Skip to main content

cratefield_adapter_typesafe/
lib.rs

1//! `cratefield-adapter-typesafe`: the `Classifier` port over `TypeSafe`'s
2//! documented Jev evaluation API (`https://docs.typesafe.ai/api.md`).
3//! Runs on the runtime's `HttpClient` port — no vendor SDK — so the same
4//! adapter works on every runtime, and it bills **the operator's own
5//! `TypeSafe` account**: the API key is a constructor argument, not a
6//! port and not a platform binding.
7//!
8//! This is the default of the three `Classifier` adapters, and the only one
9//! with a key at all. `profile()` reports `Calibration::Classifier`: the
10//! probabilities come from a purpose-trained classifier model, not from a
11//! general language model, and a threshold tuned against these numbers is
12//! wrong against the `adapter-classifier-llm` family (see `CLASSIFIER.md`).
13//!
14//! The model is **pinned**, not aliased, and the version that actually
15//! answered is recorded on every answer
16//! ([`cratefield_core::Answer::model`]) — the "The model is pinned"
17//! section below has the why.
18//!
19//! The adapter never trusts a response: an answer for a question that was
20//! not asked, a question left unanswered, a label its question does not
21//! offer, or a number outside `[0.0, 1.0]` is
22//! `ClassifierError::Rejected`, not an invented answer.
23//!
24//! An absent or blank key is `ClassifierError::NotConfigured` from `ask`,
25//! never a panic and never a network call — a venture that has not wired a
26//! key degrades the way it degrades on an unwired port.
27
28#![doc = include_str!("../README.md")]
29#![forbid(unsafe_code)]
30
31use async_trait::async_trait;
32use bytes::Bytes;
33use cratefield_core::{
34    Answer, AnswerValue, Calibration, Classifier, ClassifierError, ClassifierProfile, Clock,
35    DEFAULT_MAX_STATE_CHARS, HttpClient, HttpError, HttpPolicy, MAX_RESPONSE_BYTES, Question,
36    retry_after, validate_questions,
37};
38use http::header::{AUTHORIZATION, CONTENT_TYPE};
39use http::{Request, StatusCode};
40use std::collections::BTreeMap;
41use std::fmt;
42use std::sync::Arc;
43use std::time::Duration;
44
45/// The `TypeSafe` evaluation endpoint this adapter POSTs to, as documented
46/// at `https://docs.typesafe.ai/api.md`. Tests point elsewhere with
47/// [`TypeSafe::with_endpoint`].
48pub const DEFAULT_ENDPOINT: &str = "https://api.typesafe.ai/v1/systemone";
49
50/// The model every request carries unless overridden: a **pinned** version
51/// id, not the `jev-latest` alias the vendor's examples use — an alias
52/// moves without notice, and a threshold tuned to one version is wrong
53/// against the next (see the crate docs). Move deliberately with
54/// [`TypeSafe::with_model`].
55pub const DEFAULT_MODEL: &str = "jev-1.13.0";
56
57/// The most options a `choice` question may carry: the vendor's own
58/// ceiling, refused pre-wire (`validate_questions` covers the minimum).
59pub const MAX_CHOICE_OPTIONS: usize = 255;
60
61/// The most levels a `score` question may carry: the vendor's own
62/// ceiling, refused pre-wire (`validate_questions` covers the minimum).
63pub const MAX_SCORE_LEVELS: usize = 10;
64
65/// The request deadline asked of the [`HttpClient`] port: one evaluation
66/// round trip, not a completion, so 20 s is generous and still under the
67/// port's own 30 s ceiling.
68const REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
69
70/// `TypeSafe`'s documented "overloaded" status, outside the standard 5xx
71/// band: retryable, exactly like a 5xx.
72const OVERLOADED: u16 = 529;
73
74/// `Classifier` over `TypeSafe`'s Jev evaluation API, billed to the
75/// operator's own key.
76pub struct TypeSafe {
77    http: Arc<dyn HttpClient>,
78    /// Needed only to read the HTTP-date form of `Retry-After` (the same
79    /// reason `adapter-resend` holds one, issue #278): a seconds-form
80    /// header needs no clock, a "come back at 08:49:37 GMT" does.
81    clock: Arc<dyn Clock>,
82    /// `None`, or a blank string, is `ClassifierError::NotConfigured`.
83    api_key: Option<String>,
84    model: String,
85    endpoint: String,
86}
87
88/// `Debug` names the endpoint, the model and whether a key is configured —
89/// never the key: an adapter reaches debug output through harnesses and
90/// panic messages, and the key rides the `Authorization` header and
91/// nothing else.
92impl fmt::Debug for TypeSafe {
93    /// Never prints the key: it rides the `Authorization` header and
94    /// nothing else.
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        f.debug_struct("TypeSafe")
97            .field("endpoint", &self.endpoint)
98            .field("model", &self.model)
99            .field("api_key_configured", &self.configured_key().is_some())
100            // The `http` and `clock` ports are struct fields too; nothing
101            // about either belongs in a debug line.
102            .finish_non_exhaustive()
103    }
104}
105
106impl TypeSafe {
107    /// `api_key: None` (or a blank key) makes every `ask` answer
108    /// `ClassifierError::NotConfigured` before any network call. The model
109    /// is [`DEFAULT_MODEL`] and the endpoint [`DEFAULT_ENDPOINT`]; override
110    /// either with [`TypeSafe::with_model`] / [`TypeSafe::with_endpoint`].
111    pub fn new(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>, api_key: Option<String>) -> Self {
112        Self {
113            http,
114            clock,
115            api_key,
116            model: DEFAULT_MODEL.to_owned(),
117            endpoint: DEFAULT_ENDPOINT.to_owned(),
118        }
119    }
120
121    /// Pins the model every request carries. The default is already a
122    /// pinned version id rather than the `jev-latest` alias, because an
123    /// alias moves without notice; use this to move to a new version on
124    /// your own schedule.
125    #[must_use]
126    pub fn with_model(mut self, model: impl Into<String>) -> Self {
127        self.model = model.into();
128        self
129    }
130
131    /// Overrides the API endpoint (tests point this at a scripted client's
132    /// expected host; production uses [`DEFAULT_ENDPOINT`]).
133    #[must_use]
134    pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
135        self.endpoint = endpoint.into();
136        self
137    }
138
139    /// `Some(...)` only when `TYPESAFE_API_KEY` is set: an absent key means
140    /// the port is not provided at all. `TYPESAFE_MODEL`, when set and
141    /// non-blank, pins the model instead of [`DEFAULT_MODEL`] (the same
142    /// pattern `ANTHROPIC_MODEL` follows). On Workers the venture should
143    /// read the secret from its `Env` and use `TypeSafe::new` instead
144    /// (`std::env` has no Workers vars). A key that is set but blank still
145    /// constructs — and `ask` then answers `NotConfigured`.
146    pub fn from_env(http: Arc<dyn HttpClient>, clock: Arc<dyn Clock>) -> Option<Self> {
147        let api_key = std::env::var("TYPESAFE_API_KEY").ok()?;
148        let model = model_from_env(std::env::var("TYPESAFE_MODEL").ok());
149        Some(Self::new(http, clock, Some(api_key)).with_model(model))
150    }
151
152    /// The key this adapter authenticates with, trimmed: a padded key is
153    /// still a key, a blank one is not configured at all.
154    fn configured_key(&self) -> Option<&str> {
155        self.api_key
156            .as_deref()
157            .map(str::trim)
158            .filter(|key| !key.is_empty())
159    }
160}
161
162/// `TYPESAFE_MODEL` honoured only when set and non-blank: a blank variable
163/// is no configuration, not a model named `""`. Extracted for the same
164/// reason `from_env` cannot be exercised directly — the environment is
165/// process-global.
166fn model_from_env(value: Option<String>) -> String {
167    match value {
168        Some(model) if !model.trim().is_empty() => model.trim().to_owned(),
169        _ => DEFAULT_MODEL.to_owned(),
170    }
171}
172
173/// The request body, as `https://docs.typesafe.ai/api.md` documents it:
174/// one `state`, the pinned `model`, and **all** questions of the call as a
175/// map keyed by the caller's own ids — the vendor evaluates the set
176/// against the state in parallel, which is the whole point of the port.
177#[derive(serde::Serialize)]
178struct SystemOneRequest<'a> {
179    state: &'a str,
180    model: &'a str,
181    questions: BTreeMap<&'a str, WireQuestion<'a>>,
182}
183
184/// One question on the wire. The three kinds share `type` and
185/// `instructions`; each adds its own `criteria`, and a shape a kind does
186/// not carry is omitted rather than sent empty — the vendor never sees a
187/// `noul` with a stray `criteria`.
188#[derive(serde::Serialize)]
189struct WireQuestion<'a> {
190    #[serde(rename = "type")]
191    kind: &'static str,
192    instructions: &'a str,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    criteria: Option<WireCriteria<'a>>,
195}
196
197/// The two shapes `criteria` takes on the wire: a `choice`'s
198/// `option -> what it means` map, and a `score`'s ordered array of level
199/// descriptions. Jev's score criteria carries descriptions only — the
200/// port's level *names* do not travel, and the answers re-key to them by
201/// position (see `map_answers`).
202#[derive(serde::Serialize)]
203#[serde(untagged)]
204enum WireCriteria<'a> {
205    Options(BTreeMap<&'a str, &'a str>),
206    Levels(Vec<&'a str>),
207}
208
209/// Pure request builder: the port's question set into the vendor's
210/// documented request shape. Tested against the recorded example bodies
211/// of the API docs (`tests/fixtures/`).
212fn build_request<'a>(
213    state: &'a str,
214    model: &'a str,
215    questions: &'a BTreeMap<String, Question>,
216) -> SystemOneRequest<'a> {
217    SystemOneRequest {
218        state,
219        model,
220        questions: questions
221            .iter()
222            .map(|(id, question)| (id.as_str(), wire_question(question)))
223            .collect(),
224    }
225}
226
227fn wire_question(question: &Question) -> WireQuestion<'_> {
228    match question {
229        Question::Choice {
230            instructions,
231            criteria,
232        } => WireQuestion {
233            kind: question.kind(),
234            instructions,
235            criteria: Some(WireCriteria::Options(
236                criteria
237                    .iter()
238                    .map(|(name, meaning)| (name.as_str(), meaning.as_str()))
239                    .collect(),
240            )),
241        },
242        Question::Score {
243            instructions,
244            levels,
245        } => WireQuestion {
246            kind: question.kind(),
247            instructions,
248            criteria: Some(WireCriteria::Levels(
249                levels.iter().map(|(_, meaning)| meaning.as_str()).collect(),
250            )),
251        },
252        Question::Noul { instructions } => WireQuestion {
253            kind: question.kind(),
254            instructions,
255            criteria: None,
256        },
257    }
258}
259
260/// The vendor's own ceilings, checked after `validate_questions` and
261/// before anything is built or sent.
262///
263/// # Errors
264/// [`ClassifierError::Rejected`] when a question exceeds a limit — the
265/// vendor would 422 the whole request, and the caller can know that
266/// without paying for the round trip.
267fn check_vendor_limits(questions: &BTreeMap<String, Question>) -> Result<(), ClassifierError> {
268    for (id, question) in questions {
269        match question {
270            Question::Choice { criteria, .. } if criteria.len() > MAX_CHOICE_OPTIONS => {
271                return Err(ClassifierError::Rejected(format!(
272                    "choice question {id:?} carries {} options, over the {MAX_CHOICE_OPTIONS} the vendor accepts",
273                    criteria.len()
274                )));
275            }
276            Question::Score { levels, .. } if levels.len() > MAX_SCORE_LEVELS => {
277                return Err(ClassifierError::Rejected(format!(
278                    "score question {id:?} carries {} levels, over the {MAX_SCORE_LEVELS} the vendor accepts",
279                    levels.len()
280                )));
281            }
282            _ => {}
283        }
284    }
285    Ok(())
286}
287
288/// The success body, as documented: the versioned model that answered, one
289/// answer under each asked id, and token usage.
290#[derive(serde::Deserialize)]
291struct SystemOneResponse {
292    #[serde(default)]
293    model: String,
294    answers: BTreeMap<String, WireAnswer>,
295    #[serde(default)]
296    usage: Option<WireUsage>,
297}
298
299#[derive(serde::Deserialize, Default)]
300struct WireUsage {
301    #[serde(default)]
302    input_tokens: u32,
303    #[serde(default)]
304    output_tokens: u32,
305}
306
307/// One answer, tagged by `type` the way the vendor tags it. A score answer
308/// also carries a `legend` — the question's own level descriptions echoed
309/// back under their indices; the question in hand is the authoritative
310/// copy, so the legend is not read.
311///
312/// A body that does not fit these shapes never reaches the mapping below:
313/// it is a parse failure, and that is a `Transport` — the answer did not
314/// survive the hop — not a `Rejected` of the question set.
315#[derive(serde::Deserialize)]
316#[serde(tag = "type", rename_all = "snake_case")]
317enum WireAnswer {
318    /// p(yes), on 0..=1. No confidence and no distribution of its own;
319    /// both are derived below from this one number.
320    Noul { noul: f32 },
321    Choice {
322        choice: String,
323        probabilities: BTreeMap<String, f32>,
324        confidence: f32,
325    },
326    Score {
327        score: f32,
328        probabilities: BTreeMap<String, f32>,
329        confidence: f32,
330    },
331}
332
333/// The kind name of a wire answer, for the mismatch message.
334fn answer_kind(answer: &WireAnswer) -> &'static str {
335    match answer {
336        WireAnswer::Noul { .. } => "noul",
337        WireAnswer::Choice { .. } => "choice",
338        WireAnswer::Score { .. } => "score",
339    }
340}
341
342/// Every refusal below is a `Rejected` with a message naming the question
343/// and the offence. The log line stays generic on purpose: the message can
344/// quote vendor-echoed text, and a log field is not scrubbed the way
345/// `ClassifierError`'s `Display` is.
346fn reject(message: String) -> ClassifierError {
347    tracing::warn!(
348        provider = "typesafe",
349        "a typesafe answer does not match the question it answers"
350    );
351    ClassifierError::Rejected(message)
352}
353
354/// Maps the vendor's answer set onto the questions asked, refusing anything
355/// that does not line up, and stamping the resolved model on every answer.
356/// Every answer is checked against *its own* question: an answer for an id
357/// that was not asked, a probability on a label the question never offered,
358/// a number outside `[0.0, 1.0]`, or a value of the wrong shape is
359/// `Rejected` — the adapter does not invent answers for questions the
360/// vendor skipped, either. (Answers arrive as a JSON map keyed by id, so a
361/// doubled id collapses in parsing rather than doubling here.)
362fn map_answers(
363    answers: BTreeMap<String, WireAnswer>,
364    questions: &BTreeMap<String, Question>,
365    model: &str,
366) -> Result<BTreeMap<String, Answer>, ClassifierError> {
367    let mut mapped = BTreeMap::new();
368    for (id, answer) in answers {
369        let Some(question) = questions.get(&id) else {
370            return Err(reject(format!(
371                "typesafe answered question id {id:?}, which was not asked"
372            )));
373        };
374        let built = match (question, &answer) {
375            (Question::Noul { .. }, WireAnswer::Noul { noul }) => noul_answer(&id, *noul)?,
376            (
377                Question::Choice { criteria, .. },
378                WireAnswer::Choice {
379                    choice,
380                    probabilities,
381                    confidence,
382                },
383            ) => choice_answer(&id, choice, probabilities, *confidence, criteria)?,
384            (
385                Question::Score { levels, .. },
386                WireAnswer::Score {
387                    score,
388                    probabilities,
389                    confidence,
390                },
391            ) => score_answer(&id, *score, probabilities, *confidence, levels)?,
392            _ => {
393                return Err(reject(format!(
394                    "typesafe answered {} question {id:?} with a {} answer",
395                    question.kind(),
396                    answer_kind(&answer),
397                )));
398            }
399        };
400        mapped.insert(id, built.with_model(model));
401    }
402    if mapped.len() != questions.len() {
403        let missing: Vec<_> = questions
404            .keys()
405            .filter(|id| !mapped.contains_key(*id))
406            .cloned()
407            .collect();
408        return Err(reject(format!(
409            "typesafe did not answer the question(s) {missing:?}"
410        )));
411    }
412    Ok(mapped)
413}
414
415/// Jev answers a `noul` with one number: p(yes) — no confidence, no
416/// probabilities. The port wants a verdict plus a probability per side, so
417/// the other side is derived as `1 - p`, the verdict is `p >= 0.5`, and
418/// `Answer::noul` reads its confidence out under the chosen side (the port's
419/// bare-`f32` confidence has nowhere to say "derived"; making it optional is
420/// a separate follow-up).
421fn noul_answer(id: &str, noul: f32) -> Result<Answer, ClassifierError> {
422    if !noul.is_finite() || !(0.0..=1.0).contains(&noul) {
423        return Err(reject(format!(
424            "typesafe answered noul question {id:?} with {noul}, outside [0, 1]"
425        )));
426    }
427    let verdict = noul >= 0.5;
428    let probabilities =
429        BTreeMap::from([("true".to_owned(), noul), ("false".to_owned(), 1.0 - noul)]);
430    Ok(Answer::noul(verdict, probabilities))
431}
432
433/// A `choice` answer: the chosen label must be one of the question's own
434/// criteria, the probabilities keyed by criteria and inside the unit
435/// interval, and the confidence inside it too.
436fn choice_answer(
437    id: &str,
438    choice: &str,
439    probabilities: &BTreeMap<String, f32>,
440    confidence: f32,
441    criteria: &BTreeMap<String, String>,
442) -> Result<Answer, ClassifierError> {
443    let checked = checked_probabilities(id, probabilities, criteria.keys().map(String::as_str))?;
444    if !criteria.contains_key(choice) {
445        return Err(reject(format!(
446            "typesafe answered choice question {id:?} with {choice:?}, which is not one of its criteria"
447        )));
448    }
449    if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
450        return Err(reject(format!(
451            "typesafe answered choice question {id:?} with confidence {confidence}, outside [0, 1]"
452        )));
453    }
454    // The vendor's `confidence` is its own derivation from the
455    // distribution — the docs' own example reports `0.81` over a `0.88`
456    // top probability — so it is carried as reported through
457    // `Answer::new` rather than re-derived from `probabilities` the way
458    // `Answer::choice` would.
459    Ok(Answer::new(
460        AnswerValue::Choice(choice.to_owned()),
461        checked,
462        confidence,
463    ))
464}
465
466/// A `score` answer: re-keys Jev's index-keyed probabilities to the
467/// port's level names, and converts Jev's weighted index score onto the
468/// scale the port's `AnswerValue::Score` lives on (see
469/// `score_on_level_scale`).
470fn score_answer(
471    id: &str,
472    score: f32,
473    probabilities: &BTreeMap<String, f32>,
474    confidence: f32,
475    levels: &[(String, String)],
476) -> Result<Answer, ClassifierError> {
477    // Jev scores on the index axis of the criteria array it was sent —
478    // index 0 is the port's first level. The weighted value must land
479    // inside that axis; the tolerance absorbs the float noise of the
480    // vendor's own weighted sum, not a real overshoot. (`validate_questions`
481    // has already run, so a score question carries at least two levels and
482    // the top index below exists.)
483    #[allow(clippy::cast_precision_loss)] // a level count, never near f32's edge
484    let top = (levels.len() - 1) as f32;
485    if !score.is_finite()
486        || !(-SCORE_RANGE_TOLERANCE..=top + SCORE_RANGE_TOLERANCE).contains(&score)
487    {
488        return Err(reject(format!(
489            "typesafe scored question {id:?} at {score}, off its 0..={top} scale"
490        )));
491    }
492    if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
493        return Err(reject(format!(
494            "typesafe answered score question {id:?} with confidence {confidence}, outside [0, 1]"
495        )));
496    }
497    // Re-key by position: Jev's `"0"` is the port's first level *name*,
498    // `"1"` its second, and so on. An index the question has no level for
499    // is a refused answer, not a truncated distribution.
500    let mut checked = BTreeMap::new();
501    for (index, probability) in probabilities {
502        let Ok(level) = index.parse::<usize>() else {
503            return Err(reject(format!(
504                "typesafe answered score question {id:?} under {index:?}, which is not a level index"
505            )));
506        };
507        let Some((name, _)) = levels.get(level) else {
508            return Err(reject(format!(
509                "typesafe answered score question {id:?} at level {index}, which it does not have"
510            )));
511        };
512        if !probability.is_finite() || !(0.0..=1.0).contains(probability) {
513            return Err(reject(format!(
514                "typesafe answered question {id:?} with probability {probability} for level {index}, outside [0, 1]"
515            )));
516        }
517        checked.insert(name.clone(), *probability);
518    }
519    let value = score_on_level_scale(score, levels);
520    // `Answer::score` looks confidence up under `value.to_string()`, which
521    // only agrees with the vendor's report when the value happens to name
522    // a level; an interpolated value (or the vendor's own confidence
523    // derivation) does not, so the confidence is stated, not looked up.
524    Ok(Answer::new(AnswerValue::Score(value), checked, confidence))
525}
526
527/// The probabilities are keyed by the question's own criteria — that is
528/// what makes `confidence` and `probabilities` comparable. The port does
529/// not require the masses to sum to one, so they are validated, not
530/// renormalised.
531fn checked_probabilities<'a>(
532    id: &str,
533    probabilities: &BTreeMap<String, f32>,
534    criteria: impl Iterator<Item = &'a str>,
535) -> Result<BTreeMap<String, f32>, ClassifierError> {
536    let offered: Vec<&str> = criteria.collect();
537    let mut checked = BTreeMap::new();
538    for (label, probability) in probabilities {
539        if !offered.contains(&label.as_str()) {
540            return Err(reject(format!(
541                "typesafe answered question {id:?} with label {label:?}, which it does not offer"
542            )));
543        }
544        if !probability.is_finite() || !(0.0..=1.0).contains(probability) {
545            return Err(reject(format!(
546                "typesafe answered question {id:?} with probability {probability} for {label:?}, outside [0, 1]"
547            )));
548        }
549        checked.insert(label.clone(), *probability);
550    }
551    Ok(checked)
552}
553
554/// Slack on a weighted score's range check: the vendor's own weighted sum
555/// of reported probabilities can sit a hair outside `0..=levels-1` on a
556/// rounded answer, and that is not an off-scale score.
557const SCORE_RANGE_TOLERANCE: f32 = 1.0e-3;
558
559/// Puts Jev's weighted index score on the scale `AnswerValue::Score` lives
560/// on — the question's own level names (the port interpolates in that
561/// space, and so does `adapter-classifier-llm`). Whole indices are exact —
562/// index `i` is the port's `i`-th level — and the in-between is
563/// interpolated linearly between the caller's names. Non-numeric names
564/// (including a `"inf"` or `"NaN"`, which parse but are no axis) have no
565/// name space to convert into, so the index position is carried as-is.
566fn score_on_level_scale(score: f32, levels: &[(String, String)]) -> f32 {
567    let parsed: Option<Vec<f32>> = levels
568        .iter()
569        .map(|(name, _)| name.parse::<f32>().ok().filter(|value| value.is_finite()))
570        .collect();
571    let Some(names) = parsed else {
572        return score;
573    };
574    if names.len() < 2 {
575        return names.first().copied().unwrap_or(score);
576    }
577    #[allow(clippy::cast_precision_loss)] // a level count, never near f32's edge
578    let top = (names.len() - 1) as f32;
579    let score = score.clamp(0.0, top);
580    // clamped to 0..=top first, so neither the truncation nor the sign can bite
581    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
582    let base = (score.floor() as usize).min(names.len() - 2);
583    #[allow(clippy::cast_precision_loss)] // base <= names.len() - 2
584    let fraction = score - base as f32;
585    names[base] + fraction * (names[base + 1] - names[base])
586}
587
588#[async_trait]
589impl Classifier for TypeSafe {
590    fn profile(&self) -> ClassifierProfile {
591        // `TypeSafe` serves purpose-trained classifier models: the numbers
592        // are the model's own outputs. The state ceiling is core's
593        // conservative default, not a `TypeSafe`-published budget.
594        ClassifierProfile::new(Calibration::Classifier, DEFAULT_MAX_STATE_CHARS)
595    }
596
597    async fn ask(
598        &self,
599        state: &str,
600        questions: &BTreeMap<String, Question>,
601    ) -> Result<BTreeMap<String, Answer>, ClassifierError> {
602        let Some(api_key) = self.configured_key() else {
603            tracing::info!(
604                provider = "typesafe",
605                outcome = "not_configured",
606                "classifier outcome"
607            );
608            return Err(ClassifierError::NotConfigured);
609        };
610
611        // A malformed set is a `Rejected`, never a panic, and the vendor's
612        // own size ceilings are checked the same way, before the wire.
613        validate_questions(questions)?;
614        check_vendor_limits(questions)?;
615
616        let profile = self.profile();
617        let (state, dropped) = profile.truncate(state);
618        if dropped {
619            // The one failure mode a classifier must not have silently: a
620            // trimmed state comes back confident and wrong, so the trim is
621            // observable, with the limit that did it.
622            tracing::warn!(
623                limit = profile.max_state_chars,
624                "typesafe classifier truncated the state; the answers see only the first `limit` chars"
625            );
626        }
627
628        let payload = build_request(state, &self.model, questions);
629        let body = serde_json::to_vec(&payload)
630            .map_err(|err| ClassifierError::Transport(err.to_string()))?;
631        let mut request = Request::builder()
632            .method(http::Method::POST)
633            .uri(&self.endpoint)
634            .header(CONTENT_TYPE, "application/json")
635            .header(AUTHORIZATION, format!("Bearer {api_key}"))
636            .body(Bytes::from(body))
637            .map_err(|err| ClassifierError::Transport(err.to_string()))?;
638        // The port's defaults are ceilings, not choices, so the deadline is
639        // stated explicitly (`adapter-anthropic` does the same).
640        request.extensions_mut().insert(HttpPolicy {
641            timeout: REQUEST_TIMEOUT,
642            max_response_bytes: MAX_RESPONSE_BYTES,
643        });
644
645        let response = self.http.send(request).await.map_err(|err: HttpError| {
646            tracing::warn!(
647                provider = "typesafe",
648                outcome = "transport",
649                "typesafe transport failure"
650            );
651            ClassifierError::Transport(err.to_string())
652        })?;
653
654        let status = response.status();
655        // One parser for both `Retry-After` forms; the date form needs the
656        // clock this adapter is constructed with (issue #214/#278).
657        let retry_after = retry_after(response.headers(), self.clock.as_ref());
658        let text = String::from_utf8_lossy(response.body()).to_string();
659
660        // Throttled or down: retryable, with the vendor's back-off where it
661        // named one. 429 and 529 are named explicitly because neither sits
662        // in the plain-5xx band this branch also covers.
663        if status == StatusCode::TOO_MANY_REQUESTS
664            || status.as_u16() == OVERLOADED
665            || status.is_server_error()
666        {
667            tracing::warn!(
668                provider = "typesafe",
669                code = status.as_u16(),
670                outcome = "transient",
671                "classifier outcome"
672            );
673            return Err(ClassifierError::Transient { retry_after });
674        }
675        if !status.is_success() {
676            // 401 for a bad key, 422 for a body the vendor's validation
677            // refused: the request itself was refused, and the vendor's body
678            // is the reason (`Display` scrubs the text, issue #235).
679            tracing::warn!(
680                provider = "typesafe",
681                code = status.as_u16(),
682                outcome = "rejected",
683                "classifier outcome"
684            );
685            return Err(ClassifierError::Rejected(text));
686        }
687
688        let parsed: SystemOneResponse = match serde_json::from_str(&text) {
689            Ok(parsed) => parsed,
690            Err(err) => {
691                tracing::warn!(
692                    provider = "typesafe",
693                    outcome = "transport",
694                    "a typesafe success body did not survive the hop as an answer set"
695                );
696                return Err(ClassifierError::Transport(err.to_string()));
697            }
698        };
699        // The docs' `model` is the version that actually answered — an
700        // alias request (`jev-latest`) resolves here. Absent, it falls
701        // back to what was asked, the way `adapter-anthropic` does.
702        let resolved_model = if parsed.model.is_empty() {
703            self.model.clone()
704        } else {
705            parsed.model
706        };
707        let answers = map_answers(parsed.answers, questions, &resolved_model)?;
708        let usage = parsed.usage.unwrap_or_default();
709        tracing::debug!(
710            provider = "typesafe",
711            outcome = "answered",
712            model = %resolved_model,
713            questions = answers.len(),
714            input_tokens = usage.input_tokens,
715            output_tokens = usage.output_tokens,
716            "classifier outcome"
717        );
718        Ok(answers)
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725
726    fn good_set() -> BTreeMap<String, Question> {
727        BTreeMap::from([
728            (
729                "topic".to_owned(),
730                Question::Choice {
731                    instructions: "Which topic?".to_owned(),
732                    criteria: BTreeMap::from([
733                        ("billing".to_owned(), "money, invoices".to_owned()),
734                        ("bugs".to_owned(), "something is broken".to_owned()),
735                    ]),
736                },
737            ),
738            (
739                "angry".to_owned(),
740                Question::Noul {
741                    instructions: "Is the writer angry?".to_owned(),
742                },
743            ),
744        ])
745    }
746
747    fn vendor_answer(id: &str, answer: WireAnswer) -> (String, WireAnswer) {
748        (id.to_owned(), answer)
749    }
750
751    fn map(
752        answers: Vec<(String, WireAnswer)>,
753        questions: &BTreeMap<String, Question>,
754    ) -> Result<BTreeMap<String, Answer>, ClassifierError> {
755        map_answers(answers.into_iter().collect(), questions, DEFAULT_MODEL)
756    }
757
758    #[test]
759    fn an_answer_for_an_id_that_was_not_asked_is_refused() {
760        let answers = vec![vendor_answer("never-asked", WireAnswer::Noul { noul: 1.0 })];
761        assert!(matches!(
762            map(answers, &good_set()),
763            Err(ClassifierError::Rejected(_))
764        ));
765    }
766
767    #[test]
768    fn a_label_outside_its_question_is_refused() {
769        // Not a criterion of `topic`.
770        let answers = vec![vendor_answer(
771            "topic",
772            WireAnswer::Choice {
773                choice: "shipping".to_owned(),
774                probabilities: BTreeMap::new(),
775                confidence: 1.0,
776            },
777        )];
778        assert!(matches!(
779            map(answers, &good_set()),
780            Err(ClassifierError::Rejected(_))
781        ));
782        // A probability on a label the question never offered.
783        let answers = vec![vendor_answer(
784            "topic",
785            WireAnswer::Choice {
786                choice: "bugs".to_owned(),
787                probabilities: BTreeMap::from([("shipping".to_owned(), 1.0_f32)]),
788                confidence: 1.0,
789            },
790        )];
791        assert!(matches!(
792            map(answers, &good_set()),
793            Err(ClassifierError::Rejected(_))
794        ));
795    }
796
797    #[test]
798    fn a_number_outside_the_unit_interval_is_refused() {
799        let answers = vec![vendor_answer(
800            "topic",
801            WireAnswer::Choice {
802                choice: "bugs".to_owned(),
803                probabilities: BTreeMap::from([("bugs".to_owned(), 1.5_f32)]),
804                confidence: 0.5,
805            },
806        )];
807        assert!(matches!(
808            map(answers, &good_set()),
809            Err(ClassifierError::Rejected(_))
810        ));
811        let answers = vec![vendor_answer(
812            "topic",
813            WireAnswer::Choice {
814                choice: "bugs".to_owned(),
815                probabilities: BTreeMap::from([("bugs".to_owned(), -0.1_f32)]),
816                confidence: 0.5,
817            },
818        )];
819        assert!(matches!(
820            map(answers, &good_set()),
821            Err(ClassifierError::Rejected(_))
822        ));
823        // An overflowing literal parses as an infinity; a non-finite
824        // confidence is refused with the rest.
825        let answers = vec![vendor_answer(
826            "topic",
827            WireAnswer::Choice {
828                choice: "bugs".to_owned(),
829                probabilities: BTreeMap::from([("bugs".to_owned(), 0.5_f32)]),
830                confidence: f32::INFINITY,
831            },
832        )];
833        assert!(matches!(
834            map(answers, &good_set()),
835            Err(ClassifierError::Rejected(_))
836        ));
837    }
838
839    #[test]
840    fn a_noul_is_derived_from_its_one_number() {
841        let noul_only = BTreeMap::from([(
842            "angry".to_owned(),
843            Question::Noul {
844                instructions: "Is the writer angry?".to_owned(),
845            },
846        )]);
847        // p = 0.8: verdict true, the chosen side's probability the confidence.
848        let answers = vec![vendor_answer("angry", WireAnswer::Noul { noul: 0.8 })];
849        let mapped = map(answers, &noul_only).expect("maps");
850        let angry = &mapped["angry"];
851        assert_eq!(angry.value, AnswerValue::Noul(true));
852        assert!((angry.probabilities["true"] - 0.8).abs() < f32::EPSILON);
853        assert!((angry.probabilities["false"] - 0.2).abs() < f32::EPSILON);
854        assert!((angry.confidence - 0.8).abs() < f32::EPSILON, "{angry:?}");
855
856        // p = 0.3: verdict false, and the confidence is 1 - p.
857        let answers = vec![vendor_answer("angry", WireAnswer::Noul { noul: 0.3 })];
858        let mapped = map(answers, &noul_only).expect("maps");
859        let angry = &mapped["angry"];
860        assert_eq!(angry.value, AnswerValue::Noul(false));
861        assert!((angry.confidence - 0.7).abs() < f32::EPSILON, "{angry:?}");
862    }
863
864    #[test]
865    fn a_noul_outside_the_unit_interval_is_refused() {
866        let noul_only = BTreeMap::from([(
867            "angry".to_owned(),
868            Question::Noul {
869                instructions: "Is the writer angry?".to_owned(),
870            },
871        )]);
872        for noul in [-0.1_f32, 1.1, f32::NAN] {
873            let answers = vec![vendor_answer("angry", WireAnswer::Noul { noul })];
874            assert!(
875                matches!(map(answers, &noul_only), Err(ClassifierError::Rejected(_))),
876                "noul {noul} refused"
877            );
878        }
879    }
880
881    #[test]
882    fn a_score_re_keys_to_the_level_names_and_interpolates_the_value() {
883        // Jev's own documented example: weighted index 1.05 on a
884        // three-level scale whose names are words — no numeric name space,
885        // so the index position itself is the value.
886        let words = BTreeMap::from([(
887            "frustration".to_owned(),
888            Question::Score {
889                instructions: "How frustrated?".to_owned(),
890                levels: vec![
891                    ("Calm".to_owned(), "Calm".to_owned()),
892                    ("Frustrated".to_owned(), "Frustrated".to_owned()),
893                    ("Very angry".to_owned(), "Very angry".to_owned()),
894                ],
895            },
896        )]);
897        let answers = vec![vendor_answer(
898            "frustration",
899            WireAnswer::Score {
900                score: 1.05,
901                probabilities: BTreeMap::from([
902                    ("0".to_owned(), 0.0_f32),
903                    ("1".to_owned(), 0.95_f32),
904                    ("2".to_owned(), 0.05_f32),
905                ]),
906                confidence: 0.92,
907            },
908        )];
909        let mapped = map(answers, &words).expect("maps");
910        let frustration = &mapped["frustration"];
911        assert!(
912            (value_score(frustration) - 1.05).abs() < f32::EPSILON,
913            "{frustration:?}"
914        );
915        assert!((frustration.probabilities["Frustrated"] - 0.95).abs() < f32::EPSILON);
916        assert!((frustration.probabilities["Very angry"] - 0.05).abs() < f32::EPSILON);
917        assert!((frustration.confidence - 0.92).abs() < f32::EPSILON);
918
919        // The same weighted index on a scale named `"1"`/`"5"`: index 0 is
920        // name `1`, index 1 name `5`, and 0.7 of the way between is
921        // 1 + 0.7 * 4 = 3.8.
922        let numeric = BTreeMap::from([(
923            "severity".to_owned(),
924            Question::Score {
925                instructions: "How severe?".to_owned(),
926                levels: vec![
927                    ("1".to_owned(), "a typo".to_owned()),
928                    ("5".to_owned(), "data loss".to_owned()),
929                ],
930            },
931        )]);
932        let answers = vec![vendor_answer(
933            "severity",
934            WireAnswer::Score {
935                score: 0.7,
936                probabilities: BTreeMap::from([
937                    ("0".to_owned(), 0.3_f32),
938                    ("1".to_owned(), 0.7_f32),
939                ]),
940                confidence: 0.7,
941            },
942        )];
943        let mapped = map(answers, &numeric).expect("maps");
944        let severity = &mapped["severity"];
945        assert!((value_score(severity) - 3.8).abs() < 1.0e-5, "{severity:?}");
946        assert!((severity.probabilities["1"] - 0.3).abs() < f32::EPSILON);
947        assert!((severity.probabilities["5"] - 0.7).abs() < f32::EPSILON);
948
949        // A whole index is exactly that level's name: index 1 on the
950        // `"1"`/`"5"` scale is 5.
951        let answers = vec![vendor_answer(
952            "severity",
953            WireAnswer::Score {
954                score: 1.0,
955                probabilities: BTreeMap::from([
956                    ("0".to_owned(), 0.2_f32),
957                    ("1".to_owned(), 0.8_f32),
958                ]),
959                confidence: 0.8,
960            },
961        )];
962        let mapped = map(answers, &numeric).expect("maps");
963        assert!((value_score(&mapped["severity"]) - 5.0).abs() < f32::EPSILON);
964    }
965
966    #[test]
967    fn a_level_named_like_a_float_special_is_not_a_number_axis() {
968        // `"inf"` and `"NaN"` parse as floats but are not points on a
969        // scale: the names are words, so the index position is carried.
970        let words = BTreeMap::from([(
971            "odd".to_owned(),
972            Question::Score {
973                instructions: "How odd?".to_owned(),
974                levels: vec![
975                    ("inf".to_owned(), "not much".to_owned()),
976                    ("NaN".to_owned(), "very".to_owned()),
977                ],
978            },
979        )]);
980        let answers = vec![vendor_answer(
981            "odd",
982            WireAnswer::Score {
983                score: 0.5,
984                probabilities: BTreeMap::from([
985                    ("0".to_owned(), 0.5_f32),
986                    ("1".to_owned(), 0.5_f32),
987                ]),
988                confidence: 0.5,
989            },
990        )];
991        let mapped = map(answers, &words).expect("maps");
992        assert!((value_score(&mapped["odd"]) - 0.5).abs() < f32::EPSILON);
993    }
994
995    #[test]
996    fn a_score_off_its_scale_or_over_its_levels_is_refused() {
997        let numeric = BTreeMap::from([(
998            "severity".to_owned(),
999            Question::Score {
1000                instructions: "How severe?".to_owned(),
1001                levels: vec![
1002                    ("1".to_owned(), "a typo".to_owned()),
1003                    ("5".to_owned(), "data loss".to_owned()),
1004                ],
1005            },
1006        )]);
1007        let scored = |score: f32, probabilities: BTreeMap<String, f32>| {
1008            vec![vendor_answer(
1009                "severity",
1010                WireAnswer::Score {
1011                    score,
1012                    probabilities,
1013                    confidence: 0.7,
1014                },
1015            )]
1016        };
1017        // Past the top index.
1018        assert!(matches!(
1019            map(
1020                scored(1.5, BTreeMap::from([("0".to_owned(), 1.0_f32)])),
1021                &numeric
1022            ),
1023            Err(ClassifierError::Rejected(_))
1024        ));
1025        // A probability under an index the question has no level for.
1026        assert!(matches!(
1027            map(
1028                scored(0.5, BTreeMap::from([("7".to_owned(), 1.0_f32)])),
1029                &numeric
1030            ),
1031            Err(ClassifierError::Rejected(_))
1032        ));
1033        // A probability under something that is not an index at all.
1034        assert!(matches!(
1035            map(
1036                scored(0.5, BTreeMap::from([("data loss".to_owned(), 1.0_f32)])),
1037                &numeric
1038            ),
1039            Err(ClassifierError::Rejected(_))
1040        ));
1041    }
1042
1043    #[test]
1044    fn typesafe_model_is_honoured_only_when_set_and_non_blank() {
1045        assert_eq!(model_from_env(None), DEFAULT_MODEL);
1046        assert_eq!(model_from_env(Some(String::new())), DEFAULT_MODEL);
1047        assert_eq!(model_from_env(Some("   ".to_owned())), DEFAULT_MODEL);
1048        assert_eq!(model_from_env(Some("jev-latest".to_owned())), "jev-latest");
1049        // And what it honours, it honours trimmed.
1050        assert_eq!(
1051            model_from_env(Some("  jev-preview  ".to_owned())),
1052            "jev-preview"
1053        );
1054    }
1055
1056    /// The f32 of an [`AnswerValue::Score`], for the assertions above.
1057    fn value_score(answer: &Answer) -> f32 {
1058        match answer.value {
1059            AnswerValue::Score(score) => score,
1060            ref other => panic!("not a score: {other:?}"),
1061        }
1062    }
1063}