Skip to main content

runtime/
prefix.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//! Frozen prefix assembly: four segments, assembled
7//! once at Run start, never reassembled within the Run. Volatile types
8//! (TimeMs, usage, signals) have no conversion into `FrozenSegment` — the
9//! absence of those impls is the isolation guarantee (15.3-4).
10
11use std::collections::BTreeSet;
12
13use kernel::consts_policy::{BYTES_PER_TOKEN, PREFIX_SLOTS, STARTUP_BUDGET_TOKENS};
14use kernel::event::record::{PromptAssembled, PromptSegment, PromptSkip, SkipReason};
15use kernel::{Address, AxCode, AxError, B3Hash, Payload, SystemBlock};
16
17use crate::elision::{self, Elided};
18
19mod breakpoint;
20mod segment;
21
22pub(crate) mod shape;
23pub mod warmth;
24
25pub use breakpoint::{Breakpoint, BreakpointPlan};
26
27pub(crate) use segment::ANOTHER_ADDRESS;
28pub use segment::{FrozenSegment, SegmentSlot, SegmentSource};
29
30/// Documents inside one segment join on this separator; the rebuilder
31/// (replay) reuses it — one authority for the concatenation rule.
32pub(crate) const DOC_JOIN: &str = "\n\n";
33
34/// One prefix source document: address plus its frozen bytes, `None`
35/// when missing or unreadable (the skip itself is accounted).
36#[derive(Debug, Clone)]
37pub struct SourceDoc {
38    pub addr: Address,
39    pub bytes: Option<Vec<u8>>,
40}
41
42/// Per-slot byte budgets in bytes, converted from a token budget at
43/// [`BYTES_PER_TOKEN`] and divided across [`PREFIX_SLOTS`]. Callers
44/// derive them from config; the default splits the startup budget.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct SegmentCaps {
47    pub city: u64,
48    pub building: u64,
49    pub resident: u64,
50    pub run: u64,
51}
52
53impl SegmentCaps {
54    pub fn startup_default() -> SegmentCaps {
55        let per_slot = STARTUP_BUDGET_TOKENS.saturating_mul(BYTES_PER_TOKEN) / PREFIX_SLOTS;
56        SegmentCaps {
57            city: per_slot,
58            building: per_slot,
59            resident: per_slot,
60            run: per_slot,
61        }
62    }
63}
64
65/// The four source lists plus caps: everything `build_prefix` needs.
66#[derive(Debug, Clone)]
67pub struct PrefixPlan {
68    pub city: Vec<SourceDoc>,
69    pub building: Vec<SourceDoc>,
70    pub resident: Vec<SourceDoc>,
71    pub run: Vec<SourceDoc>,
72    pub caps: SegmentCaps,
73}
74
75/// Builds the four segments from source documents: UTF-8 only, per-slot
76/// caps with explicit truncation markers, cross-slot dedup by address
77/// (first slot wins), skips accounted in the notes. The notes travel
78/// inside the returned prefix and surface in `prompt_assembled` — they
79/// are what makes the prefix offline-rebuildable (A15, C16).
80pub fn build_prefix(plan: PrefixPlan) -> Result<FrozenPrefix, AxError> {
81    let mut seen: BTreeSet<String> = BTreeSet::new();
82    let mut skipped = Vec::new();
83    let mut built = Vec::new();
84    let slots = [
85        (SegmentSlot::City, &plan.city, plan.caps.city),
86        (SegmentSlot::Building, &plan.building, plan.caps.building),
87        (SegmentSlot::Resident, &plan.resident, plan.caps.resident),
88        (SegmentSlot::Run, &plan.run, plan.caps.run),
89    ];
90    for (slot, docs, cap) in slots {
91        let (segment, note) = build_segment(slot, docs, cap, &mut seen)?;
92        skipped.push(note);
93        built.push(segment);
94    }
95    let mut iter = built.into_iter();
96    let (Some(city), Some(building), Some(resident), Some(run)) =
97        (iter.next(), iter.next(), iter.next(), iter.next())
98    else {
99        return Err(AxError::failure(
100            AxCode::InvalidArgs,
101            "build prefix",
102            "segment construction lost a slot",
103        )
104        .with_recovery(
105            "report this against runtime::prefix: a prefix is four segments, city, \
106             building, resident and run, and fewer than four were built",
107        ));
108    };
109    let mut prefix = FrozenPrefix::assemble(city, building, resident, run)?;
110    prefix.skipped = Some(skipped);
111    Ok(prefix)
112}
113
114#[expect(
115    clippy::arithmetic_side_effects,
116    reason = "budget arithmetic on usize values bounded by document sizes already held \
117              in memory; every subtraction is guarded by a preceding comparison"
118)]
119fn build_segment(
120    slot: SegmentSlot,
121    docs: &[SourceDoc],
122    cap: u64,
123    seen: &mut BTreeSet<String>,
124) -> Result<(FrozenSegment, Vec<PromptSkip>), AxError> {
125    let cap = usize::try_from(cap).map_err(|_| {
126        AxError::failure(
127            AxCode::InvalidArgs,
128            "build prefix segment",
129            "cap exceeds usize",
130        )
131        .with_recovery(
132            "lower this slot's byte cap in `[prefix]`: it is larger than this machine \
133             can address",
134        )
135    })?;
136    let mut text = String::new();
137    let mut sources = Vec::new();
138    let mut skipped = Vec::new();
139    for doc in docs {
140        let addr = doc.addr.as_str().to_owned();
141        let left_out = |reason: SkipReason| PromptSkip {
142            addr: doc.addr.clone(),
143            reason,
144        };
145        if seen.contains(&addr) {
146            skipped.push(left_out(SkipReason::Duplicate));
147            continue;
148        }
149        let Some(bytes) = &doc.bytes else {
150            skipped.push(left_out(SkipReason::Unreadable));
151            continue;
152        };
153        let Ok(body) = std::str::from_utf8(bytes) else {
154            skipped.push(left_out(SkipReason::NotUtf8));
155            continue;
156        };
157        let joiner = if text.is_empty() { 0 } else { DOC_JOIN.len() };
158        let used = text.len() + joiner;
159        let remaining = cap.saturating_sub(used);
160        if body.len() <= remaining {
161            if joiner > 0 {
162                text.push_str(DOC_JOIN);
163            }
164            text.push_str(body);
165            seen.insert(addr.clone());
166            sources.push(SegmentSource::whole(
167                doc.addr.clone(),
168                u64::try_from(body.len()).unwrap_or(u64::MAX),
169            ));
170            continue;
171        }
172        let worst_marker = elision::marker_room(body.len());
173        if remaining <= worst_marker {
174            skipped.push(left_out(SkipReason::NoBudget));
175            continue;
176        }
177        let kept = elision::boundary_before(body, remaining.saturating_sub(worst_marker));
178        // The kept bytes, the marker and the count it carries are one
179        // assembly, and `elision` owns it: a tail cut spelled again here
180        // is how a marker and its number come apart.
181        let cut = elision::splice(body, kept, body.len(), Elided::Tail);
182        if joiner > 0 {
183            text.push_str(DOC_JOIN);
184        }
185        text.push_str(&cut.text);
186        seen.insert(addr.clone());
187        sources.push(SegmentSource {
188            addr: doc.addr.clone(),
189            kept: u64::try_from(kept).unwrap_or(u64::MAX),
190            dropped: cut.dropped.get(),
191        });
192    }
193    Ok((
194        FrozenSegment::assembled(slot, text.into_bytes(), sources),
195        skipped,
196    ))
197}
198
199/// The same assertion over the copy of the segments a request carries:
200/// four cache-marked system blocks, hashed and compared against the
201/// hashes the run froze. The request is what the provider reads, so this
202/// is the check that the wire form and the record agree.
203///
204/// **The judgement lives in `prefix/segment.rs`**, next to the bytes it
205/// hashes; this line is the path a caller uses.
206pub use segment::verified_system_hashes;
207
208/// The assembled prefix: city, building, resident, run — in that order,
209/// enforced at the only constructor.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct FrozenPrefix {
212    city: FrozenSegment,
213    building: FrozenSegment,
214    resident: FrozenSegment,
215    run: FrozenSegment,
216    /// What each slot left out, in slot order, as `build_prefix` found
217    /// it. `None` is a prefix assembled from bytes the caller already
218    /// held, which skipped nothing because it was never offered a list
219    /// of documents.
220    skipped: Option<Vec<Vec<PromptSkip>>>,
221}
222
223impl FrozenPrefix {
224    /// Slot order is the type: a segment in the wrong position is
225    /// `E_INVALID_ARGS` (fail-closed, never silently reordered).
226    pub fn assemble(
227        city: FrozenSegment,
228        building: FrozenSegment,
229        resident: FrozenSegment,
230        run: FrozenSegment,
231    ) -> Result<FrozenPrefix, AxError> {
232        let expected = [
233            (SegmentSlot::City, city.slot()),
234            (SegmentSlot::Building, building.slot()),
235            (SegmentSlot::Resident, resident.slot()),
236            (SegmentSlot::Run, run.slot()),
237        ];
238        for (want, got) in expected {
239            if want != got {
240                return Err(AxError::failure(
241                    AxCode::InvalidArgs,
242                    "assemble frozen prefix",
243                    format!("slot {} holds a {} segment", want.as_str(), got.as_str()),
244                )
245                .with_recovery("pass segments in slot order: city, building, resident, run"));
246            }
247        }
248        Ok(FrozenPrefix {
249            city,
250            building,
251            resident,
252            run,
253            skipped: None,
254        })
255    }
256
257    /// The wire form of the frozen prefix: four system blocks, each
258    /// carrying the edge breakpoint [`BreakpointPlan::marks_edge`] gives
259    /// its slot.
260    /// Segments must be UTF-8 (build_prefix guarantees it; hand-built
261    /// test prefixes must comply to reach the wire).
262    pub fn system_blocks(&self) -> Result<Vec<SystemBlock>, AxError> {
263        self.segments()
264            .iter()
265            .map(|segment| {
266                let text = std::str::from_utf8(segment.bytes())
267                    .map_err(|_| {
268                        AxError::failure(
269                            AxCode::InvalidArgs,
270                            "render system blocks",
271                            format!("{} segment is not utf-8", segment.slot().as_str()),
272                        )
273                        .with_recovery(format!(
274                            "save the documents feeding the {} slot as UTF-8; a model \
275                             prompt carries text and nothing else",
276                            segment.slot().as_str()
277                        ))
278                    })?
279                    .to_owned();
280                Ok(SystemBlock {
281                    text,
282                    cache: BreakpointPlan::marks_edge(segment.slot()),
283                })
284            })
285            .collect()
286    }
287
288    pub fn segments(&self) -> [&FrozenSegment; 4] {
289        [&self.city, &self.building, &self.resident, &self.run]
290    }
291
292    pub fn segment_hashes(&self) -> [B3Hash; 4] {
293        [
294            *self.city.hash(),
295            *self.building.hash(),
296            *self.resident.hash(),
297            *self.run.hash(),
298        ]
299    }
300
301    /// The four segment hashes, recomputed from the bytes that will be
302    /// sent and checked against the hashes recorded at assembly.
303    ///
304    /// [`segment_hashes`](Self::segment_hashes) answers with the values
305    /// fixed at construction, and nothing in the tree compared them
306    /// against the bytes. A turn that recalculates here cannot send
307    /// bytes the recorded prefix does not describe.
308    ///
309    /// # Errors
310    /// A segment whose bytes no longer hash to what the run froze.
311    pub fn verified_segment_hashes(&self) -> Result<[B3Hash; 4], AxError> {
312        let [city, building, resident, run] = self.segments();
313        Ok([
314            city.verified_hash()?,
315            building.verified_hash()?,
316            resident.verified_hash()?,
317            run.verified_hash()?,
318        ])
319    }
320
321    /// The `prompt_assembled` payload: four rows, each naming its slot,
322    /// its hash, its length, the documents it was assembled from and
323    /// what it left out, and the breakpoints `plan` puts in the request.
324    ///
325    /// One branch, not two. The source rows are read back by an offline
326    /// rebuild (A15, C16) and by the page that shows a person what
327    /// their agent was told, and they come from the segments themselves
328    /// — so a prefix assembled at the city's desk records the same rows
329    /// under the same keys as one built from a plan, and a segment made
330    /// of no documents says so with an empty list.
331    ///
332    /// # Errors
333    /// A segment longer than `u64` can hold, and a payload the Ledger
334    /// refuses.
335    pub fn prompt_payload(&self, plan: &BreakpointPlan) -> Result<Payload, AxError> {
336        let mut segments = Vec::new();
337        for (index, segment) in self.segments().into_iter().enumerate() {
338            let len = u64::try_from(segment.bytes().len()).map_err(|_| {
339                AxError::failure(
340                    AxCode::InvalidArgs,
341                    "encode prompt payload",
342                    "segment length exceeds u64",
343                )
344                .with_recovery(
345                    "lower the slot byte caps in `[prefix]`: one frozen segment is \
346                     longer than a byte count this city can hold",
347                )
348            })?;
349            segments.push(PromptSegment {
350                slot: segment.slot().as_str().to_owned(),
351                hash: *segment.hash(),
352                len,
353                sources: segment.sources().iter().map(SegmentSource::row).collect(),
354                skipped: self
355                    .skipped
356                    .as_ref()
357                    .and_then(|all| all.get(index))
358                    .cloned()
359                    .unwrap_or_default(),
360            });
361        }
362        Payload::of(&PromptAssembled {
363            segments,
364            breakpoints: plan
365                .breakpoints()
366                .into_iter()
367                .map(|breakpoint| breakpoint.as_str().to_owned())
368                .collect(),
369        })
370    }
371}
372
373#[cfg(test)]
374#[allow(
375    clippy::unwrap_used,
376    clippy::expect_used,
377    clippy::panic,
378    clippy::indexing_slicing,
379    reason = "test code"
380)]
381mod tests;