franken_ocr 0.9.0

Pure-Rust, CPU-hyper-optimized runner for the Baidu Unlimited-OCR model (single-binary CLI: focr)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
//! SmolVLM2 describe/VQA assembly (C7, bd-3jo6.3.7) — the third end-to-end
//! model path, mirroring [`super::got`]'s shape: preprocess → vision tower →
//! connector → prompt splice → dense decoder greedy → detokenize.
//!
//! * **Preprocess**: [`preprocess::preprocess_smolvlm2`] — Pillow-bit-exact
//!   LANCZOS resize/split (L0b proven maxabs 0.0 vs the torch oracle).
//! * **Vision**: [`vision_siglip`] (C3, certified cos 1.00000000) →
//!   [`token_compress::pixel_shuffle`] ×4 → the high-precision
//!   `modality_projection` GEMM (C4, certified) → `64` rows per frame.
//! * **Prompt**: the SmolVLM2 chat template (spec §5) — one literal
//!   `<|im_start|>`, `User:` (no space before an image-first content list),
//!   the `<fake_token_around_image>`/`<row_r_col_c>`/`<global-img>` image
//!   expansion with 64 `<image>` slots per frame (tiles row-major, global
//!   LAST), the question text, `<end_of_utterance>\n`, and the `Assistant:`
//!   generation suffix. Encoded by the C6 SmolLM2 tokenizer (id-exact).
//! * **Decoder**: [`DecoderConfig::smolvlm2`] (C5, certified) through the
//!   O(n)-per-token KV-cache greedy decode, stop at `<end_of_utterance>`
//!   (49279). Upstream has NO repetition guard (`no_repeat_ngram = 0`).
//!
//! Task = the natural-language question (there are no GOT-style instruction
//! modes): describe/caption and VQA are the same machine with a different
//! question string.

use image::DynamicImage;

use crate::error::{FocrError, FocrResult};
use crate::preprocess;
use crate::tokenizer::{Tokenizer, special_smollm2};

use super::decoder_qwen2::{self, DecoderConfig};
use super::tensor::Mat;
use super::vision_sam::Linear;
use super::weights::Weights;
use super::{connector, decoder, token_compress, vision_siglip};

// Clock seam: `std::time::Instant` traps on wasm32-unknown-unknown; `web-time`
// re-exports std's types on native targets, so native behavior is unchanged.
#[cfg(not(target_arch = "wasm32"))]
use std::time::Instant;
#[cfg(target_arch = "wasm32")]
use web_time::Instant;

/// The generation stop id — `<end_of_utterance>` (spec §8).
pub const EOS_ID: u32 = special_smollm2::END_OF_UTTERANCE;
/// `<image>` splice-slot id.
const IMAGE_ID: u32 = special_smollm2::IMAGE;
/// `<image>` slots per 512² frame (`processor_config.json image_seq_len`).
const IMG_SLOTS_PER_FRAME: usize = 64;
/// Pixel-shuffle scale factor (`config.scale_factor`).
const PS_SCALE: usize = 4;
/// The model-card describe question (the oracle's L0c/L4 anchor prompt).
pub const DESCRIBE_QUESTION: &str = "Can you describe this image?";
/// The model-card decode cap (`max_new_tokens=64` in every README example).
pub const DEFAULT_MAX_NEW: usize = 64;
/// `max_position_embeddings` (spec §4) — the architectural sequence budget;
/// generation is clamped to `MAX_POSITION - prompt_len`.
pub const MAX_POSITION: usize = 8192;

/// The §5 image-expansion string for an `R×C` split image: per tile
/// `<fake_token_around_image><row_r_col_c>` + 64 `<image>`s (row-major, a
/// `"\n"` after each row), then `"\n"` + the global frame bracketed by
/// `<fake_token_around_image>`. The trailing `"\n\n"` abutment BPE-merges —
/// pinned by the L0c fixture, never hand-computed (OQ-4).
fn image_prompt_string(rows: usize, cols: usize) -> String {
    const FAKE: &str = "<fake_token_around_image>";
    const GLOBAL: &str = "<global-img>";
    let slots = "<image>".repeat(IMG_SLOTS_PER_FRAME);
    let mut s = String::new();
    for r in 1..=rows {
        for c in 1..=cols {
            s.push_str(FAKE);
            s.push_str(&format!("<row_{r}_col_{c}>"));
            s.push_str(&slots);
        }
        s.push('\n');
    }
    s.push('\n');
    s.push_str(FAKE);
    s.push_str(GLOBAL);
    s.push_str(&slots);
    s.push_str(FAKE);
    s
}

