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}