Skip to main content

qualia_core_db/inference/
inference_eval.rs

1//! W1 — in-project quality oracle (no external libs): perplexity, KL-divergence, and a coherence
2//! ratio, measured by teacher-forcing an eval corpus through the engine and comparing a candidate
3//! model's output distribution against a higher-fidelity reference's.
4//!
5//! **Honesty note.** ΔPPL and KL here are *relative* (candidate vs reference on identical text), so
6//! the comparison is self-consistent regardless of who authored the corpus. They measure *engineering
7//! fidelity* — does quantization preserve the reference model's behaviour — not whether the model's
8//! outputs are true or whether any direction is correct. The "reference" is the highest-fidelity model
9//! on disk (Q8_0 unless a real F16 is supplied); it is labelled as such, never silently called "FP16".
10//!
11//! This module is pure math + thresholds + a corpus loader; the engine forward pass that feeds it
12//! lives in the bench harness. Metric paths take slices and return scalars (heap only in the loader).
13
14use std::sync::atomic::{AtomicU64, Ordering};
15
16// ── Quality gate (set by Timothy / Gemini, 2026-06-25) ────────────────────────
17/// Max relative perplexity increase vs the reference (soft evidence).
18pub const MAX_DELTA_PPL: f64 = 0.05; // ≤ 5%
19/// Max average per-token KL-divergence (reference ‖ candidate) over the corpus.
20pub const MAX_AVG_KL: f64 = 0.06;
21/// Min unique-word ratio on a generation loop (hard gate — eliminates repetition collapse).
22pub const MIN_UNIQ_WORD: f64 = 0.90;
23
24/// Numerically-stable log-sum-exp over `logits` (f64 accumulation).
25pub fn log_sum_exp(logits: &[f32]) -> f64 {
26    if logits.is_empty() {
27        return f64::NEG_INFINITY;
28    }
29    let m = logits.iter().copied().fold(f32::NEG_INFINITY, f32::max) as f64;
30    if !m.is_finite() {
31        return m;
32    }
33    let s: f64 = logits.iter().map(|&l| (l as f64 - m).exp()).sum();
34    m + s.ln()
35}
36
37/// Negative log-likelihood (nats) of `target` under `softmax(logits)` = `logsumexp - logits[target]`.
38pub fn token_nll(logits: &[f32], target: usize) -> f64 {
39    if target >= logits.len() {
40        return f64::INFINITY;
41    }
42    log_sum_exp(logits) - logits[target] as f64
43}
44
45/// Perplexity from a summed NLL (nats) over `n_tokens`: `exp(total_nll / n_tokens)`.
46pub fn perplexity(total_nll: f64, n_tokens: usize) -> f64 {
47    if n_tokens == 0 {
48        return f64::INFINITY;
49    }
50    (total_nll / n_tokens as f64).exp()
51}
52
53/// Relative perplexity increase of `candidate` over `reference`: `(cand - ref) / ref`.
54pub fn delta_ppl(reference: f64, candidate: f64) -> f64 {
55    if reference <= 0.0 || !reference.is_finite() {
56        return f64::INFINITY;
57    }
58    (candidate - reference) / reference
59}
60
61/// Write the numerically-stable log-softmax of `logits` into `out` (same length).
62fn log_softmax_into(logits: &[f32], out: &mut [f64]) {
63    let lse = log_sum_exp(logits);
64    for (o, &l) in out.iter_mut().zip(logits) {
65        *o = l as f64 - lse;
66    }
67}
68
69/// KL-divergence `D(reference ‖ candidate)` between the two softmax distributions, in nats.
70/// `= Σ p_ref · (log p_ref − log p_cand)`, computed via log-softmax for stability. Non-negative.
71pub fn kl_divergence(ref_logits: &[f32], cand_logits: &[f32], scratch: &mut [f64]) -> f64 {
72    let n = ref_logits.len();
73    if n == 0 || cand_logits.len() != n || scratch.len() < 2 * n {
74        return f64::INFINITY;
75    }
76    let (lp_ref, lp_cand) = scratch.split_at_mut(n);
77    log_softmax_into(ref_logits, lp_ref);
78    log_softmax_into(cand_logits, &mut lp_cand[..n]);
79    let mut kl = 0.0f64;
80    for i in 0..n {
81        let p = lp_ref[i].exp();
82        if p > 0.0 {
83            kl += p * (lp_ref[i] - lp_cand[i]);
84        }
85    }
86    kl.max(0.0) // clamp tiny negative from float error
87}
88
89/// Unique-word ratio (coherence proxy): distinct whitespace tokens / total. Repetition collapse → low.
90pub fn unique_word_ratio(text: &str) -> f64 {
91    let words: Vec<&str> = text.split_whitespace().collect();
92    if words.is_empty() {
93        return 0.0;
94    }
95    let uniq: std::collections::HashSet<&str> = words.iter().copied().collect();
96    uniq.len() as f64 / words.len() as f64
97}
98
99/// The three-tier verdict against the gate. `hard_pass` (coherence) must hold; ΔPPL/KL are evidence.
100#[derive(Debug, Clone, Copy)]
101pub struct QualityVerdict {
102    pub delta_ppl: f64,
103    pub avg_kl: f64,
104    pub uniq_word: f64,
105}
106
107impl QualityVerdict {
108    /// Hard gate: coherence (no repetition collapse). A change that fails this is rejected outright.
109    pub fn hard_pass(&self) -> bool {
110        self.uniq_word >= MIN_UNIQ_WORD
111    }
112    /// Soft gate: ΔPPL and KL within budget.
113    pub fn soft_pass(&self) -> bool {
114        self.delta_ppl <= MAX_DELTA_PPL && self.avg_kl <= MAX_AVG_KL
115    }
116    /// Overall accept: both gates hold.
117    pub fn accept(&self) -> bool {
118        self.hard_pass() && self.soft_pass()
119    }
120}
121
122/// Load the eval corpus (one passage per line; blank lines dropped). Searches the standard roots so it
123/// works from the crate dir or the repo root, mirroring the model/results lookups in the bench.
124pub fn load_corpus() -> std::io::Result<Vec<String>> {
125    const CANDIDATES: [&str; 3] = [
126        "benchmarks/data/eval_corpus.txt",
127        "../../benchmarks/data/eval_corpus.txt",
128        "../benchmarks/data/eval_corpus.txt",
129    ];
130    let mut last_err =
131        std::io::Error::new(std::io::ErrorKind::NotFound, "eval_corpus.txt not found");
132    for p in CANDIDATES {
133        match std::fs::read_to_string(p) {
134            Ok(s) => {
135                return Ok(s
136                    .lines()
137                    .map(|l| l.to_string())
138                    .filter(|l| !l.trim().is_empty())
139                    .collect())
140            }
141            Err(e) => last_err = e,
142        }
143    }
144    Err(last_err)
145}
146
147// ── Process-wide PPL accumulators (so a teacher-forced forward on the engine thread can report back) ──
148static EVAL_NLL_BITS: AtomicU64 = AtomicU64::new(0); // f64 total NLL, bit-encoded
149static EVAL_TOKENS: AtomicU64 = AtomicU64::new(0);
150
151/// Reset the teacher-forced PPL accumulators before an eval pass.
152pub fn reset_ppl() {
153    EVAL_NLL_BITS.store(0, Ordering::Relaxed);
154    EVAL_TOKENS.store(0, Ordering::Relaxed);
155}
156
157/// Add one position's NLL (nats) + token to the accumulators.
158pub fn add_ppl(nll: f64, tokens: u64) {
159    // simple non-atomic-RMW-safe accumulate: single eval thread, so load/store is fine.
160    let cur = f64::from_bits(EVAL_NLL_BITS.load(Ordering::Relaxed));
161    EVAL_NLL_BITS.store((cur + nll).to_bits(), Ordering::Relaxed);
162    EVAL_TOKENS.fetch_add(tokens, Ordering::Relaxed);
163}
164
165/// Current `(total_nll, token_count)` snapshot.
166pub fn ppl_snapshot() -> (f64, u64) {
167    (
168        f64::from_bits(EVAL_NLL_BITS.load(Ordering::Relaxed)),
169        EVAL_TOKENS.load(Ordering::Relaxed),
170    )
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn nll_and_ppl_known_values() {
179        // Uniform logits over 4 classes → each prob 0.25 → NLL = ln 4; PPL over n identical = 4.
180        let logits = [0.0f32, 0.0, 0.0, 0.0];
181        let nll = token_nll(&logits, 2);
182        assert!((nll - 4.0f64.ln()).abs() < 1e-9);
183        assert!((perplexity(nll * 3.0, 3) - 4.0).abs() < 1e-6);
184    }
185
186    #[test]
187    fn kl_zero_for_identical_and_positive_otherwise() {
188        let a = [2.0f32, 1.0, 0.0, -1.0];
189        let b = [0.5f32, 0.5, 0.5, 0.5];
190        let mut scratch = vec![0f64; 2 * a.len()];
191        assert!(kl_divergence(&a, &a, &mut scratch) < 1e-9);
192        assert!(kl_divergence(&a, &b, &mut scratch) > 0.0);
193    }
194
195    #[test]
196    fn delta_ppl_and_gate() {
197        assert!((delta_ppl(10.0, 10.5) - 0.05).abs() < 1e-9);
198        let pass = QualityVerdict {
199            delta_ppl: 0.03,
200            avg_kl: 0.04,
201            uniq_word: 0.95,
202        };
203        let fail_hard = QualityVerdict {
204            delta_ppl: 0.0,
205            avg_kl: 0.0,
206            uniq_word: 0.10,
207        };
208        let fail_soft = QualityVerdict {
209            delta_ppl: 0.20,
210            avg_kl: 0.04,
211            uniq_word: 0.95,
212        };
213        assert!(pass.accept());
214        assert!(!fail_hard.accept() && !fail_hard.hard_pass());
215        assert!(!fail_soft.accept() && fail_soft.hard_pass() && !fail_soft.soft_pass());
216    }
217
218    #[test]
219    fn unique_word_detects_collapse() {
220        assert!(unique_word_ratio("the quick brown fox jumps") > 0.9);
221        assert!(unique_word_ratio("the the the the the") < 0.3);
222        assert_eq!(unique_word_ratio(""), 0.0);
223    }
224}