Skip to main content

asupersync/raptorq/
test_log_schema.rs

1//! Canonical structured test logging schema for RaptorQ test runs.
2//!
3//! Defines versioned, serializable log entry types for both unit tests and E2E
4//! pipeline tests. Every RaptorQ test path emits entries conforming to these
5//! schemas so that failures are forensically diagnosable from a single artifact
6//! bundle.
7//!
8//! # Schema versions
9//!
10//! | Schema | Constant | Purpose |
11//! |--------|----------|---------|
12//! | `raptorq-e2e-log-v1` | [`E2E_LOG_SCHEMA_VERSION`] | Full pipeline E2E reports |
13//! | `raptorq-unit-log-v1` | [`UNIT_LOG_SCHEMA_VERSION`] | Lightweight unit test entries |
14//!
15//! # Required fields (contract)
16//!
17//! Every log entry — unit or E2E — MUST include:
18//! - `schema_version`: exact match to the corresponding constant
19//! - `scenario_id`: canonical scenario identifier (e.g. `RQ-E2E-SYSTEMATIC-ONLY`)
20//! - `seed`: deterministic root seed for reproducibility
21//! - `repro_command`: a shell command that reproduces the exact test case
22//!
23//! E2E entries additionally require: `run_id`, `replay_id`, `profile`,
24//! `phase_markers`, `assertion_id`, `unit_sentinel`, plus nested config/loss/
25//! symbols/outcome/proof sub-objects.
26//!
27//! # Contract validation
28//!
29//! [`validate_e2e_log_json`] and [`validate_unit_log_json`] check that a
30//! serialized JSON entry satisfies the schema contract. They return a list of
31//! violations (empty = pass). Schema contract tests call these validators and
32//! fail the run if any required field is missing or has the wrong type/version.
33
34use serde::{Deserialize, Serialize};
35
36// ============================================================================
37// Schema version constants
38// ============================================================================
39
40/// Schema version for full E2E pipeline log entries.
41pub const E2E_LOG_SCHEMA_VERSION: &str = "raptorq-e2e-log-v1";
42
43/// Schema version for lightweight unit test log entries.
44pub const UNIT_LOG_SCHEMA_VERSION: &str = "raptorq-unit-log-v1";
45
46/// Valid profile markers for E2E test runs.
47pub const VALID_PROFILES: &[&str] = &["fast", "full", "forensics"];
48
49/// Valid outcome markers for unit test log entries.
50pub const VALID_UNIT_OUTCOMES: &[&str] = &[
51    "pending",
52    "ok",
53    "fail",
54    "decode_failure",
55    "symbol_mismatch",
56    "error",
57    "cancelled",
58];
59
60/// Required phase marker set for E2E log entries.
61pub const REQUIRED_PHASE_MARKERS: &[&str] = &["encode", "loss", "decode", "proof", "report"];
62
63// ============================================================================
64// E2E log entry — full pipeline report
65// ============================================================================
66
67/// Full structured log entry for an E2E RaptorQ pipeline test run.
68///
69/// Captures every dimension needed for failure forensics: configuration, loss
70/// pattern, symbol counts, decode outcome, proof statistics, and repro context.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct E2eLogEntry {
73    /// Schema version string — must equal [`E2E_LOG_SCHEMA_VERSION`].
74    pub schema_version: String,
75    /// Human-readable scenario name (e.g. `"systematic_only"`).
76    pub scenario: String,
77    /// Canonical scenario identifier (e.g. `"RQ-E2E-SYSTEMATIC-ONLY"`).
78    pub scenario_id: String,
79    /// Replay catalog reference (e.g. `"replay:rq-e2e-systematic-only-v1"`).
80    pub replay_id: String,
81    /// Profile marker: `"fast"`, `"full"`, or `"forensics"`.
82    pub profile: String,
83    /// Linked unit test sentinel (file::function).
84    pub unit_sentinel: String,
85    /// Assertion identifier for traceability.
86    pub assertion_id: String,
87    /// Deterministic run identifier derived from replay_id + seed + params.
88    pub run_id: String,
89    /// Shell command to reproduce this exact test case.
90    pub repro_command: String,
91    /// Ordered phase markers tracking pipeline stages executed.
92    pub phase_markers: Vec<String>,
93    /// Encoding/decoding configuration.
94    pub config: LogConfigReport,
95    /// Loss pattern applied during the test.
96    pub loss: LogLossReport,
97    /// Symbol generation and reception counts.
98    pub symbols: LogSymbolReport,
99    /// Decode outcome (success/failure with reason).
100    pub outcome: LogOutcomeReport,
101    /// Decode proof statistics and hash.
102    pub proof: LogProofReport,
103}
104
105/// Encoding/decoding configuration captured in a log entry.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct LogConfigReport {
108    /// Symbol size in bytes.
109    pub symbol_size: u16,
110    /// Maximum block size.
111    pub max_block_size: usize,
112    /// Repair overhead ratio.
113    pub repair_overhead: f64,
114    /// Minimum overhead for decoder.
115    pub min_overhead: usize,
116    /// Deterministic seed for this block.
117    pub seed: u64,
118    /// Source symbols per block (K).
119    pub block_k: usize,
120    /// Number of blocks.
121    pub block_count: usize,
122    /// Total data length in bytes.
123    pub data_len: usize,
124}
125
126/// Loss pattern description in a log entry.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct LogLossReport {
129    /// Loss kind: `"none"`, `"random"`, `"burst"`, or `"insufficient"`.
130    pub kind: String,
131    /// Loss-pattern seed (if applicable).
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub seed: Option<u64>,
134    /// Drop rate in per-mille (if applicable).
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub drop_per_mille: Option<u16>,
137    /// Number of symbols dropped.
138    pub drop_count: usize,
139    /// Number of symbols kept.
140    pub keep_count: usize,
141    /// Burst start index (if burst loss).
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub burst_start: Option<usize>,
144    /// Burst length (if burst loss).
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub burst_len: Option<usize>,
147}
148
149/// Symbol generation and reception counts.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct LogSymbolCounts {
152    /// Total symbols.
153    pub total: usize,
154    /// Source symbols.
155    pub source: usize,
156    /// Repair symbols.
157    pub repair: usize,
158}
159
160/// Symbol report with generated and received counts.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct LogSymbolReport {
163    /// Symbols generated by the encoder.
164    pub generated: LogSymbolCounts,
165    /// Symbols received by the decoder (after loss).
166    pub received: LogSymbolCounts,
167}
168
169/// Decode outcome report.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct LogOutcomeReport {
172    /// Whether decoding succeeded.
173    pub success: bool,
174    /// Rejection reason (if decode failed).
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub reject_reason: Option<String>,
177    /// Number of bytes successfully decoded.
178    pub decoded_bytes: usize,
179}
180
181/// Decode proof statistics.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct LogProofReport {
184    /// Content hash of the proof.
185    pub hash: u64,
186    /// Proof summary size in bytes.
187    pub summary_bytes: usize,
188    /// Proof outcome string.
189    pub outcome: String,
190    /// Total received symbols (equations).
191    pub received_total: usize,
192    /// Source symbols received.
193    pub received_source: usize,
194    /// Repair symbols received.
195    pub received_repair: usize,
196    /// Symbols solved by peeling.
197    pub peeling_solved: usize,
198    /// Symbols resolved by inactivation.
199    pub inactivated: usize,
200    /// Pivot selections during elimination.
201    pub pivots: usize,
202    /// Row operations during Gaussian elimination.
203    pub row_ops: usize,
204    /// Total equations used in decoding.
205    pub equations_used: usize,
206}
207
208// ============================================================================
209// Unit test log entry — lightweight
210// ============================================================================
211
212/// Lightweight structured log entry for RaptorQ unit tests.
213///
214/// Contains the minimum fields needed for failure triage and deterministic
215/// replay without the full pipeline context of an E2E entry.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct UnitLogEntry {
218    /// Schema version string — must equal [`UNIT_LOG_SCHEMA_VERSION`].
219    pub schema_version: String,
220    /// Canonical scenario identifier.
221    pub scenario_id: String,
222    /// Deterministic seed.
223    pub seed: u64,
224    /// Encoded parameter set description (e.g. `"symbol_size=256,k=16"`).
225    pub parameter_set: String,
226    /// Replay catalog reference.
227    pub replay_ref: String,
228    /// Shell command to reproduce this test case.
229    pub repro_command: String,
230    /// Test outcome: one of [`VALID_UNIT_OUTCOMES`].
231    pub outcome: String,
232    /// Artifact path for forensic artifacts (if any).
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub artifact_path: Option<String>,
235    /// Decode statistics (if decode was attempted).
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub decode_stats: Option<UnitDecodeStats>,
238}
239
240/// Lightweight decode statistics for unit test log entries.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct UnitDecodeStats {
243    /// Source symbol count (K).
244    pub k: usize,
245    /// Loss percentage applied.
246    pub loss_pct: usize,
247    /// Number of symbols dropped.
248    pub dropped: usize,
249    /// Symbols solved by peeling.
250    pub peeled: usize,
251    /// Symbols resolved by inactivation.
252    pub inactivated: usize,
253    /// Gaussian elimination operations.
254    pub gauss_ops: usize,
255    /// Pivots selected during elimination.
256    pub pivots: usize,
257    /// Number of equation indices pushed into peel queue.
258    pub peel_queue_pushes: usize,
259    /// Number of equation indices popped from peel queue.
260    pub peel_queue_pops: usize,
261    /// Maximum queue depth seen during peel propagation.
262    pub peel_frontier_peak: usize,
263    /// Dense-core row count sent to elimination.
264    pub dense_core_rows: usize,
265    /// Dense-core column count sent to elimination.
266    pub dense_core_cols: usize,
267    /// Zero-information rows dropped before elimination.
268    pub dense_core_dropped_rows: usize,
269    /// Deterministic fallback reason recorded by decode pipeline.
270    pub fallback_reason: String,
271    /// True when hard-regime elimination was activated.
272    pub hard_regime_activated: bool,
273    /// Deterministic hard-regime branch label (`markowitz`/`block_schur_low_rank`).
274    pub hard_regime_branch: String,
275    /// Number of conservative hard-regime fallback transitions.
276    pub hard_regime_fallbacks: usize,
277    /// Deterministic conservative fallback reason for accelerated hard-regime paths.
278    pub conservative_fallback_reason: String,
279}
280
281// ============================================================================
282// Builders
283// ============================================================================
284
285impl UnitLogEntry {
286    /// Create a new unit log entry with required fields.
287    #[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    /// Set the artifact path.
343    #[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    /// Set decode statistics.
350    #[must_use]
351    pub fn with_decode_stats(mut self, stats: UnitDecodeStats) -> Self {
352        self.decode_stats = Some(stats);
353        self
354    }
355
356    /// Serialize to JSON string.
357    pub fn to_json(&self) -> Result<String, serde_json::Error> {
358        serde_json::to_string(self)
359    }
360
361    /// Serialize to pretty-printed JSON string.
362    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
363        serde_json::to_string_pretty(self)
364    }
365
366    /// Format as a single-line context string for panic messages.
367    ///
368    /// Compatible with the legacy `builder_failure_context()` format but
369    /// richer: includes repro command and schema version.
370    #[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// ============================================================================
386// Contract validation
387// ============================================================================
388
389/// Validate a JSON string against the E2E log entry schema contract.
390///
391/// Returns a list of violations. An empty list means the entry is valid.
392#[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    // Schema version
406    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    // Required string fields
415    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    // Profile must be one of the valid values
435    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    // Repro command must include rch exec
444    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    // Phase markers
452    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    // Required sub-objects
480    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    // Config sub-object required fields
490    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    // Loss sub-object required fields
507    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    // Symbols sub-object required fields
518    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    // Outcome sub-object required fields
547    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    // Proof sub-object required fields
559    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/// Validate a JSON string against the unit test log entry schema contract.
587///
588/// Returns a list of violations. An empty list means the entry is valid.
589#[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    // Schema version
602    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    // Required string fields
611    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    // Seed must be present and numeric
626    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    // Repro command must be present and use rch like the E2E contract.
634    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    // Outcome must be a recognized value
655    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
703/// Helper: check if a field is missing or null in a JSON value.
704fn 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// ============================================================================
847// Tests
848// ============================================================================
849
850#[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        // Should flag missing config, loss, symbols, outcome, proof
1302        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}