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}