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