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 ],
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
742pub fn event_spec(name: &str) -> Option<&'static EventSpec> {
744 EVENTS.iter().find(|spec| spec.name == name)
745}
746
747#[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 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 pub fn spec(self) -> &'static EventSpec {
799 event_spec(self.name()).expect("every EventType variant is catalogued")
800 }
801}
802
803#[derive(Debug)]
811pub struct RunContext {
812 run_id: String,
813 started: std::time::Instant,
814}
815
816impl RunContext {
817 #[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 #[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 #[must_use]
837 pub fn run_id(&self) -> &str {
838 &self.run_id
839 }
840
841 #[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 #[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
872pub 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
941pub 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#[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 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 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 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}