metrale_bench/benchmarks/video/
driver.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The `video-fidelity` state machine: one leg per `next()`, in
4//! `Phase` order: geometry, order, parity, mixed media, integrity,
5//! concurrency, the no-video control, then the score. The control runs after
6//! the legs it can invalidate, so their cells are shown beside a VACUOUS
7//! verdict.
8//!
9//! Owner: bench, video.
10//! Invariants: none beyond the types.
11
12use 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    // 2026-09-26: Correctness: the legs score replies and token counts, not
66    // time.
67    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    /// 2026-09-26: Prompt tokens of the 4 s MP4 from the geometry leg, reused
94    /// by the parity leg so it sends only the GIF.
95    full_tokens: Option<usize>,
96    /// 2026-09-26: Merged tokens per temporal group of a 224x224 clip.
97    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
105/// 2026-09-26: Every color any clip shows (`the_palette_covers_every_fixture_color`).
106/// The scorer looks only for these words.
107const PALETTE: &[&str] = &["red", "green", "blue", "yellow"];
108
109/// 2026-09-26: Reclassify an `Error` cell as `Skipped` when its message is the
110/// server refusing a container it cannot decode.
111///
112/// The two-clip concurrency leg gets its cell from
113/// `media_integrity::heterogeneous_concurrency`, which knows nothing of video
114/// decoders, so the check is made here. The other legs that send an MP4 check
115/// `is_decoder_unavailable` at their own error site.
116fn 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    /// 2026-09-26: Ask about one clip and score the ordered colors it reports.
152    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    /// 2026-09-26: Prompt tokens for one clip, or `(skip, reason)` when it could
211    /// not be measured; `skip` is true for a decoder-unavailable error.
212    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        // 2026-09-26: Write the clips to disk before any leg runs, so a
233        // provisioning failure is reported at load rather than mid-run.
234        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                // 2026-09-26: Measured 2026-08-15 on qwen3.8-27B-NVFP4: the
260                // video-before-image reply needed 153 completion tokens (a
261                // preamble, then the colors) and stopped at `finish_reason=length`
262                // under a 120-token cap, losing the fourth color; the video-only
263                // reply needed 8. A cell that stops at EOS earlier is not slowed
264                // by a larger cap.
265                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        // 2026-09-26: Every clip is 224x224, so one figure covers them all.
286        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                        // 2026-09-26: The control fails only when the reply
342                        // names every palette color.
343                        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                        // 2026-09-26: A control that could not run does not
361                        // hold.
362                        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;