Skip to main content

sqz_engine/
entropy_truncator.rs

1/// Adaptive Entropy-Weighted Truncation (Rate-Distortion Theory).
2///
3/// Instead of using fixed thresholds for truncate_strings and collapse_arrays,
4/// this module computes per-segment entropy and keeps segments above the
5/// median entropy while dropping segments below it. This preserves the
6/// information-dense parts and drops the redundant tail.
7///
8/// Based on rate-distortion theory: the optimal cutoff depends on the
9/// information density of the content being truncated, not a fixed length.
10
11use crate::error::Result;
12
13/// Configuration for entropy-weighted truncation.
14#[derive(Debug, Clone)]
15pub struct EntropyTruncConfig {
16    /// Minimum entropy (bits) below which a segment is considered low-information.
17    /// Segments below this are candidates for removal.
18    /// Default: computed dynamically as median of all segments.
19    pub min_entropy_override: Option<f64>,
20    /// Maximum number of low-entropy segments to keep (even if below threshold).
21    /// Ensures we don't drop everything.
22    /// Default: 2
23    pub min_kept_segments: usize,
24    /// Minimum segment length (chars) to analyze. Shorter segments are always kept.
25    /// Default: 20
26    pub min_segment_length: usize,
27}
28
29impl Default for EntropyTruncConfig {
30    fn default() -> Self {
31        Self {
32            min_entropy_override: None,
33            min_kept_segments: 2,
34            min_segment_length: 20,
35        }
36    }
37}
38
39/// A segment with its computed entropy.
40#[derive(Debug, Clone)]
41pub struct ScoredSegment {
42    /// The segment text.
43    pub text: String,
44    /// Shannon entropy in bits.
45    pub entropy: f64,
46    /// Whether this segment was kept in the output.
47    pub kept: bool,
48}
49
50/// Result of entropy-weighted truncation.
51#[derive(Debug, Clone)]
52pub struct EntropyTruncResult {
53    /// The truncated output.
54    pub text: String,
55    /// Segments with their scores and keep/drop decisions.
56    pub segments: Vec<ScoredSegment>,
57    /// Number of segments dropped.
58    pub segments_dropped: usize,
59    /// Entropy threshold used.
60    pub threshold: f64,
61}
62
63/// Entropy-weighted truncator.
64pub struct EntropyTruncator {
65    config: EntropyTruncConfig,
66}
67
68impl EntropyTruncator {
69    pub fn new() -> Self {
70        Self::with_config(EntropyTruncConfig::default())
71    }
72
73    pub fn with_config(config: EntropyTruncConfig) -> Self {
74        Self { config }
75    }
76
77    /// Truncate a string by keeping high-entropy (information-dense) segments
78    /// and dropping low-entropy (redundant) segments.
79    ///
80    /// Segments are split by double-newline (paragraph boundaries) or by
81    /// a fixed window if no paragraph breaks exist.
82    pub fn truncate_string(&self, text: &str) -> Result<EntropyTruncResult> {
83        let segments = split_segments(text);
84
85        if segments.len() <= 1 {
86            return Ok(EntropyTruncResult {
87                text: text.to_string(),
88                segments: segments
89                    .into_iter()
90                    .map(|s| ScoredSegment {
91                        entropy: shannon_entropy(&s),
92                        text: s,
93                        kept: true,
94                    })
95                    .collect(),
96                segments_dropped: 0,
97                threshold: 0.0,
98            });
99        }
100
101        // Compute entropy for each segment
102        let mut scored: Vec<ScoredSegment> = segments
103            .into_iter()
104            .map(|s| {
105                let entropy = shannon_entropy(&s);
106                ScoredSegment {
107                    text: s,
108                    entropy,
109                    kept: true,
110                }
111            })
112            .collect();
113
114        // Determine threshold: use override or compute median
115        let threshold = self.config.min_entropy_override.unwrap_or_else(|| {
116            let mut entropies: Vec<f64> = scored.iter().map(|s| s.entropy).collect();
117            entropies.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
118            median(&entropies)
119        });
120
121        // Mark segments below threshold for dropping
122        let mut kept_count = 0usize;
123        let total = scored.len();
124
125        for seg in &mut scored {
126            if seg.text.len() < self.config.min_segment_length || has_error_marker(&seg.text) {
127                seg.kept = true;
128                kept_count += 1;
129            } else if seg.entropy >= threshold {
130                seg.kept = true;
131                kept_count += 1;
132            } else {
133                seg.kept = false;
134            }
135        }
136
137        // Ensure we keep at least min_kept_segments
138        if kept_count < self.config.min_kept_segments {
139            // Re-add the highest-entropy dropped segments
140            let mut dropped_indices: Vec<(usize, f64)> = scored
141                .iter()
142                .enumerate()
143                .filter(|(_, s)| !s.kept)
144                .map(|(i, s)| (i, s.entropy))
145                .collect();
146            dropped_indices.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
147
148            for (idx, _) in dropped_indices {
149                if kept_count >= self.config.min_kept_segments {
150                    break;
151                }
152                scored[idx].kept = true;
153                kept_count += 1;
154            }
155        }
156
157        let segments_dropped = total - kept_count;
158
159        // Build output
160        let kept_texts: Vec<&str> = scored
161            .iter()
162            .filter(|s| s.kept)
163            .map(|s| s.text.as_str())
164            .collect();
165
166        let mut text = kept_texts.join("\n\n");
167        if segments_dropped > 0 {
168            text.push_str(&format!(
169                "\n[{segments_dropped} low-information segments omitted]"
170            ));
171            // Identifier factsheet: exact identifiers living in the dropped
172            // segments (SHAs, UUIDs, ticket codes, versions) ride along so
173            // lossy truncation can never silently lose a hash the agent
174            // needs to copy-paste. Deterministic and budget-capped.
175            let dropped_text: Vec<&str> = scored
176                .iter()
177                .filter(|s| !s.kept)
178                .map(|s| s.text.as_str())
179                .collect();
180            let kept_joined = kept_texts.join("\n");
181            let ids = extract_identifiers(&dropped_text.join("\n"), &kept_joined);
182            if !ids.is_empty() {
183                text.push_str(&format!("\n[ids in omitted segments: {}]", ids.join(", ")));
184            }
185        }
186
187        Ok(EntropyTruncResult {
188            text,
189            segments: scored,
190            segments_dropped,
191            threshold,
192        })
193    }
194
195    /// Truncate a JSON array by keeping high-entropy elements and dropping
196    /// low-entropy (redundant) ones.
197    pub fn truncate_array(&self, elements: &[serde_json::Value]) -> Result<EntropyTruncArrayResult> {
198        if elements.len() <= 2 {
199            return Ok(EntropyTruncArrayResult {
200                kept: elements.to_vec(),
201                dropped_count: 0,
202                threshold: 0.0,
203            });
204        }
205
206        // Serialize each element and compute entropy
207        let scored: Vec<(serde_json::Value, f64)> = elements
208            .iter()
209            .map(|e| {
210                let s = serde_json::to_string(e).unwrap_or_default();
211                let entropy = shannon_entropy(&s);
212                (e.clone(), entropy)
213            })
214            .collect();
215
216        // Compute median entropy
217        let mut entropies: Vec<f64> = scored.iter().map(|(_, e)| *e).collect();
218        entropies.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
219        let threshold = self.config.min_entropy_override.unwrap_or_else(|| median(&entropies));
220
221        // Keep elements above threshold, plus at least min_kept_segments
222        let mut kept = Vec::new();
223        let mut dropped = 0usize;
224
225        for (elem, entropy) in &scored {
226            if *entropy >= threshold || kept.len() < self.config.min_kept_segments {
227                kept.push(elem.clone());
228            } else {
229                dropped += 1;
230            }
231        }
232
233        // Always keep at least min_kept_segments
234        while kept.len() < self.config.min_kept_segments && dropped > 0 {
235            // Add back highest-entropy dropped elements
236            let mut remaining: Vec<&(serde_json::Value, f64)> = scored
237                .iter()
238                .filter(|(e, _)| !kept.contains(e))
239                .collect();
240            remaining.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
241            if let Some((elem, _)) = remaining.first() {
242                kept.push((*elem).clone());
243                dropped -= 1;
244            } else {
245                break;
246            }
247        }
248
249        if dropped > 0 {
250            kept.push(serde_json::Value::String(format!(
251                "[{dropped} low-information elements omitted]"
252            )));
253        }
254
255        Ok(EntropyTruncArrayResult {
256            kept,
257            dropped_count: dropped,
258            threshold,
259        })
260    }
261}
262
263impl Default for EntropyTruncator {
264    fn default() -> Self {
265        Self::new()
266    }
267}
268
269/// Result of entropy-weighted array truncation.
270#[derive(Debug, Clone)]
271pub struct EntropyTruncArrayResult {
272    /// The kept elements (plus optional summary element).
273    pub kept: Vec<serde_json::Value>,
274    /// Number of elements dropped.
275    pub dropped_count: usize,
276    /// Entropy threshold used.
277    pub threshold: f64,
278}
279
280// ── Helpers ───────────────────────────────────────────────────────────────────
281
282/// Segments mentioning an error are kept whatever their entropy. Same
283/// markers the pipeline uses to decide content is not benign.
284fn has_error_marker(text: &str) -> bool {
285    const MARKERS: &[&str] = &[
286        "error", "panic", "traceback", "failed", "failure", "exception", "fatal",
287    ];
288    let lower = text.to_ascii_lowercase();
289    MARKERS.iter().any(|m| lower.contains(m))
290}
291
292/// Extract exact identifiers from `dropped` that do not also appear in
293/// `kept`: hex ids/SHAs (7+ chars containing a digit), UUIDs, ticket codes
294/// (ABC-123), and semantic versions. Capped at 16 entries / 256 chars total
295/// so the factsheet can't become its own bloat. Order follows first
296/// appearance, deduplicated.
297fn extract_identifiers(dropped: &str, kept: &str) -> Vec<String> {
298    const MAX_IDS: usize = 16;
299    const MAX_CHARS: usize = 256;
300
301    let mut out: Vec<String> = Vec::new();
302    let mut budget = 0usize;
303    let push = |candidate: &str, out: &mut Vec<String>, budget: &mut usize| {
304        if out.len() >= MAX_IDS || *budget + candidate.len() > MAX_CHARS {
305            return;
306        }
307        if kept.contains(candidate) || out.iter().any(|s| s == candidate) {
308            return;
309        }
310        *budget += candidate.len() + 2;
311        out.push(candidate.to_string());
312    };
313
314    for token in dropped.split(|c: char| c.is_whitespace() || "()[]{}<>\"'`,;".contains(c)) {
315        let token = token.trim_end_matches(|c: char| c == '.' || c == ':');
316        if token.len() < 5 || token.len() > 64 {
317            continue;
318        }
319        let is_hex_id = token.len() >= 7
320            && token.chars().all(|c| c.is_ascii_hexdigit())
321            && token.chars().any(|c| c.is_ascii_digit())
322            && token.chars().any(|c| c.is_ascii_alphabetic());
323        let is_uuid = token.len() == 36
324            && token
325                .chars()
326                .all(|c| c.is_ascii_hexdigit() || c == '-')
327            && token.matches('-').count() == 4;
328        let is_ticket = token.contains('-')
329            && token.split('-').count() == 2
330            && token.split('-').next().is_some_and(|p| {
331                (2..=10).contains(&p.len()) && p.chars().all(|c| c.is_ascii_uppercase())
332            })
333            && token.split('-').nth(1).is_some_and(|p| {
334                !p.is_empty() && p.chars().all(|c| c.is_ascii_digit())
335            });
336        let is_version = token.starts_with('v')
337            && token[1..].split('.').count() >= 2
338            && token[1..].split('.').all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
339
340        if is_hex_id || is_uuid || is_ticket || is_version {
341            push(token, &mut out, &mut budget);
342        }
343    }
344    out
345}
346
347/// Compute Shannon entropy of a string in bits.
348fn shannon_entropy(text: &str) -> f64 {
349    if text.is_empty() {
350        return 0.0;
351    }
352
353    let mut freq = [0u32; 256];
354    let len = text.len() as f64;
355
356    for &byte in text.as_bytes() {
357        freq[byte as usize] += 1;
358    }
359
360    let mut entropy = 0.0f64;
361    for &count in &freq {
362        if count > 0 {
363            let p = count as f64 / len;
364            entropy -= p * p.log2();
365        }
366    }
367    entropy
368}
369
370/// Compute the median of a sorted slice.
371fn median(sorted: &[f64]) -> f64 {
372    if sorted.is_empty() {
373        return 0.0;
374    }
375    let mid = sorted.len() / 2;
376    if sorted.len() % 2 == 0 {
377        (sorted[mid - 1] + sorted[mid]) / 2.0
378    } else {
379        sorted[mid]
380    }
381}
382
383/// Split text into segments at paragraph boundaries (double newlines).
384/// Falls back to fixed-size windows if no paragraph breaks exist.
385fn split_segments(text: &str) -> Vec<String> {
386    // Try splitting on double newlines first
387    let segments: Vec<String> = text
388        .split("\n\n")
389        .map(|s| s.to_string())
390        .filter(|s| !s.trim().is_empty())
391        .collect();
392
393    if segments.len() > 1 {
394        return segments;
395    }
396
397    // Fall back to splitting on single newlines into groups of ~10 lines
398    let lines: Vec<&str> = text.lines().collect();
399    if lines.len() <= 10 {
400        return vec![text.to_string()];
401    }
402
403    let chunk_size = 10;
404    lines
405        .chunks(chunk_size)
406        .map(|chunk| chunk.join("\n"))
407        .collect()
408}
409
410// ── Tests ─────────────────────────────────────────────────────────────────────
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn factsheet_preserves_ids_from_dropped_segments() {
418        let truncator = EntropyTruncator::new();
419        // Low-entropy segments carrying identifiers, high-entropy segments
420        // without them. The low ones get dropped; their ids must survive
421        // in the factsheet line.
422        let low_a = format!("{} deadbeef42 PROJ-1482", "aaaaaaaaaa ".repeat(8));
423        let low_b = format!(
424            "{} 550e8400-e29b-41d4-a716-446655440000 v1.4.0",
425            "bbbbbbbbbb ".repeat(8)
426        );
427        let high_a = "The quick brown fox jumps over the lazy dog by silver rivers today.";
428        let high_b = "Zephyrs quickly vex jumbled gnomes while quartz dwarfs mix pyjamas.";
429        let input = format!("{low_a}\n\n{high_a}\n\n{low_b}\n\n{high_b}");
430
431        let result = truncator.truncate_string(&input).unwrap();
432        assert!(result.segments_dropped > 0, "fixture must drop segments");
433        assert!(result.text.contains("[ids in omitted segments:"), "{}", result.text);
434        for id in ["deadbeef42", "PROJ-1482", "550e8400-e29b-41d4-a716-446655440000", "v1.4.0"] {
435            assert!(result.text.contains(id), "lost id {id}: {}", result.text);
436        }
437    }
438
439    #[test]
440    fn error_lines_are_never_dropped() {
441        // No blank lines, so the log is scored in 10-line windows.
442        let truncator = EntropyTruncator::new();
443        let mut log: String = (0..300)
444            .map(|i| format!("2026-10-01T12:00:{:02}Z INFO worker {i} finished job {i} in {} ms\n", i % 60, i % 97))
445            .collect();
446        log.push_str("ERROR worker 299 failed: disk full\n");
447        let result = truncator.truncate_string(&log).unwrap();
448        assert!(result.segments_dropped > 0, "the routine lines still truncate");
449        assert!(result.text.contains("ERROR worker 299 failed: disk full"), "{}", result.text);
450
451        let panic = format!("{}\n\nthread 'main' panicked at src/lib.rs:9:5", "ok line\n".repeat(40));
452        let result = truncator.truncate_string(&panic).unwrap();
453        assert!(result.text.contains("panicked at src/lib.rs:9:5"), "{}", result.text);
454    }
455
456    #[test]
457    fn factsheet_skips_ids_already_kept() {
458        // An id present in a KEPT segment must not be repeated in the sheet.
459        let dropped = "deadbeef42 and cafe1234567";
460        let kept = "deadbeef42 remains visible";
461        let ids = extract_identifiers(dropped, kept);
462        assert!(!ids.contains(&"deadbeef42".to_string()));
463        assert!(ids.contains(&"cafe1234567".to_string()));
464    }
465
466    #[test]
467    fn factsheet_is_budget_capped() {
468        let dropped: String = (0..50)
469            .map(|i| format!("PROJ-{}{:04}", 1000 + i, i))
470            .collect::<Vec<_>>()
471            .join(" ");
472        let ids = extract_identifiers(&dropped, "");
473        assert!(ids.len() <= 16, "cap at 16 facts, got {}", ids.len());
474        let total: usize = ids.iter().map(|s| s.len() + 2).sum();
475        assert!(total <= 256 + 16, "char budget exceeded: {total}");
476    }
477
478    #[test]
479    fn factsheet_ignores_plain_words_and_numbers() {
480        let ids = extract_identifiers("building the module quickly 123456789 finished", "");
481        assert!(ids.is_empty(), "plain words/ints are not ids: {ids:?}");
482    }
483
484    #[test]
485    fn test_shannon_entropy_empty() {
486        assert_eq!(shannon_entropy(""), 0.0);
487    }
488
489    #[test]
490    fn test_shannon_entropy_single_char() {
491        // All same character → entropy = 0
492        assert_eq!(shannon_entropy("aaaa"), 0.0);
493    }
494
495    #[test]
496    fn test_shannon_entropy_varied() {
497        // More varied text → higher entropy
498        let low = shannon_entropy("aaaa");
499        let high = shannon_entropy("abcdefghijklmnop");
500        assert!(high > low);
501    }
502
503    #[test]
504    fn test_median_empty() {
505        assert_eq!(median(&[]), 0.0);
506    }
507
508    #[test]
509    fn test_median_odd() {
510        assert_eq!(median(&[1.0, 2.0, 3.0]), 2.0);
511    }
512
513    #[test]
514    fn test_median_even() {
515        assert_eq!(median(&[1.0, 2.0, 3.0, 4.0]), 2.5);
516    }
517
518    #[test]
519    fn test_truncate_string_short_input() {
520        let trunc = EntropyTruncator::new();
521        let result = trunc.truncate_string("hello world").unwrap();
522        assert_eq!(result.text, "hello world");
523        assert_eq!(result.segments_dropped, 0);
524    }
525
526    #[test]
527    fn test_truncate_string_with_paragraphs() {
528        let trunc = EntropyTruncator::new();
529        // Create text with varied and repetitive paragraphs
530        let varied = "The quick brown fox jumps over the lazy dog with many different words and characters.";
531        let repetitive = "aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa";
532        let text = format!("{varied}\n\n{repetitive}\n\n{varied}");
533
534        let result = trunc.truncate_string(&text).unwrap();
535        // The varied paragraphs should be kept, repetitive may be dropped
536        assert!(result.text.contains("quick brown fox"));
537    }
538
539    #[test]
540    fn test_truncate_array_short() {
541        let trunc = EntropyTruncator::new();
542        let elements = vec![
543            serde_json::json!(1),
544            serde_json::json!(2),
545        ];
546        let result = trunc.truncate_array(&elements).unwrap();
547        assert_eq!(result.kept.len(), 2);
548        assert_eq!(result.dropped_count, 0);
549    }
550
551    #[test]
552    fn test_truncate_array_with_redundant_elements() {
553        let trunc = EntropyTruncator::new();
554        let mut elements = Vec::new();
555        // Add varied elements (high entropy — many different characters)
556        elements.push(serde_json::json!({"name": "Alice Johnson", "role": "senior software engineer", "department": "platform infrastructure", "id": 12345}));
557        elements.push(serde_json::json!({"name": "Bob Williams", "role": "product designer", "department": "user experience research", "id": 67890}));
558        // Add many identical simple elements (low entropy — very repetitive)
559        for _ in 0..8 {
560            elements.push(serde_json::json!({"x": 1}));
561        }
562
563        let result = trunc.truncate_array(&elements).unwrap();
564        // Should keep the varied elements; may drop some repetitive ones
565        // At minimum, the result should not be larger than the input
566        assert!(result.kept.len() <= elements.len());
567    }
568
569    #[test]
570    fn test_split_segments_paragraphs() {
571        let text = "para 1\n\npara 2\n\npara 3";
572        let segments = split_segments(text);
573        assert_eq!(segments.len(), 3);
574    }
575
576    #[test]
577    fn test_split_segments_no_paragraphs() {
578        let lines: Vec<String> = (0..25).map(|i| format!("line {i}")).collect();
579        let text = lines.join("\n");
580        let segments = split_segments(&text);
581        assert!(segments.len() > 1);
582    }
583
584    #[test]
585    fn test_custom_config() {
586        let config = EntropyTruncConfig {
587            min_entropy_override: Some(3.0),
588            min_kept_segments: 1,
589            min_segment_length: 5,
590        };
591        let trunc = EntropyTruncator::with_config(config);
592        let result = trunc.truncate_string("hello\n\nworld").unwrap();
593        assert!(!result.text.is_empty());
594    }
595
596    // ── Property tests ────────────────────────────────────────────────────────
597
598    use proptest::prelude::*;
599
600    proptest! {
601        /// Truncation never produces empty output from non-empty input.
602        #[test]
603        fn prop_truncation_non_empty(
604            text in "[a-z ]{20,200}"
605        ) {
606            let trunc = EntropyTruncator::new();
607            let result = trunc.truncate_string(&text).unwrap();
608            prop_assert!(
609                !result.text.is_empty(),
610                "truncation should not produce empty output"
611            );
612        }
613
614        /// Shannon entropy is always non-negative.
615        #[test]
616        fn prop_entropy_non_negative(
617            text in ".{1,100}"
618        ) {
619            let e = shannon_entropy(&text);
620            prop_assert!(e >= 0.0, "entropy should be non-negative, got {e}");
621        }
622
623        /// Segments dropped count matches the actual difference.
624        #[test]
625        fn prop_segments_accounting(
626            paragraphs in proptest::collection::vec("[a-z ]{10,50}", 3..=8usize),
627        ) {
628            let text = paragraphs.join("\n\n");
629            let trunc = EntropyTruncator::new();
630            let result = trunc.truncate_string(&text).unwrap();
631
632            let total = result.segments.len();
633            let kept = result.segments.iter().filter(|s| s.kept).count();
634            prop_assert_eq!(
635                result.segments_dropped, total - kept,
636                "segments_dropped should equal total - kept"
637            );
638        }
639    }
640}