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] = &[
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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
50pub enum Stream {
51 Events,
53 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#[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
82pub struct FieldSpec {
84 pub name: &'static str,
85 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
101pub 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 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
771pub fn event_spec(name: &str) -> Option<&'static EventSpec> {
773 EVENTS.iter().find(|spec| spec.name == name)
774}
775
776#[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 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 pub fn spec(self) -> &'static EventSpec {
828 event_spec(self.name()).expect("every EventType variant is catalogued")
829 }
830}
831
832#[derive(Debug)]
840pub struct RunContext {
841 run_id: String,
842 started: std::time::Instant,
843}
844
845impl RunContext {
846 #[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 #[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 #[must_use]
866 pub fn run_id(&self) -> &str {
867 &self.run_id
868 }
869
870 #[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 #[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
901pub 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
970pub 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#[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 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 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 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}