Skip to main content

cratefield_adapter_workers_ai/
lib.rs

1//! `cratefield-adapter-workers-ai`: the `Classifier` port over the
2//! Cloudflare Workers AI binding (issue #456). The model is reached through
3//! `env.AI.run(...)` — a platform binding, no API key at all — so this
4//! adapter exists only on the Workers runtime, and it bills through the
5//! venture's Cloudflare account instead of to a vendor key.
6//!
7//! It is a convenience for ventures already on Workers, explicitly NOT the
8//! primary way in: bring-your-own-key via `cratefield-adapter-typesafe` is
9//! the default `Classifier`. But it is the same purpose-trained model, so
10//! `profile()` reports `Calibration::Classifier` too — the same family of
11//! numbers, and thresholds tuned on either hold on both (unlike the
12//! `adapter-classifier-llm` family; see `CLASSIFIER.md`).
13//!
14//! Two shapes are load-bearing here. First, the JS binding: `worker::Ai` is
15//! a `wasm-bindgen` handle held inside a `Send + Sync` trait object, which
16//! is sound without a line of `unsafe` here because `wasm-bindgen` itself
17//! declares `JsValue` `Send + Sync` off the atomics target (the same shape
18//! `runtime-cloudflare`'s D1 wrapper uses); only the returned future needs
19//! `worker`'s `into_send`. Second, the binding cannot be constructed off
20//! wasm, while `ask` must still be testable natively — so the call goes
21//! through the narrow `AiRunner` seam and tests inject a fake runner.
22//!
23//! There is no key in this adapter, so there is nothing to leak. A Workers
24//! AI binding error can still echo platform identifiers (account ids,
25//! binding names), so error text only ever travels inside
26//! `ClassifierError` — whose `Display` scrubs — and this crate's log lines
27//! carry outcome labels, never raw binding text. The same binding also
28//! carries the `Speech` port (issue #861) over its own `SpeechRunner` seam
29//! — see the `speech` module.
30
31#![doc = include_str!("../README.md")]
32#![forbid(unsafe_code)]
33
34mod speech;
35
36pub use speech::{
37    SpeechBinding, SpeechModel, SpeechRunner, TranscriptionModel, WORKERS_AI_MAX_AUDIO_BYTES,
38    WorkersAiSpeech,
39};
40
41use async_trait::async_trait;
42use cratefield_core::{
43    Answer, Calibration, Classifier, ClassifierError, ClassifierProfile, DEFAULT_MAX_STATE_CHARS,
44    Question, validate_questions,
45};
46use std::collections::BTreeMap;
47use worker::send::IntoSendFuture;
48
49/// The model this adapter runs by default: the same purpose-trained
50/// classifier the direct `adapter-typesafe` adapter calls over HTTP, one
51/// transport further away. Override it per constructor.
52pub const DEFAULT_MODEL_ID: &str = "typesafe/jev";
53
54/// The context window Workers AI documents for the model, in tokens. At the
55/// port's conservative ~4 chars/token the default state ceiling
56/// (`DEFAULT_MAX_STATE_CHARS`, 96,000 chars) spends roughly 24,000 tokens
57/// and leaves roughly a quarter of the window for the question set.
58pub const WORKERS_AI_CONTEXT_TOKENS: usize = 32_000;
59
60/// The runner seam: one call to the binding, all questions at once.
61///
62/// This exists because the binding itself cannot be constructed off wasm
63/// (`env.ai(..)` inspects a live JS constructor name), while every line
64/// around the call — request building, truncation, parsing, refusals —
65/// must be testable in a native `cargo test`. The default implementation,
66/// `AiBinding`, wraps the real binding; tests inject a fake instead.
67///
68/// The error type is `ClassifierError` itself so a fake can exercise every
69/// mapping, and the real binding maps through `map_worker_error`.
70#[async_trait]
71pub trait AiRunner: Send + Sync {
72    /// Run one model call. `input` is the full request document.
73    ///
74    /// # Errors
75    /// Whatever mapping applies to the transport below: `NotConfigured`
76    /// for an absent or unusable binding, `Rejected` when the model refuses
77    /// the input, `Transient` for rate limits and overload (with a hint
78    /// only when one exists), `Transport` otherwise.
79    async fn run(
80        &self,
81        model: &str,
82        input: serde_json::Value,
83    ) -> Result<serde_json::Value, ClassifierError>;
84}
85
86/// The real runner: the `env.AI` binding, obtained from
87/// `worker::Env::ai(..)`.
88///
89/// Holding the JS handle inside a `Send + Sync` struct needs no `unsafe`
90/// here — `wasm-bindgen` declares `JsValue` `Send + Sync` away from the
91/// atomics target, the same shape `runtime-cloudflare`'s D1 wrapper relies
92/// on. Only `Ai::run`'s future is `!Send`, and `worker`'s own `send` module
93/// fixes that at the call site. Constructing this type off wasm is
94/// impossible (there is no `Env`), which is exactly why the seam exists.
95pub struct AiBinding(worker::Ai);
96
97#[async_trait]
98impl AiRunner for AiBinding {
99    async fn run(
100        &self,
101        model: &str,
102        input: serde_json::Value,
103    ) -> Result<serde_json::Value, ClassifierError> {
104        self.0
105            .run::<serde_json::Value, serde_json::Value>(model, input)
106            .into_send()
107            .await
108            .map_err(|err| map_worker_error(&err))
109    }
110}
111
112/// `Classifier` over the Cloudflare Workers AI binding.
113///
114/// The runner is a type parameter so tests can substitute the binding; the
115/// default is the real one, and venture code writes `WorkersAi::from_env(..)`
116/// and never names the parameter.
117pub struct WorkersAi<R: AiRunner = AiBinding> {
118    runner: R,
119    model: String,
120    profile: ClassifierProfile,
121}
122
123impl WorkersAi<AiBinding> {
124    /// The adapter over the venture's `env.AI` binding. A missing or
125    /// wrong-typed binding yields `None` — the runtime simply does not
126    /// provide the port — never a panic. (`EnvBinding for worker::Ai`
127    /// rejects a binding whose JS constructor name is not `Ai`, which is
128    /// what happens under local miniflare/workerd configurations that do
129    /// not provision the real class.)
130    pub fn from_env(env: &worker::Env, binding: &str) -> Option<Self> {
131        let ai = env.ai(binding).ok()?;
132        Some(Self {
133            runner: AiBinding(ai),
134            model: DEFAULT_MODEL_ID.to_owned(),
135            profile: ClassifierProfile::new(Calibration::Classifier, DEFAULT_MAX_STATE_CHARS),
136        })
137    }
138
139    /// `from_env` with a model-id override and an explicit state ceiling:
140    /// a venture pinning a newer revision of the model, or shrinking the
141    /// ceiling below the documented window it has paid for.
142    pub fn from_env_with_model(
143        env: &worker::Env,
144        binding: &str,
145        model: impl Into<String>,
146        profile: ClassifierProfile,
147    ) -> Option<Self> {
148        let ai = env.ai(binding).ok()?;
149        Some(Self {
150            runner: AiBinding(ai),
151            model: model.into(),
152            profile,
153        })
154    }
155}
156
157impl<R: AiRunner> WorkersAi<R> {
158    /// The test constructor: any `AiRunner` stands in for the binding, so
159    /// the whole of `ask` — request building, truncation, parsing, error
160    /// mapping — runs in a native `cargo test`.
161    pub fn with_runner(runner: R, model: impl Into<String>, profile: ClassifierProfile) -> Self {
162        Self {
163            runner,
164            model: model.into(),
165            profile,
166        }
167    }
168}
169
170/// The request document: one `state` and **all** questions of the call —
171/// the binding evaluates the set against the state in a single `run()`,
172/// which is the whole point of the port. Shapes not carried by a question's
173/// kind are omitted rather than sent empty, so the model never sees a
174/// `noul` with a stray `criteria` object. These are the same documents the
175/// direct `adapter-typesafe` adapter sends over HTTP — same model, same
176/// shape, one transport further away.
177#[derive(serde::Serialize)]
178struct ClassifyRequest<'a> {
179    state: &'a str,
180    questions: Vec<WireQuestion<'a>>,
181}
182
183#[derive(serde::Serialize)]
184struct WireQuestion<'a> {
185    id: &'a str,
186    /// `choice`, `score` or `noul` — `Question::kind`'s names.
187    kind: &'a str,
188    instructions: &'a str,
189    /// `Question::Choice` only: `criterion -> what it means`.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    criteria: Option<BTreeMap<&'a str, &'a str>>,
192    /// `Question::Score` only: the ordered levels.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    levels: Option<Vec<WireLevel<'a>>>,
195}
196
197#[derive(serde::Serialize)]
198struct WireLevel<'a> {
199    name: &'a str,
200    meaning: &'a str,
201}
202
203fn wire_question<'a>(id: &'a str, question: &'a Question) -> WireQuestion<'a> {
204    match question {
205        Question::Choice {
206            instructions,
207            criteria,
208        } => WireQuestion {
209            id,
210            kind: question.kind(),
211            instructions,
212            criteria: Some(
213                criteria
214                    .iter()
215                    .map(|(name, meaning)| (name.as_str(), meaning.as_str()))
216                    .collect(),
217            ),
218            levels: None,
219        },
220        Question::Score {
221            instructions,
222            levels,
223        } => WireQuestion {
224            id,
225            kind: question.kind(),
226            instructions,
227            criteria: None,
228            levels: Some(
229                levels
230                    .iter()
231                    .map(|(name, meaning)| WireLevel { name, meaning })
232                    .collect(),
233            ),
234        },
235        Question::Noul { instructions } => WireQuestion {
236            id,
237            kind: question.kind(),
238            instructions,
239            criteria: None,
240            levels: None,
241        },
242    }
243}
244
245/// The success document: one answer per asked question id.
246#[derive(serde::Deserialize)]
247struct RunResponse {
248    answers: Vec<WireAnswer>,
249}
250
251#[derive(serde::Deserialize)]
252struct WireAnswer {
253    id: String,
254    /// A criterion or `true`/`false` label for `choice` and `noul`, a
255    /// number on the scale for `score`.
256    value: WireValue,
257    #[serde(default)]
258    probabilities: BTreeMap<String, f32>,
259}
260
261/// A model answer value. Untagged: a JSON string is a label, a JSON number
262/// is a score.
263#[derive(serde::Deserialize)]
264#[serde(untagged)]
265enum WireValue {
266    Label(String),
267    Number(f32),
268}
269
270/// A score is matched against numeric level names within this much of the
271/// model's number: both sides are decimals that parsed to `f32`, so exact
272/// equality would be greedy and `clippy::float_cmp` has a point.
273const SCORE_MATCH_TOLERANCE: f32 = 1.0e-6;
274
275/// How far a distribution may sit from summing to one and still count as
276/// already normalised. F32 sums of a handful of labels miss by at most a
277/// few `ulp`s; a real shortfall (logits without a softmax) is orders of
278/// magnitude wider.
279const SUM_TOLERANCE: f32 = 1.0e-4;
280
281/// Substrings (lowercased) that mark a binding error as the binding or the
282/// class being absent — the `NotConfigured` family, not a network failure.
283/// Shared with the `speech` module's mapping onto the `SpeechError`
284/// vocabulary.
285pub(crate) const BINDING_MARKERS: [&str; 5] = [
286    "binding cannot be cast",
287    "does not contain binding",
288    "is undefined",
289    "is not defined",
290    "not a function",
291];
292
293/// Substrings (lowercased) that mark a binding error as throttling or
294/// overload — retryable, and Workers AI offers no `Retry-After` hint
295/// through the binding, so `retry_after` stays `None` rather than an
296/// invented duration.
297pub(crate) const TRANSIENT_MARKERS: [&str; 7] = [
298    "rate limit",
299    "rate-limit",
300    "too many requests",
301    "429",
302    "overload",
303    "throttl",
304    "temporarily unavailable",
305];
306
307/// Substrings (lowercased) that mark a binding error as the model refusing
308/// or rejecting the input — a wrong model id, an input that breaks the
309/// model's contract.
310const REJECTED_MARKERS: [&str; 9] = [
311    "invalid",
312    "not supported",
313    "unsupported",
314    "not found",
315    "unauthorized",
316    "forbidden",
317    "not allowed",
318    "malformed",
319    "too large",
320];
321
322/// Maps a `worker::Error` onto the port's vocabulary. The text is only ever
323/// carried inside `ClassifierError` — whose `Display` scrubs — never in a
324/// log line: a Workers AI binding error can echo platform identifiers such
325/// as account ids, and there is no scrubbing pass over log fields.
326///
327/// The keyword sets above are a documented heuristic, not a vendor
328/// contract: Workers AI reports failures as free-form JS error strings, so
329/// classification reads the text. Anything unrecognised is `Transport` —
330/// an unknown shape must not masquerade as a retryable moment or as a
331/// verdict on the input.
332fn map_worker_error(err: &worker::Error) -> ClassifierError {
333    // The one structured variant the crate already names as a rate limit.
334    if matches!(err, worker::Error::RateLimitExceeded(_)) {
335        return ClassifierError::Transient { retry_after: None };
336    }
337    let lowered = err.to_string().to_ascii_lowercase();
338    if BINDING_MARKERS
339        .iter()
340        .any(|marker| lowered.contains(marker))
341    {
342        return ClassifierError::NotConfigured;
343    }
344    if TRANSIENT_MARKERS
345        .iter()
346        .any(|marker| lowered.contains(marker))
347    {
348        return ClassifierError::Transient { retry_after: None };
349    }
350    if REJECTED_MARKERS
351        .iter()
352        .any(|marker| lowered.contains(marker))
353    {
354        return ClassifierError::Rejected(err.to_string());
355    }
356    ClassifierError::Transport(err.to_string())
357}
358
359/// Every refusal below is a `Rejected` with a message naming the question
360/// and the offence. The log line stays generic on purpose: the message can
361/// quote model-echoed text, and a log field is not scrubbed the way
362/// `ClassifierError`'s `Display` is.
363fn reject(message: String) -> ClassifierError {
364    tracing::warn!(
365        provider = "workers-ai",
366        "a workers-ai answer does not match the question it answers"
367    );
368    ClassifierError::Rejected(message)
369}
370
371/// Validates one answer's probabilities against its own question's labels
372/// and normalises them to sum to one. A NaN never lands in the map: every
373/// comparison against NaN is false, so it falls out of the range check as
374/// "outside [0, 1]".
375fn checked_probabilities(
376    id: &str,
377    question: &Question,
378    probabilities: BTreeMap<String, f32>,
379) -> Result<BTreeMap<String, f32>, ClassifierError> {
380    // The probabilities are keyed by the question's own labels — that is
381    // what makes `confidence` and `probabilities` agree downstream.
382    let labels = question.labels();
383    let mut checked = BTreeMap::new();
384    for (label, probability) in probabilities {
385        if !labels.contains(&label.as_str()) {
386            return Err(reject(format!(
387                "workers-ai answered question {id:?} with label {label:?}, which it does not offer"
388            )));
389        }
390        if !(0.0..=1.0).contains(&probability) {
391            return Err(reject(format!(
392                "workers-ai answered question {id:?} with probability {probability} for {label:?}, outside [0, 1]"
393            )));
394        }
395        checked.insert(label, probability);
396    }
397    let total: f32 = checked.values().sum();
398    if !total.is_finite() || total <= 0.0 {
399        return Err(reject(format!(
400            "workers-ai answered question {id:?} with a distribution that does not sum to a positive total"
401        )));
402    }
403    if (total - 1.0).abs() > SUM_TOLERANCE {
404        for probability in checked.values_mut() {
405            *probability /= total;
406        }
407    }
408    Ok(checked)
409}
410
411/// Maps the model's answer set onto the questions asked, refusing anything
412/// that does not line up. Every answer is checked against *its own*
413/// question: an answer for an id that was not asked, a second answer for
414/// the same id, a probability on a label the question never offered, a
415/// probability outside `[0.0, 1.0]`, or a distribution that cannot be
416/// normalised is `Rejected` — the adapter does not invent answers for
417/// questions the model skipped either.
418///
419/// Distributions that sum to something other than one are normalised:
420/// nothing obliges the model's output to land exactly on one, and the core
421/// `Answer` constructors derive `confidence` from the (normalised)
422/// probabilities, so `confidence` always agrees with them.
423fn map_answers(
424    answers: Vec<WireAnswer>,
425    questions: &BTreeMap<String, Question>,
426) -> Result<BTreeMap<String, Answer>, ClassifierError> {
427    let mut mapped = BTreeMap::new();
428    for WireAnswer {
429        id,
430        value,
431        probabilities,
432    } in answers
433    {
434        let Some(question) = questions.get(&id) else {
435            return Err(reject(format!(
436                "workers-ai answered question id {id:?}, which was not asked"
437            )));
438        };
439        if mapped.contains_key(&id) {
440            return Err(reject(format!("workers-ai answered question {id:?} twice")));
441        }
442        let checked = checked_probabilities(&id, question, probabilities)?;
443
444        let built = match question {
445            Question::Choice { criteria, .. } => {
446                let WireValue::Label(label) = value else {
447                    return Err(reject(format!(
448                        "workers-ai answered choice question {id:?} with a number, not a criterion"
449                    )));
450                };
451                if !criteria.contains_key(&label) {
452                    return Err(reject(format!(
453                        "workers-ai answered choice question {id:?} with {label:?}, which is not one of its criteria"
454                    )));
455                }
456                Answer::choice(label, checked)
457            }
458            Question::Score { levels, .. } => {
459                let WireValue::Number(score) = value else {
460                    return Err(reject(format!(
461                        "workers-ai answered score question {id:?} with a label, not a number"
462                    )));
463                };
464                if !score.is_finite() {
465                    return Err(reject(format!(
466                        "workers-ai scored question {id:?} at a value that is not a finite number"
467                    )));
468                }
469                // When every level name is numeric, the score must land on
470                // one of them; a scale named in words has nothing to check
471                // the number against.
472                let numeric_levels: Vec<_> = levels
473                    .iter()
474                    .map(|(name, _)| name.parse::<f32>().ok())
475                    .collect();
476                let all_numeric = numeric_levels.iter().all(Option::is_some);
477                if all_numeric
478                    && !numeric_levels.iter().any(|parsed| {
479                        parsed.is_some_and(|level| (level - score).abs() < SCORE_MATCH_TOLERANCE)
480                    })
481                {
482                    return Err(reject(format!(
483                        "workers-ai scored question {id:?} at {score}, off its scale"
484                    )));
485                }
486                Answer::score(score, checked)
487            }
488            Question::Noul { .. } => {
489                let WireValue::Label(label) = value else {
490                    return Err(reject(format!(
491                        "workers-ai answered noul question {id:?} with a number, not true or false"
492                    )));
493                };
494                let verdict = match label.as_str() {
495                    "true" => true,
496                    "false" => false,
497                    other => {
498                        return Err(reject(format!(
499                            "workers-ai answered noul question {id:?} with {other:?}, which is neither true nor false"
500                        )));
501                    }
502                };
503                Answer::noul(verdict, checked)
504            }
505        };
506        mapped.insert(id, built);
507    }
508    if mapped.len() != questions.len() {
509        let missing: Vec<_> = questions
510            .keys()
511            .filter(|id| !mapped.contains_key(*id))
512            .cloned()
513            .collect();
514        return Err(reject(format!(
515            "workers-ai did not answer the question(s) {missing:?}"
516        )));
517    }
518    Ok(mapped)
519}
520
521#[async_trait]
522impl<R: AiRunner> Classifier for WorkersAi<R> {
523    fn profile(&self) -> ClassifierProfile {
524        self.profile
525    }
526
527    async fn ask(
528        &self,
529        state: &str,
530        questions: &BTreeMap<String, Question>,
531    ) -> Result<BTreeMap<String, Answer>, ClassifierError> {
532        // A malformed set is a `Rejected`, never a panic — and nothing is
533        // built or sent for one.
534        validate_questions(questions)?;
535
536        let profile = self.profile();
537        let (state, dropped) = profile.truncate(state);
538        if dropped {
539            // The one failure mode a classifier must not have silently: a
540            // trimmed state comes back confident and wrong, so the trim is
541            // observable, with the limit that did it.
542            tracing::warn!(
543                provider = "workers-ai",
544                limit = profile.max_state_chars,
545                "workers-ai truncated the state; the answers see only the first `limit` chars"
546            );
547        }
548
549        let request = ClassifyRequest {
550            state,
551            questions: questions
552                .iter()
553                .map(|(id, question)| wire_question(id, question))
554                .collect(),
555        };
556        let input = serde_json::to_value(&request)
557            .map_err(|err| ClassifierError::Transport(err.to_string()))?;
558
559        let output = self.runner.run(&self.model, input).await?;
560
561        let parsed: RunResponse = match serde_json::from_value(output) {
562            Ok(parsed) => parsed,
563            Err(err) => {
564                // A body that does not parse is a transport failure, not a
565                // verdict on the question set. The text may quote model
566                // output, so it travels only inside `ClassifierError`.
567                tracing::warn!(
568                    provider = "workers-ai",
569                    outcome = "transport",
570                    "a workers-ai success body did not survive the binding as an answer set"
571                );
572                return Err(ClassifierError::Transport(err.to_string()));
573            }
574        };
575        let answers = map_answers(parsed.answers, questions)?;
576        tracing::debug!(
577            provider = "workers-ai",
578            outcome = "answered",
579            questions = answers.len(),
580            "classifier outcome"
581        );
582        Ok(answers)
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use cratefield_core::AnswerValue;
590
591    fn good_set() -> BTreeMap<String, Question> {
592        BTreeMap::from([
593            (
594                "topic".to_owned(),
595                Question::Choice {
596                    instructions: "Which topic?".to_owned(),
597                    criteria: BTreeMap::from([
598                        ("billing".to_owned(), "money, invoices".to_owned()),
599                        ("bugs".to_owned(), "something is broken".to_owned()),
600                    ]),
601                },
602            ),
603            (
604                "severity".to_owned(),
605                Question::Score {
606                    instructions: "How severe?".to_owned(),
607                    levels: vec![
608                        ("1".to_owned(), "a typo".to_owned()),
609                        ("5".to_owned(), "data loss".to_owned()),
610                    ],
611                },
612            ),
613            (
614                "angry".to_owned(),
615                Question::Noul {
616                    instructions: "Is the writer angry?".to_owned(),
617                },
618            ),
619        ])
620    }
621
622    fn request_value(state: &str, questions: &BTreeMap<String, Question>) -> serde_json::Value {
623        let payload = ClassifyRequest {
624            state,
625            questions: questions
626                .iter()
627                .map(|(id, question)| wire_question(id, question))
628                .collect(),
629        };
630        serde_json::to_value(&payload).expect("serialises")
631    }
632
633    #[test]
634    fn the_request_carries_each_kind_in_its_own_shape() {
635        let sent = request_value("the state", &good_set());
636        assert_eq!(sent["state"], "the state");
637        let questions = sent["questions"].as_array().expect("array");
638        assert_eq!(questions.len(), 3);
639
640        let choice = questions
641            .iter()
642            .find(|q| q["id"] == "topic")
643            .expect("choice question");
644        assert_eq!(choice["kind"], "choice");
645        assert_eq!(choice["criteria"]["bugs"], "something is broken");
646        assert!(choice.get("levels").is_none());
647
648        let score = questions
649            .iter()
650            .find(|q| q["id"] == "severity")
651            .expect("score question");
652        assert_eq!(score["kind"], "score");
653        assert!(score.get("criteria").is_none());
654        assert_eq!(
655            score["levels"],
656            serde_json::json!([
657                {"name": "1", "meaning": "a typo"},
658                {"name": "5", "meaning": "data loss"},
659            ])
660        );
661
662        let noul = questions
663            .iter()
664            .find(|q| q["id"] == "angry")
665            .expect("noul question");
666        assert_eq!(noul["kind"], "noul");
667        assert!(noul.get("criteria").is_none());
668        assert!(noul.get("levels").is_none());
669    }
670
671    #[test]
672    fn the_default_model_id_is_the_shared_classifier() {
673        assert_eq!(DEFAULT_MODEL_ID, "typesafe/jev");
674    }
675
676    #[test]
677    fn the_documented_window_fits_inside_the_default_ceiling() {
678        // 96,000 chars at ~4 chars/token is ~24k tokens of the 32k window.
679        assert_eq!(WORKERS_AI_CONTEXT_TOKENS, 32_000);
680        // Compile-time: the constants must keep this relationship.
681        const {
682            assert!(DEFAULT_MAX_STATE_CHARS < WORKERS_AI_CONTEXT_TOKENS * 4);
683        }
684    }
685
686    #[test]
687    fn a_well_formed_set_maps_through_the_core_constructors() {
688        let answers = vec![
689            WireAnswer {
690                id: "topic".to_owned(),
691                value: WireValue::Label("bugs".to_owned()),
692                probabilities: BTreeMap::from([
693                    ("billing".to_owned(), 0.1_f32),
694                    ("bugs".to_owned(), 0.9_f32),
695                ]),
696            },
697            WireAnswer {
698                id: "severity".to_owned(),
699                value: WireValue::Number(5.0),
700                probabilities: BTreeMap::from([("5".to_owned(), 1.0_f32)]),
701            },
702            WireAnswer {
703                id: "angry".to_owned(),
704                value: WireValue::Label("false".to_owned()),
705                probabilities: BTreeMap::from([
706                    ("true".to_owned(), 0.2_f32),
707                    ("false".to_owned(), 0.8_f32),
708                ]),
709            },
710        ];
711        let mapped = map_answers(answers, &good_set()).expect("maps");
712        assert_eq!(mapped.len(), 3);
713        let topic = &mapped["topic"];
714        assert_eq!(topic.value, AnswerValue::Choice("bugs".to_owned()));
715        assert!((topic.confidence - 0.9).abs() < f32::EPSILON, "{topic:?}");
716        let severity = &mapped["severity"];
717        assert_eq!(severity.value, AnswerValue::Score(5.0));
718        let angry = &mapped["angry"];
719        assert_eq!(angry.value, AnswerValue::Noul(false));
720        // `confidence` agrees with `probabilities` on every answer.
721        for (id, answer) in &mapped {
722            let mass: f32 = answer.probabilities.values().sum();
723            assert!((mass - 1.0).abs() < SUM_TOLERANCE, "{id}: {answer:?}");
724        }
725    }
726
727    #[test]
728    fn a_distribution_that_does_not_sum_to_one_is_normalised() {
729        let answers = vec![WireAnswer {
730            id: "topic".to_owned(),
731            value: WireValue::Label("billing".to_owned()),
732            probabilities: BTreeMap::from([
733                ("billing".to_owned(), 0.3_f32),
734                ("bugs".to_owned(), 0.3_f32),
735            ]),
736        }];
737        let questions = BTreeMap::from([("topic".to_owned(), good_set()["topic"].clone())]);
738        let mapped = map_answers(answers, &questions).expect("maps");
739        let topic = &mapped["topic"];
740        assert!(
741            (topic.probabilities["billing"] - 0.5).abs() < 1.0e-6,
742            "{topic:?}"
743        );
744        assert!(
745            (topic.probabilities["bugs"] - 0.5).abs() < 1.0e-6,
746            "{topic:?}"
747        );
748        assert!((topic.confidence - 0.5).abs() < 1.0e-6, "{topic:?}");
749    }
750
751    #[test]
752    fn every_semantic_refusal_is_rejected() {
753        let cases: Vec<(String, Vec<WireAnswer>)> = vec![
754            (
755                "missing id".to_owned(),
756                vec![WireAnswer {
757                    id: "offTopic".to_owned(),
758                    value: WireValue::Label("bugs".to_owned()),
759                    probabilities: BTreeMap::from([("bugs".to_owned(), 1.0_f32)]),
760                }],
761            ),
762            (
763                "unknown label".to_owned(),
764                vec![WireAnswer {
765                    id: "topic".to_owned(),
766                    value: WireValue::Label("shipping".to_owned()),
767                    probabilities: BTreeMap::from([("shipping".to_owned(), 1.0_f32)]),
768                }],
769            ),
770            (
771                "empty distribution".to_owned(),
772                vec![WireAnswer {
773                    id: "topic".to_owned(),
774                    value: WireValue::Label("bugs".to_owned()),
775                    probabilities: BTreeMap::new(),
776                }],
777            ),
778            (
779                "probability above one".to_owned(),
780                vec![WireAnswer {
781                    id: "topic".to_owned(),
782                    value: WireValue::Label("bugs".to_owned()),
783                    probabilities: BTreeMap::from([("bugs".to_owned(), 1.5_f32)]),
784                }],
785            ),
786            (
787                "score off its scale".to_owned(),
788                vec![WireAnswer {
789                    id: "severity".to_owned(),
790                    value: WireValue::Number(3.5),
791                    probabilities: BTreeMap::from([("5".to_owned(), 1.0_f32)]),
792                }],
793            ),
794        ];
795        for (what, answers) in cases {
796            let mapped =
797                map_answers(answers, &good_set()).expect_err(&format!("{what} must be refused"));
798            assert!(
799                matches!(mapped, ClassifierError::Rejected(_)),
800                "{what}: {mapped:?}"
801            );
802        }
803    }
804
805    #[test]
806    fn a_question_the_model_skipped_is_rejected() {
807        let answers = vec![WireAnswer {
808            id: "topic".to_owned(),
809            value: WireValue::Label("bugs".to_owned()),
810            probabilities: BTreeMap::from([("bugs".to_owned(), 1.0_f32)]),
811        }];
812        let mapped = map_answers(answers, &good_set()).expect_err("skipped set refused");
813        assert!(matches!(mapped, ClassifierError::Rejected(_)), "{mapped:?}");
814    }
815
816    #[test]
817    fn the_error_mapping_lands_on_the_right_vocabulary() {
818        // Binding absent or unusable: `NotConfigured`.
819        for text in [
820            "Binding cannot be cast to the type Ai from Object",
821            "Env does not contain binding `AI`",
822            "Ai is not defined",
823        ] {
824            let mapped = map_worker_error(&worker::Error::JsError(text.to_owned()));
825            assert!(
826                matches!(mapped, ClassifierError::NotConfigured),
827                "{text}: {mapped:?}"
828            );
829        }
830        // Throttling and overload: `Transient`, with no invented duration.
831        for text in [
832            "rate limit exceeded",
833            "Too Many Requests",
834            "model is overloaded, try again",
835        ] {
836            let mapped = map_worker_error(&worker::Error::JsError(text.to_owned()));
837            assert!(
838                matches!(mapped, ClassifierError::Transient { retry_after: None }),
839                "{text}: {mapped:?}"
840            );
841        }
842        let structured =
843            map_worker_error(&worker::Error::RateLimitExceeded("daily cap".to_owned()));
844        assert!(matches!(
845            structured,
846            ClassifierError::Transient { retry_after: None }
847        ));
848        // The model refusing the input: `Rejected`.
849        for text in [
850            "model typesafe/jev not found",
851            "invalid input: expected an object",
852            "input too large for this model",
853        ] {
854            let mapped = map_worker_error(&worker::Error::JsError(text.to_owned()));
855            assert!(matches!(mapped, ClassifierError::Rejected(_)), "{text}");
856        }
857        // Everything else, including shapes the heuristic does not know:
858        // `Transport`.
859        for err in [
860            worker::Error::JsError("workerd hiccup".to_owned()),
861            worker::Error::BadEncoding,
862            worker::Error::RustError("connection reset".to_owned()),
863        ] {
864            let mapped = map_worker_error(&err);
865            assert!(matches!(mapped, ClassifierError::Transport(_)), "{err}");
866        }
867    }
868}