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#[derive(Clone, Default)]
78pub struct AttrMap {
79 entries: Vec<(String, CgseValue)>,
80 #[allow(clippy::box_collection)]
83 index: Option<Box<std::collections::HashMap<String, usize, rustc_hash::FxBuildHasher>>>,
84}
85
86impl AttrMap {
87 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 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 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#[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
251pub 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 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 #[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 #[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(()); }
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(()); }
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#[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
2055static 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#[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
2261pub const DECISION_LEDGER_SCHEMA_VERSION: &str = "1.0.0";
2263
2264pub const DECISION_LEDGER_RETAINED_RECORDS: usize = 8192;
2279
2280#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2281pub struct EvidenceLedger {
2282 records: Vec<DecisionRecord>,
2283 #[serde(default)]
2286 retired_records: u64,
2287 #[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 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 #[must_use]
2364 pub const fn retired_records(&self) -> u64 {
2365 self.retired_records
2366 }
2367
2368 #[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 #[must_use]
2386 pub fn to_versioned(&self) -> VersionedDecisionLedger {
2387 VersionedDecisionLedger::from_evidence_ledger(self)
2388 }
2389}
2390
2391#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
2397pub struct VersionedDecisionLedger {
2398 pub schema_version: String,
2400 pub ledger_id: String,
2402 pub created_at_unix_ms: u128,
2404 pub last_updated_unix_ms: u128,
2406 pub record_count: usize,
2408 pub records: Vec<DecisionRecord>,
2410}
2411
2412impl VersionedDecisionLedger {
2413 #[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 #[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 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 #[must_use]
2452 pub fn is_schema_compatible(&self) -> bool {
2453 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 #[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 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 pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
2512 serde_json::to_string_pretty(self)
2513 }
2514
2515 pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
2517 serde_json::from_str(json)
2518 }
2519
2520 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#[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
2542pub const UNDER_CONFIDENT_THRESHOLD: f64 = 0.3;
2548
2549pub const MILLIS_PER_WEEK: u128 = 7 * 24 * 60 * 60 * 1000;
2551
2552#[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 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 #[must_use]
2605 pub fn new() -> Self {
2606 Self {
2607 threshold: UNDER_CONFIDENT_THRESHOLD,
2608 }
2609 }
2610
2611 #[must_use]
2613 pub fn with_threshold(threshold: f64) -> Self {
2614 Self {
2615 threshold: threshold.clamp(0.0, 1.0),
2616 }
2617 }
2618
2619 #[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 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 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 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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2723pub struct OperationRiskSummary {
2724 pub operation: String,
2725 pub under_confident_count: usize,
2726}
2727
2728#[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 #[must_use]
2742 pub fn has_drift(&self) -> bool {
2743 self.recommendations
2744 .iter()
2745 .any(|r| r.contains("Drift detected"))
2746 }
2747
2748 #[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 pub allow_false_negative: f64,
2759 pub validate_cost: f64,
2761 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#[derive(Debug, Clone, Copy, PartialEq)]
3002pub struct BayesianShrinkagePrior {
3003 pub prior_mean: f64,
3005 pub prior_variance: f64,
3008}
3009
3010impl BayesianShrinkagePrior {
3011 #[must_use]
3013 pub const fn for_false_negative() -> Self {
3014 Self {
3015 prior_mean: 100.0,
3016 prior_variance: 400.0, }
3018 }
3019
3020 #[must_use]
3022 pub const fn for_validate_cost() -> Self {
3023 Self {
3024 prior_mean: 25.0,
3025 prior_variance: 100.0, }
3027 }
3028
3029 #[must_use]
3031 pub const fn for_false_positive() -> Self {
3032 Self {
3033 prior_mean: 60.0,
3034 prior_variance: 225.0, }
3036 }
3037
3038 #[must_use]
3045 pub fn shrinkage_weight(&self, empirical_variance: f64, sample_count: usize) -> f64 {
3046 if sample_count == 0 {
3047 return 0.0; }
3049 if self.prior_variance <= 0.0 {
3051 return 0.0; }
3053 let n = sample_count as f64;
3054 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 #[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#[derive(Debug, Clone, PartialEq)]
3077pub struct LossObservation {
3078 pub ts_unix_ms: u128,
3080 pub action: DecisionAction,
3082 pub was_incompatible: bool,
3084 pub realized_loss: f64,
3086 pub loss_category: LossCategory,
3088}
3089
3090#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
3092pub enum LossCategory {
3093 AllowFalseNegative,
3095 ValidateCost,
3097 RejectFalsePositive,
3099}
3100
3101#[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; }
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#[derive(Debug, Clone, PartialEq)]
3138pub struct CalibratedLossMatrix {
3139 pub matrix: LossMatrix,
3141 pub shrinkage_weights: LossShrinkageWeights,
3143 pub sample_counts: LossSampleCounts,
3145 pub calibrated_at_unix_ms: u128,
3147}
3148
3149#[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#[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#[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 #[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 #[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 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 pub fn observe_batch(&mut self, observations: &[LossObservation]) {
3230 for obs in observations {
3231 self.observe(obs);
3232 }
3233 }
3234
3235 #[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 #[must_use]
3292 pub fn samples_for_confidence(_prior: &BayesianShrinkagePrior, threshold: f64) -> usize {
3293 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 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 #[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#[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 let e_allow = p * loss.allow_false_negative;
3360 let e_validate = loss.validate_cost;
3362 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#[derive(Debug, Clone, PartialEq)]
3380pub struct AdmissionDecision {
3381 pub action: DecisionAction,
3383 pub expected_loss: f64,
3385 pub confidence: f64,
3387 pub hardened_override: bool,
3389 pub incompatibility_probability: f64,
3391 pub rationale: String,
3393}
3394
3395#[derive(Debug, Clone, Copy, PartialEq)]
3397pub struct AdmissionControllerConfig {
3398 pub min_confidence_threshold: f64,
3401 pub hardened_allow_penalty: f64,
3403 pub max_incompatibility_probability: f64,
3405 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 #[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#[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 #[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 #[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 #[must_use]
3474 pub fn mode(&self) -> CompatibilityMode {
3475 self.mode
3476 }
3477
3478 #[must_use]
3480 pub fn config(&self) -> &AdmissionControllerConfig {
3481 &self.config
3482 }
3483
3484 #[must_use]
3486 pub fn decide(&mut self, incompatibility_probability: f64) -> AdmissionDecision {
3487 self.decision_count += 1;
3488
3489 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 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 let calibrated = self.calibrator.calibrate();
3523 let samples = calibrated.sample_counts;
3524 let weights = calibrated.shrinkage_weights;
3525
3526 let confidence = weights
3528 .allow_false_negative
3529 .min(weights.validate_cost)
3530 .min(weights.reject_false_positive);
3531
3532 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 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 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 (LossCategory::AllowFalseNegative, 100.0)
3604 }
3605 DecisionAction::Allow => {
3606 return;
3608 }
3609 DecisionAction::FullValidate => {
3610 (LossCategory::ValidateCost, 25.0)
3612 }
3613 DecisionAction::FailClosed if !was_incompatible => {
3614 (LossCategory::RejectFalsePositive, 60.0)
3616 }
3617 DecisionAction::FailClosed => {
3618 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 #[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 pub fn reset(&mut self) {
3645 self.calibrator.reset();
3646 self.decision_count = 0;
3647 self.override_count = 0;
3648 }
3649}
3650
3651#[derive(Debug, Clone, PartialEq)]
3653pub struct AdmissionControllerStats {
3654 pub decision_count: u64,
3656 pub override_count: u64,
3658 pub sample_counts: LossSampleCounts,
3660 pub calibrated_matrix: CalibratedLossMatrix,
3662}
3663
3664#[derive(Debug, Clone, Copy, PartialEq)]
3670pub struct PredictionOutcome {
3671 pub predicted_probability: f64,
3673 pub actual_outcome: f64,
3675 pub ts_unix_ms: u128,
3677}
3678
3679#[derive(Debug, Clone, Default)]
3684pub struct BrierScoreTracker {
3685 predictions: Vec<PredictionOutcome>,
3686 sum_squared_error: f64,
3687}
3688
3689impl BrierScoreTracker {
3690 #[must_use]
3692 pub fn new() -> Self {
3693 Self::default()
3694 }
3695
3696 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 #[must_use]
3713 pub fn brier_score(&self) -> f64 {
3714 if self.predictions.is_empty() {
3715 return 0.5; }
3717 self.sum_squared_error / self.predictions.len() as f64
3718 }
3719
3720 #[must_use]
3722 pub fn count(&self) -> usize {
3723 self.predictions.len()
3724 }
3725
3726 #[must_use]
3728 pub fn is_calibrated(&self, threshold: f64) -> bool {
3729 self.count() >= 10 && self.brier_score() <= threshold
3730 }
3731
3732 pub fn reset(&mut self) {
3734 self.predictions.clear();
3735 self.sum_squared_error = 0.0;
3736 }
3737
3738 #[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#[derive(Debug, Clone, Copy, PartialEq)]
3794pub struct BrierScoreMetrics {
3795 pub brier_score: f64,
3797 pub sample_count: usize,
3799 pub reliability: f64,
3801 pub resolution: f64,
3803 pub base_rate: f64,
3805}
3806
3807#[derive(Debug, Clone)]
3809pub struct BrierScoreGate {
3810 tracker: BrierScoreTracker,
3811 threshold: f64,
3813 min_samples: usize,
3815 block_action: DecisionAction,
3817}
3818
3819impl Default for BrierScoreGate {
3820 fn default() -> Self {
3821 Self::new()
3822 }
3823}
3824
3825impl BrierScoreGate {
3826 #[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 #[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 pub fn record(&mut self, predicted_probability: f64, was_incompatible: bool) {
3850 self.tracker.record(predicted_probability, was_incompatible);
3851 }
3852
3853 #[must_use]
3855 pub fn is_open(&self) -> bool {
3856 self.tracker.count() < self.min_samples || self.tracker.brier_score() <= self.threshold
3858 }
3859
3860 #[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 #[must_use]
3884 pub fn brier_score(&self) -> f64 {
3885 self.tracker.brier_score()
3886 }
3887
3888 #[must_use]
3890 pub fn metrics(&self) -> BrierScoreMetrics {
3891 self.tracker.metrics()
3892 }
3893
3894 pub fn reset(&mut self) {
3896 self.tracker.reset();
3897 }
3898
3899 #[must_use]
3901 pub fn threshold(&self) -> f64 {
3902 self.threshold
3903 }
3904}
3905
3906#[derive(Debug, Clone, Copy, PartialEq)]
3908pub struct GateDecision {
3909 pub action: DecisionAction,
3911 pub gate_applied: bool,
3913 pub brier_score: f64,
3915 pub sample_count: usize,
3917}
3918
3919#[derive(Debug, Clone, Copy, PartialEq)]
3925pub struct TailSample {
3926 pub value: f64,
3928 pub ts_unix_ms: u128,
3930}
3931
3932#[derive(Debug, Clone, Copy, PartialEq)]
3934pub struct TailStabilityConfig {
3935 pub baseline_samples: usize,
3937 pub window_samples: usize,
3939 pub max_p99_ratio: f64,
3941 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, percentile: 0.99,
3952 }
3953 }
3954}
3955
3956impl TailStabilityConfig {
3957 #[must_use]
3959 pub const fn strict() -> Self {
3960 Self {
3961 baseline_samples: 50,
3962 window_samples: 30,
3963 max_p99_ratio: 1.1, percentile: 0.99,
3965 }
3966 }
3967}
3968
3969#[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 #[must_use]
3991 pub fn new() -> Self {
3992 Self::with_config(TailStabilityConfig::default())
3993 }
3994
3995 #[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 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 self.baseline.push(sample);
4017 if self.baseline.len() >= self.config.baseline_samples {
4018 self.lock_baseline();
4019 }
4020 } else {
4021 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 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 pub fn force_lock_baseline(&mut self) {
4043 if !self.baseline.is_empty() {
4044 self.lock_baseline();
4045 }
4046 }
4047
4048 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 #[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 #[must_use]
4078 pub fn baseline_p99(&self) -> Option<f64> {
4079 self.baseline_p99
4080 }
4081
4082 #[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 #[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 #[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 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 #[must_use]
4133 pub fn baseline_count(&self) -> usize {
4134 self.baseline.len()
4135 }
4136
4137 #[must_use]
4139 pub fn window_count(&self) -> usize {
4140 self.window.len()
4141 }
4142
4143 #[must_use]
4145 pub fn is_baseline_locked(&self) -> bool {
4146 self.baseline_locked
4147 }
4148}
4149
4150#[derive(Debug, Clone, Copy, PartialEq)]
4152pub struct TailStabilityStatus {
4153 pub baseline_samples: usize,
4155 pub window_samples: usize,
4157 pub baseline_locked: bool,
4159 pub baseline_p99: Option<f64>,
4161 pub window_p99: Option<f64>,
4163 pub p99_ratio: Option<f64>,
4165 pub has_shift: bool,
4167}
4168
4169#[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 #[must_use]
4185 pub fn new() -> Self {
4186 Self {
4187 tracker: TailStabilityTracker::new(),
4188 block_action: DecisionAction::FailClosed,
4189 }
4190 }
4191
4192 #[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 pub fn record(&mut self, latency: f64) {
4203 self.tracker.record(latency);
4204 }
4205
4206 #[must_use]
4208 pub fn is_open(&self) -> bool {
4209 !self.tracker.has_shift()
4210 }
4211
4212 #[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 #[must_use]
4233 pub fn status(&self) -> TailStabilityStatus {
4234 self.tracker.status()
4235 }
4236
4237 pub fn force_lock_baseline(&mut self) {
4239 self.tracker.force_lock_baseline();
4240 }
4241
4242 pub fn reset(&mut self) {
4244 self.tracker.reset();
4245 }
4246}
4247
4248#[derive(Debug, Clone, Copy, PartialEq)]
4250pub struct TailGateDecision {
4251 pub action: DecisionAction,
4253 pub gate_applied: bool,
4255 pub status: TailStabilityStatus,
4257}
4258
4259#[derive(Debug, Clone, Copy, PartialEq)]
4274pub struct GpdParameters {
4275 pub shape: f64,
4277 pub scale: f64,
4279 pub threshold: f64,
4281}
4282
4283impl GpdParameters {
4284 #[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 #[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 -self.scale * (1.0 - p).ln()
4307 } else {
4308 self.scale / self.shape * ((1.0 - p).powf(-self.shape) - 1.0)
4310 };
4311 self.threshold + exceedance
4312 }
4313
4314 #[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 (-y).exp()
4324 } else {
4325 let base = 1.0 + self.shape * y;
4326 if base <= 0.0 {
4327 0.0
4329 } else {
4330 base.powf(-1.0 / self.shape)
4331 }
4332 }
4333 }
4334
4335 #[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 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#[derive(Debug, Clone, Copy, PartialEq)]
4356pub struct GpdPrior {
4357 pub shape_mean: f64,
4359 pub shape_variance: f64,
4361 pub log_scale_mean: f64,
4363 pub log_scale_variance: f64,
4365}
4366
4367impl Default for GpdPrior {
4368 fn default() -> Self {
4369 Self {
4371 shape_mean: 0.1, shape_variance: 0.25, log_scale_mean: 0.0, log_scale_variance: 4.0, }
4376 }
4377}
4378
4379impl GpdPrior {
4380 #[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 #[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#[derive(Debug, Clone)]
4408pub struct BayesianGpdEstimator {
4409 prior: GpdPrior,
4411 exceedances: Vec<f64>,
4413 threshold: f64,
4415 total_observations: usize,
4417 posterior_shape: Option<(f64, f64)>, posterior_log_scale: Option<(f64, f64)>,
4420}
4421
4422impl BayesianGpdEstimator {
4423 #[must_use]
4425 pub fn new() -> Self {
4426 Self::with_prior(GpdPrior::default())
4427 }
4428
4429 #[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 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 fn fit(&mut self) {
4458 if self.exceedances.len() < 2 {
4459 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 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 let mom_shape = 0.5 * (1.0 - mean.powi(2) / variance);
4479 let mom_scale = mean * (1.0 - mom_shape);
4480
4481 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 prior_var / (prior_var + data_var / n)
4505 }
4506
4507 #[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 #[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 let point = params.pot_quantile(p, self.total_observations, self.exceedances.len());
4526
4527 let z = normal_quantile((1.0 + credibility) / 2.0);
4530
4531 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 #[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 #[must_use]
4567 pub fn n_exceedances(&self) -> usize {
4568 self.exceedances.len()
4569 }
4570
4571 #[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#[derive(Debug, Clone, Copy, PartialEq)]
4586pub struct GpdQuantileEstimate {
4587 pub quantile: f64,
4589 pub point_estimate: f64,
4591 pub lower_bound: f64,
4593 pub upper_bound: f64,
4595 pub credibility: f64,
4597 pub n_exceedances: usize,
4599 pub n_total: usize,
4601}
4602
4603impl GpdQuantileEstimate {
4604 #[must_use]
4606 pub fn interval_width(&self) -> f64 {
4607 self.upper_bound - self.lower_bound
4608 }
4609
4610 #[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
4621fn normal_quantile(p: f64) -> f64 {
4623 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 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#[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 #[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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4701pub enum OptimizationPhase {
4702 Baseline,
4704 Optimize,
4706 Verify,
4708 Measure,
4710 Complete,
4712}
4713
4714#[derive(Debug, Clone, PartialEq)]
4716pub struct OptimizationEvidence {
4717 pub fixtures_verified: usize,
4719 pub mismatches: usize,
4721 pub conformance_hash: String,
4723 pub verified_at_unix_ms: u128,
4725}
4726
4727#[derive(Debug, Clone, PartialEq)]
4729pub struct SloBaseline {
4730 pub row_id: SloRowId,
4732 pub p50_ms: f64,
4734 pub p95_ms: f64,
4736 pub p99_ms: f64,
4738 pub peak_memory_kb: u64,
4740 pub sample_count: usize,
4742 pub captured_at_unix_ms: u128,
4744}
4745
4746#[derive(Debug, Clone, PartialEq)]
4748pub struct OptimizationResult {
4749 pub row_id: SloRowId,
4751 pub optimization_description: String,
4753 pub baseline: SloBaseline,
4755 pub after: SloBaseline,
4757 pub evidence: OptimizationEvidence,
4759 pub success: bool,
4761 pub delta: OptimizationDelta,
4763}
4764
4765#[derive(Debug, Clone, Copy, PartialEq)]
4767pub struct OptimizationDelta {
4768 pub p50_delta_ms: f64,
4770 pub p95_delta_ms: f64,
4772 pub p99_delta_ms: f64,
4774 pub memory_delta_kb: i64,
4776 pub p95_improvement_pct: f64,
4778}
4779
4780impl OptimizationDelta {
4781 #[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 #[must_use]
4805 pub fn is_improvement(&self) -> bool {
4806 self.p95_delta_ms <= 0.0 && self.memory_delta_kb <= 1024 }
4809}
4810
4811#[derive(Debug, Clone)]
4813pub struct ProfileProveState {
4814 pub row_id: SloRowId,
4816 pub phase: OptimizationPhase,
4818 pub baseline: Option<SloBaseline>,
4820 pub after: Option<SloBaseline>,
4822 pub evidence: Option<OptimizationEvidence>,
4824 pub optimization_description: String,
4826 pub started_at_unix_ms: u128,
4828}
4829
4830impl ProfileProveState {
4831 #[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 pub fn record_baseline(&mut self, baseline: SloBaseline) {
4847 self.baseline = Some(baseline);
4848 self.phase = OptimizationPhase::Optimize;
4849 }
4850
4851 pub fn mark_optimized(&mut self) {
4853 self.phase = OptimizationPhase::Verify;
4854 }
4855
4856 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 self.phase = OptimizationPhase::Complete;
4863 } else {
4864 self.phase = OptimizationPhase::Measure;
4865 }
4866 }
4867
4868 pub fn record_after(&mut self, after: SloBaseline) {
4870 self.after = Some(after);
4871 self.phase = OptimizationPhase::Complete;
4872 }
4873
4874 #[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 #[must_use]
4897 pub fn is_complete(&self) -> bool {
4898 self.phase == OptimizationPhase::Complete
4899 }
4900}
4901
4902#[derive(Debug, Clone, Default)]
4904pub struct ProfileProveLoop {
4905 pub results: Vec<OptimizationResult>,
4907 pub active: Vec<ProfileProveState>,
4909}
4910
4911impl ProfileProveLoop {
4912 #[must_use]
4914 pub fn new() -> Self {
4915 Self::default()
4916 }
4917
4918 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 #[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 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 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 #[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#[derive(Debug, Clone, PartialEq)]
4982pub struct ProfileProveSummary {
4983 pub total_attempts: usize,
4985 pub successful: usize,
4987 pub failed: usize,
4989 pub avg_p95_improvement_pct: f64,
4991 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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
5069pub enum ParserEffectKind {
5070 Warning,
5072 Validation,
5074 FailClosed,
5076 Allow,
5078 Fallback,
5080 Sanitize,
5082 Coercion,
5084}
5085
5086impl ParserEffectKind {
5087 #[must_use]
5089 pub const fn is_terminal(&self) -> bool {
5090 matches!(self, Self::FailClosed)
5091 }
5092
5093 #[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 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
5119pub struct ParserEffect {
5120 pub ts_unix_ms: u128,
5122 pub kind: ParserEffectKind,
5124 pub operation: String,
5126 pub message: String,
5128 pub mode: CompatibilityMode,
5130 pub source_location: Option<String>,
5132 pub risk_probability: f64,
5134}
5135
5136impl ParserEffect {
5137 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
5206pub struct EffectTrace {
5207 effects: Vec<ParserEffect>,
5208 terminal_effect: Option<usize>,
5209}
5210
5211impl EffectTrace {
5212 #[must_use]
5214 pub fn new() -> Self {
5215 Self::default()
5216 }
5217
5218 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 #[must_use]
5233 pub fn is_terminated(&self) -> bool {
5234 self.terminal_effect.is_some()
5235 }
5236
5237 #[must_use]
5239 pub fn is_empty(&self) -> bool {
5240 self.effects.is_empty()
5241 }
5242
5243 #[must_use]
5245 pub fn len(&self) -> usize {
5246 self.effects.len()
5247 }
5248
5249 #[must_use]
5251 pub fn effects(&self) -> &[ParserEffect] {
5252 &self.effects
5253 }
5254
5255 #[must_use]
5257 pub fn terminal(&self) -> Option<&ParserEffect> {
5258 self.terminal_effect.map(|idx| &self.effects[idx])
5259 }
5260
5261 #[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 #[must_use]
5269 pub fn count_kind(&self, kind: ParserEffectKind) -> usize {
5270 self.effects.iter().filter(|e| e.kind == kind).count()
5271 }
5272
5273 #[must_use]
5275 pub fn warnings(&self) -> Vec<&ParserEffect> {
5276 self.filter_kind(ParserEffectKind::Warning)
5277 }
5278
5279 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 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 #[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5334pub enum EffectPolicy {
5335 #[default]
5337 CollectAll,
5338 FailOnAny,
5340 FailOnWarning,
5342 FailOnTerminal,
5344}
5345
5346impl EffectPolicy {
5347 #[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#[derive(Debug, Clone)]
5368pub struct EffectHandler {
5369 policy: EffectPolicy,
5370 trace: EffectTrace,
5371}
5372
5373impl EffectHandler {
5374 #[must_use]
5376 pub fn new(policy: EffectPolicy) -> Self {
5377 Self {
5378 policy,
5379 trace: EffectTrace::new(),
5380 }
5381 }
5382
5383 #[must_use]
5385 pub fn collect_all() -> Self {
5386 Self::new(EffectPolicy::CollectAll)
5387 }
5388
5389 #[must_use]
5391 pub fn fail_on_terminal() -> Self {
5392 Self::new(EffectPolicy::FailOnTerminal)
5393 }
5394
5395 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 #[must_use]
5404 pub fn trace(&self) -> &EffectTrace {
5405 &self.trace
5406 }
5407
5408 #[must_use]
5410 pub fn into_trace(self) -> EffectTrace {
5411 self.trace
5412 }
5413
5414 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
5436pub struct CountMinSketch {
5437 width: usize,
5439 depth: usize,
5441 counters: Vec<u64>,
5443 seeds: Vec<u64>,
5445 total_count: u64,
5447}
5448
5449impl CountMinSketch {
5450 #[must_use]
5457 pub fn new(width: usize, depth: usize) -> Self {
5458 Self::with_seed(width, depth, 0)
5459 }
5460
5461 #[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 pub fn add(&mut self, item: &str) {
5478 self.add_count(item, 1);
5479 }
5480
5481 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 #[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 #[must_use]
5510 pub fn total_count(&self) -> u64 {
5511 self.total_count
5512 }
5513
5514 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
5545pub struct ReservoirSampler<T> {
5546 capacity: usize,
5548 reservoir: Vec<T>,
5550 count: u64,
5552 rng_state: u64,
5554}
5555
5556impl<T: Clone> ReservoirSampler<T> {
5557 #[must_use]
5559 pub fn new(capacity: usize) -> Self {
5560 Self::with_seed(capacity, 0)
5561 }
5562
5563 #[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 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 #[must_use]
5592 pub fn sample(&self) -> &[T] {
5593 &self.reservoir
5594 }
5595
5596 #[must_use]
5598 pub fn count(&self) -> u64 {
5599 self.count
5600 }
5601
5602 #[must_use]
5604 pub fn is_full(&self) -> bool {
5605 self.reservoir.len() >= self.capacity
5606 }
5607
5608 pub fn clear(&mut self) {
5610 self.reservoir.clear();
5611 self.count = 0;
5612 }
5613
5614 fn next_random(&mut self) -> u64 {
5615 self.rng_state = self.rng_state.wrapping_mul(0x5DEECE66D).wrapping_add(11);
5617 self.rng_state
5618 }
5619}
5620
5621#[derive(Debug, Clone, Serialize, Deserialize)]
5626pub struct CompressedReplayLog {
5627 pub frequency_sketch: CountMinSketch,
5629 pub entry_sample: ReservoirSampler<String>,
5631 pub error_sample: ReservoirSampler<String>,
5633 pub schema_version: String,
5635}
5636
5637impl CompressedReplayLog {
5638 #[must_use]
5640 pub fn new() -> Self {
5641 Self::with_params(1000, 5, 100, 50)
5642 }
5643
5644 #[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 pub fn record_operation(&mut self, operation: &str) {
5667 self.frequency_sketch.add(operation);
5668 }
5669
5670 pub fn record_entry(&mut self, entry: String) {
5672 self.entry_sample.add(entry);
5673 }
5674
5675 pub fn record_error(&mut self, error: String) {
5677 self.frequency_sketch.add("__error__");
5678 self.error_sample.add(error);
5679 }
5680
5681 #[must_use]
5683 pub fn estimate_frequency(&self, operation: &str) -> u64 {
5684 self.frequency_sketch.estimate(operation)
5685 }
5686
5687 #[must_use]
5689 pub fn sampled_entries(&self) -> &[String] {
5690 self.entry_sample.sample()
5691 }
5692
5693 #[must_use]
5695 pub fn sampled_errors(&self) -> &[String] {
5696 self.error_sample.sample()
5697 }
5698
5699 #[must_use]
5701 pub fn total_operations(&self) -> u64 {
5702 self.frequency_sketch.total_count()
5703 }
5704
5705 pub fn merge(&mut self, other: &Self) -> Result<(), &'static str> {
5707 self.frequency_sketch.merge(&other.frequency_sketch)?;
5708 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 #[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 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 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 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 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 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 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 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 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 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 let mut incompatible = ledger.clone();
7511 incompatible.schema_version = "2.0.0".to_owned();
7512 assert!(!incompatible.is_schema_compatible());
7513
7514 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; 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 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 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, 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, 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 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 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 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 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, 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 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 #[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 let empirical_mean = 200.0;
8095 let empirical_variance = 100.0;
8096
8097 let posterior_small = prior.posterior_mean(empirical_mean, empirical_variance, 2);
8099 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 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 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 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 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 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 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 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 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 #[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 let mut strict = super::BayesianAdmissionController::new(CompatibilityMode::Strict);
8342 let mut hardened = super::BayesianAdmissionController::new(CompatibilityMode::Hardened);
8343
8344 let p = 0.25;
8346 let strict_decision = strict.decide(p);
8347 let hardened_decision = hardened.decide(p);
8348
8349 if strict_decision.action == DecisionAction::Allow {
8352 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 let decision = controller.decide(0.5);
8367
8368 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 let _ = controller.decide(0.1);
8382 let _ = controller.decide(0.5);
8383 let _ = controller.decide(0.95); let stats = controller.stats();
8386 assert_eq!(stats.decision_count, 3);
8387 assert_eq!(stats.override_count, 1); }
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 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 let decision = controller.decide(0.1);
8426 assert_eq!(decision.action, DecisionAction::Allow);
8427
8428 controller.record_outcome(&decision, false);
8430
8431 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 let decision = controller.decide(0.4);
8442
8443 if decision.action == DecisionAction::FullValidate {
8444 controller.record_outcome(&decision, false);
8446 let stats = controller.stats();
8447 assert_eq!(stats.sample_counts.validate_cost, 1);
8448 }
8449 }
8450
8451 #[test]
8456 fn brier_score_perfect_predictions() {
8457 let mut tracker = super::BrierScoreTracker::new();
8458
8459 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 tracker.record(0.0, true); tracker.record(1.0, false); 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 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 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 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 #[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 for _ in 0..15 {
8745 gate.record(0.9, false); }
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 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 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 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 assert!(gate.is_open());
8814 }
8815
8816 #[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 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, percentile: 0.99,
8847 };
8848 let mut tracker = super::TailStabilityTracker::with_config(config);
8849
8850 for i in 0..5 {
8852 tracker.record((i + 1) as f64);
8853 }
8854 let baseline_p99 = tracker.baseline_p99().unwrap();
8855
8856 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 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 for _ in 0..3 {
8910 tracker.record(10.0);
8911 }
8912
8913 for _ in 0..3 {
8915 tracker.record(15.0);
8916 }
8917 assert_eq!(tracker.window_count(), 3);
8918
8919 tracker.record(20.0);
8921 assert_eq!(tracker.window_count(), 3); 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 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 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 for _ in 0..5 {
9253 gate.record(10.0);
9254 }
9255
9256 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 #[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 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 #[test]
9434 fn gpd_parameters_quantile_exponential_case() {
9435 let params = super::GpdParameters::new(0.0, 1.0, 0.0);
9437
9438 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 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 let params = super::GpdParameters::new(0.5, 1.0, 0.0);
9459
9460 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 assert!((params.survival_probability(5.0) - 1.0).abs() < 1e-10);
9481
9482 assert!((params.survival_probability(3.0) - 1.0).abs() < 1e-10);
9484
9485 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 let values: Vec<f64> = (0..100).map(|i| (i as f64) + 1.0).collect();
9500 let threshold = 90.0; 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 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 assert!(est.lower_bound <= est.point_estimate);
9526 assert!(est.upper_bound >= est.point_estimate);
9527 assert!(est.interval_width() > 0.0);
9528
9529 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 assert!(heavy.shape_mean > light.shape_mean);
9544 assert!(heavy.shape_mean > default.shape_mean);
9545
9546 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 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 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 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 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 #[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 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 state.mark_optimized();
9701 assert_eq!(state.phase, super::OptimizationPhase::Verify);
9702
9703 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 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 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 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 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 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 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 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, 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 #[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 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 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 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 assert!(!EffectPolicy::CollectAll.should_fail(&warning));
10083 assert!(EffectPolicy::CollectAll.should_fail(&fail));
10084 assert!(!EffectPolicy::CollectAll.should_fail(&allow));
10085
10086 assert!(EffectPolicy::FailOnAny.should_fail(&warning));
10088 assert!(EffectPolicy::FailOnAny.should_fail(&fail));
10089 assert!(EffectPolicy::FailOnAny.should_fail(&allow));
10090
10091 assert!(EffectPolicy::FailOnWarning.should_fail(&warning));
10093 assert!(EffectPolicy::FailOnWarning.should_fail(&fail));
10094 assert!(!EffectPolicy::FailOnWarning.should_fail(&allow));
10095
10096 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 let result = handler.handle(ParserEffect::warning(
10110 "op1",
10111 "m1",
10112 CompatibilityMode::Strict,
10113 ));
10114 assert!(result.is_ok());
10115
10116 let result = handler.handle(ParserEffect::allow(
10118 "op2",
10119 "m2",
10120 CompatibilityMode::Strict,
10121 0.1,
10122 ));
10123 assert!(result.is_ok());
10124
10125 let result = handler.handle(ParserEffect::fail_closed(
10127 "op3",
10128 "m3",
10129 CompatibilityMode::Strict,
10130 ));
10131 assert!(result.is_err());
10132
10133 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 #[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 #[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 #[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}