Skip to main content

basal_client/
questions.rs

1//! Typed question factories and checked result views, without a schema/runtime dependency.
2
3use crate::{Answer, DecisionRequest, DecisionResponse, Error, EvidenceSpan, Question, QuestionType, Usage};
4use indexmap::IndexMap;
5use serde_json::{Map, Value};
6use std::{marker::PhantomData, sync::Arc};
7
8/// Display named criterion keys.
9#[derive(Debug, Clone, Copy, Default)]
10pub enum OptionKeys {
11    /// Show names (native default).
12    #[default]
13    Show,
14    /// Hide names.
15    Hide,
16}
17/// Choice display options.
18#[derive(Debug, Clone, Copy, Default)]
19pub struct ChoiceOptions {
20    /// Request supporting spans.
21    pub evidence: bool,
22    /// Show or hide keys.
23    pub option_keys: OptionKeys,
24}
25/// Supporting spans for noul and score.
26#[derive(Debug, Clone, Copy, Default)]
27pub struct EvidenceOptions {
28    /// Request supporting spans.
29    pub evidence: bool,
30}
31/// Multi options intentionally have no evidence field.
32#[derive(Debug, Clone, Copy, Default)]
33pub struct MultiOptions {
34    /// Selection threshold in [0, 1], default 0.5.
35    pub threshold: Option<f64>,
36    /// Minimum number of labels.
37    pub min: Option<usize>,
38    /// Maximum number of labels.
39    pub max: Option<usize>,
40}
41/// Act options. Omitted criteria use true/false outcomes.
42#[derive(Debug, Clone, Copy, Default)]
43pub struct ActOptions<'a> {
44    /// Named outcomes, in request order.
45    pub criteria: Option<&'a [(&'a str, &'a str)]>,
46    /// Request supporting spans.
47    pub evidence: bool,
48    /// Action to defer to, which must exist in costs.
49    pub defer_action: Option<&'a str>,
50    /// Certified error target; calibration capability belongs to the model.
51    pub max_error: Option<f64>,
52}
53/// Explicit action/outcome cost matrix.
54pub type CostMatrix = IndexMap<String, IndexMap<String, f64>>;
55/// Native facts preprocessing mode.
56#[derive(Debug, Clone, Copy)]
57pub enum FactsMode {
58    /// No preprocessing.
59    Off,
60    /// Let the model preprocess facts.
61    Auto,
62}
63/// Decision routing/preprocessing options.
64#[derive(Debug, Clone, Copy, Default)]
65pub struct DecisionOptions<'a> {
66    /// Exact model name, or server default.
67    pub model: Option<&'a str>,
68    /// Native facts mode.
69    pub facts: Option<FactsMode>,
70}
71
72mod sealed {
73    pub trait Sealed {}
74}
75/// Closed association between a question and its required result fields.
76pub trait AnswerKind: sealed::Sealed {
77    /// Checked result borrowed from a response.
78    type Output<'a>;
79    /// Validate required fields and return the typed view.
80    fn parse(answer: &Answer) -> Result<Self::Output<'_>, Error>;
81}
82
83/// Checked choice answer.
84#[derive(Debug, Clone, Copy)]
85pub struct ChoiceAnswer<'a> {
86    /// Selected criterion (never optional).
87    pub choice: &'a str,
88    /// Native confidence.
89    pub confidence: f64,
90    /// Native probabilities.
91    pub probabilities: &'a IndexMap<String, f64>,
92    /// Supporting spans, empty if none were returned.
93    pub evidence: &'a [EvidenceSpan],
94    /// Original answer, including evidence/extensions.
95    pub raw: &'a Answer,
96}
97/// Checked yes/no answer.
98#[derive(Debug, Clone, Copy)]
99pub struct NoulAnswer<'a> {
100    /// P(yes), never optional.
101    pub noul: f64,
102    /// Native confidence.
103    pub confidence: f64,
104    /// Native probabilities.
105    pub probabilities: &'a IndexMap<String, f64>,
106    /// Supporting spans, empty if none were returned.
107    pub evidence: &'a [EvidenceSpan],
108    /// Original answer.
109    pub raw: &'a Answer,
110}
111/// Checked score answer.
112#[derive(Debug, Clone, Copy)]
113pub struct ScoreAnswer<'a> {
114    /// Weighted score, never optional.
115    pub score: f64,
116    /// Native confidence.
117    pub confidence: f64,
118    /// Ordered level legend.
119    pub legend: &'a IndexMap<String, String>,
120    /// Native probabilities.
121    pub probabilities: &'a IndexMap<String, f64>,
122    /// Supporting spans, empty if none were returned.
123    pub evidence: &'a [EvidenceSpan],
124    /// Original answer.
125    pub raw: &'a Answer,
126}
127/// Checked multi answer.
128#[derive(Debug, Clone, Copy)]
129pub struct MultiAnswer<'a> {
130    /// Selected labels, possibly empty.
131    pub selected: &'a [String],
132    /// Applied threshold.
133    pub threshold: f64,
134    /// Confidence of exactly the selected set.
135    pub set_confidence: f64,
136    /// Independent P(yes) values.
137    pub probabilities: &'a IndexMap<String, f64>,
138    /// Original answer.
139    pub raw: &'a Answer,
140}
141/// Checked act answer.
142#[derive(Debug, Clone, Copy)]
143pub struct ActAnswer<'a> {
144    /// Chosen action, never optional.
145    pub action: &'a str,
146    /// Most probable outcome.
147    pub answer: &'a str,
148    /// Native confidence.
149    pub confidence: f64,
150    /// Calibration status.
151    pub calibration: &'a str,
152    /// Expected cost per action.
153    pub expected_costs: &'a IndexMap<String, f64>,
154    /// Native probabilities.
155    pub probabilities: &'a IndexMap<String, f64>,
156    /// Supporting spans, empty if none were returned.
157    pub evidence: &'a [EvidenceSpan],
158    /// Original answer.
159    pub raw: &'a Answer,
160}
161
162fn result_number(value: Option<f64>) -> Result<f64, Error> {
163    value.filter(|v| v.is_finite()).ok_or(Error::InvalidResponse("missing or non-finite answer field"))
164}
165macro_rules! kind {
166    ($name:ident, $view:ident, $variant:ident, $answer:ident, $body:block) => {
167        #[doc = concat!("Result marker for ", stringify!($variant), " questions.")]
168        #[derive(Debug)]
169        pub struct $name;
170        impl sealed::Sealed for $name {}
171        impl AnswerKind for $name {
172            type Output<'a> = $view<'a>;
173            fn parse($answer: &Answer) -> Result<Self::Output<'_>, Error> {
174                if !matches!($answer.kind, QuestionType::$variant) {
175                    return Err(Error::InvalidResponse("mismatched answer type"));
176                }
177                $body
178            }
179        }
180    };
181}
182kind!(ChoiceKind, ChoiceAnswer, Choice, a, {
183    Ok(ChoiceAnswer {
184        choice: a.choice.as_deref().ok_or(Error::InvalidResponse("missing choice"))?,
185        confidence: result_number(a.confidence)?,
186        probabilities: &a.probabilities,
187        evidence: a.evidence.as_deref().unwrap_or_default(),
188        raw: a,
189    })
190});
191kind!(NoulKind, NoulAnswer, Noul, a, {
192    Ok(NoulAnswer {
193        noul: result_number(a.noul)?,
194        confidence: result_number(a.confidence)?,
195        probabilities: &a.probabilities,
196        evidence: a.evidence.as_deref().unwrap_or_default(),
197        raw: a,
198    })
199});
200kind!(ScoreKind, ScoreAnswer, Score, a, {
201    Ok(ScoreAnswer {
202        score: result_number(a.score)?,
203        confidence: result_number(a.confidence)?,
204        legend: a.legend.as_ref().ok_or(Error::InvalidResponse("missing legend"))?,
205        probabilities: &a.probabilities,
206        evidence: a.evidence.as_deref().unwrap_or_default(),
207        raw: a,
208    })
209});
210kind!(MultiKind, MultiAnswer, Multi, a, {
211    Ok(MultiAnswer {
212        selected: a.selected.as_deref().ok_or(Error::InvalidResponse("missing selected labels"))?,
213        threshold: result_number(a.threshold)?,
214        set_confidence: result_number(a.set_confidence)?,
215        probabilities: &a.probabilities,
216        raw: a,
217    })
218});
219kind!(ActKind, ActAnswer, Act, a, {
220    let costs = a.expected_costs.as_ref().ok_or(Error::InvalidResponse("missing expected costs"))?;
221    if costs.values().any(|v| !v.is_finite()) {
222        return Err(Error::InvalidResponse("non-finite expected cost"));
223    }
224    let calibration = a
225        .calibration
226        .as_deref()
227        .filter(|v| matches!(*v, "validated" | "unvalidated"))
228        .ok_or(Error::InvalidResponse("invalid calibration"))?;
229    Ok(ActAnswer {
230        action: a.action.as_deref().ok_or(Error::InvalidResponse("missing action"))?,
231        answer: a.answer.as_deref().ok_or(Error::InvalidResponse("missing outcome"))?,
232        confidence: result_number(a.confidence)?,
233        calibration,
234        expected_costs: costs,
235        probabilities: &a.probabilities,
236        evidence: a.evidence.as_deref().unwrap_or_default(),
237        raw: a,
238    })
239});
240
241/// Immutable typed question snapshot. Clone shares its identity and data.
242#[derive(Debug)]
243pub struct QuestionSpec<A: AnswerKind> {
244    data: Arc<Question>,
245    marker: PhantomData<A>,
246}
247impl<A: AnswerKind> Clone for QuestionSpec<A> {
248    fn clone(&self) -> Self {
249        Self { data: self.data.clone(), marker: PhantomData }
250    }
251}
252impl<A: AnswerKind> QuestionSpec<A> {
253    fn new(question: Question) -> Self {
254        Self { data: Arc::new(question), marker: PhantomData }
255    }
256    /// Borrow the immutable native question.
257    pub fn to_wire(&self) -> &Question {
258        &self.data
259    }
260}
261/// A typed choice question.
262pub type ChoiceQuestion = QuestionSpec<ChoiceKind>;
263/// A typed yes/no question.
264pub type NoulQuestion = QuestionSpec<NoulKind>;
265/// A typed score question.
266pub type ScoreQuestion = QuestionSpec<ScoreKind>;
267/// A typed multi question.
268pub type MultiQuestion = QuestionSpec<MultiKind>;
269/// A typed act question.
270pub type ActQuestion = QuestionSpec<ActKind>;
271
272fn text(value: &str) -> Result<(), Error> {
273    if value.trim().is_empty() {
274        Err(Error::InvalidRequest("expected nonempty text"))
275    } else {
276        Ok(())
277    }
278}
279fn finite(value: f64, probability: bool) -> Result<Value, Error> {
280    if !value.is_finite() || (probability && !(0.0..=1.0).contains(&value)) {
281        return Err(Error::InvalidRequest("invalid finite number/range"));
282    }
283    Ok(Value::from(value))
284}
285fn criteria(values: &[(&str, &str)], min: usize, max: usize) -> Result<Value, Error> {
286    if values.len() < min || values.len() > max {
287        return Err(Error::InvalidRequest("invalid number of criteria"));
288    }
289    let mut fields = Map::new();
290    for &(key, value) in values {
291        text(key)?;
292        text(value)?;
293        if fields.insert(key.into(), Value::String(value.into())).is_some() {
294            return Err(Error::InvalidRequest("duplicate criterion key"));
295        }
296    }
297    Ok(Value::Object(fields))
298}
299fn base(kind: QuestionType, instructions: &str, evidence: bool) -> Result<Question, Error> {
300    text(instructions)?;
301    let mut q = Question::new(kind, instructions);
302    if evidence {
303        q.extra.insert("evidence".into(), Value::Bool(true));
304    }
305    Ok(q)
306}
307/// Choose one of 2–10 ordered named options.
308pub fn choice_with_options(
309    instructions: &str,
310    values: &[(&str, &str)],
311    options: ChoiceOptions,
312) -> Result<ChoiceQuestion, Error> {
313    let mut q = base(QuestionType::Choice, instructions, options.evidence)?;
314    q.criteria = Some(criteria(values, 2, 10)?);
315    if matches!(options.option_keys, OptionKeys::Hide) {
316        q.extra.insert("option_keys".into(), Value::String("hide".into()));
317    }
318    Ok(QuestionSpec::new(q))
319}
320/// Read a yes/no probability.
321pub fn noul_with_options(instructions: &str, options: EvidenceOptions) -> Result<NoulQuestion, Error> {
322    Ok(QuestionSpec::new(base(QuestionType::Noul, instructions, options.evidence)?))
323}
324/// Score over 2–10 ordered text levels.
325pub fn score_with_options(
326    instructions: &str,
327    levels: &[&str],
328    options: EvidenceOptions,
329) -> Result<ScoreQuestion, Error> {
330    if !(2..=10).contains(&levels.len()) {
331        return Err(Error::InvalidRequest("score requires 2–10 levels"));
332    }
333    let mut values = Vec::with_capacity(levels.len());
334    for &level in levels {
335        text(level)?;
336        values.push(Value::String(level.into()));
337    }
338    let mut q = base(QuestionType::Score, instructions, options.evidence)?;
339    q.criteria = Some(Value::Array(values));
340    Ok(QuestionSpec::new(q))
341}
342/// Select labels independently; options have no evidence.
343pub fn multi_with_options(
344    instructions: &str,
345    values: &[(&str, &str)],
346    options: MultiOptions,
347) -> Result<MultiQuestion, Error> {
348    let mut q = base(QuestionType::Multi, instructions, false)?;
349    q.criteria = Some(criteria(values, 1, usize::MAX)?);
350    if let Some(t) = options.threshold {
351        q.extra.insert("threshold".into(), finite(t, true)?);
352    }
353    for (name, limit) in [("min", options.min), ("max", options.max)] {
354        if let Some(limit) = limit {
355            if limit > values.len() {
356                return Err(Error::InvalidRequest("selection limit exceeds labels"));
357            }
358            q.extra.insert(name.into(), Value::from(limit));
359        }
360    }
361    if options.min.zip(options.max).is_some_and(|(min, max)| min > max) {
362        return Err(Error::InvalidRequest("min exceeds max"));
363    }
364    Ok(QuestionSpec::new(q))
365}
366/// Select an action from a full cost matrix. Omitted criteria use true/false outcomes.
367pub fn act_with_options(instructions: &str, costs: &CostMatrix, options: ActOptions<'_>) -> Result<ActQuestion, Error> {
368    if costs.is_empty() {
369        return Err(Error::InvalidRequest("act requires costs"));
370    }
371    let mut q = base(QuestionType::Act, instructions, options.evidence)?;
372    if let Some(values) = options.criteria {
373        q.criteria = Some(criteria(values, 2, 10)?);
374    }
375    let outcomes: Vec<&str> =
376        options.criteria.map_or_else(|| vec!["true", "false"], |values| values.iter().map(|(key, _)| *key).collect());
377    let mut matrix = Map::new();
378    for (action, row) in costs {
379        text(action)?;
380        if row.len() != outcomes.len() || outcomes.iter().any(|key| !row.contains_key(*key)) {
381            return Err(Error::InvalidRequest("each cost row must cover every outcome exactly"));
382        }
383        let mut values = Map::new();
384        for (key, value) in row {
385            values.insert(key.clone(), finite(*value, false)?);
386        }
387        matrix.insert(action.clone(), Value::Object(values));
388    }
389    q.extra.insert("costs".into(), Value::Object(matrix));
390    if let Some(action) = options.defer_action {
391        if !costs.contains_key(action) {
392            return Err(Error::InvalidRequest("unknown defer action"));
393        }
394        q.extra.insert("defer_action".into(), Value::String(action.into()));
395    }
396    if let Some(error) = options.max_error {
397        q.extra.insert("max_error".into(), finite(error, true)?);
398    }
399    Ok(QuestionSpec::new(q))
400}
401
402type Validator = fn(&Answer) -> Result<(), Error>;
403#[derive(Debug, Clone)]
404struct Entry {
405    data: Arc<Question>,
406    validate: Validator,
407}
408/// Ordered heterogeneous request builder retaining typed question identities.
409#[derive(Debug, Clone, Default)]
410pub struct Questions {
411    entries: IndexMap<String, Entry>,
412}
413impl Questions {
414    /// Start an empty builder; sending it is an input error.
415    pub fn new() -> Self {
416        Self::default()
417    }
418    /// Add a typed question, rejecting repeated IDs and repeated question objects.
419    pub fn with<A: AnswerKind>(mut self, id: &str, question: &QuestionSpec<A>) -> Result<Self, Error> {
420        text(id)?;
421        if self.entries.contains_key(id) || self.entries.values().any(|e| Arc::ptr_eq(&e.data, &question.data)) {
422            return Err(Error::InvalidRequest("duplicate ID or question object"));
423        }
424        self.entries.insert(id.into(), Entry { data: question.data.clone(), validate: |a| A::parse(a).map(|_| ()) });
425        Ok(self)
426    }
427    pub(crate) fn request(&self, state: Value, options: DecisionOptions<'_>) -> Result<DecisionRequest, Error> {
428        if state.is_null() || self.entries.is_empty() {
429            return Err(Error::InvalidRequest("state and questions must not be empty/null"));
430        }
431        let mut request = DecisionRequest::new(state);
432        if let Some(model) = options.model {
433            text(model)?;
434            request.model = Some(model.into());
435        }
436        if let Some(facts) = options.facts {
437            request.extra.insert(
438                "facts".into(),
439                Value::String(
440                    match facts {
441                        FactsMode::Off => "off",
442                        FactsMode::Auto => "auto",
443                    }
444                    .into(),
445                ),
446            );
447        }
448        for (id, entry) in &self.entries {
449            request.insert_question(id, &entry.data)?;
450        }
451        Ok(request)
452    }
453    pub(crate) fn parse(self, raw: DecisionResponse) -> Result<TypedDecisionResponse, Error> {
454        for (id, entry) in &self.entries {
455            let answer = raw.answers.get(id).ok_or(Error::InvalidResponse("missing answer"))?;
456            (entry.validate)(answer)?;
457            if answer.probabilities.values().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
458                return Err(Error::InvalidResponse("invalid probabilities"));
459            }
460            if let Some(key) = &answer.choice {
461                if entry.data.criteria.as_ref().is_none_or(|v| v.get(key).is_none()) {
462                    return Err(Error::InvalidResponse("unknown choice"));
463                }
464            }
465            if let Some(keys) = &answer.selected {
466                if keys.iter().any(|key| entry.data.criteria.as_ref().is_none_or(|v| v.get(key).is_none())) {
467                    return Err(Error::InvalidResponse("unknown multi label"));
468                }
469            }
470            if let Some(action) = &answer.action {
471                if entry.data.extra.get("costs").is_none_or(|v| v.get(action).is_none()) {
472                    return Err(Error::InvalidResponse("unknown action"));
473                }
474            }
475        }
476        Ok(TypedDecisionResponse { raw, questions: self })
477    }
478}
479/// Validated native response retaining its request's typed schema/identities.
480#[derive(Debug)]
481pub struct TypedDecisionResponse {
482    raw: DecisionResponse,
483    questions: Questions,
484}
485impl TypedDecisionResponse {
486    /// Native response, borrowed immutably for extension fields.
487    pub fn raw(&self) -> &DecisionResponse {
488        &self.raw
489    }
490    /// Model actually used.
491    pub fn model(&self) -> &str {
492        &self.raw.model
493    }
494    /// Native usage.
495    pub fn usage(&self) -> &Usage {
496        &self.raw.usage
497    }
498    /// Inferred checked answer. Result is an error for a question from a different request,
499    /// not an optional field; normal error propagation uses `?`, never unwrap/expect.
500    pub fn answer<A: AnswerKind>(&self, question: &QuestionSpec<A>) -> Result<A::Output<'_>, Error> {
501        let (id, _) = self
502            .questions
503            .entries
504            .iter()
505            .find(|(_, e)| Arc::ptr_eq(&e.data, &question.data))
506            .ok_or(Error::InvalidRequest("question was not part of this response"))?;
507        A::parse(self.raw.answers.get(id).ok_or(Error::InvalidResponse("missing answer"))?)
508    }
509}
510
511/// Choose from 2–10 options with default display settings.
512pub fn choice(instructions: &str, values: &[(&str, &str)]) -> Result<ChoiceQuestion, Error> {
513    choice_with_options(instructions, values, ChoiceOptions::default())
514}
515/// Yes/no probability with default options.
516pub fn noul(instructions: &str) -> Result<NoulQuestion, Error> {
517    noul_with_options(instructions, EvidenceOptions::default())
518}
519/// Score over ordered levels with default options.
520pub fn score(instructions: &str, levels: &[&str]) -> Result<ScoreQuestion, Error> {
521    score_with_options(instructions, levels, EvidenceOptions::default())
522}
523/// Multi selection with default threshold and no count limits.
524pub fn multi(instructions: &str, values: &[(&str, &str)]) -> Result<MultiQuestion, Error> {
525    multi_with_options(instructions, values, MultiOptions::default())
526}
527/// Act over true/false outcomes with default options.
528pub fn act(instructions: &str, costs: &CostMatrix) -> Result<ActQuestion, Error> {
529    act_with_options(instructions, costs, ActOptions::default())
530}