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}
151
152/// `steer_received`: text a person added at a phase boundary, and where
153/// it came from.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
156pub struct SteerReceived {
157    pub source: String,
158    pub text: String,
159}
160
161/// One part of a request's cache shape, as a record states it: how many
162/// bytes the part carries, and their hash.
163///
164/// Both figures because two questions read this row. A prompt cache
165/// compares the hash; a person asking why a request is expensive needs
166/// the bytes, which no hash can answer for.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
169pub struct ShapePart {
170    pub bytes: u64,
171    #[cfg_attr(feature = "schema", schemars(with = "String"))]
172    pub hash: B3Hash,
173}
174
175/// Whether one region of a request differs from the request before it.
176/// A closed pair rather than a bare `bool`, so a reader meets the word
177/// that says which way it went.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
181pub enum PartChange {
182    Same,
183    Moved,
184}
185
186/// What moved between one request's cache shape and the previous one.
187///
188/// `FirstRequest` is a variant rather than a silent "nothing moved": a
189/// run's first request has no earlier shape to compare against, and
190/// recording it as unchanged would read as a cache hit about to happen.
191/// A miss is explained by naming what moved, which is what the second
192/// variant is for.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(tag = "basis", rename_all = "snake_case")]
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196pub enum ShapeChanged {
197    FirstRequest,
198    Compared {
199        /// The frozen segments whose bytes differ, by slot name, in
200        /// prefix order. Empty means the system half is unchanged.
201        system: Vec<String>,
202        tools: PartChange,
203        run: PartChange,
204    },
205}
206
207/// `prompt_shape_compared`: one request's cache shape where a prompt
208/// cache looks at it, and which of its regions moved since the request
209/// before it in the same run.
210///
211/// The four segments are deliberately absent: `prompt_assembled`,
212/// written immediately before this line, already states each one's hash
213/// and length, and a second copy here would be a second home for them.
214/// What this adds are the two regions that record never names - the tool
215/// table and the conversation - and the comparison itself.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
218pub struct PromptShapeCompared {
219    pub tools: ShapePart,
220    pub run: ShapePart,
221    /// The most bytes this request can be billed as input, counted from
222    /// the parts above and the segments `prompt_assembled` states, added
223    /// with checked arithmetic.
224    pub upper_bound: u64,
225    pub changed: ShapeChanged,
226}
227
228#[cfg(test)]
229#[allow(
230    clippy::unwrap_used,
231    clippy::expect_used,
232    clippy::panic,
233    clippy::indexing_slicing,
234    reason = "test code"
235)]
236mod tests;