Skip to main content

fnx_runtime/
lib.rs

1#![forbid(unsafe_code)]
2
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, BTreeSet};
5use std::time::{Duration, SystemTime, UNIX_EPOCH};
6
7pub const STRUCTURED_TEST_LOG_SCHEMA_VERSION_V1: &str = "1.0.0";
8pub const CGSE_POLICY_SCHEMA_VERSION_V1: &str = "1.0.0";
9pub const CGSE_POLICY_SPEC_PATH: &str = "artifacts/cgse/v1/cgse_deterministic_policy_spec_v1.json";
10pub const CGSE_POLICY_SPEC_SCHEMA_PATH: &str =
11    "artifacts/cgse/schema/v1/cgse_deterministic_policy_spec_schema_v1.json";
12pub const CGSE_LEGACY_TIEBREAK_LEDGER_PATH: &str =
13    "artifacts/cgse/v1/cgse_legacy_tiebreak_ordering_ledger_v1.json";
14pub const CGSE_SEMANTICS_THREAT_MODEL_PATH: &str =
15    "artifacts/cgse/v1/cgse_semantics_threat_model_v1.json";
16
17#[must_use]
18pub fn structured_test_log_schema_version() -> &'static str {
19    STRUCTURED_TEST_LOG_SCHEMA_VERSION_V1
20}
21
22#[must_use]
23pub fn cgse_policy_schema_version() -> &'static str {
24    CGSE_POLICY_SCHEMA_VERSION_V1
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum CompatibilityMode {
30    Strict,
31    Hardened,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "snake_case")]
36pub enum DecisionAction {
37    Allow,
38    FullValidate,
39    FailClosed,
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
43#[serde(untagged)]
44pub enum CgseValue {
45    Bool(bool),
46    Int(i64),
47    Float(f64),
48    String(String),
49    Map(BTreeMap<String, CgseValue>),
50}
51
52impl Eq for CgseValue {}
53
54impl From<String> for CgseValue {
55    fn from(s: String) -> Self {
56        Self::String(s)
57    }
58}
59
60impl From<&str> for CgseValue {
61    fn from(s: &str) -> Self {
62        Self::String(s.to_owned())
63    }
64}
65
66impl From<f64> for CgseValue {
67    fn from(f: f64) -> Self {
68        Self::Float(f)
69    }
70}
71
72impl From<i64> for CgseValue {
73    fn from(i: i64) -> Self {
74        Self::Int(i)
75    }
76}
77
78impl From<bool> for CgseValue {
79    fn from(b: bool) -> Self {
80        Self::Bool(b)
81    }
82}
83
84impl CgseValue {
85    #[must_use]
86    pub fn as_f64(&self) -> Option<f64> {
87        match self {
88            Self::Float(f) => Some(*f),
89            Self::Int(i) => Some(*i as f64),
90            Self::String(s) => s.parse::<f64>().ok(),
91            Self::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
92            Self::Map(_) => None,
93        }
94    }
95
96    #[must_use]
97    pub fn is_strictly_numeric(&self) -> bool {
98        matches!(self, Self::Float(_) | Self::Int(_) | Self::Bool(_))
99    }
100
101    /// True iff the value is an integer variant (NOT bool, NOT float). Mirrors
102    /// Python's ``isinstance(v, numbers.Integral) and not isinstance(v, bool)``,
103    /// used by the shortest-path int/float distance-coercion parity gate.
104    #[must_use]
105    pub fn is_int(&self) -> bool {
106        matches!(self, Self::Int(_))
107    }
108
109    #[must_use]
110    pub fn as_str(&self) -> String {
111        match self {
112            Self::String(s) => s.clone(),
113            Self::Float(f) => f.to_string(),
114            Self::Int(i) => i.to_string(),
115            Self::Bool(b) => b.to_string(),
116            Self::Map(map) => serde_json::to_string(map).unwrap_or_else(|_| "{}".to_owned()),
117        }
118    }
119
120    /// Heuristic type inference for a raw string value.
121    /// Order of preference: Int -> Bool -> Float -> String.
122    #[must_use]
123    pub fn parse_relaxed(s: &str) -> Self {
124        if let Ok(i) = s.parse::<i64>() {
125            return Self::Int(i);
126        }
127        if s.eq_ignore_ascii_case("true") {
128            return Self::Bool(true);
129        }
130        if s.eq_ignore_ascii_case("false") {
131            return Self::Bool(false);
132        }
133        if let Ok(f) = s.parse::<f64>() {
134            return Self::Float(f);
135        }
136        Self::String(s.to_owned())
137    }
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
141#[serde(rename_all = "snake_case")]
142pub enum CgseOperationFamily {
143    GraphCoreMutation,
144    ViewSemantics,
145    DispatchRouting,
146    ConversionContracts,
147    ShortestPathAlgorithms,
148    ReadwriteSerialization,
149    GeneratorSemantics,
150    RuntimeConfig,
151    OracleTestSurface,
152}
153
154impl CgseOperationFamily {
155    #[must_use]
156    pub const fn as_str(self) -> &'static str {
157        match self {
158            Self::GraphCoreMutation => "graph_core_mutation",
159            Self::ViewSemantics => "view_semantics",
160            Self::DispatchRouting => "dispatch_routing",
161            Self::ConversionContracts => "conversion_contracts",
162            Self::ShortestPathAlgorithms => "shortest_path_algorithms",
163            Self::ReadwriteSerialization => "readwrite_serialization",
164            Self::GeneratorSemantics => "generator_semantics",
165            Self::RuntimeConfig => "runtime_config",
166            Self::OracleTestSurface => "oracle_test_surface",
167        }
168    }
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
172pub enum CgsePolicyRule {
173    R01,
174    R02,
175    R03,
176    R04,
177    R05,
178    R06,
179    R07,
180    R08,
181    R09,
182    R10,
183    R11,
184    R12,
185    R13,
186    R14,
187}
188
189impl CgsePolicyRule {
190    pub const ALL: [Self; 14] = [
191        Self::R01,
192        Self::R02,
193        Self::R03,
194        Self::R04,
195        Self::R05,
196        Self::R06,
197        Self::R07,
198        Self::R08,
199        Self::R09,
200        Self::R10,
201        Self::R11,
202        Self::R12,
203        Self::R13,
204        Self::R14,
205    ];
206
207    #[must_use]
208    pub fn from_rule_id(rule_id: &str) -> Option<Self> {
209        Some(match rule_id {
210            "CGSE-R01" => Self::R01,
211            "CGSE-R02" => Self::R02,
212            "CGSE-R03" => Self::R03,
213            "CGSE-R04" => Self::R04,
214            "CGSE-R05" => Self::R05,
215            "CGSE-R06" => Self::R06,
216            "CGSE-R07" => Self::R07,
217            "CGSE-R08" => Self::R08,
218            "CGSE-R09" => Self::R09,
219            "CGSE-R10" => Self::R10,
220            "CGSE-R11" => Self::R11,
221            "CGSE-R12" => Self::R12,
222            "CGSE-R13" => Self::R13,
223            "CGSE-R14" => Self::R14,
224            _ => return None,
225        })
226    }
227
228    #[must_use]
229    pub const fn as_rule_id(self) -> &'static str {
230        match self {
231            Self::R01 => "CGSE-R01",
232            Self::R02 => "CGSE-R02",
233            Self::R03 => "CGSE-R03",
234            Self::R04 => "CGSE-R04",
235            Self::R05 => "CGSE-R05",
236            Self::R06 => "CGSE-R06",
237            Self::R07 => "CGSE-R07",
238            Self::R08 => "CGSE-R08",
239            Self::R09 => "CGSE-R09",
240            Self::R10 => "CGSE-R10",
241            Self::R11 => "CGSE-R11",
242            Self::R12 => "CGSE-R12",
243            Self::R13 => "CGSE-R13",
244            Self::R14 => "CGSE-R14",
245        }
246    }
247
248    #[must_use]
249    pub const fn policy_id(self) -> &'static str {
250        match self {
251            Self::R01 => "CGSE-POL-R01",
252            Self::R02 => "CGSE-POL-R02",
253            Self::R03 => "CGSE-POL-R03",
254            Self::R04 => "CGSE-POL-R04",
255            Self::R05 => "CGSE-POL-R05",
256            Self::R06 => "CGSE-POL-R06",
257            Self::R07 => "CGSE-POL-R07",
258            Self::R08 => "CGSE-POL-R08",
259            Self::R09 => "CGSE-POL-R09",
260            Self::R10 => "CGSE-POL-R10",
261            Self::R11 => "CGSE-POL-R11",
262            Self::R12 => "CGSE-POL-R12",
263            Self::R13 => "CGSE-POL-R13",
264            Self::R14 => "CGSE-POL-R14",
265        }
266    }
267
268    #[must_use]
269    pub const fn operation_family(self) -> CgseOperationFamily {
270        match self {
271            Self::R01 | Self::R02 | Self::R03 => CgseOperationFamily::GraphCoreMutation,
272            Self::R04 => CgseOperationFamily::ViewSemantics,
273            Self::R05 | Self::R06 => CgseOperationFamily::DispatchRouting,
274            Self::R07 => CgseOperationFamily::ConversionContracts,
275            Self::R08 | Self::R09 => CgseOperationFamily::ShortestPathAlgorithms,
276            Self::R10 | Self::R11 => CgseOperationFamily::ReadwriteSerialization,
277            Self::R12 => CgseOperationFamily::GeneratorSemantics,
278            Self::R13 => CgseOperationFamily::RuntimeConfig,
279            Self::R14 => CgseOperationFamily::OracleTestSurface,
280        }
281    }
282
283    #[must_use]
284    pub const fn operation_name(self) -> &'static str {
285        match self {
286            Self::R01 => "graph_core_mutation::cgse-r01",
287            Self::R02 => "graph_core_mutation::cgse-r02",
288            Self::R03 => "graph_core_mutation::cgse-r03",
289            Self::R04 => "view_semantics::cgse-r04",
290            Self::R05 => "dispatch_routing::cgse-r05",
291            Self::R06 => "dispatch_routing::cgse-r06",
292            Self::R07 => "conversion_contracts::cgse-r07",
293            Self::R08 => "shortest_path_algorithms::cgse-r08",
294            Self::R09 => "shortest_path_algorithms::cgse-r09",
295            Self::R10 => "readwrite_serialization::cgse-r10",
296            Self::R11 => "readwrite_serialization::cgse-r11",
297            Self::R12 => "generator_semantics::cgse-r12",
298            Self::R13 => "runtime_config::cgse-r13",
299            Self::R14 => "oracle_test_surface::cgse-r14",
300        }
301    }
302
303    #[must_use]
304    pub const fn hardened_allowlist(self) -> &'static [&'static str] {
305        match self {
306            Self::R01 => &["CGSE-AMB-001"],
307            Self::R02 => &["CGSE-AMB-002"],
308            Self::R03 => &["CGSE-AMB-003"],
309            Self::R04 => &["CGSE-AMB-004"],
310            Self::R05 => &["CGSE-AMB-005"],
311            Self::R06 => &["CGSE-AMB-006"],
312            Self::R07 => &["CGSE-AMB-007"],
313            Self::R08 => &["CGSE-AMB-008"],
314            Self::R09 => &["bounded_diagnostic_enrichment"],
315            Self::R10 => &["CGSE-AMB-009"],
316            Self::R11 => &["bounded_diagnostic_enrichment"],
317            Self::R12 => &["CGSE-AMB-010"],
318            Self::R13 => &["bounded_diagnostic_enrichment"],
319            Self::R14 => &["CGSE-AMB-011"],
320        }
321    }
322
323    #[must_use]
324    pub const fn fail_closed_default(self) -> &'static str {
325        match self {
326            Self::R01 => "fail_closed_on_cgse_r01_drift",
327            Self::R02 => "fail_closed_on_cgse_r02_drift",
328            Self::R03 => "fail_closed_on_cgse_r03_drift",
329            Self::R04 => "fail_closed_on_cgse_r04_drift",
330            Self::R05 => "fail_closed_on_cgse_r05_drift",
331            Self::R06 => "fail_closed_on_cgse_r06_drift",
332            Self::R07 => "fail_closed_on_cgse_r07_drift",
333            Self::R08 => "fail_closed_on_cgse_r08_drift",
334            Self::R09 => "fail_closed_on_cgse_r09_drift",
335            Self::R10 => "fail_closed_on_cgse_r10_drift",
336            Self::R11 => "fail_closed_on_cgse_r11_drift",
337            Self::R12 => "fail_closed_on_cgse_r12_drift",
338            Self::R13 => "fail_closed_on_cgse_r13_drift",
339            Self::R14 => "fail_closed_on_cgse_r14_drift",
340        }
341    }
342
343    #[must_use]
344    pub const fn selected_rationale_probability_0_2(self, action: DecisionAction) -> &'static str {
345        match (self, action) {
346            (Self::R01, DecisionAction::Allow) => {
347                "deterministic policy CGSE-R01 selected Allow at incompatibility_probability=0.2000"
348            }
349            (Self::R01, DecisionAction::FullValidate) => {
350                "deterministic policy CGSE-R01 selected FullValidate at incompatibility_probability=0.2000"
351            }
352            (Self::R01, DecisionAction::FailClosed) => {
353                "deterministic policy CGSE-R01 selected FailClosed at incompatibility_probability=0.2000"
354            }
355            (Self::R02, DecisionAction::Allow) => {
356                "deterministic policy CGSE-R02 selected Allow at incompatibility_probability=0.2000"
357            }
358            (Self::R02, DecisionAction::FullValidate) => {
359                "deterministic policy CGSE-R02 selected FullValidate at incompatibility_probability=0.2000"
360            }
361            (Self::R02, DecisionAction::FailClosed) => {
362                "deterministic policy CGSE-R02 selected FailClosed at incompatibility_probability=0.2000"
363            }
364            (Self::R03, DecisionAction::Allow) => {
365                "deterministic policy CGSE-R03 selected Allow at incompatibility_probability=0.2000"
366            }
367            (Self::R03, DecisionAction::FullValidate) => {
368                "deterministic policy CGSE-R03 selected FullValidate at incompatibility_probability=0.2000"
369            }
370            (Self::R03, DecisionAction::FailClosed) => {
371                "deterministic policy CGSE-R03 selected FailClosed at incompatibility_probability=0.2000"
372            }
373            (Self::R04, DecisionAction::Allow) => {
374                "deterministic policy CGSE-R04 selected Allow at incompatibility_probability=0.2000"
375            }
376            (Self::R04, DecisionAction::FullValidate) => {
377                "deterministic policy CGSE-R04 selected FullValidate at incompatibility_probability=0.2000"
378            }
379            (Self::R04, DecisionAction::FailClosed) => {
380                "deterministic policy CGSE-R04 selected FailClosed at incompatibility_probability=0.2000"
381            }
382            (Self::R05, DecisionAction::Allow) => {
383                "deterministic policy CGSE-R05 selected Allow at incompatibility_probability=0.2000"
384            }
385            (Self::R05, DecisionAction::FullValidate) => {
386                "deterministic policy CGSE-R05 selected FullValidate at incompatibility_probability=0.2000"
387            }
388            (Self::R05, DecisionAction::FailClosed) => {
389                "deterministic policy CGSE-R05 selected FailClosed at incompatibility_probability=0.2000"
390            }
391            (Self::R06, DecisionAction::Allow) => {
392                "deterministic policy CGSE-R06 selected Allow at incompatibility_probability=0.2000"
393            }
394            (Self::R06, DecisionAction::FullValidate) => {
395                "deterministic policy CGSE-R06 selected FullValidate at incompatibility_probability=0.2000"
396            }
397            (Self::R06, DecisionAction::FailClosed) => {
398                "deterministic policy CGSE-R06 selected FailClosed at incompatibility_probability=0.2000"
399            }
400            (Self::R07, DecisionAction::Allow) => {
401                "deterministic policy CGSE-R07 selected Allow at incompatibility_probability=0.2000"
402            }
403            (Self::R07, DecisionAction::FullValidate) => {
404                "deterministic policy CGSE-R07 selected FullValidate at incompatibility_probability=0.2000"
405            }
406            (Self::R07, DecisionAction::FailClosed) => {
407                "deterministic policy CGSE-R07 selected FailClosed at incompatibility_probability=0.2000"
408            }
409            (Self::R08, DecisionAction::Allow) => {
410                "deterministic policy CGSE-R08 selected Allow at incompatibility_probability=0.2000"
411            }
412            (Self::R08, DecisionAction::FullValidate) => {
413                "deterministic policy CGSE-R08 selected FullValidate at incompatibility_probability=0.2000"
414            }
415            (Self::R08, DecisionAction::FailClosed) => {
416                "deterministic policy CGSE-R08 selected FailClosed at incompatibility_probability=0.2000"
417            }
418            (Self::R09, DecisionAction::Allow) => {
419                "deterministic policy CGSE-R09 selected Allow at incompatibility_probability=0.2000"
420            }
421            (Self::R09, DecisionAction::FullValidate) => {
422                "deterministic policy CGSE-R09 selected FullValidate at incompatibility_probability=0.2000"
423            }
424            (Self::R09, DecisionAction::FailClosed) => {
425                "deterministic policy CGSE-R09 selected FailClosed at incompatibility_probability=0.2000"
426            }
427            (Self::R10, DecisionAction::Allow) => {
428                "deterministic policy CGSE-R10 selected Allow at incompatibility_probability=0.2000"
429            }
430            (Self::R10, DecisionAction::FullValidate) => {
431                "deterministic policy CGSE-R10 selected FullValidate at incompatibility_probability=0.2000"
432            }
433            (Self::R10, DecisionAction::FailClosed) => {
434                "deterministic policy CGSE-R10 selected FailClosed at incompatibility_probability=0.2000"
435            }
436            (Self::R11, DecisionAction::Allow) => {
437                "deterministic policy CGSE-R11 selected Allow at incompatibility_probability=0.2000"
438            }
439            (Self::R11, DecisionAction::FullValidate) => {
440                "deterministic policy CGSE-R11 selected FullValidate at incompatibility_probability=0.2000"
441            }
442            (Self::R11, DecisionAction::FailClosed) => {
443                "deterministic policy CGSE-R11 selected FailClosed at incompatibility_probability=0.2000"
444            }
445            (Self::R12, DecisionAction::Allow) => {
446                "deterministic policy CGSE-R12 selected Allow at incompatibility_probability=0.2000"
447            }
448            (Self::R12, DecisionAction::FullValidate) => {
449                "deterministic policy CGSE-R12 selected FullValidate at incompatibility_probability=0.2000"
450            }
451            (Self::R12, DecisionAction::FailClosed) => {
452                "deterministic policy CGSE-R12 selected FailClosed at incompatibility_probability=0.2000"
453            }
454            (Self::R13, DecisionAction::Allow) => {
455                "deterministic policy CGSE-R13 selected Allow at incompatibility_probability=0.2000"
456            }
457            (Self::R13, DecisionAction::FullValidate) => {
458                "deterministic policy CGSE-R13 selected FullValidate at incompatibility_probability=0.2000"
459            }
460            (Self::R13, DecisionAction::FailClosed) => {
461                "deterministic policy CGSE-R13 selected FailClosed at incompatibility_probability=0.2000"
462            }
463            (Self::R14, DecisionAction::Allow) => {
464                "deterministic policy CGSE-R14 selected Allow at incompatibility_probability=0.2000"
465            }
466            (Self::R14, DecisionAction::FullValidate) => {
467                "deterministic policy CGSE-R14 selected FullValidate at incompatibility_probability=0.2000"
468            }
469            (Self::R14, DecisionAction::FailClosed) => {
470                "deterministic policy CGSE-R14 selected FailClosed at incompatibility_probability=0.2000"
471            }
472        }
473    }
474}
475
476fn selected_policy_rationale(
477    rule: CgsePolicyRule,
478    action: DecisionAction,
479    clamped_probability: f64,
480) -> String {
481    if clamped_probability.to_bits() == 0.2_f64.to_bits() {
482        return rule.selected_rationale_probability_0_2(action).to_owned();
483    }
484
485    format!(
486        "deterministic policy {} selected {:?} at incompatibility_probability={clamped_probability:.4}",
487        rule.as_rule_id(),
488        action
489    )
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
493#[serde(rename_all = "snake_case")]
494pub enum TestKind {
495    Unit,
496    Property,
497    Differential,
498    E2e,
499    Fuzz,
500    Perf,
501}
502
503#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
504#[serde(rename_all = "snake_case")]
505pub enum TestStatus {
506    Passed,
507    Failed,
508    Skipped,
509}
510
511#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
512#[serde(rename_all = "snake_case")]
513pub enum E2eStepStatus {
514    Started,
515    Passed,
516    Failed,
517    Skipped,
518}
519
520#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521#[serde(rename_all = "snake_case")]
522pub enum AsupersyncAdapterState {
523    Idle,
524    CapabilityCheck,
525    Syncing,
526    VerifyingChecksum,
527    Completed,
528    FailedClosed,
529}
530
531#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
532#[serde(rename_all = "snake_case")]
533pub enum AsupersyncAdapterEventType {
534    Start,
535    CapabilityAccepted,
536    CapabilityRejected,
537    ChunkCommitted,
538    ResumeApplied,
539    TransportInterrupted,
540    ChecksumVerificationStarted,
541    ChecksumValidated,
542    ChecksumMismatch,
543    ConflictDetected,
544    RetryBudgetExceeded,
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
548#[serde(rename_all = "snake_case")]
549pub enum AsupersyncAdapterReasonCode {
550    UnsupportedCapability,
551    CapabilityMismatch,
552    IntegrityPrecheckFailed,
553    ConflictDetected,
554    RetryExhausted,
555    InvalidTransition,
556    ResumeCursorAhead,
557    ResumeTransferMismatch,
558    ResumeSeedMismatch,
559}
560
561#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
562pub struct AsupersyncTransferIntent {
563    pub transfer_id: String,
564    pub artifact_id: String,
565    pub artifact_class: String,
566    pub mode: CompatibilityMode,
567    pub deterministic_seed: u64,
568    pub expected_checksum: String,
569    pub max_attempts: u8,
570}
571
572impl AsupersyncTransferIntent {
573    pub fn validate(&self) -> Result<(), String> {
574        if self.transfer_id.trim().is_empty() {
575            return Err("transfer_id must be non-empty".to_owned());
576        }
577        if self.artifact_id.trim().is_empty() {
578            return Err("artifact_id must be non-empty".to_owned());
579        }
580        if self.artifact_class.trim().is_empty() {
581            return Err("artifact_class must be non-empty".to_owned());
582        }
583        if self.expected_checksum.trim().is_empty() {
584            return Err("expected_checksum must be non-empty".to_owned());
585        }
586        if self.max_attempts == 0 {
587            return Err("max_attempts must be >= 1".to_owned());
588        }
589        Ok(())
590    }
591}
592
593#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
594pub struct AsupersyncAdapterCheckpoint {
595    pub transfer_id: String,
596    pub deterministic_seed: u64,
597    pub attempt: u8,
598    pub committed_cursor: u64,
599}
600
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
602pub struct AsupersyncAdapterTransition {
603    pub seq: u64,
604    pub from_state: AsupersyncAdapterState,
605    pub event: AsupersyncAdapterEventType,
606    pub to_state: AsupersyncAdapterState,
607    pub attempt: u8,
608    pub committed_cursor: u64,
609    pub reason_code: Option<AsupersyncAdapterReasonCode>,
610}
611
612#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
613pub struct AsupersyncAdapterMachine {
614    intent: AsupersyncTransferIntent,
615    state: AsupersyncAdapterState,
616    attempt: u8,
617    committed_cursor: u64,
618    transitions: Vec<AsupersyncAdapterTransition>,
619}
620
621impl AsupersyncAdapterMachine {
622    pub fn start(intent: AsupersyncTransferIntent) -> Result<Self, String> {
623        intent.validate()?;
624        let transition_capacity = Self::transition_capacity(intent.max_attempts);
625
626        let mut machine = Self {
627            intent,
628            state: AsupersyncAdapterState::Idle,
629            attempt: 0,
630            committed_cursor: 0,
631            transitions: Vec::with_capacity(transition_capacity),
632        };
633        machine.transition(
634            AsupersyncAdapterEventType::Start,
635            AsupersyncAdapterState::CapabilityCheck,
636            None,
637        )?;
638        Ok(machine)
639    }
640
641    pub fn resume_from_checkpoint(
642        intent: AsupersyncTransferIntent,
643        checkpoint: AsupersyncAdapterCheckpoint,
644    ) -> Result<Self, String> {
645        intent.validate()?;
646        let transition_capacity = Self::transition_capacity(intent.max_attempts);
647        if checkpoint.transfer_id != intent.transfer_id {
648            return Err("checkpoint transfer_id does not match intent transfer_id".to_owned());
649        }
650        if checkpoint.deterministic_seed != intent.deterministic_seed {
651            return Err(
652                "checkpoint deterministic_seed does not match intent deterministic_seed".to_owned(),
653            );
654        }
655        if checkpoint.attempt >= intent.max_attempts {
656            return Err("checkpoint attempt exceeds max_attempts".to_owned());
657        }
658
659        let mut machine = Self {
660            intent,
661            state: AsupersyncAdapterState::Syncing,
662            attempt: checkpoint.attempt,
663            committed_cursor: checkpoint.committed_cursor,
664            transitions: Vec::with_capacity(transition_capacity),
665        };
666        machine.append_transition(
667            AsupersyncAdapterState::Syncing,
668            AsupersyncAdapterEventType::ResumeApplied,
669            AsupersyncAdapterState::Syncing,
670            None,
671        );
672        Ok(machine)
673    }
674
675    #[must_use]
676    pub fn intent(&self) -> &AsupersyncTransferIntent {
677        &self.intent
678    }
679
680    #[must_use]
681    pub fn state(&self) -> AsupersyncAdapterState {
682        self.state
683    }
684
685    #[must_use]
686    pub fn attempt(&self) -> u8 {
687        self.attempt
688    }
689
690    #[must_use]
691    pub fn committed_cursor(&self) -> u64 {
692        self.committed_cursor
693    }
694
695    #[must_use]
696    pub fn transitions(&self) -> &[AsupersyncAdapterTransition] {
697        &self.transitions
698    }
699
700    #[must_use]
701    pub fn checkpoint(&self) -> AsupersyncAdapterCheckpoint {
702        AsupersyncAdapterCheckpoint {
703            transfer_id: self.intent.transfer_id.clone(),
704            deterministic_seed: self.intent.deterministic_seed,
705            attempt: self.attempt,
706            committed_cursor: self.committed_cursor,
707        }
708    }
709
710    pub fn mark_capability_check(&mut self, supported: bool) -> Result<(), String> {
711        self.require_active_state(
712            AsupersyncAdapterState::CapabilityCheck,
713            AsupersyncAdapterEventType::CapabilityAccepted,
714        )?;
715        if supported {
716            self.transition(
717                AsupersyncAdapterEventType::CapabilityAccepted,
718                AsupersyncAdapterState::Syncing,
719                None,
720            )
721        } else {
722            self.fail_closed(
723                AsupersyncAdapterEventType::CapabilityRejected,
724                AsupersyncAdapterReasonCode::UnsupportedCapability,
725            )
726        }
727    }
728
729    pub fn record_chunk_commit(&mut self, cursor: u64) -> Result<(), String> {
730        self.require_active_state(
731            AsupersyncAdapterState::Syncing,
732            AsupersyncAdapterEventType::ChunkCommitted,
733        )?;
734        if cursor < self.committed_cursor {
735            return self.fail_closed(
736                AsupersyncAdapterEventType::ConflictDetected,
737                AsupersyncAdapterReasonCode::ConflictDetected,
738            );
739        }
740        if cursor == self.committed_cursor {
741            return Ok(()); // No-op
742        }
743        self.committed_cursor = cursor;
744        self.transition(
745            AsupersyncAdapterEventType::ChunkCommitted,
746            AsupersyncAdapterState::Syncing,
747            None,
748        )
749    }
750
751    pub fn apply_resume_cursor(&mut self, resume_cursor: u64) -> Result<(), String> {
752        self.require_active_state(
753            AsupersyncAdapterState::Syncing,
754            AsupersyncAdapterEventType::ResumeApplied,
755        )?;
756        if resume_cursor == self.committed_cursor {
757            return Ok(()); // No-op
758        }
759        if resume_cursor > self.committed_cursor {
760            return self.fail_closed(
761                AsupersyncAdapterEventType::ConflictDetected,
762                AsupersyncAdapterReasonCode::ResumeCursorAhead,
763            );
764        }
765        self.committed_cursor = resume_cursor;
766        self.transition(
767            AsupersyncAdapterEventType::ResumeApplied,
768            AsupersyncAdapterState::Syncing,
769            None,
770        )
771    }
772
773    pub fn record_transport_interruption(&mut self) -> Result<(), String> {
774        self.require_active_state(
775            AsupersyncAdapterState::Syncing,
776            AsupersyncAdapterEventType::TransportInterrupted,
777        )?;
778
779        let Some(next_attempt) = self.attempt.checked_add(1) else {
780            return self.fail_closed(
781                AsupersyncAdapterEventType::RetryBudgetExceeded,
782                AsupersyncAdapterReasonCode::RetryExhausted,
783            );
784        };
785        if next_attempt > self.intent.max_attempts {
786            return self.fail_closed(
787                AsupersyncAdapterEventType::RetryBudgetExceeded,
788                AsupersyncAdapterReasonCode::RetryExhausted,
789            );
790        }
791        self.attempt = next_attempt;
792        self.transition(
793            AsupersyncAdapterEventType::TransportInterrupted,
794            AsupersyncAdapterState::Syncing,
795            None,
796        )
797    }
798
799    pub fn start_checksum_verification(&mut self) -> Result<(), String> {
800        self.require_active_state(
801            AsupersyncAdapterState::Syncing,
802            AsupersyncAdapterEventType::ChecksumVerificationStarted,
803        )?;
804        self.transition(
805            AsupersyncAdapterEventType::ChecksumVerificationStarted,
806            AsupersyncAdapterState::VerifyingChecksum,
807            None,
808        )
809    }
810
811    pub fn finish_checksum_verification(&mut self, observed_checksum: &str) -> Result<(), String> {
812        self.require_active_state(
813            AsupersyncAdapterState::VerifyingChecksum,
814            AsupersyncAdapterEventType::ChecksumValidated,
815        )?;
816        if observed_checksum.trim().is_empty() {
817            return self.fail_closed(
818                AsupersyncAdapterEventType::ChecksumMismatch,
819                AsupersyncAdapterReasonCode::IntegrityPrecheckFailed,
820            );
821        }
822        if observed_checksum == self.intent.expected_checksum {
823            self.transition(
824                AsupersyncAdapterEventType::ChecksumValidated,
825                AsupersyncAdapterState::Completed,
826                None,
827            )
828        } else {
829            self.fail_closed(
830                AsupersyncAdapterEventType::ChecksumMismatch,
831                AsupersyncAdapterReasonCode::IntegrityPrecheckFailed,
832            )
833        }
834    }
835
836    pub fn record_conflict(
837        &mut self,
838        expected_epoch: u64,
839        observed_epoch: u64,
840    ) -> Result<(), String> {
841        self.require_active_state(
842            AsupersyncAdapterState::Syncing,
843            AsupersyncAdapterEventType::ConflictDetected,
844        )?;
845        if expected_epoch == observed_epoch {
846            return Ok(());
847        }
848        self.fail_closed(
849            AsupersyncAdapterEventType::ConflictDetected,
850            AsupersyncAdapterReasonCode::ConflictDetected,
851        )
852    }
853
854    pub fn validate_transition_log(&self) -> Result<(), String> {
855        if self.transitions.is_empty() {
856            return Err("transition log must contain at least one transition".to_owned());
857        }
858        let mut expected_seq = 1_u64;
859        let mut reached_terminal = false;
860        for transition in &self.transitions {
861            if transition.seq != expected_seq {
862                return Err("transition sequence must be contiguous and start at 1".to_owned());
863            }
864            expected_seq = expected_seq.saturating_add(1);
865
866            if reached_terminal {
867                return Err("transition log contains events after terminal state".to_owned());
868            }
869            if matches!(
870                transition.to_state,
871                AsupersyncAdapterState::Completed | AsupersyncAdapterState::FailedClosed
872            ) {
873                reached_terminal = true;
874            }
875        }
876        Ok(())
877    }
878
879    fn require_active_state(
880        &mut self,
881        expected_state: AsupersyncAdapterState,
882        event: AsupersyncAdapterEventType,
883    ) -> Result<(), String> {
884        if self.state == expected_state {
885            return Ok(());
886        }
887        self.fail_closed(event, AsupersyncAdapterReasonCode::InvalidTransition)
888    }
889
890    fn fail_closed(
891        &mut self,
892        event: AsupersyncAdapterEventType,
893        reason_code: AsupersyncAdapterReasonCode,
894    ) -> Result<(), String> {
895        if matches!(
896            self.state,
897            AsupersyncAdapterState::Completed | AsupersyncAdapterState::FailedClosed
898        ) {
899            return Err("state machine already in terminal state".to_owned());
900        }
901        self.transition(
902            event,
903            AsupersyncAdapterState::FailedClosed,
904            Some(reason_code),
905        )?;
906        Err(format!(
907            "fail-closed transition with reason `{reason_code:?}`"
908        ))
909    }
910
911    fn transition(
912        &mut self,
913        event: AsupersyncAdapterEventType,
914        to_state: AsupersyncAdapterState,
915        reason_code: Option<AsupersyncAdapterReasonCode>,
916    ) -> Result<(), String> {
917        if matches!(
918            self.state,
919            AsupersyncAdapterState::Completed | AsupersyncAdapterState::FailedClosed
920        ) {
921            return Err("state machine already in terminal state".to_owned());
922        }
923        let from_state = self.state;
924        self.state = to_state;
925        self.append_transition(from_state, event, to_state, reason_code);
926        Ok(())
927    }
928
929    fn append_transition(
930        &mut self,
931        from_state: AsupersyncAdapterState,
932        event: AsupersyncAdapterEventType,
933        to_state: AsupersyncAdapterState,
934        reason_code: Option<AsupersyncAdapterReasonCode>,
935    ) {
936        let base = u64::try_from(self.transitions.len()).unwrap_or(u64::MAX);
937        let seq = base.saturating_add(1);
938        self.transitions.push(AsupersyncAdapterTransition {
939            seq,
940            from_state,
941            event,
942            to_state,
943            attempt: self.attempt,
944            committed_cursor: self.committed_cursor,
945            reason_code,
946        });
947    }
948
949    fn transition_capacity(max_attempts: u8) -> usize {
950        usize::from(max_attempts).saturating_add(6)
951    }
952}
953
954const FTUI_TELEMETRY_CANONICAL_FIELDS: [&str; 10] = [
955    "run_id",
956    "journey_id",
957    "event_id",
958    "state",
959    "seed",
960    "artifact_ref",
961    "test_id",
962    "replay_command",
963    "env_fingerprint",
964    "duration_ms",
965];
966
967#[must_use]
968pub fn ftui_telemetry_canonical_fields() -> &'static [&'static str] {
969    &FTUI_TELEMETRY_CANONICAL_FIELDS
970}
971
972#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
973pub struct FtuiTelemetryRecord {
974    pub correlation_id: String,
975    pub run_id: String,
976    pub journey_id: String,
977    pub event_id: String,
978    pub state: String,
979    pub seed: String,
980    pub test_id: String,
981    pub replay_command: String,
982    pub env_fingerprint: String,
983    pub duration_ms: String,
984    pub status: String,
985    pub mode: String,
986    pub artifact_ref: String,
987    pub reason_code: Option<String>,
988    pub ts_unix_ms: u128,
989}
990
991#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
992pub struct FtuiArtifactIndexEntry {
993    pub correlation_id: String,
994    pub bundle_id: String,
995    pub run_id: String,
996    pub test_id: String,
997    pub captured_unix_ms: u128,
998    pub replay_ref: String,
999    pub artifact_refs: Vec<String>,
1000    pub status: TestStatus,
1001    pub reason_code: Option<String>,
1002}
1003
1004#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1005pub struct FtuiArtifactIndex {
1006    pub entries: Vec<FtuiArtifactIndexEntry>,
1007}
1008
1009#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1010pub struct FtuiTelemetryAdapter {
1011    required_fields: BTreeSet<String>,
1012}
1013
1014impl FtuiTelemetryAdapter {
1015    #[must_use]
1016    pub fn strict_default() -> Self {
1017        let required_fields = FTUI_TELEMETRY_CANONICAL_FIELDS
1018            .iter()
1019            .map(|field| (*field).to_owned())
1020            .collect();
1021        Self { required_fields }
1022    }
1023
1024    #[must_use]
1025    pub fn required_fields(&self) -> &BTreeSet<String> {
1026        &self.required_fields
1027    }
1028
1029    pub fn ingest_row(&self, row: &BTreeMap<String, String>) -> Result<(), String> {
1030        if row.is_empty() {
1031            return Err("telemetry row must be non-empty".to_owned());
1032        }
1033
1034        for key in row.keys() {
1035            if !self.required_fields.contains(key) {
1036                return Err(format!(
1037                    "unknown telemetry field `{key}`; allowed fields: {}",
1038                    self.required_fields
1039                        .iter()
1040                        .cloned()
1041                        .collect::<Vec<_>>()
1042                        .join(", ")
1043                ));
1044            }
1045        }
1046        for field in &self.required_fields {
1047            if row.get(field).is_none_or(|value| value.trim().is_empty()) {
1048                return Err(format!("missing required telemetry field `{field}`"));
1049            }
1050        }
1051
1052        Ok(())
1053    }
1054
1055    pub fn from_structured_log(
1056        &self,
1057        log: &StructuredTestLog,
1058    ) -> Result<FtuiTelemetryRecord, String> {
1059        log.validate()?;
1060
1061        let event_id = match log.status {
1062            TestStatus::Passed => "ftui.workflow.completed",
1063            TestStatus::Failed => "ftui.workflow.failed_closed",
1064            TestStatus::Skipped => "ftui.workflow.skipped",
1065        };
1066        let state = match log.status {
1067            TestStatus::Passed => "completed",
1068            TestStatus::Failed => "failed_closed",
1069            TestStatus::Skipped => "skipped",
1070        };
1071        let journey_id = format!("{}::{}", log.packet_id, log.suite_id);
1072        let artifact_ref = log.artifact_refs.join(";");
1073        let seed = log
1074            .seed
1075            .map_or_else(|| "none".to_owned(), |value| value.to_string());
1076        let duration_ms = log.duration_ms.to_string();
1077
1078        let mut row = BTreeMap::new();
1079        row.insert("run_id".to_owned(), log.run_id.clone());
1080        row.insert("journey_id".to_owned(), journey_id.clone());
1081        row.insert("event_id".to_owned(), event_id.to_owned());
1082        row.insert("state".to_owned(), state.to_owned());
1083        row.insert("seed".to_owned(), seed.clone());
1084        row.insert("artifact_ref".to_owned(), artifact_ref.clone());
1085        row.insert("test_id".to_owned(), log.test_id.clone());
1086        row.insert("replay_command".to_owned(), log.replay_command.clone());
1087        row.insert("env_fingerprint".to_owned(), log.env_fingerprint.clone());
1088        row.insert("duration_ms".to_owned(), duration_ms.clone());
1089        self.ingest_row(&row)?;
1090
1091        let mode = enum_token("mode", &log.mode)?;
1092        let status = enum_token("status", &log.status)?;
1093        let correlation_material = format!(
1094            "{}|{}|{}|{}|{}|{}",
1095            log.run_id,
1096            log.test_id,
1097            log.forensic_bundle_id,
1098            log.hash_id,
1099            log.env_fingerprint,
1100            log.duration_ms
1101        );
1102        let correlation_id = format!(
1103            "ftui-corr-{}",
1104            stable_hash_hex(correlation_material.as_bytes())
1105        );
1106
1107        Ok(FtuiTelemetryRecord {
1108            correlation_id,
1109            run_id: log.run_id.clone(),
1110            journey_id,
1111            event_id: event_id.to_owned(),
1112            state: state.to_owned(),
1113            seed,
1114            test_id: log.test_id.clone(),
1115            replay_command: log.replay_command.clone(),
1116            env_fingerprint: log.env_fingerprint.clone(),
1117            duration_ms,
1118            status,
1119            mode,
1120            artifact_ref,
1121            reason_code: log.reason_code.clone(),
1122            ts_unix_ms: log.ts_unix_ms,
1123        })
1124    }
1125
1126    pub fn build_artifact_index(
1127        &self,
1128        logs: &[StructuredTestLog],
1129    ) -> Result<FtuiArtifactIndex, String> {
1130        let mut entries = logs
1131            .iter()
1132            .map(|log| {
1133                let event = self.from_structured_log(log)?;
1134                Ok(FtuiArtifactIndexEntry {
1135                    correlation_id: event.correlation_id,
1136                    bundle_id: log.forensic_bundle_id.clone(),
1137                    run_id: log.run_id.clone(),
1138                    test_id: log.test_id.clone(),
1139                    captured_unix_ms: log.ts_unix_ms,
1140                    replay_ref: log.replay_command.clone(),
1141                    artifact_refs: log.artifact_refs.clone(),
1142                    status: log.status,
1143                    reason_code: log.reason_code.clone(),
1144                })
1145            })
1146            .collect::<Result<Vec<_>, String>>()?;
1147
1148        entries.sort_by(|left, right| {
1149            (
1150                left.captured_unix_ms,
1151                left.run_id.as_str(),
1152                left.test_id.as_str(),
1153                left.bundle_id.as_str(),
1154                left.correlation_id.as_str(),
1155            )
1156                .cmp(&(
1157                    right.captured_unix_ms,
1158                    right.run_id.as_str(),
1159                    right.test_id.as_str(),
1160                    right.bundle_id.as_str(),
1161                    right.correlation_id.as_str(),
1162                ))
1163        });
1164
1165        Ok(FtuiArtifactIndex { entries })
1166    }
1167}
1168
1169impl Default for FtuiTelemetryAdapter {
1170    fn default() -> Self {
1171        Self::strict_default()
1172    }
1173}
1174
1175fn enum_token<T: Serialize>(label: &str, value: &T) -> Result<String, String> {
1176    let encoded = serde_json::to_value(value)
1177        .map_err(|err| format!("failed to serialize enum token for {label}: {err}"))?;
1178    encoded
1179        .as_str()
1180        .map(ToOwned::to_owned)
1181        .ok_or_else(|| format!("enum token for {label} should serialize to string"))
1182}
1183
1184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1185pub struct E2eStepTrace {
1186    pub run_id: String,
1187    pub test_id: String,
1188    pub step_id: String,
1189    pub step_label: String,
1190    pub phase: String,
1191    pub status: E2eStepStatus,
1192    pub start_unix_ms: u128,
1193    pub end_unix_ms: u128,
1194    pub duration_ms: u128,
1195    pub replay_command: String,
1196    pub forensic_bundle_id: String,
1197    pub artifact_refs: Vec<String>,
1198    pub hash_id: String,
1199    pub reason_code: Option<String>,
1200}
1201
1202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1203pub struct ForensicsBundleIndex {
1204    pub bundle_id: String,
1205    pub run_id: String,
1206    pub test_id: String,
1207    pub bundle_hash_id: String,
1208    pub captured_unix_ms: u128,
1209    pub replay_ref: String,
1210    pub artifact_refs: Vec<String>,
1211    #[serde(default)]
1212    pub raptorq_sidecar_refs: Vec<String>,
1213    #[serde(default)]
1214    pub decode_proof_refs: Vec<String>,
1215}
1216
1217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1218pub struct FailureReproData {
1219    pub failure_message: String,
1220    pub reproduction_command: String,
1221    pub expected_behavior: String,
1222    pub observed_behavior: String,
1223    pub seed: Option<u64>,
1224    pub fixture_id: Option<String>,
1225    pub artifact_hash_id: Option<String>,
1226    pub forensics_link: Option<String>,
1227}
1228
1229#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1230pub struct StructuredTestLog {
1231    pub schema_version: String,
1232    pub run_id: String,
1233    pub ts_unix_ms: u128,
1234    pub crate_name: String,
1235    pub suite_id: String,
1236    pub packet_id: String,
1237    pub test_name: String,
1238    pub test_id: String,
1239    pub test_kind: TestKind,
1240    pub mode: CompatibilityMode,
1241    pub fixture_id: Option<String>,
1242    pub seed: Option<u64>,
1243    pub environment: BTreeMap<String, String>,
1244    pub env_fingerprint: String,
1245    pub duration_ms: u128,
1246    pub replay_command: String,
1247    pub artifact_refs: Vec<String>,
1248    pub forensic_bundle_id: String,
1249    pub hash_id: String,
1250    pub status: TestStatus,
1251    pub reason_code: Option<String>,
1252    pub failure_repro: Option<FailureReproData>,
1253    #[serde(default)]
1254    pub e2e_step_traces: Vec<E2eStepTrace>,
1255    #[serde(default)]
1256    pub forensics_bundle_index: Option<ForensicsBundleIndex>,
1257}
1258
1259impl StructuredTestLog {
1260    fn require_non_empty_fixture_id(&self, context: &str) -> Result<(), String> {
1261        if self
1262            .fixture_id
1263            .as_deref()
1264            .is_none_or(|value| value.trim().is_empty())
1265        {
1266            return Err(format!(
1267                "{context} requires fixture_id for deterministic replay"
1268            ));
1269        }
1270        Ok(())
1271    }
1272
1273    fn require_environment_key(&self, key: &str, context: &str) -> Result<(), String> {
1274        if self
1275            .environment
1276            .get(key)
1277            .is_none_or(|value| value.trim().is_empty())
1278        {
1279            return Err(format!(
1280                "{context} missing required environment key `{key}`"
1281            ));
1282        }
1283        Ok(())
1284    }
1285
1286    fn validate_packet_003_replay_metadata(&self) -> Result<(), String> {
1287        if self.packet_id != "FNX-P2C-003" {
1288            return Ok(());
1289        }
1290
1291        match self.test_id.as_str() {
1292            "unit::fnx-p2c-003::contract" => {
1293                let context = "packet-003 unit contract telemetry";
1294                self.require_non_empty_fixture_id(context)?;
1295                for key in ["route_id", "backend_name", "strict_mode"] {
1296                    self.require_environment_key(key, context)?;
1297                }
1298            }
1299            "property::fnx-p2c-003::invariants" => {
1300                let context = "packet-003 property invariant telemetry";
1301                self.require_non_empty_fixture_id(context)?;
1302                if self.seed.is_none() {
1303                    return Err(
1304                        "packet-003 property invariant telemetry requires deterministic seed"
1305                            .to_owned(),
1306                    );
1307                }
1308                for key in ["graph_fingerprint", "cache_key_digest", "invariant_id"] {
1309                    self.require_environment_key(key, context)?;
1310                }
1311            }
1312            _ => {}
1313        }
1314
1315        Ok(())
1316    }
1317
1318    fn validate_packet_004_replay_metadata(&self) -> Result<(), String> {
1319        if self.packet_id != "FNX-P2C-004" {
1320            return Ok(());
1321        }
1322
1323        match self.test_id.as_str() {
1324            "unit::fnx-p2c-004::contract" => {
1325                let context = "packet-004 unit contract telemetry";
1326                self.require_non_empty_fixture_id(context)?;
1327                for key in ["conversion_path", "input_shape", "strict_mode"] {
1328                    self.require_environment_key(key, context)?;
1329                }
1330            }
1331            "property::fnx-p2c-004::invariants" => {
1332                let context = "packet-004 property invariant telemetry";
1333                self.require_non_empty_fixture_id(context)?;
1334                if self.seed.is_none() {
1335                    return Err(
1336                        "packet-004 property invariant telemetry requires deterministic seed"
1337                            .to_owned(),
1338                    );
1339                }
1340                for key in ["graph_fingerprint", "relabel_mode", "invariant_id"] {
1341                    self.require_environment_key(key, context)?;
1342                }
1343            }
1344            _ => {}
1345        }
1346
1347        Ok(())
1348    }
1349
1350    fn validate_packet_005_replay_metadata(&self) -> Result<(), String> {
1351        if self.packet_id != "FNX-P2C-005" {
1352            return Ok(());
1353        }
1354
1355        match self.test_id.as_str() {
1356            "unit::fnx-p2c-005::contract" => {
1357                let context = "packet-005 unit contract telemetry";
1358                self.require_non_empty_fixture_id(context)?;
1359                for key in [
1360                    "algorithm_family",
1361                    "source_target_pair",
1362                    "strict_mode",
1363                    "policy_row_id",
1364                ] {
1365                    self.require_environment_key(key, context)?;
1366                }
1367            }
1368            "property::fnx-p2c-005::invariants" => {
1369                let context = "packet-005 property invariant telemetry";
1370                self.require_non_empty_fixture_id(context)?;
1371                if self.seed.is_none() {
1372                    return Err(
1373                        "packet-005 property invariant telemetry requires deterministic seed"
1374                            .to_owned(),
1375                    );
1376                }
1377                for key in [
1378                    "graph_fingerprint",
1379                    "tie_break_policy",
1380                    "invariant_id",
1381                    "policy_row_id",
1382                ] {
1383                    self.require_environment_key(key, context)?;
1384                }
1385            }
1386            _ => {}
1387        }
1388
1389        Ok(())
1390    }
1391
1392    fn validate_packet_006_replay_metadata(&self) -> Result<(), String> {
1393        if self.packet_id != "FNX-P2C-006" {
1394            return Ok(());
1395        }
1396
1397        match self.test_id.as_str() {
1398            "unit::fnx-p2c-006::contract" => {
1399                let context = "packet-006 unit contract telemetry";
1400                self.require_non_empty_fixture_id(context)?;
1401                for key in ["io_path", "strict_mode", "input_digest", "output_digest"] {
1402                    self.require_environment_key(key, context)?;
1403                }
1404            }
1405            "property::fnx-p2c-006::invariants" => {
1406                let context = "packet-006 property invariant telemetry";
1407                self.require_non_empty_fixture_id(context)?;
1408                if self.seed.is_none() {
1409                    return Err(
1410                        "packet-006 property invariant telemetry requires deterministic seed"
1411                            .to_owned(),
1412                    );
1413                }
1414                for key in [
1415                    "graph_fingerprint",
1416                    "mode_policy",
1417                    "invariant_id",
1418                    "input_digest",
1419                    "output_digest",
1420                ] {
1421                    self.require_environment_key(key, context)?;
1422                }
1423            }
1424            _ => {}
1425        }
1426
1427        Ok(())
1428    }
1429
1430    pub fn validate(&self) -> Result<(), String> {
1431        if self.schema_version.trim().is_empty() {
1432            return Err("schema_version must be non-empty".to_owned());
1433        }
1434        if self.schema_version != structured_test_log_schema_version() {
1435            return Err(format!(
1436                "unsupported schema_version `{}` (expected `{}`)",
1437                self.schema_version,
1438                structured_test_log_schema_version()
1439            ));
1440        }
1441        if self.run_id.trim().is_empty() {
1442            return Err("run_id must be non-empty".to_owned());
1443        }
1444        if self.crate_name.trim().is_empty() {
1445            return Err("crate_name must be non-empty".to_owned());
1446        }
1447        if self.suite_id.trim().is_empty() {
1448            return Err("suite_id must be non-empty".to_owned());
1449        }
1450        if self.packet_id.trim().is_empty() {
1451            return Err("packet_id must be non-empty".to_owned());
1452        }
1453        if self.test_name.trim().is_empty() {
1454            return Err("test_name must be non-empty".to_owned());
1455        }
1456        if self.test_id.trim().is_empty() {
1457            return Err("test_id must be non-empty".to_owned());
1458        }
1459        if self.env_fingerprint.trim().is_empty() {
1460            return Err("env_fingerprint must be non-empty".to_owned());
1461        }
1462        if self.replay_command.trim().is_empty() {
1463            return Err("replay_command must be non-empty".to_owned());
1464        }
1465        if self.forensic_bundle_id.trim().is_empty() {
1466            return Err("forensic_bundle_id must be non-empty".to_owned());
1467        }
1468        if self.hash_id.trim().is_empty() {
1469            return Err("hash_id must be non-empty".to_owned());
1470        }
1471        if self.environment.is_empty() {
1472            return Err("environment must include at least one key".to_owned());
1473        }
1474        if self.artifact_refs.is_empty() {
1475            return Err("artifact_refs must include at least one artifact path/ref".to_owned());
1476        }
1477        if self
1478            .artifact_refs
1479            .iter()
1480            .any(|artifact_ref| artifact_ref.trim().is_empty())
1481        {
1482            return Err("artifact_refs must not contain empty entries".to_owned());
1483        }
1484        let Some(bundle_index) = &self.forensics_bundle_index else {
1485            return Err("forensics_bundle_index is required".to_owned());
1486        };
1487        if bundle_index.bundle_id.trim().is_empty() {
1488            return Err("forensics_bundle_index.bundle_id must be non-empty".to_owned());
1489        }
1490        if bundle_index.bundle_hash_id.trim().is_empty() {
1491            return Err("forensics_bundle_index.bundle_hash_id must be non-empty".to_owned());
1492        }
1493        if bundle_index.replay_ref.trim().is_empty() {
1494            return Err("forensics_bundle_index.replay_ref must be non-empty".to_owned());
1495        }
1496        if bundle_index.artifact_refs.is_empty() {
1497            return Err("forensics_bundle_index.artifact_refs must be non-empty".to_owned());
1498        }
1499        if bundle_index
1500            .artifact_refs
1501            .iter()
1502            .any(|artifact_ref| artifact_ref.trim().is_empty())
1503        {
1504            return Err(
1505                "forensics_bundle_index.artifact_refs must not contain empty entries".to_owned(),
1506            );
1507        }
1508        if bundle_index.bundle_id != self.forensic_bundle_id {
1509            return Err(
1510                "forensics_bundle_index.bundle_id must match forensic_bundle_id".to_owned(),
1511            );
1512        }
1513        if bundle_index.run_id != self.run_id {
1514            return Err("forensics_bundle_index.run_id must match run_id".to_owned());
1515        }
1516        if bundle_index.test_id != self.test_id {
1517            return Err("forensics_bundle_index.test_id must match test_id".to_owned());
1518        }
1519        if bundle_index.replay_ref != self.replay_command {
1520            return Err("forensics_bundle_index.replay_ref must match replay_command".to_owned());
1521        }
1522        self.validate_packet_003_replay_metadata()?;
1523        self.validate_packet_004_replay_metadata()?;
1524        self.validate_packet_005_replay_metadata()?;
1525        self.validate_packet_006_replay_metadata()?;
1526
1527        match self.status {
1528            TestStatus::Failed => {
1529                let Some(failure) = &self.failure_repro else {
1530                    return Err("failure_repro is required when status=failed".to_owned());
1531                };
1532                let Some(reason_code) = &self.reason_code else {
1533                    return Err("reason_code is required when status=failed".to_owned());
1534                };
1535                if reason_code.trim().is_empty() {
1536                    return Err("reason_code must be non-empty when status=failed".to_owned());
1537                }
1538                if failure.failure_message.trim().is_empty() {
1539                    return Err("failure_message must be non-empty for failed status".to_owned());
1540                }
1541                if failure.reproduction_command.trim().is_empty() {
1542                    return Err(
1543                        "reproduction_command must be non-empty for failed status".to_owned()
1544                    );
1545                }
1546                if failure.seed.is_none() && failure.fixture_id.is_none() {
1547                    return Err(
1548                        "failed status requires seed or fixture_id for reproducibility".to_owned(),
1549                    );
1550                }
1551                let Some(artifact_hash_id) = &failure.artifact_hash_id else {
1552                    return Err("failed status requires artifact_hash_id".to_owned());
1553                };
1554                if artifact_hash_id.trim().is_empty() {
1555                    return Err("artifact_hash_id must be non-empty for failed status".to_owned());
1556                }
1557                if let Some(forensics_link) = &failure.forensics_link
1558                    && forensics_link.trim().is_empty()
1559                {
1560                    return Err("forensics_link must be non-empty when provided".to_owned());
1561                }
1562            }
1563            TestStatus::Skipped => {
1564                if self
1565                    .reason_code
1566                    .as_deref()
1567                    .is_none_or(|value| value.trim().is_empty())
1568                {
1569                    return Err("reason_code is required when status=skipped".to_owned());
1570                }
1571                if self.failure_repro.is_some() {
1572                    return Err("failure_repro must be omitted unless status=failed".to_owned());
1573                }
1574            }
1575            TestStatus::Passed => {
1576                if let Some(reason_code) = &self.reason_code
1577                    && reason_code.trim().is_empty()
1578                {
1579                    return Err("reason_code must be non-empty when provided".to_owned());
1580                }
1581                if self.failure_repro.is_some() {
1582                    return Err("failure_repro must be omitted unless status=failed".to_owned());
1583                }
1584            }
1585        }
1586
1587        if self.test_kind == TestKind::E2e && self.e2e_step_traces.is_empty() {
1588            return Err("e2e_step_traces are required when test_kind=e2e".to_owned());
1589        }
1590        let mut seen_step_ids = std::collections::BTreeSet::new();
1591        for step in &self.e2e_step_traces {
1592            if step.run_id.trim().is_empty() {
1593                return Err("e2e_step_traces.run_id must be non-empty".to_owned());
1594            }
1595            if step.test_id.trim().is_empty() {
1596                return Err("e2e_step_traces.test_id must be non-empty".to_owned());
1597            }
1598            if step.step_id.trim().is_empty() {
1599                return Err("e2e_step_traces.step_id must be non-empty".to_owned());
1600            }
1601            if !seen_step_ids.insert(step.step_id.clone()) {
1602                return Err("e2e_step_traces.step_id values must be unique".to_owned());
1603            }
1604            if step.step_label.trim().is_empty() {
1605                return Err("e2e_step_traces.step_label must be non-empty".to_owned());
1606            }
1607            if step.phase.trim().is_empty() {
1608                return Err("e2e_step_traces.phase must be non-empty".to_owned());
1609            }
1610            if step.start_unix_ms > step.end_unix_ms {
1611                return Err("e2e_step_traces start_unix_ms must be <= end_unix_ms".to_owned());
1612            }
1613            let observed_duration = step.end_unix_ms.saturating_sub(step.start_unix_ms);
1614            if step.duration_ms != observed_duration {
1615                return Err(
1616                    "e2e_step_traces duration_ms must match end_unix_ms - start_unix_ms".to_owned(),
1617                );
1618            }
1619            if step.replay_command.trim().is_empty() {
1620                return Err("e2e_step_traces.replay_command must be non-empty".to_owned());
1621            }
1622            if step.hash_id.trim().is_empty() {
1623                return Err("e2e_step_traces.hash_id must be non-empty".to_owned());
1624            }
1625            if step.forensic_bundle_id.trim().is_empty() {
1626                return Err("e2e_step_traces.forensic_bundle_id must be non-empty".to_owned());
1627            }
1628            if step.artifact_refs.is_empty() {
1629                return Err("e2e_step_traces.artifact_refs must be non-empty".to_owned());
1630            }
1631            if step
1632                .artifact_refs
1633                .iter()
1634                .any(|artifact_ref| artifact_ref.trim().is_empty())
1635            {
1636                return Err(
1637                    "e2e_step_traces.artifact_refs must not contain empty entries".to_owned(),
1638                );
1639            }
1640            if step.run_id != self.run_id {
1641                return Err("e2e_step_traces.run_id must match run_id".to_owned());
1642            }
1643            if step.test_id != self.test_id {
1644                return Err("e2e_step_traces.test_id must match test_id".to_owned());
1645            }
1646            if step.forensic_bundle_id != self.forensic_bundle_id {
1647                return Err(
1648                    "e2e_step_traces.forensic_bundle_id must match forensic_bundle_id".to_owned(),
1649                );
1650            }
1651            if step.replay_command != self.replay_command {
1652                return Err("e2e_step_traces.replay_command must match replay_command".to_owned());
1653            }
1654            match step.status {
1655                E2eStepStatus::Failed | E2eStepStatus::Skipped => {
1656                    if step
1657                        .reason_code
1658                        .as_deref()
1659                        .is_none_or(|reason_code| reason_code.trim().is_empty())
1660                    {
1661                        return Err(
1662                            "e2e_step_traces.reason_code is required for failed/skipped step status"
1663                                .to_owned(),
1664                        );
1665                    }
1666                }
1667                E2eStepStatus::Started | E2eStepStatus::Passed => {
1668                    if let Some(reason_code) = &step.reason_code
1669                        && reason_code.trim().is_empty()
1670                    {
1671                        return Err(
1672                            "e2e_step_traces.reason_code must be non-empty when provided"
1673                                .to_owned(),
1674                        );
1675                    }
1676                }
1677            }
1678        }
1679
1680        Ok(())
1681    }
1682
1683    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
1684        serde_json::to_string_pretty(self)
1685    }
1686}
1687
1688#[must_use]
1689pub fn canonical_environment_fingerprint(environment: &BTreeMap<String, String>) -> String {
1690    let canonical = environment
1691        .iter()
1692        .map(|(key, value)| format!("{key}={value}"))
1693        .collect::<Vec<String>>()
1694        .join("\n");
1695    stable_hash_hex(canonical.as_bytes())
1696}
1697
1698fn stable_hash_hex(input: &[u8]) -> String {
1699    let mut hash = 0xcbf29ce484222325_u64;
1700    for byte in input {
1701        hash ^= u64::from(*byte);
1702        hash = hash.wrapping_mul(0x00000100000001B3_u64);
1703    }
1704    format!("{hash:016x}")
1705}
1706
1707#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1708pub struct EvidenceTerm {
1709    pub signal: String,
1710    pub observed_value: String,
1711    pub log_likelihood_ratio: f64,
1712}
1713
1714#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1715pub struct DecisionRecord {
1716    pub ts_unix_ms: u128,
1717    pub operation: String,
1718    pub mode: CompatibilityMode,
1719    pub action: DecisionAction,
1720    pub incompatibility_probability: f64,
1721    pub rationale: String,
1722    pub evidence: Vec<EvidenceTerm>,
1723}
1724
1725#[derive(Debug, Clone, PartialEq)]
1726pub struct CgsePolicyDecision {
1727    pub policy_id: &'static str,
1728    pub rule: CgsePolicyRule,
1729    pub allowlisted_ambiguity: bool,
1730    pub fail_closed_default: &'static str,
1731    pub policy_spec_path: &'static str,
1732    pub policy_spec_schema_path: &'static str,
1733    pub legacy_tiebreak_ledger_path: &'static str,
1734    pub semantics_threat_model_path: &'static str,
1735    pub decision: DecisionRecord,
1736}
1737
1738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1739pub struct CgsePolicyEngine {
1740    mode: CompatibilityMode,
1741}
1742
1743impl CgsePolicyEngine {
1744    #[must_use]
1745    pub const fn new(mode: CompatibilityMode) -> Self {
1746        Self { mode }
1747    }
1748
1749    #[must_use]
1750    pub const fn mode(self) -> CompatibilityMode {
1751        self.mode
1752    }
1753
1754    #[must_use]
1755    pub fn evaluate(
1756        &self,
1757        rule: CgsePolicyRule,
1758        ambiguity_tag: Option<&str>,
1759        incompatibility_probability: f64,
1760        unknown_incompatible_feature: bool,
1761    ) -> CgsePolicyDecision {
1762        self.evaluate_at(
1763            rule,
1764            ambiguity_tag,
1765            incompatibility_probability,
1766            unknown_incompatible_feature,
1767            unix_time_ms(),
1768        )
1769    }
1770
1771    #[must_use]
1772    pub fn evaluate_at(
1773        &self,
1774        rule: CgsePolicyRule,
1775        ambiguity_tag: Option<&str>,
1776        incompatibility_probability: f64,
1777        unknown_incompatible_feature: bool,
1778        ts_unix_ms: u128,
1779    ) -> CgsePolicyDecision {
1780        let nan_detected = incompatibility_probability.is_nan();
1781        let clamped_probability = if nan_detected {
1782            1.0
1783        } else {
1784            incompatibility_probability.clamp(0.0, 1.0)
1785        };
1786        let allowlisted_ambiguity =
1787            ambiguity_tag.is_some_and(|tag| rule.hardened_allowlist().contains(&tag));
1788        let hardened_ambiguity_violation = matches!(self.mode, CompatibilityMode::Hardened)
1789            && ambiguity_tag.is_some()
1790            && !allowlisted_ambiguity;
1791
1792        let action = if unknown_incompatible_feature || nan_detected || hardened_ambiguity_violation
1793        {
1794            DecisionAction::FailClosed
1795        } else {
1796            RuntimePolicy::new(self.mode).action_for(clamped_probability, false)
1797        };
1798
1799        let rationale = if nan_detected {
1800            format!(
1801                "{} triggered fail-closed due to NaN incompatibility_probability",
1802                rule.fail_closed_default()
1803            )
1804        } else if unknown_incompatible_feature {
1805            format!(
1806                "{} triggered fail-closed due to unknown incompatible feature",
1807                rule.fail_closed_default()
1808            )
1809        } else if hardened_ambiguity_violation {
1810            let tag = ambiguity_tag.unwrap_or("none");
1811            format!(
1812                "{} triggered fail-closed because ambiguity tag `{tag}` is not allowlisted in hardened mode",
1813                rule.fail_closed_default()
1814            )
1815        } else {
1816            selected_policy_rationale(rule, action, clamped_probability)
1817        };
1818
1819        let decision = DecisionRecord {
1820            ts_unix_ms,
1821            operation: rule.operation_name().to_owned(),
1822            mode: self.mode,
1823            action,
1824            incompatibility_probability: clamped_probability,
1825            rationale,
1826            evidence: vec![
1827                EvidenceTerm {
1828                    signal: "cgse_policy_rule_id".to_owned(),
1829                    observed_value: rule.as_rule_id().to_owned(),
1830                    log_likelihood_ratio: 1.0,
1831                },
1832                EvidenceTerm {
1833                    signal: "cgse_operation_family".to_owned(),
1834                    observed_value: rule.operation_family().as_str().to_owned(),
1835                    log_likelihood_ratio: 0.5,
1836                },
1837                EvidenceTerm {
1838                    signal: "cgse_ambiguity_tag".to_owned(),
1839                    observed_value: ambiguity_tag.unwrap_or("none").to_owned(),
1840                    log_likelihood_ratio: if allowlisted_ambiguity { 0.25 } else { -0.25 },
1841                },
1842                EvidenceTerm {
1843                    signal: "cgse_hardened_allowlist_hit".to_owned(),
1844                    observed_value: allowlisted_ambiguity.to_string(),
1845                    log_likelihood_ratio: if allowlisted_ambiguity { 0.75 } else { -0.75 },
1846                },
1847            ],
1848        };
1849
1850        CgsePolicyDecision {
1851            policy_id: rule.policy_id(),
1852            rule,
1853            allowlisted_ambiguity,
1854            fail_closed_default: rule.fail_closed_default(),
1855            policy_spec_path: CGSE_POLICY_SPEC_PATH,
1856            policy_spec_schema_path: CGSE_POLICY_SPEC_SCHEMA_PATH,
1857            legacy_tiebreak_ledger_path: CGSE_LEGACY_TIEBREAK_LEDGER_PATH,
1858            semantics_threat_model_path: CGSE_SEMANTICS_THREAT_MODEL_PATH,
1859            decision,
1860        }
1861    }
1862}
1863
1864pub trait CgsePolicyEvaluator {
1865    fn mode(&self) -> CompatibilityMode;
1866
1867    fn evaluate(
1868        &self,
1869        rule: CgsePolicyRule,
1870        ambiguity_tag: Option<&str>,
1871        incompatibility_probability: f64,
1872        unknown_incompatible_feature: bool,
1873    ) -> CgsePolicyDecision;
1874}
1875
1876impl CgsePolicyEvaluator for CgsePolicyEngine {
1877    fn mode(&self) -> CompatibilityMode {
1878        CgsePolicyEngine::mode(*self)
1879    }
1880
1881    fn evaluate(
1882        &self,
1883        rule: CgsePolicyRule,
1884        ambiguity_tag: Option<&str>,
1885        incompatibility_probability: f64,
1886        unknown_incompatible_feature: bool,
1887    ) -> CgsePolicyDecision {
1888        CgsePolicyEngine::evaluate(
1889            self,
1890            rule,
1891            ambiguity_tag,
1892            incompatibility_probability,
1893            unknown_incompatible_feature,
1894        )
1895    }
1896}
1897
1898/// Current schema version for decision ledger serialization.
1899pub const DECISION_LEDGER_SCHEMA_VERSION: &str = "1.0.0";
1900
1901#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
1902pub struct EvidenceLedger {
1903    records: Vec<DecisionRecord>,
1904}
1905
1906impl EvidenceLedger {
1907    #[must_use]
1908    pub fn new() -> Self {
1909        Self::default()
1910    }
1911
1912    pub fn record(&mut self, decision: DecisionRecord) {
1913        self.records.push(decision);
1914    }
1915
1916    #[must_use]
1917    pub fn records(&self) -> &[DecisionRecord] {
1918        &self.records
1919    }
1920
1921    #[must_use]
1922    pub fn is_empty(&self) -> bool {
1923        self.records.is_empty()
1924    }
1925
1926    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
1927        serde_json::to_string_pretty(self)
1928    }
1929
1930    /// Convert to a versioned ledger for serialization.
1931    #[must_use]
1932    pub fn to_versioned(&self) -> VersionedDecisionLedger {
1933        VersionedDecisionLedger::from_evidence_ledger(self)
1934    }
1935}
1936
1937/// A decision ledger with explicit schema versioning for forward compatibility.
1938///
1939/// Use this struct when persisting ledgers to disk or transmitting over the wire.
1940/// The schema version allows consumers to detect incompatible changes and
1941/// migrate data appropriately.
1942#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1943pub struct VersionedDecisionLedger {
1944    /// Schema version string (semver format)
1945    pub schema_version: String,
1946    /// Ledger identifier for tracking
1947    pub ledger_id: String,
1948    /// Unix timestamp when ledger was created (milliseconds)
1949    pub created_at_unix_ms: u128,
1950    /// Unix timestamp of most recent record (milliseconds)
1951    pub last_updated_unix_ms: u128,
1952    /// Total number of records
1953    pub record_count: usize,
1954    /// The decision records
1955    pub records: Vec<DecisionRecord>,
1956}
1957
1958impl VersionedDecisionLedger {
1959    /// Create a new versioned ledger from an evidence ledger.
1960    #[must_use]
1961    pub fn from_evidence_ledger(ledger: &EvidenceLedger) -> Self {
1962        let now = unix_time_ms();
1963        let last_updated = ledger.records().last().map_or(now, |r| r.ts_unix_ms);
1964
1965        Self {
1966            schema_version: DECISION_LEDGER_SCHEMA_VERSION.to_owned(),
1967            ledger_id: format!("ledger-{}", now),
1968            created_at_unix_ms: now,
1969            last_updated_unix_ms: last_updated,
1970            record_count: ledger.records().len(),
1971            records: ledger.records().to_vec(),
1972        }
1973    }
1974
1975    /// Create a new empty versioned ledger with a specific ID.
1976    #[must_use]
1977    pub fn new(ledger_id: &str) -> Self {
1978        let now = unix_time_ms();
1979        Self {
1980            schema_version: DECISION_LEDGER_SCHEMA_VERSION.to_owned(),
1981            ledger_id: ledger_id.to_owned(),
1982            created_at_unix_ms: now,
1983            last_updated_unix_ms: now,
1984            record_count: 0,
1985            records: Vec::new(),
1986        }
1987    }
1988
1989    /// Append a decision record to the ledger.
1990    pub fn append(&mut self, record: DecisionRecord) {
1991        self.last_updated_unix_ms = record.ts_unix_ms;
1992        self.records.push(record);
1993        self.record_count = self.records.len();
1994    }
1995
1996    /// Check if this ledger's schema is compatible with current version.
1997    #[must_use]
1998    pub fn is_schema_compatible(&self) -> bool {
1999        // Simple semver major version check
2000        let current_major = DECISION_LEDGER_SCHEMA_VERSION
2001            .split('.')
2002            .next()
2003            .and_then(|s| s.parse::<u32>().ok())
2004            .unwrap_or(0);
2005        let ledger_major = self
2006            .schema_version
2007            .split('.')
2008            .next()
2009            .and_then(|s| s.parse::<u32>().ok())
2010            .unwrap_or(0);
2011
2012        ledger_major == current_major
2013    }
2014
2015    /// Validate the ledger structure.
2016    #[must_use]
2017    pub fn validate(&self) -> LedgerValidationResult {
2018        let mut errors = Vec::new();
2019
2020        if self.schema_version.is_empty() {
2021            errors.push("schema_version is empty".to_owned());
2022        }
2023
2024        if self.ledger_id.is_empty() {
2025            errors.push("ledger_id is empty".to_owned());
2026        }
2027
2028        if self.record_count != self.records.len() {
2029            errors.push(format!(
2030                "record_count mismatch: stated {} but have {}",
2031                self.record_count,
2032                self.records.len()
2033            ));
2034        }
2035
2036        // Check records are in timestamp order
2037        for i in 1..self.records.len() {
2038            if self.records[i].ts_unix_ms < self.records[i - 1].ts_unix_ms {
2039                errors.push(format!(
2040                    "records out of order at index {}: {} < {}",
2041                    i,
2042                    self.records[i].ts_unix_ms,
2043                    self.records[i - 1].ts_unix_ms
2044                ));
2045                break;
2046            }
2047        }
2048
2049        LedgerValidationResult {
2050            is_valid: errors.is_empty(),
2051            schema_compatible: self.is_schema_compatible(),
2052            errors,
2053        }
2054    }
2055
2056    /// Serialize to JSON with pretty formatting.
2057    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
2058        serde_json::to_string_pretty(self)
2059    }
2060
2061    /// Deserialize from JSON.
2062    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
2063        serde_json::from_str(json)
2064    }
2065
2066    /// Merge another ledger's records into this one.
2067    ///
2068    /// Records are merged and sorted by timestamp.
2069    pub fn merge(&mut self, other: &Self) {
2070        self.records.extend(other.records.iter().cloned());
2071        self.records.sort_by_key(|r| r.ts_unix_ms);
2072        self.record_count = self.records.len();
2073        self.last_updated_unix_ms = self
2074            .records
2075            .last()
2076            .map_or(self.created_at_unix_ms, |r| r.ts_unix_ms);
2077    }
2078}
2079
2080/// Result of validating a versioned decision ledger.
2081#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2082pub struct LedgerValidationResult {
2083    pub is_valid: bool,
2084    pub schema_compatible: bool,
2085    pub errors: Vec<String>,
2086}
2087
2088// ---------------------------------------------------------------------------
2089// Drift Analysis
2090// ---------------------------------------------------------------------------
2091
2092/// Threshold for classifying a decision as "under-confident" (high risk).
2093pub const UNDER_CONFIDENT_THRESHOLD: f64 = 0.3;
2094
2095/// Milliseconds in one week (7 days).
2096pub const MILLIS_PER_WEEK: u128 = 7 * 24 * 60 * 60 * 1000;
2097
2098/// Analyzes decision records to identify under-confident regions and drift.
2099#[derive(Debug, Clone, Default)]
2100pub struct DriftAnalyzer {
2101    threshold: f64,
2102}
2103
2104impl DriftAnalyzer {
2105    /// Create a new analyzer with the default threshold.
2106    #[must_use]
2107    pub fn new() -> Self {
2108        Self {
2109            threshold: UNDER_CONFIDENT_THRESHOLD,
2110        }
2111    }
2112
2113    /// Create an analyzer with a custom threshold.
2114    #[must_use]
2115    pub fn with_threshold(threshold: f64) -> Self {
2116        Self {
2117            threshold: threshold.clamp(0.0, 1.0),
2118        }
2119    }
2120
2121    /// Analyze a ledger and produce a drift feedback report.
2122    #[must_use]
2123    pub fn analyze(&self, ledger: &VersionedDecisionLedger) -> DriftFeedbackReport {
2124        let records = &ledger.records;
2125        if records.is_empty() {
2126            return DriftFeedbackReport {
2127                total_decisions: 0,
2128                under_confident_count: 0,
2129                under_confident_rate: 0.0,
2130                weekly_summaries: Vec::new(),
2131                top_operations: Vec::new(),
2132                recommendations: vec!["No decisions to analyze".to_owned()],
2133            };
2134        }
2135
2136        // Find under-confident decisions
2137        let under_confident: Vec<_> = records
2138            .iter()
2139            .filter(|r| r.incompatibility_probability >= self.threshold)
2140            .collect();
2141
2142        // Group by week
2143        let min_ts = records.iter().map(|r| r.ts_unix_ms).min().unwrap_or(0);
2144        let mut weekly_map: std::collections::BTreeMap<u128, WeeklySummary> =
2145            std::collections::BTreeMap::new();
2146
2147        for record in records {
2148            let week_number = (record.ts_unix_ms - min_ts) / MILLIS_PER_WEEK;
2149            let week_start = min_ts + (week_number * MILLIS_PER_WEEK);
2150
2151            let summary = weekly_map
2152                .entry(week_start)
2153                .or_insert_with(|| WeeklySummary {
2154                    week_start_unix_ms: week_start,
2155                    total_decisions: 0,
2156                    under_confident_count: 0,
2157                    avg_probability: 0.0,
2158                    fail_closed_count: 0,
2159                });
2160
2161            summary.total_decisions += 1;
2162            if record.incompatibility_probability >= self.threshold {
2163                summary.under_confident_count += 1;
2164            }
2165            if record.action == DecisionAction::FailClosed {
2166                summary.fail_closed_count += 1;
2167            }
2168        }
2169
2170        // Calculate averages
2171        for (week_start, summary) in &mut weekly_map {
2172            let week_records: Vec<_> = records
2173                .iter()
2174                .filter(|r| {
2175                    let week_number = (r.ts_unix_ms - min_ts) / MILLIS_PER_WEEK;
2176                    min_ts + (week_number * MILLIS_PER_WEEK) == *week_start
2177                })
2178                .collect();
2179
2180            if !week_records.is_empty() {
2181                summary.avg_probability = week_records
2182                    .iter()
2183                    .map(|r| r.incompatibility_probability)
2184                    .sum::<f64>()
2185                    / week_records.len() as f64;
2186            }
2187        }
2188
2189        // Find top operations by under-confidence
2190        let mut op_counts: std::collections::HashMap<String, usize> =
2191            std::collections::HashMap::new();
2192        for record in &under_confident {
2193            *op_counts.entry(record.operation.clone()).or_insert(0) += 1;
2194        }
2195        let mut top_ops: Vec<_> = op_counts.into_iter().collect();
2196        top_ops.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
2197        let top_operations: Vec<OperationRiskSummary> = top_ops
2198            .into_iter()
2199            .take(5)
2200            .map(|(op, count)| OperationRiskSummary {
2201                operation: op,
2202                under_confident_count: count,
2203            })
2204            .collect();
2205
2206        // Generate recommendations
2207        let mut recommendations = Vec::new();
2208        let under_rate = under_confident.len() as f64 / records.len() as f64;
2209
2210        if under_rate > 0.5 {
2211            recommendations.push(
2212                "High under-confidence rate (>50%). Consider reviewing threshold or adding validation.".to_owned()
2213            );
2214        }
2215
2216        if !top_operations.is_empty() {
2217            recommendations.push(format!(
2218                "Top risky operation: '{}' ({} under-confident decisions)",
2219                top_operations[0].operation, top_operations[0].under_confident_count
2220            ));
2221        }
2222
2223        // Check for drift (increasing under-confidence over time)
2224        let weekly_summaries: Vec<_> = weekly_map.into_values().collect();
2225        if weekly_summaries.len() >= 2 {
2226            let first_rate = if weekly_summaries[0].total_decisions > 0 {
2227                weekly_summaries[0].under_confident_count as f64
2228                    / weekly_summaries[0].total_decisions as f64
2229            } else {
2230                0.0
2231            };
2232            let last_rate = if weekly_summaries.last().unwrap().total_decisions > 0 {
2233                weekly_summaries.last().unwrap().under_confident_count as f64
2234                    / weekly_summaries.last().unwrap().total_decisions as f64
2235            } else {
2236                0.0
2237            };
2238
2239            if last_rate > first_rate * 1.5 {
2240                recommendations
2241                    .push("Drift detected: under-confidence rate increasing over time.".to_owned());
2242            }
2243        }
2244
2245        DriftFeedbackReport {
2246            total_decisions: records.len(),
2247            under_confident_count: under_confident.len(),
2248            under_confident_rate: under_rate,
2249            weekly_summaries,
2250            top_operations,
2251            recommendations,
2252        }
2253    }
2254}
2255
2256/// Weekly summary of decision confidence.
2257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2258pub struct WeeklySummary {
2259    pub week_start_unix_ms: u128,
2260    pub total_decisions: usize,
2261    pub under_confident_count: usize,
2262    pub avg_probability: f64,
2263    pub fail_closed_count: usize,
2264}
2265
2266/// Summary of under-confidence for a specific operation.
2267#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2268pub struct OperationRiskSummary {
2269    pub operation: String,
2270    pub under_confident_count: usize,
2271}
2272
2273/// Drift feedback report with weekly analysis and recommendations.
2274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2275pub struct DriftFeedbackReport {
2276    pub total_decisions: usize,
2277    pub under_confident_count: usize,
2278    pub under_confident_rate: f64,
2279    pub weekly_summaries: Vec<WeeklySummary>,
2280    pub top_operations: Vec<OperationRiskSummary>,
2281    pub recommendations: Vec<String>,
2282}
2283
2284impl DriftFeedbackReport {
2285    /// Check if the report indicates drift.
2286    #[must_use]
2287    pub fn has_drift(&self) -> bool {
2288        self.recommendations
2289            .iter()
2290            .any(|r| r.contains("Drift detected"))
2291    }
2292
2293    /// Check if there are high-risk operations.
2294    #[must_use]
2295    pub fn has_high_risk_operations(&self) -> bool {
2296        !self.top_operations.is_empty()
2297    }
2298}
2299
2300#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
2301pub struct LossMatrix {
2302    /// False allow: we allowed an actually incompatible operation.
2303    pub allow_false_negative: f64,
2304    /// Validation cost regardless of compatibility.
2305    pub validate_cost: f64,
2306    /// False reject: we rejected a compatible operation.
2307    pub reject_false_positive: f64,
2308}
2309
2310impl LossMatrix {
2311    #[must_use]
2312    pub const fn strict_default() -> Self {
2313        Self {
2314            allow_false_negative: 100.0,
2315            validate_cost: 25.0,
2316            reject_false_positive: 60.0,
2317        }
2318    }
2319
2320    #[must_use]
2321    pub const fn hardened_default() -> Self {
2322        Self {
2323            allow_false_negative: 120.0,
2324            validate_cost: 30.0,
2325            reject_false_positive: 70.0,
2326        }
2327    }
2328}
2329
2330#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
2331pub struct RuntimePolicyPosterior {
2332    pub incompatibility_probability: f64,
2333    pub confidence: f64,
2334    pub observation_count: usize,
2335}
2336
2337impl Default for RuntimePolicyPosterior {
2338    fn default() -> Self {
2339        Self {
2340            incompatibility_probability: 0.0,
2341            confidence: 0.0,
2342            observation_count: 0,
2343        }
2344    }
2345}
2346
2347impl RuntimePolicyPosterior {
2348    #[must_use]
2349    pub fn observe(
2350        self,
2351        incompatibility_probability: f64,
2352        evidence_count: usize,
2353    ) -> RuntimePolicyPosterior {
2354        let next_count = self.observation_count + 1;
2355        let next_probability = if self.observation_count == 0 {
2356            incompatibility_probability
2357        } else {
2358            ((self.incompatibility_probability * self.observation_count as f64)
2359                + incompatibility_probability)
2360                / next_count as f64
2361        };
2362
2363        let evidence_weight = evidence_count as f64 / (evidence_count as f64 + 1.0);
2364        let decisiveness = 0.5 + (incompatibility_probability - 0.5).abs();
2365
2366        RuntimePolicyPosterior {
2367            incompatibility_probability: next_probability,
2368            confidence: (evidence_weight * decisiveness).clamp(0.0, 1.0),
2369            observation_count: next_count,
2370        }
2371    }
2372}
2373
2374#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2375pub struct RuntimePolicy {
2376    mode: CompatibilityMode,
2377    allowlist: BTreeSet<String>,
2378    decision_log: EvidenceLedger,
2379    posterior: RuntimePolicyPosterior,
2380    loss_matrix: LossMatrix,
2381}
2382
2383impl RuntimePolicy {
2384    #[must_use]
2385    pub fn new(mode: CompatibilityMode) -> Self {
2386        let loss_matrix = match mode {
2387            CompatibilityMode::Strict => LossMatrix::strict_default(),
2388            CompatibilityMode::Hardened => LossMatrix::hardened_default(),
2389        };
2390        Self {
2391            mode,
2392            allowlist: default_runtime_allowlist(mode),
2393            decision_log: EvidenceLedger::new(),
2394            posterior: RuntimePolicyPosterior::default(),
2395            loss_matrix,
2396        }
2397    }
2398
2399    #[must_use]
2400    pub fn with_allowlist<I, S>(mode: CompatibilityMode, allowlist: I) -> Self
2401    where
2402        I: IntoIterator<Item = S>,
2403        S: Into<String>,
2404    {
2405        let mut policy = Self::new(mode);
2406        policy.allowlist = allowlist.into_iter().map(Into::into).collect();
2407        policy
2408    }
2409
2410    #[must_use]
2411    pub fn strict() -> Self {
2412        Self::new(CompatibilityMode::Strict)
2413    }
2414
2415    #[must_use]
2416    pub fn hardened() -> Self {
2417        Self::new(CompatibilityMode::Hardened)
2418    }
2419
2420    #[must_use]
2421    pub const fn mode(&self) -> CompatibilityMode {
2422        self.mode
2423    }
2424
2425    #[must_use]
2426    pub fn allowlist(&self) -> &BTreeSet<String> {
2427        &self.allowlist
2428    }
2429
2430    #[must_use]
2431    pub fn allows(&self, ambiguity_tag: &str) -> bool {
2432        self.allowlist.contains(ambiguity_tag)
2433    }
2434
2435    #[must_use]
2436    pub fn decision_log(&self) -> &EvidenceLedger {
2437        &self.decision_log
2438    }
2439
2440    #[must_use]
2441    pub fn posterior(&self) -> RuntimePolicyPosterior {
2442        self.posterior
2443    }
2444
2445    #[must_use]
2446    pub const fn loss_matrix(&self) -> LossMatrix {
2447        self.loss_matrix
2448    }
2449
2450    #[must_use]
2451    pub fn action_for(
2452        &self,
2453        incompatibility_probability: f64,
2454        unknown_incompatible_feature: bool,
2455    ) -> DecisionAction {
2456        if unknown_incompatible_feature || incompatibility_probability.is_nan() {
2457            return DecisionAction::FailClosed;
2458        }
2459
2460        let p = incompatibility_probability.clamp(0.0, 1.0);
2461        let e_allow = p * self.loss_matrix.allow_false_negative;
2462        let e_validate = self.loss_matrix.validate_cost;
2463        let e_fail_closed = (1.0 - p) * self.loss_matrix.reject_false_positive;
2464
2465        if e_allow <= e_validate && e_allow <= e_fail_closed {
2466            DecisionAction::Allow
2467        } else if e_validate <= e_fail_closed {
2468            DecisionAction::FullValidate
2469        } else {
2470            DecisionAction::FailClosed
2471        }
2472    }
2473
2474    pub fn record(
2475        &mut self,
2476        operation: &str,
2477        action: DecisionAction,
2478        incompatibility_probability: f64,
2479        rationale: &str,
2480        evidence: Vec<EvidenceTerm>,
2481    ) {
2482        let clamped_probability = if incompatibility_probability.is_nan() {
2483            1.0
2484        } else {
2485            incompatibility_probability.clamp(0.0, 1.0)
2486        };
2487        let evidence_count = evidence.len().max(1);
2488        self.posterior = self.posterior.observe(clamped_probability, evidence_count);
2489        self.decision_log.record(DecisionRecord {
2490            ts_unix_ms: unix_time_ms(),
2491            operation: operation.to_owned(),
2492            mode: self.mode,
2493            action,
2494            incompatibility_probability: clamped_probability,
2495            rationale: rationale.to_owned(),
2496            evidence,
2497        });
2498    }
2499}
2500
2501impl Default for RuntimePolicy {
2502    fn default() -> Self {
2503        Self::strict()
2504    }
2505}
2506
2507fn default_runtime_allowlist(mode: CompatibilityMode) -> BTreeSet<String> {
2508    if matches!(mode, CompatibilityMode::Strict) {
2509        return BTreeSet::new();
2510    }
2511
2512    [
2513        CgsePolicyRule::R01,
2514        CgsePolicyRule::R02,
2515        CgsePolicyRule::R03,
2516        CgsePolicyRule::R04,
2517        CgsePolicyRule::R05,
2518        CgsePolicyRule::R06,
2519        CgsePolicyRule::R07,
2520        CgsePolicyRule::R08,
2521        CgsePolicyRule::R09,
2522        CgsePolicyRule::R10,
2523        CgsePolicyRule::R11,
2524        CgsePolicyRule::R12,
2525        CgsePolicyRule::R13,
2526        CgsePolicyRule::R14,
2527    ]
2528    .into_iter()
2529    .flat_map(|rule| rule.hardened_allowlist().iter().copied())
2530    .map(str::to_owned)
2531    .collect()
2532}
2533
2534// ──────────────────────────────────────────────────────────────────────────────
2535// D5: Loss matrix calibration with Bayesian shrinkage prior
2536// ──────────────────────────────────────────────────────────────────────────────
2537
2538/// Prior distribution parameters for Bayesian shrinkage estimation.
2539///
2540/// Uses a normal prior N(prior_mean, prior_variance) that pulls empirical
2541/// estimates toward the prior when sample size is small or variance is high.
2542#[derive(Debug, Clone, Copy, PartialEq)]
2543pub struct BayesianShrinkagePrior {
2544    /// Prior mean for the loss value.
2545    pub prior_mean: f64,
2546    /// Prior variance (τ₀²) - controls how strongly we trust the prior.
2547    /// Smaller values mean stronger pull toward prior_mean.
2548    pub prior_variance: f64,
2549}
2550
2551impl BayesianShrinkagePrior {
2552    /// Default prior for false-negative loss (allowing incompatible operation).
2553    #[must_use]
2554    pub const fn for_false_negative() -> Self {
2555        Self {
2556            prior_mean: 100.0,
2557            prior_variance: 400.0, // σ = 20
2558        }
2559    }
2560
2561    /// Default prior for validation cost.
2562    #[must_use]
2563    pub const fn for_validate_cost() -> Self {
2564        Self {
2565            prior_mean: 25.0,
2566            prior_variance: 100.0, // σ = 10
2567        }
2568    }
2569
2570    /// Default prior for false-positive loss (rejecting compatible operation).
2571    #[must_use]
2572    pub const fn for_false_positive() -> Self {
2573        Self {
2574            prior_mean: 60.0,
2575            prior_variance: 225.0, // σ = 15
2576        }
2577    }
2578
2579    /// Compute shrinkage weight given empirical variance and sample count.
2580    ///
2581    /// w = τ₀² / (τ₀² + σ²/n)
2582    ///
2583    /// When n is small or σ² is large, w is small (trust prior more).
2584    /// When n is large and σ² is small, w approaches 1 (trust data more).
2585    #[must_use]
2586    pub fn shrinkage_weight(&self, empirical_variance: f64, sample_count: usize) -> f64 {
2587        if sample_count == 0 {
2588            return 0.0; // No data: use prior entirely
2589        }
2590        // Guard against zero prior_variance (invalid config)
2591        if self.prior_variance <= 0.0 {
2592            return 0.0; // Infinite prior certainty: use prior entirely
2593        }
2594        let n = sample_count as f64;
2595        // Guard against zero or under-estimated variance producing full-weight empirical
2596        // posterior with small sample sizes.
2597        let variance_of_mean = (empirical_variance / n).max(self.prior_variance / n);
2598        self.prior_variance / (self.prior_variance + variance_of_mean)
2599    }
2600
2601    /// Compute the posterior mean using shrinkage.
2602    ///
2603    /// μ_posterior = w * ȳ + (1-w) * μ₀
2604    #[must_use]
2605    pub fn posterior_mean(
2606        &self,
2607        empirical_mean: f64,
2608        empirical_variance: f64,
2609        sample_count: usize,
2610    ) -> f64 {
2611        let w = self.shrinkage_weight(empirical_variance, sample_count);
2612        w * empirical_mean + (1.0 - w) * self.prior_mean
2613    }
2614}
2615
2616/// A single observed loss from a decision outcome.
2617#[derive(Debug, Clone, PartialEq)]
2618pub struct LossObservation {
2619    /// Unix timestamp in milliseconds when the observation was recorded.
2620    pub ts_unix_ms: u128,
2621    /// The decision action that was taken.
2622    pub action: DecisionAction,
2623    /// True state: was the operation actually incompatible?
2624    pub was_incompatible: bool,
2625    /// Realized loss from this decision.
2626    pub realized_loss: f64,
2627    /// Which loss category this contributes to.
2628    pub loss_category: LossCategory,
2629}
2630
2631/// Categories of loss that can be observed.
2632#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
2633pub enum LossCategory {
2634    /// We allowed an incompatible operation (false negative).
2635    AllowFalseNegative,
2636    /// We paid validation cost.
2637    ValidateCost,
2638    /// We rejected a compatible operation (false positive).
2639    RejectFalsePositive,
2640}
2641
2642/// Aggregated statistics for a loss category.
2643#[derive(Debug, Clone, Copy, PartialEq, Default)]
2644pub struct LossCategoryStats {
2645    pub count: usize,
2646    pub sum: f64,
2647    pub sum_sq: f64,
2648}
2649
2650impl LossCategoryStats {
2651    #[must_use]
2652    pub fn mean(&self) -> f64 {
2653        if self.count == 0 {
2654            0.0
2655        } else {
2656            self.sum / self.count as f64
2657        }
2658    }
2659
2660    #[must_use]
2661    pub fn variance(&self) -> f64 {
2662        if self.count < 2 {
2663            return f64::INFINITY; // Unknown variance with < 2 samples
2664        }
2665        let n = self.count as f64;
2666        let mean = self.sum / n;
2667        (self.sum_sq / n) - (mean * mean)
2668    }
2669
2670    pub fn add(&mut self, value: f64) {
2671        self.count += 1;
2672        self.sum += value;
2673        self.sum_sq += value * value;
2674    }
2675}
2676
2677/// A loss matrix calibrated via Bayesian shrinkage from observed data.
2678#[derive(Debug, Clone, PartialEq)]
2679pub struct CalibratedLossMatrix {
2680    /// The calibrated loss values.
2681    pub matrix: LossMatrix,
2682    /// Shrinkage weights applied (0 = pure prior, 1 = pure empirical).
2683    pub shrinkage_weights: LossShrinkageWeights,
2684    /// Sample counts per category.
2685    pub sample_counts: LossSampleCounts,
2686    /// Calibration timestamp.
2687    pub calibrated_at_unix_ms: u128,
2688}
2689
2690/// Shrinkage weights for each loss category.
2691#[derive(Debug, Clone, Copy, PartialEq, Default)]
2692pub struct LossShrinkageWeights {
2693    pub allow_false_negative: f64,
2694    pub validate_cost: f64,
2695    pub reject_false_positive: f64,
2696}
2697
2698/// Sample counts for each loss category.
2699#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2700pub struct LossSampleCounts {
2701    pub allow_false_negative: usize,
2702    pub validate_cost: usize,
2703    pub reject_false_positive: usize,
2704}
2705
2706/// Calibrator that applies Bayesian shrinkage to loss observations.
2707#[derive(Debug, Clone)]
2708pub struct LossMatrixCalibrator {
2709    prior_false_negative: BayesianShrinkagePrior,
2710    prior_validate_cost: BayesianShrinkagePrior,
2711    prior_false_positive: BayesianShrinkagePrior,
2712    stats_false_negative: LossCategoryStats,
2713    stats_validate_cost: LossCategoryStats,
2714    stats_false_positive: LossCategoryStats,
2715}
2716
2717impl Default for LossMatrixCalibrator {
2718    fn default() -> Self {
2719        Self::new()
2720    }
2721}
2722
2723impl LossMatrixCalibrator {
2724    /// Create a new calibrator with default priors.
2725    #[must_use]
2726    pub fn new() -> Self {
2727        Self {
2728            prior_false_negative: BayesianShrinkagePrior::for_false_negative(),
2729            prior_validate_cost: BayesianShrinkagePrior::for_validate_cost(),
2730            prior_false_positive: BayesianShrinkagePrior::for_false_positive(),
2731            stats_false_negative: LossCategoryStats::default(),
2732            stats_validate_cost: LossCategoryStats::default(),
2733            stats_false_positive: LossCategoryStats::default(),
2734        }
2735    }
2736
2737    /// Create a calibrator with custom priors.
2738    #[must_use]
2739    pub fn with_priors(
2740        prior_false_negative: BayesianShrinkagePrior,
2741        prior_validate_cost: BayesianShrinkagePrior,
2742        prior_false_positive: BayesianShrinkagePrior,
2743    ) -> Self {
2744        Self {
2745            prior_false_negative,
2746            prior_validate_cost,
2747            prior_false_positive,
2748            stats_false_negative: LossCategoryStats::default(),
2749            stats_validate_cost: LossCategoryStats::default(),
2750            stats_false_positive: LossCategoryStats::default(),
2751        }
2752    }
2753
2754    /// Record an observation.
2755    pub fn observe(&mut self, obs: &LossObservation) {
2756        match obs.loss_category {
2757            LossCategory::AllowFalseNegative => {
2758                self.stats_false_negative.add(obs.realized_loss);
2759            }
2760            LossCategory::ValidateCost => {
2761                self.stats_validate_cost.add(obs.realized_loss);
2762            }
2763            LossCategory::RejectFalsePositive => {
2764                self.stats_false_positive.add(obs.realized_loss);
2765            }
2766        }
2767    }
2768
2769    /// Record multiple observations.
2770    pub fn observe_batch(&mut self, observations: &[LossObservation]) {
2771        for obs in observations {
2772            self.observe(obs);
2773        }
2774    }
2775
2776    /// Produce a calibrated loss matrix using current observations.
2777    #[must_use]
2778    pub fn calibrate(&self) -> CalibratedLossMatrix {
2779        let w_fn = self.prior_false_negative.shrinkage_weight(
2780            self.stats_false_negative.variance(),
2781            self.stats_false_negative.count,
2782        );
2783        let w_vc = self.prior_validate_cost.shrinkage_weight(
2784            self.stats_validate_cost.variance(),
2785            self.stats_validate_cost.count,
2786        );
2787        let w_fp = self.prior_false_positive.shrinkage_weight(
2788            self.stats_false_positive.variance(),
2789            self.stats_false_positive.count,
2790        );
2791
2792        let calibrated_fn = self.prior_false_negative.posterior_mean(
2793            self.stats_false_negative.mean(),
2794            self.stats_false_negative.variance(),
2795            self.stats_false_negative.count,
2796        );
2797        let calibrated_vc = self.prior_validate_cost.posterior_mean(
2798            self.stats_validate_cost.mean(),
2799            self.stats_validate_cost.variance(),
2800            self.stats_validate_cost.count,
2801        );
2802        let calibrated_fp = self.prior_false_positive.posterior_mean(
2803            self.stats_false_positive.mean(),
2804            self.stats_false_positive.variance(),
2805            self.stats_false_positive.count,
2806        );
2807
2808        CalibratedLossMatrix {
2809            matrix: LossMatrix {
2810                allow_false_negative: calibrated_fn,
2811                validate_cost: calibrated_vc,
2812                reject_false_positive: calibrated_fp,
2813            },
2814            shrinkage_weights: LossShrinkageWeights {
2815                allow_false_negative: w_fn,
2816                validate_cost: w_vc,
2817                reject_false_positive: w_fp,
2818            },
2819            sample_counts: LossSampleCounts {
2820                allow_false_negative: self.stats_false_negative.count,
2821                validate_cost: self.stats_validate_cost.count,
2822                reject_false_positive: self.stats_false_positive.count,
2823            },
2824            calibrated_at_unix_ms: unix_time_ms(),
2825        }
2826    }
2827
2828    /// Minimum samples needed before shrinkage weight exceeds threshold.
2829    ///
2830    /// Returns the sample count n where w >= threshold, assuming empirical
2831    /// variance equals a reference variance (prior_variance by default).
2832    #[must_use]
2833    pub fn samples_for_confidence(_prior: &BayesianShrinkagePrior, threshold: f64) -> usize {
2834        // w = τ₀² / (τ₀² + σ²/n)
2835        // threshold = τ₀² / (τ₀² + σ²/n)
2836        // threshold * (τ₀² + σ²/n) = τ₀²
2837        // threshold * σ²/n = τ₀² - threshold * τ₀²
2838        // threshold * σ²/n = τ₀² * (1 - threshold)
2839        // n = threshold * σ² / (τ₀² * (1 - threshold))
2840        //
2841        // Assuming σ² = τ₀² (empirical variance matches prior):
2842        // n = threshold / (1 - threshold)
2843        if threshold >= 1.0 {
2844            return usize::MAX;
2845        }
2846        if threshold <= 0.0 {
2847            return 0;
2848        }
2849        let n = threshold / (1.0 - threshold);
2850        let n_floor = n.floor();
2851        if (n - n_floor).abs() < 1e-9 {
2852            n_floor as usize
2853        } else {
2854            n.ceil() as usize
2855        }
2856    }
2857
2858    /// Reset accumulated statistics.
2859    pub fn reset(&mut self) {
2860        self.stats_false_negative = LossCategoryStats::default();
2861        self.stats_validate_cost = LossCategoryStats::default();
2862        self.stats_false_positive = LossCategoryStats::default();
2863    }
2864
2865    /// Get current sample counts.
2866    #[must_use]
2867    pub fn sample_counts(&self) -> LossSampleCounts {
2868        LossSampleCounts {
2869            allow_false_negative: self.stats_false_negative.count,
2870            validate_cost: self.stats_validate_cost.count,
2871            reject_false_positive: self.stats_false_positive.count,
2872        }
2873    }
2874}
2875
2876/// Decision-theoretic selector used by runtime guards.
2877///
2878/// We choose `argmin_a E[L(a, state)]` with:
2879/// - `p`: estimated incompatibility probability
2880/// - states: `{compatible, incompatible}`
2881/// - actions: `{allow, full_validate, fail_closed}`
2882#[must_use]
2883pub fn decision_theoretic_action(
2884    mode: CompatibilityMode,
2885    incompatibility_probability: f64,
2886    unknown_incompatible_feature: bool,
2887) -> DecisionAction {
2888    if unknown_incompatible_feature || incompatibility_probability.is_nan() {
2889        return DecisionAction::FailClosed;
2890    }
2891
2892    let p = incompatibility_probability.clamp(0.0, 1.0);
2893    let loss = match mode {
2894        CompatibilityMode::Strict => LossMatrix::strict_default(),
2895        CompatibilityMode::Hardened => LossMatrix::hardened_default(),
2896    };
2897
2898    // Expected loss for each action:
2899    // If we ALLOW: we lose allow_false_negative if it IS incompatible (prob p).
2900    let e_allow = p * loss.allow_false_negative;
2901    // If we VALIDATE: we lose validate_cost regardless of state.
2902    let e_validate = loss.validate_cost;
2903    // If we FAIL_CLOSED: we lose reject_false_positive if it WAS compatible (prob 1-p).
2904    let e_fail_closed = (1.0 - p) * loss.reject_false_positive;
2905
2906    if e_allow <= e_validate && e_allow <= e_fail_closed {
2907        DecisionAction::Allow
2908    } else if e_validate <= e_fail_closed {
2909        DecisionAction::FullValidate
2910    } else {
2911        DecisionAction::FailClosed
2912    }
2913}
2914
2915// ──────────────────────────────────────────────────────────────────────────────
2916// D4: Hardened-mode Bayesian admission controller
2917// ──────────────────────────────────────────────────────────────────────────────
2918
2919/// Result of an admission decision from the Bayesian controller.
2920#[derive(Debug, Clone, PartialEq)]
2921pub struct AdmissionDecision {
2922    /// The action to take.
2923    pub action: DecisionAction,
2924    /// Expected loss for the chosen action.
2925    pub expected_loss: f64,
2926    /// Confidence in the decision (shrinkage-adjusted).
2927    pub confidence: f64,
2928    /// Whether the hardened-mode override was applied.
2929    pub hardened_override: bool,
2930    /// The probability estimate used.
2931    pub incompatibility_probability: f64,
2932    /// Rationale for the decision.
2933    pub rationale: String,
2934}
2935
2936/// Configuration for the Bayesian admission controller.
2937#[derive(Debug, Clone, Copy, PartialEq)]
2938pub struct AdmissionControllerConfig {
2939    /// Minimum confidence required to trust empirical data (0-1).
2940    /// Below this, we fall back to conservative hardened-mode defaults.
2941    pub min_confidence_threshold: f64,
2942    /// In hardened mode, apply extra penalty to allow decisions.
2943    pub hardened_allow_penalty: f64,
2944    /// Maximum allowed incompatibility probability before auto-reject.
2945    pub max_incompatibility_probability: f64,
2946    /// Minimum samples before considering empirical data.
2947    pub min_samples_for_empirical: usize,
2948}
2949
2950impl Default for AdmissionControllerConfig {
2951    fn default() -> Self {
2952        Self {
2953            min_confidence_threshold: 0.3,
2954            hardened_allow_penalty: 20.0,
2955            max_incompatibility_probability: 0.85,
2956            min_samples_for_empirical: 5,
2957        }
2958    }
2959}
2960
2961impl AdmissionControllerConfig {
2962    /// More conservative config for high-risk environments.
2963    #[must_use]
2964    pub const fn conservative() -> Self {
2965        Self {
2966            min_confidence_threshold: 0.5,
2967            hardened_allow_penalty: 40.0,
2968            max_incompatibility_probability: 0.7,
2969            min_samples_for_empirical: 10,
2970        }
2971    }
2972}
2973
2974/// Bayesian admission controller for hardened mode.
2975///
2976/// Uses calibrated loss matrices to make admission decisions, updating
2977/// beliefs based on observed outcomes and applying conservative overrides
2978/// when confidence is low.
2979#[derive(Debug, Clone)]
2980pub struct BayesianAdmissionController {
2981    mode: CompatibilityMode,
2982    config: AdmissionControllerConfig,
2983    calibrator: LossMatrixCalibrator,
2984    decision_count: u64,
2985    override_count: u64,
2986}
2987
2988impl BayesianAdmissionController {
2989    /// Create a new admission controller for the given mode.
2990    #[must_use]
2991    pub fn new(mode: CompatibilityMode) -> Self {
2992        Self {
2993            mode,
2994            config: AdmissionControllerConfig::default(),
2995            calibrator: LossMatrixCalibrator::new(),
2996            decision_count: 0,
2997            override_count: 0,
2998        }
2999    }
3000
3001    /// Create an admission controller with custom config.
3002    #[must_use]
3003    pub fn with_config(mode: CompatibilityMode, config: AdmissionControllerConfig) -> Self {
3004        Self {
3005            mode,
3006            config,
3007            calibrator: LossMatrixCalibrator::new(),
3008            decision_count: 0,
3009            override_count: 0,
3010        }
3011    }
3012
3013    /// Get the current compatibility mode.
3014    #[must_use]
3015    pub fn mode(&self) -> CompatibilityMode {
3016        self.mode
3017    }
3018
3019    /// Get the current config.
3020    #[must_use]
3021    pub fn config(&self) -> &AdmissionControllerConfig {
3022        &self.config
3023    }
3024
3025    /// Make an admission decision.
3026    #[must_use]
3027    pub fn decide(&mut self, incompatibility_probability: f64) -> AdmissionDecision {
3028        self.decision_count += 1;
3029
3030        // Handle edge cases
3031        if incompatibility_probability.is_nan() {
3032            return AdmissionDecision {
3033                action: DecisionAction::FailClosed,
3034                expected_loss: f64::INFINITY,
3035                confidence: 0.0,
3036                hardened_override: true,
3037                incompatibility_probability: f64::NAN,
3038                rationale: "NaN probability: fail closed".to_owned(),
3039            };
3040        }
3041
3042        let p = incompatibility_probability.clamp(0.0, 1.0);
3043
3044        // In hardened mode, auto-reject high-risk operations
3045        if self.mode == CompatibilityMode::Hardened
3046            && p > self.config.max_incompatibility_probability
3047        {
3048            self.override_count += 1;
3049            return AdmissionDecision {
3050                action: DecisionAction::FailClosed,
3051                expected_loss: p * 100.0,
3052                confidence: 1.0,
3053                hardened_override: true,
3054                incompatibility_probability: p,
3055                rationale: format!(
3056                    "Hardened mode: probability {} exceeds threshold {}",
3057                    p, self.config.max_incompatibility_probability
3058                ),
3059            };
3060        }
3061
3062        // Get calibrated loss matrix
3063        let calibrated = self.calibrator.calibrate();
3064        let samples = calibrated.sample_counts;
3065        let weights = calibrated.shrinkage_weights;
3066
3067        // Compute confidence as minimum shrinkage weight
3068        let confidence = weights
3069            .allow_false_negative
3070            .min(weights.validate_cost)
3071            .min(weights.reject_false_positive);
3072
3073        // If confidence is too low or insufficient samples, use defaults with hardened penalty
3074        let total_samples =
3075            samples.allow_false_negative + samples.validate_cost + samples.reject_false_positive;
3076        let use_empirical = confidence >= self.config.min_confidence_threshold
3077            && total_samples >= self.config.min_samples_for_empirical;
3078
3079        let loss = if use_empirical {
3080            calibrated.matrix
3081        } else {
3082            match self.mode {
3083                CompatibilityMode::Strict => LossMatrix::strict_default(),
3084                CompatibilityMode::Hardened => LossMatrix::hardened_default(),
3085            }
3086        };
3087
3088        // Compute expected losses with hardened-mode penalty
3089        let allow_loss = if self.mode == CompatibilityMode::Hardened {
3090            loss.allow_false_negative + self.config.hardened_allow_penalty
3091        } else {
3092            loss.allow_false_negative
3093        };
3094
3095        let e_allow = p * allow_loss;
3096        let e_validate = loss.validate_cost;
3097        let e_fail_closed = (1.0 - p) * loss.reject_false_positive;
3098
3099        let (action, expected_loss, rationale) =
3100            if e_allow <= e_validate && e_allow <= e_fail_closed {
3101                (
3102                    DecisionAction::Allow,
3103                    e_allow,
3104                    format!(
3105                        "Allow: E[L]={:.2} < validate={:.2}, fail_closed={:.2}",
3106                        e_allow, e_validate, e_fail_closed
3107                    ),
3108                )
3109            } else if e_validate <= e_fail_closed {
3110                (
3111                    DecisionAction::FullValidate,
3112                    e_validate,
3113                    format!(
3114                        "Validate: E[L]={:.2} < fail_closed={:.2}",
3115                        e_validate, e_fail_closed
3116                    ),
3117                )
3118            } else {
3119                (
3120                    DecisionAction::FailClosed,
3121                    e_fail_closed,
3122                    format!(
3123                        "FailClosed: E[L]={:.2} < validate={:.2}",
3124                        e_fail_closed, e_validate
3125                    ),
3126                )
3127            };
3128
3129        AdmissionDecision {
3130            action,
3131            expected_loss,
3132            confidence,
3133            hardened_override: false,
3134            incompatibility_probability: p,
3135            rationale,
3136        }
3137    }
3138
3139    /// Record the outcome of a decision for future calibration.
3140    pub fn record_outcome(&mut self, decision: &AdmissionDecision, was_incompatible: bool) {
3141        let (category, realized_loss) = match decision.action {
3142            DecisionAction::Allow if was_incompatible => {
3143                // False negative: we allowed but it was incompatible
3144                (LossCategory::AllowFalseNegative, 100.0)
3145            }
3146            DecisionAction::Allow => {
3147                // Correct allow: no loss
3148                return;
3149            }
3150            DecisionAction::FullValidate => {
3151                // Validation cost is always paid
3152                (LossCategory::ValidateCost, 25.0)
3153            }
3154            DecisionAction::FailClosed if !was_incompatible => {
3155                // False positive: we rejected but it was compatible
3156                (LossCategory::RejectFalsePositive, 60.0)
3157            }
3158            DecisionAction::FailClosed => {
3159                // Correct rejection: no loss
3160                return;
3161            }
3162        };
3163
3164        self.calibrator.observe(&LossObservation {
3165            ts_unix_ms: unix_time_ms(),
3166            action: decision.action,
3167            was_incompatible,
3168            realized_loss,
3169            loss_category: category,
3170        });
3171    }
3172
3173    /// Get statistics about controller behavior.
3174    #[must_use]
3175    pub fn stats(&self) -> AdmissionControllerStats {
3176        AdmissionControllerStats {
3177            decision_count: self.decision_count,
3178            override_count: self.override_count,
3179            sample_counts: self.calibrator.sample_counts(),
3180            calibrated_matrix: self.calibrator.calibrate(),
3181        }
3182    }
3183
3184    /// Reset the controller state (keeps config).
3185    pub fn reset(&mut self) {
3186        self.calibrator.reset();
3187        self.decision_count = 0;
3188        self.override_count = 0;
3189    }
3190}
3191
3192/// Statistics from the admission controller.
3193#[derive(Debug, Clone, PartialEq)]
3194pub struct AdmissionControllerStats {
3195    /// Total decisions made.
3196    pub decision_count: u64,
3197    /// Decisions where hardened-mode override was applied.
3198    pub override_count: u64,
3199    /// Sample counts per loss category.
3200    pub sample_counts: LossSampleCounts,
3201    /// Current calibrated loss matrix.
3202    pub calibrated_matrix: CalibratedLossMatrix,
3203}
3204
3205// ──────────────────────────────────────────────────────────────────────────────
3206// D6: Calibrated confidence with Brier score gate
3207// ──────────────────────────────────────────────────────────────────────────────
3208
3209/// A single prediction-outcome pair for Brier score computation.
3210#[derive(Debug, Clone, Copy, PartialEq)]
3211pub struct PredictionOutcome {
3212    /// Predicted probability of incompatibility (0-1).
3213    pub predicted_probability: f64,
3214    /// Actual outcome: 1.0 if incompatible, 0.0 if compatible.
3215    pub actual_outcome: f64,
3216    /// Timestamp of the observation.
3217    pub ts_unix_ms: u128,
3218}
3219
3220/// Brier score calibration tracker.
3221///
3222/// Computes Brier score = (1/n) * Σ(p - o)² where p is predicted probability
3223/// and o is actual outcome (0 or 1). Perfect calibration yields Brier score = 0.
3224#[derive(Debug, Clone, Default)]
3225pub struct BrierScoreTracker {
3226    predictions: Vec<PredictionOutcome>,
3227    sum_squared_error: f64,
3228}
3229
3230impl BrierScoreTracker {
3231    /// Create a new empty tracker.
3232    #[must_use]
3233    pub fn new() -> Self {
3234        Self::default()
3235    }
3236
3237    /// Record a prediction and its outcome.
3238    pub fn record(&mut self, predicted_probability: f64, was_incompatible: bool) {
3239        let actual = if was_incompatible { 1.0 } else { 0.0 };
3240        let p = predicted_probability.clamp(0.0, 1.0);
3241        let error = p - actual;
3242        let sq_error = error * error;
3243
3244        self.predictions.push(PredictionOutcome {
3245            predicted_probability: p,
3246            actual_outcome: actual,
3247            ts_unix_ms: unix_time_ms(),
3248        });
3249        self.sum_squared_error += sq_error;
3250    }
3251
3252    /// Get the current Brier score (0 = perfect, 1 = worst).
3253    #[must_use]
3254    pub fn brier_score(&self) -> f64 {
3255        if self.predictions.is_empty() {
3256            return 0.5; // Neutral score when no data
3257        }
3258        self.sum_squared_error / self.predictions.len() as f64
3259    }
3260
3261    /// Get number of predictions recorded.
3262    #[must_use]
3263    pub fn count(&self) -> usize {
3264        self.predictions.len()
3265    }
3266
3267    /// Check if predictions are well-calibrated (Brier score below threshold).
3268    #[must_use]
3269    pub fn is_calibrated(&self, threshold: f64) -> bool {
3270        self.count() >= 10 && self.brier_score() <= threshold
3271    }
3272
3273    /// Reset the tracker.
3274    pub fn reset(&mut self) {
3275        self.predictions.clear();
3276        self.sum_squared_error = 0.0;
3277    }
3278
3279    /// Get calibration metrics.
3280    #[must_use]
3281    pub fn metrics(&self) -> BrierScoreMetrics {
3282        let n = self.predictions.len();
3283        if n == 0 {
3284            return BrierScoreMetrics {
3285                brier_score: 0.5,
3286                sample_count: 0,
3287                reliability: 0.0,
3288                resolution: 0.0,
3289                base_rate: 0.5,
3290            };
3291        }
3292
3293        let brier = self.brier_score();
3294        let base_rate = self
3295            .predictions
3296            .iter()
3297            .map(|p| p.actual_outcome)
3298            .sum::<f64>()
3299            / n as f64;
3300
3301        // Reliability: how well do predicted probabilities match observed frequencies?
3302        // Lower is better. This is a simplified version.
3303        let reliability = self.compute_reliability();
3304
3305        // Resolution: ability to separate positive and negative cases
3306        // Higher is better.
3307        let resolution = self.compute_resolution(base_rate);
3308
3309        BrierScoreMetrics {
3310            brier_score: brier,
3311            sample_count: n,
3312            reliability,
3313            resolution,
3314            base_rate,
3315        }
3316    }
3317
3318    fn compute_reliability(&self) -> f64 {
3319        if self.predictions.is_empty() {
3320            return 0.0;
3321        }
3322
3323        // Bin predictions into 10 bins and compute reliability
3324        let mut bins: [Vec<&PredictionOutcome>; 10] = Default::default();
3325        for pred in &self.predictions {
3326            let bin_idx = (pred.predicted_probability * 10.0).floor().min(9.0) as usize;
3327            bins[bin_idx].push(pred);
3328        }
3329
3330        let mut reliability = 0.0;
3331        let n = self.predictions.len() as f64;
3332
3333        for (i, bin) in bins.iter().enumerate() {
3334            if bin.is_empty() {
3335                continue;
3336            }
3337            let bin_prob = (i as f64 + 0.5) / 10.0; // Bin center
3338            let actual_freq = bin.iter().map(|p| p.actual_outcome).sum::<f64>() / bin.len() as f64;
3339            let error = bin_prob - actual_freq;
3340            reliability += (bin.len() as f64 / n) * error * error;
3341        }
3342
3343        reliability
3344    }
3345
3346    fn compute_resolution(&self, base_rate: f64) -> f64 {
3347        if self.predictions.is_empty() {
3348            return 0.0;
3349        }
3350
3351        // Resolution measures how much predictions deviate from base rate
3352        let mut resolution = 0.0;
3353        let n = self.predictions.len() as f64;
3354
3355        // Bin predictions and compute resolution
3356        let mut bins: [Vec<&PredictionOutcome>; 10] = Default::default();
3357        for pred in &self.predictions {
3358            let bin_idx = (pred.predicted_probability * 10.0).floor().min(9.0) as usize;
3359            bins[bin_idx].push(pred);
3360        }
3361
3362        for bin in &bins {
3363            if bin.is_empty() {
3364                continue;
3365            }
3366            let actual_freq = bin.iter().map(|p| p.actual_outcome).sum::<f64>() / bin.len() as f64;
3367            let deviation = actual_freq - base_rate;
3368            resolution += (bin.len() as f64 / n) * deviation * deviation;
3369        }
3370
3371        resolution
3372    }
3373}
3374
3375/// Detailed Brier score metrics.
3376#[derive(Debug, Clone, Copy, PartialEq)]
3377pub struct BrierScoreMetrics {
3378    /// Overall Brier score (0 = perfect, 1 = worst).
3379    pub brier_score: f64,
3380    /// Number of samples used.
3381    pub sample_count: usize,
3382    /// Reliability component (lower is better).
3383    pub reliability: f64,
3384    /// Resolution component (higher is better).
3385    pub resolution: f64,
3386    /// Base rate of positive outcomes.
3387    pub base_rate: f64,
3388}
3389
3390/// Gate that blocks decisions when calibration is poor.
3391#[derive(Debug, Clone)]
3392pub struct BrierScoreGate {
3393    tracker: BrierScoreTracker,
3394    /// Maximum acceptable Brier score (default 0.25).
3395    threshold: f64,
3396    /// Minimum samples required before gate is active.
3397    min_samples: usize,
3398    /// Action to take when gate blocks.
3399    block_action: DecisionAction,
3400}
3401
3402impl Default for BrierScoreGate {
3403    fn default() -> Self {
3404        Self::new()
3405    }
3406}
3407
3408impl BrierScoreGate {
3409    /// Create a new gate with default settings.
3410    #[must_use]
3411    pub fn new() -> Self {
3412        Self {
3413            tracker: BrierScoreTracker::new(),
3414            threshold: 0.25,
3415            min_samples: 10,
3416            block_action: DecisionAction::FullValidate,
3417        }
3418    }
3419
3420    /// Create a gate with custom settings.
3421    #[must_use]
3422    pub fn with_settings(threshold: f64, min_samples: usize, block_action: DecisionAction) -> Self {
3423        Self {
3424            tracker: BrierScoreTracker::new(),
3425            threshold: threshold.clamp(0.0, 1.0),
3426            min_samples,
3427            block_action,
3428        }
3429    }
3430
3431    /// Record a prediction outcome.
3432    pub fn record(&mut self, predicted_probability: f64, was_incompatible: bool) {
3433        self.tracker.record(predicted_probability, was_incompatible);
3434    }
3435
3436    /// Check if the gate is currently open (calibration is acceptable).
3437    #[must_use]
3438    pub fn is_open(&self) -> bool {
3439        // Gate is open if we don't have enough samples yet OR calibration is good
3440        self.tracker.count() < self.min_samples || self.tracker.brier_score() <= self.threshold
3441    }
3442
3443    /// Apply the gate to a proposed action.
3444    ///
3445    /// Returns the action unchanged if gate is open, or block_action if closed.
3446    #[must_use]
3447    pub fn apply(&self, proposed_action: DecisionAction) -> GateDecision {
3448        if self.is_open() {
3449            GateDecision {
3450                action: proposed_action,
3451                gate_applied: false,
3452                brier_score: self.tracker.brier_score(),
3453                sample_count: self.tracker.count(),
3454            }
3455        } else {
3456            GateDecision {
3457                action: self.block_action,
3458                gate_applied: true,
3459                brier_score: self.tracker.brier_score(),
3460                sample_count: self.tracker.count(),
3461            }
3462        }
3463    }
3464
3465    /// Get current Brier score.
3466    #[must_use]
3467    pub fn brier_score(&self) -> f64 {
3468        self.tracker.brier_score()
3469    }
3470
3471    /// Get detailed metrics.
3472    #[must_use]
3473    pub fn metrics(&self) -> BrierScoreMetrics {
3474        self.tracker.metrics()
3475    }
3476
3477    /// Reset the gate.
3478    pub fn reset(&mut self) {
3479        self.tracker.reset();
3480    }
3481
3482    /// Get the threshold.
3483    #[must_use]
3484    pub fn threshold(&self) -> f64 {
3485        self.threshold
3486    }
3487}
3488
3489/// Result of applying the Brier score gate.
3490#[derive(Debug, Clone, Copy, PartialEq)]
3491pub struct GateDecision {
3492    /// The action after gate is applied.
3493    pub action: DecisionAction,
3494    /// Whether the gate blocked the original action.
3495    pub gate_applied: bool,
3496    /// Current Brier score.
3497    pub brier_score: f64,
3498    /// Number of samples in the tracker.
3499    pub sample_count: usize,
3500}
3501
3502// ──────────────────────────────────────────────────────────────────────────────
3503// E12: Tail-stability gate: 30-sample p99 distribution shift
3504// ──────────────────────────────────────────────────────────────────────────────
3505
3506/// A single latency sample with timestamp.
3507#[derive(Debug, Clone, Copy, PartialEq)]
3508pub struct TailSample {
3509    /// Latency value (e.g., milliseconds).
3510    pub value: f64,
3511    /// Timestamp when the sample was recorded.
3512    pub ts_unix_ms: u128,
3513}
3514
3515/// Configuration for the tail stability gate.
3516#[derive(Debug, Clone, Copy, PartialEq)]
3517pub struct TailStabilityConfig {
3518    /// Number of samples required for baseline.
3519    pub baseline_samples: usize,
3520    /// Number of samples in the sliding window for comparison.
3521    pub window_samples: usize,
3522    /// Maximum allowed ratio of current p99 to baseline p99.
3523    pub max_p99_ratio: f64,
3524    /// Percentile to track (e.g., 0.99 for p99).
3525    pub percentile: f64,
3526}
3527
3528impl Default for TailStabilityConfig {
3529    fn default() -> Self {
3530        Self {
3531            baseline_samples: 30,
3532            window_samples: 30,
3533            max_p99_ratio: 1.2, // 20% regression threshold
3534            percentile: 0.99,
3535        }
3536    }
3537}
3538
3539impl TailStabilityConfig {
3540    /// Stricter config for production.
3541    #[must_use]
3542    pub const fn strict() -> Self {
3543        Self {
3544            baseline_samples: 50,
3545            window_samples: 30,
3546            max_p99_ratio: 1.1, // 10% regression threshold
3547            percentile: 0.99,
3548        }
3549    }
3550}
3551
3552/// Tracker for tail latency stability.
3553///
3554/// Maintains a baseline and a sliding window of recent samples, detecting
3555/// distribution shifts based on p99 changes.
3556#[derive(Debug, Clone)]
3557pub struct TailStabilityTracker {
3558    config: TailStabilityConfig,
3559    baseline: Vec<TailSample>,
3560    window: Vec<TailSample>,
3561    baseline_p99: Option<f64>,
3562    baseline_locked: bool,
3563}
3564
3565impl Default for TailStabilityTracker {
3566    fn default() -> Self {
3567        Self::new()
3568    }
3569}
3570
3571impl TailStabilityTracker {
3572    /// Create a new tracker with default config.
3573    #[must_use]
3574    pub fn new() -> Self {
3575        Self::with_config(TailStabilityConfig::default())
3576    }
3577
3578    /// Create a tracker with custom config.
3579    #[must_use]
3580    pub fn with_config(config: TailStabilityConfig) -> Self {
3581        Self {
3582            config,
3583            baseline: Vec::new(),
3584            window: Vec::new(),
3585            baseline_p99: None,
3586            baseline_locked: false,
3587        }
3588    }
3589
3590    /// Record a new latency sample.
3591    pub fn record(&mut self, value: f64) {
3592        let sample = TailSample {
3593            value,
3594            ts_unix_ms: unix_time_ms(),
3595        };
3596
3597        if !self.baseline_locked {
3598            // Still collecting baseline
3599            self.baseline.push(sample);
3600            if self.baseline.len() >= self.config.baseline_samples {
3601                self.lock_baseline();
3602            }
3603        } else {
3604            // Baseline locked, update sliding window
3605            self.window.push(sample);
3606            if self.window.len() > self.config.window_samples {
3607                self.window.remove(0);
3608            }
3609        }
3610    }
3611
3612    /// Lock the baseline and compute baseline p99.
3613    fn lock_baseline(&mut self) {
3614        if self.baseline.is_empty() {
3615            return;
3616        }
3617        self.baseline_p99 = Some(Self::compute_percentile(
3618            &self.baseline,
3619            self.config.percentile,
3620        ));
3621        self.baseline_locked = true;
3622    }
3623
3624    /// Force lock the baseline with current samples (even if not full).
3625    pub fn force_lock_baseline(&mut self) {
3626        if !self.baseline.is_empty() {
3627            self.lock_baseline();
3628        }
3629    }
3630
3631    /// Compute percentile of samples.
3632    fn compute_percentile(samples: &[TailSample], percentile: f64) -> f64 {
3633        if samples.is_empty() {
3634            return 0.0;
3635        }
3636        let mut values: Vec<f64> = samples.iter().map(|s| s.value).collect();
3637        values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
3638
3639        let idx = (values.len() as f64 * percentile).floor() as usize;
3640        let idx = idx.min(values.len() - 1);
3641        values[idx]
3642    }
3643
3644    /// Get the current window p99.
3645    #[must_use]
3646    pub fn window_p99(&self) -> Option<f64> {
3647        if self.window.is_empty() {
3648            return None;
3649        }
3650        Some(Self::compute_percentile(
3651            &self.window,
3652            self.config.percentile,
3653        ))
3654    }
3655
3656    /// Get the baseline p99.
3657    #[must_use]
3658    pub fn baseline_p99(&self) -> Option<f64> {
3659        self.baseline_p99
3660    }
3661
3662    /// Check if there is a distribution shift (regression).
3663    #[must_use]
3664    pub fn has_shift(&self) -> bool {
3665        match (self.baseline_p99, self.window_p99()) {
3666            (Some(base), Some(current)) if base > 0.0 => current / base > self.config.max_p99_ratio,
3667            _ => false,
3668        }
3669    }
3670
3671    /// Get the current p99 ratio (window / baseline).
3672    #[must_use]
3673    pub fn p99_ratio(&self) -> Option<f64> {
3674        match (self.baseline_p99, self.window_p99()) {
3675            (Some(base), Some(current)) if base > 0.0 => Some(current / base),
3676            _ => None,
3677        }
3678    }
3679
3680    /// Get status of the tracker.
3681    #[must_use]
3682    pub fn status(&self) -> TailStabilityStatus {
3683        TailStabilityStatus {
3684            baseline_samples: self.baseline.len(),
3685            window_samples: self.window.len(),
3686            baseline_locked: self.baseline_locked,
3687            baseline_p99: self.baseline_p99,
3688            window_p99: self.window_p99(),
3689            p99_ratio: self.p99_ratio(),
3690            has_shift: self.has_shift(),
3691        }
3692    }
3693
3694    /// Reset the tracker.
3695    pub fn reset(&mut self) {
3696        self.baseline.clear();
3697        self.window.clear();
3698        self.baseline_p99 = None;
3699        self.baseline_locked = false;
3700    }
3701
3702    /// Number of samples in baseline.
3703    #[must_use]
3704    pub fn baseline_count(&self) -> usize {
3705        self.baseline.len()
3706    }
3707
3708    /// Number of samples in window.
3709    #[must_use]
3710    pub fn window_count(&self) -> usize {
3711        self.window.len()
3712    }
3713
3714    /// Whether baseline is locked.
3715    #[must_use]
3716    pub fn is_baseline_locked(&self) -> bool {
3717        self.baseline_locked
3718    }
3719}
3720
3721/// Status snapshot from the tail stability tracker.
3722#[derive(Debug, Clone, Copy, PartialEq)]
3723pub struct TailStabilityStatus {
3724    /// Number of baseline samples.
3725    pub baseline_samples: usize,
3726    /// Number of window samples.
3727    pub window_samples: usize,
3728    /// Whether baseline is locked.
3729    pub baseline_locked: bool,
3730    /// Baseline p99 (if locked).
3731    pub baseline_p99: Option<f64>,
3732    /// Current window p99.
3733    pub window_p99: Option<f64>,
3734    /// Ratio of window p99 to baseline p99.
3735    pub p99_ratio: Option<f64>,
3736    /// Whether a shift (regression) is detected.
3737    pub has_shift: bool,
3738}
3739
3740/// Gate that blocks when tail latency regresses.
3741#[derive(Debug, Clone)]
3742pub struct TailStabilityGate {
3743    tracker: TailStabilityTracker,
3744    block_action: DecisionAction,
3745}
3746
3747impl Default for TailStabilityGate {
3748    fn default() -> Self {
3749        Self::new()
3750    }
3751}
3752
3753impl TailStabilityGate {
3754    /// Create a new gate with default settings.
3755    #[must_use]
3756    pub fn new() -> Self {
3757        Self {
3758            tracker: TailStabilityTracker::new(),
3759            block_action: DecisionAction::FailClosed,
3760        }
3761    }
3762
3763    /// Create a gate with custom config and block action.
3764    #[must_use]
3765    pub fn with_config(config: TailStabilityConfig, block_action: DecisionAction) -> Self {
3766        Self {
3767            tracker: TailStabilityTracker::with_config(config),
3768            block_action,
3769        }
3770    }
3771
3772    /// Record a latency sample.
3773    pub fn record(&mut self, latency: f64) {
3774        self.tracker.record(latency);
3775    }
3776
3777    /// Check if the gate is open (no regression detected).
3778    #[must_use]
3779    pub fn is_open(&self) -> bool {
3780        !self.tracker.has_shift()
3781    }
3782
3783    /// Apply the gate to a proposed action.
3784    #[must_use]
3785    pub fn apply(&self, proposed_action: DecisionAction) -> TailGateDecision {
3786        let status = self.tracker.status();
3787        if self.is_open() {
3788            TailGateDecision {
3789                action: proposed_action,
3790                gate_applied: false,
3791                status,
3792            }
3793        } else {
3794            TailGateDecision {
3795                action: self.block_action,
3796                gate_applied: true,
3797                status,
3798            }
3799        }
3800    }
3801
3802    /// Get the current status.
3803    #[must_use]
3804    pub fn status(&self) -> TailStabilityStatus {
3805        self.tracker.status()
3806    }
3807
3808    /// Force lock baseline early.
3809    pub fn force_lock_baseline(&mut self) {
3810        self.tracker.force_lock_baseline();
3811    }
3812
3813    /// Reset the gate.
3814    pub fn reset(&mut self) {
3815        self.tracker.reset();
3816    }
3817}
3818
3819/// Result of applying the tail stability gate.
3820#[derive(Debug, Clone, Copy, PartialEq)]
3821pub struct TailGateDecision {
3822    /// The action after gate is applied.
3823    pub action: DecisionAction,
3824    /// Whether the gate blocked the original action.
3825    pub gate_applied: bool,
3826    /// Current stability status.
3827    pub status: TailStabilityStatus,
3828}
3829
3830// ──────────────────────────────────────────────────────────────────────────────
3831// G2: Bayesian rare-event estimation with Generalized Pareto Distribution (GPD)
3832// ──────────────────────────────────────────────────────────────────────────────
3833
3834/// Parameters of the Generalized Pareto Distribution.
3835///
3836/// The GPD CDF is:
3837/// - F(x) = 1 - (1 + ξx/σ)^(-1/ξ) for ξ ≠ 0
3838/// - F(x) = 1 - exp(-x/σ) for ξ = 0 (exponential)
3839///
3840/// The shape parameter ξ (xi) determines tail behavior:
3841/// - ξ > 0: heavy-tailed (Pareto-type), infinite higher moments
3842/// - ξ = 0: exponential tails (light-tailed)
3843/// - ξ < 0: bounded support, finite endpoint at -σ/ξ
3844#[derive(Debug, Clone, Copy, PartialEq)]
3845pub struct GpdParameters {
3846    /// Shape parameter (xi). Controls tail heaviness.
3847    pub shape: f64,
3848    /// Scale parameter (sigma). Must be positive.
3849    pub scale: f64,
3850    /// Threshold above which GPD applies.
3851    pub threshold: f64,
3852}
3853
3854impl GpdParameters {
3855    /// Create new GPD parameters.
3856    ///
3857    /// # Panics
3858    /// Panics if scale is not positive.
3859    #[must_use]
3860    pub fn new(shape: f64, scale: f64, threshold: f64) -> Self {
3861        assert!(scale > 0.0, "scale must be positive");
3862        Self {
3863            shape,
3864            scale,
3865            threshold,
3866        }
3867    }
3868
3869    /// Compute the quantile (inverse CDF) for probability p.
3870    ///
3871    /// Returns the value x such that P(X <= x) = p for exceedances.
3872    #[must_use]
3873    pub fn quantile(&self, p: f64) -> f64 {
3874        let p = p.clamp(0.0, 1.0 - 1e-15);
3875        let exceedance = if self.shape.abs() < 1e-10 {
3876            // Exponential case (shape ≈ 0)
3877            -self.scale * (1.0 - p).ln()
3878        } else {
3879            // General GPD case
3880            self.scale / self.shape * ((1.0 - p).powf(-self.shape) - 1.0)
3881        };
3882        self.threshold + exceedance
3883    }
3884
3885    /// Compute the survival probability P(X > x) for exceedance x above threshold.
3886    #[must_use]
3887    pub fn survival_probability(&self, x: f64) -> f64 {
3888        if x <= self.threshold {
3889            return 1.0;
3890        }
3891        let y = (x - self.threshold) / self.scale;
3892        if self.shape.abs() < 1e-10 {
3893            // Exponential case
3894            (-y).exp()
3895        } else {
3896            let base = 1.0 + self.shape * y;
3897            if base <= 0.0 {
3898                // Beyond support for negative shape
3899                0.0
3900            } else {
3901                base.powf(-1.0 / self.shape)
3902            }
3903        }
3904    }
3905
3906    /// Estimate the extreme quantile using POT (Peaks Over Threshold).
3907    ///
3908    /// Given n total observations and nu threshold exceedances,
3909    /// estimates the p-quantile of the original distribution.
3910    #[must_use]
3911    pub fn pot_quantile(&self, p: f64, n: usize, nu: usize) -> f64 {
3912        if nu == 0 || n == 0 {
3913            return self.threshold;
3914        }
3915        // Adjust probability for exceedance rate
3916        let exceed_rate = nu as f64 / n as f64;
3917        let exceed_prob = (1.0 - p) / exceed_rate;
3918        if exceed_prob >= 1.0 {
3919            return self.threshold;
3920        }
3921        self.quantile(1.0 - exceed_prob)
3922    }
3923}
3924
3925/// Prior distribution for Bayesian GPD parameter estimation.
3926#[derive(Debug, Clone, Copy, PartialEq)]
3927pub struct GpdPrior {
3928    /// Prior mean for shape parameter.
3929    pub shape_mean: f64,
3930    /// Prior variance for shape parameter.
3931    pub shape_variance: f64,
3932    /// Prior mean for log(scale).
3933    pub log_scale_mean: f64,
3934    /// Prior variance for log(scale).
3935    pub log_scale_variance: f64,
3936}
3937
3938impl Default for GpdPrior {
3939    fn default() -> Self {
3940        // Weakly informative priors centered on light tails
3941        Self {
3942            shape_mean: 0.1,         // Slight positive shape (common for latency)
3943            shape_variance: 0.25,    // Allows range roughly [-0.9, 1.1]
3944            log_scale_mean: 0.0,     // Scale prior centered at 1.0
3945            log_scale_variance: 4.0, // Wide prior on scale
3946        }
3947    }
3948}
3949
3950impl GpdPrior {
3951    /// Create a prior for heavy-tailed data (e.g., p99.9 latency).
3952    #[must_use]
3953    pub fn heavy_tailed() -> Self {
3954        Self {
3955            shape_mean: 0.3,
3956            shape_variance: 0.16,
3957            log_scale_mean: 0.0,
3958            log_scale_variance: 4.0,
3959        }
3960    }
3961
3962    /// Create a prior for light-tailed data (e.g., low-variance latency).
3963    #[must_use]
3964    pub fn light_tailed() -> Self {
3965        Self {
3966            shape_mean: 0.0,
3967            shape_variance: 0.09,
3968            log_scale_mean: 0.0,
3969            log_scale_variance: 4.0,
3970        }
3971    }
3972}
3973
3974/// Bayesian GPD estimator for rare-event tail latency modeling.
3975///
3976/// Uses Peaks Over Threshold (POT) method with Bayesian parameter estimation.
3977/// Provides credible intervals for extreme quantiles like P99.9, P99.99.
3978#[derive(Debug, Clone)]
3979pub struct BayesianGpdEstimator {
3980    /// Prior distribution for parameters.
3981    prior: GpdPrior,
3982    /// Exceedances (values above threshold).
3983    exceedances: Vec<f64>,
3984    /// Threshold value (e.g., empirical P95).
3985    threshold: f64,
3986    /// Total number of observations (including non-exceedances).
3987    total_observations: usize,
3988    /// Cached posterior parameters (updated on fit).
3989    posterior_shape: Option<(f64, f64)>, // (mean, variance)
3990    posterior_log_scale: Option<(f64, f64)>,
3991}
3992
3993impl BayesianGpdEstimator {
3994    /// Create a new estimator with default prior.
3995    #[must_use]
3996    pub fn new() -> Self {
3997        Self::with_prior(GpdPrior::default())
3998    }
3999
4000    /// Create an estimator with custom prior.
4001    #[must_use]
4002    pub fn with_prior(prior: GpdPrior) -> Self {
4003        Self {
4004            prior,
4005            exceedances: Vec::new(),
4006            threshold: 0.0,
4007            total_observations: 0,
4008            posterior_shape: None,
4009            posterior_log_scale: None,
4010        }
4011    }
4012
4013    /// Set the threshold and record observations.
4014    ///
4015    /// Values above threshold are recorded as exceedances.
4016    pub fn observe(&mut self, values: &[f64], threshold: f64) {
4017        self.threshold = threshold;
4018        self.total_observations = values.len();
4019        self.exceedances = values
4020            .iter()
4021            .filter(|&&v| v > threshold)
4022            .map(|&v| v - threshold)
4023            .collect();
4024        self.fit();
4025    }
4026
4027    /// Fit the GPD using method of moments with Bayesian shrinkage.
4028    fn fit(&mut self) {
4029        if self.exceedances.len() < 2 {
4030            // Not enough data - use prior
4031            self.posterior_shape = Some((self.prior.shape_mean, self.prior.shape_variance));
4032            self.posterior_log_scale =
4033                Some((self.prior.log_scale_mean, self.prior.log_scale_variance));
4034            return;
4035        }
4036
4037        let n = self.exceedances.len() as f64;
4038
4039        // Method of moments estimates
4040        let mean: f64 = self.exceedances.iter().sum::<f64>() / n;
4041        let variance: f64 = self
4042            .exceedances
4043            .iter()
4044            .map(|x| (x - mean).powi(2))
4045            .sum::<f64>()
4046            / n;
4047
4048        // MoM estimators for GPD
4049        let mom_shape = 0.5 * (1.0 - mean.powi(2) / variance);
4050        let mom_scale = mean * (1.0 - mom_shape);
4051
4052        // Bayesian shrinkage toward prior
4053        let shape_shrink = self.compute_shrinkage(n, self.prior.shape_variance, 0.25);
4054        let log_scale_shrink = self.compute_shrinkage(n, self.prior.log_scale_variance, 1.0);
4055
4056        let posterior_shape_mean = shape_shrink * self.prior.shape_mean
4057            + (1.0 - shape_shrink) * mom_shape.clamp(-0.5, 1.0);
4058        let posterior_shape_var = shape_shrink * self.prior.shape_variance;
4059
4060        let mom_log_scale = if mom_scale > 0.0 {
4061            mom_scale.ln()
4062        } else {
4063            mean.ln()
4064        };
4065        let posterior_log_scale_mean =
4066            log_scale_shrink * self.prior.log_scale_mean + (1.0 - log_scale_shrink) * mom_log_scale;
4067        let posterior_log_scale_var = log_scale_shrink * self.prior.log_scale_variance;
4068
4069        self.posterior_shape = Some((posterior_shape_mean, posterior_shape_var));
4070        self.posterior_log_scale = Some((posterior_log_scale_mean, posterior_log_scale_var));
4071    }
4072
4073    fn compute_shrinkage(&self, n: f64, prior_var: f64, data_var: f64) -> f64 {
4074        // Shrinkage weight: w = prior_var / (prior_var + data_var/n)
4075        prior_var / (prior_var + data_var / n)
4076    }
4077
4078    /// Get the posterior mean GPD parameters.
4079    #[must_use]
4080    pub fn posterior_parameters(&self) -> Option<GpdParameters> {
4081        let (shape, _) = self.posterior_shape?;
4082        let (log_scale, _) = self.posterior_log_scale?;
4083        Some(GpdParameters::new(shape, log_scale.exp(), self.threshold))
4084    }
4085
4086    /// Estimate an extreme quantile with credible interval.
4087    ///
4088    /// Returns (point estimate, lower bound, upper bound) at given credibility level.
4089    #[must_use]
4090    pub fn estimate_quantile(&self, p: f64, credibility: f64) -> Option<GpdQuantileEstimate> {
4091        let params = self.posterior_parameters()?;
4092        let (shape_mean, shape_var) = self.posterior_shape?;
4093        let (log_scale_mean, log_scale_var) = self.posterior_log_scale?;
4094
4095        // Point estimate using posterior means
4096        let point = params.pot_quantile(p, self.total_observations, self.exceedances.len());
4097
4098        // Approximate credible interval using parameter uncertainty
4099        // Use Delta method: propagate variance through quantile function
4100        let z = normal_quantile((1.0 + credibility) / 2.0);
4101
4102        // Compute bounds by perturbing parameters
4103        let shape_lo = shape_mean - z * shape_var.sqrt();
4104        let shape_hi = shape_mean + z * shape_var.sqrt();
4105        let scale_lo = (log_scale_mean - z * log_scale_var.sqrt()).exp();
4106        let scale_hi = (log_scale_mean + z * log_scale_var.sqrt()).exp();
4107
4108        let params_lo = GpdParameters::new(shape_lo, scale_lo, self.threshold);
4109        let params_hi = GpdParameters::new(shape_hi, scale_hi, self.threshold);
4110
4111        let lower = params_lo.pot_quantile(p, self.total_observations, self.exceedances.len());
4112        let upper = params_hi.pot_quantile(p, self.total_observations, self.exceedances.len());
4113
4114        Some(GpdQuantileEstimate {
4115            quantile: p,
4116            point_estimate: point,
4117            lower_bound: lower.min(upper),
4118            upper_bound: lower.max(upper),
4119            credibility,
4120            n_exceedances: self.exceedances.len(),
4121            n_total: self.total_observations,
4122        })
4123    }
4124
4125    /// Estimate the probability of exceeding a given value.
4126    #[must_use]
4127    pub fn exceedance_probability(&self, value: f64) -> Option<f64> {
4128        let params = self.posterior_parameters()?;
4129        if self.total_observations == 0 {
4130            return None;
4131        }
4132        let exceed_rate = self.exceedances.len() as f64 / self.total_observations as f64;
4133        Some(exceed_rate * params.survival_probability(value))
4134    }
4135
4136    /// Get the number of exceedances.
4137    #[must_use]
4138    pub fn n_exceedances(&self) -> usize {
4139        self.exceedances.len()
4140    }
4141
4142    /// Get the threshold.
4143    #[must_use]
4144    pub fn threshold(&self) -> f64 {
4145        self.threshold
4146    }
4147}
4148
4149impl Default for BayesianGpdEstimator {
4150    fn default() -> Self {
4151        Self::new()
4152    }
4153}
4154
4155/// Quantile estimate with credible interval from GPD model.
4156#[derive(Debug, Clone, Copy, PartialEq)]
4157pub struct GpdQuantileEstimate {
4158    /// The quantile being estimated (e.g., 0.999 for P99.9).
4159    pub quantile: f64,
4160    /// Point estimate (posterior mean).
4161    pub point_estimate: f64,
4162    /// Lower bound of credible interval.
4163    pub lower_bound: f64,
4164    /// Upper bound of credible interval.
4165    pub upper_bound: f64,
4166    /// Credibility level (e.g., 0.95 for 95% CI).
4167    pub credibility: f64,
4168    /// Number of threshold exceedances used for estimation.
4169    pub n_exceedances: usize,
4170    /// Total number of observations.
4171    pub n_total: usize,
4172}
4173
4174impl GpdQuantileEstimate {
4175    /// Width of the credible interval.
4176    #[must_use]
4177    pub fn interval_width(&self) -> f64 {
4178        self.upper_bound - self.lower_bound
4179    }
4180
4181    /// Relative uncertainty (interval width / point estimate).
4182    #[must_use]
4183    pub fn relative_uncertainty(&self) -> f64 {
4184        if self.point_estimate > 0.0 {
4185            self.interval_width() / self.point_estimate
4186        } else {
4187            f64::INFINITY
4188        }
4189    }
4190}
4191
4192/// Approximate standard normal quantile (inverse CDF).
4193fn normal_quantile(p: f64) -> f64 {
4194    // Rational approximation (Abramowitz & Stegun 26.2.23)
4195    if p <= 0.0 {
4196        return f64::NEG_INFINITY;
4197    }
4198    if p >= 1.0 {
4199        return f64::INFINITY;
4200    }
4201
4202    let t = if p < 0.5 {
4203        (-2.0 * p.ln()).sqrt()
4204    } else {
4205        (-2.0 * (1.0 - p).ln()).sqrt()
4206    };
4207
4208    // Coefficients for rational approximation
4209    let c0 = 2.515517;
4210    let c1 = 0.802853;
4211    let c2 = 0.010328;
4212    let d1 = 1.432788;
4213    let d2 = 0.189269;
4214    let d3 = 0.001308;
4215
4216    let q = t - (c0 + c1 * t + c2 * t * t) / (1.0 + d1 * t + d2 * t * t + d3 * t * t * t);
4217
4218    if p < 0.5 { -q } else { q }
4219}
4220
4221// ──────────────────────────────────────────────────────────────────────────────
4222// E3: Profile-and-prove optimization loop per SLO row
4223// ──────────────────────────────────────────────────────────────────────────────
4224
4225/// Identifier for SLO metric rows from SPEC §17.
4226#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4227pub enum SloRowId {
4228    ShortestPath,
4229    Components,
4230    Centrality,
4231    Flow,
4232    IoRoundtrip,
4233    MutationCycle,
4234    MutationRegression,
4235    MemoryRegression,
4236    P99TailRegression,
4237}
4238
4239impl SloRowId {
4240    /// Get the metric ID string.
4241    #[must_use]
4242    pub const fn metric_id(&self) -> &'static str {
4243        match self {
4244            Self::ShortestPath => "shortest_path",
4245            Self::Components => "components",
4246            Self::Centrality => "centrality",
4247            Self::Flow => "flow",
4248            Self::IoRoundtrip => "io_roundtrip",
4249            Self::MutationCycle => "mutation_cycle",
4250            Self::MutationRegression => "mutation_regression",
4251            Self::MemoryRegression => "memory_regression",
4252            Self::P99TailRegression => "p99_tail_regression",
4253        }
4254    }
4255
4256    /// All SLO row IDs.
4257    pub const ALL: &'static [Self] = &[
4258        Self::ShortestPath,
4259        Self::Components,
4260        Self::Centrality,
4261        Self::Flow,
4262        Self::IoRoundtrip,
4263        Self::MutationCycle,
4264        Self::MutationRegression,
4265        Self::MemoryRegression,
4266        Self::P99TailRegression,
4267    ];
4268}
4269
4270/// Phase of the optimization loop.
4271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4272pub enum OptimizationPhase {
4273    /// Baseline measurement before optimization.
4274    Baseline,
4275    /// Applying optimization.
4276    Optimize,
4277    /// Verifying behavior unchanged via conformance.
4278    Verify,
4279    /// Final measurement after optimization.
4280    Measure,
4281    /// Loop complete.
4282    Complete,
4283}
4284
4285/// Evidence that optimization didn't change behavior.
4286#[derive(Debug, Clone, PartialEq)]
4287pub struct OptimizationEvidence {
4288    /// Conformance fixture count verified.
4289    pub fixtures_verified: usize,
4290    /// Mismatches detected (should be 0 for valid optimization).
4291    pub mismatches: usize,
4292    /// Hash of conformance report.
4293    pub conformance_hash: String,
4294    /// Timestamp of verification.
4295    pub verified_at_unix_ms: u128,
4296}
4297
4298/// Baseline measurement for an SLO row.
4299#[derive(Debug, Clone, PartialEq)]
4300pub struct SloBaseline {
4301    /// SLO row being measured.
4302    pub row_id: SloRowId,
4303    /// p50 latency in milliseconds.
4304    pub p50_ms: f64,
4305    /// p95 latency in milliseconds.
4306    pub p95_ms: f64,
4307    /// p99 latency in milliseconds.
4308    pub p99_ms: f64,
4309    /// Peak memory in kilobytes.
4310    pub peak_memory_kb: u64,
4311    /// Number of samples.
4312    pub sample_count: usize,
4313    /// Timestamp of baseline capture.
4314    pub captured_at_unix_ms: u128,
4315}
4316
4317/// Result of an optimization attempt.
4318#[derive(Debug, Clone, PartialEq)]
4319pub struct OptimizationResult {
4320    /// SLO row that was optimized.
4321    pub row_id: SloRowId,
4322    /// Description of the optimization applied.
4323    pub optimization_description: String,
4324    /// Baseline before optimization.
4325    pub baseline: SloBaseline,
4326    /// Measurement after optimization.
4327    pub after: SloBaseline,
4328    /// Evidence that behavior unchanged.
4329    pub evidence: OptimizationEvidence,
4330    /// Whether the optimization was successful (improved and verified).
4331    pub success: bool,
4332    /// Delta statistics.
4333    pub delta: OptimizationDelta,
4334}
4335
4336/// Delta between baseline and optimized measurements.
4337#[derive(Debug, Clone, Copy, PartialEq)]
4338pub struct OptimizationDelta {
4339    /// Change in p50 (negative = improvement).
4340    pub p50_delta_ms: f64,
4341    /// Change in p95 (negative = improvement).
4342    pub p95_delta_ms: f64,
4343    /// Change in p99 (negative = improvement).
4344    pub p99_delta_ms: f64,
4345    /// Change in memory (negative = improvement).
4346    pub memory_delta_kb: i64,
4347    /// Percentage improvement in p95.
4348    pub p95_improvement_pct: f64,
4349}
4350
4351impl OptimizationDelta {
4352    /// Compute delta from baseline and after measurements.
4353    #[must_use]
4354    pub fn compute(baseline: &SloBaseline, after: &SloBaseline) -> Self {
4355        let p50_delta = after.p50_ms - baseline.p50_ms;
4356        let p95_delta = after.p95_ms - baseline.p95_ms;
4357        let p99_delta = after.p99_ms - baseline.p99_ms;
4358        let memory_delta = after.peak_memory_kb as i64 - baseline.peak_memory_kb as i64;
4359        let p95_improvement = if baseline.p95_ms > 0.0 {
4360            -100.0 * p95_delta / baseline.p95_ms
4361        } else {
4362            0.0
4363        };
4364
4365        Self {
4366            p50_delta_ms: p50_delta,
4367            p95_delta_ms: p95_delta,
4368            p99_delta_ms: p99_delta,
4369            memory_delta_kb: memory_delta,
4370            p95_improvement_pct: p95_improvement,
4371        }
4372    }
4373
4374    /// Check if this represents an improvement (not regression).
4375    #[must_use]
4376    pub fn is_improvement(&self) -> bool {
4377        // Improvement if p95 is better (lower) and memory isn't much worse
4378        self.p95_delta_ms <= 0.0 && self.memory_delta_kb <= 1024 // Allow up to 1MB memory increase
4379    }
4380}
4381
4382/// State of the profile-prove loop for a single SLO row.
4383#[derive(Debug, Clone)]
4384pub struct ProfileProveState {
4385    /// SLO row being optimized.
4386    pub row_id: SloRowId,
4387    /// Current phase.
4388    pub phase: OptimizationPhase,
4389    /// Baseline measurement (set after baseline phase).
4390    pub baseline: Option<SloBaseline>,
4391    /// After measurement (set after measure phase).
4392    pub after: Option<SloBaseline>,
4393    /// Verification evidence (set after verify phase).
4394    pub evidence: Option<OptimizationEvidence>,
4395    /// Description of optimization being attempted.
4396    pub optimization_description: String,
4397    /// Started timestamp.
4398    pub started_at_unix_ms: u128,
4399}
4400
4401impl ProfileProveState {
4402    /// Create a new state for an SLO row.
4403    #[must_use]
4404    pub fn new(row_id: SloRowId, optimization_description: &str) -> Self {
4405        Self {
4406            row_id,
4407            phase: OptimizationPhase::Baseline,
4408            baseline: None,
4409            after: None,
4410            evidence: None,
4411            optimization_description: optimization_description.to_owned(),
4412            started_at_unix_ms: unix_time_ms(),
4413        }
4414    }
4415
4416    /// Record baseline measurement.
4417    pub fn record_baseline(&mut self, baseline: SloBaseline) {
4418        self.baseline = Some(baseline);
4419        self.phase = OptimizationPhase::Optimize;
4420    }
4421
4422    /// Mark optimization as applied.
4423    pub fn mark_optimized(&mut self) {
4424        self.phase = OptimizationPhase::Verify;
4425    }
4426
4427    /// Record verification evidence.
4428    pub fn record_verification(&mut self, evidence: OptimizationEvidence) {
4429        let has_mismatches = evidence.mismatches > 0;
4430        self.evidence = Some(evidence);
4431        if has_mismatches {
4432            // Verification failed - loop aborted
4433            self.phase = OptimizationPhase::Complete;
4434        } else {
4435            self.phase = OptimizationPhase::Measure;
4436        }
4437    }
4438
4439    /// Record final measurement.
4440    pub fn record_after(&mut self, after: SloBaseline) {
4441        self.after = Some(after);
4442        self.phase = OptimizationPhase::Complete;
4443    }
4444
4445    /// Get the optimization result (only valid when phase is Complete).
4446    #[must_use]
4447    pub fn result(&self) -> Option<OptimizationResult> {
4448        let baseline = self.baseline.as_ref()?;
4449        let after = self.after.as_ref()?;
4450        let evidence = self.evidence.as_ref()?;
4451
4452        let delta = OptimizationDelta::compute(baseline, after);
4453        let success = evidence.mismatches == 0 && delta.is_improvement();
4454
4455        Some(OptimizationResult {
4456            row_id: self.row_id,
4457            optimization_description: self.optimization_description.clone(),
4458            baseline: baseline.clone(),
4459            after: after.clone(),
4460            evidence: evidence.clone(),
4461            success,
4462            delta,
4463        })
4464    }
4465
4466    /// Check if the loop is complete.
4467    #[must_use]
4468    pub fn is_complete(&self) -> bool {
4469        self.phase == OptimizationPhase::Complete
4470    }
4471}
4472
4473/// Orchestrator for running profile-prove loops across SLO rows.
4474#[derive(Debug, Clone, Default)]
4475pub struct ProfileProveLoop {
4476    /// Results from completed optimization attempts.
4477    pub results: Vec<OptimizationResult>,
4478    /// Currently running states.
4479    pub active: Vec<ProfileProveState>,
4480}
4481
4482impl ProfileProveLoop {
4483    /// Create a new empty loop.
4484    #[must_use]
4485    pub fn new() -> Self {
4486        Self::default()
4487    }
4488
4489    /// Start an optimization attempt for an SLO row.
4490    pub fn start(&mut self, row_id: SloRowId, optimization_description: &str) {
4491        self.active
4492            .push(ProfileProveState::new(row_id, optimization_description));
4493    }
4494
4495    /// Get active state for a row (if any).
4496    #[must_use]
4497    pub fn get_active(&self, row_id: SloRowId) -> Option<&ProfileProveState> {
4498        self.active.iter().find(|s| s.row_id == row_id)
4499    }
4500
4501    /// Get mutable active state for a row.
4502    pub fn get_active_mut(&mut self, row_id: SloRowId) -> Option<&mut ProfileProveState> {
4503        self.active.iter_mut().find(|s| s.row_id == row_id)
4504    }
4505
4506    /// Complete an active state and move result to results.
4507    pub fn complete(&mut self, row_id: SloRowId) {
4508        if let Some(idx) = self.active.iter().position(|s| s.row_id == row_id) {
4509            let state = self.active.remove(idx);
4510            if let Some(result) = state.result() {
4511                self.results.push(result);
4512            }
4513        }
4514    }
4515
4516    /// Get summary of all completed optimizations.
4517    #[must_use]
4518    pub fn summary(&self) -> ProfileProveSummary {
4519        let total = self.results.len();
4520        let successful = self.results.iter().filter(|r| r.success).count();
4521        let failed = total - successful;
4522
4523        let total_p95_improvement: f64 = self
4524            .results
4525            .iter()
4526            .filter(|r| r.success)
4527            .map(|r| r.delta.p95_improvement_pct)
4528            .sum();
4529
4530        let avg_p95_improvement = if successful > 0 {
4531            total_p95_improvement / successful as f64
4532        } else {
4533            0.0
4534        };
4535
4536        ProfileProveSummary {
4537            total_attempts: total,
4538            successful,
4539            failed,
4540            avg_p95_improvement_pct: avg_p95_improvement,
4541            rows_optimized: self
4542                .results
4543                .iter()
4544                .filter(|r| r.success)
4545                .map(|r| r.row_id)
4546                .collect(),
4547        }
4548    }
4549}
4550
4551/// Summary of profile-prove loop results.
4552#[derive(Debug, Clone, PartialEq)]
4553pub struct ProfileProveSummary {
4554    /// Total optimization attempts.
4555    pub total_attempts: usize,
4556    /// Successful optimizations (verified + improved).
4557    pub successful: usize,
4558    /// Failed attempts (verification failed or regression).
4559    pub failed: usize,
4560    /// Average p95 improvement percentage.
4561    pub avg_p95_improvement_pct: f64,
4562    /// SLO rows that were successfully optimized.
4563    pub rows_optimized: Vec<SloRowId>,
4564}
4565
4566#[must_use]
4567pub fn unix_time_ms() -> u128 {
4568    SystemTime::now()
4569        .duration_since(UNIX_EPOCH)
4570        .unwrap_or(Duration::from_millis(0))
4571        .as_millis()
4572}
4573
4574#[cfg(feature = "asupersync-integration")]
4575pub mod asupersync_bridge {
4576    use super::{AsupersyncAdapterMachine, AsupersyncTransferIntent};
4577
4578    pub trait ArtifactSyncAdapter {
4579        fn begin_sync(
4580            &self,
4581            cx: &asupersync::Cx,
4582            intent: AsupersyncTransferIntent,
4583        ) -> Result<AsupersyncAdapterMachine, String>;
4584    }
4585
4586    pub struct CompileCheckAdapter;
4587
4588    impl ArtifactSyncAdapter for CompileCheckAdapter {
4589        fn begin_sync(
4590            &self,
4591            cx: &asupersync::Cx,
4592            intent: AsupersyncTransferIntent,
4593        ) -> Result<AsupersyncAdapterMachine, String> {
4594            let _ = core::mem::size_of_val(cx);
4595            let mut machine = AsupersyncAdapterMachine::start(intent)?;
4596            machine.mark_capability_check(true)?;
4597            Ok(machine)
4598        }
4599    }
4600
4601    /// Compile-time marker proving asupersync is wired into this crate.
4602    #[must_use]
4603    pub fn integration_marker() -> &'static str {
4604        let _ = core::any::type_name::<asupersync::Cx>();
4605        "asupersync-integration-enabled"
4606    }
4607}
4608
4609#[cfg(feature = "ftui-integration")]
4610pub mod ftui_bridge {
4611    use super::{FtuiArtifactIndex, FtuiTelemetryAdapter, StructuredTestLog};
4612
4613    pub trait TelemetryArtifactIndexAdapter {
4614        fn build_index(&self, logs: &[StructuredTestLog]) -> Result<FtuiArtifactIndex, String>;
4615    }
4616
4617    pub struct CompileCheckTelemetryAdapter;
4618
4619    impl TelemetryArtifactIndexAdapter for CompileCheckTelemetryAdapter {
4620        fn build_index(&self, logs: &[StructuredTestLog]) -> Result<FtuiArtifactIndex, String> {
4621            let _ = core::any::type_name::<ftui::Theme>();
4622            FtuiTelemetryAdapter::strict_default().build_artifact_index(logs)
4623        }
4624    }
4625
4626    /// Compile-time marker proving FrankenTUI types are available.
4627    #[must_use]
4628    pub fn integration_marker() -> &'static str {
4629        let _ = core::any::type_name::<ftui::Theme>();
4630        "ftui-integration-enabled"
4631    }
4632}
4633
4634// ────────────────────────────────────────────────────────────────────────────────
4635// G6: Algebraic effect tracking for parser modes
4636// ────────────────────────────────────────────────────────────────────────────────
4637
4638/// Categories of effects that can occur during parsing operations.
4639#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
4640pub enum ParserEffectKind {
4641    /// A recoverable warning was emitted.
4642    Warning,
4643    /// Full validation was triggered.
4644    Validation,
4645    /// Parser failed closed (non-recoverable).
4646    FailClosed,
4647    /// Operation was allowed to proceed.
4648    Allow,
4649    /// A fallback strategy was used.
4650    Fallback,
4651    /// Input was sanitized or normalized.
4652    Sanitize,
4653    /// A type coercion occurred.
4654    Coercion,
4655}
4656
4657impl ParserEffectKind {
4658    /// Returns true if this effect indicates a terminal failure.
4659    #[must_use]
4660    pub const fn is_terminal(&self) -> bool {
4661        matches!(self, Self::FailClosed)
4662    }
4663
4664    /// Returns true if this effect indicates a recoverable condition.
4665    #[must_use]
4666    pub const fn is_recoverable(&self) -> bool {
4667        matches!(
4668            self,
4669            Self::Warning | Self::Fallback | Self::Sanitize | Self::Coercion
4670        )
4671    }
4672
4673    /// Returns the effect kind as a static string.
4674    #[must_use]
4675    pub const fn as_str(&self) -> &'static str {
4676        match self {
4677            Self::Warning => "warning",
4678            Self::Validation => "validation",
4679            Self::FailClosed => "fail_closed",
4680            Self::Allow => "allow",
4681            Self::Fallback => "fallback",
4682            Self::Sanitize => "sanitize",
4683            Self::Coercion => "coercion",
4684        }
4685    }
4686}
4687
4688/// A single effect that occurred during parsing.
4689#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4690pub struct ParserEffect {
4691    /// Timestamp when the effect occurred (Unix milliseconds).
4692    pub ts_unix_ms: u128,
4693    /// The kind of effect.
4694    pub kind: ParserEffectKind,
4695    /// The parser operation that produced this effect.
4696    pub operation: String,
4697    /// A human-readable message describing the effect.
4698    pub message: String,
4699    /// The compatibility mode in which the effect occurred.
4700    pub mode: CompatibilityMode,
4701    /// Optional source location (e.g., line number in input).
4702    pub source_location: Option<String>,
4703    /// Associated risk probability (0.0 to 1.0).
4704    pub risk_probability: f64,
4705}
4706
4707impl ParserEffect {
4708    /// Create a new parser effect with current timestamp.
4709    #[must_use]
4710    pub fn new(
4711        kind: ParserEffectKind,
4712        operation: impl Into<String>,
4713        message: impl Into<String>,
4714        mode: CompatibilityMode,
4715        risk_probability: f64,
4716    ) -> Self {
4717        Self {
4718            ts_unix_ms: unix_time_ms(),
4719            kind,
4720            operation: operation.into(),
4721            message: message.into(),
4722            mode,
4723            source_location: None,
4724            risk_probability: risk_probability.clamp(0.0, 1.0),
4725        }
4726    }
4727
4728    /// Create a new parser effect with a source location.
4729    #[must_use]
4730    pub fn with_location(mut self, location: impl Into<String>) -> Self {
4731        self.source_location = Some(location.into());
4732        self
4733    }
4734
4735    /// Create a warning effect.
4736    #[must_use]
4737    pub fn warning(
4738        operation: impl Into<String>,
4739        message: impl Into<String>,
4740        mode: CompatibilityMode,
4741    ) -> Self {
4742        Self::new(ParserEffectKind::Warning, operation, message, mode, 0.5)
4743    }
4744
4745    /// Create a fail-closed effect.
4746    #[must_use]
4747    pub fn fail_closed(
4748        operation: impl Into<String>,
4749        message: impl Into<String>,
4750        mode: CompatibilityMode,
4751    ) -> Self {
4752        Self::new(ParserEffectKind::FailClosed, operation, message, mode, 1.0)
4753    }
4754
4755    /// Create an allow effect.
4756    #[must_use]
4757    pub fn allow(
4758        operation: impl Into<String>,
4759        message: impl Into<String>,
4760        mode: CompatibilityMode,
4761        risk_probability: f64,
4762    ) -> Self {
4763        Self::new(
4764            ParserEffectKind::Allow,
4765            operation,
4766            message,
4767            mode,
4768            risk_probability,
4769        )
4770    }
4771}
4772
4773/// An effect trace that collects effects during parsing.
4774///
4775/// Effect traces are composable and can be filtered or transformed.
4776#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
4777pub struct EffectTrace {
4778    effects: Vec<ParserEffect>,
4779    terminal_effect: Option<usize>,
4780}
4781
4782impl EffectTrace {
4783    /// Create a new empty effect trace.
4784    #[must_use]
4785    pub fn new() -> Self {
4786        Self::default()
4787    }
4788
4789    /// Record an effect in the trace.
4790    ///
4791    /// If the effect is terminal (FailClosed), subsequent effects are still
4792    /// recorded but the trace is marked as terminated.
4793    pub fn record(&mut self, effect: ParserEffect) {
4794        let idx = self.effects.len();
4795        let is_terminal = effect.kind.is_terminal();
4796        self.effects.push(effect);
4797        if is_terminal && self.terminal_effect.is_none() {
4798            self.terminal_effect = Some(idx);
4799        }
4800    }
4801
4802    /// Returns true if the trace contains any terminal effects.
4803    #[must_use]
4804    pub fn is_terminated(&self) -> bool {
4805        self.terminal_effect.is_some()
4806    }
4807
4808    /// Returns true if the trace has no effects.
4809    #[must_use]
4810    pub fn is_empty(&self) -> bool {
4811        self.effects.is_empty()
4812    }
4813
4814    /// Returns the number of effects in the trace.
4815    #[must_use]
4816    pub fn len(&self) -> usize {
4817        self.effects.len()
4818    }
4819
4820    /// Returns a slice of all effects.
4821    #[must_use]
4822    pub fn effects(&self) -> &[ParserEffect] {
4823        &self.effects
4824    }
4825
4826    /// Returns the terminal effect, if any.
4827    #[must_use]
4828    pub fn terminal(&self) -> Option<&ParserEffect> {
4829        self.terminal_effect.map(|idx| &self.effects[idx])
4830    }
4831
4832    /// Filter effects by kind.
4833    #[must_use]
4834    pub fn filter_kind(&self, kind: ParserEffectKind) -> Vec<&ParserEffect> {
4835        self.effects.iter().filter(|e| e.kind == kind).collect()
4836    }
4837
4838    /// Count effects by kind.
4839    #[must_use]
4840    pub fn count_kind(&self, kind: ParserEffectKind) -> usize {
4841        self.effects.iter().filter(|e| e.kind == kind).count()
4842    }
4843
4844    /// Returns all warning effects.
4845    #[must_use]
4846    pub fn warnings(&self) -> Vec<&ParserEffect> {
4847        self.filter_kind(ParserEffectKind::Warning)
4848    }
4849
4850    /// Merge another trace into this one.
4851    pub fn merge(&mut self, other: EffectTrace) {
4852        let offset = self.effects.len();
4853        for effect in other.effects {
4854            self.record(effect);
4855        }
4856        // Update terminal index if the merged trace had a terminal
4857        if self.terminal_effect.is_none()
4858            && let Some(idx) = other.terminal_effect
4859        {
4860            self.terminal_effect = Some(offset + idx);
4861        }
4862    }
4863
4864    /// Convert to a summary for logging.
4865    #[must_use]
4866    pub fn summary(&self) -> EffectTraceSummary {
4867        let mut by_kind = std::collections::HashMap::new();
4868        for effect in &self.effects {
4869            *by_kind.entry(effect.kind).or_insert(0) += 1;
4870        }
4871
4872        EffectTraceSummary {
4873            total_effects: self.effects.len(),
4874            warnings: self.count_kind(ParserEffectKind::Warning),
4875            failures: self.count_kind(ParserEffectKind::FailClosed),
4876            coercions: self.count_kind(ParserEffectKind::Coercion),
4877            is_terminated: self.is_terminated(),
4878            max_risk: self
4879                .effects
4880                .iter()
4881                .map(|e| e.risk_probability)
4882                .fold(0.0_f64, f64::max),
4883        }
4884    }
4885}
4886
4887/// A summary of an effect trace for logging and metrics.
4888#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
4889pub struct EffectTraceSummary {
4890    pub total_effects: usize,
4891    pub warnings: usize,
4892    pub failures: usize,
4893    pub coercions: usize,
4894    pub is_terminated: bool,
4895    pub max_risk: f64,
4896}
4897
4898/// Policy for handling effects during parsing.
4899#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
4900pub enum EffectPolicy {
4901    /// Collect all effects but continue on recoverable ones.
4902    #[default]
4903    CollectAll,
4904    /// Fail immediately on any effect.
4905    FailOnAny,
4906    /// Fail on warnings or worse.
4907    FailOnWarning,
4908    /// Only fail on terminal effects.
4909    FailOnTerminal,
4910}
4911
4912impl EffectPolicy {
4913    /// Check if an effect should cause immediate failure under this policy.
4914    #[must_use]
4915    pub fn should_fail(&self, effect: &ParserEffect) -> bool {
4916        match self {
4917            Self::CollectAll => effect.kind.is_terminal(),
4918            Self::FailOnAny => true,
4919            Self::FailOnWarning => {
4920                matches!(
4921                    effect.kind,
4922                    ParserEffectKind::Warning
4923                        | ParserEffectKind::FailClosed
4924                        | ParserEffectKind::Validation
4925                )
4926            }
4927            Self::FailOnTerminal => effect.kind.is_terminal(),
4928        }
4929    }
4930}
4931
4932/// An effect handler that processes effects according to a policy.
4933#[derive(Debug, Clone)]
4934pub struct EffectHandler {
4935    policy: EffectPolicy,
4936    trace: EffectTrace,
4937}
4938
4939impl EffectHandler {
4940    /// Create a new effect handler with the given policy.
4941    #[must_use]
4942    pub fn new(policy: EffectPolicy) -> Self {
4943        Self {
4944            policy,
4945            trace: EffectTrace::new(),
4946        }
4947    }
4948
4949    /// Create an effect handler that collects all effects.
4950    #[must_use]
4951    pub fn collect_all() -> Self {
4952        Self::new(EffectPolicy::CollectAll)
4953    }
4954
4955    /// Create an effect handler that fails on terminal effects only.
4956    #[must_use]
4957    pub fn fail_on_terminal() -> Self {
4958        Self::new(EffectPolicy::FailOnTerminal)
4959    }
4960
4961    /// Handle an effect, returning Err if the policy requires immediate failure.
4962    pub fn handle(&mut self, effect: ParserEffect) -> Result<(), ParserEffect> {
4963        let should_fail = self.policy.should_fail(&effect);
4964        self.trace.record(effect.clone());
4965        if should_fail { Err(effect) } else { Ok(()) }
4966    }
4967
4968    /// Get the current effect trace.
4969    #[must_use]
4970    pub fn trace(&self) -> &EffectTrace {
4971        &self.trace
4972    }
4973
4974    /// Consume the handler and return the effect trace.
4975    #[must_use]
4976    pub fn into_trace(self) -> EffectTrace {
4977        self.trace
4978    }
4979
4980    /// Get a summary of the effects.
4981    #[must_use]
4982    pub fn summary(&self) -> EffectTraceSummary {
4983        self.trace.summary()
4984    }
4985}
4986
4987impl Default for EffectHandler {
4988    fn default() -> Self {
4989        Self::collect_all()
4990    }
4991}
4992
4993// ---------------------------------------------------------------------------
4994// Compressed-Sensing Replay Logs (G8)
4995// ---------------------------------------------------------------------------
4996
4997/// Count-Min Sketch for space-efficient frequency estimation.
4998///
4999/// Uses multiple hash functions to estimate item frequencies with bounded
5000/// error. Space usage is O(width * depth) regardless of stream size.
5001#[derive(Debug, Clone, Serialize, Deserialize)]
5002pub struct CountMinSketch {
5003    /// Width of each row (number of counters per hash function).
5004    width: usize,
5005    /// Depth (number of hash functions / rows).
5006    depth: usize,
5007    /// Counter matrix stored in row-major order.
5008    counters: Vec<u64>,
5009    /// Seeds for hash functions (one per row).
5010    seeds: Vec<u64>,
5011    /// Total items added.
5012    total_count: u64,
5013}
5014
5015impl CountMinSketch {
5016    /// Create a new sketch with given width and depth.
5017    ///
5018    /// - `width`: Number of counters per row (larger = more accurate)
5019    /// - `depth`: Number of hash functions (larger = lower error probability)
5020    ///
5021    /// Typical values: width=1000, depth=5 for ~1% error with 99% confidence.
5022    #[must_use]
5023    pub fn new(width: usize, depth: usize) -> Self {
5024        Self::with_seed(width, depth, 0)
5025    }
5026
5027    /// Create a sketch with a specific seed for reproducibility.
5028    #[must_use]
5029    pub fn with_seed(width: usize, depth: usize, seed: u64) -> Self {
5030        let seeds: Vec<u64> = (0..depth as u64)
5031            .map(|i| seed.wrapping_add(i * 0x9E3779B97F4A7C15))
5032            .collect();
5033        Self {
5034            width,
5035            depth,
5036            counters: vec![0; width * depth],
5037            seeds,
5038            total_count: 0,
5039        }
5040    }
5041
5042    /// Add an item to the sketch.
5043    pub fn add(&mut self, item: &str) {
5044        self.add_count(item, 1);
5045    }
5046
5047    /// Add an item with a specific count.
5048    pub fn add_count(&mut self, item: &str, count: u64) {
5049        for (row, &seed) in self.seeds.iter().enumerate() {
5050            let col = self.hash(item, seed) % self.width;
5051            self.counters[row * self.width + col] =
5052                self.counters[row * self.width + col].saturating_add(count);
5053        }
5054        self.total_count = self.total_count.saturating_add(count);
5055    }
5056
5057    /// Estimate the frequency of an item.
5058    ///
5059    /// Returns the minimum count across all hash functions, which is
5060    /// guaranteed to be >= true count (never underestimates).
5061    #[must_use]
5062    pub fn estimate(&self, item: &str) -> u64 {
5063        self.seeds
5064            .iter()
5065            .enumerate()
5066            .map(|(row, &seed)| {
5067                let col = self.hash(item, seed) % self.width;
5068                self.counters[row * self.width + col]
5069            })
5070            .min()
5071            .unwrap_or(0)
5072    }
5073
5074    /// Get the total count of all items added.
5075    #[must_use]
5076    pub fn total_count(&self) -> u64 {
5077        self.total_count
5078    }
5079
5080    /// Merge another sketch into this one (both must have same dimensions).
5081    pub fn merge(&mut self, other: &Self) -> Result<(), &'static str> {
5082        if self.width != other.width || self.depth != other.depth {
5083            return Err("sketch dimensions must match for merge");
5084        }
5085        if self.seeds != other.seeds {
5086            return Err("sketch seeds must match for merge");
5087        }
5088        for (a, b) in self.counters.iter_mut().zip(&other.counters) {
5089            *a = a.saturating_add(*b);
5090        }
5091        self.total_count = self.total_count.saturating_add(other.total_count);
5092        Ok(())
5093    }
5094
5095    fn hash(&self, item: &str, seed: u64) -> usize {
5096        // FNV-1a inspired hash with seed mixing
5097        let mut h = seed ^ 0xcbf29ce484222325;
5098        for byte in item.bytes() {
5099            h ^= u64::from(byte);
5100            h = h.wrapping_mul(0x100000001b3);
5101        }
5102        h as usize
5103    }
5104}
5105
5106/// Reservoir sampler for uniform random sampling from a stream.
5107///
5108/// Maintains a fixed-size sample that is uniformly distributed over
5109/// all items seen, using Algorithm R.
5110#[derive(Debug, Clone, Serialize, Deserialize)]
5111pub struct ReservoirSampler<T> {
5112    /// Maximum number of items to retain.
5113    capacity: usize,
5114    /// Current sample.
5115    reservoir: Vec<T>,
5116    /// Number of items seen so far.
5117    count: u64,
5118    /// Random state for sampling decisions.
5119    rng_state: u64,
5120}
5121
5122impl<T: Clone> ReservoirSampler<T> {
5123    /// Create a new sampler with given capacity.
5124    #[must_use]
5125    pub fn new(capacity: usize) -> Self {
5126        Self::with_seed(capacity, 0)
5127    }
5128
5129    /// Create a sampler with a specific seed for reproducibility.
5130    #[must_use]
5131    pub fn with_seed(capacity: usize, seed: u64) -> Self {
5132        Self {
5133            capacity,
5134            reservoir: Vec::with_capacity(capacity),
5135            count: 0,
5136            rng_state: seed ^ 0x5DEECE66D,
5137        }
5138    }
5139
5140    /// Add an item to the stream.
5141    ///
5142    /// The item is added to the reservoir with probability k/n where
5143    /// k is capacity and n is the number of items seen.
5144    pub fn add(&mut self, item: T) {
5145        self.count += 1;
5146        if self.reservoir.len() < self.capacity {
5147            self.reservoir.push(item);
5148        } else {
5149            let j = self.next_random() % self.count;
5150            if j < self.capacity as u64 {
5151                self.reservoir[j as usize] = item;
5152            }
5153        }
5154    }
5155
5156    /// Get the current sample.
5157    #[must_use]
5158    pub fn sample(&self) -> &[T] {
5159        &self.reservoir
5160    }
5161
5162    /// Get the number of items seen.
5163    #[must_use]
5164    pub fn count(&self) -> u64 {
5165        self.count
5166    }
5167
5168    /// Check if the reservoir is full.
5169    #[must_use]
5170    pub fn is_full(&self) -> bool {
5171        self.reservoir.len() >= self.capacity
5172    }
5173
5174    /// Clear the reservoir and reset count.
5175    pub fn clear(&mut self) {
5176        self.reservoir.clear();
5177        self.count = 0;
5178    }
5179
5180    fn next_random(&mut self) -> u64 {
5181        // Linear congruential generator
5182        self.rng_state = self.rng_state.wrapping_mul(0x5DEECE66D).wrapping_add(11);
5183        self.rng_state
5184    }
5185}
5186
5187/// Compressed replay log combining count-min sketch and reservoir sampling.
5188///
5189/// Tracks both frequency estimates (via CMS) and representative samples
5190/// (via reservoir) for efficient log analysis.
5191#[derive(Debug, Clone, Serialize, Deserialize)]
5192pub struct CompressedReplayLog {
5193    /// Frequency sketch for operation counts.
5194    pub frequency_sketch: CountMinSketch,
5195    /// Sample of recent log entries.
5196    pub entry_sample: ReservoirSampler<String>,
5197    /// Sample of error messages.
5198    pub error_sample: ReservoirSampler<String>,
5199    /// Schema version for serialization compatibility.
5200    pub schema_version: String,
5201}
5202
5203impl CompressedReplayLog {
5204    /// Create a new compressed replay log with default parameters.
5205    #[must_use]
5206    pub fn new() -> Self {
5207        Self::with_params(1000, 5, 100, 50)
5208    }
5209
5210    /// Create with custom parameters.
5211    ///
5212    /// - `sketch_width`: Width of frequency sketch
5213    /// - `sketch_depth`: Depth of frequency sketch
5214    /// - `entry_sample_size`: Number of log entries to retain
5215    /// - `error_sample_size`: Number of error messages to retain
5216    #[must_use]
5217    pub fn with_params(
5218        sketch_width: usize,
5219        sketch_depth: usize,
5220        entry_sample_size: usize,
5221        error_sample_size: usize,
5222    ) -> Self {
5223        Self {
5224            frequency_sketch: CountMinSketch::new(sketch_width, sketch_depth),
5225            entry_sample: ReservoirSampler::new(entry_sample_size),
5226            error_sample: ReservoirSampler::new(error_sample_size),
5227            schema_version: "1.0.0".to_owned(),
5228        }
5229    }
5230
5231    /// Record an operation.
5232    pub fn record_operation(&mut self, operation: &str) {
5233        self.frequency_sketch.add(operation);
5234    }
5235
5236    /// Record a log entry.
5237    pub fn record_entry(&mut self, entry: String) {
5238        self.entry_sample.add(entry);
5239    }
5240
5241    /// Record an error.
5242    pub fn record_error(&mut self, error: String) {
5243        self.frequency_sketch.add("__error__");
5244        self.error_sample.add(error);
5245    }
5246
5247    /// Estimate the frequency of an operation.
5248    #[must_use]
5249    pub fn estimate_frequency(&self, operation: &str) -> u64 {
5250        self.frequency_sketch.estimate(operation)
5251    }
5252
5253    /// Get sampled log entries.
5254    #[must_use]
5255    pub fn sampled_entries(&self) -> &[String] {
5256        self.entry_sample.sample()
5257    }
5258
5259    /// Get sampled errors.
5260    #[must_use]
5261    pub fn sampled_errors(&self) -> &[String] {
5262        self.error_sample.sample()
5263    }
5264
5265    /// Get total operation count.
5266    #[must_use]
5267    pub fn total_operations(&self) -> u64 {
5268        self.frequency_sketch.total_count()
5269    }
5270
5271    /// Merge another log into this one.
5272    pub fn merge(&mut self, other: &Self) -> Result<(), &'static str> {
5273        self.frequency_sketch.merge(&other.frequency_sketch)?;
5274        // Note: reservoir merge is lossy - we just extend and re-sample
5275        for entry in other.entry_sample.sample() {
5276            self.entry_sample.add(entry.clone());
5277        }
5278        for error in other.error_sample.sample() {
5279            self.error_sample.add(error.clone());
5280        }
5281        Ok(())
5282    }
5283}
5284
5285impl Default for CompressedReplayLog {
5286    fn default() -> Self {
5287        Self::new()
5288    }
5289}
5290
5291#[cfg(test)]
5292mod tests {
5293    use super::{
5294        AsupersyncAdapterMachine, AsupersyncAdapterReasonCode, AsupersyncAdapterState,
5295        AsupersyncTransferIntent, CGSE_POLICY_SPEC_PATH, CgseOperationFamily, CgsePolicyEngine,
5296        CgsePolicyEvaluator, CgsePolicyRule, CompatibilityMode, DecisionAction, E2eStepStatus,
5297        E2eStepTrace, EvidenceLedger, FailureReproData, ForensicsBundleIndex, FtuiTelemetryAdapter,
5298        StructuredTestLog, TestKind, TestStatus, canonical_environment_fingerprint,
5299        cgse_policy_schema_version, decision_theoretic_action, ftui_telemetry_canonical_fields,
5300        structured_test_log_schema_version,
5301    };
5302    use std::collections::{BTreeMap, BTreeSet};
5303
5304    fn base_env() -> BTreeMap<String, String> {
5305        let mut env = BTreeMap::new();
5306        env.insert("arch".to_owned(), "x86_64".to_owned());
5307        env.insert("os".to_owned(), "linux".to_owned());
5308        env
5309    }
5310
5311    fn base_forensics_bundle(
5312        run_id: &str,
5313        test_id: &str,
5314        replay_ref: &str,
5315        bundle_id: &str,
5316        artifact_refs: Vec<String>,
5317    ) -> ForensicsBundleIndex {
5318        ForensicsBundleIndex {
5319            bundle_id: bundle_id.to_owned(),
5320            run_id: run_id.to_owned(),
5321            test_id: test_id.to_owned(),
5322            bundle_hash_id: "bundle_hash_123".to_owned(),
5323            captured_unix_ms: 1,
5324            replay_ref: replay_ref.to_owned(),
5325            artifact_refs,
5326            raptorq_sidecar_refs: Vec::new(),
5327            decode_proof_refs: Vec::new(),
5328        }
5329    }
5330
5331    #[test]
5332    fn strict_mode_prefers_validation_for_uncertain_inputs() {
5333        let action = decision_theoretic_action(CompatibilityMode::Strict, 0.3, false);
5334        assert_eq!(action, DecisionAction::FullValidate);
5335    }
5336
5337    #[test]
5338    fn hardened_mode_fails_closed_for_high_risk_inputs() {
5339        let action = decision_theoretic_action(CompatibilityMode::Hardened, 0.9, false);
5340        assert_eq!(action, DecisionAction::FailClosed);
5341    }
5342
5343    #[test]
5344    fn both_modes_fail_closed_for_unknown_incompatible_features() {
5345        let strict = decision_theoretic_action(CompatibilityMode::Strict, 0.0, true);
5346        let hardened = decision_theoretic_action(CompatibilityMode::Hardened, 0.0, true);
5347        assert_eq!(strict, DecisionAction::FailClosed);
5348        assert_eq!(hardened, DecisionAction::FailClosed);
5349    }
5350
5351    #[test]
5352    fn cgse_policy_rule_table_stays_stable_and_unique() {
5353        assert_eq!(cgse_policy_schema_version(), "1.0.0");
5354        assert_eq!(CgsePolicyRule::ALL.len(), 14);
5355        let mut ids = BTreeSet::new();
5356        for rule in CgsePolicyRule::ALL {
5357            assert!(ids.insert(rule.as_rule_id()));
5358            assert!(!rule.policy_id().is_empty());
5359            assert!(!rule.fail_closed_default().is_empty());
5360            assert!(!rule.hardened_allowlist().is_empty());
5361            assert_eq!(
5362                rule.operation_name(),
5363                format!(
5364                    "{}::{}",
5365                    rule.operation_family().as_str(),
5366                    rule.as_rule_id().to_lowercase()
5367                )
5368            );
5369        }
5370        assert_eq!(
5371            CgsePolicyRule::R08.operation_family(),
5372            CgseOperationFamily::ShortestPathAlgorithms
5373        );
5374    }
5375
5376    #[test]
5377    fn cgse_policy_engine_hardened_mode_fails_closed_on_non_allowlisted_ambiguity() {
5378        let engine = CgsePolicyEngine::new(CompatibilityMode::Hardened);
5379        let decision = CgsePolicyEvaluator::evaluate(
5380            &engine,
5381            CgsePolicyRule::R01,
5382            Some("CGSE-AMB-999"),
5383            0.01,
5384            false,
5385        );
5386        assert_eq!(
5387            CgsePolicyEvaluator::mode(&engine),
5388            CompatibilityMode::Hardened
5389        );
5390        assert_eq!(decision.policy_spec_path, CGSE_POLICY_SPEC_PATH);
5391        assert_eq!(decision.decision.action, DecisionAction::FailClosed);
5392        assert!(!decision.allowlisted_ambiguity);
5393        assert_eq!(decision.decision.evidence.len(), 4);
5394    }
5395
5396    #[test]
5397    fn cgse_policy_engine_is_deterministic_for_fixed_timestamp() {
5398        let engine = CgsePolicyEngine::new(CompatibilityMode::Strict);
5399        let left = engine.evaluate_at(CgsePolicyRule::R08, Some("CGSE-AMB-008"), 0.3, false, 42);
5400        let right = engine.evaluate_at(CgsePolicyRule::R08, Some("CGSE-AMB-008"), 0.3, false, 42);
5401        assert_eq!(left, right);
5402        assert_eq!(left.decision.action, DecisionAction::FullValidate);
5403    }
5404
5405    #[test]
5406    fn cgse_policy_engine_allowlisted_path_matches_runtime_policy_action() {
5407        let engine = CgsePolicyEngine::new(CompatibilityMode::Strict);
5408        let decision =
5409            engine.evaluate_at(CgsePolicyRule::R08, Some("CGSE-AMB-008"), 0.3, false, 42);
5410        let expected = super::RuntimePolicy::new(CompatibilityMode::Strict).action_for(0.3, false);
5411
5412        assert_eq!(decision.decision.action, expected);
5413    }
5414
5415    fn expected_selected_policy_rationale(
5416        rule: CgsePolicyRule,
5417        action: DecisionAction,
5418        clamped_probability: f64,
5419    ) -> String {
5420        format!(
5421            "deterministic policy {} selected {:?} at incompatibility_probability={clamped_probability:.4}",
5422            rule.as_rule_id(),
5423            action
5424        )
5425    }
5426
5427    fn print_rationale_golden_line(rule: CgsePolicyRule, case_name: &str, rationale: &str) {
5428        println!(
5429            "CGSE_RATIONALE_GOLDEN\t{}\t{case_name}\t{rationale}",
5430            rule.as_rule_id()
5431        );
5432    }
5433
5434    #[test]
5435    fn cgse_policy_engine_rationale_matrix_matches_legacy_text() {
5436        let strict = CgsePolicyEngine::new(CompatibilityMode::Strict);
5437        let hardened = CgsePolicyEngine::new(CompatibilityMode::Hardened);
5438
5439        for rule in CgsePolicyRule::ALL {
5440            for (case_name, probability) in [
5441                ("strict_selected_allow_0_2", 0.2),
5442                ("strict_selected_validate_0_3", 0.3),
5443                ("strict_selected_fail_0_9", 0.9),
5444            ] {
5445                let decision = strict.evaluate_at(rule, None, probability, false, 42);
5446                let expected = expected_selected_policy_rationale(
5447                    rule,
5448                    decision.decision.action,
5449                    probability.clamp(0.0, 1.0),
5450                );
5451                assert_eq!(decision.decision.rationale, expected);
5452                print_rationale_golden_line(rule, case_name, &decision.decision.rationale);
5453            }
5454
5455            let nan_decision = strict.evaluate_at(rule, None, f64::NAN, false, 42);
5456            let expected_nan = format!(
5457                "{} triggered fail-closed due to NaN incompatibility_probability",
5458                rule.fail_closed_default()
5459            );
5460            assert_eq!(nan_decision.decision.rationale, expected_nan);
5461            print_rationale_golden_line(rule, "strict_nan", &nan_decision.decision.rationale);
5462
5463            let unknown_decision = strict.evaluate_at(rule, None, 0.0, true, 42);
5464            let expected_unknown = format!(
5465                "{} triggered fail-closed due to unknown incompatible feature",
5466                rule.fail_closed_default()
5467            );
5468            assert_eq!(unknown_decision.decision.rationale, expected_unknown);
5469            print_rationale_golden_line(
5470                rule,
5471                "strict_unknown_feature",
5472                &unknown_decision.decision.rationale,
5473            );
5474
5475            let ambiguity_decision =
5476                hardened.evaluate_at(rule, Some("CGSE-AMB-999"), 0.01, false, 42);
5477            let expected_ambiguity = format!(
5478                "{} triggered fail-closed because ambiguity tag `CGSE-AMB-999` is not allowlisted in hardened mode",
5479                rule.fail_closed_default()
5480            );
5481            assert_eq!(ambiguity_decision.decision.rationale, expected_ambiguity);
5482            print_rationale_golden_line(
5483                rule,
5484                "hardened_non_allowlisted_ambiguity",
5485                &ambiguity_decision.decision.rationale,
5486            );
5487        }
5488    }
5489
5490    #[test]
5491    fn ledger_serializes_to_json() {
5492        let ledger = EvidenceLedger::new();
5493        let json = ledger
5494            .to_json_pretty()
5495            .expect("ledger json should serialize");
5496        assert!(json.contains("records"));
5497    }
5498
5499    #[test]
5500    fn structured_test_log_validates_passed_record() {
5501        let env = base_env();
5502
5503        let log = StructuredTestLog {
5504            schema_version: structured_test_log_schema_version().to_owned(),
5505            run_id: "run-1".to_owned(),
5506            ts_unix_ms: 1,
5507            crate_name: "fnx-runtime".to_owned(),
5508            suite_id: "unit".to_owned(),
5509            packet_id: "FNX-P2C-FOUNDATION".to_owned(),
5510            test_name: "ledger_serializes_to_json".to_owned(),
5511            test_id: "tests::ledger_serializes_to_json".to_owned(),
5512            test_kind: TestKind::Unit,
5513            mode: CompatibilityMode::Strict,
5514            fixture_id: None,
5515            seed: Some(7),
5516            env_fingerprint: canonical_environment_fingerprint(&env),
5517            environment: env.clone(),
5518            duration_ms: 1,
5519            replay_command: "cargo test -p fnx-runtime ledger_serializes_to_json".to_owned(),
5520            artifact_refs: vec!["artifacts/conformance/latest/smoke_report.json".to_owned()],
5521            forensic_bundle_id: "forensics::unit::ledger".to_owned(),
5522            hash_id: "sha256:abc123".to_owned(),
5523            status: TestStatus::Passed,
5524            reason_code: None,
5525            failure_repro: None,
5526            e2e_step_traces: Vec::new(),
5527            forensics_bundle_index: Some(base_forensics_bundle(
5528                "run-1",
5529                "tests::ledger_serializes_to_json",
5530                "cargo test -p fnx-runtime ledger_serializes_to_json",
5531                "forensics::unit::ledger",
5532                vec!["artifacts/conformance/latest/smoke_report.json".to_owned()],
5533            )),
5534        };
5535
5536        assert!(log.validate().is_ok());
5537        let json = log.to_json_pretty().expect("log should serialize");
5538        assert!(json.contains("ledger_serializes_to_json"));
5539    }
5540
5541    #[test]
5542    fn structured_test_log_failed_requires_repro_seed_or_fixture() {
5543        let env = base_env();
5544
5545        let log = StructuredTestLog {
5546            schema_version: structured_test_log_schema_version().to_owned(),
5547            run_id: "run-2".to_owned(),
5548            ts_unix_ms: 1,
5549            crate_name: "fnx-runtime".to_owned(),
5550            suite_id: "property".to_owned(),
5551            packet_id: "FNX-P2C-FOUNDATION".to_owned(),
5552            test_name: "failure_case".to_owned(),
5553            test_id: "tests::failure_case".to_owned(),
5554            test_kind: TestKind::Property,
5555            mode: CompatibilityMode::Hardened,
5556            fixture_id: None,
5557            seed: None,
5558            env_fingerprint: canonical_environment_fingerprint(&env),
5559            environment: env,
5560            duration_ms: 4,
5561            replay_command: "cargo test -p fnx-conformance -- --nocapture".to_owned(),
5562            artifact_refs: vec!["artifacts/conformance/latest/smoke_report.json".to_owned()],
5563            forensic_bundle_id: "forensics::prop::failure_case".to_owned(),
5564            hash_id: "sha256:def456".to_owned(),
5565            status: TestStatus::Failed,
5566            reason_code: Some("mismatch".to_owned()),
5567            failure_repro: Some(FailureReproData {
5568                failure_message: "expected no mismatch".to_owned(),
5569                reproduction_command: "cargo test -p fnx-conformance -- --nocapture".to_owned(),
5570                expected_behavior: "zero drift".to_owned(),
5571                observed_behavior: "mismatch_count=1".to_owned(),
5572                seed: None,
5573                fixture_id: None,
5574                artifact_hash_id: Some("sha256:def456".to_owned()),
5575                forensics_link: Some(
5576                    "artifacts/conformance/latest/failure_case.report.json".to_owned(),
5577                ),
5578            }),
5579            e2e_step_traces: Vec::new(),
5580            forensics_bundle_index: Some(base_forensics_bundle(
5581                "run-2",
5582                "tests::failure_case",
5583                "cargo test -p fnx-conformance -- --nocapture",
5584                "forensics::prop::failure_case",
5585                vec!["artifacts/conformance/latest/smoke_report.json".to_owned()],
5586            )),
5587        };
5588
5589        let err = log
5590            .validate()
5591            .expect_err("failed status without seed/fixture should reject");
5592        assert!(err.contains("seed or fixture_id"));
5593    }
5594
5595    #[test]
5596    fn structured_test_log_skipped_requires_reason_code() {
5597        let env = base_env();
5598        let log = StructuredTestLog {
5599            schema_version: structured_test_log_schema_version().to_owned(),
5600            run_id: "run-3".to_owned(),
5601            ts_unix_ms: 1,
5602            crate_name: "fnx-runtime".to_owned(),
5603            suite_id: "integration".to_owned(),
5604            packet_id: "FNX-P2C-FOUNDATION".to_owned(),
5605            test_name: "skip_case".to_owned(),
5606            test_id: "tests::skip_case".to_owned(),
5607            test_kind: TestKind::Unit,
5608            mode: CompatibilityMode::Strict,
5609            fixture_id: None,
5610            seed: Some(1),
5611            env_fingerprint: canonical_environment_fingerprint(&env),
5612            environment: env,
5613            duration_ms: 0,
5614            replay_command: "cargo test -p fnx-conformance skip_case -- --nocapture".to_owned(),
5615            artifact_refs: vec!["artifacts/conformance/latest/skip_case.report.json".to_owned()],
5616            forensic_bundle_id: "forensics::integration::skip_case".to_owned(),
5617            hash_id: "sha256:skip".to_owned(),
5618            status: TestStatus::Skipped,
5619            reason_code: None,
5620            failure_repro: None,
5621            e2e_step_traces: Vec::new(),
5622            forensics_bundle_index: Some(base_forensics_bundle(
5623                "run-3",
5624                "tests::skip_case",
5625                "cargo test -p fnx-conformance skip_case -- --nocapture",
5626                "forensics::integration::skip_case",
5627                vec!["artifacts/conformance/latest/skip_case.report.json".to_owned()],
5628            )),
5629        };
5630
5631        let err = log
5632            .validate()
5633            .expect_err("skipped status without reason code should reject");
5634        assert!(err.contains("reason_code is required"));
5635    }
5636
5637    #[test]
5638    fn structured_test_log_e2e_requires_step_traces() {
5639        let env = base_env();
5640        let log = StructuredTestLog {
5641            schema_version: structured_test_log_schema_version().to_owned(),
5642            run_id: "run-3b".to_owned(),
5643            ts_unix_ms: 10,
5644            crate_name: "fnx-runtime".to_owned(),
5645            suite_id: "integration".to_owned(),
5646            packet_id: "FNX-P2C-FOUNDATION".to_owned(),
5647            test_name: "e2e_no_steps".to_owned(),
5648            test_id: "tests::e2e_no_steps".to_owned(),
5649            test_kind: TestKind::E2e,
5650            mode: CompatibilityMode::Strict,
5651            fixture_id: None,
5652            seed: Some(1),
5653            env_fingerprint: canonical_environment_fingerprint(&env),
5654            environment: env,
5655            duration_ms: 5,
5656            replay_command: "cargo test -p fnx-conformance e2e_no_steps -- --nocapture".to_owned(),
5657            artifact_refs: vec!["artifacts/conformance/latest/e2e_no_steps.report.json".to_owned()],
5658            forensic_bundle_id: "forensics::integration::e2e_no_steps".to_owned(),
5659            hash_id: "sha256:e2e-no-steps".to_owned(),
5660            status: TestStatus::Passed,
5661            reason_code: None,
5662            failure_repro: None,
5663            e2e_step_traces: Vec::new(),
5664            forensics_bundle_index: Some(base_forensics_bundle(
5665                "run-3b",
5666                "tests::e2e_no_steps",
5667                "cargo test -p fnx-conformance e2e_no_steps -- --nocapture",
5668                "forensics::integration::e2e_no_steps",
5669                vec!["artifacts/conformance/latest/e2e_no_steps.report.json".to_owned()],
5670            )),
5671        };
5672
5673        let err = log
5674            .validate()
5675            .expect_err("e2e logs without steps should reject");
5676        assert!(err.contains("e2e_step_traces are required"));
5677    }
5678
5679    #[test]
5680    fn structured_test_log_rejects_step_trace_bundle_mismatch() {
5681        let env = base_env();
5682        let replay = "cargo test -p fnx-conformance e2e_bundle_mismatch -- --nocapture";
5683        let log = StructuredTestLog {
5684            schema_version: structured_test_log_schema_version().to_owned(),
5685            run_id: "run-3c".to_owned(),
5686            ts_unix_ms: 100,
5687            crate_name: "fnx-runtime".to_owned(),
5688            suite_id: "integration".to_owned(),
5689            packet_id: "FNX-P2C-FOUNDATION".to_owned(),
5690            test_name: "e2e_bundle_mismatch".to_owned(),
5691            test_id: "tests::e2e_bundle_mismatch".to_owned(),
5692            test_kind: TestKind::E2e,
5693            mode: CompatibilityMode::Strict,
5694            fixture_id: None,
5695            seed: Some(1),
5696            env_fingerprint: canonical_environment_fingerprint(&env),
5697            environment: env,
5698            duration_ms: 3,
5699            replay_command: replay.to_owned(),
5700            artifact_refs: vec![
5701                "artifacts/conformance/latest/e2e_bundle_mismatch.report.json".to_owned(),
5702            ],
5703            forensic_bundle_id: "forensics::integration::bundle_a".to_owned(),
5704            hash_id: "sha256:e2e-bundle-mismatch".to_owned(),
5705            status: TestStatus::Passed,
5706            reason_code: None,
5707            failure_repro: None,
5708            e2e_step_traces: vec![E2eStepTrace {
5709                run_id: "run-3c".to_owned(),
5710                test_id: "tests::e2e_bundle_mismatch".to_owned(),
5711                step_id: "step-1".to_owned(),
5712                step_label: "setup".to_owned(),
5713                phase: "arrange".to_owned(),
5714                status: E2eStepStatus::Passed,
5715                start_unix_ms: 100,
5716                end_unix_ms: 101,
5717                duration_ms: 1,
5718                replay_command: replay.to_owned(),
5719                forensic_bundle_id: "forensics::integration::bundle_b".to_owned(),
5720                artifact_refs: vec![
5721                    "artifacts/conformance/latest/e2e_bundle_mismatch.report.json".to_owned(),
5722                ],
5723                hash_id: "step-hash-1".to_owned(),
5724                reason_code: None,
5725            }],
5726            forensics_bundle_index: Some(base_forensics_bundle(
5727                "run-3c",
5728                "tests::e2e_bundle_mismatch",
5729                replay,
5730                "forensics::integration::bundle_a",
5731                vec!["artifacts/conformance/latest/e2e_bundle_mismatch.report.json".to_owned()],
5732            )),
5733        };
5734
5735        let err = log
5736            .validate()
5737            .expect_err("step trace bundle id mismatch should reject");
5738        assert!(err.contains("e2e_step_traces.forensic_bundle_id must match"));
5739    }
5740
5741    #[test]
5742    fn structured_test_log_rejects_unknown_schema_version() {
5743        let env = base_env();
5744        let log = StructuredTestLog {
5745            schema_version: "9.9.9".to_owned(),
5746            run_id: "run-4".to_owned(),
5747            ts_unix_ms: 1,
5748            crate_name: "fnx-runtime".to_owned(),
5749            suite_id: "integration".to_owned(),
5750            packet_id: "FNX-P2C-FOUNDATION".to_owned(),
5751            test_name: "schema_gate".to_owned(),
5752            test_id: "tests::schema_gate".to_owned(),
5753            test_kind: TestKind::Unit,
5754            mode: CompatibilityMode::Strict,
5755            fixture_id: None,
5756            seed: Some(1),
5757            env_fingerprint: canonical_environment_fingerprint(&env),
5758            environment: env,
5759            duration_ms: 0,
5760            replay_command: "cargo test -p fnx-runtime schema_gate".to_owned(),
5761            artifact_refs: vec!["artifacts/conformance/latest/schema_gate.report.json".to_owned()],
5762            forensic_bundle_id: "forensics::schema_gate".to_owned(),
5763            hash_id: "sha256:schema-gate".to_owned(),
5764            status: TestStatus::Passed,
5765            reason_code: None,
5766            failure_repro: None,
5767            e2e_step_traces: Vec::new(),
5768            forensics_bundle_index: Some(base_forensics_bundle(
5769                "run-4",
5770                "tests::schema_gate",
5771                "cargo test -p fnx-runtime schema_gate",
5772                "forensics::schema_gate",
5773                vec!["artifacts/conformance/latest/schema_gate.report.json".to_owned()],
5774            )),
5775        };
5776
5777        let err = log
5778            .validate()
5779            .expect_err("unsupported schema version should fail closed");
5780        assert!(err.contains("unsupported schema_version"));
5781    }
5782
5783    #[test]
5784    fn structured_test_log_packet_003_unit_contract_requires_route_metadata() {
5785        let mut env = base_env();
5786        env.insert("route_id".to_owned(), "dispatch::shortest_path".to_owned());
5787        env.insert("backend_name".to_owned(), "alpha-backend".to_owned());
5788        env.insert("strict_mode".to_owned(), "true".to_owned());
5789
5790        let replay = "rch exec -- cargo test -p fnx-dispatch unit_packet_003_contract_asserted -- --nocapture";
5791        let log = StructuredTestLog {
5792            schema_version: structured_test_log_schema_version().to_owned(),
5793            run_id: "run-p2c003-unit".to_owned(),
5794            ts_unix_ms: 1,
5795            crate_name: "fnx-dispatch".to_owned(),
5796            suite_id: "unit".to_owned(),
5797            packet_id: "FNX-P2C-003".to_owned(),
5798            test_name: "unit_packet_003_contract_asserted".to_owned(),
5799            test_id: "unit::fnx-p2c-003::contract".to_owned(),
5800            test_kind: TestKind::Unit,
5801            mode: CompatibilityMode::Strict,
5802            fixture_id: Some("dispatch::contract::strict".to_owned()),
5803            seed: Some(7103),
5804            env_fingerprint: canonical_environment_fingerprint(&env),
5805            environment: env,
5806            duration_ms: 3,
5807            replay_command: replay.to_owned(),
5808            artifact_refs: vec!["artifacts/conformance/latest/structured_logs.jsonl".to_owned()],
5809            forensic_bundle_id: "forensics::dispatch::unit::contract".to_owned(),
5810            hash_id: "sha256:p2c003-unit".to_owned(),
5811            status: TestStatus::Passed,
5812            reason_code: None,
5813            failure_repro: None,
5814            e2e_step_traces: Vec::new(),
5815            forensics_bundle_index: Some(base_forensics_bundle(
5816                "run-p2c003-unit",
5817                "unit::fnx-p2c-003::contract",
5818                replay,
5819                "forensics::dispatch::unit::contract",
5820                vec!["artifacts/conformance/latest/structured_logs.jsonl".to_owned()],
5821            )),
5822        };
5823        log.validate()
5824            .expect("packet-003 unit contract log should satisfy metadata schema");
5825
5826        let mut missing_backend = log.clone();
5827        missing_backend.environment.remove("backend_name");
5828        let err = missing_backend
5829            .validate()
5830            .expect_err("missing backend_name metadata must fail closed");
5831        assert!(err.contains("backend_name"));
5832
5833        let mut missing_fixture = log;
5834        missing_fixture.fixture_id = None;
5835        let err = missing_fixture
5836            .validate()
5837            .expect_err("packet-003 unit contract should require fixture_id");
5838        assert!(err.contains("fixture_id"));
5839    }
5840
5841    #[test]
5842    fn structured_test_log_packet_003_property_invariants_require_seed_and_digest() {
5843        let mut env = base_env();
5844        env.insert("graph_fingerprint".to_owned(), "graph-fp-003".to_owned());
5845        env.insert("cache_key_digest".to_owned(), "cache-digest-003".to_owned());
5846        env.insert("invariant_id".to_owned(), "P2C003-IV-1".to_owned());
5847
5848        let replay =
5849            "rch exec -- cargo test -p fnx-dispatch property_packet_003_invariants -- --nocapture";
5850        let log = StructuredTestLog {
5851            schema_version: structured_test_log_schema_version().to_owned(),
5852            run_id: "run-p2c003-property".to_owned(),
5853            ts_unix_ms: 2,
5854            crate_name: "fnx-dispatch".to_owned(),
5855            suite_id: "property".to_owned(),
5856            packet_id: "FNX-P2C-003".to_owned(),
5857            test_name: "property_packet_003_invariants".to_owned(),
5858            test_id: "property::fnx-p2c-003::invariants".to_owned(),
5859            test_kind: TestKind::Property,
5860            mode: CompatibilityMode::Hardened,
5861            fixture_id: Some("dispatch::property::matrix".to_owned()),
5862            seed: Some(7203),
5863            env_fingerprint: canonical_environment_fingerprint(&env),
5864            environment: env,
5865            duration_ms: 9,
5866            replay_command: replay.to_owned(),
5867            artifact_refs: vec![
5868                "artifacts/conformance/latest/structured_log_emitter_normalization_report.json"
5869                    .to_owned(),
5870            ],
5871            forensic_bundle_id: "forensics::dispatch::property::invariants".to_owned(),
5872            hash_id: "sha256:p2c003-property".to_owned(),
5873            status: TestStatus::Failed,
5874            reason_code: Some("mismatch".to_owned()),
5875            failure_repro: Some(FailureReproData {
5876                failure_message: "deterministic replay mismatch".to_owned(),
5877                reproduction_command: replay.to_owned(),
5878                expected_behavior: "replay-stable dispatch decision".to_owned(),
5879                observed_behavior: "decision diverged".to_owned(),
5880                seed: Some(7203),
5881                fixture_id: Some("dispatch::property::matrix".to_owned()),
5882                artifact_hash_id: Some("sha256:p2c003-property".to_owned()),
5883                forensics_link: Some(
5884                    "artifacts/conformance/latest/structured_log_emitter_normalization_report.json"
5885                        .to_owned(),
5886                ),
5887            }),
5888            e2e_step_traces: Vec::new(),
5889            forensics_bundle_index: Some(base_forensics_bundle(
5890                "run-p2c003-property",
5891                "property::fnx-p2c-003::invariants",
5892                replay,
5893                "forensics::dispatch::property::invariants",
5894                vec![
5895                    "artifacts/conformance/latest/structured_log_emitter_normalization_report.json"
5896                        .to_owned(),
5897                ],
5898            )),
5899        };
5900        log.validate()
5901            .expect("packet-003 property log should satisfy metadata schema");
5902
5903        let mut missing_seed = log.clone();
5904        missing_seed.seed = None;
5905        let err = missing_seed
5906            .validate()
5907            .expect_err("packet-003 property log should require deterministic seed");
5908        assert!(err.contains("deterministic seed"));
5909
5910        let mut missing_digest = log;
5911        missing_digest.environment.remove("cache_key_digest");
5912        let err = missing_digest
5913            .validate()
5914            .expect_err("packet-003 property log should require cache digest metadata");
5915        assert!(err.contains("cache_key_digest"));
5916    }
5917
5918    #[test]
5919    fn structured_test_log_packet_004_unit_contract_requires_conversion_metadata() {
5920        let mut env = base_env();
5921        env.insert("conversion_path".to_owned(), "edge_list".to_owned());
5922        env.insert("input_shape".to_owned(), "edge_list_payload".to_owned());
5923        env.insert("strict_mode".to_owned(), "true".to_owned());
5924
5925        let replay = "rch exec -- cargo test -p fnx-convert unit_packet_004_contract_asserted -- --nocapture";
5926        let log = StructuredTestLog {
5927            schema_version: structured_test_log_schema_version().to_owned(),
5928            run_id: "run-p2c004-unit".to_owned(),
5929            ts_unix_ms: 3,
5930            crate_name: "fnx-convert".to_owned(),
5931            suite_id: "unit".to_owned(),
5932            packet_id: "FNX-P2C-004".to_owned(),
5933            test_name: "unit_packet_004_contract_asserted".to_owned(),
5934            test_id: "unit::fnx-p2c-004::contract".to_owned(),
5935            test_kind: TestKind::Unit,
5936            mode: CompatibilityMode::Strict,
5937            fixture_id: Some("convert::contract::edge_list".to_owned()),
5938            seed: Some(7104),
5939            env_fingerprint: canonical_environment_fingerprint(&env),
5940            environment: env,
5941            duration_ms: 4,
5942            replay_command: replay.to_owned(),
5943            artifact_refs: vec!["artifacts/conformance/latest/structured_logs.jsonl".to_owned()],
5944            forensic_bundle_id: "forensics::convert::unit::contract".to_owned(),
5945            hash_id: "sha256:p2c004-unit".to_owned(),
5946            status: TestStatus::Passed,
5947            reason_code: None,
5948            failure_repro: None,
5949            e2e_step_traces: Vec::new(),
5950            forensics_bundle_index: Some(base_forensics_bundle(
5951                "run-p2c004-unit",
5952                "unit::fnx-p2c-004::contract",
5953                replay,
5954                "forensics::convert::unit::contract",
5955                vec!["artifacts/conformance/latest/structured_logs.jsonl".to_owned()],
5956            )),
5957        };
5958        log.validate()
5959            .expect("packet-004 unit contract log should satisfy metadata schema");
5960
5961        let mut missing_conversion_path = log.clone();
5962        missing_conversion_path
5963            .environment
5964            .remove("conversion_path");
5965        let err = missing_conversion_path
5966            .validate()
5967            .expect_err("missing conversion_path metadata must fail closed");
5968        assert!(err.contains("conversion_path"));
5969
5970        let mut missing_fixture = log;
5971        missing_fixture.fixture_id = None;
5972        let err = missing_fixture
5973            .validate()
5974            .expect_err("packet-004 unit contract should require fixture_id");
5975        assert!(err.contains("fixture_id"));
5976    }
5977
5978    #[test]
5979    fn structured_test_log_packet_004_property_invariants_require_seed_and_fingerprint() {
5980        let mut env = base_env();
5981        env.insert("graph_fingerprint".to_owned(), "graph-fp-004".to_owned());
5982        env.insert("relabel_mode".to_owned(), "copy".to_owned());
5983        env.insert("invariant_id".to_owned(), "P2C004-IV-1".to_owned());
5984
5985        let replay =
5986            "rch exec -- cargo test -p fnx-convert property_packet_004_invariants -- --nocapture";
5987        let log = StructuredTestLog {
5988            schema_version: structured_test_log_schema_version().to_owned(),
5989            run_id: "run-p2c004-property".to_owned(),
5990            ts_unix_ms: 4,
5991            crate_name: "fnx-convert".to_owned(),
5992            suite_id: "property".to_owned(),
5993            packet_id: "FNX-P2C-004".to_owned(),
5994            test_name: "property_packet_004_invariants".to_owned(),
5995            test_id: "property::fnx-p2c-004::invariants".to_owned(),
5996            test_kind: TestKind::Property,
5997            mode: CompatibilityMode::Hardened,
5998            fixture_id: Some("convert::property::edge_list_matrix".to_owned()),
5999            seed: Some(7204),
6000            env_fingerprint: canonical_environment_fingerprint(&env),
6001            environment: env,
6002            duration_ms: 9,
6003            replay_command: replay.to_owned(),
6004            artifact_refs: vec![
6005                "artifacts/conformance/latest/structured_log_emitter_normalization_report.json"
6006                    .to_owned(),
6007            ],
6008            forensic_bundle_id: "forensics::convert::property::invariants".to_owned(),
6009            hash_id: "sha256:p2c004-property".to_owned(),
6010            status: TestStatus::Passed,
6011            reason_code: None,
6012            failure_repro: None,
6013            e2e_step_traces: Vec::new(),
6014            forensics_bundle_index: Some(base_forensics_bundle(
6015                "run-p2c004-property",
6016                "property::fnx-p2c-004::invariants",
6017                replay,
6018                "forensics::convert::property::invariants",
6019                vec![
6020                    "artifacts/conformance/latest/structured_log_emitter_normalization_report.json"
6021                        .to_owned(),
6022                ],
6023            )),
6024        };
6025        log.validate()
6026            .expect("packet-004 property log should satisfy metadata schema");
6027
6028        let mut missing_seed = log.clone();
6029        missing_seed.seed = None;
6030        let err = missing_seed
6031            .validate()
6032            .expect_err("packet-004 property log should require deterministic seed");
6033        assert!(err.contains("deterministic seed"));
6034
6035        let mut missing_fingerprint = log;
6036        missing_fingerprint.environment.remove("graph_fingerprint");
6037        let err = missing_fingerprint
6038            .validate()
6039            .expect_err("packet-004 property log should require graph fingerprint metadata");
6040        assert!(err.contains("graph_fingerprint"));
6041    }
6042
6043    #[test]
6044    fn structured_test_log_packet_005_unit_contract_requires_algorithm_metadata() {
6045        let mut env = base_env();
6046        env.insert(
6047            "algorithm_family".to_owned(),
6048            "shortest_path_first_wave".to_owned(),
6049        );
6050        env.insert("source_target_pair".to_owned(), "a->e".to_owned());
6051        env.insert("strict_mode".to_owned(), "true".to_owned());
6052        env.insert("policy_row_id".to_owned(), "CGSE-POL-R08".to_owned());
6053
6054        let replay = "rch exec -- cargo test -p fnx-algorithms unit_packet_005_contract_asserted -- --nocapture";
6055        let log = StructuredTestLog {
6056            schema_version: structured_test_log_schema_version().to_owned(),
6057            run_id: "run-p2c005-unit".to_owned(),
6058            ts_unix_ms: 5,
6059            crate_name: "fnx-algorithms".to_owned(),
6060            suite_id: "unit".to_owned(),
6061            packet_id: "FNX-P2C-005".to_owned(),
6062            test_name: "unit_packet_005_contract_asserted".to_owned(),
6063            test_id: "unit::fnx-p2c-005::contract".to_owned(),
6064            test_kind: TestKind::Unit,
6065            mode: CompatibilityMode::Strict,
6066            fixture_id: Some("algorithms::contract::shortest_path_wave".to_owned()),
6067            seed: Some(7105),
6068            env_fingerprint: canonical_environment_fingerprint(&env),
6069            environment: env,
6070            duration_ms: 6,
6071            replay_command: replay.to_owned(),
6072            artifact_refs: vec!["artifacts/conformance/latest/structured_logs.jsonl".to_owned()],
6073            forensic_bundle_id: "forensics::algorithms::unit::contract".to_owned(),
6074            hash_id: "sha256:p2c005-unit".to_owned(),
6075            status: TestStatus::Passed,
6076            reason_code: None,
6077            failure_repro: None,
6078            e2e_step_traces: Vec::new(),
6079            forensics_bundle_index: Some(base_forensics_bundle(
6080                "run-p2c005-unit",
6081                "unit::fnx-p2c-005::contract",
6082                replay,
6083                "forensics::algorithms::unit::contract",
6084                vec!["artifacts/conformance/latest/structured_logs.jsonl".to_owned()],
6085            )),
6086        };
6087        log.validate()
6088            .expect("packet-005 unit contract log should satisfy metadata schema");
6089
6090        let mut missing_algorithm_family = log.clone();
6091        missing_algorithm_family
6092            .environment
6093            .remove("algorithm_family");
6094        let err = missing_algorithm_family
6095            .validate()
6096            .expect_err("missing algorithm_family metadata must fail closed");
6097        assert!(err.contains("algorithm_family"));
6098
6099        let mut missing_fixture = log;
6100        missing_fixture.fixture_id = None;
6101        let err = missing_fixture
6102            .validate()
6103            .expect_err("packet-005 unit contract should require fixture_id");
6104        assert!(err.contains("fixture_id"));
6105
6106        let mut missing_policy_row = missing_algorithm_family.clone();
6107        missing_policy_row.environment.insert(
6108            "algorithm_family".to_owned(),
6109            "shortest_path_first_wave".to_owned(),
6110        );
6111        missing_policy_row.environment.remove("policy_row_id");
6112        let err = missing_policy_row
6113            .validate()
6114            .expect_err("packet-005 unit contract should require policy_row_id metadata");
6115        assert!(err.contains("policy_row_id"));
6116    }
6117
6118    #[test]
6119    fn structured_test_log_packet_005_property_invariants_require_seed_and_tie_break_metadata() {
6120        let mut env = base_env();
6121        env.insert("graph_fingerprint".to_owned(), "graph-fp-005".to_owned());
6122        env.insert(
6123            "tie_break_policy".to_owned(),
6124            "lexical_neighbor_order".to_owned(),
6125        );
6126        env.insert("invariant_id".to_owned(), "P2C005-INV-1".to_owned());
6127        env.insert("policy_row_id".to_owned(), "CGSE-POL-R08".to_owned());
6128
6129        let replay = "rch exec -- cargo test -p fnx-algorithms property_packet_005_invariants -- --nocapture";
6130        let log = StructuredTestLog {
6131            schema_version: structured_test_log_schema_version().to_owned(),
6132            run_id: "run-p2c005-property".to_owned(),
6133            ts_unix_ms: 6,
6134            crate_name: "fnx-algorithms".to_owned(),
6135            suite_id: "property".to_owned(),
6136            packet_id: "FNX-P2C-005".to_owned(),
6137            test_name: "property_packet_005_invariants".to_owned(),
6138            test_id: "property::fnx-p2c-005::invariants".to_owned(),
6139            test_kind: TestKind::Property,
6140            mode: CompatibilityMode::Hardened,
6141            fixture_id: Some("algorithms::property::path_and_centrality_matrix".to_owned()),
6142            seed: Some(7205),
6143            env_fingerprint: canonical_environment_fingerprint(&env),
6144            environment: env,
6145            duration_ms: 10,
6146            replay_command: replay.to_owned(),
6147            artifact_refs: vec![
6148                "artifacts/conformance/latest/structured_log_emitter_normalization_report.json"
6149                    .to_owned(),
6150            ],
6151            forensic_bundle_id: "forensics::algorithms::property::invariants".to_owned(),
6152            hash_id: "sha256:p2c005-property".to_owned(),
6153            status: TestStatus::Passed,
6154            reason_code: None,
6155            failure_repro: None,
6156            e2e_step_traces: Vec::new(),
6157            forensics_bundle_index: Some(base_forensics_bundle(
6158                "run-p2c005-property",
6159                "property::fnx-p2c-005::invariants",
6160                replay,
6161                "forensics::algorithms::property::invariants",
6162                vec![
6163                    "artifacts/conformance/latest/structured_log_emitter_normalization_report.json"
6164                        .to_owned(),
6165                ],
6166            )),
6167        };
6168        log.validate()
6169            .expect("packet-005 property log should satisfy metadata schema");
6170
6171        let mut missing_seed = log.clone();
6172        missing_seed.seed = None;
6173        let err = missing_seed
6174            .validate()
6175            .expect_err("packet-005 property log should require deterministic seed");
6176        assert!(err.contains("deterministic seed"));
6177
6178        let mut missing_tie_break = log;
6179        missing_tie_break.environment.remove("tie_break_policy");
6180        let err = missing_tie_break
6181            .validate()
6182            .expect_err("packet-005 property log should require tie_break_policy metadata");
6183        assert!(err.contains("tie_break_policy"));
6184
6185        let mut missing_policy_row = missing_seed;
6186        missing_policy_row.seed = Some(7205);
6187        missing_policy_row.environment.remove("policy_row_id");
6188        let err = missing_policy_row
6189            .validate()
6190            .expect_err("packet-005 property log should require policy_row_id metadata");
6191        assert!(err.contains("policy_row_id"));
6192    }
6193
6194    #[test]
6195    fn structured_test_log_packet_006_unit_contract_requires_readwrite_metadata() {
6196        let mut env = base_env();
6197        env.insert("io_path".to_owned(), "edgelist+json_graph".to_owned());
6198        env.insert("strict_mode".to_owned(), "true".to_owned());
6199        env.insert("input_digest".to_owned(), "sha256:input-p2c006".to_owned());
6200        env.insert(
6201            "output_digest".to_owned(),
6202            "sha256:output-p2c006".to_owned(),
6203        );
6204
6205        let replay = "rch exec -- cargo test -p fnx-readwrite unit_packet_006_contract_asserted -- --nocapture";
6206        let log = StructuredTestLog {
6207            schema_version: structured_test_log_schema_version().to_owned(),
6208            run_id: "run-p2c006-unit".to_owned(),
6209            ts_unix_ms: 7,
6210            crate_name: "fnx-readwrite".to_owned(),
6211            suite_id: "unit".to_owned(),
6212            packet_id: "FNX-P2C-006".to_owned(),
6213            test_name: "unit_packet_006_contract_asserted".to_owned(),
6214            test_id: "unit::fnx-p2c-006::contract".to_owned(),
6215            test_kind: TestKind::Unit,
6216            mode: CompatibilityMode::Strict,
6217            fixture_id: Some("readwrite::contract::edgelist_json_roundtrip".to_owned()),
6218            seed: Some(7106),
6219            env_fingerprint: canonical_environment_fingerprint(&env),
6220            environment: env,
6221            duration_ms: 8,
6222            replay_command: replay.to_owned(),
6223            artifact_refs: vec!["artifacts/conformance/latest/structured_logs.jsonl".to_owned()],
6224            forensic_bundle_id: "forensics::readwrite::unit::contract".to_owned(),
6225            hash_id: "sha256:p2c006-unit".to_owned(),
6226            status: TestStatus::Passed,
6227            reason_code: None,
6228            failure_repro: None,
6229            e2e_step_traces: Vec::new(),
6230            forensics_bundle_index: Some(base_forensics_bundle(
6231                "run-p2c006-unit",
6232                "unit::fnx-p2c-006::contract",
6233                replay,
6234                "forensics::readwrite::unit::contract",
6235                vec!["artifacts/conformance/latest/structured_logs.jsonl".to_owned()],
6236            )),
6237        };
6238        log.validate()
6239            .expect("packet-006 unit contract log should satisfy metadata schema");
6240
6241        let mut missing_io_path = log.clone();
6242        missing_io_path.environment.remove("io_path");
6243        let err = missing_io_path
6244            .validate()
6245            .expect_err("packet-006 unit log should require io_path metadata");
6246        assert!(err.contains("io_path"));
6247
6248        let mut missing_output_digest = log.clone();
6249        missing_output_digest.environment.remove("output_digest");
6250        let err = missing_output_digest
6251            .validate()
6252            .expect_err("packet-006 unit log should require output_digest metadata");
6253        assert!(err.contains("output_digest"));
6254
6255        let mut missing_fixture = log;
6256        missing_fixture.fixture_id = None;
6257        let err = missing_fixture
6258            .validate()
6259            .expect_err("packet-006 unit contract should require fixture_id");
6260        assert!(err.contains("fixture_id"));
6261    }
6262
6263    #[test]
6264    fn structured_test_log_packet_006_property_invariants_require_seed_and_mode_policy_metadata() {
6265        let mut env = base_env();
6266        env.insert("graph_fingerprint".to_owned(), "graph-fp-006".to_owned());
6267        env.insert("mode_policy".to_owned(), "strict_and_hardened".to_owned());
6268        env.insert("invariant_id".to_owned(), "P2C006-IV-1".to_owned());
6269        env.insert(
6270            "input_digest".to_owned(),
6271            "sha256:input-prop-p2c006".to_owned(),
6272        );
6273        env.insert(
6274            "output_digest".to_owned(),
6275            "sha256:output-prop-p2c006".to_owned(),
6276        );
6277
6278        let replay =
6279            "rch exec -- cargo test -p fnx-readwrite property_packet_006_invariants -- --nocapture";
6280        let log = StructuredTestLog {
6281            schema_version: structured_test_log_schema_version().to_owned(),
6282            run_id: "run-p2c006-property".to_owned(),
6283            ts_unix_ms: 8,
6284            crate_name: "fnx-readwrite".to_owned(),
6285            suite_id: "property".to_owned(),
6286            packet_id: "FNX-P2C-006".to_owned(),
6287            test_name: "property_packet_006_invariants".to_owned(),
6288            test_id: "property::fnx-p2c-006::invariants".to_owned(),
6289            test_kind: TestKind::Property,
6290            mode: CompatibilityMode::Hardened,
6291            fixture_id: Some("readwrite::property::roundtrip_recovery_matrix".to_owned()),
6292            seed: Some(7206),
6293            env_fingerprint: canonical_environment_fingerprint(&env),
6294            environment: env,
6295            duration_ms: 12,
6296            replay_command: replay.to_owned(),
6297            artifact_refs: vec![
6298                "artifacts/conformance/latest/structured_log_emitter_normalization_report.json"
6299                    .to_owned(),
6300            ],
6301            forensic_bundle_id: "forensics::readwrite::property::invariants".to_owned(),
6302            hash_id: "sha256:p2c006-property".to_owned(),
6303            status: TestStatus::Passed,
6304            reason_code: None,
6305            failure_repro: None,
6306            e2e_step_traces: Vec::new(),
6307            forensics_bundle_index: Some(base_forensics_bundle(
6308                "run-p2c006-property",
6309                "property::fnx-p2c-006::invariants",
6310                replay,
6311                "forensics::readwrite::property::invariants",
6312                vec![
6313                    "artifacts/conformance/latest/structured_log_emitter_normalization_report.json"
6314                        .to_owned(),
6315                ],
6316            )),
6317        };
6318        log.validate()
6319            .expect("packet-006 property log should satisfy metadata schema");
6320
6321        let mut missing_seed = log.clone();
6322        missing_seed.seed = None;
6323        let err = missing_seed
6324            .validate()
6325            .expect_err("packet-006 property log should require deterministic seed");
6326        assert!(err.contains("deterministic seed"));
6327
6328        let mut missing_mode_policy = log.clone();
6329        missing_mode_policy.environment.remove("mode_policy");
6330        let err = missing_mode_policy
6331            .validate()
6332            .expect_err("packet-006 property log should require mode_policy metadata");
6333        assert!(err.contains("mode_policy"));
6334
6335        let mut missing_input_digest = log;
6336        missing_input_digest.environment.remove("input_digest");
6337        let err = missing_input_digest
6338            .validate()
6339            .expect_err("packet-006 property log should require input_digest metadata");
6340        assert!(err.contains("input_digest"));
6341    }
6342
6343    fn base_transfer_intent() -> AsupersyncTransferIntent {
6344        AsupersyncTransferIntent {
6345            transfer_id: "tx-asup-001".to_owned(),
6346            artifact_id: "artifacts/e2e/latest/e2e_scenario_matrix_report_v1.json".to_owned(),
6347            artifact_class: "conformance_fixture_bundle".to_owned(),
6348            mode: CompatibilityMode::Strict,
6349            deterministic_seed: 17,
6350            expected_checksum: "sha256:expected-123".to_owned(),
6351            max_attempts: 3,
6352        }
6353    }
6354
6355    #[test]
6356    fn asupersync_adapter_resume_is_deterministic_across_checkpoint_restart() {
6357        let intent = base_transfer_intent();
6358        let mut machine =
6359            AsupersyncAdapterMachine::start(intent.clone()).expect("start should succeed");
6360        machine
6361            .mark_capability_check(true)
6362            .expect("capability check should pass");
6363        machine
6364            .record_chunk_commit(256)
6365            .expect("chunk commit should succeed");
6366        machine
6367            .record_transport_interruption()
6368            .expect("interruption should consume retry budget");
6369        let checkpoint = machine.checkpoint();
6370
6371        let mut resumed = AsupersyncAdapterMachine::resume_from_checkpoint(intent, checkpoint)
6372            .expect("resume_from_checkpoint should succeed");
6373        assert_eq!(resumed.state(), AsupersyncAdapterState::Syncing);
6374        assert_eq!(resumed.attempt(), 1);
6375        assert_eq!(resumed.committed_cursor(), 256);
6376
6377        resumed
6378            .apply_resume_cursor(256)
6379            .expect("resume cursor should be accepted");
6380        resumed
6381            .start_checksum_verification()
6382            .expect("checksum verification should start");
6383        resumed
6384            .finish_checksum_verification("sha256:expected-123")
6385            .expect("checksum should finalize");
6386        assert_eq!(resumed.state(), AsupersyncAdapterState::Completed);
6387        assert!(resumed.validate_transition_log().is_ok());
6388    }
6389
6390    #[test]
6391    fn asupersync_adapter_resume_rejects_transfer_mismatch() {
6392        let intent = base_transfer_intent();
6393        let checkpoint = AsupersyncAdapterMachine::start(intent.clone())
6394            .expect("start should succeed")
6395            .checkpoint();
6396
6397        let mut mismatched_intent = intent;
6398        mismatched_intent.transfer_id = "tx-asup-other".to_owned();
6399        let err = AsupersyncAdapterMachine::resume_from_checkpoint(mismatched_intent, checkpoint)
6400            .expect_err("transfer mismatch should reject resume");
6401        assert!(err.contains("transfer_id"));
6402    }
6403
6404    #[test]
6405    fn asupersync_adapter_checksum_mismatch_is_fail_closed_and_audited() {
6406        let intent = base_transfer_intent();
6407        let mut machine = AsupersyncAdapterMachine::start(intent).expect("start should succeed");
6408        machine
6409            .mark_capability_check(true)
6410            .expect("capability check should pass");
6411        machine
6412            .record_chunk_commit(10)
6413            .expect("chunk commit should succeed");
6414        machine
6415            .start_checksum_verification()
6416            .expect("checksum verification should start");
6417        let err = machine
6418            .finish_checksum_verification("sha256:unexpected")
6419            .expect_err("checksum mismatch must fail closed");
6420        assert!(err.contains("IntegrityPrecheckFailed"));
6421        assert_eq!(machine.state(), AsupersyncAdapterState::FailedClosed);
6422        let last = machine
6423            .transitions()
6424            .last()
6425            .expect("transition log should contain mismatch event");
6426        assert_eq!(
6427            last.reason_code,
6428            Some(AsupersyncAdapterReasonCode::IntegrityPrecheckFailed)
6429        );
6430    }
6431
6432    #[test]
6433    fn asupersync_adapter_conflict_detection_is_explicit_fail_closed() {
6434        let intent = base_transfer_intent();
6435        let mut machine = AsupersyncAdapterMachine::start(intent).expect("start should succeed");
6436        machine
6437            .mark_capability_check(true)
6438            .expect("capability check should pass");
6439        let err = machine
6440            .record_conflict(5, 6)
6441            .expect_err("epoch mismatch should fail closed");
6442        assert!(err.contains("ConflictDetected"));
6443        assert_eq!(machine.state(), AsupersyncAdapterState::FailedClosed);
6444    }
6445
6446    #[test]
6447    fn asupersync_adapter_retry_budget_exhaustion_fault_injection_is_fail_closed() {
6448        let mut intent = base_transfer_intent();
6449        intent.max_attempts = 2;
6450        let mut machine =
6451            AsupersyncAdapterMachine::start(intent).expect("start should succeed for retry test");
6452        machine
6453            .mark_capability_check(true)
6454            .expect("capability check should pass");
6455
6456        machine
6457            .record_transport_interruption()
6458            .expect("first interruption should consume retry");
6459        machine
6460            .record_transport_interruption()
6461            .expect("second interruption should consume retry");
6462        let err = machine
6463            .record_transport_interruption()
6464            .expect_err("third interruption should fail closed");
6465        assert!(err.contains("RetryExhausted"));
6466        assert_eq!(machine.state(), AsupersyncAdapterState::FailedClosed);
6467        let last = machine
6468            .transitions()
6469            .last()
6470            .expect("transition log should contain retry exhaustion transition");
6471        assert_eq!(
6472            last.reason_code,
6473            Some(AsupersyncAdapterReasonCode::RetryExhausted)
6474        );
6475    }
6476
6477    #[test]
6478    fn asupersync_adapter_partial_write_cursor_regression_is_fail_closed() {
6479        let intent = base_transfer_intent();
6480        let mut machine = AsupersyncAdapterMachine::start(intent).expect("start should succeed");
6481        machine
6482            .mark_capability_check(true)
6483            .expect("capability check should pass");
6484        machine
6485            .record_chunk_commit(200)
6486            .expect("initial chunk commit should succeed");
6487        let err = machine
6488            .record_chunk_commit(150)
6489            .expect_err("cursor regression should fail closed");
6490        assert!(err.contains("ConflictDetected"));
6491        assert_eq!(machine.state(), AsupersyncAdapterState::FailedClosed);
6492    }
6493
6494    #[test]
6495    fn asupersync_adapter_stale_metadata_seed_mismatch_rejects_resume() {
6496        let intent = base_transfer_intent();
6497        let checkpoint = AsupersyncAdapterMachine::start(intent.clone())
6498            .expect("start should succeed")
6499            .checkpoint();
6500        let mut stale_intent = intent;
6501        stale_intent.deterministic_seed = 999;
6502
6503        let err = AsupersyncAdapterMachine::resume_from_checkpoint(stale_intent, checkpoint)
6504            .expect_err("seed mismatch should reject stale metadata resume");
6505        assert!(err.contains("deterministic_seed"));
6506    }
6507
6508    #[test]
6509    fn asupersync_adapter_property_same_fault_sequence_has_identical_transitions() {
6510        for seed in [3_u64, 17_u64, 91_u64] {
6511            let make_machine = || {
6512                let mut intent = base_transfer_intent();
6513                intent.deterministic_seed = seed;
6514                let mut machine =
6515                    AsupersyncAdapterMachine::start(intent).expect("start should succeed");
6516                machine
6517                    .mark_capability_check(true)
6518                    .expect("capability check should pass");
6519                machine
6520                    .record_chunk_commit(88)
6521                    .expect("chunk commit should succeed");
6522                machine
6523                    .record_transport_interruption()
6524                    .expect("interruption should succeed");
6525                machine
6526                    .apply_resume_cursor(88)
6527                    .expect("resume cursor should succeed");
6528                machine
6529                    .start_checksum_verification()
6530                    .expect("checksum phase should start");
6531                machine
6532                    .finish_checksum_verification("sha256:expected-123")
6533                    .expect("checksum should complete");
6534                machine
6535            };
6536
6537            let first = make_machine();
6538            let second = make_machine();
6539            assert_eq!(
6540                first.transitions(),
6541                second.transitions(),
6542                "same fault sequence should produce identical transitions for seed {seed}"
6543            );
6544            assert_eq!(first.state(), AsupersyncAdapterState::Completed);
6545            assert_eq!(second.state(), AsupersyncAdapterState::Completed);
6546        }
6547    }
6548
6549    fn base_ftui_log(
6550        run_id: &str,
6551        test_id: &str,
6552        suite_id: &str,
6553        ts_unix_ms: u128,
6554        status: TestStatus,
6555        reason_code: Option<&str>,
6556    ) -> StructuredTestLog {
6557        let env = base_env();
6558        StructuredTestLog {
6559            schema_version: structured_test_log_schema_version().to_owned(),
6560            run_id: run_id.to_owned(),
6561            ts_unix_ms,
6562            crate_name: "fnx-conformance".to_owned(),
6563            suite_id: suite_id.to_owned(),
6564            packet_id: "FNX-P2C-FTUI".to_owned(),
6565            test_name: format!("test::{test_id}"),
6566            test_id: test_id.to_owned(),
6567            test_kind: TestKind::E2e,
6568            mode: CompatibilityMode::Strict,
6569            fixture_id: Some("fixture-ftui-001".to_owned()),
6570            seed: Some(7),
6571            env_fingerprint: canonical_environment_fingerprint(&env),
6572            environment: env,
6573            duration_ms: 42,
6574            replay_command: "rch exec -- cargo test -p fnx-conformance -- --nocapture".to_owned(),
6575            artifact_refs: vec![
6576                "artifacts/e2e/latest/e2e_scenario_matrix_steps_v1.jsonl".to_owned(),
6577                "artifacts/conformance/latest/structured_logs.jsonl".to_owned(),
6578            ],
6579            forensic_bundle_id: format!("forensics::{suite_id}::{test_id}"),
6580            hash_id: format!("sha256:{run_id}:{test_id}"),
6581            status,
6582            reason_code: reason_code.map(ToOwned::to_owned),
6583            failure_repro: match status {
6584                TestStatus::Failed => Some(FailureReproData {
6585                    failure_message: "deterministic failure".to_owned(),
6586                    reproduction_command:
6587                        "rch exec -- cargo test -p fnx-conformance -- --nocapture".to_owned(),
6588                    expected_behavior: "completed".to_owned(),
6589                    observed_behavior: "failed_closed".to_owned(),
6590                    seed: Some(7),
6591                    fixture_id: Some("fixture-ftui-001".to_owned()),
6592                    artifact_hash_id: Some(format!("sha256:{run_id}:{test_id}")),
6593                    forensics_link: Some(
6594                        "artifacts/conformance/latest/structured_logs.jsonl".to_owned(),
6595                    ),
6596                }),
6597                TestStatus::Passed | TestStatus::Skipped => None,
6598            },
6599            e2e_step_traces: vec![E2eStepTrace {
6600                run_id: run_id.to_owned(),
6601                test_id: test_id.to_owned(),
6602                step_id: format!("step::{test_id}"),
6603                step_label: "ftui-step".to_owned(),
6604                phase: "execute".to_owned(),
6605                status: match status {
6606                    TestStatus::Passed => E2eStepStatus::Passed,
6607                    TestStatus::Failed => E2eStepStatus::Failed,
6608                    TestStatus::Skipped => E2eStepStatus::Skipped,
6609                },
6610                start_unix_ms: ts_unix_ms,
6611                end_unix_ms: ts_unix_ms + 42,
6612                duration_ms: 42,
6613                replay_command: "rch exec -- cargo test -p fnx-conformance -- --nocapture"
6614                    .to_owned(),
6615                forensic_bundle_id: format!("forensics::{suite_id}::{test_id}"),
6616                artifact_refs: vec![
6617                    "artifacts/e2e/latest/e2e_scenario_matrix_steps_v1.jsonl".to_owned(),
6618                ],
6619                hash_id: format!("step-hash::{run_id}::{test_id}"),
6620                reason_code: reason_code.map(ToOwned::to_owned),
6621            }],
6622            forensics_bundle_index: Some(base_forensics_bundle(
6623                run_id,
6624                test_id,
6625                "rch exec -- cargo test -p fnx-conformance -- --nocapture",
6626                &format!("forensics::{suite_id}::{test_id}"),
6627                vec![
6628                    "artifacts/e2e/latest/e2e_scenario_matrix_steps_v1.jsonl".to_owned(),
6629                    "artifacts/conformance/latest/structured_logs.jsonl".to_owned(),
6630                ],
6631            )),
6632        }
6633    }
6634
6635    #[test]
6636    fn ftui_adapter_rejects_unknown_telemetry_fields() {
6637        let adapter = FtuiTelemetryAdapter::strict_default();
6638        let mut row = BTreeMap::new();
6639        for field in ftui_telemetry_canonical_fields() {
6640            row.insert((*field).to_owned(), "value".to_owned());
6641        }
6642        row.insert("unknown_field".to_owned(), "boom".to_owned());
6643        let err = adapter
6644            .ingest_row(&row)
6645            .expect_err("unknown field should fail closed");
6646        assert!(err.contains("unknown telemetry field"));
6647        assert!(err.contains("allowed fields"));
6648    }
6649
6650    #[test]
6651    fn ftui_adapter_produces_deterministic_artifact_index_ordering() {
6652        let adapter = FtuiTelemetryAdapter::strict_default();
6653        let log_a = base_ftui_log(
6654            "run-b",
6655            "tests::b",
6656            "suite-b",
6657            2_000,
6658            TestStatus::Passed,
6659            None,
6660        );
6661        let log_b = base_ftui_log(
6662            "run-a",
6663            "tests::a",
6664            "suite-a",
6665            1_000,
6666            TestStatus::Passed,
6667            None,
6668        );
6669        let log_c = base_ftui_log(
6670            "run-c",
6671            "tests::c",
6672            "suite-c",
6673            2_000,
6674            TestStatus::Failed,
6675            Some("integrity_precheck_failed"),
6676        );
6677
6678        let index_one = adapter
6679            .build_artifact_index(&[log_a.clone(), log_b.clone(), log_c.clone()])
6680            .expect("index build should succeed");
6681        let index_two = adapter
6682            .build_artifact_index(&[log_c, log_b, log_a])
6683            .expect("index build should succeed");
6684
6685        assert_eq!(
6686            index_one, index_two,
6687            "deterministic artifact index should be stable across input permutations"
6688        );
6689        assert_eq!(index_one.entries.len(), 3);
6690        assert_eq!(index_one.entries[0].run_id, "run-a");
6691        assert_eq!(index_one.entries[1].run_id, "run-b");
6692        assert_eq!(index_one.entries[2].run_id, "run-c");
6693    }
6694
6695    #[test]
6696    fn ftui_adapter_property_replay_mapping_is_deterministic_for_permuted_inputs() {
6697        let adapter = FtuiTelemetryAdapter::strict_default();
6698        let logs = vec![
6699            base_ftui_log(
6700                "run-a",
6701                "tests::ftui_a",
6702                "suite-a",
6703                1_000,
6704                TestStatus::Passed,
6705                None,
6706            ),
6707            base_ftui_log(
6708                "run-b",
6709                "tests::ftui_b",
6710                "suite-b",
6711                2_000,
6712                TestStatus::Failed,
6713                Some("integrity_precheck_failed"),
6714            ),
6715            base_ftui_log(
6716                "run-c",
6717                "tests::ftui_c",
6718                "suite-c",
6719                3_000,
6720                TestStatus::Skipped,
6721                Some("skipped_by_policy"),
6722            ),
6723            base_ftui_log(
6724                "run-d",
6725                "tests::ftui_d",
6726                "suite-d",
6727                4_000,
6728                TestStatus::Passed,
6729                None,
6730            ),
6731        ];
6732
6733        let baseline = adapter
6734            .build_artifact_index(&logs)
6735            .expect("baseline artifact index should build");
6736
6737        for permutation in [[2_usize, 0, 3, 1], [1, 3, 0, 2], [3, 2, 1, 0]] {
6738            let permuted_logs = permutation
6739                .iter()
6740                .map(|idx| logs[*idx].clone())
6741                .collect::<Vec<_>>();
6742            let candidate = adapter
6743                .build_artifact_index(&permuted_logs)
6744                .expect("permuted artifact index should build");
6745            assert_eq!(
6746                candidate, baseline,
6747                "artifact index and replay mapping should be stable for permutation {permutation:?}"
6748            );
6749        }
6750
6751        for entry in &baseline.entries {
6752            assert!(
6753                entry.replay_ref.contains("rch exec --"),
6754                "replay_ref should stay rch-offloaded"
6755            );
6756            assert!(
6757                !entry.artifact_refs.is_empty(),
6758                "artifact_refs should remain non-empty for replay mapping"
6759            );
6760            assert!(
6761                entry
6762                    .artifact_refs
6763                    .iter()
6764                    .all(|path| path.starts_with("artifacts/")),
6765                "artifact_refs should remain workspace artifact paths"
6766            );
6767        }
6768    }
6769
6770    #[test]
6771    fn ftui_adapter_snapshot_artifact_index_entry_is_stable() {
6772        let adapter = FtuiTelemetryAdapter::strict_default();
6773        let log = base_ftui_log(
6774            "run-snap",
6775            "tests::ftui_snapshot",
6776            "snapshot",
6777            5_000,
6778            TestStatus::Passed,
6779            None,
6780        );
6781        let index = adapter
6782            .build_artifact_index(&[log])
6783            .expect("snapshot artifact index should build");
6784        assert_eq!(index.entries.len(), 1, "snapshot expects one index entry");
6785
6786        let observed = serde_json::to_value(&index.entries[0])
6787            .expect("snapshot entry should serialize to json value");
6788        let expected = serde_json::json!({
6789            "correlation_id": "ftui-corr-9d1b150a8730cdb8",
6790            "bundle_id": "forensics::snapshot::tests::ftui_snapshot",
6791            "run_id": "run-snap",
6792            "test_id": "tests::ftui_snapshot",
6793            "captured_unix_ms": 5000,
6794            "replay_ref": "rch exec -- cargo test -p fnx-conformance -- --nocapture",
6795            "artifact_refs": [
6796                "artifacts/e2e/latest/e2e_scenario_matrix_steps_v1.jsonl",
6797                "artifacts/conformance/latest/structured_logs.jsonl"
6798            ],
6799            "status": "passed",
6800            "reason_code": null
6801        });
6802        assert_eq!(
6803            observed, expected,
6804            "snapshot entry should remain deterministic for canonical FTUI fixtures"
6805        );
6806    }
6807
6808    #[test]
6809    fn ftui_adapter_fails_closed_when_structured_log_is_incompatible() {
6810        let adapter = FtuiTelemetryAdapter::strict_default();
6811        let mut invalid = base_ftui_log(
6812            "run-invalid",
6813            "tests::invalid",
6814            "suite-invalid",
6815            3_000,
6816            TestStatus::Passed,
6817            None,
6818        );
6819        invalid.forensics_bundle_index = None;
6820        let err = adapter
6821            .from_structured_log(&invalid)
6822            .expect_err("missing forensics_bundle_index should fail closed");
6823        assert!(err.contains("forensics_bundle_index"));
6824    }
6825
6826    // -----------------------------------------------------------------------
6827    // Versioned Decision Ledger tests
6828    // -----------------------------------------------------------------------
6829
6830    #[test]
6831    fn versioned_ledger_has_correct_schema_version() {
6832        let ledger = super::VersionedDecisionLedger::new("test-ledger");
6833        assert_eq!(ledger.schema_version, super::DECISION_LEDGER_SCHEMA_VERSION);
6834        assert_eq!(ledger.ledger_id, "test-ledger");
6835        assert_eq!(ledger.record_count, 0);
6836        assert!(ledger.records.is_empty());
6837    }
6838
6839    #[test]
6840    fn versioned_ledger_append_updates_metadata() {
6841        let mut ledger = super::VersionedDecisionLedger::new("append-test");
6842        let initial_time = ledger.created_at_unix_ms;
6843
6844        let record = super::DecisionRecord {
6845            ts_unix_ms: initial_time + 1000,
6846            operation: "test_op".to_owned(),
6847            mode: CompatibilityMode::Strict,
6848            action: DecisionAction::Allow,
6849            incompatibility_probability: 0.01,
6850            rationale: "test".to_owned(),
6851            evidence: vec![],
6852        };
6853
6854        ledger.append(record);
6855
6856        assert_eq!(ledger.record_count, 1);
6857        assert_eq!(ledger.records.len(), 1);
6858        assert_eq!(ledger.last_updated_unix_ms, initial_time + 1000);
6859    }
6860
6861    #[test]
6862    fn versioned_ledger_schema_compatibility() {
6863        let ledger = super::VersionedDecisionLedger::new("compat-test");
6864        assert!(ledger.is_schema_compatible());
6865
6866        // Create ledger with different major version
6867        let mut incompatible = ledger.clone();
6868        incompatible.schema_version = "2.0.0".to_owned();
6869        assert!(!incompatible.is_schema_compatible());
6870
6871        // Same major version is compatible
6872        let mut compatible = ledger.clone();
6873        compatible.schema_version = "1.5.0".to_owned();
6874        assert!(compatible.is_schema_compatible());
6875    }
6876
6877    #[test]
6878    fn versioned_ledger_validation_passes_for_valid_ledger() {
6879        let ledger = super::VersionedDecisionLedger::new("valid-test");
6880        let result = ledger.validate();
6881        assert!(result.is_valid);
6882        assert!(result.schema_compatible);
6883        assert!(result.errors.is_empty());
6884    }
6885
6886    #[test]
6887    fn versioned_ledger_validation_catches_empty_fields() {
6888        let mut ledger = super::VersionedDecisionLedger::new("invalid-test");
6889        ledger.schema_version = String::new();
6890
6891        let result = ledger.validate();
6892        assert!(!result.is_valid);
6893        assert!(result.errors.iter().any(|e| e.contains("schema_version")));
6894    }
6895
6896    #[test]
6897    fn versioned_ledger_validation_catches_count_mismatch() {
6898        let mut ledger = super::VersionedDecisionLedger::new("count-test");
6899        ledger.record_count = 5; // Claim 5 records but have 0
6900
6901        let result = ledger.validate();
6902        assert!(!result.is_valid);
6903        assert!(result.errors.iter().any(|e| e.contains("record_count")));
6904    }
6905
6906    #[test]
6907    fn versioned_ledger_json_roundtrip() {
6908        let mut ledger = super::VersionedDecisionLedger::new("json-test");
6909        ledger.append(super::DecisionRecord {
6910            ts_unix_ms: 1000,
6911            operation: "roundtrip_op".to_owned(),
6912            mode: CompatibilityMode::Hardened,
6913            action: DecisionAction::FullValidate,
6914            incompatibility_probability: 0.5,
6915            rationale: "testing roundtrip".to_owned(),
6916            evidence: vec![super::EvidenceTerm {
6917                signal: "test_signal".to_owned(),
6918                observed_value: "test_value".to_owned(),
6919                log_likelihood_ratio: 1.5,
6920            }],
6921        });
6922
6923        let json = ledger.to_json_pretty().expect("serialization should work");
6924        let restored =
6925            super::VersionedDecisionLedger::from_json(&json).expect("deserialization should work");
6926
6927        assert_eq!(restored.schema_version, ledger.schema_version);
6928        assert_eq!(restored.ledger_id, ledger.ledger_id);
6929        assert_eq!(restored.records.len(), 1);
6930        assert_eq!(restored.records[0].operation, "roundtrip_op");
6931    }
6932
6933    #[test]
6934    fn versioned_ledger_merge_combines_and_sorts() {
6935        let mut ledger1 = super::VersionedDecisionLedger::new("merge-1");
6936        ledger1.append(super::DecisionRecord {
6937            ts_unix_ms: 1000,
6938            operation: "op1".to_owned(),
6939            mode: CompatibilityMode::Strict,
6940            action: DecisionAction::Allow,
6941            incompatibility_probability: 0.01,
6942            rationale: "first".to_owned(),
6943            evidence: vec![],
6944        });
6945        ledger1.append(super::DecisionRecord {
6946            ts_unix_ms: 3000,
6947            operation: "op3".to_owned(),
6948            mode: CompatibilityMode::Strict,
6949            action: DecisionAction::Allow,
6950            incompatibility_probability: 0.01,
6951            rationale: "third".to_owned(),
6952            evidence: vec![],
6953        });
6954
6955        let mut ledger2 = super::VersionedDecisionLedger::new("merge-2");
6956        ledger2.append(super::DecisionRecord {
6957            ts_unix_ms: 2000,
6958            operation: "op2".to_owned(),
6959            mode: CompatibilityMode::Strict,
6960            action: DecisionAction::Allow,
6961            incompatibility_probability: 0.01,
6962            rationale: "second".to_owned(),
6963            evidence: vec![],
6964        });
6965
6966        ledger1.merge(&ledger2);
6967
6968        assert_eq!(ledger1.record_count, 3);
6969        assert_eq!(ledger1.records[0].ts_unix_ms, 1000);
6970        assert_eq!(ledger1.records[1].ts_unix_ms, 2000);
6971        assert_eq!(ledger1.records[2].ts_unix_ms, 3000);
6972    }
6973
6974    #[test]
6975    fn evidence_ledger_to_versioned_conversion() {
6976        let mut evidence = EvidenceLedger::new();
6977        evidence.record(super::DecisionRecord {
6978            ts_unix_ms: 5000,
6979            operation: "convert_op".to_owned(),
6980            mode: CompatibilityMode::Strict,
6981            action: DecisionAction::Allow,
6982            incompatibility_probability: 0.01,
6983            rationale: "conversion test".to_owned(),
6984            evidence: vec![],
6985        });
6986
6987        let versioned = evidence.to_versioned();
6988        assert_eq!(
6989            versioned.schema_version,
6990            super::DECISION_LEDGER_SCHEMA_VERSION
6991        );
6992        assert_eq!(versioned.record_count, 1);
6993        assert_eq!(versioned.records[0].operation, "convert_op");
6994    }
6995
6996    // -----------------------------------------------------------------------
6997    // Drift Analysis tests
6998    // -----------------------------------------------------------------------
6999
7000    #[test]
7001    fn drift_analyzer_empty_ledger() {
7002        let ledger = super::VersionedDecisionLedger::new("empty");
7003        let analyzer = super::DriftAnalyzer::new();
7004        let report = analyzer.analyze(&ledger);
7005
7006        assert_eq!(report.total_decisions, 0);
7007        assert_eq!(report.under_confident_count, 0);
7008        assert!(!report.has_drift());
7009        assert!(!report.has_high_risk_operations());
7010    }
7011
7012    #[test]
7013    fn drift_analyzer_identifies_under_confident_decisions() {
7014        let mut ledger = super::VersionedDecisionLedger::new("under-confident");
7015
7016        // Add some low-confidence decisions
7017        ledger.append(super::DecisionRecord {
7018            ts_unix_ms: 1000,
7019            operation: "safe_op".to_owned(),
7020            mode: CompatibilityMode::Strict,
7021            action: DecisionAction::Allow,
7022            incompatibility_probability: 0.1, // Low, confident
7023            rationale: "confident".to_owned(),
7024            evidence: vec![],
7025        });
7026        ledger.append(super::DecisionRecord {
7027            ts_unix_ms: 2000,
7028            operation: "risky_op".to_owned(),
7029            mode: CompatibilityMode::Strict,
7030            action: DecisionAction::FullValidate,
7031            incompatibility_probability: 0.5, // High, under-confident
7032            rationale: "uncertain".to_owned(),
7033            evidence: vec![],
7034        });
7035
7036        let analyzer = super::DriftAnalyzer::new();
7037        let report = analyzer.analyze(&ledger);
7038
7039        assert_eq!(report.total_decisions, 2);
7040        assert_eq!(report.under_confident_count, 1);
7041        assert!((report.under_confident_rate - 0.5).abs() < 0.01);
7042        assert!(report.has_high_risk_operations());
7043        assert_eq!(report.top_operations[0].operation, "risky_op");
7044    }
7045
7046    #[test]
7047    fn drift_analyzer_custom_threshold() {
7048        let mut ledger = super::VersionedDecisionLedger::new("custom-threshold");
7049
7050        ledger.append(super::DecisionRecord {
7051            ts_unix_ms: 1000,
7052            operation: "op".to_owned(),
7053            mode: CompatibilityMode::Strict,
7054            action: DecisionAction::Allow,
7055            incompatibility_probability: 0.25,
7056            rationale: "test".to_owned(),
7057            evidence: vec![],
7058        });
7059
7060        // Default threshold is 0.3, so 0.25 is confident
7061        let default_analyzer = super::DriftAnalyzer::new();
7062        let default_report = default_analyzer.analyze(&ledger);
7063        assert_eq!(default_report.under_confident_count, 0);
7064
7065        // Custom threshold of 0.2 makes 0.25 under-confident
7066        let custom_analyzer = super::DriftAnalyzer::with_threshold(0.2);
7067        let custom_report = custom_analyzer.analyze(&ledger);
7068        assert_eq!(custom_report.under_confident_count, 1);
7069    }
7070
7071    #[test]
7072    fn drift_analyzer_weekly_grouping() {
7073        let mut ledger = super::VersionedDecisionLedger::new("weekly");
7074        let base_time = 1000u128;
7075
7076        // Week 1 decisions
7077        for i in 0..3 {
7078            ledger.append(super::DecisionRecord {
7079                ts_unix_ms: base_time + (i * 1000),
7080                operation: "week1_op".to_owned(),
7081                mode: CompatibilityMode::Strict,
7082                action: DecisionAction::Allow,
7083                incompatibility_probability: 0.1,
7084                rationale: "week1".to_owned(),
7085                evidence: vec![],
7086            });
7087        }
7088
7089        // Week 2 decisions (7+ days later)
7090        for i in 0..2 {
7091            ledger.append(super::DecisionRecord {
7092                ts_unix_ms: base_time + super::MILLIS_PER_WEEK + (i * 1000),
7093                operation: "week2_op".to_owned(),
7094                mode: CompatibilityMode::Strict,
7095                action: DecisionAction::FullValidate,
7096                incompatibility_probability: 0.4, // Under-confident
7097                rationale: "week2".to_owned(),
7098                evidence: vec![],
7099            });
7100        }
7101
7102        let analyzer = super::DriftAnalyzer::new();
7103        let report = analyzer.analyze(&ledger);
7104
7105        assert_eq!(report.total_decisions, 5);
7106        assert_eq!(report.weekly_summaries.len(), 2);
7107        assert_eq!(report.weekly_summaries[0].total_decisions, 3);
7108        assert_eq!(report.weekly_summaries[1].total_decisions, 2);
7109        assert_eq!(report.weekly_summaries[1].under_confident_count, 2);
7110    }
7111
7112    #[test]
7113    fn drift_report_recommendations() {
7114        let mut ledger = super::VersionedDecisionLedger::new("recommendations");
7115
7116        // Add many under-confident decisions
7117        for i in 0..10 {
7118            ledger.append(super::DecisionRecord {
7119                ts_unix_ms: i * 1000,
7120                operation: "risky_operation".to_owned(),
7121                mode: CompatibilityMode::Strict,
7122                action: DecisionAction::FailClosed,
7123                incompatibility_probability: 0.7,
7124                rationale: "high risk".to_owned(),
7125                evidence: vec![],
7126            });
7127        }
7128
7129        let analyzer = super::DriftAnalyzer::new();
7130        let report = analyzer.analyze(&ledger);
7131
7132        assert!(!report.recommendations.is_empty());
7133        assert!(
7134            report
7135                .recommendations
7136                .iter()
7137                .any(|r| r.contains("High under-confidence"))
7138        );
7139    }
7140
7141    #[test]
7142    fn drift_analyzer_tie_breaks_top_operations_by_name() {
7143        let mut ledger = super::VersionedDecisionLedger::new("tied-top-operations");
7144
7145        for operation in ["zeta_op", "alpha_op", "middle_op"] {
7146            ledger.append(super::DecisionRecord {
7147                ts_unix_ms: 1000,
7148                operation: operation.to_owned(),
7149                mode: CompatibilityMode::Strict,
7150                action: DecisionAction::FullValidate,
7151                incompatibility_probability: 0.7,
7152                rationale: "tied under-confidence count".to_owned(),
7153                evidence: vec![],
7154            });
7155        }
7156
7157        let analyzer = super::DriftAnalyzer::new();
7158        let report = analyzer.analyze(&ledger);
7159        let operations: Vec<_> = report
7160            .top_operations
7161            .iter()
7162            .map(|summary| summary.operation.as_str())
7163            .collect();
7164
7165        assert_eq!(operations, vec!["alpha_op", "middle_op", "zeta_op"]);
7166    }
7167
7168    // ──────────────────────────────────────────────────────────────────────────
7169    // D5: Loss matrix calibration with Bayesian shrinkage prior tests
7170    // ──────────────────────────────────────────────────────────────────────────
7171
7172    #[test]
7173    fn shrinkage_prior_returns_prior_mean_with_no_data() {
7174        let prior = super::BayesianShrinkagePrior::for_false_negative();
7175        let posterior = prior.posterior_mean(0.0, 0.0, 0);
7176        assert!(
7177            (posterior - prior.prior_mean).abs() < 1e-10,
7178            "With no data, posterior should equal prior mean"
7179        );
7180    }
7181
7182    #[test]
7183    fn shrinkage_weight_increases_with_sample_count() {
7184        let prior = super::BayesianShrinkagePrior::for_false_negative();
7185        let variance = 100.0;
7186
7187        let w1 = prior.shrinkage_weight(variance, 1);
7188        let w10 = prior.shrinkage_weight(variance, 10);
7189        let w100 = prior.shrinkage_weight(variance, 100);
7190
7191        assert!(w1 < w10, "Weight should increase with more samples");
7192        assert!(w10 < w100, "Weight should increase with more samples");
7193        assert!(w100 < 1.0, "Weight should be less than 1");
7194        assert!(w1 > 0.0, "Weight should be positive");
7195    }
7196
7197    #[test]
7198    fn shrinkage_pulls_empirical_toward_prior() {
7199        let prior = super::BayesianShrinkagePrior {
7200            prior_mean: 100.0,
7201            prior_variance: 400.0,
7202        };
7203
7204        // Empirical mean far from prior
7205        let empirical_mean = 200.0;
7206        let empirical_variance = 100.0;
7207
7208        // With few samples, posterior should be closer to prior
7209        let posterior_small = prior.posterior_mean(empirical_mean, empirical_variance, 2);
7210        // With many samples, posterior should be closer to empirical
7211        let posterior_large = prior.posterior_mean(empirical_mean, empirical_variance, 100);
7212
7213        assert!(
7214            (posterior_small - prior.prior_mean).abs() < (posterior_large - prior.prior_mean).abs(),
7215            "Small sample posterior should be closer to prior"
7216        );
7217        assert!(
7218            (posterior_large - empirical_mean).abs() < (posterior_small - empirical_mean).abs(),
7219            "Large sample posterior should be closer to empirical"
7220        );
7221    }
7222
7223    #[test]
7224    fn calibrator_with_no_observations_returns_prior() {
7225        let calibrator = super::LossMatrixCalibrator::new();
7226        let calibrated = calibrator.calibrate();
7227
7228        let prior_fn = super::BayesianShrinkagePrior::for_false_negative();
7229        let prior_vc = super::BayesianShrinkagePrior::for_validate_cost();
7230        let prior_fp = super::BayesianShrinkagePrior::for_false_positive();
7231
7232        assert!(
7233            (calibrated.matrix.allow_false_negative - prior_fn.prior_mean).abs() < 1e-10,
7234            "No data: should return prior mean for false negative"
7235        );
7236        assert!(
7237            (calibrated.matrix.validate_cost - prior_vc.prior_mean).abs() < 1e-10,
7238            "No data: should return prior mean for validate cost"
7239        );
7240        assert!(
7241            (calibrated.matrix.reject_false_positive - prior_fp.prior_mean).abs() < 1e-10,
7242            "No data: should return prior mean for false positive"
7243        );
7244
7245        assert_eq!(calibrated.sample_counts.allow_false_negative, 0);
7246        assert_eq!(calibrated.sample_counts.validate_cost, 0);
7247        assert_eq!(calibrated.sample_counts.reject_false_positive, 0);
7248    }
7249
7250    #[test]
7251    fn calibrator_adapts_to_observations() {
7252        let mut calibrator = super::LossMatrixCalibrator::new();
7253
7254        // Add observations with variance (alternating between 140 and 160, mean=150)
7255        // This ensures we have empirical variance for shrinkage to apply
7256        for i in 0..50 {
7257            let loss = if i % 2 == 0 { 140.0 } else { 160.0 };
7258            calibrator.observe(&super::LossObservation {
7259                ts_unix_ms: i as u128,
7260                action: DecisionAction::Allow,
7261                was_incompatible: true,
7262                realized_loss: loss,
7263                loss_category: super::LossCategory::AllowFalseNegative,
7264            });
7265        }
7266
7267        let calibrated = calibrator.calibrate();
7268
7269        // With 50 observations averaging 150.0, the calibrated value should be pulled
7270        // toward 150.0 (away from prior of 100.0), but with shrinkage applied
7271        assert!(
7272            calibrated.matrix.allow_false_negative > 100.0,
7273            "Calibrated false negative should exceed prior (got {})",
7274            calibrated.matrix.allow_false_negative
7275        );
7276        // With variance, shrinkage pulls toward prior so we don't hit exactly 150
7277        assert!(
7278            calibrated.matrix.allow_false_negative < 150.0,
7279            "Calibrated false negative should be shrunk from empirical (got {})",
7280            calibrated.matrix.allow_false_negative
7281        );
7282        assert!(
7283            calibrated.shrinkage_weights.allow_false_negative > 0.5,
7284            "With 50 samples, shrinkage weight should favor empirical (got {})",
7285            calibrated.shrinkage_weights.allow_false_negative
7286        );
7287    }
7288
7289    #[test]
7290    fn calibrator_handles_all_categories() {
7291        let mut calibrator = super::LossMatrixCalibrator::new();
7292
7293        // Add observations to each category
7294        calibrator.observe(&super::LossObservation {
7295            ts_unix_ms: 1,
7296            action: DecisionAction::Allow,
7297            was_incompatible: true,
7298            realized_loss: 120.0,
7299            loss_category: super::LossCategory::AllowFalseNegative,
7300        });
7301        calibrator.observe(&super::LossObservation {
7302            ts_unix_ms: 2,
7303            action: DecisionAction::FullValidate,
7304            was_incompatible: false,
7305            realized_loss: 30.0,
7306            loss_category: super::LossCategory::ValidateCost,
7307        });
7308        calibrator.observe(&super::LossObservation {
7309            ts_unix_ms: 3,
7310            action: DecisionAction::FailClosed,
7311            was_incompatible: false,
7312            realized_loss: 70.0,
7313            loss_category: super::LossCategory::RejectFalsePositive,
7314        });
7315
7316        let calibrated = calibrator.calibrate();
7317
7318        assert_eq!(calibrated.sample_counts.allow_false_negative, 1);
7319        assert_eq!(calibrated.sample_counts.validate_cost, 1);
7320        assert_eq!(calibrated.sample_counts.reject_false_positive, 1);
7321    }
7322
7323    #[test]
7324    fn calibrator_reset_clears_statistics() {
7325        let mut calibrator = super::LossMatrixCalibrator::new();
7326
7327        calibrator.observe(&super::LossObservation {
7328            ts_unix_ms: 1,
7329            action: DecisionAction::Allow,
7330            was_incompatible: true,
7331            realized_loss: 120.0,
7332            loss_category: super::LossCategory::AllowFalseNegative,
7333        });
7334
7335        assert_eq!(calibrator.sample_counts().allow_false_negative, 1);
7336
7337        calibrator.reset();
7338
7339        assert_eq!(calibrator.sample_counts().allow_false_negative, 0);
7340
7341        let calibrated = calibrator.calibrate();
7342        let prior_fn = super::BayesianShrinkagePrior::for_false_negative();
7343        assert!(
7344            (calibrated.matrix.allow_false_negative - prior_fn.prior_mean).abs() < 1e-10,
7345            "After reset, should return prior mean"
7346        );
7347    }
7348
7349    #[test]
7350    fn samples_for_confidence_computes_correctly() {
7351        let prior = super::BayesianShrinkagePrior::for_false_negative();
7352
7353        // At threshold 0.5, w = 0.5, so n = 0.5 / 0.5 = 1
7354        let n_50 = super::LossMatrixCalibrator::samples_for_confidence(&prior, 0.5);
7355        assert_eq!(n_50, 1, "50% confidence should need 1 sample");
7356
7357        // At threshold 0.9, w = 0.9, so n = 0.9 / 0.1 = 9
7358        let n_90 = super::LossMatrixCalibrator::samples_for_confidence(&prior, 0.9);
7359        assert_eq!(n_90, 9, "90% confidence should need 9 samples");
7360
7361        // Edge cases
7362        let n_0 = super::LossMatrixCalibrator::samples_for_confidence(&prior, 0.0);
7363        assert_eq!(n_0, 0, "0% confidence needs 0 samples");
7364
7365        let n_1 = super::LossMatrixCalibrator::samples_for_confidence(&prior, 1.0);
7366        assert_eq!(n_1, usize::MAX, "100% confidence is impossible");
7367    }
7368
7369    #[test]
7370    fn loss_category_stats_computes_mean_and_variance() {
7371        let mut stats = super::LossCategoryStats::default();
7372        stats.add(10.0);
7373        stats.add(20.0);
7374        stats.add(30.0);
7375
7376        assert_eq!(stats.count, 3);
7377        assert!((stats.mean() - 20.0).abs() < 1e-10, "Mean should be 20");
7378
7379        // Variance = E[X²] - E[X]² = (100+400+900)/3 - 400 = 466.67 - 400 = 66.67
7380        let expected_variance = (100.0 + 400.0 + 900.0) / 3.0 - 400.0;
7381        assert!(
7382            (stats.variance() - expected_variance).abs() < 1e-10,
7383            "Variance calculation mismatch"
7384        );
7385    }
7386
7387    #[test]
7388    fn calibrator_observe_batch_works() {
7389        let mut calibrator = super::LossMatrixCalibrator::new();
7390        let observations = vec![
7391            super::LossObservation {
7392                ts_unix_ms: 1,
7393                action: DecisionAction::Allow,
7394                was_incompatible: true,
7395                realized_loss: 100.0,
7396                loss_category: super::LossCategory::AllowFalseNegative,
7397            },
7398            super::LossObservation {
7399                ts_unix_ms: 2,
7400                action: DecisionAction::Allow,
7401                was_incompatible: true,
7402                realized_loss: 120.0,
7403                loss_category: super::LossCategory::AllowFalseNegative,
7404            },
7405        ];
7406
7407        calibrator.observe_batch(&observations);
7408        assert_eq!(calibrator.sample_counts().allow_false_negative, 2);
7409    }
7410
7411    // ──────────────────────────────────────────────────────────────────────────
7412    // D4: Hardened-mode Bayesian admission controller tests
7413    // ──────────────────────────────────────────────────────────────────────────
7414
7415    #[test]
7416    fn admission_controller_strict_mode_allows_low_risk() {
7417        let mut controller = super::BayesianAdmissionController::new(CompatibilityMode::Strict);
7418        let decision = controller.decide(0.1);
7419
7420        assert_eq!(decision.action, DecisionAction::Allow);
7421        assert!(!decision.hardened_override);
7422        assert!((decision.incompatibility_probability - 0.1).abs() < 1e-10);
7423    }
7424
7425    #[test]
7426    fn admission_controller_hardened_mode_rejects_high_risk() {
7427        let mut controller = super::BayesianAdmissionController::new(CompatibilityMode::Hardened);
7428        let decision = controller.decide(0.9);
7429
7430        assert_eq!(decision.action, DecisionAction::FailClosed);
7431        assert!(decision.hardened_override);
7432        assert!(
7433            decision.rationale.contains("exceeds threshold"),
7434            "Rationale should mention threshold"
7435        );
7436    }
7437
7438    #[test]
7439    fn admission_controller_nan_probability_fails_closed() {
7440        let mut controller = super::BayesianAdmissionController::new(CompatibilityMode::Strict);
7441        let decision = controller.decide(f64::NAN);
7442
7443        assert_eq!(decision.action, DecisionAction::FailClosed);
7444        assert!(decision.hardened_override);
7445        assert!(decision.rationale.contains("NaN"));
7446    }
7447
7448    #[test]
7449    fn admission_controller_hardened_penalty_shifts_decisions() {
7450        // In hardened mode, the extra penalty should make validation preferred over allow
7451        // at moderate probability levels
7452        let mut strict = super::BayesianAdmissionController::new(CompatibilityMode::Strict);
7453        let mut hardened = super::BayesianAdmissionController::new(CompatibilityMode::Hardened);
7454
7455        // At moderate probability, strict might allow but hardened should validate
7456        let p = 0.25;
7457        let strict_decision = strict.decide(p);
7458        let hardened_decision = hardened.decide(p);
7459
7460        // The hardened mode applies a penalty to allow, making validate more likely
7461        // This is a tendency check, not a strict assertion
7462        if strict_decision.action == DecisionAction::Allow {
7463            // Hardened should be at least as conservative
7464            assert!(
7465                hardened_decision.action != DecisionAction::Allow
7466                    || hardened_decision.expected_loss >= strict_decision.expected_loss,
7467                "Hardened mode should be at least as conservative"
7468            );
7469        }
7470    }
7471
7472    #[test]
7473    fn admission_controller_record_outcome_updates_calibrator() {
7474        let mut controller = super::BayesianAdmissionController::new(CompatibilityMode::Strict);
7475
7476        // Make a decision
7477        let decision = controller.decide(0.5);
7478
7479        // Record outcome as false negative (was incompatible but we allowed)
7480        if decision.action == DecisionAction::Allow {
7481            controller.record_outcome(&decision, true);
7482            let stats = controller.stats();
7483            assert_eq!(stats.sample_counts.allow_false_negative, 1);
7484        }
7485    }
7486
7487    #[test]
7488    fn admission_controller_stats_track_decisions() {
7489        let mut controller = super::BayesianAdmissionController::new(CompatibilityMode::Hardened);
7490
7491        // Make several decisions
7492        let _ = controller.decide(0.1);
7493        let _ = controller.decide(0.5);
7494        let _ = controller.decide(0.95); // Should trigger override
7495
7496        let stats = controller.stats();
7497        assert_eq!(stats.decision_count, 3);
7498        assert_eq!(stats.override_count, 1); // One high-risk override
7499    }
7500
7501    #[test]
7502    fn admission_controller_reset_clears_state() {
7503        let mut controller = super::BayesianAdmissionController::new(CompatibilityMode::Strict);
7504
7505        let decision = controller.decide(0.5);
7506        controller.record_outcome(&decision, true);
7507
7508        let before = controller.stats();
7509        assert!(before.decision_count > 0);
7510
7511        controller.reset();
7512
7513        let after = controller.stats();
7514        assert_eq!(after.decision_count, 0);
7515        assert_eq!(after.override_count, 0);
7516        assert_eq!(after.sample_counts.allow_false_negative, 0);
7517    }
7518
7519    #[test]
7520    fn admission_controller_custom_config() {
7521        let config = super::AdmissionControllerConfig::conservative();
7522        let mut controller =
7523            super::BayesianAdmissionController::with_config(CompatibilityMode::Hardened, config);
7524
7525        // Conservative config has max_incompatibility_probability of 0.7
7526        let decision = controller.decide(0.75);
7527        assert_eq!(decision.action, DecisionAction::FailClosed);
7528        assert!(decision.hardened_override);
7529    }
7530
7531    #[test]
7532    fn admission_controller_correct_decisions_not_recorded() {
7533        let mut controller = super::BayesianAdmissionController::new(CompatibilityMode::Strict);
7534
7535        // Make an allow decision
7536        let decision = controller.decide(0.1);
7537        assert_eq!(decision.action, DecisionAction::Allow);
7538
7539        // Record as correct (not incompatible)
7540        controller.record_outcome(&decision, false);
7541
7542        // No loss should be recorded for correct allows
7543        let stats = controller.stats();
7544        assert_eq!(stats.sample_counts.allow_false_negative, 0);
7545    }
7546
7547    #[test]
7548    fn admission_controller_validation_always_records() {
7549        let mut controller = super::BayesianAdmissionController::new(CompatibilityMode::Strict);
7550
7551        // Force a validate decision by giving moderate probability
7552        let decision = controller.decide(0.4);
7553
7554        if decision.action == DecisionAction::FullValidate {
7555            // Validation cost is always paid, regardless of actual state
7556            controller.record_outcome(&decision, false);
7557            let stats = controller.stats();
7558            assert_eq!(stats.sample_counts.validate_cost, 1);
7559        }
7560    }
7561
7562    // ──────────────────────────────────────────────────────────────────────────
7563    // D6: Calibrated confidence with Brier score gate tests
7564    // ──────────────────────────────────────────────────────────────────────────
7565
7566    #[test]
7567    fn brier_score_perfect_predictions() {
7568        let mut tracker = super::BrierScoreTracker::new();
7569
7570        // Perfect predictions: predict 1.0 when true, 0.0 when false
7571        tracker.record(1.0, true);
7572        tracker.record(0.0, false);
7573        tracker.record(1.0, true);
7574        tracker.record(0.0, false);
7575
7576        let score = tracker.brier_score();
7577        assert!(
7578            score < 0.01,
7579            "Perfect predictions should have near-zero Brier score (got {})",
7580            score
7581        );
7582    }
7583
7584    #[test]
7585    fn brier_score_worst_predictions() {
7586        let mut tracker = super::BrierScoreTracker::new();
7587
7588        // Worst predictions: predict opposite of reality
7589        tracker.record(0.0, true); // Predicted 0, was 1 -> error = -1
7590        tracker.record(1.0, false); // Predicted 1, was 0 -> error = 1
7591        tracker.record(0.0, true);
7592        tracker.record(1.0, false);
7593
7594        let score = tracker.brier_score();
7595        assert!(
7596            (score - 1.0).abs() < 0.01,
7597            "Worst predictions should have Brier score near 1.0 (got {})",
7598            score
7599        );
7600    }
7601
7602    #[test]
7603    fn brier_score_calibrated_predictions() {
7604        let mut tracker = super::BrierScoreTracker::new();
7605
7606        // Well-calibrated: predict 0.5, get 50% true
7607        for _ in 0..5 {
7608            tracker.record(0.5, true);
7609        }
7610        for _ in 0..5 {
7611            tracker.record(0.5, false);
7612        }
7613
7614        let score = tracker.brier_score();
7615        // (0.5 - 1)^2 = 0.25 for true cases
7616        // (0.5 - 0)^2 = 0.25 for false cases
7617        // Average = 0.25
7618        assert!(
7619            (score - 0.25).abs() < 0.01,
7620            "50% predictions with 50% outcomes should have Brier score of 0.25 (got {})",
7621            score
7622        );
7623    }
7624
7625    #[test]
7626    fn brier_score_empty_tracker() {
7627        let tracker = super::BrierScoreTracker::new();
7628        let score = tracker.brier_score();
7629        assert!(
7630            (score - 0.5).abs() < 0.01,
7631            "Empty tracker should return 0.5 (got {})",
7632            score
7633        );
7634    }
7635
7636    #[test]
7637    fn brier_score_tracker_reset() {
7638        let mut tracker = super::BrierScoreTracker::new();
7639        tracker.record(0.8, true);
7640        tracker.record(0.2, false);
7641        assert!(tracker.count() > 0);
7642
7643        tracker.reset();
7644
7645        assert_eq!(tracker.count(), 0);
7646        assert!((tracker.brier_score() - 0.5).abs() < 0.01);
7647    }
7648
7649    #[test]
7650    fn brier_score_metrics_computes_base_rate() {
7651        let mut tracker = super::BrierScoreTracker::new();
7652
7653        // 3 positives, 7 negatives -> base rate = 0.3
7654        for _ in 0..3 {
7655            tracker.record(0.5, true);
7656        }
7657        for _ in 0..7 {
7658            tracker.record(0.5, false);
7659        }
7660
7661        let metrics = tracker.metrics();
7662        assert!(
7663            (metrics.base_rate - 0.3).abs() < 0.01,
7664            "Base rate should be 0.3 (got {})",
7665            metrics.base_rate
7666        );
7667        assert_eq!(metrics.sample_count, 10);
7668    }
7669
7670    #[test]
7671    fn brier_gate_open_with_insufficient_samples() {
7672        let gate = super::BrierScoreGate::new();
7673        assert!(gate.is_open(), "Gate should be open with no samples");
7674
7675        let decision = gate.apply(DecisionAction::Allow);
7676        assert_eq!(decision.action, DecisionAction::Allow);
7677        assert!(!decision.gate_applied);
7678    }
7679
7680    #[test]
7681    fn brier_gate_blocks_poor_calibration() {
7682        let mut gate = super::BrierScoreGate::with_settings(0.25, 10, DecisionAction::FailClosed);
7683
7684        // Record many bad predictions
7685        for _ in 0..15 {
7686            gate.record(0.9, false); // Predict 0.9 but outcome is 0
7687        }
7688
7689        let score = gate.brier_score();
7690        assert!(
7691            score > 0.25,
7692            "Bad predictions should have high Brier score (got {})",
7693            score
7694        );
7695        assert!(
7696            !gate.is_open(),
7697            "Gate should be closed with poor calibration"
7698        );
7699
7700        let decision = gate.apply(DecisionAction::Allow);
7701        assert_eq!(decision.action, DecisionAction::FailClosed);
7702        assert!(decision.gate_applied);
7703    }
7704
7705    #[test]
7706    fn brier_gate_allows_good_calibration() {
7707        let mut gate = super::BrierScoreGate::with_settings(0.3, 10, DecisionAction::FullValidate);
7708
7709        // Record well-calibrated predictions
7710        for _ in 0..6 {
7711            gate.record(0.7, true);
7712        }
7713        for _ in 0..4 {
7714            gate.record(0.3, false);
7715        }
7716
7717        // (0.7-1)^2 = 0.09 for true, (0.3-0)^2 = 0.09 for false
7718        // Average ≈ 0.09, below threshold
7719        let score = gate.brier_score();
7720        assert!(
7721            score <= 0.3,
7722            "Good calibration should have low Brier score (got {})",
7723            score
7724        );
7725        assert!(gate.is_open(), "Gate should be open with good calibration");
7726
7727        let decision = gate.apply(DecisionAction::Allow);
7728        assert_eq!(decision.action, DecisionAction::Allow);
7729        assert!(!decision.gate_applied);
7730    }
7731
7732    #[test]
7733    fn brier_gate_reset_reopens() {
7734        let mut gate = super::BrierScoreGate::with_settings(0.2, 5, DecisionAction::FailClosed);
7735
7736        // Make gate close with bad predictions
7737        for _ in 0..10 {
7738            gate.record(1.0, false);
7739        }
7740        assert!(!gate.is_open());
7741
7742        gate.reset();
7743
7744        assert!(gate.is_open(), "Gate should be open after reset");
7745        assert_eq!(gate.metrics().sample_count, 0);
7746    }
7747
7748    #[test]
7749    fn brier_gate_custom_settings() {
7750        let gate = super::BrierScoreGate::with_settings(0.15, 20, DecisionAction::FullValidate);
7751
7752        assert!((gate.threshold() - 0.15).abs() < 0.01);
7753        // With min_samples=20, gate should be open until we have 20 samples
7754        assert!(gate.is_open());
7755    }
7756
7757    // ──────────────────────────────────────────────────────────────────────────
7758    // E12: Tail-stability gate tests
7759    // ──────────────────────────────────────────────────────────────────────────
7760
7761    #[test]
7762    fn tail_tracker_collects_baseline() {
7763        let config = super::TailStabilityConfig {
7764            baseline_samples: 5,
7765            window_samples: 3,
7766            max_p99_ratio: 1.2,
7767            percentile: 0.99,
7768        };
7769        let mut tracker = super::TailStabilityTracker::with_config(config);
7770
7771        // Collect baseline
7772        for i in 0..5 {
7773            tracker.record((i + 1) as f64);
7774        }
7775
7776        assert!(tracker.is_baseline_locked());
7777        assert!(tracker.baseline_p99().is_some());
7778        assert_eq!(tracker.baseline_count(), 5);
7779    }
7780
7781    #[test]
7782    fn tail_tracker_detects_regression() {
7783        let config = super::TailStabilityConfig {
7784            baseline_samples: 5,
7785            window_samples: 5,
7786            max_p99_ratio: 1.2, // 20% regression threshold
7787            percentile: 0.99,
7788        };
7789        let mut tracker = super::TailStabilityTracker::with_config(config);
7790
7791        // Low baseline (1-5)
7792        for i in 0..5 {
7793            tracker.record((i + 1) as f64);
7794        }
7795        let baseline_p99 = tracker.baseline_p99().unwrap();
7796
7797        // High window samples (10-50) - significant regression
7798        for i in 0..5 {
7799            tracker.record(((i + 1) * 10) as f64);
7800        }
7801
7802        let window_p99 = tracker.window_p99().unwrap();
7803        assert!(
7804            window_p99 > baseline_p99 * 1.2,
7805            "Window p99 ({}) should be > 1.2x baseline p99 ({})",
7806            window_p99,
7807            baseline_p99
7808        );
7809        assert!(tracker.has_shift(), "Should detect regression");
7810    }
7811
7812    #[test]
7813    fn tail_tracker_no_shift_when_stable() {
7814        let config = super::TailStabilityConfig {
7815            baseline_samples: 5,
7816            window_samples: 5,
7817            max_p99_ratio: 1.2,
7818            percentile: 0.99,
7819        };
7820        let mut tracker = super::TailStabilityTracker::with_config(config);
7821
7822        // Same distribution for baseline and window
7823        for _ in 0..5 {
7824            tracker.record(10.0);
7825        }
7826        for _ in 0..5 {
7827            tracker.record(10.0);
7828        }
7829
7830        assert!(!tracker.has_shift(), "Should not detect shift when stable");
7831        let ratio = tracker.p99_ratio().unwrap();
7832        assert!(
7833            (ratio - 1.0).abs() < 0.01,
7834            "Ratio should be ~1.0 (got {})",
7835            ratio
7836        );
7837    }
7838
7839    #[test]
7840    fn tail_tracker_window_slides() {
7841        let config = super::TailStabilityConfig {
7842            baseline_samples: 3,
7843            window_samples: 3,
7844            max_p99_ratio: 1.5,
7845            percentile: 0.99,
7846        };
7847        let mut tracker = super::TailStabilityTracker::with_config(config);
7848
7849        // Baseline
7850        for _ in 0..3 {
7851            tracker.record(10.0);
7852        }
7853
7854        // Fill window
7855        for _ in 0..3 {
7856            tracker.record(15.0);
7857        }
7858        assert_eq!(tracker.window_count(), 3);
7859
7860        // Add more - should slide
7861        tracker.record(20.0);
7862        assert_eq!(tracker.window_count(), 3); // Still 3 samples
7863
7864        // Window should now contain [15, 15, 20] approximately
7865        let p99 = tracker.window_p99().unwrap();
7866        assert!(p99 >= 15.0, "Window p99 should be >= 15 (got {})", p99);
7867    }
7868
7869    #[test]
7870    fn tail_tracker_reset() {
7871        let mut tracker = super::TailStabilityTracker::new();
7872
7873        for i in 0..35 {
7874            tracker.record(i as f64);
7875        }
7876        assert!(tracker.is_baseline_locked());
7877
7878        tracker.reset();
7879
7880        assert!(!tracker.is_baseline_locked());
7881        assert_eq!(tracker.baseline_count(), 0);
7882        assert_eq!(tracker.window_count(), 0);
7883        assert!(tracker.baseline_p99().is_none());
7884    }
7885
7886    #[test]
7887    fn tail_tracker_force_lock_baseline() {
7888        let config = super::TailStabilityConfig {
7889            baseline_samples: 30,
7890            window_samples: 10,
7891            max_p99_ratio: 1.2,
7892            percentile: 0.99,
7893        };
7894        let mut tracker = super::TailStabilityTracker::with_config(config);
7895
7896        // Only add 5 samples (less than 30)
7897        for i in 0..5 {
7898            tracker.record(i as f64);
7899        }
7900        assert!(!tracker.is_baseline_locked());
7901
7902        tracker.force_lock_baseline();
7903
7904        assert!(tracker.is_baseline_locked());
7905        assert!(tracker.baseline_p99().is_some());
7906    }
7907
7908    #[test]
7909    fn tail_gate_open_during_baseline_collection() {
7910        let mut gate = super::TailStabilityGate::new();
7911
7912        // During baseline collection, gate should be open
7913        for i in 0..10 {
7914            gate.record(i as f64);
7915            assert!(
7916                gate.is_open(),
7917                "Gate should be open during baseline collection"
7918            );
7919        }
7920    }
7921
7922    #[test]
7923    fn tail_gate_blocks_on_regression() {
7924        let config = super::TailStabilityConfig {
7925            baseline_samples: 5,
7926            window_samples: 5,
7927            max_p99_ratio: 1.2,
7928            percentile: 0.99,
7929        };
7930        let mut gate = super::TailStabilityGate::with_config(config, DecisionAction::FailClosed);
7931
7932        // Low baseline
7933        for _ in 0..5 {
7934            gate.record(10.0);
7935        }
7936
7937        // High window - causes regression
7938        for _ in 0..5 {
7939            gate.record(50.0);
7940        }
7941
7942        assert!(!gate.is_open(), "Gate should be closed on regression");
7943
7944        let decision = gate.apply(DecisionAction::Allow);
7945        assert_eq!(decision.action, DecisionAction::FailClosed);
7946        assert!(decision.gate_applied);
7947    }
7948
7949    #[test]
7950    fn tail_gate_status_includes_details() {
7951        let mut gate = super::TailStabilityGate::new();
7952
7953        for i in 0..35 {
7954            gate.record((i % 10) as f64);
7955        }
7956
7957        let status = gate.status();
7958        assert!(status.baseline_locked);
7959        assert!(status.baseline_p99.is_some());
7960        assert!(status.p99_ratio.is_some());
7961    }
7962
7963    #[test]
7964    fn tail_gate_reset_reopens() {
7965        let config = super::TailStabilityConfig {
7966            baseline_samples: 3,
7967            window_samples: 3,
7968            max_p99_ratio: 1.1,
7969            percentile: 0.99,
7970        };
7971        let mut gate = super::TailStabilityGate::with_config(config, DecisionAction::FailClosed);
7972
7973        // Cause regression
7974        for _ in 0..3 {
7975            gate.record(10.0);
7976        }
7977        for _ in 0..3 {
7978            gate.record(50.0);
7979        }
7980        assert!(!gate.is_open());
7981
7982        gate.reset();
7983
7984        assert!(gate.is_open(), "Gate should be open after reset");
7985    }
7986
7987    // ──────────────────────────────────────────────────────────────────────────
7988    // G2: Bayesian GPD tests
7989    // ──────────────────────────────────────────────────────────────────────────
7990
7991    #[test]
7992    fn gpd_parameters_quantile_exponential_case() {
7993        // When shape ≈ 0, GPD reduces to exponential
7994        let params = super::GpdParameters::new(0.0, 1.0, 0.0);
7995
7996        // For exponential, median is ln(2) ≈ 0.693
7997        let median = params.quantile(0.5);
7998        assert!(
7999            (median - 0.693).abs() < 0.01,
8000            "Median should be ~0.693 for exponential, got {}",
8001            median
8002        );
8003
8004        // P99 for exponential is -ln(0.01) ≈ 4.605
8005        let p99 = params.quantile(0.99);
8006        assert!(
8007            (p99 - 4.605).abs() < 0.01,
8008            "P99 should be ~4.605 for exponential, got {}",
8009            p99
8010        );
8011    }
8012
8013    #[test]
8014    fn gpd_parameters_quantile_heavy_tail() {
8015        // Positive shape = heavy tail (Pareto-type)
8016        let params = super::GpdParameters::new(0.5, 1.0, 0.0);
8017
8018        // Heavy tail should have higher quantiles than exponential
8019        let p99 = params.quantile(0.99);
8020        assert!(
8021            p99 > 5.0,
8022            "Heavy tail P99 should exceed exponential, got {}",
8023            p99
8024        );
8025
8026        let p999 = params.quantile(0.999);
8027        assert!(
8028            p999 > p99 * 2.0,
8029            "P99.9 should be much larger than P99 for heavy tail"
8030        );
8031    }
8032
8033    #[test]
8034    fn gpd_survival_probability() {
8035        let params = super::GpdParameters::new(0.0, 1.0, 5.0);
8036
8037        // At threshold, survival = 1
8038        assert!((params.survival_probability(5.0) - 1.0).abs() < 1e-10);
8039
8040        // Below threshold, survival = 1
8041        assert!((params.survival_probability(3.0) - 1.0).abs() < 1e-10);
8042
8043        // Above threshold: for exponential, P(X > u+x) = exp(-x)
8044        let surv = params.survival_probability(6.0);
8045        assert!(
8046            (surv - (-1.0_f64).exp()).abs() < 0.01,
8047            "Survival at threshold+1 should be exp(-1), got {}",
8048            surv
8049        );
8050    }
8051
8052    #[test]
8053    fn bayesian_gpd_estimator_with_exceedances() {
8054        let mut estimator = super::BayesianGpdEstimator::new();
8055
8056        // Simulate some latency data with known tail
8057        let values: Vec<f64> = (0..100).map(|i| (i as f64) + 1.0).collect();
8058        let threshold = 90.0; // Top 10% are exceedances
8059
8060        estimator.observe(&values, threshold);
8061
8062        assert_eq!(estimator.n_exceedances(), 10);
8063        assert!((estimator.threshold() - 90.0).abs() < 1e-10);
8064
8065        let params = estimator.posterior_parameters().unwrap();
8066        assert!(params.scale > 0.0, "Scale should be positive");
8067        assert!(params.threshold == 90.0);
8068    }
8069
8070    #[test]
8071    fn bayesian_gpd_quantile_estimate() {
8072        let mut estimator = super::BayesianGpdEstimator::new();
8073
8074        // Generate data with known distribution (uniform 0-100)
8075        let values: Vec<f64> = (0..1000).map(|i| i as f64 / 10.0).collect();
8076        let threshold = 90.0;
8077
8078        estimator.observe(&values, threshold);
8079
8080        let est = estimator.estimate_quantile(0.99, 0.95).unwrap();
8081
8082        // Check that credible interval is reasonable
8083        assert!(est.lower_bound <= est.point_estimate);
8084        assert!(est.upper_bound >= est.point_estimate);
8085        assert!(est.interval_width() > 0.0);
8086
8087        // Should have finite uncertainty with 1000 observations
8088        assert!(
8089            est.relative_uncertainty().is_finite(),
8090            "Relative uncertainty should be finite with n=1000"
8091        );
8092    }
8093
8094    #[test]
8095    fn gpd_prior_variants() {
8096        let default = super::GpdPrior::default();
8097        let heavy = super::GpdPrior::heavy_tailed();
8098        let light = super::GpdPrior::light_tailed();
8099
8100        // Heavy-tailed prior should have higher shape mean
8101        assert!(heavy.shape_mean > light.shape_mean);
8102        assert!(heavy.shape_mean > default.shape_mean);
8103
8104        // Light-tailed should be close to exponential (shape = 0)
8105        assert!((light.shape_mean).abs() < 0.1);
8106    }
8107
8108    #[test]
8109    fn gpd_exceedance_probability() {
8110        let mut estimator = super::BayesianGpdEstimator::new();
8111
8112        let values: Vec<f64> = (0..100).map(|i| i as f64).collect();
8113        estimator.observe(&values, 90.0);
8114
8115        // Probability of exceeding threshold should be ~exceedance rate
8116        let p_exceed_threshold = estimator.exceedance_probability(90.0).unwrap();
8117        assert!(
8118            (p_exceed_threshold - 0.10).abs() < 0.05,
8119            "P(X > 90) should be ~0.10, got {}",
8120            p_exceed_threshold
8121        );
8122
8123        // Probability of exceeding very high value should be small
8124        let p_exceed_high = estimator.exceedance_probability(200.0).unwrap();
8125        assert!(p_exceed_high < 0.01, "P(X > 200) should be very small");
8126    }
8127
8128    #[test]
8129    fn normal_quantile_symmetry() {
8130        // Test symmetry of normal quantile approximation
8131        let q_05 = super::normal_quantile(0.05);
8132        let q_95 = super::normal_quantile(0.95);
8133        assert!(
8134            (q_05 + q_95).abs() < 0.01,
8135            "Quantiles should be symmetric: {} + {} = {}",
8136            q_05,
8137            q_95,
8138            q_05 + q_95
8139        );
8140
8141        // Standard normal quantiles
8142        let q_975 = super::normal_quantile(0.975);
8143        assert!(
8144            (q_975 - 1.96).abs() < 0.01,
8145            "0.975 quantile should be ~1.96, got {}",
8146            q_975
8147        );
8148    }
8149
8150    // ──────────────────────────────────────────────────────────────────────────
8151    // E3: Profile-and-prove optimization loop tests
8152    // ──────────────────────────────────────────────────────────────────────────
8153
8154    #[test]
8155    fn slo_row_id_metric_ids() {
8156        assert_eq!(super::SloRowId::ShortestPath.metric_id(), "shortest_path");
8157        assert_eq!(super::SloRowId::Components.metric_id(), "components");
8158        assert_eq!(super::SloRowId::Centrality.metric_id(), "centrality");
8159        assert_eq!(super::SloRowId::Flow.metric_id(), "flow");
8160        assert_eq!(super::SloRowId::IoRoundtrip.metric_id(), "io_roundtrip");
8161        assert_eq!(super::SloRowId::MutationCycle.metric_id(), "mutation_cycle");
8162        assert_eq!(
8163            super::SloRowId::MutationRegression.metric_id(),
8164            "mutation_regression"
8165        );
8166        assert_eq!(
8167            super::SloRowId::MemoryRegression.metric_id(),
8168            "memory_regression"
8169        );
8170        assert_eq!(
8171            super::SloRowId::P99TailRegression.metric_id(),
8172            "p99_tail_regression"
8173        );
8174        assert_eq!(super::SloRowId::ALL.len(), 9);
8175    }
8176
8177    #[test]
8178    fn optimization_delta_computes_improvement() {
8179        let baseline = super::SloBaseline {
8180            row_id: super::SloRowId::ShortestPath,
8181            p50_ms: 100.0,
8182            p95_ms: 200.0,
8183            p99_ms: 300.0,
8184            peak_memory_kb: 100_000,
8185            sample_count: 30,
8186            captured_at_unix_ms: 1,
8187        };
8188
8189        let after = super::SloBaseline {
8190            row_id: super::SloRowId::ShortestPath,
8191            p50_ms: 80.0,
8192            p95_ms: 160.0,
8193            p99_ms: 240.0,
8194            peak_memory_kb: 100_000,
8195            sample_count: 30,
8196            captured_at_unix_ms: 2,
8197        };
8198
8199        let delta = super::OptimizationDelta::compute(&baseline, &after);
8200
8201        assert!((delta.p50_delta_ms - (-20.0)).abs() < 0.01);
8202        assert!((delta.p95_delta_ms - (-40.0)).abs() < 0.01);
8203        assert!((delta.p99_delta_ms - (-60.0)).abs() < 0.01);
8204        assert_eq!(delta.memory_delta_kb, 0);
8205        assert!((delta.p95_improvement_pct - 20.0).abs() < 0.01);
8206        assert!(delta.is_improvement());
8207    }
8208
8209    #[test]
8210    fn optimization_delta_detects_regression() {
8211        let baseline = super::SloBaseline {
8212            row_id: super::SloRowId::Components,
8213            p50_ms: 100.0,
8214            p95_ms: 200.0,
8215            p99_ms: 300.0,
8216            peak_memory_kb: 100_000,
8217            sample_count: 30,
8218            captured_at_unix_ms: 1,
8219        };
8220
8221        let after = super::SloBaseline {
8222            row_id: super::SloRowId::Components,
8223            p50_ms: 120.0,
8224            p95_ms: 250.0,
8225            p99_ms: 400.0,
8226            peak_memory_kb: 100_000,
8227            sample_count: 30,
8228            captured_at_unix_ms: 2,
8229        };
8230
8231        let delta = super::OptimizationDelta::compute(&baseline, &after);
8232        assert!(
8233            !delta.is_improvement(),
8234            "Regression should not be improvement"
8235        );
8236    }
8237
8238    #[test]
8239    fn profile_prove_state_transitions() {
8240        let mut state =
8241            super::ProfileProveState::new(super::SloRowId::Flow, "vectorize inner loop");
8242
8243        assert_eq!(state.phase, super::OptimizationPhase::Baseline);
8244
8245        // Record baseline
8246        state.record_baseline(super::SloBaseline {
8247            row_id: super::SloRowId::Flow,
8248            p50_ms: 100.0,
8249            p95_ms: 200.0,
8250            p99_ms: 300.0,
8251            peak_memory_kb: 50_000,
8252            sample_count: 30,
8253            captured_at_unix_ms: 1,
8254        });
8255        assert_eq!(state.phase, super::OptimizationPhase::Optimize);
8256
8257        // Mark optimized
8258        state.mark_optimized();
8259        assert_eq!(state.phase, super::OptimizationPhase::Verify);
8260
8261        // Record successful verification
8262        state.record_verification(super::OptimizationEvidence {
8263            fixtures_verified: 78,
8264            mismatches: 0,
8265            conformance_hash: "abc123".to_owned(),
8266            verified_at_unix_ms: 2,
8267        });
8268        assert_eq!(state.phase, super::OptimizationPhase::Measure);
8269
8270        // Record after measurement (improved)
8271        state.record_after(super::SloBaseline {
8272            row_id: super::SloRowId::Flow,
8273            p50_ms: 80.0,
8274            p95_ms: 160.0,
8275            p99_ms: 240.0,
8276            peak_memory_kb: 50_000,
8277            sample_count: 30,
8278            captured_at_unix_ms: 3,
8279        });
8280        assert_eq!(state.phase, super::OptimizationPhase::Complete);
8281        assert!(state.is_complete());
8282
8283        // Get result
8284        let result = state.result().expect("should have result");
8285        assert!(result.success);
8286        assert!(result.delta.p95_improvement_pct > 0.0);
8287    }
8288
8289    #[test]
8290    fn profile_prove_state_fails_on_mismatch() {
8291        let mut state = super::ProfileProveState::new(super::SloRowId::Centrality, "cache results");
8292
8293        state.record_baseline(super::SloBaseline {
8294            row_id: super::SloRowId::Centrality,
8295            p50_ms: 500.0,
8296            p95_ms: 800.0,
8297            p99_ms: 1000.0,
8298            peak_memory_kb: 200_000,
8299            sample_count: 30,
8300            captured_at_unix_ms: 1,
8301        });
8302        state.mark_optimized();
8303
8304        // Verification fails with mismatches
8305        state.record_verification(super::OptimizationEvidence {
8306            fixtures_verified: 78,
8307            mismatches: 3,
8308            conformance_hash: "bad123".to_owned(),
8309            verified_at_unix_ms: 2,
8310        });
8311
8312        // Should jump to Complete (not Measure)
8313        assert_eq!(state.phase, super::OptimizationPhase::Complete);
8314        assert!(state.is_complete());
8315        assert!(
8316            state.result().is_none(),
8317            "Should not have result without after measurement"
8318        );
8319    }
8320
8321    #[test]
8322    fn profile_prove_loop_tracks_results() {
8323        let mut loop_runner = super::ProfileProveLoop::new();
8324
8325        // Start optimization for ShortestPath
8326        loop_runner.start(super::SloRowId::ShortestPath, "use priority queue");
8327
8328        assert!(
8329            loop_runner
8330                .get_active(super::SloRowId::ShortestPath)
8331                .is_some()
8332        );
8333
8334        // Simulate successful optimization
8335        if let Some(state) = loop_runner.get_active_mut(super::SloRowId::ShortestPath) {
8336            state.record_baseline(super::SloBaseline {
8337                row_id: super::SloRowId::ShortestPath,
8338                p50_ms: 300.0,
8339                p95_ms: 400.0,
8340                p99_ms: 500.0,
8341                peak_memory_kb: 80_000,
8342                sample_count: 30,
8343                captured_at_unix_ms: 1,
8344            });
8345            state.mark_optimized();
8346            state.record_verification(super::OptimizationEvidence {
8347                fixtures_verified: 78,
8348                mismatches: 0,
8349                conformance_hash: "hash1".to_owned(),
8350                verified_at_unix_ms: 2,
8351            });
8352            state.record_after(super::SloBaseline {
8353                row_id: super::SloRowId::ShortestPath,
8354                p50_ms: 250.0,
8355                p95_ms: 320.0,
8356                p99_ms: 400.0,
8357                peak_memory_kb: 80_000,
8358                sample_count: 30,
8359                captured_at_unix_ms: 3,
8360            });
8361        }
8362
8363        loop_runner.complete(super::SloRowId::ShortestPath);
8364
8365        let summary = loop_runner.summary();
8366        assert_eq!(summary.total_attempts, 1);
8367        assert_eq!(summary.successful, 1);
8368        assert_eq!(summary.failed, 0);
8369        assert!(summary.avg_p95_improvement_pct > 0.0);
8370        assert_eq!(summary.rows_optimized.len(), 1);
8371    }
8372
8373    #[test]
8374    fn profile_prove_summary_handles_failures() {
8375        let mut loop_runner = super::ProfileProveLoop::new();
8376
8377        // Add a failed result manually
8378        loop_runner.results.push(super::OptimizationResult {
8379            row_id: super::SloRowId::Centrality,
8380            optimization_description: "bad optimization".to_owned(),
8381            baseline: super::SloBaseline {
8382                row_id: super::SloRowId::Centrality,
8383                p50_ms: 100.0,
8384                p95_ms: 200.0,
8385                p99_ms: 300.0,
8386                peak_memory_kb: 50_000,
8387                sample_count: 30,
8388                captured_at_unix_ms: 1,
8389            },
8390            after: super::SloBaseline {
8391                row_id: super::SloRowId::Centrality,
8392                p50_ms: 150.0,
8393                p95_ms: 280.0,
8394                p99_ms: 400.0,
8395                peak_memory_kb: 50_000,
8396                sample_count: 30,
8397                captured_at_unix_ms: 2,
8398            },
8399            evidence: super::OptimizationEvidence {
8400                fixtures_verified: 78,
8401                mismatches: 0,
8402                conformance_hash: "hash".to_owned(),
8403                verified_at_unix_ms: 2,
8404            },
8405            success: false, // Regression
8406            delta: super::OptimizationDelta {
8407                p50_delta_ms: 50.0,
8408                p95_delta_ms: 80.0,
8409                p99_delta_ms: 100.0,
8410                memory_delta_kb: 0,
8411                p95_improvement_pct: -40.0,
8412            },
8413        });
8414
8415        let summary = loop_runner.summary();
8416        assert_eq!(summary.total_attempts, 1);
8417        assert_eq!(summary.successful, 0);
8418        assert_eq!(summary.failed, 1);
8419        assert!((summary.avg_p95_improvement_pct - 0.0).abs() < 0.01);
8420    }
8421
8422    // ──────────────────────────────────────────────────────────────────────────
8423    // G6: Algebraic effect tracking tests
8424    // ──────────────────────────────────────────────────────────────────────────
8425
8426    #[test]
8427    fn parser_effect_kind_properties() {
8428        use super::ParserEffectKind;
8429
8430        assert!(ParserEffectKind::FailClosed.is_terminal());
8431        assert!(!ParserEffectKind::Warning.is_terminal());
8432        assert!(!ParserEffectKind::Allow.is_terminal());
8433
8434        assert!(ParserEffectKind::Warning.is_recoverable());
8435        assert!(ParserEffectKind::Fallback.is_recoverable());
8436        assert!(ParserEffectKind::Coercion.is_recoverable());
8437        assert!(!ParserEffectKind::FailClosed.is_recoverable());
8438
8439        assert_eq!(ParserEffectKind::Warning.as_str(), "warning");
8440        assert_eq!(ParserEffectKind::FailClosed.as_str(), "fail_closed");
8441    }
8442
8443    #[test]
8444    fn parser_effect_constructors() {
8445        use super::{CompatibilityMode, ParserEffect, ParserEffectKind};
8446
8447        let warning = ParserEffect::warning(
8448            "read_graphml",
8449            "missing attribute",
8450            CompatibilityMode::Strict,
8451        );
8452        assert_eq!(warning.kind, ParserEffectKind::Warning);
8453        assert_eq!(warning.operation, "read_graphml");
8454        assert!(warning.risk_probability > 0.0);
8455
8456        let fail =
8457            ParserEffect::fail_closed("read_json", "invalid json", CompatibilityMode::Strict);
8458        assert_eq!(fail.kind, ParserEffectKind::FailClosed);
8459        assert!((fail.risk_probability - 1.0).abs() < 0.001);
8460
8461        let allow = ParserEffect::allow("parse", "ok", CompatibilityMode::Hardened, 0.1);
8462        assert_eq!(allow.kind, ParserEffectKind::Allow);
8463        assert!((allow.risk_probability - 0.1).abs() < 0.001);
8464    }
8465
8466    #[test]
8467    fn parser_effect_with_location() {
8468        use super::{CompatibilityMode, ParserEffect};
8469
8470        let effect = ParserEffect::warning("read_edgelist", "bad line", CompatibilityMode::Strict)
8471            .with_location("line 42");
8472        assert_eq!(effect.source_location, Some("line 42".to_owned()));
8473    }
8474
8475    #[test]
8476    fn effect_trace_basic_operations() {
8477        use super::{CompatibilityMode, EffectTrace, ParserEffect, ParserEffectKind};
8478
8479        let mut trace = EffectTrace::new();
8480        assert!(trace.is_empty());
8481        assert!(!trace.is_terminated());
8482
8483        trace.record(ParserEffect::warning(
8484            "op1",
8485            "msg1",
8486            CompatibilityMode::Strict,
8487        ));
8488        assert_eq!(trace.len(), 1);
8489        assert!(!trace.is_empty());
8490        assert!(!trace.is_terminated());
8491
8492        trace.record(ParserEffect::allow(
8493            "op2",
8494            "msg2",
8495            CompatibilityMode::Strict,
8496            0.1,
8497        ));
8498        assert_eq!(trace.len(), 2);
8499        assert!(!trace.is_terminated());
8500
8501        // Add a terminal effect
8502        trace.record(ParserEffect::fail_closed(
8503            "op3",
8504            "error",
8505            CompatibilityMode::Strict,
8506        ));
8507        assert_eq!(trace.len(), 3);
8508        assert!(trace.is_terminated());
8509
8510        // Can still add more effects after terminal
8511        trace.record(ParserEffect::warning(
8512            "op4",
8513            "msg4",
8514            CompatibilityMode::Strict,
8515        ));
8516        assert_eq!(trace.len(), 4);
8517        assert!(trace.is_terminated());
8518
8519        // Terminal effect is still the first terminal
8520        let terminal = trace.terminal().unwrap();
8521        assert_eq!(terminal.kind, ParserEffectKind::FailClosed);
8522        assert_eq!(terminal.operation, "op3");
8523    }
8524
8525    #[test]
8526    fn effect_trace_filtering() {
8527        use super::{CompatibilityMode, EffectTrace, ParserEffect, ParserEffectKind};
8528
8529        let mut trace = EffectTrace::new();
8530        trace.record(ParserEffect::warning(
8531            "op1",
8532            "warn1",
8533            CompatibilityMode::Strict,
8534        ));
8535        trace.record(ParserEffect::warning(
8536            "op2",
8537            "warn2",
8538            CompatibilityMode::Strict,
8539        ));
8540        trace.record(ParserEffect::allow(
8541            "op3",
8542            "ok",
8543            CompatibilityMode::Strict,
8544            0.1,
8545        ));
8546        trace.record(ParserEffect::warning(
8547            "op4",
8548            "warn3",
8549            CompatibilityMode::Strict,
8550        ));
8551
8552        let warnings = trace.warnings();
8553        assert_eq!(warnings.len(), 3);
8554
8555        assert_eq!(trace.count_kind(ParserEffectKind::Warning), 3);
8556        assert_eq!(trace.count_kind(ParserEffectKind::Allow), 1);
8557        assert_eq!(trace.count_kind(ParserEffectKind::FailClosed), 0);
8558    }
8559
8560    #[test]
8561    fn effect_trace_merge() {
8562        use super::{CompatibilityMode, EffectTrace, ParserEffect, ParserEffectKind};
8563
8564        let mut trace1 = EffectTrace::new();
8565        trace1.record(ParserEffect::warning("a", "m1", CompatibilityMode::Strict));
8566        trace1.record(ParserEffect::warning("b", "m2", CompatibilityMode::Strict));
8567
8568        let mut trace2 = EffectTrace::new();
8569        trace2.record(ParserEffect::allow(
8570            "c",
8571            "m3",
8572            CompatibilityMode::Strict,
8573            0.1,
8574        ));
8575        trace2.record(ParserEffect::fail_closed(
8576            "d",
8577            "m4",
8578            CompatibilityMode::Strict,
8579        ));
8580
8581        trace1.merge(trace2);
8582
8583        assert_eq!(trace1.len(), 4);
8584        assert!(trace1.is_terminated());
8585        assert_eq!(
8586            trace1.terminal().unwrap().kind,
8587            ParserEffectKind::FailClosed
8588        );
8589    }
8590
8591    #[test]
8592    fn effect_trace_summary() {
8593        use super::{CompatibilityMode, EffectTrace, ParserEffect};
8594
8595        let mut trace = EffectTrace::new();
8596        trace.record(ParserEffect::warning("a", "m1", CompatibilityMode::Strict));
8597        trace.record(ParserEffect::new(
8598            super::ParserEffectKind::Coercion,
8599            "b",
8600            "m2",
8601            CompatibilityMode::Strict,
8602            0.3,
8603        ));
8604        trace.record(ParserEffect::fail_closed(
8605            "c",
8606            "m3",
8607            CompatibilityMode::Strict,
8608        ));
8609
8610        let summary = trace.summary();
8611        assert_eq!(summary.total_effects, 3);
8612        assert_eq!(summary.warnings, 1);
8613        assert_eq!(summary.failures, 1);
8614        assert_eq!(summary.coercions, 1);
8615        assert!(summary.is_terminated);
8616        assert!((summary.max_risk - 1.0).abs() < 0.001);
8617    }
8618
8619    #[test]
8620    fn effect_policy_should_fail() {
8621        use super::{CompatibilityMode, EffectPolicy, ParserEffect};
8622
8623        let warning = ParserEffect::warning("op", "msg", CompatibilityMode::Strict);
8624        let fail = ParserEffect::fail_closed("op", "msg", CompatibilityMode::Strict);
8625        let allow = ParserEffect::allow("op", "msg", CompatibilityMode::Strict, 0.1);
8626
8627        // CollectAll: only fails on terminal
8628        assert!(!EffectPolicy::CollectAll.should_fail(&warning));
8629        assert!(EffectPolicy::CollectAll.should_fail(&fail));
8630        assert!(!EffectPolicy::CollectAll.should_fail(&allow));
8631
8632        // FailOnAny: fails on everything
8633        assert!(EffectPolicy::FailOnAny.should_fail(&warning));
8634        assert!(EffectPolicy::FailOnAny.should_fail(&fail));
8635        assert!(EffectPolicy::FailOnAny.should_fail(&allow));
8636
8637        // FailOnWarning: fails on warning or terminal
8638        assert!(EffectPolicy::FailOnWarning.should_fail(&warning));
8639        assert!(EffectPolicy::FailOnWarning.should_fail(&fail));
8640        assert!(!EffectPolicy::FailOnWarning.should_fail(&allow));
8641
8642        // FailOnTerminal: only fails on terminal
8643        assert!(!EffectPolicy::FailOnTerminal.should_fail(&warning));
8644        assert!(EffectPolicy::FailOnTerminal.should_fail(&fail));
8645        assert!(!EffectPolicy::FailOnTerminal.should_fail(&allow));
8646    }
8647
8648    #[test]
8649    fn effect_handler_collect_all() {
8650        use super::{CompatibilityMode, EffectHandler, ParserEffect};
8651
8652        let mut handler = EffectHandler::collect_all();
8653
8654        // Warnings should be Ok
8655        let result = handler.handle(ParserEffect::warning(
8656            "op1",
8657            "m1",
8658            CompatibilityMode::Strict,
8659        ));
8660        assert!(result.is_ok());
8661
8662        // Allows should be Ok
8663        let result = handler.handle(ParserEffect::allow(
8664            "op2",
8665            "m2",
8666            CompatibilityMode::Strict,
8667            0.1,
8668        ));
8669        assert!(result.is_ok());
8670
8671        // FailClosed should be Err
8672        let result = handler.handle(ParserEffect::fail_closed(
8673            "op3",
8674            "m3",
8675            CompatibilityMode::Strict,
8676        ));
8677        assert!(result.is_err());
8678
8679        // Trace should have all effects
8680        assert_eq!(handler.trace().len(), 3);
8681        assert!(handler.trace().is_terminated());
8682    }
8683
8684    #[test]
8685    fn effect_handler_fail_on_terminal() {
8686        use super::{CompatibilityMode, EffectHandler, ParserEffect};
8687
8688        let mut handler = EffectHandler::fail_on_terminal();
8689
8690        let result = handler.handle(ParserEffect::warning(
8691            "op",
8692            "msg",
8693            CompatibilityMode::Strict,
8694        ));
8695        assert!(result.is_ok());
8696
8697        let result = handler.handle(ParserEffect::fail_closed(
8698            "op",
8699            "msg",
8700            CompatibilityMode::Strict,
8701        ));
8702        assert!(result.is_err());
8703    }
8704
8705    #[test]
8706    fn effect_handler_into_trace() {
8707        use super::{CompatibilityMode, EffectHandler, ParserEffect};
8708
8709        let mut handler = EffectHandler::collect_all();
8710        let _ = handler.handle(ParserEffect::warning(
8711            "op",
8712            "msg",
8713            CompatibilityMode::Strict,
8714        ));
8715
8716        let trace = handler.into_trace();
8717        assert_eq!(trace.len(), 1);
8718    }
8719
8720    #[test]
8721    fn effect_handler_summary() {
8722        use super::{CompatibilityMode, EffectHandler, ParserEffect};
8723
8724        let mut handler = EffectHandler::collect_all();
8725        let _ = handler.handle(ParserEffect::warning(
8726            "op1",
8727            "m1",
8728            CompatibilityMode::Strict,
8729        ));
8730        let _ = handler.handle(ParserEffect::warning(
8731            "op2",
8732            "m2",
8733            CompatibilityMode::Strict,
8734        ));
8735
8736        let summary = handler.summary();
8737        assert_eq!(summary.total_effects, 2);
8738        assert_eq!(summary.warnings, 2);
8739    }
8740
8741    #[test]
8742    fn runtime_policy_defaults_track_mode_specific_state() {
8743        let strict = super::RuntimePolicy::strict();
8744        assert_eq!(strict.mode(), super::CompatibilityMode::Strict);
8745        assert!(strict.allowlist().is_empty());
8746        assert_eq!(strict.loss_matrix(), super::LossMatrix::strict_default());
8747
8748        let hardened = super::RuntimePolicy::hardened();
8749        assert_eq!(hardened.mode(), super::CompatibilityMode::Hardened);
8750        assert!(hardened.allows("bounded_diagnostic_enrichment"));
8751        assert_eq!(
8752            hardened.loss_matrix(),
8753            super::LossMatrix::hardened_default()
8754        );
8755    }
8756
8757    #[test]
8758    fn runtime_policy_records_decisions_and_updates_posterior() {
8759        let mut policy = super::RuntimePolicy::hardened();
8760        policy.record(
8761            "read_graphml",
8762            super::DecisionAction::FullValidate,
8763            0.7,
8764            "parser warning observed",
8765            vec![super::EvidenceTerm {
8766                signal: "warning".to_owned(),
8767                observed_value: "parser warning observed".to_owned(),
8768                log_likelihood_ratio: 1.25,
8769            }],
8770        );
8771
8772        assert_eq!(policy.decision_log().records().len(), 1);
8773        assert_eq!(policy.decision_log().records()[0].mode, policy.mode());
8774        assert_eq!(policy.posterior().observation_count, 1);
8775        assert!(policy.posterior().confidence > 0.0);
8776        assert!(policy.posterior().incompatibility_probability > 0.0);
8777    }
8778}