/// Render the full describe/VQA chat prompt (image-first content, spec §5):
/// `<|im_start|>User:{expansion}{question}<end_of_utterance>\nAssistant:`.
fn describe_prompt(rows: usize, cols: usize, question: &str) -> String {
    format!(
        "<|im_start|>User:{}{}<end_of_utterance>\nAssistant:",
        image_prompt_string(rows, cols),
        question
    )
}

/// Encode the describe/VQA prompt to ids (the C6 tokenizer owns the specials
/// splitting; nothing is auto-prepended — the template supplies
/// `<|im_start|>` literally).
///
/// # Errors
/// A tokenizer encode error (impossible for a valid vocab).
pub fn describe_prompt_ids(
    tk: &Tokenizer,
    rows: usize,
    cols: usize,
    question: &str,
) -> FocrResult<Vec<u32>> {
    tk.encode(&describe_prompt(rows, cols, question))
}

/// Run the certified vision stack over preprocessed frames: SigLIP per frame
/// → pixel-shuffle ×4 → one stacked `modality_projection` GEMM. Returns the
/// `[n_frames * 64, 960]` vision rows in frame order (tiles row-major, global
/// LAST — the same order the prompt expansion emits `<image>` slots).
///
/// # Errors
/// A hydration/forward error, or a shape violation.
pub fn vision_rows(
    weights: &Weights,
    statics: &SmolStatics,
    frames: &[f32],
    n_frames: usize,
) -> FocrResult<Mat> {
    // Three arms, all byte-identical to each other:
    //   * streamed  — no retained tower, one block live at a time
    //     (vision_siglip::tests::streamed_frames_match_whole_tower_hydration)
    //   * batched   — one transformer pass over all frames stacked, the
    //     desktop default (bd-av64.10)
    //     (vision_siglip::tests::batched_frames_match_sequential_byte_for_byte)
    //   * sequential — the per-frame reference loop; FOCR_SIGLIP_SEQ=1 is its
    //     kill-switch, and it only applies to the retained-tower arms since the
    //     streamed path is already sequential over frames.
    let post = match statics.siglip.as_ref() {
        Some(sw) => {
            if std::env::var_os("FOCR_SIGLIP_SEQ").is_some_and(|v| v == "1") {
                vision_siglip::forward_frames(sw, frames, n_frames)?
            } else {
                vision_siglip::forward_frames_batched(sw, frames, n_frames)?
            }
        }
        None => vision_siglip::forward_frames_streamed(weights, &statics.prefix, frames, n_frames)?,
    };

    let ps_cols = vision_siglip::EMBED_DIM * PS_SCALE * PS_SCALE; // 12288
    let mut ps = Mat::zeros(n_frames * IMG_SLOTS_PER_FRAME, ps_cols);
    for (f, frame) in post.iter().enumerate() {
        let shuffled = token_compress::pixel_shuffle(frame, PS_SCALE)?;
        if shuffled.cols != ps_cols || shuffled.rows != IMG_SLOTS_PER_FRAME {
            return Err(FocrError::Other(anyhow::anyhow!(
                "smolvlm2 vision: pixel_shuffle produced [{}, {}], want [{IMG_SLOTS_PER_FRAME}, {ps_cols}]",
                shuffled.rows,
                shuffled.cols
            )));
        }
        let dst_start = f * IMG_SLOTS_PER_FRAME * ps_cols;
        ps.data[dst_start..dst_start + shuffled.data.len()].copy_from_slice(&shuffled.data);
    }

    statics.proj.apply(&ps)
}

/// The per-model-constant SmolVLM2 tensors — the SigLIP tower, the
/// `modality_projection` connector, and the widened text embed table —
/// hydrated ONCE and cached on the [`super::OcrModel`] (bd-av64.10 pass 6/7
/// idiom: the describe/VQA path re-widened all three per call).
pub struct SmolStatics {
    /// The hydrated SigLIP tower (`model.vision_model`), or `None` when the
    /// tower is streamed per block — see [`hydrate_statics`].
    pub siglip: Option<vision_siglip::SiglipWeights>,
    /// The `modality_projection` `Linear(12288→960, no bias)` (pre-transposed).
    pub proj: Linear,
    /// The widened (untied) `[vocab, hidden]` text embed table.
    pub embed: Mat,
    /// The vision tower's tensor-name prefix, kept so the streamed arm can
    /// re-address blocks after hydration.
    pub prefix: String,
}

