use crate::error::Result;
#[derive(Debug, Clone)]
pub struct EntropyTruncConfig {
pub min_entropy_override: Option<f64>,
pub min_kept_segments: usize,
pub min_segment_length: usize,
}
impl Default for EntropyTruncConfig {
fn default() -> Self {
Self {
min_entropy_override: None,
min_kept_segments: 2,
min_segment_length: 20,
}
}
}
#[derive(Debug, Clone)]
pub struct ScoredSegment {
pub text: String,
pub entropy: f64,
pub kept: bool,
}
#[derive(Debug, Clone)]
pub struct EntropyTruncResult {
pub text: String,
pub segments: Vec<ScoredSegment>,
pub segments_dropped: usize,
pub threshold: f64,
}
pub struct EntropyTruncator {
config: EntropyTruncConfig,
}
impl EntropyTruncator {
pub fn new() -> Self {
Self::with_config(EntropyTruncConfig::default())
}
pub fn with_config(config: EntropyTruncConfig) -> Self {
Self { config }
}
pub fn truncate_string(&self, text: &str) -> Result<EntropyTruncResult> {
let segments = split_segments(text);
if segments.len() <= 1 {
return Ok(EntropyTruncResult {
text: text.to_string(),
segments: segments
.into_iter()
.map(|s| ScoredSegment {
entropy: shannon_entropy(&s),
text: s,
kept: true,
})
.collect(),
segments_dropped: 0,
threshold: 0.0,
});
}
let mut scored: Vec<ScoredSegment> = segments
.into_iter()
.map(|s| {
let entropy = shannon_entropy(&s);
ScoredSegment {
text: s,
entropy,
kept: true,
}
})
.collect();
let threshold = self.config.min_entropy_override.unwrap_or_else(|| {
let mut entropies: Vec<f64> = scored.iter().map(|s| s.entropy).collect();
entropies.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
median(&entropies)
});
let mut kept_count = 0usize;
let total = scored.len();
for seg in &mut scored {
if seg.text.len() < self.config.min_segment_length {
seg.kept = true;
kept_count += 1;
} else if seg.entropy >= threshold {
seg.kept = true;
kept_count += 1;
} else {
seg.kept = false;
}
}
if kept_count < self.config.min_kept_segments {
let mut dropped_indices: Vec<(usize, f64)> = scored
.iter()
.enumerate()
.filter(|(_, s)| !s.kept)
.map(|(i, s)| (i, s.entropy))
.collect();
dropped_indices.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
for (idx, _) in dropped_indices {
if kept_count >= self.config.min_kept_segments {
break;
}
scored[idx].kept = true;
kept_count += 1;
}
}
let segments_dropped = total - kept_count;
let kept_texts: Vec<&str> = scored
.iter()
.filter(|s| s.kept)
.map(|s| s.text.as_str())
.collect();
let mut text = kept_texts.join("\n\n");
if segments_dropped > 0 {
text.push_str(&format!(
"\n[{segments_dropped} low-information segments omitted]"
));
let dropped_text: Vec<&str> = scored
.iter()
.filter(|s| !s.kept)
.map(|s| s.text.as_str())
.collect();
let kept_joined = kept_texts.join("\n");
let ids = extract_identifiers(&dropped_text.join("\n"), &kept_joined);
if !ids.is_empty() {
text.push_str(&format!("\n[ids in omitted segments: {}]", ids.join(", ")));
}
}
Ok(EntropyTruncResult {
text,
segments: scored,
segments_dropped,
threshold,
})
}
pub fn truncate_array(&self, elements: &[serde_json::Value]) -> Result<EntropyTruncArrayResult> {
if elements.len() <= 2 {
return Ok(EntropyTruncArrayResult {
kept: elements.to_vec(),
dropped_count: 0,
threshold: 0.0,
});
}
let scored: Vec<(serde_json::Value, f64)> = elements
.iter()
.map(|e| {
let s = serde_json::to_string(e).unwrap_or_default();
let entropy = shannon_entropy(&s);
(e.clone(), entropy)
})
.collect();
let mut entropies: Vec<f64> = scored.iter().map(|(_, e)| *e).collect();
entropies.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let threshold = self.config.min_entropy_override.unwrap_or_else(|| median(&entropies));
let mut kept = Vec::new();
let mut dropped = 0usize;
for (elem, entropy) in &scored {
if *entropy >= threshold || kept.len() < self.config.min_kept_segments {
kept.push(elem.clone());
} else {
dropped += 1;
}
}
while kept.len() < self.config.min_kept_segments && dropped > 0 {
let mut remaining: Vec<&(serde_json::Value, f64)> = scored
.iter()
.filter(|(e, _)| !kept.contains(e))
.collect();
remaining.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
if let Some((elem, _)) = remaining.first() {
kept.push((*elem).clone());
dropped -= 1;
} else {
break;
}
}
if dropped > 0 {
kept.push(serde_json::Value::String(format!(
"[{dropped} low-information elements omitted]"
)));
}
Ok(EntropyTruncArrayResult {
kept,
dropped_count: dropped,
threshold,
})
}
}
impl Default for EntropyTruncator {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct EntropyTruncArrayResult {
pub kept: Vec<serde_json::Value>,
pub dropped_count: usize,
pub threshold: f64,
}
fn extract_identifiers(dropped: &str, kept: &str) -> Vec<String> {
const MAX_IDS: usize = 16;
const MAX_CHARS: usize = 256;
let mut out: Vec<String> = Vec::new();
let mut budget = 0usize;
let push = |candidate: &str, out: &mut Vec<String>, budget: &mut usize| {
if out.len() >= MAX_IDS || *budget + candidate.len() > MAX_CHARS {
return;
}
if kept.contains(candidate) || out.iter().any(|s| s == candidate) {
return;
}
*budget += candidate.len() + 2;
out.push(candidate.to_string());
};
for token in dropped.split(|c: char| c.is_whitespace() || "()[]{}<>\"'`,;".contains(c)) {
let token = token.trim_end_matches(|c: char| c == '.' || c == ':');
if token.len() < 5 || token.len() > 64 {
continue;
}
let is_hex_id = token.len() >= 7
&& token.chars().all(|c| c.is_ascii_hexdigit())
&& token.chars().any(|c| c.is_ascii_digit())
&& token.chars().any(|c| c.is_ascii_alphabetic());
let is_uuid = token.len() == 36
&& token
.chars()
.all(|c| c.is_ascii_hexdigit() || c == '-')
&& token.matches('-').count() == 4;
let is_ticket = token.contains('-')
&& token.split('-').count() == 2
&& token.split('-').next().is_some_and(|p| {
(2..=10).contains(&p.len()) && p.chars().all(|c| c.is_ascii_uppercase())
})
&& token.split('-').nth(1).is_some_and(|p| {
!p.is_empty() && p.chars().all(|c| c.is_ascii_digit())
});
let is_version = token.starts_with('v')
&& token[1..].split('.').count() >= 2
&& token[1..].split('.').all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_digit()));
if is_hex_id || is_uuid || is_ticket || is_version {
push(token, &mut out, &mut budget);
}
}
out
}
fn shannon_entropy(text: &str) -> f64 {
if text.is_empty() {
return 0.0;
}
let mut freq = [0u32; 256];
let len = text.len() as f64;
for &byte in text.as_bytes() {
freq[byte as usize] += 1;
}
let mut entropy = 0.0f64;
for &count in &freq {
if count > 0 {
let p = count as f64 / len;
entropy -= p * p.log2();
}
}
entropy
}
fn median(sorted: &[f64]) -> f64 {
if sorted.is_empty() {
return 0.0;
}
let mid = sorted.len() / 2;
if sorted.len() % 2 == 0 {
(sorted[mid - 1] + sorted[mid]) / 2.0
} else {
sorted[mid]
}
}
fn split_segments(text: &str) -> Vec<String> {
let segments: Vec<String> = text
.split("\n\n")
.map(|s| s.to_string())
.filter(|s| !s.trim().is_empty())
.collect();
if segments.len() > 1 {
return segments;
}
let lines: Vec<&str> = text.lines().collect();
if lines.len() <= 10 {
return vec![text.to_string()];
}
let chunk_size = 10;
lines
.chunks(chunk_size)
.map(|chunk| chunk.join("\n"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn factsheet_preserves_ids_from_dropped_segments() {
let truncator = EntropyTruncator::new();
let low_a = format!("{} deadbeef42 PROJ-1482", "aaaaaaaaaa ".repeat(8));
let low_b = format!(
"{} 550e8400-e29b-41d4-a716-446655440000 v1.4.0",
"bbbbbbbbbb ".repeat(8)
);
let high_a = "The quick brown fox jumps over the lazy dog by silver rivers today.";
let high_b = "Zephyrs quickly vex jumbled gnomes while quartz dwarfs mix pyjamas.";
let input = format!("{low_a}\n\n{high_a}\n\n{low_b}\n\n{high_b}");
let result = truncator.truncate_string(&input).unwrap();
assert!(result.segments_dropped > 0, "fixture must drop segments");
assert!(result.text.contains("[ids in omitted segments:"), "{}", result.text);
for id in ["deadbeef42", "PROJ-1482", "550e8400-e29b-41d4-a716-446655440000", "v1.4.0"] {
assert!(result.text.contains(id), "lost id {id}: {}", result.text);
}
}
#[test]
fn factsheet_skips_ids_already_kept() {
let dropped = "deadbeef42 and cafe1234567";
let kept = "deadbeef42 remains visible";
let ids = extract_identifiers(dropped, kept);
assert!(!ids.contains(&"deadbeef42".to_string()));
assert!(ids.contains(&"cafe1234567".to_string()));
}
#[test]
fn factsheet_is_budget_capped() {
let dropped: String = (0..50)
.map(|i| format!("PROJ-{}{:04}", 1000 + i, i))
.collect::<Vec<_>>()
.join(" ");
let ids = extract_identifiers(&dropped, "");
assert!(ids.len() <= 16, "cap at 16 facts, got {}", ids.len());
let total: usize = ids.iter().map(|s| s.len() + 2).sum();
assert!(total <= 256 + 16, "char budget exceeded: {total}");
}
#[test]
fn factsheet_ignores_plain_words_and_numbers() {
let ids = extract_identifiers("building the module quickly 123456789 finished", "");
assert!(ids.is_empty(), "plain words/ints are not ids: {ids:?}");
}
#[test]
fn test_shannon_entropy_empty() {
assert_eq!(shannon_entropy(""), 0.0);
}
#[test]
fn test_shannon_entropy_single_char() {
assert_eq!(shannon_entropy("aaaa"), 0.0);
}
#[test]
fn test_shannon_entropy_varied() {
let low = shannon_entropy("aaaa");
let high = shannon_entropy("abcdefghijklmnop");
assert!(high > low);
}
#[test]
fn test_median_empty() {
assert_eq!(median(&[]), 0.0);
}
#[test]
fn test_median_odd() {
assert_eq!(median(&[1.0, 2.0, 3.0]), 2.0);
}
#[test]
fn test_median_even() {
assert_eq!(median(&[1.0, 2.0, 3.0, 4.0]), 2.5);
}
#[test]
fn test_truncate_string_short_input() {
let trunc = EntropyTruncator::new();
let result = trunc.truncate_string("hello world").unwrap();
assert_eq!(result.text, "hello world");
assert_eq!(result.segments_dropped, 0);
}
#[test]
fn test_truncate_string_with_paragraphs() {
let trunc = EntropyTruncator::new();
let varied = "The quick brown fox jumps over the lazy dog with many different words and characters.";
let repetitive = "aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa aaa";
let text = format!("{varied}\n\n{repetitive}\n\n{varied}");
let result = trunc.truncate_string(&text).unwrap();
assert!(result.text.contains("quick brown fox"));
}
#[test]
fn test_truncate_array_short() {
let trunc = EntropyTruncator::new();
let elements = vec![
serde_json::json!(1),
serde_json::json!(2),
];
let result = trunc.truncate_array(&elements).unwrap();
assert_eq!(result.kept.len(), 2);
assert_eq!(result.dropped_count, 0);
}
#[test]
fn test_truncate_array_with_redundant_elements() {
let trunc = EntropyTruncator::new();
let mut elements = Vec::new();
elements.push(serde_json::json!({"name": "Alice Johnson", "role": "senior software engineer", "department": "platform infrastructure", "id": 12345}));
elements.push(serde_json::json!({"name": "Bob Williams", "role": "product designer", "department": "user experience research", "id": 67890}));
for _ in 0..8 {
elements.push(serde_json::json!({"x": 1}));
}
let result = trunc.truncate_array(&elements).unwrap();
assert!(result.kept.len() <= elements.len());
}
#[test]
fn test_split_segments_paragraphs() {
let text = "para 1\n\npara 2\n\npara 3";
let segments = split_segments(text);
assert_eq!(segments.len(), 3);
}
#[test]
fn test_split_segments_no_paragraphs() {
let lines: Vec<String> = (0..25).map(|i| format!("line {i}")).collect();
let text = lines.join("\n");
let segments = split_segments(&text);
assert!(segments.len() > 1);
}
#[test]
fn test_custom_config() {
let config = EntropyTruncConfig {
min_entropy_override: Some(3.0),
min_kept_segments: 1,
min_segment_length: 5,
};
let trunc = EntropyTruncator::with_config(config);
let result = trunc.truncate_string("hello\n\nworld").unwrap();
assert!(!result.text.is_empty());
}
use proptest::prelude::*;
proptest! {
#[test]
fn prop_truncation_non_empty(
text in "[a-z ]{20,200}"
) {
let trunc = EntropyTruncator::new();
let result = trunc.truncate_string(&text).unwrap();
prop_assert!(
!result.text.is_empty(),
"truncation should not produce empty output"
);
}
#[test]
fn prop_entropy_non_negative(
text in ".{1,100}"
) {
let e = shannon_entropy(&text);
prop_assert!(e >= 0.0, "entropy should be non-negative, got {e}");
}
#[test]
fn prop_segments_accounting(
paragraphs in proptest::collection::vec("[a-z ]{10,50}", 3..=8usize),
) {
let text = paragraphs.join("\n\n");
let trunc = EntropyTruncator::new();
let result = trunc.truncate_string(&text).unwrap();
let total = result.segments.len();
let kept = result.segments.iter().filter(|s| s.kept).count();
prop_assert_eq!(
result.segments_dropped, total - kept,
"segments_dropped should equal total - kept"
);
}
}
}