1use 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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
24pub struct TranscriptHypothesis {
25 pub stable_prefix: String,
27 pub unstable_suffix: String,
29 pub revision: u64,
31 pub finalised: bool,
33}
34
35impl TranscriptHypothesis {
36 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#[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 pub fn new(turn_id: TurnId) -> Self {
66 Self {
67 turn_id,
68 ..Default::default()
69 }
70 }
71
72 pub fn begin_turn(&mut self, turn_id: TurnId) {
74 *self = Self::new(turn_id);
75 }
76
77 pub fn turn_id(&self) -> TurnId {
79 self.turn_id
80 }
81
82 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 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 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 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#[derive(Debug, Default)]
134pub struct GenerationGuard {
135 current: AtomicU64,
136}
137
138impl GenerationGuard {
139 pub fn new() -> Self {
141 Self::default()
142 }
143
144 pub fn current(&self) -> u64 {
146 self.current.load(Ordering::Acquire)
147 }
148
149 pub fn advance(&self) -> u64 {
151 self.current.fetch_add(1, Ordering::AcqRel) + 1
152 }
153
154 pub fn is_current(&self, generation: u64) -> bool {
156 self.current() == generation
157 }
158}
159
160#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum SpeculationDecision {
176 Fire,
178 Debounced,
180 InsufficientNewContent,
182 NothingStable,
184}
185
186impl SpeculationGate {
187 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 pub fn reset(&mut self) {
199 self.last_fired_at = None;
200 self.last_query_tokens.clear();
201 }
202
203 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}