Skip to main content

lean_ctx/core/
markdown_compact.rs

1//! Deterministic Markdown/documentation compaction for LLM-agent reads.
2//!
3//! Keeps heading topology intact, treats fenced code blocks as atomic units, and
4//! selects high-signal body units with a small IDF-style scorer. Intentionally
5//! std-only and byte-stable (#498): per-line token sets are ordered
6//! (`BTreeSet`), so the f64 score summation order — and therefore the selected
7//! lines and omission markers — are a pure function of the input bytes.
8
9use std::collections::{BTreeSet, HashMap, HashSet};
10use std::fmt::Write as _;
11
12const SECTION_BODY_FRACTION: f64 = 0.35;
13const MIN_SECTION_BODY_LINES: usize = 2;
14/// Documents with fewer content (non-blank) lines pass through untouched.
15const MIN_CONTENT_LINES: usize = 24;
16
17const STOP_WORDS: &[&str] = &[
18    "the", "and", "for", "with", "that", "this", "from", "into", "your", "you", "are", "can",
19    "will", "not", "all", "use", "using", "used", "lean", "ctx", "context", "agent", "agents",
20];
21
22/// One compaction unit: a heading line, a prose line, or an atomic fenced block.
23///
24/// Fenced blocks are kept or dropped only as a whole, so the output never
25/// contains an unbalanced fence or an omission marker inside a code example.
26struct Unit {
27    /// Raw line range `[start, end)` covered by this unit.
28    start: usize,
29    end: usize,
30    kind: UnitKind,
31}
32
33#[derive(Clone, Copy, PartialEq, Eq)]
34enum UnitKind {
35    Heading,
36    Prose,
37    Fence,
38}
39
40impl Unit {
41    /// Non-blank lines covered by this unit (blank interior fence lines are
42    /// emitted verbatim but carry no "content" weight in budgets or markers).
43    fn content_lines(&self, lines: &[&str]) -> usize {
44        lines[self.start..self.end]
45            .iter()
46            .filter(|l| !l.trim().is_empty())
47            .count()
48    }
49}
50
51/// Compact Markdown while preserving all headings, whole fenced code blocks,
52/// and high-signal details. Returns `None` when the document is too small, not
53/// markdown-shaped, or compaction would not actually shrink it.
54pub fn compact_markdown(content: &str) -> Option<String> {
55    if content.trim().is_empty() || !looks_like_markdown(content) {
56        return None;
57    }
58
59    let lines: Vec<&str> = content.lines().collect();
60    let units = parse_units(&lines);
61    let content_total: usize = units.iter().map(|u| u.content_lines(&lines)).sum();
62    if content_total < MIN_CONTENT_LINES {
63        return None;
64    }
65
66    let keep = select_units(&lines, &units, content_total);
67
68    let mut out = String::new();
69    let mut omitted = 0usize;
70    for (uidx, unit) in units.iter().enumerate() {
71        if keep.contains(&uidx) {
72            flush_omission(&mut out, &mut omitted);
73            for line in &lines[unit.start..unit.end] {
74                out.push_str(line.trim_end());
75                out.push('\n');
76            }
77        } else {
78            omitted += unit.content_lines(&lines);
79        }
80    }
81    flush_omission(&mut out, &mut omitted);
82
83    let kept_content = out.lines().filter(|l| !l.trim().is_empty()).count();
84    if kept_content >= content_total || out.len() >= content.len() {
85        None
86    } else {
87        Some(out)
88    }
89}
90
91/// True when the document carries at least one ATX heading — the structural
92/// signal a plain `.txt` file must show before the lossy compactor may touch it
93/// (hyphen lists alone are not enough to call a text file "markdown").
94pub fn has_markdown_headings(content: &str) -> bool {
95    content.lines().any(is_heading)
96}
97
98/// Splits raw lines into units. Blank lines outside fences belong to no unit:
99/// they carry no signal and are dropped silently (not counted as "omitted"),
100/// matching the compact typography of the output. Lines inside a fence — blank
101/// or not — always travel with their block.
102fn parse_units(lines: &[&str]) -> Vec<Unit> {
103    let mut units = Vec::new();
104    let mut i = 0;
105    while i < lines.len() {
106        let trimmed = lines[i].trim_start();
107        if trimmed.is_empty() {
108            i += 1;
109            continue;
110        }
111        if let Some(marker) = fence_marker(trimmed) {
112            let mut end = i + 1;
113            while end < lines.len() && !is_closing_fence(lines[end], marker) {
114                end += 1;
115            }
116            // Include the closing fence; an unterminated block runs to EOF.
117            let end = (end + 1).min(lines.len());
118            units.push(Unit {
119                start: i,
120                end,
121                kind: UnitKind::Fence,
122            });
123            i = end;
124            continue;
125        }
126        let kind = if is_heading(lines[i]) {
127            UnitKind::Heading
128        } else {
129            UnitKind::Prose
130        };
131        units.push(Unit {
132            start: i,
133            end: i + 1,
134            kind,
135        });
136        i += 1;
137    }
138    units
139}
140
141/// Returns the fence character when `trimmed` opens a code fence (``` or ~~~).
142fn fence_marker(trimmed: &str) -> Option<char> {
143    ['`', '~']
144        .into_iter()
145        .find(|&marker| trimmed.chars().take_while(|c| *c == marker).count() >= 3)
146}
147
148/// A closing fence is a run of >=3 fence characters with nothing but whitespace
149/// after it (an info string like ```rust only ever opens a block).
150fn is_closing_fence(line: &str, marker: char) -> bool {
151    let trimmed = line.trim_start();
152    trimmed.chars().take_while(|c| *c == marker).count() >= 3
153        && trimmed.chars().all(|c| c == marker || c.is_whitespace())
154}
155
156fn select_units(lines: &[&str], units: &[Unit], content_total: usize) -> HashSet<usize> {
157    let docs = token_sets(lines);
158    let df = document_frequency(&docs);
159    let mut keep = HashSet::new();
160    let mut section_body: Vec<usize> = Vec::new();
161
162    for (uidx, unit) in units.iter().enumerate() {
163        if unit.kind == UnitKind::Heading {
164            select_section_body(
165                &mut keep,
166                &section_body,
167                lines,
168                units,
169                &docs,
170                &df,
171                content_total,
172            );
173            section_body.clear();
174            keep.insert(uidx);
175        } else {
176            section_body.push(uidx);
177        }
178    }
179    select_section_body(
180        &mut keep,
181        &section_body,
182        lines,
183        units,
184        &docs,
185        &df,
186        content_total,
187    );
188    keep
189}
190
191fn select_section_body(
192    keep: &mut HashSet<usize>,
193    body: &[usize],
194    lines: &[&str],
195    units: &[Unit],
196    docs: &[BTreeSet<String>],
197    df: &HashMap<String, usize>,
198    content_total: usize,
199) {
200    if body.is_empty() {
201        return;
202    }
203
204    let body_lines: usize = body.iter().map(|u| units[*u].content_lines(lines)).sum();
205    let target = section_body_budget(body_lines);
206
207    let mut scored: Vec<(usize, f64)> = body
208        .iter()
209        .map(|uidx| {
210            (
211                *uidx,
212                score_unit(&units[*uidx], lines, docs, content_total, df),
213            )
214        })
215        .collect();
216    scored.sort_by(|a, b| {
217        b.1.partial_cmp(&a.1)
218            .unwrap_or(std::cmp::Ordering::Equal)
219            .then_with(|| a.0.cmp(&b.0))
220    });
221
222    // The first body unit anchors the section (usually its intro sentence).
223    if let Some(first) = body.first() {
224        keep.insert(*first);
225    }
226    // Greedy by content lines so a large fenced block spends its real size of
227    // the budget instead of counting as one line.
228    let mut taken = 0usize;
229    for (uidx, _) in scored {
230        if taken >= target {
231            break;
232        }
233        keep.insert(uidx);
234        taken += units[uidx].content_lines(lines);
235    }
236}
237
238fn section_body_budget(line_count: usize) -> usize {
239    let fractional = ((line_count as f64) * SECTION_BODY_FRACTION).ceil() as usize;
240    fractional.max(MIN_SECTION_BODY_LINES).min(line_count)
241}
242
243fn flush_omission(out: &mut String, omitted: &mut usize) {
244    if *omitted == 0 {
245        return;
246    }
247    let _ = writeln!(out, "... [lean-ctx: omitted {omitted} lines]");
248    *omitted = 0;
249}
250
251fn looks_like_markdown(content: &str) -> bool {
252    content.lines().any(is_heading)
253        || content.lines().any(|l| l.trim_start().starts_with("- "))
254        || content.lines().any(|l| l.trim_start().starts_with("* "))
255        || content.lines().any(|l| l.trim_start().starts_with("| "))
256}
257
258/// ATX heading per CommonMark: 1–6 `#` followed by a space (or end of line).
259/// The space requirement keeps shebangs (`#!/usr/bin/env`) and `#pragma`-style
260/// lines from masquerading as document structure.
261fn is_heading(line: &str) -> bool {
262    let trimmed = line.trim_start();
263    let hashes = trimmed.chars().take_while(|c| *c == '#').count();
264    (1..=6).contains(&hashes) && (trimmed.len() == hashes || trimmed[hashes..].starts_with(' '))
265}
266
267/// Per-line token sets. `BTreeSet` (not `HashSet`) is load-bearing: the score
268/// is an f64 sum over these tokens, and f64 addition is not associative, so the
269/// iteration order must be fixed for the output to be byte-stable (#498).
270fn token_sets(lines: &[&str]) -> Vec<BTreeSet<String>> {
271    lines
272        .iter()
273        .map(|line| tokens(line).into_iter().collect())
274        .collect()
275}
276
277fn document_frequency(docs: &[BTreeSet<String>]) -> HashMap<String, usize> {
278    let mut df = HashMap::new();
279    for doc in docs {
280        for token in doc {
281            *df.entry(token.clone()).or_insert(0) += 1;
282        }
283    }
284    df
285}
286
287fn score_unit(
288    unit: &Unit,
289    lines: &[&str],
290    docs: &[BTreeSet<String>],
291    content_total: usize,
292    df: &HashMap<String, usize>,
293) -> f64 {
294    match unit.kind {
295        UnitKind::Fence => {
296            // A block is one unit of meaning: score the union of its tokens
297            // once, with the same code bonus a backticked prose line gets.
298            let mut tokens = BTreeSet::new();
299            for doc in &docs[unit.start..unit.end] {
300                tokens.extend(doc.iter().cloned());
301            }
302            idf_sum(&tokens, content_total, df) + 10.0 + position_bonus(unit.start)
303        }
304        _ => score_line(
305            unit.start,
306            lines[unit.start],
307            &docs[unit.start],
308            content_total,
309            df,
310        ),
311    }
312}
313
314fn score_line(
315    idx: usize,
316    line: &str,
317    tokens: &BTreeSet<String>,
318    line_count: usize,
319    df: &HashMap<String, usize>,
320) -> f64 {
321    let mut score = idf_sum(tokens, line_count, df);
322
323    let trimmed = line.trim_start();
324    if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("| ") {
325        score += 2.0;
326    }
327    if line.contains('`')
328        || line.contains("MUST")
329        || line.contains("SHOULD")
330        || line.contains("BLOCKING")
331        || line.contains("WARNING")
332        || line.contains("ctx_")
333        || line.contains("lean-ctx")
334    {
335        score += 10.0;
336    }
337    score + position_bonus(idx)
338}
339
340/// IDF-style sum; iterating a `BTreeSet` keeps the f64 summation order fixed.
341fn idf_sum(tokens: &BTreeSet<String>, line_count: usize, df: &HashMap<String, usize>) -> f64 {
342    let mut score = 0.0;
343    for token in tokens {
344        let freq = *df.get(token).unwrap_or(&1) as f64;
345        score += ((line_count as f64 + 1.0) / (freq + 1.0)).ln();
346    }
347    score
348}
349
350fn position_bonus(idx: usize) -> f64 {
351    1.0 / (idx + 1) as f64
352}
353
354fn tokens(line: &str) -> Vec<String> {
355    let mut out = Vec::new();
356    let mut cur = String::new();
357    for ch in line.chars() {
358        if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '/' | '.' | ':') {
359            cur.push(ch);
360        } else if !cur.is_empty() {
361            push_token(&mut out, &cur);
362            cur.clear();
363        }
364    }
365    if !cur.is_empty() {
366        push_token(&mut out, &cur);
367    }
368    out
369}
370
371fn push_token(out: &mut Vec<String>, token: &str) {
372    let t = token
373        .trim_matches(|c: char| matches!(c, '.' | ',' | ':' | ';' | '(' | ')' | '[' | ']'))
374        .to_ascii_lowercase();
375    if t.len() < 3 || STOP_WORDS.contains(&t.as_str()) {
376        return;
377    }
378    if t.len() >= 8 || t.chars().any(|c| matches!(c, '_' | '-' | '/' | '.' | ':')) {
379        out.push(t);
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::{compact_markdown, has_markdown_headings};
386
387    #[test]
388    fn keeps_all_headings_and_shrinks() {
389        let input = "# Title\n\nIntro paragraph with ordinary words.\n\n## Setup\n\n";
390        let body = "- `ctx_read` keeps important details for agents.\n";
391        let repeated = "This sentence is useful once but repeated many times for filler.\n";
392        let mut doc = input.to_string();
393        for _ in 0..20 {
394            doc.push_str(repeated);
395        }
396        doc.push_str(body);
397        doc.push_str("## Safety\n\nMUST preserve warnings and exact commands.\n");
398        for _ in 0..20 {
399            doc.push_str(repeated);
400        }
401
402        let compacted = compact_markdown(&doc).expect("markdown should compact");
403        assert!(compacted.len() < doc.len());
404        assert!(compacted.contains("# Title"));
405        assert!(compacted.contains("## Setup"));
406        assert!(compacted.contains("## Safety"));
407        assert!(compacted.contains("ctx_read"));
408        assert!(compacted.contains("MUST preserve"));
409        assert!(compacted.contains("[lean-ctx: omitted"));
410    }
411
412    #[test]
413    fn keeps_body_lines_from_each_section() {
414        let mut doc = String::from("# Root\n\nRoot intro.\n");
415        for section in 0..8 {
416            doc.push_str(&format!("\n## Section {section}\n\n"));
417            doc.push_str(&format!("Section {section} overview line.\n"));
418            for item in 0..12 {
419                doc.push_str(&format!(
420                    "- repeated filler item {item} for section {section}.\n"
421                ));
422            }
423            doc.push_str(&format!(
424                "BLOCKING section {section} exact requirement with `ctx_read`.\n"
425            ));
426        }
427
428        let compacted = compact_markdown(&doc).expect("markdown should compact");
429        assert!(compacted.len() < doc.len());
430        for section in 0..8 {
431            assert!(compacted.contains(&format!("## Section {section}")));
432            assert!(compacted.contains(&format!("Section {section} overview line.")));
433            assert!(compacted.contains(&format!("BLOCKING section {section}")));
434        }
435        assert!(compacted.contains("[lean-ctx: omitted"));
436    }
437
438    #[test]
439    fn short_docs_pass_through() {
440        assert!(compact_markdown("# Title\n\nSmall.\n").is_none());
441    }
442
443    #[test]
444    fn output_is_deterministic_across_calls() {
445        // #498 regression guard: the score is an f64 sum over per-line token
446        // sets. With unordered sets, near-tied lines could flip across calls
447        // (each std HashSet instance iterates in its own random order), moving
448        // omission markers and changing bytes. Mixed token frequencies below
449        // engineer many near-ties on purpose.
450        let mut doc = String::from("# Determinism\n\nIntro line for the document.\n");
451        for section in 0..4 {
452            doc.push_str(&format!("\n## Section {section}\n\n"));
453            for i in 0..30 {
454                doc.push_str(&format!(
455                    "candidate_{i} shared_token_{} another_token_{} overlapping detail item.\n",
456                    i % 3,
457                    i % 7,
458                ));
459            }
460        }
461
462        let first = compact_markdown(&doc).expect("doc should compact");
463        for _ in 0..16 {
464            let next = compact_markdown(&doc).expect("doc should compact");
465            assert_eq!(first, next, "compaction must be byte-stable across calls");
466        }
467    }
468
469    #[test]
470    fn fenced_blocks_stay_atomic() {
471        let filler = "Ordinary explanatory filler sentence repeated for volume.\n";
472        let mut doc = String::from("# Guide\n\nIntro line.\n\n## Usage\n\n");
473        for _ in 0..30 {
474            doc.push_str(filler);
475        }
476        doc.push_str("Run the exact `ctx_read` command below:\n\n");
477        doc.push_str(
478            "```bash\nlean-ctx read src/lib.rs\n\nlean-ctx search \"ctx_read\" src/\n```\n",
479        );
480        for _ in 0..30 {
481            doc.push_str(filler);
482        }
483
484        let compacted = compact_markdown(&doc).expect("doc should compact");
485
486        // The fence must never be split: fences stay balanced and no omission
487        // marker may appear inside a block.
488        let mut in_fence = false;
489        for line in compacted.lines() {
490            if line.trim_start().starts_with("```") {
491                in_fence = !in_fence;
492            } else if in_fence {
493                assert!(
494                    !line.starts_with("... [lean-ctx:"),
495                    "omission marker inside a fenced block:\n{compacted}"
496                );
497            }
498        }
499        assert!(!in_fence, "unbalanced code fences:\n{compacted}");
500
501        // This block is high-signal, so it must survive whole — including its
502        // blank interior line (kept fences are verbatim).
503        assert!(compacted.contains(
504            "```bash\nlean-ctx read src/lib.rs\n\nlean-ctx search \"ctx_read\" src/\n```"
505        ));
506        assert!(compacted.contains("[lean-ctx: omitted"));
507    }
508
509    #[test]
510    fn heading_inside_fence_is_not_structure() {
511        // A `# comment` inside a code block is code, not a heading: it must not
512        // be force-kept or start a new section.
513        let mut doc = String::from("# Real Heading\n\nIntro.\n\n");
514        doc.push_str("```sh\n# just a shell comment\necho done\n```\n");
515        for i in 0..30 {
516            doc.push_str(&format!("Body filler sentence number {i} for volume.\n"));
517        }
518
519        let compacted = compact_markdown(&doc).expect("doc should compact");
520        let fences = compacted.matches("```").count();
521        assert_eq!(fences % 2, 0, "fences must stay balanced:\n{compacted}");
522    }
523
524    #[test]
525    fn has_markdown_headings_requires_atx_space() {
526        assert!(has_markdown_headings("# Title\nbody\n"));
527        assert!(has_markdown_headings("###\nempty heading is valid\n"));
528        assert!(!has_markdown_headings("#!/usr/bin/env bash\necho hi\n"));
529        assert!(!has_markdown_headings("#pragma once\nplain text\n"));
530        assert!(!has_markdown_headings("- a list\n- alone\n"));
531    }
532}