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;