Skip to main content

areev_loop/
decide.rs

1//! Optional decision backend (`docs/decision-model-proposal.md` §4, rows
2//! E1–E3).
3//!
4//! A **decision** (System One) model answers named, typed questions about a
5//! `state` with probabilities — no text generation. The loop uses one, when
6//! a host installs it with [`Engine::with_decider`](crate::Engine::with_decider),
7//! in three places:
8//!
9//! - **E1** — GROUND and VERIFY of LLM drafts: a calibrated `noul` per draft ×
10//!   cited evidence replaces the LLM's grounding verdict, and a calibrated
11//!   `noul` "is it sound?" supplies VERIFY's routing number in place of the
12//!   verifier's self-reported confidence (the LLM's keep/kill still runs).
13//! - **E2** — the duplicate and contradiction sweeps widen past token Jaccard
14//!   and the seeded functional relations, asking "same claim?" / "can both be
15//!   true?" of the candidate pairs the deterministic rules cannot decide.
16//! - **E3** — a free-text tool failure cause is classified into the closed
17//!   cause vocabulary with a `choice`.
18//!
19//! **A decision model may score; only code gates.** Nothing here approves,
20//! applies or rolls back: a judged draft is still a pending recommendation,
21//! and the engine refuses to auto-apply anything a model judged
22//! (`Recommendation::judged_by`). **An uncalibrated backend never omits** — a
23//! probability from one is not used to drop or propose anything (proposal §2
24//! rule 2); those stages fall back to today's rule. **Fail-soft** — a backend
25//! error or malformed answer drops that stage's contribution for the run
26//! (`LOP-E051`, recorded on the run's [`DeciderReport`]), never the run.
27//!
28//! This crate has no Areev dependencies, so the seam is a minimal trait over
29//! the proposal's WIRE JSON: `{"state": …, "questions": {…}}` in, `{"answers":
30//! {…}, "provider", "model", "calibrated", "latency_ms"}` out. The Areev bridge
31//! (`areev_loop_adapter::LoopDecider`) implements it over
32//! `areev_core::decide::DecisionBackend`; a subprocess could implement it just
33//! as well.
34
35use crate::error::{Error, Result};
36use serde::{Deserialize, Serialize};
37use serde_json::{json, Map, Value};
38use std::collections::BTreeMap;
39use std::sync::Mutex;
40
41/// The seam: one wire request in, one wire response out.
42pub trait DecideBackend: Send + Sync {
43    /// Answer one request. `request_json` is `{"state": …, "questions": {id:
44    /// question}}` in the proposal's wire shape; the returned text is the wire
45    /// response plus provenance (`provider`, `model`, `calibrated`,
46    /// `latency_ms`) and is validated by the caller.
47    fn decide(&self, request_json: &str) -> Result<String>;
48    /// Whether this backend's probabilities are calibrated. Uncalibrated
49    /// backends may reorder, never omit.
50    fn calibrated(&self) -> bool;
51    /// Stable human label for provenance, e.g. `"typesafe:jev-latest"`.
52    fn describe(&self) -> String;
53}
54
55impl<T: DecideBackend + ?Sized> DecideBackend for Box<T> {
56    fn decide(&self, request_json: &str) -> Result<String> {
57        (**self).decide(request_json)
58    }
59    fn calibrated(&self) -> bool {
60        (**self).calibrated()
61    }
62    fn describe(&self) -> String {
63        (**self).describe()
64    }
65}
66
67/// The probability a `noul` answer must reach for a calibrated decision to
68/// ground a draft, route it past VERIFY, or propose a sweep draft. The same
69/// number as the LLM verifier's confidence floor.
70pub const DECIDE_MIN_P: f64 = 0.75;
71/// The probability the argmax of a tool-cause `choice` must reach to be used;
72/// below it the cause stays `unknown`.
73pub const CAUSE_MIN_P: f64 = 0.6;
74/// The default per-sweep, per-run cap on pairs sent to the backend.
75pub const DEFAULT_PAIR_CAP: usize = 200;
76/// Questions batched into one request by the sweeps and the cause classifier.
77pub const QUESTIONS_PER_REQUEST: usize = 16;
78
79/// The closed tool-failure cause vocabulary with the description each option
80/// is offered under. Mirrors `areev_core::types::FailureCause` (the adapter
81/// pins the two against each other in a test — this crate cannot import it).
82pub const TOOL_CAUSES: &[(&str, &str)] = &[
83    ("timeout", "The call ran out of time: a deadline, timeout or no response in time."),
84    (
85        "executor_error",
86        "The executor or the remote service failed while running the call: a transport fault, a crash, a 5xx, or an error the tool raised.",
87    ),
88    (
89        "schema_validation_failed",
90        "The call's input or output did not match the tool's schema: a missing, extra or wrongly typed field.",
91    ),
92    ("user_aborted", "A person or the calling agent cancelled or aborted the call."),
93    (
94        "context_overflow",
95        "The model refused the request because the prompt was too long for its context window.",
96    ),
97    ("unknown", "None of the above, or the text does not say why the call failed."),
98];
99
100/// Who judged a recommendation, with what, and what it answered — the
101/// attribution record (proposal §2 rule 4) carried on a
102/// [`Recommendation`](crate::Recommendation) a decision shaped.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104pub struct JudgedBy {
105    /// The installed backend's `describe()`, e.g. `"typesafe:jev-latest"`.
106    pub backend: String,
107    /// The entry that answered (a chain reports the one that did).
108    pub provider: String,
109    pub model: String,
110    pub calibrated: bool,
111    pub latency_ms: u64,
112    /// Which judgment: `ground_verify`, `duplicate`, `contradiction` or
113    /// `tool_cause`.
114    pub stage: String,
115    /// The probabilities that decided it, by question (or option) id.
116    #[serde(default)]
117    pub answers: BTreeMap<String, f64>,
118}
119
120/// What the decision backend did during one run — on
121/// [`RunResult::decider`](crate::RunResult::decider), beside the LLM funnel.
122#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
123pub struct DeciderReport {
124    /// The backend's `describe()`.
125    pub backend: String,
126    /// The backend's `calibrated()`. When false, no stage used a probability
127    /// to drop or propose anything.
128    pub calibrated: bool,
129    /// Requests sent.
130    pub calls: u64,
131    /// Requests that failed or returned a malformed answer. Each dropped its
132    /// stage's contribution for the run; the run continued.
133    pub failed_calls: u64,
134    /// The last failure's message (`LOP-E051 …`), when there was one.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub last_error: Option<String>,
137}
138
139/// One validated response.
140#[derive(Debug, Clone, PartialEq)]
141pub struct Answered {
142    /// `noul` answers: `p(yes)`. Choice answers: absent here, see `choices`.
143    pub noul: BTreeMap<String, f64>,
144    /// `choice` answers: the probability of every option.
145    pub choices: BTreeMap<String, BTreeMap<String, f64>>,
146    pub provider: String,
147    pub model: String,
148    /// The backend's flag AND the response's own `calibrated` field.
149    pub calibrated: bool,
150    pub latency_ms: u64,
151}
152
153impl Answered {
154    /// The attribution record for this response.
155    pub fn judged_by(&self, backend: &str, stage: &str, answers: BTreeMap<String, f64>) -> JudgedBy {
156        JudgedBy {
157            backend: backend.to_string(),
158            provider: self.provider.clone(),
159            model: self.model.clone(),
160            calibrated: self.calibrated,
161            latency_ms: self.latency_ms,
162            stage: stage.to_string(),
163            answers,
164        }
165    }
166}
167
168/// A question to ask: `(id, instructions)` for a `noul`, or with options for
169/// a `choice`.
170#[derive(Debug, Clone)]
171pub enum Ask {
172    Noul { id: String, instructions: String },
173    Choice { id: String, instructions: String, options: Vec<(String, String)> },
174}
175
176impl Ask {
177    fn id(&self) -> &str {
178        match self {
179            Ask::Noul { id, .. } | Ask::Choice { id, .. } => id,
180        }
181    }
182    fn to_wire(&self) -> Value {
183        match self {
184            Ask::Noul { instructions, .. } => json!({"type": "noul", "instructions": instructions}),
185            Ask::Choice { instructions, options, .. } => {
186                let criteria: Map<String, Value> =
187                    options.iter().map(|(k, d)| (k.clone(), Value::from(d.clone()))).collect();
188                json!({"type": "choice", "instructions": instructions, "criteria": criteria})
189            }
190        }
191    }
192}
193
194/// A classified tool cause: the vocabulary key, its probability, and the
195/// decision that produced it — `None` when the cause stays `unknown`.
196pub type CauseVerdict = Option<(String, f64, JudgedBy)>;
197
198/// The engine's handle on an installed backend: the typed helpers every
199/// stage uses, the per-run counters behind [`DeciderReport`], and the per-run
200/// tool-cause cache.
201pub struct Decider {
202    backend: Box<dyn DecideBackend>,
203    pair_cap: usize,
204    stats: Mutex<DeciderReport>,
205    cause_cache: Mutex<BTreeMap<String, CauseVerdict>>,
206}
207
208impl Decider {
209    pub fn new(backend: Box<dyn DecideBackend>) -> Self {
210        Decider {
211            backend,
212            pair_cap: DEFAULT_PAIR_CAP,
213            stats: Mutex::new(DeciderReport::default()),
214            cause_cache: Mutex::new(BTreeMap::new()),
215        }
216    }
217
218    /// Set the per-sweep, per-run pair cap (default [`DEFAULT_PAIR_CAP`]).
219    pub fn with_pair_cap(mut self, cap: usize) -> Self {
220        self.pair_cap = cap;
221        self
222    }
223
224    pub fn pair_cap(&self) -> usize {
225        self.pair_cap
226    }
227    pub fn calibrated(&self) -> bool {
228        self.backend.calibrated()
229    }
230    pub fn describe(&self) -> String {
231        self.backend.describe()
232    }
233
234    /// Clear the counters and the cause cache — the engine calls this at the
235    /// start of every analysis pass, so both are per-run.
236    pub(crate) fn reset(&self) {
237        if let Ok(mut s) = self.stats.lock() {
238            *s = DeciderReport::default();
239        }
240        if let Ok(mut c) = self.cause_cache.lock() {
241            c.clear();
242        }
243    }
244
245    /// This run's report so far.
246    pub fn report(&self) -> DeciderReport {
247        let mut r = self.stats.lock().map(|s| s.clone()).unwrap_or_default();
248        r.backend = self.describe();
249        r.calibrated = self.calibrated();
250        r
251    }
252
253    /// Ask `questions` about `state` in ONE request and validate the answer
254    /// against what was asked. Every failure is `LOP-E051` and is counted on
255    /// the run's report; callers drop their stage's contribution on `Err`.
256    pub fn ask(&self, state: Value, questions: &[Ask]) -> Result<Answered> {
257        let out = self.ask_inner(state, questions);
258        if let Ok(mut s) = self.stats.lock() {
259            s.calls += 1;
260            if let Err(e) = &out {
261                s.failed_calls += 1;
262                s.last_error = Some(e.to_string());
263            }
264        }
265        out
266    }
267
268    fn ask_inner(&self, state: Value, questions: &[Ask]) -> Result<Answered> {
269        if questions.is_empty() {
270            return Err(Error::DecideBackend("no questions".into()));
271        }
272        let qs: Map<String, Value> =
273            questions.iter().map(|q| (q.id().to_string(), q.to_wire())).collect();
274        let body = json!({"state": state, "questions": qs}).to_string();
275        let raw = self.backend.decide(&body).map_err(|e| match e {
276            Error::DecideBackend(_) => e,
277            other => Error::DecideBackend(other.to_string()),
278        })?;
279        parse_response(&raw, questions, self.backend.calibrated())
280    }
281
282    /// The tool-cause classifier (E3): for each distinct free-text cause,
283    /// the closed-vocabulary cause and its probability when a CALIBRATED
284    /// backend's argmax reaches [`CAUSE_MIN_P`]; `None` otherwise (the caller
285    /// keeps `unknown`). Cached by string for the run, so one string is asked
286    /// at most once; at most `pair_cap` distinct strings are asked per run.
287    /// A failed request leaves its strings `unknown` for the run (cached as
288    /// `None`, so a later caller does not retry into the same failure).
289    pub fn classify_causes(&self, texts: &[String]) -> BTreeMap<String, CauseVerdict> {
290        let mut out = BTreeMap::new();
291        if !self.calibrated() {
292            for t in texts {
293                out.insert(t.clone(), None);
294            }
295            return out;
296        }
297        let mut todo: Vec<String> = Vec::new();
298        {
299            let cache = self.cause_cache.lock().ok();
300            for t in texts {
301                match cache.as_ref().and_then(|c| c.get(t)) {
302                    Some(hit) => {
303                        out.insert(t.clone(), hit.clone());
304                    }
305                    None if !todo.contains(t) => todo.push(t.clone()),
306                    None => {}
307                }
308            }
309        }
310        let asked_before = self.cause_cache.lock().map(|c| c.len()).unwrap_or(0);
311        let budget = self.pair_cap.saturating_sub(asked_before);
312        let (ask, skip) = todo.split_at(todo.len().min(budget));
313        for t in skip {
314            out.insert(t.clone(), None);
315        }
316        let backend = self.describe();
317        for chunk in ask.chunks(QUESTIONS_PER_REQUEST) {
318            let mut failures = Map::new();
319            let mut questions = Vec::new();
320            for (i, t) in chunk.iter().enumerate() {
321                let id = format!("c{i}");
322                failures.insert(id.clone(), Value::from(t.clone()));
323                questions.push(Ask::Choice {
324                    instructions: format!(
325                        "Which cause best explains the tool failure described by item \"{id}\" (in state.failures)?"
326                    ),
327                    id,
328                    options: TOOL_CAUSES.iter().map(|(k, d)| (k.to_string(), d.to_string())).collect(),
329                });
330            }
331            let answered = self.ask(json!({"failures": failures}), &questions);
332            for (i, t) in chunk.iter().enumerate() {
333                let verdict = answered.as_ref().ok().and_then(|a| {
334                    if !a.calibrated {
335                        return None;
336                    }
337                    let probs = a.choices.get(&format!("c{i}"))?;
338                    let (best, p) = argmax(probs)?;
339                    (p >= CAUSE_MIN_P).then(|| {
340                        let judged = a.judged_by(&backend, "tool_cause", probs.clone());
341                        (best, p, judged)
342                    })
343                });
344                if let Ok(mut c) = self.cause_cache.lock() {
345                    c.insert(t.clone(), verdict.clone());
346                }
347                out.insert(t.clone(), verdict);
348            }
349        }
350        out
351    }
352}
353
354/// The highest-probability option; ties break to the lexicographically
355/// smallest key (deterministic).
356fn argmax(probs: &BTreeMap<String, f64>) -> Option<(String, f64)> {
357    let mut best: Option<(&String, f64)> = None;
358    for (k, &p) in probs {
359        if best.is_none_or(|(_, bp)| p > bp) {
360            best = Some((k, p));
361        }
362    }
363    best.map(|(k, p)| (k.clone(), p))
364}
365
366fn prob(v: &Value) -> Option<f64> {
367    v.as_f64().filter(|p| p.is_finite() && (0.0..=1.0).contains(p))
368}
369
370/// Validate a wire response against what was asked: every id present with
371/// the matching type, every probability finite and in `[0, 1]`, choice
372/// options drawn from what was offered. Anything else is `LOP-E051`.
373pub fn parse_response(raw: &str, asked: &[Ask], backend_calibrated: bool) -> Result<Answered> {
374    let bad = |m: String| Error::DecideBackend(format!("malformed answer: {m}"));
375    let v: Value = serde_json::from_str(raw.trim()).map_err(|e| bad(format!("not JSON: {e}")))?;
376    let answers = v
377        .get("answers")
378        .and_then(Value::as_object)
379        .ok_or_else(|| bad("no `answers` object".into()))?;
380    let mut noul = BTreeMap::new();
381    let mut choices = BTreeMap::new();
382    for q in asked {
383        let a = answers.get(q.id()).ok_or_else(|| bad(format!("no answer for {:?}", q.id())))?;
384        match q {
385            Ask::Noul { id, .. } => {
386                if a.get("type").and_then(Value::as_str).is_some_and(|t| t != "noul") {
387                    return Err(bad(format!("{id:?} is not a noul answer")));
388                }
389                let p = a
390                    .get("noul")
391                    .and_then(prob)
392                    .ok_or_else(|| bad(format!("{id:?} has no probability in [0, 1]")))?;
393                noul.insert(id.clone(), p);
394            }
395            Ask::Choice { id, options, .. } => {
396                if a.get("type").and_then(Value::as_str).is_some_and(|t| t != "choice") {
397                    return Err(bad(format!("{id:?} is not a choice answer")));
398                }
399                let probs = a
400                    .get("probabilities")
401                    .and_then(Value::as_object)
402                    .ok_or_else(|| bad(format!("{id:?} has no probabilities")))?;
403                let mut m = BTreeMap::new();
404                for (k, p) in probs {
405                    if !options.iter().any(|(o, _)| o == k) {
406                        return Err(bad(format!("{id:?} answered an option nobody offered: {k:?}")));
407                    }
408                    let p = prob(p).ok_or_else(|| bad(format!("{id:?} option {k:?} is not in [0, 1]")))?;
409                    m.insert(k.clone(), p);
410                }
411                if m.is_empty() {
412                    return Err(bad(format!("{id:?} has no probabilities")));
413                }
414                choices.insert(id.clone(), m);
415            }
416        }
417    }
418    let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or("").to_string();
419    Ok(Answered {
420        noul,
421        choices,
422        provider: s("provider"),
423        model: s("model"),
424        calibrated: backend_calibrated && v.get("calibrated").and_then(Value::as_bool).unwrap_or(true),
425        latency_ms: v.get("latency_ms").and_then(Value::as_u64).unwrap_or(0),
426    })
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn a_response_must_answer_every_question_with_a_probability() {
435        let asked = vec![
436            Ask::Noul { id: "a".into(), instructions: "?".into() },
437            Ask::Choice {
438                id: "b".into(),
439                instructions: "?".into(),
440                options: vec![("x".into(), "".into()), ("y".into(), "".into())],
441            },
442        ];
443        let ok = r#"{"answers":{"a":{"type":"noul","noul":0.9},
444            "b":{"type":"choice","choice":"x","probabilities":{"x":0.7,"y":0.3}}},
445            "provider":"fake","model":"m","calibrated":true,"latency_ms":3}"#;
446        let a = parse_response(ok, &asked, true).unwrap();
447        assert_eq!(a.noul["a"], 0.9);
448        assert_eq!(a.choices["b"]["x"], 0.7);
449        assert!(a.calibrated);
450        assert_eq!(a.latency_ms, 3);
451        // The response's own flag can only lower the backend's.
452        let unc = ok.replace("\"calibrated\":true", "\"calibrated\":false");
453        assert!(!parse_response(&unc, &asked, true).unwrap().calibrated);
454        assert!(!parse_response(ok, &asked, false).unwrap().calibrated);
455
456        for broken in [
457            "not json",
458            r#"{"answers":{"a":{"type":"noul","noul":0.9}}}"#,
459            r#"{"answers":{"a":{"type":"noul","noul":1.5},"b":{"probabilities":{"x":1}}}}"#,
460            r#"{"answers":{"a":{"type":"noul","noul":0.5},"b":{"probabilities":{"z":1}}}}"#,
461            r#"{"answers":{"a":{"type":"choice","noul":0.5},"b":{"probabilities":{"x":1}}}}"#,
462        ] {
463            let e = parse_response(broken, &asked, true).unwrap_err();
464            assert_eq!(e.code(), "LOP-E051", "{broken}");
465        }
466    }
467
468    #[test]
469    fn argmax_breaks_ties_deterministically() {
470        let m: BTreeMap<String, f64> = [("b".to_string(), 0.5), ("a".to_string(), 0.5)].into();
471        assert_eq!(argmax(&m), Some(("a".to_string(), 0.5)));
472        assert_eq!(argmax(&BTreeMap::new()), None);
473    }
474}