qualia_core_db/inference/
inference_eval.rs1use std::sync::atomic::{AtomicU64, Ordering};
15
16pub const MAX_DELTA_PPL: f64 = 0.05; pub const MAX_AVG_KL: f64 = 0.06;
21pub const MIN_UNIQ_WORD: f64 = 0.90;
23
24pub 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
37pub 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
45pub 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
53pub 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
61fn 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
69pub 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) }
88
89pub 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#[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 pub fn hard_pass(&self) -> bool {
110 self.uniq_word >= MIN_UNIQ_WORD
111 }
112 pub fn soft_pass(&self) -> bool {
114 self.delta_ppl <= MAX_DELTA_PPL && self.avg_kl <= MAX_AVG_KL
115 }
116 pub fn accept(&self) -> bool {
118 self.hard_pass() && self.soft_pass()
119 }
120}
121
122pub 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
147static EVAL_NLL_BITS: AtomicU64 = AtomicU64::new(0); static EVAL_TOKENS: AtomicU64 = AtomicU64::new(0);
150
151pub fn reset_ppl() {
153 EVAL_NLL_BITS.store(0, Ordering::Relaxed);
154 EVAL_TOKENS.store(0, Ordering::Relaxed);
155}
156
157pub fn add_ppl(nll: f64, tokens: u64) {
159 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
165pub 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 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}