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