1use crate::error::Result;
12
13#[derive(Debug, Clone)]
15pub struct EntropyTruncConfig {
16 pub min_entropy_override: Option<f64>,
20 pub min_kept_segments: usize,
24 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#[derive(Debug, Clone)]
41pub struct ScoredSegment {
42 pub text: String,
44 pub entropy: f64,
46 pub kept: bool,
48}
49
50#[derive(Debug, Clone)]
52pub struct EntropyTruncResult {
53 pub text: String,
55 pub segments: Vec<ScoredSegment>,
57 pub segments_dropped: usize,
59 pub threshold: f64,
61}
62
63pub 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 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 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 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 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 if kept_count < self.config.min_kept_segments {
139 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 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 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 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 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 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 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 while kept.len() < self.config.min_kept_segments && dropped > 0 {
235 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#[derive(Debug, Clone)]
271pub struct EntropyTruncArrayResult {
272 pub kept: Vec<serde_json::Value>,
274 pub dropped_count: usize,
276 pub threshold: f64,
278}
279
280fn 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
292fn 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
347fn 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
370fn 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
383fn split_segments(text: &str) -> Vec<String> {
386 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 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#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn factsheet_preserves_ids_from_dropped_segments() {
418 let truncator = EntropyTruncator::new();
419 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 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 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 assert_eq!(shannon_entropy("aaaa"), 0.0);
493 }
494
495 #[test]
496 fn test_shannon_entropy_varied() {
497 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 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 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 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 for _ in 0..8 {
560 elements.push(serde_json::json!({"x": 1}));
561 }
562
563 let result = trunc.truncate_array(&elements).unwrap();
564 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 use proptest::prelude::*;
599
600 proptest! {
601 #[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 #[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 #[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}