/// The SigLIP tower's tensor-name prefix (the SMOLVLM2 descriptor's
/// `vision_tower_prefix()`).
const VISION_PREFIX: &str = "model.vision_model";

/// Hydrate a [`SmolStatics`] from the artifact.
///
/// `stream_vision` drops the retained SigLIP tower (~340 MB of f32) in favour
/// of hydrating one encoder block at a time inside
/// [`vision_siglip::forward_frames_streamed`]. Bit-identical either way; the
/// trade is peak residency against re-reading blocks from the mapped blob.
///
/// # Errors
/// A missing or mis-shaped tensor.
pub fn hydrate_statics(weights: &Weights, stream_vision: bool) -> FocrResult<SmolStatics> {
    let th = Instant::now();
    let ps_cols = vision_siglip::EMBED_DIM * PS_SCALE * PS_SCALE; // 12288
    let proj = weights.mat("model.connector.modality_projection.proj.weight")?;
    if (proj.rows, proj.cols) != (960, ps_cols) {
        return Err(FocrError::Other(anyhow::anyhow!(
            "smolvlm2 connector: proj shape [{}, {}], want [960, {ps_cols}]",
            proj.rows,
            proj.cols
        )));
    }
    let statics = SmolStatics {
        siglip: if stream_vision {
            None
        } else {
            Some(vision_siglip::siglip_weights_from(weights, VISION_PREFIX)?)
        },
        proj: Linear::from_row_major(&proj.data, Vec::new(), 960, ps_cols)?,
        embed: weights.mat("model.text_model.embed_tokens.weight")?,
        prefix: VISION_PREFIX.to_string(),
    };
    super::timing_log(&format!(
        "  smolvlm2.hydrate({}) {:.2}s",
        if stream_vision {
            "streamed vision"
        } else {
            "cached"
        },
        th.elapsed().as_secs_f64()
    ));
    Ok(statics)
}

/// Build the decoder `inputs_embeds`: embed the prompt ids against the
/// (untied) `model.text_model.embed_tokens.weight`, then scatter the vision
/// rows into the `<image>` (49190) slots in prompt order — the
/// `inputs_merger` splice.
///
/// # Errors
/// An embed error, or a [`connector::masked_scatter`] mismatch (the number of
/// `<image>` slots must equal `vision.rows`).
pub fn build_inputs_embeds(
    statics: &SmolStatics,
    vision: &Mat,
    prompt_ids: &[u32],
) -> FocrResult<Mat> {
    let embed = &statics.embed;
    let (vocab, hidden) = (embed.rows, embed.cols);
    let mut inputs_embeds = decoder::embed_tokens(&embed.data, vocab, hidden, prompt_ids)?;
    let mask: Vec<bool> = prompt_ids.iter().map(|&id| id == IMAGE_ID).collect();
    connector::masked_scatter(&mut inputs_embeds, vision, &mask)?;
    Ok(inputs_embeds)
}

/// End-to-end SmolVLM2 describe/VQA: LANCZOS split preprocess → SigLIP +
/// pixel-shuffle + connector → `<image>` splice → SmolLM2 KV-cache greedy →
/// detokenize (specials stripped, trimmed). `question` is the task; `max_new`
/// caps generation (model-card default [`DEFAULT_MAX_NEW`]). Stops at
/// `<end_of_utterance>`.
///
/// # Errors
/// A preprocess, vision, decode, or tokenizer error.
pub fn recognize(
    weights: &Weights,
    statics: &SmolStatics,
    tk: &Tokenizer,
    img: &DynamicImage,
    question: &str,
    max_new: usize,
) -> FocrResult<String> {
    let tv = Instant::now();
    let pre = preprocess::preprocess_smolvlm2(img)?;
    let vision = vision_rows(weights, statics, &pre.frames, pre.n_frames)?;
    let prompt_ids = describe_prompt_ids(tk, pre.rows, pre.cols, question)?;
    let inputs_embeds = build_inputs_embeds(statics, &vision, &prompt_ids)?;
    super::timing_log(&format!(
        "  smolvlm2.vision+splice {:.2}s ({} frames, {} prompt ids)",
        tv.elapsed().as_secs_f64(),
        pre.n_frames,
        prompt_ids.len()
    ));
    let tg = Instant::now();
    let cfg = DecoderConfig::smolvlm2();
    // Clamp to the architectural position budget net of the prompt (upstream
    // has no config max_new; the RoPE table stops at max_position 8192).
    let max_new = max_new.min(MAX_POSITION.saturating_sub(inputs_embeds.rows));
    let ids =
        decoder_qwen2::generate_greedy_kvcache(weights, &cfg, &inputs_embeds, max_new, EOS_ID)?;
    super::timing_log(&format!(
        "  smolvlm2.generate {} tokens {:.2}s",
        ids.len(),
        tg.elapsed().as_secs_f64()
    ));
    Ok(tk.decode_skip_special(&ids)?.trim().to_string())
}

