Skip to main content

fnx_runtime/
lib.rs

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