Skip to main content

ftts_cli/
robot.rs

1//! The robot NDJSON contract — defined once, consumed three ways.
2//!
3//! Agent users are the primary consumers of `ftts` (goal G5), so the event stream is a
4//! promised interface, not incidental logging. The catalogue below is the *only* place that
5//! definition lives:
6//!
7//! 1. `ftts robot schema` serialises it via [`schema_document`], so the machine-readable self-description cannot drift
8//!    from what the binary actually emits;
9//! 2. [`validate_event`] checks emitted objects against it, so an event that grew a field or
10//!    lost one fails a test rather than silently changing an agent's parse;
11//! 3. the frozen fixture in `ftts-conformance` pins it, so a schema change is a deliberate
12//!    fixture update and never a silent one.
13//!
14//! Validation is **strict in both directions**: a missing required field and an *unknown* field
15//! are both violations. A contract that only checks for absence lets the surface grow silently,
16//! which is the failure mode that breaks downstream parsers.
17//!
18//! Bead: frankentts-p0-robot-82c.
19
20use std::collections::BTreeMap;
21
22use serde_json::{Value, json};
23
24use crate::error::FttsExitCode;
25
26/// Version carried by every emitted object. Bump deliberately; the frozen fixture will fail.
27pub const SCHEMA_VERSION: u8 = 1;
28
29/// Environment recognised by the CLI, as reported by `robot schema`.
30///
31/// It lives beside the catalogue rather than in `lib.rs` because it is part of the promised
32/// agent-facing contract, and the frozen fixture pins it: adding a variable without documenting
33/// it here fails the contract test.
34/// The ONE list of user-facing environment levers. `ftts doctor` reports exactly this set,
35/// and the frozen schema fixture pins it, so a lever added anywhere else without an entry
36/// here fails the contract test — the split-brain where `robot schema` and `doctor`
37/// disagreed (and neither mentioned the resident daemon's levers) is what this fixes.
38/// Development-only probes (`FTTS_SPEC_PROBE`, `FTTS_ORACLE_FIXTURES`, `FTTS_RECEIPTS`,
39/// `FTTS_TOKENIZER_REGEX`, `FTTS_LOAD_THREADS`) are deliberately not part of the promise.
40pub const DOCUMENTED_ENVIRONMENT: &[&str] = &[
41    "FTTS_MODEL_DIR",
42    "FTTS_DEFAULT_VOICE",
43    "FTTS_THREADS",
44    "FTTS_PROFILE",
45    "FTTS_PACKET_FRAMES",
46    "FTTS_MATH_MODE",
47    "FTTS_QUANT",
48    "FTTS_FORCE_ARCH",
49    "FTTS_NUMA",
50    "FTTS_MAX_FRAMES",
51    "FTTS_MEMORY_BUDGET_MB",
52    "FTTS_STAGE_BUDGET_SYNTHESIS_MS",
53    "FTTS_STAGE_BUDGET_FRAME_MS",
54    "FTTS_STAGE_BUDGET_ENROLL_MS",
55    "FTTS_NO_RESIDENT",
56    "FTTS_RESIDENT_IDLE_SECS",
57    "FTTS_RESIDENT_DIR",
58    "FTTS_RESIDENT_LOG",
59    "FTTS_RESIDENT_SPAWN_WAIT_SECS",
60    "FTTS_RESIDENT_CLIENT_TIMEOUT_SECS",
61    "FTTS_DENOISE_ENGINE",
62    "FTTS_INT8",
63    "FTTS_INT8_TIER",
64    "FTTS_INT8_SCOPE",
65    "FTTS_INT8_CODEC",
66    "FTTS_INT8_THREADS",
67    "FTTS_ARTIFACT_Q8",
68    "FTTS_FAST_SNAKE",
69];
70
71/// Which stream an object is written to.
72#[derive(Clone, Copy, Debug, Eq, PartialEq)]
73pub enum Stream {
74    /// Written to stdout, unless `say --stream raw` has given stdout to PCM.
75    Events,
76    /// Always stderr: the human-facing error channel mirrors the machine one.
77    Stderr,
78}
79
80impl Stream {
81    const fn as_str(self) -> &'static str {
82        match self {
83            Self::Events => "events",
84            Self::Stderr => "stderr",
85        }
86    }
87}
88
89/// Stream events describe a run in progress; replies answer a one-shot query.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum Kind {
92    Stream,
93    Reply,
94}
95
96impl Kind {
97    const fn as_str(self) -> &'static str {
98        match self {
99            Self::Stream => "stream",
100            Self::Reply => "reply",
101        }
102    }
103}
104
105/// One field of one event.
106pub struct FieldSpec {
107    pub name: &'static str,
108    /// A tiny type language: `u64`, `i64`, `u8`, `bool`, `string`, `object`, `array`, and
109    /// `<ty>|null` for a nullable field. Deliberately small — this describes a wire contract,
110    /// not a type system.
111    pub ty: &'static str,
112    pub required: bool,
113    pub summary: &'static str,
114}
115
116pub struct EventSpec {
117    pub name: &'static str,
118    pub kind: Kind,
119    pub stream: Stream,
120    pub summary: &'static str,
121    pub fields: &'static [FieldSpec],
122}
123
124/// Present on every object, so the per-event lists below do not repeat them.
125pub const COMMON_FIELDS: &[FieldSpec] = &[
126    FieldSpec {
127        name: "schema_version",
128        ty: "u8",
129        required: true,
130        summary: "contract version; pinned by the frozen fixture",
131    },
132    FieldSpec {
133        name: "event",
134        ty: "string",
135        required: true,
136        summary: "discriminator naming this object's type",
137    },
138];
139
140pub const EVENTS: &[EventSpec] = &[
141    EventSpec {
142        name: "run_start",
143        kind: Kind::Stream,
144        stream: Stream::Events,
145        summary: "opens every run; every later event in the run repeats its run_id",
146        fields: &[
147            FieldSpec {
148                name: "run_id",
149                ty: "string",
150                required: true,
151                summary: "correlates the events of one run",
152            },
153            FieldSpec {
154                name: "command",
155                ty: "string",
156                required: true,
157                summary: "subcommand being run",
158            },
159            FieldSpec {
160                name: "profile",
161                ty: "string",
162                required: true,
163                summary: "execution profile in force",
164            },
165            FieldSpec {
166                name: "packet_frames",
167                ty: "string",
168                required: true,
169                summary: "streaming packet size",
170            },
171            FieldSpec {
172                name: "math_mode",
173                ty: "string",
174                required: true,
175                summary: "strict or fast",
176            },
177            FieldSpec {
178                name: "stateless",
179                ty: "bool",
180                required: true,
181                summary: "true unless durable tracing was opted into",
182            },
183            FieldSpec {
184                name: "seed",
185                ty: "u64|null",
186                required: true,
187                summary: "sampler seed, null when unset",
188            },
189            FieldSpec {
190                name: "model",
191                ty: "string|null",
192                required: true,
193                summary: "resolved model artifact, null before resolution",
194            },
195            FieldSpec {
196                name: "voice",
197                ty: "string|null",
198                required: true,
199                summary: "resolved voice pack, null when none",
200            },
201        ],
202    },
203    EventSpec {
204        name: "stage",
205        kind: Kind::Stream,
206        stream: Stream::Events,
207        summary: "one pipeline stage began or ended",
208        fields: &[
209            FieldSpec {
210                name: "run_id",
211                ty: "string",
212                required: true,
213                summary: "owning run",
214            },
215            FieldSpec {
216                name: "name",
217                ty: "string",
218                required: true,
219                summary: "stage name",
220            },
221            FieldSpec {
222                name: "seq",
223                ty: "u64",
224                required: true,
225                summary: "monotonic index within the run",
226            },
227            FieldSpec {
228                name: "state",
229                ty: "string",
230                required: true,
231                summary: "started or finished",
232            },
233            FieldSpec {
234                name: "elapsed_ms",
235                ty: "u64",
236                required: true,
237                summary: "milliseconds since run_start",
238            },
239            FieldSpec {
240                name: "budget_ms",
241                ty: "u64|null",
242                required: true,
243                summary: "stage budget, null when unbounded",
244            },
245        ],
246    },
247    EventSpec {
248        name: "frame",
249        kind: Kind::Stream,
250        stream: Stream::Events,
251        summary: "coarse decode progress; throttled, never one per frame",
252        fields: &[
253            FieldSpec {
254                name: "run_id",
255                ty: "string",
256                required: true,
257                summary: "owning run",
258            },
259            FieldSpec {
260                name: "index",
261                ty: "u64",
262                required: true,
263                summary: "frames emitted so far",
264            },
265            FieldSpec {
266                name: "total_estimate",
267                ty: "u64|null",
268                required: true,
269                summary: "predicted total, null when unknown",
270            },
271            FieldSpec {
272                name: "elapsed_ms",
273                ty: "u64",
274                required: true,
275                summary: "milliseconds since run_start",
276            },
277        ],
278    },
279    EventSpec {
280        name: "audio_chunk",
281        kind: Kind::Stream,
282        stream: Stream::Events,
283        summary: "PCM was written to the sink; the bytes themselves never appear here",
284        fields: &[
285            FieldSpec {
286                name: "run_id",
287                ty: "string",
288                required: true,
289                summary: "owning run",
290            },
291            FieldSpec {
292                name: "byte_offset",
293                ty: "u64",
294                required: true,
295                summary: "offset of this chunk within the stream",
296            },
297            FieldSpec {
298                name: "bytes",
299                ty: "u64",
300                required: true,
301                summary: "chunk length in bytes",
302            },
303            FieldSpec {
304                name: "duration_ms",
305                ty: "u64",
306                required: true,
307                summary: "audio duration of this chunk",
308            },
309            FieldSpec {
310                name: "packet_frames",
311                ty: "string",
312                required: true,
313                summary: "packet size that produced it",
314            },
315            FieldSpec {
316                name: "sink",
317                ty: "string",
318                required: true,
319                summary: "where the PCM went: file, fd, or stdout",
320            },
321        ],
322    },
323    EventSpec {
324        name: "health",
325        kind: Kind::Reply,
326        stream: Stream::Events,
327        summary: "readiness snapshot; also answers `robot health`",
328        fields: &[
329            FieldSpec {
330                name: "status",
331                ty: "string",
332                required: true,
333                summary: "coarse readiness state",
334            },
335            FieldSpec {
336                name: "model_loaded",
337                ty: "bool",
338                required: true,
339                summary: "whether a model artifact is resident",
340            },
341            FieldSpec {
342                name: "model_present",
343                ty: "bool",
344                required: true,
345                summary: "artifact found by magic-bytes sniff, never a tensor load",
346            },
347            FieldSpec {
348                name: "model_path",
349                ty: "string|null",
350                required: true,
351                summary: "resolved artifact path when present",
352            },
353            FieldSpec {
354                name: "model_dir",
355                ty: "string|null",
356                required: true,
357                summary: "FTTS_MODEL_DIR as configured, null when unset",
358            },
359            FieldSpec {
360                name: "searched",
361                ty: "array",
362                required: true,
363                summary: "every directory consulted; makes resolution failures actionable",
364            },
365            FieldSpec {
366                name: "stateless_default",
367                ty: "bool",
368                required: true,
369                summary: "no synthesis history is persisted by default",
370            },
371            FieldSpec {
372                name: "threads",
373                ty: "u64|null",
374                required: true,
375                summary: "configured worker count, null when unset",
376            },
377            FieldSpec {
378                name: "recommended_command",
379                ty: "string",
380                required: true,
381                summary: "next actionable command",
382            },
383        ],
384    },
385    EventSpec {
386        name: "run_complete",
387        kind: Kind::Stream,
388        stream: Stream::Events,
389        summary: "closes a successful run",
390        fields: &[
391            FieldSpec {
392                name: "run_id",
393                ty: "string",
394                required: true,
395                summary: "owning run",
396            },
397            FieldSpec {
398                name: "exit_code",
399                ty: "u8",
400                required: true,
401                summary: "process exit code this run will produce",
402            },
403            FieldSpec {
404                name: "elapsed_ms",
405                ty: "u64",
406                required: true,
407                summary: "total run duration",
408            },
409            FieldSpec {
410                name: "frames",
411                ty: "u64",
412                required: true,
413                summary: "frames produced",
414            },
415            FieldSpec {
416                name: "audio_bytes",
417                ty: "u64",
418                required: true,
419                summary: "PCM bytes written",
420            },
421            FieldSpec {
422                name: "ttfa_ms",
423                ty: "u64",
424                required: false,
425                summary: "synthesis start to first decoded PCM packet; excludes model load \
426                          (bounded by the load stage events)",
427            },
428            // The three fields below are emitted by synthesizing runs only; `--check` and
429            // non-synthesis commands close their runs without them. They were emitted (and
430            // consumed by the human presenter) before they were catalogued — documenting
431            // them here is what makes a strict validator accept a real `say` stream.
432            FieldSpec {
433                name: "samples",
434                ty: "u64",
435                required: false,
436                summary: "PCM samples synthesized (24 kHz mono)",
437            },
438            FieldSpec {
439                name: "duration_ms",
440                ty: "u64",
441                required: false,
442                summary: "duration of the synthesized audio itself",
443            },
444            FieldSpec {
445                name: "prepared_token_count",
446                ty: "u64",
447                required: false,
448                summary: "prompt tokens after normalization and template assembly",
449            },
450            FieldSpec {
451                name: "video_bytes",
452                ty: "u64",
453                required: false,
454                summary: "container bytes written by a make-video run (its `frames` are video frames)",
455            },
456        ],
457    },
458    EventSpec {
459        name: "health_violation",
460        kind: Kind::Stream,
461        stream: Stream::Events,
462        summary: "a runtime-health detector fired mid-run (ftts_core::health)",
463        fields: &[
464            FieldSpec {
465                name: "run_id",
466                ty: "string",
467                required: true,
468                summary: "owning run",
469            },
470            FieldSpec {
471                name: "violation",
472                ty: "string",
473                required: true,
474                summary: "stable violation class, e.g. non_finite or output_silent",
475            },
476            FieldSpec {
477                name: "detail",
478                ty: "string",
479                required: true,
480                summary: "the specific occurrence, including the locating scalars",
481            },
482            FieldSpec {
483                name: "remedy",
484                ty: "string",
485                required: true,
486                summary: "the concrete next action, not a restatement of the problem",
487            },
488            FieldSpec {
489                name: "invalidates_output",
490                ty: "bool",
491                required: true,
492                summary: "false for a kernel demotion or thermal report — those runs are still correct",
493            },
494            FieldSpec {
495                name: "elapsed_ms",
496                ty: "u64",
497                required: true,
498                summary: "milliseconds since run_start",
499            },
500        ],
501    },
502    EventSpec {
503        name: "run_error",
504        kind: Kind::Stream,
505        stream: Stream::Stderr,
506        summary: "closes a failed run and carries the exit code the process will return",
507        fields: &[
508            FieldSpec {
509                name: "run_id",
510                ty: "string",
511                required: true,
512                summary: "owning run",
513            },
514            FieldSpec {
515                name: "exit_code",
516                ty: "u8",
517                required: true,
518                summary: "process exit code; matches the stable table in `robot schema`",
519            },
520            FieldSpec {
521                name: "kind",
522                ty: "string",
523                required: true,
524                summary: "stable error class",
525            },
526            FieldSpec {
527                name: "message",
528                ty: "string",
529                required: true,
530                summary: "what went wrong",
531            },
532            FieldSpec {
533                name: "remediation",
534                ty: "string",
535                required: true,
536                summary: "the concrete next command to try",
537            },
538            FieldSpec {
539                name: "elapsed_ms",
540                ty: "u64",
541                required: true,
542                summary: "run duration before the failure",
543            },
544        ],
545    },
546    EventSpec {
547        name: "text_prepared",
548        kind: Kind::Stream,
549        stream: Stream::Events,
550        summary: "text normalization finished; reports SHAPE and PROVENANCE only, never the text",
551        fields: &[
552            FieldSpec {
553                name: "run_id",
554                ty: "string",
555                required: true,
556                summary: "owning run",
557            },
558            FieldSpec {
559                name: "normalize",
560                ty: "string",
561                required: true,
562                summary: "normalization mode in force",
563            },
564            FieldSpec {
565                name: "unicode_version",
566                ty: "string",
567                required: true,
568                summary: "Unicode version this build normalizes against",
569            },
570            FieldSpec {
571                name: "char_count",
572                ty: "u64",
573                required: true,
574                summary: "input length in characters; a size, never the content",
575            },
576            FieldSpec {
577                name: "trace_requested",
578                ty: "bool",
579                required: true,
580                summary: "whether a normalization trace was requested",
581            },
582        ],
583    },
584    EventSpec {
585        name: "check_complete",
586        kind: Kind::Reply,
587        stream: Stream::Events,
588        summary: "answers `say --check`: resolution and admission without synthesis",
589        fields: &[
590            FieldSpec {
591                name: "run_id",
592                ty: "string",
593                required: true,
594                summary: "owning run",
595            },
596            FieldSpec {
597                name: "model",
598                ty: "string",
599                required: true,
600                summary: "resolved model artifact",
601            },
602            FieldSpec {
603                name: "voice",
604                ty: "string|null",
605                required: true,
606                summary: "resolved voice pack",
607            },
608            FieldSpec {
609                name: "profile",
610                ty: "string",
611                required: true,
612                summary: "execution profile",
613            },
614            FieldSpec {
615                name: "packet_frames",
616                ty: "string",
617                required: true,
618                summary: "streaming packet size",
619            },
620            FieldSpec {
621                name: "math_mode",
622                ty: "string",
623                required: true,
624                summary: "strict or fast",
625            },
626            FieldSpec {
627                name: "voice_pack",
628                ty: "string",
629                required: true,
630                summary: "voice-pack privacy profile",
631            },
632            FieldSpec {
633                name: "normalize",
634                ty: "string",
635                required: true,
636                summary: "text-transformation mode",
637            },
638            FieldSpec {
639                name: "normalization_trace_requested",
640                ty: "bool",
641                required: true,
642                summary: "whether an emitted normalization trace was asked for",
643            },
644            FieldSpec {
645                name: "seed",
646                ty: "u64|null",
647                required: true,
648                summary: "sampler seed",
649            },
650            FieldSpec {
651                name: "trace",
652                ty: "string|null",
653                required: true,
654                summary: "opt-in trace path",
655            },
656            FieldSpec {
657                name: "output",
658                ty: "string|null",
659                required: true,
660                summary: "audio sink path",
661            },
662            FieldSpec {
663                name: "admission",
664                ty: "object",
665                required: true,
666                summary: "resource-admission decision",
667            },
668        ],
669    },
670    EventSpec {
671        name: "robot_schema",
672        kind: Kind::Reply,
673        stream: Stream::Events,
674        summary: "answers `robot schema`: this catalogue, machine-readably",
675        fields: &[
676            FieldSpec {
677                name: "events",
678                ty: "array",
679                required: true,
680                summary: "every object type this binary can emit",
681            },
682            FieldSpec {
683                name: "stdout_contract",
684                ty: "string",
685                required: true,
686                summary: "what owns stdout",
687            },
688            FieldSpec {
689                name: "raw_stream_contract",
690                ty: "string",
691                required: true,
692                summary: "stream split under --stream raw",
693            },
694            FieldSpec {
695                name: "environment_variables",
696                ty: "array",
697                required: true,
698                summary: "recognised environment",
699            },
700            FieldSpec {
701                name: "exit_codes",
702                ty: "object",
703                required: true,
704                summary: "stable exit-code table",
705            },
706        ],
707    },
708    EventSpec {
709        name: "backends",
710        kind: Kind::Reply,
711        stream: Stream::Events,
712        summary: "answers `robot backends`: detected ISA tier, kernel plan, pool sizing",
713        fields: &[
714            FieldSpec {
715                name: "available",
716                ty: "array",
717                required: true,
718                summary: "kernel tiers this build can dispatch",
719            },
720            FieldSpec {
721                name: "dispatched",
722                ty: "string|null",
723                required: true,
724                summary: "tier actually selected, null before selection",
725            },
726            FieldSpec {
727                name: "isa_features",
728                ty: "array",
729                required: true,
730                summary: "CPU features detected at runtime",
731            },
732            FieldSpec {
733                name: "kernel_plan",
734                ty: "string|null",
735                required: true,
736                summary: "autotuned plan identity, null when unpacked",
737            },
738            FieldSpec {
739                name: "pool_sizing",
740                ty: "object|null",
741                required: true,
742                summary: "USL-derived worker counts, null when unmeasured",
743            },
744            FieldSpec {
745                name: "force_arch",
746                ty: "string|null",
747                required: true,
748                summary: "FTTS_FORCE_ARCH override",
749            },
750        ],
751    },
752    EventSpec {
753        name: "selftest",
754        kind: Kind::Reply,
755        stream: Stream::Events,
756        summary: "answers `robot selftest`: shipped kernel proofs, including the i32-overflow rows",
757        fields: &[
758            FieldSpec {
759                name: "status",
760                ty: "string",
761                required: true,
762                summary: "passed, failed, or skipped",
763            },
764            FieldSpec {
765                name: "reason",
766                ty: "string|null",
767                required: true,
768                summary: "why it skipped; null when it ran",
769            },
770            FieldSpec {
771                name: "checks",
772                ty: "array",
773                required: true,
774                summary: "per-check outcomes",
775            },
776        ],
777    },
778    EventSpec {
779        name: "voice_inspect",
780        kind: Kind::Reply,
781        stream: Stream::Events,
782        summary: "answers `voice inspect`",
783        fields: &[
784            FieldSpec {
785                name: "path",
786                ty: "string",
787                required: true,
788                summary: "voice pack inspected",
789            },
790            FieldSpec {
791                name: "status",
792                ty: "string",
793                required: true,
794                summary: "inspection outcome",
795            },
796        ],
797    },
798];
799
800/// Look up one event's specification by name.
801pub fn event_spec(name: &str) -> Option<&'static EventSpec> {
802    EVENTS.iter().find(|spec| spec.name == name)
803}
804
805/// A named event, as a constructor for a partially-filled object.
806///
807/// Emitters build an object with the common fields already correct and then insert their own,
808/// so `schema_version` and the `event` discriminator can never be forgotten or mistyped at a
809/// call site. (This ergonomic shape is preserved from a concurrent implementation of this
810/// contract that I destroyed — see the incident note on frankentts-p0-robot-82c.)
811#[derive(Clone, Copy, Debug, Eq, PartialEq)]
812pub enum EventType {
813    RunStart,
814    Stage,
815    Frame,
816    AudioChunk,
817    Health,
818    RunComplete,
819    RunError,
820    HealthViolation,
821    CheckComplete,
822    TextPrepared,
823    Backends,
824    Selftest,
825    VoiceInspect,
826}
827
828impl EventType {
829    pub const fn name(self) -> &'static str {
830        match self {
831            Self::RunStart => "run_start",
832            Self::Stage => "stage",
833            Self::Frame => "frame",
834            Self::AudioChunk => "audio_chunk",
835            Self::Health => "health",
836            Self::RunComplete => "run_complete",
837            Self::RunError => "run_error",
838            Self::HealthViolation => "health_violation",
839            Self::CheckComplete => "check_complete",
840            Self::TextPrepared => "text_prepared",
841            Self::Backends => "backends",
842            Self::Selftest => "selftest",
843            Self::VoiceInspect => "voice_inspect",
844        }
845    }
846
847    /// An object carrying the common fields, ready for this event's own fields.
848    pub fn event(self) -> serde_json::Map<String, Value> {
849        let mut object = serde_json::Map::new();
850        object.insert("schema_version".to_owned(), json!(SCHEMA_VERSION));
851        object.insert("event".to_owned(), json!(self.name()));
852        object
853    }
854
855    /// The catalogue entry for this event.
856    pub fn spec(self) -> &'static EventSpec {
857        event_spec(self.name()).expect("every EventType variant is catalogued")
858    }
859}
860
861/// Run-scoped emitter state: the shared `run_id` and the run's own clock.
862///
863/// Every event in a run repeats its `run_id`, and the lifecycle events carry `elapsed_ms` measured
864/// from the same origin, so an agent can stitch a run together from either stream without guessing.
865/// The id is derived from the process id and a monotonic start stamp: unique per run, cheap, and
866/// carrying nothing about the user's text or filesystem — the CLI is stateless by default and the
867/// run id must not become a back door for run history.
868#[derive(Debug)]
869pub struct RunContext {
870    run_id: String,
871    started: std::time::Instant,
872}
873
874impl RunContext {
875    /// Adopt an explicit id — used by tests so assertions are not clock-dependent.
876    #[must_use]
877    pub fn with_id(run_id: impl Into<String>) -> Self {
878        Self {
879            run_id: run_id.into(),
880            started: std::time::Instant::now(),
881        }
882    }
883
884    /// Derive a fresh id for this process invocation.
885    #[must_use]
886    pub fn generate() -> Self {
887        let nanos = std::time::SystemTime::now()
888            .duration_since(std::time::UNIX_EPOCH)
889            .map_or(0, |since| since.subsec_nanos());
890        // OS-seeded entropy joins pid + subsecond-nanos: two runs on different hosts (or a
891        // pid reuse after reboot) could otherwise mint the same id inside aggregated logs.
892        let entropy = {
893            use std::hash::{BuildHasher as _, Hasher as _};
894            let mut hasher = std::hash::RandomState::new().build_hasher();
895            hasher.write_u32(nanos);
896            hasher.finish() as u32
897        };
898        Self::with_id(format!(
899            "r{:x}{:08x}{:08x}",
900            std::process::id(),
901            nanos,
902            entropy
903        ))
904    }
905
906    /// The id every event in this run repeats.
907    #[must_use]
908    pub fn run_id(&self) -> &str {
909        &self.run_id
910    }
911
912    /// Milliseconds since the run began.
913    #[must_use]
914    pub fn elapsed_ms(&self) -> u64 {
915        u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
916    }
917
918    /// An object carrying the common fields plus this run's id.
919    #[must_use]
920    pub fn event(&self, event_type: EventType) -> serde_json::Map<String, Value> {
921        let mut object = event_type.event();
922        object.insert("run_id".to_owned(), json!(self.run_id));
923        object
924    }
925}
926
927fn matches_type(value: &Value, ty: &str) -> bool {
928    if let Some(inner) = ty.strip_suffix("|null") {
929        return value.is_null() || matches_type(value, inner);
930    }
931    match ty {
932        "string" => value.is_string(),
933        "bool" => value.is_boolean(),
934        "object" => value.is_object(),
935        "array" => value.is_array(),
936        "u8" => value.as_u64().is_some_and(|n| n <= u64::from(u8::MAX)),
937        "u64" => value.as_u64().is_some(),
938        "i64" => value.as_i64().is_some(),
939        _ => false,
940    }
941}
942
943/// Check one emitted object against the catalogue. An empty result means it conforms.
944///
945/// Unknown fields are violations, not warnings: a contract that only checks for absence lets
946/// the surface grow silently and breaks downstream parsers at their leisure.
947pub fn validate_event(value: &Value) -> Vec<String> {
948    let mut problems = Vec::new();
949    let Some(object) = value.as_object() else {
950        problems.push("emitted object is not a JSON object".to_owned());
951        return problems;
952    };
953
954    match object.get("schema_version").and_then(Value::as_u64) {
955        Some(version) if version == u64::from(SCHEMA_VERSION) => {}
956        Some(version) => problems.push(format!(
957            "schema_version {version} != contract version {SCHEMA_VERSION}"
958        )),
959        None => problems.push("missing schema_version".to_owned()),
960    }
961
962    let Some(name) = object.get("event").and_then(Value::as_str) else {
963        problems.push("missing or non-string `event` discriminator".to_owned());
964        return problems;
965    };
966    let Some(spec) = event_spec(name) else {
967        problems.push(format!(
968            "unknown event {name:?}; the catalogue defines: {}",
969            EVENTS
970                .iter()
971                .map(|spec| spec.name)
972                .collect::<Vec<_>>()
973                .join(", ")
974        ));
975        return problems;
976    };
977
978    for field in COMMON_FIELDS.iter().chain(spec.fields.iter()) {
979        match object.get(field.name) {
980            Some(value) => {
981                if !matches_type(value, field.ty) {
982                    problems.push(format!(
983                        "{name}.{}: expected {} but found {value}",
984                        field.name, field.ty
985                    ));
986                }
987            }
988            None if field.required => {
989                problems.push(format!("{name}: missing required field `{}`", field.name));
990            }
991            None => {}
992        }
993    }
994
995    let known: Vec<&str> = COMMON_FIELDS
996        .iter()
997        .chain(spec.fields.iter())
998        .map(|field| field.name)
999        .collect();
1000    for key in object.keys() {
1001        if !known.contains(&key.as_str()) {
1002            problems.push(format!(
1003                "{name}: unknown field `{key}`; extending the contract requires a catalogue \
1004                 entry and a frozen-fixture update"
1005            ));
1006        }
1007    }
1008
1009    problems
1010}
1011
1012/// Validate a whole NDJSON stream, reporting the line number of each violation.
1013pub fn validate_ndjson(stream: &str) -> Vec<String> {
1014    let mut problems = Vec::new();
1015    for (index, line) in stream.lines().enumerate() {
1016        let line_number = index + 1;
1017        if line.trim().is_empty() {
1018            problems.push(format!(
1019                "line {line_number}: blank line in an NDJSON stream"
1020            ));
1021            continue;
1022        }
1023        match serde_json::from_str::<Value>(line) {
1024            Ok(value) => problems.extend(
1025                validate_event(&value)
1026                    .into_iter()
1027                    .map(|problem| format!("line {line_number}: {problem}")),
1028            ),
1029            Err(error) => problems.push(format!("line {line_number}: not valid JSON: {error}")),
1030        }
1031    }
1032    problems
1033}
1034
1035fn exit_codes_json() -> BTreeMap<String, String> {
1036    [
1037        FttsExitCode::Success,
1038        FttsExitCode::Generic,
1039        FttsExitCode::Usage,
1040        FttsExitCode::ModelNotFound,
1041        FttsExitCode::Input,
1042        FttsExitCode::BudgetTimeout,
1043        FttsExitCode::Cancelled,
1044        FttsExitCode::ArtifactFormat,
1045        FttsExitCode::EnrollmentQualityRefusal,
1046    ]
1047    .into_iter()
1048    .map(|code| (code.as_u8().to_string(), code.description().to_owned()))
1049    .collect()
1050}
1051
1052/// The machine-readable self-description emitted by `ftts robot schema`.
1053/// Render an engine health signal as its robot event.
1054///
1055/// The single conversion point between `ftts_core::health` and the wire, so the two crates cannot
1056/// disagree about what a violation is called or whether it invalidates the run. The wire strings
1057/// and the `invalidates_output` predicate come from the engine types rather than being restated
1058/// here — restating them is how a detector and its report drift apart.
1059///
1060/// `detail` is the violation's `Display`, which carries the locating scalars (which seam, which
1061/// index, how many milliseconds), and `remedy` is the action. An agent needs both: the class tells
1062/// it what happened, the remedy tells it what to do, and neither substitutes for the other.
1063#[must_use]
1064pub fn health_violation_event(
1065    run_id: &str,
1066    event: ftts_core::HealthEvent,
1067    elapsed_ms: u64,
1068) -> Value {
1069    let mut object = EventType::HealthViolation.event();
1070    object.insert("run_id".to_owned(), json!(run_id));
1071    object.insert("violation".to_owned(), json!(event.as_str()));
1072    object.insert(
1073        "invalidates_output".to_owned(),
1074        json!(event.invalidates_output()),
1075    );
1076    object.insert("elapsed_ms".to_owned(), json!(elapsed_ms));
1077    let (detail, remedy) = match event {
1078        ftts_core::HealthEvent::Violation(violation) => (violation.to_string(), violation.remedy()),
1079        ftts_core::HealthEvent::BudgetExceeded => (
1080            "a stage exceeded its configured budget".to_owned(),
1081            // The synthesis deadline is startup grace plus one frame budget per frame produced, so
1082            // which knob to reach for depends on where it stopped: no frames means startup, some
1083            // frames means the per-frame rate. Naming only the first sent readers to the wrong one.
1084            "the run stopped making progress fast enough, which is not the same as the request \
1085             being too long — the deadline already grows with each frame produced. If it stopped \
1086             before the first frame, raise FTTS_STAGE_BUDGET_SYNTHESIS_MS (startup grace); if it \
1087             stopped mid-utterance, raise FTTS_STAGE_BUDGET_FRAME_MS (per-frame rate). An \
1088             unoptimized build is already granted 32x both. The partial result is not a completed \
1089             utterance",
1090        ),
1091        ftts_core::HealthEvent::Cancelled => (
1092            "the run observed cooperative cancellation".to_owned(),
1093            "this is the caller's own cancellation taking effect; the audio stops at a frame \
1094             boundary and is truncated, not finished",
1095        ),
1096    };
1097    object.insert("detail".to_owned(), json!(detail));
1098    object.insert("remedy".to_owned(), json!(remedy));
1099    Value::Object(object)
1100}
1101
1102pub fn schema_document(environment_variables: &[&str]) -> Value {
1103    let events: Vec<Value> = EVENTS
1104        .iter()
1105        .map(|spec| {
1106            let fields: Vec<Value> = COMMON_FIELDS
1107                .iter()
1108                .chain(spec.fields.iter())
1109                .map(|field| {
1110                    json!({
1111                        "name": field.name,
1112                        "type": field.ty,
1113                        "required": field.required,
1114                        "summary": field.summary,
1115                    })
1116                })
1117                .collect();
1118            json!({
1119                "name": spec.name,
1120                "kind": spec.kind.as_str(),
1121                "stream": spec.stream.as_str(),
1122                "summary": spec.summary,
1123                "fields": fields,
1124            })
1125        })
1126        .collect();
1127
1128    json!({
1129        "schema_version": SCHEMA_VERSION,
1130        "event": "robot_schema",
1131        "events": events,
1132        "stdout_contract": "one JSON object per line; stdout carries events unless `say --stream raw` gives stdout to PCM",
1133        "raw_stream_contract": "under `say --stream raw`, PCM owns stdout and every event goes to stderr; the two are never interleaved on one stream",
1134        "environment_variables": environment_variables,
1135        "exit_codes": exit_codes_json(),
1136    })
1137}
1138
1139#[cfg(test)]
1140mod tests {
1141    use super::*;
1142
1143    fn minimal(name: &str) -> Value {
1144        let spec = event_spec(name).expect("known event");
1145        let mut object = serde_json::Map::new();
1146        object.insert("schema_version".to_owned(), json!(SCHEMA_VERSION));
1147        object.insert("event".to_owned(), json!(name));
1148        for field in spec.fields.iter().filter(|field| field.required) {
1149            let value = match field.ty.strip_suffix("|null") {
1150                Some(_) => Value::Null,
1151                None => match field.ty {
1152                    "string" => json!("x"),
1153                    "bool" => json!(true),
1154                    "object" => json!({}),
1155                    "array" => json!([]),
1156                    _ => json!(1),
1157                },
1158            };
1159            object.insert(field.name.to_owned(), value);
1160        }
1161        Value::Object(object)
1162    }
1163
1164    #[test]
1165    fn every_catalogued_event_validates_when_minimally_populated() {
1166        for spec in EVENTS {
1167            let problems = validate_event(&minimal(spec.name));
1168            assert!(problems.is_empty(), "{}: {problems:?}", spec.name);
1169        }
1170    }
1171
1172    #[test]
1173    fn a_missing_required_field_is_a_violation() {
1174        let mut event = minimal("run_complete");
1175        event.as_object_mut().expect("object").remove("exit_code");
1176        let problems = validate_event(&event);
1177        assert!(
1178            problems.iter().any(|problem| problem.contains("exit_code")),
1179            "{problems:?}"
1180        );
1181    }
1182
1183    #[test]
1184    fn an_unknown_field_is_a_violation() {
1185        // The direction that matters: a surface may not grow without a catalogue entry.
1186        let mut event = minimal("health");
1187        event
1188            .as_object_mut()
1189            .expect("object")
1190            .insert("undeclared".to_owned(), json!(1));
1191        let problems = validate_event(&event);
1192        assert!(
1193            problems
1194                .iter()
1195                .any(|problem| problem.contains("undeclared")),
1196            "{problems:?}"
1197        );
1198    }
1199
1200    #[test]
1201    fn a_wrong_type_is_a_violation() {
1202        let mut event = minimal("frame");
1203        event
1204            .as_object_mut()
1205            .expect("object")
1206            .insert("index".to_owned(), json!("not a number"));
1207        let problems = validate_event(&event);
1208        assert!(
1209            problems
1210                .iter()
1211                .any(|problem| problem.contains("frame.index")),
1212            "{problems:?}"
1213        );
1214    }
1215
1216    #[test]
1217    fn a_stale_schema_version_is_a_violation() {
1218        let mut event = minimal("run_start");
1219        event
1220            .as_object_mut()
1221            .expect("object")
1222            .insert("schema_version".to_owned(), json!(SCHEMA_VERSION + 1));
1223        assert!(!validate_event(&event).is_empty());
1224    }
1225
1226    #[test]
1227    fn nullable_fields_accept_both_null_and_their_type() {
1228        let mut event = minimal("run_start");
1229        assert!(validate_event(&event).is_empty());
1230        event
1231            .as_object_mut()
1232            .expect("object")
1233            .insert("seed".to_owned(), json!(7));
1234        assert!(validate_event(&event).is_empty());
1235    }
1236
1237    #[test]
1238    fn schema_output_describes_itself_and_conforms() {
1239        let schema = schema_document(&["FTTS_MODEL_DIR"]);
1240        assert!(
1241            validate_event(&schema).is_empty(),
1242            "robot schema must satisfy its own contract"
1243        );
1244        let names: Vec<&str> = schema["events"]
1245            .as_array()
1246            .expect("events array")
1247            .iter()
1248            .map(|event| event["name"].as_str().expect("name"))
1249            .collect();
1250        for required in [
1251            "run_start",
1252            "stage",
1253            "frame",
1254            "audio_chunk",
1255            "health",
1256            "run_complete",
1257            "run_error",
1258        ] {
1259            assert!(names.contains(&required), "catalogue is missing {required}");
1260        }
1261    }
1262
1263    #[test]
1264    fn every_event_type_variant_is_catalogued_and_prefilled() {
1265        // Guards the EventType <-> EVENTS correspondence in both directions: a new catalogue
1266        // entry without a variant is fine, but a variant whose name is not catalogued would
1267        // panic in spec() at runtime, which is exactly when nobody is looking.
1268        for variant in [
1269            EventType::RunStart,
1270            EventType::Stage,
1271            EventType::Frame,
1272            EventType::AudioChunk,
1273            EventType::Health,
1274            EventType::RunComplete,
1275            EventType::RunError,
1276            EventType::CheckComplete,
1277            EventType::TextPrepared,
1278            EventType::Backends,
1279            EventType::Selftest,
1280            EventType::VoiceInspect,
1281        ] {
1282            let spec = variant.spec();
1283            assert_eq!(spec.name, variant.name());
1284            let object = variant.event();
1285            assert_eq!(object["schema_version"], json!(SCHEMA_VERSION));
1286            assert_eq!(object["event"], json!(variant.name()));
1287        }
1288    }
1289
1290    #[test]
1291    fn ndjson_validation_reports_line_numbers() {
1292        let stream = format!(
1293            "{}\n{{\"schema_version\":1,\"event\":\"nope\"}}\n",
1294            serde_json::to_string(&minimal("health")).expect("json")
1295        );
1296        let problems = validate_ndjson(&stream);
1297        assert!(
1298            problems
1299                .iter()
1300                .any(|problem| problem.starts_with("line 2:")),
1301            "{problems:?}"
1302        );
1303    }
1304}