Skip to main content

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}