Skip to main content

aurum_core/remote/
long_form.rs

1//! Boundary-aware long-form STT planning, overlap stitch, and policy (JOE-2219).
2//!
3//! Replaces blind non-overlapping windows with silence-aware cuts and a bounded
4//! overlap when no quiet region exists. Deduplication is deterministic and never
5//! drops low-confidence text.
6
7use crate::error::{Result, UserError};
8use crate::providers::Segment;
9use crate::remote::stt_chunk::{ChunkWindow, DEFAULT_REMOTE_STT_CHUNK_SECS};
10use serde::{Deserialize, Serialize};
11
12/// Default silence search half-width around the target cut (±seconds).
13pub const DEFAULT_BOUNDARY_SEARCH_SECS: f64 = 15.0;
14/// Default minimum quiet duration to accept a silence boundary (seconds).
15pub const DEFAULT_MIN_SILENCE_SECS: f64 = 0.25;
16/// Default overlap when cutting without silence (seconds).
17pub const DEFAULT_OVERLAP_SECS: f64 = 1.5;
18/// Maximum overlap as a fraction of chunk duration.
19pub const DEFAULT_MAX_OVERLAP_FRACTION: f64 = 0.05;
20/// Max tokens examined for overlap dedupe.
21pub const MAX_DEDUPE_TOKENS: usize = 40;
22/// Minimum exact token overlap required to drop a later-chunk prefix.
23pub const MIN_DEDUPE_TOKENS: usize = 3;
24
25/// How a segment's timing was obtained (JOE-2219).
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27#[serde(rename_all = "snake_case")]
28pub enum TimestampSource {
29    /// Local model timing.
30    NativeModel,
31    /// Word timing from a remote ASR provider.
32    ProviderWord,
33    /// Segment timing from a provider.
34    ProviderSegment,
35    /// Provider timing shifted by chunk start offset.
36    ChunkOffset,
37    /// Aurum split text and estimated time proportionally.
38    Interpolated,
39    /// One full-duration span created because no provider timing existed.
40    SyntheticSpan,
41    /// No usable timing.
42    #[default]
43    Unavailable,
44}
45
46impl TimestampSource {
47    pub fn as_str(self) -> &'static str {
48        match self {
49            Self::NativeModel => "native_model",
50            Self::ProviderWord => "provider_word",
51            Self::ProviderSegment => "provider_segment",
52            Self::ChunkOffset => "chunk_offset",
53            Self::Interpolated => "interpolated",
54            Self::SyntheticSpan => "synthetic_span",
55            Self::Unavailable => "unavailable",
56        }
57    }
58
59    /// Conservative reliability for the legacy `timestamps_reliable` boolean.
60    pub fn is_reliable(self) -> bool {
61        matches!(
62            self,
63            Self::NativeModel | Self::ProviderWord | Self::ProviderSegment | Self::ChunkOffset
64        )
65    }
66
67    /// Approximate / non-native timing that SRT rejects by default.
68    pub fn is_approximate(self) -> bool {
69        matches!(
70            self,
71            Self::Interpolated | Self::SyntheticSpan | Self::Unavailable
72        )
73    }
74}
75
76/// Why a planned cut was chosen.
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum BoundaryKind {
80    Silence,
81    TargetWithOverlap,
82    ShortSingle,
83    FixedFallback,
84}
85
86/// Validated long-form chunking policy (not free-form env vars).
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88pub struct LongFormPolicy {
89    /// Target window length in seconds (~210 product default).
90    pub target_secs: f64,
91    pub min_secs: f64,
92    pub max_secs: f64,
93    /// Search ± this many seconds around the target for silence.
94    pub search_secs: f64,
95    pub min_silence_secs: f64,
96    /// RMS threshold relative to peak (0..1) for "quiet".
97    pub silence_rms_ratio: f64,
98    pub overlap_secs: f64,
99    pub max_overlap_fraction: f64,
100}
101
102impl Default for LongFormPolicy {
103    fn default() -> Self {
104        Self {
105            target_secs: DEFAULT_REMOTE_STT_CHUNK_SECS,
106            min_secs: 30.0,
107            max_secs: 300.0,
108            search_secs: DEFAULT_BOUNDARY_SEARCH_SECS,
109            min_silence_secs: DEFAULT_MIN_SILENCE_SECS,
110            silence_rms_ratio: 0.08,
111            overlap_secs: DEFAULT_OVERLAP_SECS,
112            max_overlap_fraction: DEFAULT_MAX_OVERLAP_FRACTION,
113        }
114    }
115}
116
117impl LongFormPolicy {
118    pub fn validate(&self) -> Result<()> {
119        for (name, v) in [
120            ("target_secs", self.target_secs),
121            ("min_secs", self.min_secs),
122            ("max_secs", self.max_secs),
123            ("search_secs", self.search_secs),
124            ("min_silence_secs", self.min_silence_secs),
125            ("silence_rms_ratio", self.silence_rms_ratio),
126            ("overlap_secs", self.overlap_secs),
127            ("max_overlap_fraction", self.max_overlap_fraction),
128        ] {
129            if !v.is_finite() || v < 0.0 {
130                return Err(UserError::InvalidConfig {
131                    reason: format!("LongFormPolicy.{name} must be finite and non-negative"),
132                }
133                .into());
134            }
135        }
136        if self.target_secs <= 0.0 || self.min_secs <= 0.0 || self.max_secs <= 0.0 {
137            return Err(UserError::InvalidConfig {
138                reason: "LongFormPolicy window sizes must be > 0".into(),
139            }
140            .into());
141        }
142        if self.min_secs > self.target_secs || self.target_secs > self.max_secs {
143            return Err(UserError::InvalidConfig {
144                reason: "LongFormPolicy requires min_secs ≤ target_secs ≤ max_secs".into(),
145            }
146            .into());
147        }
148        if self.silence_rms_ratio > 1.0 {
149            return Err(UserError::InvalidConfig {
150                reason: "LongFormPolicy.silence_rms_ratio must be ≤ 1.0".into(),
151            }
152            .into());
153        }
154        if self.max_overlap_fraction > 0.5 {
155            return Err(UserError::InvalidConfig {
156                reason: "LongFormPolicy.max_overlap_fraction must be ≤ 0.5".into(),
157            }
158            .into());
159        }
160        // Zero silence duration becomes 1 sample and stalls the scan stride (v0.0.23).
161        if self.min_silence_secs <= 0.0 {
162            return Err(UserError::InvalidConfig {
163                reason: "LongFormPolicy.min_silence_secs must be > 0".into(),
164            }
165            .into());
166        }
167        Ok(())
168    }
169
170    /// Build from env `AURUM_REMOTE_STT_CHUNK_SECS` when set, else defaults.
171    pub fn from_env_or_default() -> Self {
172        let mut p = Self::default();
173        if let Ok(s) = std::env::var("AURUM_REMOTE_STT_CHUNK_SECS") {
174            if let Ok(v) = s.trim().parse::<f64>() {
175                if v.is_finite() && v > 0.0 {
176                    p.target_secs = v;
177                    p.max_secs = p.max_secs.max(v);
178                }
179            }
180        }
181        p
182    }
183}
184
185/// One planned window with boundary metadata.
186#[derive(Debug, Clone, PartialEq)]
187pub struct PlannedWindow {
188    pub window: ChunkWindow,
189    pub kind: BoundaryKind,
190    /// Overlap duration in seconds with the previous window (0 for first).
191    pub overlap_secs: f64,
192}
193
194/// Plan silence-aware (or overlap) windows covering all samples.
195///
196/// [`PlannedWindow::overlap_secs`] is the overlap **with the previous window**
197/// (0 for the first). When a hard cut is used, the *next* window records the
198/// overlap so stitchers can dedupe against the predecessor.
199pub fn plan_boundary_windows(
200    samples: &[f32],
201    sample_rate: u32,
202    policy: &LongFormPolicy,
203) -> Result<Vec<PlannedWindow>> {
204    policy.validate()?;
205    let total = samples.len();
206    if total == 0 || sample_rate == 0 {
207        return Ok(vec![PlannedWindow {
208            window: ChunkWindow {
209                start_sample: 0,
210                end_sample: total,
211                offset_secs: 0.0,
212            },
213            kind: BoundaryKind::ShortSingle,
214            overlap_secs: 0.0,
215        }]);
216    }
217
218    let target = ((policy.target_secs * f64::from(sample_rate)).round() as usize).max(1);
219    if total <= target {
220        return Ok(vec![PlannedWindow {
221            window: ChunkWindow {
222                start_sample: 0,
223                end_sample: total,
224                offset_secs: 0.0,
225            },
226            kind: BoundaryKind::ShortSingle,
227            overlap_secs: 0.0,
228        }]);
229    }
230
231    let min_len = ((policy.min_secs * f64::from(sample_rate)).round() as usize).max(1);
232    let max_len = ((policy.max_secs * f64::from(sample_rate)).round() as usize).max(min_len);
233    let search = ((policy.search_secs * f64::from(sample_rate)).round() as usize).max(1);
234    let min_silence = ((policy.min_silence_secs * f64::from(sample_rate)).round() as usize).max(1);
235    let peak = peak_abs(samples).max(1e-9);
236    let quiet_thresh = peak * policy.silence_rms_ratio as f32;
237
238    let mut out = Vec::new();
239    let mut start = 0usize;
240    // Overlap this window has with its predecessor (set when finishing the previous cut).
241    let mut pending_overlap_secs = 0.0f64;
242    while start < total {
243        let remaining = total - start;
244        if remaining <= max_len {
245            out.push(PlannedWindow {
246                window: ChunkWindow {
247                    start_sample: start,
248                    end_sample: total,
249                    offset_secs: start as f64 / f64::from(sample_rate),
250                },
251                kind: if out.is_empty() {
252                    BoundaryKind::ShortSingle
253                } else if pending_overlap_secs > 0.0 {
254                    BoundaryKind::TargetWithOverlap
255                } else {
256                    BoundaryKind::Silence
257                },
258                overlap_secs: pending_overlap_secs,
259            });
260            break;
261        }
262
263        let ideal = (start + target).min(total);
264        let search_lo = ideal.saturating_sub(search).max(start + min_len);
265        let search_hi = (ideal + search).min(start + max_len).min(total);
266
267        let silence_cut =
268            find_silence_boundary(samples, search_lo, search_hi, min_silence, quiet_thresh);
269
270        let (end, kind, overlap_for_next) = if let Some(cut) = silence_cut {
271            (cut, BoundaryKind::Silence, 0.0f64)
272        } else {
273            // Hard cut at ideal; next window starts early by `overlap` samples.
274            let end = ideal.min(total);
275            let raw_overlap = ((policy.overlap_secs * f64::from(sample_rate)).round() as usize)
276                .min(((end - start) as f64 * policy.max_overlap_fraction).round() as usize);
277            let overlap = raw_overlap.min(end.saturating_sub(start) / 4);
278            (
279                end,
280                BoundaryKind::TargetWithOverlap,
281                overlap as f64 / f64::from(sample_rate),
282            )
283        };
284
285        let end = end.max(start + 1).min(total);
286        out.push(PlannedWindow {
287            window: ChunkWindow {
288                start_sample: start,
289                end_sample: end,
290                offset_secs: start as f64 / f64::from(sample_rate),
291            },
292            // Kind describes how this window ends / how the next boundary was chosen.
293            kind,
294            overlap_secs: pending_overlap_secs,
295        });
296
297        if end >= total {
298            break;
299        }
300        // Advance: for overlap cuts, next window starts `overlap` samples before end.
301        let overlap_samples = if overlap_for_next > 0.0 {
302            ((overlap_for_next * f64::from(sample_rate)).round() as usize).min(end - start)
303        } else {
304            0
305        };
306        let next = end.saturating_sub(overlap_samples);
307        pending_overlap_secs = overlap_for_next;
308        if next <= start {
309            // Degenerate: force progress without infinite loop.
310            start = end;
311            pending_overlap_secs = 0.0;
312        } else {
313            start = next;
314        }
315    }
316
317    // Coverage: first starts at 0, last ends at total, no gaps in exclusive coverage of unique audio.
318    if let Some(first) = out.first() {
319        if first.window.start_sample != 0 {
320            return Err(UserError::Other {
321                message: "long-form planner: first window must start at sample 0".into(),
322            }
323            .into());
324        }
325        if first.overlap_secs != 0.0 {
326            return Err(UserError::Other {
327                message: "long-form planner: first window must have zero predecessor overlap"
328                    .into(),
329            }
330            .into());
331        }
332    }
333    if let Some(last) = out.last() {
334        if last.window.end_sample != total {
335            return Err(UserError::Other {
336                message: "long-form planner: last window must end at total samples".into(),
337            }
338            .into());
339        }
340    }
341    Ok(out)
342}
343
344fn peak_abs(samples: &[f32]) -> f32 {
345    let mut p = 0.0f32;
346    for &s in samples {
347        p = p.max(s.abs());
348    }
349    p
350}
351
352/// Find the earliest quiet region of `min_silence` samples with lowest mean energy.
353fn find_silence_boundary(
354    samples: &[f32],
355    lo: usize,
356    hi: usize,
357    min_silence: usize,
358    quiet_thresh: f32,
359) -> Option<usize> {
360    if hi <= lo + min_silence {
361        return None;
362    }
363    let mut best: Option<(f64, usize)> = None; // (energy, cut_end)
364    let mut i = lo;
365    while i + min_silence <= hi {
366        let window = &samples[i..i + min_silence];
367        let mut energy = 0.0f64;
368        let mut all_quiet = true;
369        for &s in window {
370            let a = s.abs() as f64;
371            energy += a * a;
372            if s.abs() > quiet_thresh {
373                all_quiet = false;
374                break;
375            }
376        }
377        if all_quiet {
378            energy /= min_silence as f64;
379            let cut = i + min_silence / 2;
380            match best {
381                None => best = Some((energy, cut)),
382                Some((e, c)) => {
383                    if energy < e - 1e-18 || ((energy - e).abs() < 1e-18 && cut < c) {
384                        best = Some((energy, cut));
385                    }
386                }
387            }
388        }
389        // Always advance by at least one sample so min_silence=1 cannot hang.
390        let step = (min_silence.max(1) / 2).max(1);
391        i += step;
392    }
393    best.map(|(_, cut)| cut)
394}
395
396// ---------------------------------------------------------------------------
397// Overlap text / segment deduplication
398// ---------------------------------------------------------------------------
399
400/// Result of stitching two transcript pieces across an overlap.
401#[derive(Debug, Clone, PartialEq)]
402pub struct DedupeOutcome {
403    pub text: String,
404    pub dropped_prefix_tokens: usize,
405    pub confident: bool,
406    pub warning: Option<String>,
407}
408
409/// Normalize tokens for dedupe (lowercase alnum words).
410pub fn normalize_tokens(s: &str) -> Vec<String> {
411    s.split(|c: char| !c.is_alphanumeric())
412        .filter(|w| !w.is_empty())
413        .map(|w| w.to_ascii_lowercase())
414        .collect()
415}
416
417/// Deduplicate `later` against the suffix of `earlier` within a bounded token window.
418///
419/// Never drops content when confidence is below threshold.
420pub fn dedupe_overlap_text(earlier: &str, later: &str) -> DedupeOutcome {
421    let earlier_t = normalize_tokens(earlier);
422    let later_t = normalize_tokens(later);
423    if earlier_t.is_empty() || later_t.is_empty() {
424        return DedupeOutcome {
425            text: later.to_string(),
426            dropped_prefix_tokens: 0,
427            confident: true,
428            warning: None,
429        };
430    }
431
432    let max_n = MAX_DEDUPE_TOKENS.min(earlier_t.len()).min(later_t.len());
433    let mut best = 0usize;
434    for n in (MIN_DEDUPE_TOKENS..=max_n).rev() {
435        let suffix = &earlier_t[earlier_t.len() - n..];
436        let prefix = &later_t[..n];
437        if suffix == prefix {
438            best = n;
439            break;
440        }
441    }
442
443    if best >= MIN_DEDUPE_TOKENS {
444        // Drop the first `best` raw tokens from later, preserving remaining punctuation.
445        let stripped = drop_n_tokens(later, best);
446        DedupeOutcome {
447            text: stripped,
448            dropped_prefix_tokens: best,
449            confident: true,
450            warning: None,
451        }
452    } else {
453        DedupeOutcome {
454            text: later.to_string(),
455            dropped_prefix_tokens: 0,
456            confident: false,
457            warning: Some(
458                "overlap could not be resolved confidently; retained full later-chunk text".into(),
459            ),
460        }
461    }
462}
463
464fn drop_n_tokens(s: &str, n: usize) -> String {
465    if n == 0 {
466        return s.to_string();
467    }
468    let mut seen = 0usize;
469    let mut in_tok = false;
470    let mut cut = 0usize;
471    for (i, ch) in s.char_indices() {
472        if ch.is_alphanumeric() {
473            if !in_tok {
474                in_tok = true;
475                seen += 1;
476                if seen > n {
477                    cut = i;
478                    break;
479                }
480            }
481        } else {
482            in_tok = false;
483            if seen >= n {
484                // skip whitespace after dropped tokens
485                cut = i;
486                if !ch.is_whitespace() {
487                    break;
488                }
489            }
490        }
491        if seen >= n && !in_tok && !ch.is_whitespace() {
492            cut = i;
493            break;
494        }
495    }
496    if seen < n {
497        return String::new();
498    }
499    // Advance past leftover whitespace
500    let rest = s[cut..].trim_start();
501    rest.to_string()
502}
503
504/// Join transcript parts with overlap-aware dedupe (deterministic).
505pub fn stitch_text_with_overlap(parts: &[(String, f64)]) -> (String, Vec<String>) {
506    let mut warnings = Vec::new();
507    if parts.is_empty() {
508        return (String::new(), warnings);
509    }
510    let mut out = parts[0].0.trim().to_string();
511    for (text, overlap_secs) in parts.iter().skip(1) {
512        let t = text.trim();
513        if t.is_empty() {
514            continue;
515        }
516        if *overlap_secs > 0.0 {
517            let d = dedupe_overlap_text(&out, t);
518            if let Some(w) = d.warning {
519                warnings.push(w);
520            }
521            if d.text.is_empty() {
522                continue;
523            }
524            if !out.is_empty() && !out.ends_with(char::is_whitespace) {
525                out.push(' ');
526            }
527            out.push_str(d.text.trim());
528        } else {
529            if !out.is_empty() && !out.ends_with(char::is_whitespace) {
530                out.push(' ');
531            }
532            out.push_str(t);
533        }
534    }
535    (out, warnings)
536}
537
538/// Deduplicate segments across an overlap boundary using text + time evidence.
539pub fn dedupe_segments_overlap(
540    earlier: &[Segment],
541    later: &[Segment],
542    overlap_secs: f64,
543    later_offset_secs: f64,
544) -> (Vec<Segment>, Option<String>) {
545    if later.is_empty() {
546        return (Vec::new(), None);
547    }
548    if overlap_secs <= 0.0 || earlier.is_empty() {
549        return (later.to_vec(), None);
550    }
551    // If the first later segment text is a prefix-overlap of the last earlier segment, drop it.
552    let last_earlier = earlier.last().map(|s| s.text()).unwrap_or("");
553    let first_later = later[0].text();
554    let d = dedupe_overlap_text(last_earlier, first_later);
555    if d.confident && d.dropped_prefix_tokens > 0 {
556        let mut out = Vec::with_capacity(later.len());
557        if d.text.trim().is_empty() {
558            out.extend(later.iter().skip(1).cloned());
559        } else {
560            let mut first = later[0].clone();
561            first.set_text(d.text);
562            // Keep times; source remains whatever it was (usually chunk_offset).
563            let _ = later_offset_secs;
564            out.push(first);
565            out.extend(later.iter().skip(1).cloned());
566        }
567        return (out, None);
568    }
569    if !d.confident {
570        return (
571            later.to_vec(),
572            Some("segment overlap not confidently deduped; retained later segments".into()),
573        );
574    }
575    (later.to_vec(), None)
576}
577
578/// Whether SRT should fail closed for this provenance set.
579pub fn srt_requires_allow_approximate(sources: &[TimestampSource]) -> bool {
580    sources.iter().any(|s| s.is_approximate())
581}
582
583/// Derive legacy `timestamps_reliable` from provenance.
584pub fn derive_timestamps_reliable(sources: &[TimestampSource]) -> bool {
585    !sources.is_empty() && sources.iter().all(|s| s.is_reliable())
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591    use crate::audio::WHISPER_SAMPLE_RATE;
592
593    #[test]
594    fn policy_rejects_inverted_bounds() {
595        let p = LongFormPolicy {
596            min_secs: 250.0,
597            target_secs: 210.0,
598            ..Default::default()
599        };
600        assert!(p.validate().is_err());
601    }
602
603    #[test]
604    fn short_audio_single_window() {
605        let sr = WHISPER_SAMPLE_RATE;
606        let n = sr as usize * 30;
607        let samples = vec![0.1f32; n];
608        let plan = plan_boundary_windows(&samples, sr, &LongFormPolicy::default()).unwrap();
609        assert_eq!(plan.len(), 1);
610        assert_eq!(plan[0].kind, BoundaryKind::ShortSingle);
611        assert_eq!(plan[0].window.end_sample, n);
612    }
613
614    #[test]
615    fn silence_boundary_preferred_over_hard_cut() {
616        let sr = WHISPER_SAMPLE_RATE;
617        // 400s of speech-like noise with silence near 210s.
618        let n = (400.0 * f64::from(sr)) as usize;
619        let mut samples = vec![0.3f32; n];
620        let silence_at = (205.0 * f64::from(sr)) as usize;
621        let silence_len = (sr as usize) * 2; // 2s silence
622        for s in samples.iter_mut().skip(silence_at).take(silence_len) {
623            *s = 0.0;
624        }
625        let plan = plan_boundary_windows(&samples, sr, &LongFormPolicy::default()).unwrap();
626        assert!(plan.len() >= 2);
627        // First cut should land near silence, not exactly 210 if silence found.
628        let first_end = plan[0].window.end_sample as f64 / f64::from(sr);
629        assert!((200.0..220.0).contains(&first_end), "first end {first_end}");
630        assert_eq!(plan[0].kind, BoundaryKind::Silence);
631        assert_eq!(plan.last().unwrap().window.end_sample, n);
632    }
633
634    #[test]
635    fn continuous_noise_uses_overlap() {
636        let sr = WHISPER_SAMPLE_RATE;
637        let n = (500.0 * f64::from(sr)) as usize;
638        let samples = vec![0.4f32; n];
639        let plan = plan_boundary_windows(&samples, sr, &LongFormPolicy::default()).unwrap();
640        assert!(plan.len() >= 2);
641        assert!(plan.iter().any(|p| matches!(
642            p.kind,
643            BoundaryKind::TargetWithOverlap | BoundaryKind::FixedFallback
644        )));
645        // Overlap is recorded on the *later* window (relationship with previous).
646        assert_eq!(plan[0].overlap_secs, 0.0);
647        for w in plan.windows(2) {
648            if w[1].overlap_secs > 0.0 {
649                assert!(w[1].window.start_sample < w[0].window.end_sample);
650            }
651        }
652        assert!(
653            plan.iter().skip(1).any(|p| p.overlap_secs > 0.0),
654            "expected at least one later window with predecessor overlap"
655        );
656        assert_eq!(plan[0].window.start_sample, 0);
657        assert_eq!(plan.last().unwrap().window.end_sample, n);
658    }
659
660    #[test]
661    fn min_silence_zero_rejected() {
662        let p = LongFormPolicy {
663            min_silence_secs: 0.0,
664            ..Default::default()
665        };
666        assert!(p.validate().is_err());
667    }
668
669    #[test]
670    fn two_window_hard_cut_overlap_on_later() {
671        let sr = WHISPER_SAMPLE_RATE;
672        // Continuous noise, ~2× target → two windows with hard-cut overlap.
673        let n = (420.0 * f64::from(sr)) as usize;
674        let samples = vec![0.5f32; n];
675        let plan = plan_boundary_windows(&samples, sr, &LongFormPolicy::default()).unwrap();
676        assert!(plan.len() >= 2, "plan len {}", plan.len());
677        assert_eq!(plan[0].overlap_secs, 0.0);
678        // Second window must carry the overlap used to rewind its start.
679        if plan[0].kind == BoundaryKind::TargetWithOverlap || plan.len() == 2 {
680            assert!(
681                plan[1].overlap_secs > 0.0,
682                "later window overlap_secs={}, kind0={:?}",
683                plan[1].overlap_secs,
684                plan[0].kind
685            );
686            assert!(plan[1].window.start_sample < plan[0].window.end_sample);
687        }
688    }
689
690    #[test]
691    fn dedupe_exact_overlap() {
692        let d = dedupe_overlap_text(
693            "the quick brown fox jumps over",
694            "fox jumps over the lazy dog",
695        );
696        assert!(d.confident);
697        assert!(d.dropped_prefix_tokens >= 3);
698        assert_eq!(d.text.trim(), "the lazy dog");
699    }
700
701    #[test]
702    fn dedupe_low_confidence_retains() {
703        let d = dedupe_overlap_text("alpha beta gamma", "delta epsilon zeta");
704        assert!(!d.confident);
705        assert_eq!(d.text, "delta epsilon zeta");
706        assert!(d.warning.is_some());
707    }
708
709    #[test]
710    fn srt_approximate_gate() {
711        assert!(srt_requires_allow_approximate(&[
712            TimestampSource::ChunkOffset,
713            TimestampSource::Interpolated
714        ]));
715        assert!(!srt_requires_allow_approximate(&[
716            TimestampSource::NativeModel,
717            TimestampSource::ChunkOffset
718        ]));
719        assert!(!derive_timestamps_reliable(&[
720            TimestampSource::Interpolated
721        ]));
722        assert!(derive_timestamps_reliable(&[
723            TimestampSource::ProviderSegment
724        ]));
725    }
726
727    #[test]
728    fn stitch_text_with_overlap_dedupes() {
729        let (text, warns) = stitch_text_with_overlap(&[
730            ("hello world from aurum".into(), 0.0),
731            ("from aurum systems".into(), 1.5),
732        ]);
733        assert!(text.contains("hello"));
734        assert!(text.contains("systems"));
735        assert!(warns.is_empty() || text.contains("from"));
736    }
737}