1use std::collections::HashMap;
12
13use crate::error::Result;
14
15#[derive(Debug, Clone)]
17pub struct PrunerConfig {
18 pub predictability_threshold: f64,
22 pub min_token_length: usize,
25 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
40pub struct TokenPruner {
42 config: PrunerConfig,
43 trigram_table: HashMap<(String, String), HashMap<String, u32>>,
45 bigram_totals: HashMap<(String, String), u32>,
47}
48
49impl TokenPruner {
50 pub fn new() -> Self {
52 Self::with_config(PrunerConfig::default())
53 }
54
55 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 fn load_builtin_patterns(&mut self) {
68 let patterns: &[(&str, &str, &str, u32)] = &[
71 ("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 ("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", "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 ("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 ("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 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 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 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 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 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 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 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 let mut ranked: Vec<(String, usize)> = freq_map.into_iter().collect();
261 ranked.sort_by(|a, b| b.1.cmp(&a.1));
262
263 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 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 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 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 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#[derive(Debug, Clone)]
336pub struct PruneResult {
337 pub text: String,
339 pub tokens_removed: u32,
341 pub tokens_original: u32,
343}
344
345impl PruneResult {
346 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
356fn 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
366fn count_words(text: &str) -> usize {
368 text.split_whitespace().count()
369}
370
371fn is_code_line(line: &str) -> bool {
373 let trimmed = line.trim();
374 if trimmed.is_empty() {
375 return false;
376 }
377 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
406fn 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#[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 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 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 let result = pruner.prune("this is a very long sentence with many words in order to test").unwrap();
563 assert!(!result.text.is_empty());
565 }
566
567 use proptest::prelude::*;
570
571 proptest! {
572 #[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, "pruned text ({}) should not be longer than input ({})",
582 result.text.len(), text.len()
583 );
584 }
585
586 #[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 #[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 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 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 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 #[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}