Skip to main content

basal_client/
questions.rs

1//! Typed question factories and checked result views, without a schema/runtime dependency.
2
3use crate::{
4    Answer, DecisionRequest, DecisionResponse, Error, EvidenceSpan, Language, MediaDetail, MediaInput, Question,
5    QuestionType, ToolRanking, Usage,
6};
7use indexmap::IndexMap;
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10use std::{marker::PhantomData, sync::Arc};
11
12/// Display named criterion keys.
13#[derive(Debug, Clone, Copy, Default)]
14pub enum OptionKeys {
15    /// Show names (native default).
16    #[default]
17    Show,
18    /// Hide names.
19    Hide,
20}
21/// Choice display options.
22#[derive(Debug, Clone, Copy, Default)]
23pub struct ChoiceOptions {
24    /// Request supporting spans.
25    pub evidence: bool,
26    /// Show or hide keys.
27    pub option_keys: OptionKeys,
28}
29/// Supporting spans for noul and score.
30#[derive(Debug, Clone, Copy, Default)]
31pub struct EvidenceOptions {
32    /// Request supporting spans.
33    pub evidence: bool,
34}
35/// Multi options intentionally have no evidence field.
36#[derive(Debug, Clone, Copy, Default)]
37pub struct MultiOptions<'a> {
38    /// Selection threshold in [0, 1], default 0.5.
39    pub threshold: Option<f64>,
40    /// Minimum number of labels.
41    pub min: Option<usize>,
42    /// Maximum number of labels.
43    pub max: Option<usize>,
44    /// Selection objective, threshold by default.
45    pub objective: MultiObjective,
46    /// Mutually exclusive label keys.
47    pub exclusive: &'a [&'a str],
48    /// Fallback label key.
49    pub fallback: Option<&'a str>,
50    /// Explicit fallback probability threshold.
51    pub fallback_threshold: Option<f64>,
52}
53/// Multi-set selection objective.
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum MultiObjective {
57    /// Independent threshold selection.
58    #[default]
59    Threshold,
60    /// Most probable exact set.
61    Exact,
62    /// Maximize expected intersection over union.
63    Iou,
64    /// Maximize expected F1.
65    F1,
66}
67impl MultiObjective {
68    fn as_str(self) -> &'static str {
69        match self {
70            Self::Threshold => "threshold",
71            Self::Exact => "exact",
72            Self::Iou => "iou",
73            Self::F1 => "f1",
74        }
75    }
76}
77/// Optional non-tool choices.
78#[derive(Debug, Clone, Copy, Serialize)]
79#[serde(rename_all = "snake_case")]
80pub enum ToolExtra {
81    /// Answer without a tool.
82    AnswerDirectly,
83    /// Ask the user for clarification.
84    AskUser,
85    /// Select no tool.
86    None,
87}
88/// Tool routing options. Definitions are normalized and validated by the server.
89#[derive(Debug, Clone, Copy, Default)]
90pub struct ToolOptions<'a> {
91    /// Non-tool choices appended to definitions. `None` keeps the server default (all three for a single tool,
92    /// none in multi mode); `Some(&[])` sends no non-tool choice.
93    pub extra: Option<&'a [ToolExtra]>,
94    /// Number of ranked candidates.
95    pub rank: Option<usize>,
96    /// Select a set of tools.
97    pub multi: bool,
98    /// Certified error target.
99    pub max_error: Option<f64>,
100    /// Multi selection rules, only valid in multi mode.
101    pub selection: MultiOptions<'a>,
102}
103/// Act options. Omitted criteria use true/false outcomes.
104#[derive(Debug, Clone, Copy, Default)]
105pub struct ActOptions<'a> {
106    /// Named outcomes, in request order.
107    pub criteria: Option<&'a [(&'a str, &'a str)]>,
108    /// Request supporting spans.
109    pub evidence: bool,
110    /// Action to defer to, which must exist in costs.
111    pub defer_action: Option<&'a str>,
112    /// Certified error target; calibration capability belongs to the model.
113    pub max_error: Option<f64>,
114}
115/// Explicit action/outcome cost matrix.
116pub type CostMatrix = IndexMap<String, IndexMap<String, f64>>;
117/// Native facts preprocessing mode.
118#[derive(Debug, Clone, Copy)]
119pub enum FactsMode {
120    /// No preprocessing.
121    Off,
122    /// Let the model preprocess facts.
123    Auto,
124}
125/// Decision routing/preprocessing options.
126#[derive(Debug, Clone, Copy, Default)]
127pub struct DecisionOptions<'a> {
128    /// Exact model name, or server default.
129    pub model: Option<&'a str>,
130    /// Native facts mode.
131    pub facts: Option<FactsMode>,
132    /// Explicit prompt language.
133    pub lang: Option<Language>,
134    /// Inline media in request order.
135    pub media: Option<&'a [MediaInput]>,
136    /// Image detail budget.
137    pub detail: Option<MediaDetail>,
138}
139
140mod sealed {
141    pub trait Sealed {}
142}
143/// Closed association between a question and its required result fields.
144pub trait AnswerKind: sealed::Sealed {
145    /// Checked result borrowed from a response.
146    type Output<'a>;
147    /// Validate required fields and return the typed view.
148    fn parse(answer: &Answer) -> Result<Self::Output<'_>, Error>;
149}
150
151/// Checked choice answer.
152#[derive(Debug, Clone, Copy)]
153pub struct ChoiceAnswer<'a> {
154    /// Selected criterion (never optional).
155    pub choice: &'a str,
156    /// Native confidence.
157    pub confidence: f64,
158    /// Native probabilities.
159    pub probabilities: &'a IndexMap<String, f64>,
160    /// Supporting spans, empty if none were returned.
161    pub evidence: &'a [EvidenceSpan],
162    /// Original answer, including evidence/extensions.
163    pub raw: &'a Answer,
164}
165/// Checked yes/no answer.
166#[derive(Debug, Clone, Copy)]
167pub struct NoulAnswer<'a> {
168    /// P(yes), never optional.
169    pub noul: f64,
170    /// Native confidence.
171    pub confidence: f64,
172    /// Native probabilities.
173    pub probabilities: &'a IndexMap<String, f64>,
174    /// Supporting spans, empty if none were returned.
175    pub evidence: &'a [EvidenceSpan],
176    /// Original answer.
177    pub raw: &'a Answer,
178}
179/// Checked score answer.
180#[derive(Debug, Clone, Copy)]
181pub struct ScoreAnswer<'a> {
182    /// Weighted score, never optional.
183    pub score: f64,
184    /// Native confidence.
185    pub confidence: f64,
186    /// Ordered level legend.
187    pub legend: &'a IndexMap<String, String>,
188    /// Native probabilities.
189    pub probabilities: &'a IndexMap<String, f64>,
190    /// Supporting spans, empty if none were returned.
191    pub evidence: &'a [EvidenceSpan],
192    /// Original answer.
193    pub raw: &'a Answer,
194}
195/// Checked multi answer.
196#[derive(Debug, Clone, Copy)]
197pub struct MultiAnswer<'a> {
198    /// Selected labels, possibly empty.
199    pub selected: &'a [String],
200    /// Applied threshold; absent for exact, IoU and F1 objectives.
201    pub threshold: Option<f64>,
202    /// Selection objective.
203    pub objective: MultiObjective,
204    /// Expected objective score, if reported.
205    pub expected_score: Option<f64>,
206    /// Constraint diagnostics.
207    pub constraints_applied: Option<&'a Value>,
208    /// Confidence of exactly the selected set.
209    pub set_confidence: f64,
210    /// Independent P(yes) values.
211    pub probabilities: &'a IndexMap<String, f64>,
212    /// Original answer.
213    pub raw: &'a Answer,
214}
215/// Checked tool routing answer. This SDK never executes a selected tool.
216#[derive(Debug, Clone, Copy)]
217pub struct ToolAnswer<'a> {
218    /// Single selected tool; absent for multi mode or deferral.
219    pub tool: Option<&'a str>,
220    /// Whether an actual tool was selected.
221    pub call: bool,
222    /// Single-tool confidence, absent in multi mode.
223    pub confidence: Option<f64>,
224    /// Whether this is a multi selection.
225    pub multi: bool,
226    /// Selected tool keys in multi mode.
227    pub tools: &'a [String],
228    /// Multi-mode selection threshold or objective score and constraints, with `tools` as selected labels.
229    pub selection: Option<MultiAnswer<'a>>,
230    /// Ranked candidates.
231    pub ranking: &'a [ToolRanking],
232    /// Server probabilities.
233    pub probabilities: &'a IndexMap<String, f64>,
234    /// Original result, including deferral metadata.
235    pub raw: &'a Answer,
236}
237/// Checked act answer.
238#[derive(Debug, Clone, Copy)]
239pub struct ActAnswer<'a> {
240    /// Chosen action, never optional.
241    pub action: &'a str,
242    /// Most probable outcome.
243    pub answer: &'a str,
244    /// Native confidence.
245    pub confidence: f64,
246    /// Calibration status.
247    pub calibration: &'a str,
248    /// Expected cost per action.
249    pub expected_costs: &'a IndexMap<String, f64>,
250    /// Native probabilities.
251    pub probabilities: &'a IndexMap<String, f64>,
252    /// Supporting spans, empty if none were returned.
253    pub evidence: &'a [EvidenceSpan],
254    /// Original answer.
255    pub raw: &'a Answer,
256}
257
258fn result_number(value: Option<f64>) -> Result<f64, Error> {
259    value.filter(|v| v.is_finite()).ok_or(Error::InvalidResponse("missing or non-finite answer field"))
260}
261macro_rules! kind {
262    ($name:ident, $view:ident, $variant:ident, $answer:ident, $body:block) => {
263        #[doc = concat!("Result marker for ", stringify!($variant), " questions.")]
264        #[derive(Debug)]
265        pub struct $name;
266        impl sealed::Sealed for $name {}
267        impl AnswerKind for $name {
268            type Output<'a> = $view<'a>;
269            fn parse($answer: &Answer) -> Result<Self::Output<'_>, Error> {
270                if !matches!($answer.kind, QuestionType::$variant) {
271                    return Err(Error::InvalidResponse("mismatched answer type"));
272                }
273                $body
274            }
275        }
276    };
277}
278kind!(ChoiceKind, ChoiceAnswer, Choice, a, {
279    Ok(ChoiceAnswer {
280        choice: a.choice.as_deref().ok_or(Error::InvalidResponse("missing choice"))?,
281        confidence: result_number(a.confidence)?,
282        probabilities: &a.probabilities,
283        evidence: a.evidence.as_deref().unwrap_or_default(),
284        raw: a,
285    })
286});
287kind!(NoulKind, NoulAnswer, Noul, a, {
288    Ok(NoulAnswer {
289        noul: result_number(a.noul)?,
290        confidence: result_number(a.confidence)?,
291        probabilities: &a.probabilities,
292        evidence: a.evidence.as_deref().unwrap_or_default(),
293        raw: a,
294    })
295});
296kind!(ScoreKind, ScoreAnswer, Score, a, {
297    Ok(ScoreAnswer {
298        score: result_number(a.score)?,
299        confidence: result_number(a.confidence)?,
300        legend: a.legend.as_ref().ok_or(Error::InvalidResponse("missing legend"))?,
301        probabilities: &a.probabilities,
302        evidence: a.evidence.as_deref().unwrap_or_default(),
303        raw: a,
304    })
305});
306/// Selection fields shared by multi answers and multi-mode tool answers.
307fn selection<'a>(a: &'a Answer, selected: &'a [String]) -> Result<MultiAnswer<'a>, Error> {
308    let objective = a.objective.unwrap_or_default();
309    Ok(MultiAnswer {
310        selected,
311        threshold: if objective == MultiObjective::Threshold { Some(result_number(a.threshold)?) } else { None },
312        objective,
313        expected_score: a.expected_score.map(|v| result_number(Some(v))).transpose()?,
314        constraints_applied: a.constraints_applied.as_ref(),
315        set_confidence: result_number(a.set_confidence)?,
316        probabilities: &a.probabilities,
317        raw: a,
318    })
319}
320kind!(MultiKind, MultiAnswer, Multi, a, {
321    selection(a, a.selected.as_deref().ok_or(Error::InvalidResponse("missing selected labels"))?)
322});
323kind!(ActKind, ActAnswer, Act, a, {
324    let costs = a.expected_costs.as_ref().ok_or(Error::InvalidResponse("missing expected costs"))?;
325    if costs.values().any(|v| !v.is_finite()) {
326        return Err(Error::InvalidResponse("non-finite expected cost"));
327    }
328    let calibration = a
329        .calibration
330        .as_deref()
331        .filter(|v| matches!(*v, "validated" | "unvalidated" | "text"))
332        .ok_or(Error::InvalidResponse("invalid calibration"))?;
333    Ok(ActAnswer {
334        action: a.action.as_deref().ok_or(Error::InvalidResponse("missing action"))?,
335        answer: a.answer.as_deref().ok_or(Error::InvalidResponse("missing outcome"))?,
336        confidence: result_number(a.confidence)?,
337        calibration,
338        expected_costs: costs,
339        probabilities: &a.probabilities,
340        evidence: a.evidence.as_deref().unwrap_or_default(),
341        raw: a,
342    })
343});
344
345kind!(ToolKind, ToolAnswer, Tool, a, {
346    let multi = a.multi.unwrap_or(false);
347    let selection = if multi {
348        let tools = a.tools.as_deref().ok_or(Error::InvalidResponse("missing selected tools"))?;
349        if tools.iter().any(|k| !a.probabilities.contains_key(k)) {
350            return Err(Error::InvalidResponse("unknown selected tool"));
351        }
352        Some(selection(a, tools)?)
353    } else {
354        result_number(a.confidence)?;
355        if a.tool.as_ref().is_some_and(|k| !a.probabilities.contains_key(k))
356            || (a.tool.is_none() && a.deferred != Some(true))
357        {
358            return Err(Error::InvalidResponse("missing or unknown tool"));
359        }
360        None
361    };
362    if a.ranking.as_deref().unwrap_or_default().iter().any(|r| {
363        !a.probabilities.contains_key(&r.tool) || !r.probability.is_finite() || !(0.0..=1.0).contains(&r.probability)
364    }) {
365        return Err(Error::InvalidResponse("invalid tool ranking"));
366    }
367    Ok(ToolAnswer {
368        tool: a.tool.as_deref(),
369        call: a.call.ok_or(Error::InvalidResponse("missing call flag"))?,
370        confidence: a.confidence,
371        multi,
372        tools: a.tools.as_deref().unwrap_or_default(),
373        selection,
374        ranking: a.ranking.as_deref().unwrap_or_default(),
375        probabilities: &a.probabilities,
376        raw: a,
377    })
378});
379
380/// Immutable typed question definition. It can be reused under multiple request keys.
381#[derive(Debug)]
382pub struct QuestionSpec<A: AnswerKind> {
383    data: Arc<Question>,
384    marker: PhantomData<A>,
385}
386impl<A: AnswerKind> Clone for QuestionSpec<A> {
387    fn clone(&self) -> Self {
388        Self { data: self.data.clone(), marker: PhantomData }
389    }
390}
391impl<A: AnswerKind> QuestionSpec<A> {
392    fn new(question: Question) -> Self {
393        Self { data: Arc::new(question), marker: PhantomData }
394    }
395    /// Borrow the immutable native question.
396    pub fn to_wire(&self) -> &Question {
397        &self.data
398    }
399}
400/// A typed choice question.
401pub type ChoiceQuestion = QuestionSpec<ChoiceKind>;
402/// A typed yes/no question.
403pub type NoulQuestion = QuestionSpec<NoulKind>;
404/// A typed score question.
405pub type ScoreQuestion = QuestionSpec<ScoreKind>;
406/// A typed multi question.
407pub type MultiQuestion = QuestionSpec<MultiKind>;
408/// A typed act question.
409pub type ActQuestion = QuestionSpec<ActKind>;
410/// Tool routing definition.
411pub type ToolQuestion = QuestionSpec<ToolKind>;
412
413fn text(value: &str) -> Result<(), Error> {
414    if value.trim().is_empty() {
415        Err(Error::InvalidRequest("expected nonempty text"))
416    } else {
417        Ok(())
418    }
419}
420fn finite(value: f64, probability: bool) -> Result<Value, Error> {
421    if !value.is_finite() || (probability && !(0.0..=1.0).contains(&value)) {
422        return Err(Error::InvalidRequest("invalid finite number/range"));
423    }
424    Ok(Value::from(value))
425}
426fn criteria(values: &[(&str, &str)], min: usize, max: usize) -> Result<Value, Error> {
427    if values.len() < min || values.len() > max {
428        return Err(Error::InvalidRequest("invalid number of criteria"));
429    }
430    let mut fields = Map::new();
431    for &(key, value) in values {
432        text(key)?;
433        text(value)?;
434        if fields.insert(key.into(), Value::String(value.into())).is_some() {
435            return Err(Error::InvalidRequest("duplicate criterion key"));
436        }
437    }
438    Ok(Value::Object(fields))
439}
440fn base(kind: QuestionType, instructions: &str, evidence: bool) -> Result<Question, Error> {
441    text(instructions)?;
442    let mut q = Question::new(kind, instructions);
443    if evidence {
444        q.extra.insert("evidence".into(), Value::Bool(true));
445    }
446    Ok(q)
447}
448/// Choose one of 2–255 ordered named options.
449pub fn choice_with_options(
450    instructions: &str,
451    values: &[(&str, &str)],
452    options: ChoiceOptions,
453) -> Result<ChoiceQuestion, Error> {
454    let mut q = base(QuestionType::Choice, instructions, options.evidence)?;
455    q.criteria = Some(criteria(values, 2, 255)?);
456    if matches!(options.option_keys, OptionKeys::Hide) {
457        q.extra.insert("option_keys".into(), Value::String("hide".into()));
458    }
459    Ok(QuestionSpec::new(q))
460}
461/// Read a yes/no probability.
462pub fn noul_with_options(instructions: &str, options: EvidenceOptions) -> Result<NoulQuestion, Error> {
463    Ok(QuestionSpec::new(base(QuestionType::Noul, instructions, options.evidence)?))
464}
465/// Score over 2–255 ordered text levels.
466pub fn score_with_options(
467    instructions: &str,
468    levels: &[&str],
469    options: EvidenceOptions,
470) -> Result<ScoreQuestion, Error> {
471    if !(2..=255).contains(&levels.len()) {
472        return Err(Error::InvalidRequest("score requires 2–255 levels"));
473    }
474    let mut values = Vec::with_capacity(levels.len());
475    for &level in levels {
476        text(level)?;
477        values.push(Value::String(level.into()));
478    }
479    let mut q = base(QuestionType::Score, instructions, options.evidence)?;
480    q.criteria = Some(Value::Array(values));
481    Ok(QuestionSpec::new(q))
482}
483/// Select labels independently; options have no evidence.
484pub fn multi_with_options(
485    instructions: &str,
486    values: &[(&str, &str)],
487    options: MultiOptions<'_>,
488) -> Result<MultiQuestion, Error> {
489    let mut q = base(QuestionType::Multi, instructions, false)?;
490    q.criteria = Some(criteria(values, 1, usize::MAX)?);
491    q.extra.extend(selection_fields(options, Some(values))?);
492    Ok(QuestionSpec::new(q))
493}
494fn selection_fields(options: MultiOptions<'_>, values: Option<&[(&str, &str)]>) -> Result<Map<String, Value>, Error> {
495    let mut fields = Map::new();
496    if options.objective != MultiObjective::Threshold {
497        fields.insert("objective".into(), serde_json::to_value(options.objective).map_err(Error::Encode)?);
498    }
499    if let Some(t) = options.threshold {
500        if options.objective != MultiObjective::Threshold {
501            return Err(Error::InvalidRequest("threshold requires threshold objective"));
502        }
503        fields.insert("threshold".into(), finite(t, true)?);
504    }
505    for (key, limit) in [("min", options.min), ("max", options.max)] {
506        if let Some(n) = limit {
507            if values.is_some_and(|v| n > v.len()) {
508                return Err(Error::InvalidRequest("selection limit exceeds labels"));
509            }
510            fields.insert(key.into(), n.into());
511        }
512    }
513    if options.min.zip(options.max).is_some_and(|(a, b)| a > b) {
514        return Err(Error::InvalidRequest("min exceeds max"));
515    }
516    let valid_key = |key: &str| -> Result<(), Error> {
517        text(key)?;
518        if values.is_some_and(|vs| !vs.iter().any(|(k, _)| *k == key)) {
519            return Err(Error::InvalidRequest("unknown selection key"));
520        }
521        Ok(())
522    };
523    if !options.exclusive.is_empty() {
524        for key in options.exclusive {
525            valid_key(key)?;
526        }
527        fields.insert("exclusive".into(), serde_json::to_value(options.exclusive).map_err(Error::Encode)?);
528    }
529    if let Some(key) = options.fallback {
530        valid_key(key)?;
531        fields.insert("fallback".into(), key.into());
532    }
533    if let Some(t) = options.fallback_threshold {
534        if options.fallback.is_none() {
535            return Err(Error::InvalidRequest("fallback_threshold requires fallback"));
536        }
537        fields.insert("fallback_threshold".into(), finite(t, true)?);
538    }
539    Ok(fields)
540}
541/// Select tools from OpenAI, MCP or Anthropic definition objects.
542pub fn tool_with_options(instructions: &str, tools: &[Value], options: ToolOptions<'_>) -> Result<ToolQuestion, Error> {
543    if tools.iter().any(|v| !v.is_object()) {
544        return Err(Error::InvalidRequest("tools require definition objects"));
545    }
546    let mut q = base(QuestionType::Tool, instructions, false)?;
547    q.extra.insert("tools".into(), Value::Array(tools.to_vec()));
548    if let Some(extra) = options.extra {
549        q.extra.insert("extra".into(), serde_json::to_value(extra).map_err(Error::Encode)?);
550    }
551    if let Some(rank) = options.rank {
552        if rank == 0 {
553            return Err(Error::InvalidRequest("rank must be positive"));
554        }
555        q.extra.insert("rank".into(), rank.into());
556    }
557    if let Some(v) = options.max_error {
558        q.extra.insert("max_error".into(), finite(v, true)?);
559    }
560    let fields = selection_fields(options.selection, None)?;
561    if !options.multi && !fields.is_empty() {
562        return Err(Error::InvalidRequest("selection rules require multi"));
563    }
564    if options.multi {
565        q.extra.insert("multi".into(), true.into());
566        q.extra.extend(fields);
567    }
568    Ok(QuestionSpec::new(q))
569}
570/// Select one tool with default options; does not execute it.
571pub fn tool(instructions: &str, tools: &[Value]) -> Result<ToolQuestion, Error> {
572    tool_with_options(instructions, tools, ToolOptions::default())
573}
574
575/// Select an action from a full cost matrix. Omitted criteria use true/false outcomes.
576pub fn act_with_options(instructions: &str, costs: &CostMatrix, options: ActOptions<'_>) -> Result<ActQuestion, Error> {
577    if costs.is_empty() {
578        return Err(Error::InvalidRequest("act requires costs"));
579    }
580    let mut q = base(QuestionType::Act, instructions, options.evidence)?;
581    if let Some(values) = options.criteria {
582        q.criteria = Some(criteria(values, 2, 255)?);
583    }
584    let outcomes: Vec<&str> =
585        options.criteria.map_or_else(|| vec!["true", "false"], |values| values.iter().map(|(key, _)| *key).collect());
586    let mut matrix = Map::new();
587    for (action, row) in costs {
588        text(action)?;
589        if row.len() != outcomes.len() || outcomes.iter().any(|key| !row.contains_key(*key)) {
590            return Err(Error::InvalidRequest("each cost row must cover every outcome exactly"));
591        }
592        let mut values = Map::new();
593        for (key, value) in row {
594            values.insert(key.clone(), finite(*value, false)?);
595        }
596        matrix.insert(action.clone(), Value::Object(values));
597    }
598    q.extra.insert("costs".into(), Value::Object(matrix));
599    if let Some(action) = options.defer_action {
600        if !costs.contains_key(action) {
601            return Err(Error::InvalidRequest("unknown defer action"));
602        }
603        q.extra.insert("defer_action".into(), Value::String(action.into()));
604    }
605    if let Some(error) = options.max_error {
606        q.extra.insert("max_error".into(), finite(error, true)?);
607    }
608    Ok(QuestionSpec::new(q))
609}
610
611type Validator = fn(&Answer) -> Result<(), Error>;
612#[derive(Debug, Clone)]
613struct Entry {
614    data: Arc<Question>,
615    validate: Validator,
616}
617/// Ordered heterogeneous request builder. Request keys identify answers, not definition identity.
618#[derive(Debug, Clone, Default)]
619pub struct Questions {
620    entries: IndexMap<String, Entry>,
621}
622impl Questions {
623    /// Start an empty builder; sending it is an input error.
624    pub fn new() -> Self {
625        Self::default()
626    }
627    /// Add a typed question under a unique ID. One definition may be used under several IDs.
628    pub fn with<A: AnswerKind>(mut self, id: &str, question: &QuestionSpec<A>) -> Result<Self, Error> {
629        text(id)?;
630        if self.entries.contains_key(id) {
631            return Err(Error::InvalidRequest("duplicate question ID"));
632        }
633        self.entries.insert(id.into(), Entry { data: question.data.clone(), validate: |a| A::parse(a).map(|_| ()) });
634        Ok(self)
635    }
636    pub(crate) fn request(&self, state: Value, options: DecisionOptions<'_>) -> Result<DecisionRequest, Error> {
637        if state.is_null() || self.entries.is_empty() {
638            return Err(Error::InvalidRequest("state and questions must not be empty/null"));
639        }
640        let mut request = DecisionRequest::new(state);
641        if let Some(model) = options.model {
642            text(model)?;
643            request.model = Some(model.into());
644        }
645        if let Some(facts) = options.facts {
646            request.extra.insert(
647                "facts".into(),
648                Value::String(
649                    match facts {
650                        FactsMode::Off => "off",
651                        FactsMode::Auto => "auto",
652                    }
653                    .into(),
654                ),
655            );
656        }
657        request.lang = options.lang;
658        request.detail = options.detail;
659        if let Some(media) = options.media {
660            if !(1..=8).contains(&media.len()) {
661                return Err(Error::InvalidRequest("media requires 1 to 8 inputs"));
662            }
663            for input in media {
664                input.validate()?;
665            }
666            request.media = Some(media.to_vec());
667        }
668        for (id, entry) in &self.entries {
669            request.insert_question(id, &entry.data)?;
670        }
671        Ok(request)
672    }
673    pub(crate) fn parse<S: AnswerSchema>(
674        self,
675        raw: DecisionResponse,
676        schema: S,
677    ) -> Result<TypedDecisionResponse<S>, Error> {
678        for (id, entry) in &self.entries {
679            let answer = raw.answers.get(id).ok_or(Error::InvalidResponse("missing answer"))?;
680            (entry.validate)(answer)?;
681            if matches!(answer.kind, QuestionType::Tool)
682                && answer.multi.unwrap_or(false)
683                    != entry.data.extra.get("multi").and_then(Value::as_bool).unwrap_or(false)
684            {
685                return Err(Error::InvalidResponse("mismatched tool selection mode"));
686            }
687            if matches!(answer.kind, QuestionType::Multi)
688                || (matches!(answer.kind, QuestionType::Tool) && answer.multi == Some(true))
689            {
690                let expected = entry.data.extra.get("objective").and_then(Value::as_str).unwrap_or("threshold");
691                if answer.objective.unwrap_or_default().as_str() != expected {
692                    return Err(Error::InvalidResponse("mismatched selection objective"));
693                }
694            }
695            if answer.probabilities.values().any(|v| !v.is_finite() || !(0.0..=1.0).contains(v)) {
696                return Err(Error::InvalidResponse("invalid probabilities"));
697            }
698            if let Some(key) = &answer.choice {
699                if entry.data.criteria.as_ref().is_none_or(|v| v.get(key).is_none()) {
700                    return Err(Error::InvalidResponse("unknown choice"));
701                }
702            }
703            if let Some(keys) = &answer.selected {
704                if keys.iter().any(|key| entry.data.criteria.as_ref().is_none_or(|v| v.get(key).is_none())) {
705                    return Err(Error::InvalidResponse("unknown multi label"));
706                }
707            }
708            if let Some(action) = &answer.action {
709                if entry.data.extra.get("costs").is_none_or(|v| v.get(action).is_none()) {
710                    return Err(Error::InvalidResponse("unknown action"));
711                }
712            }
713        }
714        Ok(TypedDecisionResponse { raw, questions: self, schema })
715    }
716}
717/// Checked answer selected by a runtime question key.
718#[derive(Debug, Clone, Copy)]
719pub enum AnswerResult<'a> {
720    /// One selected criterion.
721    Choice(ChoiceAnswer<'a>),
722    /// Yes/no probability.
723    Noul(NoulAnswer<'a>),
724    /// Weighted score.
725    Score(ScoreAnswer<'a>),
726    /// Independently selected labels.
727    Multi(MultiAnswer<'a>),
728    /// Selected action.
729    Act(ActAnswer<'a>),
730    /// Tool routing result.
731    Tool(ToolAnswer<'a>),
732}
733
734impl<'a> AnswerResult<'a> {
735    fn parse(answer: &'a Answer) -> Result<Self, Error> {
736        match answer.kind {
737            QuestionType::Choice => ChoiceKind::parse(answer).map(Self::Choice),
738            QuestionType::Noul => NoulKind::parse(answer).map(Self::Noul),
739            QuestionType::Score => ScoreKind::parse(answer).map(Self::Score),
740            QuestionType::Multi => MultiKind::parse(answer).map(Self::Multi),
741            QuestionType::Act => ActKind::parse(answer).map(Self::Act),
742            QuestionType::Tool => ToolKind::parse(answer).map(Self::Tool),
743        }
744    }
745}
746
747/// Constructs borrowed answer fields for a request shape.
748///
749/// Usually generated by [`crate::questions!`]. Implementations must use request keys to select fields;
750/// missing keys or mismatched answer kinds must return an error.
751pub trait AnswerSchema: Clone {
752    /// Named fields or a map of checked answer variants, borrowed from the response.
753    type Answers<'a>;
754    /// Build checked views without copying probability maps or borrowing question definitions.
755    fn parse<'a>(&self, raw: &'a DecisionResponse, questions: &'a Questions) -> Result<Self::Answers<'a>, Error>;
756}
757
758/// Schema for a runtime-keyed [`Questions`] map.
759#[derive(Debug, Clone, Copy, Default)]
760pub struct DynamicSchema;
761
762impl AnswerSchema for DynamicSchema {
763    type Answers<'a> = IndexMap<&'a str, AnswerResult<'a>>;
764
765    fn parse<'a>(&self, raw: &'a DecisionResponse, questions: &'a Questions) -> Result<Self::Answers<'a>, Error> {
766        questions
767            .entries
768            .keys()
769            .map(|id| {
770                let answer = raw.answers.get(id).ok_or(Error::InvalidResponse("missing answer"))?;
771                Ok((id.as_str(), AnswerResult::parse(answer)?))
772            })
773            .collect()
774    }
775}
776
777/// Request questions accepted by [`crate::Client::decide`].
778///
779/// Use [`Questions`] for dynamic keys or [`crate::questions!`] for inferred named answer fields.
780pub trait QuestionSet: sealed::Sealed {
781    /// Response shape retained from these questions.
782    type Schema: AnswerSchema;
783    /// Ordered, validated question definitions.
784    fn as_questions(&self) -> &Questions;
785    /// Response field schema, independent of question object identities.
786    fn schema(&self) -> Self::Schema;
787}
788
789impl sealed::Sealed for Questions {}
790impl QuestionSet for Questions {
791    type Schema = DynamicSchema;
792    fn as_questions(&self) -> &Questions {
793        self
794    }
795    fn schema(&self) -> Self::Schema {
796        DynamicSchema
797    }
798}
799
800/// Ordered question definitions together with their named response shape.
801/// Usually constructed by [`crate::questions!`].
802#[derive(Debug, Clone)]
803pub struct PreparedQuestions<S: AnswerSchema> {
804    questions: Questions,
805    schema: S,
806}
807
808impl<S: AnswerSchema> PreparedQuestions<S> {
809    /// Combine definitions with a custom response schema. Prefer [`crate::questions!`].
810    pub fn new(questions: Questions, schema: S) -> Self {
811        Self { questions, schema }
812    }
813}
814
815impl<S: AnswerSchema> sealed::Sealed for PreparedQuestions<S> {}
816impl<S: AnswerSchema> QuestionSet for PreparedQuestions<S> {
817    type Schema = S;
818    fn as_questions(&self) -> &Questions {
819        &self.questions
820    }
821    fn schema(&self) -> Self::Schema {
822        self.schema.clone()
823    }
824}
825
826/// Validated response retaining request keys and an optional named answer shape.
827#[derive(Debug)]
828pub struct TypedDecisionResponse<S: AnswerSchema = DynamicSchema> {
829    raw: DecisionResponse,
830    questions: Questions,
831    schema: S,
832}
833impl<S: AnswerSchema> TypedDecisionResponse<S> {
834    /// Native response, borrowed immutably for extension fields.
835    pub fn raw(&self) -> &DecisionResponse {
836        &self.raw
837    }
838    /// Model actually used.
839    pub fn model(&self) -> &str {
840        &self.raw.model
841    }
842    /// Native usage.
843    pub fn usage(&self) -> &Usage {
844        &self.raw.usage
845    }
846    /// Borrow named fields inferred by [`crate::questions!`], or a map for dynamic questions.
847    /// The normal macro-generated schema has already been validated before the client returns.
848    pub fn answers(&self) -> Result<S::Answers<'_>, Error> {
849        self.schema.parse(&self.raw, &self.questions)
850    }
851    fn answer_raw(&self, id: &str) -> Result<&Answer, Error> {
852        if !self.questions.entries.contains_key(id) {
853            return Err(Error::InvalidRequest("question key was not part of this response"));
854        }
855        self.raw.answers.get(id).ok_or(Error::InvalidResponse("missing answer"))
856    }
857    /// Look up a checked answer by its request key. Match the enum for dynamic heterogeneous keys.
858    pub fn answer(&self, id: &str) -> Result<AnswerResult<'_>, Error> {
859        AnswerResult::parse(self.answer_raw(id)?)
860    }
861    /// Look up a choice answer by key; a different question kind is an error.
862    pub fn choice(&self, id: &str) -> Result<ChoiceAnswer<'_>, Error> {
863        ChoiceKind::parse(self.answer_raw(id)?)
864    }
865    /// Look up a yes/no answer by key.
866    pub fn noul(&self, id: &str) -> Result<NoulAnswer<'_>, Error> {
867        NoulKind::parse(self.answer_raw(id)?)
868    }
869    /// Look up a score answer by key.
870    pub fn score(&self, id: &str) -> Result<ScoreAnswer<'_>, Error> {
871        ScoreKind::parse(self.answer_raw(id)?)
872    }
873    /// Look up a multi answer by key.
874    pub fn multi(&self, id: &str) -> Result<MultiAnswer<'_>, Error> {
875        MultiKind::parse(self.answer_raw(id)?)
876    }
877    /// Look up a tool routing answer by key.
878    pub fn tool(&self, id: &str) -> Result<ToolAnswer<'_>, Error> {
879        ToolKind::parse(self.answer_raw(id)?)
880    }
881    /// Look up an action answer by key.
882    pub fn act(&self, id: &str) -> Result<ActAnswer<'_>, Error> {
883        ActKind::parse(self.answer_raw(id)?)
884    }
885}
886
887/// Build a named request shape with inferred, checked answer fields.
888///
889/// Field names become wire keys. Expressions are evaluated once, and one definition can be reused.
890/// No procedural macro, code generator or runtime dependency is required.
891///
892/// ```rust,ignore
893/// let questions = questions! { department: &routing, second_department: &routing }?;
894/// let response = client.decide(state, &questions).await?;
895/// let answers = response.answers()?;
896/// println!("{} {}", answers.department.choice, answers.second_department.choice);
897/// ```
898#[macro_export]
899macro_rules! questions {
900    ($($field:ident : $question:expr),+ $(,)?) => {{
901        // The caller's field identifiers also carry inferred answer-kind type parameters.
902        #[allow(non_camel_case_types, reason = "macro type parameters reuse caller field names")]
903        mod __basal_question_shape {
904            pub struct Schema<$($field: $crate::AnswerKind),+> {
905                marker: ::std::marker::PhantomData<($($field,)+)>,
906            }
907
908            impl<$($field: $crate::AnswerKind),+> ::std::fmt::Debug for self::Schema<$($field),+> {
909                fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
910                    f.write_str("QuestionSchema")
911                }
912            }
913
914            impl<$($field: $crate::AnswerKind),+> ::std::clone::Clone for self::Schema<$($field),+> {
915                fn clone(&self) -> Self { Self { marker: ::std::marker::PhantomData } }
916            }
917
918            pub struct Answers<'a, $($field: $crate::AnswerKind),+> {
919                $(pub $field: <$field as $crate::AnswerKind>::Output<'a>,)+
920            }
921
922            impl<$($field: $crate::AnswerKind),+> $crate::AnswerSchema for self::Schema<$($field),+> {
923                type Answers<'a> = self::Answers<'a, $($field),+>;
924
925                fn parse<'a>(
926                    &self,
927                    raw: &'a $crate::DecisionResponse,
928                    _: &'a $crate::Questions,
929                ) -> ::std::result::Result<Self::Answers<'a>, $crate::Error> {
930                    ::std::result::Result::Ok(self::Answers {
931                        $($field: {
932                            let id = ::std::stringify!($field);
933                            let id = id.strip_prefix("r#").unwrap_or(id);
934                            let answer = raw.answers.get(id).ok_or($crate::Error::InvalidResponse("missing answer"))?;
935                            <$field as $crate::AnswerKind>::parse(answer)?
936                        },)+
937                    })
938                }
939            }
940
941            pub fn build<$($field: $crate::AnswerKind),+>(
942                $($field: &$crate::QuestionSpec<$field>),+
943            ) -> ::std::result::Result<$crate::PreparedQuestions<self::Schema<$($field),+>>, $crate::Error> {
944                let questions = $crate::Questions::new();
945                $(let id = ::std::stringify!($field);
946                  let id = id.strip_prefix("r#").unwrap_or(id);
947                  let questions = questions.with(id, $field)?;)+
948                ::std::result::Result::Ok($crate::PreparedQuestions::new(questions, self::Schema { marker: ::std::marker::PhantomData }))
949            }
950        }
951        // This binding extends factory temporaries and infers block expressions before coercing references.
952        let ($($field,)+) = ($(&$question,)+);
953        __basal_question_shape::build($($field),+)
954    }};
955}
956
957/// Choose from 2–255 options with default display settings.
958pub fn choice(instructions: &str, values: &[(&str, &str)]) -> Result<ChoiceQuestion, Error> {
959    choice_with_options(instructions, values, ChoiceOptions::default())
960}
961/// Yes/no probability with default options.
962pub fn noul(instructions: &str) -> Result<NoulQuestion, Error> {
963    noul_with_options(instructions, EvidenceOptions::default())
964}
965/// Score over ordered levels with default options.
966pub fn score(instructions: &str, levels: &[&str]) -> Result<ScoreQuestion, Error> {
967    score_with_options(instructions, levels, EvidenceOptions::default())
968}
969/// Multi selection with default threshold and no count limits.
970pub fn multi(instructions: &str, values: &[(&str, &str)]) -> Result<MultiQuestion, Error> {
971    multi_with_options(instructions, values, MultiOptions::default())
972}
973/// Act over true/false outcomes with default options.
974pub fn act(instructions: &str, costs: &CostMatrix) -> Result<ActQuestion, Error> {
975    act_with_options(instructions, costs, ActOptions::default())
976}