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 definition. It can be reused under multiple request keys.
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. Request keys identify answers, not definition identity.
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 under a unique ID. One definition may be used under several IDs.
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) {
422            return Err(Error::InvalidRequest("duplicate question ID"));
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<S: AnswerSchema>(
454        self,
455        raw: DecisionResponse,
456        schema: S,
457    ) -> Result<TypedDecisionResponse<S>, Error> {
458        for (id, entry) in &self.entries {
459            let answer = raw.answers.get(id).ok_or(Error::InvalidResponse("missing answer"))?;
460            (entry.validate)(answer)?;
461            if answer.probabilities.values().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
462                return Err(Error::InvalidResponse("invalid probabilities"));
463            }
464            if let Some(key) = &answer.choice {
465                if entry.data.criteria.as_ref().is_none_or(|v| v.get(key).is_none()) {
466                    return Err(Error::InvalidResponse("unknown choice"));
467                }
468            }
469            if let Some(keys) = &answer.selected {
470                if keys.iter().any(|key| entry.data.criteria.as_ref().is_none_or(|v| v.get(key).is_none())) {
471                    return Err(Error::InvalidResponse("unknown multi label"));
472                }
473            }
474            if let Some(action) = &answer.action {
475                if entry.data.extra.get("costs").is_none_or(|v| v.get(action).is_none()) {
476                    return Err(Error::InvalidResponse("unknown action"));
477                }
478            }
479        }
480        Ok(TypedDecisionResponse { raw, questions: self, schema })
481    }
482}
483/// Checked answer selected by a runtime question key.
484#[derive(Debug, Clone, Copy)]
485pub enum AnswerResult<'a> {
486    /// One selected criterion.
487    Choice(ChoiceAnswer<'a>),
488    /// Yes/no probability.
489    Noul(NoulAnswer<'a>),
490    /// Weighted score.
491    Score(ScoreAnswer<'a>),
492    /// Independently selected labels.
493    Multi(MultiAnswer<'a>),
494    /// Selected action.
495    Act(ActAnswer<'a>),
496}
497
498impl<'a> AnswerResult<'a> {
499    fn parse(answer: &'a Answer) -> Result<Self, Error> {
500        match answer.kind {
501            QuestionType::Choice => ChoiceKind::parse(answer).map(Self::Choice),
502            QuestionType::Noul => NoulKind::parse(answer).map(Self::Noul),
503            QuestionType::Score => ScoreKind::parse(answer).map(Self::Score),
504            QuestionType::Multi => MultiKind::parse(answer).map(Self::Multi),
505            QuestionType::Act => ActKind::parse(answer).map(Self::Act),
506        }
507    }
508}
509
510/// Constructs borrowed answer fields for a request shape.
511///
512/// Usually generated by [`crate::questions!`]. Implementations must use request keys to select fields;
513/// missing keys or mismatched answer kinds must return an error.
514pub trait AnswerSchema: Clone {
515    /// Named fields or a map of checked answer variants, borrowed from the response.
516    type Answers<'a>;
517    /// Build checked views without copying probability maps or borrowing question definitions.
518    fn parse<'a>(&self, raw: &'a DecisionResponse, questions: &'a Questions) -> Result<Self::Answers<'a>, Error>;
519}
520
521/// Schema for a runtime-keyed [`Questions`] map.
522#[derive(Debug, Clone, Copy, Default)]
523pub struct DynamicSchema;
524
525impl AnswerSchema for DynamicSchema {
526    type Answers<'a> = IndexMap<&'a str, AnswerResult<'a>>;
527
528    fn parse<'a>(&self, raw: &'a DecisionResponse, questions: &'a Questions) -> Result<Self::Answers<'a>, Error> {
529        questions
530            .entries
531            .keys()
532            .map(|id| {
533                let answer = raw.answers.get(id).ok_or(Error::InvalidResponse("missing answer"))?;
534                Ok((id.as_str(), AnswerResult::parse(answer)?))
535            })
536            .collect()
537    }
538}
539
540/// Request questions accepted by [`crate::Client::decide`].
541///
542/// Use [`Questions`] for dynamic keys or [`crate::questions!`] for inferred named answer fields.
543pub trait QuestionSet: sealed::Sealed {
544    /// Response shape retained from these questions.
545    type Schema: AnswerSchema;
546    /// Ordered, validated question definitions.
547    fn as_questions(&self) -> &Questions;
548    /// Response field schema, independent of question object identities.
549    fn schema(&self) -> Self::Schema;
550}
551
552impl sealed::Sealed for Questions {}
553impl QuestionSet for Questions {
554    type Schema = DynamicSchema;
555    fn as_questions(&self) -> &Questions {
556        self
557    }
558    fn schema(&self) -> Self::Schema {
559        DynamicSchema
560    }
561}
562
563/// Ordered question definitions together with their named response shape.
564/// Usually constructed by [`crate::questions!`].
565#[derive(Debug, Clone)]
566pub struct PreparedQuestions<S: AnswerSchema> {
567    questions: Questions,
568    schema: S,
569}
570
571impl<S: AnswerSchema> PreparedQuestions<S> {
572    /// Combine definitions with a custom response schema. Prefer [`crate::questions!`].
573    pub fn new(questions: Questions, schema: S) -> Self {
574        Self { questions, schema }
575    }
576}
577
578impl<S: AnswerSchema> sealed::Sealed for PreparedQuestions<S> {}
579impl<S: AnswerSchema> QuestionSet for PreparedQuestions<S> {
580    type Schema = S;
581    fn as_questions(&self) -> &Questions {
582        &self.questions
583    }
584    fn schema(&self) -> Self::Schema {
585        self.schema.clone()
586    }
587}
588
589/// Validated response retaining request keys and an optional named answer shape.
590#[derive(Debug)]
591pub struct TypedDecisionResponse<S: AnswerSchema = DynamicSchema> {
592    raw: DecisionResponse,
593    questions: Questions,
594    schema: S,
595}
596impl<S: AnswerSchema> TypedDecisionResponse<S> {
597    /// Native response, borrowed immutably for extension fields.
598    pub fn raw(&self) -> &DecisionResponse {
599        &self.raw
600    }
601    /// Model actually used.
602    pub fn model(&self) -> &str {
603        &self.raw.model
604    }
605    /// Native usage.
606    pub fn usage(&self) -> &Usage {
607        &self.raw.usage
608    }
609    /// Borrow named fields inferred by [`crate::questions!`], or a map for dynamic questions.
610    /// The normal macro-generated schema has already been validated before the client returns.
611    pub fn answers(&self) -> Result<S::Answers<'_>, Error> {
612        self.schema.parse(&self.raw, &self.questions)
613    }
614    fn answer_raw(&self, id: &str) -> Result<&Answer, Error> {
615        if !self.questions.entries.contains_key(id) {
616            return Err(Error::InvalidRequest("question key was not part of this response"));
617        }
618        self.raw.answers.get(id).ok_or(Error::InvalidResponse("missing answer"))
619    }
620    /// Look up a checked answer by its request key. Match the enum for dynamic heterogeneous keys.
621    pub fn answer(&self, id: &str) -> Result<AnswerResult<'_>, Error> {
622        AnswerResult::parse(self.answer_raw(id)?)
623    }
624    /// Look up a choice answer by key; a different question kind is an error.
625    pub fn choice(&self, id: &str) -> Result<ChoiceAnswer<'_>, Error> {
626        ChoiceKind::parse(self.answer_raw(id)?)
627    }
628    /// Look up a yes/no answer by key.
629    pub fn noul(&self, id: &str) -> Result<NoulAnswer<'_>, Error> {
630        NoulKind::parse(self.answer_raw(id)?)
631    }
632    /// Look up a score answer by key.
633    pub fn score(&self, id: &str) -> Result<ScoreAnswer<'_>, Error> {
634        ScoreKind::parse(self.answer_raw(id)?)
635    }
636    /// Look up a multi answer by key.
637    pub fn multi(&self, id: &str) -> Result<MultiAnswer<'_>, Error> {
638        MultiKind::parse(self.answer_raw(id)?)
639    }
640    /// Look up an action answer by key.
641    pub fn act(&self, id: &str) -> Result<ActAnswer<'_>, Error> {
642        ActKind::parse(self.answer_raw(id)?)
643    }
644}
645
646/// Build a named request shape with inferred, checked answer fields.
647///
648/// Field names become wire keys. Expressions are evaluated once, and one definition can be reused.
649/// No procedural macro, code generator or runtime dependency is required.
650///
651/// ```rust,ignore
652/// let questions = questions! { department: &routing, second_department: &routing }?;
653/// let response = client.decide(state, &questions).await?;
654/// let answers = response.answers()?;
655/// println!("{} {}", answers.department.choice, answers.second_department.choice);
656/// ```
657#[macro_export]
658macro_rules! questions {
659    ($($field:ident : $question:expr),+ $(,)?) => {{
660        // The caller's field identifiers also carry inferred answer-kind type parameters.
661        #[allow(non_camel_case_types, reason = "macro type parameters reuse caller field names")]
662        mod __basal_question_shape {
663            pub struct Schema<$($field: $crate::AnswerKind),+> {
664                marker: ::std::marker::PhantomData<($($field,)+)>,
665            }
666
667            impl<$($field: $crate::AnswerKind),+> ::std::fmt::Debug for self::Schema<$($field),+> {
668                fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
669                    f.write_str("QuestionSchema")
670                }
671            }
672
673            impl<$($field: $crate::AnswerKind),+> ::std::clone::Clone for self::Schema<$($field),+> {
674                fn clone(&self) -> Self { Self { marker: ::std::marker::PhantomData } }
675            }
676
677            pub struct Answers<'a, $($field: $crate::AnswerKind),+> {
678                $(pub $field: <$field as $crate::AnswerKind>::Output<'a>,)+
679            }
680
681            impl<$($field: $crate::AnswerKind),+> $crate::AnswerSchema for self::Schema<$($field),+> {
682                type Answers<'a> = self::Answers<'a, $($field),+>;
683
684                fn parse<'a>(
685                    &self,
686                    raw: &'a $crate::DecisionResponse,
687                    _: &'a $crate::Questions,
688                ) -> ::std::result::Result<Self::Answers<'a>, $crate::Error> {
689                    ::std::result::Result::Ok(self::Answers {
690                        $($field: {
691                            let id = ::std::stringify!($field);
692                            let id = id.strip_prefix("r#").unwrap_or(id);
693                            let answer = raw.answers.get(id).ok_or($crate::Error::InvalidResponse("missing answer"))?;
694                            <$field as $crate::AnswerKind>::parse(answer)?
695                        },)+
696                    })
697                }
698            }
699
700            pub fn build<$($field: $crate::AnswerKind),+>(
701                $($field: &$crate::QuestionSpec<$field>),+
702            ) -> ::std::result::Result<$crate::PreparedQuestions<self::Schema<$($field),+>>, $crate::Error> {
703                let questions = $crate::Questions::new();
704                $(let id = ::std::stringify!($field);
705                  let id = id.strip_prefix("r#").unwrap_or(id);
706                  let questions = questions.with(id, $field)?;)+
707                ::std::result::Result::Ok($crate::PreparedQuestions::new(questions, self::Schema { marker: ::std::marker::PhantomData }))
708            }
709        }
710        // This binding extends factory temporaries and infers block expressions before coercing references.
711        let ($($field,)+) = ($(&$question,)+);
712        __basal_question_shape::build($($field),+)
713    }};
714}
715
716/// Choose from 2–10 options with default display settings.
717pub fn choice(instructions: &str, values: &[(&str, &str)]) -> Result<ChoiceQuestion, Error> {
718    choice_with_options(instructions, values, ChoiceOptions::default())
719}
720/// Yes/no probability with default options.
721pub fn noul(instructions: &str) -> Result<NoulQuestion, Error> {
722    noul_with_options(instructions, EvidenceOptions::default())
723}
724/// Score over ordered levels with default options.
725pub fn score(instructions: &str, levels: &[&str]) -> Result<ScoreQuestion, Error> {
726    score_with_options(instructions, levels, EvidenceOptions::default())
727}
728/// Multi selection with default threshold and no count limits.
729pub fn multi(instructions: &str, values: &[(&str, &str)]) -> Result<MultiQuestion, Error> {
730    multi_with_options(instructions, values, MultiOptions::default())
731}
732/// Act over true/false outcomes with default options.
733pub fn act(instructions: &str, costs: &CostMatrix) -> Result<ActQuestion, Error> {
734    act_with_options(instructions, costs, ActOptions::default())
735}