Skip to main content

kernel/event/record/
turn.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//! What one turn records: the prefix it assembled, the call it sent,
7//! the reply that came back, and the steer that arrived at a boundary.
8
9use serde::{Deserialize, Serialize};
10
11use crate::address::Address;
12use crate::budget::UsdMicros;
13use crate::event::identity::TimeMs;
14use crate::event::payload::Payload;
15use crate::locator::B3Hash;
16use crate::model::{ModelUsage, StopReason};
17
18/// `prompt_assembled`: the four segments a request was built from, and
19/// the cache breakpoints it carried.
20///
21/// This is the line an offline rebuild reads (A15): every segment says
22/// which documents went into it and how much of each one survived the
23/// slot's byte cap, so the bytes the model read can be reconstructed
24/// from the files without the request being kept.
25#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
26#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
27pub struct PromptAssembled {
28    #[serde(default)]
29    pub segments: Vec<PromptSegment>,
30    /// The explicit cache breakpoints the request carried, in request
31    /// order: a slot name for a segment edge, `tail` for the last
32    /// message. The plan has one author, `runtime::prefix::BreakpointPlan`,
33    /// and this key carries what that author printed. A ledger written
34    /// before the plan existed holds the four slot names here instead.
35    #[serde(default)]
36    pub breakpoints: Vec<String>,
37}
38
39/// One frozen segment, as `prompt_assembled` states it.
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
42pub struct PromptSegment {
43    pub slot: String,
44    #[cfg_attr(feature = "schema", schemars(with = "String"))]
45    pub hash: B3Hash,
46    /// Bytes this segment contributed to the request.
47    pub len: u64,
48    /// The documents behind the segment, in concatenation order.
49    /// Written even when empty: a segment built from an identity rather
50    /// than from files names none, and that is a fact rather than an
51    /// omission.
52    #[serde(default)]
53    pub sources: Vec<PromptSource>,
54    /// The documents the slot was offered and did not take.
55    #[serde(default)]
56    pub skipped: Vec<PromptSkip>,
57}
58
59/// One document a segment was assembled from.
60///
61/// `kept` and `dropped` state both what the model read and what the cap
62/// cut off; `marker` says a truncation marker follows the kept bytes,
63/// which the byte counts alone cannot say.
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66pub struct PromptSource {
67    pub addr: Address,
68    pub kept: u64,
69    pub marker: bool,
70    pub dropped: u64,
71}
72
73/// One document a slot was offered and left out, and why.
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76pub struct PromptSkip {
77    pub addr: Address,
78    pub reason: SkipReason,
79}
80
81/// Why a document did not reach its segment. Closed: an assembler that
82/// finds a fifth reason states it here, where every reader sees it.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
86pub enum SkipReason {
87    /// An earlier slot already carried this document; the first slot
88    /// wins, because the earlier slot is the more cacheable one.
89    Duplicate,
90    /// The resolver returned no bytes for the address.
91    Unreadable,
92    /// The bytes are not UTF-8, and a prompt is text.
93    NotUtf8,
94    /// The slot's byte cap was already spent.
95    NoBudget,
96}
97
98/// `model_called`: which prefix went out, under which model name.
99///
100/// The request body is deliberately absent: it is reconstructible from
101/// [`PromptAssembled`] plus the window, and a copy of it here would be
102/// the second home of every message the run ever sent.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105pub struct ModelCalled {
106    /// The four segment hashes, in prefix order.
107    #[cfg_attr(feature = "schema", schemars(with = "Vec<String>"))]
108    pub segments: Vec<B3Hash>,
109    /// The endpoint's own model id, as the request spelled it.
110    pub model: String,
111}
112
113/// `model_returned`: the reply, the size of the wave it asked for, and
114/// what the provider said it cost.
115///
116/// Every field but `message` and `calls` is absent when the provider
117/// did not report it, which is not the same fact as reporting zero.
118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
119#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
120pub struct ModelReturned {
121    /// The assistant message, in the canonical content-block shape.
122    pub message: Payload,
123    /// How many tool calls the reply asked for.
124    pub calls: u64,
125    /// Four token counts in the one meaning `ModelUsage` states, the
126    /// dialect that reported them, and the meaning's version `v`.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    #[cfg_attr(
129        feature = "schema",
130        schemars(with = "Option<std::collections::BTreeMap<String, serde_json::Value>>")
131    )]
132    pub usage: Option<ModelUsage>,
133    /// Why the provider stopped, when it said.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    #[cfg_attr(feature = "schema", schemars(with = "Option<String>"))]
136    pub stop: Option<StopReason>,
137    /// What the provider itself billed, when it returns a figure; the
138    /// city's own price list is what fills the gap when it does not.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub billed_usd_micros: Option<UsdMicros>,
141    /// When the reply's first non-empty prose or reasoning reached the
142    /// city, read from the turn's clock (`crates/kernel/spec/Event/Record.lean` ยง8-75). A moment,
143    /// not a duration: the time to first content is this minus the
144    /// `model_called` line's `t`. Absent when the reply came through a
145    /// door that reports nothing before it settles, when it streamed
146    /// only tool calls, and on every line written before the key
147    /// existed - none of which is a zero.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub first_at: Option<TimeMs>,
150    /// Whole microseconds from the moment the attempt that returned went
151    /// out to its first non-empty prose or reasoning, on the turn's
152    /// monotonic clock (`crates/kernel/spec/Event/Record.lean` D20).
153    /// Absent exactly where `first_at` is.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub first_us: Option<u64>,
156    /// Whole microseconds from the moment the attempt that returned went
157    /// out to the moment its reply was whole, on the same clock. Absent
158    /// on every line written before the key existed.
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub took_us: Option<u64>,
161}
162
163/// `steer_received`: text added to a run at a phase boundary, and who
164/// spoke it.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
167pub struct SteerReceived {
168    /// [`SteerReceived::PERSON_SOURCE`], [`SteerReceived::CITY_SOURCE`],
169    /// or a resident's `@<room>` followed by its letter's attributes.
170    pub source: String,
171    pub text: String,
172}
173
174impl SteerReceived {
175    /// The `source` of a steer the person spoke. `runtime::conversation`
176    /// writes it and `wire::note_of` reads it; the spelling lives here
177    /// because both depend on this crate and neither on the other
178    /// (`crates/wire/spec/Reading.lean` D41).
179    pub const PERSON_SOURCE: &'static str = "user";
180    /// The `source` of the city's own word to a run: a policy change, a
181    /// sync wait that ran out.
182    pub const CITY_SOURCE: &'static str = "city";
183}
184
185/// One part of a request's cache shape, as a record states it: how many
186/// bytes the part carries, and their hash.
187///
188/// Both figures because two questions read this row. A prompt cache
189/// compares the hash; a person asking why a request is expensive needs
190/// the bytes, which no hash can answer for.
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
193pub struct ShapePart {
194    pub bytes: u64,
195    #[cfg_attr(feature = "schema", schemars(with = "String"))]
196    pub hash: B3Hash,
197}
198
199/// Whether one region of a request differs from the request before it.
200/// A closed pair rather than a bare `bool`, so a reader meets the word
201/// that says which way it went.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
205pub enum PartChange {
206    Same,
207    Moved,
208}
209
210/// What moved between one request's cache shape and the previous one.
211///
212/// `FirstRequest` is a variant rather than a silent "nothing moved": a
213/// run's first request has no earlier shape to compare against, and
214/// recording it as unchanged would read as a cache hit about to happen.
215/// A miss is explained by naming what moved, which is what the second
216/// variant is for.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(tag = "basis", rename_all = "snake_case")]
219#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
220pub enum ShapeChanged {
221    FirstRequest,
222    Compared {
223        /// The frozen segments whose bytes differ, by slot name, in
224        /// prefix order. Empty means the system half is unchanged.
225        system: Vec<String>,
226        tools: PartChange,
227        run: PartChange,
228    },
229}
230
231/// `prompt_shape_compared`: one request's cache shape where a prompt
232/// cache looks at it, and which of its regions moved since the request
233/// before it in the same run.
234///
235/// The four segments are deliberately absent: `prompt_assembled`,
236/// written immediately before this line, already states each one's hash
237/// and length, and a second copy here would be a second home for them.
238/// What this adds are the two regions that record never names - the tool
239/// table and the conversation - and the comparison itself.
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242pub struct PromptShapeCompared {
243    pub tools: ShapePart,
244    pub run: ShapePart,
245    /// The most bytes this request can be billed as input, counted from
246    /// the parts above and the segments `prompt_assembled` states, added
247    /// with checked arithmetic.
248    pub upper_bound: u64,
249    pub changed: ShapeChanged,
250}
251
252#[cfg(test)]
253#[allow(
254    clippy::unwrap_used,
255    clippy::expect_used,
256    clippy::panic,
257    clippy::indexing_slicing,
258    reason = "test code"
259)]
260mod tests;