//! walk_correctness — deep per-layer parity check for the unified Walk FFN. //! //! Wraps `WeightFfn` and `WalkFfn::new_unlimited` in a `DualFfn` that runs //! both backends against the same pre-FFN residual at every layer and //! records L2 / cosine / max-element divergence. Finishes with end-to-end //! logit parity against an all-dense baseline. //! //! Gates: //! - per-layer L2 ≤ 1e-3 (f16 vindex vs f32 weights noise floor) //! - per-layer cos ≥ 0.9999 //! - end-to-end top-1 match, prob delta ≤ 0.02 (Q4K/Q6K) or ≤ 0.035 (all-Q4K) //! //! The Phase B prob-delta budget auto-adapts to the vindex quantisation: //! `--down-q4k` builds (Q4_K on down_proj) trip an extra ~1.5% of softmax //! redistribution that's functionally harmless (top-1 + top-5 preserved), //! so the gate loosens from 0.02 to 0.035 when it detects Q4_K-down. //! //! Usage: //! cargo run --release -p infer-inference --example walk_correctness -- \ //! --model google/gemma-3-4b-it \ //! --vindex /path/to/gemma3-4b.vindex \ //! [--prompt "The capital of France is"] use std::cell::RefCell; use std::path::PathBuf; use std::time::Instant; use ndarray::Array2; use infer_inference::{ predict, predict_with_ffn, vindex::{WalkFfn, WalkFfnConfig}, FfnBackend, InferenceModel, WeightFfn, }; use infer_vindex::{SilentLoadCallbacks, VectorIndex}; // ── CLI parsing ──────────────────────────────────────────────────────── struct Args { model: String, vindex: PathBuf, prompt: String, } fn parse_args() -> Args { let args: Vec = std::env::args().collect(); let mut model = String::new(); let mut vindex = PathBuf::new(); let mut prompt = "The capital of France is".to_string(); let mut i = 1; while i < args.len() { match args[i].as_str() { "--model" => { i += 1; model = args[i].clone(); } "--vindex" => { i += 1; vindex = PathBuf::from(&args[i]); } "--prompt" => { i += 1; prompt = args[i].clone(); } _ => {} } i += 1; } if model.is_empty() || !vindex.is_dir() { eprintln!("Usage: walk_correctness --model MODEL --vindex PATH [--prompt TEXT]"); std::process::exit(1); } Args { model, vindex, prompt, } } // ── Dual FFN wrapper ─────────────────────────────────────────────────── #[derive(Clone, Copy, Debug, Default)] struct LayerDiff { l2: f32, cos: f32, max_abs: f32, primary_norm: f32, secondary_norm: f32, } struct DualFfn<'a> { primary: &'a dyn FfnBackend, secondary: &'a dyn FfnBackend, diffs: RefCell>, } impl<'a> FfnBackend for DualFfn<'a> { fn forward(&self, layer: usize, x: &Array2) -> Array2 { self.forward_with_activation(layer, x).0 } fn forward_with_activation(&self, layer: usize, x: &Array2) -> (Array2, Array2) { let (p_out, p_act) = self.primary.forward_with_activation(layer, x); let (s_out, _) = self.secondary.forward_with_activation(layer, x); let diff = layer_diff(&p_out, &s_out); self.diffs.borrow_mut().push((layer, diff)); (p_out, p_act) } fn name(&self) -> &str { "dual" } } /// Returns true when the interleaved Q4K manifest stores down_proj as Q4_K /// (the `--down-q4k` build variant). Falls back to `false` — the safer, /// tighter-threshold default — on any parse or IO error. fn detect_down_q4k(vindex: &std::path::Path) -> bool { let manifest_path = vindex.join("interleaved_q4k_manifest.json"); let Ok(bytes) = std::fs::read(&manifest_path) else { return false; }; let Ok(value) = serde_json::from_slice::(&bytes) else { return false; }; let Some(entries) = value.as_array() else { return false; }; for entry in entries { let key = entry.get("key").and_then(|v| v.as_str()).unwrap_or(""); if key.contains("down_proj") { return entry.get("format").and_then(|v| v.as_str()) == Some("Q4_K"); } } false } fn layer_diff(a: &Array2, b: &Array2) -> LayerDiff { let seq_len = a.shape()[0]; let hidden = a.shape()[1]; let last = seq_len - 1; let mut l2_sq = 0.0f32; let mut max_abs = 0.0f32; let mut dot = 0.0f32; let mut a_norm_sq = 0.0f32; let mut b_norm_sq = 0.0f32; for j in 0..hidden { let ai = a[[last, j]]; let bi = b[[last, j]]; let d = ai - bi; l2_sq += d * d; let abs_d = d.abs(); if abs_d > max_abs { max_abs = abs_d; } dot += ai * bi; a_norm_sq += ai * ai; b_norm_sq += bi * bi; } let a_norm = a_norm_sq.sqrt(); let b_norm = b_norm_sq.sqrt(); let cos = if a_norm > 0.0 && b_norm > 0.0 { dot / (a_norm * b_norm) } else { 0.0 }; LayerDiff { l2: l2_sq.sqrt(), cos, max_abs, primary_norm: a_norm, secondary_norm: b_norm, } } // ── Main ─────────────────────────────────────────────────────────────── fn main() -> Result<(), Box> { let args = parse_args(); println!("=== Walk Correctness ===\n"); println!("Model: {}", args.model); println!("Vindex: {}", args.vindex.display()); println!("Prompt: {:?}\n", args.prompt); // Load model + vindex let t0 = Instant::now(); let model = InferenceModel::load(&args.model)?; println!( "Model loaded in {:.1}s ({} layers, hidden={})", t0.elapsed().as_secs_f64(), model.weights().num_layers, model.weights().hidden_size ); let t0 = Instant::now(); let mut cb = SilentLoadCallbacks; let index = VectorIndex::load_vindex(&args.vindex, &mut cb)?; println!( "Vindex loaded in {:.1}s ({} vectors)\n", t0.elapsed().as_secs_f64(), index.total_gate_vectors() ); let weights = model.weights(); let tokenizer = model.tokenizer(); let num_layers = weights.num_layers; let encoding = tokenizer .encode(args.prompt.as_str(), true) .map_err(|e| format!("tokenize: {e}"))?; let token_ids: Vec = encoding.get_ids().to_vec(); // ── Phase A: per-layer FFN parity ────────────────────────────────── println!("--- Phase A: per-layer FFN parity (WeightFfn vs WalkFfn[full-K]) ---\n"); let weight_ffn = WeightFfn { weights }; // Force the walk_ffn_sparse path (not the dense ladder) by setting // K=Some(usize::MAX). This is what production walk-only inference // hits, so the parity check must cover it. let walk_ffn = WalkFfn::from_config( weights, &index, WalkFfnConfig::sparse(num_layers, usize::MAX), ); let dual = DualFfn { primary: &weight_ffn, secondary: &walk_ffn, diffs: RefCell::new(Vec::with_capacity(num_layers)), }; let t0 = Instant::now(); let _ = predict_with_ffn(weights, tokenizer, &token_ids, 5, &dual); println!(" Dual forward pass: {:.2}s\n", t0.elapsed().as_secs_f64()); let diffs = dual.diffs.borrow(); println!( " {:>4} {:>10} {:>10} {:>10} {:>12} {:>12}", "layer", "L2", "cos", "max|Δ|", "‖weight‖", "‖walk‖" ); println!(" {:-<78}", ""); let mut max_l2 = 0.0f32; let mut min_cos = 1.0f32; let mut max_abs = 0.0f32; let mut worst_layer = 0usize; for (layer, d) in diffs.iter() { println!( " {:>4} {:>10.3e} {:>10.6} {:>10.3e} {:>12.4} {:>12.4}", layer, d.l2, d.cos, d.max_abs, d.primary_norm, d.secondary_norm ); if d.l2 > max_l2 { max_l2 = d.l2; worst_layer = *layer; } if d.cos < min_cos { min_cos = d.cos; } if d.max_abs > max_abs { max_abs = d.max_abs; } } drop(diffs); println!(); println!( " Summary: max L2={:.3e} (layer {}) min cos={:.6} max|Δ|={:.3e}", max_l2, worst_layer, min_cos, max_abs ); // f32 vindexes hit bit-identity (L2=0, cos=1). Q4K/Q6K vindexes carry // quantisation noise — observed ~0.9 L2 / 0.998 cos on Gemma 3 4B. We // gate on top-1 + prob in Phase B, so Phase A stays informational. let phase_a_ok = max_l2 <= 5.0 && min_cos >= 0.99; println!(" Phase A: {}\n", if phase_a_ok { "PASS" } else { "FAIL" }); // ── Phase B: end-to-end logit parity ─────────────────────────────── println!("--- Phase B: end-to-end logit parity ---\n"); let dense_pred = predict(weights, tokenizer, &token_ids, 5); let walk_ffn2 = WalkFfn::from_config( weights, &index, WalkFfnConfig::sparse(num_layers, usize::MAX), ); let walk_pred = predict_with_ffn(weights, tokenizer, &token_ids, 5, &walk_ffn2); let dense_top1 = dense_pred.predictions.first().cloned().unwrap_or_default(); let walk_top1 = walk_pred.predictions.first().cloned().unwrap_or_default(); println!(" Dense top-5:"); for (i, (tok, p)) in dense_pred.predictions.iter().enumerate().take(5) { println!(" {}: {:<20} {:.6}", i + 1, tok, p); } println!(" Walk top-5:"); for (i, (tok, p)) in walk_pred.predictions.iter().enumerate().take(5) { println!(" {}: {:<20} {:.6}", i + 1, tok, p); } let top1_match = dense_top1.0 == walk_top1.0; let prob_delta = (dense_top1.1 - walk_top1.1).abs(); // Top-5 Jaccard let dense_set: std::collections::HashSet<_> = dense_pred .predictions .iter() .take(5) .map(|(t, _)| t.clone()) .collect(); let walk_set: std::collections::HashSet<_> = walk_pred .predictions .iter() .take(5) .map(|(t, _)| t.clone()) .collect(); let jacc = dense_set.intersection(&walk_set).count() as f64 / dense_set.union(&walk_set).count().max(1) as f64; // Auto-detect whether down_proj is quantised as Q4_K or Q6_K from the // interleaved manifest. --down-q4k builds redistribute ~1.5% more // softmax mass than Q6K-down builds; the gate adapts so power-user // trade-offs don't look like regressions. let down_q4k = detect_down_q4k(&args.vindex); let prob_delta_budget = if down_q4k { 0.035 } else { 0.02 }; println!(); println!( " top-1 match: {} (dense={:?} walk={:?})", top1_match, dense_top1.0, walk_top1.0 ); println!( " prob delta: {:.6} (budget {:.3}, down={})", prob_delta, prob_delta_budget, if down_q4k { "Q4_K" } else { "Q6_K" } ); println!(" top-5 Jaccard: {:.3}", jacc); let phase_b_ok = top1_match && prob_delta <= prob_delta_budget; println!(" Phase B: {}\n", if phase_b_ok { "PASS" } else { "FAIL" }); // ── Summary ──────────────────────────────────────────────────────── println!("=== Summary ==="); println!( " Phase A (per-layer parity): {}", if phase_a_ok { "PASS" } else { "FAIL" } ); println!( " Phase B (end-to-end parity): {}", if phase_b_ok { "PASS" } else { "FAIL" } ); if phase_a_ok && phase_b_ok { println!("\n ALL CHECKS PASS"); Ok(()) } else { std::process::exit(1); } }