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;