Skip to main content

lean_ctx/core/
surprise.rs

1//! Predictive Surprise Scoring — conditional entropy relative to LLM knowledge.
2//!
3//! Instead of measuring Shannon entropy in isolation (H(X)), we measure
4//! how surprising each line is to the LLM: H(X | LLM_knowledge).
5//!
6//! Approximation: use BPE token frequency ranks from o200k_base as a proxy
7//! for P(token | LLM). Common tokens (high frequency rank) carry low surprise;
8//! rare tokens (low rank / unknown to the vocab) carry high surprise.
9//!
10//! Scientific basis: Cross-entropy H(P,Q) = -sum(P(x) * log Q(x))
11//! where P is the true distribution and Q is the model's prior.
12
13use std::sync::OnceLock;
14
15use super::tokens::encode_tokens;
16
17static VOCAB_LOG_PROBS: OnceLock<Vec<f64>> = OnceLock::new();
18
19/// Build a log-probability table indexed by token ID.
20/// Uses a Zipfian approximation: P(rank r) ~ 1/(r * H_n) where H_n is the
21/// harmonic number. This closely matches empirical BPE token distributions.
22fn 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; // 1-indexed rank
29                let p = 1.0 / (r as f64 * h_n);
30                -p.log2()
31            })
32            .collect()
33    })
34}
35
36/// Compute the surprise score for a line of text.
37///
38/// Returns the mean negative log-probability (cross-entropy) of the line's
39/// BPE tokens under the Zipfian prior. Higher values = more surprising to
40/// the LLM = more important to keep.
41///
42/// Range: typically 5.0 (very common) to 17.0+ (very rare).
43pub 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 // max surprise for OOV tokens (~log2(200000))
59            }
60        })
61        .sum();
62
63    total / tokens.len() as f64
64}
65
66/// Classify how surprising a line is relative to the LLM's expected knowledge.
67/// Uses empirically calibrated thresholds for o200k_base.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum SurpriseLevel {
70    /// Common patterns — safe to compress aggressively
71    Low,
72    /// Mixed content — standard compression
73    Medium,
74    /// Rare/unique tokens — preserve carefully
75    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
89/// Enhanced entropy filter that combines Shannon entropy with predictive surprise.
90/// Lines pass if EITHER their entropy is above threshold OR their surprise is high.
91/// This prevents dropping lines that look "low entropy" but contain rare, unique tokens.
92pub 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    // New: check if line has high surprise despite low entropy.
109    // This catches lines like `CustomDomainType::validate()`
110    // which have low token diversity but high surprise per-token.
111    let surprise = line_surprise(trimmed);
112    surprise >= 11.0
113}
114
115// ---------------------------------------------------------------------------
116// Semantic redundancy scoring (#544, EFF-7)
117// ---------------------------------------------------------------------------
118//
119// The Zipf prior measures *lexical* rarity: a rare boilerplate identifier
120// scores high (kept), a frequent but semantically unique line scores low
121// (dropped). The LLMLingua family (survey 2410.12388) climbs this ladder
122// with real likelihood models; the strongest model-assisted step we can take
123// without shipping a token classifier is MMR-style semantic dedup: a line
124// that is near-identical *in embedding space* to something already kept
125// carries almost no marginal information (rate-distortion: spend bits on
126// distinct content only). This is exactly what H2O/SnapKV exploit at the
127// KV level.
128//
129// The embedder is injected as a function so the production path can use the
130// real (feature-gated, lazily loaded) embedding engine while the scoring
131// math stays testable; `None` results fall back to pure Zipf behavior,
132// keeping the no-embeddings build byte-identical.
133
134/// Kept-line embedding window for MMR redundancy checks. Capped so the
135/// incremental cost stays O(n·64) per file.
136const KEPT_WINDOW: usize = 64;
137/// Cosine similarity at or above this means "semantically duplicate".
138const REDUNDANCY_COSINE: f64 = 0.92;
139
140/// Sliding context of already-kept line embeddings.
141#[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    /// Max cosine similarity of `emb` to any kept embedding.
159    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
185/// Semantic-redundancy keep decision (#544): the Zipf path nominates keep
186/// candidates exactly as `should_keep_line`; candidates that embed nearly
187/// identically to an already-kept line are dropped (MMR). `embed` returning
188/// `None` (engine not loaded / feature off) preserves today's behavior
189/// byte-for-byte.
190pub 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    /// Deterministic hashed bag-of-words vectorizer: a real (if simple)
257    /// embedding function for unit-testing the MMR math. Production uses the
258    /// feature-gated neural engine through the same `embed` seam.
259    #[allow(clippy::unnecessary_wraps)] // Option matches the fallible embed seam
260    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        // Same bag of words, reordered — semantically duplicate logic.
281        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}