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