metrale_bench/benchmarks/vision/
driver.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The `vision-fidelity` state machine: one leg per `next()`, in
4//! `Phase` order: calibrate (measures the template overhead every geometry
5//! cell subtracts), geometry, capability probes, integrity, concurrency, the
6//! no-image control, then the score. The control runs after the legs it can
7//! invalidate, so their cells are shown beside a VACUOUS verdict.
8//!
9//! Owner: bench, vision.
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::geometry::expected_vision_tokens_bounded;
26use super::probes::{CONTROL, PROBES, Probe, concurrency_probe};
27use super::provision::{self, FIXTURES, provision};
28use super::request;
29use super::score::{
30    GeomCell, ProbeCell, Verdict as VisionVerdict, asserted_cells, reply_matches, verdict,
31    with_runtime_checks,
32};
33
34const SUMMARY: &str = "Vision fidelity: exact vision-token geometry across a resolution ladder, \
35                       plus capability probes with a no-image control.";
36
37pub const METADATA: PluginMetadata = PluginMetadata::metrale(SUMMARY);
38
39pub const DESCRIPTOR: BenchmarkDescriptor = BenchmarkDescriptor {
40    id: "vision-fidelity",
41    name: "Vision Fidelity",
42    summary: SUMMARY,
43    detail: "Two legs. GEOMETRY sends a ladder of committed fixtures (224² through 1280×720, \
44             square, wide and portrait, deliberately mixing grid-exact sizes with ones that \
45             must snap) and asserts the EXACT vision-token count from usage.prompt_tokens \
46             against patch/merge arithmetic — the observable that moves when preprocessing \
47             changes, and the one a capability check cannot see. CAPABILITY asks unambiguous \
48             questions about those images. A no-image CONTROL runs last: if it answers as \
49             though it saw a picture, the capability leg proved nothing and the run reports \
50             VACUOUS rather than PASS. Images the encoder REFUSES report UNMEASURED, never \
51             FAIL — that is a deployment setting, not a defect. A serve that caps vision \
52             area does not refuse, it downscales: declare its bound with vision_max_pixels \
53             and the ladder predicts the downscaled geometry and still asserts every rung.",
54    duration_hint: "~1-2 min",
55    expected_secs: 120,
56    updated: "2026-08-14",
57    needs_confirmation: false,
58    // 2026-09-26: The predictions assume patch 16 and merge 2 and calibrate the
59    // template overhead per run, so the benchmark names no one checkpoint.
60    intended_for: None,
61    threshold_params: &[],
62    // 2026-09-26: Correctness: the legs score replies and token counts, not
63    // time.
64    sensitivity: Sensitivity::Correctness,
65    ctor: || Box::new(VisionFidelity::default()),
66};
67
68#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)]
69enum Phase {
70    #[default]
71    Calibrate,
72    Geometry,
73    Probes,
74    Integrity,
75    Concurrency,
76    Control,
77    Score,
78    Done,
79}
80
81#[derive(Default)]
82pub struct VisionFidelity {
83    handle: Option<PluginHandle>,
84    phase: Phase,
85    started: Option<Instant>,
86    /// 2026-09-26: Chat-template cost in tokens, measured in `Calibrate` and
87    /// subtracted by every geometry cell.
88    overhead: Option<usize>,
89    geom: Vec<GeomCell>,
90    probes: Vec<ProbeCell>,
91    control_held: bool,
92    cursor: usize,
93    conc_results: Vec<crate::benchmarks::video::concurrency::LevelResult>,
94    integrity: Vec<crate::benchmarks::media_integrity::Cell>,
95    max_tokens: usize,
96    request_timeout_s: u64,
97    /// 2026-09-26: The target serve's `--vision-max-pixels`, an area in pixels;
98    /// 0 means not declared. Every geometry prediction is made under it,
99    /// because a serve with a bound downscales a larger image and answers.
100    vision_max_pixels: u64,
101}
102
103impl VisionFidelity {
104    fn handle(&self) -> Result<&PluginHandle> {
105        self.handle.as_ref().context("benchmark was not loaded")
106    }
107
108    fn elapsed(&self) -> Duration {
109        self.started.map(|s| s.elapsed()).unwrap_or_default()
110    }
111
112    fn timeout(&self) -> Duration {
113        Duration::from_secs(self.request_timeout_s)
114    }
115
116    fn fixture(&self, name: &str) -> Result<&'static [u8]> {
117        FIXTURES
118            .iter()
119            .find(|(n, _, _, _)| *n == name)
120            .map(|(_, b, _, _)| *b)
121            .with_context(|| format!("fixture {name} is not in the provisioned set"))
122    }
123
124    fn frame(&self, phase: &str, log: Vec<LogLine>) -> BenchmarkResult {
125        let mut r = BenchmarkResult::running(phase, self.elapsed());
126        r.progress = Some((
127            (self.geom.len() + self.probes.len()) as u64,
128            (FIXTURES.len() + PROBES.len()) as u64,
129        ));
130        r.log = log;
131        r
132    }
133
134    /// 2026-09-26: Send one probe and score it.
135    async fn run_probe(&self, p: &Probe) -> ProbeCell {
136        let handle = match self.handle() {
137            Ok(h) => h,
138            Err(e) => {
139                return ProbeCell::Error {
140                    id: p.id,
141                    msg: one_line(format!("{e:#}")),
142                };
143            }
144        };
145        let images: Vec<&[u8]> = match p
146            .images
147            .iter()
148            .map(|n| self.fixture(n))
149            .collect::<Result<Vec<_>>>()
150        {
151            Ok(v) => v,
152            Err(e) => {
153                return ProbeCell::Error {
154                    id: p.id,
155                    msg: one_line(format!("{e:#}")),
156                };
157            }
158        };
159        let body = request::body(&handle.target().model, &images, p.prompt, self.max_tokens);
160        match http::chat_stream(handle.target(), &body, self.timeout()).await {
161            Ok(o) => {
162                if reply_matches(&o.text, p.want_all, p.want_none) {
163                    ProbeCell::Pass { id: p.id }
164                } else {
165                    ProbeCell::Fail {
166                        id: p.id,
167                        reply: one_line(&o.text),
168                    }
169                }
170            }
171            Err(e) => ProbeCell::Error {
172                id: p.id,
173                msg: one_line(format!("{e:#}")),
174            },
175        }
176    }
177}
178
179impl Plugin for VisionFidelity {
180    fn metadata(&self) -> &'static PluginMetadata {
181        &METADATA
182    }
183
184    async fn load(&mut self, handle: PluginHandle) -> Result<()> {
185        // 2026-09-26: Write the fixtures to disk before any leg runs, so a
186        // provisioning failure is reported at load rather than mid-run.
187        provision(handle.artifacts()).context("provisioning vision fixtures")?;
188        self.handle = Some(handle);
189        Ok(())
190    }
191}
192
193impl Benchmark for VisionFidelity {
194    fn descriptor(&self) -> &'static BenchmarkDescriptor {
195        &DESCRIPTOR
196    }
197
198    fn parameters(&self) -> Vec<ParamSpec> {
199        vec![
200            ParamSpec::new(
201                "max_tokens",
202                "Max tokens per reply",
203                "Probe replies are short by design; the geometry leg needs almost none. \
204                 Keep this well above the model's thinking budget if you disable the \
205                 thinking-off default, or a reasoning block will consume the whole budget \
206                 and return empty content that reads as a vision failure.",
207                ParamKind::Int { min: 16, max: 2048 },
208                ParamValue::Int(128),
209            ),
210            ParamSpec::new(
211                "request_timeout_s",
212                "Per-request timeout (s)",
213                "A large fixture at a high area bound can take a while to prefill.",
214                ParamKind::Int { min: 30, max: 3600 },
215                ParamValue::Int(300),
216            ),
217            ParamSpec::new(
218                "vision_max_pixels",
219                "Target serve's --vision-max-pixels (0 = not set)",
220                "Set this to the SAME area the target server was started with. It is an \
221                 area in pixels, not an edge length, exactly like the flag. Leave it 0 \
222                 when the serve does not pass the flag — the checkpoint's own bound is \
223                 far above every fixture, so nothing downscales and the ladder asserts \
224                 native geometry. \
225                 \n\nWhy this exists: a serve that caps vision area does NOT refuse an \
226                 oversized image, it silently downscales it and answers normally. The \
227                 reply then carries fewer vision tokens than a native-resolution \
228                 prediction expects, and the cell fails while the model is perfectly \
229                 healthy. A run against `--vision-max-pixels 262144` scored 9/14 for \
230                 exactly that reason (2026-08-21) — the five fixtures above the bound. \
231                 Declaring the bound here does not SKIP those rungs: the prediction is \
232                 recomputed for the downscaled geometry and the rung is still asserted, \
233                 so an engine that does not honour its own declared bound still fails.",
234                ParamKind::Int {
235                    min: 0,
236                    // 2026-09-26: 4096², the long-side ceiling squared: no
237                    // served image is larger, so a bigger bound could never bind.
238                    max: 16_777_216,
239                },
240                ParamValue::Int(0),
241            ),
242        ]
243    }
244
245    fn configure(&mut self, values: &ParamValues) -> Result<()> {
246        self.max_tokens = values.int("max_tokens")? as usize;
247        self.request_timeout_s = values.int("request_timeout_s")? as u64;
248        self.vision_max_pixels = values.int("vision_max_pixels")? as u64;
249        if self.max_tokens == 0 {
250            bail!("max_tokens must be positive");
251        }
252        Ok(())
253    }
254
255    async fn next(&mut self) -> Result<BenchmarkResult> {
256        let handle = self.handle()?.clone();
257        handle.check_cancelled()?;
258        if self.started.is_none() {
259            self.started = Some(Instant::now());
260        }
261
262        match self.phase {
263            Phase::Calibrate => self.calibrate_leg(&handle).await,
264
265            Phase::Geometry => self.geometry_leg(&handle).await,
266
267            Phase::Probes => {
268                let p = &PROBES[self.cursor];
269                let cell = self.run_probe(p).await;
270                let line = match &cell {
271                    ProbeCell::Pass { id } => LogLine::info(format!("{id}: pass")),
272                    ProbeCell::Fail { id, reply } => LogLine::warn(format!("{id}: {reply}")),
273                    ProbeCell::Error { id, msg } => LogLine::warn(format!("{id}: {msg}")),
274                };
275                self.probes.push(cell);
276                self.cursor += 1;
277                if self.cursor >= PROBES.len() {
278                    self.cursor = 0;
279                    self.phase = Phase::Integrity;
280                }
281                Ok(self.frame("probes", vec![line]))
282            }
283
284            Phase::Integrity => self.integrity_leg().await,
285
286            Phase::Concurrency => self.concurrency_leg().await,
287
288            Phase::Control => {
289                let cell = self.run_probe(&CONTROL).await;
290                self.control_held = matches!(cell, ProbeCell::Pass { .. });
291                let line = if self.control_held {
292                    LogLine::info("control: no image, no answer — capability results stand")
293                } else {
294                    LogLine::warn(
295                        "control: answered as though it saw an image — capability results are \
296                         VACUOUS, the server may not be splicing vision embeddings at all",
297                    )
298                };
299                self.phase = Phase::Score;
300                Ok(self.frame("control", vec![line]))
301            }
302
303            Phase::Score => {
304                self.phase = Phase::Done;
305                // 2026-09-26: A measured integrity leg that did not pass, or a
306                // concurrency sweep that is not clean, turns a Pass into a
307                // Fail (`with_runtime_checks`).
308                let integ_failed = self.integrity.iter().any(|c| c.measured() && !c.passed());
309                let concurrency_clean =
310                    crate::benchmarks::video::concurrency::sweep_ok(&self.conc_results);
311                let v = with_runtime_checks(
312                    verdict(&self.geom, &self.probes, self.control_held),
313                    integ_failed,
314                    concurrency_clean,
315                );
316                let asserted = asserted_cells(&self.geom);
317                let passed = self
318                    .probes
319                    .iter()
320                    .filter(|c| matches!(c, ProbeCell::Pass { .. }))
321                    .count();
322
323                let mut r = BenchmarkResult::running("score", self.elapsed());
324                r.status = if v == VisionVerdict::Pass {
325                    RunStatus::Completed
326                } else {
327                    RunStatus::Failed
328                };
329                r.summary = vec![
330                    Stat::new("verdict", v.to_string(), ""),
331                    Stat::new(
332                        "geometry",
333                        format!("{asserted}/{}", self.geom.len()),
334                        "asserted",
335                    ),
336                    Stat::new(
337                        "probes",
338                        format!("{passed}/{}", self.probes.len()),
339                        "passed",
340                    ),
341                ];
342                r.metrics
343                    .insert("geometry_asserted".into(), asserted as f64);
344                // 2026-09-26: `geometry_asserted` counts Match and Mismatch,
345                // so it drops when cells go Unmeasured; `geometry_matched`
346                // counts Match only, so it drops when counts are wrong. The
347                // BENCH.toml vision entries bound both (for example
348                // `kernels/gb10/qwen3.6-27b/BENCH.toml`).
349                let matched = self
350                    .geom
351                    .iter()
352                    .filter(|c| matches!(c, GeomCell::Match { .. }))
353                    .count();
354                r.metrics.insert("geometry_matched".into(), matched as f64);
355                r.metrics
356                    .insert("geometry_cells".into(), self.geom.len() as f64);
357                r.metrics.insert("probes_passed".into(), passed as f64);
358                r.metrics
359                    .insert("probes_total".into(), self.probes.len() as f64);
360                r.metrics
361                    .insert("control_held".into(), self.control_held as u8 as f64);
362                // 2026-09-26: No BENCH.toml entry bounds these; the sweep
363                // enters the verdict through `with_runtime_checks`.
364                let baseline_prompt_tokens = self
365                    .conc_results
366                    .first()
367                    .and_then(|baseline| baseline.prompt_tokens);
368                let conc_clean = self
369                    .conc_results
370                    .iter()
371                    .filter(|r| {
372                        baseline_prompt_tokens.is_some_and(|baseline| r.ok_against(baseline))
373                    })
374                    .count();
375                r.metrics
376                    .insert("concurrency_levels_clean".into(), conc_clean as f64);
377                let integ_passed = self.integrity.iter().filter(|c| c.passed()).count();
378                let integ_measured = self.integrity.iter().filter(|c| c.measured()).count();
379                r.metrics
380                    .insert("integrity_passed".into(), integ_passed as f64);
381                r.metrics
382                    .insert("integrity_measured".into(), integ_measured as f64);
383                for lr in &self.conc_results {
384                    r.metrics
385                        .insert(format!("conc_{}_wall_ms", lr.conc), lr.wall_ms as f64);
386                }
387                r.verdict = Some(match v {
388                    VisionVerdict::Pass => RunVerdict::pass(format!(
389                        "{asserted} geometry cells matched, {passed}/{} probes, control held",
390                        self.probes.len()
391                    )),
392                    VisionVerdict::Fail => {
393                        // 2026-09-26: The reason names up to five geometry
394                        // mismatches or errors, not only the counts.
395                        let mism: Vec<String> = self
396                            .geom
397                            .iter()
398                            .filter_map(|c| match c {
399                                GeomCell::Mismatch { fixture, want, got } => {
400                                    Some(format!("{fixture} want {want} got {got}"))
401                                }
402                                GeomCell::Error { fixture, msg } => {
403                                    Some(format!("{fixture} ERROR {msg}"))
404                                }
405                                _ => None,
406                            })
407                            .collect();
408                        let mut reason = format!(
409                            "{matched}/{asserted} geometry matched, {passed}/{} probes",
410                            self.probes.len()
411                        );
412                        if !mism.is_empty() {
413                            reason.push_str(": ");
414                            reason.push_str(&mism[..mism.len().min(5)].join("; "));
415                            if mism.len() > 5 {
416                                reason.push_str(&format!(" (+{} more)", mism.len() - 5));
417                            }
418                        }
419                        // 2026-09-26: With no declared bound and every mismatch
420                        // below its prediction, the reason suggests setting
421                        // `vision_max_pixels`, since a serve with a bound
422                        // downscales.
423                        let all_low = self.geom.iter().all(|c| match c {
424                            GeomCell::Mismatch { want, got, .. } => got < want,
425                            _ => true,
426                        });
427                        if self.vision_max_pixels == 0 && !mism.is_empty() && all_low {
428                            reason.push_str(
429                                " — every mismatch reads LOW: if the target serve passes \
430                                 --vision-max-pixels, set the vision_max_pixels param to the \
431                                 same value",
432                            );
433                        }
434                        RunVerdict::fail(reason)
435                    }
436                    VisionVerdict::Vacuous => RunVerdict::fail(
437                        "VACUOUS: the no-image control answered as though it saw one, so the \
438                         capability probes are not evidence"
439                            .to_string(),
440                    ),
441                });
442                r.log = vec![match v {
443                    VisionVerdict::Pass => LogLine::info(format!(
444                        "PASS — {asserted} geometry cells asserted, {passed}/{} probes",
445                        self.probes.len()
446                    )),
447                    VisionVerdict::Fail => LogLine::warn("FAIL — see the cells above"),
448                    VisionVerdict::Vacuous => LogLine::warn(
449                        "VACUOUS — the no-image control answered, so the capability probes are \
450                         not evidence. Geometry results above are still valid.",
451                    ),
452                }];
453                Ok(r)
454            }
455
456            Phase::Done => {
457                let mut r = BenchmarkResult::running("done", self.elapsed());
458                r.status = RunStatus::Completed;
459                Ok(r)
460            }
461        }
462    }
463}
464
465#[path = "driver_ladder.rs"]
466mod ladder;
467
468#[path = "driver_integrity.rs"]
469mod integrity;