Skip to main content

sqz_engine/
token_pruner.rs

1/// Self-Information Token Pruning (inspired by CompactPrompt, arxiv 2510.18043).
2///
3/// Uses n-gram frequency as a lightweight proxy for token predictability.
4/// Tokens with high predictability (low self-information) are prunable
5/// without affecting LLM comprehension — grounded in Shannon's source
6/// coding theorem: only the "surprise" bits need to be transmitted.
7///
8/// This module uses a built-in trigram frequency table derived from common
9/// code/prose patterns rather than shipping a large external asset.
10
11use std::collections::HashMap;
12
13use crate::error::Result;
14
15/// Configuration for the token pruner.
16#[derive(Debug, Clone)]
17pub struct PrunerConfig {
18    /// Probability threshold above which a token is considered prunable.
19    /// Tokens with P(token | context) > threshold are removed.
20    /// Default: 0.85
21    pub predictability_threshold: f64,
22    /// Minimum token length to consider for pruning (skip short tokens).
23    /// Default: 2
24    pub min_token_length: usize,
25    /// Whether to preserve tokens that appear in code-like contexts.
26    /// Default: true
27    pub preserve_code_tokens: bool,
28}
29
30impl Default for PrunerConfig {
31    fn default() -> Self {
32        Self {
33            predictability_threshold: 0.85,
34            min_token_length: 2,
35            preserve_code_tokens: true,
36        }
37    }
38}
39
40/// Trigram-based self-information token pruner.
41pub struct TokenPruner {
42    config: PrunerConfig,
43    /// Trigram frequency table: (w1, w2) -> {w3 -> count}
44    trigram_table: HashMap<(String, String), HashMap<String, u32>>,
45    /// Bigram totals for normalization: (w1, w2) -> total_count
46    bigram_totals: HashMap<(String, String), u32>,
47}
48
49impl TokenPruner {
50    /// Create a new pruner with default config and built-in frequency table.
51    pub fn new() -> Self {
52        Self::with_config(PrunerConfig::default())
53    }
54
55    /// Create a new pruner with custom config.
56    pub fn with_config(config: PrunerConfig) -> Self {
57        let mut pruner = Self {
58            config,
59            trigram_table: HashMap::new(),
60            bigram_totals: HashMap::new(),
61        };
62        pruner.load_builtin_patterns();
63        pruner
64    }
65
66    /// Load built-in trigram patterns from common code and prose.
67    fn load_builtin_patterns(&mut self) {
68        // Common English prose trigrams (highly predictable completions)
69        // Only completions whose removal keeps the meaning ("there are no" keeps "no").
70        let patterns: &[(&str, &str, &str, u32)] = &[
71            // Articles and prepositions
72            ("in", "the", "same", 80),
73            ("in", "the", "following", 75),
74            ("is", "a", "function", 50),
75            ("is", "a", "method", 48),
76            ("is", "a", "type", 45),
77            // Common code-adjacent prose
78            ("this", "is", "a", 90),
79            ("this", "is", "the", 85),
80            ("this", "is", "an", 80),
81            ("it", "is", "a", 85),
82            ("it", "is", "the", 80),
83            ("it", "is", "not", 75),
84            ("there", "is", "a", 80),
85            ("there", "is", "no", 75),
86            ("that", "is", "a", 75),
87            ("that", "is", "the", 70),
88            ("which", "is", "a", 70),
89            ("which", "is", "the", 65),
90            // Error message patterns
91            ("error", "in", "the", 60),
92            ("failed", "to", "connect", 55),
93            ("failed", "to", "open", 50),
94            ("failed", "to", "read", 50),
95            ("unable", "to", "find", 55),
96            ("unable", "to", "open", 50),
97            ("could", "not", "find", 60),
98            ("could", "not", "open", 55),
99            ("is", "not", "a", 60),
100            ("is", "not", "the", 55),
101            // Documentation patterns
102            ("for", "more", "information", 80),
103            ("for", "more", "details", 75),
104            ("refer", "to", "the", 65),
105            ("please", "refer", "to", 60),
106            ("note", "that", "the", 55),
107            ("note", "that", "this", 50),
108            ("make", "sure", "that", 60),
109            ("make", "sure", "to", 55),
110            // Filler phrases
111            ("in", "order", "to", 90),
112            ("as", "well", "as", 85),
113            ("due", "to", "the", 70),
114            ("based", "on", "the", 65),
115            ("with", "respect", "to", 60),
116            ("in", "addition", "to", 55),
117            ("as", "opposed", "to", 50),
118            ("on", "behalf", "of", 50),
119        ];
120
121        for &(w1, w2, w3, count) in patterns {
122            let key = (w1.to_lowercase(), w2.to_lowercase());
123            self.trigram_table
124                .entry(key.clone())
125                .or_default()
126                .insert(w3.to_lowercase(), count);
127            *self.bigram_totals.entry(key).or_insert(0) += count;
128        }
129    }
130
131    /// Train the pruner on additional text to improve frequency estimates.
132    pub fn train(&mut self, text: &str) {
133        let words: Vec<String> = tokenize_words(text);
134        if words.len() < 3 {
135            return;
136        }
137        for window in words.windows(3) {
138            let key = (window[0].clone(), window[1].clone());
139            self.trigram_table
140                .entry(key.clone())
141                .or_default()
142                .entry(window[2].clone())
143                .and_modify(|c| *c += 1)
144                .or_insert(1);
145            *self.bigram_totals.entry(key).or_insert(0) += 1;
146        }
147    }
148
149    /// Compute the predictability P(w3 | w1, w2) for a trigram.
150    /// Returns 0.0 if the bigram context has never been seen.
151    fn predictability(&self, w1: &str, w2: &str, w3: &str) -> f64 {
152        let key = (w1.to_lowercase(), w2.to_lowercase());
153        let total = match self.bigram_totals.get(&key) {
154            Some(&t) if t > 0 => t,
155            _ => return 0.0,
156        };
157        let count = self
158            .trigram_table
159            .get(&key)
160            .and_then(|m| m.get(&w3.to_lowercase()))
161            .copied()
162            .unwrap_or(0);
163        count as f64 / total as f64
164    }
165
166    /// Prune highly predictable tokens from prose text.
167    ///
168    /// Returns the pruned text and the number of tokens removed.
169    pub fn prune(&self, text: &str) -> Result<PruneResult> {
170        let mut output = String::with_capacity(text.len());
171        let mut total_removed = 0u32;
172        let mut total_original = 0u32;
173
174        for line in text.split_inclusive('\n') {
175            let body = line.strip_suffix('\n').map_or(line, |l| l.strip_suffix('\r').unwrap_or(l));
176            // Skip code-like lines if preserve_code_tokens is enabled
177            if self.config.preserve_code_tokens && is_code_line(body) {
178                output.push_str(line);
179                total_original += count_words(body) as u32;
180                continue;
181            }
182
183            let words: Vec<&str> = body.split_whitespace().collect();
184            total_original += words.len() as u32;
185
186            if words.len() < 3
187                || body.starts_with(|c: char| c.is_whitespace() || c == '+' || c == '-')
188                || crate::cmd_formatters::is_error_line(body)
189            {
190                output.push_str(line);
191                continue;
192            }
193
194            // Always keep first two words (context)
195            let mut kept = String::with_capacity(line.len());
196            let mut rest = body;
197            let mut removed = 0u32;
198            for (i, word) in words.iter().enumerate() {
199                let after = rest.trim_start();
200                let space = &rest[..rest.len() - after.len()];
201                rest = &after[word.len()..];
202                let w3_clean = word
203                    .trim_matches(|c: char| !c.is_alphanumeric())
204                    .to_lowercase();
205                if i >= 2
206                    && w3_clean.len() >= self.config.min_token_length
207                    && self.predictability(words[i - 2], words[i - 1], &w3_clean)
208                        > self.config.predictability_threshold
209                {
210                    removed += 1;
211                } else {
212                    kept.push_str(space);
213                    kept.push_str(word);
214                }
215            }
216
217            if removed == 0 {
218                output.push_str(line);
219                continue;
220            }
221            total_removed += removed;
222            output.push_str(&kept);
223            output.push_str(&line[body.len() - rest.len()..]);
224        }
225
226        Ok(PruneResult {
227            text: output,
228            tokens_removed: total_removed,
229            tokens_original: total_original,
230        })
231    }
232
233    /// Zipf's Law vocabulary pruning.
234    ///
235    /// Zipf's law: f(r) ∝ 1/r — the frequency of a word is inversely
236    /// proportional to its rank. Words appearing at or above their expected
237    /// Zipf frequency are redundant and can be pruned. Words appearing
238    /// below their expected frequency carry more information and are preserved.
239    ///
240    /// Returns the pruned text and stats.
241    pub fn zipf_prune(&self, text: &str) -> Result<PruneResult> {
242        let words: Vec<&str> = text.split_whitespace().collect();
243        let total_original = words.len() as u32;
244
245        if words.len() < 10 {
246            return Ok(PruneResult {
247                text: text.to_string(),
248                tokens_removed: 0,
249                tokens_original: total_original,
250            });
251        }
252
253        // Count word frequencies
254        let mut freq_map: HashMap<String, usize> = HashMap::new();
255        for &w in &words {
256            *freq_map.entry(w.to_lowercase()).or_insert(0) += 1;
257        }
258
259        // Rank words by frequency (descending)
260        let mut ranked: Vec<(String, usize)> = freq_map.into_iter().collect();
261        ranked.sort_by(|a, b| b.1.cmp(&a.1));
262
263        // Compute expected Zipf frequency for each rank
264        // f_expected(r) = C / r, where C = total_words / H_n (harmonic number)
265        let _n = ranked.len() as f64;
266        let harmonic: f64 = (1..=ranked.len()).map(|r| 1.0 / r as f64).sum();
267        let c = words.len() as f64 / harmonic;
268
269        // Mark words that are "Zipf-redundant": actual frequency >= 1.5× expected
270        let mut redundant_words: std::collections::HashSet<String> = std::collections::HashSet::new();
271        for (rank_idx, (word, actual_freq)) in ranked.iter().enumerate() {
272            let rank = rank_idx + 1;
273            let expected = c / rank as f64;
274            // Word is redundant if it appears much more than Zipf predicts
275            // AND it's a common filler word (short, non-technical)
276            if *actual_freq as f64 > expected * 1.5
277                && word.len() <= 4
278                && !is_technical_word(word)
279            {
280                redundant_words.insert(word.clone());
281            }
282        }
283
284        if redundant_words.is_empty() {
285            return Ok(PruneResult {
286                text: text.to_string(),
287                tokens_removed: 0,
288                tokens_original: total_original,
289            });
290        }
291
292        // Remove redundant words, keeping at least one occurrence of each
293        let mut seen_counts: HashMap<String, usize> = HashMap::new();
294        let mut kept = Vec::new();
295        let mut removed = 0u32;
296
297        for &w in &words {
298            let lower = w.to_lowercase();
299            if redundant_words.contains(&lower) {
300                let count = seen_counts.entry(lower.clone()).or_insert(0);
301                *count += 1;
302                // Keep the first occurrence, prune subsequent ones
303                if *count <= 1 {
304                    kept.push(w);
305                } else {
306                    removed += 1;
307                }
308            } else {
309                kept.push(w);
310            }
311        }
312
313        let result = kept.join(" ");
314        let result = if text.ends_with('\n') && !result.ends_with('\n') {
315            format!("{result}\n")
316        } else {
317            result
318        };
319
320        Ok(PruneResult {
321            text: result,
322            tokens_removed: removed,
323            tokens_original: total_original,
324        })
325    }
326}
327
328impl Default for TokenPruner {
329    fn default() -> Self {
330        Self::new()
331    }
332}
333
334/// Result of a prune operation.
335#[derive(Debug, Clone)]
336pub struct PruneResult {
337    /// The pruned text.
338    pub text: String,
339    /// Number of tokens removed.
340    pub tokens_removed: u32,
341    /// Original token count.
342    pub tokens_original: u32,
343}
344
345impl PruneResult {
346    /// Fraction of tokens removed (0.0 to 1.0).
347    pub fn reduction_ratio(&self) -> f64 {
348        if self.tokens_original == 0 {
349            0.0
350        } else {
351            self.tokens_removed as f64 / self.tokens_original as f64
352        }
353    }
354}
355
356// ── Helpers ───────────────────────────────────────────────────────────────────
357
358/// Tokenize text into lowercase words.
359fn tokenize_words(text: &str) -> Vec<String> {
360    text.split(|c: char| !c.is_alphanumeric() && c != '\'')
361        .filter(|s| !s.is_empty())
362        .map(|s| s.to_lowercase())
363        .collect()
364}
365
366/// Count whitespace-separated words.
367fn count_words(text: &str) -> usize {
368    text.split_whitespace().count()
369}
370
371/// Heuristic: is this line likely code rather than prose?
372fn is_code_line(line: &str) -> bool {
373    let trimmed = line.trim();
374    if trimmed.is_empty() {
375        return false;
376    }
377    // Lines starting with common code indicators
378    trimmed.starts_with("fn ")
379        || trimmed.starts_with("pub ")
380        || trimmed.starts_with("let ")
381        || trimmed.starts_with("const ")
382        || trimmed.starts_with("var ")
383        || trimmed.starts_with("def ")
384        || trimmed.starts_with("class ")
385        || trimmed.starts_with("import ")
386        || trimmed.starts_with("from ")
387        || trimmed.starts_with("use ")
388        || trimmed.starts_with("return ")
389        || trimmed.starts_with("if ")
390        || trimmed.starts_with("for ")
391        || trimmed.starts_with("while ")
392        || trimmed.starts_with('#')
393        || trimmed.starts_with("//")
394        || trimmed.starts_with("/*")
395        || trimmed.starts_with('*')
396        || trimmed.ends_with('{')
397        || trimmed.ends_with('}')
398        || trimmed.ends_with(';')
399        || trimmed.ends_with(')')
400        || trimmed.contains("->")
401        || trimmed.contains("=>")
402        || trimmed.contains("::")
403        || trimmed.contains("()")
404}
405
406/// Check if a short word is technical/meaningful (should not be pruned).
407fn is_technical_word(word: &str) -> bool {
408    matches!(
409        word,
410        "null" | "none" | "true" | "false" | "void" | "self" | "this"
411        | "type" | "enum" | "impl" | "func" | "main" | "test" | "init"
412        | "open" | "read" | "send" | "recv" | "lock" | "drop" | "move"
413        | "copy" | "sync" | "push" | "pull" | "port" | "host" | "path"
414        | "file" | "line" | "code" | "data" | "node" | "root" | "hash"
415        | "size" | "name" | "list" | "loop" | "exit" | "fail" | "pass"
416        | "skip" | "todo" | "warn" | "info" | "http" | "json" | "yaml"
417        | "toml" | "html" | "rust" | "java" | "bash"
418    )
419}
420
421// ── Tests ─────────────────────────────────────────────────────────────────────
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    #[test]
428    fn test_default_creates_pruner() {
429        let pruner = TokenPruner::new();
430        assert!(!pruner.trigram_table.is_empty());
431        assert!(!pruner.bigram_totals.is_empty());
432    }
433
434    #[test]
435    fn test_prune_empty_input() {
436        let pruner = TokenPruner::new();
437        let result = pruner.prune("").unwrap();
438        assert_eq!(result.text, "");
439        assert_eq!(result.tokens_removed, 0);
440    }
441
442    #[test]
443    fn test_prune_short_input_unchanged() {
444        let pruner = TokenPruner::new();
445        let result = pruner.prune("hello world").unwrap();
446        assert_eq!(result.text, "hello world");
447        assert_eq!(result.tokens_removed, 0);
448    }
449
450    #[test]
451    fn test_prune_removes_predictable_tokens() {
452        let pruner = TokenPruner::new();
453        // "in order to" is a highly predictable trigram — "to" should be pruned
454        let result = pruner.prune("We need in order to do this task").unwrap();
455        assert!(
456            result.tokens_removed > 0 || result.text.len() <= "We need in order to do this task".len(),
457            "expected some pruning on predictable prose"
458        );
459    }
460
461    #[test]
462    fn test_prune_never_changes_meaning() {
463        let pruner = TokenPruner::new();
464        for phrase in [
465            "There are no failing tests in this run",
466            "The name foo does not exist in the current context",
467            "Both writes happen at the same time on the leader",
468            "The second checksum is the same as the first",
469            "As a result the build stops before linking",
470            "On the other hand the cache stays warm",
471            "See the documentation for the full list",
472            "Both are of the same type here",
473        ] {
474            let result = pruner.prune(phrase).unwrap();
475            assert_eq!(result.text, phrase, "pruning changed: {phrase}");
476        }
477    }
478
479    #[test]
480    fn test_prune_rewrites_only_lines_that_lose_a_word() {
481        let pruner = TokenPruner::new();
482        let text = "We cache   the token in order to skip a round trip.\r\n\
483                    Two  spaces   stay  here  untouched today\r\n\
484                    +        keep the previous value in order to retry\n\
485                    -        drop the old value in order to retry\n\
486                    \x20   indented in order to stay as written\n\
487                    Error in the payment gateway: failed due to the timeout\n";
488        let result = pruner.prune(text).unwrap();
489        assert_eq!(result.text, text.replacen(" to skip", " skip", 1));
490        assert_eq!(result.tokens_removed, 1);
491    }
492
493    #[test]
494    fn test_prune_preserves_code_lines() {
495        let pruner = TokenPruner::new();
496        let code = "fn main() {\n    let x = 42;\n}";
497        let result = pruner.prune(code).unwrap();
498        assert_eq!(result.text, code);
499        assert_eq!(result.tokens_removed, 0);
500    }
501
502    #[test]
503    fn test_prune_preserves_trailing_newline() {
504        let pruner = TokenPruner::new();
505        let result = pruner.prune("hello world\n").unwrap();
506        assert!(result.text.ends_with('\n'));
507    }
508
509    #[test]
510    fn test_train_adds_patterns() {
511        let mut pruner = TokenPruner::new();
512        let initial_size = pruner.trigram_table.len();
513        pruner.train("the quick brown fox jumps over the lazy dog and the quick brown cat");
514        assert!(pruner.trigram_table.len() >= initial_size);
515    }
516
517    #[test]
518    fn test_predictability_unknown_context() {
519        let pruner = TokenPruner::new();
520        let p = pruner.predictability("xyzzy", "plugh", "foo");
521        assert_eq!(p, 0.0);
522    }
523
524    #[test]
525    fn test_predictability_known_pattern() {
526        let pruner = TokenPruner::new();
527        // "in order to" is in the built-in table with high count
528        let p = pruner.predictability("in", "order", "to");
529        assert!(p > 0.5, "expected high predictability, got {p}");
530    }
531
532    #[test]
533    fn test_reduction_ratio_zero_for_empty() {
534        let result = PruneResult {
535            text: String::new(),
536            tokens_removed: 0,
537            tokens_original: 0,
538        };
539        assert_eq!(result.reduction_ratio(), 0.0);
540    }
541
542    #[test]
543    fn test_is_code_line_detection() {
544        assert!(is_code_line("fn main() {"));
545        assert!(is_code_line("  let x = 42;"));
546        assert!(is_code_line("// comment"));
547        assert!(is_code_line("import os"));
548        assert!(!is_code_line("This is a normal sentence."));
549        assert!(!is_code_line("The error occurred in the module."));
550        assert!(!is_code_line(""));
551    }
552
553    #[test]
554    fn test_custom_config() {
555        let config = PrunerConfig {
556            predictability_threshold: 0.5,
557            min_token_length: 1,
558            preserve_code_tokens: false,
559        };
560        let pruner = TokenPruner::with_config(config);
561        // With lower threshold, more tokens should be pruned
562        let result = pruner.prune("this is a very long sentence with many words in order to test").unwrap();
563        // Just verify it doesn't crash
564        assert!(!result.text.is_empty());
565    }
566
567    // ── Property tests ────────────────────────────────────────────────────────
568
569    use proptest::prelude::*;
570
571    proptest! {
572        /// Pruning never produces output longer than input.
573        #[test]
574        fn prop_prune_never_increases_length(
575            text in "[a-z ]{10,200}"
576        ) {
577            let pruner = TokenPruner::new();
578            let result = pruner.prune(&text).unwrap();
579            prop_assert!(
580                result.text.len() <= text.len() + 1, // +1 for possible trailing newline
581                "pruned text ({}) should not be longer than input ({})",
582                result.text.len(), text.len()
583            );
584        }
585
586        /// tokens_removed + remaining tokens == tokens_original
587        #[test]
588        fn prop_prune_token_accounting(
589            text in "[a-z ]{10,200}"
590        ) {
591            let pruner = TokenPruner::new();
592            let result = pruner.prune(&text).unwrap();
593            let remaining = count_words(&result.text) as u32;
594            prop_assert!(
595                result.tokens_removed + remaining <= result.tokens_original + 1,
596                "removed ({}) + remaining ({}) should be <= original ({})",
597                result.tokens_removed, remaining, result.tokens_original
598            );
599        }
600    }
601
602    // ── Zipf's Law pruning tests ──────────────────────────────────────────
603
604    #[test]
605    fn test_zipf_prune_short_text_unchanged() {
606        let pruner = TokenPruner::new();
607        let result = pruner.zipf_prune("hello world").unwrap();
608        assert_eq!(result.text, "hello world");
609        assert_eq!(result.tokens_removed, 0);
610    }
611
612    #[test]
613    fn test_zipf_prune_removes_overrepresented_fillers() {
614        let pruner = TokenPruner::new();
615        // "the" appears way more than Zipf predicts for a text this size
616        let text = "the cat the dog the bird the fish the tree the rock the sky the sun the moon the star";
617        let result = pruner.zipf_prune(text).unwrap();
618        // Should remove some "the" occurrences but keep at least one
619        assert!(result.text.contains("the"), "should keep at least one 'the'");
620        assert!(
621            result.tokens_removed > 0,
622            "should prune overrepresented filler words"
623        );
624    }
625
626    #[test]
627    fn test_zipf_prune_preserves_technical_words() {
628        let pruner = TokenPruner::new();
629        let text = "null null null null null null null null null null check for null values";
630        let result = pruner.zipf_prune(text).unwrap();
631        // "null" is a technical word — should NOT be pruned
632        assert_eq!(result.tokens_removed, 0, "technical words should be preserved");
633    }
634
635    #[test]
636    fn test_is_technical_word() {
637        assert!(is_technical_word("null"));
638        assert!(is_technical_word("type"));
639        assert!(is_technical_word("json"));
640        assert!(!is_technical_word("the"));
641        assert!(!is_technical_word("and"));
642        assert!(!is_technical_word("xyz"));
643    }
644
645    proptest! {
646        /// Zipf pruning never produces empty output from non-empty input.
647        #[test]
648        fn prop_zipf_prune_non_empty(
649            text in "[a-z]{2,5}( [a-z]{2,5}){10,30}"
650        ) {
651            let pruner = TokenPruner::new();
652            let result = pruner.zipf_prune(&text).unwrap();
653            prop_assert!(!result.text.is_empty());
654        }
655    }
656}