Skip to main content

gemini_adk_rs/decision/
service.rs

1//! [`Decisions`]: a bank of questions asked of a decision model against the
2//! rolling conversation.
3
4use std::collections::{BTreeMap, BTreeSet};
5use std::hash::{Hash, Hasher};
6use std::sync::Arc;
7use std::time::{Duration, Instant};
8
9use serde_json::{Value, json};
10
11use super::{Answer, DecisionModel, DecisionRequest, Question};
12use crate::flow::{DECISION_TURN_KEY, DecisionRecord, DecisionScope, Outcome, decision_key};
13use crate::live::transcript::TranscriptTurn;
14use crate::state::State;
15
16/// The `P(true)` a boolean answer needs to count as yes (and `1 -` this to
17/// count as no), unless the question sets its own.
18pub const DEFAULT_BOOLEAN_THRESHOLD: f64 = 0.85;
19/// The certainty a choice or score answer needs, unless the question sets
20/// its own.
21pub const DEFAULT_CERTAINTY_THRESHOLD: f64 = 0.6;
22/// How long a round waits for the decision model.
23pub const DEFAULT_DECISION_TIMEOUT: Duration = Duration::from_secs(2);
24/// How many turns of the conversation the decision model reads.
25pub const DEFAULT_HISTORY_TURNS: usize = 40;
26/// The option a choice gets from [`Decision::or_none`].
27pub const NONE_OPTION: &str = "none_of_these";
28
29/// One question in the bank.
30#[derive(Debug, Clone, PartialEq)]
31pub struct Decision {
32    /// The question.
33    pub question: Question,
34    /// For a choice: a state path (`key` or `key.field.0`) holding the
35    /// options, read at each ask. Each array element is an option: a string,
36    /// a number, or an object keyed by its `id`, `value` or `name` field.
37    pub options_from: Option<String>,
38    /// For a choice: add [`NONE_OPTION`] with this description. A choice
39    /// always picks something, so a question like "which offered time did
40    /// the caller pick?" needs a way to say they have not.
41    pub none: Option<String>,
42    /// The `P(true)` (boolean) or certainty (choice, score) an answer needs.
43    pub at_least: Option<f64>,
44    /// Write the decided value into this state key: the chosen option (an
45    /// object option whole), the score, or the boolean. The question is then
46    /// also asked whenever an active guard reads that key.
47    pub writes: Option<String>,
48}
49
50impl Decision {
51    /// A question with the defaults.
52    pub fn new(question: Question) -> Self {
53        Self {
54            question,
55            options_from: None,
56            none: None,
57            at_least: None,
58            writes: None,
59        }
60    }
61
62    /// Read a choice's options from a state path.
63    #[must_use]
64    pub fn options_from(mut self, path: impl Into<String>) -> Self {
65        self.options_from = Some(path.into());
66        self
67    }
68
69    /// Add a "none of these" option meaning `description`.
70    #[must_use]
71    pub fn or_none(mut self, description: impl Into<String>) -> Self {
72        self.none = Some(description.into());
73        self
74    }
75
76    /// Require this probability (boolean) or certainty (choice, score).
77    #[must_use]
78    pub fn at_least(mut self, threshold: f64) -> Self {
79        self.at_least = Some(threshold);
80        self
81    }
82
83    /// Write the decided value into `key`.
84    #[must_use]
85    pub fn writes(mut self, key: impl Into<String>) -> Self {
86        self.writes = Some(key.into());
87        self
88    }
89
90    /// Record `answer` to question `id` for caller turn `turn` as if a
91    /// decision model gave it with full certainty, writing what a live ask
92    /// writes: `true`/`false` for a boolean, an option's key for a choice
93    /// ([`NONE_OPTION`] for none), a number for a score, anything else for
94    /// unsure. For scripted conversations: offline scenarios and tests.
95    pub fn record_scripted(&self, id: &str, answer: &Value, state: &State, turn: &str) {
96        let (_, values) = resolve(self, state);
97        let answer = match answer {
98            Value::Bool(b) => Some(Answer::boolean(if *b { 1.0 } else { 0.0 })),
99            Value::String(o) => Some(Answer::choice(o.clone(), 1.0)),
100            Value::Number(n) => n.as_f64().map(|n| Answer::score(n, 1.0)),
101            _ => None,
102        };
103        let (outcome, value, confidence, write) = decide(self, answer.as_ref(), &values);
104        record(state, id, outcome, value, confidence, turn);
105        if let (Some(key), Some(write)) = (&self.writes, write) {
106            let _ = state.set(key, write);
107        }
108    }
109
110    fn threshold(&self) -> f64 {
111        let default = match self.question {
112            Question::Boolean { .. } => DEFAULT_BOOLEAN_THRESHOLD,
113            _ => DEFAULT_CERTAINTY_THRESHOLD,
114        };
115        self.at_least.unwrap_or(default)
116    }
117}
118
119/// The conversation a decision model reads: caller and agent lines and tool
120/// results in order, the facts the active stages ground the model in, and
121/// which stages are active.
122#[derive(Debug, Clone, Default, PartialEq)]
123pub struct Conversation {
124    entries: Vec<Value>,
125    context: Vec<String>,
126    active_stages: Vec<String>,
127    turn: String,
128}
129
130impl Conversation {
131    /// The conversation in `turns`, oldest first. Each turn is the caller's
132    /// words, then its tool calls, then the agent's reply.
133    pub fn from_turns(turns: &[TranscriptTurn]) -> Self {
134        let mut entries = Vec::new();
135        let mut turn = String::new();
136        for t in turns {
137            let caller = t.user.trim();
138            if !caller.is_empty() {
139                entries.push(json!({ "caller": caller }));
140                let mut h = std::collections::hash_map::DefaultHasher::new();
141                caller.hash(&mut h);
142                turn = format!("{}:{:x}", t.turn_number, h.finish());
143            }
144            for call in &t.tool_calls {
145                let result = serde_json::from_str::<Value>(&call.result_summary)
146                    .unwrap_or_else(|_| Value::String(call.result_summary.clone()));
147                entries.push(json!({ "tool": call.name, "result": result }));
148            }
149            let agent = t.model.trim();
150            if !agent.is_empty() {
151                entries.push(json!({ "agent": agent }));
152            }
153        }
154        Self {
155            entries,
156            turn,
157            ..Self::default()
158        }
159    }
160
161    /// Add the facts the active stages ground the model in.
162    #[must_use]
163    pub fn with_context<S: Into<String>>(mut self, lines: impl IntoIterator<Item = S>) -> Self {
164        self.context = lines.into_iter().map(Into::into).collect();
165        self
166    }
167
168    /// Add the active stages' ids.
169    #[must_use]
170    pub fn with_active_stages<S: Into<String>>(
171        mut self,
172        stages: impl IntoIterator<Item = S>,
173    ) -> Self {
174        self.active_stages = stages.into_iter().map(Into::into).collect();
175        self
176    }
177
178    /// The caller turn the latest caller words belong to: the turn's number
179    /// and a hash of the words. Empty when the caller has not spoken.
180    pub fn turn(&self) -> &str {
181        &self.turn
182    }
183
184    /// Make this conversation's caller turn the one answers count for, so an
185    /// answer about an earlier turn stops satisfying guards. Call it at every
186    /// decision point, including one where nothing is asked.
187    pub fn begin_turn(&self, state: &State) {
188        if state.get::<String>(DECISION_TURN_KEY).as_deref() != Some(self.turn.as_str()) {
189            let _ = state.set(DECISION_TURN_KEY, self.turn.clone());
190        }
191    }
192
193    /// Whether the caller has said anything.
194    pub fn has_caller(&self) -> bool {
195        !self.turn.is_empty()
196    }
197
198    /// The state sent to the decision model.
199    pub fn to_state(&self) -> Value {
200        let mut out = json!({ "conversation": self.entries });
201        if !self.context.is_empty() {
202            out["context"] = json!(self.context);
203        }
204        if !self.active_stages.is_empty() {
205            out["active_stages"] = json!(self.active_stages);
206        }
207        out
208    }
209}
210
211/// What one ask did.
212#[derive(Debug, Clone, Default)]
213pub struct Round {
214    /// Questions sent to the model.
215    pub asked: Vec<String>,
216    /// Questions already answered for this caller turn.
217    pub reused: Vec<String>,
218    /// How long the model took.
219    pub latency: Duration,
220    /// Why the model could not answer. Its questions were recorded unsure.
221    pub error: Option<String>,
222}
223
224/// A bank of questions asked of a decision model against the rolling
225/// conversation.
226///
227/// Guards name the questions (`{"decided": "confirmed"}`, see
228/// [`crate::flow::decided`]); the runtime asks the ones the flow can act on at
229/// each decision point, in one request, and records each answer under
230/// `decision:{id}` with the caller turn it is about. An answer counts only
231/// for that turn, so nothing is latched and nothing has to be cleared when
232/// the caller corrects themselves: the next ask reads the correction.
233///
234/// ```
235/// use std::sync::Arc;
236/// use gemini_adk_rs::decision::{Answer, Decision, Decisions, MockDecisionModel, Question};
237///
238/// let model = Arc::new(MockDecisionModel::new(|_| {
239///     Ok([("confirmed".to_string(), Answer::boolean(0.97))].into())
240/// }));
241/// let decisions = Decisions::new(model).question(
242///     "confirmed",
243///     Decision::new(Question::boolean(
244///         "In their last turn, did the caller agree to the booking that was read back?",
245///     )),
246/// );
247/// assert!(decisions.get("confirmed").is_some());
248/// ```
249pub struct Decisions {
250    model: Arc<dyn DecisionModel>,
251    bank: BTreeMap<String, Decision>,
252    standing: BTreeSet<String>,
253    history_turns: usize,
254    timeout: Duration,
255}
256
257impl std::fmt::Debug for Decisions {
258    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259        f.debug_struct("Decisions")
260            .field("model", &self.model.model_id())
261            .field("questions", &self.bank.keys().collect::<Vec<_>>())
262            .field("standing", &self.standing)
263            .field("history_turns", &self.history_turns)
264            .field("timeout", &self.timeout)
265            .finish_non_exhaustive()
266    }
267}
268
269impl Decisions {
270    /// An empty bank over `model`.
271    pub fn new(model: Arc<dyn DecisionModel>) -> Self {
272        Self {
273            model,
274            bank: BTreeMap::new(),
275            standing: BTreeSet::new(),
276            history_turns: DEFAULT_HISTORY_TURNS,
277            timeout: DEFAULT_DECISION_TIMEOUT,
278        }
279    }
280
281    /// Declare a question.
282    #[must_use]
283    pub fn question(mut self, id: impl Into<String>, decision: Decision) -> Self {
284        self.bank.insert(id.into(), decision);
285        self
286    }
287
288    /// Ask these questions at every caller turn, whatever the flow needs:
289    /// questions read outside the flow, by phase transitions or patterns.
290    #[must_use]
291    pub fn standing<S: Into<String>>(mut self, ids: impl IntoIterator<Item = S>) -> Self {
292        self.standing.extend(ids.into_iter().map(Into::into));
293        self
294    }
295
296    /// How many turns the decision model reads (default 40).
297    #[must_use]
298    pub fn history_turns(mut self, turns: usize) -> Self {
299        self.history_turns = turns.max(1);
300        self
301    }
302
303    /// How long an ask waits for the model (default 2 s). A slow or failed
304    /// ask records its questions unsure, so guards on them do not hold.
305    #[must_use]
306    pub fn with_timeout(mut self, timeout: Duration) -> Self {
307        self.timeout = timeout;
308        self
309    }
310
311    /// The declared questions.
312    pub fn bank(&self) -> &BTreeMap<String, Decision> {
313        &self.bank
314    }
315
316    /// One declared question.
317    pub fn get(&self, id: &str) -> Option<&Decision> {
318        self.bank.get(id)
319    }
320
321    /// How many turns the decision model reads.
322    pub fn history_len(&self) -> usize {
323        self.history_turns
324    }
325
326    /// The model's identifier.
327    pub fn model_id(&self) -> &str {
328        self.model.model_id()
329    }
330
331    /// The questions worth asking at a turn's end for `scope`: the declared
332    /// ones it names, the standing ones, and those that write a key it reads.
333    pub fn select(&self, scope: &DecisionScope) -> BTreeSet<String> {
334        let mut ids = self.governing(scope);
335        ids.extend(
336            self.standing
337                .iter()
338                .filter(|id| self.bank.contains_key(*id))
339                .cloned(),
340        );
341        ids
342    }
343
344    /// The questions `scope` itself depends on: the declared ones it names
345    /// and those that write a key it reads, without the standing ones. What
346    /// a tool call waits for before it is admitted.
347    pub fn governing(&self, scope: &DecisionScope) -> BTreeSet<String> {
348        self.bank
349            .iter()
350            .filter(|(id, d)| {
351                scope.questions.contains(*id)
352                    || d.writes.as_ref().is_some_and(|k| scope.reads.contains(k))
353            })
354            .map(|(id, _)| id.clone())
355            .collect()
356    }
357
358    /// Ask `ids` about `conversation` and record the answers in `state`.
359    ///
360    /// A question already answered in `state` for the conversation's caller
361    /// turn is not asked again; one recorded unsure because the model could
362    /// not be asked (or had no options to offer) is. With no caller words
363    /// yet, nothing is asked. The bank holds no session state, so one bank
364    /// can serve many sessions.
365    pub async fn ask(
366        &self,
367        ids: &BTreeSet<String>,
368        conversation: &Conversation,
369        state: &State,
370    ) -> Round {
371        let mut round = Round::default();
372        let turn = conversation.turn().to_string();
373        conversation.begin_turn(state);
374        if !conversation.has_caller() {
375            return round;
376        }
377
378        let mut questions = BTreeMap::new();
379        let mut options: BTreeMap<String, BTreeMap<String, Value>> = BTreeMap::new();
380        for id in ids {
381            let Some(decision) = self.bank.get(id) else {
382                continue;
383            };
384            let answered = DecisionRecord::current(state, id)
385                .is_some_and(|r| r.outcome != Outcome::Unsure || r.confidence.is_some());
386            if answered {
387                round.reused.push(id.clone());
388                continue;
389            }
390            let (question, values) = resolve(decision, state);
391            if question.validate().is_err() {
392                // No options yet (nothing offered): unsure, not stale.
393                record(state, id, Outcome::Unsure, Value::Null, None, &turn);
394                continue;
395            }
396            questions.insert(id.clone(), question);
397            options.insert(id.clone(), values);
398        }
399        if questions.is_empty() {
400            return round;
401        }
402        round.asked = questions.keys().cloned().collect();
403
404        let started = Instant::now();
405        let request = DecisionRequest {
406            state: conversation.to_state(),
407            questions,
408            provider_options: None,
409        };
410        let response = tokio::time::timeout(self.timeout, self.model.decide(request)).await;
411        round.latency = started.elapsed();
412        let response = match response {
413            Ok(Ok(r)) => r,
414            failed => {
415                let error = match failed {
416                    Ok(Err(e)) => e.to_string(),
417                    _ => format!("no answer within {} ms", self.timeout.as_millis()),
418                };
419                tracing::warn!(model = %self.model.model_id(), asked = ?round.asked, "decision round failed: {error}");
420                for id in &round.asked {
421                    record(state, id, Outcome::Unsure, Value::Null, None, &turn);
422                }
423                round.error = Some(error);
424                return round;
425            }
426        };
427
428        let mut outcomes = Vec::new();
429        for id in &round.asked {
430            let decision = &self.bank[id];
431            let (outcome, value, confidence, write) =
432                decide(decision, response.answers.get(id), &options[id]);
433            record(state, id, outcome, value, confidence, &turn);
434            if let (Some(key), Some(write)) = (&decision.writes, write) {
435                let _ = state.set(key, write);
436            }
437            outcomes.push(format!("{id}={outcome:?}"));
438        }
439        tracing::info!(
440            model = %response.model,
441            ms = round.latency.as_millis() as u64,
442            outcomes = %outcomes.join(" "),
443            "decisions"
444        );
445        round
446    }
447}
448
449/// The question as asked now (options read from state, "none of these"
450/// added) and each option key's value.
451fn resolve(decision: &Decision, state: &State) -> (Question, BTreeMap<String, Value>) {
452    let mut question = decision.question.clone();
453    let mut values = BTreeMap::new();
454    if let Question::Choice { criteria, .. } = &mut question {
455        if let Some(path) = &decision.options_from {
456            let found = read_path(state, path)
457                .and_then(|v| v.as_array().cloned())
458                .unwrap_or_default();
459            if !found.is_empty() {
460                criteria.clear();
461            }
462            for option in found.into_iter().take(super::MAX_CHOICE_OPTIONS) {
463                let key = option_key(&option);
464                let description = match &option {
465                    Value::String(s) => s.clone(),
466                    other => other.to_string(),
467                };
468                criteria.insert(key.clone(), description);
469                values.insert(key, option);
470            }
471        }
472        if let Some(none) = &decision.none
473            && !criteria.is_empty()
474        {
475            criteria.insert(NONE_OPTION.to_string(), none.clone());
476        }
477    }
478    (question, values)
479}
480
481/// How an answer comes out: the outcome, the recorded value, the confidence,
482/// and the value to write.
483fn decide(
484    decision: &Decision,
485    answer: Option<&Answer>,
486    values: &BTreeMap<String, Value>,
487) -> (Outcome, Value, Option<f64>, Option<Value>) {
488    let t = decision.threshold();
489    match answer {
490        Some(Answer::Boolean { probability: p }) => {
491            if *p >= t {
492                (Outcome::Yes, Value::Null, Some(*p), Some(json!(true)))
493            } else if *p <= 1.0 - t {
494                (Outcome::No, Value::Null, Some(*p), Some(json!(false)))
495            } else {
496                (Outcome::Unsure, Value::Null, Some(*p), None)
497            }
498        }
499        Some(a @ Answer::Choice { choice, .. }) => {
500            let c = a.certainty();
501            if c.unwrap_or(0.0) < t {
502                (Outcome::Unsure, json!(choice), c, None)
503            } else if choice == NONE_OPTION {
504                (Outcome::None, Value::Null, c, None)
505            } else {
506                let write = values.get(choice).cloned().unwrap_or_else(|| json!(choice));
507                (Outcome::Chosen, json!(choice), c, Some(write))
508            }
509        }
510        Some(a @ Answer::Score { score, .. }) => {
511            let c = a.certainty();
512            if c.unwrap_or(0.0) >= t {
513                (Outcome::Scored, json!(score), c, Some(json!(score)))
514            } else {
515                (Outcome::Unsure, json!(score), c, None)
516            }
517        }
518        Some(Answer::Refusal(_) | Answer::Other(_)) | None => {
519            (Outcome::Unsure, Value::Null, None, None)
520        }
521    }
522}
523
524fn record(
525    state: &State,
526    id: &str,
527    outcome: Outcome,
528    value: Value,
529    confidence: Option<f64>,
530    turn: &str,
531) {
532    let record = DecisionRecord {
533        outcome,
534        value,
535        confidence,
536        turn: turn.to_string(),
537    };
538    if let Ok(v) = serde_json::to_value(record) {
539        let _ = state.set(decision_key(id), v);
540    }
541}
542
543/// An option's key: its own text for a string or number; for an object, its
544/// `id`, `value` or `name` field, else its JSON text.
545fn option_key(option: &Value) -> String {
546    match option {
547        Value::String(s) => s.clone(),
548        Value::Object(m) => ["id", "value", "name"]
549            .iter()
550            .find_map(|k| m.get(*k))
551            .map_or_else(
552                || option.to_string(),
553                |v| v.as_str().map_or_else(|| v.to_string(), str::to_string),
554            ),
555        other => other.to_string(),
556    }
557}
558
559/// Read `key` or `key.field.0` from state.
560fn read_path(state: &State, path: &str) -> Option<Value> {
561    let mut parts = path.split('.');
562    let mut value = state.get_raw(parts.next()?)?;
563    for part in parts {
564        value = match value {
565            Value::Object(mut m) => m.remove(part)?,
566            Value::Array(mut a) => {
567                let i: usize = part.parse().ok()?;
568                if i < a.len() {
569                    a.swap_remove(i)
570                } else {
571                    return None;
572                }
573            }
574            _ => return None,
575        };
576    }
577    Some(value)
578}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use crate::decision::MockDecisionModel;
584    use crate::flow::{Decided, DecisionRecord, Expect};
585
586    fn turn(n: u32, user: &str, model: &str) -> TranscriptTurn {
587        TranscriptTurn {
588            turn_number: n,
589            user: user.into(),
590            model: model.into(),
591            tool_calls: Vec::new(),
592            timestamp: std::time::Instant::now(),
593        }
594    }
595
596    fn holds(state: &State, q: &str, expect: Expect) -> bool {
597        Decided {
598            question: q.into(),
599            expect,
600        }
601        .holds(state)
602    }
603
604    fn answering(answers: Vec<(&'static str, Answer)>) -> Arc<MockDecisionModel> {
605        Arc::new(MockDecisionModel::new(move |_| {
606            Ok(answers
607                .iter()
608                .map(|(k, a)| (k.to_string(), a.clone()))
609                .collect())
610        }))
611    }
612
613    fn ids(list: &[&str]) -> BTreeSet<String> {
614        list.iter().map(ToString::to_string).collect()
615    }
616
617    fn confirm() -> Decision {
618        Decision::new(Question::boolean("Did the caller agree to the booking?"))
619    }
620
621    #[tokio::test]
622    async fn an_answer_is_recorded_for_its_turn_and_asked_once_per_turn() {
623        let model = answering(vec![("confirmed", Answer::boolean(0.97))]);
624        let decisions = Decisions::new(model.clone()).question("confirmed", confirm());
625        let state = State::new();
626        let convo = Conversation::from_turns(&[
627            turn(0, "", "Four at seven under Rossi. Shall I book it?"),
628            turn(1, "Yes, book it.", ""),
629        ]);
630        let round = decisions.ask(&ids(&["confirmed"]), &convo, &state).await;
631        assert_eq!(round.asked, ["confirmed"]);
632        assert!(holds(&state, "confirmed", Expect::Yes));
633
634        let again = decisions.ask(&ids(&["confirmed"]), &convo, &state).await;
635        assert_eq!(
636            again.reused,
637            ["confirmed"],
638            "same caller turn: not asked again"
639        );
640        assert_eq!(model.requests().len(), 1);
641
642        // The caller speaks again: the old yes no longer counts until asked.
643        let next = Conversation::from_turns(&[
644            turn(1, "Yes, book it.", "Before I do, any allergies?"),
645            turn(2, "Hmm, wait.", ""),
646        ]);
647        let _ = state.set(DECISION_TURN_KEY, next.turn());
648        assert!(!holds(&state, "confirmed", Expect::Yes));
649    }
650
651    #[tokio::test]
652    async fn the_model_reads_the_whole_conversation_with_context() {
653        let model = answering(vec![("confirmed", Answer::boolean(0.5))]);
654        let decisions = Decisions::new(model.clone()).question("confirmed", confirm());
655        let state = State::new();
656        let mut asked = turn(1, "Four at seven.", "Seven is free. Book it?");
657        asked
658            .tool_calls
659            .push(crate::live::transcript::ToolCallSummary {
660                name: "check_availability".into(),
661                args_summary: "{}".into(),
662                result_summary: r#"{"options":["19:00"]}"#.into(),
663            });
664        let convo = Conversation::from_turns(&[turn(0, "", "Hello."), asked, turn(2, "Yes.", "")])
665            .with_context(["Party of 4 at 19:00."])
666            .with_active_stages(["confirm"]);
667        decisions.ask(&ids(&["confirmed"]), &convo, &state).await;
668        assert_eq!(
669            model.requests()[0].state,
670            json!({
671                "conversation": [
672                    { "agent": "Hello." },
673                    { "caller": "Four at seven." },
674                    { "tool": "check_availability", "result": { "options": ["19:00"] } },
675                    { "agent": "Seven is free. Book it?" },
676                    { "caller": "Yes." }
677                ],
678                "context": ["Party of 4 at 19:00."],
679                "active_stages": ["confirm"]
680            })
681        );
682        let record: DecisionRecord = state.get(&decision_key("confirmed")).unwrap();
683        assert_eq!(record.outcome, Outcome::Unsure, "0.5 is neither yes nor no");
684    }
685
686    #[tokio::test]
687    async fn a_pick_writes_its_option_and_none_writes_nothing() {
688        let model = answering(vec![("slot", Answer::choice("2026-10-20T09:00", 0.9))]);
689        let decisions = Decisions::new(model).question(
690            "slot",
691            Decision::new(Question::choice::<&str, &str>("Which offered time?", []))
692                .options_from("availability.slots")
693                .or_none("not picked yet")
694                .writes("slot_choice"),
695        );
696        let state = State::new();
697        let _ = state.set(
698            "availability",
699            json!({ "slots": ["2026-10-20T09:00", "2026-10-21T10:00"] }),
700        );
701        let convo = Conversation::from_turns(&[turn(0, "The first one.", "")]);
702        decisions.ask(&ids(&["slot"]), &convo, &state).await;
703        assert_eq!(
704            state.get_raw("slot_choice"),
705            Some(json!("2026-10-20T09:00"))
706        );
707        assert!(holds(&state, "slot", Expect::Is("2026-10-20T09:00".into())));
708
709        let model = answering(vec![("slot", Answer::choice(NONE_OPTION, 0.95))]);
710        let decisions = Decisions::new(model).question(
711            "slot",
712            Decision::new(Question::choice::<&str, &str>("Which offered time?", []))
713                .options_from("availability.slots")
714                .or_none("not picked yet")
715                .writes("other"),
716        );
717        let state = State::new();
718        let _ = state.set(
719            "availability",
720            json!({ "slots": ["2026-10-20T09:00", "2026-10-21T10:00"] }),
721        );
722        decisions.ask(&ids(&["slot"]), &convo, &state).await;
723        assert_eq!(state.get_raw("other"), None);
724        assert!(holds(&state, "slot", Expect::No));
725    }
726
727    #[tokio::test]
728    async fn without_options_a_pick_is_unsure_and_not_asked() {
729        let model = answering(vec![]);
730        let decisions = Decisions::new(model.clone()).question(
731            "slot",
732            Decision::new(Question::choice::<&str, &str>("Which offered time?", []))
733                .options_from("availability.slots"),
734        );
735        let state = State::new();
736        let convo = Conversation::from_turns(&[turn(0, "Something next week.", "")]);
737        let round = decisions.ask(&ids(&["slot"]), &convo, &state).await;
738        assert!(round.asked.is_empty());
739        assert!(model.requests().is_empty());
740        let record: DecisionRecord = state.get(&decision_key("slot")).unwrap();
741        assert_eq!(record.outcome, Outcome::Unsure);
742    }
743
744    #[tokio::test]
745    async fn a_failed_round_fails_closed() {
746        let failing = Arc::new(MockDecisionModel::new(|_| {
747            Err(crate::llm::LlmError::Api {
748                status: 503,
749                message: "busy".into(),
750            })
751        }));
752        let decisions = Decisions::new(failing).question("confirmed", confirm());
753        let state = State::new();
754        let convo = Conversation::from_turns(&[turn(0, "Yes.", "")]);
755        let round = decisions.ask(&ids(&["confirmed"]), &convo, &state).await;
756        assert!(round.error.is_some());
757        assert!(!holds(&state, "confirmed", Expect::Yes));
758        assert!(!holds(&state, "confirmed", Expect::No));
759
760        let slow = Arc::new(
761            MockDecisionModel::new(|_| {
762                Ok([("confirmed".to_string(), Answer::boolean(0.99))].into())
763            })
764            .with_latency(Duration::from_millis(200)),
765        );
766        let decisions = Decisions::new(slow)
767            .question("confirmed", confirm())
768            .with_timeout(Duration::from_millis(20));
769        let round = decisions.ask(&ids(&["confirmed"]), &convo, &state).await;
770        assert!(round.error.unwrap().contains("no answer within 20 ms"));
771        assert!(!holds(&state, "confirmed", Expect::Yes));
772
773        // A question the model could not answer is asked again on the same
774        // turn, at the next decision point.
775        let model = answering(vec![("confirmed", Answer::boolean(0.97))]);
776        let decisions = Decisions::new(model).question("confirmed", confirm());
777        let round = decisions.ask(&ids(&["confirmed"]), &convo, &state).await;
778        assert_eq!(round.asked, ["confirmed"]);
779        assert!(holds(&state, "confirmed", Expect::Yes));
780    }
781
782    #[test]
783    fn a_new_caller_turn_retires_earlier_answers_even_when_nothing_is_asked() {
784        let state = State::new();
785        let first = Conversation::from_turns(&[turn(0, "Yes.", "")]);
786        first.begin_turn(&state);
787        record(
788            &state,
789            "confirmed",
790            Outcome::Yes,
791            Value::Null,
792            None,
793            first.turn(),
794        );
795        assert!(holds(&state, "confirmed", Expect::Yes));
796
797        let next = Conversation::from_turns(&[turn(0, "Yes.", "Booked?"), turn(1, "Wait.", "")]);
798        next.begin_turn(&state);
799        assert!(!holds(&state, "confirmed", Expect::Yes));
800
801        // The same turn again writes nothing.
802        let cursor = state.mutation_cursor();
803        next.begin_turn(&state);
804        assert_eq!(state.mutation_cursor(), cursor);
805    }
806
807    #[tokio::test]
808    async fn one_bank_serves_many_sessions() {
809        let model = Arc::new(MockDecisionModel::new(|request| {
810            let said = request.state["conversation"][0]["caller"].as_str() == Some("Yes.");
811            Ok([(
812                "confirmed".to_string(),
813                Answer::boolean(if said { 0.97 } else { 0.03 }),
814            )]
815            .into())
816        }));
817        let decisions = Decisions::new(model.clone()).question("confirmed", confirm());
818        let (a, b) = (State::new(), State::new());
819        let yes = Conversation::from_turns(&[turn(0, "Yes.", "")]);
820        let no = Conversation::from_turns(&[turn(0, "No.", "")]);
821        decisions.ask(&ids(&["confirmed"]), &yes, &a).await;
822        let round = decisions.ask(&ids(&["confirmed"]), &no, &b).await;
823        assert_eq!(round.asked, ["confirmed"], "b's turn is its own");
824        assert!(holds(&a, "confirmed", Expect::Yes));
825        assert!(holds(&b, "confirmed", Expect::No));
826        assert_eq!(model.requests().len(), 2);
827    }
828
829    #[tokio::test]
830    async fn a_score_split_between_adjacent_levels_is_decided() {
831        let split = Answer::Score {
832            score: 2.5,
833            probabilities: [("0", 0.0), ("1", 0.0), ("2", 0.5), ("3", 0.5)]
834                .into_iter()
835                .map(|(k, p)| (k.to_string(), p))
836                .collect(),
837            confidence: Some(0.5),
838        };
839        let model = answering(vec![("anger", split)]);
840        let decisions = Decisions::new(model).question(
841            "anger",
842            Decision::new(Question::score(
843                "How angry?",
844                ["calm", "a bit", "upset", "angry"],
845            )),
846        );
847        let state = State::new();
848        let convo = Conversation::from_turns(&[turn(0, "This is the third time!", "")]);
849        decisions.ask(&ids(&["anger"]), &convo, &state).await;
850        assert!(holds(&state, "anger", Expect::AtLeast(2.0)));
851    }
852
853    #[test]
854    fn selection_takes_named_standing_and_written_questions() {
855        let decisions = Decisions::new(answering(vec![]))
856            .question("confirmed", confirm())
857            .question("anger", Decision::new(Question::score("?", ["a", "b"])))
858            .question(
859                "slot",
860                Decision::new(Question::choice("?", [("a", "a")])).writes("slot_choice"),
861            )
862            .question("unused", confirm())
863            .standing(["anger"]);
864        let scope = DecisionScope {
865            questions: ids(&["confirmed", "unknown"]),
866            reads: ids(&["slot_choice"]),
867        };
868        assert_eq!(
869            decisions.select(&scope),
870            ids(&["anger", "confirmed", "slot"])
871        );
872    }
873}