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