gemini_adk_fluent_rs/
simulation.rs

1//! Model-free conversation simulation.
2//!
3//! A deterministic harness that drives a [`CompiledConversation`] without any live
4//! API: a **fake user** supplies utterances (run through the conversation's
5//! recognizers) or sets slots directly, tools succeed on demand or after a
6//! latency, and the [`FlowStack`] advances turn by turn. Everything is driven by
7//! `State` + guards, so motifs, repair, policies, and digressions become testable
8//! in CI — "a flow SDK with simulation is infra; without it, a demo framework".
9//!
10//! ```no_run
11//! # use gemini_adk_fluent_rs::prelude::*;
12//! # use gemini_adk_fluent_rs::conversation::Conversation;
13//! # use gemini_adk_fluent_rs::simulation::Sim;
14//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
15//! let convo = Conversation::new("booking")
16//!     .stage("check").collect(["party_size", "slot"])
17//!         .next("confirm", Guard::captured(["party_size", "slot"]))
18//!     .stage("confirm").commit("book", Guard::is_true("user_confirmed"))
19//!         .next("done", Guard::called_ok("book"))
20//!     .stage("done").terminal()
21//!     .require(["done"])
22//!     .compile()?;
23//! let mut sim = Sim::new(&convo, Enforcement::Enforce);
24//! sim.user("a table for 4 tomorrow at 7pm").await;
25//! assert!(sim.active().contains(&"check".to_string()));
26//! assert!(!sim.allowed("book"));            // not confirmed yet
27//! sim.set("user_confirmed", true);
28//! sim.tool_ok("book");
29//! assert!(sim.is_complete());
30//! # Ok(())
31//! # }
32//! ```
33
34use std::collections::BTreeMap;
35use std::sync::Arc;
36use std::time::Instant;
37
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40
41use gemini_adk_rs::flow::{Enforcement, FlowExplanation};
42use gemini_adk_rs::live::{TranscriptTurn, TurnExtractor};
43use gemini_adk_rs::state::State;
44
45use crate::conversation::{CompiledConversation, FlowStack};
46
47struct BoundExtractor {
48    extractor: Arc<dyn TurnExtractor>,
49    /// (field name, state key) — how to promote the returned record into `State`.
50    fields: Vec<(String, String)>,
51}
52
53/// A deterministic, model-free driver for a compiled conversation.
54pub struct Sim {
55    stack: FlowStack,
56    extractors: Vec<BoundExtractor>,
57    state: State,
58    turn_no: u32,
59    /// Tools scheduled to succeed at a future turn — models tool latency.
60    pending_tools: Vec<(String, u32)>,
61}
62
63impl Sim {
64    /// Build a simulator over `convo` in the given enforcement mode.
65    pub fn new(convo: &CompiledConversation, mode: Enforcement) -> Self {
66        let extractors = convo
67            .all_extractors()
68            .into_iter()
69            .map(|e| BoundExtractor {
70                fields: e.field_state_keys(),
71                extractor: e.into_extractor(),
72            })
73            .collect();
74        Self {
75            stack: convo.stack(mode),
76            extractors,
77            state: State::new(),
78            turn_no: 0,
79            pending_tools: Vec::new(),
80        }
81    }
82
83    /// Set a state value directly (information a recognizer can't supply, or a
84    /// scripted shortcut). Does not advance a turn.
85    pub fn set(&self, key: impl Into<String>, value: impl Serialize) -> &Self {
86        let _ = self.state.set(key, value);
87        self
88    }
89
90    /// The fake user speaks: run the conversation's extractors over the utterance
91    /// to fill slots (respecting validators), then advance a turn.
92    pub async fn user(&mut self, utterance: &str) -> &mut Self {
93        let window = [TranscriptTurn {
94            turn_number: self.turn_no,
95            user: utterance.to_string(),
96            model: String::new(),
97            tool_calls: Vec::new(),
98            timestamp: Instant::now(),
99        }];
100        for bound in &self.extractors {
101            if let Ok(Value::Object(obj)) = bound
102                .extractor
103                .extract_with_state(&window, &self.state)
104                .await
105            {
106                for (name, key) in &bound.fields {
107                    if let Some(v) = obj.get(name)
108                        && !v.is_null()
109                    {
110                        let _ = self.state.set(key.clone(), v.clone());
111                    }
112                }
113            }
114        }
115        self.advance();
116        self
117    }
118
119    /// Advance a turn with no new user input (e.g. waiting on a tool/resolver).
120    pub fn turn(&mut self) -> &mut Self {
121        self.advance();
122        self
123    }
124
125    /// A tool succeeds now; records it and advances a turn (processing any
126    /// digression resume).
127    pub fn tool_ok(&mut self, tool: &str) -> &mut Self {
128        self.stack.on_tool_ok(tool, &self.state);
129        self.advance();
130        self
131    }
132
133    /// Schedule a tool to succeed `after` turns — models tool latency.
134    pub fn schedule_tool(&mut self, tool: impl Into<String>, after: u32) -> &mut Self {
135        self.pending_tools
136            .push((tool.into(), self.turn_no + after.max(1)));
137        self
138    }
139
140    fn advance(&mut self) {
141        self.turn_no += 1;
142        // Fire any tools whose latency has elapsed.
143        let due: Vec<String> = self
144            .pending_tools
145            .iter()
146            .filter(|(_, at)| *at <= self.turn_no)
147            .map(|(t, _)| t.clone())
148            .collect();
149        self.pending_tools.retain(|(_, at)| *at > self.turn_no);
150        for tool in due {
151            self.stack.on_tool_ok(&tool, &self.state);
152        }
153        self.stack.on_turn(&self.state);
154    }
155
156    /// Active step ids in the currently-driving layer.
157    pub fn active(&self) -> Vec<String> {
158        self.stack.explain(&self.state).active
159    }
160
161    /// The active digression, if one is suspending the main flow.
162    pub fn active_overlay(&self) -> Option<&str> {
163        self.stack.active_overlay()
164    }
165
166    /// Whether `tool` is admitted right now.
167    pub fn allowed(&self, tool: &str) -> bool {
168        self.stack.admits_tool(tool, &self.state).is_ok()
169    }
170
171    /// Currently-blocked tools, mapped to the reason.
172    pub fn denied(&self) -> BTreeMap<String, String> {
173        self.stack.explain(&self.state).blocked_tools
174    }
175
176    /// Whether the conversation is complete.
177    pub fn is_complete(&self) -> bool {
178        self.stack.is_complete()
179    }
180
181    /// Read a slot value.
182    pub fn slot<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
183        self.state.get(key)
184    }
185
186    /// The active layer's control-plane explanation.
187    pub fn explain(&self) -> FlowExplanation {
188        self.stack.explain(&self.state)
189    }
190
191    /// The simulation state (for custom assertions / slot evidence).
192    pub fn state(&self) -> &State {
193        &self.state
194    }
195}
196
197/// One step in a serializable [`Scenario`].
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum SimStep {
201    /// The fake user speaks (recognizers fill slots), then a turn advances.
202    User(String),
203    /// Set a state value directly.
204    Set {
205        /// State key.
206        key: String,
207        /// Value to store.
208        value: Value,
209    },
210    /// A tool succeeds now.
211    ToolOk(String),
212    /// Schedule a tool to succeed after N turns (latency).
213    ScheduleTool {
214        /// Tool name.
215        tool: String,
216        /// Turns to wait.
217        after: u32,
218    },
219    /// Advance a turn with no input.
220    Turn,
221    /// Assert these step ids are active.
222    ExpectActive(Vec<String>),
223    /// Assert a tool is currently blocked.
224    ExpectDenied(String),
225    /// Assert a tool is currently admitted.
226    ExpectAllowed(String),
227    /// Assert a slot equals a value.
228    ExpectSlot {
229        /// State key.
230        key: String,
231        /// Expected value.
232        value: Value,
233    },
234    /// Assert the conversation is complete.
235    ExpectComplete,
236}
237
238/// A serializable simulation script — a deterministic, model-free test case that
239/// can be authored in code or loaded from YAML/JSON.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct Scenario {
242    /// Scenario name (for diagnostics).
243    pub name: String,
244    /// The steps to execute, in order.
245    pub steps: Vec<SimStep>,
246}
247
248impl Scenario {
249    /// Run the scenario against `convo`. Returns `Ok(())` if every `Expect*` step
250    /// holds, else `Err` with the failing step index and a diagnostic.
251    pub async fn run(&self, convo: &CompiledConversation, mode: Enforcement) -> Result<(), String> {
252        let mut sim = Sim::new(convo, mode);
253        for (i, step) in self.steps.iter().enumerate() {
254            let fail = |msg: String| Err(format!("[{}] step {i} ({step:?}): {msg}", self.name));
255            match step {
256                SimStep::User(text) => {
257                    sim.user(text).await;
258                }
259                SimStep::Set { key, value } => {
260                    sim.set(key.clone(), value.clone());
261                }
262                SimStep::ToolOk(tool) => {
263                    sim.tool_ok(tool);
264                }
265                SimStep::ScheduleTool { tool, after } => {
266                    sim.schedule_tool(tool.clone(), *after);
267                }
268                SimStep::Turn => {
269                    sim.turn();
270                }
271                SimStep::ExpectActive(expected) => {
272                    let active = sim.active();
273                    for e in expected {
274                        if !active.contains(e) {
275                            return fail(format!("expected active '{e}', got {active:?}"));
276                        }
277                    }
278                }
279                SimStep::ExpectDenied(tool) => {
280                    if sim.allowed(tool) {
281                        return fail(format!("expected '{tool}' denied, but it was admitted"));
282                    }
283                }
284                SimStep::ExpectAllowed(tool) => {
285                    if !sim.allowed(tool) {
286                        let why = sim.denied().get(tool).cloned().unwrap_or_default();
287                        return fail(format!("expected '{tool}' allowed, but denied: {why}"));
288                    }
289                }
290                SimStep::ExpectSlot { key, value } => {
291                    let got = sim.state().get_raw(key);
292                    if got.as_ref() != Some(value) {
293                        return fail(format!("expected slot '{key}' = {value}, got {got:?}"));
294                    }
295                }
296                SimStep::ExpectComplete => {
297                    if !sim.is_complete() {
298                        return fail("expected conversation complete".into());
299                    }
300                }
301            }
302        }
303        Ok(())
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::conversation::Conversation;
311    use gemini_adk_rs::flow::Guard;
312    use gemini_adk_rs::frame::{Frame, FrameSpec, SlotRecognizer, SlotSpec};
313
314    struct Booking;
315    impl Frame for Booking {
316        fn frame() -> FrameSpec {
317            FrameSpec {
318                name: "booking".into(),
319                slots: vec![SlotSpec {
320                    recognizer: Some(SlotRecognizer::IntegerNear(vec!["people".into()])),
321                    ..SlotSpec::new("party_size")
322                }],
323            }
324        }
325    }
326
327    fn booking() -> CompiledConversation {
328        Conversation::new("booking")
329            .stage("collect")
330            .collect_frame::<Booking>()
331            .next("confirm", Guard::captured(["party_size"]))
332            .stage("confirm")
333            .commit("book", Guard::is_true("user_confirmed"))
334            .next("done", Guard::called_ok("book"))
335            .stage("done")
336            .terminal()
337            .require(["done"])
338            .compile()
339            .expect("compiles")
340    }
341
342    #[tokio::test]
343    async fn fake_user_fills_slots_and_gates_commit() {
344        let convo = booking();
345        let mut sim = Sim::new(&convo, Enforcement::Enforce);
346
347        assert!(sim.active().contains(&"collect".to_string()));
348        assert!(!sim.allowed("book"));
349
350        // The fake user speaks; the recognizer fills party_size.
351        sim.user("a table for 4 people").await;
352        assert_eq!(sim.slot::<u32>("party_size"), Some(4));
353        assert!(sim.active().contains(&"confirm".to_string()));
354
355        // book is gated until confirmation.
356        assert!(!sim.allowed("book"));
357        sim.set("user_confirmed", true);
358        sim.turn();
359        assert!(sim.allowed("book"));
360
361        sim.tool_ok("book");
362        assert!(sim.is_complete());
363    }
364
365    #[tokio::test]
366    async fn scenario_runs_and_round_trips() {
367        let scenario = Scenario {
368            name: "happy_path".into(),
369            steps: vec![
370                SimStep::ExpectActive(vec!["collect".into()]),
371                SimStep::ExpectDenied("book".into()),
372                SimStep::User("party of 4 people".into()),
373                SimStep::ExpectSlot {
374                    key: "party_size".into(),
375                    value: serde_json::json!(4),
376                },
377                SimStep::ExpectActive(vec!["confirm".into()]),
378                SimStep::Set {
379                    key: "user_confirmed".into(),
380                    value: serde_json::json!(true),
381                },
382                SimStep::Turn,
383                SimStep::ExpectAllowed("book".into()),
384                SimStep::ToolOk("book".into()),
385                SimStep::ExpectComplete,
386            ],
387        };
388
389        scenario
390            .run(&booking(), Enforcement::Enforce)
391            .await
392            .expect("scenario passes");
393
394        // Scenarios are serializable (authorable as YAML/JSON).
395        let json = serde_json::to_string(&scenario).unwrap();
396        let back: Scenario = serde_json::from_str(&json).unwrap();
397        back.run(&booking(), Enforcement::Enforce)
398            .await
399            .expect("round-tripped scenario passes");
400    }
401
402    #[tokio::test]
403    async fn scenario_reports_failed_expectation() {
404        let scenario = Scenario {
405            name: "bad".into(),
406            steps: vec![SimStep::ExpectComplete], // not complete at the start
407        };
408        let err = scenario
409            .run(&booking(), Enforcement::Enforce)
410            .await
411            .unwrap_err();
412        assert!(err.contains("expected conversation complete"));
413    }
414
415    #[tokio::test]
416    async fn tool_latency_resolves_after_delay() {
417        let convo = booking();
418        let mut sim = Sim::new(&convo, Enforcement::Enforce);
419        sim.user("4 people").await;
420        sim.set("user_confirmed", true);
421        sim.turn();
422        // book completes after 2 turns of latency rather than immediately.
423        sim.schedule_tool("book", 2);
424        assert!(!sim.is_complete());
425        sim.turn();
426        assert!(!sim.is_complete());
427        sim.turn();
428        assert!(sim.is_complete());
429    }
430}