1use serde::{Deserialize, Serialize};
35
36pub const E2E_LOG_SCHEMA_VERSION: &str = "raptorq-e2e-log-v1";
42
43pub const UNIT_LOG_SCHEMA_VERSION: &str = "raptorq-unit-log-v1";
45
46pub const VALID_PROFILES: &[&str] = &["fast", "full", "forensics"];
48
49pub const VALID_UNIT_OUTCOMES: &[&str] = &[
51 "pending",
52 "ok",
53 "fail",
54 "decode_failure",
55 "symbol_mismatch",
56 "error",
57 "cancelled",
58];
59
60pub const REQUIRED_PHASE_MARKERS: &[&str] = &["encode", "loss", "decode", "proof", "report"];
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct E2eLogEntry {
73 pub schema_version: String,
75 pub scenario: String,
77 pub scenario_id: String,
79 pub replay_id: String,
81 pub profile: String,
83 pub unit_sentinel: String,
85 pub assertion_id: String,
87 pub run_id: String,
89 pub repro_command: String,
91 pub phase_markers: Vec<String>,
93 pub config: LogConfigReport,
95 pub loss: LogLossReport,
97 pub symbols: LogSymbolReport,
99 pub outcome: LogOutcomeReport,
101 pub proof: LogProofReport,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct LogConfigReport {
108 pub symbol_size: u16,
110 pub max_block_size: usize,
112 pub repair_overhead: f64,
114 pub min_overhead: usize,
116 pub seed: u64,
118 pub block_k: usize,
120 pub block_count: usize,
122 pub data_len: usize,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct LogLossReport {
129 pub kind: String,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 pub seed: Option<u64>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 pub drop_per_mille: Option<u16>,
137 pub drop_count: usize,
139 pub keep_count: usize,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub burst_start: Option<usize>,
144 #[serde(skip_serializing_if = "Option::is_none")]
146 pub burst_len: Option<usize>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct LogSymbolCounts {
152 pub total: usize,
154 pub source: usize,
156 pub repair: usize,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct LogSymbolReport {
163 pub generated: LogSymbolCounts,
165 pub received: LogSymbolCounts,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct LogOutcomeReport {
172 pub success: bool,
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub reject_reason: Option<String>,
177 pub decoded_bytes: usize,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct LogProofReport {
184 pub hash: u64,
186 pub summary_bytes: usize,
188 pub outcome: String,
190 pub received_total: usize,
192 pub received_source: usize,
194 pub received_repair: usize,
196 pub peeling_solved: usize,
198 pub inactivated: usize,
200 pub pivots: usize,
202 pub row_ops: usize,
204 pub equations_used: usize,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct UnitLogEntry {
218 pub schema_version: String,
220 pub scenario_id: String,
222 pub seed: u64,
224 pub parameter_set: String,
226 pub replay_ref: String,
228 pub repro_command: String,
230 pub outcome: String,
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub artifact_path: Option<String>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub decode_stats: Option<UnitDecodeStats>,
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct UnitDecodeStats {
243 pub k: usize,
245 pub loss_pct: usize,
247 pub dropped: usize,
249 pub peeled: usize,
251 pub inactivated: usize,
253 pub gauss_ops: usize,
255 pub pivots: usize,
257 pub peel_queue_pushes: usize,
259 pub peel_queue_pops: usize,
261 pub peel_frontier_peak: usize,
263 pub dense_core_rows: usize,
265 pub dense_core_cols: usize,
267 pub dense_core_dropped_rows: usize,
269 pub fallback_reason: String,
271 pub hard_regime_activated: bool,
273 pub hard_regime_branch: String,
275 pub hard_regime_fallbacks: usize,
277 pub conservative_fallback_reason: String,
279}
280
281impl UnitLogEntry {
286 #[must_use]
288 pub fn new(
289 scenario_id: &str,
290 seed: u64,
291 parameter_set: &str,
292 replay_ref: &str,
293 repro_command: &str,
294 outcome: &str,
295 ) -> Self {
296 let scenario_id = scenario_id.trim();
297 assert!(
298 !scenario_id.is_empty(),
299 "UnitLogEntry::new requires a non-empty scenario_id"
300 );
301 let parameter_set = parameter_set.trim();
302 assert!(
303 !parameter_set.is_empty(),
304 "UnitLogEntry::new requires a non-empty parameter_set"
305 );
306 let replay_ref = replay_ref.trim();
307 assert!(
308 !replay_ref.is_empty(),
309 "UnitLogEntry::new requires a non-empty replay_ref"
310 );
311 let repro_command = repro_command.trim();
312 assert!(
313 !repro_command.is_empty(),
314 "UnitLogEntry::new requires a non-empty repro command"
315 );
316 assert!(
317 repro_command.contains("rch exec --"),
318 "UnitLogEntry::new requires an rch-backed repro command"
319 );
320 let outcome = outcome.trim();
321 assert!(
322 !outcome.is_empty(),
323 "UnitLogEntry::new requires a non-empty outcome"
324 );
325 assert!(
326 VALID_UNIT_OUTCOMES.contains(&outcome),
327 "UnitLogEntry::new requires a recognized outcome"
328 );
329 Self {
330 schema_version: UNIT_LOG_SCHEMA_VERSION.to_string(),
331 scenario_id: scenario_id.to_string(),
332 seed,
333 parameter_set: parameter_set.to_string(),
334 replay_ref: replay_ref.to_string(),
335 repro_command: repro_command.to_string(),
336 outcome: outcome.to_string(),
337 artifact_path: None,
338 decode_stats: None,
339 }
340 }
341
342 #[must_use]
344 pub fn with_artifact_path(mut self, path: &str) -> Self {
345 self.artifact_path = Some(path.to_string());
346 self
347 }
348
349 #[must_use]
351 pub fn with_decode_stats(mut self, stats: UnitDecodeStats) -> Self {
352 self.decode_stats = Some(stats);
353 self
354 }
355
356 pub fn to_json(&self) -> Result<String, serde_json::Error> {
358 serde_json::to_string(self)
359 }
360
361 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
363 serde_json::to_string_pretty(self)
364 }
365
366 #[must_use]
371 pub fn to_context_string(&self) -> String {
372 format!(
373 "schema={} scenario_id={} seed={} parameter_set={} replay_ref={} outcome={} repro='{}'",
374 self.schema_version,
375 self.scenario_id,
376 self.seed,
377 self.parameter_set,
378 self.replay_ref,
379 self.outcome,
380 self.repro_command,
381 )
382 }
383}
384
385#[must_use]
393#[allow(clippy::too_many_lines)]
394pub fn validate_e2e_log_json(json: &str) -> Vec<String> {
395 let mut violations = Vec::new();
396
397 let value: serde_json::Value = match serde_json::from_str(json) {
398 Ok(v) => v,
399 Err(e) => {
400 violations.push(format!("invalid JSON: {e}"));
401 return violations;
402 }
403 };
404
405 match value.get("schema_version").and_then(|v| v.as_str()) {
407 Some(v) if v == E2E_LOG_SCHEMA_VERSION => {}
408 Some(v) => violations.push(format!(
409 "schema_version mismatch: expected '{E2E_LOG_SCHEMA_VERSION}', got '{v}'"
410 )),
411 None => violations.push("missing required field: schema_version".to_string()),
412 }
413
414 for field in &[
416 "scenario",
417 "scenario_id",
418 "replay_id",
419 "profile",
420 "unit_sentinel",
421 "assertion_id",
422 "run_id",
423 "repro_command",
424 ] {
425 match value.get(*field).and_then(|v| v.as_str()) {
426 Some("") => {
427 violations.push(format!("required field '{field}' is empty"));
428 }
429 Some(_) => {}
430 None => violations.push(format!("missing required field: {field}")),
431 }
432 }
433
434 if let Some(profile) = value.get("profile").and_then(|v| v.as_str()) {
436 if !VALID_PROFILES.contains(&profile) {
437 violations.push(format!(
438 "invalid profile '{profile}': expected one of {VALID_PROFILES:?}"
439 ));
440 }
441 }
442
443 if let Some(cmd) = value.get("repro_command").and_then(|v| v.as_str()) {
445 if !cmd.contains("rch exec --") {
446 violations
447 .push("repro_command must include 'rch exec --' for remote execution".to_string());
448 }
449 }
450
451 match value.get("phase_markers").and_then(|v| v.as_array()) {
453 Some(markers) => {
454 if markers.len() != REQUIRED_PHASE_MARKERS.len() {
455 violations.push(format!(
456 "phase_markers: expected {} markers, got {}",
457 REQUIRED_PHASE_MARKERS.len(),
458 markers.len()
459 ));
460 }
461 match markers
462 .iter()
463 .map(serde_json::Value::as_str)
464 .collect::<Option<Vec<_>>>()
465 {
466 Some(actual) => {
467 if actual.as_slice() != REQUIRED_PHASE_MARKERS {
468 violations.push(format!(
469 "phase_markers mismatch: expected {REQUIRED_PHASE_MARKERS:?}, got {actual:?}",
470 ));
471 }
472 }
473 None => violations.push("phase_markers must be an array of strings".to_string()),
474 }
475 }
476 None => violations.push("missing required field: phase_markers".to_string()),
477 }
478
479 for section in &["config", "loss", "symbols", "outcome", "proof"] {
481 if !value
482 .get(*section)
483 .is_some_and(serde_json::Value::is_object)
484 {
485 violations.push(format!("missing or non-object required section: {section}"));
486 }
487 }
488
489 if let Some(config) = value.get("config") {
491 validate_required_unsigned_integer_field(config, "symbol_size", "config", &mut violations);
492 validate_required_unsigned_integer_field(config, "seed", "config", &mut violations);
493 validate_required_unsigned_integer_field(config, "block_k", "config", &mut violations);
494 validate_required_unsigned_integer_field(config, "data_len", "config", &mut violations);
495 validate_required_unsigned_integer_field(
496 config,
497 "max_block_size",
498 "config",
499 &mut violations,
500 );
501 validate_required_unsigned_integer_field(config, "min_overhead", "config", &mut violations);
502 validate_required_unsigned_integer_field(config, "block_count", "config", &mut violations);
503 validate_required_number_field(config, "repair_overhead", "config", &mut violations);
504 }
505
506 if let Some(loss) = value.get("loss") {
508 validate_required_string_field(loss, "kind", "loss", &mut violations);
509 validate_required_unsigned_integer_field(loss, "drop_count", "loss", &mut violations);
510 validate_required_unsigned_integer_field(loss, "keep_count", "loss", &mut violations);
511 validate_optional_unsigned_integer_field(loss, "seed", "loss", &mut violations);
512 validate_optional_unsigned_integer_field(loss, "drop_per_mille", "loss", &mut violations);
513 validate_optional_unsigned_integer_field(loss, "burst_start", "loss", &mut violations);
514 validate_optional_unsigned_integer_field(loss, "burst_len", "loss", &mut violations);
515 }
516
517 if let Some(symbols) = value.get("symbols") {
519 for subsection in &["generated", "received"] {
520 match symbols.get(*subsection) {
521 Some(counts) if counts.is_object() => {
522 validate_required_unsigned_integer_field(
523 counts,
524 "total",
525 &format!("symbols.{subsection}"),
526 &mut violations,
527 );
528 validate_required_unsigned_integer_field(
529 counts,
530 "source",
531 &format!("symbols.{subsection}"),
532 &mut violations,
533 );
534 validate_required_unsigned_integer_field(
535 counts,
536 "repair",
537 &format!("symbols.{subsection}"),
538 &mut violations,
539 );
540 }
541 _ => violations.push(format!("symbols.{subsection} is missing or non-object")),
542 }
543 }
544 }
545
546 if let Some(outcome) = value.get("outcome") {
548 validate_required_bool_field(outcome, "success", "outcome", &mut violations);
549 validate_required_unsigned_integer_field(
550 outcome,
551 "decoded_bytes",
552 "outcome",
553 &mut violations,
554 );
555 validate_optional_string_field(outcome, "reject_reason", "outcome", &mut violations);
556 }
557
558 if let Some(proof) = value.get("proof") {
560 validate_required_unsigned_integer_field(proof, "hash", "proof", &mut violations);
561 validate_required_unsigned_integer_field(proof, "summary_bytes", "proof", &mut violations);
562 validate_required_string_field(proof, "outcome", "proof", &mut violations);
563 validate_required_unsigned_integer_field(proof, "received_total", "proof", &mut violations);
564 validate_required_unsigned_integer_field(
565 proof,
566 "received_source",
567 "proof",
568 &mut violations,
569 );
570 validate_required_unsigned_integer_field(
571 proof,
572 "received_repair",
573 "proof",
574 &mut violations,
575 );
576 validate_required_unsigned_integer_field(proof, "peeling_solved", "proof", &mut violations);
577 validate_required_unsigned_integer_field(proof, "inactivated", "proof", &mut violations);
578 validate_required_unsigned_integer_field(proof, "pivots", "proof", &mut violations);
579 validate_required_unsigned_integer_field(proof, "row_ops", "proof", &mut violations);
580 validate_required_unsigned_integer_field(proof, "equations_used", "proof", &mut violations);
581 }
582
583 violations
584}
585
586#[must_use]
590pub fn validate_unit_log_json(json: &str) -> Vec<String> {
591 let mut violations = Vec::new();
592
593 let value: serde_json::Value = match serde_json::from_str(json) {
594 Ok(v) => v,
595 Err(e) => {
596 violations.push(format!("invalid JSON: {e}"));
597 return violations;
598 }
599 };
600
601 match value.get("schema_version").and_then(|v| v.as_str()) {
603 Some(v) if v == UNIT_LOG_SCHEMA_VERSION => {}
604 Some(v) => violations.push(format!(
605 "schema_version mismatch: expected '{UNIT_LOG_SCHEMA_VERSION}', got '{v}'"
606 )),
607 None => violations.push("missing required field: schema_version".to_string()),
608 }
609
610 for field in &["scenario_id", "parameter_set", "replay_ref", "outcome"] {
612 match value.get(*field) {
613 Some(raw) if raw.as_str().is_some_and(|text| text.trim().is_empty()) => {
614 violations.push(format!("required field '{field}' is empty"));
615 }
616 Some(raw) if raw.as_str().is_some() => {}
617 Some(raw) if raw.is_null() => {
618 violations.push(format!("missing required field: {field}"));
619 }
620 Some(_) => violations.push(format!("{field} must be a string")),
621 None => violations.push(format!("missing required field: {field}")),
622 }
623 }
624
625 match value.get("seed") {
627 Some(seed) if seed.as_u64().is_some() => {}
628 Some(seed) if seed.is_null() => violations.push("missing required field: seed".to_string()),
629 Some(_) => violations.push("seed must be an unsigned integer".to_string()),
630 None => violations.push("missing required field: seed".to_string()),
631 }
632
633 match value.get("repro_command") {
635 Some(cmd) if cmd.as_str().is_some_and(|text| text.trim().is_empty()) => {
636 violations.push("required field 'repro_command' is empty".to_string());
637 }
638 Some(cmd)
639 if cmd
640 .as_str()
641 .is_some_and(|text| !text.contains("rch exec --")) =>
642 {
643 violations
644 .push("repro_command must include 'rch exec --' for remote execution".to_string());
645 }
646 Some(cmd) if cmd.as_str().is_some() => {}
647 Some(cmd) if cmd.is_null() => {
648 violations.push("missing required field: repro_command".to_string());
649 }
650 Some(_) => violations.push("repro_command must be a string".to_string()),
651 None => violations.push("missing required field: repro_command".to_string()),
652 }
653
654 if let Some(outcome) = value.get("outcome").and_then(|v| v.as_str()) {
656 if !outcome.trim().is_empty() && !VALID_UNIT_OUTCOMES.contains(&outcome) {
657 violations.push(format!(
658 "unrecognized outcome '{outcome}': expected one of {VALID_UNIT_OUTCOMES:?}"
659 ));
660 }
661 }
662
663 if let Some(decode_stats) = value.get("decode_stats") {
664 if decode_stats.is_object() {
665 for field in &[
666 "k",
667 "loss_pct",
668 "dropped",
669 "peeled",
670 "inactivated",
671 "gauss_ops",
672 "pivots",
673 "peel_queue_pushes",
674 "peel_queue_pops",
675 "peel_frontier_peak",
676 "dense_core_rows",
677 "dense_core_cols",
678 "dense_core_dropped_rows",
679 "hard_regime_fallbacks",
680 ] {
681 validate_decode_stats_unsigned_integer_field(decode_stats, field, &mut violations);
682 }
683 for field in &[
684 "fallback_reason",
685 "hard_regime_branch",
686 "conservative_fallback_reason",
687 ] {
688 validate_decode_stats_string_field(decode_stats, field, &mut violations);
689 }
690 validate_decode_stats_bool_field(
691 decode_stats,
692 "hard_regime_activated",
693 &mut violations,
694 );
695 } else {
696 violations.push("decode_stats must be an object".to_string());
697 }
698 }
699
700 violations
701}
702
703fn value_missing_or_null(parent: &serde_json::Value, field: &str) -> bool {
705 parent.get(field).is_none_or(serde_json::Value::is_null)
706}
707
708fn validate_required_unsigned_integer_field(
709 parent: &serde_json::Value,
710 field: &str,
711 path: &str,
712 violations: &mut Vec<String>,
713) {
714 match parent.get(field) {
715 Some(value) if value.as_u64().is_some() => {}
716 Some(value) if value.is_null() => {
717 violations.push(format!("{path}.{field} is missing or null"));
718 }
719 Some(_) => violations.push(format!("{path}.{field} must be an unsigned integer")),
720 None => violations.push(format!("{path}.{field} is missing or null")),
721 }
722}
723
724fn validate_required_number_field(
725 parent: &serde_json::Value,
726 field: &str,
727 path: &str,
728 violations: &mut Vec<String>,
729) {
730 match parent.get(field) {
731 Some(value) if value.is_number() => {}
732 Some(value) if value.is_null() => {
733 violations.push(format!("{path}.{field} is missing or null"));
734 }
735 Some(_) => violations.push(format!("{path}.{field} must be a number")),
736 None => violations.push(format!("{path}.{field} is missing or null")),
737 }
738}
739
740fn validate_required_string_field(
741 parent: &serde_json::Value,
742 field: &str,
743 path: &str,
744 violations: &mut Vec<String>,
745) {
746 match parent.get(field) {
747 Some(value) if value.as_str().is_some_and(|text| !text.is_empty()) => {}
748 Some(value) if value.is_null() => {
749 violations.push(format!("{path}.{field} is missing or null"));
750 }
751 Some(value) if value.as_str().is_some() => {
752 violations.push(format!("{path}.{field} must be a non-empty string"));
753 }
754 Some(_) => violations.push(format!("{path}.{field} must be a string")),
755 None => violations.push(format!("{path}.{field} is missing or null")),
756 }
757}
758
759fn validate_required_bool_field(
760 parent: &serde_json::Value,
761 field: &str,
762 path: &str,
763 violations: &mut Vec<String>,
764) {
765 match parent.get(field) {
766 Some(value) if value.is_boolean() => {}
767 Some(value) if value.is_null() => {
768 violations.push(format!("{path}.{field} is missing or null"));
769 }
770 Some(_) => violations.push(format!("{path}.{field} must be a boolean")),
771 None => violations.push(format!("{path}.{field} is missing or null")),
772 }
773}
774
775fn validate_optional_unsigned_integer_field(
776 parent: &serde_json::Value,
777 field: &str,
778 path: &str,
779 violations: &mut Vec<String>,
780) {
781 if let Some(value) = parent.get(field) {
782 if !value.is_null() && value.as_u64().is_none() {
783 violations.push(format!("{path}.{field} must be an unsigned integer"));
784 }
785 }
786}
787
788fn validate_optional_string_field(
789 parent: &serde_json::Value,
790 field: &str,
791 path: &str,
792 violations: &mut Vec<String>,
793) {
794 if let Some(value) = parent.get(field) {
795 if !value.is_null() && value.as_str().is_none() {
796 violations.push(format!("{path}.{field} must be a string"));
797 }
798 }
799}
800
801fn validate_decode_stats_unsigned_integer_field(
802 decode_stats: &serde_json::Value,
803 field: &str,
804 violations: &mut Vec<String>,
805) {
806 match decode_stats.get(field) {
807 Some(value) if value.as_u64().is_some() => {}
808 Some(value) if value.is_null() => {
809 violations.push(format!("decode_stats.{field} is missing or null"));
810 }
811 Some(_) => violations.push(format!("decode_stats.{field} must be an unsigned integer")),
812 None => violations.push(format!("decode_stats.{field} is missing or null")),
813 }
814}
815
816fn validate_decode_stats_string_field(
817 decode_stats: &serde_json::Value,
818 field: &str,
819 violations: &mut Vec<String>,
820) {
821 match decode_stats.get(field) {
822 Some(value) if value.as_str().is_some() => {}
823 Some(value) if value.is_null() => {
824 violations.push(format!("decode_stats.{field} is missing or null"));
825 }
826 Some(_) => violations.push(format!("decode_stats.{field} must be a string")),
827 None => violations.push(format!("decode_stats.{field} is missing or null")),
828 }
829}
830
831fn validate_decode_stats_bool_field(
832 decode_stats: &serde_json::Value,
833 field: &str,
834 violations: &mut Vec<String>,
835) {
836 match decode_stats.get(field) {
837 Some(value) if value.is_boolean() => {}
838 Some(value) if value.is_null() => {
839 violations.push(format!("decode_stats.{field} is missing or null"));
840 }
841 Some(_) => violations.push(format!("decode_stats.{field} must be a boolean")),
842 None => violations.push(format!("decode_stats.{field} is missing or null")),
843 }
844}
845
846#[cfg(test)]
851mod tests {
852 use super::*;
853 use serde_json::json;
854
855 fn valid_e2e_log_value() -> serde_json::Value {
856 json!({
857 "schema_version": E2E_LOG_SCHEMA_VERSION,
858 "scenario": "test",
859 "scenario_id": "RQ-E2E-TEST",
860 "replay_id": "replay:test-v1",
861 "profile": "fast",
862 "unit_sentinel": "test::fn",
863 "assertion_id": "E2E-TEST",
864 "run_id": "run-1",
865 "repro_command": "rch exec -- cargo test",
866 "phase_markers": REQUIRED_PHASE_MARKERS,
867 "config": {
868 "symbol_size": 64,
869 "seed": 42,
870 "block_k": 16,
871 "data_len": 1024,
872 "max_block_size": 1024,
873 "repair_overhead": 1.0,
874 "min_overhead": 0,
875 "block_count": 1
876 },
877 "loss": {"kind": "none", "drop_count": 0, "keep_count": 16},
878 "symbols": {
879 "generated": {"total": 16, "source": 16, "repair": 0},
880 "received": {"total": 16, "source": 16, "repair": 0}
881 },
882 "outcome": {"success": true, "decoded_bytes": 1024},
883 "proof": {
884 "hash": 123,
885 "summary_bytes": 100,
886 "outcome": "success",
887 "received_total": 16,
888 "received_source": 16,
889 "received_repair": 0,
890 "peeling_solved": 16,
891 "inactivated": 0,
892 "pivots": 0,
893 "row_ops": 0,
894 "equations_used": 16
895 }
896 })
897 }
898
899 #[test]
900 fn unit_log_entry_roundtrip() {
901 let entry = UnitLogEntry::new(
902 "RQ-U-BUILDER-SEND-TRANSMIT",
903 42,
904 "symbol_size=256,data_len=1024",
905 "replay:rq-u-builder-send-transmit-v1",
906 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::unit_log_entry_roundtrip -- --nocapture",
907 "ok",
908 );
909
910 let json = entry.to_json().expect("serialize");
911 let parsed: UnitLogEntry = serde_json::from_str(&json).expect("deserialize");
912 assert_eq!(parsed.schema_version, UNIT_LOG_SCHEMA_VERSION);
913 assert_eq!(parsed.scenario_id, "RQ-U-BUILDER-SEND-TRANSMIT");
914 assert_eq!(parsed.seed, 42);
915 }
916
917 #[test]
918 fn unit_log_entry_context_string() {
919 let entry = UnitLogEntry::new(
920 "RQ-U-TEST",
921 99,
922 "k=8,symbol_size=32",
923 "replay:rq-u-test-v1",
924 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::unit_log_entry_context_string -- --nocapture",
925 "ok",
926 );
927
928 let ctx = entry.to_context_string();
929 assert!(ctx.contains("scenario_id=RQ-U-TEST"));
930 assert!(ctx.contains("seed=99"));
931 assert!(ctx.contains("replay_ref=replay:rq-u-test-v1"));
932 }
933
934 #[test]
935 fn validate_unit_log_valid() {
936 let entry = UnitLogEntry::new(
937 "RQ-U-ROUNDTRIP",
938 1000,
939 "k=16,symbol_size=32",
940 "replay:rq-u-roundtrip-v1",
941 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::validate_unit_log_valid -- --nocapture",
942 "ok",
943 );
944
945 let json = entry.to_json().expect("serialize");
946 let violations = validate_unit_log_json(&json);
947 assert!(
948 violations.is_empty(),
949 "unexpected violations: {violations:?}"
950 );
951 }
952
953 #[test]
954 fn unit_log_entry_constructor_emits_schema_valid_repro_command() {
955 let entry = UnitLogEntry::new(
956 "RQ-U-CONSTRUCTOR-REPRO",
957 777,
958 "k=8,symbol_size=32",
959 "replay:rq-u-constructor-repro-v1",
960 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::unit_log_entry_constructor_emits_schema_valid_repro_command -- --nocapture",
961 "ok",
962 );
963
964 let json = entry.to_json().expect("serialize");
965 let violations = validate_unit_log_json(&json);
966 assert!(
967 violations.is_empty(),
968 "constructor-built entry should satisfy schema contract: {violations:?}"
969 );
970 assert_eq!(
971 entry.repro_command,
972 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::unit_log_entry_constructor_emits_schema_valid_repro_command -- --nocapture"
973 );
974 }
975
976 #[test]
977 #[should_panic(expected = "UnitLogEntry::new requires a non-empty scenario_id")]
978 fn unit_log_entry_constructor_rejects_empty_scenario_id() {
979 let _ = UnitLogEntry::new(
980 " ",
981 1,
982 "k=8,symbol_size=32",
983 "replay:rq-u-empty-scenario-v1",
984 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::unit_log_entry_constructor_rejects_empty_scenario_id -- --nocapture",
985 "ok",
986 );
987 }
988
989 #[test]
990 #[should_panic(expected = "UnitLogEntry::new requires a non-empty parameter_set")]
991 fn unit_log_entry_constructor_rejects_empty_parameter_set() {
992 let _ = UnitLogEntry::new(
993 "RQ-U-EMPTY-PARAMS",
994 1,
995 "",
996 "replay:rq-u-empty-params-v1",
997 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::unit_log_entry_constructor_rejects_empty_parameter_set -- --nocapture",
998 "ok",
999 );
1000 }
1001
1002 #[test]
1003 #[should_panic(expected = "UnitLogEntry::new requires a non-empty replay_ref")]
1004 fn unit_log_entry_constructor_rejects_empty_replay_ref() {
1005 let _ = UnitLogEntry::new(
1006 "RQ-U-EMPTY-REPLAY",
1007 1,
1008 "k=8,symbol_size=32",
1009 " ",
1010 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::unit_log_entry_constructor_rejects_empty_replay_ref -- --nocapture",
1011 "ok",
1012 );
1013 }
1014
1015 #[test]
1016 #[should_panic(expected = "UnitLogEntry::new requires a non-empty outcome")]
1017 fn unit_log_entry_constructor_rejects_empty_outcome() {
1018 let _ = UnitLogEntry::new(
1019 "RQ-U-EMPTY-OUTCOME",
1020 1,
1021 "k=8,symbol_size=32",
1022 "replay:rq-u-empty-outcome-v1",
1023 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::unit_log_entry_constructor_rejects_empty_outcome -- --nocapture",
1024 " ",
1025 );
1026 }
1027
1028 #[test]
1029 #[should_panic(expected = "UnitLogEntry::new requires a recognized outcome")]
1030 fn unit_log_entry_constructor_rejects_unrecognized_outcome() {
1031 let _ = UnitLogEntry::new(
1032 "RQ-U-BAD-OUTCOME",
1033 1,
1034 "k=8,symbol_size=32",
1035 "replay:rq-u-bad-outcome-v1",
1036 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::unit_log_entry_constructor_rejects_unrecognized_outcome -- --nocapture",
1037 "mystery",
1038 );
1039 }
1040
1041 #[test]
1042 fn validate_unit_log_missing_fields() {
1043 let json = r#"{"schema_version": "raptorq-unit-log-v1", "seed": 42}"#;
1044 let violations = validate_unit_log_json(json);
1045 assert!(
1046 violations.iter().any(|v| v.contains("scenario_id")),
1047 "should flag missing scenario_id"
1048 );
1049 assert!(
1050 violations.iter().any(|v| v.contains("parameter_set")),
1051 "should flag missing parameter_set"
1052 );
1053 }
1054
1055 #[test]
1056 fn validate_unit_log_wrong_schema_version() {
1057 let json = r#"{
1058 "schema_version": "wrong-version",
1059 "scenario_id": "RQ-U-TEST",
1060 "seed": 42,
1061 "parameter_set": "k=8",
1062 "replay_ref": "replay:test-v1",
1063 "repro_command": "rch exec -- cargo test --lib raptorq::tests::unit_log_wrong_schema_version -- --nocapture",
1064 "outcome": "ok"
1065 }"#;
1066 let violations = validate_unit_log_json(json);
1067 assert!(
1068 violations.iter().any(|v| v.contains("schema_version")),
1069 "should flag wrong schema version"
1070 );
1071 }
1072
1073 #[test]
1074 fn validate_unit_log_rejects_whitespace_only_required_fields() {
1075 let json = r#"{
1076 "schema_version": "raptorq-unit-log-v1",
1077 "scenario_id": " ",
1078 "seed": 42,
1079 "parameter_set": "\t",
1080 "replay_ref": " ",
1081 "repro_command": "rch exec -- cargo test --lib raptorq::test_log_schema::tests::validate_unit_log_rejects_whitespace_only_required_fields -- --nocapture",
1082 "outcome": " "
1083 }"#;
1084 let violations = validate_unit_log_json(json);
1085 for field in ["scenario_id", "parameter_set", "replay_ref", "outcome"] {
1086 assert!(
1087 violations.iter().any(|violation| {
1088 violation.contains(&format!("required field '{field}' is empty"))
1089 }),
1090 "should flag whitespace-only {field}: {violations:?}"
1091 );
1092 }
1093 assert!(
1094 !violations
1095 .iter()
1096 .any(|violation| violation.contains("unrecognized outcome")),
1097 "whitespace-only outcome should not also report unrecognized outcome: {violations:?}"
1098 );
1099 }
1100
1101 #[test]
1102 fn validate_unit_log_rejects_whitespace_only_repro_command() {
1103 let json = r#"{
1104 "schema_version": "raptorq-unit-log-v1",
1105 "scenario_id": "RQ-U-WHITESPACE-REPRO",
1106 "seed": 42,
1107 "parameter_set": "k=8",
1108 "replay_ref": "replay:rq-u-whitespace-repro-v1",
1109 "repro_command": " ",
1110 "outcome": "ok"
1111 }"#;
1112 let violations = validate_unit_log_json(json);
1113 assert!(
1114 violations
1115 .iter()
1116 .any(|violation| violation == "required field 'repro_command' is empty"),
1117 "should flag whitespace-only repro command: {violations:?}"
1118 );
1119 }
1120
1121 #[test]
1122 fn validate_unit_log_rejects_non_string_required_fields() {
1123 let json = r#"{
1124 "schema_version": "raptorq-unit-log-v1",
1125 "scenario_id": 7,
1126 "seed": 42,
1127 "parameter_set": ["k=8"],
1128 "replay_ref": false,
1129 "repro_command": {"cmd":"rch exec -- cargo test"},
1130 "outcome": {"value":"ok"}
1131 }"#;
1132 let violations = validate_unit_log_json(json);
1133 for expected in [
1134 "scenario_id must be a string",
1135 "parameter_set must be a string",
1136 "replay_ref must be a string",
1137 "repro_command must be a string",
1138 "outcome must be a string",
1139 ] {
1140 assert!(
1141 violations.iter().any(|violation| violation == expected),
1142 "should flag `{expected}`: {violations:?}"
1143 );
1144 }
1145 }
1146
1147 #[test]
1148 fn validate_unit_log_requires_rch_exec_repro_command() {
1149 let entry = UnitLogEntry {
1150 schema_version: UNIT_LOG_SCHEMA_VERSION.to_string(),
1151 scenario_id: "RQ-U-TEST".to_string(),
1152 seed: 42,
1153 parameter_set: "k=8".to_string(),
1154 replay_ref: "replay:test-v1".to_string(),
1155 repro_command: "cargo test -p asupersync --lib".to_string(),
1156 outcome: "ok".to_string(),
1157 artifact_path: None,
1158 decode_stats: None,
1159 };
1160 let json = entry.to_json().expect("serialize");
1161 let violations = validate_unit_log_json(&json);
1162 assert!(
1163 violations
1164 .iter()
1165 .any(|v| v.contains("repro_command must include 'rch exec --'")),
1166 "should enforce rch-backed repro commands: {violations:?}"
1167 );
1168 }
1169
1170 #[test]
1171 fn validate_unit_log_bad_outcome() {
1172 let entry = UnitLogEntry {
1173 schema_version: UNIT_LOG_SCHEMA_VERSION.to_string(),
1174 scenario_id: "RQ-U-TEST".to_string(),
1175 seed: 42,
1176 parameter_set: "k=8".to_string(),
1177 replay_ref: "replay:test-v1".to_string(),
1178 repro_command:
1179 "rch exec -- cargo test --lib raptorq::tests::validate_unit_log_bad_outcome -- --nocapture"
1180 .to_string(),
1181 outcome: "unknown_outcome".to_string(),
1182 artifact_path: None,
1183 decode_stats: None,
1184 };
1185 let json = entry.to_json().expect("serialize");
1186 let violations = validate_unit_log_json(&json);
1187 assert!(
1188 violations
1189 .iter()
1190 .any(|v| v.contains("unrecognized outcome")),
1191 "should flag unrecognized outcome"
1192 );
1193 }
1194
1195 #[test]
1196 fn validate_unit_log_rejects_non_numeric_seed() {
1197 let mut entry = serde_json::to_value(UnitLogEntry::new(
1198 "RQ-U-SEED-TYPE",
1199 42,
1200 "k=8,symbol_size=32",
1201 "replay:rq-u-seed-type-v1",
1202 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::validate_unit_log_rejects_non_numeric_seed -- --nocapture",
1203 "ok",
1204 ))
1205 .expect("serialize to value");
1206 entry["seed"] = json!("forty-two");
1207
1208 let violations = validate_unit_log_json(&entry.to_string());
1209 assert!(
1210 violations
1211 .iter()
1212 .any(|v| v.contains("seed must be an unsigned integer")),
1213 "should reject non-numeric seed types: {violations:?}"
1214 );
1215 }
1216
1217 #[test]
1218 fn validate_unit_log_rejects_non_object_decode_stats() {
1219 let mut entry = serde_json::to_value(UnitLogEntry::new(
1220 "RQ-U-DECODE-STATS-OBJECT",
1221 42,
1222 "k=8,symbol_size=32",
1223 "replay:rq-u-decode-stats-object-v1",
1224 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::validate_unit_log_rejects_non_object_decode_stats -- --nocapture",
1225 "ok",
1226 ))
1227 .expect("serialize to value");
1228 entry["decode_stats"] = json!(["not", "an", "object"]);
1229
1230 let violations = validate_unit_log_json(&entry.to_string());
1231 assert!(
1232 violations
1233 .iter()
1234 .any(|v| v.contains("decode_stats must be an object")),
1235 "should reject non-object decode_stats payloads: {violations:?}"
1236 );
1237 }
1238
1239 #[test]
1240 fn validate_unit_log_rejects_type_invalid_decode_stats_fields() {
1241 let mut entry = serde_json::to_value(
1242 UnitLogEntry::new(
1243 "RQ-U-DECODE-STATS-TYPES",
1244 42,
1245 "k=8,symbol_size=32",
1246 "replay:rq-u-decode-stats-types-v1",
1247 "rch exec -- cargo test --lib raptorq::test_log_schema::tests::validate_unit_log_rejects_type_invalid_decode_stats_fields -- --nocapture",
1248 "decode_failure",
1249 )
1250 .with_decode_stats(UnitDecodeStats {
1251 k: 8,
1252 loss_pct: 25,
1253 dropped: 2,
1254 peeled: 6,
1255 inactivated: 1,
1256 gauss_ops: 12,
1257 pivots: 4,
1258 peel_queue_pushes: 9,
1259 peel_queue_pops: 9,
1260 peel_frontier_peak: 3,
1261 dense_core_rows: 4,
1262 dense_core_cols: 4,
1263 dense_core_dropped_rows: 1,
1264 fallback_reason: String::new(),
1265 hard_regime_activated: false,
1266 hard_regime_branch: "markowitz".to_string(),
1267 hard_regime_fallbacks: 0,
1268 conservative_fallback_reason: String::new(),
1269 }),
1270 )
1271 .expect("serialize to value");
1272 entry["decode_stats"]["k"] = json!("eight");
1273 entry["decode_stats"]["hard_regime_activated"] = json!("false");
1274 entry["decode_stats"]["fallback_reason"] = json!(7);
1275
1276 let violations = validate_unit_log_json(&entry.to_string());
1277 assert!(
1278 violations
1279 .iter()
1280 .any(|v| v.contains("decode_stats.k must be an unsigned integer")),
1281 "should reject non-numeric decode_stats.k: {violations:?}"
1282 );
1283 assert!(
1284 violations
1285 .iter()
1286 .any(|v| v.contains("decode_stats.hard_regime_activated must be a boolean")),
1287 "should reject non-boolean hard_regime_activated: {violations:?}"
1288 );
1289 assert!(
1290 violations
1291 .iter()
1292 .any(|v| v.contains("decode_stats.fallback_reason must be a string")),
1293 "should reject non-string fallback_reason: {violations:?}"
1294 );
1295 }
1296
1297 #[test]
1298 fn validate_e2e_log_missing_sections() {
1299 let json = r#"{"schema_version": "raptorq-e2e-log-v1", "scenario_id": "TEST"}"#;
1300 let violations = validate_e2e_log_json(json);
1301 assert!(
1303 violations.iter().any(|v| v.contains("config")),
1304 "should flag missing config"
1305 );
1306 assert!(
1307 violations.iter().any(|v| v.contains("proof")),
1308 "should flag missing proof"
1309 );
1310 }
1311
1312 #[test]
1313 fn validate_e2e_log_invalid_profile() {
1314 let json = r#"{
1315 "schema_version": "raptorq-e2e-log-v1",
1316 "scenario": "test",
1317 "scenario_id": "RQ-E2E-TEST",
1318 "replay_id": "replay:test-v1",
1319 "profile": "invalid_profile",
1320 "unit_sentinel": "test::fn",
1321 "assertion_id": "E2E-TEST",
1322 "run_id": "run-1",
1323 "repro_command": "rch exec -- cargo test",
1324 "phase_markers": ["encode", "loss", "decode", "proof", "report"],
1325 "config": {"symbol_size": 64, "seed": 42, "block_k": 16, "data_len": 1024, "max_block_size": 1024, "repair_overhead": 1.0, "min_overhead": 0, "block_count": 1},
1326 "loss": {"kind": "none", "drop_count": 0, "keep_count": 16},
1327 "symbols": {"generated": {"total": 16, "source": 16, "repair": 0}, "received": {"total": 16, "source": 16, "repair": 0}},
1328 "outcome": {"success": true, "decoded_bytes": 1024},
1329 "proof": {"hash": 123, "summary_bytes": 100, "outcome": "success", "received_total": 16, "received_source": 16, "received_repair": 0, "peeling_solved": 16, "inactivated": 0, "pivots": 0, "row_ops": 0, "equations_used": 16}
1330 }"#;
1331 let violations = validate_e2e_log_json(json);
1332 assert!(
1333 violations.iter().any(|v| v.contains("invalid profile")),
1334 "should flag invalid profile: {violations:?}"
1335 );
1336 }
1337
1338 #[test]
1339 fn validate_e2e_log_rejects_out_of_order_phase_markers() {
1340 let mut entry = valid_e2e_log_value();
1341 entry["phase_markers"] = json!(["loss", "encode", "decode", "proof", "report"]);
1342
1343 let violations = validate_e2e_log_json(&entry.to_string());
1344 assert!(
1345 violations
1346 .iter()
1347 .any(|v| v.contains("phase_markers mismatch")),
1348 "should reject out-of-order markers: {violations:?}"
1349 );
1350 }
1351
1352 #[test]
1353 fn validate_e2e_log_rejects_duplicate_phase_markers() {
1354 let mut entry = valid_e2e_log_value();
1355 entry["phase_markers"] = json!(["encode", "loss", "decode", "decode", "report"]);
1356
1357 let violations = validate_e2e_log_json(&entry.to_string());
1358 assert!(
1359 violations
1360 .iter()
1361 .any(|v| v.contains("phase_markers mismatch")),
1362 "should reject duplicate markers: {violations:?}"
1363 );
1364 }
1365
1366 #[test]
1367 fn validate_e2e_log_rejects_unexpected_phase_markers() {
1368 let mut entry = valid_e2e_log_value();
1369 entry["phase_markers"] = json!(["encode", "loss", "decode", "finalize", "report"]);
1370
1371 let violations = validate_e2e_log_json(&entry.to_string());
1372 assert!(
1373 violations
1374 .iter()
1375 .any(|v| v.contains("phase_markers mismatch")),
1376 "should reject unexpected marker names: {violations:?}"
1377 );
1378 }
1379
1380 #[test]
1381 fn validate_e2e_log_rejects_non_string_phase_markers() {
1382 let mut entry = valid_e2e_log_value();
1383 entry["phase_markers"] = json!(["encode", "loss", "decode", 7, "report"]);
1384
1385 let violations = validate_e2e_log_json(&entry.to_string());
1386 assert!(
1387 violations
1388 .iter()
1389 .any(|v| v.contains("phase_markers must be an array of strings")),
1390 "should reject non-string markers: {violations:?}"
1391 );
1392 }
1393
1394 #[test]
1395 fn validate_e2e_log_rejects_type_invalid_nested_fields() {
1396 let mut entry = valid_e2e_log_value();
1397 entry["config"]["seed"] = json!("42");
1398 entry["config"]["repair_overhead"] = json!("1.0");
1399 entry["loss"]["kind"] = json!(7);
1400 entry["symbols"]["generated"]["total"] = json!("16");
1401 entry["outcome"]["success"] = json!("true");
1402 entry["proof"]["hash"] = json!("123");
1403 entry["proof"]["outcome"] = json!(false);
1404
1405 let violations = validate_e2e_log_json(&entry.to_string());
1406 assert!(
1407 violations
1408 .iter()
1409 .any(|v| v.contains("config.seed must be an unsigned integer")),
1410 "should reject non-numeric config.seed: {violations:?}"
1411 );
1412 assert!(
1413 violations
1414 .iter()
1415 .any(|v| v.contains("config.repair_overhead must be a number")),
1416 "should reject non-numeric config.repair_overhead: {violations:?}"
1417 );
1418 assert!(
1419 violations
1420 .iter()
1421 .any(|v| v.contains("loss.kind must be a string")),
1422 "should reject non-string loss.kind: {violations:?}"
1423 );
1424 assert!(
1425 violations
1426 .iter()
1427 .any(|v| v.contains("symbols.generated.total must be an unsigned integer")),
1428 "should reject non-numeric generated total: {violations:?}"
1429 );
1430 assert!(
1431 violations
1432 .iter()
1433 .any(|v| v.contains("outcome.success must be a boolean")),
1434 "should reject non-boolean outcome.success: {violations:?}"
1435 );
1436 assert!(
1437 violations
1438 .iter()
1439 .any(|v| v.contains("proof.hash must be an unsigned integer")),
1440 "should reject non-numeric proof.hash: {violations:?}"
1441 );
1442 assert!(
1443 violations
1444 .iter()
1445 .any(|v| v.contains("proof.outcome must be a string")),
1446 "should reject non-string proof.outcome: {violations:?}"
1447 );
1448 }
1449
1450 #[test]
1451 fn e2e_log_entry_full_roundtrip() {
1452 let entry = E2eLogEntry {
1453 schema_version: E2E_LOG_SCHEMA_VERSION.to_string(),
1454 scenario: "systematic_only".to_string(),
1455 scenario_id: "RQ-E2E-SYSTEMATIC-ONLY".to_string(),
1456 replay_id: "replay:rq-e2e-systematic-only-v1".to_string(),
1457 profile: "fast".to_string(),
1458 unit_sentinel: "raptorq::tests::edge_cases::repair_zero_only_source".to_string(),
1459 assertion_id: "E2E-ROUNDTRIP-SYSTEMATIC".to_string(),
1460 run_id: "replay:rq-e2e-systematic-only-v1-seed42-k16-len1024".to_string(),
1461 repro_command: "rch exec -- cargo test --test raptorq_conformance e2e_pipeline_reports_are_deterministic -- --nocapture".to_string(),
1462 phase_markers: REQUIRED_PHASE_MARKERS.iter().map(|s| (*s).to_string()).collect(),
1463 config: LogConfigReport {
1464 symbol_size: 64,
1465 max_block_size: 1024,
1466 repair_overhead: 1.0,
1467 min_overhead: 0,
1468 seed: 42,
1469 block_k: 16,
1470 block_count: 1,
1471 data_len: 1024,
1472 },
1473 loss: LogLossReport {
1474 kind: "none".to_string(),
1475 seed: None,
1476 drop_per_mille: None,
1477 drop_count: 0,
1478 keep_count: 16,
1479 burst_start: None,
1480 burst_len: None,
1481 },
1482 symbols: LogSymbolReport {
1483 generated: LogSymbolCounts { total: 16, source: 16, repair: 0 },
1484 received: LogSymbolCounts { total: 16, source: 16, repair: 0 },
1485 },
1486 outcome: LogOutcomeReport {
1487 success: true,
1488 reject_reason: None,
1489 decoded_bytes: 1024,
1490 },
1491 proof: LogProofReport {
1492 hash: 12345,
1493 summary_bytes: 200,
1494 outcome: "success".to_string(),
1495 received_total: 16,
1496 received_source: 16,
1497 received_repair: 0,
1498 peeling_solved: 16,
1499 inactivated: 0,
1500 pivots: 0,
1501 row_ops: 0,
1502 equations_used: 16,
1503 },
1504 };
1505
1506 let json = serde_json::to_string(&entry).expect("serialize");
1507 let violations = validate_e2e_log_json(&json);
1508 assert!(
1509 violations.is_empty(),
1510 "full E2E entry should pass validation: {violations:?}"
1511 );
1512
1513 let parsed: E2eLogEntry = serde_json::from_str(&json).expect("deserialize");
1514 assert_eq!(parsed.schema_version, E2E_LOG_SCHEMA_VERSION);
1515 assert_eq!(parsed.scenario_id, "RQ-E2E-SYSTEMATIC-ONLY");
1516 }
1517
1518 #[test]
1519 fn unit_log_with_decode_stats() {
1520 let entry = UnitLogEntry::new(
1521 "RQ-U-SEED-SWEEP",
1522 5042,
1523 "k=16,symbol_size=32",
1524 "replay:rq-u-seed-sweep-structured-v1",
1525 "rch exec -- cargo test --test raptorq_perf_invariants seed_sweep_structured_logging -- --nocapture",
1526 "ok",
1527 )
1528 .with_decode_stats(UnitDecodeStats {
1529 k: 16,
1530 loss_pct: 25,
1531 dropped: 4,
1532 peeled: 10,
1533 inactivated: 2,
1534 gauss_ops: 8,
1535 pivots: 2,
1536 peel_queue_pushes: 12,
1537 peel_queue_pops: 10,
1538 peel_frontier_peak: 4,
1539 dense_core_rows: 5,
1540 dense_core_cols: 3,
1541 dense_core_dropped_rows: 1,
1542 fallback_reason: "peeling_exhausted_to_dense_core".to_string(),
1543 hard_regime_activated: true,
1544 hard_regime_branch: "block_schur_low_rank".to_string(),
1545 hard_regime_fallbacks: 1,
1546 conservative_fallback_reason: "block_schur_failed_to_converge".to_string(),
1547 });
1548
1549 let json = entry.to_json().expect("serialize");
1550 let violations = validate_unit_log_json(&json);
1551 assert!(
1552 violations.is_empty(),
1553 "unit entry with stats should pass: {violations:?}"
1554 );
1555
1556 let parsed: UnitLogEntry = serde_json::from_str(&json).expect("deserialize");
1557 let stats = parsed.decode_stats.expect("should have stats");
1558 assert_eq!(stats.k, 16);
1559 assert_eq!(stats.dropped, 4);
1560 }
1561}