Skip to main content

ftts_core/
health.rs

1//! Runtime health: catch a run going wrong *while it is still running*.
2//!
3//! [`admission`](crate::admission) answers "will this fit" before anything is allocated. This
4//! module answers a different question: the request was affordable and started, so **is it still
5//! behaving?** Every detector here exists because the corresponding failure produces output that
6//! looks superficially fine — a plausible-length WAV, a completed run, a nonzero byte count — and
7//! an agent consuming `ftts` cannot listen to it. Silence, a stuck decoder, and a repetition loop
8//! all sound like "success" to a program.
9//!
10//! Each detector is a small, allocation-free state machine that a hot loop can call per frame, and
11//! each violation carries a remedy rather than a bare label. Nothing here samples wall-clock or
12//! spawns a thread: the caller supplies `Instant`s, so tests inject time instead of sleeping.
13//!
14//! # Why the seam policy is configurable
15//!
16//! A NaN check over every activation of every layer is a real cost in the steady-state decode
17//! loop, and the loop is the whole project. So the policy is explicit: [`SeamPolicy::All`] while
18//! developing a kernel, [`SeamPolicy::Sampled`] in production where a NaN that appears at all will
19//! almost certainly appear again within a few frames, [`SeamPolicy::Off`] only for a measured
20//! benchmark. What is *not* offered is a silent default that quietly stops checking.
21//!
22//! Bead: `frankentts-v-reliability-d65`.
23
24use core::fmt;
25use core::time::Duration;
26
27use crate::admission::StopReason;
28
29/// A named point in the pipeline where values can be inspected.
30///
31/// Deliberately coarse. These are the boundaries where a numeric fault becomes *observable*, not
32/// every tensor: a NaN born in the talker shows up at its logits, and one born in the codec shows
33/// up in the PCM.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum Seam {
36    /// Talker logits, before sampling.
37    TalkerLogits,
38    /// Microdecoder logits for one residual depth.
39    MicrodecoderLogits,
40    /// Codec decoder output, before PCM quantisation.
41    CodecOutput,
42    /// Final PCM handed to the sink.
43    Pcm,
44}
45
46impl Seam {
47    /// Stable wire string for robot mode.
48    #[must_use]
49    pub const fn as_str(self) -> &'static str {
50        match self {
51            Self::TalkerLogits => "talker_logits",
52            Self::MicrodecoderLogits => "microdecoder_logits",
53            Self::CodecOutput => "codec_output",
54            Self::Pcm => "pcm",
55        }
56    }
57}
58
59/// How often numeric seams are checked.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum SeamPolicy {
62    /// Never check. Only for a measured benchmark; a run under this policy may not be reported as
63    /// numerically clean, because nothing looked.
64    Off,
65    /// Check every `every`-th call. A NaN that occurs at all recurs within a few frames in
66    /// practice, so sampling trades a bounded detection delay for a hot loop that stays hot.
67    Sampled { every: u32 },
68    /// Check every call. The kernel-development setting.
69    All,
70}
71
72impl SeamPolicy {
73    /// Whether a run under this policy is entitled to claim it was numerically checked.
74    ///
75    /// `Off` is not. This exists so a report can say "unchecked" instead of implying clean.
76    #[must_use]
77    pub const fn is_checking(self) -> bool {
78        !matches!(self, Self::Off)
79    }
80}
81
82/// A detected runtime-health problem.
83///
84/// Every variant is `Copy` and carries only scalars, so it can travel through the observer without
85/// allocating on the hot path.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub enum HealthViolation {
88    /// A non-finite value reached a seam.
89    NonFinite {
90        seam: Seam,
91        /// Index within the inspected slice, so the fault is locatable.
92        index: usize,
93        /// Whether it was NaN (`true`) or an infinity.
94        is_nan: bool,
95    },
96    /// No frame boundary was reached within the watchdog timeout.
97    NoProgress {
98        frames_emitted: u64,
99        stalled_millis: u64,
100    },
101    /// The reported stop reason contradicts the observed counters.
102    StopInconsistent {
103        claimed: StopReason,
104        frames_emitted: u64,
105        frame_cap: u64,
106    },
107    /// One token, or a short cycle, repeated past the runaway threshold.
108    RepetitionRunaway { token: u32, repeats: u32 },
109    /// Output stayed below the silence floor for longer than allowed.
110    OutputSilent { silent_millis: u64 },
111    /// An optimised kernel failed its selftest and the certified scalar path took over.
112    KernelDemoted { from: KernelTier, to: KernelTier },
113    /// Sustained throughput fell materially below the opening window.
114    ThermalDegraded {
115        /// Percent below baseline, rounded down.
116        percent_below_baseline: u32,
117    },
118}
119
120impl HealthViolation {
121    /// Stable wire string for robot mode.
122    #[must_use]
123    pub const fn as_str(self) -> &'static str {
124        match self {
125            Self::NonFinite { .. } => "non_finite",
126            Self::NoProgress { .. } => "no_progress",
127            Self::StopInconsistent { .. } => "stop_inconsistent",
128            Self::RepetitionRunaway { .. } => "repetition_runaway",
129            Self::OutputSilent { .. } => "output_silent",
130            Self::KernelDemoted { .. } => "kernel_demoted",
131            Self::ThermalDegraded { .. } => "thermal_degraded",
132        }
133    }
134
135    /// Whether this violation means the audio must not be presented as a clean result.
136    ///
137    /// A demotion and a thermal report are *informational*: the run is still correct, just slower
138    /// or on a different kernel tier. The rest mean the output is wrong, stuck, or empty.
139    #[must_use]
140    pub const fn invalidates_output(self) -> bool {
141        !matches!(
142            self,
143            Self::KernelDemoted { .. } | Self::ThermalDegraded { .. }
144        )
145    }
146
147    /// What the caller should actually do about it.
148    #[must_use]
149    pub const fn remedy(self) -> &'static str {
150        match self {
151            Self::NonFinite { .. } => {
152                "a non-finite value reached this seam: rerun with FTTS_MATH_MODE=strict; if it \
153                 persists there, the fault is in the kernel rather than a fast-math approximation"
154            }
155            Self::NoProgress { .. } => {
156                "generation stopped advancing: cancel and retry; if reproducible, capture the \
157                 prompt — a stalled decode loop is a bug, not a capacity problem"
158            }
159            Self::StopInconsistent { .. } => {
160                "the stop reason disagrees with the frame counters; treat this result as \
161                 untrusted and report it — one of the two is lying about whether audio was cut off"
162            }
163            Self::RepetitionRunaway { .. } => {
164                "the model entered a repetition loop: raise the repetition penalty or shorten the \
165                 input; the audio to this point is usable, everything after the loop began is not"
166            }
167            Self::OutputSilent { .. } => {
168                "output was silent past the allowed window: check the voice pack and reference \
169                 audio; a silent result is a failure even though it produced bytes"
170            }
171            Self::KernelDemoted { .. } => {
172                "an optimised kernel failed its selftest and the certified scalar path took over: \
173                 results stay correct and slower; report the ISA and CPU"
174            }
175            Self::ThermalDegraded { .. } => {
176                "sustained throughput fell below the opening window: expected under thermal load; \
177                 do not quote this run's rate as a steady-state number"
178            }
179        }
180    }
181}
182
183impl fmt::Display for HealthViolation {
184    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185        match self {
186            Self::NonFinite {
187                seam,
188                index,
189                is_nan,
190            } => write!(
191                formatter,
192                "{} at {} index {index}",
193                if *is_nan { "NaN" } else { "infinity" },
194                seam.as_str()
195            ),
196            Self::NoProgress {
197                frames_emitted,
198                stalled_millis,
199            } => write!(
200                formatter,
201                "no frame progress for {stalled_millis} ms after {frames_emitted} frame(s)"
202            ),
203            Self::StopInconsistent {
204                claimed,
205                frames_emitted,
206                frame_cap,
207            } => write!(
208                formatter,
209                "stop reason {} contradicts {frames_emitted} frame(s) against a cap of {frame_cap}",
210                claimed.as_str()
211            ),
212            Self::RepetitionRunaway { token, repeats } => {
213                write!(formatter, "token {token} repeated {repeats} times")
214            }
215            Self::OutputSilent { silent_millis } => {
216                write!(formatter, "output silent for {silent_millis} ms")
217            }
218            Self::KernelDemoted { from, to } => write!(
219                formatter,
220                "kernel demoted from {} to {}",
221                from.as_str(),
222                to.as_str()
223            ),
224            Self::ThermalDegraded {
225                percent_below_baseline,
226            } => write!(
227                formatter,
228                "throughput {percent_below_baseline}% below the opening window"
229            ),
230        }
231    }
232}
233
234// --------------------------------------------------------------------------------------
235// NaN / Inf seams
236// --------------------------------------------------------------------------------------
237
238/// Checks numeric seams according to a [`SeamPolicy`].
239#[derive(Clone, Debug)]
240pub struct NumericGuard {
241    policy: SeamPolicy,
242    calls: u32,
243}
244
245impl NumericGuard {
246    #[must_use]
247    pub const fn new(policy: SeamPolicy) -> Self {
248        Self { policy, calls: 0 }
249    }
250
251    #[must_use]
252    pub const fn policy(&self) -> SeamPolicy {
253        self.policy
254    }
255
256    /// Inspect one slice at `seam`, honouring the sampling policy.
257    ///
258    /// Reports the **first** offending index rather than a count: the first NaN is where the fault
259    /// entered, and everything after it is downstream contamination.
260    pub fn check(&mut self, seam: Seam, values: &[f32]) -> Result<(), HealthViolation> {
261        if !self.should_check() {
262            return Ok(());
263        }
264        for (index, value) in values.iter().enumerate() {
265            if !value.is_finite() {
266                return Err(HealthViolation::NonFinite {
267                    seam,
268                    index,
269                    is_nan: value.is_nan(),
270                });
271            }
272        }
273        Ok(())
274    }
275
276    fn should_check(&mut self) -> bool {
277        match self.policy {
278            SeamPolicy::Off => false,
279            SeamPolicy::All => true,
280            SeamPolicy::Sampled { every } => {
281                // `every == 0` would divide by zero; treat it as "every call" rather than
282                // silently disabling the guard, because a misconfigured sampler must not be the
283                // reason nothing was checked.
284                if every <= 1 {
285                    return true;
286                }
287                let due = self.calls.is_multiple_of(every);
288                self.calls = self.calls.wrapping_add(1);
289                due
290            }
291        }
292    }
293}
294
295// --------------------------------------------------------------------------------------
296// No-progress watchdog
297// --------------------------------------------------------------------------------------
298
299/// Detects a decode loop that stopped advancing.
300///
301/// Takes the current time from the caller rather than reading the clock, so a test proves the
302/// stall behaviour in microseconds instead of sleeping through the timeout.
303#[derive(Clone, Debug)]
304pub struct ProgressWatchdog<T> {
305    timeout: Duration,
306    last_progress: T,
307    frames_emitted: u64,
308}
309
310impl<T: Copy + core::ops::Sub<T, Output = Duration>> ProgressWatchdog<T> {
311    #[must_use]
312    pub const fn new(timeout: Duration, started: T) -> Self {
313        Self {
314            timeout,
315            last_progress: started,
316            frames_emitted: 0,
317        }
318    }
319
320    /// Record a frame boundary, resetting the stall timer.
321    pub fn record_frame(&mut self, now: T) {
322        self.frames_emitted += 1;
323        self.last_progress = now;
324    }
325
326    #[must_use]
327    pub const fn frames_emitted(&self) -> u64 {
328        self.frames_emitted
329    }
330
331    /// Fail if no frame has been recorded within the timeout.
332    pub fn check(&self, now: T) -> Result<(), HealthViolation> {
333        let stalled = now - self.last_progress;
334        if stalled > self.timeout {
335            return Err(HealthViolation::NoProgress {
336                frames_emitted: self.frames_emitted,
337                stalled_millis: u64::try_from(stalled.as_millis()).unwrap_or(u64::MAX),
338            });
339        }
340        Ok(())
341    }
342}
343
344// --------------------------------------------------------------------------------------
345// Stop-reason consistency
346// --------------------------------------------------------------------------------------
347
348/// Cross-check a claimed stop reason against the counters that should corroborate it.
349///
350/// This is a guard against the *reporting* path lying, which matters more here than usual: the
351/// whole truncation story rests on `StopReason` being trustworthy, and an agent has no way to
352/// verify it by listening. Two contradictions are detectable without model knowledge:
353///
354/// - `EndOfSpeech` while sitting exactly on the frame cap. Landing on the cap by coincidence is
355///   possible but overwhelmingly likely to be a cap-stop mislabelled as a clean finish — which is
356///   precisely the counterfeit green this bead exists to prevent.
357/// - `FrameCapReached` while short of the cap. The cap demonstrably did not stop it.
358pub fn check_stop_consistency(
359    claimed: StopReason,
360    frames_emitted: u64,
361    frame_cap: u64,
362) -> Result<(), HealthViolation> {
363    let inconsistent = match claimed {
364        StopReason::EndOfSpeech => frames_emitted >= frame_cap,
365        StopReason::FrameCapReached => frames_emitted < frame_cap,
366        // A duration limit is orthogonal to the frame cap, and a cancellation can land anywhere.
367        StopReason::DurationLimitReached | StopReason::Cancelled => false,
368    };
369    if inconsistent {
370        return Err(HealthViolation::StopInconsistent {
371            claimed,
372            frames_emitted,
373            frame_cap,
374        });
375    }
376    Ok(())
377}
378
379// --------------------------------------------------------------------------------------
380// Repetition runaway
381// --------------------------------------------------------------------------------------
382
383/// Detects the classic autoregressive failure: the model latching onto a token or a short cycle.
384///
385/// Tracks consecutive repeats of a single token and, separately, a repeating short cycle — a
386/// two-token ping-pong never trips a consecutive-repeat counter but is just as dead.
387#[derive(Clone, Debug)]
388pub struct RunawayDetector {
389    max_consecutive: u32,
390    max_cycle_repeats: u32,
391    last: Option<u32>,
392    consecutive: u32,
393    recent: [u32; Self::CYCLE_WINDOW],
394    filled: usize,
395    cycle_repeats: u32,
396}
397
398impl RunawayDetector {
399    /// Longest cycle length considered. Longer cycles are the sampler's business, not a hang.
400    const CYCLE_WINDOW: usize = 8;
401
402    #[must_use]
403    pub const fn new(max_consecutive: u32, max_cycle_repeats: u32) -> Self {
404        Self {
405            max_consecutive,
406            max_cycle_repeats,
407            last: None,
408            consecutive: 0,
409            recent: [u32::MAX; Self::CYCLE_WINDOW],
410            filled: 0,
411            cycle_repeats: 0,
412        }
413    }
414
415    /// Observe one sampled token.
416    pub fn observe(&mut self, token: u32) -> Result<(), HealthViolation> {
417        if self.last == Some(token) {
418            self.consecutive += 1;
419        } else {
420            self.consecutive = 1;
421            self.last = Some(token);
422        }
423        if self.consecutive > self.max_consecutive {
424            return Err(HealthViolation::RepetitionRunaway {
425                token,
426                repeats: self.consecutive,
427            });
428        }
429
430        // Two-token cycle detection: compare against the token two positions back.
431        if self.filled >= 2 && self.recent[(self.filled - 2) % Self::CYCLE_WINDOW] == token {
432            self.cycle_repeats += 1;
433            if self.cycle_repeats > self.max_cycle_repeats {
434                return Err(HealthViolation::RepetitionRunaway {
435                    token,
436                    repeats: self.cycle_repeats,
437                });
438            }
439        } else {
440            self.cycle_repeats = 0;
441        }
442        self.recent[self.filled % Self::CYCLE_WINDOW] = token;
443        self.filled += 1;
444        Ok(())
445    }
446}
447
448// --------------------------------------------------------------------------------------
449// Output silence
450// --------------------------------------------------------------------------------------
451
452/// Detects an utterance that is producing bytes but no sound.
453///
454/// A silent result is a *failure* that every byte-count and duration check calls success, which is
455/// exactly why it needs its own detector.
456#[derive(Clone, Debug)]
457pub struct SilenceDetector {
458    floor: i16,
459    max_silent_samples: u64,
460    sample_rate: u32,
461    silent_samples: u64,
462}
463
464impl SilenceDetector {
465    /// `floor` is the absolute amplitude at or below which a sample counts as silent.
466    ///
467    /// The window is converted to a **sample count** once, here, rather than compared in
468    /// milliseconds on every packet. Comparing integer milliseconds truncates: at 24 kHz a
469    /// 100 ms window and 2,401 silent samples both round to 100 ms, so the boundary case
470    /// silently failed to fire. Samples are the unit the detector actually counts.
471    #[must_use]
472    pub const fn new(floor: i16, max_silent: Duration, sample_rate: u32) -> Self {
473        Self {
474            floor,
475            max_silent_samples: (max_silent.as_millis() as u64) * (sample_rate as u64) / 1000,
476            sample_rate,
477            silent_samples: 0,
478        }
479    }
480
481    /// Observe one PCM packet. Any sample above the floor resets the run.
482    pub fn observe(&mut self, samples: &[i16]) -> Result<(), HealthViolation> {
483        for sample in samples {
484            if sample.saturating_abs() > self.floor {
485                self.silent_samples = 0;
486            } else {
487                self.silent_samples += 1;
488            }
489        }
490        if self.sample_rate == 0 {
491            return Ok(());
492        }
493        if self.silent_samples > self.max_silent_samples {
494            return Err(HealthViolation::OutputSilent {
495                silent_millis: self.silent_millis(),
496            });
497        }
498        Ok(())
499    }
500
501    /// Milliseconds of trailing silence observed so far.
502    #[must_use]
503    pub const fn silent_millis(&self) -> u64 {
504        if self.sample_rate == 0 {
505            return 0;
506        }
507        self.silent_samples * 1000 / self.sample_rate as u64
508    }
509}
510
511// --------------------------------------------------------------------------------------
512// Kernel demotion
513// --------------------------------------------------------------------------------------
514
515/// Which kernel tier is executing.
516#[derive(Clone, Copy, Debug, PartialEq, Eq)]
517pub enum KernelTier {
518    /// An ISA-specialised path, named by its feature (`i8mm`, `avx512-vnni`, …).
519    Optimized(&'static str),
520    /// The certified scalar baseline that every target can compile and every tier is proved against.
521    Scalar,
522}
523
524impl KernelTier {
525    #[must_use]
526    pub const fn as_str(self) -> &'static str {
527        match self {
528            Self::Optimized(name) => name,
529            Self::Scalar => "scalar",
530        }
531    }
532}
533
534/// Selects a kernel tier and demotes to the certified scalar baseline on selftest failure.
535///
536/// The direction is one-way on purpose. A tier that failed its selftest has produced at least one
537/// wrong answer on this machine; re-promoting it later because a subsequent check passed would be
538/// trusting the same evidence that already lied. Correctness outranks speed (G1 > G2), so the run
539/// finishes slower and correct.
540#[derive(Clone, Debug)]
541pub struct KernelSelector {
542    preferred: KernelTier,
543    active: KernelTier,
544    demoted: bool,
545}
546
547impl KernelSelector {
548    #[must_use]
549    pub const fn new(preferred: KernelTier) -> Self {
550        Self {
551            preferred,
552            active: preferred,
553            demoted: false,
554        }
555    }
556
557    #[must_use]
558    pub const fn active(&self) -> KernelTier {
559        self.active
560    }
561
562    #[must_use]
563    pub const fn demoted(&self) -> bool {
564        self.demoted
565    }
566
567    /// Record a selftest failure and fall back. Idempotent; already-scalar stays scalar.
568    pub fn on_selftest_failure(&mut self) -> Option<HealthViolation> {
569        if self.demoted || matches!(self.active, KernelTier::Scalar) {
570            self.demoted = true;
571            self.active = KernelTier::Scalar;
572            return None;
573        }
574        let from = self.active;
575        self.active = KernelTier::Scalar;
576        self.demoted = true;
577        Some(HealthViolation::KernelDemoted {
578            from,
579            to: KernelTier::Scalar,
580        })
581    }
582
583    /// The tier that was preferred before any demotion, for reporting.
584    #[must_use]
585    pub const fn preferred(&self) -> KernelTier {
586        self.preferred
587    }
588}
589
590// --------------------------------------------------------------------------------------
591// Thermal degradation
592// --------------------------------------------------------------------------------------
593
594/// Reports sustained-throughput decline against the opening window.
595///
596/// Not a failure — a *reporting* obligation. A laptop's first thirty seconds are a turbo window,
597/// and quoting that rate as steady-state is how misleading performance numbers get published
598/// (plan §9.6 sustained-performance gate). This makes the decline visible so the number can be
599/// qualified instead of quietly inflated.
600#[derive(Clone, Debug)]
601pub struct ThermalReporter {
602    baseline: Option<f64>,
603    latest: Option<f64>,
604    report_below_percent: u32,
605}
606
607impl ThermalReporter {
608    #[must_use]
609    pub const fn new(report_below_percent: u32) -> Self {
610        Self {
611            baseline: None,
612            latest: None,
613            report_below_percent,
614        }
615    }
616
617    /// Observe one window's throughput (any consistent unit; real-time factor is the intended one).
618    ///
619    /// The first non-zero observation becomes the baseline.
620    pub fn observe(&mut self, throughput: f64) -> Option<HealthViolation> {
621        if !throughput.is_finite() || throughput <= 0.0 {
622            return None;
623        }
624        self.latest = Some(throughput);
625        let baseline = *self.baseline.get_or_insert(throughput);
626        if throughput >= baseline {
627            return None;
628        }
629        let percent = ((baseline - throughput) / baseline * 100.0).floor();
630        let percent = percent.clamp(0.0, f64::from(u32::MAX)) as u32;
631        if percent >= self.report_below_percent {
632            return Some(HealthViolation::ThermalDegraded {
633                percent_below_baseline: percent,
634            });
635        }
636        None
637    }
638
639    #[must_use]
640    pub const fn baseline(&self) -> Option<f64> {
641        self.baseline
642    }
643
644    #[must_use]
645    pub const fn latest(&self) -> Option<f64> {
646        self.latest
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use std::time::Instant;
654
655    #[test]
656    fn a_nan_is_located_at_its_first_index() {
657        let mut guard = NumericGuard::new(SeamPolicy::All);
658        let values = [1.0, 2.0, f32::NAN, f32::NAN];
659        let violation = guard
660            .check(Seam::TalkerLogits, &values)
661            .expect_err("NaN must be caught");
662        assert_eq!(
663            violation,
664            HealthViolation::NonFinite {
665                seam: Seam::TalkerLogits,
666                index: 2,
667                is_nan: true,
668            }
669        );
670        assert!(violation.invalidates_output());
671    }
672
673    #[test]
674    fn an_infinity_is_distinguished_from_a_nan() {
675        let mut guard = NumericGuard::new(SeamPolicy::All);
676        let violation = guard
677            .check(Seam::CodecOutput, &[f32::INFINITY])
678            .expect_err("infinity must be caught");
679        assert!(matches!(
680            violation,
681            HealthViolation::NonFinite { is_nan: false, .. }
682        ));
683    }
684
685    #[test]
686    fn policy_off_never_looks_and_says_so() {
687        let mut guard = NumericGuard::new(SeamPolicy::Off);
688        assert!(guard.check(Seam::Pcm, &[f32::NAN]).is_ok());
689        // The point of is_checking: a run under Off may not be reported as numerically clean.
690        assert!(!guard.policy().is_checking());
691    }
692
693    #[test]
694    fn a_zero_sampling_interval_checks_rather_than_disables() {
695        // A misconfigured sampler must not be the reason nothing was inspected.
696        let mut guard = NumericGuard::new(SeamPolicy::Sampled { every: 0 });
697        assert!(guard.check(Seam::Pcm, &[f32::NAN]).is_err());
698    }
699
700    #[test]
701    fn sampling_checks_periodically() {
702        let mut guard = NumericGuard::new(SeamPolicy::Sampled { every: 3 });
703        let bad = [f32::NAN];
704        assert!(guard.check(Seam::Pcm, &bad).is_err(), "first call checks");
705        assert!(guard.check(Seam::Pcm, &bad).is_ok(), "second is skipped");
706        assert!(guard.check(Seam::Pcm, &bad).is_ok(), "third is skipped");
707        assert!(guard.check(Seam::Pcm, &bad).is_err(), "fourth checks again");
708    }
709
710    #[test]
711    fn the_watchdog_fires_only_after_the_timeout() {
712        let start = Instant::now();
713        let watchdog = ProgressWatchdog::new(Duration::from_millis(500), start);
714        assert!(watchdog.check(start + Duration::from_millis(499)).is_ok());
715        let violation = watchdog
716            .check(start + Duration::from_millis(501))
717            .expect_err("a stall past the timeout must fire");
718        assert!(matches!(
719            violation,
720            HealthViolation::NoProgress {
721                frames_emitted: 0,
722                ..
723            }
724        ));
725    }
726
727    #[test]
728    fn recording_a_frame_resets_the_stall_timer() {
729        let start = Instant::now();
730        let mut watchdog = ProgressWatchdog::new(Duration::from_millis(100), start);
731        let later = start + Duration::from_millis(90);
732        watchdog.record_frame(later);
733        assert!(watchdog.check(later + Duration::from_millis(90)).is_ok());
734        assert_eq!(watchdog.frames_emitted(), 1);
735    }
736
737    #[test]
738    fn end_of_speech_on_the_cap_is_reported_as_inconsistent() {
739        // The counterfeit-green case: a cap-stop relabelled as a clean finish.
740        let violation = check_stop_consistency(StopReason::EndOfSpeech, 2048, 2048)
741            .expect_err("EOS exactly on the cap must be challenged");
742        assert!(matches!(
743            violation,
744            HealthViolation::StopInconsistent { .. }
745        ));
746        assert!(violation.invalidates_output());
747    }
748
749    #[test]
750    fn a_cap_stop_short_of_the_cap_is_inconsistent() {
751        assert!(check_stop_consistency(StopReason::FrameCapReached, 100, 2048).is_err());
752    }
753
754    #[test]
755    fn consistent_outcomes_pass() {
756        assert!(check_stop_consistency(StopReason::EndOfSpeech, 100, 2048).is_ok());
757        assert!(check_stop_consistency(StopReason::FrameCapReached, 2048, 2048).is_ok());
758        // Cancellation and a duration limit can legitimately land anywhere.
759        assert!(check_stop_consistency(StopReason::Cancelled, 7, 2048).is_ok());
760        assert!(check_stop_consistency(StopReason::DurationLimitReached, 7, 2048).is_ok());
761    }
762
763    #[test]
764    fn a_stuck_token_trips_the_runaway_detector() {
765        let mut detector = RunawayDetector::new(4, 8);
766        for _ in 0..4 {
767            detector.observe(42).expect("within threshold");
768        }
769        let violation = detector
770            .observe(42)
771            .expect_err("the fifth repeat must trip");
772        assert!(matches!(
773            violation,
774            HealthViolation::RepetitionRunaway {
775                token: 42,
776                repeats: 5
777            }
778        ));
779    }
780
781    #[test]
782    fn a_two_token_cycle_trips_even_though_nothing_repeats_consecutively() {
783        // The case a consecutive-repeat counter alone would miss entirely.
784        let mut detector = RunawayDetector::new(100, 3);
785        let mut result = Ok(());
786        for index in 0..12 {
787            result = detector.observe(if index % 2 == 0 { 7 } else { 9 });
788            if result.is_err() {
789                break;
790            }
791        }
792        assert!(result.is_err(), "a ping-pong cycle must be detected");
793    }
794
795    #[test]
796    fn ordinary_variety_does_not_trip_the_detector() {
797        let mut detector = RunawayDetector::new(4, 3);
798        for token in 0..64u32 {
799            detector.observe(token).expect("varied tokens are healthy");
800        }
801    }
802
803    #[test]
804    fn silence_past_the_window_is_a_violation() {
805        let mut detector = SilenceDetector::new(4, Duration::from_millis(100), 24_000);
806        // 24 kHz: 2,400 samples is exactly 100 ms, so 2,401 exceeds it.
807        let silent = vec![0i16; 2_401];
808        let violation = detector
809            .observe(&silent)
810            .expect_err("silence past the window must fire");
811        assert!(matches!(violation, HealthViolation::OutputSilent { .. }));
812    }
813
814    #[test]
815    fn any_audible_sample_resets_the_silence_run() {
816        let mut detector = SilenceDetector::new(4, Duration::from_millis(100), 24_000);
817        detector.observe(&vec![0i16; 2_000]).expect("under window");
818        detector.observe(&[9_000]).expect("audible sample resets");
819        assert_eq!(detector.silent_millis(), 0);
820        detector
821            .observe(&vec![0i16; 2_000])
822            .expect("run restarted, so still under the window");
823    }
824
825    #[test]
826    fn demotion_is_one_way_and_reported_once() {
827        let mut selector = KernelSelector::new(KernelTier::Optimized("i8mm"));
828        assert_eq!(selector.active(), KernelTier::Optimized("i8mm"));
829
830        let violation = selector
831            .on_selftest_failure()
832            .expect("the first demotion is reported");
833        assert_eq!(
834            violation,
835            HealthViolation::KernelDemoted {
836                from: KernelTier::Optimized("i8mm"),
837                to: KernelTier::Scalar,
838            }
839        );
840        // Informational: the run stays correct, just slower.
841        assert!(!violation.invalidates_output());
842        assert_eq!(selector.active(), KernelTier::Scalar);
843
844        // A second failure must not re-report, and must never re-promote: the tier already
845        // produced a wrong answer on this machine.
846        assert!(selector.on_selftest_failure().is_none());
847        assert_eq!(selector.active(), KernelTier::Scalar);
848        assert!(selector.demoted());
849        assert_eq!(selector.preferred(), KernelTier::Optimized("i8mm"));
850    }
851
852    #[test]
853    fn thermal_decline_is_reported_against_the_opening_window() {
854        let mut reporter = ThermalReporter::new(10);
855        assert!(
856            reporter.observe(20.0).is_none(),
857            "the first sample is the baseline"
858        );
859        assert!(
860            reporter.observe(19.0).is_none(),
861            "5% is under the threshold"
862        );
863        let violation = reporter
864            .observe(17.0)
865            .expect("15% below baseline must be reported");
866        assert_eq!(
867            violation,
868            HealthViolation::ThermalDegraded {
869                percent_below_baseline: 15
870            }
871        );
872        // A slower sustained rate is honest, not broken.
873        assert!(!violation.invalidates_output());
874        assert_eq!(reporter.baseline(), Some(20.0));
875        assert_eq!(reporter.latest(), Some(17.0));
876    }
877
878    #[test]
879    fn a_nonsense_throughput_sample_is_ignored_rather_than_becoming_the_baseline() {
880        let mut reporter = ThermalReporter::new(10);
881        assert!(reporter.observe(0.0).is_none());
882        assert!(reporter.observe(f64::NAN).is_none());
883        assert_eq!(reporter.baseline(), None, "no baseline was established");
884        assert!(reporter.observe(10.0).is_none());
885        assert_eq!(reporter.baseline(), Some(10.0));
886    }
887
888    #[test]
889    fn every_violation_carries_a_remedy_and_a_wire_name() {
890        let violations = [
891            HealthViolation::NonFinite {
892                seam: Seam::Pcm,
893                index: 0,
894                is_nan: true,
895            },
896            HealthViolation::NoProgress {
897                frames_emitted: 1,
898                stalled_millis: 2,
899            },
900            HealthViolation::StopInconsistent {
901                claimed: StopReason::EndOfSpeech,
902                frames_emitted: 1,
903                frame_cap: 1,
904            },
905            HealthViolation::RepetitionRunaway {
906                token: 1,
907                repeats: 2,
908            },
909            HealthViolation::OutputSilent { silent_millis: 1 },
910            HealthViolation::KernelDemoted {
911                from: KernelTier::Optimized("i8mm"),
912                to: KernelTier::Scalar,
913            },
914            HealthViolation::ThermalDegraded {
915                percent_below_baseline: 11,
916            },
917        ];
918        for violation in violations {
919            assert!(!violation.as_str().is_empty());
920            assert!(
921                violation.remedy().len() > 40,
922                "{}: a remedy must tell the caller what to do",
923                violation.as_str()
924            );
925            assert!(!violation.to_string().is_empty());
926        }
927    }
928}