1use 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
16pub const DEFAULT_BOOLEAN_THRESHOLD: f64 = 0.85;
19pub const DEFAULT_CERTAINTY_THRESHOLD: f64 = 0.6;
22pub const DEFAULT_DECISION_TIMEOUT: Duration = Duration::from_secs(2);
24pub const DEFAULT_HISTORY_TURNS: usize = 40;
26pub const NONE_OPTION: &str = "none_of_these";
28
29#[derive(Debug, Clone, PartialEq)]
31pub struct Decision {
32 pub question: Question,
34 pub options_from: Option<String>,
38 pub none: Option<String>,
42 pub at_least: Option<f64>,
44 pub writes: Option<String>,
48}
49
50impl Decision {
51 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 #[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 #[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 #[must_use]
78 pub fn at_least(mut self, threshold: f64) -> Self {
79 self.at_least = Some(threshold);
80 self
81 }
82
83 #[must_use]
85 pub fn writes(mut self, key: impl Into<String>) -> Self {
86 self.writes = Some(key.into());
87 self
88 }
89
90 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#[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 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 #[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 #[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 pub fn turn(&self) -> &str {
181 &self.turn
182 }
183
184 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 pub fn has_caller(&self) -> bool {
195 !self.turn.is_empty()
196 }
197
198 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#[derive(Debug, Clone, Default)]
213pub struct Round {
214 pub asked: Vec<String>,
216 pub reused: Vec<String>,
218 pub latency: Duration,
220 pub error: Option<String>,
222}
223
224pub 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 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 #[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 #[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 #[must_use]
298 pub fn history_turns(mut self, turns: usize) -> Self {
299 self.history_turns = turns.max(1);
300 self
301 }
302
303 #[must_use]
306 pub fn with_timeout(mut self, timeout: Duration) -> Self {
307 self.timeout = timeout;
308 self
309 }
310
311 pub fn bank(&self) -> &BTreeMap<String, Decision> {
313 &self.bank
314 }
315
316 pub fn get(&self, id: &str) -> Option<&Decision> {
318 self.bank.get(id)
319 }
320
321 pub fn history_len(&self) -> usize {
323 self.history_turns
324 }
325
326 pub fn model_id(&self) -> &str {
328 self.model.model_id()
329 }
330
331 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 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 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 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
449fn 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
481fn 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
543fn 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
559fn 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 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 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 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}