Skip to main content

runtime/
digest.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//! The digest pipeline: what a long document looks like from outside.
7//!
8//! Three rules hold this module together, and each of them exists because
9//! a summary is lossy and the loss happens before anyone can check it.
10//!
11//! - **A structure tree is mechanical.** Headings, their levels and their
12//!   byte spans are read from the document itself, so this half of a
13//!   digest is reproducible and never disagrees with the source.
14//! - **Anything a model wrote is `suspect`.** Where a digest and the
15//!   source conflict, the source wins: at the moment a conflict is
16//!   noticed the only certain fact is that one of them is wrong, and the
17//!   source is the one nobody rewrote.
18//! - **A content hash is digested once in its life.** The cache is keyed
19//!   by the hash of the bytes, so the same document costs one digest no
20//!   matter how many runs meet it.
21//!
22//! The model call itself is the caller's: this module decides what to ask
23//! for and what to trust, and `bin::assembly` owns the provider.
24
25use kernel::{AxCode, AxError, B3Hash, ByteLen, Locator};
26use serde::{Deserialize, Serialize};
27
28/// One heading and the bytes beneath it.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct StructureNode {
31    /// Heading depth, 1 for `#`. Depth is the document's, not ours.
32    pub level: u8,
33    pub title: String,
34    /// Byte offset of the heading line within the document.
35    pub offset: ByteLen,
36    /// Bytes from this heading up to the next heading of the same or a
37    /// shallower level.
38    pub span: ByteLen,
39}
40
41/// A digest of one document.
42///
43/// `prose` is present only when a model wrote one, and carries `suspect`
44/// with it for as long as it exists. There is no method that clears the
45/// flag: a summary does not stop being a summary by being read twice.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct Digest {
48    source: B3Hash,
49    origin: Option<Locator>,
50    structure: Vec<StructureNode>,
51    prose: Option<String>,
52}
53
54impl Digest {
55    /// The mechanical digest: structure only, and nothing to distrust.
56    #[must_use]
57    pub fn structural(source: B3Hash, origin: Option<Locator>, text: &str) -> Digest {
58        Digest {
59            source,
60            origin,
61            structure: structure_of(text),
62            prose: None,
63        }
64    }
65
66    /// Adds the prose a model wrote. It is suspect from here on, which is
67    /// why this consumes and returns rather than mutating in place: the
68    /// value that carries prose is a different value.
69    #[must_use]
70    pub fn with_prose(mut self, prose: String) -> Digest {
71        self.prose = Some(prose);
72        self
73    }
74
75    #[must_use]
76    pub fn source(&self) -> B3Hash {
77        self.source
78    }
79
80    #[must_use]
81    pub fn origin(&self) -> Option<&Locator> {
82        self.origin.as_ref()
83    }
84
85    #[must_use]
86    pub fn structure(&self) -> &[StructureNode] {
87        &self.structure
88    }
89
90    #[must_use]
91    pub fn prose(&self) -> Option<&str> {
92        self.prose.as_deref()
93    }
94
95    /// True while any part of this digest was written rather than read.
96    #[must_use]
97    pub fn is_suspect(&self) -> bool {
98        self.prose.is_some()
99    }
100
101    /// How a digest introduces itself in a window. A suspect digest says
102    /// so and says where the source is, so the reader can go and check
103    /// rather than believing a paraphrase.
104    #[must_use]
105    pub fn window_header(&self) -> String {
106        let origin = self
107            .origin
108            .as_ref()
109            .map_or_else(|| self.source.to_string(), Locator::to_string);
110        if self.is_suspect() {
111            format!("digest of {origin} (suspect: written, not read; the source decides)")
112        } else {
113            format!("structure of {origin} (read from the source)")
114        }
115    }
116}
117
118/// Reads the heading structure of a markdown document.
119///
120/// Pure and total: a document with no headings has no structure, which is
121/// a fact about the document rather than a failure. Fenced code blocks are
122/// skipped, because a `#` comment inside a shell example is not a heading
123/// and a structure tree that says otherwise would send a reader to a line
124/// that does not exist.
125#[must_use]
126pub fn structure_of(text: &str) -> Vec<StructureNode> {
127    let mut nodes: Vec<StructureNode> = Vec::new();
128    let mut starts: Vec<(usize, usize)> = Vec::new(); // (node index, offset)
129    let mut offset: usize = 0;
130    let mut fenced = false;
131    for line in text.split_inclusive('\n') {
132        let trimmed = line.trim_start();
133        if trimmed.starts_with("```") {
134            fenced = !fenced;
135        } else if !fenced && trimmed.starts_with('#') {
136            let level = trimmed.chars().take_while(|c| *c == '#').count();
137            let title = trimmed
138                .trim_start_matches('#')
139                .trim_matches(|c: char| c == ' ' || c == '\n' || c == '\r')
140                .to_owned();
141            if (1..=6).contains(&level) && !title.is_empty() {
142                let level = u8::try_from(level).unwrap_or(6);
143                close_deeper(&mut nodes, &mut starts, level, offset);
144                starts.push((nodes.len(), offset));
145                nodes.push(StructureNode {
146                    level,
147                    title,
148                    offset: ByteLen::new(u64::try_from(offset).unwrap_or(u64::MAX)),
149                    span: ByteLen::new(0),
150                });
151            }
152        }
153        offset = offset.saturating_add(line.len());
154    }
155    close_deeper(&mut nodes, &mut starts, 0, offset);
156    nodes
157}
158
159/// Closes every open heading at or deeper than `level`, giving each the
160/// span from its own offset to `end`.
161fn close_deeper(
162    nodes: &mut [StructureNode],
163    starts: &mut Vec<(usize, usize)>,
164    level: u8,
165    end: usize,
166) {
167    while let Some((index, start)) = starts.last().copied() {
168        let Some(node) = nodes.get_mut(index) else {
169            starts.pop();
170            continue;
171        };
172        if level != 0 && node.level < level {
173            break;
174        }
175        node.span = ByteLen::new(u64::try_from(end.saturating_sub(start)).unwrap_or(u64::MAX));
176        starts.pop();
177    }
178}
179
180/// The circuit breaker around a digester that can fail.
181///
182/// Counted, not timed: a wall clock inside a decision would break replay,
183/// and "three failures in a row" is a fact the caller can reproduce.
184/// Opening the breaker is not an error — it means the pipeline stops
185/// asking and hands back the source, which is the answer that was always
186/// correct and merely more expensive to read.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub struct Breaker {
189    limit: u32,
190    consecutive: u32,
191}
192
193/// What the pipeline should do next. Exhaustive on purpose.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum BreakerVerdict {
196    /// Ask the digester.
197    Attempt,
198    /// Stop asking and use the source as it is.
199    Open { after: u32 },
200}
201
202impl Breaker {
203    #[must_use]
204    pub fn new(limit: u32) -> Breaker {
205        Breaker {
206            limit,
207            consecutive: 0,
208        }
209    }
210
211    #[must_use]
212    pub fn verdict(&self) -> BreakerVerdict {
213        if self.limit > 0 && self.consecutive >= self.limit {
214            BreakerVerdict::Open {
215                after: self.consecutive,
216            }
217        } else {
218            BreakerVerdict::Attempt
219        }
220    }
221
222    /// One digest came back. The count resets, because the failures this
223    /// breaker cares about are consecutive ones: an intermittent provider
224    /// is a different condition from a broken one.
225    pub fn succeeded(&mut self) {
226        self.consecutive = 0;
227    }
228
229    pub fn failed(&mut self) {
230        self.consecutive = self.consecutive.saturating_add(1);
231    }
232
233    #[must_use]
234    pub fn consecutive_failures(&self) -> u32 {
235        self.consecutive
236    }
237}
238
239/// What one pass over a document produced.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub enum DigestOutcome {
242    /// Read out of the cache; nothing was asked of anyone.
243    Cached(Digest),
244    /// Digested now, and worth writing to the cache under `source`.
245    Fresh(Digest),
246    /// The prose digester failed or was not attempted; the structural
247    /// digest still stands, because reading headings cannot fail.
248    Structural { digest: Digest, reason: AxError },
249}
250
251impl DigestOutcome {
252    #[must_use]
253    pub fn digest(&self) -> &Digest {
254        match self {
255            DigestOutcome::Cached(digest)
256            | DigestOutcome::Fresh(digest)
257            | DigestOutcome::Structural { digest, .. } => digest,
258        }
259    }
260}
261
262/// Digests one document, once in its life.
263///
264/// `cached` answers what the store already holds for this content hash;
265/// `write_prose` is the model call, which the caller owns. The order is
266/// the whole policy: hash, ask the cache, read the structure, and only
267/// then spend a model call — and skip that call entirely once the breaker
268/// is open.
269///
270/// # Errors
271/// Propagates a cache read that fails. A prose failure is not an error
272/// here: it becomes [`DigestOutcome::Structural`], because a document
273/// whose summary failed is still a document with headings.
274pub fn digest_once(
275    text: &str,
276    origin: Option<Locator>,
277    breaker: &mut Breaker,
278    cached: &mut dyn FnMut(&B3Hash) -> Result<Option<Digest>, AxError>,
279    write_prose: &mut dyn FnMut(&str) -> Result<String, AxError>,
280) -> Result<DigestOutcome, AxError> {
281    let source = B3Hash::digest(text.as_bytes());
282    if let Some(hit) = cached(&source)? {
283        return Ok(DigestOutcome::Cached(hit));
284    }
285    let structural = Digest::structural(source, origin, text);
286    if let BreakerVerdict::Open { after } = breaker.verdict() {
287        return Ok(DigestOutcome::Structural {
288            digest: structural,
289            reason: AxError::failure(
290                AxCode::DigestSuspect,
291                "summarise a document",
292                format!("the digester failed {after} times in a row"),
293            )
294            .with_recovery("read the structure and the source; prose resumes after one success"),
295        });
296    }
297    match write_prose(text) {
298        Ok(prose) => {
299            breaker.succeeded();
300            Ok(DigestOutcome::Fresh(structural.with_prose(prose)))
301        }
302        Err(reason) => {
303            breaker.failed();
304            Ok(DigestOutcome::Structural {
305                digest: structural,
306                reason,
307            })
308        }
309    }
310}
311
312#[cfg(test)]
313#[allow(
314    clippy::unwrap_used,
315    clippy::expect_used,
316    clippy::panic,
317    clippy::indexing_slicing,
318    clippy::arithmetic_side_effects,
319    reason = "test code"
320)]
321mod tests;