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