/// Batched SmolVLM2 describe/VQA over MANY images (A7.5): vision + splice
/// run SEQUENTIALLY per image (one live forward), then ONE continuous-batch
/// greedy decode — per image byte-identical to [`recognize`] (the per-page
/// position-budget clamp rides the scheduler's per-stream cap).
///
/// # Errors
/// As [`recognize`].
pub fn recognize_batch(
    weights: &Weights,
    statics: &SmolStatics,
    tk: &Tokenizer,
    imgs: &[&DynamicImage],
    question: &str,
    max_new: usize,
) -> FocrResult<Vec<String>> {
    let tv = Instant::now();
    let mut embeds_list: Vec<Mat> = Vec::with_capacity(imgs.len());
    let mut caps: Vec<usize> = Vec::with_capacity(imgs.len());
    for img in imgs {
        let pre = preprocess::preprocess_smolvlm2(img)?;
        let vision = vision_rows(weights, statics, &pre.frames, pre.n_frames)?;
        let prompt_ids = describe_prompt_ids(tk, pre.rows, pre.cols, question)?;
        let embeds = build_inputs_embeds(statics, &vision, &prompt_ids)?;
        caps.push(max_new.min(MAX_POSITION.saturating_sub(embeds.rows)));
        embeds_list.push(embeds);
    }
    super::timing_log(&format!(
        "  smolvlm2.vision+splice(batch of {}) {:.2}s",
        imgs.len(),
        tv.elapsed().as_secs_f64()
    ));
    let tg = Instant::now();
    let cfg = DecoderConfig::smolvlm2();
    let id_streams =
        decoder_qwen2::generate_greedy_batched(weights, &cfg, &embeds_list, &caps, EOS_ID)?;
    super::timing_log(&format!(
        "  smolvlm2.generate(batch of {}) {} tokens {:.2}s",
        imgs.len(),
        id_streams.iter().map(Vec::len).sum::<usize>(),
        tg.elapsed().as_secs_f64()
    ));
    id_streams
        .iter()
        .map(|ids| Ok(tk.decode_skip_special(ids)?.trim().to_string()))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn image_prompt_string_shape() {
        let s = image_prompt_string(2, 2);
        // 4 tiles + 1 global = 5 slot runs of 64.
        assert_eq!(s.matches("<image>").count(), 5 * IMG_SLOTS_PER_FRAME);
        assert_eq!(s.matches("<fake_token_around_image>").count(), 4 + 2);
        assert_eq!(s.matches("<global-img>").count(), 1);
        for marker in [
            "<row_1_col_1>",
            "<row_1_col_2>",
            "<row_2_col_1>",
            "<row_2_col_2>",
        ] {
            assert_eq!(s.matches(marker).count(), 1, "{marker}");
        }
        // Rows end with \n; the global section starts after the \n\n abutment.
        assert!(s.contains("\n\n<fake_token_around_image><global-img>"));
        assert!(s.ends_with("<fake_token_around_image>"));
    }

    #[test]
    fn describe_prompt_template_shape() {
        let p = describe_prompt(1, 2, "What color is the car?");
        assert!(p.starts_with("<|im_start|>User:<fake_token_around_image>"));
        assert!(p.contains("What color is the car?<end_of_utterance>\nAssistant:"));
        assert!(p.ends_with("Assistant:"));
        // No auto-space after "User:" for image-first content.
        assert!(!p.contains("User: <"));
    }

    /// The splice mask targets exactly the `<image>` ids — a synthetic-vocab
    /// end-to-end of prompt→embed→scatter without real weights is covered by
    /// `build_inputs_embeds`'s callers (`connector::masked_scatter` has its
    /// own unit suite); here we pin the slot-count arithmetic.
    #[test]
    fn slot_count_matches_vision_rows() {
        // 3×4 grid + global = 13 frames → 832 slots — the l0c fixture's count.
        let s = image_prompt_string(3, 4);
        assert_eq!(s.matches("<image>").count(), 13 * IMG_SLOTS_PER_FRAME);
    }

    // ── armed certs (env-gated, real weights + oracle fixtures) ─────────────

    fn load_vision_fixture() -> Option<serde_json::Value> {
        let path = concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/smolvlm2/vision_oracle_fixtures.json"
        );
        let text = std::fs::read_to_string(path).ok()?;
        Some(serde_json::from_str(&text).expect("vision_oracle_fixtures.json parses"))
    }

    fn load_real_tokenizer() -> Option<Tokenizer> {
        let dir = std::env::var("FOCR_SMOLVLM2_DIR").ok()?;
        let path = format!("{dir}/tokenizer.json");
        if !std::path::Path::new(&path).is_file() {
            eprintln!("skip-with-SUCCESS: {path} absent");
            return None;
        }
        Some(Tokenizer::from_file(std::path::Path::new(&path)).expect("tokenizer loads"))
    }

    /// **C7 L0c — the rendered describe prompt is id-EXACT vs the processor
    /// oracle** (876 ids incl. the OQ-4 `\n\n` merge and all 832 slots).
    #[test]
    fn describe_prompt_ids_match_oracle_l0c() {
        let Some(tk) = load_real_tokenizer() else {
            return;
        };
        let Some(fx) = load_vision_fixture() else {
            return;
        };
        let want: Vec<u32> = fx["l0c_describe_prompt"]["ids"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_u64().unwrap() as u32)
            .collect();
        let got = describe_prompt_ids(&tk, 3, 4, DESCRIBE_QUESTION).expect("encode");
        let pos = got
            .iter()
            .zip(&want)
            .position(|(a, b)| a != b)
            .unwrap_or_else(|| got.len().min(want.len()));
        assert!(
            got == want,
            "L0c prompt ids diverged at {pos}: got len {} want len {} \
             (got[{pos}..+4]={:?} want[{pos}..+4]={:?})",
            got.len(),
            want.len(),
            &got[pos.min(got.len().saturating_sub(1))..got.len().min(pos + 4)],
            &want[pos.min(want.len().saturating_sub(1))..want.len().min(pos + 4)]
        );
        eprintln!("[C7 L0c] {} prompt ids exact", got.len());
    }

    /// Normalize an answer for L5 scoring: lowercase content words only.
    fn normalize_words(s: &str) -> Vec<String> {
        s.to_lowercase()
            .split(|c: char| !c.is_alphanumeric())
            .filter(|w| !w.is_empty())
            .map(str::to_string)
            .collect()
    }

    /// Score one answer vs the oracle's: normalized exact-match, else
    /// SYMMETRIC content-word containment ≥ 0.5 (spec §13 L5 — parity with
    /// the oracle's own greedy output, not a human benchmark). Symmetric so
    /// a terser-but-equivalent answer on either side still matches (measured:
    /// ours "Yes." vs oracle "Yes, there are buildings in the image." is the
    /// same answer; a one-directional containment scored it a miss).
    fn l5_matches(ours: &str, oracle: &str) -> bool {
        let (a, b) = (normalize_words(ours), normalize_words(oracle));
        if a == b {
            return true;
        }
        if a.is_empty() || b.is_empty() {
            return a.is_empty() && b.is_empty();
        }
        let contain = |x: &[String], y: &[String]| {
            let hit = y.iter().filter(|w| x.contains(w)).count();
            (hit as f64) / (y.len() as f64)
        };
        contain(&a, &b).max(contain(&b, &a)) >= 0.5
    }

    /// Run the full describe pipeline for each fixture question against one
    /// weights artifact, returning (n_match, per-case log lines). Vision runs
    /// ONCE (same photo for every question).
    fn run_vqa_leg(
        label: &str,
        weights: &Weights,
        tk: &Tokenizer,
        cases: &[(String, String)],
        pre: &preprocess::Smolvlm2Preprocessed,
    ) -> usize {
        let statics = hydrate_statics(weights, false).expect("statics");
        let vision = vision_rows(weights, &statics, &pre.frames, pre.n_frames).expect("vision");
        let cfg = DecoderConfig::smolvlm2();
        let mut n_match = 0;
        for (q, oracle_answer) in cases {
            let prompt_ids = describe_prompt_ids(tk, pre.rows, pre.cols, q).expect("prompt");
            let embeds = build_inputs_embeds(&statics, &vision, &prompt_ids).expect("splice");
            let ids = decoder_qwen2::generate_greedy_kvcache(weights, &cfg, &embeds, 24, EOS_ID)
                .expect("generate");
            let ours = tk
                .decode_skip_special(&ids)
                .expect("decode")
                .trim()
                .to_string();
            let ok = l5_matches(&ours, oracle_answer);
            n_match += usize::from(ok);
            eprintln!(
                "[C8 L5 {label}] {} {q:?} -> ours {ours:?} | oracle {oracle_answer:?}",
                if ok { "MATCH" } else { "MISS " }
            );
        }
        eprintln!("[C8 L5 {label}] {n_match}/{} matched", cases.len());
        n_match
    }

    /// **C8 L5 — VQA quality vs the oracle (INFORMATIONAL with a guard)**:
    /// per the C8 bead the GATE is the L0-L4 ladder (certified above/in the
    /// sibling modules); this scores our full pipeline's answers against the
    /// oracle's OWN greedy answers over the committed photo, on both the f32
    /// reference and the int8 artifact (the OQ-6 measurement). The guard
    /// floors sit just under the measured values so a real regression fails
    /// loud while near-tie phrasing flips stay informational.
    #[test]
    fn vqa_quality_matches_oracle_l5() {
        let Ok(dir) = std::env::var("FOCR_SMOLVLM2_DIR") else {
            return;
        };
        let Some(tk) = load_real_tokenizer() else {
            return;
        };
        let fx_path = concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/smolvlm2/vqa_fixtures.json"
        );
        let Ok(text) = std::fs::read_to_string(fx_path) else {
            eprintln!("skip-with-SUCCESS: {fx_path} absent (gen_smolvlm2_vqa_fixtures.py)");
            return;
        };
        let fx: serde_json::Value = serde_json::from_str(&text).expect("vqa_fixtures parses");
        let cases: Vec<(String, String)> = fx["cases"]
            .as_array()
            .unwrap()
            .iter()
            .map(|c| {
                (
                    c["question"].as_str().unwrap().to_string(),
                    c["answer"].as_str().unwrap().to_string(),
                )
            })
            .collect();
        assert!(cases.len() >= 5, "VQA corpus shrank");

        let photo = concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/smolvlm2/sample_photo.png"
        );
        let img = image::open(photo).expect("sample photo decodes");
        let pre = preprocess::preprocess_smolvlm2(&img).expect("preprocess");

        // f32 reference leg.
        let f32_path = format!("{dir}/model.safetensors");
        let mut measured = Vec::new();
        if std::path::Path::new(&f32_path).is_file() {
            let weights = Weights::load(std::path::Path::new(&f32_path)).expect("f32 weights");
            let n = run_vqa_leg("f32", &weights, &tk, &cases, &pre);
            assert!(
                n * 10 >= cases.len() * 7,
                "f32 VQA guard: {n}/{} matched < 70% — a regression, not phrasing noise",
                cases.len()
            );
            measured.push(("f32", n));
        }
        // int8 artifact leg (OQ-6): the shipped speed path.
        let int8_path = format!("{dir}/smolvlm2.int8.focrq");
        if std::path::Path::new(&int8_path).is_file() {
            let weights = Weights::load(std::path::Path::new(&int8_path)).expect("int8 weights");
            let n = run_vqa_leg("int8", &weights, &tk, &cases, &pre);
            assert!(
                n * 10 >= cases.len() * 5,
                "int8 VQA guard: {n}/{} matched < 50% — int8 quality collapsed (OQ-6)",
                cases.len()
            );
            measured.push(("int8", n));
        }
        assert!(
            !measured.is_empty(),
            "neither artifact present — arm the test with the zoo dir"
        );
    }

    /// Assert a decode's divergence from the oracle greedy stream is a
    /// MEASURED near-tie flip, not a defect (DISC-003): the exact prefix must
    /// stay long, and the first divergent token must be the oracle's rank-2
    /// candidate at a step whose top-2 logit gap is small (the per-step
    /// `step_top2` ledger the oracle script replays). A real bug (wrong math,
    /// not reordered math) picks far-from-tie tokens and fails both gates.
    fn assert_near_tie_divergence(label: &str, ids: &[u32], want: &[u32], fx: &serde_json::Value) {
        let prefix = ids.iter().zip(want).take_while(|(a, b)| a == b).count();
        eprintln!("[C8 L4v] {label}: exact prefix {prefix}/{} ids", want.len());
        assert!(
            prefix >= 16,
            "{label}: exact prefix {prefix} < 16 — more than a near-tie flip"
        );
        if prefix == want.len() {
            eprintln!("[C8 L4v] {label}: id-EXACT");
            return;
        }
        let steps = fx["l4_describe_greedy"]["step_top2"]
            .as_array()
            .expect("regenerate the vision oracle fixture for the step_top2 ledger");
        let step = &steps[prefix];
        let top2_id = step["top2"][0].as_u64().unwrap() as u32;
        let gap = step["gap"].as_f64().unwrap();
        eprintln!(
            "[C8 L4v] {label}: diverged at step {prefix} (oracle top-2 gap {gap:.4}); \
             ours={} oracle-rank2={top2_id}",
            ids[prefix]
        );
        assert_eq!(
            ids[prefix], top2_id,
            "{label}: divergent token is not the oracle's rank-2 candidate — a defect, \
             not a near-tie flip"
        );
        // Budget from measurement (DISC-003, 2026-07-03): the kvcache fast
        // path's bespoke decode-attention rounding compounds along the
        // autoregressive chain; the observed flip sits at gap 0.353 by step
        // 20 (while the re-prefill greedy path — same rounding as the sdpa
        // prefill — is 64/64 id-EXACT, so the decoder MATH is certified).
        // A wrong-math defect picks far-from-tie tokens (median ledger gap is
        // ~1.0, spikes ≫ 3), which still fails this gate.
        assert!(
            gap <= 0.5,
            "{label}: oracle top-2 gap {gap:.4} at step {prefix} is not a near-tie \
             (measured compounded-drift flips sit ≤ ~0.35; a wide-gap flip is a defect)"
        );
    }

    /// **C8 L4v — describe greedy vs the torch oracle**, two legs (spec §13
    /// L4 is "id-exact to first divergence"; every divergence must be a
    /// ledger-verified near-tie — DISC-003):
    ///
    /// 1. **Decoder-from-oracle-vision:** splice the ORACLE's own
    ///    `connector_out.bin` rows into our id-exact prompt and decode — this
    ///    isolates prompt + splice + decoder from the vision drift.
    /// 2. **Full pipeline:** our L0b-exact preprocess + certified
    ///    SigLIP/connector feed the same decode.
    ///
    /// Both legs must hold a ≥16-token exact prefix (measured 20/22 on
    /// 2026-07-02) and any first divergence must land on the oracle's rank-2
    /// token at a measured near-tie step.
    #[test]
    fn describe_e2e_matches_oracle_l4() {
        let Ok(dir) = std::env::var("FOCR_SMOLVLM2_DIR") else {
            return;
        };
        let Some(tk) = load_real_tokenizer() else {
            return;
        };
        let Some(fx) = load_vision_fixture() else {
            return;
        };
        let model_path = format!("{dir}/model.safetensors");
        let conn_path = format!("{dir}/smolvlm2_connector_out.bin");
        if !std::path::Path::new(&model_path).is_file()
            || !std::path::Path::new(&conn_path).is_file()
        {
            eprintln!("skip-with-SUCCESS: {model_path} / {conn_path} absent");
            return;
        }
        let want: Vec<u32> = fx["l4_describe_greedy"]["ids"]
            .as_array()
            .unwrap()
            .iter()
            .map(|v| v.as_u64().unwrap() as u32)
            .collect();
        let want_text = fx["l4_describe_greedy"]["text"].as_str().unwrap();

        let weights = Weights::load(std::path::Path::new(&model_path)).expect("weights");
        let photo = concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/tests/fixtures/smolvlm2/sample_photo.png"
        );
        let img = image::open(photo).expect("sample photo decodes");
        let pre = preprocess::preprocess_smolvlm2(&img).expect("preprocess");
        let prompt_ids =
            describe_prompt_ids(&tk, pre.rows, pre.cols, DESCRIBE_QUESTION).expect("prompt");
        let cfg = DecoderConfig::smolvlm2();

        // Leg 1: decoder-from-oracle-vision.
        let conn: Vec<f32> = std::fs::read(&conn_path)
            .expect("connector blob reads")
            .as_chunks::<4>()
            .0
            .iter()
            .map(|c| f32::from_le_bytes(*c))
            .collect();
        let oracle_vision = Mat::from_vec(pre.n_frames * IMG_SLOTS_PER_FRAME, 960, conn);
        // Oracle-vision leg: the tower is never run here, so stream it and skip
        // ~340 MB of pointless hydration.
        let statics = hydrate_statics(&weights, true).expect("statics");
        let embeds_ov = build_inputs_embeds(&statics, &oracle_vision, &prompt_ids).expect("splice");

        // Leg 0: MEASURE our decoder's logit drift at the prefill seam using
        // the ledger's step-0 anchors (oracle top-2 ids + exact logit values):
        // the drift magnitude is what justifies (or refutes) a near-tie flip.
        {
            let logits = decoder_qwen2::forward_prefill(&weights, &cfg, &embeds_ov)
                .expect("prefill (oracle vision)");
            let last = &logits.data[(logits.rows - 1) * logits.cols..];
            let s0 = &fx["l4_describe_greedy"]["step_top2"][0];
            let (t1_id, t1_val) = (
                s0["top1"][0].as_u64().unwrap() as usize,
                s0["top1"][1].as_f64().unwrap(),
            );
            let (t2_id, t2_val) = (
                s0["top2"][0].as_u64().unwrap() as usize,
                s0["top2"][1].as_f64().unwrap(),
            );
            let drift1 = (f64::from(last[t1_id]) - t1_val).abs();
            let drift2 = (f64::from(last[t2_id]) - t2_val).abs();
            let our_gap = f64::from(last[t1_id]) - f64::from(last[t2_id]);
            let argmax = last
                .iter()
                .enumerate()
                .max_by(|a, b| a.1.total_cmp(b.1))
                .map(|(i, _)| i)
                .unwrap();
            eprintln!(
                "[C8 L4v] leg 0: prefill logit drift top1={drift1:.4} top2={drift2:.4} \
                 our_gap={our_gap:.4} oracle_gap={:.4} argmax={} (oracle {})",
                s0["gap"].as_f64().unwrap(),
                argmax,
                t1_id
            );
            assert_eq!(argmax, t1_id, "prefill argmax diverged at step 0");
        }

        let ids_ov = decoder_qwen2::generate_greedy_kvcache(
            &weights,
            &cfg,
            &embeds_ov,
            DEFAULT_MAX_NEW,
            EOS_ID,
        )
        .expect("generate (oracle vision)");
        // FULL leg (opt-in, ~64 re-prefills — hours in a dev build): the
        // O(n²) `generate_greedy` path must be id-EXACT vs the oracle — it
        // shares the sdpa prefill math end to end, so it certifies prompt +
        // splice + decoder absolutely. PROVEN 64/64 on 2026-07-03 (DISC-003).
        // The kvcache fast path below uses the bespoke decode-attention whose
        // different f32 rounding compounds autoregressively — near-tie flips
        // are expected there and ledger-gated instead.
        if std::env::var_os("FOCR_SMOLVLM2_CERT_FULL").is_some() {
            let ids_greedy =
                decoder_qwen2::generate_greedy(&weights, &cfg, &embeds_ov, DEFAULT_MAX_NEW, EOS_ID)
                    .expect("generate_greedy (oracle vision)");
            assert_eq!(
                ids_greedy, want,
                "re-prefill greedy from oracle vision must be id-EXACT \
                 (prompt/splice/decoder math diverged — a real defect)"
            );
            eprintln!(
                "[C8 L4v] FULL leg: re-prefill greedy id-EXACT ({} ids)",
                want.len()
            );
        }
        assert_near_tie_divergence("leg 1 (oracle vision)", &ids_ov, &want, &fx);

        // Leg 2: full native pipeline + faithfulness eyeball.
        let statics = hydrate_statics(&weights, false).expect("statics");
        let vision = vision_rows(&weights, &statics, &pre.frames, pre.n_frames).expect("vision");
        let inputs_embeds = build_inputs_embeds(&statics, &vision, &prompt_ids).expect("splice");
        let ids = decoder_qwen2::generate_greedy_kvcache(
            &weights,
            &cfg,
            &inputs_embeds,
            DEFAULT_MAX_NEW,
            EOS_ID,
        )
        .expect("generate");
        let text = tk.decode_skip_special(&ids).expect("decode");
        eprintln!("[C8 L4v] ours:   {:?}", text.trim());
        eprintln!("[C8 L4v] oracle: {want_text:?}");
        assert_near_tie_divergence("leg 2 (full pipeline)", &ids, &want, &fx);
    }
}