Skip to main content

runtime/
compaction.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! When to shorten something, and what to keep.
7//!
8//! The mechanism half of this — moving a big result out of the window
9//! and leaving a reference — is `offload`'s and was frozen first. This
10//! is the policy half: given a piece of text, what kind of thing is it,
11//! and which end of it carries the information.
12//!
13//! Three rules hold the whole module up.
14//!
15//! **The result is never longer than the input.** Shortening that
16//! lengthens is not a corner case, it is the failure that makes a
17//! context budget meaningless, so it is an invariant with a test rather
18//! than a property people remember.
19//!
20//! **No regular expressions on this path.** Detection is a handful of
21//! prefix and count checks over the first few lines; a pattern engine
22//! here would put backtracking between a model and its next turn.
23//!
24//! **Cuts land on character boundaries.** A budget in bytes and a cut in
25//! the middle of a character is how a compactor produces text nothing
26//! downstream can read. The boundary rule and the marker that announces
27//! the loss both belong to `elision`, which every shortening path here
28//! shares.
29
30use kernel::ByteLen;
31
32use crate::elision::{self, Cut, Elided};
33
34mod exchange;
35
36pub(crate) use exchange::Exchange;
37
38/// What a piece of text is, as far as shortening is concerned. Eight,
39/// and `Unknown` is one of them: a compactor that had to guess would
40/// guess wrong on exactly the material nobody anticipated.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Content {
43    /// Sentences. The beginning says what it is about.
44    Prose,
45    /// Source. Both ends matter and the middle repeats.
46    Code,
47    /// A patch. The hunk headers are the information.
48    Diff,
49    /// Lines with a shape, most of them the same. The end is the news.
50    Log,
51    /// Structured data. Shortening it makes it unparseable, so it goes
52    /// out of the window whole instead.
53    Structured,
54    /// Rows and columns. The header row is load-bearing.
55    Table,
56    /// A document with a section structure: LaTeX, or Markdown with
57    /// headings. Its bones are the headings, so it is cut at section
58    /// boundaries and the headings survive the cut.
59    Markup,
60    /// Anything else, including binary-looking material.
61    Unknown,
62}
63
64/// Which end of a text survives a shortening.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum Strategy {
67    /// Keep the front. Prose and tables read from the top.
68    Head,
69    /// Keep both ends and mark the gap. Code and diffs are read from
70    /// the edges inward.
71    Ends,
72    /// Keep the back. A log's news is at the end.
73    Tail,
74    /// Keep whole sections from the front, then the headings of the
75    /// sections that did not fit. The reader loses bodies and keeps the
76    /// map of what was written.
77    Sections,
78}
79
80/// What has to happen to one piece of text: decided here, carried out
81/// by the caller.
82///
83/// Exhaustive, and `MustOffload` is why it exists: "do not shorten it"
84/// is an answer the caller cannot read as "shorten it plainly, then",
85/// so this crate holds one answer to whether structured data may be
86/// truncated.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum Shrink {
89    /// It is within budget and goes to the model unchanged.
90    Keep,
91    /// Shorten it, keeping this end.
92    Cut(Strategy),
93    /// Do not shorten it at all — move it out of the window and leave a
94    /// reference. Truncated structured data is worse than absent
95    /// structured data, because it looks like it can be parsed.
96    MustOffload,
97}
98
99/// Detection, in priority order, over the first lines only.
100///
101/// Order is the design: a diff is also code, a table is also prose, and
102/// whichever check runs first wins. Cheapest and most specific first.
103#[must_use]
104pub fn detect(text: &str) -> Content {
105    let trimmed = text.trim_start();
106    if trimmed.is_empty() {
107        return Content::Unknown;
108    }
109    if trimmed.starts_with("diff --git")
110        || trimmed.starts_with("--- ")
111        || trimmed.starts_with("@@ ")
112    {
113        return Content::Diff;
114    }
115    if trimmed.starts_with('{') || trimmed.starts_with('[') || trimmed.starts_with("<?xml") {
116        return Content::Structured;
117    }
118    let head: Vec<&str> = trimmed.lines().take(8).collect();
119    // Markup comes before Table: a LaTeX document's proof tables put two
120    // `|` lines in the first screenful, and a table it is not. It comes
121    // before Code too, so a `.tex` preamble is not read as source.
122    if looks_like_markup(&head) {
123        return Content::Markup;
124    }
125    if head.len() >= 2 && head.iter().filter(|line| line.contains('|')).count() >= 2 {
126        return Content::Table;
127    }
128    if head
129        .iter()
130        .filter(|line| looks_like_a_log_line(line))
131        .count()
132        >= 2
133    {
134        return Content::Log;
135    }
136    if head.iter().any(|line| looks_like_code(line)) {
137        return Content::Code;
138    }
139    if head.iter().any(|line| line.split_whitespace().count() >= 5) {
140        return Content::Prose;
141    }
142    Content::Unknown
143}
144
145/// Whether the first lines read as a document rather than as a program.
146///
147/// LaTeX announces itself with the two commands that open a document.
148/// Markdown is recognised by heading density, and only in a text whose
149/// first lines hold nothing that reads as code: a source file's `# `
150/// comments share the heading shape, and reading them as a skeleton
151/// would cut code while keeping its comments.
152fn looks_like_markup(head: &[&str]) -> bool {
153    if head.iter().any(|line| {
154        let line = line.trim_start();
155        line.starts_with("\\documentclass") || line.starts_with("\\begin{document}")
156    }) {
157        return true;
158    }
159    !head.iter().any(|line| looks_like_code(line))
160        && head.iter().filter(|line| is_atx_heading(line)).count() >= 2
161}
162
163/// One line that reads as source: a declaration keyword, or the pair of
164/// braces and semicolons statements end in.
165fn looks_like_code(line: &str) -> bool {
166    let line = line.trim_start();
167    line.starts_with("fn ")
168        || line.starts_with("def ")
169        || line.starts_with("class ")
170        || line.starts_with("import ")
171        || line.starts_with("use ")
172        || line.ends_with('{')
173        || line.ends_with(';')
174}
175
176/// A Markdown heading: one to six `#`, then a space. A Python comment
177/// with no space after its `#`, and a `#!` line, are neither.
178fn is_atx_heading(line: &str) -> bool {
179    let line = line.trim_start();
180    let hashes = line.chars().take_while(|c| *c == '#').count();
181    (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ')
182}
183
184/// A log line is one that starts with something that is not prose: a
185/// timestamp, a level, or a bracket. Counted rather than matched, so
186/// one stray line does not decide.
187fn looks_like_a_log_line(line: &str) -> bool {
188    let line = line.trim_start();
189    let starts_numeric = line.chars().next().is_some_and(|c| c.is_ascii_digit());
190    starts_numeric
191        || line.starts_with('[')
192        || line.starts_with("INFO")
193        || line.starts_with("WARN")
194        || line.starts_with("ERROR")
195        || line.starts_with("DEBUG")
196}
197
198/// What happens to that kind of content, at that size.
199///
200/// The size comparison lives here rather than at the call site, so that
201/// "does it fit" and "what is done when it does not" are one decision
202/// with one home.
203#[must_use]
204pub fn plan(content: Content, size: ByteLen, budget: ByteLen) -> Shrink {
205    if size.get() <= budget.get() {
206        return Shrink::Keep;
207    }
208    match content {
209        Content::Structured => Shrink::MustOffload,
210        Content::Log => Shrink::Cut(Strategy::Tail),
211        Content::Code | Content::Diff => Shrink::Cut(Strategy::Ends),
212        Content::Prose | Content::Table => Shrink::Cut(Strategy::Head),
213        Content::Markup => Shrink::Cut(Strategy::Sections),
214        // Nothing is known about it, so nothing is thrown away on a
215        // guess: it leaves the window whole and keeps its reference.
216        Content::Unknown => Shrink::MustOffload,
217    }
218}
219
220/// Carries out a shortening the caller has already decided on.
221///
222/// The answer carries the text, the source bytes it lost and which end
223/// lost them, so no caller has to recover the loss by subtracting two
224/// lengths — a subtraction that counted the inserted marker as surviving
225/// text and under-reported the loss by its width.
226///
227/// Taking the strategy rather than deciding it again is what keeps this
228/// module out of the business of what leaves the window: a caller
229/// holding [`Shrink::MustOffload`] has nothing to call here.
230#[must_use]
231pub fn shorten(text: &str, strategy: Strategy, budget: ByteLen) -> Cut {
232    let limit = usize::try_from(budget.get()).unwrap_or(usize::MAX);
233    let cut = match strategy {
234        Strategy::Head => keep_head(text, limit),
235        Strategy::Tail => keep_tail(text, limit),
236        Strategy::Ends => keep_ends(text, limit),
237        Strategy::Sections => keep_sections(text, limit),
238    };
239    // The invariant, checked here rather than trusted: if a strategy
240    // ever produced something longer, the input is returned instead. A
241    // compactor that grows its input makes every budget meaningless.
242    if cut.text.len() >= text.len() {
243        return Cut::whole(text);
244    }
245    cut
246}
247
248fn keep_head(text: &str, limit: usize) -> Cut {
249    let room = elision::marker_room(text.len());
250    let front = elision::boundary_before(text, limit.saturating_sub(room));
251    elision::splice(text, front, text.len(), Elided::Tail)
252}
253
254fn keep_tail(text: &str, limit: usize) -> Cut {
255    let room = elision::marker_room(text.len());
256    let keep = limit.saturating_sub(room);
257    let back = elision::boundary_after(text, text.len().saturating_sub(keep));
258    elision::splice(text, 0, back, Elided::Head)
259}
260
261fn keep_ends(text: &str, limit: usize) -> Cut {
262    let room = elision::gap_marker_room(text.len());
263    let each = limit.saturating_sub(room) / 2;
264    let front = elision::boundary_before(text, each);
265    let back = elision::boundary_after(text, text.len().saturating_sub(each));
266    if back <= front {
267        return keep_head(text, limit);
268    }
269    elision::splice(text, front, back, Elided::Middle)
270}
271
272/// One heading line: where its section begins, and the line's own bytes
273/// with the terminator the span includes. The skeleton is assembled from
274/// source spans only, so the reported loss stays the distance between
275/// what was read and what was kept, and the line travels with its
276/// position because an offset and the bytes it indexes are two facts a
277/// later reader could pair wrongly.
278struct Heading<'a> {
279    start: usize,
280    line: &'a str,
281}
282
283/// Whole sections from the front while they fit, then the headings of
284/// the sections that did not. A reader of a document loses bodies and
285/// keeps the map of what was written, which is what a section cut is
286/// for. Fewer than two headings is a title rather than a structure, and
287/// falls back to keeping the head.
288fn keep_sections(text: &str, limit: usize) -> Cut {
289    let headings = headings_of(text);
290    if headings.len() < 2 {
291        return keep_head(text, limit);
292    }
293    let room = elision::gap_marker_room(text.len());
294    let Some(available) = limit.checked_sub(room) else {
295        return keep_head(text, limit);
296    };
297    // Anything before the first heading (a LaTeX preamble, a Markdown
298    // title block) travels with the first section.
299    let mut head_end = headings.first().map_or(0, |heading| heading.start);
300    if head_end > available {
301        return keep_head(text, limit);
302    }
303    let mut index = 0usize;
304    while let Some(heading) = headings.get(index) {
305        let section_end = headings
306            .get(index.saturating_add(1))
307            .map_or(text.len(), |next| next.start)
308            .max(heading.start);
309        if section_end > available {
310            break;
311        }
312        head_end = section_end;
313        index = index.saturating_add(1);
314    }
315    // Every heading of every dropped section, while it fits: what the
316    // strategy exists to keep is this list.
317    let mut skeleton = String::new();
318    for heading in headings.iter().skip(index) {
319        if head_end
320            .saturating_add(skeleton.len())
321            .saturating_add(heading.line.len())
322            > available
323        {
324            break;
325        }
326        skeleton.push_str(heading.line);
327    }
328    // A section cut keeps whole sections from the front, so its front
329    // position is one the text reports. The bytes and the count below
330    // are taken from that one value: measured apart they are two answers
331    // to "how much of the front survived", and the marker then tells the
332    // reader whichever one is wrong.
333    let head = elision::Boundary::before(text, head_end).head();
334    let dropped = text
335        .len()
336        .saturating_sub(head.len())
337        .saturating_sub(skeleton.len());
338    if dropped == 0 {
339        return keep_head(text, limit);
340    }
341    let survivors = head.len().saturating_add(skeleton.len());
342    let mut out = String::with_capacity(survivors.saturating_add(room));
343    out.push_str(head);
344    out.push_str(&elision::gap_marker(ByteLen::new(
345        u64::try_from(dropped).unwrap_or(u64::MAX),
346    )));
347    out.push_str(&skeleton);
348    Cut {
349        text: out,
350        dropped: ByteLen::new(u64::try_from(dropped).unwrap_or(u64::MAX)),
351        place: Elided::Middle,
352    }
353}
354
355/// Every heading line in the text, in order, with the span it occupies
356/// including its terminator.
357fn headings_of(text: &str) -> Vec<Heading<'_>> {
358    let mut headings = Vec::new();
359    let mut at = 0usize;
360    for piece in text.split_inclusive('\n') {
361        let start = at;
362        let end = at.saturating_add(piece.len());
363        if is_section_heading(piece.trim_end_matches(['\n', '\r'])) {
364            headings.push(Heading { start, line: piece });
365        }
366        at = end;
367    }
368    headings
369}
370
371/// The lines a section cut recognises as structure: a Markdown heading,
372/// or the LaTeX commands that open a section.
373fn is_section_heading(line: &str) -> bool {
374    let line = line.trim_start();
375    is_atx_heading(line)
376        || [
377            "\\section",
378            "\\subsection",
379            "\\chapter",
380            "\\part",
381            "\\paragraph",
382        ]
383        .iter()
384        .any(|command| line.starts_with(command))
385}
386
387#[cfg(test)]
388mod tests;