Skip to main content

hikmah_kernel/
decision_port.rs

1//! Typed decision port: unstructured state in, typed decisions out.
2//!
3//! This is the boundary for "System One" style engines (for example TypeSafe's Jev) that answer
4//! bounded questions with probabilities instead of prose. The doctrine is the same as the text
5//! [`crate::model_port::ProposalEngine`]: engines propose, the kernel admits.
6//!
7//! - Questions are declared up front ([`QuestionKind::Choice`], [`QuestionKind::Score`],
8//!   [`QuestionKind::Noul`]).
9//! - [`admit`] checks every answer against the question that was asked. Any violation rejects the
10//!   *whole* response and every answer becomes an explicit abstention; nothing is repaired.
11//! - Only the kernel crate can construct an [`AdmittedDecision`] (it is `#[non_exhaustive]`).
12//! - Engine probabilities are passed through as reported and flagged `calibrated: false`.
13//!   Calibration is earned from recorded outcomes (see [`crate::calibration`]).
14//! - Admitted answers can be recorded as `Prediction` traces with a `model:` source. They are
15//!   never verified, never supersede anything, and never count as consolidation evidence.
16//! - A person or agent records a [`Forecast`] in the same shape, under its own source, so its
17//!   calibration can be compared with an engine's on the same family.
18use crate::error::{KernelError, Result};
19use crate::secrets::contains_secret;
20use crate::trace::{PredictionRecord, Trace, TraceKind, MODEL_SOURCE_PREFIX};
21use serde::{Deserialize, Serialize};
22use std::collections::{BTreeMap, BTreeSet};
23use std::time::Instant;
24
25pub const MAX_QUESTIONS: usize = 32;
26pub const MAX_STATE_CHARS: usize = 32_000;
27/// Engines round probabilities (Jev reports two decimals), so the allowed deviation of the sum
28/// from 1 grows with the number of keys.
29fn sum_tolerance(keys: usize) -> f64 {
30    (0.005 * keys as f64).max(0.02) + 1e-9
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34#[serde(tag = "type", rename_all = "snake_case")]
35pub enum QuestionKind {
36    /// Pick exactly one option. Map of option id to its meaning (2..=255 options).
37    Choice { options: BTreeMap<String, String> },
38    /// Rate along ordered levels, lowest first (2..=10 levels).
39    Score { levels: Vec<String> },
40    /// Yes/no, answered as the probability of `true`.
41    Noul {
42        #[serde(default, skip_serializing_if = "Option::is_none")]
43        if_true: Option<String>,
44        #[serde(default, skip_serializing_if = "Option::is_none")]
45        if_false: Option<String>,
46    },
47}
48
49impl QuestionKind {
50    fn name(&self) -> &'static str {
51        match self {
52            Self::Choice { .. } => "choice",
53            Self::Score { .. } => "score",
54            Self::Noul { .. } => "noul",
55        }
56    }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct Question {
61    pub id: String,
62    pub instructions: String,
63    #[serde(flatten)]
64    pub kind: QuestionKind,
65    /// Calibration bucket. Defaults to the question id.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub family: Option<String>,
68}
69
70impl Question {
71    pub fn noul(id: impl Into<String>, instructions: impl Into<String>) -> Self {
72        Self {
73            id: id.into(),
74            instructions: instructions.into(),
75            kind: QuestionKind::Noul {
76                if_true: None,
77                if_false: None,
78            },
79            family: None,
80        }
81    }
82
83    pub fn family(&self) -> &str {
84        self.family.as_deref().unwrap_or(&self.id)
85    }
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
89pub struct DecisionRequest {
90    pub state: String,
91    pub questions: Vec<Question>,
92}
93
94impl DecisionRequest {
95    pub fn new(state: impl Into<String>, questions: Vec<Question>) -> Result<Self> {
96        let request = Self {
97            state: state.into(),
98            questions,
99        };
100        request.validate()?;
101        Ok(request)
102    }
103
104    pub fn validate(&self) -> Result<()> {
105        if self.state.trim().is_empty() {
106            return Err(KernelError::Invalid(
107                "decision state cannot be empty".into(),
108            ));
109        }
110        if self.state.chars().count() > MAX_STATE_CHARS {
111            return Err(KernelError::Invalid(format!(
112                "decision state exceeds {MAX_STATE_CHARS} characters"
113            )));
114        }
115        if self.questions.is_empty() || self.questions.len() > MAX_QUESTIONS {
116            return Err(KernelError::Invalid(format!(
117                "a decision request needs 1..={MAX_QUESTIONS} questions"
118            )));
119        }
120        let mut ids = BTreeSet::new();
121        for question in &self.questions {
122            let valid_id = !question.id.is_empty()
123                && question.id.len() <= 64
124                && question
125                    .id
126                    .chars()
127                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
128            if !valid_id {
129                return Err(KernelError::Invalid(format!(
130                    "question id `{}` must be 1-64 chars of [A-Za-z0-9_-]",
131                    question.id
132                )));
133            }
134            if !ids.insert(question.id.as_str()) {
135                return Err(KernelError::Invalid(format!(
136                    "duplicate question id: {}",
137                    question.id
138                )));
139            }
140            if question.instructions.trim().is_empty() {
141                return Err(KernelError::Invalid(format!(
142                    "question {} needs instructions",
143                    question.id
144                )));
145            }
146            match &question.kind {
147                QuestionKind::Choice { options } => {
148                    if !(2..=255).contains(&options.len()) {
149                        return Err(KernelError::Invalid(format!(
150                            "choice question {} needs 2..=255 options",
151                            question.id
152                        )));
153                    }
154                    if options.keys().any(|k| k.trim().is_empty()) {
155                        return Err(KernelError::Invalid(format!(
156                            "choice question {} has an empty option id",
157                            question.id
158                        )));
159                    }
160                }
161                QuestionKind::Score { levels } => {
162                    if !(2..=10).contains(&levels.len()) {
163                        return Err(KernelError::Invalid(format!(
164                            "score question {} needs 2..=10 levels",
165                            question.id
166                        )));
167                    }
168                }
169                QuestionKind::Noul { if_true, if_false } => {
170                    if if_true.is_some() != if_false.is_some() {
171                        return Err(KernelError::Invalid(format!(
172                            "noul question {} needs both if_true and if_false, or neither",
173                            question.id
174                        )));
175                    }
176                }
177            }
178        }
179        Ok(())
180    }
181
182    /// Every piece of text that would be sent to an engine.
183    pub fn outbound_texts(&self) -> Vec<&str> {
184        let mut texts = vec![self.state.as_str()];
185        for question in &self.questions {
186            texts.push(&question.instructions);
187            if let Some(family) = &question.family {
188                texts.push(family);
189            }
190            match &question.kind {
191                QuestionKind::Choice { options } => {
192                    for (id, meaning) in options {
193                        texts.push(id);
194                        texts.push(meaning);
195                    }
196                }
197                QuestionKind::Score { levels } => texts.extend(levels.iter().map(String::as_str)),
198                QuestionKind::Noul { if_true, if_false } => {
199                    texts.extend(if_true.as_deref());
200                    texts.extend(if_false.as_deref());
201                }
202            }
203        }
204        texts
205    }
206
207    /// Stable id derived from the canonical request (state + questions).
208    pub fn request_id(&self) -> String {
209        let canonical = serde_json::to_vec(self).unwrap_or_default();
210        let hash = blake3::hash(&canonical).to_hex().to_string();
211        format!("req_{}", &hash[..16])
212    }
213
214    pub fn question(&self, id: &str) -> Option<&Question> {
215        self.questions.iter().find(|q| q.id == id)
216    }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
220pub struct EngineDescriptor {
221    pub name: String,
222    pub version: String,
223}
224
225impl EngineDescriptor {
226    /// `name@version`, trimmed: the forecaster a prediction record names. An engine reports its
227    /// own version (Jev's response `model` field), so surrounding whitespace is dropped here,
228    /// once, and the trace source and the record always name the same engine.
229    pub fn identity(&self) -> String {
230        format!("{}@{}", self.name.trim(), self.version.trim())
231    }
232
233    /// `model:<identity>`: the provenance source of this engine's answers.
234    pub fn source(&self) -> String {
235        format!("{MODEL_SOURCE_PREFIX}{}", self.identity())
236    }
237}
238
239/// An answer exactly as an engine reported it. The field names match TypeSafe's System One
240/// response shape, so Jev answers deserialize directly.
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
242#[serde(tag = "type", rename_all = "snake_case")]
243pub enum RawAnswer {
244    Choice {
245        choice: String,
246        #[serde(default)]
247        probabilities: Option<BTreeMap<String, f64>>,
248        #[serde(default)]
249        confidence: Option<f64>,
250    },
251    Score {
252        score: f64,
253        #[serde(default)]
254        probabilities: Option<BTreeMap<String, f64>>,
255        #[serde(default)]
256        confidence: Option<f64>,
257    },
258    Noul {
259        noul: f64,
260    },
261    Abstain {
262        reason: String,
263    },
264    /// Output an adapter could not parse. Admission rejects the whole response.
265    Malformed {
266        detail: String,
267    },
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
271pub struct RawDecision {
272    pub request_id: String,
273    pub engine: EngineDescriptor,
274    pub answers: BTreeMap<String, RawAnswer>,
275    pub latency_ms: u64,
276}
277
278/// A typed decision engine. Implementations live outside the trusted kernel.
279pub trait DecisionEngine {
280    fn descriptor(&self) -> EngineDescriptor;
281    fn decide(&self, request: &DecisionRequest) -> Result<RawDecision>;
282}
283
284/// The default engine: abstains on everything, so callers must handle "unknown".
285#[derive(Debug, Default, Clone, Copy)]
286pub struct NoEngine;
287
288impl DecisionEngine for NoEngine {
289    fn descriptor(&self) -> EngineDescriptor {
290        EngineDescriptor {
291            name: "none".into(),
292            version: "0".into(),
293        }
294    }
295
296    fn decide(&self, request: &DecisionRequest) -> Result<RawDecision> {
297        Ok(RawDecision {
298            request_id: request.request_id(),
299            engine: self.descriptor(),
300            answers: request
301                .questions
302                .iter()
303                .map(|q| {
304                    (
305                        q.id.clone(),
306                        RawAnswer::Abstain {
307                            reason: "no decision engine attached".into(),
308                        },
309                    )
310                })
311                .collect(),
312            latency_ms: 0,
313        })
314    }
315}
316
317/// Replays fixed answers. Useful for tests, fixtures, and replaying a recorded engine response.
318#[derive(Debug, Clone)]
319pub struct StaticEngine {
320    pub descriptor: EngineDescriptor,
321    pub answers: BTreeMap<String, RawAnswer>,
322}
323
324impl DecisionEngine for StaticEngine {
325    fn descriptor(&self) -> EngineDescriptor {
326        self.descriptor.clone()
327    }
328
329    fn decide(&self, request: &DecisionRequest) -> Result<RawDecision> {
330        Ok(RawDecision {
331            request_id: request.request_id(),
332            engine: self.descriptor.clone(),
333            answers: self.answers.clone(),
334            latency_ms: 0,
335        })
336    }
337}
338
339#[derive(Debug, Clone, Serialize, PartialEq)]
340#[serde(tag = "type", rename_all = "snake_case")]
341pub enum AdmittedAnswer {
342    Choice {
343        choice: String,
344        probabilities: BTreeMap<String, f64>,
345        confidence: Option<f64>,
346    },
347    Score {
348        /// Engine's expected level index in `[0, levels-1]`.
349        score: f64,
350        /// `score / (levels - 1)`, in `[0, 1]`.
351        normalized: f64,
352        /// Nearest level index.
353        level: usize,
354        probabilities: BTreeMap<String, f64>,
355        confidence: Option<f64>,
356    },
357    Noul {
358        p_true: f64,
359    },
360    Abstain {
361        reason: String,
362    },
363}
364
365impl AdmittedAnswer {
366    pub fn is_abstain(&self) -> bool {
367        matches!(self, Self::Abstain { .. })
368    }
369
370    pub fn p_true(&self) -> Option<f64> {
371        match self {
372            Self::Noul { p_true } => Some(*p_true),
373            _ => None,
374        }
375    }
376
377    pub fn normalized_score(&self) -> Option<f64> {
378        match self {
379            Self::Score { normalized, .. } => Some(*normalized),
380            _ => None,
381        }
382    }
383}
384
385/// Only the kernel crate constructs this (through [`admit`] / [`ask`]).
386#[derive(Debug, Clone, Serialize)]
387#[non_exhaustive]
388pub struct AdmittedDecision {
389    pub request_id: String,
390    pub engine: EngineDescriptor,
391    pub answers: BTreeMap<String, AdmittedAnswer>,
392    /// Why the engine's whole response was rejected (or why the engine failed), if it was.
393    pub rejected: Option<String>,
394    pub latency_ms: u64,
395    /// Engine probabilities are reported as-is and are not calibrated by the kernel.
396    pub calibrated: bool,
397}
398
399impl AdmittedDecision {
400    fn abstain_all(
401        request: &DecisionRequest,
402        engine: EngineDescriptor,
403        reason: String,
404        latency_ms: u64,
405    ) -> Self {
406        Self {
407            request_id: request.request_id(),
408            engine,
409            answers: request
410                .questions
411                .iter()
412                .map(|q| {
413                    (
414                        q.id.clone(),
415                        AdmittedAnswer::Abstain {
416                            reason: reason.clone(),
417                        },
418                    )
419                })
420                .collect(),
421            rejected: Some(reason),
422            latency_ms,
423            calibrated: false,
424        }
425    }
426
427    pub fn answer(&self, question_id: &str) -> Option<&AdmittedAnswer> {
428        self.answers.get(question_id)
429    }
430
431    /// Build `Prediction` traces for every non-abstaining answer. They carry a `model:` source,
432    /// are unverified, and are excluded from default recall and from consolidation.
433    pub fn prediction_traces(&self, request: &DecisionRequest) -> Vec<Trace> {
434        let mut traces = Vec::new();
435        for question in &request.questions {
436            let Some(answer) = self.answers.get(&question.id) else {
437                continue;
438            };
439            let (answer_kind, value, p, probabilities) = match answer {
440                AdmittedAnswer::Abstain { .. } => continue,
441                AdmittedAnswer::Noul { p_true } => (
442                    "noul",
443                    if *p_true >= 0.5 { "true" } else { "false" }.to_string(),
444                    Some(*p_true),
445                    BTreeMap::from([
446                        ("true".to_string(), *p_true),
447                        ("false".to_string(), 1.0 - p_true),
448                    ]),
449                ),
450                AdmittedAnswer::Choice {
451                    choice,
452                    probabilities,
453                    confidence,
454                } => (
455                    "choice",
456                    choice.clone(),
457                    probabilities.get(choice).copied().or(*confidence),
458                    probabilities.clone(),
459                ),
460                AdmittedAnswer::Score {
461                    level,
462                    probabilities,
463                    confidence,
464                    ..
465                } => (
466                    "score",
467                    level.to_string(),
468                    probabilities
469                        .get(&level.to_string())
470                        .copied()
471                        .or(*confidence),
472                    probabilities.clone(),
473                ),
474            };
475            let answer_space: Vec<String> = match &question.kind {
476                QuestionKind::Noul { .. } => vec!["true".into(), "false".into()],
477                QuestionKind::Choice { options } => options.keys().cloned().collect(),
478                QuestionKind::Score { levels } => {
479                    (0..levels.len()).map(|i| i.to_string()).collect()
480                }
481            };
482            let shown_p = p
483                .map(|p| format!("{p:.2}"))
484                .unwrap_or_else(|| "unknown".into());
485            let mut trace = Trace::new(
486                TraceKind::Prediction,
487                format!(
488                    "{} → {value} (p={shown_p}, {})",
489                    question.instructions.trim(),
490                    self.engine.source()
491                ),
492                self.engine.source(),
493            );
494            trace.tags = vec!["prediction".into(), question.family().to_string()];
495            trace.salience = 0.3;
496            trace.confidence = 0.5;
497            trace.prediction = Some(PredictionRecord {
498                request_id: self.request_id.clone(),
499                question_id: question.id.clone(),
500                family: question.family().to_string(),
501                answer_kind: answer_kind.into(),
502                engine: self.engine.identity(),
503                p,
504                value,
505                probabilities,
506                answer_space,
507                calibrated: false,
508            });
509            traces.push(trace);
510        }
511        traces
512    }
513}
514
515/// A forecast made by a person or an agent (`hikmah predict`), recorded in the same
516/// [`PredictionRecord`] shape as an engine answer so `hikmah calibration` can score both on the
517/// same family. The record's `engine` is the source principal (for example `human:alex`), and
518/// calibration keys rows by source class as well as name, so the two always get separate rows.
519/// A forecast is never verified; an outcome resolves it.
520#[derive(Debug, Clone, PartialEq)]
521pub struct Forecast {
522    /// Calibration bucket, shared with any engine that forecasts the same thing.
523    pub family: String,
524    /// What is being forecast, in words.
525    pub question: String,
526    /// `noul`, `choice`, or `score`.
527    pub kind: String,
528    /// Noul: probability of `true`. Choice and score: probability of `value`.
529    pub p: f64,
530    /// Choice and score: the forecast answer, one of `answer_space`. Noul: none (it follows
531    /// from `p`).
532    pub value: Option<String>,
533    /// Choice: the option ids. Score: the levels, lowest first. Noul: empty (`true`/`false`).
534    pub answer_space: Vec<String>,
535    /// Who forecast, as `<kind>:<name>`: for example `human:alex` or `agent:planner`. Never a
536    /// `model:` source.
537    pub source: String,
538    /// Where the forecast was made, for example a decision record.
539    pub locator: Option<String>,
540}
541
542impl Forecast {
543    /// Validate the forecast and build its `prediction` trace. Engine answers (`model:`
544    /// sources) are refused here: they are recorded from an admitted decision.
545    pub fn into_trace(self) -> Result<Trace> {
546        let invalid = |message: String| Err(KernelError::Invalid(message));
547        let source = self.source.trim().to_string();
548        let family = self.family.trim().to_string();
549        let question = self.question.trim().to_string();
550        if source.is_empty() || source.eq_ignore_ascii_case("unknown") {
551            return invalid("a forecast needs a named source, for example `human:<name>`".into());
552        }
553        if source.to_ascii_lowercase().starts_with(MODEL_SOURCE_PREFIX) {
554            return invalid(format!(
555                "`{MODEL_SOURCE_PREFIX}` sources are engine answers; record them with `hikmah ask --record` or `hikmah decide --record`"
556            ));
557        }
558        // A principal names its kind (`human:alex`, `agent:planner`), so a forecaster's row never
559        // reads like an engine identity such as `jev@jev-1.13.0`. Calibration also keeps the two
560        // classes apart by source (`forecaster_kind`), whatever a principal calls itself.
561        let named_principal = source.split_once(':').is_some_and(|(kind, name)| {
562            !kind.is_empty()
563                && kind
564                    .chars()
565                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
566                && !name.trim().is_empty()
567        });
568        if !named_principal {
569            return invalid(format!(
570                "forecast source `{source}` must be `<kind>:<name>`, for example `human:alex` or `agent:planner`"
571            ));
572        }
573        if family.is_empty() || question.is_empty() {
574            return invalid("a forecast needs a family and a question".into());
575        }
576        if !unit(self.p) {
577            return invalid(format!("--p {} is not a probability in [0, 1]", self.p));
578        }
579        let kind = self.kind.trim().to_ascii_lowercase();
580        let (value, answer_space, probabilities) = match kind.as_str() {
581            "noul" => {
582                if self.value.is_some() || !self.answer_space.is_empty() {
583                    return invalid(
584                        "a noul forecast is P(true) in --p; it takes no --value or --answer-space"
585                            .into(),
586                    );
587                }
588                (
589                    if self.p >= 0.5 { "true" } else { "false" }.to_string(),
590                    vec!["true".to_string(), "false".to_string()],
591                    BTreeMap::from([
592                        ("true".to_string(), self.p),
593                        ("false".to_string(), 1.0 - self.p),
594                    ]),
595                )
596            }
597            "choice" | "score" => {
598                let space: Vec<String> = self
599                    .answer_space
600                    .iter()
601                    .map(|v| v.trim().to_string())
602                    .collect();
603                let bounds = if kind == "choice" { 2..=255 } else { 2..=10 };
604                let distinct: BTreeSet<&str> = space.iter().map(String::as_str).collect();
605                if !bounds.contains(&space.len())
606                    || distinct.len() != space.len()
607                    || distinct.contains("")
608                {
609                    return invalid(format!(
610                        "a {kind} forecast needs --answer-space with {}..={} distinct, non-empty values",
611                        bounds.start(),
612                        bounds.end()
613                    ));
614                }
615                let Some(value) = self.value.as_deref().map(str::trim) else {
616                    return invalid(format!("a {kind} forecast needs --value"));
617                };
618                if !distinct.contains(value) {
619                    return invalid(format!("--value `{value}` is not one of {space:?}"));
620                }
621                // Only P(value) is known. The rest of the distribution is not invented, so
622                // calibration scores this forecast on its top label.
623                (value.to_string(), space, BTreeMap::new())
624            }
625            other => {
626                return invalid(format!(
627                    "unknown forecast type `{other}` (expected noul, choice, or score)"
628                ))
629            }
630        };
631        let created_at_ms = crate::trace::now_ms();
632        let seed = format!("{source}\n{family}\n{question}\n{created_at_ms}");
633        let request_id = format!("fc_{}", &blake3::hash(seed.as_bytes()).to_hex()[..16]);
634        let mut trace = Trace::new(
635            TraceKind::Prediction,
636            format!("{question} → {value} (p={:.2}, {source})", self.p),
637            source.clone(),
638        );
639        trace.created_at_ms = created_at_ms;
640        trace.tags = vec!["prediction".into(), family.clone()];
641        trace.salience = 0.3;
642        trace.confidence = 0.5;
643        trace.provenance.locator = self.locator;
644        trace.prediction = Some(PredictionRecord {
645            request_id,
646            question_id: family.clone(),
647            family,
648            answer_kind: kind,
649            engine: source,
650            p: Some(self.p),
651            value,
652            probabilities,
653            answer_space,
654            calibrated: false,
655        });
656        trace.validate()?;
657        Ok(trace)
658    }
659}
660
661/// Ask an engine. Invalid requests and credential-bearing state are refused before any call;
662/// engine failures come back as an all-abstain decision with `rejected` set.
663pub fn ask(engine: &dyn DecisionEngine, request: &DecisionRequest) -> Result<AdmittedDecision> {
664    request.validate()?;
665    if request.outbound_texts().into_iter().any(contains_secret) {
666        return Err(KernelError::Invalid(
667            "the request appears to contain a credential; refusing to send it to a decision engine"
668                .into(),
669        ));
670    }
671    let started = Instant::now();
672    match engine.decide(request) {
673        Ok(raw) => Ok(admit(request, raw)),
674        Err(error) => Ok(AdmittedDecision::abstain_all(
675            request,
676            engine.descriptor(),
677            format!("engine error: {error}"),
678            started.elapsed().as_millis() as u64,
679        )),
680    }
681}
682
683/// Validate a raw engine response against the request. All-or-nothing.
684pub fn admit(request: &DecisionRequest, raw: RawDecision) -> AdmittedDecision {
685    match admit_inner(request, &raw) {
686        Ok(answers) => AdmittedDecision {
687            request_id: raw.request_id,
688            engine: raw.engine,
689            answers,
690            rejected: None,
691            latency_ms: raw.latency_ms,
692            calibrated: false,
693        },
694        Err(detail) => AdmittedDecision::abstain_all(
695            request,
696            raw.engine,
697            format!("response rejected: {detail}"),
698            raw.latency_ms,
699        ),
700    }
701}
702
703fn admit_inner(
704    request: &DecisionRequest,
705    raw: &RawDecision,
706) -> std::result::Result<BTreeMap<String, AdmittedAnswer>, String> {
707    if raw.request_id != request.request_id() {
708        return Err(format!(
709            "request id {} does not match {}",
710            raw.request_id,
711            request.request_id()
712        ));
713    }
714    for id in raw.answers.keys() {
715        if request.question(id).is_none() {
716            return Err(format!("answered a question that was not asked: {id}"));
717        }
718    }
719    let mut admitted = BTreeMap::new();
720    for question in &request.questions {
721        let answer = match raw.answers.get(&question.id) {
722            None => AdmittedAnswer::Abstain {
723                reason: "engine did not answer".into(),
724            },
725            Some(answer) => admit_answer(question, answer)?,
726        };
727        admitted.insert(question.id.clone(), answer);
728    }
729    Ok(admitted)
730}
731
732fn unit(value: f64) -> bool {
733    value.is_finite() && (0.0..=1.0).contains(&value)
734}
735
736fn check_confidence(
737    question: &Question,
738    confidence: Option<f64>,
739) -> std::result::Result<(), String> {
740    match confidence {
741        Some(c) if !unit(c) => Err(format!("{}: confidence {c} is not in [0,1]", question.id)),
742        _ => Ok(()),
743    }
744}
745
746fn check_distribution(
747    question: &Question,
748    probabilities: &BTreeMap<String, f64>,
749    allowed: impl Fn(&str) -> bool,
750) -> std::result::Result<(), String> {
751    if probabilities.is_empty() {
752        return Ok(());
753    }
754    let mut sum = 0.0;
755    for (key, p) in probabilities {
756        if !allowed(key) {
757            return Err(format!(
758                "{}: probability for unknown option `{key}`",
759                question.id
760            ));
761        }
762        if !unit(*p) {
763            return Err(format!(
764                "{}: probability {p} for `{key}` is not in [0,1]",
765                question.id
766            ));
767        }
768        sum += p;
769    }
770    if (sum - 1.0).abs() > sum_tolerance(probabilities.len()) {
771        return Err(format!(
772            "{}: probabilities sum to {sum:.3}, not 1",
773            question.id
774        ));
775    }
776    Ok(())
777}
778
779fn admit_answer(
780    question: &Question,
781    answer: &RawAnswer,
782) -> std::result::Result<AdmittedAnswer, String> {
783    match (answer, &question.kind) {
784        (RawAnswer::Malformed { detail }, _) => {
785            Err(format!("{}: malformed answer: {detail}", question.id))
786        }
787        (RawAnswer::Abstain { reason }, _) => Ok(AdmittedAnswer::Abstain {
788            reason: reason.clone(),
789        }),
790        (RawAnswer::Noul { noul }, QuestionKind::Noul { .. }) => {
791            if unit(*noul) {
792                Ok(AdmittedAnswer::Noul { p_true: *noul })
793            } else {
794                Err(format!("{}: noul {noul} is not in [0,1]", question.id))
795            }
796        }
797        (
798            RawAnswer::Choice {
799                choice,
800                probabilities,
801                confidence,
802            },
803            QuestionKind::Choice { options },
804        ) => {
805            if !options.contains_key(choice) {
806                return Err(format!(
807                    "{}: `{choice}` is not one of the offered options",
808                    question.id
809                ));
810            }
811            check_confidence(question, *confidence)?;
812            let probabilities = probabilities.clone().unwrap_or_default();
813            check_distribution(question, &probabilities, |k| options.contains_key(k))?;
814            if !probabilities.is_empty() {
815                let chosen = probabilities.get(choice).copied().unwrap_or(0.0);
816                let best = probabilities.values().copied().fold(0.0_f64, f64::max);
817                if chosen <= 0.0 || chosen + 1e-9 < best {
818                    return Err(format!(
819                        "{}: `{choice}` is not the most probable option in its own distribution",
820                        question.id
821                    ));
822                }
823            }
824            Ok(AdmittedAnswer::Choice {
825                choice: choice.clone(),
826                probabilities,
827                confidence: *confidence,
828            })
829        }
830        (
831            RawAnswer::Score {
832                score,
833                probabilities,
834                confidence,
835            },
836            QuestionKind::Score { levels },
837        ) => {
838            let top = (levels.len() - 1) as f64;
839            if !score.is_finite() || *score < 0.0 || *score > top {
840                return Err(format!(
841                    "{}: score {score} is outside [0, {top}]",
842                    question.id
843                ));
844            }
845            check_confidence(question, *confidence)?;
846            let probabilities = probabilities.clone().unwrap_or_default();
847            // Level keys must be canonical indices ("0", "1", ...), never aliases like "01".
848            check_distribution(question, &probabilities, |k| {
849                k.parse::<usize>()
850                    .map(|i| i < levels.len() && k == i.to_string())
851                    .unwrap_or(false)
852            })?;
853            if !probabilities.is_empty() {
854                let expected: f64 = probabilities
855                    .iter()
856                    .map(|(k, p)| k.parse::<f64>().unwrap_or(0.0) * p)
857                    .sum();
858                let tolerance = 0.02 * top + 0.02;
859                if (expected - score).abs() > tolerance {
860                    return Err(format!(
861                        "{}: score {score} disagrees with its distribution (expected {expected:.3})",
862                        question.id
863                    ));
864                }
865            }
866            Ok(AdmittedAnswer::Score {
867                score: *score,
868                normalized: score / top,
869                level: score.round() as usize,
870                probabilities,
871                confidence: *confidence,
872            })
873        }
874        (answer, kind) => Err(format!(
875            "{}: answer type does not match a {} question: {answer:?}",
876            question.id,
877            kind.name()
878        )),
879    }
880}