kernel/event/record/adviser.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 consultation of the adviser records: the question put to it,
7//! the answer it gave, and the fallback when no answer was used.
8//!
9//! **The fallback line is the point of the family.** An adviser that is
10//! slow, absent or unreadable must not leave the city's behaviour to be
11//! inferred: the deterministic strategy answered instead, and that is a
12//! fact about the move the city made. Without this line a window that no
13//! adviser shaped and a window an adviser shaped would fold to the same
14//! history.
15//!
16//! **Two questions never appear here.** The model a run talks to and the
17//! effort it thinks with are frozen before the prefix exists, so an
18//! adviser never answers them; the one `Choice` this vocabulary holds is
19//! asked when a session's model is chosen, before anything is frozen.
20
21use serde::{Deserialize, Serialize};
22
23/// The three questions the adviser port takes. Closed: the answer types
24/// below pair with these variant for variant, so a reader can never pair
25/// an answer with a question that was not asked.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
29pub enum AdviserAsk {
30 /// "Is this still needed?" - asked of one item that is about to
31 /// leave or stay in the window.
32 Noul,
33 /// "How dense is this text?" - the figure the window's budget
34 /// spends before it cuts.
35 Score,
36 /// "Which of these?" - asked when a session's model is chosen, and
37 /// nowhere else.
38 Choice,
39}
40
41/// `adviser_asked`: which question was put, and about what.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44pub struct AdviserAsked {
45 /// The question.
46 pub ask: AdviserAsk,
47 /// What it was about, in the asking side's own words - a tool call's
48 /// id, a slot's name. The caller owns the spelling, as it does for
49 /// `ToolCalled.id`.
50 pub subject: String,
51}
52
53/// How the adviser answered.
54///
55/// The tag is the question, and the answer types are the three answer
56/// shapes: this is why one enum rather than one answer type with three
57/// optional fields, which would let a line hold an answer to a question
58/// nobody asked.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(tag = "ask", rename_all = "snake_case")]
61#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
62pub enum AdviserAnswer {
63 /// `keep` is the answer to "is this still needed?"; `confidence_bp`
64 /// is how sure the adviser was, in basis points of one. A
65 /// probability reaches the ledger scaled to an integer, because no
66 /// payload here carries a float.
67 Noul { keep: bool, confidence_bp: u16 },
68 /// The density, in basis points of one.
69 Score { score_bp: u16 },
70 /// The chosen option, as the asking side spelled it in the question.
71 Choice { chosen: String },
72}
73
74/// `adviser_answered`: what the adviser said, and how long it took.
75///
76/// This is the line a replay reads instead of asking again: an answer
77/// that reached the window is as much a recorded fact as the reply a
78/// model returned.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct AdviserAnswered {
82 /// What the question was about, spelled as [`AdviserAsked`] spelled
83 /// it.
84 pub subject: String,
85 #[serde(flatten)]
86 pub answer: AdviserAnswer,
87 /// How long the adviser took. Recorded because a second opinion on
88 /// the city's critical path is a cost, and one that grew has to be
89 /// visible without re-running it.
90 pub elapsed_ms: u64,
91}
92
93/// Why no adviser answer reached the window. Closed: a fallback this
94/// vocabulary cannot name is a fallback nobody can count.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
98pub enum AdviserFailure {
99 /// No endpoint was attached for the adviser, or the port refused the
100 /// call.
101 Unavailable,
102 /// The port gave up waiting.
103 Timeout,
104 /// An answer arrived and it is not the answer to the question that
105 /// was asked. The words are not kept: what a reader needs is that
106 /// the deterministic strategy answered instead.
107 Unreadable,
108}
109
110/// `adviser_fell_back`: the deterministic strategy answered, and why.
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
112#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
113pub struct AdviserFellBack {
114 /// What the question was about, spelled as [`AdviserAsked`] spelled
115 /// it. What was asked is not repeated here: the asked line carries
116 /// it, and two homes for it could disagree.
117 pub subject: String,
118 /// Why the adviser could not answer.
119 pub reason: AdviserFailure,
120}
121
122#[cfg(test)]
123#[allow(
124 clippy::unwrap_used,
125 clippy::expect_used,
126 clippy::panic,
127 clippy::indexing_slicing,
128 reason = "test code"
129)]
130mod tests {
131 use super::*;
132 use crate::event::Payload;
133
134 #[test]
135 fn an_asked_line_carries_the_question_and_its_subject() {
136 let asked = AdviserAsked {
137 ask: AdviserAsk::Noul,
138 subject: "toolu_01".to_owned(),
139 };
140 let payload = Payload::of(&asked).unwrap();
141 assert_eq!(
142 serde_json::to_string(&payload).unwrap(),
143 "{\"ask\":\"noul\",\"subject\":\"toolu_01\"}"
144 );
145 assert_eq!(payload.read::<AdviserAsked>().unwrap(), asked);
146 }
147
148 /// The three answer shapes, one line each: the tag says which
149 /// question the answer belongs to, so a reader never has to find the
150 /// asked line to interpret it.
151 #[test]
152 fn each_answer_shape_keeps_its_own_keys_under_one_tag() {
153 for (answer, wire) in [
154 (
155 AdviserAnswer::Noul {
156 keep: false,
157 confidence_bp: 8_750,
158 },
159 "{\"ask\":\"noul\",\"confidence_bp\":8750,\"elapsed_ms\":41,\"keep\":false,\"subject\":\"toolu_01\"}",
160 ),
161 (
162 AdviserAnswer::Score { score_bp: 420 },
163 "{\"ask\":\"score\",\"elapsed_ms\":41,\"score_bp\":420,\"subject\":\"toolu_01\"}",
164 ),
165 (
166 AdviserAnswer::Choice {
167 chosen: "claude-sonnet-5".to_owned(),
168 },
169 "{\"ask\":\"choice\",\"chosen\":\"claude-sonnet-5\",\"elapsed_ms\":41,\"subject\":\"toolu_01\"}",
170 ),
171 ] {
172 let answered = AdviserAnswered {
173 subject: "toolu_01".to_owned(),
174 answer,
175 elapsed_ms: 41,
176 };
177 let payload = Payload::of(&answered).unwrap();
178 assert_eq!(serde_json::to_string(&payload).unwrap(), wire);
179 assert_eq!(payload.read::<AdviserAnswered>().unwrap(), answered);
180 }
181 }
182
183 #[test]
184 fn a_fallback_line_names_the_subject_and_the_reason() {
185 let fell = AdviserFellBack {
186 subject: "toolu_01".to_owned(),
187 reason: AdviserFailure::Timeout,
188 };
189 let payload = Payload::of(&fell).unwrap();
190 assert_eq!(
191 serde_json::to_string(&payload).unwrap(),
192 "{\"reason\":\"timeout\",\"subject\":\"toolu_01\"}"
193 );
194 assert_eq!(payload.read::<AdviserFellBack>().unwrap(), fell);
195 }
196}