Skip to main content

runtime/pipeline/
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//! The window's adviser: three typed questions, one recorded answer, and
7//! the deterministic strategy that answers when no adviser does.
8//!
9//! **The adviser is the window's, never the prefix's.** The port below
10//! takes a [`Conversation`] and nothing else — no `FrozenConfig`, no frozen
11//! prefix, no call shape — so a question that would move the cached half
12//! of a request cannot be spelled. Freezing the model and the effort is
13//! the session's business: a consultant asked to re-decide either would
14//! invalidate the message cache breakpoints on every turn, which is the
15//! one thing the frozen half exists to prevent.
16//!
17//! **The call happens outside the sieve, and its verdict enters
18//! [`crate::pipeline::package`].** The sieve chain is synchronous and
19//! fallible in no place a consultant could be awaited; asking before the
20//! packaging decision and handing the answer in keeps the chain as it
21//! is.
22//!
23//! **A fallback is never silent.** When no adviser is attached, when it
24//! does not answer, or when it answers a question that was not asked,
25//! the consultation says so, and [`Consultation::payloads`] gives the
26//! caller the two ledger lines (`adviser_asked` then `adviser_answered`
27//! or `adviser_fell_back`) that make the fallback a recorded fact. The
28//! answer the city then acts on is the same one it acted on before any
29//! adviser existed, because a consultation with no answer changes
30//! nothing.
31//!
32//! The port is a boxed function rather than a `pub trait` because the
33//! seam table in ARCHITECTURE §4 is add-only and lists every file a
34//! `pub trait` may live in. The implementations are still plural — an
35//! attached endpoint in `bin::assembly`, a script here — and the
36//! function value carries exactly the two arguments a trait method
37//! would.
38
39use kernel::event::record::{
40    AdviserAnswer, AdviserAnswered, AdviserAsked, AdviserFailure, AdviserFellBack,
41};
42use kernel::{AxError, Payload};
43
44use crate::conversation::Conversation;
45
46/// Which of the three questions is being asked. Re-exported from
47/// `kernel::event::record`, whose payload vocabulary is the question's
48/// one home; an answer is validated against it before anything acts.
49pub use kernel::event::record::AdviserAsk;
50
51/// One question, spelled for the adviser.
52///
53/// `material` is the text under judgement — a tool result about to be
54/// packaged — and `options` is non-empty only for a `Choice`. The two
55/// are fields of the question rather than a second argument, so a caller
56/// cannot pair a `Choice` with material or a density score with options.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Ask {
59    pub kind: AdviserAsk,
60    pub subject: String,
61    pub options: Vec<String>,
62    pub material: Option<String>,
63}
64
65impl Ask {
66    /// "Is this still needed?" about one item on its way into the window.
67    pub fn noul(subject: impl Into<String>, material: impl Into<String>) -> Ask {
68        Ask {
69            kind: AdviserAsk::Noul,
70            subject: subject.into(),
71            options: Vec::new(),
72            material: Some(material.into()),
73        }
74    }
75
76    /// "How dense is this text?" — the figure the window's budget spends
77    /// before it cuts.
78    pub fn score(subject: impl Into<String>, material: impl Into<String>) -> Ask {
79        Ask {
80            kind: AdviserAsk::Score,
81            subject: subject.into(),
82            options: Vec::new(),
83            material: Some(material.into()),
84        }
85    }
86
87    /// "Which of these?" — asked when a session's model is chosen, before
88    /// anything is frozen, and nowhere else.
89    pub fn choice(subject: impl Into<String>, options: Vec<String>) -> Ask {
90        Ask {
91            kind: AdviserAsk::Choice,
92            subject: subject.into(),
93            options,
94            material: None,
95        }
96    }
97}
98
99/// What one consultation produced, as the ledger keeps it and as
100/// [`crate::pipeline::package`] acts on it.
101///
102/// Exhaustive: an answer and a fallback are different facts about the
103/// move the city made, and a single struct with an optional answer would
104/// let a reader forget which one they are looking at.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub enum Consultation {
107    Answered {
108        ask: AdviserAsk,
109        subject: String,
110        answer: AdviserAnswer,
111        elapsed_ms: u64,
112    },
113    FellBack {
114        ask: AdviserAsk,
115        subject: String,
116        reason: AdviserFailure,
117    },
118}
119
120impl Consultation {
121    /// The answer to act on, or `None` when the deterministic strategy
122    /// stands. A caller that acts only on `Some` cannot accidentally act
123    /// on a fallback.
124    #[must_use]
125    pub fn answer(&self) -> Option<&AdviserAnswer> {
126        match self {
127            Consultation::Answered { answer, .. } => Some(answer),
128            Consultation::FellBack { .. } => None,
129        }
130    }
131
132    /// The two ledger lines this consultation writes: the question, then
133    /// its answer or its fallback.
134    ///
135    /// # Errors
136    /// Propagates what `Payload::of` says about the encoding.
137    pub fn payloads(&self) -> Result<Vec<Payload>, AxError> {
138        let (ask, subject) = match self {
139            Consultation::Answered { ask, subject, .. }
140            | Consultation::FellBack { ask, subject, .. } => (*ask, subject),
141        };
142        let asked = Payload::of(&AdviserAsked {
143            ask,
144            subject: subject.clone(),
145        })?;
146        let outcome = match self {
147            Consultation::Answered {
148                subject,
149                answer,
150                elapsed_ms,
151                ..
152            } => Payload::of(&AdviserAnswered {
153                subject: subject.clone(),
154                answer: answer.clone(),
155                elapsed_ms: *elapsed_ms,
156            })?,
157            Consultation::FellBack {
158                subject, reason, ..
159            } => Payload::of(&AdviserFellBack {
160                subject: subject.clone(),
161                reason: *reason,
162            })?,
163        };
164        Ok(vec![asked, outcome])
165    }
166}
167
168/// A consultant that can fail. [`Consultation::Answered`] is only
169/// reachable through [`Adviser::consult`], which checks the answer
170/// against the question before anything believes it.
171type Answer = Box<
172    dyn for<'a, 'b> FnMut(&'a Ask, &'b Conversation) -> Result<AdviserAnswer, AdviserFailure>
173        + Send,
174>;
175
176/// The window's adviser, as a city holds it: an answer function, or
177/// nothing at all.
178///
179/// `none` is the default and the worst case is exactly the behaviour a
180/// city without advisers always had: no answer means no adjustment.
181pub struct Adviser {
182    answer: Option<Answer>,
183}
184
185impl Adviser {
186    /// No adviser attached. Every consultation falls back with
187    /// `Unavailable`, which is what makes the missing consultant a fact
188    /// in the ledger rather than an absence.
189    #[must_use]
190    pub fn none() -> Adviser {
191        Adviser { answer: None }
192    }
193
194    /// An adviser implemented elsewhere — the attached endpoint in
195    /// `bin::assembly`, a script in a test. The function value's second
196    /// argument is the window: a closure cannot reach `FrozenConfig`
197    /// through it, which is the whole type-level promise of this port.
198    pub fn with(
199        answer: impl for<'a, 'b> FnMut(
200            &'a Ask,
201            &'b Conversation,
202        ) -> Result<AdviserAnswer, AdviserFailure>
203        + Send
204        + 'static,
205    ) -> Adviser {
206        Adviser {
207            answer: Some(Box::new(answer)),
208        }
209    }
210
211    /// Asks one question and writes down which answer the city used.
212    ///
213    /// `elapsed_ms` is the caller's, like every other measurement that
214    /// reaches the ledger: a second opinion on the critical path is a
215    /// cost, and the caller is the one who paid it.
216    pub fn consult(
217        &mut self,
218        ask: Ask,
219        conversation: &Conversation,
220        elapsed_ms: u64,
221    ) -> Consultation {
222        let kind = ask.kind;
223        let Some(answer) = self.answer.as_mut() else {
224            return Consultation::FellBack {
225                ask: kind,
226                subject: ask.subject,
227                reason: AdviserFailure::Unavailable,
228            };
229        };
230        match answer(&ask, conversation) {
231            Ok(answer) if accepts(kind, &answer, &ask.options) => Consultation::Answered {
232                ask: kind,
233                subject: ask.subject,
234                answer,
235                elapsed_ms,
236            },
237            // An answer arrived and it is not the answer to the question
238            // that was asked. The words do not travel: what a reader
239            // needs is that the deterministic strategy answered instead.
240            Ok(_) => Consultation::FellBack {
241                ask: kind,
242                subject: ask.subject,
243                reason: AdviserFailure::Unreadable,
244            },
245            Err(reason) => Consultation::FellBack {
246                ask: kind,
247                subject: ask.subject,
248                reason,
249            },
250        }
251    }
252}
253
254/// Whether an answer is an answer to this question at all — the pairing
255/// `AdviserAnswer`'s tag declares, plus the range each figure must sit
256/// in and the option list a `Choice` must name.
257fn accepts(kind: AdviserAsk, answer: &AdviserAnswer, options: &[String]) -> bool {
258    match (kind, answer) {
259        (AdviserAsk::Noul, AdviserAnswer::Noul { confidence_bp, .. }) => {
260            *confidence_bp <= BASIS_POINTS
261        }
262        (AdviserAsk::Score, AdviserAnswer::Score { score_bp }) => *score_bp <= BASIS_POINTS,
263        (AdviserAsk::Choice, AdviserAnswer::Choice { chosen }) => {
264            options.iter().any(|option| option == chosen)
265        }
266        (
267            AdviserAsk::Noul | AdviserAsk::Score | AdviserAsk::Choice,
268            AdviserAnswer::Noul { .. } | AdviserAnswer::Score { .. } | AdviserAnswer::Choice { .. },
269        ) => false,
270    }
271}
272
273/// Basis points of one: `10_000` is a whole, and the figure every
274/// adviser answer is scaled to so no payload carries a float.
275pub(crate) const BASIS_POINTS: u16 = 10_000;
276
277#[cfg(test)]
278mod tests;