Skip to main content

ftts_cli/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Shared, stateless command-line dispatch for both FrankenTTS binaries.
4
5mod error;
6pub mod robot;
7pub mod synth;
8
9pub use error::{FttsError, FttsExitCode};
10pub use robot::{EventType, validate_event, validate_ndjson};
11
12use std::collections::BTreeMap;
13use std::ffi::OsString;
14use std::fs;
15use std::io::{self, Read, Write};
16use std::path::{Path, PathBuf};
17use std::process::ExitCode;
18use std::sync::OnceLock;
19
20#[cfg(test)]
21use clap::CommandFactory;
22use clap::{Parser, Subcommand, ValueEnum};
23use ftts_artifacts::census::{ExpectedTensor, WeightsManifest};
24use ftts_artifacts::converter::{
25    StreamingConversionPlan, TensorConversion, TensorStoragePolicy, convert_safetensors_streaming,
26};
27use ftts_artifacts::fttsq::{AccessClass, MappedFttsq};
28use ftts_artifacts::safetensors::Dtype;
29use ftts_core::{NormalizationMode, NormalizationOptions, SynthesisRequest};
30use ftts_kernels::mmap::MappedFile;
31use serde_json::{Value, json};
32
33const ROBOT_SCHEMA_VERSION: u8 = 1;
34const SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES: usize = 1_048_576;
35const MODEL_BASENAME: &str = "qwen3-tts-12hz-0.6b-base.fttsq";
36const PINNED_MAIN_WEIGHTS_FILENAME: &str = "model.safetensors";
37const PINNED_MAIN_WEIGHTS_SHA256: &str =
38    "180b3b10eb1c9f1b4db7806d5475bae3071c0243c299d49926bab1da3b6946f6";
39const PINNED_MODEL_REVISION: &str = "5d83992436eae1d760afd27aff78a71d676296fc";
40const PINNED_MAIN_TENSOR_COUNT: usize = 478;
41//  Crate-local pinned copies (`pinned/`): `cargo package` cannot ship files outside the crate
42//  root, and these three are compile-time product surfaces (the artifact census, the model-dir
43//  pin assertion, and the Apache attribution the binary prints). A unit test asserts each copy is
44//  byte-identical to the truth-pack canonical whenever the truth pack is present.
45const PINNED_TENSOR_INVENTORY: &str = include_str!("../pinned/TENSOR_INVENTORY.json");
46const PINNED_MODEL_CONFIG: &str = include_str!("../pinned/model_config.json");
47const APACHE_LICENSE: &str = include_str!("../pinned/QWEN_APACHE_LICENSE");
48//  The `ftts pull` download contract: which release assets make a complete model directory, and
49//  the exact digest each must carry. Embedded so a shipped binary can fetch and verify the model
50//  with no network-served manifest to trust.
51const PINNED_MODEL_MANIFEST: &str = include_str!("../pinned/model_manifest.json");
52/// Subdirectory of `$HOME/.cache` that `ftts pull` fills and model resolution falls back to.
53const DEFAULT_MODEL_CACHE_SUBDIR: &str = ".cache/franken_tts/model";
54const ENVIRONMENT_VARIABLES: [&str; 11] = [
55    "FTTS_MODEL_DIR",
56    "FTTS_DEFAULT_VOICE",
57    "FTTS_THREADS",
58    "FTTS_PROFILE",
59    "FTTS_PACKET_FRAMES",
60    "FTTS_MATH_MODE",
61    "FTTS_QUANT",
62    "FTTS_FORCE_ARCH",
63    "FTTS_NUMA",
64    "FTTS_MAX_FRAMES",
65    "FTTS_MEMORY_BUDGET_MB",
66];
67
68/// Runs the shared `ftts` / `franken_tts` command-line interface.
69pub fn cli_main() -> ExitCode {
70    // The optimized route is the DEFAULT everywhere (library-level: see
71    // `ftts_kernels::route`). `FTTS_INT8=0` selects the f32 reference route end to end;
72    // DISC-003 records the decision and the evidence.
73
74    let cli = match Cli::try_parse() {
75        Ok(cli) => cli,
76        Err(error) => {
77            let exit_code = match error.kind() {
78                clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion => {
79                    FttsExitCode::Success
80                }
81                _ => FttsExitCode::Usage,
82            };
83            let _ = error.print();
84            return exit_code.as_exit_code();
85        }
86    };
87
88    let mut stdin = io::stdin().lock();
89    let mut stdout = io::stdout().lock();
90    let mut stderr = io::stderr().lock();
91    match dispatch(cli, environment(), &mut stdin, &mut stdout, &mut stderr) {
92        Ok(()) => FttsExitCode::Success.as_exit_code(),
93        Err(error) => {
94            let _ = writeln!(stderr, "error: {error}");
95            error.exit_code().as_exit_code()
96        }
97    }
98}
99
100#[derive(Debug, Parser)]
101#[command(
102    name = "ftts",
103    version,
104    about = "Pure-Rust Qwen3-TTS command-line interface",
105    long_about = "FrankenTTS is stateless by default: synthesis history is never persisted. \
106                  Use `ftts robot schema` for the versioned NDJSON contract.",
107    arg_required_else_help = true
108)]
109struct Cli {
110    /// Execution profile. The default is balanced unless FTTS_PROFILE overrides it.
111    #[arg(long, global = true, value_enum)]
112    profile: Option<ExecutionProfile>,
113
114    /// Codec packet size in frames. The default is profile-dependent.
115    #[arg(long, global = true, value_enum)]
116    packet_frames: Option<PacketFrames>,
117
118    /// Math contract used by this invocation.
119    #[arg(long, global = true, value_enum)]
120    math_mode: Option<MathMode>,
121
122    /// Voice-pack serialization profile used by enrollment.
123    #[arg(long, global = true, value_enum)]
124    voice_pack: Option<VoicePackProfile>,
125
126    /// Text-normalization policy.
127    #[arg(long, global = true, value_enum)]
128    normalize: Option<NormalizeMode>,
129
130    /// Request a structured trace from synthesis without default-persisting sensitive text.
131    #[arg(long, global = true, value_name = "DIR")]
132    trace: Option<PathBuf>,
133
134    /// Reproducibility seed for a future sampler.
135    #[arg(long, global = true)]
136    seed: Option<u64>,
137
138    #[command(subcommand)]
139    command: Command,
140}
141
142#[derive(Debug, Subcommand)]
143enum Command {
144    /// Validate or synthesize text with an optional voice pack.
145    Say(SayArgs),
146    /// Build a consent-bearing voice pack from reference audio.
147    Enroll(EnrollArgs),
148    /// Inspect a portable voice pack.
149    Voice(VoiceArgs),
150    /// Convert pinned source weights into a portable .fttsq artifact.
151    Convert(ConvertArgs),
152    /// Download and verify the pinned model files into the model directory.
153    Pull(PullArgs),
154    /// Emit versioned, line-oriented robot contract data.
155    Robot(RobotArgs),
156    /// Report local configuration and readiness without inference.
157    Doctor(DoctorArgs),
158}
159
160#[derive(Debug, clap::Args)]
161struct SayArgs {
162    /// Text to synthesize. Use `-` to read UTF-8 text from stdin.
163    #[arg(value_name = "TEXT")]
164    text: Option<String>,
165
166    /// Output file (same as -o). Format follows the extension: .wav is written natively;
167    /// .m4a, .mp3, and .flac are encoded from the native WAV by the first available system
168    /// encoder (afconvert, ffmpeg, lame, flac).
169    #[arg(value_name = "OUTPUT", conflicts_with_all = ["stream", "output"])]
170    output_positional: Option<PathBuf>,
171
172    /// Read UTF-8 text from PATH. Use `-` for stdin.
173    #[arg(long, value_name = "PATH", conflicts_with = "text")]
174    file: Option<PathBuf>,
175
176    /// Explicit .fttsq model artifact. No network lookup is performed.
177    #[arg(long, value_name = "PATH")]
178    model: Option<PathBuf>,
179
180    /// Optional .ftvoice voice pack.
181    #[arg(long, value_name = "PATH")]
182    voice: Option<PathBuf>,
183
184    /// Write WAV output here. Mutually exclusive with raw stdout streaming.
185    #[arg(short = 'o', long, value_name = "PATH", conflicts_with = "stream")]
186    output: Option<PathBuf>,
187
188    /// Stream raw PCM on stdout; robot events then use stderr.
189    #[arg(long, value_enum)]
190    stream: Option<StreamMode>,
191
192    /// Parse inputs and run the conservative admission preflight without synthesis.
193    #[arg(long)]
194    check: bool,
195}
196
197#[derive(Debug, clap::Args)]
198struct EnrollArgs {
199    /// Reference audio: WAV/FLAC decode natively; m4a/mp3/aac/ogg/opus route through the first
200    /// system decoder found (afconvert on macOS, ffmpeg).
201    #[arg(value_name = "REFERENCE_AUDIO")]
202    reference_audio: PathBuf,
203
204    /// Explicit model directory or .fttsq model artifact. No network lookup is performed.
205    #[arg(long, value_name = "PATH")]
206    model: Option<PathBuf>,
207
208    /// Write the enrolled raw 1,024-wide x-vector here. Refuses to overwrite an existing file.
209    #[arg(short = 'o', long, value_name = "PATH", conflicts_with = "default")]
210    output: Option<PathBuf>,
211
212    /// Write MODEL_DIR/default.spk, which `ftts say` uses when `--voice` is absent.
213    #[arg(long, conflicts_with = "output")]
214    default: bool,
215
216    /// Explicitly proceed after an enrollment-quality warning where safe.
217    #[arg(long)]
218    force: bool,
219}
220
221#[derive(Debug, clap::Args)]
222struct VoiceArgs {
223    #[command(subcommand)]
224    command: VoiceCommand,
225}
226
227#[derive(Debug, Subcommand)]
228enum VoiceCommand {
229    /// Inspect a .ftvoice header without synthesizing.
230    Inspect { path: PathBuf },
231}
232
233#[derive(Debug, clap::Args)]
234struct ConvertArgs {
235    /// Pinned source-weight directory or file.
236    #[arg(value_name = "SOURCE")]
237    source: PathBuf,
238
239    /// Destination .fttsq path. Refuses to overwrite an existing artifact.
240    #[arg(short = 'o', long, value_name = "PATH")]
241    output: PathBuf,
242}
243
244#[derive(Debug, clap::Args)]
245struct PullArgs {
246    /// Destination model directory. Defaults to FTTS_MODEL_DIR, then ~/.cache/franken_tts/model.
247    #[arg(long, value_name = "PATH")]
248    model: Option<PathBuf>,
249
250    /// Re-download every file even when it is already present and verified.
251    #[arg(long)]
252    force: bool,
253}
254
255#[derive(Debug, clap::Args)]
256struct RobotArgs {
257    #[command(subcommand)]
258    command: RobotCommand,
259}
260
261#[derive(Clone, Debug, Subcommand)]
262enum RobotCommand {
263    /// Print the versioned NDJSON event schema.
264    Schema,
265    /// Print a versioned machine-readable readiness event.
266    Health,
267    /// Print available backend routes without probing model weights.
268    Backends,
269    /// Print the self-test state; no unavailable kernel is reported as passing.
270    Selftest,
271}
272
273#[derive(Debug, clap::Args)]
274struct DoctorArgs {
275    /// Emit one JSON object on stdout instead of a human-readable report.
276    #[arg(long)]
277    json: bool,
278}
279
280#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
281enum ExecutionProfile {
282    Interactive,
283    Balanced,
284    Throughput,
285    Strict,
286}
287
288impl ExecutionProfile {
289    const fn as_str(self) -> &'static str {
290        match self {
291            Self::Interactive => "interactive",
292            Self::Balanced => "balanced",
293            Self::Throughput => "throughput",
294            Self::Strict => "strict",
295        }
296    }
297}
298
299#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
300enum PacketFrames {
301    #[value(name = "1")]
302    One,
303    #[value(name = "2")]
304    Two,
305    #[value(name = "4")]
306    Four,
307    Auto,
308}
309
310impl PacketFrames {
311    const fn as_str(self) -> &'static str {
312        match self {
313            Self::One => "1",
314            Self::Two => "2",
315            Self::Four => "4",
316            Self::Auto => "auto",
317        }
318    }
319
320    /// Codec frames carried by one PCM packet.
321    ///
322    /// `auto` resolves to 4 here. The autotuner that will choose it per machine is
323    /// `frankentts-k-packet-tuning-28u`; until it exists, `auto` means "the balanced default"
324    /// rather than a number this call site invented on the spot.
325    const fn frames_per_packet(self) -> u8 {
326        match self {
327            Self::One => 1,
328            Self::Two => 2,
329            Self::Four | Self::Auto => 4,
330        }
331    }
332
333    /// Samples in one packet: frames times the codec's 1,920 samples per 80 ms frame.
334    const fn samples_per_packet(self) -> usize {
335        self.frames_per_packet() as usize * ftts_core::audio::SAMPLES_PER_FRAME
336    }
337
338    const fn default_for(profile: ExecutionProfile) -> Self {
339        match profile {
340            ExecutionProfile::Interactive => Self::One,
341            ExecutionProfile::Balanced => Self::Four,
342            ExecutionProfile::Throughput => Self::Auto,
343            ExecutionProfile::Strict => Self::Four,
344        }
345    }
346}
347
348#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
349enum MathMode {
350    Strict,
351    Fast,
352}
353
354impl MathMode {
355    const fn as_str(self) -> &'static str {
356        match self {
357            Self::Strict => "strict",
358            Self::Fast => "fast",
359        }
360    }
361}
362
363#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
364enum VoicePackProfile {
365    Portable,
366    Private,
367    Minimal,
368}
369
370impl VoicePackProfile {
371    const fn as_str(self) -> &'static str {
372        match self {
373            Self::Portable => "portable",
374            Self::Private => "private",
375            Self::Minimal => "minimal",
376        }
377    }
378}
379
380#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
381enum NormalizeMode {
382    Verbatim,
383    Conservative,
384    LocaleAware,
385}
386
387impl NormalizeMode {
388    const fn as_str(self) -> &'static str {
389        match self {
390            Self::Verbatim => "verbatim",
391            Self::Conservative => "conservative",
392            Self::LocaleAware => "locale-aware",
393        }
394    }
395}
396
397impl From<NormalizeMode> for NormalizationMode {
398    fn from(mode: NormalizeMode) -> Self {
399        match mode {
400            NormalizeMode::Verbatim => Self::Verbatim,
401            NormalizeMode::Conservative => Self::Conservative,
402            NormalizeMode::LocaleAware => Self::LocaleAware,
403        }
404    }
405}
406
407#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
408enum StreamMode {
409    Raw,
410}
411
412#[derive(Debug, Default)]
413struct Environment {
414    values: BTreeMap<&'static str, Option<OsString>>,
415    stage_budget_values: BTreeMap<OsString, OsString>,
416}
417
418impl Environment {
419    fn from_process() -> Self {
420        let values = ENVIRONMENT_VARIABLES
421            .into_iter()
422            .map(|name| (name, std::env::var_os(name)))
423            .collect();
424        let stage_budget_values = std::env::vars_os()
425            .filter(|(name, _)| {
426                name.to_str().is_some_and(|name| {
427                    name.starts_with("FTTS_STAGE_BUDGET_") && name.ends_with("_MS")
428                })
429            })
430            .collect();
431        Self {
432            values,
433            stage_budget_values,
434        }
435    }
436
437    fn value(&self, name: &'static str) -> Option<&str> {
438        self.values.get(name)?.as_deref()?.to_str()
439    }
440
441    fn documented_values(&self) -> BTreeMap<String, Option<String>> {
442        let mut values = self
443            .values
444            .iter()
445            .map(|(name, value)| {
446                (
447                    (*name).to_owned(),
448                    value
449                        .as_ref()
450                        .map(|value| value.to_string_lossy().into_owned()),
451                )
452            })
453            .collect::<BTreeMap<_, _>>();
454        values.insert("FTTS_STAGE_BUDGET_*_MS".to_owned(), None);
455        values.extend(self.stage_budget_values.iter().map(|(name, value)| {
456            (
457                name.to_string_lossy().into_owned(),
458                Some(value.to_string_lossy().into_owned()),
459            )
460        }));
461        values
462    }
463}
464
465fn environment() -> &'static Environment {
466    static ENVIRONMENT: OnceLock<Environment> = OnceLock::new();
467    ENVIRONMENT.get_or_init(Environment::from_process)
468}
469
470#[derive(Debug)]
471struct EffectiveSettings {
472    profile: ExecutionProfile,
473    packet_frames: PacketFrames,
474    math_mode: MathMode,
475    voice_pack: VoicePackProfile,
476    normalize: NormalizeMode,
477}
478
479impl EffectiveSettings {
480    fn resolve(cli: &Cli, environment: &Environment) -> Result<Self, FttsError> {
481        let profile = cli
482            .profile
483            .or(parse_env_value(
484                environment.value("FTTS_PROFILE"),
485                "FTTS_PROFILE",
486                ExecutionProfile::value_variants(),
487            )?)
488            .unwrap_or(ExecutionProfile::Balanced);
489        let packet_frames = cli
490            .packet_frames
491            .or(parse_env_value(
492                environment.value("FTTS_PACKET_FRAMES"),
493                "FTTS_PACKET_FRAMES",
494                PacketFrames::value_variants(),
495            )?)
496            .unwrap_or_else(|| PacketFrames::default_for(profile));
497        let math_mode = cli
498            .math_mode
499            .or(parse_env_value(
500                environment.value("FTTS_MATH_MODE"),
501                "FTTS_MATH_MODE",
502                MathMode::value_variants(),
503            )?)
504            .unwrap_or(MathMode::Fast);
505        let voice_pack = cli.voice_pack.unwrap_or(VoicePackProfile::Portable);
506        let normalize = cli.normalize.unwrap_or(NormalizeMode::Verbatim);
507        Ok(Self {
508            profile,
509            packet_frames,
510            math_mode,
511            voice_pack,
512            normalize,
513        })
514    }
515
516    fn normalization_options(&self) -> NormalizationOptions {
517        NormalizationOptions {
518            mode: self.normalize.into(),
519            ..NormalizationOptions::default()
520        }
521    }
522}
523
524fn parse_env_value<T>(
525    value: Option<&str>,
526    name: &str,
527    variants: &'static [T],
528) -> Result<Option<T>, FttsError>
529where
530    T: ValueEnum + Copy,
531{
532    match value {
533        None => Ok(None),
534        Some(value) => T::from_str(value, true).map(Some).map_err(|_| {
535            let choices = variants
536                .iter()
537                .filter_map(|variant| variant.to_possible_value())
538                .map(|variant| variant.get_name().to_owned())
539                .collect::<Vec<_>>()
540                .join(", ");
541            FttsError::Usage(format!("invalid {name}={value:?}; use one of: {choices}"))
542        }),
543    }
544}
545
546fn dispatch(
547    cli: Cli,
548    environment: &Environment,
549    stdin: &mut dyn Read,
550    stdout: &mut dyn Write,
551    stderr: &mut dyn Write,
552) -> Result<(), FttsError> {
553    match &cli.command {
554        Command::Say(args) => run_say(&cli, args, environment, stdin, stdout, stderr),
555        Command::Enroll(args) => run_enroll(args, environment, stdout),
556        Command::Voice(VoiceArgs {
557            command: VoiceCommand::Inspect { path },
558        }) => run_voice_inspect(path, stdout),
559        Command::Convert(args) => run_convert(&cli, args, environment, stdout, stderr),
560        Command::Pull(args) => run_pull(args, environment, stdout),
561        Command::Robot(args) => run_robot(args.command.clone(), environment, stdout),
562        Command::Doctor(args) => run_doctor(args, environment, stdout),
563    }
564}
565
566/// A source tensor pinned by the truth-pack inventory and its reviewed storage policy.
567#[derive(Clone, Debug)]
568struct PinnedMainTensor {
569    name: String,
570    dtype: Dtype,
571    shape: Vec<usize>,
572    access_class: AccessClass,
573    storage: TensorStoragePolicy,
574}
575
576fn run_convert(
577    cli: &Cli,
578    args: &ConvertArgs,
579    environment: &Environment,
580    stdout: &mut dyn Write,
581    stderr: &mut dyn Write,
582) -> Result<(), FttsError> {
583    let run = robot::RunContext::generate();
584    let outcome = run_convert_events(cli, args, environment, &run, &mut |event| {
585        write_json_line(stdout, event)
586    });
587
588    if let Err(error) = &outcome {
589        let mut event = run.event(robot::EventType::RunError);
590        event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
591        event.insert("kind".to_owned(), json!(error.exit_code().description()));
592        event.insert("message".to_owned(), json!(error.to_string()));
593        event.insert("remediation".to_owned(), json!(error.remediation()));
594        event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
595        write_json_line(stderr, &Value::Object(event))?;
596    }
597
598    outcome
599}
600
601/// Converts the pinned main checkpoint and emits the normal run lifecycle receipt.
602///
603/// The source mapping owns no writable state. The destination is first created under a unique
604/// sibling name with `create_new`, then atomically renamed only after the streaming writer, an
605/// `fsync`, and a digest-validating mapped re-read all succeed. We deliberately leave a failed
606/// staging file in place for diagnosis rather than deleting data behind the caller's back.
607fn run_convert_events(
608    cli: &Cli,
609    args: &ConvertArgs,
610    environment: &Environment,
611    run: &robot::RunContext,
612    emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
613) -> Result<(), FttsError> {
614    let settings = EffectiveSettings::resolve(cli, environment)?;
615    let mut start = run.event(robot::EventType::RunStart);
616    start.insert("command".to_owned(), json!("convert"));
617    start.insert("profile".to_owned(), json!(settings.profile.as_str()));
618    start.insert(
619        "packet_frames".to_owned(),
620        json!(settings.packet_frames.as_str()),
621    );
622    start.insert("math_mode".to_owned(), json!(settings.math_mode.as_str()));
623    start.insert("stateless".to_owned(), json!(true));
624    start.insert("seed".to_owned(), json!(cli.seed));
625    start.insert("model".to_owned(), Value::Null);
626    start.insert("voice".to_owned(), Value::Null);
627    emit(&Value::Object(start))?;
628
629    let mut seq = 0_u64;
630    emit_stage(run, emit, "source_preflight", "begin", &mut seq)?;
631    let source = resolve_pinned_main_source(&args.source)?;
632    let mapping = MappedFile::open(&source).map_err(|error| {
633        FttsError::Input(format!(
634            "cannot memory-map pinned source checkpoint {}: {error}",
635            source.display()
636        ))
637    })?;
638    let (manifest, plan) = pinned_main_conversion_plan()?;
639    let staging = conversion_staging_path(&args.output)?;
640    emit_stage(run, emit, "source_preflight", "end", &mut seq)?;
641
642    emit_stage(run, emit, "convert", "begin", &mut seq)?;
643    let destination = std::fs::File::options()
644        .write(true)
645        .create_new(true)
646        .open(&staging)
647        .map_err(|error| {
648            FttsError::Input(format!(
649                "cannot create conversion staging artifact {}: {error}; the output path is never overwritten",
650                staging.display()
651            ))
652        })?;
653    let destination = convert_safetensors_streaming(
654        mapping.as_slice(),
655        &manifest,
656        &plan,
657        destination,
658    )
659    .map_err(|error| {
660        FttsError::ArtifactFormat(format!(
661            "conversion failed before publication: {error}; staging artifact retained at {}",
662            staging.display()
663        ))
664    })?;
665    destination.sync_all().map_err(|error| {
666        FttsError::ArtifactFormat(format!(
667            "cannot sync converted artifact at {}: {error}; staging artifact retained",
668            staging.display()
669        ))
670    })?;
671    drop(destination);
672    emit_stage(run, emit, "convert", "end", &mut seq)?;
673
674    emit_stage(run, emit, "verify", "begin", &mut seq)?;
675    let verified = MappedFttsq::open(&staging).map_err(|error| {
676        FttsError::ArtifactFormat(format!(
677            "converted staging artifact did not pass digest re-read: {error}; retained at {}",
678            staging.display()
679        ))
680    })?;
681    if verified.reader().source_sha256() != PINNED_MAIN_WEIGHTS_SHA256 {
682        return Err(FttsError::ArtifactFormat(format!(
683            "converted staging artifact recorded an unexpected source digest {}; retained at {}",
684            verified.reader().source_sha256(),
685            staging.display()
686        )));
687    }
688    drop(verified);
689    std::fs::rename(&staging, &args.output).map_err(|error| {
690        FttsError::ArtifactFormat(format!(
691            "converted artifact verified but could not be published from {} to {}: {error}; staging artifact retained",
692            staging.display(),
693            args.output.display()
694        ))
695    })?;
696    emit_stage(run, emit, "verify", "end", &mut seq)?;
697
698    let mut complete = run.event(robot::EventType::RunComplete);
699    complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
700    complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
701    complete.insert("frames".to_owned(), json!(0));
702    complete.insert("audio_bytes".to_owned(), json!(0));
703    emit(&Value::Object(complete))
704}
705
706fn resolve_pinned_main_source(source: &Path) -> Result<PathBuf, FttsError> {
707    let source = if source.is_dir() {
708        source.join(PINNED_MAIN_WEIGHTS_FILENAME)
709    } else {
710        source.to_owned()
711    };
712    if !source.is_file() {
713        return Err(FttsError::Input(format!(
714            "pinned main checkpoint {} does not exist or is not a file; pass model.safetensors or its containing directory",
715            source.display()
716        )));
717    }
718    if source.file_name().and_then(|name| name.to_str()) != Some(PINNED_MAIN_WEIGHTS_FILENAME) {
719        return Err(FttsError::Input(format!(
720            "this converter accepts the pinned main checkpoint named {PINNED_MAIN_WEIGHTS_FILENAME}, not {}",
721            source.display()
722        )));
723    }
724    Ok(source)
725}
726
727fn conversion_staging_path(output: &Path) -> Result<PathBuf, FttsError> {
728    if output.exists() {
729        return Err(FttsError::Input(format!(
730            "refusing to overwrite existing output {}; choose a new -o path",
731            output.display()
732        )));
733    }
734    let parent = output.parent().unwrap_or_else(|| Path::new("."));
735    let file_name = output
736        .file_name()
737        .and_then(|name| name.to_str())
738        .ok_or_else(|| {
739            FttsError::Usage("conversion output must name a file, not a directory".to_owned())
740        })?;
741    let nonce = std::time::SystemTime::now()
742        .duration_since(std::time::UNIX_EPOCH)
743        .map_err(|error| FttsError::Generic(format!("system clock is before UNIX_EPOCH: {error}")))?
744        .as_nanos();
745    let staging = parent.join(format!(
746        ".{file_name}.fttsq-converting-{}-{nonce}",
747        std::process::id()
748    ));
749    if staging.exists() {
750        return Err(FttsError::Input(format!(
751            "conversion staging path already exists {}; inspect or move it before retrying",
752            staging.display()
753        )));
754    }
755    Ok(staging)
756}
757
758fn pinned_main_conversion_plan() -> Result<(WeightsManifest, StreamingConversionPlan), FttsError> {
759    let specs = pinned_main_tensor_specs()?;
760    let manifest = WeightsManifest::from_expectations(
761        "Qwen/Qwen3-TTS-12Hz-0.6B-Base main checkpoint",
762        specs
763            .iter()
764            .map(|spec| ExpectedTensor::new(&spec.name, spec.shape.clone(), spec.dtype)),
765    );
766    let model_config = serde_json::from_str(PINNED_MODEL_CONFIG).map_err(|error| {
767        FttsError::Generic(format!(
768            "checked-in pinned model config is invalid JSON: {error}"
769        ))
770    })?;
771    let q8_count = specs
772        .iter()
773        .filter(|spec| spec.storage == TensorStoragePolicy::Q8PerOutputChannel)
774        .count();
775    let mut plan = StreamingConversionPlan::new(
776        "qwen3-tts-12hz-0.6b-base",
777        PINNED_MAIN_WEIGHTS_SHA256,
778    )
779    .license_notice(pinned_license_notice())
780    .model_config(model_config)
781    .quantization_manifest(json!({
782        "source": {
783            "repository": "Qwen/Qwen3-TTS-12Hz-0.6B-Base",
784            "revision": PINNED_MODEL_REVISION,
785            "file": PINNED_MAIN_WEIGHTS_FILENAME,
786            "sha256": PINNED_MAIN_WEIGHTS_SHA256,
787        },
788        "q8_recipe": "symmetric per-output-channel int8; zero_point=0; scale=max_abs(row)/127",
789        "q8_tensor_count": q8_count,
790        "verbatim_tensor_count": specs.len() - q8_count,
791        "q8_scope": "talker and residual-code-microdecoder attention/MLP projection matrices only",
792        "verbatim_scope": "norms, heads, embeddings, speaker path, and every tensor outside the reviewed Q8 projection set",
793    }));
794    for spec in specs {
795        let conversion = match spec.storage {
796            TensorStoragePolicy::Verbatim => {
797                TensorConversion::verbatim(&spec.name, &spec.name, spec.access_class)
798            }
799            TensorStoragePolicy::Q8PerOutputChannel => {
800                TensorConversion::q8_per_output_channel(&spec.name, &spec.name, spec.access_class)
801            }
802        };
803        plan = plan.tensor(conversion);
804    }
805    Ok((manifest, plan))
806}
807
808fn pinned_main_tensor_specs() -> Result<Vec<PinnedMainTensor>, FttsError> {
809    let inventory: Value = serde_json::from_str(PINNED_TENSOR_INVENTORY).map_err(|error| {
810        FttsError::Generic(format!(
811            "checked-in tensor inventory is invalid JSON: {error}"
812        ))
813    })?;
814    if inventory.get("source_pin").and_then(Value::as_str)
815        != Some(&format!(
816            "Qwen/Qwen3-TTS-12Hz-0.6B-Base@{PINNED_MODEL_REVISION}"
817        ))
818    {
819        return Err(FttsError::Generic(
820            "checked-in tensor inventory does not name the pinned Qwen3-TTS revision".to_owned(),
821        ));
822    }
823    let records = inventory
824        .get("tensors")
825        .and_then(Value::as_array)
826        .ok_or_else(|| {
827            FttsError::Generic("checked-in tensor inventory lacks tensors[]".to_owned())
828        })?;
829    let mut specs = Vec::new();
830    for record in records {
831        if record.get("source").and_then(Value::as_str) != Some(PINNED_MAIN_WEIGHTS_FILENAME) {
832            continue;
833        }
834        let name = required_inventory_string(record, "name")?.to_owned();
835        let dtype = match required_inventory_string(record, "dtype")? {
836            "BF16" => Dtype::Bf16,
837            "F32" => Dtype::F32,
838            other => {
839                return Err(FttsError::Generic(format!(
840                    "pinned main inventory has unsupported dtype {other:?} for {name}"
841                )));
842            }
843        };
844        let shape = record
845            .get("shape")
846            .and_then(Value::as_array)
847            .ok_or_else(|| {
848                FttsError::Generic(format!("pinned inventory tensor {name} lacks shape[]"))
849            })?
850            .iter()
851            .map(|dimension| {
852                dimension
853                    .as_u64()
854                    .and_then(|dimension| usize::try_from(dimension).ok())
855                    .ok_or_else(|| {
856                        FttsError::Generic(format!(
857                            "pinned inventory tensor {name} has a non-usize shape dimension"
858                        ))
859                    })
860            })
861            .collect::<Result<Vec<_>, _>>()?;
862        let storage = if is_q8_projection(&name) {
863            TensorStoragePolicy::Q8PerOutputChannel
864        } else {
865            TensorStoragePolicy::Verbatim
866        };
867        specs.push(PinnedMainTensor {
868            access_class: main_access_class(&name)?,
869            name,
870            dtype,
871            shape,
872            storage,
873        });
874    }
875    if specs.len() != PINNED_MAIN_TENSOR_COUNT {
876        return Err(FttsError::Generic(format!(
877            "pinned main inventory contains {} tensors, expected {PINNED_MAIN_TENSOR_COUNT}",
878            specs.len()
879        )));
880    }
881    Ok(specs)
882}
883
884fn required_inventory_string<'a>(record: &'a Value, field: &str) -> Result<&'a str, FttsError> {
885    record.get(field).and_then(Value::as_str).ok_or_else(|| {
886        FttsError::Generic(format!(
887            "checked-in tensor inventory record lacks string {field:?}"
888        ))
889    })
890}
891
892fn is_q8_projection(name: &str) -> bool {
893    (name.starts_with("talker.model.layers.")
894        || name.starts_with("talker.code_predictor.model.layers."))
895        && [
896            ".self_attn.q_proj.weight",
897            ".self_attn.k_proj.weight",
898            ".self_attn.v_proj.weight",
899            ".self_attn.o_proj.weight",
900            ".mlp.gate_proj.weight",
901            ".mlp.up_proj.weight",
902            ".mlp.down_proj.weight",
903        ]
904        .iter()
905        .any(|suffix| name.ends_with(suffix))
906}
907
908fn main_access_class(name: &str) -> Result<AccessClass, FttsError> {
909    if name == "talker.model.text_embedding.weight" {
910        Ok(AccessClass::ColdTextEmbedding)
911    } else if name.starts_with("speaker_encoder.") {
912        Ok(AccessClass::EnrollmentSpeakerEncoder)
913    } else if name.starts_with("talker.code_predictor.")
914        || name == "talker.model.codec_embedding.weight"
915    {
916        Ok(AccessClass::HotRecurrentMicrodecoder)
917    } else if name.starts_with("talker.model.")
918        || name.starts_with("talker.codec_head.")
919        || name.starts_with("talker.text_projection.")
920    {
921        Ok(AccessClass::HotRecurrentTalker)
922    } else {
923        Err(FttsError::Generic(format!(
924            "pinned main tensor {name} has no reviewed access-class assignment"
925        )))
926    }
927}
928
929fn pinned_license_notice() -> String {
930    format!(
931        "This artifact contains model weights derived from\n\
932         Qwen3-TTS-12Hz-0.6B-Base (https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-Base)\n\
933         and code derived from QwenLM/Qwen3-TTS (https://github.com/QwenLM/Qwen3-TTS).\n\n\
934         Copyright 2026 Alibaba Cloud\n\n\
935         Licensed under the Apache License, Version 2.0.\n\
936         http://www.apache.org/licenses/LICENSE-2.0\n\n\
937         CHANGES: the original bfloat16 weights were converted to franken_tts's\n\
938         quantized .fttsq container. Tensors were requantized according to the\n\
939         artifact's quantization manifest; protected tensors remain verbatim.\n\
940         The model graph is re-implemented in Rust.\n\n\
941         Apache License, Version 2.0:\n\n{APACHE_LICENSE}"
942    )
943}
944
945fn run_say(
946    cli: &Cli,
947    args: &SayArgs,
948    environment: &Environment,
949    stdin: &mut dyn Read,
950    stdout: &mut dyn Write,
951    stderr: &mut dyn Write,
952) -> Result<(), FttsError> {
953    let run = robot::RunContext::generate();
954    // `--stream raw` puts PCM on stdout, so events move to stderr. One contract, chosen once here
955    // so no later emission can pick the other stream and interleave NDJSON with audio bytes. The
956    // two branches also settle the borrows: in raw mode audio owns `stdout` and events own
957    // `stderr`; otherwise events own `stdout` and the raw sink is a discard that never runs.
958    let outcome = if args.stream == Some(StreamMode::Raw) {
959        run_say_events(cli, args, environment, stdin, &run, stdout, &mut |event| {
960            write_json_line(stderr, event)
961        })
962    } else {
963        let mut discard = io::sink();
964        run_say_events(
965            cli,
966            args,
967            environment,
968            stdin,
969            &run,
970            &mut discard,
971            &mut |event| write_json_line(stdout, event),
972        )
973    };
974
975    if let Err(error) = &outcome {
976        // The error is reported on the machine contract, not only as a human line: run_error is a
977        // stderr event by definition (see the catalogue), so it goes to stderr on both stream
978        // shapes.
979        let mut event = run.event(robot::EventType::RunError);
980        event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
981        event.insert("kind".to_owned(), json!(error.exit_code().description()));
982        event.insert("message".to_owned(), json!(error.to_string()));
983        event.insert("remediation".to_owned(), json!(error.remediation()));
984        event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
985        write_json_line(stderr, &Value::Object(event))?;
986    }
987
988    outcome
989}
990
991/// Emit one `stage` event and advance the run's stage counter.
992fn emit_stage(
993    run: &robot::RunContext,
994    emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
995    name: &str,
996    state: &str,
997    seq: &mut u64,
998) -> Result<(), FttsError> {
999    let mut event = run.event(robot::EventType::Stage);
1000    event.insert("name".to_owned(), json!(name));
1001    event.insert("seq".to_owned(), json!(*seq));
1002    event.insert("state".to_owned(), json!(state));
1003    event.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1004    event.insert("budget_ms".to_owned(), Value::Null);
1005    *seq += 1;
1006    emit(&Value::Object(event))
1007}
1008
1009/// The `say` pipeline proper, emitting its lifecycle through `emit`.
1010///
1011/// Split out so the caller owns stream selection and the single `run_error` emission point: a
1012/// pipeline that emitted its own errors would have to know which stream it was on at every `?`.
1013fn run_say_events(
1014    cli: &Cli,
1015    args: &SayArgs,
1016    environment: &Environment,
1017    stdin: &mut dyn Read,
1018    run: &robot::RunContext,
1019    raw_audio: &mut dyn Write,
1020    emit: &mut dyn FnMut(&Value) -> Result<(), FttsError>,
1021) -> Result<(), FttsError> {
1022    let settings = EffectiveSettings::resolve(cli, environment)?;
1023
1024    let mut start = run.event(robot::EventType::RunStart);
1025    start.insert("command".to_owned(), json!("say"));
1026    start.insert("profile".to_owned(), json!(settings.profile.as_str()));
1027    start.insert(
1028        "packet_frames".to_owned(),
1029        json!(settings.packet_frames.as_str()),
1030    );
1031    start.insert("math_mode".to_owned(), json!(settings.math_mode.as_str()));
1032    start.insert("stateless".to_owned(), json!(true));
1033    start.insert("seed".to_owned(), json!(cli.seed));
1034    start.insert("model".to_owned(), json!(args.model.as_deref()));
1035    start.insert(
1036        "voice".to_owned(),
1037        json!(args.voice.as_ref().map(|path| path.display().to_string())),
1038    );
1039    emit(&Value::Object(start))?;
1040
1041    let mut seq = 0u64;
1042
1043    emit_stage(run, emit, "resolve", "begin", &mut seq)?;
1044    let text = read_text(args, stdin)?;
1045    let model = resolve_model(args.model.as_deref(), environment)?;
1046    let voice = resolve_requested_voice(args.voice.as_deref(), environment)?;
1047    emit_stage(run, emit, "resolve", "end", &mut seq)?;
1048
1049    let request = SynthesisRequest::new(text)
1050        .with_normalization_options(settings.normalization_options())
1051        .with_normalization_trace(cli.trace.is_some());
1052
1053    // Privacy-safe by construction: shape and rule names only, never the text itself. The CLI
1054    // promises no persisted synthesis history, and an event stream an agent may log is exactly
1055    // where that promise would leak if this carried the input.
1056    let mut prepared = run.event(robot::EventType::TextPrepared);
1057    prepared.insert("normalize".to_owned(), json!(settings.normalize.as_str()));
1058    prepared.insert(
1059        "unicode_version".to_owned(),
1060        json!(ftts_model_qwen::tokenizer::unicode_version()),
1061    );
1062    prepared.insert("char_count".to_owned(), json!(request.text.chars().count()));
1063    prepared.insert(
1064        "trace_requested".to_owned(),
1065        json!(request.trace_normalization),
1066    );
1067    emit(&Value::Object(prepared))?;
1068
1069    // `-o PATH` and the positional OUTPUT are the same request; clap rejects supplying both.
1070    let requested_output: Option<PathBuf> = args
1071        .output
1072        .clone()
1073        .or_else(|| args.output_positional.clone());
1074    let output_plan = requested_output
1075        .as_deref()
1076        .map(OutputPlan::for_path)
1077        .transpose()?;
1078
1079    emit_stage(run, emit, "admission", "begin", &mut seq)?;
1080    let admission = admission_plan(&request.text, &settings)?;
1081    emit_stage(run, emit, "admission", "end", &mut seq)?;
1082
1083    if args.check {
1084        let event = json!({
1085            "schema_version": ROBOT_SCHEMA_VERSION,
1086            "event": "check_complete",
1087            "run_id": run.run_id(),
1088            "model": model,
1089            "voice": voice,
1090            "profile": settings.profile.as_str(),
1091            "packet_frames": settings.packet_frames.as_str(),
1092            "math_mode": settings.math_mode.as_str(),
1093            "voice_pack": settings.voice_pack.as_str(),
1094            "normalize": settings.normalize.as_str(),
1095            "normalization_trace_requested": request.trace_normalization,
1096            "seed": cli.seed,
1097            "trace": cli.trace.as_ref().map(|path| path.display().to_string()),
1098            "output": requested_output.as_ref().map(|path| path.display().to_string()),
1099            "admission": admission,
1100        });
1101        emit(&event)?;
1102        let mut complete = run.event(robot::EventType::RunComplete);
1103        complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
1104        complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1105        complete.insert("frames".to_owned(), json!(0));
1106        complete.insert("audio_bytes".to_owned(), json!(0));
1107        emit(&Value::Object(complete))?;
1108        return Ok(());
1109    }
1110
1111    // --- audio destination, decided before any model work ----------------------------------
1112    // A run that synthesizes for thirty seconds and then discovers it has nowhere to put the
1113    // result has wasted the user's time; the refusal belongs here, before the weights load.
1114    let raw_stream = args.stream == Some(StreamMode::Raw);
1115    let mut audio = match (&output_plan, raw_stream) {
1116        (Some(plan), false) => AudioOutput::wav(&plan.wav_path)?,
1117        (None, true) => AudioOutput::raw(),
1118        (None, false) => {
1119            return Err(FttsError::Usage(
1120                "`ftts say` has nowhere to put the audio; add an output path (`ftts say \"text\" \
1121                 out.wav`, or `-o PATH`) or `--stream raw` for PCM on stdout"
1122                    .to_owned(),
1123            ));
1124        }
1125        // clap declares every output form and `--stream` mutually exclusive.
1126        (Some(_), true) => unreachable!("clap enforces the conflict"),
1127    };
1128
1129    // --- model load ------------------------------------------------------------------------
1130    emit_stage(run, emit, "load", "begin", &mut seq)?;
1131    let bundle = synth::ModelBundle::resolve(Path::new(&model))?;
1132    let voice_path = voice
1133        .as_deref()
1134        .map(PathBuf::from)
1135        .or_else(|| {
1136            let candidate = bundle.root.join("default.spk");
1137            candidate.is_file().then_some(candidate)
1138        })
1139        .ok_or_else(|| {
1140            FttsError::Usage(
1141                "`ftts say` needs a real voice source; pass --voice PATH, set \
1142                 FTTS_DEFAULT_VOICE=PATH, or run `ftts enroll REF.wav --model MODEL_DIR --default`. \
1143                 Qwen3-TTS Base has no preset speaker, so a missing default is refused rather than \
1144                 fabricated."
1145                    .to_owned(),
1146            )
1147        })?;
1148    let speaker = synth::speaker_from_voice(&bundle, &voice_path)?;
1149    let loaded = synth::LoadedModel::load(&bundle)?;
1150    emit_stage(run, emit, "load", "end", &mut seq)?;
1151
1152    // --- synthesis -------------------------------------------------------------------------
1153    // The engine's observer is not forwarded to the event stream here. Its events are lifecycle
1154    // facts the robot contract already carries as `stage` events, and per-frame progress cannot be
1155    // emitted from inside this call anyway: `emit` is borrowed for the duration. Progress becomes
1156    // observable per packet once synthesis returns, and genuinely incremental frame events belong
1157    // with the streaming decode path rather than being faked from a completed run.
1158    let observer = |_event: ftts_core::SynthesisEvent| {};
1159
1160    emit_stage(run, emit, "synthesis", "begin", &mut seq)?;
1161    let engine = ftts_core::TtsEngine::from_process_environment()
1162        .map_err(|error| FttsError::Generic(format!("cannot start the engine: {error}")))?;
1163    let cancellation = ftts_core::CancellationToken::new();
1164    let audio_result = synth::synthesize(
1165        &loaded,
1166        &engine,
1167        &request,
1168        &speaker,
1169        // Canonical greedy consumes no RNG state, so an absent `--seed` changes nothing today;
1170        // 0 is the documented default rather than a value picked per run, which would make a
1171        // future switch to the production sampler silently irreproducible.
1172        cli.seed.unwrap_or(0),
1173        &cancellation,
1174        &observer,
1175    )?;
1176    emit_stage(run, emit, "synthesis", "end", &mut seq)?;
1177
1178    // --- the output tail ---------------------------------------------------------------------
1179    let packet_samples = settings.packet_frames.samples_per_packet();
1180    let packet_frame_count = settings.packet_frames.frames_per_packet();
1181    emit_stage(run, emit, "output", "begin", &mut seq)?;
1182    for packet in audio_result.pcm.chunks(packet_samples) {
1183        let event = audio.write_packet(packet, raw_audio, run.run_id(), packet_frame_count)?;
1184        emit(&event)?;
1185    }
1186    let audio_bytes = audio.byte_offset();
1187    let samples = audio.finish()?;
1188    if let Some(plan) = &output_plan {
1189        plan.finalize()?;
1190    }
1191    emit_stage(run, emit, "output", "end", &mut seq)?;
1192
1193    let mut complete = run.event(robot::EventType::RunComplete);
1194    complete.insert("exit_code".to_owned(), json!(FttsExitCode::Success.as_u8()));
1195    complete.insert("elapsed_ms".to_owned(), json!(run.elapsed_ms()));
1196    complete.insert("frames".to_owned(), json!(audio_result.frames));
1197    complete.insert("audio_bytes".to_owned(), json!(audio_bytes));
1198    complete.insert("samples".to_owned(), json!(samples));
1199    complete.insert(
1200        "duration_ms".to_owned(),
1201        json!(samples * 1000 / u64::from(ftts_core::audio::SAMPLE_RATE_HZ)),
1202    );
1203    complete.insert(
1204        "prepared_token_count".to_owned(),
1205        json!(audio_result.prepared_token_count),
1206    );
1207    emit(&Value::Object(complete))?;
1208    Ok(())
1209}
1210
1211/// Where synthesised PCM goes, and the `audio_chunk` events that describe it.
1212///
1213/// The two destinations are mutually exclusive by contract (AGENTS.md agent ergonomics): either
1214/// events own stdout and audio goes to `-o PATH`, or `--stream raw` gives stdout to PCM and every
1215/// event goes to stderr. Raw bytes and NDJSON are never interleaved on one stream, so this type
1216/// owns the decision once instead of leaving it to each call site.
1217///
1218/// `audio_chunk` reports the bytes written, never the bytes themselves.
1219pub enum AudioSink {
1220    /// A WAV file. The header is finalised on [`AudioSink::finish`], so a run cut short still
1221    /// leaves a playable file describing the samples that landed.
1222    Wav(Box<ftts_core::audio::WavWriter<fs::File>>),
1223    /// Raw little-endian 16-bit PCM on a caller-supplied stream (`--stream raw`).
1224    RawPcm,
1225    /// `--check` and other non-synthesising paths.
1226    None,
1227}
1228
1229/// Accumulating state for the `audio_chunk` event stream.
1230pub struct AudioOutput {
1231    sink: AudioSink,
1232    byte_offset: u64,
1233    samples_written: u64,
1234}
1235
1236/// Audio container selected by the output path's extension.
1237#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1238enum OutputFormat {
1239    /// Written natively by the pure-Rust WAV writer.
1240    Wav,
1241    /// AAC in an MPEG-4 container, encoded by `afconvert` (macOS) or `ffmpeg`.
1242    M4a,
1243    /// MP3, encoded by `lame` or `ffmpeg`.
1244    Mp3,
1245    /// FLAC, encoded by `flac` or `ffmpeg`.
1246    Flac,
1247}
1248
1249/// Where the WAV bytes land and what happens to them after synthesis.
1250///
1251/// Synthesis always writes the pure-Rust WAV stream ("self-contained" covers everything up to
1252/// and including that file). For a compressed extension the WAV goes to a sibling staging file
1253/// and is then handed to the first available *system* encoder — an optional post-step, never a
1254/// runtime dependency of synthesis itself. No encoder found is a refusal with the tool list,
1255/// not a silent format switch.
1256#[derive(Clone, Debug)]
1257struct OutputPlan {
1258    /// The path the user asked for.
1259    final_path: PathBuf,
1260    /// Where the WAV sink writes; equals `final_path` for `.wav`.
1261    wav_path: PathBuf,
1262    format: OutputFormat,
1263}
1264
1265impl OutputPlan {
1266    fn for_path(path: &Path) -> Result<Self, FttsError> {
1267        let extension = path
1268            .extension()
1269            .and_then(|extension| extension.to_str())
1270            .map(str::to_ascii_lowercase);
1271        let format = match extension.as_deref() {
1272            Some("wav") | None => OutputFormat::Wav,
1273            Some("m4a" | "aac") => OutputFormat::M4a,
1274            Some("mp3") => OutputFormat::Mp3,
1275            Some("flac") => OutputFormat::Flac,
1276            Some(other) => {
1277                return Err(FttsError::Usage(format!(
1278                    "unsupported output extension `.{other}`; use .wav (native), .m4a, .mp3, or \
1279                     .flac (system encoder)"
1280                )));
1281            }
1282        };
1283        let wav_path = if format == OutputFormat::Wav {
1284            path.to_path_buf()
1285        } else {
1286            let mut staging = path.as_os_str().to_owned();
1287            staging.push(".ftts-staging.wav");
1288            PathBuf::from(staging)
1289        };
1290        Ok(Self {
1291            final_path: path.to_path_buf(),
1292            wav_path,
1293            format,
1294        })
1295    }
1296
1297    /// Encodes the staged WAV into the requested container and removes the staging file.
1298    fn finalize(&self) -> Result<(), FttsError> {
1299        if self.format == OutputFormat::Wav {
1300            return Ok(());
1301        }
1302        let wav = self.wav_path.as_os_str();
1303        let target = self.final_path.as_os_str();
1304        // (encoder, arguments) attempts in preference order; the first tool present decides.
1305        let attempts: &[(&str, Vec<&std::ffi::OsStr>)] = &match self.format {
1306            OutputFormat::M4a => [
1307                (
1308                    "afconvert",
1309                    vec![
1310                        "-f".as_ref(),
1311                        "m4af".as_ref(),
1312                        "-d".as_ref(),
1313                        "aac".as_ref(),
1314                        wav,
1315                        target,
1316                    ],
1317                ),
1318                (
1319                    "ffmpeg",
1320                    vec![
1321                        "-y".as_ref(),
1322                        "-loglevel".as_ref(),
1323                        "error".as_ref(),
1324                        "-i".as_ref(),
1325                        wav,
1326                        "-c:a".as_ref(),
1327                        "aac".as_ref(),
1328                        target,
1329                    ],
1330                ),
1331            ],
1332            OutputFormat::Mp3 => [
1333                (
1334                    "lame",
1335                    vec!["--quiet".as_ref(), "-V2".as_ref(), wav, target],
1336                ),
1337                (
1338                    "ffmpeg",
1339                    vec![
1340                        "-y".as_ref(),
1341                        "-loglevel".as_ref(),
1342                        "error".as_ref(),
1343                        "-i".as_ref(),
1344                        wav,
1345                        "-codec:a".as_ref(),
1346                        "libmp3lame".as_ref(),
1347                        "-q:a".as_ref(),
1348                        "2".as_ref(),
1349                        target,
1350                    ],
1351                ),
1352            ],
1353            OutputFormat::Flac => [
1354                (
1355                    "flac",
1356                    vec![
1357                        "--totally-silent".as_ref(),
1358                        "-f".as_ref(),
1359                        "-o".as_ref(),
1360                        target,
1361                        wav,
1362                    ],
1363                ),
1364                (
1365                    "ffmpeg",
1366                    vec![
1367                        "-y".as_ref(),
1368                        "-loglevel".as_ref(),
1369                        "error".as_ref(),
1370                        "-i".as_ref(),
1371                        wav,
1372                        "-c:a".as_ref(),
1373                        "flac".as_ref(),
1374                        target,
1375                    ],
1376                ),
1377            ],
1378            OutputFormat::Wav => unreachable!("handled above"),
1379        };
1380
1381        let mut tried = Vec::new();
1382        for (tool, arguments) in attempts {
1383            // `tool` is always one of the fixed string literals in `attempts` above — a
1384            // compile-time allowlist. User-controlled data (the two paths) enters only as argv.
1385            match std::process::Command::new(tool).args(arguments).status() {
1386                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1387                    tried.push(*tool);
1388                }
1389                Err(error) => {
1390                    return Err(FttsError::Generic(format!(
1391                        "audio encoder `{tool}` could not run: {error}; the synthesized WAV is \
1392                         preserved at {}",
1393                        self.wav_path.display()
1394                    )));
1395                }
1396                Ok(status) if status.success() => {
1397                    // The staging WAV is an intermediate this run created; the requested artifact
1398                    // now exists, so the intermediate is removed.
1399                    let _ = fs::remove_file(&self.wav_path);
1400                    return Ok(());
1401                }
1402                Ok(status) => {
1403                    return Err(FttsError::Generic(format!(
1404                        "audio encoder `{tool}` exited with {status}; the synthesized WAV is \
1405                         preserved at {}",
1406                        self.wav_path.display()
1407                    )));
1408                }
1409            }
1410        }
1411        Err(FttsError::Generic(format!(
1412            "no system audio encoder found for {} (tried: {}); install one or use a .wav output. \
1413             The synthesized WAV is preserved at {}",
1414            self.final_path.display(),
1415            tried.join(", "),
1416            self.wav_path.display()
1417        )))
1418    }
1419}
1420
1421impl AudioOutput {
1422    /// Open a WAV file sink.
1423    ///
1424    /// # Errors
1425    ///
1426    /// If the file cannot be created or the provisional header cannot be written.
1427    pub fn wav(path: &Path) -> Result<Self, FttsError> {
1428        let file = fs::File::create(path).map_err(|error| {
1429            FttsError::Generic(format!(
1430                "cannot create audio output {}: {error}",
1431                path.display()
1432            ))
1433        })?;
1434        let writer = ftts_core::audio::WavWriter::new(file, ftts_core::audio::SAMPLE_RATE_HZ)
1435            .map_err(|error| {
1436                FttsError::Generic(format!(
1437                    "cannot write WAV header to {}: {error}",
1438                    path.display()
1439                ))
1440            })?;
1441        Ok(Self {
1442            sink: AudioSink::Wav(Box::new(writer)),
1443            byte_offset: 0,
1444            samples_written: 0,
1445        })
1446    }
1447
1448    /// A raw-PCM sink; the caller supplies the stream on each write.
1449    #[must_use]
1450    pub const fn raw() -> Self {
1451        Self {
1452            sink: AudioSink::RawPcm,
1453            byte_offset: 0,
1454            samples_written: 0,
1455        }
1456    }
1457
1458    /// A sink that discards audio, for paths that synthesise nothing.
1459    #[must_use]
1460    pub const fn none() -> Self {
1461        Self {
1462            sink: AudioSink::None,
1463            byte_offset: 0,
1464            samples_written: 0,
1465        }
1466    }
1467
1468    /// The stable `sink` string reported in `audio_chunk`.
1469    #[must_use]
1470    pub const fn sink_name(&self) -> &'static str {
1471        match self.sink {
1472            AudioSink::Wav(_) => "file",
1473            AudioSink::RawPcm => "stdout",
1474            AudioSink::None => "none",
1475        }
1476    }
1477
1478    /// Bytes of audio emitted so far.
1479    #[must_use]
1480    pub const fn byte_offset(&self) -> u64 {
1481        self.byte_offset
1482    }
1483
1484    /// Write one packet and return its `audio_chunk` event.
1485    ///
1486    /// `raw` is where PCM goes under `--stream raw`; it is ignored by the other sinks. The event's
1487    /// `byte_offset` is the offset *before* this packet, so a consumer can seek with it.
1488    ///
1489    /// `duration_ms` is derived from the sample count rather than taken from a caller-supplied
1490    /// clock: it describes how much *audio* this packet holds, which is a property of the samples,
1491    /// not of how long the run took to produce them.
1492    ///
1493    /// # Errors
1494    ///
1495    /// If the sink rejects the write.
1496    pub fn write_packet(
1497        &mut self,
1498        pcm: &[f32],
1499        raw: &mut dyn Write,
1500        run_id: &str,
1501        frame_count: u8,
1502    ) -> Result<Value, FttsError> {
1503        let offset_before = self.byte_offset;
1504        let bytes = (pcm.len() * 2) as u64;
1505
1506        match &mut self.sink {
1507            AudioSink::Wav(writer) => writer.write_samples(pcm).map_err(|error| {
1508                FttsError::Generic(format!("cannot write audio samples: {error}"))
1509            })?,
1510            AudioSink::RawPcm => {
1511                let mut buffer = Vec::with_capacity(pcm.len() * 2);
1512                for sample in pcm {
1513                    buffer
1514                        .extend_from_slice(&ftts_core::audio::sample_to_i16(*sample).to_le_bytes());
1515                }
1516                raw.write_all(&buffer).map_err(|error| {
1517                    FttsError::Generic(format!("cannot write raw PCM: {error}"))
1518                })?;
1519            }
1520            AudioSink::None => {}
1521        }
1522
1523        self.byte_offset += bytes;
1524        self.samples_written += pcm.len() as u64;
1525
1526        let mut event = robot::EventType::AudioChunk.event();
1527        event.insert("run_id".to_owned(), json!(run_id));
1528        event.insert("byte_offset".to_owned(), json!(offset_before));
1529        event.insert("bytes".to_owned(), json!(bytes));
1530        event.insert(
1531            "duration_ms".to_owned(),
1532            json!((pcm.len() as u64) * 1000 / u64::from(ftts_core::audio::SAMPLE_RATE_HZ.max(1))),
1533        );
1534        event.insert("packet_frames".to_owned(), json!(frame_count.to_string()));
1535        event.insert("sink".to_owned(), json!(self.sink_name()));
1536        Ok(Value::Object(event))
1537    }
1538
1539    /// Finalise the sink, patching the WAV header to the real length.
1540    ///
1541    /// # Errors
1542    ///
1543    /// If the header cannot be rewritten.
1544    pub fn finish(self) -> Result<u64, FttsError> {
1545        let samples = self.samples_written;
1546        if let AudioSink::Wav(writer) = self.sink {
1547            writer.finish().map_err(|error| {
1548                FttsError::Generic(format!("cannot finalize the WAV header: {error}"))
1549            })?;
1550        }
1551        Ok(samples)
1552    }
1553}
1554
1555fn read_text(args: &SayArgs, stdin: &mut dyn Read) -> Result<String, FttsError> {
1556    let text = match (&args.text, &args.file) {
1557        (Some(text), None) if text == "-" => read_utf8(stdin, "stdin")?,
1558        (Some(text), None) => text.clone(),
1559        (None, Some(path)) if path == Path::new("-") => read_utf8(stdin, "stdin")?,
1560        (None, Some(path)) => fs::read_to_string(path).map_err(|error| {
1561            FttsError::Input(format!(
1562                "cannot read text file {}: {error}; use `ftts say --file PATH --check --model PATH`",
1563                path.display()
1564            ))
1565        })?,
1566        (None, None) => {
1567            return Err(FttsError::Usage(
1568                "missing text; use `ftts say TEXT`, `ftts say --file PATH`, or `ftts say -`".to_owned(),
1569            ));
1570        }
1571        (Some(_), Some(_)) => unreachable!("clap enforces the conflict"),
1572    };
1573
1574    if text.trim().is_empty() {
1575        return Err(FttsError::Input(
1576            "text is empty; provide non-whitespace UTF-8 text to `ftts say`".to_owned(),
1577        ));
1578    }
1579    Ok(text)
1580}
1581
1582fn read_utf8(reader: &mut dyn Read, source: &str) -> Result<String, FttsError> {
1583    let mut bytes = Vec::new();
1584    reader.read_to_end(&mut bytes).map_err(|error| {
1585        FttsError::Input(format!(
1586            "cannot read {source}: {error}; retry with readable UTF-8 input"
1587        ))
1588    })?;
1589    String::from_utf8(bytes).map_err(|error| {
1590        FttsError::Input(format!(
1591            "{source} is not valid UTF-8: {error}; transcode it before `ftts say`"
1592        ))
1593    })
1594}
1595
1596fn resolve_model(explicit: Option<&Path>, environment: &Environment) -> Result<String, FttsError> {
1597    resolve_model_from(
1598        explicit,
1599        &model_search_paths(environment),
1600        default_pull_model_dir(environment).as_deref(),
1601    )
1602}
1603
1604/// The full resolution order — `--model`, then the searched artifact paths (`FTTS_MODEL_DIR`,
1605/// then the home cache), then the `ftts pull` destination directory, accepted only when it holds
1606/// a complete bundle. Takes its inputs as data so tests can exercise the order against temp
1607/// directories without mutating process environment.
1608fn resolve_model_from(
1609    explicit: Option<&Path>,
1610    searched: &[PathBuf],
1611    pull_dir: Option<&Path>,
1612) -> Result<String, FttsError> {
1613    if let Some(path) = explicit {
1614        // A pinned checkpoint is a *directory* of five files, so `--model DIR` is the natural
1615        // thing to type and is accepted as such. `.fttsq` is a single file, and both forms reach
1616        // the same resolver rather than one being a special case documented somewhere else.
1617        if path.is_dir() {
1618            return Ok(path.display().to_string());
1619        }
1620        return resolve_existing_file(path, "model artifact")
1621            .map(|path| path.display().to_string());
1622    }
1623
1624    if let Some(path) = searched.iter().find(|path| path.is_file()) {
1625        return Ok(path.display().to_string());
1626    }
1627    // A directory named by FTTS_MODEL_DIR may itself BE the model: a pinned checkpoint snapshot
1628    // (`model.safetensors` + configs) or a directory holding the canonical artifact. `--model DIR`
1629    // already accepts that shape; the search path accepting it too is what lets a bare
1630    // `ftts say "text" out.wav` work after one exported variable.
1631    if let Some(directory) = searched
1632        .iter()
1633        .filter_map(|path| path.parent())
1634        .find(|directory| directory.join("model.safetensors").is_file())
1635    {
1636        return Ok(directory.display().to_string());
1637    }
1638
1639    // The `ftts pull` destination is a *bundle* directory (checkpoints + tokenizer files), not a
1640    // single artifact, so it counts only when the whole bundle is present — a half-finished pull
1641    // resolving here would fail later with a less actionable error than the one below.
1642    if let Some(directory) = pull_dir
1643        && directory.is_dir()
1644        && synth::ModelBundle::resolve(directory).is_ok()
1645    {
1646        return Ok(directory.display().to_string());
1647    }
1648
1649    let searched = searched
1650        .iter()
1651        .map(|path| path.display().to_string())
1652        .collect::<Vec<_>>()
1653        .join(", ");
1654    Err(FttsError::ModelNotFound(format!(
1655        "no model artifact was found; searched: [{searched}]; run `ftts pull` to fetch the model \
1656         (~2.0 GB), or pass --model PATH or set FTTS_MODEL_DIR"
1657    )))
1658}
1659
1660fn resolve_optional_file(path: Option<&Path>, label: &str) -> Result<Option<String>, FttsError> {
1661    path.map(|path| resolve_existing_file(path, label).map(|path| path.display().to_string()))
1662        .transpose()
1663}
1664
1665fn resolve_requested_voice(
1666    explicit: Option<&Path>,
1667    environment: &Environment,
1668) -> Result<Option<String>, FttsError> {
1669    if let Some(path) = explicit {
1670        return resolve_optional_file(Some(path), "voice source");
1671    }
1672    environment
1673        .value("FTTS_DEFAULT_VOICE")
1674        .map(Path::new)
1675        .map(|path| resolve_existing_file(path, "FTTS_DEFAULT_VOICE"))
1676        .transpose()
1677        .map(|path| path.map(|path| path.display().to_string()))
1678}
1679
1680fn run_enroll(
1681    args: &EnrollArgs,
1682    environment: &Environment,
1683    stdout: &mut dyn Write,
1684) -> Result<(), FttsError> {
1685    let _ = args.force;
1686    let model = resolve_model(args.model.as_deref(), environment)?;
1687    let bundle = synth::ModelBundle::resolve(Path::new(&model))?;
1688    let output = match (&args.output, args.default) {
1689        (Some(path), false) => path.clone(),
1690        (None, true) => bundle.root.join("default.spk"),
1691        (None, false) => {
1692            return Err(FttsError::Usage(
1693                "`ftts enroll` needs -o PATH or --default; enrollment never overwrites a voice source"
1694                    .to_owned(),
1695            ));
1696        }
1697        (Some(_), true) => unreachable!("clap enforces the conflict"),
1698    };
1699    let speaker = synth::speaker_from_voice(&bundle, &args.reference_audio)?;
1700    synth::write_speaker_vector_new(&output, &speaker)?;
1701    writeln!(
1702        stdout,
1703        "enrolled x-vector from {} to {}{}",
1704        args.reference_audio.display(),
1705        output.display(),
1706        if args.default {
1707            "; `ftts say` will use it when --voice is absent"
1708        } else {
1709            ""
1710        },
1711    )
1712    .map_err(|error| FttsError::Generic(format!("cannot write enrollment result: {error}")))
1713}
1714
1715/// One downloadable model file from the embedded manifest.
1716#[derive(Clone, Debug)]
1717struct ModelManifestFile {
1718    /// The bare release-asset name on the GitHub release.
1719    asset: String,
1720    /// Relative path under the model directory the asset lands at.
1721    dest: String,
1722    /// Pinned lowercase-hex SHA-256 the downloaded bytes must carry.
1723    sha256: String,
1724    /// Pinned exact size, checked before the (much more expensive) digest.
1725    bytes: u64,
1726}
1727
1728/// The embedded `ftts pull` download contract: release coordinates plus per-file pins.
1729#[derive(Clone, Debug)]
1730struct ModelManifest {
1731    model_id: String,
1732    release_tag: String,
1733    repo: String,
1734    files: Vec<ModelManifestFile>,
1735}
1736
1737impl ModelManifest {
1738    /// The compiled-in manifest. Parsing it can only fail if the checked-in copy is malformed,
1739    /// which the unit tests catch before a binary ships.
1740    fn embedded() -> Result<Self, FttsError> {
1741        Self::parse(PINNED_MODEL_MANIFEST)
1742    }
1743
1744    /// Parses and validates manifest text; every refusal names the offending field, because a
1745    /// manifest bug otherwise surfaces as a mystery mid-download.
1746    fn parse(text: &str) -> Result<Self, FttsError> {
1747        let value: Value = serde_json::from_str(text).map_err(|error| {
1748            FttsError::ArtifactFormat(format!("model manifest is not valid JSON: {error}"))
1749        })?;
1750        if value["schema_version"].as_u64() != Some(1) {
1751            return Err(FttsError::ArtifactFormat(format!(
1752                "model manifest schema_version {} is not the supported 1",
1753                value["schema_version"]
1754            )));
1755        }
1756        let model_id = manifest_string(&value, "model_id")?;
1757        let release_tag = manifest_string(&value, "release_tag")?;
1758        let repo = manifest_string(&value, "repo")?;
1759        let files = value["files"]
1760            .as_array()
1761            .filter(|files| !files.is_empty())
1762            .ok_or_else(|| {
1763                FttsError::ArtifactFormat("model manifest needs a non-empty files array".to_owned())
1764            })?
1765            .iter()
1766            .map(parse_manifest_file)
1767            .collect::<Result<Vec<_>, _>>()?;
1768        Ok(Self {
1769            model_id,
1770            release_tag,
1771            repo,
1772            files,
1773        })
1774    }
1775
1776    /// The release-asset URL for one file; the only endpoint `ftts pull` ever contacts.
1777    fn download_url(&self, file: &ModelManifestFile) -> String {
1778        format!(
1779            "https://github.com/{}/releases/download/{}/{}",
1780            self.repo, self.release_tag, file.asset
1781        )
1782    }
1783
1784    fn total_bytes(&self) -> u64 {
1785        self.files.iter().map(|file| file.bytes).sum()
1786    }
1787}
1788
1789fn manifest_string(value: &Value, field: &str) -> Result<String, FttsError> {
1790    value[field]
1791        .as_str()
1792        .filter(|text| !text.is_empty())
1793        .map(str::to_owned)
1794        .ok_or_else(|| {
1795            FttsError::ArtifactFormat(format!(
1796                "model manifest field {field} must be a non-empty string"
1797            ))
1798        })
1799}
1800
1801fn parse_manifest_file(value: &Value) -> Result<ModelManifestFile, FttsError> {
1802    let asset = manifest_string(value, "asset")?;
1803    if asset.contains('/') || asset.contains('\\') {
1804        return Err(FttsError::ArtifactFormat(format!(
1805            "manifest asset {asset:?} must be a bare release-asset name"
1806        )));
1807    }
1808    let dest = manifest_string(value, "dest")?;
1809    validate_manifest_dest(&dest)?;
1810    let sha256 = manifest_string(value, "sha256")?;
1811    if !is_sha256_hex(&sha256) {
1812        return Err(FttsError::ArtifactFormat(format!(
1813            "manifest sha256 for {asset} must be 64 lowercase hex characters"
1814        )));
1815    }
1816    let bytes = value["bytes"]
1817        .as_u64()
1818        .filter(|bytes| *bytes > 0)
1819        .ok_or_else(|| {
1820            FttsError::ArtifactFormat(format!(
1821                "manifest bytes for {asset} must be a positive integer"
1822            ))
1823        })?;
1824    Ok(ModelManifestFile {
1825        asset,
1826        dest,
1827        sha256,
1828        bytes,
1829    })
1830}
1831
1832/// A manifest `dest` is joined under the model directory, so it must not be able to escape it:
1833/// no absolute paths, no `..`, no `.`, no backslash separators.
1834fn validate_manifest_dest(dest: &str) -> Result<(), FttsError> {
1835    let path = Path::new(dest);
1836    let traversal_free = path
1837        .components()
1838        .all(|component| matches!(component, std::path::Component::Normal(_)));
1839    if path.is_absolute() || dest.contains('\\') || !traversal_free {
1840        return Err(FttsError::ArtifactFormat(format!(
1841            "manifest dest {dest:?} must be a relative path with no traversal; it is joined under the model directory"
1842        )));
1843    }
1844    Ok(())
1845}
1846
1847fn is_sha256_hex(text: &str) -> bool {
1848    text.len() == 64
1849        && text
1850            .bytes()
1851            .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
1852}
1853
1854/// The directory `ftts pull` fills when `--model` is absent, and the last resort model resolution
1855/// falls back to: the first `FTTS_MODEL_DIR` entry when set, else `$HOME/.cache/franken_tts/model`.
1856fn default_pull_model_dir(environment: &Environment) -> Option<PathBuf> {
1857    if let Some(first) = environment
1858        .value("FTTS_MODEL_DIR")
1859        .and_then(|dirs| std::env::split_paths(dirs).next())
1860        .filter(|path| !path.as_os_str().is_empty())
1861    {
1862        return Some(first);
1863    }
1864    std::env::var_os("HOME").map(|home| PathBuf::from(home).join(DEFAULT_MODEL_CACHE_SUBDIR))
1865}
1866
1867/// Skip-versus-download for one manifest file, factored out so it is testable without a network.
1868#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1869enum PullDecision {
1870    /// The destination already carries the pinned size AND the pinned digest.
1871    Skip,
1872    /// Absent, wrong-sized, wrong-hashed, or `--force`.
1873    Download,
1874}
1875
1876/// `Skip` requires both pins to hold: size alone accepts a same-length corruption, and hashing
1877/// alone would digest gigabytes a length check could reject for free (which is why the cheap check
1878/// runs first). Any mismatch re-downloads rather than erroring — repairing a bad file is exactly
1879/// what `pull` is for.
1880fn pull_decision(dest: &Path, file: &ModelManifestFile, force: bool) -> PullDecision {
1881    if force {
1882        return PullDecision::Download;
1883    }
1884    let Ok(metadata) = fs::metadata(dest) else {
1885        return PullDecision::Download;
1886    };
1887    if !metadata.is_file() || metadata.len() != file.bytes {
1888        return PullDecision::Download;
1889    }
1890    match ftts_artifacts::sha256::hex_digest_file(dest) {
1891        Ok(digest) if digest == file.sha256 => PullDecision::Skip,
1892        _ => PullDecision::Download,
1893    }
1894}
1895
1896/// Downloads `url` to `staging` with the system `curl`.
1897///
1898/// Shelling out is deliberate: inference never touches the network, so the binary links no HTTP
1899/// stack. Only `pull` needs one, and `curl` is the same system-tool seam the audio encoders and
1900/// decoders already use.
1901fn download_with_curl(url: &str, staging: &Path) -> Result<(), FttsError> {
1902    let outcome = std::process::Command::new("curl")
1903        .args(["-L", "--fail", "--retry", "3", "-sS", "-o"])
1904        .arg(staging)
1905        .arg(url)
1906        .status();
1907    match outcome {
1908        Err(error) if error.kind() == io::ErrorKind::NotFound => Err(FttsError::Generic(
1909            "`ftts pull` downloads with the system `curl`, which was not found on PATH; \
1910             install curl and retry, or download the release assets by hand"
1911                .to_owned(),
1912        )),
1913        Err(error) => Err(FttsError::Generic(format!("cannot run curl: {error}"))),
1914        Ok(status) if status.success() => Ok(()),
1915        Ok(status) => Err(FttsError::Generic(format!(
1916            "curl failed downloading {url} ({status}); check network access and retry `ftts pull`"
1917        ))),
1918    }
1919}
1920
1921/// Size first, digest second, both against the embedded pins.
1922fn verify_pulled_file(path: &Path, file: &ModelManifestFile) -> Result<(), FttsError> {
1923    let metadata = fs::metadata(path).map_err(|error| {
1924        FttsError::Generic(format!(
1925            "cannot stat downloaded {}: {error}",
1926            path.display()
1927        ))
1928    })?;
1929    if metadata.len() != file.bytes {
1930        return Err(FttsError::ArtifactFormat(format!(
1931            "downloaded {} is {} bytes, expected {}; the incomplete download was discarded, retry `ftts pull`",
1932            file.asset,
1933            metadata.len(),
1934            file.bytes
1935        )));
1936    }
1937    let digest = ftts_artifacts::sha256::hex_digest_file(path).map_err(|error| {
1938        FttsError::Generic(format!(
1939            "cannot hash downloaded {}: {error}",
1940            path.display()
1941        ))
1942    })?;
1943    if digest != file.sha256 {
1944        return Err(FttsError::ArtifactFormat(format!(
1945            "downloaded {} carries sha256 {digest}, expected {}; the corrupt download was discarded, retry `ftts pull`",
1946            file.asset, file.sha256
1947        )));
1948    }
1949    Ok(())
1950}
1951
1952/// `<dest>.part`, in the destination directory so the final rename never crosses a filesystem.
1953fn pull_staging_path(dest: &Path) -> PathBuf {
1954    let mut name = dest.file_name().map(OsString::from).unwrap_or_default();
1955    name.push(".part");
1956    dest.with_file_name(name)
1957}
1958
1959/// Downloads one asset to `<dest>.part`, verifies it against the pins, and atomically publishes.
1960///
1961/// A staging file that fails verification is removed. This is the opposite of `convert`'s
1962/// retained staging, on purpose: a failed local conversion is diagnosable evidence, while a
1963/// corrupt download says nothing beyond "the network truncated it", and a multi-gigabyte corpse
1964/// in the cache directory helps no one.
1965fn pull_one_file(
1966    manifest: &ModelManifest,
1967    file: &ModelManifestFile,
1968    dest: &Path,
1969) -> Result<(), FttsError> {
1970    if let Some(parent) = dest.parent() {
1971        fs::create_dir_all(parent).map_err(|error| {
1972            FttsError::Generic(format!(
1973                "cannot create model directory {}: {error}",
1974                parent.display()
1975            ))
1976        })?;
1977    }
1978    let staging = pull_staging_path(dest);
1979    let url = manifest.download_url(file);
1980    let outcome =
1981        download_with_curl(&url, &staging).and_then(|()| verify_pulled_file(&staging, file));
1982    if let Err(error) = outcome {
1983        let _ = fs::remove_file(&staging);
1984        return Err(error);
1985    }
1986    fs::rename(&staging, dest).map_err(|error| {
1987        FttsError::Generic(format!(
1988            "downloaded {} verified but could not be published to {}: {error}",
1989            file.asset,
1990            dest.display()
1991        ))
1992    })
1993}
1994
1995fn run_pull(
1996    args: &PullArgs,
1997    environment: &Environment,
1998    stdout: &mut dyn Write,
1999) -> Result<(), FttsError> {
2000    let manifest = ModelManifest::embedded()?;
2001    let destination = match &args.model {
2002        Some(path) => path.clone(),
2003        None => default_pull_model_dir(environment).ok_or_else(|| {
2004            FttsError::Usage(
2005                "cannot choose a model directory: pass --model PATH, or set FTTS_MODEL_DIR or HOME"
2006                    .to_owned(),
2007            )
2008        })?,
2009    };
2010    writeln!(
2011        stdout,
2012        "pulling {} ({} files, {} bytes) into {}",
2013        manifest.model_id,
2014        manifest.files.len(),
2015        manifest.total_bytes(),
2016        destination.display()
2017    )
2018    .map_err(output_error)?;
2019    for file in &manifest.files {
2020        let dest = destination.join(&file.dest);
2021        match pull_decision(&dest, file, args.force) {
2022            PullDecision::Skip => writeln!(
2023                stdout,
2024                "{} ({} bytes): already present, verified",
2025                file.dest, file.bytes
2026            )
2027            .map_err(output_error)?,
2028            PullDecision::Download => {
2029                writeln!(stdout, "{} ({} bytes): downloading", file.dest, file.bytes)
2030                    .map_err(output_error)?;
2031                pull_one_file(&manifest, file, &dest)?;
2032                writeln!(stdout, "{} ({} bytes): verified", file.dest, file.bytes)
2033                    .map_err(output_error)?;
2034            }
2035        }
2036    }
2037    writeln!(stdout, "model ready at {}", destination.display()).map_err(output_error)
2038}
2039
2040fn resolve_existing_file<'a>(path: &'a Path, label: &str) -> Result<&'a Path, FttsError> {
2041    if path.is_file() {
2042        Ok(path)
2043    } else {
2044        Err(FttsError::ModelNotFound(format!(
2045            "{label} {} does not exist or is not a file; use an existing PATH",
2046            path.display()
2047        )))
2048    }
2049}
2050
2051/// Preflight admission for `say --check`, computed by the **engine**, not by the CLI.
2052///
2053/// This used to be a CLI-local heuristic whose own text said "model-specific KV and memory
2054/// admission is pending the V_REL engine". That engine now exists, so the preflight calls
2055/// [`ftts_core::admission`] directly. The point is not code reuse: it is that `--check` and the
2056/// synthesis that follows it must reach the *same* verdict for the same request. A preflight that
2057/// says yes and an engine that then says no is worse than no preflight, because the caller
2058/// budgeted on the first answer.
2059///
2060/// Prompt length is not knowable before tokenization, so `--check` reports the admission decision
2061/// for an *estimated* prompt length and labels it as such. The binding decision remains the
2062/// engine's, taken after real tokenization.
2063fn admission_plan(text: &str, settings: &EffectiveSettings) -> Result<Value, FttsError> {
2064    if text.len() > SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES {
2065        return Err(FttsError::BudgetTimeout(format!(
2066            "text is {} bytes, above the Phase-0 admission bound of {} bytes; split the document before retrying",
2067            text.len(),
2068            SCAFFOLD_ADMISSION_TEXT_LIMIT_BYTES
2069        )));
2070    }
2071
2072    let characters = text.chars().count();
2073    // A deliberately conservative stand-in until the tokenizer is on this path: over-estimating
2074    // the prompt can only make the preflight refuse something the engine would admit, which is the
2075    // safe direction. Under-estimating would promise capacity that is not there.
2076    let estimated_prompt_tokens = u64::try_from(characters).unwrap_or(u64::MAX);
2077    // The engine's own env-resolved policy (FTTS_MEMORY_BUDGET_MB / FTTS_MAX_FRAMES), not a copy
2078    // of it — a second parse of the same variables is a second thing to drift.
2079    let policy = ftts_core::process_engine_config().admission;
2080
2081    match policy.admit(estimated_prompt_tokens) {
2082        Ok(plan) => Ok(json!({
2083            "status": "accepted",
2084            "scope": "preflight on an ESTIMATED prompt length; the binding decision is the \
2085                      engine's, taken after tokenization",
2086            "text_bytes": text.len(),
2087            "text_characters": characters,
2088            "estimated_prompt_tokens": estimated_prompt_tokens,
2089            "predicted_max_frames": plan.predicted_max_frames,
2090            "predicted_peak_bytes": plan.predicted_peak_bytes,
2091            "budget_bytes": plan.budget_bytes,
2092            "binding_constraint": plan.binding_constraint.as_str(),
2093            "packet_frames": settings.packet_frames.as_str(),
2094            "profile": settings.profile.as_str(),
2095        })),
2096        // AdmissionRejection's Display already carries the shortfall, the binding constraint and
2097        // what to do about it, so it is passed through rather than re-summarised into something
2098        // less specific.
2099        Err(rejection) => Err(FttsError::BudgetTimeout(rejection.to_string())),
2100    }
2101}
2102
2103fn run_voice_inspect(path: &Path, stdout: &mut dyn Write) -> Result<(), FttsError> {
2104    let path = resolve_existing_file(path, "voice pack")?;
2105    write_json_line(
2106        stdout,
2107        &json!({
2108            "schema_version": ROBOT_SCHEMA_VERSION,
2109            "event": "voice_inspect",
2110            "path": path.display().to_string(),
2111            "status": "header_inspection_pending_artifact_reader",
2112        }),
2113    )
2114}
2115
2116fn run_robot(
2117    command: RobotCommand,
2118    environment: &Environment,
2119    stdout: &mut dyn Write,
2120) -> Result<(), FttsError> {
2121    // Every object below is built from `robot::EventType`, so the discriminator and
2122    // schema_version cannot be forgotten, and the frozen contract test in ftts-conformance
2123    // fails if any of these stops matching the catalogue.
2124    let event = match command {
2125        RobotCommand::Schema => robot::schema_document(robot::DOCUMENTED_ENVIRONMENT),
2126        RobotCommand::Health => {
2127            let searched = model_search_paths(environment);
2128            let found = searched.iter().find(|path| looks_like_model_artifact(path));
2129            let mut object = robot::EventType::Health.event();
2130            object.insert("status".to_owned(), json!("phase0_skeleton"));
2131            object.insert("model_loaded".to_owned(), json!(false));
2132            // Presence is a magic-bytes header sniff, never a tensor load: `robot health` must
2133            // stay cheap enough for an agent to call it on every invocation.
2134            object.insert("model_present".to_owned(), json!(found.is_some()));
2135            object.insert(
2136                "model_path".to_owned(),
2137                json!(found.map(|path| path.display().to_string())),
2138            );
2139            object.insert(
2140                "model_dir".to_owned(),
2141                json!(environment.value("FTTS_MODEL_DIR")),
2142            );
2143            // Every directory consulted, so a resolution failure is actionable rather than a
2144            // bare "not found".
2145            object.insert(
2146                "searched".to_owned(),
2147                json!(
2148                    searched
2149                        .iter()
2150                        .map(|path| path.display().to_string())
2151                        .collect::<Vec<_>>()
2152                ),
2153            );
2154            object.insert("stateless_default".to_owned(), json!(true));
2155            object.insert(
2156                "threads".to_owned(),
2157                json!(
2158                    environment
2159                        .value("FTTS_THREADS")
2160                        .and_then(|value| value.parse::<u64>().ok())
2161                ),
2162            );
2163            object.insert(
2164                "recommended_command".to_owned(),
2165                json!("ftts say --check --model PATH TEXT"),
2166            );
2167            Value::Object(object)
2168        }
2169        RobotCommand::Backends => {
2170            let mut object = robot::EventType::Backends.event();
2171            // Capability vs executed-route split: `available` is every tier this build can
2172            // certify on this CPU; `dispatched` is the one the int8 route would actually run.
2173            object.insert(
2174                "available".to_owned(),
2175                json!(
2176                    ftts_kernels::int8::Int8Tier::available()
2177                        .iter()
2178                        .map(|tier| tier.as_str())
2179                        .collect::<Vec<_>>()
2180                ),
2181            );
2182            object.insert(
2183                "dispatched".to_owned(),
2184                json!(ftts_kernels::int8::Int8Tier::dispatch().as_str()),
2185            );
2186            object.insert("isa_features".to_owned(), json!(detected_isa_features()));
2187            let plan = ftts_kernels::int8::autotuned_plan();
2188            object.insert(
2189                "kernel_plan".to_owned(),
2190                json!({
2191                    "version": 0,
2192                    "decode_gemv": plan.decode_gemv.as_str(),
2193                    "batch_gemm": plan.batch_gemm.as_str(),
2194                    "persisted": false,
2195                }),
2196            );
2197            object.insert("pool_sizing".to_owned(), Value::Null);
2198            object.insert(
2199                "force_arch".to_owned(),
2200                json!(environment.value("FTTS_FORCE_ARCH")),
2201            );
2202            Value::Object(object)
2203        }
2204        RobotCommand::Selftest => {
2205            // The permanent integer-kernel law, executed on the end user's silicon: every census
2206            // binding row through the real dot kernels on every dispatchable tier. The event's
2207            // top-level fields are pinned by the frozen v1 schema fixture (status/reason/checks);
2208            // per-row detail lives inside `checks`.
2209            let report = ftts_kernels::selftest::run_selftest();
2210            let checks: Vec<Value> = report
2211                .checks
2212                .iter()
2213                .map(|check| {
2214                    json!({
2215                        "row": check.row.id,
2216                        "scope": check.row.scope.as_str(),
2217                        "census_tensor": check.row.census_tensor,
2218                        "reduction_k": check.row.reduction_k,
2219                        "tier": check.tier.as_str(),
2220                        "contract": check.contract.as_str(),
2221                        "dispatched": check.tier == report.dispatched,
2222                        "accumulator_i32": check.accumulator_i32,
2223                        "reference_i64": check.reference_i64,
2224                        "passed": check.passed,
2225                    })
2226                })
2227                .collect();
2228            let mut object = robot::EventType::Selftest.event();
2229            object.insert(
2230                "status".to_owned(),
2231                json!(if report.passed() { "passed" } else { "failed" }),
2232            );
2233            object.insert("reason".to_owned(), Value::Null);
2234            object.insert("checks".to_owned(), json!(checks));
2235            Value::Object(object)
2236        }
2237    };
2238    write_json_line(stdout, &event)
2239}
2240
2241/// Every path the model resolver consults, in order.
2242///
2243/// Shared by `robot health` and the resolution error so the two can never disagree about what
2244/// was searched — a "not found" that lists different directories than `health` reports is worse
2245/// than no list at all.
2246fn model_search_paths(environment: &Environment) -> Vec<PathBuf> {
2247    let mut searched = environment
2248        .value("FTTS_MODEL_DIR")
2249        .map(std::env::split_paths)
2250        .map(|paths| {
2251            paths
2252                .map(|path| path.join(MODEL_BASENAME))
2253                .collect::<Vec<_>>()
2254        })
2255        .unwrap_or_default();
2256    if let Some(home) = std::env::var_os("HOME") {
2257        let home = PathBuf::from(home);
2258        searched.push(home.join(".cache/franken_tts/models").join(MODEL_BASENAME));
2259        // The `ftts pull` destination, listed after the legacy plural directory so existing
2260        // installs keep winning; it is also the bundle-directory fallback in `resolve_model_from`,
2261        // which is what lets a bare `ftts pull` then `ftts say` work with no env var at all.
2262        searched.push(home.join(DEFAULT_MODEL_CACHE_SUBDIR).join(MODEL_BASENAME));
2263    }
2264    searched
2265}
2266
2267/// A cheap header sniff: is there a plausible `.fttsq` artifact at this path?
2268///
2269/// Reads the magic bytes only. Deliberately never opens the tensor data — `robot health` is
2270/// meant to be callable on every agent invocation, and a multi-gigabyte read would make it a
2271/// thing agents avoid calling, which defeats the point.
2272fn looks_like_model_artifact(path: &Path) -> bool {
2273    use std::io::Read as _;
2274
2275    let Ok(mut file) = fs::File::open(path) else {
2276        return false;
2277    };
2278    let mut magic = [0u8; 5];
2279    file.read_exact(&mut magic).is_ok() && &magic == b"FTTSQ"
2280}
2281
2282/// ISA features detected at runtime, for `robot backends`.
2283///
2284/// Reported as a plain list so an agent can see what the dispatcher had available; the kernel
2285/// tiers themselves land with the Phase-3 engines.
2286fn detected_isa_features() -> Vec<&'static str> {
2287    let mut features = Vec::new();
2288    #[cfg(target_arch = "aarch64")]
2289    {
2290        if std::arch::is_aarch64_feature_detected!("neon") {
2291            features.push("neon");
2292        }
2293        if std::arch::is_aarch64_feature_detected!("dotprod") {
2294            features.push("dotprod");
2295        }
2296        if std::arch::is_aarch64_feature_detected!("i8mm") {
2297            features.push("i8mm");
2298        }
2299    }
2300    #[cfg(target_arch = "x86_64")]
2301    {
2302        if std::arch::is_x86_feature_detected!("avx2") {
2303            features.push("avx2");
2304        }
2305        if std::arch::is_x86_feature_detected!("avxvnni") {
2306            features.push("avx-vnni");
2307        }
2308        if std::arch::is_x86_feature_detected!("avx512vnni") {
2309            features.push("avx512-vnni");
2310        }
2311    }
2312    features
2313}
2314
2315fn run_doctor(
2316    args: &DoctorArgs,
2317    environment: &Environment,
2318    stdout: &mut dyn Write,
2319) -> Result<(), FttsError> {
2320    let report = json!({
2321        "schema_version": ROBOT_SCHEMA_VERSION,
2322        "status": "phase0_skeleton",
2323        "stateless_default": true,
2324        "persistent_history": false,
2325        "environment": environment.documented_values(),
2326        "recommended_command": "ftts robot schema",
2327    });
2328    if args.json {
2329        write_json_line(stdout, &report)
2330    } else {
2331        writeln!(stdout, "FrankenTTS Phase-0 CLI skeleton")
2332            .and_then(|_| writeln!(stdout, "stateless default: yes"))
2333            .and_then(|_| writeln!(stdout, "model loaded: no"))
2334            .and_then(|_| writeln!(stdout, "next: ftts robot schema"))
2335            .map_err(output_error)
2336    }
2337}
2338
2339fn write_json_line(writer: &mut dyn Write, value: &Value) -> Result<(), FttsError> {
2340    serde_json::to_writer(&mut *writer, value)
2341        .map_err(|error| FttsError::Generic(format!("cannot serialize CLI JSON: {error}")))?;
2342    writer.write_all(b"\n").map_err(output_error)
2343}
2344
2345fn output_error(error: io::Error) -> FttsError {
2346    FttsError::Generic(format!("cannot write CLI output: {error}"))
2347}
2348
2349#[cfg(test)]
2350mod tests {
2351    use super::*;
2352    use std::io::Cursor;
2353
2354    const CLAP_SURFACE_SNAPSHOT: &str = "commands=say,enroll,voice,convert,pull,robot,doctor\nrobot=schema,health,backends,selftest\nsay=file,model,voice,output,stream,check\npull=model,force\nglobal=profile,packet-frames,math-mode,voice-pack,normalize,trace,seed\n";
2355
2356    #[test]
2357    fn clap_surface_matches_snapshot() {
2358        let command = Cli::command();
2359        let commands = command
2360            .get_subcommands()
2361            .map(|command| command.get_name())
2362            .collect::<Vec<_>>()
2363            .join(",");
2364        let robot = command
2365            .get_subcommands()
2366            .find(|command| command.get_name() == "robot")
2367            .expect("robot subcommand")
2368            .get_subcommands()
2369            .map(|command| command.get_name())
2370            .collect::<Vec<_>>()
2371            .join(",");
2372        let say = command
2373            .get_subcommands()
2374            .find(|command| command.get_name() == "say")
2375            .expect("say subcommand")
2376            .get_arguments()
2377            .filter_map(|argument| argument.get_long())
2378            .collect::<Vec<_>>()
2379            .join(",");
2380        let pull = command
2381            .get_subcommands()
2382            .find(|command| command.get_name() == "pull")
2383            .expect("pull subcommand")
2384            .get_arguments()
2385            .filter_map(|argument| argument.get_long())
2386            .collect::<Vec<_>>()
2387            .join(",");
2388        let global = command
2389            .get_arguments()
2390            .filter_map(|argument| argument.get_long())
2391            .filter(|argument| *argument != "help")
2392            .collect::<Vec<_>>()
2393            .join(",");
2394        let actual = format!(
2395            "commands={commands}\nrobot={robot}\nsay={say}\npull={pull}\nglobal={global}\n"
2396        );
2397        assert_eq!(actual, CLAP_SURFACE_SNAPSHOT);
2398    }
2399
2400    #[test]
2401    fn argument_file_and_stdin_text_are_identical() {
2402        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../README.md");
2403        let expected = fs::read_to_string(&root).expect("checked-in README");
2404        let from_argument = read_text(
2405            &SayArgs {
2406                output_positional: None,
2407                text: Some(expected.clone()),
2408                file: None,
2409                model: None,
2410                voice: None,
2411                output: None,
2412                stream: None,
2413                check: true,
2414            },
2415            &mut Cursor::new(Vec::<u8>::new()),
2416        )
2417        .expect("argument text");
2418        let from_file = read_text(
2419            &SayArgs {
2420                output_positional: None,
2421                text: None,
2422                file: Some(root),
2423                model: None,
2424                voice: None,
2425                output: None,
2426                stream: None,
2427                check: true,
2428            },
2429            &mut Cursor::new(Vec::<u8>::new()),
2430        )
2431        .expect("file text");
2432        let from_stdin = read_text(
2433            &SayArgs {
2434                output_positional: None,
2435                text: Some("-".to_owned()),
2436                file: None,
2437                model: None,
2438                voice: None,
2439                output: None,
2440                stream: None,
2441                check: true,
2442            },
2443            &mut Cursor::new(expected.as_bytes()),
2444        )
2445        .expect("stdin text");
2446
2447        assert_eq!(from_argument, from_file);
2448        assert_eq!(from_argument, from_stdin);
2449    }
2450
2451    #[test]
2452    fn check_plan_is_deterministic_and_marks_its_scope() {
2453        let settings = EffectiveSettings {
2454            profile: ExecutionProfile::Strict,
2455            packet_frames: PacketFrames::Four,
2456            math_mode: MathMode::Strict,
2457            voice_pack: VoicePackProfile::Portable,
2458            normalize: NormalizeMode::Conservative,
2459        };
2460        let first = admission_plan("hello", &settings).expect("admission plan");
2461        let second = admission_plan("hello", &settings).expect("admission plan");
2462        assert_eq!(first, second);
2463        assert_eq!(first["status"], "accepted");
2464        // The preflight is explicit that it estimates the prompt and that the engine decides.
2465        assert!(
2466            first["scope"]
2467                .as_str()
2468                .unwrap_or_default()
2469                .contains("ESTIMATED")
2470        );
2471    }
2472
2473    #[test]
2474    fn the_cli_preflight_and_the_engine_agree_on_the_same_request() {
2475        // The property that matters: `--check` saying yes and the engine then saying no is worse
2476        // than no preflight, because the caller budgeted on the first answer. Both must be the
2477        // same computation, not two implementations of the same rule.
2478        let settings = EffectiveSettings {
2479            profile: ExecutionProfile::Balanced,
2480            packet_frames: PacketFrames::Four,
2481            math_mode: MathMode::Strict,
2482            voice_pack: VoicePackProfile::Portable,
2483            normalize: NormalizeMode::Verbatim,
2484        };
2485        let text = "a moderately sized utterance for admission";
2486        let plan = admission_plan(text, &settings).expect("preflight admits");
2487
2488        let policy = ftts_core::process_engine_config().admission;
2489        let engine = policy
2490            .admit(text.chars().count() as u64)
2491            .expect("engine admits the same request");
2492
2493        assert_eq!(plan["predicted_peak_bytes"], engine.predicted_peak_bytes);
2494        assert_eq!(plan["predicted_max_frames"], engine.predicted_max_frames);
2495        assert_eq!(plan["budget_bytes"], engine.budget_bytes);
2496        assert_eq!(
2497            plan["binding_constraint"],
2498            engine.binding_constraint.as_str()
2499        );
2500    }
2501
2502    #[test]
2503    fn a_wav_sink_writes_a_playable_file_and_conforming_audio_chunk_events() {
2504        let dir = std::env::temp_dir().join(format!("ftts-wav-sink-{}", std::process::id()));
2505        std::fs::create_dir_all(&dir).expect("temp dir");
2506        let path = dir.join("out.wav");
2507
2508        let frame: Vec<f32> = (0..1_920)
2509            .map(|i| (i as f32 / 1_920.0 * std::f32::consts::TAU).sin() * 0.5)
2510            .collect();
2511        let mut sink = AudioOutput::wav(&path).expect("wav sink");
2512        let mut discard = Vec::new();
2513
2514        let first = sink
2515            .write_packet(&frame, &mut discard, "run-1", 1)
2516            .expect("packet 1");
2517        let second = sink
2518            .write_packet(&frame, &mut discard, "run-1", 1)
2519            .expect("packet 2");
2520
2521        // Every emitted object must satisfy the frozen robot contract, not merely look plausible.
2522        assert!(robot::validate_event(&first).is_empty(), "{first:?}");
2523        assert!(robot::validate_event(&second).is_empty(), "{second:?}");
2524        assert_eq!(first["sink"], "file");
2525        assert_eq!(first["byte_offset"], 0);
2526        assert_eq!(first["bytes"], 1_920 * 2);
2527        assert_eq!(first["duration_ms"], 80, "1,920 samples at 24 kHz is 80 ms");
2528        // The offset is cumulative, so a consumer can seek with it.
2529        assert_eq!(second["byte_offset"], 1_920 * 2);
2530
2531        let samples = sink.finish().expect("finish");
2532        assert_eq!(samples, 1_920 * 2);
2533
2534        // The file on disk must describe exactly what it holds.
2535        let bytes = std::fs::read(&path).expect("read wav");
2536        assert_eq!(&bytes[0..4], b"RIFF");
2537        assert_eq!(&bytes[8..12], b"WAVE");
2538        let declared = u32::from_le_bytes(bytes[40..44].try_into().expect("data size"));
2539        assert_eq!(declared as usize, 1_920 * 2 * 2);
2540        assert_eq!(bytes.len(), 44 + 1_920 * 2 * 2);
2541        assert!(
2542            discard.is_empty(),
2543            "a file sink must not also emit raw PCM to the stream"
2544        );
2545    }
2546
2547    #[test]
2548    fn a_raw_sink_writes_pcm_to_the_stream_and_never_mixes_it_with_events() {
2549        // The stream contract: under --stream raw, stdout carries PCM only. An event object landing
2550        // in the same buffer would corrupt both — the audio and the NDJSON.
2551        let mut sink = AudioOutput::raw();
2552        let mut raw = Vec::new();
2553        let pcm = vec![0.5f32; 4];
2554        let event = sink
2555            .write_packet(&pcm, &mut raw, "run-1", 1)
2556            .expect("packet");
2557
2558        assert!(robot::validate_event(&event).is_empty(), "{event:?}");
2559        assert_eq!(event["sink"], "stdout");
2560        assert_eq!(raw.len(), 8, "four 16-bit samples");
2561        let first = i16::from_le_bytes([raw[0], raw[1]]);
2562        assert_eq!(first, ftts_core::audio::sample_to_i16(0.5));
2563        // The PCM buffer must contain no JSON.
2564        assert!(
2565            !raw.windows(2).any(|w| w == b"{\""),
2566            "raw PCM stream must never contain an event object"
2567        );
2568    }
2569
2570    #[test]
2571    fn a_none_sink_still_reports_conforming_events() {
2572        let mut sink = AudioOutput::none();
2573        let mut discard = Vec::new();
2574        let event = sink
2575            .write_packet(&[0.0f32; 960], &mut discard, "run-1", 2)
2576            .expect("packet");
2577        assert!(robot::validate_event(&event).is_empty(), "{event:?}");
2578        assert_eq!(event["sink"], "none");
2579        assert_eq!(event["packet_frames"], "2");
2580        assert!(discard.is_empty());
2581        assert_eq!(sink.finish().expect("finish"), 960);
2582    }
2583
2584    #[test]
2585    fn a_health_violation_renders_as_a_contract_conforming_robot_event() {
2586        // The engine-to-wire seam: the violation's class, remedy and invalidates_output must
2587        // survive the crossing, and the result must satisfy the frozen robot contract.
2588        let silent =
2589            ftts_core::HealthEvent::Violation(ftts_core::health::HealthViolation::OutputSilent {
2590                silent_millis: 1_500,
2591            });
2592        let event = robot::health_violation_event("run-1", silent, 42);
2593        assert!(
2594            robot::validate_event(&event).is_empty(),
2595            "{:?}",
2596            robot::validate_event(&event)
2597        );
2598        assert_eq!(event["event"], "health_violation");
2599        assert_eq!(event["violation"], "output_silent");
2600        assert_eq!(event["invalidates_output"], true);
2601        assert!(event["detail"].as_str().expect("detail").contains("1500"));
2602        assert!(event["remedy"].as_str().expect("remedy").len() > 40);
2603
2604        // A kernel demotion is informational: the run stayed correct, just slower. If this were
2605        // reported as invalidating, an agent would discard good audio.
2606        let demoted =
2607            ftts_core::HealthEvent::Violation(ftts_core::health::HealthViolation::KernelDemoted {
2608                from: ftts_core::health::KernelTier::Optimized("i8mm"),
2609                to: ftts_core::health::KernelTier::Scalar,
2610            });
2611        let event = robot::health_violation_event("run-1", demoted, 43);
2612        assert!(robot::validate_event(&event).is_empty());
2613        assert_eq!(event["invalidates_output"], false);
2614
2615        // Budget and cancellation are health signals too, and both truncate the audio.
2616        for event in [
2617            ftts_core::HealthEvent::BudgetExceeded,
2618            ftts_core::HealthEvent::Cancelled,
2619        ] {
2620            let rendered = robot::health_violation_event("run-1", event, 44);
2621            assert!(robot::validate_event(&rendered).is_empty());
2622            assert_eq!(rendered["invalidates_output"], true);
2623        }
2624    }
2625
2626    #[test]
2627    fn normalization_defaults_to_verbatim_conformance_mode() {
2628        let cli = Cli {
2629            profile: None,
2630            packet_frames: None,
2631            math_mode: None,
2632            voice_pack: None,
2633            normalize: None,
2634            trace: None,
2635            seed: None,
2636            command: Command::Robot(RobotArgs {
2637                command: RobotCommand::Health,
2638            }),
2639        };
2640        assert_eq!(
2641            EffectiveSettings::resolve(&cli, &Environment::default())
2642                .expect("default settings")
2643                .normalize,
2644            NormalizeMode::Verbatim
2645        );
2646        assert_eq!(
2647            EffectiveSettings::resolve(&cli, &Environment::default())
2648                .expect("default settings")
2649                .normalization_options(),
2650            NormalizationOptions::default(),
2651            "CLI defaults must use the same verbatim options as the library"
2652        );
2653    }
2654
2655    #[test]
2656    fn cli_normalization_modes_map_to_shared_engine_options() {
2657        for (cli_mode, engine_mode) in [
2658            (NormalizeMode::Verbatim, NormalizationMode::Verbatim),
2659            (NormalizeMode::Conservative, NormalizationMode::Conservative),
2660            (NormalizeMode::LocaleAware, NormalizationMode::LocaleAware),
2661        ] {
2662            let settings = EffectiveSettings {
2663                profile: ExecutionProfile::Balanced,
2664                packet_frames: PacketFrames::Four,
2665                math_mode: MathMode::Strict,
2666                voice_pack: VoicePackProfile::Portable,
2667                normalize: cli_mode,
2668            };
2669            assert_eq!(settings.normalization_options().mode, engine_mode);
2670        }
2671    }
2672
2673    #[test]
2674    fn pinned_main_conversion_plan_preserves_the_reviewed_q8_boundary() {
2675        let specs = pinned_main_tensor_specs().expect("checked-in main inventory parses");
2676        let (_manifest, _plan) =
2677            pinned_main_conversion_plan().expect("checked-in main conversion plan builds");
2678        assert_eq!(specs.len(), PINNED_MAIN_TENSOR_COUNT);
2679        assert_eq!(
2680            specs
2681                .iter()
2682                .filter(|spec| spec.storage == TensorStoragePolicy::Q8PerOutputChannel)
2683                .count(),
2684            231,
2685            "28 talker + 5 microdecoder layers times seven attention/MLP projections"
2686        );
2687
2688        let text_embedding = specs
2689            .iter()
2690            .find(|spec| spec.name == "talker.model.text_embedding.weight");
2691        assert!(
2692            text_embedding.is_some(),
2693            "pinned inventory must contain the text embedding"
2694        );
2695        if let Some(text_embedding) = text_embedding {
2696            assert_eq!(text_embedding.storage, TensorStoragePolicy::Verbatim);
2697            assert_eq!(text_embedding.access_class, AccessClass::ColdTextEmbedding);
2698        }
2699
2700        let talker_projection = specs
2701            .iter()
2702            .find(|spec| spec.name == "talker.model.layers.0.mlp.down_proj.weight");
2703        assert!(
2704            talker_projection.is_some(),
2705            "pinned inventory must contain the talker projection"
2706        );
2707        if let Some(talker_projection) = talker_projection {
2708            assert_eq!(
2709                talker_projection.storage,
2710                TensorStoragePolicy::Q8PerOutputChannel
2711            );
2712            assert_eq!(
2713                talker_projection.access_class,
2714                AccessClass::HotRecurrentTalker
2715            );
2716        }
2717
2718        let micro_projection = specs
2719            .iter()
2720            .find(|spec| spec.name == "talker.code_predictor.model.layers.0.mlp.down_proj.weight");
2721        assert!(
2722            micro_projection.is_some(),
2723            "pinned inventory must contain the microdecoder projection"
2724        );
2725        if let Some(micro_projection) = micro_projection {
2726            assert_eq!(
2727                micro_projection.storage,
2728                TensorStoragePolicy::Q8PerOutputChannel
2729            );
2730            assert_eq!(
2731                micro_projection.access_class,
2732                AccessClass::HotRecurrentMicrodecoder
2733            );
2734        }
2735
2736        let primary_embedding = specs
2737            .iter()
2738            .find(|spec| spec.name == "talker.model.codec_embedding.weight");
2739        assert!(
2740            primary_embedding.is_some(),
2741            "pinned inventory must contain the primary-code embedding"
2742        );
2743        if let Some(primary_embedding) = primary_embedding {
2744            assert_eq!(
2745                primary_embedding.access_class,
2746                AccessClass::HotRecurrentMicrodecoder,
2747                "the primary-code embedding feeds residual depth one every frame"
2748            );
2749        }
2750
2751        let primary_head = specs
2752            .iter()
2753            .find(|spec| spec.name == "talker.codec_head.weight");
2754        assert!(
2755            primary_head.is_some(),
2756            "pinned inventory must contain the primary-code head"
2757        );
2758        if let Some(primary_head) = primary_head {
2759            assert_eq!(primary_head.storage, TensorStoragePolicy::Verbatim);
2760            assert_eq!(primary_head.access_class, AccessClass::HotRecurrentTalker);
2761        }
2762
2763        let text_projection = specs
2764            .iter()
2765            .find(|spec| spec.name == "talker.text_projection.linear_fc1.weight");
2766        assert!(
2767            text_projection.is_some(),
2768            "pinned inventory must contain the text-projection MLP"
2769        );
2770        if let Some(text_projection) = text_projection {
2771            assert_eq!(text_projection.storage, TensorStoragePolicy::Verbatim);
2772            assert_eq!(
2773                text_projection.access_class,
2774                AccessClass::HotRecurrentTalker
2775            );
2776        }
2777
2778        let head = specs
2779            .iter()
2780            .find(|spec| spec.name == "talker.code_predictor.lm_head.0.weight");
2781        assert!(
2782            head.is_some(),
2783            "pinned inventory must contain the residual-code head"
2784        );
2785        if let Some(head) = head {
2786            assert_eq!(head.storage, TensorStoragePolicy::Verbatim);
2787            assert_eq!(head.access_class, AccessClass::HotRecurrentMicrodecoder);
2788        }
2789
2790        let speaker = specs
2791            .iter()
2792            .find(|spec| spec.name == "speaker_encoder.fc.weight");
2793        assert!(
2794            speaker.is_some(),
2795            "pinned inventory must contain the speaker encoder"
2796        );
2797        if let Some(speaker) = speaker {
2798            assert_eq!(speaker.storage, TensorStoragePolicy::Verbatim);
2799            assert_eq!(speaker.access_class, AccessClass::EnrollmentSpeakerEncoder);
2800        }
2801    }
2802
2803    #[test]
2804    fn conversion_notice_carries_changes_and_the_full_license() {
2805        let notice = pinned_license_notice();
2806        assert!(notice.contains("Copyright 2026 Alibaba Cloud"));
2807        assert!(notice.contains("CHANGES: the original bfloat16 weights were converted"));
2808        assert!(notice.contains("Apache License"));
2809        assert!(notice.contains("TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION"));
2810    }
2811
2812    #[test]
2813    fn convert_refusal_still_emits_a_versioned_robot_lifecycle() {
2814        let cli = Cli {
2815            profile: None,
2816            packet_frames: None,
2817            math_mode: None,
2818            voice_pack: None,
2819            normalize: None,
2820            trace: None,
2821            seed: None,
2822            command: Command::Robot(RobotArgs {
2823                command: RobotCommand::Health,
2824            }),
2825        };
2826        let args = ConvertArgs {
2827            // A readable file with the wrong name reaches the explicit pinned-source refusal
2828            // before any destination is created.
2829            source: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"),
2830            output: PathBuf::from("never-created.fttsq"),
2831        };
2832        let mut stdout = Vec::new();
2833        let mut stderr = Vec::new();
2834        let error = run_convert(
2835            &cli,
2836            &args,
2837            &Environment::default(),
2838            &mut stdout,
2839            &mut stderr,
2840        )
2841        .expect_err("the non-pinned source must be refused");
2842        assert_eq!(error.exit_code(), FttsExitCode::Input);
2843
2844        let stdout = String::from_utf8(stdout).expect("NDJSON stdout");
2845        let stderr = String::from_utf8(stderr).expect("NDJSON stderr");
2846        assert!(robot::validate_ndjson(&stdout).is_empty());
2847        assert!(robot::validate_ndjson(&stderr).is_empty());
2848        let stdout_events = stdout
2849            .lines()
2850            .map(|line| serde_json::from_str::<Value>(line).expect("JSON event"))
2851            .collect::<Vec<_>>();
2852        assert_eq!(stdout_events[0]["event"], "run_start");
2853        assert_eq!(stdout_events[1]["event"], "stage");
2854        assert_eq!(
2855            serde_json::from_str::<Value>(stderr.trim()).expect("run error")["event"],
2856            "run_error"
2857        );
2858    }
2859
2860    #[test]
2861    fn say_check_emits_a_versioned_admission_outcome() {
2862        let model = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
2863        let cli = Cli {
2864            profile: Some(ExecutionProfile::Balanced),
2865            packet_frames: Some(PacketFrames::Four),
2866            math_mode: Some(MathMode::Strict),
2867            voice_pack: Some(VoicePackProfile::Portable),
2868            normalize: Some(NormalizeMode::Conservative),
2869            trace: None,
2870            seed: Some(7),
2871            command: Command::Robot(RobotArgs {
2872                command: RobotCommand::Health,
2873            }),
2874        };
2875        let args = SayArgs {
2876            text: Some("checked text".to_owned()),
2877            output_positional: None,
2878            file: None,
2879            model: Some(model),
2880            voice: None,
2881            output: None,
2882            stream: None,
2883            check: true,
2884        };
2885        let mut stdin = Cursor::new(Vec::<u8>::new());
2886        let mut stdout = Vec::new();
2887        let mut stderr = Vec::new();
2888
2889        run_say(
2890            &cli,
2891            &args,
2892            &Environment::default(),
2893            &mut stdin,
2894            &mut stdout,
2895            &mut stderr,
2896        )
2897        .expect("check path");
2898
2899        assert!(stderr.is_empty());
2900        let text = String::from_utf8(stdout).expect("utf-8 events");
2901
2902        // The whole emitted stream must conform, not just the event this test cares about.
2903        assert!(
2904            robot::validate_ndjson(&text).is_empty(),
2905            "emitted stream violates the contract: {:?}",
2906            robot::validate_ndjson(&text)
2907        );
2908
2909        let events: Vec<Value> = text
2910            .lines()
2911            .map(|line| serde_json::from_str(line).expect("one JSON object per line"))
2912            .collect();
2913        let names: Vec<&str> = events
2914            .iter()
2915            .map(|event| event["event"].as_str().expect("event name"))
2916            .collect();
2917        assert_eq!(
2918            names,
2919            vec![
2920                "run_start",
2921                "stage",
2922                "stage",
2923                "text_prepared",
2924                "stage",
2925                "stage",
2926                "check_complete",
2927                "run_complete",
2928            ],
2929            "the skeleton lifecycle must flow end-to-end on the empty pipeline"
2930        );
2931
2932        // Every event in a run repeats the same run_id, which is what lets an agent stitch a run
2933        // together across the two streams.
2934        let run_id = events[0]["run_id"]
2935            .as_str()
2936            .expect("run_start carries run_id");
2937        assert!(!run_id.is_empty());
2938        assert!(events.iter().all(|event| event["run_id"] == run_id));
2939        assert!(
2940            events
2941                .iter()
2942                .all(|event| event["schema_version"] == ROBOT_SCHEMA_VERSION)
2943        );
2944
2945        // Stage sequence numbers are dense and ordered, so a consumer can detect a dropped event.
2946        let seqs: Vec<u64> = events
2947            .iter()
2948            .filter(|event| event["event"] == "stage")
2949            .map(|event| event["seq"].as_u64().expect("seq"))
2950            .collect();
2951        assert_eq!(seqs, vec![0, 1, 2, 3]);
2952
2953        let check = &events[6];
2954        // "accepted", not "scaffold_accepted": the preflight is now the engine's own
2955        // ftts_core::admission computation rather than a CLI-local heuristic, so `--check` and the
2956        // synthesis that follows it cannot reach different verdicts for the same request.
2957        assert_eq!(check["admission"]["status"], "accepted");
2958        assert!(
2959            check["admission"]["predicted_peak_bytes"].is_u64(),
2960            "the engine-backed plan reports a real predicted peak"
2961        );
2962        assert_eq!(check["normalization_trace_requested"], false);
2963
2964        // text_prepared reports shape and provenance only; the input text must never appear.
2965        let prepared = &events[3];
2966        assert_eq!(prepared["char_count"], "checked text".chars().count());
2967        assert!(prepared["unicode_version"].is_string());
2968        assert!(
2969            !text.contains("checked text"),
2970            "the event stream must not carry the user's text"
2971        );
2972
2973        assert_eq!(events[7]["exit_code"], 0);
2974    }
2975
2976    #[test]
2977    fn a_newline_inside_a_field_cannot_break_ndjson_framing() {
2978        // The entire contract rests on one JSON object per line. serde_json escapes control
2979        // characters, so a message containing a newline stays one line and the newline survives as
2980        // data -- but nothing pinned that until now, and a hand-rolled serializer or a raw write
2981        // path would silently break every downstream parser.
2982        let run = robot::RunContext::with_id("r-test");
2983        let error = FttsError::Generic("first\nsecond".to_owned());
2984        let mut event = run.event(robot::EventType::RunError);
2985        event.insert("exit_code".to_owned(), json!(error.exit_code().as_u8()));
2986        event.insert("kind".to_owned(), json!(error.exit_code().description()));
2987        event.insert("message".to_owned(), json!(error.to_string()));
2988        event.insert("remediation".to_owned(), json!(error.remediation()));
2989        event.insert("elapsed_ms".to_owned(), json!(0));
2990        let value = Value::Object(event);
2991
2992        let mut buffer = Vec::new();
2993        write_json_line(&mut buffer, &value).expect("serializes");
2994        let text = String::from_utf8(buffer).expect("utf-8");
2995
2996        assert_eq!(
2997            text.lines().count(),
2998            1,
2999            "framing broken by an embedded newline"
3000        );
3001        assert!(robot::validate_ndjson(&text).is_empty());
3002        let parsed: Value = serde_json::from_str(text.trim_end()).expect("still one object");
3003        assert!(
3004            parsed["message"].as_str().expect("message").contains('\n'),
3005            "the newline must survive as data, not be stripped"
3006        );
3007    }
3008
3009    #[test]
3010    fn pinned_copies_match_the_truth_pack_canonicals() {
3011        //  `pinned/` exists because `cargo package` cannot ship the truth pack. The truth pack
3012        //  stays canonical; a drifted copy would embed a stale pin assertion or attribution in
3013        //  the shipped binary, so byte-identity is asserted whenever the repo checkout is present.
3014        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
3015        for (canonical, embedded, name) in [
3016            (
3017                "docs/truth-pack/TENSOR_INVENTORY.json",
3018                PINNED_TENSOR_INVENTORY,
3019                "TENSOR_INVENTORY.json",
3020            ),
3021            (
3022                "docs/truth-pack/snapshots/hf/config.json",
3023                PINNED_MODEL_CONFIG,
3024                "model_config.json",
3025            ),
3026            (
3027                "docs/truth-pack/snapshots/gh/LICENSE",
3028                APACHE_LICENSE,
3029                "QWEN_APACHE_LICENSE",
3030            ),
3031        ] {
3032            match std::fs::read_to_string(root.join(canonical)) {
3033                Ok(bytes) => assert_eq!(
3034                    bytes, embedded,
3035                    "pinned/{name} drifted from {canonical}; re-copy it"
3036                ),
3037                Err(_) => eprintln!(
3038                    "SKIP pinned-copy check for {name}: {canonical} absent (no repo checkout)"
3039                ),
3040            }
3041        }
3042    }
3043
3044    #[test]
3045    fn embedded_model_manifest_is_wellformed_and_agrees_with_the_converter_pin() {
3046        let manifest = ModelManifest::embedded().expect("embedded manifest parses");
3047        assert_eq!(manifest.model_id, "qwen3-tts-12hz-0.6b-base");
3048        assert_eq!(manifest.release_tag, "model-qwen3-tts-v1");
3049        assert_eq!(manifest.repo, "Dicklesworthstone/franken_tts");
3050        assert_eq!(manifest.files.len(), 7);
3051
3052        for file in &manifest.files {
3053            assert!(
3054                is_sha256_hex(&file.sha256),
3055                "{} carries a malformed digest",
3056                file.asset
3057            );
3058            assert!(file.bytes > 0, "{} has no pinned size", file.asset);
3059            let dest = Path::new(&file.dest);
3060            assert!(!dest.is_absolute(), "{} dest is absolute", file.asset);
3061            assert!(
3062                dest.components()
3063                    .all(|component| matches!(component, std::path::Component::Normal(_))),
3064                "{} dest can traverse out of the model directory",
3065                file.asset
3066            );
3067        }
3068
3069        // Since frankentts-zm5 the pull ships the canonical quantized artifact, not the raw main
3070        // checkpoint: enrollment and synthesis both hydrate from the .fttsq, so pulling the raw
3071        // 1.7 GB main would be pure waste. The artifact lands at the exact basename every model
3072        // search path probes for.
3073        let main = manifest
3074            .files
3075            .iter()
3076            .find(|file| file.dest == MODEL_BASENAME)
3077            .expect("manifest carries the canonical artifact");
3078        assert_eq!(
3079            manifest.download_url(main),
3080            "https://github.com/Dicklesworthstone/franken_tts/releases/download/model-qwen3-tts-v1/qwen3-tts-12hz-0.6b-base.fttsq"
3081        );
3082        assert!(
3083            !manifest
3084                .files
3085                .iter()
3086                .any(|file| file.dest == PINNED_MAIN_WEIGHTS_FILENAME),
3087            "pull must not fetch the raw main checkpoint alongside the canonical artifact"
3088        );
3089
3090        // Together the files are exactly what ModelBundle::resolve requires plus the two config
3091        // sidecars, so a completed pull always resolves.
3092        let dests: Vec<&str> = manifest
3093            .files
3094            .iter()
3095            .map(|file| file.dest.as_str())
3096            .collect();
3097        for required in [
3098            MODEL_BASENAME,
3099            "speech_tokenizer/model.safetensors",
3100            "vocab.json",
3101            "merges.txt",
3102            "tokenizer_config.json",
3103        ] {
3104            assert!(dests.contains(&required), "manifest is missing {required}");
3105        }
3106    }
3107
3108    #[test]
3109    fn malformed_model_manifests_are_refused_with_the_field_named() {
3110        fn manifest_with(
3111            schema_version: u64,
3112            asset: &str,
3113            dest: &str,
3114            sha256: &str,
3115            bytes: u64,
3116        ) -> String {
3117            json!({
3118                "schema_version": schema_version,
3119                "model_id": "m",
3120                "release_tag": "t",
3121                "repo": "owner/repo",
3122                "files": [{"asset": asset, "dest": dest, "sha256": sha256, "bytes": bytes}],
3123            })
3124            .to_string()
3125        }
3126        let good_sha = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
3127
3128        // The happy shape parses, so every refusal below is attributable to its one bad field.
3129        ModelManifest::parse(&manifest_with(1, "a.bin", "a.bin", good_sha, 1))
3130            .expect("well-formed manifest parses");
3131
3132        for (label, text) in [
3133            (
3134                "unsupported schema_version",
3135                manifest_with(2, "a.bin", "a.bin", good_sha, 1),
3136            ),
3137            (
3138                "short sha256",
3139                manifest_with(1, "a.bin", "a.bin", "abc123", 1),
3140            ),
3141            (
3142                "uppercase sha256",
3143                manifest_with(1, "a.bin", "a.bin", &good_sha.to_uppercase(), 1),
3144            ),
3145            (
3146                "zero bytes",
3147                manifest_with(1, "a.bin", "a.bin", good_sha, 0),
3148            ),
3149            (
3150                "absolute dest",
3151                manifest_with(1, "a.bin", "/etc/passwd", good_sha, 1),
3152            ),
3153            (
3154                "traversal dest",
3155                manifest_with(1, "a.bin", "../escape.bin", good_sha, 1),
3156            ),
3157            (
3158                "asset with a path separator",
3159                manifest_with(1, "dir/a.bin", "a.bin", good_sha, 1),
3160            ),
3161            (
3162                "empty files array",
3163                json!({
3164                    "schema_version": 1,
3165                    "model_id": "m",
3166                    "release_tag": "t",
3167                    "repo": "owner/repo",
3168                    "files": [],
3169                })
3170                .to_string(),
3171            ),
3172        ] {
3173            let error = ModelManifest::parse(&text)
3174                .expect_err(&format!("a manifest with {label} must be refused"));
3175            assert_eq!(error.exit_code(), FttsExitCode::ArtifactFormat, "{label}");
3176        }
3177    }
3178
3179    #[test]
3180    fn pull_skips_only_a_file_matching_both_pinned_size_and_digest() {
3181        let dir = std::env::temp_dir().join(format!("ftts-pull-decision-{}", std::process::id()));
3182        fs::create_dir_all(&dir).expect("temp dir");
3183        let payload = b"pinned payload";
3184        let file = ModelManifestFile {
3185            asset: "a.bin".to_owned(),
3186            dest: "a.bin".to_owned(),
3187            sha256: ftts_artifacts::sha256::hex_digest(payload),
3188            bytes: payload.len() as u64,
3189        };
3190        let dest = dir.join("a.bin");
3191
3192        let _ = fs::remove_file(&dest);
3193        assert_eq!(
3194            pull_decision(&dest, &file, false),
3195            PullDecision::Download,
3196            "absent file must download"
3197        );
3198
3199        fs::write(&dest, payload).expect("write verified payload");
3200        assert_eq!(
3201            pull_decision(&dest, &file, false),
3202            PullDecision::Skip,
3203            "matching size and digest must skip"
3204        );
3205        assert_eq!(
3206            pull_decision(&dest, &file, true),
3207            PullDecision::Download,
3208            "--force must re-download even a verified file"
3209        );
3210
3211        fs::write(&dest, b"pinned_payload").expect("write same-length corruption");
3212        assert_eq!(
3213            pull_decision(&dest, &file, false),
3214            PullDecision::Download,
3215            "a same-length corruption must be caught by the digest"
3216        );
3217
3218        fs::write(&dest, b"short").expect("write truncation");
3219        assert_eq!(
3220            pull_decision(&dest, &file, false),
3221            PullDecision::Download,
3222            "a truncated file must be caught by the size check"
3223        );
3224    }
3225
3226    #[test]
3227    fn model_resolution_prefers_explicit_then_searched_then_the_pull_directory() {
3228        let root = std::env::temp_dir().join(format!("ftts-resolve-order-{}", std::process::id()));
3229
3230        // A complete bundle directory: ModelBundle::resolve only asks `is_file`, so empty files
3231        // are a sufficient fake (and would fail loudly if the resolver ever started reading).
3232        let bundle = root.join("bundle");
3233        for relative in [
3234            "model.safetensors",
3235            "speech_tokenizer/model.safetensors",
3236            "vocab.json",
3237            "merges.txt",
3238            "tokenizer_config.json",
3239        ] {
3240            let path = bundle.join(relative);
3241            fs::create_dir_all(path.parent().expect("bundle parent")).expect("bundle dirs");
3242            fs::write(&path, b"").expect("bundle file");
3243        }
3244        assert!(
3245            synth::ModelBundle::resolve(&bundle).is_ok(),
3246            "five empty files must satisfy the resolver's is_file checks"
3247        );
3248
3249        let searched_artifact = root.join("searched").join(MODEL_BASENAME);
3250        fs::create_dir_all(searched_artifact.parent().expect("searched parent"))
3251            .expect("searched dir");
3252        fs::write(&searched_artifact, b"").expect("searched artifact");
3253        let searched = vec![searched_artifact.clone()];
3254        let absent = vec![root.join("absent").join(MODEL_BASENAME)];
3255
3256        // 1. `--model` outranks everything.
3257        assert_eq!(
3258            resolve_model_from(Some(&bundle), &searched, Some(&bundle)).expect("explicit"),
3259            bundle.display().to_string()
3260        );
3261
3262        // 2. A searched artifact outranks the pull directory.
3263        assert_eq!(
3264            resolve_model_from(None, &searched, Some(&bundle)).expect("searched"),
3265            searched_artifact.display().to_string()
3266        );
3267
3268        // 3. The pull directory resolves when nothing searched exists.
3269        assert_eq!(
3270            resolve_model_from(None, &absent, Some(&bundle)).expect("pull fallback"),
3271            bundle.display().to_string()
3272        );
3273
3274        // 4. An incomplete pull directory does not resolve, and the error teaches `ftts pull`.
3275        let incomplete = root.join("incomplete");
3276        fs::create_dir_all(&incomplete).expect("incomplete dir");
3277        let error = resolve_model_from(None, &absent, Some(&incomplete))
3278            .expect_err("an empty pull directory must not resolve");
3279        assert_eq!(error.exit_code(), FttsExitCode::ModelNotFound);
3280        assert!(error.to_string().contains("ftts pull"), "{error}");
3281        assert!(error.to_string().contains("2.0 GB"), "{error}");
3282        assert!(error.to_string().contains("FTTS_MODEL_DIR"), "{error}");
3283    }
3284
3285    #[test]
3286    fn the_pull_directory_default_prefers_the_env_override() {
3287        let mut environment = Environment::default();
3288        environment
3289            .values
3290            .insert("FTTS_MODEL_DIR", Some(OsString::from("/tmp/env-model-dir")));
3291        assert_eq!(
3292            default_pull_model_dir(&environment),
3293            Some(PathBuf::from("/tmp/env-model-dir"))
3294        );
3295
3296        // Without the override the default lives under $HOME; the exact suffix is the contract
3297        // `ftts pull` and model resolution share.
3298        if std::env::var_os("HOME").is_some() {
3299            let fallback = default_pull_model_dir(&Environment::default())
3300                .expect("HOME is set, so a default exists");
3301            assert!(
3302                fallback.ends_with(DEFAULT_MODEL_CACHE_SUBDIR),
3303                "{fallback:?}"
3304            );
3305        }
3306    }
3307}