Skip to main content

lean_ctx/core/
entropy.rs

1use std::collections::{HashMap, HashSet};
2use std::hash::{Hash, Hasher};
3use std::io::Write;
4
5use flate2::write::GzEncoder;
6use flate2::Compression;
7
8use super::tokens::{count_tokens, encode_tokens};
9
10const BPE_ENTROPY_THRESHOLD: f64 = 1.0;
11
12/// Result of entropy-based compression: output text, token counts, and techniques used.
13#[derive(Debug)]
14pub struct EntropyResult {
15    pub output: String,
16    pub original_tokens: usize,
17    pub compressed_tokens: usize,
18    pub techniques: Vec<String>,
19}
20
21impl EntropyResult {
22    /// Returns the percentage of tokens saved by compression.
23    pub fn savings_percent(&self) -> f64 {
24        if self.original_tokens == 0 {
25            return 0.0;
26        }
27        let saved = self.original_tokens.saturating_sub(self.compressed_tokens);
28        (saved as f64 / self.original_tokens as f64) * 100.0
29    }
30}
31
32/// Computes Shannon entropy (bits) over character frequencies in the text.
33pub fn shannon_entropy(text: &str) -> f64 {
34    if text.is_empty() {
35        return 0.0;
36    }
37    let mut freq: HashMap<char, usize> = HashMap::new();
38    let total = text.chars().count();
39
40    for c in text.chars() {
41        *freq.entry(c).or_default() += 1;
42    }
43
44    freq.values().fold(0.0_f64, |acc, &count| {
45        let p = count as f64 / total as f64;
46        acc - p * p.log2()
47    })
48}
49
50/// Shannon entropy over already-encoded BPE token IDs (o200k_base).
51pub fn token_entropy_from_ids(tokens: &[u32]) -> f64 {
52    if tokens.is_empty() {
53        return 0.0;
54    }
55    let total = tokens.len();
56    let mut freq: HashMap<u32, usize> = HashMap::new();
57    for &t in tokens {
58        *freq.entry(t).or_default() += 1;
59    }
60    freq.values().fold(0.0_f64, |acc, &count| {
61        let p = count as f64 / total as f64;
62        acc - p * p.log2()
63    })
64}
65
66/// Shannon entropy over BPE token IDs (o200k_base).
67/// More LLM-relevant than character entropy since LLMs process BPE tokens.
68pub fn token_entropy(text: &str) -> f64 {
69    let tokens = encode_tokens(text);
70    token_entropy_from_ids(&tokens)
71}
72
73/// Normalized Shannon entropy over encoded token IDs: H(X) / log₂(n), n = unique token count.
74pub fn normalized_token_entropy_from_ids(tokens: &[u32]) -> f64 {
75    if tokens.is_empty() {
76        return 0.0;
77    }
78    let total = tokens.len();
79    let mut freq: HashMap<u32, usize> = HashMap::new();
80    for &t in tokens {
81        *freq.entry(t).or_default() += 1;
82    }
83    let n_unique = freq.len();
84    if n_unique <= 1 {
85        return 0.0;
86    }
87    let h = freq.values().fold(0.0_f64, |acc, &count| {
88        let p = count as f64 / total as f64;
89        acc - p * p.log2()
90    });
91    let h_max = (n_unique as f64).log2();
92    h / h_max
93}
94
95/// Normalized Shannon entropy: H(X) / log₂(n) where n = number of unique symbols.
96/// Returns a value in [0, 1] where 0 = perfectly predictable, 1 = maximum entropy.
97/// This makes thresholds comparable across different alphabet sizes.
98pub fn normalized_token_entropy(text: &str) -> f64 {
99    let tokens = encode_tokens(text);
100    normalized_token_entropy_from_ids(&tokens)
101}
102
103/// Computes word-set Jaccard similarity between two strings (0.0–1.0).
104pub fn jaccard_similarity(a: &str, b: &str) -> f64 {
105    let set_a: HashSet<&str> = a.split_whitespace().collect();
106    let set_b: HashSet<&str> = b.split_whitespace().collect();
107
108    let intersection = set_a.intersection(&set_b).count();
109    let union = set_a.union(&set_b).count();
110
111    if union == 0 {
112        return 0.0;
113    }
114    intersection as f64 / union as f64
115}
116
117/// N-gram Jaccard similarity — preserves word order (unlike word-set Jaccard).
118pub fn ngram_jaccard(a: &str, b: &str, n: usize) -> f64 {
119    let set_a = ngram_set(a, n);
120    let set_b = ngram_set(b, n);
121
122    let intersection = set_a.intersection(&set_b).count();
123    let union = set_a.union(&set_b).count();
124
125    if union == 0 {
126        return 0.0;
127    }
128    intersection as f64 / union as f64
129}
130
131fn ngram_set(text: &str, n: usize) -> HashSet<Vec<String>> {
132    let words: Vec<&str> = text.split_whitespace().collect();
133    if words.len() < n {
134        let mut set = HashSet::new();
135        if !words.is_empty() {
136            set.insert(words.iter().map(std::string::ToString::to_string).collect());
137        }
138        return set;
139    }
140    words
141        .windows(n)
142        .map(|w| w.iter().map(std::string::ToString::to_string).collect())
143        .collect()
144}
145
146/// Minhash signature for approximate Jaccard via LSH.
147/// Uses k independent hash functions (polynomial hashing with different seeds).
148pub fn minhash_signature(text: &str, n: usize, k: usize) -> Vec<u64> {
149    let ngrams = ngram_set(text, n);
150    if ngrams.is_empty() {
151        return vec![u64::MAX; k];
152    }
153    let mut signature = vec![u64::MAX; k];
154    for ngram in &ngrams {
155        for (i, min) in signature.iter_mut().enumerate() {
156            let h = hash_with_seed(ngram, i as u64);
157            if h < *min {
158                *min = h;
159            }
160        }
161    }
162    signature
163}
164
165/// Approximate Jaccard from two minhash signatures.
166pub fn minhash_similarity(sig_a: &[u64], sig_b: &[u64]) -> f64 {
167    if sig_a.len() != sig_b.len() || sig_a.is_empty() {
168        return 0.0;
169    }
170    let matches = sig_a
171        .iter()
172        .zip(sig_b.iter())
173        .filter(|(a, b)| a == b)
174        .count();
175    matches as f64 / sig_a.len() as f64
176}
177
178fn hash_with_seed<T: Hash>(value: &T, seed: u64) -> u64 {
179    let mut hasher = std::collections::hash_map::DefaultHasher::new();
180    seed.hash(&mut hasher);
181    value.hash(&mut hasher);
182    hasher.finish()
183}
184
185/// Kolmogorov complexity proxy: K(x) ≈ len(gzip(x)) / len(x).
186/// Lower values = more compressible = more redundant.
187pub fn kolmogorov_proxy(content: &str) -> f64 {
188    if content.is_empty() {
189        return 1.0;
190    }
191    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
192    encoder.write_all(content.as_bytes()).ok();
193    let compressed = encoder.finish().unwrap_or_default();
194    compressed.len() as f64 / content.len() as f64
195}
196
197/// Classification of content compressibility based on Kolmogorov proxy (gzip ratio).
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub enum CompressibilityClass {
200    High,
201    Medium,
202    Low,
203}
204
205impl CompressibilityClass {
206    /// Returns a human-readable label with the Kolmogorov threshold range.
207    pub fn label(&self) -> &'static str {
208        match self {
209            Self::High => "high (K<0.3)",
210            Self::Medium => "medium (0.3≤K<0.6)",
211            Self::Low => "low (K≥0.6)",
212        }
213    }
214}
215
216/// Classify how compressible content is based on gzip ratio.
217pub fn compressibility_class(content: &str) -> CompressibilityClass {
218    let k = kolmogorov_proxy(content);
219    if k < 0.3 {
220        CompressibilityClass::High
221    } else if k < 0.6 {
222        CompressibilityClass::Medium
223    } else {
224        CompressibilityClass::Low
225    }
226}
227
228/// Compresses content by removing low-entropy lines and deduplicating patterns.
229pub fn entropy_compress(content: &str) -> EntropyResult {
230    entropy_compress_with_thresholds(content, BPE_ENTROPY_THRESHOLD, 0.7)
231}
232
233/// Entropy compression with the opportunistic semantic redundancy filter
234/// (#544) pinned OFF. The regular path uses the shared embedding engine
235/// whenever it happens to be loaded, so its output depends on runtime state.
236/// Benchmarks and the scorecard (#211) need run-to-run and machine-to-machine
237/// reproducibility, so they must go through this entry point.
238pub fn entropy_compress_deterministic(content: &str) -> EntropyResult {
239    entropy_compress_inner(content, BPE_ENTROPY_THRESHOLD, 0.7, &[], false)
240}
241
242/// Entropy compression with file-type-adaptive thresholds and event emission.
243pub fn entropy_compress_adaptive(content: &str, path: &str) -> EntropyResult {
244    let thresholds = super::adaptive_thresholds::adaptive_thresholds(path, content);
245    let before_lines = content.lines().count() as u32;
246    let result =
247        entropy_compress_with_thresholds(content, thresholds.bpe_entropy, thresholds.jaccard);
248    let after_lines = result.output.lines().count() as u32;
249
250    if before_lines != after_lines {
251        super::events::emit(super::events::EventKind::Compression {
252            path: path.to_string(),
253            before_lines,
254            after_lines,
255            strategy: "entropy_adaptive".to_string(),
256            kept_line_count: after_lines,
257            removed_line_count: before_lines.saturating_sub(after_lines),
258        });
259    }
260
261    result
262}
263
264/// Task-conditioned entropy compression: lines that would normally be dropped
265/// for low entropy are kept if they contain task-relevant keywords.  This is
266/// the Information Bottleneck proxy: we compress away only what is neither
267/// surprising (high H) *nor* task-relevant (mentions goal concepts).
268/// Falls back to pure entropy when `task_keywords` is empty.
269pub fn entropy_compress_task_conditioned(
270    content: &str,
271    path: &str,
272    task_keywords: &[String],
273) -> EntropyResult {
274    let thresholds = super::adaptive_thresholds::adaptive_thresholds(path, content);
275    let before_lines = content.lines().count() as u32;
276    let result = entropy_compress_with_task(
277        content,
278        thresholds.bpe_entropy,
279        thresholds.jaccard,
280        task_keywords,
281    );
282    let after_lines = result.output.lines().count() as u32;
283    if before_lines != after_lines {
284        super::events::emit(super::events::EventKind::Compression {
285            path: path.to_string(),
286            before_lines,
287            after_lines,
288            strategy: "entropy_task_conditioned".to_string(),
289            kept_line_count: after_lines,
290            removed_line_count: before_lines.saturating_sub(after_lines),
291        });
292    }
293    result
294}
295
296/// Real line embedder for the semantic redundancy filter (#544): uses the
297/// shared neural engine only when it is ALREADY loaded (never blocks a read
298/// on a model load) and only for files small enough that per-line embedding
299/// stays within the read-path latency budget. Embeddings are cached by line
300/// hash so re-reads cost nothing.
301#[cfg(feature = "embeddings")]
302fn line_embedder(line_count: usize) -> impl Fn(&str) -> Option<Vec<f32>> {
303    use std::collections::HashMap;
304    use std::sync::Mutex;
305
306    const MAX_LINES_FOR_SEMANTIC: usize = 400;
307    const CACHE_CAP: usize = 8192;
308    static LINE_EMBED_CACHE: Mutex<Option<HashMap<u64, Vec<f32>>>> = Mutex::new(None);
309
310    let engine = if line_count <= MAX_LINES_FOR_SEMANTIC {
311        crate::tools::ctx_knowledge::embeddings::embedding_engine_nonblocking()
312    } else {
313        None
314    };
315
316    move |line: &str| {
317        let engine = engine?;
318        if line.len() < 8 {
319            return None;
320        }
321        let mut hasher = std::collections::hash_map::DefaultHasher::new();
322        std::hash::Hash::hash(&line, &mut hasher);
323        let key = std::hash::Hasher::finish(&hasher);
324
325        if let Ok(mut guard) = LINE_EMBED_CACHE.lock() {
326            if let Some(hit) = guard.get_or_insert_with(HashMap::new).get(&key) {
327                return Some(hit.clone());
328            }
329        }
330        let emb = engine.embed(line).ok()?;
331        if let Ok(mut guard) = LINE_EMBED_CACHE.lock() {
332            let map = guard.get_or_insert_with(HashMap::new);
333            if map.len() >= CACHE_CAP {
334                map.clear();
335            }
336            map.insert(key, emb.clone());
337        }
338        Some(emb)
339    }
340}
341
342#[cfg(not(feature = "embeddings"))]
343fn line_embedder(_line_count: usize) -> impl Fn(&str) -> Option<Vec<f32>> {
344    |_: &str| None
345}
346
347fn entropy_compress_with_task(
348    content: &str,
349    entropy_threshold: f64,
350    jaccard_threshold: f64,
351    task_keywords: &[String],
352) -> EntropyResult {
353    entropy_compress_inner(
354        content,
355        entropy_threshold,
356        jaccard_threshold,
357        task_keywords,
358        true,
359    )
360}
361
362fn entropy_compress_inner(
363    content: &str,
364    entropy_threshold: f64,
365    jaccard_threshold: f64,
366    task_keywords: &[String],
367    semantic: bool,
368) -> EntropyResult {
369    let original_tokens = count_tokens(content);
370    let mut lines: Vec<&str> = content.lines().collect();
371    let mut techniques = Vec::new();
372
373    let kw_lower: Vec<String> = task_keywords.iter().map(|k| k.to_lowercase()).collect();
374    let original_count = lines.len();
375    let mut task_rescued = 0usize;
376    // Semantic redundancy (#544): when the embedding engine is already
377    // loaded, kept lines that embed near-identically to earlier kept lines
378    // are dropped (MMR). Without the engine the closure returns None and the
379    // decision path is byte-identical to the Zipf-only filter.
380    // `semantic=false` (deterministic contract) requests an embedder above
381    // the size cutoff, which never resolves an engine.
382    let embed = line_embedder(if semantic { original_count } else { usize::MAX });
383    let mut scoring_ctx = super::surprise::ScoringCtx::new();
384    lines.retain(|line| {
385        let trimmed = line.trim();
386        if super::surprise::should_keep_line_semantic(
387            trimmed,
388            entropy_threshold,
389            &embed,
390            &mut scoring_ctx,
391        ) {
392            return true;
393        }
394        // Task-conditioned rescue: keep low-entropy lines that mention task keywords.
395        if !kw_lower.is_empty() {
396            let lower = trimmed.to_lowercase();
397            if kw_lower.iter().any(|kw| lower.contains(kw.as_str())) {
398                task_rescued += 1;
399                return true;
400            }
401        }
402        false
403    });
404    let removed = original_count - lines.len();
405    if removed > 0 || task_rescued > 0 {
406        let mut msg = format!("⊘ {removed} low-entropy lines (BPE H<{entropy_threshold:.2})");
407        if task_rescued > 0 {
408            msg.push_str(&format!(" [+{task_rescued} task-rescued]"));
409        }
410        techniques.push(msg);
411    }
412
413    let blocks = extract_blocks(&lines);
414    let groups = find_pattern_groups(&blocks, jaccard_threshold);
415    let mut dedup_count = 0;
416    for group in &groups {
417        if group.len() > 1 {
418            dedup_count += group.len() - 1;
419        }
420    }
421    if dedup_count > 0 {
422        techniques.push(format!("⊘ {dedup_count} duplicate patterns (J≥0.7)"));
423    }
424
425    let mut result: Vec<String> = Vec::new();
426    let mut skip_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
427    for group in &groups {
428        if group.len() > 1 {
429            for &idx in &group[1..] {
430                skip_indices.insert(idx);
431            }
432        }
433    }
434    for (i, line) in lines.iter().enumerate() {
435        if !skip_indices.contains(&i) {
436            result.push(line.to_string());
437        }
438    }
439
440    let mut collapsed = Vec::new();
441    let mut blank_count = 0;
442    for line in &result {
443        if line.trim().is_empty() {
444            blank_count += 1;
445            if blank_count <= 1 {
446                collapsed.push(line.clone());
447            }
448        } else {
449            blank_count = 0;
450            collapsed.push(line.clone());
451        }
452    }
453    let output = collapsed.join("\n");
454    let compressed_tokens = count_tokens(&output);
455
456    // Safeguard: BPE re-tokenization of a line subset can, for tiny adversarial
457    // inputs, exceed the original token count (e.g. a dropped trailing newline
458    // merges differently). Never inflate — fall back to the original verbatim.
459    let final_output = if compressed_tokens > original_tokens {
460        content.to_string()
461    } else {
462        output
463    };
464    let final_tokens = if compressed_tokens > original_tokens {
465        original_tokens
466    } else {
467        compressed_tokens
468    };
469
470    EntropyResult {
471        output: final_output,
472        original_tokens,
473        compressed_tokens: final_tokens,
474        techniques,
475    }
476}
477
478fn entropy_compress_with_thresholds(
479    content: &str,
480    entropy_threshold: f64,
481    jaccard_threshold: f64,
482) -> EntropyResult {
483    entropy_compress_with_task(content, entropy_threshold, jaccard_threshold, &[])
484}
485
486/// Budget-based compression to a target density (SDE principle: aim for a
487/// *target* information density instead of maximum compression).
488///
489/// Keeps the highest-entropy lines — in original order — until the BPE token
490/// budget `original_tokens * target` is exhausted. Deterministic: ties break
491/// on line index. `target` is clamped to [0.05, 1.0]; the top-scored line is
492/// always kept so output is never empty for non-empty input.
493pub fn entropy_compress_to_density(content: &str, target: f64) -> EntropyResult {
494    let target = target.clamp(0.05, 1.0);
495    let original_tokens = count_tokens(content);
496    if content.is_empty() || original_tokens == 0 {
497        return EntropyResult {
498            output: String::new(),
499            original_tokens,
500            compressed_tokens: 0,
501            techniques: vec![format!("density target={target:.2} (empty input)")],
502        };
503    }
504
505    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
506    let budget = ((original_tokens as f64) * target).ceil() as usize;
507
508    let lines: Vec<&str> = content.lines().collect();
509    let mut scored: Vec<(usize, f64, usize)> = lines
510        .iter()
511        .enumerate()
512        .map(|(i, l)| {
513            let trimmed = l.trim();
514            // +1 approximates the newline token; keeps the per-line sum close
515            // to the whole-file count so the budget is meaningful.
516            let toks = count_tokens(trimmed).max(1);
517            (i, token_entropy(trimmed), toks)
518        })
519        .collect();
520    scored.sort_by(|a, b| {
521        b.1.partial_cmp(&a.1)
522            .unwrap_or(std::cmp::Ordering::Equal)
523            .then(a.0.cmp(&b.0))
524    });
525
526    let mut keep = vec![false; lines.len()];
527    let mut used = 0usize;
528    let mut kept_count = 0usize;
529    // The single most informative line is always kept, even if it alone
530    // exceeds the budget — a density target that drops the highest-signal
531    // line would be self-defeating. Everything else fills greedily.
532    if let Some(&(idx, _, toks)) = scored.first() {
533        keep[idx] = true;
534        used += toks;
535        kept_count += 1;
536    }
537    for &(idx, _h, toks) in scored.iter().skip(1) {
538        if used + toks > budget {
539            // Greedy knapsack: skip lines that overshoot, smaller ones may fit.
540            continue;
541        }
542        keep[idx] = true;
543        used += toks;
544        kept_count += 1;
545    }
546
547    let mut out_lines: Vec<&str> = Vec::with_capacity(kept_count);
548    for (i, line) in lines.iter().enumerate() {
549        if keep[i] {
550            out_lines.push(line);
551        }
552    }
553    let output = out_lines.join("\n");
554    let compressed_tokens = count_tokens(&output);
555
556    let dropped = lines.len() - kept_count;
557    EntropyResult {
558        output,
559        original_tokens,
560        compressed_tokens,
561        techniques: vec![format!(
562            "density target={target:.2} budget={budget} tok, kept {kept_count}/{} lines (⊘ {dropped})",
563            lines.len()
564        )],
565    }
566}
567
568/// Per-line entropy statistics for a block of content.
569#[derive(Debug)]
570pub struct EntropyAnalysis {
571    pub avg_entropy: f64,
572    pub low_entropy_count: usize,
573    pub high_entropy_count: usize,
574    pub total_lines: usize,
575}
576
577/// Analyzes per-line BPE token entropy, counting low/high entropy lines.
578pub fn analyze_entropy(content: &str) -> EntropyAnalysis {
579    let lines: Vec<&str> = content.lines().collect();
580    let total = lines.len();
581    let mut sum = 0.0;
582    let mut low = 0;
583    let mut high = 0;
584    let mut counted = 0;
585
586    for line in &lines {
587        let trimmed = line.trim();
588        if trimmed.is_empty() {
589            continue;
590        }
591        let h = token_entropy(trimmed);
592        sum += h;
593        counted += 1;
594        if h < BPE_ENTROPY_THRESHOLD {
595            low += 1;
596        }
597        if h > 3.0 {
598            high += 1;
599        }
600    }
601
602    EntropyAnalysis {
603        avg_entropy: if counted > 0 {
604            sum / counted as f64
605        } else {
606            0.0
607        },
608        low_entropy_count: low,
609        high_entropy_count: high,
610        total_lines: total,
611    }
612}
613
614struct Block {
615    content: String,
616}
617
618fn extract_blocks(lines: &[&str]) -> Vec<Block> {
619    let mut blocks = Vec::new();
620    let mut current = String::new();
621
622    for line in lines {
623        let trimmed = line.trim();
624        if trimmed.is_empty() && !current.is_empty() {
625            blocks.push(Block {
626                content: current.clone(),
627            });
628            current.clear();
629        } else if !trimmed.is_empty() {
630            current.push_str(trimmed);
631            current.push('\n');
632        }
633    }
634
635    if !current.is_empty() {
636        blocks.push(Block { content: current });
637    }
638
639    blocks
640}
641
642fn find_pattern_groups(blocks: &[Block], threshold: f64) -> Vec<Vec<usize>> {
643    // Exact n-gram Jaccard, but with precomputed n-gram sets per block to avoid
644    // rebuilding allocations per pair. Includes a size-ratio impossibility check
645    // (max possible Jaccard is |A|/|B| for |A|<=|B|).
646    let sets: Vec<HashSet<Vec<String>>> = blocks.iter().map(|b| ngram_set(&b.content, 2)).collect();
647    let sizes: Vec<usize> = sets.iter().map(std::collections::HashSet::len).collect();
648
649    let mut groups: Vec<Vec<usize>> = Vec::new();
650    let mut assigned: HashSet<usize> = HashSet::new();
651
652    for i in 0..blocks.len() {
653        if assigned.contains(&i) {
654            continue;
655        }
656        let mut group = vec![i];
657        for j in (i + 1)..blocks.len() {
658            if assigned.contains(&j) {
659                continue;
660            }
661            let size_i = sizes[i];
662            let size_j = sizes[j];
663            let min_sz = size_i.min(size_j);
664            let max_sz = size_i.max(size_j);
665            if max_sz > 0 && (min_sz as f64) < (threshold * max_sz as f64) {
666                continue;
667            }
668            let inter = sets[i].intersection(&sets[j]).count();
669            let union = size_i + size_j - inter;
670            if union > 0 && (inter as f64 / union as f64) >= threshold {
671                group.push(j);
672                assigned.insert(j);
673            }
674        }
675        if group.len() > 1 {
676            assigned.insert(i);
677        }
678        groups.push(group);
679    }
680
681    groups
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687
688    #[test]
689    fn shannon_entropy_empty_is_zero() {
690        assert_eq!(shannon_entropy(""), 0.0);
691    }
692
693    #[test]
694    fn shannon_entropy_single_char() {
695        assert_eq!(shannon_entropy("aaaa"), 0.0);
696    }
697
698    #[test]
699    fn shannon_entropy_high_for_varied_text() {
700        let varied = "abcdefghijklmnopqrstuvwxyz0123456789";
701        let uniform = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
702        assert!(
703            shannon_entropy(varied) > shannon_entropy(uniform),
704            "varied text should have higher entropy"
705        );
706    }
707
708    #[test]
709    fn jaccard_identical_is_one() {
710        let sim = jaccard_similarity("hello world", "hello world");
711        assert!((sim - 1.0).abs() < f64::EPSILON);
712    }
713
714    #[test]
715    fn jaccard_disjoint_is_zero() {
716        let sim = jaccard_similarity("abc", "xyz");
717        assert_eq!(sim, 0.0);
718    }
719
720    #[test]
721    fn jaccard_partial_overlap() {
722        let sim = jaccard_similarity("hello world", "hello rust");
723        assert!(sim > 0.0 && sim < 1.0);
724    }
725
726    #[test]
727    fn entropy_compress_produces_output() {
728        let content = "fn main() {\n    println!(\"hello\");\n}\n\n// comment\n// another comment\n\nfn helper() {\n    let x = 42;\n}\n";
729        let result = entropy_compress(content);
730        assert!(!result.output.is_empty(), "should produce non-empty output");
731        assert!(result.compressed_tokens <= result.original_tokens);
732    }
733
734    #[test]
735    fn entropy_result_savings() {
736        let r = EntropyResult {
737            output: "short".to_string(),
738            original_tokens: 100,
739            compressed_tokens: 60,
740            techniques: vec!["test".to_string()],
741        };
742        assert!((r.savings_percent() - 40.0).abs() < 0.1);
743    }
744
745    #[test]
746    fn entropy_result_zero_original() {
747        let r = EntropyResult {
748            output: String::new(),
749            original_tokens: 0,
750            compressed_tokens: 0,
751            techniques: vec![],
752        };
753        assert_eq!(r.savings_percent(), 0.0);
754    }
755
756    #[test]
757    fn token_entropy_empty_is_zero() {
758        assert_eq!(token_entropy(""), 0.0);
759    }
760
761    #[test]
762    fn token_entropy_single_repeated_token() {
763        assert_eq!(token_entropy("}"), 0.0);
764    }
765
766    #[test]
767    fn token_entropy_higher_for_diverse_code() {
768        let diverse = "let result = compute_something(x, y, z);";
769        let repetitive = "aaaa aaaa aaaa aaaa";
770        assert!(
771            token_entropy(diverse) > token_entropy(repetitive),
772            "diverse code should have higher BPE token entropy"
773        );
774    }
775
776    #[test]
777    fn token_entropy_vs_char_entropy_differ() {
778        let code = "fn main() { println!(\"hello world\"); }";
779        let te = token_entropy(code);
780        let ce = shannon_entropy(code);
781        assert!(te != ce, "BPE and char entropy should differ for code");
782    }
783
784    #[test]
785    fn ngram_jaccard_preserves_order() {
786        let a = "a b c d";
787        let b = "d c b a";
788        let word_j = jaccard_similarity(a, b);
789        let ngram_j = ngram_jaccard(a, b, 2);
790        assert!(
791            ngram_j < word_j,
792            "reordered text should have lower bigram Jaccard ({ngram_j}) than word Jaccard ({word_j})"
793        );
794    }
795
796    #[test]
797    fn ngram_jaccard_identical_is_one() {
798        let text = "fn main() { println!(\"hello\"); }";
799        let j = ngram_jaccard(text, text, 2);
800        assert!((j - 1.0).abs() < f64::EPSILON);
801    }
802
803    #[test]
804    fn ngram_jaccard_disjoint_is_zero() {
805        let j = ngram_jaccard("alpha beta gamma", "delta epsilon zeta", 2);
806        assert_eq!(j, 0.0);
807    }
808
809    #[test]
810    fn minhash_approximates_jaccard() {
811        let a = "fn main() { let x = 1; let y = 2; let z = x + y; println!(z); }";
812        let b = "fn main() { let x = 1; let y = 2; let z = x + y; return z; }";
813        let exact = ngram_jaccard(a, b, 2);
814        let sig_a = minhash_signature(a, 2, 128);
815        let sig_b = minhash_signature(b, 2, 128);
816        let approx = minhash_similarity(&sig_a, &sig_b);
817        assert!(
818            (exact - approx).abs() < 0.2,
819            "minhash ({approx}) should approximate exact ({exact}) within 0.2"
820        );
821    }
822
823    #[test]
824    fn minhash_empty_text() {
825        let sig = minhash_signature("", 2, 64);
826        assert!(sig.iter().all(|&v| v == u64::MAX));
827    }
828
829    #[test]
830    fn kolmogorov_empty_is_one() {
831        assert_eq!(kolmogorov_proxy(""), 1.0);
832    }
833
834    #[test]
835    fn kolmogorov_repetitive_is_low() {
836        let repetitive = "aaa\n".repeat(1000);
837        let k = kolmogorov_proxy(&repetitive);
838        assert!(
839            k < 0.1,
840            "highly repetitive text should compress well: K={k}"
841        );
842    }
843
844    #[test]
845    fn kolmogorov_diverse_is_higher() {
846        let repetitive = "aaa\n".repeat(500);
847        let diverse = (0..500)
848            .map(|i| format!("line_{i}_unique_content_{}", i * 17 % 97))
849            .collect::<Vec<_>>()
850            .join("\n");
851        assert!(
852            kolmogorov_proxy(&diverse) > kolmogorov_proxy(&repetitive),
853            "diverse content should have higher K than repetitive"
854        );
855    }
856
857    #[test]
858    fn compressibility_class_repetitive_is_high() {
859        let text = "use std::io;\n".repeat(200);
860        assert_eq!(compressibility_class(&text), CompressibilityClass::High);
861    }
862
863    #[test]
864    fn kolmogorov_diverse_higher_than_repetitive() {
865        let rep = "test\n".repeat(500);
866        let diverse = (0..500)
867            .map(|i| format!("unique_line_{i}_xk{}", i * 31 % 1000))
868            .collect::<Vec<_>>()
869            .join("\n");
870        assert!(
871            kolmogorov_proxy(&diverse) > kolmogorov_proxy(&rep),
872            "diverse content should have higher K"
873        );
874    }
875
876    fn density_fixture() -> String {
877        (0..120)
878            .map(|i| {
879                if i % 3 == 0 {
880                    format!(
881                        "fn compute_value_{i}(input: &str, flags: u32) -> Result<Output, Error> {{"
882                    )
883                } else if i % 3 == 1 {
884                    format!("    let intermediate_{i} = transform(input, flags ^ {i});")
885                } else {
886                    "}".to_string()
887                }
888            })
889            .collect::<Vec<_>>()
890            .join("\n")
891    }
892
893    #[test]
894    fn density_respects_token_budget() {
895        let content = density_fixture();
896        let orig = count_tokens(&content);
897        for target in [0.3, 0.5, 0.7] {
898            let r = entropy_compress_to_density(&content, target);
899            let actual = r.compressed_tokens as f64 / orig as f64;
900            assert!(
901                actual <= target + 0.10,
902                "target {target}: actual density {actual:.2} exceeds budget"
903            );
904            assert!(!r.output.is_empty());
905        }
906    }
907
908    #[test]
909    fn density_is_deterministic() {
910        let content = density_fixture();
911        let a = entropy_compress_to_density(&content, 0.4);
912        let b = entropy_compress_to_density(&content, 0.4);
913        assert_eq!(a.output, b.output);
914        assert_eq!(a.compressed_tokens, b.compressed_tokens);
915    }
916
917    #[test]
918    fn density_target_one_keeps_everything() {
919        let content = density_fixture();
920        let r = entropy_compress_to_density(&content, 1.0);
921        assert_eq!(r.output, content);
922    }
923
924    #[test]
925    fn density_clamps_out_of_range_target() {
926        let content = density_fixture();
927        let low = entropy_compress_to_density(&content, 0.0);
928        assert!(!low.output.is_empty(), "clamped to 0.05, never empty");
929        let high = entropy_compress_to_density(&content, 5.0);
930        assert_eq!(high.output, content, "clamped to 1.0 keeps all");
931    }
932
933    #[test]
934    fn density_empty_input() {
935        let r = entropy_compress_to_density("", 0.5);
936        assert_eq!(r.compressed_tokens, 0);
937        assert!(r.output.is_empty());
938    }
939
940    #[test]
941    fn density_prefers_high_entropy_lines() {
942        let content =
943            "}\n}\n}\nlet complex_result = compute_unique_hash(seed, nonce, payload);\n}\n}";
944        let r = entropy_compress_to_density(content, 0.6);
945        assert!(
946            r.output.contains("compute_unique_hash"),
947            "high-entropy line must survive: {}",
948            r.output
949        );
950    }
951}