Skip to main content

ftts_core/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Safe, blocking public engine primitives.
4//!
5//! `TtsEngine` owns the one async runtime used below the synchronous public
6//! facade. Model work is intentionally absent in Phase 0, but the admission,
7//! cancellation, budget, observer, and bounded-streaming contracts are real so
8//! later model stages cannot introduce a second orchestration path.
9
10pub mod admission;
11pub mod audio;
12pub mod health;
13
14use std::{
15    env, fmt,
16    ops::Range,
17    sync::{
18        Arc, OnceLock,
19        atomic::{AtomicBool, Ordering},
20        mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError},
21    },
22    thread,
23    time::{Duration, Instant},
24};
25
26use asupersync::runtime::{Runtime, RuntimeBuilder};
27
28/// Identifies this crate's scaffold revision.
29pub const SCAFFOLD_REVISION: u8 = 2;
30
31const DEFAULT_QUEUE_CAPACITY: usize = 8;
32const DEFAULT_SYNTHESIS_BUDGET: Duration = Duration::from_secs(30);
33const DEFAULT_ENROLL_BUDGET: Duration = Duration::from_secs(30);
34const BACKPRESSURE_POLL: Duration = Duration::from_millis(1);
35
36/// Wall time allowed per generated frame, on top of the stage's startup allowance.
37///
38/// A flat whole-stage deadline cannot tell "the model is hung" from "the caller asked for more
39/// speech", and it answers both with the same refusal. That is what made a twelve-word utterance
40/// fail while a two-word one passed: nothing was wrong, the request was simply longer. Bounding the
41/// *rate* instead is length-independent, which is the property a budget actually wants.
42///
43/// The number is measured, not guessed. On the machine this was calibrated on, release synthesis
44/// runs ~1.05 s/frame (20 frames in 20.9 s), so 8 s/frame leaves ~7.6x headroom for a colder or
45/// busier host while still catching a genuine stall within one frame. This is deliberately loose:
46/// the codebase is pre-optimization (the whole project exists to move this number), so a tight
47/// budget here would encode today's slowness as tomorrow's contract.
48const DEFAULT_SYNTHESIS_FRAME_BUDGET: Duration = Duration::from_secs(8);
49
50/// How much slower an unoptimized build is, applied to both synthesis budgets.
51///
52/// Measured on the same machine and the same utterance as the frame budget above: a debug build
53/// spent 26.8 s loading where release spent 2.2 s (12x), and had not finished the same 20 frames
54/// after 20 minutes where release took 20.9 s — so >57x on the decode loop, or >60 s/frame.
55///
56/// That measurement is a lower bound, not a clean one: the run shared the machine with concurrent
57/// cargo builds, so some of the 57x is contention rather than the profile. 32x is chosen to sit
58/// above the honest part of that range with room to spare — at 32x the per-frame allowance is 256 s
59/// against >60 s observed, roughly 4x headroom, matching the release tier's intent rather than
60/// leaving debug on a knife edge. The cost of being too generous is only that a genuinely hung
61/// debug run takes a few minutes to be caught; the cost of being too tight is refusing correct
62/// work, which is the failure this whole mechanism exists to stop.
63///
64/// This exists because a developer running `cargo test` or `cargo run` without `--release` is doing
65/// something legitimate, and being told their correct request "exceeded its budget" teaches them
66/// the engine is broken when it is only slow.
67const DEBUG_BUILD_SLOWDOWN: u32 = 32;
68
69/// Process-wide engine defaults read once from `FTTS_STAGE_BUDGET_*_MS`.
70///
71/// The initial budget names are `FTTS_STAGE_BUDGET_SYNTHESIS_MS` and
72/// `FTTS_STAGE_BUDGET_ENROLL_MS`. Invalid or zero values retain their documented
73/// defaults; configuration errors should never silently create an unbounded stage.
74pub fn process_engine_config() -> EngineConfig {
75    static CONFIG: OnceLock<EngineConfig> = OnceLock::new();
76    CONFIG.get_or_init(EngineConfig::from_environment).clone()
77}
78
79/// Fixed limits for one engine instance.
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub struct EngineConfig {
82    /// Capacity for each independent PCM and event queue.
83    pub stream_queue_capacity: usize,
84    /// Wall time allowed for one synthesis stage *before* the per-frame allowance is added.
85    ///
86    /// This is the startup grace: prefill, cache warmup, and the first frame. It is not the whole
87    /// stage's ceiling — see [`Self::synthesis_frame_budget`], which extends the deadline as frames
88    /// are actually produced. A generator that never yields its first frame still trips at exactly
89    /// this value, so this remains the knob that catches a hang.
90    pub synthesis_stage_budget: Duration,
91    /// Wall time added to the synthesis deadline for each frame already generated.
92    ///
93    /// This is what makes the budget scale with the length of the utterance instead of refusing
94    /// long ones. Zero is rejected by [`Self::validate`]: it would silently restore the flat
95    /// whole-stage deadline this field exists to replace.
96    pub synthesis_frame_budget: Duration,
97    /// Maximum wall time for one enrollment CPU stage.
98    pub enroll_stage_budget: Duration,
99    /// Predicted-peak-memory policy applied to every synthesis request.
100    pub admission: admission::AdmissionPolicy,
101}
102
103impl Default for EngineConfig {
104    fn default() -> Self {
105        // The synthesis budgets are scaled by build profile; enrollment is not, because it does no
106        // per-frame model work and its 30 s is not close to binding.
107        let slowdown = build_profile_slowdown();
108        Self {
109            stream_queue_capacity: DEFAULT_QUEUE_CAPACITY,
110            synthesis_stage_budget: DEFAULT_SYNTHESIS_BUDGET * slowdown,
111            synthesis_frame_budget: DEFAULT_SYNTHESIS_FRAME_BUDGET * slowdown,
112            enroll_stage_budget: DEFAULT_ENROLL_BUDGET,
113            admission: admission::AdmissionPolicy::default(),
114        }
115    }
116}
117
118/// The multiplier applied to the synthesis budgets for the current build profile.
119///
120/// `debug_assertions` is the available proxy for "unoptimized". It is not exact — a release build
121/// with `debug-assertions = true` is charged the debug multiplier — but erring toward the larger
122/// budget only costs a hung run some extra seconds before it is caught, while erring the other way
123/// refuses correct work, which is the failure this whole mechanism exists to stop.
124const fn build_profile_slowdown() -> u32 {
125    if cfg!(debug_assertions) {
126        DEBUG_BUILD_SLOWDOWN
127    } else {
128        1
129    }
130}
131
132impl EngineConfig {
133    fn from_environment() -> Self {
134        let mut config = Self::default();
135        config.synthesis_stage_budget = stage_budget_from_environment(
136            "FTTS_STAGE_BUDGET_SYNTHESIS_MS",
137            config.synthesis_stage_budget,
138        );
139        config.synthesis_frame_budget = stage_budget_from_environment(
140            "FTTS_STAGE_BUDGET_FRAME_MS",
141            config.synthesis_frame_budget,
142        );
143        config.enroll_stage_budget = stage_budget_from_environment(
144            "FTTS_STAGE_BUDGET_ENROLL_MS",
145            config.enroll_stage_budget,
146        );
147        // An unparseable or zero value keeps the documented default rather than creating an
148        // unbounded budget, matching the stage-budget policy above: a configuration mistake must
149        // never silently remove a limit.
150        config.admission.budget_bytes = positive_u64_from_environment("FTTS_MEMORY_BUDGET_MB")
151            .and_then(|megabytes| megabytes.checked_mul(1024 * 1024))
152            .unwrap_or(config.admission.budget_bytes);
153        if let Some(max_frames) = positive_u64_from_environment("FTTS_MAX_FRAMES") {
154            // An explicit cap is obeyed exactly: it both replaces the policy default and
155            // disables the text-derived EOS backstop that otherwise bounds a bare `ftts say`.
156            config.admission.max_new_tokens = max_frames;
157            config.admission.heuristic_eos_backstop = false;
158        }
159        config
160    }
161
162    fn validate(&self) -> Result<(), EngineError> {
163        if self.stream_queue_capacity == 0 {
164            return Err(EngineError::InvalidConfiguration(
165                "stream queue capacity must be greater than zero",
166            ));
167        }
168        if self.synthesis_stage_budget.is_zero()
169            || self.synthesis_frame_budget.is_zero()
170            || self.enroll_stage_budget.is_zero()
171        {
172            return Err(EngineError::InvalidConfiguration(
173                "stage budgets must be greater than zero",
174            ));
175        }
176        Ok(())
177    }
178}
179
180/// Reads a strictly positive `u64` from the environment, or `None` when unset or unusable.
181fn positive_u64_from_environment(name: &str) -> Option<u64> {
182    env::var(name)
183        .ok()
184        .and_then(|value| value.parse::<u64>().ok())
185        .filter(|value| *value > 0)
186}
187
188fn stage_budget_from_environment(name: &str, fallback: Duration) -> Duration {
189    env::var(name)
190        .ok()
191        .and_then(|value| value.parse::<u64>().ok())
192        .filter(|milliseconds| *milliseconds > 0)
193        .map(Duration::from_millis)
194        .unwrap_or(fallback)
195}
196
197/// A caller-owned cancellation signal for one request.
198///
199/// Cloning this token is cheap and preserves a single cancellation state. The
200/// token is passed into CPU-stage closures; those closures must checkpoint at
201/// every talker-frame boundary once model execution is connected.
202#[derive(Clone, Debug, Default)]
203pub struct CancellationToken {
204    cancelled: Arc<AtomicBool>,
205}
206
207impl CancellationToken {
208    /// Creates an active cancellation token.
209    #[must_use]
210    pub fn new() -> Self {
211        Self::default()
212    }
213
214    /// Requests cooperative cancellation.
215    pub fn cancel(&self) {
216        self.cancelled.store(true, Ordering::Release);
217    }
218
219    /// Returns whether cancellation has been requested.
220    #[must_use]
221    pub fn is_cancelled(&self) -> bool {
222        self.cancelled.load(Ordering::Acquire)
223    }
224
225    /// Returns `Cancelled` when a stage must stop cooperatively.
226    pub fn checkpoint(&self) -> Result<(), EngineError> {
227        if self.is_cancelled() {
228            Err(EngineError::Cancelled)
229        } else {
230            Ok(())
231        }
232    }
233}
234
235/// The kind of a bounded stream queue.
236#[derive(Clone, Copy, Debug, PartialEq, Eq)]
237pub enum StreamKind {
238    /// PCM packets only.
239    Pcm,
240    /// Structured lifecycle events only.
241    Events,
242}
243
244/// A PCM packet emitted by the codec path.
245#[derive(Clone, Debug, PartialEq, Eq)]
246pub struct PcmPacket {
247    /// Number of 80 ms codec frames represented by this packet.
248    pub frame_count: u8,
249    /// Interleaved signed 16-bit PCM samples.
250    pub samples: Vec<i16>,
251}
252
253/// A streaming endpoint pair with independent bounded PCM and event queues.
254///
255/// The queue separation makes an event consumer stall unable to block PCM
256/// delivery (and vice versa). Producers park under backpressure and observe
257/// request cancellation while waiting; no unbounded buffering is available.
258pub struct StreamQueues {
259    /// PCM producer endpoint.
260    pub pcm: BoundedSender<PcmPacket>,
261    /// PCM consumer endpoint.
262    pub pcm_receiver: BoundedReceiver<PcmPacket>,
263    /// Event producer endpoint.
264    pub events: BoundedSender<SynthesisEvent>,
265    /// Event consumer endpoint.
266    pub event_receiver: BoundedReceiver<SynthesisEvent>,
267}
268
269impl StreamQueues {
270    /// Creates distinct bounded queues for PCM and lifecycle events.
271    pub fn new(capacity: usize) -> Result<Self, EngineError> {
272        if capacity == 0 {
273            return Err(EngineError::InvalidConfiguration(
274                "stream queue capacity must be greater than zero",
275            ));
276        }
277        let (pcm, pcm_receiver) = bounded_queue(capacity, StreamKind::Pcm);
278        let (events, event_receiver) = bounded_queue(capacity, StreamKind::Events);
279        Ok(Self {
280            pcm,
281            pcm_receiver,
282            events,
283            event_receiver,
284        })
285    }
286}
287
288/// A bounded queue producer that cooperates with cancellation while stalled.
289#[derive(Clone)]
290pub struct BoundedSender<T> {
291    kind: StreamKind,
292    sender: SyncSender<T>,
293}
294
295impl<T> BoundedSender<T> {
296    /// Sends one item, parking while the bounded queue is full.
297    pub fn send(&self, mut item: T, cancellation: &CancellationToken) -> Result<(), EngineError> {
298        loop {
299            cancellation.checkpoint()?;
300            match self.sender.try_send(item) {
301                Ok(()) => return Ok(()),
302                Err(TrySendError::Full(returned)) => {
303                    item = returned;
304                    thread::sleep(BACKPRESSURE_POLL);
305                }
306                Err(TrySendError::Disconnected(_)) => {
307                    return Err(EngineError::StreamDisconnected(self.kind));
308                }
309            }
310        }
311    }
312}
313
314/// A bounded queue consumer.
315pub struct BoundedReceiver<T> {
316    kind: StreamKind,
317    receiver: Receiver<T>,
318}
319
320impl<T> BoundedReceiver<T> {
321    /// Receives one item, timing out when no item arrives in `timeout`.
322    pub fn recv_timeout(&self, timeout: Duration) -> Result<T, EngineError> {
323        match self.receiver.recv_timeout(timeout) {
324            Ok(item) => Ok(item),
325            Err(RecvTimeoutError::Timeout) => Err(EngineError::QueueTimeout),
326            Err(RecvTimeoutError::Disconnected) => Err(EngineError::StreamDisconnected(self.kind)),
327        }
328    }
329}
330
331fn bounded_queue<T>(capacity: usize, kind: StreamKind) -> (BoundedSender<T>, BoundedReceiver<T>) {
332    let (sender, receiver) = mpsc::sync_channel(capacity);
333    (
334        BoundedSender { kind, sender },
335        BoundedReceiver { kind, receiver },
336    )
337}
338
339/// The caller-visible text-normalization policy.
340#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
341pub enum NormalizationMode {
342    /// Pinned upstream semantics: NFC and nothing else.
343    #[default]
344    Verbatim,
345    /// Reserved for unambiguous policies; currently deliberately no-op beyond NFC.
346    Conservative,
347    /// Apply explicit language-span pronunciation entries after NFC.
348    LocaleAware,
349}
350
351/// A byte range in normalized text with a caller-supplied language identifier.
352#[derive(Clone, Debug, Eq, PartialEq)]
353pub struct LanguageSpan {
354    /// The range, expressed over the NFC-normalized input.
355    pub range: Range<usize>,
356    /// A caller-supplied BCP-47-like language identifier.
357    pub language: String,
358}
359
360/// An explicit pronunciation expansion.
361///
362/// Entries are only applied in a matching language span, or globally when
363/// `language` is `"und"`. The engine neither persists nor logs this text.
364#[derive(Clone, Debug, Eq, PartialEq)]
365pub struct PronunciationEntry {
366    /// Language to which the entry applies.
367    pub language: String,
368    /// Surface text to recognize.
369    pub surface: String,
370    /// Caller-supplied spoken replacement.
371    pub spoken: String,
372}
373
374/// Caller-supplied behavior layered over the pinned verbatim path.
375#[derive(Clone, Debug, Default, Eq, PartialEq)]
376pub struct NormalizationOptions {
377    /// Requested policy. The default is the ConformanceExact verbatim route.
378    pub mode: NormalizationMode,
379    /// Explicit language overrides for locale-aware entries.
380    pub language_spans: Vec<LanguageSpan>,
381    /// Caller-supplied pronunciation entries for locale-aware handling.
382    pub pronunciation_lexicon: Vec<PronunciationEntry>,
383}
384
385/// One observable normalization change.
386///
387/// This detailed form is returned only to the caller that owns the input text.
388/// Observer events use [`NormalizationTraceSummary`] instead, so trace sinks do
389/// not receive sensitive before/after text.
390#[derive(Clone, Debug, Eq, PartialEq)]
391pub struct NormalizationChange {
392    /// Stable name of the rule that made the change.
393    pub rule: &'static str,
394    /// Input before the rule was applied.
395    pub before: String,
396    /// Output after the rule was applied.
397    pub after: String,
398}
399
400/// A deterministic record of what the normalizer did and why.
401#[derive(Clone, Debug, Eq, PartialEq)]
402pub struct NormalizationTrace {
403    /// Policy used for the request.
404    pub mode: NormalizationMode,
405    /// Unicode data version used by the tokenizer implementation.
406    pub unicode_version: String,
407    /// Detailed caller-owned changes.
408    pub changes: Vec<NormalizationChange>,
409}
410
411impl NormalizationTrace {
412    /// Produces the privacy-safe observer form of this trace.
413    #[must_use]
414    pub fn summary(&self) -> NormalizationTraceSummary {
415        let mut rules = self
416            .changes
417            .iter()
418            .map(|change| change.rule.to_owned())
419            .collect::<Vec<_>>();
420        rules.sort_unstable();
421        rules.dedup();
422        NormalizationTraceSummary {
423            mode: self.mode,
424            unicode_version: self.unicode_version.clone(),
425            rules,
426            change_count: self.changes.len(),
427        }
428    }
429}
430
431/// The privacy-safe normalization information allowed on an observer event.
432#[derive(Clone, Debug, Eq, PartialEq)]
433pub struct NormalizationTraceSummary {
434    /// Policy used for the request.
435    pub mode: NormalizationMode,
436    /// Unicode data version used by the tokenizer implementation.
437    pub unicode_version: String,
438    /// Applied rule names, sorted and deduplicated.
439    pub rules: Vec<String>,
440    /// Number of detailed changes made by those rules.
441    pub change_count: usize,
442}
443
444/// Token ids and a caller-owned trace returned by a model-specific text preparer.
445#[derive(Clone, Debug, Eq, PartialEq)]
446pub struct PreparedText {
447    /// Token ids the model will consume.
448    pub token_ids: Vec<u32>,
449    /// Detailed normalization record, retained only in request-local memory.
450    pub normalization_trace: NormalizationTrace,
451}
452
453impl PreparedText {
454    /// Constructs a prepared text payload from model-specific tokenization.
455    #[must_use]
456    pub fn new(token_ids: Vec<u32>, normalization_trace: NormalizationTrace) -> Self {
457        Self {
458            token_ids,
459            normalization_trace,
460        }
461    }
462}
463
464/// Named failure from a model-specific text preparer.
465#[derive(Clone, Debug, Eq, PartialEq)]
466pub struct TextPreparationError {
467    message: String,
468}
469
470impl TextPreparationError {
471    /// Constructs a named preparation failure without exposing model error types to the engine.
472    #[must_use]
473    pub fn new(message: impl Into<String>) -> Self {
474        Self {
475            message: message.into(),
476        }
477    }
478}
479
480impl fmt::Display for TextPreparationError {
481    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
482        formatter.write_str(&self.message)
483    }
484}
485
486impl std::error::Error for TextPreparationError {}
487
488/// Model-specific text preparation used by the blocking engine facade.
489///
490/// `ftts-core` owns this boundary so it never depends on a particular model
491/// crate. Model crates implement it with their tokenizer and retain ownership
492/// of the detailed text trace.
493pub trait TextPreparer: Send + Sync {
494    /// Normalizes and tokenizes one request according to its explicit options.
495    fn prepare(
496        &self,
497        text: &str,
498        options: &NormalizationOptions,
499    ) -> Result<PreparedText, TextPreparationError>;
500}
501
502/// One generated codec frame: the talker's primary code plus the 15 residual codes.
503#[derive(Clone, Debug, PartialEq, Eq)]
504pub struct CodeFrame {
505    /// Group 0 is the primary code; groups 1..16 are the microdecoder residuals, in depth order.
506    pub codes: Vec<u32>,
507}
508
509/// A model-side failure while generating codec frames.
510#[derive(Clone, Debug, PartialEq, Eq)]
511pub struct GenerationError {
512    message: String,
513}
514
515impl GenerationError {
516    /// Wraps a model-specific failure description.
517    #[must_use]
518    pub fn new(message: impl Into<String>) -> Self {
519        Self {
520            message: message.into(),
521        }
522    }
523}
524
525impl fmt::Display for GenerationError {
526    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
527        formatter.write_str(&self.message)
528    }
529}
530
531impl std::error::Error for GenerationError {}
532
533/// Model-specific autoregressive frame generation behind the blocking engine facade.
534///
535/// Like [`TextPreparer`], `ftts-core` owns only the boundary: the model crate implements the
536/// prompt assembly, talker forward, and 15-step microdecoder behind these two calls, and the
537/// engine owns admission, budgets, cancellation, and observer events around them.
538pub trait FrameGenerator {
539    /// Prepares per-utterance state (prompt assembly and talker prefill) for one request.
540    fn begin_utterance(&mut self, prepared: &PreparedText) -> Result<(), GenerationError>;
541
542    /// Produces the next 16-code frame, or `None` once the model emits codec EOS.
543    fn next_frame(&mut self) -> Result<Option<CodeFrame>, GenerationError>;
544}
545
546/// A synchronous synthesis request.
547#[derive(Clone, Debug, PartialEq, Eq)]
548pub struct SynthesisRequest {
549    /// Text to synthesize. The Phase 0 shell accepts an empty request.
550    pub text: String,
551    /// Caller-owned policy passed unchanged to the model-specific tokenizer.
552    pub normalization_options: NormalizationOptions,
553    /// Whether the observer may receive a privacy-safe normalization summary.
554    pub trace_normalization: bool,
555}
556
557impl SynthesisRequest {
558    /// Creates a request from caller-owned text.
559    #[must_use]
560    pub fn new(text: impl Into<String>) -> Self {
561        Self {
562            text: text.into(),
563            normalization_options: NormalizationOptions::default(),
564            trace_normalization: false,
565        }
566    }
567
568    /// Replaces the default verbatim normalization policy for this request.
569    #[must_use]
570    pub fn with_normalization_options(
571        mut self,
572        normalization_options: NormalizationOptions,
573    ) -> Self {
574        self.normalization_options = normalization_options;
575        self
576    }
577
578    /// Allows the caller-owned observer to receive a text-free trace summary.
579    #[must_use]
580    pub const fn with_normalization_trace(mut self, trace_normalization: bool) -> Self {
581        self.trace_normalization = trace_normalization;
582        self
583    }
584}
585
586/// A synchronous enrollment request.
587#[derive(Clone, Debug, PartialEq, Eq)]
588pub struct EnrollmentRequest {
589    /// Caller-provided reference bytes. Decoding is connected in the model stage.
590    pub reference_audio: Vec<u8>,
591}
592
593/// A completed synthesis result.
594#[derive(Clone, Debug, PartialEq, Eq)]
595pub struct SynthesisResult {
596    /// Number of generated codec frames.
597    pub generated_frames: u64,
598    /// Every generated 16-code frame, in emission order, for the codec stage.
599    pub code_frames: Vec<CodeFrame>,
600    /// Number of token ids produced by the request-local text preparer.
601    pub prepared_token_count: usize,
602}
603
604/// A completed empty-pipeline enrollment result.
605#[derive(Clone, Debug, PartialEq, Eq)]
606pub struct EnrollmentResult {
607    /// The engine shell has not yet created a voice pack.
608    pub accepted_reference_bytes: usize,
609}
610
611/// A stage named in observer events and budget errors.
612#[derive(Clone, Copy, Debug, PartialEq, Eq)]
613pub enum EngineStage {
614    /// The complete synthesis pipeline.
615    Synthesis,
616    /// The complete enrollment pipeline.
617    Enrollment,
618}
619
620/// A health signal emitted through the caller-owned observer.
621#[derive(Clone, Copy, Debug, PartialEq, Eq)]
622pub enum HealthEvent {
623    /// A request exceeded its configured stage budget.
624    BudgetExceeded,
625    /// A request observed cooperative cancellation.
626    Cancelled,
627    /// A runtime-health detector fired ([`health`]).
628    ///
629    /// Carried through the same observer as every other lifecycle event so a caller learns about
630    /// a NaN, a stall, a repetition loop or a silent output *while the run is happening*, rather
631    /// than inferring it afterwards from audio it cannot listen to. The violation itself says
632    /// whether the output is still usable — see [`health::HealthViolation::invalidates_output`].
633    Violation(health::HealthViolation),
634}
635
636impl HealthEvent {
637    /// Whether this signal means the run's output must not be presented as a clean result.
638    #[must_use]
639    pub const fn invalidates_output(self) -> bool {
640        match self {
641            Self::BudgetExceeded | Self::Cancelled => true,
642            Self::Violation(violation) => violation.invalidates_output(),
643        }
644    }
645
646    /// Stable wire string for robot mode.
647    #[must_use]
648    pub const fn as_str(self) -> &'static str {
649        match self {
650            Self::BudgetExceeded => "budget_exceeded",
651            Self::Cancelled => "cancelled",
652            Self::Violation(violation) => violation.as_str(),
653        }
654    }
655}
656
657/// Lifecycle information delivered to a caller-owned observer.
658#[derive(Clone, Debug, PartialEq, Eq)]
659pub enum SynthesisEvent {
660    /// Concurrency admission outcome before model work begins.
661    Admission { accepted: bool },
662    /// Resource admission outcome: the predicted peak memory for this utterance.
663    ///
664    /// Distinct from [`SynthesisEvent::Admission`], which is the one-live-synthesis lease. Emitted
665    /// for accepted and rejected requests alike, so a capacity problem is visible in the event
666    /// stream rather than only in an error string.
667    ResourceAdmission {
668        /// Whether the request was admitted.
669        admitted: bool,
670        /// Frames the request may generate.
671        predicted_max_frames: u64,
672        /// Predicted peak bytes for the utterance.
673        predicted_peak_bytes: u64,
674        /// The budget it was measured against.
675        budget_bytes: u64,
676    },
677    /// A CPU stage started.
678    StageStarted { stage: EngineStage },
679    /// A CPU stage completed within its budget.
680    StageFinished {
681        stage: EngineStage,
682        elapsed: Duration,
683    },
684    /// A talker-frame boundary was reached.
685    FrameProgress { frame: u64 },
686    /// A caller explicitly requested a privacy-safe normalization trace summary.
687    TextPrepared {
688        /// Number of token ids that entered the model path.
689        token_count: usize,
690        /// No raw or rewritten text is included in this observer payload.
691        normalization: NormalizationTraceSummary,
692    },
693    /// A packet entered the PCM stream.
694    PacketEmitted {
695        frame_count: u8,
696        sample_count: usize,
697    },
698    /// A health event for the current request.
699    Health { event: HealthEvent },
700}
701
702/// Caller-owned telemetry for synthesis and enrollment.
703///
704/// CLI trace mode, robot NDJSON, and benchmarking all consume this same hook;
705/// the engine keeps neither global telemetry nor persisted synthesis state.
706pub trait SynthesisObserver: Send + Sync {
707    /// Receives one lifecycle event synchronously on the calling thread.
708    fn on_event(&self, event: SynthesisEvent);
709}
710
711impl<F> SynthesisObserver for F
712where
713    F: Fn(SynthesisEvent) + Send + Sync,
714{
715    fn on_event(&self, event: SynthesisEvent) {
716        self(event);
717    }
718}
719
720/// Errors produced by the synchronous engine facade.
721#[derive(Clone, Debug, PartialEq, Eq)]
722pub enum EngineError {
723    /// The one-live-synthesis admission limit rejected a concurrent request.
724    Busy,
725    /// The caller or an expired stage budget requested cancellation.
726    Cancelled,
727    /// A CPU stage did not complete within its configured budget.
728    BudgetExceeded(EngineStage),
729    /// A stream consumer disappeared.
730    StreamDisconnected(StreamKind),
731    /// A queue receive timed out.
732    QueueTimeout,
733    /// The model-specific text preparer rejected the request.
734    TextPreparation(TextPreparationError),
735    /// The model-specific frame generator failed mid-utterance.
736    Generation(GenerationError),
737    /// Predicted peak memory for this utterance exceeded the budget.
738    ///
739    /// Raised **before** any KV or codec state is allocated, so a rejected request has committed
740    /// nothing and the caller can retry with shorter text or a different cap.
741    ResourceAdmission(admission::AdmissionRejection),
742    /// Engine construction received an invalid setting.
743    InvalidConfiguration(&'static str),
744    /// The owned runtime could not be constructed.
745    Runtime(String),
746}
747
748impl fmt::Display for EngineError {
749    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
750        match self {
751            Self::Busy => formatter.write_str("another synthesis is already active"),
752            Self::Cancelled => formatter.write_str("synthesis cancelled"),
753            Self::BudgetExceeded(stage) => write!(formatter, "{stage:?} stage budget exceeded"),
754            Self::StreamDisconnected(kind) => write!(formatter, "{kind:?} stream disconnected"),
755            Self::QueueTimeout => formatter.write_str("bounded queue receive timed out"),
756            Self::TextPreparation(error) => write!(formatter, "text preparation failed: {error}"),
757            Self::Generation(error) => write!(formatter, "frame generation failed: {error}"),
758            Self::ResourceAdmission(rejection) => {
759                write!(
760                    formatter,
761                    "resource admission refused the request: {rejection}"
762                )
763            }
764            Self::InvalidConfiguration(message) => formatter.write_str(message),
765            Self::Runtime(message) => write!(formatter, "runtime initialization failed: {message}"),
766        }
767    }
768}
769
770impl std::error::Error for EngineError {}
771
772/// Blocking public engine facade.
773///
774/// The runtime is owned below this facade and is never exposed to callers.
775/// Admission uses an atomic lease, so no mutex is held across CPU work and one
776/// engine never runs more than one synthesis fanout at a time.
777pub struct TtsEngine {
778    runtime: Runtime,
779    config: EngineConfig,
780    synthesis_active: AtomicBool,
781}
782
783impl TtsEngine {
784    /// Creates an engine with explicit, validated limits.
785    pub fn new(config: EngineConfig) -> Result<Self, EngineError> {
786        config.validate()?;
787        let runtime = RuntimeBuilder::current_thread()
788            .blocking_threads(1, 1)
789            .build()
790            .map_err(|error| EngineError::Runtime(error.to_string()))?;
791        Ok(Self {
792            runtime,
793            config,
794            synthesis_active: AtomicBool::new(false),
795        })
796    }
797
798    /// Creates an engine from the process-wide environment defaults.
799    pub fn from_process_environment() -> Result<Self, EngineError> {
800        Self::new(process_engine_config())
801    }
802
803    /// Runs one blocking synthesis: text preparation, admission, then the model decode loop.
804    ///
805    /// The decode loop runs on the calling thread rather than through [`Self::run_stage`]: frame
806    /// generators borrow model weights, so they cannot cross the `'static` spawn boundary, and a
807    /// per-frame deadline check is the natural budget seam for an autoregressive loop anyway.
808    pub fn synthesize<P: TextPreparer + ?Sized>(
809        &self,
810        request: SynthesisRequest,
811        text_preparer: &P,
812        frame_generator: &mut dyn FrameGenerator,
813        cancellation: &CancellationToken,
814        observer: &dyn SynthesisObserver,
815    ) -> Result<SynthesisResult, EngineError> {
816        let _admission = self.acquire_synthesis_admission(observer)?;
817        cancellation.checkpoint().inspect_err(|_| {
818            observer.on_event(SynthesisEvent::Health {
819                event: HealthEvent::Cancelled,
820            });
821        })?;
822        let prepared = text_preparer
823            .prepare(&request.text, &request.normalization_options)
824            .map_err(EngineError::TextPreparation)?;
825        if request.trace_normalization {
826            observer.on_event(SynthesisEvent::TextPrepared {
827                token_count: prepared.token_ids.len(),
828                normalization: prepared.normalization_trace.summary(),
829            });
830        }
831
832        // Resource admission sits exactly here, and the position is the point: after tokenization
833        // (the prompt length is not knowable before it) and before any stage runs. A request that
834        // cannot fit is refused having allocated nothing — never discovered halfway through a long
835        // generation. See `admission` for the OQ-6 rule.
836        let prompt_tokens = prepared.token_ids.len() as u64;
837        let plan = match self.config.admission.admit(prompt_tokens) {
838            Ok(plan) => {
839                observer.on_event(SynthesisEvent::ResourceAdmission {
840                    admitted: true,
841                    predicted_max_frames: plan.predicted_max_frames,
842                    predicted_peak_bytes: plan.predicted_peak_bytes,
843                    budget_bytes: plan.budget_bytes,
844                });
845                plan
846            }
847            Err(rejection) => {
848                if let admission::AdmissionRejection::BudgetExceeded { plan } = rejection {
849                    observer.on_event(SynthesisEvent::ResourceAdmission {
850                        admitted: false,
851                        predicted_max_frames: plan.predicted_max_frames,
852                        predicted_peak_bytes: plan.predicted_peak_bytes,
853                        budget_bytes: plan.budget_bytes,
854                    });
855                }
856                return Err(EngineError::ResourceAdmission(rejection));
857            }
858        };
859
860        observer.on_event(SynthesisEvent::StageStarted {
861            stage: EngineStage::Synthesis,
862        });
863        let started = Instant::now();
864        // The deadline rolls forward as frames are produced: startup grace, plus one frame budget
865        // for every frame already in hand. A stalled generator makes no progress, so its deadline
866        // stops moving and it is caught within one frame budget of wherever it stopped — while a
867        // caller who simply asked for more speech is granted proportionally more time instead of
868        // being refused for it. Total work stays bounded by `predicted_max_frames` regardless, so
869        // dropping the flat whole-stage ceiling gives up no safety.
870        let startup_budget = self.config.synthesis_stage_budget;
871        let frame_budget = self.config.synthesis_frame_budget;
872        let mut code_frames: Vec<CodeFrame> = Vec::new();
873        frame_generator
874            .begin_utterance(&prepared)
875            .map_err(EngineError::Generation)?;
876        while (code_frames.len() as u64) < plan.predicted_max_frames {
877            cancellation.checkpoint().inspect_err(|_| {
878                observer.on_event(SynthesisEvent::Health {
879                    event: HealthEvent::Cancelled,
880                });
881            })?;
882            // Saturating, because a caller-supplied frame budget times a large frame count can
883            // overflow `Duration`; an unreachable deadline is the right answer there, not a panic.
884            let deadline = frame_budget
885                .checked_mul(u32::try_from(code_frames.len()).unwrap_or(u32::MAX))
886                .and_then(|earned| earned.checked_add(startup_budget))
887                .unwrap_or(Duration::MAX);
888            if started.elapsed() > deadline {
889                observer.on_event(SynthesisEvent::Health {
890                    event: HealthEvent::BudgetExceeded,
891                });
892                return Err(EngineError::BudgetExceeded(EngineStage::Synthesis));
893            }
894            match frame_generator
895                .next_frame()
896                .map_err(EngineError::Generation)?
897            {
898                Some(frame) => {
899                    observer.on_event(SynthesisEvent::FrameProgress {
900                        frame: code_frames.len() as u64,
901                    });
902                    code_frames.push(frame);
903                }
904                None => break,
905            }
906        }
907        observer.on_event(SynthesisEvent::StageFinished {
908            stage: EngineStage::Synthesis,
909            elapsed: started.elapsed(),
910        });
911        Ok(SynthesisResult {
912            generated_frames: code_frames.len() as u64,
913            code_frames,
914            prepared_token_count: prepared.token_ids.len(),
915        })
916    }
917
918    /// Runs the Phase 0 enrollment shell through the owned runtime.
919    pub fn enroll(
920        &self,
921        request: EnrollmentRequest,
922        cancellation: &CancellationToken,
923        observer: &dyn SynthesisObserver,
924    ) -> Result<EnrollmentResult, EngineError> {
925        observer.on_event(SynthesisEvent::Admission { accepted: true });
926        self.run_stage(
927            EngineStage::Enrollment,
928            self.config.enroll_stage_budget,
929            cancellation,
930            observer,
931            |_| Ok(()),
932        )?;
933        Ok(EnrollmentResult {
934            accepted_reference_bytes: request.reference_audio.len(),
935        })
936    }
937
938    fn acquire_synthesis_admission(
939        &self,
940        observer: &dyn SynthesisObserver,
941    ) -> Result<SynthesisAdmission<'_>, EngineError> {
942        match self.synthesis_active.compare_exchange(
943            false,
944            true,
945            Ordering::AcqRel,
946            Ordering::Acquire,
947        ) {
948            Ok(_) => {
949                observer.on_event(SynthesisEvent::Admission { accepted: true });
950                Ok(SynthesisAdmission { engine: self })
951            }
952            Err(_) => {
953                observer.on_event(SynthesisEvent::Admission { accepted: false });
954                Err(EngineError::Busy)
955            }
956        }
957    }
958
959    fn run_stage<R, F>(
960        &self,
961        stage: EngineStage,
962        budget: Duration,
963        cancellation: &CancellationToken,
964        observer: &dyn SynthesisObserver,
965        work: F,
966    ) -> Result<R, EngineError>
967    where
968        R: Send + 'static,
969        F: FnOnce(CancellationToken) -> Result<R, EngineError> + Send + 'static,
970    {
971        cancellation.checkpoint().inspect_err(|_| {
972            observer.on_event(SynthesisEvent::Health {
973                event: HealthEvent::Cancelled,
974            });
975        })?;
976        observer.on_event(SynthesisEvent::StageStarted { stage });
977        let started = Instant::now();
978        let (sender, receiver) = mpsc::sync_channel(1);
979        let stage_cancellation = cancellation.clone();
980        let task_cancellation = cancellation.clone();
981        let task = self
982            .runtime
983            .spawn_blocking(move || {
984                let result = task_cancellation
985                    .checkpoint()
986                    .and_then(|()| work(task_cancellation));
987                let _ignored_if_timed_out = sender.send(result);
988            })
989            .ok_or_else(|| EngineError::Runtime("blocking pool was not configured".to_owned()))?;
990
991        match receiver.recv_timeout(budget) {
992            Ok(result) => {
993                let result = result?;
994                observer.on_event(SynthesisEvent::StageFinished {
995                    stage,
996                    elapsed: started.elapsed(),
997                });
998                Ok(result)
999            }
1000            Err(RecvTimeoutError::Timeout) => {
1001                stage_cancellation.cancel();
1002                task.cancel();
1003                observer.on_event(SynthesisEvent::Health {
1004                    event: HealthEvent::BudgetExceeded,
1005                });
1006                Err(EngineError::BudgetExceeded(stage))
1007            }
1008            Err(RecvTimeoutError::Disconnected) => Err(EngineError::Runtime(
1009                "blocking stage disconnected before producing a result".to_owned(),
1010            )),
1011        }
1012    }
1013}
1014
1015struct SynthesisAdmission<'a> {
1016    engine: &'a TtsEngine,
1017}
1018
1019impl Drop for SynthesisAdmission<'_> {
1020    fn drop(&mut self) {
1021        self.engine.synthesis_active.store(false, Ordering::Release);
1022    }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028    use std::sync::Mutex;
1029
1030    #[derive(Default)]
1031    struct RecordingObserver {
1032        events: Mutex<Vec<SynthesisEvent>>,
1033    }
1034
1035    impl RecordingObserver {
1036        fn events(&self) -> Vec<SynthesisEvent> {
1037            self.events
1038                .lock()
1039                .expect("test observer lock poisoned")
1040                .clone()
1041        }
1042    }
1043
1044    impl SynthesisObserver for RecordingObserver {
1045        fn on_event(&self, event: SynthesisEvent) {
1046            self.events
1047                .lock()
1048                .expect("test observer lock poisoned")
1049                .push(event);
1050        }
1051    }
1052
1053    fn engine_with_budget(budget: Duration) -> TtsEngine {
1054        TtsEngine::new(EngineConfig {
1055            synthesis_stage_budget: budget,
1056            ..EngineConfig::default()
1057        })
1058        .expect("test engine builds")
1059    }
1060
1061    /// An engine with both synthesis budgets pinned, for exercising the rolling deadline.
1062    fn engine_with_frame_budget(startup: Duration, per_frame: Duration) -> TtsEngine {
1063        TtsEngine::new(EngineConfig {
1064            synthesis_stage_budget: startup,
1065            synthesis_frame_budget: per_frame,
1066            ..EngineConfig::default()
1067        })
1068        .expect("test engine builds")
1069    }
1070
1071    /// Emits `remaining` frames, each costing `per_frame`, then either stops at EOS or hangs.
1072    ///
1073    /// Real slowness and a real hang differ only in whether progress continues, which is exactly
1074    /// what the rolling deadline keys on — so both have to be expressible by one generator.
1075    /// `stall: None` ends the utterance cleanly; `Some(d)` wedges it, so the deadline is what ends
1076    /// it. Getting this wrong is easy and silent: a generator that hangs instead of stopping makes
1077    /// the "slow but legal" case fail as a budget refusal and look like the bug it was testing for.
1078    struct PacedFrameGenerator {
1079        remaining: usize,
1080        per_frame: Duration,
1081        stall: Option<Duration>,
1082        began: bool,
1083    }
1084
1085    impl FrameGenerator for PacedFrameGenerator {
1086        fn begin_utterance(&mut self, _prepared: &PreparedText) -> Result<(), GenerationError> {
1087            self.began = true;
1088            Ok(())
1089        }
1090
1091        fn next_frame(&mut self) -> Result<Option<CodeFrame>, GenerationError> {
1092            assert!(self.began, "next_frame before begin_utterance");
1093            if self.remaining == 0 {
1094                let Some(stall) = self.stall else {
1095                    return Ok(None);
1096                };
1097                thread::sleep(stall);
1098                return Ok(Some(CodeFrame { codes: vec![0; 16] }));
1099            }
1100            self.remaining -= 1;
1101            thread::sleep(self.per_frame);
1102            Ok(Some(CodeFrame { codes: vec![0; 16] }))
1103        }
1104    }
1105
1106    /// The humane case: steady progress that would blow a flat whole-stage deadline still finishes.
1107    ///
1108    /// This is the regression that motivated the rolling budget — a twelve-word utterance was
1109    /// refused for being long while a two-word one passed, with nothing actually wrong. Ten frames
1110    /// at 20 ms each need ~200 ms, far past the 50 ms startup grace; only the per-frame term makes
1111    /// the run legal, so a reversion to a flat ceiling fails here.
1112    #[test]
1113    fn steady_progress_past_the_startup_grace_is_not_refused_for_being_long() {
1114        let engine = engine_with_frame_budget(Duration::from_millis(50), Duration::from_millis(30));
1115        let observer = RecordingObserver::default();
1116        let mut generator = PacedFrameGenerator {
1117            remaining: 10,
1118            per_frame: Duration::from_millis(20),
1119            stall: None,
1120            began: false,
1121        };
1122
1123        let result = engine
1124            .synthesize(
1125                SynthesisRequest::new(""),
1126                &TestTextPreparer,
1127                &mut generator,
1128                &CancellationToken::new(),
1129                &observer,
1130            )
1131            .expect("a steadily-progressing run must not be refused");
1132
1133        assert_eq!(
1134            result.generated_frames, 10,
1135            "all ten frames must survive; a flat 50 ms ceiling would have cut this at ~2"
1136        );
1137    }
1138
1139    /// The other half: a generator that stops progressing is still caught, and caught promptly.
1140    ///
1141    /// Without this, "scale the budget with the work" could be satisfied by removing the budget.
1142    #[test]
1143    fn a_generator_that_stops_progressing_is_still_caught_within_its_earned_deadline() {
1144        let engine = engine_with_frame_budget(Duration::from_millis(50), Duration::from_millis(30));
1145        let observer = RecordingObserver::default();
1146        let mut generator = PacedFrameGenerator {
1147            remaining: 3,
1148            per_frame: Duration::from_millis(1),
1149            stall: Some(Duration::from_millis(400)),
1150            began: false,
1151        };
1152
1153        let started = Instant::now();
1154        let error = engine
1155            .synthesize(
1156                SynthesisRequest::new(""),
1157                &TestTextPreparer,
1158                &mut generator,
1159                &CancellationToken::new(),
1160                &observer,
1161            )
1162            .expect_err("a stalled generator must still be refused");
1163        let elapsed = started.elapsed();
1164
1165        assert_eq!(error, EngineError::BudgetExceeded(EngineStage::Synthesis));
1166        assert!(
1167            observer.events().contains(&SynthesisEvent::Health {
1168                event: HealthEvent::BudgetExceeded,
1169            }),
1170            "the stall must be reported on the health channel, not only as a return value"
1171        );
1172        // Three frames earn 50 + 3*30 = 140 ms. One 400 ms stall crosses it; the run must end on
1173        // that stall rather than accumulating further deadline it never earned.
1174        assert!(
1175            elapsed < Duration::from_millis(2000),
1176            "stall detection took {elapsed:?}; the deadline is not supposed to keep growing while \
1177             no frames are produced"
1178        );
1179    }
1180
1181    /// A zero per-frame budget silently restores the flat deadline, so it is a configuration error.
1182    #[test]
1183    fn a_zero_frame_budget_is_rejected_rather_than_collapsing_to_a_flat_deadline() {
1184        let built = TtsEngine::new(EngineConfig {
1185            synthesis_frame_budget: Duration::ZERO,
1186            ..EngineConfig::default()
1187        });
1188        assert!(
1189            matches!(built, Err(EngineError::InvalidConfiguration(_))),
1190            "a zero per-frame budget must be refused, not accepted as a flat deadline"
1191        );
1192    }
1193
1194    /// The unoptimized build gets a larger allowance, because it is slower for reasons that are
1195    /// not the caller's fault. Asserting the relationship rather than the constant keeps this
1196    /// honest if the measured multiplier is ever re-calibrated.
1197    #[test]
1198    fn an_unoptimized_build_is_granted_a_larger_synthesis_budget() {
1199        let config = EngineConfig::default();
1200        let expected = u32::from(cfg!(debug_assertions)) * (DEBUG_BUILD_SLOWDOWN - 1) + 1;
1201        assert_eq!(
1202            config.synthesis_frame_budget,
1203            DEFAULT_SYNTHESIS_FRAME_BUDGET * expected
1204        );
1205        assert_eq!(
1206            config.synthesis_stage_budget,
1207            DEFAULT_SYNTHESIS_BUDGET * expected
1208        );
1209        // Enrollment does no per-frame model work, so it is deliberately not scaled.
1210        assert_eq!(config.enroll_stage_budget, DEFAULT_ENROLL_BUDGET);
1211    }
1212
1213    /// Emits a fixed number of all-zero frames, then EOS. Panics if the loop skips prefill.
1214    ///
1215    /// `polls` counts every `next_frame` call, which is what separates "the generator stopped" from
1216    /// "the loop stopped asking": a ceiling-bound run never polls for the frame past the ceiling,
1217    /// while an EOS-bound run must poll exactly once more than it received.
1218    struct ScriptedFrameGenerator {
1219        remaining: usize,
1220        began: bool,
1221        endless: bool,
1222        polls: usize,
1223    }
1224
1225    impl ScriptedFrameGenerator {
1226        fn emitting(frames: usize) -> Self {
1227            Self {
1228                remaining: frames,
1229                began: false,
1230                endless: false,
1231                polls: 0,
1232            }
1233        }
1234
1235        /// Never returns `None`, so only the engine's own ceiling can end the utterance.
1236        fn endless() -> Self {
1237            Self {
1238                remaining: 0,
1239                began: false,
1240                endless: true,
1241                polls: 0,
1242            }
1243        }
1244    }
1245
1246    impl FrameGenerator for ScriptedFrameGenerator {
1247        fn begin_utterance(&mut self, _prepared: &PreparedText) -> Result<(), GenerationError> {
1248            self.began = true;
1249            Ok(())
1250        }
1251
1252        fn next_frame(&mut self) -> Result<Option<CodeFrame>, GenerationError> {
1253            assert!(self.began, "next_frame before begin_utterance");
1254            self.polls += 1;
1255            if self.endless {
1256                return Ok(Some(CodeFrame { codes: vec![0; 16] }));
1257            }
1258            if self.remaining == 0 {
1259                return Ok(None);
1260            }
1261            self.remaining -= 1;
1262            Ok(Some(CodeFrame { codes: vec![0; 16] }))
1263        }
1264    }
1265
1266    /// An engine whose admitted ceiling is exactly `max_new_tokens` frames.
1267    fn engine_with_frame_ceiling(max_new_tokens: u64) -> TtsEngine {
1268        TtsEngine::new(EngineConfig {
1269            synthesis_stage_budget: Duration::from_secs(5),
1270            admission: admission::AdmissionPolicy {
1271                max_new_tokens,
1272                ..admission::AdmissionPolicy::default()
1273            },
1274            ..EngineConfig::default()
1275        })
1276        .expect("test engine builds")
1277    }
1278
1279    /// The ceiling the engine admitted this request under, as the observer saw it.
1280    fn admitted_ceiling(observer: &RecordingObserver) -> u64 {
1281        observer
1282            .events()
1283            .into_iter()
1284            .find_map(|event| match event {
1285                SynthesisEvent::ResourceAdmission {
1286                    admitted: true,
1287                    predicted_max_frames,
1288                    ..
1289                } => Some(predicted_max_frames),
1290                _ => None,
1291            })
1292            .expect("an admitted request reports its frame ceiling")
1293    }
1294
1295    struct TestTextPreparer;
1296
1297    impl TextPreparer for TestTextPreparer {
1298        fn prepare(
1299            &self,
1300            _text: &str,
1301            options: &NormalizationOptions,
1302        ) -> Result<PreparedText, TextPreparationError> {
1303            Ok(PreparedText::new(
1304                vec![7, 11],
1305                NormalizationTrace {
1306                    mode: options.mode,
1307                    unicode_version: "15.1.0".to_owned(),
1308                    changes: vec![NormalizationChange {
1309                        rule: "unicode_nfc",
1310                        before: "caller-owned secret".to_owned(),
1311                        after: "caller-owned secret".to_owned(),
1312                    }],
1313                },
1314            ))
1315        }
1316    }
1317
1318    /// The EOS case: the generator decides, and the loop asks exactly once past the last frame.
1319    ///
1320    /// Frame count alone cannot make this claim — a ceiling that happened to equal the frame count
1321    /// would produce the same number. Asserting the ceiling had slack *and* that the loop polled
1322    /// for the frame after the last one pins the stop to the generator's `None`.
1323    #[test]
1324    fn the_decode_loop_stops_on_the_generators_eos_and_polls_exactly_once_past_it() {
1325        let engine = engine_with_frame_ceiling(64);
1326        let observer = RecordingObserver::default();
1327        let mut generator = ScriptedFrameGenerator::emitting(3);
1328
1329        let result = engine
1330            .synthesize(
1331                SynthesisRequest::new(""),
1332                &TestTextPreparer,
1333                &mut generator,
1334                &CancellationToken::new(),
1335                &observer,
1336            )
1337            .expect("scripted pipeline succeeds");
1338
1339        let ceiling = admitted_ceiling(&observer);
1340        assert!(
1341            ceiling > 3,
1342            "ceiling {ceiling} must exceed the 3 emitted frames, or the stop is ambiguous"
1343        );
1344        assert_eq!(result.generated_frames, 3, "EOS bounds the utterance");
1345        assert_eq!(result.code_frames.len(), 3);
1346        assert_eq!(
1347            generator.polls, 4,
1348            "the loop must poll once past the last frame to observe EOS, and then stop"
1349        );
1350    }
1351
1352    /// The ceiling case: a generator that never stops is truncated at exactly the admitted ceiling.
1353    ///
1354    /// This is where an off-by-one would live, and where nothing else would catch it — a loop that
1355    /// ran one frame long or short would still look like "it stopped".
1356    #[test]
1357    fn a_generator_that_never_stops_is_truncated_exactly_at_the_admitted_ceiling() {
1358        let engine = engine_with_frame_ceiling(5);
1359        let observer = RecordingObserver::default();
1360        let mut generator = ScriptedFrameGenerator::endless();
1361
1362        let result = engine
1363            .synthesize(
1364                SynthesisRequest::new(""),
1365                &TestTextPreparer,
1366                &mut generator,
1367                &CancellationToken::new(),
1368                &observer,
1369            )
1370            .expect("a ceiling-bound utterance still completes");
1371
1372        let ceiling = admitted_ceiling(&observer);
1373        assert_eq!(
1374            ceiling, 5,
1375            "the policy's max_new_tokens is the ceiling here"
1376        );
1377        assert_eq!(
1378            result.generated_frames, ceiling,
1379            "an endless generator must be cut at the ceiling, not one frame either side"
1380        );
1381        assert_eq!(result.code_frames.len() as u64, ceiling);
1382        assert_eq!(
1383            generator.polls as u64, ceiling,
1384            "once the ceiling is reached the loop must stop asking, not poll a discarded frame"
1385        );
1386    }
1387
1388    /// The boundary: EOS arriving exactly at the ceiling is still a clean stop, not an overrun.
1389    #[test]
1390    fn eos_landing_exactly_on_the_ceiling_yields_the_ceiling_frames() {
1391        let engine = engine_with_frame_ceiling(4);
1392        let observer = RecordingObserver::default();
1393        let mut generator = ScriptedFrameGenerator::emitting(4);
1394
1395        let result = engine
1396            .synthesize(
1397                SynthesisRequest::new(""),
1398                &TestTextPreparer,
1399                &mut generator,
1400                &CancellationToken::new(),
1401                &observer,
1402            )
1403            .expect("scripted pipeline succeeds");
1404
1405        assert_eq!(admitted_ceiling(&observer), 4);
1406        assert_eq!(result.generated_frames, 4);
1407        assert_eq!(
1408            generator.polls, 4,
1409            "the ceiling is reached first, so the generator is never asked for a fifth frame"
1410        );
1411    }
1412
1413    #[test]
1414    fn the_decode_loop_drives_the_generator_and_reports_every_frame() {
1415        let engine = engine_with_budget(Duration::from_secs(1));
1416        let cancellation = CancellationToken::new();
1417        let observer = RecordingObserver::default();
1418        let mut generator = ScriptedFrameGenerator::emitting(2);
1419
1420        let result = engine
1421            .synthesize(
1422                SynthesisRequest::new(""),
1423                &TestTextPreparer,
1424                &mut generator,
1425                &cancellation,
1426                &observer,
1427            )
1428            .expect("scripted pipeline succeeds");
1429
1430        assert_eq!(result.generated_frames, 2);
1431        assert_eq!(result.code_frames.len(), 2);
1432        assert!(
1433            result
1434                .code_frames
1435                .iter()
1436                .all(|frame| frame.codes.len() == 16)
1437        );
1438        assert_eq!(result.prepared_token_count, 2);
1439        let events = observer.events();
1440        assert!(
1441            matches!(
1442                events.as_slice(),
1443                [
1444                    SynthesisEvent::Admission { accepted: true },
1445                    // Resource admission runs after tokenization and before the first stage.
1446                    SynthesisEvent::ResourceAdmission { admitted: true, .. },
1447                    SynthesisEvent::StageStarted {
1448                        stage: EngineStage::Synthesis,
1449                    },
1450                    SynthesisEvent::FrameProgress { frame: 0 },
1451                    SynthesisEvent::FrameProgress { frame: 1 },
1452                    SynthesisEvent::StageFinished {
1453                        stage: EngineStage::Synthesis,
1454                        ..
1455                    },
1456                ]
1457            ),
1458            "unexpected event sequence: {events:?}"
1459        );
1460    }
1461
1462    /// The load-bearing promise: an unaffordable request is refused having allocated nothing.
1463    ///
1464    /// Proven by the *absence* of any stage event — if a stage had started, work would already have
1465    /// been committed, which is the "died halfway through" failure admission exists to prevent.
1466    #[test]
1467    fn an_unaffordable_request_is_refused_before_any_stage_runs() {
1468        let mut config = EngineConfig {
1469            synthesis_stage_budget: Duration::from_secs(1),
1470            ..EngineConfig::default()
1471        };
1472        // A budget far below even the bounded per-utterance state.
1473        config.admission.budget_bytes = 1;
1474        let engine = TtsEngine::new(config).expect("engine builds");
1475        let cancellation = CancellationToken::new();
1476        let observer = RecordingObserver::default();
1477
1478        let error = engine
1479            .synthesize(
1480                SynthesisRequest::new(""),
1481                &TestTextPreparer,
1482                &mut ScriptedFrameGenerator::emitting(0),
1483                &cancellation,
1484                &observer,
1485            )
1486            .expect_err("an unaffordable request must be refused");
1487
1488        assert!(
1489            matches!(error, EngineError::ResourceAdmission(_)),
1490            "got {error}"
1491        );
1492
1493        let events = observer.events();
1494        assert!(
1495            !events.iter().any(|event| matches!(
1496                event,
1497                SynthesisEvent::StageStarted { .. }
1498                    | SynthesisEvent::StageFinished { .. }
1499                    | SynthesisEvent::FrameProgress { .. }
1500            )),
1501            "a refused request must not start any stage; got {events:?}"
1502        );
1503        // The rejection is visible in the stream, not only in the error string.
1504        assert!(
1505            events.iter().any(|event| matches!(
1506                event,
1507                SynthesisEvent::ResourceAdmission {
1508                    admitted: false,
1509                    ..
1510                }
1511            )),
1512            "a capacity refusal must appear in the event stream: {events:?}"
1513        );
1514    }
1515
1516    #[test]
1517    fn the_admission_policy_is_configurable_and_defaults_are_documented() {
1518        let config = EngineConfig::default();
1519        assert_eq!(
1520            config.admission.budget_bytes,
1521            admission::DEFAULT_BUDGET_BYTES
1522        );
1523        assert_eq!(
1524            config.admission.max_new_tokens,
1525            admission::DEFAULT_MAX_NEW_TOKENS
1526        );
1527        // The default policy applies the text-derived EOS backstop: a 512-token prompt is
1528        // granted `512 * 4 + 64` frames, not the flat 8,192-frame ceiling — the sampled EOS is a
1529        // stochastic stop, so an unbounded default would let one unlucky utterance run for
1530        // minutes. The flat ceiling still binds for explicit caps (see the admission tests).
1531        let plan = config
1532            .admission
1533            .admit(512)
1534            .expect("the documented default must admit its own worked case");
1535        assert_eq!(
1536            plan.predicted_max_frames,
1537            512 * admission::HEURISTIC_FRAMES_PER_PROMPT_TOKEN
1538                + admission::HEURISTIC_FRAME_HEADROOM
1539        );
1540        assert!(plan.fits());
1541
1542        // And the worked 8192-frame sizing case still admits when the cap is explicit.
1543        let mut explicit = config.admission;
1544        explicit.heuristic_eos_backstop = false;
1545        let plan = explicit
1546            .admit(512)
1547            .expect("the documented explicit-cap case must admit");
1548        assert_eq!(plan.predicted_max_frames, admission::DEFAULT_MAX_NEW_TOKENS);
1549        assert!(plan.fits());
1550    }
1551
1552    #[test]
1553    fn cancellation_is_observed_before_the_cpu_stage_starts() {
1554        let engine = engine_with_budget(Duration::from_secs(1));
1555        let cancellation = CancellationToken::new();
1556        cancellation.cancel();
1557        let observer = RecordingObserver::default();
1558
1559        let error = engine
1560            .synthesize(
1561                SynthesisRequest::new("cancelled"),
1562                &TestTextPreparer,
1563                &mut ScriptedFrameGenerator::emitting(0),
1564                &cancellation,
1565                &observer,
1566            )
1567            .expect_err("cancelled request must not run");
1568
1569        assert_eq!(error, EngineError::Cancelled);
1570        assert_eq!(
1571            observer.events(),
1572            vec![
1573                SynthesisEvent::Admission { accepted: true },
1574                SynthesisEvent::Health {
1575                    event: HealthEvent::Cancelled,
1576                },
1577            ]
1578        );
1579    }
1580
1581    #[test]
1582    fn stage_budget_cancels_cooperative_cpu_work() {
1583        let engine = engine_with_budget(Duration::from_millis(5));
1584        let cancellation = CancellationToken::new();
1585        let observer = RecordingObserver::default();
1586
1587        let error = engine
1588            .run_stage(
1589                EngineStage::Synthesis,
1590                Duration::from_millis(5),
1591                &cancellation,
1592                &observer,
1593                |token| -> Result<(), EngineError> {
1594                    loop {
1595                        token.checkpoint()?;
1596                        thread::sleep(Duration::from_millis(1));
1597                    }
1598                },
1599            )
1600            .expect_err("long stage must time out");
1601
1602        assert_eq!(error, EngineError::BudgetExceeded(EngineStage::Synthesis));
1603        assert!(cancellation.is_cancelled());
1604        assert!(observer.events().contains(&SynthesisEvent::Health {
1605            event: HealthEvent::BudgetExceeded,
1606        }));
1607    }
1608
1609    #[test]
1610    fn pcm_and_events_have_independent_bounded_queues() {
1611        let queues = StreamQueues::new(1).expect("queue config is valid");
1612        let cancellation = CancellationToken::new();
1613        queues
1614            .events
1615            .send(SynthesisEvent::Admission { accepted: true }, &cancellation)
1616            .expect("event queue accepts first event");
1617        queues
1618            .pcm
1619            .send(
1620                PcmPacket {
1621                    frame_count: 1,
1622                    samples: vec![1, -1],
1623                },
1624                &cancellation,
1625            )
1626            .expect("full event queue cannot block PCM queue");
1627
1628        assert_eq!(
1629            queues
1630                .pcm_receiver
1631                .recv_timeout(Duration::from_millis(10))
1632                .expect("PCM arrives"),
1633            PcmPacket {
1634                frame_count: 1,
1635                samples: vec![1, -1],
1636            }
1637        );
1638    }
1639
1640    #[test]
1641    fn explicit_normalization_trace_is_text_free() {
1642        let engine = engine_with_budget(Duration::from_secs(1));
1643        let observer = RecordingObserver::default();
1644        let request = SynthesisRequest::new("caller-owned secret")
1645            .with_normalization_options(NormalizationOptions {
1646                mode: NormalizationMode::LocaleAware,
1647                ..NormalizationOptions::default()
1648            })
1649            .with_normalization_trace(true);
1650
1651        engine
1652            .synthesize(
1653                request,
1654                &TestTextPreparer,
1655                &mut ScriptedFrameGenerator::emitting(0),
1656                &CancellationToken::new(),
1657                &observer,
1658            )
1659            .expect("explicit trace request succeeds");
1660
1661        let trace = observer
1662            .events()
1663            .into_iter()
1664            .find_map(|event| match event {
1665                SynthesisEvent::TextPrepared {
1666                    token_count,
1667                    normalization,
1668                } => Some((token_count, normalization)),
1669                _ => None,
1670            })
1671            .expect("explicit request emits a trace summary");
1672        assert_eq!(trace.0, 2);
1673        assert_eq!(trace.1.mode, NormalizationMode::LocaleAware);
1674        assert_eq!(trace.1.unicode_version, "15.1.0");
1675        assert_eq!(trace.1.rules, vec!["unicode_nfc"]);
1676        assert_eq!(trace.1.change_count, 1);
1677        assert!(
1678            !format!("{:?}", trace.1).contains("caller-owned secret"),
1679            "observer trace summaries must never contain sensitive before/after text"
1680        );
1681    }
1682
1683    #[test]
1684    fn a_health_violation_reaches_the_caller_through_the_observer() {
1685        // The wiring the reliability bead requires: a detector firing must be visible to the
1686        // caller through the SAME hook as every other lifecycle event. A violation that only
1687        // exists inside the engine is a violation nobody can act on.
1688        let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
1689        let sink = Arc::clone(&seen);
1690        let observer = move |event: SynthesisEvent| {
1691            if let SynthesisEvent::Health { event } = event {
1692                sink.lock().expect("observer lock").push(event);
1693            }
1694        };
1695
1696        let violation = health::HealthViolation::OutputSilent {
1697            silent_millis: 1_500,
1698        };
1699        observer(SynthesisEvent::Health {
1700            event: HealthEvent::Violation(violation),
1701        });
1702        let demotion = health::HealthViolation::KernelDemoted {
1703            from: health::KernelTier::Optimized("i8mm"),
1704            to: health::KernelTier::Scalar,
1705        };
1706        observer(SynthesisEvent::Health {
1707            event: HealthEvent::Violation(demotion),
1708        });
1709
1710        let events = seen.lock().expect("observer lock").clone();
1711        assert_eq!(events.len(), 2);
1712        assert_eq!(events[0], HealthEvent::Violation(violation));
1713        assert_eq!(events[0].as_str(), "output_silent");
1714        // Silence invalidates the result; a kernel demotion does not — the run is still correct.
1715        assert!(events[0].invalidates_output());
1716        assert!(!events[1].invalidates_output());
1717        assert_eq!(events[1].as_str(), "kernel_demoted");
1718    }
1719
1720    #[test]
1721    fn many_utterances_without_deadlock_watchdog() {
1722        let (done_sender, done_receiver) = mpsc::sync_channel(1);
1723        let worker = thread::spawn(move || {
1724            let engine = engine_with_budget(Duration::from_secs(1));
1725            let observer = RecordingObserver::default();
1726            for _ in 0..64 {
1727                engine
1728                    .synthesize(
1729                        SynthesisRequest::new("watchdog"),
1730                        &TestTextPreparer,
1731                        &mut ScriptedFrameGenerator::emitting(1),
1732                        &CancellationToken::new(),
1733                        &observer,
1734                    )
1735                    .expect("empty utterance succeeds");
1736            }
1737            done_sender
1738                .send(())
1739                .expect("watchdog completion receiver lives");
1740        });
1741
1742        done_receiver
1743            .recv_timeout(Duration::from_secs(2))
1744            .expect("many utterances watchdog expired");
1745        worker.join().expect("watchdog worker does not panic");
1746    }
1747}