Skip to main content

cratefield_core/ports/
classifier.rs

1//! The `Classifier` port (issue #456): a typed, calibrated decision —
2//! "which of these is it, and how sure are you" — asked of whichever
3//! adapter the venture wired, the way [`TextModel`] is a completion asked
4//! of whichever provider serves a tier. A module holds
5//! `Arc<dyn Classifier>` and never learns who answered.
6//!
7//! Questions are asked **as a set**, not in a loop. The provider
8//! evaluates the questions of one `ask` call against one `state` in
9//! parallel — the expensive part of the call is carrying the state, not
10//! answering — so the trait takes a `BTreeMap<String, Question>` and
11//! hands back the answers keyed the same way, and an adapter can batch
12//! what it serves. A loop of one-question calls is possible but is the
13//! wrong shape: it pays for the state once per question and serialises
14//! what the provider would have run concurrently.
15//!
16//! **Confidence is not comparable across adapters.** Calibration is a
17//! property of the family of numbers an adapter's probabilities come from
18//! ([`Calibration`]): a `0.8` from a purpose-trained classifier and a
19//! `0.8` elicited from a general language model are different numbers,
20//! and a threshold tuned against one is wrong against the other. A module
21//! that thresholds on [`Answer::confidence`] says so in its own docs, as
22//! a per-adapter threshold.
23//!
24//! **`state` is truncated by the adapter** when it exceeds
25//! [`ClassifierProfile::max_state_chars`] — deterministically, on a char
26//! boundary, with a log line. A silently trimmed state is the worst
27//! failure mode a classifier has: the answer comes back confident and
28//! wrong. [`DEFAULT_MAX_STATE_CHARS`] is the ceiling a conservative
29//! adapter starts from.
30//!
31//! There is no outcome enum on this port, unlike [`Mailer`](crate::Mailer)
32//! and [`Push`](crate::Push), and that is deliberate, as on
33//! [`TextModel`]: a decision has no "delivered but not configured" middle
34//! state — either an answer came back or nothing did. The unwired answer
35//! is therefore [`ClassifierError::NotConfigured`], an error variant the
36//! caller can match, so a module that cannot degrade without its
37//! classifier fails loudly instead of silently producing nothing.
38
39use std::collections::BTreeMap;
40use std::time::Duration;
41
42use async_trait::async_trait;
43use serde::{Deserialize, Serialize};
44
45/// Chars of `state` a conservative adapter will send before it truncates:
46/// roughly 4 chars per token against the 32k-token window Workers AI
47/// documents, which leaves room in that window for the questions and the
48/// answer. An adapter whose provider has a different budget reports its
49/// own limit through [`ClassifierProfile::max_state_chars`] — this is the
50/// default, not a law.
51pub const DEFAULT_MAX_STATE_CHARS: usize = 96_000;
52
53/// Which family of numbers an adapter's probabilities come from.
54///
55/// Calibration is **not portable**: a `0.8` from a purpose-trained
56/// classifier and a `0.8` elicited from a general language model are
57/// different numbers, and a threshold tuned against one is wrong against
58/// the other. A module that thresholds on [`Answer::confidence`] states in
59/// its own docs which [`Calibration`] its threshold was tuned against —
60/// swapping the adapter re-tunes every threshold downstream of it.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum Calibration {
64    /// Numbers from a model trained to classify: the probabilities are the
65    /// model's own outputs, tuned on labelled data.
66    Classifier,
67    /// Numbers elicited from a general language model — asked to rate its
68    /// own certainty, or read off token log-probs. A different sharpness
69    /// at the same number than [`Calibration::Classifier`], and it drifts
70    /// with the model version.
71    LanguageModel,
72}
73
74impl Calibration {
75    /// The name used in errors and logs.
76    pub fn name(&self) -> &'static str {
77        match self {
78            Calibration::Classifier => "classifier",
79            Calibration::LanguageModel => "language_model",
80        }
81    }
82}
83
84impl std::fmt::Display for Calibration {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.write_str(self.name())
87    }
88}
89
90/// What a caller can learn about the adapter answering, short of its
91/// identity: the family its `confidence` comes from, and how much `state`
92/// it will carry before it truncates.
93///
94/// `#[non_exhaustive]`: what an adapter can report about itself grows, and
95/// it should not be a breaking change for every caller.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97#[non_exhaustive]
98pub struct ClassifierProfile {
99    /// The family the adapter's `confidence` numbers come from. See
100    /// [`Calibration`] — not comparable across families.
101    pub calibration: Calibration,
102    /// Chars of `state` this adapter will send before it truncates.
103    /// Measured as the state's UTF-8 byte length, so multi-byte text can
104    /// trim slightly under the limit — never mid-char.
105    pub max_state_chars: usize,
106}
107
108impl ClassifierProfile {
109    /// A profile with the calibration and the state ceiling given.
110    #[must_use]
111    pub fn new(calibration: Calibration, max_state_chars: usize) -> Self {
112        Self {
113            calibration,
114            max_state_chars,
115        }
116    }
117
118    /// Truncate `state` to `max_state_chars`, deterministically and on a
119    /// char boundary: the same `state` and profile always produce the
120    /// same prefix. Returns the slice to send and whether anything was
121    /// dropped.
122    ///
123    /// An adapter calls this as it builds its request, and **logs when the
124    /// second element is `true`**: a silently trimmed state produces a
125    /// confident wrong answer, and a log line is the only chance a
126    /// maintainer has of connecting the two.
127    #[must_use]
128    pub fn truncate<'a>(&self, state: &'a str) -> (&'a str, bool) {
129        if state.len() <= self.max_state_chars {
130            return (state, false);
131        }
132        let mut end = self.max_state_chars;
133        while end > 0 && !state.is_char_boundary(end) {
134            end -= 1;
135        }
136        (&state[..end], true)
137    }
138}
139
140/// One question asked of `state`. Three shapes, because a classifier is
141/// asked three kinds of thing: pick one of a named set
142/// ([`Question::Choice`]), place on an ordered scale
143/// ([`Question::Score`]), or answer yes or no ([`Question::Noul`]).
144///
145/// Not `#[non_exhaustive]`: an adapter matches on this exhaustively to
146/// render each shape into its provider's request, and a new shape should
147/// be a compile error in every adapter until it is handled, not a silent
148/// fallthrough.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub enum Question {
151    /// Pick one of the named criteria. The map is `criterion name -> what
152    /// that criterion means`, because the provider only sees the words it
153    /// is given; the keys are also the labels the answer's probabilities
154    /// are keyed by.
155    Choice {
156        instructions: String,
157        criteria: BTreeMap<String, String>,
158    },
159    /// Place `state` on an ordered scale, in order: each level is
160    /// `(name, what that level means)`. The level names are the labels
161    /// the answer's probabilities are keyed by.
162    Score {
163        instructions: String,
164        levels: Vec<(String, String)>,
165    },
166    /// A yes/no question, answered as `true` or `false`.
167    Noul { instructions: String },
168}
169
170impl Question {
171    /// The instruction every shape carries — the part the adapter turns
172    /// into its provider's prompt or class descriptions.
173    #[must_use]
174    pub fn instructions(&self) -> &str {
175        match self {
176            Question::Choice { instructions, .. }
177            | Question::Score { instructions, .. }
178            | Question::Noul { instructions } => instructions,
179        }
180    }
181
182    /// Which shape this is, named for logs and provider payloads:
183    /// `"choice"`, `"score"` or `"noul"`.
184    #[must_use]
185    pub fn kind(&self) -> &'static str {
186        match self {
187            Question::Choice { .. } => "choice",
188            Question::Score { .. } => "score",
189            Question::Noul { .. } => "noul",
190        }
191    }
192
193    /// The labels an answer to this question is keyed by: the criteria
194    /// names, the level names, or `["true", "false"]`.
195    /// [`Answer::probabilities`] and [`Answer::confidence`] are per these.
196    #[must_use]
197    pub fn labels(&self) -> Vec<&str> {
198        match self {
199            Question::Choice { criteria, .. } => criteria.keys().map(String::as_str).collect(),
200            Question::Score { levels, .. } => {
201                levels.iter().map(|(name, _)| name.as_str()).collect()
202            }
203            Question::Noul { .. } => vec!["true", "false"],
204        }
205    }
206}
207
208/// The decided value of one [`Question`], shaped to match it.
209#[derive(Debug, Clone, PartialEq)]
210pub enum AnswerValue {
211    /// The criterion chosen — one of [`Question::Choice`]'s keys.
212    Choice(String),
213    /// The score decided, on [`Question::Score`]'s scale.
214    Score(f32),
215    /// The yes/no verdict for a [`Question::Noul`].
216    Noul(bool),
217}
218
219/// One answer: the value decided, the probability mass per label, and the
220/// probability of the value that came back.
221///
222/// `#[non_exhaustive]`, so an adapter builds one with [`Answer::new`] or
223/// the three conveniences rather than a struct literal, and a field added
224/// later is not a breaking change for every adapter.
225///
226/// [`Answer::confidence`] is **not comparable across adapters** — see
227/// [`Calibration`].
228#[derive(Debug, Clone, PartialEq)]
229#[non_exhaustive]
230pub struct Answer {
231    /// The value decided, matching the shape of the [`Question`] asked.
232    pub value: AnswerValue,
233    /// Probability mass per label, keyed as [`Question::labels`] names
234    /// them. The port does not require the masses to sum to one: some
235    /// providers report only a top-1.
236    pub probabilities: BTreeMap<String, f32>,
237    /// Probability of the label in `value`. See [`Calibration`] — not
238    /// comparable across adapters.
239    pub confidence: f32,
240    /// The vendor-reported model version that produced this answer, when
241    /// the adapter knows it. An alias (`jev-latest`) can move under an
242    /// unchanged request, and a threshold was tuned against a specific
243    /// version's numbers — the version that actually answered is how a
244    /// drifted answer is traced back to its cause. `None` when the adapter
245    /// does not know (or its port has no such report); set with
246    /// [`Answer::with_model`].
247    pub model: Option<String>,
248}
249
250impl Answer {
251    /// An answer with the value, probabilities and confidence given. The
252    /// conveniences below are the usual way in: they pick `confidence`
253    /// out of `probabilities` themselves.
254    #[must_use]
255    pub fn new(value: AnswerValue, probabilities: BTreeMap<String, f32>, confidence: f32) -> Self {
256        Self {
257            value,
258            probabilities,
259            confidence,
260            model: None,
261        }
262    }
263
264    /// Records the vendor-reported model version that produced this
265    /// answer, for the adapters whose vendor reports one. Chainable on
266    /// any constructor:
267    /// `Answer::noul(..).with_model("jev-1.13.0")`.
268    #[must_use]
269    pub fn with_model(mut self, model: impl Into<String>) -> Self {
270        self.model = Some(model.into());
271        self
272    }
273
274    /// An answer to a [`Question::Choice`]: `label` is one of its
275    /// criteria, and `confidence` is looked up in `probabilities` under
276    /// it — 0.0 when the label is absent.
277    #[must_use]
278    pub fn choice(label: impl Into<String>, probabilities: BTreeMap<String, f32>) -> Self {
279        let label = label.into();
280        let confidence = probabilities.get(&label).copied().unwrap_or(0.0);
281        Self {
282            value: AnswerValue::Choice(label),
283            probabilities,
284            confidence,
285            model: None,
286        }
287    }
288
289    /// An answer to a [`Question::Score`]: `value` is on the scale, and
290    /// `confidence` is looked up in `probabilities` under the score's own
291    /// name — the convention when a scale's levels are named for their
292    /// scores (`"1"`..`"5"`). A provider that reports confidence another
293    /// way builds the answer with [`Answer::new`] instead. 0.0 when the
294    /// label is absent.
295    #[must_use]
296    pub fn score(value: f32, probabilities: BTreeMap<String, f32>) -> Self {
297        let confidence = probabilities
298            .get(&value.to_string())
299            .copied()
300            .unwrap_or(0.0);
301        Self {
302            value: AnswerValue::Score(value),
303            probabilities,
304            confidence,
305            model: None,
306        }
307    }
308
309    /// An answer to a [`Question::Noul`]: `confidence` is looked up in
310    /// `probabilities` under `"true"` or `"false"` — 0.0 when the label
311    /// is absent.
312    #[must_use]
313    pub fn noul(value: bool, probabilities: BTreeMap<String, f32>) -> Self {
314        let label = if value { "true" } else { "false" };
315        let confidence = probabilities.get(label).copied().unwrap_or(0.0);
316        Self {
317            value: AnswerValue::Noul(value),
318            probabilities,
319            confidence,
320            model: None,
321        }
322    }
323}
324
325/// Classification failures.
326///
327/// [`NotConfigured`](Self::NotConfigured) sits on the **error** enum here,
328/// unlike [`SendOutcome::NotConfigured`](crate::SendOutcome) and
329/// [`PushOutcome::NotConfigured`](crate::PushOutcome): a decision has no
330/// "delivered but not configured" middle state, so an unwired port is an
331/// error the caller matches, not an outcome it inspects.
332///
333/// `Transient` deliberately carries **only** `retry_after`, where
334/// [`PushError::Transient`](crate::PushError) also carries a message: with
335/// no provider text of its own there is nothing to scrub, and provider
336/// text belongs on [`Rejected`](Self::Rejected) and
337/// [`Transport`](Self::Transport).
338///
339/// The two variants that carry provider text are sanitized in `Display`,
340/// the same way [`TextModelError`](crate::TextModelError)'s,
341/// [`PushError`](crate::PushError)'s and [`MailError`](crate::MailError)'s
342/// are (issue #235). What an adapter wraps is the provider's own words,
343/// and `state` is exactly the kind of value that rides back in them — a
344/// triage module sends a customer's note, and the provider's `4xx` quotes
345/// it straight back. `Display` therefore runs it through
346/// [`crate::logging::scrub_text`]; `Debug` still shows the raw string for
347/// tests.
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub enum ClassifierError {
350    /// The port has no adapter — the venture did not wire it. Nothing is
351    /// wrong with the questions: a module may degrade, the way it degrades
352    /// on a `NotConfigured` mailer or text model.
353    NotConfigured,
354    /// The provider refused the request (a `4xx`), or the adapter refused
355    /// the question set — [`validate_questions`] exists so that a
356    /// malformed set lands here and never on a panic. Not retryable
357    /// without a change.
358    Rejected(String),
359    /// A transient failure (a `5xx`, a `429`, a transport error): retry
360    /// later, and not before `retry_after` when the provider named one.
361    /// Carries no message — provider text belongs on
362    /// [`Rejected`](Self::Rejected) and [`Transport`](Self::Transport).
363    Transient { retry_after: Option<Duration> },
364    /// The request never completed as a decision — the adapter could not
365    /// reach the provider, or the answer did not survive the hop.
366    Transport(String),
367}
368
369impl std::fmt::Display for ClassifierError {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        let scrub = crate::logging::scrub_text;
372        match self {
373            Self::NotConfigured => f.write_str("no classifier is wired for this venture"),
374            Self::Rejected(message) => write!(f, "classification rejected: {}", scrub(message)),
375            Self::Transient { .. } => f.write_str("classification failed, retryable"),
376            Self::Transport(message) => {
377                write!(f, "classification transport failed: {}", scrub(message))
378            }
379        }
380    }
381}
382
383impl std::error::Error for ClassifierError {}
384
385impl ClassifierError {
386    /// How long the provider asked the caller to wait, where it said.
387    pub fn retry_after(&self) -> Option<Duration> {
388        match self {
389            ClassifierError::Transient { retry_after } => *retry_after,
390            _ => None,
391        }
392    }
393}
394
395/// Checks a question set before an adapter renders it, so a malformed set
396/// is [`ClassifierError::Rejected`] and never a panic anywhere: every
397/// adapter calls this first, and every caller knows the shapes it hands
398/// the provider are whole.
399///
400/// # Errors
401/// [`ClassifierError::Rejected`] when the set is empty, an id is blank,
402/// instructions are blank, a [`Question::Choice`] has fewer than two
403/// criteria, or a [`Question::Score`] has fewer than two levels.
404pub fn validate_questions(questions: &BTreeMap<String, Question>) -> Result<(), ClassifierError> {
405    if questions.is_empty() {
406        return Err(ClassifierError::Rejected(
407            "a question set must not be empty".to_owned(),
408        ));
409    }
410    for (id, question) in questions {
411        if id.trim().is_empty() {
412            return Err(ClassifierError::Rejected(format!(
413                "question id must not be blank, got {id:?}"
414            )));
415        }
416        if question.instructions().trim().is_empty() {
417            return Err(ClassifierError::Rejected(format!(
418                "question {id:?} has blank instructions"
419            )));
420        }
421        match question {
422            Question::Choice { criteria, .. } if criteria.len() < 2 => {
423                return Err(ClassifierError::Rejected(format!(
424                    "choice question {id:?} needs at least two criteria, got {}",
425                    criteria.len()
426                )));
427            }
428            Question::Score { levels, .. } if levels.len() < 2 => {
429                return Err(ClassifierError::Rejected(format!(
430                    "score question {id:?} needs at least two levels, got {}",
431                    levels.len()
432                )));
433            }
434            _ => {}
435        }
436    }
437    Ok(())
438}
439
440/// Answers a set of typed questions about a `state`, over whichever
441/// classifier the venture wired. The sibling of `TextModel`: a module
442/// asks "which of these is it, and how sure are you" and holds
443/// `Arc<dyn Classifier>` without learning who answered.
444///
445/// # Confidence is per-adapter
446///
447/// [`Answer::confidence`] is not comparable across adapters. It comes
448/// from the family [`ClassifierProfile::calibration`] names, and a `0.8`
449/// from one family is not a `0.8` from the other — see [`Calibration`].
450/// A module that thresholds on it must say in its own docs that the
451/// threshold is per-adapter: swapping the adapter re-tunes every
452/// threshold downstream of it.
453///
454/// # `state` is truncated by the adapter
455///
456/// When `state` is longer than [`ClassifierProfile::max_state_chars`] the
457/// adapter truncates it — deterministically, on a char boundary, with a
458/// log line. It has to be the adapter that does it: only the adapter
459/// knows its provider's budget. A silently trimmed state is the worst
460/// failure mode a classifier has, because the answer comes back
461/// confident and wrong.
462#[async_trait]
463pub trait Classifier: Send + Sync {
464    /// What family of numbers this adapter's `confidence` comes from, and
465    /// how much `state` it will carry.
466    fn profile(&self) -> ClassifierProfile;
467
468    /// Answers every question in `questions` about `state`, keyed the
469    /// same way.
470    ///
471    /// `questions` is a set, not a loop: the provider evaluates the
472    /// questions of one call against one `state` in parallel, and the
473    /// expensive part is carrying the state, not answering. An adapter
474    /// validates the set with [`validate_questions`] and answers
475    /// [`ClassifierError::Rejected`] rather than panicking on a
476    /// malformed one.
477    ///
478    /// # Errors
479    /// [`ClassifierError::NotConfigured`] when no adapter is wired;
480    /// [`ClassifierError::Rejected`] when the provider or
481    /// [`validate_questions`] refused the request;
482    /// [`ClassifierError::Transient`] when the call is retryable, with
483    /// the provider's back-off where it named one;
484    /// [`ClassifierError::Transport`] when the call never completed as a
485    /// decision.
486    async fn ask(
487        &self,
488        state: &str,
489        questions: &BTreeMap<String, Question>,
490    ) -> Result<BTreeMap<String, Answer>, ClassifierError>;
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    // -----------------------------------------------------------------
498    // Calibration
499
500    #[test]
501    fn a_calibration_names_itself_and_round_trips_through_json() {
502        for calibration in [Calibration::Classifier, Calibration::LanguageModel] {
503            let json = serde_json::to_string(&calibration).expect("serialises");
504            let back: Calibration = serde_json::from_str(&json).expect("deserialises");
505            assert_eq!(calibration, back);
506        }
507        // The point of `rename_all`: the persisted name is the prose name,
508        // so a config file reads `"language_model"`, not `"LanguageModel"`.
509        assert_eq!(
510            serde_json::to_value(Calibration::LanguageModel).unwrap(),
511            "language_model"
512        );
513        assert_eq!(Calibration::Classifier.name(), "classifier");
514        assert_eq!(Calibration::LanguageModel.to_string(), "language_model");
515    }
516
517    // -----------------------------------------------------------------
518    // ClassifierProfile::truncate
519
520    #[test]
521    fn truncate_is_a_no_op_under_the_limit() {
522        assert_eq!(DEFAULT_MAX_STATE_CHARS, 96_000);
523        let profile = ClassifierProfile::new(Calibration::Classifier, DEFAULT_MAX_STATE_CHARS);
524        let state = "a state well under the ceiling";
525        let (sent, dropped) = profile.truncate(state);
526        assert_eq!(sent, state);
527        assert!(!dropped);
528    }
529
530    #[test]
531    fn truncate_trims_deterministically_over_the_limit() {
532        let profile = ClassifierProfile::new(Calibration::Classifier, 16);
533        let state = "the same state, asked twice, over the limit";
534        let (first, dropped) = profile.truncate(state);
535        let (second, dropped_again) = profile.truncate(state);
536        assert!(dropped);
537        assert!(dropped_again);
538        assert_eq!(first, second, "the same state always trims the same way");
539        assert_eq!(first.len(), 16, "the prefix spends the whole budget");
540        assert!(state.starts_with(first), "the prefix is a prefix");
541    }
542
543    #[test]
544    fn truncate_never_splits_a_multi_byte_char() {
545        // Four 3-byte chars, twelve bytes; a budget of seven lands in the
546        // middle of the third. The floor boundary is byte six, so the
547        // last whole char is kept and the slice is still valid UTF-8.
548        let state = "漢字漢字";
549        let profile = ClassifierProfile::new(Calibration::LanguageModel, 7);
550        let (sent, dropped) = profile.truncate(state);
551        assert!(dropped);
552        assert_eq!(sent, "漢字");
553        assert_eq!(sent.chars().count(), 2);
554    }
555
556    #[test]
557    fn a_zero_limit_keeps_nothing_but_stays_on_a_boundary() {
558        let profile = ClassifierProfile::new(Calibration::Classifier, 0);
559        let (sent, dropped) = profile.truncate("漢字");
560        assert_eq!(sent, "");
561        assert!(dropped);
562    }
563
564    // -----------------------------------------------------------------
565    // Question
566
567    #[test]
568    fn a_question_names_its_shape_and_labels() {
569        let choice = Question::Choice {
570            instructions: "Pick.".to_owned(),
571            criteria: BTreeMap::from([
572                ("billing".to_owned(), "money and invoices".to_owned()),
573                ("bugs".to_owned(), "something is broken".to_owned()),
574            ]),
575        };
576        assert_eq!(choice.kind(), "choice");
577        assert_eq!(choice.instructions(), "Pick.");
578        assert_eq!(choice.labels(), vec!["billing", "bugs"]);
579
580        let score = Question::Score {
581            instructions: "Rate.".to_owned(),
582            levels: vec![
583                ("1".to_owned(), "low".to_owned()),
584                ("5".to_owned(), "high".to_owned()),
585            ],
586        };
587        assert_eq!(score.kind(), "score");
588        assert_eq!(score.labels(), vec!["1", "5"]);
589
590        let noul = Question::Noul {
591            instructions: "Yes or no?".to_owned(),
592        };
593        assert_eq!(noul.kind(), "noul");
594        assert_eq!(noul.instructions(), "Yes or no?");
595        assert_eq!(noul.labels(), vec!["true", "false"]);
596    }
597
598    // -----------------------------------------------------------------
599    // Answer constructors
600
601    #[test]
602    fn answer_conveniences_pick_confidence_from_the_probabilities() {
603        let criteria = BTreeMap::from([("spam".to_owned(), 0.9_f32), ("ham".to_owned(), 0.1_f32)]);
604        let answer = Answer::choice("spam", criteria.clone());
605        assert_eq!(answer.value, AnswerValue::Choice("spam".to_owned()));
606        assert_eq!(answer.probabilities, criteria);
607        assert!((answer.confidence - 0.9).abs() < f32::EPSILON);
608
609        // A scale whose levels are named for their scores is the case
610        // `score` looks confidence up for.
611        let levels = BTreeMap::from([("1".to_owned(), 0.25_f32), ("2".to_owned(), 0.75_f32)]);
612        let answer = Answer::score(2.0, levels);
613        assert_eq!(answer.value, AnswerValue::Score(2.0));
614        assert!((answer.confidence - 0.75).abs() < f32::EPSILON);
615
616        let verdicts =
617            BTreeMap::from([("true".to_owned(), 0.7_f32), ("false".to_owned(), 0.3_f32)]);
618        let answer = Answer::noul(true, verdicts);
619        assert_eq!(answer.value, AnswerValue::Noul(true));
620        assert!((answer.confidence - 0.7).abs() < f32::EPSILON);
621    }
622
623    #[test]
624    fn an_absent_label_yields_zero_confidence() {
625        // Compared with a tolerance, not `==`: a float that was looked up
626        // is still a float (and clippy::float_cmp has a point).
627        let verdicts = BTreeMap::from([("true".to_owned(), 1.0_f32)]);
628        assert!(
629            Answer::noul(false, verdicts).confidence.abs() < f32::EPSILON,
630            "an absent label answers 0.0"
631        );
632        let confidence = Answer::choice(
633            "unheard-of",
634            BTreeMap::from([("known".to_owned(), 1.0_f32)]),
635        )
636        .confidence;
637        assert!(
638            confidence.abs() < f32::EPSILON,
639            "an absent label answers 0.0"
640        );
641        assert!(
642            Answer::score(3.5, BTreeMap::new()).confidence.abs() < f32::EPSILON,
643            "an absent label answers 0.0"
644        );
645
646        // `new` is the escape hatch: the caller states the confidence
647        // rather than looking it up by label.
648        let answer = Answer::new(AnswerValue::Score(4.0), BTreeMap::new(), 0.5);
649        assert!((answer.confidence - 0.5).abs() < f32::EPSILON);
650    }
651
652    #[test]
653    fn with_model_records_the_reporting_version_and_defaults_to_none() {
654        // The constructors do not invent a vendor they do not have; only
655        // an adapter that knows sets it.
656        let answer = Answer::noul(true, BTreeMap::new());
657        assert_eq!(answer.model, None);
658        assert_eq!(
659            Answer::choice("spam", BTreeMap::new())
660                .with_model("jev-1.13.0")
661                .model
662                .as_deref(),
663            Some("jev-1.13.0")
664        );
665        assert_eq!(
666            Answer::score(3.0, BTreeMap::new()).with_model("v2").model,
667            Some("v2".to_owned())
668        );
669    }
670
671    // -----------------------------------------------------------------
672    // validate_questions
673
674    fn good_set() -> BTreeMap<String, Question> {
675        BTreeMap::from([
676            (
677                "topic".to_owned(),
678                Question::Choice {
679                    instructions: "Which topic does the note belong to?".to_owned(),
680                    criteria: BTreeMap::from([
681                        ("billing".to_owned(), "money, invoices".to_owned()),
682                        ("bugs".to_owned(), "something is broken".to_owned()),
683                    ]),
684                },
685            ),
686            (
687                "severity".to_owned(),
688                Question::Score {
689                    instructions: "How severe is the note?".to_owned(),
690                    levels: vec![
691                        ("1".to_owned(), "a typo".to_owned()),
692                        ("5".to_owned(), "data loss".to_owned()),
693                    ],
694                },
695            ),
696            (
697                "angry".to_owned(),
698                Question::Noul {
699                    instructions: "Is the writer angry?".to_owned(),
700                },
701            ),
702        ])
703    }
704
705    #[test]
706    fn a_good_set_is_accepted() {
707        assert_eq!(validate_questions(&good_set()), Ok(()));
708    }
709
710    #[test]
711    fn an_empty_set_is_rejected() {
712        let error = validate_questions(&BTreeMap::new()).unwrap_err();
713        assert!(matches!(error, ClassifierError::Rejected(_)));
714    }
715
716    #[test]
717    fn a_blank_id_is_rejected() {
718        let mut questions = good_set();
719        questions.insert("   ".to_owned(), questions["topic"].clone());
720        let error = validate_questions(&questions).unwrap_err();
721        assert!(matches!(error, ClassifierError::Rejected(_)));
722    }
723
724    #[test]
725    fn blank_instructions_are_rejected() {
726        let questions = BTreeMap::from([(
727            "topic".to_owned(),
728            Question::Noul {
729                instructions: "   ".to_owned(),
730            },
731        )]);
732        assert!(matches!(
733            validate_questions(&questions),
734            Err(ClassifierError::Rejected(_))
735        ));
736    }
737
738    #[test]
739    fn a_choice_with_one_criterion_is_rejected() {
740        let mut questions = good_set();
741        questions.insert(
742            "solo".to_owned(),
743            Question::Choice {
744                instructions: "Pick one.".to_owned(),
745                criteria: BTreeMap::from([("only".to_owned(), "the only one".to_owned())]),
746            },
747        );
748        assert!(matches!(
749            validate_questions(&questions),
750            Err(ClassifierError::Rejected(_))
751        ));
752    }
753
754    #[test]
755    fn a_score_with_one_level_is_rejected() {
756        let mut questions = good_set();
757        questions.insert(
758            "one-note".to_owned(),
759            Question::Score {
760                instructions: "Rate it.".to_owned(),
761                levels: vec![("3".to_owned(), "middling".to_owned())],
762            },
763        );
764        assert!(matches!(
765            validate_questions(&questions),
766            Err(ClassifierError::Rejected(_))
767        ));
768    }
769
770    // -----------------------------------------------------------------
771    // ClassifierError
772
773    #[test]
774    fn display_sanitizes_the_provider_text() {
775        // Issue #235. The text an adapter wraps is the provider's own
776        // words, and `state` is often whatever a user wrote: what the
777        // provider echoes back must not survive into a log line or a
778        // dead-letter row carrying its URLs, tokens or addresses.
779        let error = ClassifierError::Rejected(
780            "provider 400 for https://api.example.test/v1/classify?token=secret-abcdef".to_owned(),
781        );
782        let text = error.to_string();
783        assert!(text.contains("classification rejected"), "{text}");
784        assert!(!text.contains("secret-abcdef"), "{text}");
785        assert!(text.contains("?[redacted]"), "{text}");
786
787        let error = ClassifierError::Transport("timeout quoting alice@example.test".to_owned());
788        let text = error.to_string();
789        assert!(text.contains("classification transport failed"), "{text}");
790        assert!(!text.contains('@'), "{text}");
791
792        // `Debug` still shows the raw string for a failing test to read.
793        assert!(format!("{error:?}").contains("alice@example.test"));
794    }
795
796    #[test]
797    fn transient_says_retryable_and_carries_no_text_to_scrub() {
798        // `Transient` has no message field on purpose — the fixed sentence
799        // is the whole `Display`, and a provider's words would be an
800        // unsanitised leak by construction.
801        let error = ClassifierError::Transient {
802            retry_after: Some(Duration::from_secs(30)),
803        };
804        assert_eq!(error.to_string(), "classification failed, retryable");
805    }
806
807    #[test]
808    fn not_configured_names_the_missing_wiring_and_carries_no_back_off() {
809        assert_eq!(
810            ClassifierError::NotConfigured.to_string(),
811            "no classifier is wired for this venture"
812        );
813        assert_eq!(ClassifierError::NotConfigured.retry_after(), None);
814        assert_eq!(
815            ClassifierError::Rejected("422".to_owned()).retry_after(),
816            None,
817            "a rejection is not a back-off"
818        );
819        assert_eq!(
820            ClassifierError::Transport("timed out".to_owned()).retry_after(),
821            None
822        );
823        let throttled = ClassifierError::Transient {
824            retry_after: Some(Duration::from_secs(30)),
825        };
826        assert_eq!(throttled.retry_after(), Some(Duration::from_secs(30)));
827    }
828}