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