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