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            // The three fields below are emitted by synthesizing runs only; `--check` and
406            // non-synthesis commands close their runs without them. They were emitted (and
407            // consumed by the human presenter) before they were catalogued — documenting
408            // them here is what makes a strict validator accept a real `say` stream.
409            FieldSpec {
410                name: "samples",
411                ty: "u64",
412                required: false,
413                summary: "PCM samples synthesized (24 kHz mono)",
414            },
415            FieldSpec {
416                name: "duration_ms",
417                ty: "u64",
418                required: false,
419                summary: "duration of the synthesized audio itself",
420            },
421            FieldSpec {
422                name: "prepared_token_count",
423                ty: "u64",
424                required: false,
425                summary: "prompt tokens after normalization and template assembly",
426            },
427        ],
428    },
429    EventSpec {
430        name: "health_violation",
431        kind: Kind::Stream,
432        stream: Stream::Events,
433        summary: "a runtime-health detector fired mid-run (ftts_core::health)",
434        fields: &[
435            FieldSpec {
436                name: "run_id",
437                ty: "string",
438                required: true,
439                summary: "owning run",
440            },
441            FieldSpec {
442                name: "violation",
443                ty: "string",
444                required: true,
445                summary: "stable violation class, e.g. non_finite or output_silent",
446            },
447            FieldSpec {
448                name: "detail",
449                ty: "string",
450                required: true,
451                summary: "the specific occurrence, including the locating scalars",
452            },
453            FieldSpec {
454                name: "remedy",
455                ty: "string",
456                required: true,
457                summary: "the concrete next action, not a restatement of the problem",
458            },
459            FieldSpec {
460                name: "invalidates_output",
461                ty: "bool",
462                required: true,
463                summary: "false for a kernel demotion or thermal report — those runs are still correct",
464            },
465            FieldSpec {
466                name: "elapsed_ms",
467                ty: "u64",
468                required: true,
469                summary: "milliseconds since run_start",
470            },
471        ],
472    },
473    EventSpec {
474        name: "run_error",
475        kind: Kind::Stream,
476        stream: Stream::Stderr,
477        summary: "closes a failed run and carries the exit code the process will return",
478        fields: &[
479            FieldSpec {
480                name: "run_id",
481                ty: "string",
482                required: true,
483                summary: "owning run",
484            },
485            FieldSpec {
486                name: "exit_code",
487                ty: "u8",
488                required: true,
489                summary: "process exit code; matches the stable table in `robot schema`",
490            },
491            FieldSpec {
492                name: "kind",
493                ty: "string",
494                required: true,
495                summary: "stable error class",
496            },
497            FieldSpec {
498                name: "message",
499                ty: "string",
500                required: true,
501                summary: "what went wrong",
502            },
503            FieldSpec {
504                name: "remediation",
505                ty: "string",
506                required: true,
507                summary: "the concrete next command to try",
508            },
509            FieldSpec {
510                name: "elapsed_ms",
511                ty: "u64",
512                required: true,
513                summary: "run duration before the failure",
514            },
515        ],
516    },
517    EventSpec {
518        name: "text_prepared",
519        kind: Kind::Stream,
520        stream: Stream::Events,
521        summary: "text normalization finished; reports SHAPE and PROVENANCE only, never the text",
522        fields: &[
523            FieldSpec {
524                name: "run_id",
525                ty: "string",
526                required: true,
527                summary: "owning run",
528            },
529            FieldSpec {
530                name: "normalize",
531                ty: "string",
532                required: true,
533                summary: "normalization mode in force",
534            },
535            FieldSpec {
536                name: "unicode_version",
537                ty: "string",
538                required: true,
539                summary: "Unicode version this build normalizes against",
540            },
541            FieldSpec {
542                name: "char_count",
543                ty: "u64",
544                required: true,
545                summary: "input length in characters; a size, never the content",
546            },
547            FieldSpec {
548                name: "trace_requested",
549                ty: "bool",
550                required: true,
551                summary: "whether a normalization trace was requested",
552            },
553        ],
554    },
555    EventSpec {
556        name: "check_complete",
557        kind: Kind::Reply,
558        stream: Stream::Events,
559        summary: "answers `say --check`: resolution and admission without synthesis",
560        fields: &[
561            FieldSpec {
562                name: "run_id",
563                ty: "string",
564                required: true,
565                summary: "owning run",
566            },
567            FieldSpec {
568                name: "model",
569                ty: "string",
570                required: true,
571                summary: "resolved model artifact",
572            },
573            FieldSpec {
574                name: "voice",
575                ty: "string|null",
576                required: true,
577                summary: "resolved voice pack",
578            },
579            FieldSpec {
580                name: "profile",
581                ty: "string",
582                required: true,
583                summary: "execution profile",
584            },
585            FieldSpec {
586                name: "packet_frames",
587                ty: "string",
588                required: true,
589                summary: "streaming packet size",
590            },
591            FieldSpec {
592                name: "math_mode",
593                ty: "string",
594                required: true,
595                summary: "strict or fast",
596            },
597            FieldSpec {
598                name: "voice_pack",
599                ty: "string",
600                required: true,
601                summary: "voice-pack privacy profile",
602            },
603            FieldSpec {
604                name: "normalize",
605                ty: "string",
606                required: true,
607                summary: "text-transformation mode",
608            },
609            FieldSpec {
610                name: "normalization_trace_requested",
611                ty: "bool",
612                required: true,
613                summary: "whether an emitted normalization trace was asked for",
614            },
615            FieldSpec {
616                name: "seed",
617                ty: "u64|null",
618                required: true,
619                summary: "sampler seed",
620            },
621            FieldSpec {
622                name: "trace",
623                ty: "string|null",
624                required: true,
625                summary: "opt-in trace path",
626            },
627            FieldSpec {
628                name: "output",
629                ty: "string|null",
630                required: true,
631                summary: "audio sink path",
632            },
633            FieldSpec {
634                name: "admission",
635                ty: "object",
636                required: true,
637                summary: "resource-admission decision",
638            },
639        ],
640    },
641    EventSpec {
642        name: "robot_schema",
643        kind: Kind::Reply,
644        stream: Stream::Events,
645        summary: "answers `robot schema`: this catalogue, machine-readably",
646        fields: &[
647            FieldSpec {
648                name: "events",
649                ty: "array",
650                required: true,
651                summary: "every object type this binary can emit",
652            },
653            FieldSpec {
654                name: "stdout_contract",
655                ty: "string",
656                required: true,
657                summary: "what owns stdout",
658            },
659            FieldSpec {
660                name: "raw_stream_contract",
661                ty: "string",
662                required: true,
663                summary: "stream split under --stream raw",
664            },
665            FieldSpec {
666                name: "environment_variables",
667                ty: "array",
668                required: true,
669                summary: "recognised environment",
670            },
671            FieldSpec {
672                name: "exit_codes",
673                ty: "object",
674                required: true,
675                summary: "stable exit-code table",
676            },
677        ],
678    },
679    EventSpec {
680        name: "backends",
681        kind: Kind::Reply,
682        stream: Stream::Events,
683        summary: "answers `robot backends`: detected ISA tier, kernel plan, pool sizing",
684        fields: &[
685            FieldSpec {
686                name: "available",
687                ty: "array",
688                required: true,
689                summary: "kernel tiers this build can dispatch",
690            },
691            FieldSpec {
692                name: "dispatched",
693                ty: "string|null",
694                required: true,
695                summary: "tier actually selected, null before selection",
696            },
697            FieldSpec {
698                name: "isa_features",
699                ty: "array",
700                required: true,
701                summary: "CPU features detected at runtime",
702            },
703            FieldSpec {
704                name: "kernel_plan",
705                ty: "string|null",
706                required: true,
707                summary: "autotuned plan identity, null when unpacked",
708            },
709            FieldSpec {
710                name: "pool_sizing",
711                ty: "object|null",
712                required: true,
713                summary: "USL-derived worker counts, null when unmeasured",
714            },
715            FieldSpec {
716                name: "force_arch",
717                ty: "string|null",
718                required: true,
719                summary: "FTTS_FORCE_ARCH override",
720            },
721        ],
722    },
723    EventSpec {
724        name: "selftest",
725        kind: Kind::Reply,
726        stream: Stream::Events,
727        summary: "answers `robot selftest`: shipped kernel proofs, including the i32-overflow rows",
728        fields: &[
729            FieldSpec {
730                name: "status",
731                ty: "string",
732                required: true,
733                summary: "passed, failed, or skipped",
734            },
735            FieldSpec {
736                name: "reason",
737                ty: "string|null",
738                required: true,
739                summary: "why it skipped; null when it ran",
740            },
741            FieldSpec {
742                name: "checks",
743                ty: "array",
744                required: true,
745                summary: "per-check outcomes",
746            },
747        ],
748    },
749    EventSpec {
750        name: "voice_inspect",
751        kind: Kind::Reply,
752        stream: Stream::Events,
753        summary: "answers `voice inspect`",
754        fields: &[
755            FieldSpec {
756                name: "path",
757                ty: "string",
758                required: true,
759                summary: "voice pack inspected",
760            },
761            FieldSpec {
762                name: "status",
763                ty: "string",
764                required: true,
765                summary: "inspection outcome",
766            },
767        ],
768    },
769];
770
771/// Look up one event's specification by name.
772pub fn event_spec(name: &str) -> Option<&'static EventSpec> {
773    EVENTS.iter().find(|spec| spec.name == name)
774}
775
776/// A named event, as a constructor for a partially-filled object.
777///
778/// Emitters build an object with the common fields already correct and then insert their own,
779/// so `schema_version` and the `event` discriminator can never be forgotten or mistyped at a
780/// call site. (This ergonomic shape is preserved from a concurrent implementation of this
781/// contract that I destroyed — see the incident note on frankentts-p0-robot-82c.)
782#[derive(Clone, Copy, Debug, Eq, PartialEq)]
783pub enum EventType {
784    RunStart,
785    Stage,
786    Frame,
787    AudioChunk,
788    Health,
789    RunComplete,
790    RunError,
791    HealthViolation,
792    CheckComplete,
793    TextPrepared,
794    Backends,
795    Selftest,
796    VoiceInspect,
797}
798
799impl EventType {
800    pub const fn name(self) -> &'static str {
801        match self {
802            Self::RunStart => "run_start",
803            Self::Stage => "stage",
804            Self::Frame => "frame",
805            Self::AudioChunk => "audio_chunk",
806            Self::Health => "health",
807            Self::RunComplete => "run_complete",
808            Self::RunError => "run_error",
809            Self::HealthViolation => "health_violation",
810            Self::CheckComplete => "check_complete",
811            Self::TextPrepared => "text_prepared",
812            Self::Backends => "backends",
813            Self::Selftest => "selftest",
814            Self::VoiceInspect => "voice_inspect",
815        }
816    }
817
818    /// An object carrying the common fields, ready for this event's own fields.
819    pub fn event(self) -> serde_json::Map<String, Value> {
820        let mut object = serde_json::Map::new();
821        object.insert("schema_version".to_owned(), json!(SCHEMA_VERSION));
822        object.insert("event".to_owned(), json!(self.name()));
823        object
824    }
825
826    /// The catalogue entry for this event.
827    pub fn spec(self) -> &'static EventSpec {
828        event_spec(self.name()).expect("every EventType variant is catalogued")
829    }
830}
831
832/// Run-scoped emitter state: the shared `run_id` and the run's own clock.
833///
834/// Every event in a run repeats its `run_id`, and the lifecycle events carry `elapsed_ms` measured
835/// from the same origin, so an agent can stitch a run together from either stream without guessing.
836/// The id is derived from the process id and a monotonic start stamp: unique per run, cheap, and
837/// carrying nothing about the user's text or filesystem — the CLI is stateless by default and the
838/// run id must not become a back door for run history.
839#[derive(Debug)]
840pub struct RunContext {
841    run_id: String,
842    started: std::time::Instant,
843}
844
845impl RunContext {
846    /// Adopt an explicit id — used by tests so assertions are not clock-dependent.
847    #[must_use]
848    pub fn with_id(run_id: impl Into<String>) -> Self {
849        Self {
850            run_id: run_id.into(),
851            started: std::time::Instant::now(),
852        }
853    }
854
855    /// Derive a fresh id for this process invocation.
856    #[must_use]
857    pub fn generate() -> Self {
858        let nanos = std::time::SystemTime::now()
859            .duration_since(std::time::UNIX_EPOCH)
860            .map_or(0, |since| since.subsec_nanos());
861        Self::with_id(format!("r{:x}{:08x}", std::process::id(), nanos))
862    }
863
864    /// The id every event in this run repeats.
865    #[must_use]
866    pub fn run_id(&self) -> &str {
867        &self.run_id
868    }
869
870    /// Milliseconds since the run began.
871    #[must_use]
872    pub fn elapsed_ms(&self) -> u64 {
873        u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX)
874    }
875
876    /// An object carrying the common fields plus this run's id.
877    #[must_use]
878    pub fn event(&self, event_type: EventType) -> serde_json::Map<String, Value> {
879        let mut object = event_type.event();
880        object.insert("run_id".to_owned(), json!(self.run_id));
881        object
882    }
883}
884
885fn matches_type(value: &Value, ty: &str) -> bool {
886    if let Some(inner) = ty.strip_suffix("|null") {
887        return value.is_null() || matches_type(value, inner);
888    }
889    match ty {
890        "string" => value.is_string(),
891        "bool" => value.is_boolean(),
892        "object" => value.is_object(),
893        "array" => value.is_array(),
894        "u8" => value.as_u64().is_some_and(|n| n <= u64::from(u8::MAX)),
895        "u64" => value.as_u64().is_some(),
896        "i64" => value.as_i64().is_some(),
897        _ => false,
898    }
899}
900
901/// Check one emitted object against the catalogue. An empty result means it conforms.
902///
903/// Unknown fields are violations, not warnings: a contract that only checks for absence lets
904/// the surface grow silently and breaks downstream parsers at their leisure.
905pub fn validate_event(value: &Value) -> Vec<String> {
906    let mut problems = Vec::new();
907    let Some(object) = value.as_object() else {
908        problems.push("emitted object is not a JSON object".to_owned());
909        return problems;
910    };
911
912    match object.get("schema_version").and_then(Value::as_u64) {
913        Some(version) if version == u64::from(SCHEMA_VERSION) => {}
914        Some(version) => problems.push(format!(
915            "schema_version {version} != contract version {SCHEMA_VERSION}"
916        )),
917        None => problems.push("missing schema_version".to_owned()),
918    }
919
920    let Some(name) = object.get("event").and_then(Value::as_str) else {
921        problems.push("missing or non-string `event` discriminator".to_owned());
922        return problems;
923    };
924    let Some(spec) = event_spec(name) else {
925        problems.push(format!(
926            "unknown event {name:?}; the catalogue defines: {}",
927            EVENTS
928                .iter()
929                .map(|spec| spec.name)
930                .collect::<Vec<_>>()
931                .join(", ")
932        ));
933        return problems;
934    };
935
936    for field in COMMON_FIELDS.iter().chain(spec.fields.iter()) {
937        match object.get(field.name) {
938            Some(value) => {
939                if !matches_type(value, field.ty) {
940                    problems.push(format!(
941                        "{name}.{}: expected {} but found {value}",
942                        field.name, field.ty
943                    ));
944                }
945            }
946            None if field.required => {
947                problems.push(format!("{name}: missing required field `{}`", field.name));
948            }
949            None => {}
950        }
951    }
952
953    let known: Vec<&str> = COMMON_FIELDS
954        .iter()
955        .chain(spec.fields.iter())
956        .map(|field| field.name)
957        .collect();
958    for key in object.keys() {
959        if !known.contains(&key.as_str()) {
960            problems.push(format!(
961                "{name}: unknown field `{key}`; extending the contract requires a catalogue \
962                 entry and a frozen-fixture update"
963            ));
964        }
965    }
966
967    problems
968}
969
970/// Validate a whole NDJSON stream, reporting the line number of each violation.
971pub fn validate_ndjson(stream: &str) -> Vec<String> {
972    let mut problems = Vec::new();
973    for (index, line) in stream.lines().enumerate() {
974        let line_number = index + 1;
975        if line.trim().is_empty() {
976            problems.push(format!(
977                "line {line_number}: blank line in an NDJSON stream"
978            ));
979            continue;
980        }
981        match serde_json::from_str::<Value>(line) {
982            Ok(value) => problems.extend(
983                validate_event(&value)
984                    .into_iter()
985                    .map(|problem| format!("line {line_number}: {problem}")),
986            ),
987            Err(error) => problems.push(format!("line {line_number}: not valid JSON: {error}")),
988        }
989    }
990    problems
991}
992
993fn exit_codes_json() -> BTreeMap<String, String> {
994    [
995        FttsExitCode::Success,
996        FttsExitCode::Generic,
997        FttsExitCode::Usage,
998        FttsExitCode::ModelNotFound,
999        FttsExitCode::Input,
1000        FttsExitCode::BudgetTimeout,
1001        FttsExitCode::Cancelled,
1002        FttsExitCode::ArtifactFormat,
1003        FttsExitCode::EnrollmentQualityRefusal,
1004    ]
1005    .into_iter()
1006    .map(|code| (code.as_u8().to_string(), code.description().to_owned()))
1007    .collect()
1008}
1009
1010/// The machine-readable self-description emitted by `ftts robot schema`.
1011/// Render an engine health signal as its robot event.
1012///
1013/// The single conversion point between `ftts_core::health` and the wire, so the two crates cannot
1014/// disagree about what a violation is called or whether it invalidates the run. The wire strings
1015/// and the `invalidates_output` predicate come from the engine types rather than being restated
1016/// here — restating them is how a detector and its report drift apart.
1017///
1018/// `detail` is the violation's `Display`, which carries the locating scalars (which seam, which
1019/// index, how many milliseconds), and `remedy` is the action. An agent needs both: the class tells
1020/// it what happened, the remedy tells it what to do, and neither substitutes for the other.
1021#[must_use]
1022pub fn health_violation_event(
1023    run_id: &str,
1024    event: ftts_core::HealthEvent,
1025    elapsed_ms: u64,
1026) -> Value {
1027    let mut object = EventType::HealthViolation.event();
1028    object.insert("run_id".to_owned(), json!(run_id));
1029    object.insert("violation".to_owned(), json!(event.as_str()));
1030    object.insert(
1031        "invalidates_output".to_owned(),
1032        json!(event.invalidates_output()),
1033    );
1034    object.insert("elapsed_ms".to_owned(), json!(elapsed_ms));
1035    let (detail, remedy) = match event {
1036        ftts_core::HealthEvent::Violation(violation) => (violation.to_string(), violation.remedy()),
1037        ftts_core::HealthEvent::BudgetExceeded => (
1038            "a stage exceeded its configured budget".to_owned(),
1039            // The synthesis deadline is startup grace plus one frame budget per frame produced, so
1040            // which knob to reach for depends on where it stopped: no frames means startup, some
1041            // frames means the per-frame rate. Naming only the first sent readers to the wrong one.
1042            "the run stopped making progress fast enough, which is not the same as the request \
1043             being too long — the deadline already grows with each frame produced. If it stopped \
1044             before the first frame, raise FTTS_STAGE_BUDGET_SYNTHESIS_MS (startup grace); if it \
1045             stopped mid-utterance, raise FTTS_STAGE_BUDGET_FRAME_MS (per-frame rate). An \
1046             unoptimized build is already granted 32x both. The partial result is not a completed \
1047             utterance",
1048        ),
1049        ftts_core::HealthEvent::Cancelled => (
1050            "the run observed cooperative cancellation".to_owned(),
1051            "this is the caller's own cancellation taking effect; the audio stops at a frame \
1052             boundary and is truncated, not finished",
1053        ),
1054    };
1055    object.insert("detail".to_owned(), json!(detail));
1056    object.insert("remedy".to_owned(), json!(remedy));
1057    Value::Object(object)
1058}
1059
1060pub fn schema_document(environment_variables: &[&str]) -> Value {
1061    let events: Vec<Value> = EVENTS
1062        .iter()
1063        .map(|spec| {
1064            let fields: Vec<Value> = COMMON_FIELDS
1065                .iter()
1066                .chain(spec.fields.iter())
1067                .map(|field| {
1068                    json!({
1069                        "name": field.name,
1070                        "type": field.ty,
1071                        "required": field.required,
1072                        "summary": field.summary,
1073                    })
1074                })
1075                .collect();
1076            json!({
1077                "name": spec.name,
1078                "kind": spec.kind.as_str(),
1079                "stream": spec.stream.as_str(),
1080                "summary": spec.summary,
1081                "fields": fields,
1082            })
1083        })
1084        .collect();
1085
1086    json!({
1087        "schema_version": SCHEMA_VERSION,
1088        "event": "robot_schema",
1089        "events": events,
1090        "stdout_contract": "one JSON object per line; stdout carries events unless `say --stream raw` gives stdout to PCM",
1091        "raw_stream_contract": "under `say --stream raw`, PCM owns stdout and every event goes to stderr; the two are never interleaved on one stream",
1092        "environment_variables": environment_variables,
1093        "exit_codes": exit_codes_json(),
1094    })
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099    use super::*;
1100
1101    fn minimal(name: &str) -> Value {
1102        let spec = event_spec(name).expect("known event");
1103        let mut object = serde_json::Map::new();
1104        object.insert("schema_version".to_owned(), json!(SCHEMA_VERSION));
1105        object.insert("event".to_owned(), json!(name));
1106        for field in spec.fields.iter().filter(|field| field.required) {
1107            let value = match field.ty.strip_suffix("|null") {
1108                Some(_) => Value::Null,
1109                None => match field.ty {
1110                    "string" => json!("x"),
1111                    "bool" => json!(true),
1112                    "object" => json!({}),
1113                    "array" => json!([]),
1114                    _ => json!(1),
1115                },
1116            };
1117            object.insert(field.name.to_owned(), value);
1118        }
1119        Value::Object(object)
1120    }
1121
1122    #[test]
1123    fn every_catalogued_event_validates_when_minimally_populated() {
1124        for spec in EVENTS {
1125            let problems = validate_event(&minimal(spec.name));
1126            assert!(problems.is_empty(), "{}: {problems:?}", spec.name);
1127        }
1128    }
1129
1130    #[test]
1131    fn a_missing_required_field_is_a_violation() {
1132        let mut event = minimal("run_complete");
1133        event.as_object_mut().expect("object").remove("exit_code");
1134        let problems = validate_event(&event);
1135        assert!(
1136            problems.iter().any(|problem| problem.contains("exit_code")),
1137            "{problems:?}"
1138        );
1139    }
1140
1141    #[test]
1142    fn an_unknown_field_is_a_violation() {
1143        // The direction that matters: a surface may not grow without a catalogue entry.
1144        let mut event = minimal("health");
1145        event
1146            .as_object_mut()
1147            .expect("object")
1148            .insert("undeclared".to_owned(), json!(1));
1149        let problems = validate_event(&event);
1150        assert!(
1151            problems
1152                .iter()
1153                .any(|problem| problem.contains("undeclared")),
1154            "{problems:?}"
1155        );
1156    }
1157
1158    #[test]
1159    fn a_wrong_type_is_a_violation() {
1160        let mut event = minimal("frame");
1161        event
1162            .as_object_mut()
1163            .expect("object")
1164            .insert("index".to_owned(), json!("not a number"));
1165        let problems = validate_event(&event);
1166        assert!(
1167            problems
1168                .iter()
1169                .any(|problem| problem.contains("frame.index")),
1170            "{problems:?}"
1171        );
1172    }
1173
1174    #[test]
1175    fn a_stale_schema_version_is_a_violation() {
1176        let mut event = minimal("run_start");
1177        event
1178            .as_object_mut()
1179            .expect("object")
1180            .insert("schema_version".to_owned(), json!(SCHEMA_VERSION + 1));
1181        assert!(!validate_event(&event).is_empty());
1182    }
1183
1184    #[test]
1185    fn nullable_fields_accept_both_null_and_their_type() {
1186        let mut event = minimal("run_start");
1187        assert!(validate_event(&event).is_empty());
1188        event
1189            .as_object_mut()
1190            .expect("object")
1191            .insert("seed".to_owned(), json!(7));
1192        assert!(validate_event(&event).is_empty());
1193    }
1194
1195    #[test]
1196    fn schema_output_describes_itself_and_conforms() {
1197        let schema = schema_document(&["FTTS_MODEL_DIR"]);
1198        assert!(
1199            validate_event(&schema).is_empty(),
1200            "robot schema must satisfy its own contract"
1201        );
1202        let names: Vec<&str> = schema["events"]
1203            .as_array()
1204            .expect("events array")
1205            .iter()
1206            .map(|event| event["name"].as_str().expect("name"))
1207            .collect();
1208        for required in [
1209            "run_start",
1210            "stage",
1211            "frame",
1212            "audio_chunk",
1213            "health",
1214            "run_complete",
1215            "run_error",
1216        ] {
1217            assert!(names.contains(&required), "catalogue is missing {required}");
1218        }
1219    }
1220
1221    #[test]
1222    fn every_event_type_variant_is_catalogued_and_prefilled() {
1223        // Guards the EventType <-> EVENTS correspondence in both directions: a new catalogue
1224        // entry without a variant is fine, but a variant whose name is not catalogued would
1225        // panic in spec() at runtime, which is exactly when nobody is looking.
1226        for variant in [
1227            EventType::RunStart,
1228            EventType::Stage,
1229            EventType::Frame,
1230            EventType::AudioChunk,
1231            EventType::Health,
1232            EventType::RunComplete,
1233            EventType::RunError,
1234            EventType::CheckComplete,
1235            EventType::TextPrepared,
1236            EventType::Backends,
1237            EventType::Selftest,
1238            EventType::VoiceInspect,
1239        ] {
1240            let spec = variant.spec();
1241            assert_eq!(spec.name, variant.name());
1242            let object = variant.event();
1243            assert_eq!(object["schema_version"], json!(SCHEMA_VERSION));
1244            assert_eq!(object["event"], json!(variant.name()));
1245        }
1246    }
1247
1248    #[test]
1249    fn ndjson_validation_reports_line_numbers() {
1250        let stream = format!(
1251            "{}\n{{\"schema_version\":1,\"event\":\"nope\"}}\n",
1252            serde_json::to_string(&minimal("health")).expect("json")
1253        );
1254        let problems = validate_ndjson(&stream);
1255        assert!(
1256            problems
1257                .iter()
1258                .any(|problem| problem.starts_with("line 2:")),
1259            "{problems:?}"
1260        );
1261    }
1262}