lean_ctx/core/
surprise.rs1use std::sync::OnceLock;
14
15use super::tokens::encode_tokens;
16
17static VOCAB_LOG_PROBS: OnceLock<Vec<f64>> = OnceLock::new();
18
19fn get_vocab_log_probs() -> &'static Vec<f64> {
23 VOCAB_LOG_PROBS.get_or_init(|| {
24 let vocab_size = 200_000usize;
25 let h_n: f64 = (1..=vocab_size).map(|r| 1.0 / r as f64).sum();
26 (0..vocab_size)
27 .map(|rank| {
28 let r = rank + 1; let p = 1.0 / (r as f64 * h_n);
30 -p.log2()
31 })
32 .collect()
33 })
34}
35
36pub fn line_surprise(text: &str) -> f64 {
44 let tokens = encode_tokens(text);
45 if tokens.is_empty() {
46 return 0.0;
47 }
48 let log_probs = get_vocab_log_probs();
49 let max_id = log_probs.len();
50
51 let total: f64 = tokens
52 .iter()
53 .map(|&t| {
54 let id = t as usize;
55 if id < max_id {
56 log_probs[id]
57 } else {
58 17.6 }
60 })
61 .sum();
62
63 total / tokens.len() as f64
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum SurpriseLevel {
70 Low,
72 Medium,
74 High,
76}
77
78pub fn classify_surprise(text: &str) -> SurpriseLevel {
79 let s = line_surprise(text);
80 if s < 8.0 {
81 SurpriseLevel::Low
82 } else if s < 12.0 {
83 SurpriseLevel::Medium
84 } else {
85 SurpriseLevel::High
86 }
87}
88
89pub fn should_keep_line(trimmed: &str, entropy_threshold: f64) -> bool {
93 if trimmed.is_empty() || trimmed.len() < 3 {
94 return true;
95 }
96
97 let tokens = encode_tokens(trimmed);
98 let h = super::entropy::token_entropy_from_ids(&tokens);
99 if h >= entropy_threshold {
100 return true;
101 }
102
103 let h_norm = super::entropy::normalized_token_entropy_from_ids(&tokens);
104 if h_norm >= 0.3 {
105 return true;
106 }
107
108 let surprise = line_surprise(trimmed);
112 surprise >= 11.0
113}
114
115const KEPT_WINDOW: usize = 64;
137const REDUNDANCY_COSINE: f64 = 0.92;
139
140#[derive(Default)]
142pub struct ScoringCtx {
143 kept: std::collections::VecDeque<Vec<f32>>,
144}
145
146impl ScoringCtx {
147 pub fn new() -> Self {
148 Self::default()
149 }
150
151 pub fn push_kept(&mut self, embedding: Vec<f32>) {
152 if self.kept.len() >= KEPT_WINDOW {
153 self.kept.pop_front();
154 }
155 self.kept.push_back(embedding);
156 }
157
158 pub fn max_cosine(&self, emb: &[f32]) -> f64 {
160 self.kept
161 .iter()
162 .map(|k| cosine(k, emb))
163 .fold(0.0_f64, f64::max)
164 }
165}
166
167fn cosine(a: &[f32], b: &[f32]) -> f64 {
168 if a.is_empty() || a.len() != b.len() {
169 return 0.0;
170 }
171 let mut dot = 0.0_f64;
172 let mut na = 0.0_f64;
173 let mut nb = 0.0_f64;
174 for (x, y) in a.iter().zip(b.iter()) {
175 dot += f64::from(*x) * f64::from(*y);
176 na += f64::from(*x) * f64::from(*x);
177 nb += f64::from(*y) * f64::from(*y);
178 }
179 if na <= 0.0 || nb <= 0.0 {
180 return 0.0;
181 }
182 dot / (na.sqrt() * nb.sqrt())
183}
184
185pub fn should_keep_line_semantic(
191 trimmed: &str,
192 entropy_threshold: f64,
193 embed: &dyn Fn(&str) -> Option<Vec<f32>>,
194 ctx: &mut ScoringCtx,
195) -> bool {
196 if !should_keep_line(trimmed, entropy_threshold) {
197 return false;
198 }
199 let Some(emb) = embed(trimmed) else {
200 return true;
201 };
202 if ctx.max_cosine(&emb) >= REDUNDANCY_COSINE {
203 return false;
204 }
205 ctx.push_kept(emb);
206 true
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn common_code_has_low_surprise() {
215 let common = "let x = 1;";
216 let s = line_surprise(common);
217 assert!(s > 0.0, "surprise should be positive");
218 }
219
220 #[test]
221 fn rare_identifiers_have_higher_surprise() {
222 let common = "let x = 1;";
223 let rare = "let zygomorphic_validator = XenolithProcessor::new();";
224 assert!(
225 line_surprise(rare) > line_surprise(common),
226 "rare identifiers should have higher surprise"
227 );
228 }
229
230 #[test]
231 fn empty_returns_zero() {
232 assert_eq!(line_surprise(""), 0.0);
233 }
234
235 #[test]
236 fn classify_surprise_is_consistent() {
237 let simple = "let x = 1;";
238 let complex = "ZygomorphicXenolithValidator::process_quantum_state(&mut ctx)";
239 let s_simple = line_surprise(simple);
240 let s_complex = line_surprise(complex);
241 assert!(
242 s_complex > s_simple,
243 "rare identifiers ({s_complex}) should have higher surprise than common code ({s_simple})"
244 );
245 }
246
247 #[test]
248 fn should_keep_preserves_rare_lines() {
249 let rare = "ZygomorphicValidator::process_xenolith(&mut state)";
250 assert!(
251 should_keep_line(rare, 1.0) || line_surprise(rare) < 11.0,
252 "rare lines should be preserved or have measurable surprise"
253 );
254 }
255
256 #[allow(clippy::unnecessary_wraps)] fn bow_embed(line: &str) -> Option<Vec<f32>> {
261 let mut v = vec![0.0f32; 64];
262 for tok in line.to_lowercase().split(|c: char| !c.is_alphanumeric()) {
263 if tok.len() < 2 {
264 continue;
265 }
266 let mut h = 0u64;
267 for b in tok.bytes() {
268 h = h.wrapping_mul(31).wrapping_add(u64::from(b));
269 }
270 v[(h % 64) as usize] += 1.0;
271 }
272 Some(v)
273 }
274
275 #[test]
276 fn semantic_dedup_drops_near_duplicate_kept_lines() {
277 let mut ctx = ScoringCtx::new();
278 let embed = |s: &str| bow_embed(s);
279 let a = "fn validate_user_payload(payload: &UserPayload) -> Result<(), ValidationError>";
280 let b = "fn validate_user_payload(payload: &UserPayload) -> Result<(), ValidationError> ";
282 let c = "const MAX_RETRY_BACKOFF_MS: u64 = 30_000;";
283
284 assert!(should_keep_line_semantic(a, 0.5, &embed, &mut ctx));
285 assert!(
286 !should_keep_line_semantic(b, 0.5, &embed, &mut ctx),
287 "near-identical line must be dropped as redundant"
288 );
289 assert!(
290 should_keep_line_semantic(c, 0.5, &embed, &mut ctx),
291 "distinct line stays"
292 );
293 }
294
295 #[test]
296 fn no_embedder_is_identical_to_zipf_path() {
297 let mut ctx = ScoringCtx::new();
298 let none = |_: &str| None;
299 for line in [
300 "fn main() { run(); }",
301 "let x = 1;",
302 "ZygomorphicXenolithValidator::process_quantum_state(&mut ctx)",
303 "// plain comment",
304 ] {
305 assert_eq!(
306 should_keep_line_semantic(line, 1.0, &none, &mut ctx),
307 should_keep_line(line, 1.0),
308 "without embeddings the decision must match the Zipf path: {line}"
309 );
310 }
311 }
312
313 #[test]
314 fn kept_window_is_bounded() {
315 let mut ctx = ScoringCtx::new();
316 for i in 0..200 {
317 ctx.push_kept(vec![i as f32; 8]);
318 }
319 assert!(ctx.kept.len() <= 64);
320 }
321
322 #[test]
323 fn cosine_handles_degenerate_inputs() {
324 assert_eq!(cosine(&[], &[]), 0.0);
325 assert_eq!(cosine(&[1.0], &[1.0, 2.0]), 0.0);
326 assert_eq!(cosine(&[0.0, 0.0], &[0.0, 0.0]), 0.0);
327 assert!((cosine(&[1.0, 0.0], &[1.0, 0.0]) - 1.0).abs() < 1e-9);
328 }
329}