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    /// The policy's configuration.
107    pub fn config(&self) -> &TurnCommitConfig {
108        &self.config
109    }
110
111    /// Change the end-of-turn hold mid-session (a stage's endpointing). A
112    /// hold already running is measured against the new value.
113    pub fn set_eot_hold(&mut self, hold: Duration) {
114        self.config.eot_hold = hold;
115    }
116
117    /// Apply the VAD edges observed in the chunk ending at `now_ms`, then
118    /// advance pending holds/sustains to `now_ms`. `model_speaking` is the
119    /// caller's knowledge of whether the model currently holds the floor.
120    /// Returns the signals that COMMIT at this call, in order.
121    pub fn advance(
122        &mut self,
123        now_ms: u64,
124        edges: &[VadEvent],
125        model_speaking: bool,
126    ) -> Vec<TurnSignal> {
127        let mut signals = Vec::new();
128
129        // Apply edges in slice order.
130        for &edge in edges {
131            match edge {
132                VadEvent::SpeechStart => {
133                    // Cancel any pending end-hold (pause bridged — no ActivityEnd will fire).
134                    self.pending_end_at = None;
135
136                    if self.turn_active {
137                        // If a user turn is already active, nothing else.
138                    } else if model_speaking && self.config.min_interruption > Duration::ZERO {
139                        // Record a pending interruption start at now_ms (nothing emitted yet).
140                        self.pending_interruption_at = Some(now_ms);
141                    } else {
142                        // Emit ActivityStart (or InterruptionStart if model_speaking with min_interruption == 0).
143                        let signal = if model_speaking {
144                            TurnSignal::InterruptionStart
145                        } else {
146                            TurnSignal::ActivityStart
147                        };
148                        signals.push(signal);
149                        self.turn_active = true;
150                    }
151                }
152                VadEvent::SpeechEnd => {
153                    if self.pending_interruption_at.is_some() {
154                        // Pending interruption start exists (speech never sustained).
155                        // Discard it silently — that was a backchannel.
156                        self.pending_interruption_at = None;
157                    } else if self.turn_active {
158                        // Turn is active: record pending end at now_ms (emit nothing yet).
159                        self.pending_end_at = Some(now_ms);
160                    }
161                    // If eot_hold == 0 this commits ActivityEnd on the same advance() call
162                    // (handled below in expiry).
163                }
164            }
165        }
166
167        // After applying edges, expire pendings against now_ms.
168        if let Some(pending_at) = self.pending_interruption_at {
169            let age_ms = now_ms.saturating_sub(pending_at);
170            if age_ms >= self.config.min_interruption.as_millis() as u64 {
171                signals.push(TurnSignal::InterruptionStart);
172                self.turn_active = true;
173                self.pending_interruption_at = None;
174            }
175        }
176
177        if let Some(pending_at) = self.pending_end_at {
178            let age_ms = now_ms.saturating_sub(pending_at);
179            if age_ms >= self.config.eot_hold.as_millis() as u64 {
180                signals.push(TurnSignal::ActivityEnd);
181                self.turn_active = false;
182                self.pending_end_at = None;
183            }
184        }
185
186        signals
187    }
188
189    /// Whether an activityStart/InterruptionStart has been committed without
190    /// a matching ActivityEnd yet.
191    pub fn user_turn_active(&self) -> bool {
192        self.turn_active
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn immediate_passthrough_start() {
202        let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
203        let edges = vec![VadEvent::SpeechStart];
204        let signals = policy.advance(0, &edges, false);
205        assert_eq!(signals, vec![TurnSignal::ActivityStart]);
206        assert!(policy.user_turn_active());
207    }
208
209    #[test]
210    fn immediate_passthrough_end() {
211        let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
212        // Start first
213        let edges = vec![VadEvent::SpeechStart];
214        let signals = policy.advance(0, &edges, false);
215        assert_eq!(signals, vec![TurnSignal::ActivityStart]);
216        // Then end
217        let edges = vec![VadEvent::SpeechEnd];
218        let signals = policy.advance(100, &edges, false);
219        assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
220        assert!(!policy.user_turn_active());
221    }
222
223    #[test]
224    fn immediate_passthrough_interruption() {
225        let mut policy = TurnCommitPolicy::new(TurnCommitConfig::immediate());
226        let edges = vec![VadEvent::SpeechStart];
227        let signals = policy.advance(0, &edges, true);
228        assert_eq!(signals, vec![TurnSignal::InterruptionStart]);
229        assert!(policy.user_turn_active());
230    }
231
232    #[test]
233    fn pause_bridging() {
234        let config = TurnCommitConfig {
235            eot_hold: Duration::from_millis(600),
236            min_interruption: Duration::from_millis(600),
237        };
238        let mut policy = TurnCommitPolicy::new(config);
239
240        // Start speech
241        let signals = policy.advance(0, &[VadEvent::SpeechStart], false);
242        assert_eq!(signals, vec![TurnSignal::ActivityStart]);
243
244        // End speech at t=100
245        let signals = policy.advance(100, &[VadEvent::SpeechEnd], false);
246        assert!(signals.is_empty(), "Should not emit yet, pending");
247
248        // Resume within hold (at 100 + 300ms < 600ms hold)
249        let signals = policy.advance(400, &[VadEvent::SpeechStart], false);
250        assert!(signals.is_empty(), "Speech resumed, cancels pending end");
251        assert!(policy.user_turn_active());
252
253        // Eventually end and wait past hold
254        let signals = policy.advance(500, &[VadEvent::SpeechEnd], false);
255        assert!(signals.is_empty(), "Pending end at 500");
256
257        let signals = policy.advance(1100, &[], false);
258        assert_eq!(signals, vec![TurnSignal::ActivityEnd], "Hold expired");
259        assert!(!policy.user_turn_active());
260    }
261
262    #[test]
263    fn backchannel_suppression() {
264        let config = TurnCommitConfig {
265            eot_hold: Duration::from_millis(600),
266            min_interruption: Duration::from_millis(600),
267        };
268        let mut policy = TurnCommitPolicy::new(config);
269
270        // Model speaking
271        let signals = policy.advance(0, &[VadEvent::SpeechStart], true);
272        // Pending interruption, not emitted yet
273        assert!(signals.is_empty());
274        assert!(!policy.user_turn_active());
275
276        // End shortly after (200ms < 600ms sustain requirement)
277        let signals = policy.advance(200, &[VadEvent::SpeechEnd], true);
278        // Backchannel discarded, nothing emitted
279        assert!(signals.is_empty());
280        assert!(!policy.user_turn_active());
281    }
282
283    #[test]
284    fn sustained_interruption() {
285        let config = TurnCommitConfig {
286            eot_hold: Duration::from_millis(600),
287            min_interruption: Duration::from_millis(600),
288        };
289        let mut policy = TurnCommitPolicy::new(config);
290
291        // Model speaking, user starts speech
292        let signals = policy.advance(0, &[VadEvent::SpeechStart], true);
293        assert!(signals.is_empty(), "Pending interruption");
294
295        // Advance past sustain threshold without ending
296        let signals = policy.advance(700, &[], true);
297        assert_eq!(
298            signals,
299            vec![TurnSignal::InterruptionStart],
300            "Sustain expired, interrupt emitted"
301        );
302        assert!(policy.user_turn_active());
303
304        // Now end and wait for hold
305        let signals = policy.advance(800, &[VadEvent::SpeechEnd], true);
306        assert!(signals.is_empty(), "Pending end");
307
308        let signals = policy.advance(1500, &[], true);
309        assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
310        assert!(!policy.user_turn_active());
311    }
312
313    #[test]
314    fn free_floor_start_never_delayed() {
315        let config = TurnCommitConfig {
316            eot_hold: Duration::from_millis(800),
317            min_interruption: Duration::from_millis(10000), // huge
318        };
319        let mut policy = TurnCommitPolicy::new(config);
320
321        // Free floor (model not speaking)
322        let signals = policy.advance(0, &[VadEvent::SpeechStart], false);
323        assert_eq!(signals, vec![TurnSignal::ActivityStart]);
324        assert!(policy.user_turn_active());
325    }
326
327    #[test]
328    fn eot_hold_expiry_timing() {
329        let config = TurnCommitConfig {
330            eot_hold: Duration::from_millis(400),
331            min_interruption: Duration::from_millis(600),
332        };
333        let mut policy = TurnCommitPolicy::new(config);
334
335        // Start and end
336        policy.advance(0, &[VadEvent::SpeechStart], false);
337        policy.advance(100, &[VadEvent::SpeechEnd], false);
338
339        // Advance to hold - 1ms, should not emit
340        let signals = policy.advance(499, &[], false);
341        assert!(signals.is_empty());
342        assert!(policy.user_turn_active());
343
344        // Advance to hold, should emit
345        let signals = policy.advance(500, &[], false);
346        assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
347        assert!(!policy.user_turn_active());
348    }
349
350    #[test]
351    fn config_presets() {
352        let immediate = TurnCommitConfig::immediate();
353        assert_eq!(immediate.eot_hold, Duration::ZERO);
354        assert_eq!(immediate.min_interruption, Duration::ZERO);
355
356        let responsive = TurnCommitConfig::responsive();
357        assert_eq!(responsive.eot_hold, Duration::from_millis(400));
358        assert_eq!(responsive.min_interruption, Duration::from_millis(600));
359
360        let conversational = TurnCommitConfig::conversational();
361        assert_eq!(conversational.eot_hold, Duration::from_millis(800));
362        assert_eq!(conversational.min_interruption, Duration::from_millis(1400));
363
364        // Default == responsive
365        let default = TurnCommitConfig::default();
366        assert_eq!(default, responsive);
367    }
368
369    #[test]
370    fn multiple_edges_in_one_call() {
371        let config = TurnCommitConfig {
372            eot_hold: Duration::from_millis(600),
373            min_interruption: Duration::from_millis(600),
374        };
375        let mut policy = TurnCommitPolicy::new(config);
376
377        // Start and end in same call
378        let signals = policy.advance(0, &[VadEvent::SpeechStart, VadEvent::SpeechEnd], false);
379        assert_eq!(signals, vec![TurnSignal::ActivityStart]);
380        // SpeechEnd recorded as pending, not expired yet
381        assert!(policy.user_turn_active());
382
383        // Expire the hold
384        let signals = policy.advance(700, &[], false);
385        assert_eq!(signals, vec![TurnSignal::ActivityEnd]);
386        assert!(!policy.user_turn_active());
387    }
388}