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