Skip to main content

runtime/prefix/
segment.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//! One segment: its slot, its bytes, the documents behind it, and the
7//! one row shape those documents are recorded in.
8//!
9//! **Why the row has a single author.** `prompt_assembled` is what an
10//! offline rebuild reads to reconstruct a prefix (A15), and it is also
11//! what a page reads to show a person which files their agent was given.
12//! Two producers of that row — one for a prefix built from a plan, one
13//! for a prefix assembled by hand at the city's own desk — would be two
14//! spellings of the same fact, and `replay::rebuild_prefix` would only
15//! understand whichever it was written against.
16
17use kernel::event::record::PromptSource;
18use kernel::{Address, AxCode, AxError, B3Hash, SystemBlock};
19
20use super::BreakpointPlan;
21
22/// The four slots in stability order; the order is the cache economics.
23/// Exactly four — deliberately exhaustive.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum SegmentSlot {
26    City,
27    Building,
28    Resident,
29    Run,
30}
31
32impl SegmentSlot {
33    /// The four slots in prefix order, which is also the order the
34    /// cache breakpoints are declared in on the wire.
35    pub const ALL: [SegmentSlot; 4] = [
36        SegmentSlot::City,
37        SegmentSlot::Building,
38        SegmentSlot::Resident,
39        SegmentSlot::Run,
40    ];
41
42    pub(crate) fn as_str(self) -> &'static str {
43        match self {
44            SegmentSlot::City => "city",
45            SegmentSlot::Building => "building",
46            SegmentSlot::Resident => "resident",
47            SegmentSlot::Run => "run",
48        }
49    }
50}
51
52/// One frozen segment: static bytes from frozen sources, hashed at
53/// construction. The sole constructor takes bytes, not values — there is
54/// deliberately no `From<TimeMs>` or any other volatile conversion.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct FrozenSegment {
57    slot: SegmentSlot,
58    bytes: Vec<u8>,
59    hash: B3Hash,
60    sources: Vec<SegmentSource>,
61}
62
63impl FrozenSegment {
64    /// A segment whose bytes name no documents - the resident slot is
65    /// built from an identity and a catalog rather than from files.
66    pub fn new(slot: SegmentSlot, bytes: Vec<u8>) -> FrozenSegment {
67        FrozenSegment::assembled(slot, bytes, Vec::new())
68    }
69
70    /// A segment and the documents it was assembled from, which is what
71    /// `prompt_assembled` records and what a page shows a person.
72    pub fn assembled(
73        slot: SegmentSlot,
74        bytes: Vec<u8>,
75        sources: Vec<SegmentSource>,
76    ) -> FrozenSegment {
77        let hash = B3Hash::digest(&bytes);
78        FrozenSegment {
79            slot,
80            bytes,
81            hash,
82            sources,
83        }
84    }
85
86    pub fn sources(&self) -> &[SegmentSource] {
87        &self.sources
88    }
89
90    pub fn slot(&self) -> SegmentSlot {
91        self.slot
92    }
93
94    pub fn hash(&self) -> &B3Hash {
95        &self.hash
96    }
97
98    /// Recomputes this segment's hash from the bytes a request will
99    /// carry and checks it against the hash fixed at construction.
100    ///
101    /// # Errors
102    /// The bytes and the recorded hash disagree — which is the one thing
103    /// a frozen prefix may not survive.
104    pub(crate) fn verified_hash(&self) -> Result<B3Hash, AxError> {
105        let fresh = B3Hash::digest(&self.bytes);
106        if fresh != self.hash {
107            return Err(prefix_drifted(format!(
108                "the {} segment's bytes hash to {fresh}, not the recorded {}",
109                self.slot.as_str(),
110                self.hash
111            )));
112        }
113        Ok(fresh)
114    }
115
116    /// A segment whose stored hash does not describe its bytes, so the
117    /// invariant check has something to refuse. Only a test can build
118    /// one; production reaches the check through the bytes it sends.
119    #[cfg(test)]
120    pub(crate) fn mislabelled(slot: SegmentSlot, bytes: Vec<u8>, hash: B3Hash) -> FrozenSegment {
121        FrozenSegment {
122            slot,
123            bytes,
124            hash,
125            sources: Vec::new(),
126        }
127    }
128
129    pub fn bytes(&self) -> &[u8] {
130        &self.bytes
131    }
132}
133
134/// One document a segment was assembled from, as the assembler saw it.
135///
136/// `kept` is how many bytes of that document reached the segment and
137/// `dropped` is what the slot's budget cut off the end of it, so the
138/// pair states both what the model read and what it did not. A document
139/// that fitted whole reports `dropped: 0`, which is a measurement
140/// rather than an absence.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct SegmentSource {
143    pub addr: Address,
144    pub kept: u64,
145    pub dropped: u64,
146}
147
148impl SegmentSource {
149    /// A document that reached its segment whole.
150    pub fn whole(addr: Address, kept: u64) -> SegmentSource {
151        SegmentSource {
152            addr,
153            kept,
154            dropped: 0,
155        }
156    }
157
158    /// The `prompt_assembled` row for this document.
159    ///
160    /// `marker` is carried because the rebuild reads it: it says a
161    /// truncation marker follows the kept bytes, and the concatenation
162    /// rule cannot be recovered from the byte counts alone.
163    pub(crate) fn row(&self) -> PromptSource {
164        PromptSource {
165            addr: self.addr.clone(),
166            kept: self.kept,
167            marker: self.dropped > 0,
168            dropped: self.dropped,
169        }
170    }
171}
172
173/// The one way out of a frozen session a person can take today, in the
174/// words the verb is spelled with.
175///
176/// Every refusal meaning "this session cannot take this turn" ends
177/// here, so no refusal promises a verb the wire does not carry: every
178/// client can send a task to another address, and this sentence is
179/// where that way out is spelled, once (`crates/runtime/spec/Prefix.lean` §8-4-1).
180pub(crate) const ANOTHER_ADDRESS: &str =
181    "send this task to another address, which opens a session of its own";
182
183/// The refusal a prefix that no longer matches its own record earns.
184///
185/// A session cannot send one prefix and keep calling it frozen, and the
186/// run's recorded history belongs to the bytes it was told; the way out
187/// leaves this run's ledger alone.
188fn prefix_drifted(subject: String) -> AxError {
189    AxError::failure(AxCode::CasCorrupt, "send the frozen prefix", subject).with_recovery(format!(
190        "{ANOTHER_ADDRESS}: the prefix this turn would send is not the one the session \
191         froze, and the two cannot share a history"
192    ))
193}
194
195/// The same assertion over the copy of the segments a request carries:
196/// four system blocks, hashed and compared against the hashes the run
197/// froze. The request is what the provider reads, so this is the check
198/// that the wire form and the record agree.
199///
200/// The edge breakpoints are checked here too, against
201/// [`BreakpointPlan::marks_edge`], because the record states the plan
202/// and the wire must carry what the record states.
203///
204/// # Errors
205/// A request that does not carry four blocks, one that moved a
206/// breakpoint off its edge or added one where the tail anchor lives, or
207/// whose blocks hash to something the run did not freeze.
208pub fn verified_system_hashes(
209    system: &[SystemBlock],
210    frozen: &[B3Hash; 4],
211) -> Result<[B3Hash; 4], AxError> {
212    let [first, second, third, fourth] = system else {
213        return Err(prefix_drifted(format!(
214            "the request carries {} system blocks, not the four the prefix has",
215            system.len()
216        )));
217    };
218    let blocks = [first, second, third, fourth];
219    let mut fresh: [B3Hash; 4] = [B3Hash::digest(b""); 4];
220    for (index, (block, expected)) in blocks.iter().zip(frozen.iter()).enumerate() {
221        let marked = SegmentSlot::ALL
222            .get(index)
223            .is_some_and(|slot| BreakpointPlan::marks_edge(*slot));
224        let slot = SegmentSlot::ALL
225            .get(index)
226            .map_or("unknown", |slot| slot.as_str());
227        if block.cache && !marked {
228            return Err(prefix_drifted(format!(
229                "the {slot} segment carries a cache breakpoint the plan does not put there"
230            )));
231        }
232        if !block.cache && marked {
233            return Err(prefix_drifted(format!(
234                "the {slot} segment lost its cache breakpoint"
235            )));
236        }
237        let hash = B3Hash::digest(block.text.as_bytes());
238        if hash != *expected {
239            return Err(prefix_drifted(format!(
240                "the {slot} segment hashes to {hash}, not the recorded {expected}"
241            )));
242        }
243        if let Some(slot) = fresh.get_mut(index) {
244            *slot = hash;
245        }
246    }
247    Ok(fresh)
248}
249
250#[cfg(test)]
251#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
252mod tests {
253    use super::*;
254
255    /// The four keys `replay::rebuild_prefix` reads, by the names it
256    /// reads them under.
257    #[test]
258    fn a_row_names_the_four_keys_a_rebuild_reads() {
259        let source = SegmentSource {
260            addr: Address::parse("City.md").unwrap(),
261            kept: 12,
262            dropped: 4,
263        };
264        assert_eq!(
265            serde_json::to_value(source.row()).unwrap(),
266            serde_json::json!({ "addr": "City.md", "kept": 12, "marker": true, "dropped": 4 })
267        );
268    }
269
270    /// A document that fitted whole says nothing was cut, rather than
271    /// leaving a reader to infer it from a missing key.
272    #[test]
273    fn a_whole_document_reports_nothing_dropped_and_no_marker() {
274        let source = SegmentSource::whole(Address::parse("City.md").unwrap(), 9);
275        assert_eq!(
276            serde_json::to_value(source.row()).unwrap(),
277            serde_json::json!({ "addr": "City.md", "kept": 9, "marker": false, "dropped": 0 })
278        );
279    }
280}