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::Compression;
6use flate2::write::GzEncoder;
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    // Sum in a canonical (sorted-by-count) order: f64 addition is not
61    // associative, so folding over HashMap iteration order — random per instance
62    // — would make the entropy, and thus `density`/`entropy` mode output,
63    // non-deterministic across processes and defeat prompt caching (#498).
64    let mut counts: Vec<usize> = freq.into_values().collect();
65    counts.sort_unstable();
66    counts.iter().fold(0.0_f64, |acc, &count| {
67        let p = count as f64 / total as f64;
68        acc - p * p.log2()
69    })
70}
71
72/// Shannon entropy over BPE token IDs (o200k_base).
73/// More LLM-relevant than character entropy since LLMs process BPE tokens.
74pub fn token_entropy(text: &str) -> f64 {
75    let tokens = encode_tokens(text);
76    token_entropy_from_ids(&tokens)
77}
78
79/// Normalized Shannon entropy over encoded token IDs: H(X) / log₂(n), n = unique token count.
80pub fn normalized_token_entropy_from_ids(tokens: &[u32]) -> f64 {
81    if tokens.is_empty() {
82        return 0.0;
83    }
84    let total = tokens.len();
85    let mut freq: HashMap<u32, usize> = HashMap::new();
86    for &t in tokens {
87        *freq.entry(t).or_default() += 1;
88    }
89    let n_unique = freq.len();
90    if n_unique <= 1 {
91        return 0.0;
92    }
93    // Canonical summation order for determinism (#498); see `token_entropy_from_ids`.
94    let mut counts: Vec<usize> = freq.into_values().collect();
95    counts.sort_unstable();
96    let h = counts.iter().fold(0.0_f64, |acc, &count| {
97        let p = count as f64 / total as f64;
98        acc - p * p.log2()
99    });
100    let h_max = (n_unique as f64).log2();
101    h / h_max
102}
103
104/// Normalized Shannon entropy: H(X) / log₂(n) where n = number of unique symbols.
105/// Returns a value in [0, 1] where 0 = perfectly predictable, 1 = maximum entropy.
106/// This makes thresholds comparable across different alphabet sizes.
107pub fn normalized_token_entropy(text: &str) -> f64 {
108    let tokens = encode_tokens(text);
109    normalized_token_entropy_from_ids(&tokens)
110}
111
112/// Computes word-set Jaccard similarity between two strings (0.0–1.0).
113pub fn jaccard_similarity(a: &str, b: &str) -> f64 {
114    let set_a: HashSet<&str> = a.split_whitespace().collect();
115    let set_b: HashSet<&str> = b.split_whitespace().collect();
116
117    let intersection = set_a.intersection(&set_b).count();
118    let union = set_a.union(&set_b).count();
119
120    if union == 0 {
121        return 0.0;
122    }
123    intersection as f64 / union as f64
124}
125
126/// N-gram Jaccard similarity — preserves word order (unlike word-set Jaccard).
127pub fn ngram_jaccard(a: &str, b: &str, n: usize) -> f64 {
128    let set_a = ngram_set(a, n);
129    let set_b = ngram_set(b, n);
130
131    let intersection = set_a.intersection(&set_b).count();
132    let union = set_a.union(&set_b).count();
133
134    if union == 0 {
135        return 0.0;
136    }
137    intersection as f64 / union as f64
138}
139
140fn ngram_set(text: &str, n: usize) -> HashSet<Vec<String>> {
141    let words: Vec<&str> = text.split_whitespace().collect();
142    if words.len() < n {
143        let mut set = HashSet::new();
144        if !words.is_empty() {
145            set.insert(words.iter().map(std::string::ToString::to_string).collect());
146        }
147        return set;
148    }
149    words
150        .windows(n)
151        .map(|w| w.iter().map(std::string::ToString::to_string).collect())
152        .collect()
153}
154
155/// Minhash signature for approximate Jaccard via LSH.
156/// Uses k independent hash functions (polynomial hashing with different seeds).
157pub fn minhash_signature(text: &str, n: usize, k: usize) -> Vec<u64> {
158    let ngrams = ngram_set(text, n);
159    if ngrams.is_empty() {
160        return vec![u64::MAX; k];
161    }
162    let mut signature = vec![u64::MAX; k];
163    for ngram in &ngrams {
164        for (i, min) in signature.iter_mut().enumerate() {
165            let h = hash_with_seed(ngram, i as u64);
166            if h < *min {
167                *min = h;
168            }
169        }
170    }
171    signature
172}
173
174/// Approximate Jaccard from two minhash signatures.
175pub fn minhash_similarity(sig_a: &[u64], sig_b: &[u64]) -> f64 {
176    if sig_a.len() != sig_b.len() || sig_a.is_empty() {
177        return 0.0;
178    }
179    let matches = sig_a
180        .iter()
181        .zip(sig_b.iter())
182        .filter(|(a, b)| a == b)
183        .count();
184    matches as f64 / sig_a.len() as f64
185}
186
187fn hash_with_seed<T: Hash>(value: &T, seed: u64) -> u64 {
188    let mut hasher = std::collections::hash_map::DefaultHasher::new();
189    seed.hash(&mut hasher);
190    value.hash(&mut hasher);
191    hasher.finish()
192}
193
194/// Kolmogorov complexity proxy: K(x) ≈ len(gzip(x)) / len(x).
195/// Lower values = more compressible = more redundant.
196pub fn kolmogorov_proxy(content: &str) -> f64 {
197    if content.is_empty() {
198        return 1.0;
199    }
200    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
201    encoder.write_all(content.as_bytes()).ok();
202    let compressed = encoder.finish().unwrap_or_default();
203    compressed.len() as f64 / content.len() as f64
204}
205
206/// Classification of content compressibility based on Kolmogorov proxy (gzip ratio).
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum CompressibilityClass {
209    High,
210    Medium,
211    Low,
212}
213
214impl CompressibilityClass {
215    /// Returns a human-readable label with the Kolmogorov threshold range.
216    pub fn label(&self) -> &'static str {
217        match self {
218            Self::High => "high (K<0.3)",
219            Self::Medium => "medium (0.3≤K<0.6)",
220            Self::Low => "low (K≥0.6)",
221        }
222    }
223}
224
225/// Classify how compressible content is based on gzip ratio.
226pub fn compressibility_class(content: &str) -> CompressibilityClass {
227    let k = kolmogorov_proxy(content);
228    if k < 0.3 {
229        CompressibilityClass::High
230    } else if k < 0.6 {
231        CompressibilityClass::Medium
232    } else {
233        CompressibilityClass::Low
234    }
235}
236
237/// Compresses content by removing low-entropy lines and deduplicating patterns.
238pub fn entropy_compress(content: &str) -> EntropyResult {
239    entropy_compress_with_thresholds(content, BPE_ENTROPY_THRESHOLD, 0.7, &[])
240}
241
242/// Entropy compression with the opportunistic semantic redundancy filter
243/// (#544) pinned OFF. The regular path uses the shared embedding engine
244/// whenever it happens to be loaded, so its output depends on runtime state.
245/// Benchmarks and the scorecard (#211) need run-to-run and machine-to-machine
246/// reproducibility, so they must go through this entry point.
247pub fn entropy_compress_deterministic(content: &str) -> EntropyResult {
248    entropy_compress_inner(content, BPE_ENTROPY_THRESHOLD, 0.7, &[], false, &[])
249}
250
251/// Entropy compression with file-type-adaptive thresholds and event emission.
252/// `force_keep` lines (explicit `protect` tokens, #709) survive verbatim; pass
253/// `&[]` to keep the pre-protect behaviour byte-identical (#498).
254pub fn entropy_compress_adaptive(
255    content: &str,
256    path: &str,
257    force_keep: &[String],
258) -> EntropyResult {
259    let thresholds = super::adaptive_thresholds::read_thresholds(path, content);
260    let before_lines = content.lines().count() as u32;
261    let result = entropy_compress_with_thresholds(
262        content,
263        thresholds.bpe_entropy,
264        thresholds.jaccard,
265        force_keep,
266    );
267    let after_lines = result.output.lines().count() as u32;
268
269    if before_lines != after_lines {
270        super::events::emit(super::events::EventKind::Compression {
271            path: path.to_string(),
272            before_lines,
273            after_lines,
274            strategy: "entropy_adaptive".to_string(),
275            kept_line_count: after_lines,
276            removed_line_count: before_lines.saturating_sub(after_lines),
277        });
278    }
279
280    result
281}
282
283/// Like [`entropy_compress_adaptive`] but overrides the file-adaptive BPE-entropy
284/// threshold (e.g. from the aggressiveness knob) while keeping the file-adaptive
285/// jaccard. Pure function of its inputs (#498). Higher `bpe_entropy` drops more
286/// low-information lines.
287pub fn entropy_compress_with_threshold(
288    content: &str,
289    path: &str,
290    bpe_entropy: f64,
291    force_keep: &[String],
292) -> EntropyResult {
293    let thresholds = super::adaptive_thresholds::read_thresholds(path, content);
294    entropy_compress_with_thresholds(content, bpe_entropy, thresholds.jaccard, force_keep)
295}
296
297/// Task-conditioned entropy compression: lines that would normally be dropped
298/// for low entropy are kept if they contain task-relevant keywords.  This is
299/// the Information Bottleneck proxy: we compress away only what is neither
300/// surprising (high H) *nor* task-relevant (mentions goal concepts).
301/// Falls back to pure entropy when `task_keywords` is empty.
302pub fn entropy_compress_task_conditioned(
303    content: &str,
304    path: &str,
305    task_keywords: &[String],
306    force_keep: &[String],
307) -> EntropyResult {
308    let thresholds = super::adaptive_thresholds::read_thresholds(path, content);
309    let before_lines = content.lines().count() as u32;
310    let result = entropy_compress_with_task(
311        content,
312        thresholds.bpe_entropy,
313        thresholds.jaccard,
314        task_keywords,
315        force_keep,
316    );
317    let after_lines = result.output.lines().count() as u32;
318    if before_lines != after_lines {
319        super::events::emit(super::events::EventKind::Compression {
320            path: path.to_string(),
321            before_lines,
322            after_lines,
323            strategy: "entropy_task_conditioned".to_string(),
324            kept_line_count: after_lines,
325            removed_line_count: before_lines.saturating_sub(after_lines),
326        });
327    }
328    result
329}
330
331/// Real line embedder for the semantic redundancy filter (#544): uses the
332/// shared neural engine only when it is ALREADY loaded (never blocks a read
333/// on a model load) and only for files small enough that per-line embedding
334/// stays within the read-path latency budget. Embeddings are cached by line
335/// hash so re-reads cost nothing.
336#[cfg(feature = "embeddings")]
337fn line_embedder(line_count: usize) -> impl Fn(&str) -> Option<Vec<f32>> {
338    use std::collections::HashMap;
339    use std::sync::Mutex;
340
341    const MAX_LINES_FOR_SEMANTIC: usize = 400;
342    const CACHE_CAP: usize = 8192;
343    static LINE_EMBED_CACHE: Mutex<Option<HashMap<u64, Vec<f32>>>> = Mutex::new(None);
344
345    let engine = if line_count <= MAX_LINES_FOR_SEMANTIC {
346        crate::core::embedding_access::embedding_engine_nonblocking()
347    } else {
348        None
349    };
350
351    move |line: &str| {
352        let engine = engine?;
353        if line.len() < 8 {
354            return None;
355        }
356        let mut hasher = std::collections::hash_map::DefaultHasher::new();
357        std::hash::Hash::hash(&line, &mut hasher);
358        let key = std::hash::Hasher::finish(&hasher);
359
360        if let Ok(mut guard) = LINE_EMBED_CACHE.lock()
361            && let Some(hit) = guard.get_or_insert_with(HashMap::new).get(&key)
362        {
363            return Some(hit.clone());
364        }
365        let emb = engine.embed(line).ok()?;
366        if let Ok(mut guard) = LINE_EMBED_CACHE.lock() {
367            let map = guard.get_or_insert_with(HashMap::new);
368            if map.len() >= CACHE_CAP {
369                map.clear();
370            }
371            map.insert(key, emb.clone());
372        }
373        Some(emb)
374    }
375}
376
377#[cfg(not(feature = "embeddings"))]
378fn line_embedder(_line_count: usize) -> impl Fn(&str) -> Option<Vec<f32>> {
379    |_: &str| None
380}
381
382/// Read-path entropy compression. The semantic redundancy filter (#544) is
383/// off here: it ran only while the embedding model happened to be loaded, so
384/// two reads of the same file could return different text — which breaks the
385/// output-determinism contract (#498) and, since #1910 made `entropy` drop
386/// lines at all, showed up as `read_mode_deterministic:entropy` failing.
387fn entropy_compress_with_task(
388    content: &str,
389    entropy_threshold: f64,
390    jaccard_threshold: f64,
391    task_keywords: &[String],
392    force_keep: &[String],
393) -> EntropyResult {
394    entropy_compress_inner(
395        content,
396        entropy_threshold,
397        jaccard_threshold,
398        task_keywords,
399        false,
400        force_keep,
401    )
402}
403
404#[path = "entropy_omission.rs"]
405mod omission;
406use omission::{
407    LineFate, coverage_terms, gap_delta, omission_runs, render_with_omissions,
408    rescue_uncovered_lines,
409};
410
411fn entropy_compress_inner(
412    content: &str,
413    entropy_threshold: f64,
414    jaccard_threshold: f64,
415    task_keywords: &[String],
416    semantic: bool,
417    force_keep: &[String],
418) -> EntropyResult {
419    let original_tokens = count_tokens(content);
420    let lines: Vec<&str> = content.lines().collect();
421    let mut techniques = Vec::new();
422
423    let kw_lower: Vec<String> = task_keywords.iter().map(|k| k.to_lowercase()).collect();
424    let original_count = lines.len();
425    let mut task_rescued = 0usize;
426    // Semantic redundancy (#544): when the embedding engine is already
427    // loaded, kept lines that embed near-identically to earlier kept lines
428    // are dropped (MMR). Without the engine the closure returns None and the
429    // decision path is byte-identical to the Zipf-only filter.
430    // `semantic=false` (deterministic contract) requests an embedder above
431    // the size cutoff, which never resolves an engine.
432    let embed = line_embedder(if semantic { original_count } else { usize::MAX });
433    let mut scoring_ctx = super::surprise::ScoringCtx::new();
434    // #1910: the threshold sets how much of *this* file is shed (most
435    // predictable lines first), so it — and aggressiveness — take effect.
436    let floor = super::surprise::surprise_floor(lines.iter().copied(), entropy_threshold);
437    let mut fate: Vec<LineFate> = lines
438        .iter()
439        .map(|line| {
440            let trimmed = line.trim();
441            // Explicit protect tokens (#709) win over every lossy heuristic: a line
442            // containing one is force-kept verbatim before any threshold is applied.
443            if super::protect::line_is_protected(line, force_keep)
444                || super::surprise::should_keep_line_semantic(
445                    trimmed,
446                    floor,
447                    &embed,
448                    &mut scoring_ctx,
449                )
450            {
451                return LineFate::Keep;
452            }
453            // Task-conditioned rescue: keep predictable lines that mention task keywords.
454            if !kw_lower.is_empty() {
455                let lower = trimmed.to_lowercase();
456                if kw_lower.iter().any(|kw| lower.contains(kw.as_str())) {
457                    task_rescued += 1;
458                    return LineFate::Keep;
459                }
460            }
461            LineFate::Predictable
462        })
463        .collect();
464
465    // Near-duplicate blocks among the surviving lines (indices into `kept`).
466    let kept: Vec<usize> = (0..lines.len())
467        .filter(|&i| fate[i] == LineFate::Keep)
468        .collect();
469    let kept_lines: Vec<&str> = kept.iter().map(|&i| lines[i]).collect();
470    let blocks = extract_blocks(&kept_lines);
471    for group in find_pattern_groups(&blocks, jaccard_threshold) {
472        for &idx in group.iter().skip(1) {
473            // Protected lines (#709) are a hard keep: never dedup them away,
474            // not just exempt them from the entropy threshold above.
475            if !super::protect::line_is_protected(kept_lines[idx], force_keep) {
476                fate[kept[idx]] = LineFate::Duplicate;
477            }
478        }
479    }
480
481    let covered = rescue_uncovered_lines(&lines, &mut fate);
482    let (result, omitted_predictable, omitted_duplicate) = render_with_omissions(&lines, &fate);
483    if omitted_predictable > 0 || task_rescued > 0 {
484        let mut msg = format!("⊘ {omitted_predictable} predictable lines");
485        if task_rescued > 0 {
486            msg.push_str(&format!(" [+{task_rescued} task-rescued]"));
487        }
488        techniques.push(msg);
489    }
490    if omitted_duplicate > 0 {
491        techniques.push(format!("⊘ {omitted_duplicate} duplicate patterns (J≥0.7)"));
492    }
493    if covered > 0 {
494        techniques.push(format!("+{covered} kept for unique identifiers"));
495    }
496
497    let mut collapsed = Vec::new();
498    let mut blank_count = 0;
499    for line in &result {
500        if line.trim().is_empty() {
501            blank_count += 1;
502            if blank_count <= 1 {
503                collapsed.push(line.clone());
504            }
505        } else {
506            blank_count = 0;
507            collapsed.push(line.clone());
508        }
509    }
510    let output = collapsed.join("\n");
511    let compressed_tokens = count_tokens(&output);
512
513    // Safeguard: BPE re-tokenization of a line subset can, for tiny adversarial
514    // inputs, exceed the original token count (e.g. a dropped trailing newline
515    // merges differently). Never inflate — fall back to the original verbatim.
516    let final_output = if compressed_tokens > original_tokens {
517        content.to_string()
518    } else {
519        output
520    };
521    let final_tokens = if compressed_tokens > original_tokens {
522        original_tokens
523    } else {
524        compressed_tokens
525    };
526
527    EntropyResult {
528        output: final_output,
529        original_tokens,
530        compressed_tokens: final_tokens,
531        techniques,
532    }
533}
534
535fn entropy_compress_with_thresholds(
536    content: &str,
537    entropy_threshold: f64,
538    jaccard_threshold: f64,
539    force_keep: &[String],
540) -> EntropyResult {
541    entropy_compress_with_task(
542        content,
543        entropy_threshold,
544        jaccard_threshold,
545        &[],
546        force_keep,
547    )
548}
549
550/// #798: Walk kept lines in source order; when a kept line has unclosed
551/// call/index delimiters `()` `[]`, force-keep subsequent lines until
552/// balance is restored. Prevents partial call expressions like
553/// `logger.info(` surviving without their arguments.
554///
555/// Intentionally ignores `{}` — block braces wrap large code bodies and
556/// force-keeping entire function blocks would defeat the density target.
557fn delimiter_balance_force_keep(
558    lines: &[&str],
559    scored: &[(usize, f64, usize)],
560    keep: &mut [bool],
561    used: &mut usize,
562    kept_count: &mut usize,
563) {
564    let tok_for =
565        |idx: usize| -> usize { scored.iter().find(|(i, _, _)| *i == idx).map_or(1, |s| s.2) };
566
567    // Phase 1: identify multi-line delimiter spans (opener_line, closer_line).
568    let mut spans: Vec<(usize, usize)> = Vec::new();
569    let mut open_stack: Vec<usize> = Vec::new();
570    for (i, line) in lines.iter().enumerate() {
571        for ch in line.chars() {
572            match ch {
573                '(' | '[' => open_stack.push(i),
574                ')' | ']' => {
575                    if let Some(opener) = open_stack.pop()
576                        && opener != i
577                    {
578                        spans.push((opener, i));
579                    }
580                }
581                _ => {}
582            }
583        }
584    }
585
586    // Phase 2: if ANY line in a span is kept, force-keep all lines in it.
587    // This handles both directions: opener->continuation and continuation->opener.
588    for &(start, end) in &spans {
589        let any_kept = (start..=end).any(|i| keep[i]);
590        if any_kept {
591            for (i, kept) in keep
592                .iter_mut()
593                .enumerate()
594                .skip(start)
595                .take(end - start + 1)
596            {
597                if !*kept {
598                    *kept = true;
599                    *used += tok_for(i);
600                    *kept_count += 1;
601                }
602            }
603        }
604    }
605}
606
607/// Budget-based compression to a target density (SDE principle: aim for a
608/// *target* information density instead of maximum compression).
609///
610/// Keeps the highest-entropy lines — in original order — until the BPE token
611/// budget `original_tokens * target` is exhausted. Deterministic: ties break
612/// on line index. `target` is clamped to [0.05, 1.0]; the top-scored line is
613/// always kept so output is never empty for non-empty input.
614pub fn entropy_compress_to_density(content: &str, target: f64) -> EntropyResult {
615    entropy_compress_to_density_for(content, target, "")
616}
617
618/// Lines a density view of source code must keep for it to stay a map of the
619/// file: symbol definitions (signature start lines) and imports. Empty for
620/// unknown extensions, which keeps plain text purely budget-driven.
621fn structural_anchor_lines(content: &str, lines: &[&str], ext: &str) -> Vec<usize> {
622    if ext.is_empty() {
623        return Vec::new();
624    }
625    let mut anchors: Vec<usize> = super::signatures::extract_signatures(content, ext)
626        .iter()
627        .filter(|s| {
628            matches!(
629                s.kind,
630                "fn" | "method" | "class" | "struct" | "interface" | "trait" | "enum"
631            )
632        })
633        .filter_map(|s| s.start_line)
634        .filter_map(|line| line.checked_sub(1))
635        .filter(|&i| i < lines.len())
636        .collect();
637    for import in super::deps::extract_deps(content, ext).imports {
638        if let Some(i) = lines.iter().position(|l| l.contains(import.as_str())) {
639            anchors.push(i);
640        }
641    }
642    anchors.sort_unstable();
643    anchors.dedup();
644    anchors
645}
646
647/// [`entropy_compress_to_density`] with structure for source files (`ext`):
648/// definition and import lines are kept first, then lines that carry an
649/// identifier or literal not yet in the view, then the highest-entropy rest.
650/// Dropped runs become `[lean-ctx: omitted N lines]` markers, so the result
651/// never passes for complete source.
652pub fn entropy_compress_to_density_for(content: &str, target: f64, ext: &str) -> EntropyResult {
653    let target = target.clamp(0.05, 1.0);
654    let original_tokens = count_tokens(content);
655    if content.is_empty() || original_tokens == 0 {
656        return EntropyResult {
657            output: String::new(),
658            original_tokens,
659            compressed_tokens: 0,
660            techniques: vec![format!("density target={target:.2} (empty input)")],
661        };
662    }
663    if target >= 1.0 {
664        // The whole budget: nothing to shed, so no line selection (whose
665        // per-line token sums and marker reserve could drop the last lines).
666        return EntropyResult {
667            output: content.to_string(),
668            original_tokens,
669            compressed_tokens: original_tokens,
670            techniques: vec![format!("density target={target:.2} (kept all)")],
671        };
672    }
673
674    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
675    let budget = ((original_tokens as f64) * target).ceil() as usize;
676
677    let lines: Vec<&str> = content.lines().collect();
678    let mut scored: Vec<(usize, f64, usize)> = lines
679        .iter()
680        .enumerate()
681        .map(|(i, l)| {
682            let trimmed = l.trim();
683            // +1 approximates the newline token; keeps the per-line sum close
684            // to the whole-file count so the budget is meaningful.
685            let toks = count_tokens(trimmed).max(1);
686            (i, token_entropy(trimmed), toks)
687        })
688        .collect();
689    scored.sort_by(|a, b| {
690        b.1.partial_cmp(&a.1)
691            .unwrap_or(std::cmp::Ordering::Equal)
692            .then(a.0.cmp(&b.0))
693    });
694
695    let mut keep = vec![false; lines.len()];
696    let mut used = 0usize;
697    let mut kept_count = 0usize;
698    let tokens_of = |idx: usize| -> usize { scored.iter().find(|s| s.0 == idx).map_or(1, |s| s.2) };
699    // The single most informative line is always kept, even if it alone
700    // exceeds the budget — a density target that drops the highest-signal
701    // line would be self-defeating. Structural anchors (definitions, imports)
702    // are kept even past the budget: without them the view stops being a map
703    // of the file, and the quality guard would discard it anyway.
704    let anchors = structural_anchor_lines(content, &lines, ext);
705    for idx in scored.first().map(|s| s.0).into_iter().chain(anchors) {
706        if !keep[idx] {
707            keep[idx] = true;
708            used += tokens_of(idx);
709            kept_count += 1;
710        }
711    }
712    // Then lines that bring an identifier or literal the view lacks, best
713    // entropy first; then the highest-entropy rest. Both stop at the budget,
714    // which also pays for the omission markers: keeping a line inside a
715    // dropped run splits it (one more marker), closing a one-line gap saves one.
716    let marker_cost = count_tokens("[lean-ctx: omitted 10 lines]");
717    let mut gaps = omission_runs(&keep);
718    let mut vocab: std::collections::HashSet<&str> = (0..lines.len())
719        .filter(|&i| keep[i])
720        .flat_map(|i| coverage_terms(lines[i]))
721        .collect();
722    for coverage_pass in [true, false] {
723        for &(idx, _h, toks) in &scored {
724            if keep[idx] {
725                continue;
726            }
727            let new_gaps = gaps.saturating_add_signed(gap_delta(&keep, idx));
728            if used + toks + new_gaps * marker_cost > budget {
729                continue;
730            }
731            if coverage_pass && coverage_terms(lines[idx]).all(|t| vocab.contains(t)) {
732                continue;
733            }
734            keep[idx] = true;
735            used += toks;
736            gaps = new_gaps;
737            kept_count += 1;
738            vocab.extend(coverage_terms(lines[idx]));
739        }
740    }
741
742    // #798 Delimiter Balance Guard: when a kept line opens unclosed
743    // delimiters ( [ {, force-keep subsequent lines until the balance
744    // is restored. This prevents `logger.info(` from being kept while
745    // its arguments are dropped, leaving syntactically broken output.
746    delimiter_balance_force_keep(&lines, &scored, &mut keep, &mut used, &mut kept_count);
747
748    let fate: Vec<LineFate> = keep
749        .iter()
750        .map(|&k| {
751            if k {
752                LineFate::Keep
753            } else {
754                LineFate::Predictable
755            }
756        })
757        .collect();
758    let (out_lines, omitted, _) = render_with_omissions(&lines, &fate);
759    let output = out_lines.join("\n");
760    let compressed_tokens = count_tokens(&output);
761
762    EntropyResult {
763        output,
764        original_tokens,
765        compressed_tokens,
766        techniques: vec![format!(
767            "density target={target:.2} budget={budget} tok, kept {}/{} lines (⊘ {omitted})",
768            lines.len() - omitted,
769            lines.len()
770        )],
771    }
772}
773
774/// Per-line entropy statistics for a block of content.
775#[derive(Debug)]
776pub struct EntropyAnalysis {
777    pub avg_entropy: f64,
778    pub low_entropy_count: usize,
779    pub high_entropy_count: usize,
780    pub total_lines: usize,
781}
782
783/// Analyzes per-line BPE token entropy, counting low/high entropy lines.
784pub fn analyze_entropy(content: &str) -> EntropyAnalysis {
785    let lines: Vec<&str> = content.lines().collect();
786    let total = lines.len();
787    let mut sum = 0.0;
788    let mut low = 0;
789    let mut high = 0;
790    let mut counted = 0;
791
792    for line in &lines {
793        let trimmed = line.trim();
794        if trimmed.is_empty() {
795            continue;
796        }
797        let h = token_entropy(trimmed);
798        sum += h;
799        counted += 1;
800        if h < BPE_ENTROPY_THRESHOLD {
801            low += 1;
802        }
803        if h > 3.0 {
804            high += 1;
805        }
806    }
807
808    EntropyAnalysis {
809        avg_entropy: if counted > 0 {
810            sum / counted as f64
811        } else {
812            0.0
813        },
814        low_entropy_count: low,
815        high_entropy_count: high,
816        total_lines: total,
817    }
818}
819
820struct Block {
821    content: String,
822}
823
824fn extract_blocks(lines: &[&str]) -> Vec<Block> {
825    let mut blocks = Vec::new();
826    let mut current = String::new();
827
828    for line in lines {
829        let trimmed = line.trim();
830        if trimmed.is_empty() && !current.is_empty() {
831            blocks.push(Block {
832                content: current.clone(),
833            });
834            current.clear();
835        } else if !trimmed.is_empty() {
836            current.push_str(trimmed);
837            current.push('\n');
838        }
839    }
840
841    if !current.is_empty() {
842        blocks.push(Block { content: current });
843    }
844
845    blocks
846}
847
848fn find_pattern_groups(blocks: &[Block], threshold: f64) -> Vec<Vec<usize>> {
849    // Exact n-gram Jaccard, but with precomputed n-gram sets per block to avoid
850    // rebuilding allocations per pair. Includes a size-ratio impossibility check
851    // (max possible Jaccard is |A|/|B| for |A|<=|B|).
852    let sets: Vec<HashSet<Vec<String>>> = blocks.iter().map(|b| ngram_set(&b.content, 2)).collect();
853    let sizes: Vec<usize> = sets.iter().map(std::collections::HashSet::len).collect();
854
855    let mut groups: Vec<Vec<usize>> = Vec::new();
856    let mut assigned: HashSet<usize> = HashSet::new();
857
858    for i in 0..blocks.len() {
859        if assigned.contains(&i) {
860            continue;
861        }
862        let mut group = vec![i];
863        for j in (i + 1)..blocks.len() {
864            if assigned.contains(&j) {
865                continue;
866            }
867            let size_i = sizes[i];
868            let size_j = sizes[j];
869            let min_sz = size_i.min(size_j);
870            let max_sz = size_i.max(size_j);
871            if max_sz > 0 && (min_sz as f64) < (threshold * max_sz as f64) {
872                continue;
873            }
874            let inter = sets[i].intersection(&sets[j]).count();
875            let union = size_i + size_j - inter;
876            if union > 0 && (inter as f64 / union as f64) >= threshold {
877                group.push(j);
878                assigned.insert(j);
879            }
880        }
881        if group.len() > 1 {
882            assigned.insert(i);
883        }
884        groups.push(group);
885    }
886
887    groups
888}
889
890#[cfg(test)]
891mod tests {
892    use super::*;
893
894    #[test]
895    fn protect_force_keeps_lines_in_entropy() {
896        // A block of identical low-information lines that entropy + dedup would
897        // normally collapse to a single survivor (or drop entirely).
898        let mut content = String::new();
899        for _ in 0..15 {
900            content.push_str("boilerplate noise line\n");
901        }
902        let baseline =
903            entropy_compress_inner(&content, BPE_ENTROPY_THRESHOLD, 0.7, &[], false, &[]);
904        let protected = entropy_compress_inner(
905            &content,
906            BPE_ENTROPY_THRESHOLD,
907            0.7,
908            &[],
909            false,
910            &["boilerplate".to_string()],
911        );
912        let baseline_hits = baseline.output.matches("boilerplate").count();
913        let protected_hits = protected.output.matches("boilerplate").count();
914        // Every protected line survives verbatim — a hard keep over both the
915        // entropy threshold and the dedup pass.
916        assert_eq!(
917            protected_hits, 15,
918            "all protected lines must survive: {}",
919            protected.output
920        );
921        assert!(
922            protected_hits >= baseline_hits,
923            "protect must keep at least as many lines as the baseline"
924        );
925    }
926
927    #[test]
928    fn empty_force_keep_is_byte_identical() {
929        // The protect feature must not perturb the unprotected path (#498).
930        let content = "fn a() {}\n// note\nlet x = 1;\nlet x = 1;\n// note\n";
931        let a = entropy_compress_inner(content, BPE_ENTROPY_THRESHOLD, 0.7, &[], false, &[]);
932        let b = entropy_compress_deterministic(content);
933        assert_eq!(a.output, b.output);
934    }
935
936    #[test]
937    fn shannon_entropy_empty_is_zero() {
938        assert_eq!(shannon_entropy(""), 0.0);
939    }
940
941    #[test]
942    fn shannon_entropy_single_char() {
943        assert_eq!(shannon_entropy("aaaa"), 0.0);
944    }
945
946    #[test]
947    fn shannon_entropy_high_for_varied_text() {
948        let varied = "abcdefghijklmnopqrstuvwxyz0123456789";
949        let uniform = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
950        assert!(
951            shannon_entropy(varied) > shannon_entropy(uniform),
952            "varied text should have higher entropy"
953        );
954    }
955
956    #[test]
957    fn jaccard_identical_is_one() {
958        let sim = jaccard_similarity("hello world", "hello world");
959        assert!((sim - 1.0).abs() < f64::EPSILON);
960    }
961
962    #[test]
963    fn jaccard_disjoint_is_zero() {
964        let sim = jaccard_similarity("abc", "xyz");
965        assert_eq!(sim, 0.0);
966    }
967
968    #[test]
969    fn jaccard_partial_overlap() {
970        let sim = jaccard_similarity("hello world", "hello rust");
971        assert!(sim > 0.0 && sim < 1.0);
972    }
973
974    #[test]
975    fn entropy_compress_produces_output() {
976        let content = "fn main() {\n    println!(\"hello\");\n}\n\n// comment\n// another comment\n\nfn helper() {\n    let x = 42;\n}\n";
977        let result = entropy_compress(content);
978        assert!(!result.output.is_empty(), "should produce non-empty output");
979        assert!(result.compressed_tokens <= result.original_tokens);
980    }
981
982    #[test]
983    fn entropy_result_savings() {
984        let r = EntropyResult {
985            output: "short".to_string(),
986            original_tokens: 100,
987            compressed_tokens: 60,
988            techniques: vec!["test".to_string()],
989        };
990        assert!((r.savings_percent() - 40.0).abs() < 0.1);
991    }
992
993    #[test]
994    fn entropy_result_zero_original() {
995        let r = EntropyResult {
996            output: String::new(),
997            original_tokens: 0,
998            compressed_tokens: 0,
999            techniques: vec![],
1000        };
1001        assert_eq!(r.savings_percent(), 0.0);
1002    }
1003
1004    #[test]
1005    fn token_entropy_empty_is_zero() {
1006        assert_eq!(token_entropy(""), 0.0);
1007    }
1008
1009    #[test]
1010    fn token_entropy_single_repeated_token() {
1011        assert_eq!(token_entropy("}"), 0.0);
1012    }
1013
1014    #[test]
1015    fn token_entropy_higher_for_diverse_code() {
1016        let diverse = "let result = compute_something(x, y, z);";
1017        let repetitive = "aaaa aaaa aaaa aaaa";
1018        assert!(
1019            token_entropy(diverse) > token_entropy(repetitive),
1020            "diverse code should have higher BPE token entropy"
1021        );
1022    }
1023
1024    #[test]
1025    fn token_entropy_vs_char_entropy_differ() {
1026        let code = "fn main() { println!(\"hello world\"); }";
1027        let te = token_entropy(code);
1028        let ce = shannon_entropy(code);
1029        assert!(te != ce, "BPE and char entropy should differ for code");
1030    }
1031
1032    #[test]
1033    fn ngram_jaccard_preserves_order() {
1034        let a = "a b c d";
1035        let b = "d c b a";
1036        let word_j = jaccard_similarity(a, b);
1037        let ngram_j = ngram_jaccard(a, b, 2);
1038        assert!(
1039            ngram_j < word_j,
1040            "reordered text should have lower bigram Jaccard ({ngram_j}) than word Jaccard ({word_j})"
1041        );
1042    }
1043
1044    #[test]
1045    fn ngram_jaccard_identical_is_one() {
1046        let text = "fn main() { println!(\"hello\"); }";
1047        let j = ngram_jaccard(text, text, 2);
1048        assert!((j - 1.0).abs() < f64::EPSILON);
1049    }
1050
1051    #[test]
1052    fn ngram_jaccard_disjoint_is_zero() {
1053        let j = ngram_jaccard("alpha beta gamma", "delta epsilon zeta", 2);
1054        assert_eq!(j, 0.0);
1055    }
1056
1057    #[test]
1058    fn minhash_approximates_jaccard() {
1059        let a = "fn main() { let x = 1; let y = 2; let z = x + y; println!(z); }";
1060        let b = "fn main() { let x = 1; let y = 2; let z = x + y; return z; }";
1061        let exact = ngram_jaccard(a, b, 2);
1062        let sig_a = minhash_signature(a, 2, 128);
1063        let sig_b = minhash_signature(b, 2, 128);
1064        let approx = minhash_similarity(&sig_a, &sig_b);
1065        assert!(
1066            (exact - approx).abs() < 0.2,
1067            "minhash ({approx}) should approximate exact ({exact}) within 0.2"
1068        );
1069    }
1070
1071    #[test]
1072    fn minhash_empty_text() {
1073        let sig = minhash_signature("", 2, 64);
1074        assert!(sig.iter().all(|&v| v == u64::MAX));
1075    }
1076
1077    #[test]
1078    fn kolmogorov_empty_is_one() {
1079        assert_eq!(kolmogorov_proxy(""), 1.0);
1080    }
1081
1082    #[test]
1083    fn kolmogorov_repetitive_is_low() {
1084        let repetitive = "aaa\n".repeat(1000);
1085        let k = kolmogorov_proxy(&repetitive);
1086        assert!(
1087            k < 0.1,
1088            "highly repetitive text should compress well: K={k}"
1089        );
1090    }
1091
1092    #[test]
1093    fn kolmogorov_diverse_is_higher() {
1094        let repetitive = "aaa\n".repeat(500);
1095        let diverse = (0..500)
1096            .map(|i| format!("line_{i}_unique_content_{}", i * 17 % 97))
1097            .collect::<Vec<_>>()
1098            .join("\n");
1099        assert!(
1100            kolmogorov_proxy(&diverse) > kolmogorov_proxy(&repetitive),
1101            "diverse content should have higher K than repetitive"
1102        );
1103    }
1104
1105    #[test]
1106    fn compressibility_class_repetitive_is_high() {
1107        let text = "use std::io;\n".repeat(200);
1108        assert_eq!(compressibility_class(&text), CompressibilityClass::High);
1109    }
1110
1111    #[test]
1112    fn kolmogorov_diverse_higher_than_repetitive() {
1113        let rep = "test\n".repeat(500);
1114        let diverse = (0..500)
1115            .map(|i| format!("unique_line_{i}_xk{}", i * 31 % 1000))
1116            .collect::<Vec<_>>()
1117            .join("\n");
1118        assert!(
1119            kolmogorov_proxy(&diverse) > kolmogorov_proxy(&rep),
1120            "diverse content should have higher K"
1121        );
1122    }
1123
1124    fn density_fixture() -> String {
1125        (0..120)
1126            .map(|i| {
1127                if i % 3 == 0 {
1128                    format!(
1129                        "fn compute_value_{i}(input: &str, flags: u32) -> Result<Output, Error> {{"
1130                    )
1131                } else if i % 3 == 1 {
1132                    format!("    let intermediate_{i} = transform(input, flags ^ {i});")
1133                } else {
1134                    "}".to_string()
1135                }
1136            })
1137            .collect::<Vec<_>>()
1138            .join("\n")
1139    }
1140
1141    #[test]
1142    fn density_respects_token_budget() {
1143        let content = density_fixture();
1144        let orig = count_tokens(&content);
1145        for target in [0.3, 0.5, 0.7] {
1146            let r = entropy_compress_to_density(&content, target);
1147            let actual = r.compressed_tokens as f64 / orig as f64;
1148            // Tolerance of 0.15: the delimiter balance guard (#798) may
1149            // force-keep call/index continuation lines beyond the budget.
1150            assert!(
1151                actual <= target + 0.15,
1152                "target {target}: actual density {actual:.2} exceeds budget"
1153            );
1154            assert!(!r.output.is_empty(), "`r.output` is empty");
1155        }
1156    }
1157
1158    #[test]
1159    fn density_is_deterministic() {
1160        let content = density_fixture();
1161        let a = entropy_compress_to_density(&content, 0.4);
1162        let b = entropy_compress_to_density(&content, 0.4);
1163        assert_eq!(a.output, b.output);
1164        assert_eq!(a.compressed_tokens, b.compressed_tokens);
1165    }
1166
1167    /// A table of look-alike rows (`rewrite_registry.rs`): every row is
1168    /// "predictable", but each literal exists exactly once.
1169    fn registry_fixture() -> String {
1170        let mut rows = vec!["pub const REWRITE_COMMANDS: &[RewriteEntry] = &[".to_string()];
1171        for cmd in [
1172            "git",
1173            "gh",
1174            "cargo",
1175            "npm",
1176            "pnpm",
1177            "yarn",
1178            "bun",
1179            "deno",
1180            "vite",
1181            "tsc",
1182            "python",
1183            "pip",
1184            "pip3",
1185            "uv",
1186            "pytest",
1187            "mypy",
1188            "ruff",
1189            "go",
1190            "docker",
1191            "kubectl",
1192            "helm",
1193            "aws",
1194            "terraform",
1195            "tofu",
1196            "eslint",
1197            "prettier",
1198            "biome",
1199            "curl",
1200            "wget",
1201            "php",
1202        ] {
1203            rows.push(format!("    re(\"{cmd}\", Category::Build),"));
1204        }
1205        rows.push("];".to_string());
1206        rows.push(String::new());
1207        rows.push("pub enum Category {".to_string());
1208        for v in ["Vcs", "Build", "PackageManager", "Lint", "Infra", "Http"] {
1209            rows.push(format!("    {v},"));
1210        }
1211        rows.push("}".to_string());
1212        rows.join("\n")
1213    }
1214
1215    /// Regression: `entropy` dropped `re("git", Category::Vcs)` and every
1216    /// `Category` variant without a trace, so the model saw a complete-looking
1217    /// table that silently lacked `git`.
1218    #[test]
1219    fn entropy_never_loses_a_literal_and_marks_every_gap() {
1220        let content = registry_fixture();
1221        for threshold in [0.8, 1.2, 1.9] {
1222            let r = entropy_compress_with_thresholds(&content, threshold, 0.7, &[]);
1223            for cmd in ["\"git\"", "\"gh\"", "\"php\"", "\"pip3\""] {
1224                assert!(r.output.contains(cmd), "t={threshold}: lost {cmd}");
1225            }
1226            let kept_lines = r
1227                .output
1228                .lines()
1229                .filter(|l| !l.contains("[lean-ctx: omitted"));
1230            let kept_source_lines = kept_lines.clone().count();
1231            let marked: usize = r
1232                .output
1233                .lines()
1234                .filter_map(|l| l.split("[lean-ctx: omitted ").nth(1))
1235                .filter_map(|rest| rest.split(' ').next()?.parse::<usize>().ok())
1236                .sum();
1237            assert_eq!(
1238                kept_source_lines + marked,
1239                content.lines().count(),
1240                "t={threshold}: every source line is either shown or counted in a marker"
1241            );
1242            for line in kept_lines {
1243                assert!(content.lines().any(|l| l == line), "invented line {line:?}");
1244            }
1245        }
1246    }
1247
1248    /// Density views keep the file's definitions, carry the omission markers
1249    /// inside the budget, and never invent content.
1250    #[test]
1251    fn density_keeps_definitions_and_budgets_its_markers() {
1252        use std::fmt::Write as _;
1253        let mut content = String::new();
1254        for i in 0..40 {
1255            writeln!(content, "pub fn handler_{i}(input: &str) -> usize {{").unwrap();
1256            writeln!(content, "    let parsed_{i} = input.len() + {i};").unwrap();
1257            for k in 0..6 {
1258                writeln!(
1259                    content,
1260                    "    let step_{i}_{k} = parsed_{i}.wrapping_mul({k} + {i});"
1261                )
1262                .unwrap();
1263            }
1264            writeln!(content, "    parsed_{i} * 2\n}}").unwrap();
1265        }
1266        let orig = count_tokens(&content);
1267        for target in [0.3, 0.5] {
1268            let r = entropy_compress_to_density_for(&content, target, "rs");
1269            for i in 0..40 {
1270                assert!(
1271                    r.output.contains(&format!("pub fn handler_{i}(")),
1272                    "t={target}: definition handler_{i} dropped"
1273                );
1274            }
1275            assert!(
1276                r.output.contains("[lean-ctx: omitted "),
1277                "t={target}: gaps must be marked"
1278            );
1279            let actual = r.compressed_tokens as f64 / orig as f64;
1280            assert!(
1281                actual <= target + 0.1,
1282                "t={target}: {actual:.2} — the omission markers must fit the budget"
1283            );
1284        }
1285    }
1286
1287    #[test]
1288    fn density_target_one_keeps_everything() {
1289        let content = density_fixture();
1290        let r = entropy_compress_to_density(&content, 1.0);
1291        assert_eq!(r.output, content);
1292    }
1293
1294    #[test]
1295    fn density_clamps_out_of_range_target() {
1296        let content = density_fixture();
1297        let low = entropy_compress_to_density(&content, 0.0);
1298        assert!(!low.output.is_empty(), "clamped to 0.05, never empty");
1299        let high = entropy_compress_to_density(&content, 5.0);
1300        assert_eq!(high.output, content, "clamped to 1.0 keeps all");
1301    }
1302
1303    #[test]
1304    fn density_empty_input() {
1305        let r = entropy_compress_to_density("", 0.5);
1306        assert_eq!(r.compressed_tokens, 0);
1307        assert!(r.output.is_empty(), "{:?}", r.output);
1308    }
1309
1310    #[test]
1311    fn density_delimiter_balance_guard() {
1312        // #798: when `logger.info(` is kept, its continuation lines must
1313        // also survive so the output is syntactically valid.
1314        let content = [
1315            "import logging",
1316            "logger = logging.getLogger(__name__)",
1317            "logger.info(",
1318            "    'Processing batch %d of %d',",
1319            "    batch_idx,",
1320            "    total_batches,",
1321            ")",
1322            "result = process(data)",
1323        ]
1324        .join("\n");
1325        let r = entropy_compress_to_density(&content, 0.5);
1326        // If `logger.info(` is kept, all lines through `)` must be too.
1327        if r.output.contains("logger.info(") {
1328            assert!(
1329                r.output.contains(')'),
1330                "delimiter guard must keep closing paren: {}",
1331                r.output
1332            );
1333            assert!(
1334                r.output.contains("batch_idx"),
1335                "delimiter guard must keep continuation args: {}",
1336                r.output
1337            );
1338        }
1339    }
1340
1341    #[test]
1342    fn density_delimiter_backward_propagation() {
1343        // #798 regression: when a high-entropy continuation line inside a
1344        // multi-line call is kept by the entropy scorer, the opener line
1345        // must also be force-kept (backward propagation through the span).
1346        //
1347        // Uses many low-entropy padding lines to force the budget to drop the
1348        // opener while the high-entropy format string survives on its own merit.
1349        let mut lines: Vec<&str> = Vec::new();
1350        lines.push("import logging");
1351        lines.push("logger = logging.getLogger(__name__)");
1352        lines.push("");
1353        // Low-entropy padding to push density budget down
1354        lines.extend(std::iter::repeat_n("    x = 1", 20));
1355        lines.push("    logger.info(");
1356        lines.push("        \"Processed item %s status=%s result=%s region=%s\",");
1357        lines.push("        item.id,");
1358        lines.push("        item.status,");
1359        lines.push("        result.summary,");
1360        lines.push("        region_name");
1361        lines.push("    )");
1362        // More padding
1363        lines.extend(std::iter::repeat_n("    y = 2", 10));
1364        let content = lines.join("\n");
1365        let r = entropy_compress_to_density(&content, 0.3);
1366        // The format string has high entropy and will be kept.
1367        // The span guard must then force-keep the opener + closer.
1368        if r.output.contains("Processed item") {
1369            assert!(
1370                r.output.contains("logger.info("),
1371                "backward propagation: opener must be kept when format string is kept.\nGot: {}",
1372                r.output
1373            );
1374            assert!(
1375                r.output.contains(')'),
1376                "backward propagation: closer must also be kept.\nGot: {}",
1377                r.output
1378            );
1379        }
1380        // Also verify: if logger.info( is kept, all args through ) are kept
1381        if r.output.contains("logger.info(") {
1382            assert!(
1383                r.output.contains("item.id"),
1384                "forward propagation: args must be kept when opener is kept.\nGot: {}",
1385                r.output
1386            );
1387        }
1388    }
1389
1390    #[test]
1391    fn density_prefers_high_entropy_lines() {
1392        let content =
1393            "}\n}\n}\nlet complex_result = compute_unique_hash(seed, nonce, payload);\n}\n}";
1394        let r = entropy_compress_to_density(content, 0.6);
1395        assert!(
1396            r.output.contains("compute_unique_hash"),
1397            "high-entropy line must survive: {}",
1398            r.output
1399        );
1400    }
1401}