Skip to main content

runtime/
sieve.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//! What survives a command's output, decided by which command produced
7//! it. `compaction` reads the shape of a text; this reads the identity
8//! of its author, because `cargo build` and `git status` are both logs
9//! and their noise has nothing in common.
10//!
11//! **The replay path never calls a model.** The same seed must replay a
12//! byte-identical window (ARCHITECTURE.md §10), and a compactor that
13//! thinks cannot be replayed. A first run may ask an adviser, and its
14//! answer is a recorded fact (`adviser_asked`, `adviser_answered`,
15//! `adviser_fell_back`) rather than something this module recomputes —
16//! which is why no adviser appears in the stage chain below.
17//!
18//! Seven stages run in a fixed order, each accepted only when it shrank
19//! the text, and every stage that ran — applied, no-op, refused or
20//! unavailable — is carried in the answer: a cut's account, or a pass's
21//! account. Below the floor no stage runs at all and the reason is the
22//! account.
23//!
24//! The tee comes first and is not optional. The original is pinned in
25//! the CAS and materialized as a rest file before anything is cut,
26//! which is why this may cut harder than a compactor that cannot
27//! promise the way back. Parameters are `crates/runtime/spec/Sieve.lean` §8-27-5, and are
28//! not choices to revisit here.
29
30use kernel::{AxError, Locator};
31
32use crate::offload::{OffloadSite, tee};
33
34pub mod diff;
35pub mod filter;
36pub mod key;
37pub mod record;
38pub(crate) mod scan;
39pub(crate) mod stages;
40
41pub use diff::SieveHistory;
42pub use filter::{Filter, FilterTable};
43pub use key::CommandKey;
44pub use record::{
45    PassReason, ResultOffloaded, SieveAccount, SieveRecord, Sieved, Stage, StageOutcome,
46    StageReport,
47};
48
49use scan::{Priority, is_protected, priority};
50use stages::Cuts;
51
52/// Output at or below this passes untouched, without even a tee.
53pub const SIEVE_FLOOR: usize = 2048;
54/// More lines than this and the middle is kept by priority.
55pub const MAX_TOTAL_LINES: usize = 240;
56pub const HEAD_LINES: usize = 40;
57pub const TAIL_LINES: usize = 40;
58pub const MIDDLE_LINES: usize = 60;
59
60pub struct SieveInput<'a> {
61    pub key: &'a CommandKey,
62    pub exit_code: Option<i64>,
63    pub text: &'a str,
64}
65
66fn bytes_of(lines: &[String]) -> u64 {
67    lines
68        .iter()
69        .map(|l| u64::try_from(l.len()).unwrap_or(u64::MAX).saturating_add(1))
70        .fold(0u64, u64::saturating_add)
71}
72
73/// The text as it stands between stages, and the account of every
74/// stage so far. Values that travel together, kept together.
75struct Draft {
76    lines: Vec<String>,
77    account: Vec<StageReport>,
78}
79
80impl Draft {
81    fn step(&mut self, stage: Stage, pass: impl FnOnce(&[String]) -> Vec<String>) {
82        let candidate = pass(&self.lines);
83        self.accept(stage, candidate);
84    }
85
86    /// The one acceptance rule: a stage's result replaces the text only
87    /// when it is not longer. Whatever happened is written down.
88    fn accept(&mut self, stage: Stage, candidate: Vec<String>) {
89        let outcome = if candidate == self.lines {
90            StageOutcome::Noop
91        } else {
92            let before = bytes_of(&self.lines);
93            let after = bytes_of(&candidate);
94            if after > before {
95                StageOutcome::Rejected { grew_to: after }
96            } else {
97                self.lines = candidate;
98                StageOutcome::Applied {
99                    bytes_before: before,
100                    bytes_after: after,
101                }
102            }
103        };
104        self.account.push(StageReport { stage, outcome });
105    }
106
107    fn unavailable(&mut self, stage: Stage, reason: &str) {
108        self.account.push(StageReport {
109            stage,
110            outcome: StageOutcome::Unavailable {
111                reason: reason.to_owned(),
112            },
113        });
114    }
115}
116
117/// What the filter keeps, and at what standing: a kept line at its
118/// own priority or `Warning`, whichever is higher, extended over the
119/// lines that follow it up to a blank one when the filter groups.
120/// These lines are also exempt from template folding.
121fn filter_marks(lines: &[String], filter: &Filter) -> Vec<Option<Priority>> {
122    let mut marks: Vec<Option<Priority>> = vec![None; lines.len()];
123    let mut carried: Option<Priority> = None;
124    for (index, line) in lines.iter().enumerate() {
125        let mark = if filter.keeps(line) {
126            let head = priority(line)
127                .unwrap_or(Priority::Warning)
128                .min(Priority::Warning);
129            carried = filter.group_until_blank.then_some(head);
130            Some(head)
131        } else if line.trim().is_empty() {
132            carried = None;
133            None
134        } else {
135            carried
136        };
137        if let Some(slot) = marks.get_mut(index) {
138            *slot = mark;
139        }
140    }
141    marks
142}
143
144/// Stages 1 to 3, as one normalisation; the previous output is put
145/// through the same one before it is differenced against.
146fn normalised(lines: &[String], filter: &Filter) -> Vec<String> {
147    let stripped = if filter.strip_ansi {
148        stages::strip_ansi(lines)
149    } else {
150        lines.to_vec()
151    };
152    let folded = stages::fold_blank(&stripped);
153    let exempt: Vec<bool> = filter_marks(&folded, filter)
154        .iter()
155        .map(Option::is_some)
156        .collect();
157    stages::dedup_templates(&folded, &exempt)
158}
159
160/// The previous output of this command, normalised the same way, or
161/// the reason there is none.
162fn previous_lines(
163    previous: Option<Locator>,
164    site: &mut OffloadSite<'_>,
165    filter: &Filter,
166) -> Result<Vec<String>, &'static str> {
167    let Some(Locator::Cas { hash, .. }) = previous else {
168        return Err("no previous run of this command in this run");
169    };
170    let bytes = site
171        .cas
172        .get(&hash)
173        .map_err(|_| "previous original is no longer in the CAS")?;
174    let text = String::from_utf8_lossy(&bytes);
175    let lines: Vec<String> = text.lines().map(str::to_owned).collect();
176    Ok(normalised(&lines, filter))
177}
178
179/// Each line's standing for the truncation stage: its own priority or
180/// the filter's, whichever is higher.
181fn marks(lines: &[String], filter: &Filter) -> Vec<Option<Priority>> {
182    lines
183        .iter()
184        .zip(filter_marks(lines, filter))
185        .map(|(line, kept)| match (priority(line), kept) {
186            (Some(own), Some(k)) => Some(own.min(k)),
187            (own, kept) => own.or(kept),
188        })
189        .collect()
190}
191
192fn size(bytes: u64) -> String {
193    if bytes < 1024 {
194        return format!("{bytes} B");
195    }
196    let tenths = bytes.saturating_mul(10).checked_div(1024).unwrap_or(0);
197    format!(
198        "{}.{} KiB",
199        tenths.checked_div(10).unwrap_or(0),
200        tenths.checked_rem(10).unwrap_or(0)
201    )
202}
203
204fn grouped(n: u64) -> String {
205    let digits = n.to_string();
206    let mut out = String::new();
207    for (i, c) in digits.chars().enumerate() {
208        if i > 0 && (digits.len().saturating_sub(i)).checked_rem(3) == Some(0) {
209            out.push(',');
210        }
211        out.push(c);
212    }
213    out
214}
215
216/// Sieves one command output. Order and parameters are the SPEC's;
217/// the tee runs before any stage, and the history is updated whether
218/// or not anything was cut, so the next call can difference against
219/// this one. `Passed` carries the input unchanged.
220pub fn sieve(
221    input: SieveInput<'_>,
222    table: &FilterTable,
223    site: &mut OffloadSite<'_>,
224    history: &mut SieveHistory,
225) -> Result<Sieved, AxError> {
226    let text = input.text;
227    if text.len() <= SIEVE_FLOOR {
228        return Ok(Sieved::Passed {
229            text: text.to_owned(),
230            reason: PassReason::BelowFloor,
231            // No stage runs at all below the floor, so there is no
232            // account to carry and the reason is the whole of it.
233            account: None,
234        });
235    }
236    let pinned = tee(text.as_bytes(), site)?;
237    let previous = history.previous(input.key).cloned();
238    history.record(input.key.clone(), pinned.original.clone());
239    let filter = table.lookup(input.key);
240    let lines: Vec<String> = text.lines().map(str::to_owned).collect();
241    let lines_in = u64::try_from(lines.len()).unwrap_or(u64::MAX);
242    let mut draft = Draft {
243        lines,
244        account: Vec::with_capacity(7),
245    };
246
247    if filter.strip_ansi {
248        draft.step(Stage::StripAnsi, stages::strip_ansi);
249    } else {
250        draft.unavailable(Stage::StripAnsi, "disabled by the filter");
251    }
252    draft.step(Stage::FoldBlank, stages::fold_blank);
253    draft.step(Stage::DedupTemplate, |now| {
254        let exempt: Vec<bool> = filter_marks(now, filter)
255            .iter()
256            .map(Option::is_some)
257            .collect();
258        stages::dedup_templates(now, &exempt)
259    });
260    match previous_lines(previous, site, filter) {
261        Ok(before) => draft.step(Stage::DiffPrevious, |now| diff::only_changes(now, &before)),
262        Err(reason) => draft.unavailable(Stage::DiffPrevious, reason),
263    }
264    draft.step(Stage::Filter, |now| {
265        let kept: Vec<String> = now
266            .iter()
267            .filter(|l| filter.keeps(l) || is_protected(l) || !filter.drops(l))
268            .cloned()
269            .collect();
270        if kept.iter().all(|l| l.trim().is_empty()) {
271            vec![filter.when_empty(input.key, input.exit_code)]
272        } else {
273            kept
274        }
275    });
276    draft.step(Stage::CutLongLine, stages::cut_long_lines);
277    let cuts = Cuts {
278        max_lines: MAX_TOTAL_LINES,
279        head: filter
280            .head
281            .and_then(|h| usize::try_from(h).ok())
282            .unwrap_or(HEAD_LINES),
283        tail: filter
284            .tail
285            .and_then(|t| usize::try_from(t).ok())
286            .unwrap_or(TAIL_LINES),
287        middle: MIDDLE_LINES,
288    };
289    draft.step(Stage::Truncate, |now| {
290        stages::truncate(now, &marks(now, filter), cuts)
291    });
292
293    let Draft { lines, account } = draft;
294    let body = lines.join("\n");
295    let bytes_in = u64::try_from(text.len()).unwrap_or(u64::MAX);
296    let body_len = u64::try_from(body.len()).unwrap_or(u64::MAX);
297    let lines_out = u64::try_from(lines.len()).unwrap_or(u64::MAX);
298    let footer = format!(
299        "[sieve: {} → {} lines, {} → {}, filter={}, rest at {}]",
300        grouped(lines_in),
301        grouped(lines_out),
302        size(bytes_in),
303        size(body_len),
304        filter.id,
305        pinned.rest_path
306    );
307    let out = format!("{body}\n{footer}");
308    if out.len() > text.len() {
309        // Every stage ran and the total did not shrink. The account
310        // travels with the pass instead of being dropped here, so a
311        // result the sieve declined to cut still says what was tried
312        // when it later leaves the window through the plain store.
313        return Ok(Sieved::Passed {
314            text: text.to_owned(),
315            reason: PassReason::NothingShrank,
316            account: Some(SieveAccount {
317                filter: filter.id.clone(),
318                lines_in,
319                // The sieve emitted nothing: the model reads the input,
320                // so the line count it reads is the one it had.
321                lines_out: lines_in,
322                stages: account,
323            }),
324        });
325    }
326    let bytes_out = u64::try_from(out.len()).unwrap_or(u64::MAX);
327    Ok(Sieved::Cut(SieveRecord {
328        text: out,
329        original: pinned.original,
330        rest_path: pinned.rest_path,
331        filter: filter.id.clone(),
332        lines_in,
333        lines_out,
334        bytes_in,
335        bytes_out,
336        stages: account,
337    }))
338}
339
340#[cfg(test)]
341#[allow(
342    clippy::unwrap_used,
343    clippy::expect_used,
344    clippy::panic,
345    clippy::indexing_slicing,
346    clippy::arithmetic_side_effects,
347    clippy::as_conversions,
348    reason = "test code"
349)]
350mod tests;