Skip to main content

areev_core/
decide.rs

1//! Decision backends — the pure seam (`docs/decision-model-proposal.md` §3).
2//!
3//! A **decision** (System One) model takes a `state` plus named, typed
4//! questions and returns typed answers with probabilities, in one parallel
5//! evaluation and with no text generation. Three question types exist:
6//!
7//! - [`Question::Noul`] — yes/no; the answer is `p(yes)`.
8//! - [`Question::Choice`] — one of 2..=255 named options.
9//! - [`Question::Score`] — one of 2..=10 ordered levels, index 0 lowest.
10//!
11//! This module holds only what every consumer shares — the questions, the
12//! answers, the request and its [`Decision`], the [`DecisionBackend`] trait,
13//! the `DEC` error domain, and the wire (de)serialization with its answer
14//! validation. It does no I/O. It lives in `areev-core` so the memory stack
15//! (`areev-store`, `areev-cal`, `areev-context`) can accept a backend without
16//! depending on an LLM or HTTP crate; the adapters (the TypeSafe-shape HTTP
17//! client, Cloudflare, the command backend, LLM emulation) and the provider
18//! chain live in `areev_llm::decide`, which re-exports everything here under
19//! its original paths.
20//!
21//! A decision model may score and order; only code omits, gates, approves or
22//! applies — and code that omits on a probability must check
23//! [`Decision::calibrated`] first (proposal §2, rules 1–2).
24//!
25//! Every answer is validated on the way OUT of an adapter
26//! ([`parse_wire_answers`], [`Decision::from_wire`]): every requested id
27//! present, the type matching the question, probabilities present and
28//! summing to 1 within `1e-2` (then normalized exactly). `confidence` for
29//! Choice/Score is `(n·p_max − 1)/(n − 1)` — computed here when a provider
30//! omits it, and always for emulation, so the field means one thing across
31//! providers.
32
33use std::collections::BTreeMap;
34use std::fmt;
35use std::time::{Duration, Instant};
36
37use serde_json::{json, Map, Value};
38
39/// The per-call default deadline (`--decide-timeout-ms`,
40/// `AREEV_DECIDE_TIMEOUT_MS`): 2000 ms.
41pub const DEFAULT_DECIDE_TIMEOUT: Duration = Duration::from_millis(2000);
42
43/// Option-count bounds for [`Question::Choice`].
44pub const CHOICE_OPTIONS: std::ops::RangeInclusive<usize> = 2..=255;
45/// Level-count bounds for [`Question::Score`].
46pub const SCORE_LEVELS: std::ops::RangeInclusive<usize> = 2..=10;
47
48/// How far a provider's probabilities may sum from 1 before the answer is
49/// refused as malformed rather than normalized. 1e-2, not tighter: hosted
50/// providers round each probability to two places, so an honest answer can
51/// sum to 0.99 or 1.01.
52const SUM_TOLERANCE: f32 = 1e-2;
53/// Float slack on top of [`SUM_TOLERANCE`]: f32 addition of two-place
54/// values lands a hair outside it (0.33+0.33+0.33 = 0.98999995), and an
55/// honest 0.99 must not be refused for that.
56const SUM_SLACK: f32 = 1e-4;
57
58// ---- errors ----------------------------------------------------------------
59
60/// Every decision-backend failure. `Display` leads with a stable `DEC-Ennn`
61/// code and [`DecideError::code`] returns it; codes are append-only
62/// (`ERROR_CODES.md`).
63#[derive(Debug, Clone, PartialEq)]
64pub enum DecideError {
65    /// `DEC-E001` — no backend configured, or the spec did not parse
66    /// (unknown provider, missing key — the message names the env var).
67    NotConfigured(String),
68    /// `DEC-E002` — provider transport or HTTP error. `status` is the HTTP
69    /// status when there was one; `retryable` is true for 5xx and transport
70    /// faults, false for 4xx.
71    Provider {
72        provider: String,
73        status: Option<u16>,
74        message: String,
75        retryable: bool,
76    },
77    /// `DEC-E003` — malformed answer: a missing question id, probabilities
78    /// absent or not summing to 1, a type mismatch, an unknown type.
79    Malformed(String),
80    /// `DEC-E004` — the deadline elapsed before an answer arrived.
81    Deadline(String),
82    /// `DEC-E005` — every chain entry failed; carries `(describe(), error)`
83    /// per entry, in chain order.
84    ChainExhausted(Vec<(String, DecideError)>),
85    /// `DEC-E006` — an invalid question (option/level count out of range,
86    /// empty instructions, no questions at all). Never retried.
87    InvalidQuestion(String),
88    /// `DEC-E007` — rate limited (HTTP 429). `retry_after_secs` is the
89    /// `Retry-After` header when the provider sent one in seconds form. The
90    /// adapter never sleeps on it.
91    RateLimited {
92        provider: String,
93        retry_after_secs: Option<u64>,
94    },
95    /// `DEC-E008` — egress pseudonymization of the request's `state` failed
96    /// (`areev_llm::PseudonymizingDecider`), so the request was NOT sent. It
97    /// stops a chain ([`DecideError::stops_chain`]): a later entry may be
98    /// unwrapped, and moving on would send the raw state the policy just
99    /// refused to let out.
100    EgressRefused(String),
101}
102
103impl DecideError {
104    /// Stable machine-readable code in `DEC-Ennn` form.
105    pub fn code(&self) -> &'static str {
106        match self {
107            DecideError::NotConfigured(_) => "DEC-E001",
108            DecideError::Provider { .. } => "DEC-E002",
109            DecideError::Malformed(_) => "DEC-E003",
110            DecideError::Deadline(_) => "DEC-E004",
111            DecideError::ChainExhausted(_) => "DEC-E005",
112            DecideError::InvalidQuestion(_) => "DEC-E006",
113            DecideError::RateLimited { .. } => "DEC-E007",
114            DecideError::EgressRefused(_) => "DEC-E008",
115        }
116    }
117
118    /// The HTTP status behind a `DEC-E002`, if any.
119    pub fn status(&self) -> Option<u16> {
120        match self {
121            DecideError::Provider { status, .. } => *status,
122            _ => None,
123        }
124    }
125
126    /// `Retry-After` seconds carried by a `DEC-E007`.
127    pub fn retry_after_secs(&self) -> Option<u64> {
128        match self {
129            DecideError::RateLimited { retry_after_secs, .. } => *retry_after_secs,
130            _ => None,
131        }
132    }
133
134    /// Whether a provider chain (`areev_llm::decide::Chain`) must stop here instead of trying the next entry:
135    /// our own invalid question (`DEC-E006`), or a provider that refused the
136    /// request itself as invalid (HTTP 400/422) — resending an invalid
137    /// request elsewhere is not a fallback — or an egress pseudonymization
138    /// failure (`DEC-E008`), where moving on could send raw state to an
139    /// entry the wrap does not cover.
140    pub fn stops_chain(&self) -> bool {
141        matches!(
142            self,
143            DecideError::InvalidQuestion(_)
144                | DecideError::Provider { status: Some(400 | 422), .. }
145                | DecideError::EgressRefused(_)
146        )
147    }
148}
149
150impl fmt::Display for DecideError {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        let code = self.code();
153        match self {
154            DecideError::NotConfigured(m) => write!(f, "{code}: decision backend not configured: {m}"),
155            DecideError::Provider { provider, status, message, retryable } => {
156                let st = status.map(|s| format!(" HTTP {s}")).unwrap_or_default();
157                let rt = if *retryable { " (retryable)" } else { "" };
158                write!(f, "{code}: decision provider {provider}{st}{rt}: {message}")
159            }
160            DecideError::Malformed(m) => write!(f, "{code}: malformed decision answer: {m}"),
161            DecideError::Deadline(m) => write!(f, "{code}: decision deadline exceeded: {m}"),
162            DecideError::ChainExhausted(errs) => {
163                write!(f, "{code}: every decision backend failed")?;
164                for (i, (who, e)) in errs.iter().enumerate() {
165                    let sep = if i == 0 { ": " } else { "; " };
166                    write!(f, "{sep}{who} → {e}")?;
167                }
168                Ok(())
169            }
170            DecideError::InvalidQuestion(m) => write!(f, "{code}: invalid decision question: {m}"),
171            DecideError::RateLimited { provider, retry_after_secs } => match retry_after_secs {
172                Some(s) => write!(f, "{code}: decision provider {provider} rate limited (retry after {s}s)"),
173                None => write!(f, "{code}: decision provider {provider} rate limited"),
174            },
175            DecideError::EgressRefused(m) => write!(
176                f,
177                "{code}: egress pseudonymization failed; the decision request was not sent: {m}"
178            ),
179        }
180    }
181}
182
183impl std::error::Error for DecideError {}
184
185// ---- questions ---------------------------------------------------------------
186
187/// What the two outcomes of a yes/no question mean. Wire keys are `"true"`
188/// and `"false"`.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct NoulCriteria {
191    pub yes: String,
192    pub no: String,
193}
194
195/// One typed question.
196#[derive(Debug, Clone, PartialEq, Eq)]
197pub enum Question {
198    /// Yes/no. `criteria` optionally describes the two outcomes.
199    Noul { instructions: String, criteria: Option<NoulCriteria> },
200    /// One of 2..=255 named options; the map value is the option's description.
201    Choice { instructions: String, criteria: BTreeMap<String, String> },
202    /// Ordered 2..=10 levels, index 0 = lowest.
203    Score { instructions: String, levels: Vec<String> },
204}
205
206impl Question {
207    /// A yes/no question with no outcome descriptions.
208    pub fn noul(instructions: impl Into<String>) -> Self {
209        Question::Noul { instructions: instructions.into(), criteria: None }
210    }
211
212    /// A choice over `(key, description)` options.
213    pub fn choice<K: Into<String>, D: Into<String>>(
214        instructions: impl Into<String>,
215        options: impl IntoIterator<Item = (K, D)>,
216    ) -> Self {
217        Question::Choice {
218            instructions: instructions.into(),
219            criteria: options.into_iter().map(|(k, d)| (k.into(), d.into())).collect(),
220        }
221    }
222
223    /// A score over ordered levels, lowest first.
224    pub fn score<L: Into<String>>(
225        instructions: impl Into<String>,
226        levels: impl IntoIterator<Item = L>,
227    ) -> Self {
228        Question::Score {
229            instructions: instructions.into(),
230            levels: levels.into_iter().map(Into::into).collect(),
231        }
232    }
233
234    /// The wire `type`: `"noul"`, `"choice"` or `"score"`.
235    pub fn kind(&self) -> &'static str {
236        match self {
237            Question::Noul { .. } => "noul",
238            Question::Choice { .. } => "choice",
239            Question::Score { .. } => "score",
240        }
241    }
242
243    pub fn instructions(&self) -> &str {
244        match self {
245            Question::Noul { instructions, .. }
246            | Question::Choice { instructions, .. }
247            | Question::Score { instructions, .. } => instructions,
248        }
249    }
250
251    /// `DEC-E006` unless the question is askable: non-empty instructions,
252    /// 2..=255 non-empty option keys, 2..=10 levels.
253    pub fn validate(&self, id: &str) -> Result<(), DecideError> {
254        let bad = |m: String| Err(DecideError::InvalidQuestion(format!("{id:?}: {m}")));
255        if self.instructions().trim().is_empty() {
256            return bad("instructions are empty".into());
257        }
258        match self {
259            Question::Noul { .. } => Ok(()),
260            Question::Choice { criteria, .. } => {
261                if !CHOICE_OPTIONS.contains(&criteria.len()) {
262                    return bad(format!("a choice needs 2..=255 options, got {}", criteria.len()));
263                }
264                if criteria.keys().any(|k| k.trim().is_empty()) {
265                    return bad("a choice option key is empty".into());
266                }
267                Ok(())
268            }
269            Question::Score { levels, .. } => {
270                if !SCORE_LEVELS.contains(&levels.len()) {
271                    return bad(format!("a score needs 2..=10 levels, got {}", levels.len()));
272                }
273                Ok(())
274            }
275        }
276    }
277
278    /// The wire form: `criteria` is an object for noul/choice and an ARRAY of
279    /// levels for score.
280    pub fn to_wire(&self) -> Value {
281        match self {
282            Question::Noul { instructions, criteria } => {
283                let mut o = json!({"type": "noul", "instructions": instructions});
284                if let Some(c) = criteria {
285                    o["criteria"] = json!({"true": c.yes, "false": c.no});
286                }
287                o
288            }
289            Question::Choice { instructions, criteria } => {
290                json!({"type": "choice", "instructions": instructions, "criteria": criteria})
291            }
292            Question::Score { instructions, levels } => {
293                json!({"type": "score", "instructions": instructions, "criteria": levels})
294            }
295        }
296    }
297
298    /// Parse one wire question. Shape faults are `DEC-E006`; the counts are
299    /// checked too (this calls [`Question::validate`]).
300    pub fn from_wire(id: &str, v: &Value) -> Result<Self, DecideError> {
301        let bad = |m: &str| DecideError::InvalidQuestion(format!("{id:?}: {m}"));
302        let o = v.as_object().ok_or_else(|| bad("a question must be a JSON object"))?;
303        let instructions = o
304            .get("instructions")
305            .and_then(Value::as_str)
306            .ok_or_else(|| bad("missing string `instructions`"))?
307            .to_string();
308        let criteria = o.get("criteria").filter(|c| !c.is_null());
309        let q = match o.get("type").and_then(Value::as_str) {
310            Some("noul") => {
311                let criteria = match criteria {
312                    None => None,
313                    Some(c) => {
314                        let c = c.as_object().ok_or_else(|| bad("noul `criteria` must be an object"))?;
315                        let get = |a: &str, b: &str| {
316                            c.get(a).or_else(|| c.get(b)).and_then(Value::as_str).map(str::to_string)
317                        };
318                        Some(NoulCriteria {
319                            yes: get("true", "yes").ok_or_else(|| bad("noul `criteria` needs a \"true\" string"))?,
320                            no: get("false", "no").ok_or_else(|| bad("noul `criteria` needs a \"false\" string"))?,
321                        })
322                    }
323                };
324                Question::Noul { instructions, criteria }
325            }
326            Some("choice") => {
327                let c = criteria
328                    .and_then(Value::as_object)
329                    .ok_or_else(|| bad("choice `criteria` must be an object of option → description"))?;
330                let mut criteria = BTreeMap::new();
331                for (k, d) in c {
332                    let d = d.as_str().ok_or_else(|| bad("choice option descriptions must be strings"))?;
333                    criteria.insert(k.clone(), d.to_string());
334                }
335                Question::Choice { instructions, criteria }
336            }
337            Some("score") => {
338                let c = criteria
339                    .and_then(Value::as_array)
340                    .ok_or_else(|| bad("score `criteria` must be an array of levels, lowest first"))?;
341                let mut levels = Vec::with_capacity(c.len());
342                for l in c {
343                    levels.push(l.as_str().ok_or_else(|| bad("score levels must be strings"))?.to_string());
344                }
345                Question::Score { instructions, levels }
346            }
347            Some(other) => return Err(bad(&format!("unknown question type {other:?} (noul|choice|score)"))),
348            None => return Err(bad("missing `type` (noul|choice|score)")),
349        };
350        q.validate(id)?;
351        Ok(q)
352    }
353}
354
355/// Parse the wire `questions` object (`{id: question, …}`) — the shape
356/// `areev decide --questions` and the bindings' `decide()` take.
357pub fn questions_from_wire(v: &Value) -> Result<BTreeMap<String, Question>, DecideError> {
358    let o = v
359        .as_object()
360        .ok_or_else(|| DecideError::InvalidQuestion("`questions` must be a JSON object of id → question".into()))?;
361    let mut out = BTreeMap::new();
362    for (id, q) in o {
363        out.insert(id.clone(), Question::from_wire(id, q)?);
364    }
365    if out.is_empty() {
366        return Err(DecideError::InvalidQuestion("no questions".into()));
367    }
368    Ok(out)
369}
370
371/// The wire `questions` object.
372pub fn questions_to_wire(questions: &BTreeMap<String, Question>) -> Value {
373    Value::Object(questions.iter().map(|(id, q)| (id.clone(), q.to_wire())).collect())
374}
375
376// ---- answers -----------------------------------------------------------------
377
378/// One typed answer.
379#[derive(Debug, Clone, PartialEq)]
380pub enum Answer {
381    /// `p` = probability of yes/true.
382    Noul { p: f32 },
383    Choice { choice: String, probabilities: BTreeMap<String, f32>, confidence: f32 },
384    /// `score` = Σ p_i · i over 0-based level indices (probability-weighted
385    /// index). `probabilities` and `legend` are keyed by the index as a
386    /// decimal string (`"0"`, `"1"`, …).
387    Score {
388        score: f32,
389        probabilities: BTreeMap<String, f32>,
390        confidence: f32,
391        legend: BTreeMap<String, String>,
392    },
393}
394
395impl Answer {
396    /// The wire `type`.
397    pub fn kind(&self) -> &'static str {
398        match self {
399            Answer::Noul { .. } => "noul",
400            Answer::Choice { .. } => "choice",
401            Answer::Score { .. } => "score",
402        }
403    }
404
405    /// The wire form (TypeSafe's response shape for one answer).
406    pub fn to_wire(&self) -> Value {
407        let probs = |m: &BTreeMap<String, f32>| {
408            Value::Object(m.iter().map(|(k, p)| (k.clone(), num(*p))).collect())
409        };
410        match self {
411            Answer::Noul { p } => json!({"type": "noul", "noul": num(*p)}),
412            Answer::Choice { choice, probabilities, confidence } => json!({
413                "type": "choice", "choice": choice,
414                "probabilities": probs(probabilities), "confidence": num(*confidence),
415            }),
416            Answer::Score { score, probabilities, confidence, legend } => json!({
417                "type": "score", "score": num(*score),
418                "probabilities": probs(probabilities), "legend": legend,
419                "confidence": num(*confidence),
420            }),
421        }
422    }
423}
424
425/// `(n·p_max − 1)/(n − 1)`: 0 for a uniform distribution, 1 for a certain
426/// one. TypeSafe's formula; `n` is the option or level count.
427pub fn confidence_from(p_max: f32, n: usize) -> f32 {
428    if n < 2 {
429        return 1.0;
430    }
431    let n = n as f32;
432    ((n * p_max - 1.0) / (n - 1.0)).clamp(0.0, 1.0)
433}
434
435/// An f32 as JSON by its shortest decimal form (`0.8`, not
436/// `0.800000011920929`). Non-finite values never reach here (validation
437/// refuses them) but degrade to `null` rather than panicking.
438fn num(x: f32) -> Value {
439    format!("{x}")
440        .parse::<f64>()
441        .ok()
442        .and_then(serde_json::Number::from_f64)
443        .map(Value::Number)
444        .unwrap_or(Value::Null)
445}
446
447fn as_f32(v: &Value) -> Option<f32> {
448    v.as_f64().map(|x| x as f32).filter(|x| x.is_finite())
449}
450
451/// Parse strictness: providers get [`Strict`](Mode::Strict); an LLM asked to
452/// self-report gets [`Lenient`](Mode::Lenient) (any positive sum
453/// renormalizes, level names accepted as keys, confidence always computed).
454#[derive(Clone, Copy, PartialEq, Eq)]
455enum Mode {
456    Strict,
457    Lenient,
458}
459
460/// Validate and normalize a provider's `answers` object against the
461/// questions that were asked (`DEC-E003` on any fault). Every requested id
462/// must be present with a matching type; answers to ids nobody asked are
463/// dropped, as are unknown fields. Probabilities summing within `1e-2` of 1
464/// are normalized exactly; anything further off is refused. A provider's own
465/// `score` and `confidence` are kept when present and computed only when
466/// absent.
467pub fn parse_wire_answers(
468    questions: &BTreeMap<String, Question>,
469    answers: &Value,
470) -> Result<BTreeMap<String, Answer>, DecideError> {
471    parse_answers(questions, answers, Mode::Strict)
472}
473
474/// The lenient parse for an LLM asked to self-report (`areev_llm`'s
475/// `LlmEmulated`): a bare number is accepted as a noul probability, any
476/// positive sum renormalizes, level names are accepted as score keys, a
477/// named choice with no distribution becomes one-hot, and `confidence` is
478/// always recomputed so the field means one thing across providers. A
479/// missing id or a type mismatch is still `DEC-E003`.
480pub fn parse_emulated_answers(
481    questions: &BTreeMap<String, Question>,
482    answers: &Value,
483) -> Result<BTreeMap<String, Answer>, DecideError> {
484    parse_answers(questions, answers, Mode::Lenient)
485}
486
487fn parse_answers(
488    questions: &BTreeMap<String, Question>,
489    answers: &Value,
490    mode: Mode,
491) -> Result<BTreeMap<String, Answer>, DecideError> {
492    let o = answers
493        .as_object()
494        .ok_or_else(|| DecideError::Malformed("`answers` is not a JSON object".into()))?;
495    let mut out = BTreeMap::new();
496    for (id, q) in questions {
497        let a = o
498            .get(id)
499            .ok_or_else(|| DecideError::Malformed(format!("no answer for question {id:?}")))?;
500        out.insert(id.clone(), parse_answer(id, q, a, mode)?);
501    }
502    Ok(out)
503}
504
505fn parse_answer(id: &str, q: &Question, a: &Value, mode: Mode) -> Result<Answer, DecideError> {
506    let bad = |m: String| DecideError::Malformed(format!("{id:?}: {m}"));
507    // A bare number is an LLM's shorthand for a noul probability.
508    if let (Mode::Lenient, Question::Noul { .. }, Some(p)) = (mode, q, as_f32(a)) {
509        return noul_p(p).map(|p| Answer::Noul { p }).map_err(bad);
510    }
511    let o = a.as_object().ok_or_else(|| bad("an answer must be a JSON object".into()))?;
512    match o.get("type").and_then(Value::as_str) {
513        Some(t) if t == q.kind() => {}
514        Some(t) if matches!(t, "noul" | "choice" | "score") => {
515            return Err(bad(format!("answered as {t:?} but asked as {:?}", q.kind())))
516        }
517        Some(t) => return Err(bad(format!("unknown answer type {t:?}"))),
518        None if o.contains_key("type") => return Err(bad("`type` is not a string".into())),
519        None => {} // inferred from the question
520    }
521    match q {
522        Question::Noul { .. } => {
523            let p = ["noul", "p", "probability"]
524                .iter()
525                .find_map(|k| o.get(*k))
526                .ok_or_else(|| bad("noul answer has no `noul` probability".into()))?;
527            let p = as_f32(p).ok_or_else(|| bad("noul probability is not a finite number".into()))?;
528            noul_p(p).map(|p| Answer::Noul { p }).map_err(bad)
529        }
530        Question::Choice { criteria, .. } => {
531            let mut dist: BTreeMap<String, f32> = criteria.keys().map(|k| (k.clone(), 0.0)).collect();
532            let named = o.get("choice").and_then(Value::as_str).map(str::to_string);
533            match o.get("probabilities").and_then(Value::as_object) {
534                Some(ps) => {
535                    for (k, p) in ps {
536                        let slot = dist
537                            .get_mut(k)
538                            .ok_or_else(|| bad(format!("probability for unknown option {k:?}")))?;
539                        *slot = as_f32(p).ok_or_else(|| bad(format!("probability for {k:?} is not a finite number")))?;
540                    }
541                }
542                None => match (mode, &named) {
543                    // An LLM that named a choice but gave no distribution: one-hot.
544                    (Mode::Lenient, Some(c)) if dist.contains_key(c) => {
545                        dist.insert(c.clone(), 1.0);
546                    }
547                    _ => return Err(bad("choice answer has no `probabilities`".into())),
548                },
549            }
550            normalize(&mut dist, mode).map_err(bad)?;
551            let (arg, p_max) = argmax(&dist);
552            let choice = match named {
553                Some(c) if dist.contains_key(&c) => c,
554                Some(c) if mode == Mode::Strict => return Err(bad(format!("choice {c:?} is not an option"))),
555                _ => arg,
556            };
557            let confidence = provided_confidence(o, mode)
558                .map_err(bad)?
559                .unwrap_or_else(|| confidence_from(p_max, criteria.len()));
560            Ok(Answer::Choice { choice, probabilities: dist, confidence })
561        }
562        Question::Score { levels, .. } => {
563            let n = levels.len();
564            let mut dist: BTreeMap<String, f32> = (0..n).map(|i| (i.to_string(), 0.0)).collect();
565            match o.get("probabilities").and_then(Value::as_object) {
566                Some(ps) => {
567                    for (k, p) in ps {
568                        let idx = k
569                            .parse::<usize>()
570                            .ok()
571                            .filter(|i| *i < n)
572                            .or_else(|| match mode {
573                                Mode::Lenient => levels.iter().position(|l| l == k),
574                                Mode::Strict => None,
575                            })
576                            .ok_or_else(|| bad(format!("probability for unknown level {k:?}")))?;
577                        let p = as_f32(p).ok_or_else(|| bad(format!("probability for {k:?} is not a finite number")))?;
578                        dist.insert(idx.to_string(), p);
579                    }
580                }
581                None => match (mode, o.get("score").and_then(as_f32)) {
582                    (Mode::Lenient, Some(s)) if s >= 0.0 && s <= (n - 1) as f32 => {
583                        dist.insert((s.round() as usize).to_string(), 1.0);
584                    }
585                    _ => return Err(bad("score answer has no `probabilities`".into())),
586                },
587            }
588            normalize(&mut dist, mode).map_err(bad)?;
589            let weighted: f32 = dist.iter().map(|(k, p)| k.parse::<f32>().unwrap_or(0.0) * p).sum();
590            let score = match (mode, o.get("score").and_then(as_f32)) {
591                (Mode::Strict, Some(s)) if s >= 0.0 && s <= (n - 1) as f32 => s,
592                (Mode::Strict, Some(s)) => return Err(bad(format!("score {s} is outside 0..={}", n - 1))),
593                _ => weighted,
594            };
595            let (_, p_max) = argmax(&dist);
596            let confidence = provided_confidence(o, mode)
597                .map_err(bad)?
598                .unwrap_or_else(|| confidence_from(p_max, n));
599            let legend = o
600                .get("legend")
601                .and_then(Value::as_object)
602                .filter(|_| mode == Mode::Strict)
603                .map(|l| {
604                    l.iter()
605                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
606                        .collect::<BTreeMap<_, _>>()
607                })
608                .filter(|l| l.len() == n)
609                .unwrap_or_else(|| levels.iter().enumerate().map(|(i, l)| (i.to_string(), l.clone())).collect());
610            Ok(Answer::Score { score, probabilities: dist, confidence, legend })
611        }
612    }
613}
614
615fn noul_p(p: f32) -> Result<f32, String> {
616    if (-SUM_TOLERANCE..=1.0 + SUM_TOLERANCE).contains(&p) {
617        Ok(p.clamp(0.0, 1.0))
618    } else {
619        Err(format!("noul probability {p} is outside 0..=1"))
620    }
621}
622
623/// Normalize a distribution in place. Strict: the sum must already be within
624/// [`SUM_TOLERANCE`] of 1. Lenient: any positive sum.
625fn normalize(dist: &mut BTreeMap<String, f32>, mode: Mode) -> Result<(), String> {
626    if let Some((k, p)) = dist.iter().find(|(_, p)| **p < 0.0) {
627        return Err(format!("negative probability {p} for {k:?}"));
628    }
629    let sum: f32 = dist.values().sum();
630    let ok = match mode {
631        Mode::Strict => (sum - 1.0).abs() <= SUM_TOLERANCE + SUM_SLACK,
632        Mode::Lenient => sum > 0.0,
633    };
634    if !ok {
635        return Err(format!("probabilities sum to {sum}, not 1"));
636    }
637    for p in dist.values_mut() {
638        *p /= sum;
639    }
640    Ok(())
641}
642
643/// The highest-probability key (first in key order on a tie) and its mass.
644fn argmax(dist: &BTreeMap<String, f32>) -> (String, f32) {
645    let mut best = (String::new(), f32::NEG_INFINITY);
646    for (k, p) in dist {
647        if *p > best.1 {
648            best = (k.clone(), *p);
649        }
650    }
651    best
652}
653
654/// A provider's own `confidence`, honoured in strict mode only (emulation
655/// always recomputes so the field means one thing).
656fn provided_confidence(o: &Map<String, Value>, mode: Mode) -> Result<Option<f32>, String> {
657    if mode == Mode::Lenient {
658        return Ok(None);
659    }
660    match o.get("confidence") {
661        None | Some(Value::Null) => Ok(None),
662        Some(v) => as_f32(v)
663            .map(|c| Some(c.clamp(0.0, 1.0)))
664            .ok_or_else(|| "confidence is not a finite number".to_string()),
665    }
666}
667
668// ---- request / decision --------------------------------------------------------
669
670/// One decision call.
671#[derive(Debug, Clone, PartialEq)]
672pub struct DecideRequest {
673    /// What is being judged: a string, object or array.
674    pub state: Value,
675    pub questions: BTreeMap<String, Question>,
676    /// Per-call budget; `None` = the backend's (or chain's) default.
677    pub deadline: Option<Duration>,
678}
679
680impl DecideRequest {
681    pub fn new(state: impl Into<Value>, questions: BTreeMap<String, Question>) -> Self {
682        DecideRequest { state: state.into(), questions, deadline: None }
683    }
684
685    pub fn with_deadline(mut self, deadline: Option<Duration>) -> Self {
686        self.deadline = deadline;
687        self
688    }
689
690    /// `DEC-E006` unless there is at least one question and every question
691    /// is askable, and `state` is a string, object or array.
692    pub fn validate(&self) -> Result<(), DecideError> {
693        if self.questions.is_empty() {
694            return Err(DecideError::InvalidQuestion("no questions".into()));
695        }
696        if !(self.state.is_string() || self.state.is_object() || self.state.is_array()) {
697            return Err(DecideError::InvalidQuestion(
698                "`state` must be a string, object or array".into(),
699            ));
700        }
701        for (id, q) in &self.questions {
702            if id.trim().is_empty() {
703                return Err(DecideError::InvalidQuestion("a question id is empty".into()));
704            }
705            q.validate(id)?;
706        }
707        Ok(())
708    }
709
710    /// The wire request. `model` is omitted when `None` (the Cloudflare and
711    /// command shapes carry none).
712    pub fn to_wire(&self, model: Option<&str>) -> Value {
713        let mut o = Map::new();
714        if let Some(m) = model {
715            o.insert("model".into(), Value::String(m.to_string()));
716        }
717        o.insert("state".into(), self.state.clone());
718        o.insert("questions".into(), questions_to_wire(&self.questions));
719        Value::Object(o)
720    }
721}
722
723/// A validated answer set plus its provenance (proposal §2 rule 4).
724#[derive(Debug, Clone, PartialEq)]
725pub struct Decision {
726    pub answers: BTreeMap<String, Answer>,
727    /// As served, e.g. `"jev-1.13.0"`.
728    pub model: String,
729    /// Spec name, e.g. `"typesafe"`, `"cloudflare"`, `"cmd"`, `"llm"`.
730    pub provider: String,
731    pub calibrated: bool,
732    pub input_tokens: Option<u64>,
733    pub output_tokens: Option<u64>,
734    /// The provider-reported price of this decision (`usage.cost`, USD, as
735    /// OpenRouter-style gateways report it) in micro-dollars, rounded UP so
736    /// a sum of sub-micro calls never under-charges a budget. `None` when
737    /// the provider reported no cost — never estimated from tokens.
738    pub usd_micros: Option<u64>,
739    pub latency_ms: u64,
740}
741
742impl Decision {
743    /// The wire response plus provenance: `{model, answers, usage?, provider,
744    /// calibrated, latency_ms}` — what `areev decide` and the bindings print.
745    /// `usage` carries `usd_micros` when the provider reported a cost.
746    pub fn to_json(&self) -> Value {
747        let mut o = json!({
748            "model": self.model,
749            "answers": Value::Object(self.answers.iter().map(|(k, a)| (k.clone(), a.to_wire())).collect()),
750            "provider": self.provider,
751            "calibrated": self.calibrated,
752            "latency_ms": self.latency_ms,
753        });
754        if self.input_tokens.is_some() || self.output_tokens.is_some() || self.usd_micros.is_some() {
755            o["usage"] = json!({"input_tokens": self.input_tokens, "output_tokens": self.output_tokens});
756            if let Some(n) = self.usd_micros {
757                o["usage"]["usd_micros"] = json!(n);
758            }
759        }
760        o
761    }
762
763    /// Build from a wire response body (`{model?, answers, usage?}`),
764    /// validating it strictly against the request's questions
765    /// ([`parse_wire_answers`]). `model` falls back to `default_model` when
766    /// the body names none; `latency_ms` is measured from `started`. The
767    /// constructor every HTTP and command adapter shares.
768    pub fn from_wire(
769        req: &DecideRequest,
770        body: &Value,
771        provider: &str,
772        default_model: &str,
773        calibrated: bool,
774        started: Instant,
775    ) -> Result<Decision, DecideError> {
776        let answers = body
777            .get("answers")
778            .ok_or_else(|| DecideError::Malformed("response has no `answers`".into()))?;
779        let answers = parse_answers(&req.questions, answers, Mode::Strict)?;
780        let tokens = |k: &str| body.get("usage").and_then(|u| u.get(k)).and_then(Value::as_u64);
781        Ok(Decision {
782            answers,
783            model: body
784                .get("model")
785                .and_then(Value::as_str)
786                .filter(|m| !m.is_empty())
787                .unwrap_or(default_model)
788                .to_string(),
789            provider: provider.to_string(),
790            calibrated,
791            input_tokens: tokens("input_tokens"),
792            output_tokens: tokens("output_tokens"),
793            usd_micros: body
794                .get("usage")
795                .and_then(|u| u.get("cost"))
796                .and_then(Value::as_f64)
797                .and_then(usd_to_micros),
798            latency_ms: elapsed_ms(started),
799        })
800    }
801}
802
803/// A provider's USD `cost` as whole micro-dollars, rounded up. A negative,
804/// non-finite or absurd value is not a price — `None`, never a guess.
805fn usd_to_micros(usd: f64) -> Option<u64> {
806    let micros = (usd * 1_000_000.0).ceil();
807    (usd.is_finite() && usd >= 0.0 && micros < u64::MAX as f64).then_some(micros as u64)
808}
809
810fn elapsed_ms(started: Instant) -> u64 {
811    u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
812}
813
814/// The seam. Implementations validate the request (`DEC-E006`) and their
815/// answers (`DEC-E003`) and honour `req.deadline`.
816pub trait DecisionBackend: Send + Sync {
817    fn decide(&self, req: &DecideRequest) -> Result<Decision, DecideError>;
818    /// Whether this backend's probabilities are calibrated. Policy that
819    /// omits/drops/skips on a probability must fall back to rank-only
820    /// behaviour when this is false.
821    fn calibrated(&self) -> bool;
822    /// Stable human label for provenance, e.g. `"typesafe:jev-latest"`.
823    fn describe(&self) -> String;
824}
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    fn qs(pairs: Vec<(&str, Question)>) -> BTreeMap<String, Question> {
830        pairs.into_iter().map(|(k, q)| (k.to_string(), q)).collect()
831    }
832
833    #[test]
834    fn provider_cost_is_micro_dollars_rounded_up_and_never_guessed() {
835        assert_eq!(usd_to_micros(0.00001575), Some(16));
836        assert_eq!(usd_to_micros(0.0), Some(0));
837        assert_eq!(usd_to_micros(1.5), Some(1_500_000));
838        assert_eq!(usd_to_micros(-0.01), None);
839        assert_eq!(usd_to_micros(f64::NAN), None);
840        assert_eq!(usd_to_micros(f64::INFINITY), None);
841        let req = DecideRequest::new("s", qs(vec![("ok", Question::noul("ok?"))]));
842        let body = json!({"answers": {"ok": {"type": "noul", "noul": 0.9}}, "usage": {"input_tokens": 3}});
843        let d = Decision::from_wire(&req, &body, "p", "m", true, Instant::now()).unwrap();
844        assert_eq!(d.usd_micros, None, "no reported cost is no cost, not an estimate");
845        assert!(d.to_json()["usage"].get("usd_micros").is_none());
846    }
847
848    #[test]
849    fn codes_are_unique_well_formed_and_lead_display() {
850        let all = [
851            DecideError::NotConfigured(String::new()),
852            DecideError::Provider { provider: String::new(), status: None, message: String::new(), retryable: false },
853            DecideError::Malformed(String::new()),
854            DecideError::Deadline(String::new()),
855            DecideError::ChainExhausted(Vec::new()),
856            DecideError::InvalidQuestion(String::new()),
857            DecideError::RateLimited { provider: String::new(), retry_after_secs: None },
858            DecideError::EgressRefused(String::new()),
859        ];
860        let mut seen = std::collections::BTreeSet::new();
861        for e in &all {
862            let c = e.code();
863            assert!(c.starts_with("DEC-E") && c.len() == 8, "bad code {c}");
864            assert!(seen.insert(c), "duplicate code {c}");
865            assert!(e.to_string().starts_with(&format!("{c}: ")), "{e}");
866        }
867        assert_eq!(seen.len(), 8);
868    }
869
870    #[test]
871    fn confidence_formula() {
872        assert_eq!(confidence_from(0.5, 2), 0.0);
873        assert_eq!(confidence_from(1.0, 4), 1.0);
874        assert!((confidence_from(0.8, 2) - 0.6).abs() < 1e-6);
875        assert!((confidence_from(0.5, 3) - 0.25).abs() < 1e-6);
876    }
877
878    #[test]
879    fn question_bounds_are_enforced() {
880        let one = Question::choice("pick", [("a", "A")]);
881        assert_eq!(one.validate("q").unwrap_err().code(), "DEC-E006");
882        let many = Question::Choice {
883            instructions: "pick".into(),
884            criteria: (0..256).map(|i| (format!("o{i}"), String::new())).collect(),
885        };
886        assert_eq!(many.validate("q").unwrap_err().code(), "DEC-E006");
887        let max = Question::Choice {
888            instructions: "pick".into(),
889            criteria: (0..255).map(|i| (format!("o{i}"), String::new())).collect(),
890        };
891        assert!(max.validate("q").is_ok());
892        assert!(Question::score("s", ["lo"]).validate("q").is_err());
893        assert!(Question::score("s", (0..11).map(|i| i.to_string())).validate("q").is_err());
894        assert!(Question::score("s", (0..10).map(|i| i.to_string())).validate("q").is_ok());
895        assert!(Question::noul("  ").validate("q").is_err());
896        let empty = DecideRequest::new("s", BTreeMap::new());
897        assert_eq!(empty.validate().unwrap_err().code(), "DEC-E006");
898        let numeric_state = DecideRequest::new(json!(3), qs(vec![("a", Question::noul("x"))]));
899        assert_eq!(numeric_state.validate().unwrap_err().code(), "DEC-E006");
900    }
901
902    #[test]
903    fn question_wire_roundtrips() {
904        let q = qs(vec![
905            ("n", Question::Noul {
906                instructions: "is it?".into(),
907                criteria: Some(NoulCriteria { yes: "it is".into(), no: "it is not".into() }),
908            }),
909            ("c", Question::choice("which", [("a", "A"), ("b", "B")])),
910            ("s", Question::score("how much", ["low", "mid", "high"])),
911        ]);
912        let wire = questions_to_wire(&q);
913        assert_eq!(wire["n"]["criteria"], json!({"true": "it is", "false": "it is not"}));
914        assert_eq!(wire["s"]["criteria"], json!(["low", "mid", "high"]));
915        assert_eq!(questions_from_wire(&wire).unwrap(), q);
916        let bad = json!({"x": {"type": "rank", "instructions": "?"}});
917        assert_eq!(questions_from_wire(&bad).unwrap_err().code(), "DEC-E006");
918    }
919
920    #[test]
921    fn strict_parse_normalizes_within_tolerance_and_refuses_beyond() {
922        let q = qs(vec![("c", Question::choice("w", [("a", ""), ("b", "")]))]);
923        let near = json!({"c": {"type": "choice", "choice": "a", "probabilities": {"a": 0.8, "b": 0.2005}}});
924        let a = parse_wire_answers(&q, &near).unwrap();
925        let Answer::Choice { probabilities, confidence, .. } = &a["c"] else { panic!() };
926        assert!((probabilities.values().sum::<f32>() - 1.0).abs() < 1e-6);
927        assert!((confidence - 0.6).abs() < 1e-3, "computed when omitted: {confidence}");
928        let rounded = json!({"c": {"type": "choice", "probabilities": {"a": 0.8, "b": 0.19}}});
929        assert!(parse_wire_answers(&q, &rounded).is_ok(), "two-place rounding (sum 0.99) normalizes");
930        let far = json!({"c": {"type": "choice", "choice": "a", "probabilities": {"a": 1.0, "b": 1.0}}});
931        assert_eq!(parse_wire_answers(&q, &far).unwrap_err().code(), "DEC-E003");
932        let mismatch = json!({"c": {"type": "noul", "noul": 0.5}});
933        assert_eq!(parse_wire_answers(&q, &mismatch).unwrap_err().code(), "DEC-E003");
934        let unknown = json!({"c": {"type": "rank"}});
935        assert_eq!(parse_wire_answers(&q, &unknown).unwrap_err().code(), "DEC-E003");
936        let no_probs = json!({"c": {"type": "choice", "choice": "a"}});
937        assert_eq!(parse_wire_answers(&q, &no_probs).unwrap_err().code(), "DEC-E003");
938        let stray = json!({"c": {"type": "choice", "probabilities": {"a": 0.5, "z": 0.5}}});
939        assert_eq!(parse_wire_answers(&q, &stray).unwrap_err().code(), "DEC-E003");
940    }
941
942    /// Regression (LoCoMo A/B, 2026-09-25): TypeSafe via OpenRouter rounds
943    /// each probability to two places, so a four-level score can sum to
944    /// 0.99 — which f32 addition turns into 0.98999995, refused by a bare
945    /// `<= 1e-2` check. 55 of 1,972 rerank requests failed on exactly this.
946    #[test]
947    fn two_place_rounding_at_the_tolerance_edge_normalizes() {
948        let q = qs(vec![("s", Question::score("s", ["a", "b", "c", "d"]))]);
949        for target in [99u32, 101] {
950            for a in 0..=target.min(100) {
951                for b in 0..=(target - a).min(100) {
952                    let rest = target - a - b;
953                    let (c, d) = (rest / 2, rest - rest / 2);
954                    if c > 100 || d > 100 {
955                        continue;
956                    }
957                    let p = |x: u32| x as f64 / 100.0;
958                    let ans = json!({"s": {"type": "score", "probabilities":
959                        {"0": p(a), "1": p(b), "2": p(c), "3": p(d)}}});
960                    assert!(
961                        parse_wire_answers(&q, &ans).is_ok(),
962                        "{a}+{b}+{c}+{d} = {target}/100 must normalize"
963                    );
964                }
965            }
966        }
967        // Still refused beyond the tolerance.
968        let far = json!({"s": {"type": "score", "probabilities": {"0": 0.5, "1": 0.2, "2": 0.2, "3": 0.08}}});
969        assert_eq!(parse_wire_answers(&q, &far).unwrap_err().code(), "DEC-E003");
970    }
971
972    #[test]
973    fn floats_serialize_by_shortest_form() {
974        assert_eq!(num(0.8).to_string(), "0.8");
975        assert_eq!(Answer::Noul { p: 0.93 }.to_wire(), json!({"type": "noul", "noul": 0.93}));
976    }
977}