Skip to main content

gemini_memory_rs/
transcript.rs

1//! Transcript accumulation, stable-prefix tracking, and speculation gating.
2//!
3//! Gemini Live emits input transcription independently of turn boundaries and
4//! revises partial results as recognition improves. Two consequences shape this
5//! module:
6//!
7//! 1. **Partial transcripts are hypotheses.** They may prefetch context; they
8//!    may never become evidence. Only `is_final` output is admissible.
9//! 2. **Ordering cannot be assumed.** Turn identity is assigned locally from
10//!    VAD and turn-complete events, and every asynchronous task carries the
11//!    generation it was started for, so a late result cannot overwrite a newer
12//!    one.
13
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::time::{Duration, Instant};
16
17use serde::{Deserialize, Serialize};
18
19use crate::bm25::tokenize;
20use crate::core::{TranscriptConfig, TurnId};
21
22/// The current reading of an in-progress utterance.
23#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
24pub struct TranscriptHypothesis {
25    /// The portion that has stopped changing between revisions.
26    pub stable_prefix: String,
27    /// The portion still being revised.
28    pub unstable_suffix: String,
29    /// How many revisions have been seen for this turn.
30    pub revision: u64,
31    /// Whether the transcript has been finalized.
32    pub finalised: bool,
33}
34
35impl TranscriptHypothesis {
36    /// The full text as currently understood.
37    pub fn text(&self) -> String {
38        if self.unstable_suffix.is_empty() {
39            self.stable_prefix.clone()
40        } else if self.stable_prefix.is_empty() {
41            self.unstable_suffix.clone()
42        } else {
43            format!("{} {}", self.stable_prefix, self.unstable_suffix)
44        }
45    }
46}
47
48/// Accumulates transcript revisions for one turn.
49///
50/// The stable prefix is the longest word-aligned common prefix across
51/// revisions. Speculating on the stable prefix rather than the whole partial
52/// avoids re-running retrieval every time the recognizer changes its mind about
53/// the last word.
54#[derive(Debug, Default)]
55pub struct TranscriptAccumulator {
56    turn_id: TurnId,
57    previous_words: Vec<String>,
58    stable_words: Vec<String>,
59    revision: u64,
60    finalised: bool,
61}
62
63impl TranscriptAccumulator {
64    /// Start accumulating for a turn.
65    pub fn new(turn_id: TurnId) -> Self {
66        Self {
67            turn_id,
68            ..Default::default()
69        }
70    }
71
72    /// Discard state and begin a new turn.
73    pub fn begin_turn(&mut self, turn_id: TurnId) {
74        *self = Self::new(turn_id);
75    }
76
77    /// The turn being accumulated.
78    pub fn turn_id(&self) -> TurnId {
79        self.turn_id
80    }
81
82    /// Fold in a partial transcript, returning the updated hypothesis.
83    pub fn push_partial(&mut self, text: &str) -> TranscriptHypothesis {
84        let words: Vec<String> = text.split_whitespace().map(str::to_string).collect();
85        let common = common_prefix_len(&self.previous_words, &words);
86        // The final word of a partial is the one most likely to be revised, so
87        // it is never treated as stable until a later revision confirms it.
88        let stable_len = common.min(words.len().saturating_sub(1));
89        if stable_len > self.stable_words.len() {
90            self.stable_words = words[..stable_len].to_vec();
91        }
92        self.previous_words = words.clone();
93        self.revision += 1;
94
95        TranscriptHypothesis {
96            stable_prefix: self.stable_words.join(" "),
97            unstable_suffix: words[self.stable_words.len().min(words.len())..].join(" "),
98            revision: self.revision,
99            finalised: false,
100        }
101    }
102
103    /// Fold in the finalized transcript.
104    pub fn finalize(&mut self, text: &str) -> TranscriptHypothesis {
105        self.stable_words = text.split_whitespace().map(str::to_string).collect();
106        self.previous_words = self.stable_words.clone();
107        self.revision += 1;
108        self.finalised = true;
109        TranscriptHypothesis {
110            stable_prefix: self.stable_words.join(" "),
111            unstable_suffix: String::new(),
112            revision: self.revision,
113            finalised: true,
114        }
115    }
116
117    /// Whether the turn's transcript has been finalized.
118    pub fn is_finalised(&self) -> bool {
119        self.finalised
120    }
121}
122
123fn common_prefix_len(a: &[String], b: &[String]) -> usize {
124    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
125}
126
127/// The generation counter that makes stale asynchronous work harmless.
128///
129/// Every speculative task records the generation it was started at; before
130/// publishing a result it re-reads the counter, and abandons the result if the
131/// world has moved on. Without this, a slow retrieval for turn 4 could overwrite
132/// the prepared context for turn 5.
133#[derive(Debug, Default)]
134pub struct GenerationGuard {
135    current: AtomicU64,
136}
137
138impl GenerationGuard {
139    /// A guard starting at generation zero.
140    pub fn new() -> Self {
141        Self::default()
142    }
143
144    /// The generation in force.
145    pub fn current(&self) -> u64 {
146        self.current.load(Ordering::Acquire)
147    }
148
149    /// Invalidate outstanding work and return the new generation.
150    pub fn advance(&self) -> u64 {
151        self.current.fetch_add(1, Ordering::AcqRel) + 1
152    }
153
154    /// Whether a result computed at `generation` is still wanted.
155    pub fn is_current(&self, generation: u64) -> bool {
156        self.current() == generation
157    }
158}
159
160/// Decides whether a partial transcript revision is worth speculating on.
161///
162/// Two gates, both cheap: a debounce window, and a minimum amount of genuinely
163/// new content. Recognizers emit revisions far faster than retrieval can
164/// usefully run, and most revisions change nothing that would change the query.
165#[derive(Debug)]
166pub struct SpeculationGate {
167    debounce: Duration,
168    minimum_new_tokens: usize,
169    last_fired_at: Option<Instant>,
170    last_query_tokens: Vec<String>,
171}
172
173/// Why the gate did or did not fire.
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum SpeculationDecision {
176    /// Speculate now.
177    Fire,
178    /// Too soon since the last speculation.
179    Debounced,
180    /// Not enough new content to change the query.
181    InsufficientNewContent,
182    /// Nothing stable to speculate on yet.
183    NothingStable,
184}
185
186impl SpeculationGate {
187    /// Build a gate from transcript configuration.
188    pub fn new(config: &TranscriptConfig) -> Self {
189        Self {
190            debounce: Duration::from_millis(config.partial_debounce_ms),
191            minimum_new_tokens: config.minimum_new_content_tokens,
192            last_fired_at: None,
193            last_query_tokens: Vec::new(),
194        }
195    }
196
197    /// Reset between turns.
198    pub fn reset(&mut self) {
199        self.last_fired_at = None;
200        self.last_query_tokens.clear();
201    }
202
203    /// Decide whether to speculate on `hypothesis` at `now`.
204    ///
205    /// A signal the deterministic extractor already recognised (a known entity,
206    /// an explicit recall phrase) overrides both gates: those are exactly the
207    /// cases where prefetching pays for itself.
208    pub fn consider(
209        &mut self,
210        hypothesis: &TranscriptHypothesis,
211        has_strong_signal: bool,
212        now: Instant,
213    ) -> SpeculationDecision {
214        let tokens = tokenize(&hypothesis.stable_prefix);
215        if tokens.is_empty() && !hypothesis.finalised {
216            return SpeculationDecision::NothingStable;
217        }
218
219        let new_tokens = tokens
220            .len()
221            .saturating_sub(common_prefix_len_str(&self.last_query_tokens, &tokens));
222        let urgent = has_strong_signal || hypothesis.finalised;
223
224        if !urgent {
225            if let Some(last) = self.last_fired_at
226                && now.duration_since(last) < self.debounce
227            {
228                return SpeculationDecision::Debounced;
229            }
230            if new_tokens < self.minimum_new_tokens {
231                return SpeculationDecision::InsufficientNewContent;
232            }
233        }
234
235        self.last_fired_at = Some(now);
236        self.last_query_tokens = tokens;
237        SpeculationDecision::Fire
238    }
239}
240
241fn common_prefix_len_str(a: &[String], b: &[String]) -> usize {
242    a.iter().zip(b.iter()).take_while(|(x, y)| x == y).count()
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn the_stable_prefix_grows_as_revisions_agree() {
251        let mut acc = TranscriptAccumulator::new(TurnId(1));
252        acc.push_partial("what should");
253        let h = acc.push_partial("what should we eat");
254        assert_eq!(h.stable_prefix, "what should");
255        assert_eq!(h.unstable_suffix, "we eat");
256
257        let h = acc.push_partial("what should we eat tonight");
258        assert_eq!(h.stable_prefix, "what should we eat");
259    }
260
261    #[test]
262    fn a_revised_tail_does_not_corrupt_the_stable_prefix() {
263        let mut acc = TranscriptAccumulator::new(TurnId(1));
264        acc.push_partial("book a table for read");
265        let h = acc.push_partial("book a table for Rhea");
266        assert_eq!(h.stable_prefix, "book a table for");
267        assert!(h.text().contains("Rhea"));
268    }
269
270    #[test]
271    fn finalizing_replaces_the_hypothesis_wholesale() {
272        let mut acc = TranscriptAccumulator::new(TurnId(1));
273        acc.push_partial("I am vegetarian");
274        let h = acc.finalize("I am pescatarian");
275        assert!(h.finalised);
276        assert_eq!(h.stable_prefix, "I am pescatarian");
277        assert!(h.unstable_suffix.is_empty());
278        assert!(acc.is_finalised());
279    }
280
281    #[test]
282    fn beginning_a_turn_clears_previous_state() {
283        let mut acc = TranscriptAccumulator::new(TurnId(1));
284        acc.finalize("first turn");
285        acc.begin_turn(TurnId(2));
286        assert_eq!(acc.turn_id(), TurnId(2));
287        assert!(!acc.is_finalised());
288        let h = acc.push_partial("second");
289        assert!(h.stable_prefix.is_empty());
290    }
291
292    #[test]
293    fn stale_generations_are_rejected() {
294        let guard = GenerationGuard::new();
295        let started_at = guard.current();
296        assert!(guard.is_current(started_at));
297        let newer = guard.advance();
298        assert!(!guard.is_current(started_at));
299        assert!(guard.is_current(newer));
300    }
301
302    #[test]
303    fn the_gate_debounces_rapid_revisions() {
304        let config = TranscriptConfig::default();
305        let mut gate = SpeculationGate::new(&config);
306        let t0 = Instant::now();
307
308        let first = TranscriptHypothesis {
309            stable_prefix: "what should we eat for dinner".into(),
310            ..Default::default()
311        };
312        assert_eq!(gate.consider(&first, false, t0), SpeculationDecision::Fire);
313
314        let second = TranscriptHypothesis {
315            stable_prefix: "what should we eat for dinner tonight with Rhea and Kushal nearby"
316                .into(),
317            ..Default::default()
318        };
319        assert_eq!(
320            gate.consider(&second, false, t0 + Duration::from_millis(50)),
321            SpeculationDecision::Debounced
322        );
323        assert_eq!(
324            gate.consider(&second, false, t0 + Duration::from_millis(400)),
325            SpeculationDecision::Fire
326        );
327    }
328
329    #[test]
330    fn the_gate_ignores_revisions_that_add_nothing() {
331        let mut gate = SpeculationGate::new(&TranscriptConfig::default());
332        let t0 = Instant::now();
333        let h = TranscriptHypothesis {
334            stable_prefix: "what should we eat for dinner".into(),
335            ..Default::default()
336        };
337        assert_eq!(gate.consider(&h, false, t0), SpeculationDecision::Fire);
338
339        let barely_more = TranscriptHypothesis {
340            stable_prefix: "what should we eat for dinner now".into(),
341            ..Default::default()
342        };
343        assert_eq!(
344            gate.consider(&barely_more, false, t0 + Duration::from_secs(5)),
345            SpeculationDecision::InsufficientNewContent
346        );
347    }
348
349    #[test]
350    fn a_strong_signal_or_a_final_transcript_bypasses_both_gates() {
351        let mut gate = SpeculationGate::new(&TranscriptConfig::default());
352        let t0 = Instant::now();
353        let h = TranscriptHypothesis {
354            stable_prefix: "tell me about Rhea".into(),
355            ..Default::default()
356        };
357        assert_eq!(gate.consider(&h, false, t0), SpeculationDecision::Fire);
358        assert_eq!(
359            gate.consider(&h, true, t0 + Duration::from_millis(1)),
360            SpeculationDecision::Fire
361        );
362
363        let finalised = TranscriptHypothesis {
364            stable_prefix: "tell me about Rhea".into(),
365            finalised: true,
366            ..Default::default()
367        };
368        assert_eq!(
369            gate.consider(&finalised, false, t0 + Duration::from_millis(2)),
370            SpeculationDecision::Fire
371        );
372    }
373
374    #[test]
375    fn nothing_stable_yet_means_nothing_to_speculate_on() {
376        let mut gate = SpeculationGate::new(&TranscriptConfig::default());
377        let empty = TranscriptHypothesis {
378            unstable_suffix: "wha".into(),
379            ..Default::default()
380        };
381        assert_eq!(
382            gate.consider(&empty, false, Instant::now()),
383            SpeculationDecision::NothingStable
384        );
385    }
386}