gemini_adk_rs/live/
turn_commit.rs

1//! Pure turn-commit state machine.
2//!
3//! Raw VAD edges make two measured mistakes when used directly as turn signals.
4//! (1) Firing end-of-turn on every speech offset commits during mid-turn pauses:
5//! false-positive rate 0.206 at recall 0.895. Holding the commit through extra
6//! silence walks a clean frontier: hold 600ms → fp 0.135; 800ms → recall 0.798
7//! fp 0.087; 1200ms → fp 0.032. (2) Treating every speech onset during the
8//! other side's turn as an interruption fires on backchannels ("mm-hm"):
9//! fp 0.702. Requiring the speech to SUSTAIN before committing suppresses them:
10//! sustain 600ms → fp 0.319 recall 0.939; 1000ms → fp 0.126; 1400ms → fp 0.062
11//! recall 0.899. This module is that mechanism: a policy layer between VAD
12//! edges and turn signals, with the operating point as configuration.
13//!
14//! Measurements are from Sesame's TurnBench dev set (38 conversations of real
15//! dyadic speech).
16
17use gemini_genai_rs::prelude::VadEvent;
18use std::time::Duration;
19
20/// Operating point for turn commitment. All timings are measured from the
21/// VAD edge (which already includes the detector's hangover).
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct TurnCommitConfig {
24    /// Extra silence after SpeechEnd before committing end-of-turn; speech
25    /// resuming within the hold cancels the commit (the pause is bridged).
26    pub eot_hold: Duration,
27    /// Speech that begins while the model holds the floor must sustain this
28    /// long before committing as an interruption; shorter overlapped speech
29    /// is treated as a backchannel and never surfaces.
30    pub min_interruption: Duration,
31}
32
33impl TurnCommitConfig {
34    /// Zeros — bit-compatible with raw edge forwarding.
35    pub fn immediate() -> Self {
36        Self {
37            eot_hold: Duration::ZERO,
38            min_interruption: Duration::ZERO,
39        }
40    }
41
42    /// Mid-frontier: eot_hold 400ms, min_interruption 600ms.
43    pub fn responsive() -> Self {
44        Self {
45            eot_hold: Duration::from_millis(400),
46            min_interruption: Duration::from_millis(600),
47        }
48    }
49
50    /// TurnBench 0.1-fp-budget qualifying point: eot_hold 800ms, min_interruption 1400ms.
51    pub fn conversational() -> Self {
52        Self {
53            eot_hold: Duration::from_millis(800),
54            min_interruption: Duration::from_millis(1400),
55        }
56    }
57}
58
59impl Default for TurnCommitConfig {
60    fn default() -> Self {
61        Self::responsive()
62    }
63}
64
65/// A committed turn signal, produced at its commit time.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum TurnSignal {
68    /// User took a free floor — forward as activityStart immediately.
69    ActivityStart,
70    /// Sustained user speech while the model held the floor — the
71    /// activityStart of a deliberate barge-in.
72    InterruptionStart,
73    /// User turn ended (silence outlasted the hold) — forward as activityEnd.
74    ActivityEnd,
75}
76
77/// The policy state machine. Driven by a monotonic millisecond audio clock
78/// (advance it by the duration of each audio chunk, not wall time).
79///
80/// Commit time IS the call's now_ms — signals carry no timestamps.
81pub struct TurnCommitPolicy {
82    config: TurnCommitConfig,
83    /// Whether an activityStart or InterruptionStart has been committed without
84    /// a matching ActivityEnd yet.
85    turn_active: bool,
86    /// If Some, a SpeechEnd was recorded at this time; ActivityEnd will emit
87    /// when age >= eot_hold.
88    pending_end_at: Option<u64>,
89    /// If Some, a SpeechStart during model_speaking was recorded at this time;
90    /// InterruptionStart will emit when age >= min_interruption, provided the
91    /// SpeechEnd never arrived (sustain check).
92    pending_interruption_at: Option<u64>,
93}
94
95impl TurnCommitPolicy {
96    /// Create a new policy state machine with the given config.
97    pub fn new(config: TurnCommitConfig) -> Self {
98        Self {
99            config,
100            turn_active: false,
101            pending_end_at: None,
102            pending_interruption_at: None,
103        }
104    }
105
106    /// Apply the VAD edges observed in the chunk ending at `now_ms`, then
107    /// advance pending holds/sustains to `now_ms`. `model_speaking` is the
108    /// caller's knowledge of whether the model currently holds the floor.
109    /// Returns the signals that COMMIT at this call, in order.
110    pub fn advance(
111        &mut self,
112        now_ms: u64,
113        edges: &[VadEvent],
114        model_speaking: bool,
115    ) -> Vec<TurnSignal> {
116        let mut signals = Vec::new();
117
118        // Apply edges in slice order.
119        for &edge in edges {
120            match edge {
121                VadEvent::SpeechStart => {
122                    // Cancel any pending end-hold (pause bridged — no ActivityEnd will fire).
123                    self.pending_end_at = None;
124
125                    if self.turn_active {
126                        // If a user turn is already active, nothing else.
127                    } else if model_speaking && self.config.min_interruption > Duration::ZERO {
128                        // Record a pending interruption start at now_ms (nothing emitted yet).
129                        self.pending_interruption_at = Some(now_ms);
130                    } else {
131                        // Emit ActivityStart (or InterruptionStart if model_speaking with min_interruption == 0).
132                        let signal = if model_speaking {
133                            TurnSignal::InterruptionStart
134                        } else {
135                            TurnSignal::ActivityStart
136                        };
137                        signals.push(signal);
138                        self.turn_active = true;
139                    }
140                }
141                VadEvent::SpeechEnd => {
142                    if self.pending_interruption_at.is_some() {
143                        // Pending interruption start exists (speech never sustained).
144                        // Discard it silently — that was a backchannel.
145                        self.pending_interruption_at = None;
146                    } else if self.turn_active {
147                        // Turn is active: record pending end at now_ms (emit nothing yet).
148                        self.pending_end_at = Some(now_ms);
149                    }
150                    // If eot_hold == 0 this commits ActivityEnd on the same advance() call
151                    // (handled below in expiry).
152                }
153            }
154        }
155
156        // After applying edges, expire pendings against now_ms.
157        if let Some(pending_at) = self.pending_interruption_at {
158            let age_ms = now_ms.saturating_sub(pending_at);
159            if age_ms >= self.config.min_interruption.as_millis() as u64 {
160                signals.push(TurnSignal::InterruptionStart);
161                self.turn_active = true;
162                self.pending_interruption_at = None;
163            }
164        }
165
166        if let Some(pending_at) = self.pending_end_at {
167            let age_ms = now_ms.saturating_sub(pending_at);
168            if age_ms >= self.config.eot_hold.as_millis() as u64 {
169                signals.push(TurnSignal::ActivityEnd);
170                self.turn_active = false;
171                self.pending_end_at = None;
172            }
173        }
174
175        signals
176    }
177
178    /// Whether an activityStart/InterruptionStart has been committed without
179    /// a matching ActivityEnd yet.
180    pub fn user_turn_active(&self) -> bool {
181        self.turn_active
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn immediate_passthrough_start() {
191        let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
192        let edges = vec![VadEvent::SpeechStart];
193        let signals = policy.advance(0, &edges, false);
194        assert_eq!(signals, vec![TurnSignal::ActivityStart]);
195        assert!(policy.user_turn_active());
196    }
197
198    #[test]
199    fn immediate_passthrough_end() {
200        let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
201        // Start first
202        let edges = vec![VadEvent::SpeechStart];
203        let signals = policy.advance(0, &edges, false);
204        assert_eq!(signals, vec![TurnSignal::ActivityStart]);
205        // Then end
206        let edges = vec![VadEvent::SpeechEnd];
207        let signals = policy.advance(100, &edges, false);
208        assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
209        assert!(!policy.user_turn_active());
210    }
211
212    #[test]
213    fn immediate_passthrough_interruption() {
214        let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
215        let edges = vec![VadEvent::SpeechStart];
216        let signals = policy.advance(0, &edges, true);
217        assert_eq!(signals, vec![TurnSignal::InterruptionStart]);
218        assert!(policy.user_turn_active());
219    }
220
221    #[test]
222    fn pause_bridging() {
223        let config = TurnCommitConfig {
224            eot_hold: Duration::from_millis(600),
225            min_interruption: Duration::from_millis(600),
226        };
227        let mut policy = TurnCommitPolicy::new(config);
228
229        // Start speech
230        let signals = policy.advance(0, &[VadEvent::SpeechStart], false);
231        assert_eq!(signals, vec![TurnSignal::ActivityStart]);
232
233        // End speech at t=100
234        let signals = policy.advance(100, &[VadEvent::SpeechEnd], false);
235        assert!(signals.is_empty(), "Should not emit yet, pending");
236
237        // Resume within hold (at 100 + 300ms < 600ms hold)
238        let signals = policy.advance(400, &[VadEvent::SpeechStart], false);
239        assert!(signals.is_empty(), "Speech resumed, cancels pending end");
240        assert!(policy.user_turn_active());
241
242        // Eventually end and wait past hold
243        let signals = policy.advance(500, &[VadEvent::SpeechEnd], false);
244        assert!(signals.is_empty(), "Pending end at 500");
245
246        let signals = policy.advance(1100, &[], false);
247        assert_eq!(signals, vec![TurnSignal::ActivityEnd], "Hold expired");
248        assert!(!policy.user_turn_active());
249    }
250
251    #[test]
252    fn backchannel_suppression() {
253        let config = TurnCommitConfig {
254            eot_hold: Duration::from_millis(600),
255            min_interruption: Duration::from_millis(600),
256        };
257        let mut policy = TurnCommitPolicy::new(config);
258
259        // Model speaking
260        let signals = policy.advance(0, &[VadEvent::SpeechStart], true);
261        // Pending interruption, not emitted yet
262        assert!(signals.is_empty());
263        assert!(!policy.user_turn_active());
264
265        // End shortly after (200ms < 600ms sustain requirement)
266        let signals = policy.advance(200, &[VadEvent::SpeechEnd], true);
267        // Backchannel discarded, nothing emitted
268        assert!(signals.is_empty());
269        assert!(!policy.user_turn_active());
270    }
271
272    #[test]
273    fn sustained_interruption() {
274        let config = TurnCommitConfig {
275            eot_hold: Duration::from_millis(600),
276            min_interruption: Duration::from_millis(600),
277        };
278        let mut policy = TurnCommitPolicy::new(config);
279
280        // Model speaking, user starts speech
281        let signals = policy.advance(0, &[VadEvent::SpeechStart], true);
282        assert!(signals.is_empty(), "Pending interruption");
283
284        // Advance past sustain threshold without ending
285        let signals = policy.advance(700, &[], true);
286        assert_eq!(
287            signals,
288            vec![TurnSignal::InterruptionStart],
289            "Sustain expired, interrupt emitted"
290        );
291        assert!(policy.user_turn_active());
292
293        // Now end and wait for hold
294        let signals = policy.advance(800, &[VadEvent::SpeechEnd], true);
295        assert!(signals.is_empty(), "Pending end");
296
297        let signals = policy.advance(1500, &[], true);
298        assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
299        assert!(!policy.user_turn_active());
300    }
301
302    #[test]
303    fn free_floor_start_never_delayed() {
304        let config = TurnCommitConfig {
305            eot_hold: Duration::from_millis(800),
306            min_interruption: Duration::from_millis(10000), // huge
307        };
308        let mut policy = TurnCommitPolicy::new(config);
309
310        // Free floor (model not speaking)
311        let signals = policy.advance(0, &[VadEvent::SpeechStart], false);
312        assert_eq!(signals, vec![TurnSignal::ActivityStart]);
313        assert!(policy.user_turn_active());
314    }
315
316    #[test]
317    fn eot_hold_expiry_timing() {
318        let config = TurnCommitConfig {
319            eot_hold: Duration::from_millis(400),
320            min_interruption: Duration::from_millis(600),
321        };
322        let mut policy = TurnCommitPolicy::new(config);
323
324        // Start and end
325        policy.advance(0, &[VadEvent::SpeechStart], false);
326        policy.advance(100, &[VadEvent::SpeechEnd], false);
327
328        // Advance to hold - 1ms, should not emit
329        let signals = policy.advance(499, &[], false);
330        assert!(signals.is_empty());
331        assert!(policy.user_turn_active());
332
333        // Advance to hold, should emit
334        let signals = policy.advance(500, &[], false);
335        assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
336        assert!(!policy.user_turn_active());
337    }
338
339    #[test]
340    fn config_presets() {
341        let immediate = TurnCommitConfig::immediate();
342        assert_eq!(immediate.eot_hold, Duration::ZERO);
343        assert_eq!(immediate.min_interruption, Duration::ZERO);
344
345        let responsive = TurnCommitConfig::responsive();
346        assert_eq!(responsive.eot_hold, Duration::from_millis(400));
347        assert_eq!(responsive.min_interruption, Duration::from_millis(600));
348
349        let conversational = TurnCommitConfig::conversational();
350        assert_eq!(conversational.eot_hold, Duration::from_millis(800));
351        assert_eq!(conversational.min_interruption, Duration::from_millis(1400));
352
353        // Default == responsive
354        let default = TurnCommitConfig::default();
355        assert_eq!(default, responsive);
356    }
357
358    #[test]
359    fn multiple_edges_in_one_call() {
360        let config = TurnCommitConfig {
361            eot_hold: Duration::from_millis(600),
362            min_interruption: Duration::from_millis(600),
363        };
364        let mut policy = TurnCommitPolicy::new(config);
365
366        // Start and end in same call
367        let signals = policy.advance(0, &[VadEvent::SpeechStart, VadEvent::SpeechEnd], false);
368        assert_eq!(signals, vec![TurnSignal::ActivityStart]);
369        // SpeechEnd recorded as pending, not expired yet
370        assert!(policy.user_turn_active());
371
372        // Expire the hold
373        let signals = policy.advance(700, &[], false);
374        assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
375        assert!(!policy.user_turn_active());
376    }
377}