Skip to main content

qualia_core_db/inference/inference_bench/
probes.rs

1//! Correctness / parity / evaluation probes that drive the real engine on a
2//! dedicated runtime: top-k A/B decode, single-decode metrics, GEMM parity
3//! (Q8/F16), native perplexity, GPU KV readback, the AWQ α-sweep, exact-sampler
4//! decode, and the speculative-decode verify probe. Pure code motion — unchanged.
5
6use crate::llm_agent::{AgentBackend, LocalLlmAgent};
7
8use super::metrics::tok_per_s;
9use super::*;
10
11/// A1a correctness: decode the same prompt with the GPU top-k path **off** then **on** (same resident
12/// model, deterministic argmax) and return both strings. Since k=1 top-k == argmax, the texts must be
13/// byte-identical — this verifies the GEMM→top-k wiring, not just the kernel (which is oracle-tested).
14pub fn compare_topk_decode(
15    model_path: &str,
16    prompt: &str,
17    decode_tokens: u32,
18) -> Result<(String, String), String> {
19    if !std::path::Path::new(model_path).exists() {
20        return Err(format!("model not found: {model_path}"));
21    }
22    let agent = LocalLlmAgent::with_local_backend(
23        "did:qualia:bench",
24        AgentBackend::Local {
25            model_path: model_path.to_string(),
26            context_window: 4096,
27            quantization: "auto".into(),
28            vision_projector_path: None,
29            modality: "text".into(),
30            architecture: None,
31        },
32    );
33    set_decode_budget_override(decode_tokens);
34    let model_id = crate::q_hash(model_path);
35    let _ = crate::resident_model::mount_resident_gguf(model_id, model_path, false);
36
37    set_gpu_topk(false);
38    let (off_text, _, _, _) = agent.infer_local_model_streaming::<fn(String)>(prompt, "", None);
39    set_gpu_topk(true);
40    let (on_text, _, _, _) = agent.infer_local_model_streaming::<fn(String)>(prompt, "", None);
41
42    set_gpu_topk(false);
43    set_decode_budget_override(0);
44    crate::resident_model::clear_resident_model();
45    Ok((off_text, on_text))
46}
47
48/// `compare_topk_decode` inside a fresh multi-thread runtime (residency mount needs `block_in_place`).
49pub fn compare_topk_decode_blocking(
50    model_path: &str,
51    prompt: &str,
52    decode_tokens: u32,
53) -> Result<(String, String), String> {
54    let rt = tokio::runtime::Builder::new_multi_thread()
55        .worker_threads(2)
56        .enable_all()
57        .build()
58        .map_err(|e| e.to_string())?;
59    rt.block_on(async { compare_topk_decode(model_path, prompt, decode_tokens) })
60}
61
62/// A1b: mount a model (auto-detecting `P64` vs GGUF by magic) and run ONE decode of `prompt` for
63/// `decode_tokens`, returning `(text, decode_tok_s)`. For a ternary `.q42` the FFN routing follows
64/// the global `set_ternary_ffn` toggle, so a caller can measure GPU-ON vs CPU-OFF on identical
65/// weights. Caller sets the toggle before invoking. (Use the `_blocking` wrapper from sync code.)
66#[cfg(not(target_arch = "wasm32"))]
67pub fn decode_with_metrics(
68    model_path: &str,
69    prompt: &str,
70    decode_tokens: u32,
71) -> Result<(String, f64), String> {
72    if !std::path::Path::new(model_path).exists() {
73        return Err(format!("model not found: {model_path}"));
74    }
75    let is_q42 = {
76        use std::io::Read;
77        let mut buf = [0u8; 4];
78        std::fs::File::open(model_path)
79            .and_then(|mut f| f.read_exact(&mut buf))
80            .map(|_| &buf == b"p64\0")
81            .unwrap_or(false)
82    };
83    let agent = LocalLlmAgent::with_local_backend(
84        "did:qualia:bench",
85        AgentBackend::Local {
86            model_path: model_path.to_string(),
87            context_window: 4096,
88            quantization: "auto".into(),
89            vision_projector_path: None,
90            modality: "text".into(),
91            architecture: None,
92        },
93    );
94    set_decode_budget_override(decode_tokens);
95    let model_id = crate::q_hash(model_path);
96    if is_q42 {
97        crate::resident_model::mount_resident_q42(model_id, model_path, false)?;
98    } else {
99        let _ = crate::resident_model::mount_resident_gguf(model_id, model_path, false);
100    }
101    reset_phase_metrics();
102    let (text, _, _, _) = agent.infer_local_model_streaming::<fn(String)>(prompt, "", None);
103    let snap = phase_snapshot();
104    let decode_tok_s = tok_per_s(snap.decode_tokens, snap.decode_ns);
105    set_decode_budget_override(0);
106    crate::resident_model::clear_resident_model();
107    Ok((text, decode_tok_s))
108}
109
110/// W3 — GPU↔CPU GEMM parity probe (test/diagnostic). Builds a fresh engine, synthesizes a random
111/// Q8_0 weight matrix (`n_out` rows × `n_in`; `n_in` must be a multiple of 32) + input from `seed`,
112/// runs the GPU kernel and the CPU reference on **identical** bytes, and returns
113/// `(max_abs_err, mean_abs_err, max_ulp, gpu_gemm_passes_profiled)`. A non-zero pass count proves the
114/// GPU path actually executed — the engine readback falls back to CPU when no tokio handle is present,
115/// so the `rt.enter()` below installs one to force the real GPU path.
116#[cfg(not(target_arch = "wasm32"))]
117pub fn gemm_parity_probe_blocking(
118    n_in: usize,
119    n_out: usize,
120    seed: u64,
121) -> Result<(f32, f64, u64, u64), String> {
122    use crate::gguf_sharder::GgufTensorInfo;
123    if n_in == 0 || n_out == 0 || n_in % 32 != 0 {
124        return Err("n_in must be a non-zero multiple of 32 (Q8_0 block size); n_out > 0".into());
125    }
126    let rt = tokio::runtime::Builder::new_multi_thread()
127        .worker_threads(2)
128        .enable_all()
129        .build()
130        .map_err(|e| e.to_string())?;
131    let mut engine = rt
132        .block_on(crate::gguf_bridge::QTensorEngine::try_new())
133        .map_err(|e| format!("engine init: {e}"))?;
134    let _guard = rt.enter(); // install a tokio handle on this thread so the GPU readback path runs
135
136    // Deterministic LCG → values in [-1, 1).
137    let mut s = seed | 1;
138    let mut rng = move || -> f32 {
139        s = s
140            .wrapping_mul(6364136223846793005)
141            .wrapping_add(1442695040888963407);
142        ((s >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
143    };
144
145    let row_bytes = crate::llm_kernel_parity::q8_0_bytes(n_in);
146    let mut raw = vec![0u8; row_bytes * n_out];
147    let mut row_f32 = vec![0f32; n_in];
148    for r in 0..n_out {
149        for x in row_f32.iter_mut() {
150            *x = rng();
151        }
152        if !crate::llm_kernel_parity::quantize_q8_0_from_f32(
153            &row_f32,
154            &mut raw[r * row_bytes..(r + 1) * row_bytes],
155        ) {
156            return Err("q8_0 quantize failed".into());
157        }
158    }
159    let input: Vec<f32> = (0..n_in).map(|_| rng()).collect();
160
161    let info = GgufTensorInfo {
162        dims: [n_in as u64, n_out as u64, 1, 1],
163        n_dims: 2,
164        ggml_type: crate::ggml_quants::GGML_TYPE_Q8_0,
165        byte_offset: 0,
166    };
167
168    crate::llm_gpu_profiler::set_enabled(true);
169    crate::llm_gpu_profiler::reset();
170    let mut gpu_out = vec![0f32; n_out];
171    let mut cpu_out = vec![0f32; n_out];
172    let ok = engine.gemm_parity_probe(&info, &raw, &input, &mut gpu_out, &mut cpu_out, n_in, n_out);
173    let calls = crate::llm_gpu_profiler::snapshot()
174        .iter()
175        .find(|t| matches!(t.phase, crate::llm_gpu_profiler::Phase::Gemm))
176        .map(|t| t.calls)
177        .unwrap_or(0);
178    crate::llm_gpu_profiler::set_enabled(false);
179    if !ok {
180        return Err("gemm_parity_probe: GPU or CPU path returned false".into());
181    }
182    Ok((
183        crate::llm_kernel_parity::max_abs_err(&gpu_out, &cpu_out),
184        crate::llm_kernel_parity::mean_abs_err(&gpu_out, &cpu_out),
185        crate::llm_kernel_parity::max_ulp_diff(&gpu_out, &cpu_out),
186        calls,
187    ))
188}
189
190/// W3/F16 — GPU↔CPU parity for the new **F16** GEMM path (`unpack2x16float` in the shader vs the CPU
191/// `dequant_f16` reference). Synthesizes a random F16 weight matrix (`n_out` rows × `n_in`; no block
192/// constraint) + input from `seed`, runs both on identical bytes, returns
193/// `(max_abs_err, mean_abs_err, max_ulp, gpu_gemm_passes)`. Same witness rule as the Q8 probe.
194#[cfg(not(target_arch = "wasm32"))]
195pub fn gemm_parity_probe_f16_blocking(
196    n_in: usize,
197    n_out: usize,
198    seed: u64,
199) -> Result<(f32, f64, u64, u64), String> {
200    use crate::gguf_sharder::GgufTensorInfo;
201    if n_in == 0 || n_out == 0 {
202        return Err("n_in and n_out must be > 0".into());
203    }
204    let rt = tokio::runtime::Builder::new_multi_thread()
205        .worker_threads(2)
206        .enable_all()
207        .build()
208        .map_err(|e| e.to_string())?;
209    let mut engine = rt
210        .block_on(crate::gguf_bridge::QTensorEngine::try_new())
211        .map_err(|e| format!("engine init: {e}"))?;
212    let _guard = rt.enter();
213
214    let mut s = seed | 1;
215    let mut rng = move || -> f32 {
216        s = s
217            .wrapping_mul(6364136223846793005)
218            .wrapping_add(1442695040888963407);
219        ((s >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
220    };
221
222    let row_bytes = crate::llm_kernel_parity::f16_bytes(n_in);
223    let mut raw = vec![0u8; row_bytes * n_out];
224    let mut row_f32 = vec![0f32; n_in];
225    for r in 0..n_out {
226        for x in row_f32.iter_mut() {
227            *x = rng();
228        }
229        if !crate::llm_kernel_parity::quantize_f16_from_f32(
230            &row_f32,
231            &mut raw[r * row_bytes..(r + 1) * row_bytes],
232        ) {
233            return Err("f16 quantize failed".into());
234        }
235    }
236    let input: Vec<f32> = (0..n_in).map(|_| rng()).collect();
237
238    let info = GgufTensorInfo {
239        dims: [n_in as u64, n_out as u64, 1, 1],
240        n_dims: 2,
241        ggml_type: crate::ggml_quants::GGML_TYPE_F16,
242        byte_offset: 0,
243    };
244
245    crate::llm_gpu_profiler::set_enabled(true);
246    crate::llm_gpu_profiler::reset();
247    let mut gpu_out = vec![0f32; n_out];
248    let mut cpu_out = vec![0f32; n_out];
249    let ok = engine.gemm_parity_probe(&info, &raw, &input, &mut gpu_out, &mut cpu_out, n_in, n_out);
250    let calls = crate::llm_gpu_profiler::snapshot()
251        .iter()
252        .find(|t| matches!(t.phase, crate::llm_gpu_profiler::Phase::Gemm))
253        .map(|t| t.calls)
254        .unwrap_or(0);
255    crate::llm_gpu_profiler::set_enabled(false);
256    if !ok {
257        return Err("gemm_parity_probe (f16): GPU or CPU path returned false".into());
258    }
259    Ok((
260        crate::llm_kernel_parity::max_abs_err(&gpu_out, &cpu_out),
261        crate::llm_kernel_parity::mean_abs_err(&gpu_out, &cpu_out),
262        crate::llm_kernel_parity::max_ulp_diff(&gpu_out, &cpu_out),
263        calls,
264    ))
265}
266
267/// W1 — teacher-forced perplexity of `model_path` over the eval corpus, run through Qualia's **native**
268/// engine (never an external runtime). For each corpus passage: `reset_kv_cache`, then per position
269/// embed → `dispatch_transformer_forward` → `apply_output_norm_inplace` → `dispatch_output_logits_into`
270/// → NLL of the true next token; PPL = `exp(ΣNLL / Σtokens)`. `max_tok` = 0 scores the whole passage,
271/// >0 caps it (to bound the slow F16-on-CPU path for big models). Returns `(perplexity, tokens_scored)`.
272/// Runs on a dedicated thread with a current-thread tokio runtime (mirrors the decode path) so the
273/// engine's GPU readback works. Handles both GGUF and `.q42` containers.
274#[cfg(not(target_arch = "wasm32"))]
275pub fn perplexity_eval_blocking(model_path: &str, max_tok: usize) -> Result<(f64, usize), String> {
276    use crate::gguf_bridge::QTensorEngine;
277    use crate::gguf_sharder::{GgufTensorIndex, GgufTokenizer};
278
279    let corpus = crate::llm_eval::load_corpus().map_err(|e| format!("corpus load: {e}"))?;
280    if corpus.is_empty() {
281        return Err("eval corpus is empty".into());
282    }
283    let model_path = model_path.to_string();
284
285    std::thread::spawn(move || -> Result<(f64, usize), String> {
286        let rt = tokio::runtime::Builder::new_current_thread()
287            .enable_all()
288            .build()
289            .map_err(|e| e.to_string())?;
290        let _g = rt.enter();
291
292        let mut engine = QTensorEngine::new();
293        let mut magic = [0u8; 4];
294        let is_q42 = {
295            use std::io::Read;
296            std::fs::File::open(&model_path)
297                .and_then(|mut f| f.read_exact(&mut magic))
298                .map(|_| &magic == b"p64\0")
299                .unwrap_or(false)
300        };
301        if is_q42 {
302            let f = std::fs::File::open(&model_path).map_err(|e| e.to_string())?;
303            let mmap = unsafe { memmap2::Mmap::map(&f) }.map_err(|e| e.to_string())?;
304            engine
305                .adopt_resident_p64_mmap(std::sync::Arc::new(mmap))
306                .map_err(|e| format!("q42 adopt: {e}"))?;
307        } else {
308            engine.load_gguf(&model_path);
309        }
310
311        let mmap = engine
312            .gguf_mmap
313            .clone()
314            .ok_or_else(|| "model did not memory-map (load failed)".to_string())?;
315        let is_q42_mmap = mmap.len() >= 4 && mmap[0..4] == *b"p64\0";
316        let tok = if is_q42_mmap {
317            crate::p64_weight::P64TensorIndex::from_p64(&mmap)
318                .ok()
319                .and_then(|qi| GgufTokenizer::from_p64_section(qi.tokenizer_bytes(&mmap)))
320                .unwrap_or_default()
321        } else {
322            GgufTokenizer::from_gguf(&mmap)
323        };
324        let tensor_idx = if is_q42_mmap {
325            crate::p64_weight::P64TensorIndex::from_p64(&mmap)
326                .map(|qi| qi.to_gguf_index())
327                .map_err(|e| format!("q42 index: {e}"))?
328        } else {
329            GgufTensorIndex::from_gguf(&mmap)
330        };
331
332        let emb_dim = tensor_idx.emb_dim();
333        if emb_dim == 0 {
334            return Err("embedding dimension is 0 (tensor index parse failed)".into());
335        }
336        let vocab = tok.vocab_len().max(1) as usize;
337
338        let mut emb_buf = vec![0f32; emb_dim.max(8192)];
339        let mut scratch_a = vec![0f32; 16384];
340        let mut scratch_b = vec![0f32; 16384];
341        let mut logits = vec![0f32; vocab];
342        let mmap_bytes: &[u8] = &mmap;
343
344        let mut total_nll = 0.0f64;
345        let mut total_tok = 0usize;
346        for passage in &corpus {
347            let toks = tok.encode(passage);
348            if toks.len() < 2 {
349                continue;
350            }
351            let limit = if max_tok > 0 {
352                (max_tok + 1).min(toks.len())
353            } else {
354                toks.len()
355            };
356            engine.reset_kv_cache();
357            for i in 0..limit - 1 {
358                let n_emb = tensor_idx.dequantize_token_embedding_into(
359                    mmap_bytes,
360                    toks[i],
361                    &mut emb_buf[..emb_dim],
362                );
363                if n_emb == 0 {
364                    return Err(format!("embedding lookup failed for token {}", toks[i]));
365                }
366                // AWQ calibration: reset the per-forward layer cursor so the FFN hook tags layers
367                // 0..n_layer-1 correctly (no-op when AWQ capture is off).
368                crate::llm_awq::begin_forward();
369                let _ = engine.dispatch_transformer_forward(
370                    &tensor_idx,
371                    &mut emb_buf[..emb_dim],
372                    emb_dim,
373                    &mut scratch_a,
374                    &mut scratch_b,
375                    i as u32,
376                    0, // 0 = all layers (full model depth)
377                );
378                let _ =
379                    engine.apply_output_norm_inplace(&tensor_idx, &mut emb_buf[..emb_dim], emb_dim);
380                let n = engine.dispatch_output_logits_into(
381                    &tensor_idx,
382                    &emb_buf[..emb_dim],
383                    emb_dim,
384                    &mut logits,
385                );
386                if n == 0 {
387                    return Err("output projection produced no logits".into());
388                }
389                let nll = crate::llm_eval::token_nll(&logits[..n], toks[i + 1] as usize);
390                if nll.is_finite() {
391                    total_nll += nll;
392                    total_tok += 1;
393                }
394            }
395        }
396        if total_tok == 0 {
397            return Err("no tokens scored".into());
398        }
399        Ok((crate::llm_eval::perplexity(total_nll, total_tok), total_tok))
400    })
401    .join()
402    .map_err(|_| "perplexity eval thread panicked".to_string())?
403}
404
405/// GPU-readback KV capture for the W5b sparse-dictionary go/no-go — the independent route to the
406/// CPU-reference hook. Loads the model with an **f32** KV cache, runs the REAL fast GPU decode forward
407/// over the eval corpus, and after each passage reads the KV arena back from VRAM
408/// ([`QTensorEngine::capture_kv_f32`]), accumulating up to `max_per_layer` K and V vectors per layer.
409/// Because it samples the actual decode-path vectors (not the CPU reference), it cross-checks the hook
410/// capture: if both agree, the measured KV geometry is trustworthy. Stops early once every layer's cap
411/// is hit. Needs a GPU.
412#[cfg(not(target_arch = "wasm32"))]
413pub fn capture_kv_gpu_readback(
414    model_path: &str,
415    max_tok: usize,
416    max_per_layer: usize,
417) -> Result<crate::kv_capture::KvCapture, String> {
418    use crate::gguf_bridge::QTensorEngine;
419    use crate::gguf_sharder::{GgufTensorIndex, GgufTokenizer};
420
421    let corpus = crate::llm_eval::load_corpus().map_err(|e| format!("corpus load: {e}"))?;
422    if corpus.is_empty() {
423        return Err("eval corpus is empty".into());
424    }
425    let model_path = model_path.to_string();
426
427    std::thread::spawn(move || -> Result<crate::kv_capture::KvCapture, String> {
428        let rt = tokio::runtime::Builder::new_current_thread()
429            .enable_all()
430            .build()
431            .map_err(|e| e.to_string())?;
432        let _g = rt.enter();
433
434        // Force an f32 KV layout so the readback decodes via k_index/v_index (int8 packs differently).
435        let prev_int8 = kv_int8_enabled();
436        set_kv_int8(false);
437
438        let mut engine = QTensorEngine::new();
439        let mut magic = [0u8; 4];
440        let is_q42 = {
441            use std::io::Read;
442            std::fs::File::open(&model_path)
443                .and_then(|mut f| f.read_exact(&mut magic))
444                .map(|_| &magic == b"p64\0")
445                .unwrap_or(false)
446        };
447        if is_q42 {
448            let f = std::fs::File::open(&model_path).map_err(|e| e.to_string())?;
449            let mmap = unsafe { memmap2::Mmap::map(&f) }.map_err(|e| e.to_string())?;
450            engine
451                .adopt_resident_p64_mmap(std::sync::Arc::new(mmap))
452                .map_err(|e| format!("q42 adopt: {e}"))?;
453        } else {
454            engine.load_gguf(&model_path);
455        }
456
457        let mmap = engine
458            .gguf_mmap
459            .clone()
460            .ok_or_else(|| "model did not memory-map (load failed)".to_string())?;
461        let is_q42_mmap = mmap.len() >= 4 && mmap[0..4] == *b"p64\0";
462        let tok = if is_q42_mmap {
463            crate::p64_weight::P64TensorIndex::from_p64(&mmap)
464                .ok()
465                .and_then(|qi| GgufTokenizer::from_p64_section(qi.tokenizer_bytes(&mmap)))
466                .unwrap_or_default()
467        } else {
468            GgufTokenizer::from_gguf(&mmap)
469        };
470        let tensor_idx = if is_q42_mmap {
471            crate::p64_weight::P64TensorIndex::from_p64(&mmap)
472                .map(|qi| qi.to_gguf_index())
473                .map_err(|e| format!("q42 index: {e}"))?
474        } else {
475            GgufTensorIndex::from_gguf(&mmap)
476        };
477
478        let emb_dim = tensor_idx.emb_dim();
479        if emb_dim == 0 {
480            return Err("embedding dimension is 0".into());
481        }
482        let mut emb_buf = vec![0f32; emb_dim.max(8192)];
483        let mut scratch_a = vec![0f32; 16384];
484        let mut scratch_b = vec![0f32; 16384];
485        let mmap_bytes: &[u8] = &mmap;
486
487        let mut acc_k: Vec<Vec<Vec<f32>>> = Vec::new();
488        let mut acc_v: Vec<Vec<Vec<f32>>> = Vec::new();
489        let mut head_dim = 0usize;
490
491        'corpus: for passage in &corpus {
492            let toks = tok.encode(passage);
493            if toks.len() < 2 {
494                continue;
495            }
496            let limit = if max_tok > 0 {
497                (max_tok + 1).min(toks.len())
498            } else {
499                toks.len()
500            };
501            engine.reset_kv_cache();
502            for i in 0..limit - 1 {
503                let n_emb = tensor_idx.dequantize_token_embedding_into(
504                    mmap_bytes,
505                    toks[i],
506                    &mut emb_buf[..emb_dim],
507                );
508                if n_emb == 0 {
509                    return Err(format!("embedding lookup failed for token {}", toks[i]));
510                }
511                let _ = engine.dispatch_transformer_forward(
512                    &tensor_idx,
513                    &mut emb_buf[..emb_dim],
514                    emb_dim,
515                    &mut scratch_a,
516                    &mut scratch_b,
517                    i as u32,
518                    0,
519                );
520            }
521            // Read this passage's KV back from VRAM and merge into the accumulator.
522            if let Some(cap) = engine.capture_kv_f32((limit - 1) as u32, max_per_layer) {
523                head_dim = cap.head_dim;
524                if acc_k.is_empty() {
525                    acc_k = vec![Vec::new(); cap.k.len()];
526                    acc_v = vec![Vec::new(); cap.v.len()];
527                }
528                let mut all_full = true;
529                for l in 0..cap.k.len().min(acc_k.len()) {
530                    for vk in &cap.k[l] {
531                        if acc_k[l].len() < max_per_layer {
532                            acc_k[l].push(vk.clone());
533                        }
534                    }
535                    for vv in &cap.v[l] {
536                        if acc_v[l].len() < max_per_layer {
537                            acc_v[l].push(vv.clone());
538                        }
539                    }
540                    if acc_k[l].len() < max_per_layer {
541                        all_full = false;
542                    }
543                }
544                if all_full {
545                    break 'corpus; // caps hit — stop early, don't burn the rest of the corpus
546                }
547            }
548        }
549
550        set_kv_int8(prev_int8);
551        if head_dim == 0 {
552            return Err("no KV captured via GPU readback (int8 layout, or empty forward)".into());
553        }
554        Ok(crate::kv_capture::KvCapture {
555            head_dim,
556            k: acc_k,
557            v: acc_v,
558        })
559    })
560    .join()
561    .map_err(|_| "kv capture thread panicked".to_string())?
562}
563
564/// AWQ α-sweep on the ternary FFN (AWQ steps 1–3 end to end): capture activation salience from the Q8
565/// reference at `gguf_path`, then for each α compile an AWQ-scaled ternary `.q42`
566/// (`compile_gguf_to_q42_ternary_ffn_awq`), evaluate its perplexity + unique-word coherence, and return
567/// `(reference_ppl, [(alpha, ppl, uniq)])`. α=0.0 is plain ternary (the baseline). `max_tok` caps
568/// tokens/passage to bound the sweep. Honest: this measures whether AWQ rescues ternary — it does not
569/// assume it does. Needs a GPU.
570#[cfg(not(target_arch = "wasm32"))]
571pub fn awq_sweep_blocking(
572    gguf_path: &str,
573    alphas: &[f32],
574    max_tok: usize,
575    quant: crate::p64_weight::FfnQuant,
576) -> Result<(f64, Vec<(f32, f64, f64)>), String> {
577    use crate::p64_weight::compile_gguf_to_q42_ffn_quant_awq;
578
579    let bytes = std::fs::read(gguf_path).map_err(|e| format!("read gguf: {e}"))?;
580    let idx = crate::gguf_sharder::GgufTensorIndex::from_gguf(&bytes);
581    let n_layer = idx.hyperparams.n_layer;
582    let n_embd = idx.hyperparams.n_embd;
583    if n_layer == 0 || n_embd == 0 {
584        return Err("gguf parse failed (n_layer/n_embd = 0)".into());
585    }
586
587    // 1. Capture per-channel salience + the Q8 reference PPL in one calibration forward.
588    set_ternary_ffn(false);
589    crate::llm_awq::enable(n_layer, n_embd)?;
590    let (ref_ppl, _) = perplexity_eval_blocking(gguf_path, max_tok)?;
591    let stats = crate::llm_awq::snapshot();
592    crate::llm_awq::disable();
593    if stats.is_empty() {
594        return Err("AWQ: no activation stats captured".into());
595    }
596
597    // 2. Sweep: AWQ-scaled .q42 per α → eval PPL + coherence. Ternary needs the resident 2-bit path;
598    //    Q4_0 runs through the standard quantized GEMM (no ternary toggle).
599    set_ternary_ffn(matches!(quant, crate::p64_weight::FfnQuant::Ternary));
600    let tmp = std::env::temp_dir();
601    let mut results = Vec::with_capacity(alphas.len());
602    for &alpha in alphas {
603        let scales = if alpha == 0.0 {
604            None
605        } else {
606            Some(stats.as_slice())
607        };
608        let q42 = compile_gguf_to_q42_ffn_quant_awq(&bytes, 14, scales, alpha, quant)
609            .map_err(|e| format!("AWQ compile (alpha={alpha}): {e}"))?;
610        let path = tmp.join(format!("awq_sweep_a{:.2}.q42", alpha));
611        std::fs::write(&path, &q42).map_err(|e| format!("write q42: {e}"))?;
612        let ps = path.to_string_lossy().to_string();
613        let (ppl, _) = perplexity_eval_blocking(&ps, max_tok)?;
614        let (text, _) = decode_with_metrics_blocking(&ps, "Once upon a time, there was a", 24)?;
615        let uniq = crate::llm_eval::unique_word_ratio(&text);
616        let _ = std::fs::remove_file(&path);
617        results.push((alpha, ppl, uniq));
618    }
619    set_ternary_ffn(false);
620    Ok((ref_ppl, results))
621}
622
623/// `decode_with_metrics` inside a fresh multi-thread runtime (residency mount needs `block_in_place`).
624#[cfg(not(target_arch = "wasm32"))]
625pub fn decode_with_metrics_blocking(
626    model_path: &str,
627    prompt: &str,
628    decode_tokens: u32,
629) -> Result<(String, f64), String> {
630    let rt = tokio::runtime::Builder::new_multi_thread()
631        .worker_threads(2)
632        .enable_all()
633        .build()
634        .map_err(|e| e.to_string())?;
635    rt.block_on(async { decode_with_metrics(model_path, prompt, decode_tokens) })
636}
637
638/// W2: decode with the exact CPU sampler installed for the duration of the call. Returns
639/// `(text, tok/s)`. Restores greedy (`None`) afterwards so it never leaks into other tests.
640#[cfg(not(target_arch = "wasm32"))]
641pub fn decode_sampled_blocking(
642    model_path: &str,
643    prompt: &str,
644    decode_tokens: u32,
645    cfg: crate::sampler::SamplerConfig,
646) -> Result<(String, f64), String> {
647    set_sampler_config(Some(cfg));
648    let out = decode_with_metrics_blocking(model_path, prompt, decode_tokens);
649    set_sampler_config(None);
650    out
651}
652
653/// W6a — batched verify-primitive correctness probe. Prefills `prompt` (positions `[0, p)`,
654/// `p = prompt_len-1`), snapshots the KV cache, then:
655///   * **reference** — sequentially forwards `b` steps from the last prompt token via the exact
656///     per-token path (`dispatch_transformer_forward` → output norm → full-logit argmax), collecting
657///     the greedy continuation `r0..r_{b-1}` and the inputs `[cur, r0..r_{b-2}]`;
658///   * restores the post-prefill KV, then runs the **batched** `verify_draft_batch(inputs, p)`.
659/// Returns `(reference, verify)`; they must be equal — the batched forward writes byte-identical KV
660/// and both sides take a full-logit CPU argmax (no top-k tie-break gap). Runs on a dedicated thread
661/// with a current-thread runtime (mirrors the decode/perplexity paths so GPU readback works).
662#[cfg(not(target_arch = "wasm32"))]
663pub fn spec_verify_probe_blocking(
664    model_path: &str,
665    prompt: &str,
666    b: usize,
667) -> Result<(Vec<u32>, Vec<u32>, Vec<u32>), String> {
668    use crate::gguf_bridge::QTensorEngine;
669    use crate::gguf_sharder::{GgufTensorIndex, GgufTokenizer};
670    if !std::path::Path::new(model_path).exists() {
671        return Err(format!("model not found: {model_path}"));
672    }
673    let model_path = model_path.to_string();
674    let prompt = prompt.to_string();
675
676    std::thread::spawn(move || -> Result<(Vec<u32>, Vec<u32>, Vec<u32>), String> {
677        let rt = tokio::runtime::Builder::new_current_thread()
678            .enable_all()
679            .build()
680            .map_err(|e| e.to_string())?;
681        let _g = rt.enter();
682
683        let mut engine = QTensorEngine::new();
684        engine.load_gguf(&model_path);
685        let mmap = engine
686            .gguf_mmap
687            .clone()
688            .ok_or_else(|| "model did not memory-map".to_string())?;
689        let tok = GgufTokenizer::from_gguf(&mmap);
690        let idx = GgufTensorIndex::from_gguf(&mmap);
691        // The batched verify tail binds the RESIDENT logits projection (like resident_decode). Plain
692        // `load_gguf` does not upload it (only the residency-mount path does), so do it here.
693        if !engine.mc8_upload_resident_logits(&idx) {
694            return Err("resident logits upload failed (verify tail needs it)".into());
695        }
696        let emb_dim = idx.emb_dim();
697        if emb_dim == 0 {
698            return Err("embedding dimension is 0".into());
699        }
700        let vocab = tok.vocab_len().max(1) as usize;
701        let toks = tok.encode(&prompt);
702        if toks.len() < b + 2 {
703            return Err(format!(
704                "prompt too short: {} tokens, need >= {}",
705                toks.len(),
706                b + 2
707            ));
708        }
709
710        let mut emb = vec![0f32; emb_dim.max(8192)];
711        let mut sa = vec![0f32; 16384];
712        let mut sb = vec![0f32; 16384];
713        let mut logits = vec![0f32; vocab];
714        let mmap_b: &[u8] = &mmap;
715
716        let argmax = |v: &[f32]| -> u32 {
717            let mut best_i = 0u32;
718            let mut best_v = f32::NEG_INFINITY;
719            for (i, &x) in v.iter().enumerate() {
720                if x > best_v {
721                    best_v = x;
722                    best_i = i as u32;
723                }
724            }
725            best_i
726        };
727
728        // Prefill positions [0, p) so the prefix KV matches what decode would have.
729        let p = toks.len() - 1;
730        engine.reset_kv_cache();
731        for i in 0..p {
732            let n = idx.dequantize_token_embedding_into(mmap_b, toks[i], &mut emb[..emb_dim]);
733            if n == 0 {
734                return Err(format!("embedding lookup failed for token {}", toks[i]));
735            }
736            let _ = engine.dispatch_transformer_forward(
737                &idx,
738                &mut emb[..emb_dim],
739                emb_dim,
740                &mut sa,
741                &mut sb,
742                i as u32,
743                0,
744            );
745        }
746        // No KV snapshot needed: the reference decode below writes only positions [p, p+b), never the
747        // prefix [0, p) — so the prefix KV stays valid — and `verify_draft_batch` overwrites [p, p+b)
748        // with the same inputs. (Note: `get_kv_cache_cpu` returns the CPU mirror, which is NOT synced
749        // from the GPU KV writes, so it cannot be used to snapshot the GPU cache here.)
750
751        // Reference: exact sequential greedy decode of `b` steps from the last prompt token.
752        let mut inputs: Vec<u32> = Vec::with_capacity(b);
753        let mut reference: Vec<u32> = Vec::with_capacity(b);
754        let mut input = toks[p];
755        let mut pos = p as u32;
756        for _ in 0..b {
757            inputs.push(input);
758            let n = idx.dequantize_token_embedding_into(mmap_b, input, &mut emb[..emb_dim]);
759            if n == 0 {
760                return Err("reference embedding lookup failed".into());
761            }
762            let _ = engine.dispatch_transformer_forward(
763                &idx,
764                &mut emb[..emb_dim],
765                emb_dim,
766                &mut sa,
767                &mut sb,
768                pos,
769                0,
770            );
771            let _ = engine.apply_output_norm_inplace(&idx, &mut emb[..emb_dim], emb_dim);
772            let nl =
773                engine.dispatch_output_logits_into(&idx, &emb[..emb_dim], emb_dim, &mut logits);
774            if nl == 0 {
775                return Err("reference output projection produced no logits".into());
776            }
777            let out = argmax(&logits[..nl]);
778            reference.push(out);
779            input = out;
780            pos += 1;
781        }
782
783        // Run the batched verify over the same inputs (prefix KV [0, p) is still valid).
784        let mut verify_out: Vec<u32> = Vec::new();
785        let mut verify_logit: Vec<f32> = Vec::new();
786        engine
787            .verify_draft_batch(&idx, &inputs, p as u32, &mut verify_out, &mut verify_logit)
788            .ok_or_else(|| "verify_draft_batch ineligible/failed".to_string())?;
789
790        // Resident-path reference: re-prefill the prefix, then forward each input through the DEFAULT
791        // single-fence resident path (GPU top-1). Isolates verify(batched, CPU argmax) vs
792        // resident(single, GPU top-1) — the crux of the transparency question. u32::MAX marks a
793        // resident-ineligible position.
794        engine.reset_kv_cache();
795        for i in 0..p {
796            let n = idx.dequantize_token_embedding_into(mmap_b, toks[i], &mut emb[..emb_dim]);
797            if n == 0 {
798                return Err("resident-ref prefill embedding failed".into());
799            }
800            let _ = engine.dispatch_transformer_forward(
801                &idx,
802                &mut emb[..emb_dim],
803                emb_dim,
804                &mut sa,
805                &mut sb,
806                i as u32,
807                0,
808            );
809        }
810        let mut resident_out: Vec<u32> = Vec::with_capacity(b);
811        for (j, &t) in inputs.iter().enumerate() {
812            let n = idx.dequantize_token_embedding_into(mmap_b, t, &mut emb[..emb_dim]);
813            if n == 0 {
814                return Err("resident-ref embedding failed".into());
815            }
816            match engine.dispatch_token_forward_resident(&idx, &emb[..emb_dim], (p + j) as u32) {
817                Some(r) => resident_out.push(r.best_token_id),
818                None => resident_out.push(u32::MAX),
819            }
820        }
821
822        Ok((reference, verify_out, resident_out))
823    })
824    .join()
825    .map_err(|_| "spec-verify probe thread panicked".to_string())?
826}