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/// Threshold at which the entropy filter starts dropping lines (#1910).
67const DROP_SHARE_ORIGIN: f64 = 0.4;
68/// Extra share of lines dropped per unit of threshold above the origin.
69const DROP_SHARE_SLOPE: f64 = 0.4;
70/// Upper bound: even the most aggressive setting keeps most of a file.
71const MAX_DROP_SHARE: f64 = 0.6;
72
73/// Share of a file's candidate lines the entropy filter drops for a threshold.
74///
75/// Strictly increasing on `(0.4, 1.9)`, which spans every file-adaptive
76/// threshold and the whole aggressiveness range (`0.6 + 1.4a`), so a more
77/// aggressive setting always drops at least as much.
78#[must_use]
79pub fn drop_share(entropy_threshold: f64) -> f64 {
80    ((entropy_threshold - DROP_SHARE_ORIGIN) * DROP_SHARE_SLOPE).clamp(0.0, MAX_DROP_SHARE)
81}
82
83/// Per-file keep floor for [`should_keep_line`] (#1910): the mean per-token
84/// surprise below which a line is among the [`drop_share`] most predictable
85/// lines of `lines`.
86///
87/// The cut is relative to the file on purpose. Absolute cut-offs never fired:
88/// almost every code or prose line has only distinct BPE tokens (normalized
89/// entropy 1.0) and a mean surprise above 11 bits, so a fixed bar either keeps
90/// everything or depends on how the Zipf prior happens to score a language.
91/// Deterministic: the floor is a value from the sorted scores, and ties at it
92/// are kept. Returns `f64::NEG_INFINITY` (keep everything) when nothing is cut.
93#[must_use]
94pub fn surprise_floor<'a>(lines: impl IntoIterator<Item = &'a str>, entropy_threshold: f64) -> f64 {
95    let mut scores: Vec<f64> = lines
96        .into_iter()
97        .map(str::trim)
98        .filter(|t| t.len() >= 3)
99        .map(line_surprise)
100        .collect();
101    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
102    let cut = (scores.len() as f64 * drop_share(entropy_threshold)).floor() as usize;
103    if cut == 0 {
104        return f64::NEG_INFINITY;
105    }
106    scores.sort_by(f64::total_cmp);
107    scores[cut.min(scores.len() - 1)]
108}
109
110/// Keeps a line unless it is one of the file's most predictable lines, i.e.
111/// its mean per-token surprise is below `surprise_floor` (see
112/// [`surprise_floor`]). Rare identifiers raise the per-token surprise, so a
113/// short line like `CustomDomainType::validate()` survives while boilerplate
114/// built from common tokens is shed first. Lines under 3 bytes always stay.
115pub fn should_keep_line(trimmed: &str, surprise_floor: f64) -> bool {
116    trimmed.len() < 3 || line_surprise(trimmed) >= surprise_floor
117}
118
119// ---------------------------------------------------------------------------
120// Semantic redundancy scoring (#544, EFF-7)
121// ---------------------------------------------------------------------------
122//
123// The Zipf prior measures *lexical* rarity: a rare boilerplate identifier
124// scores high (kept), a frequent but semantically unique line scores low
125// (dropped). The LLMLingua family (survey 2410.12388) climbs this ladder
126// with real likelihood models; the strongest model-assisted step we can take
127// without shipping a token classifier is MMR-style semantic dedup: a line
128// that is near-identical *in embedding space* to something already kept
129// carries almost no marginal information (rate-distortion: spend bits on
130// distinct content only). This is exactly what H2O/SnapKV exploit at the
131// KV level.
132//
133// The embedder is injected as a function so the production path can use the
134// real (feature-gated, lazily loaded) embedding engine while the scoring
135// math stays testable; `None` results fall back to pure Zipf behavior,
136// keeping the no-embeddings build byte-identical.
137
138/// Kept-line embedding window for MMR redundancy checks. Capped so the
139/// incremental cost stays O(n·64) per file.
140const KEPT_WINDOW: usize = 64;
141/// Cosine similarity at or above this means "semantically duplicate".
142const REDUNDANCY_COSINE: f64 = 0.92;
143
144/// Sliding context of already-kept line embeddings.
145#[derive(Default)]
146pub struct ScoringCtx {
147    kept: std::collections::VecDeque<Vec<f32>>,
148}
149
150impl ScoringCtx {
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    pub fn push_kept(&mut self, embedding: Vec<f32>) {
156        if self.kept.len() >= KEPT_WINDOW {
157            self.kept.pop_front();
158        }
159        self.kept.push_back(embedding);
160    }
161
162    /// Max cosine similarity of `emb` to any kept embedding.
163    pub fn max_cosine(&self, emb: &[f32]) -> f64 {
164        self.kept
165            .iter()
166            .map(|k| cosine(k, emb))
167            .fold(0.0_f64, f64::max)
168    }
169}
170
171fn cosine(a: &[f32], b: &[f32]) -> f64 {
172    if a.is_empty() || a.len() != b.len() {
173        return 0.0;
174    }
175    let mut dot = 0.0_f64;
176    let mut na = 0.0_f64;
177    let mut nb = 0.0_f64;
178    for (x, y) in a.iter().zip(b.iter()) {
179        dot += f64::from(*x) * f64::from(*y);
180        na += f64::from(*x) * f64::from(*x);
181        nb += f64::from(*y) * f64::from(*y);
182    }
183    if na <= 0.0 || nb <= 0.0 {
184        return 0.0;
185    }
186    dot / (na.sqrt() * nb.sqrt())
187}
188
189/// Semantic-redundancy keep decision (#544): the Zipf path nominates keep
190/// candidates exactly as `should_keep_line`; candidates that embed nearly
191/// identically to an already-kept line are dropped (MMR). `embed` returning
192/// `None` (engine not loaded / feature off) preserves today's behavior
193/// byte-for-byte.
194pub fn should_keep_line_semantic(
195    trimmed: &str,
196    surprise_floor: f64,
197    embed: &dyn Fn(&str) -> Option<Vec<f32>>,
198    ctx: &mut ScoringCtx,
199) -> bool {
200    if !should_keep_line(trimmed, surprise_floor) {
201        return false;
202    }
203    let Some(emb) = embed(trimmed) else {
204        return true;
205    };
206    if ctx.max_cosine(&emb) >= REDUNDANCY_COSINE {
207        return false;
208    }
209    ctx.push_kept(emb);
210    true
211}
212
213#[cfg(test)]
214pub mod tests {
215    use super::*;
216
217    #[test]
218    fn common_code_has_low_surprise() {
219        let common = "let x = 1;";
220        let s = line_surprise(common);
221        assert!(s > 0.0, "surprise should be positive");
222    }
223
224    #[test]
225    fn rare_identifiers_have_higher_surprise() {
226        let common = "let x = 1;";
227        let rare = "let zygomorphic_validator = XenolithProcessor::new();";
228        assert!(
229            line_surprise(rare) > line_surprise(common),
230            "rare identifiers should have higher surprise"
231        );
232    }
233
234    #[test]
235    fn empty_returns_zero() {
236        assert_eq!(line_surprise(""), 0.0);
237    }
238
239    #[test]
240    fn classify_surprise_is_consistent() {
241        let simple = "let x = 1;";
242        let complex = "ZygomorphicXenolithValidator::process_quantum_state(&mut ctx)";
243        let s_simple = line_surprise(simple);
244        let s_complex = line_surprise(complex);
245        assert!(
246            s_complex > s_simple,
247            "rare identifiers ({s_complex}) should have higher surprise than common code ({s_simple})"
248        );
249    }
250
251    #[test]
252    fn should_keep_preserves_rare_lines() {
253        let rare = "ZygomorphicValidator::process_xenolith(&mut state)";
254        let common = [
255            "let a = 1;",
256            "let b = 2;",
257            "x += 1;",
258            "return None;",
259            "Ok(())",
260            "let mut v = Vec::new();",
261            "if x { y }",
262            "self.len()",
263            "use std::fmt;",
264        ];
265        let mut file = common.to_vec();
266        file.push(rare);
267        // 1.9 → drop share 0.6: the six most predictable lines fall below it.
268        let floor = surprise_floor(file.iter().copied(), 1.9);
269        assert!(should_keep_line(rare, floor), "rare line must survive");
270        let shed = common
271            .iter()
272            .filter(|l| !should_keep_line(l, floor))
273            .count();
274        assert!(shed >= 1, "boilerplate is shed first (floor {floor})");
275        assert!(should_keep_line("}", floor), "sub-3-byte lines always stay");
276    }
277
278    /// #1910: the threshold must actually steer the filter — the old absolute
279    /// escapes kept every line whatever the threshold or aggressiveness.
280    #[test]
281    fn drop_share_is_monotonic_and_bounded() {
282        assert_eq!(drop_share(0.0), 0.0);
283        assert_eq!(drop_share(DROP_SHARE_ORIGIN), 0.0);
284        let mut prev = drop_share(0.6);
285        for step in 1..=13 {
286            let t = 0.6 + 0.1 * f64::from(step);
287            let share = drop_share(t);
288            assert!(share > prev, "{t}: {share} must exceed {prev}");
289            prev = share;
290        }
291        assert_eq!(drop_share(10.0), MAX_DROP_SHARE);
292    }
293
294    #[test]
295    fn surprise_floor_cuts_the_requested_share() {
296        let lines: Vec<String> = (0..40)
297            .map(|i| format!("let value_{i} = compute_{}(input);", "x".repeat(i % 7)))
298            .collect();
299        let refs = || lines.iter().map(String::as_str);
300        for t in [0.6, 1.0, 1.4, 1.9] {
301            let floor = surprise_floor(refs(), t);
302            let dropped = refs().filter(|l| !should_keep_line(l, floor)).count();
303            #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
304            let cap = (40.0 * drop_share(t)).floor() as usize;
305            assert!(dropped <= cap, "t={t}: dropped {dropped} > cap {cap}");
306            assert_eq!(floor, surprise_floor(refs(), t), "deterministic (#498)");
307        }
308        assert_eq!(surprise_floor(refs(), 0.4), f64::NEG_INFINITY);
309        assert_eq!(surprise_floor(std::iter::empty(), 1.9), f64::NEG_INFINITY);
310    }
311
312    /// Deterministic hashed bag-of-words vectorizer: a real (if simple)
313    /// embedding function for unit-testing the MMR math. Production uses the
314    /// feature-gated neural engine through the same `embed` seam.
315    #[allow(clippy::unnecessary_wraps)] // Option matches the fallible embed seam
316    fn bow_embed(line: &str) -> Option<Vec<f32>> {
317        let mut v = vec![0.0f32; 64];
318        for tok in line.to_lowercase().split(|c: char| !c.is_alphanumeric()) {
319            if tok.len() < 2 {
320                continue;
321            }
322            let mut h = 0u64;
323            for b in tok.bytes() {
324                h = h.wrapping_mul(31).wrapping_add(u64::from(b));
325            }
326            v[(h % 64) as usize] += 1.0;
327        }
328        Some(v)
329    }
330
331    #[test]
332    fn semantic_dedup_drops_near_duplicate_kept_lines() {
333        let mut ctx = ScoringCtx::new();
334        let embed = |s: &str| bow_embed(s);
335        let a = "fn validate_user_payload(payload: &UserPayload) -> Result<(), ValidationError>";
336        // Same bag of words, reordered — semantically duplicate logic.
337        let b = "fn validate_user_payload(payload: &UserPayload) -> Result<(), ValidationError> ";
338        let c = "const MAX_RETRY_BACKOFF_MS: u64 = 30_000;";
339
340        assert!(should_keep_line_semantic(a, 0.5, &embed, &mut ctx));
341        assert!(
342            !should_keep_line_semantic(b, 0.5, &embed, &mut ctx),
343            "near-identical line must be dropped as redundant"
344        );
345        assert!(
346            should_keep_line_semantic(c, 0.5, &embed, &mut ctx),
347            "distinct line stays"
348        );
349    }
350
351    #[test]
352    fn no_embedder_is_identical_to_zipf_path() {
353        let mut ctx = ScoringCtx::new();
354        let none = |_: &str| None;
355        for line in [
356            "fn main() { run(); }",
357            "let x = 1;",
358            "ZygomorphicXenolithValidator::process_quantum_state(&mut ctx)",
359            "// plain comment",
360        ] {
361            assert_eq!(
362                should_keep_line_semantic(line, 1.0, &none, &mut ctx),
363                should_keep_line(line, 1.0),
364                "without embeddings the decision must match the Zipf path: {line}"
365            );
366        }
367    }
368
369    #[test]
370    fn kept_window_is_bounded() {
371        let mut ctx = ScoringCtx::new();
372        for i in 0..200 {
373            ctx.push_kept(vec![i as f32; 8]);
374        }
375        assert!(ctx.kept.len() <= 64);
376    }
377
378    #[test]
379    fn cosine_handles_degenerate_inputs() {
380        assert_eq!(cosine(&[], &[]), 0.0);
381        assert_eq!(cosine(&[1.0], &[1.0, 2.0]), 0.0);
382        assert_eq!(cosine(&[0.0, 0.0], &[0.0, 0.0]), 0.0);
383        assert!((cosine(&[1.0, 0.0], &[1.0, 0.0]) - 1.0).abs() < 1e-9);
384    }
385}