1use crate::hardware::Sensitivity;
13use std::time::{Duration, Instant};
14
15use anyhow::{Context, Result, bail};
16
17use crate::benchmark::{Benchmark, BenchmarkDescriptor};
18use crate::benchmarks::one_line;
19use crate::http;
20use crate::metadata::PluginMetadata;
21use crate::params::{ParamKind, ParamSpec, ParamValue, ParamValues};
22use crate::plugin::{Plugin, PluginHandle};
23use crate::result::{BenchmarkResult, LogLine, RunStatus, Stat, Verdict as RunVerdict};
24
25use super::concurrency::{LEVELS, LevelResult, run_level};
26use super::geometry::{check_proportional, tokens_per_group};
27use super::provision::{CLIPS, Clip, clip, provision};
28use super::request;
29use super::score::{
30 self, CountCell, OrderCell, Verdict as VideoVerdict, asserted, order_matches, passed, verdict,
31};
32
33const SUMMARY: &str = "Video fidelity: temporal-order reading of a color sequence, group-count \
34 geometry, MP4/GIF backend parity, and a no-video control.";
35
36pub const METADATA: PluginMetadata = PluginMetadata::metrale(SUMMARY);
37
38pub const DESCRIPTOR: BenchmarkDescriptor = BenchmarkDescriptor {
39 id: "video-fidelity",
40 name: "Video Fidelity",
41 summary: SUMMARY,
42 detail: "Seven check groups over clips of solid colors, one per second. ORDER sends the same \
43 sequence forwards and REVERSED and requires the answer to reverse with it — the \
44 only assertion that separates 'the frames arrived in order' from 'something \
45 arrived', and the one that caught the splice defect where video pad tokens \
46 received no encoder rows at all and the model calmly described a gray field while \
47 every token count was perfect. GEOMETRY asserts that a clip of twice the duration \
48 costs twice the temporal groups, stated as a RATIO so it holds at any \
49 --video-fps the server was started with. PARITY requires an MP4 and an identical \
50 GIF to produce the same geometry, one through ffmpeg and one through the \
51 in-process decoder. MIXED sends an image and a video together, exercising the \
52 ordering contract between collection, template markers and pad expansion. \
53 INTEGRITY varies media order, request history, and opposite clips in flight. \
54 CONCURRENCY requires correct replies and the same prompt-token geometry at \
55 C=1, C=2, and C=4. A \
56 no-video CONTROL runs last: if it describes a clip it never received, the run is \
57 VACUOUS rather than PASS. Legs needing a decoder the server lacks are SKIPPED, \
58 never failed — that is a deployment choice.",
59 duration_hint: "~1-2 min",
60 expected_secs: 70,
61 updated: "2026-08-24",
62 needs_confirmation: false,
63 intended_for: None,
64 threshold_params: &[],
65 sensitivity: Sensitivity::Correctness,
68 ctor: || Box::new(VideoFidelity::default()),
69};
70
71#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
72enum Phase {
73 #[default]
74 Geometry,
75 Order,
76 Parity,
77 Mixed,
78 Integrity,
79 Concurrency,
80 Control,
81 Score,
82 Done,
83}
84
85#[derive(Default)]
86pub struct VideoFidelity {
87 handle: Option<PluginHandle>,
88 phase: Phase,
89 started: Option<Instant>,
90 order: Vec<OrderCell>,
91 counts: Vec<CountCell>,
92 control_held: bool,
93 full_tokens: Option<usize>,
96 plane: usize,
98 cursor: usize,
99 conc_results: Vec<LevelResult>,
100 integrity: Vec<crate::benchmarks::media_integrity::Cell>,
101 max_tokens: usize,
102 request_timeout_s: u64,
103}
104
105const PALETTE: &[&str] = &["red", "green", "blue", "yellow"];
108
109fn skip_if_decoder_unavailable(
117 cell: crate::benchmarks::media_integrity::Cell,
118) -> crate::benchmarks::media_integrity::Cell {
119 use crate::benchmarks::media_integrity::Cell;
120 match cell {
121 Cell::Error { id, msg } if request::is_decoder_unavailable(&msg) => {
122 Cell::Skipped { id, why: msg }
123 }
124 other => other,
125 }
126}
127
128impl VideoFidelity {
129 fn handle(&self) -> Result<&PluginHandle> {
130 self.handle.as_ref().context("benchmark was not loaded")
131 }
132
133 fn elapsed(&self) -> Duration {
134 self.started.map(|s| s.elapsed()).unwrap_or_default()
135 }
136
137 fn timeout(&self) -> Duration {
138 Duration::from_secs(self.request_timeout_s)
139 }
140
141 fn frame(&self, phase: &str, log: Vec<LogLine>) -> BenchmarkResult {
142 let mut r = BenchmarkResult::running(phase, self.elapsed());
143 r.progress = Some((
144 (self.order.len() + self.counts.len()) as u64,
145 (CLIPS.len() + 2) as u64,
146 ));
147 r.log = log;
148 r
149 }
150
151 async fn read_order(&self, c: &'static Clip) -> OrderCell {
153 let h = match self.handle() {
154 Ok(h) => h,
155 Err(e) => {
156 return OrderCell::Error {
157 clip: c.name,
158 msg: one_line(format!("{e:#}")),
159 };
160 }
161 };
162 let body = request::video_body(
163 &h.target().model,
164 c.mime,
165 c.bytes,
166 request::ORDER_PROMPT,
167 self.max_tokens,
168 );
169 match http::chat_stream(h.target(), &body, self.timeout()).await {
170 Ok(out) => {
171 let reply = out.text.trim().to_string();
172 if order_matches(&reply, c.colors, PALETTE) {
173 OrderCell::Match {
174 clip: c.name,
175 seen: one_line(reply),
176 }
177 } else {
178 let got = super::score::colors_in_order(&reply, PALETTE);
179 if got.is_empty() {
180 OrderCell::NotSeen {
181 clip: c.name,
182 reply: one_line(reply),
183 }
184 } else {
185 OrderCell::WrongOrder {
186 clip: c.name,
187 want: c.colors.join(", "),
188 got: got.join(", "),
189 }
190 }
191 }
192 }
193 Err(e) => {
194 let msg = format!("{e:#}");
195 if request::is_decoder_unavailable(&msg) {
196 OrderCell::Skipped {
197 clip: c.name,
198 why: one_line(msg),
199 }
200 } else {
201 OrderCell::Error {
202 clip: c.name,
203 msg: one_line(msg),
204 }
205 }
206 }
207 }
208 }
209
210 async fn tokens_for(&self, c: &'static Clip) -> std::result::Result<usize, (bool, String)> {
213 let h = self.handle().map_err(|e| (false, format!("{e:#}")))?;
214 let body = request::video_body(&h.target().model, c.mime, c.bytes, "Reply with OK.", 8);
215 match http::chat_stream(h.target(), &body, self.timeout()).await {
216 Ok(out) => Ok(out.prompt_tokens),
217 Err(e) => {
218 let msg = format!("{e:#}");
219 let skip = request::is_decoder_unavailable(&msg);
220 Err((skip, one_line(msg)))
221 }
222 }
223 }
224}
225
226impl Plugin for VideoFidelity {
227 fn metadata(&self) -> &'static PluginMetadata {
228 &METADATA
229 }
230
231 async fn load(&mut self, handle: PluginHandle) -> Result<()> {
232 provision(handle.artifacts()).context("provisioning video fixtures")?;
235 self.handle = Some(handle);
236 Ok(())
237 }
238}
239
240impl Benchmark for VideoFidelity {
241 fn descriptor(&self) -> &'static BenchmarkDescriptor {
242 &DESCRIPTOR
243 }
244
245 fn parameters(&self) -> Vec<ParamSpec> {
246 vec![
247 ParamSpec::new(
248 "max_tokens",
249 "Max tokens per reply",
250 "The color list is short by design, so this only needs to be generous \
251 enough that a reply is not truncated mid-sequence. Keep it well above the \
252 model's thinking budget if you re-enable thinking, or a reasoning block \
253 consumes the whole budget and returns empty content that reads as a video \
254 failure and is not one. The same trap has a thinking-OFF form, which is \
255 why the default is 320 and not the 120 it was: a cell that sends TWO \
256 media items can provoke a preamble the single-item cells never see, and \
257 a budget that truncates the preamble truncates the answer with it.",
258 ParamKind::Int { min: 16, max: 2048 },
259 ParamValue::Int(320),
266 ),
267 ParamSpec::new(
268 "request_timeout_s",
269 "Per-request timeout (s)",
270 "Video prefill is heavier than an image's: a clip costs its whole patch \
271 grid once per temporal group, so a long clip at a high --video-fps is \
272 several images' worth of work.",
273 ParamKind::Int { min: 30, max: 3600 },
274 ParamValue::Int(300),
275 ),
276 ]
277 }
278
279 fn configure(&mut self, values: &ParamValues) -> Result<()> {
280 self.max_tokens = values.int("max_tokens")? as usize;
281 self.request_timeout_s = values.int("request_timeout_s")? as u64;
282 if self.max_tokens == 0 {
283 bail!("max_tokens must be positive");
284 }
285 self.plane = tokens_per_group(224, 224, 16, 2) as usize;
287 self.started = Some(Instant::now());
288 Ok(())
289 }
290
291 async fn next(&mut self) -> Result<BenchmarkResult> {
292 match self.phase {
293 Phase::Geometry => self.geometry_leg().await,
294
295 Phase::Order => {
296 let ordered: Vec<&'static Clip> =
297 CLIPS.iter().filter(|c| c.colors.len() == 4).collect();
298 let c = ordered[self.cursor];
299 let cell = self.read_order(c).await;
300 let line = match &cell {
301 OrderCell::Match { seen, .. } => LogLine::info(format!("{}: {seen}", c.name)),
302 OrderCell::WrongOrder { want, got, .. } => {
303 LogLine::warn(format!("{}: wanted [{want}], got [{got}]", c.name))
304 }
305 OrderCell::NotSeen { reply, .. } => {
306 LogLine::warn(format!("{}: no colors named — {reply}", c.name))
307 }
308 OrderCell::Skipped { why, .. } => {
309 LogLine::info(format!("{}: skipped — {why}", c.name))
310 }
311 OrderCell::Error { msg, .. } => LogLine::warn(format!("{}: {msg}", c.name)),
312 };
313 self.order.push(cell);
314 self.cursor += 1;
315 if self.cursor >= ordered.len() {
316 self.cursor = 0;
317 self.phase = Phase::Parity;
318 }
319 Ok(self.frame("order", vec![line]))
320 }
321
322 Phase::Parity => self.parity_leg().await,
323
324 Phase::Mixed => self.mixed_leg().await,
325
326 Phase::Integrity => self.integrity_leg().await,
327
328 Phase::Concurrency => self.concurrency_leg().await,
329
330 Phase::Control => {
331 self.phase = Phase::Score;
332 let h = self.handle()?;
333 let body = request::text_only_body(
334 &h.target().model,
335 request::ORDER_PROMPT,
336 self.max_tokens,
337 );
338 let line = match http::chat_stream(h.target(), &body, self.timeout()).await {
339 Ok(out) => {
340 let reply = out.text.trim();
341 let looks_seen =
344 super::score::colors_in_order(reply, PALETTE).len() >= PALETTE.len();
345 self.control_held = !looks_seen;
346 if self.control_held {
347 LogLine::info(format!(
348 "control: no video, no full sequence — readings stand ({})",
349 one_line(reply.chars().take(60).collect::<String>())
350 ))
351 } else {
352 LogLine::warn(format!(
353 "control: named every color with NO video attached — the \
354 readings are not evidence ({})",
355 one_line(reply.chars().take(60).collect::<String>())
356 ))
357 }
358 }
359 Err(e) => {
360 self.control_held = false;
363 LogLine::warn(format!("control failed: {}", one_line(format!("{e:#}"))))
364 }
365 };
366 Ok(self.frame("control", vec![line]))
367 }
368
369 Phase::Score => {
370 self.phase = Phase::Done;
371 let v = verdict(&self.order, &self.counts, self.control_held);
372 let asserted_n = asserted(&self.order, &self.counts);
373 let passed_n = passed(&self.order, &self.counts);
374 let skipped = self
375 .order
376 .iter()
377 .filter(|c| matches!(c, OrderCell::Skipped { .. }))
378 .count()
379 + self
380 .counts
381 .iter()
382 .filter(|c| matches!(c, CountCell::Skipped { .. }))
383 .count();
384
385 let mut r = BenchmarkResult::running("score", self.elapsed());
386 r.status = if v == VideoVerdict::Pass {
387 RunStatus::Completed
388 } else {
389 RunStatus::Failed
390 };
391 r.summary = vec![
392 Stat::new("verdict", v.to_string(), ""),
393 Stat::new("legs", format!("{passed_n}/{asserted_n}"), "passed"),
394 Stat::new("skipped", skipped.to_string(), "legs"),
395 ];
396 r.metrics.insert("legs_passed".into(), passed_n as f64);
397 r.metrics.insert("legs_asserted".into(), asserted_n as f64);
398 r.metrics.insert("legs_skipped".into(), skipped as f64);
399 r.metrics
400 .insert("control_held".into(), self.control_held as u8 as f64);
401 for lr in &self.conc_results {
402 r.metrics
403 .insert(format!("conc_{}_wall_ms", lr.conc), lr.wall_ms as f64);
404 r.metrics
405 .insert(format!("conc_{}_correct", lr.conc), lr.correct as f64);
406 }
407 r.verdict = Some(match v {
408 VideoVerdict::Pass => RunVerdict::pass(format!(
409 "{passed_n}/{asserted_n} legs passed, control held, {skipped} skipped"
410 )),
411 VideoVerdict::Fail => RunVerdict::fail(format!(
412 "{passed_n}/{asserted_n} legs passed, {skipped} skipped"
413 )),
414 VideoVerdict::Vacuous => RunVerdict::fail(
415 "VACUOUS: the no-video control named the whole color sequence, so the \
416 readings are not evidence"
417 .to_string(),
418 ),
419 VideoVerdict::Inconclusive => RunVerdict::fail(
420 "INCONCLUSIVE: every leg was skipped — no video decoder is available, \
421 so nothing was measured"
422 .to_string(),
423 ),
424 });
425 Ok(r)
426 }
427
428 Phase::Done => {
429 let mut r = BenchmarkResult::running("done", self.elapsed());
430 r.status = RunStatus::Completed;
431 Ok(r)
432 }
433 }
434 }
435}
436
437#[path = "driver_counts.rs"]
438mod counts;
439
440#[path = "driver_media.rs"]
441mod media;
442
443#[cfg(test)]
444#[path = "driver_tests.rs"]
445mod driver_tests;