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