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}