1#![forbid(unsafe_code)]
85
86use std::collections::BTreeMap;
87use std::fmt;
88
89use franken_evidence::{EvidenceLedger, EvidenceLedgerBuilder};
90use franken_kernel::{DecisionId, TraceId};
91use serde::{Deserialize, Deserializer, Serialize};
92
93#[derive(Clone, Debug, PartialEq)]
99pub enum ValidationError {
100 InvalidLoss {
102 state: usize,
104 action: usize,
106 value: f64,
108 },
109 NegativeLoss {
111 state: usize,
113 action: usize,
115 value: f64,
117 },
118 DimensionMismatch {
120 expected: usize,
122 got: usize,
124 },
125 PosteriorNotNormalized {
127 sum: f64,
129 },
130 InvalidPosteriorProbability {
132 index: usize,
134 value: f64,
136 },
137 PosteriorLengthMismatch {
139 expected: usize,
141 got: usize,
143 },
144 EmptySpace {
146 field: &'static str,
148 },
149 ThresholdOutOfRange {
151 field: &'static str,
153 value: f64,
155 },
156 ActionIndexOutOfRange {
165 index: usize,
167 action_set_len: usize,
169 from_fallback: bool,
172 },
173}
174
175impl fmt::Display for ValidationError {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 match self {
178 Self::InvalidLoss {
179 state,
180 action,
181 value,
182 } => write!(
183 f,
184 "loss must be finite at state={state}, action={action}, got {value}"
185 ),
186 Self::NegativeLoss {
187 state,
188 action,
189 value,
190 } => write!(f, "negative loss {value} at state={state}, action={action}"),
191 Self::DimensionMismatch { expected, got } => {
192 write!(
193 f,
194 "dimension mismatch: expected {expected} values, got {got}"
195 )
196 }
197 Self::PosteriorNotNormalized { sum } => {
198 write!(f, "posterior sums to {sum}, expected 1.0")
199 }
200 Self::InvalidPosteriorProbability { index, value } => {
201 write!(
202 f,
203 "posterior[{index}] must be finite and non-negative, got {value}"
204 )
205 }
206 Self::PosteriorLengthMismatch { expected, got } => {
207 write!(
208 f,
209 "posterior length {got} does not match state count {expected}"
210 )
211 }
212 Self::EmptySpace { field } => write!(f, "{field} must not be empty"),
213 Self::ThresholdOutOfRange { field, value } => {
214 write!(f, "{field} threshold {value} out of valid range")
215 }
216 Self::ActionIndexOutOfRange {
217 index,
218 action_set_len,
219 from_fallback,
220 } => {
221 let path = if *from_fallback {
222 "fallback_action"
223 } else {
224 "choose_action"
225 };
226 write!(
227 f,
228 "{path} returned action_index {index} but action_set has only {action_set_len} entries"
229 )
230 }
231 }
232 }
233}
234
235impl std::error::Error for ValidationError {}
236
237#[derive(Clone, Debug, Serialize, PartialEq)]
247pub struct LossMatrix {
248 state_names: Vec<String>,
249 action_names: Vec<String>,
250 values: Vec<f64>,
251}
252
253#[derive(Deserialize)]
254struct LossMatrixRepr {
255 state_names: Vec<String>,
256 action_names: Vec<String>,
257 values: Vec<f64>,
258}
259
260impl<'de> Deserialize<'de> for LossMatrix {
261 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
262 where
263 D: Deserializer<'de>,
264 {
265 let repr = LossMatrixRepr::deserialize(deserializer)?;
266 Self::new(repr.state_names, repr.action_names, repr.values)
267 .map_err(serde::de::Error::custom)
268 }
269}
270
271impl LossMatrix {
272 pub fn new(
278 state_names: Vec<String>,
279 action_names: Vec<String>,
280 values: Vec<f64>,
281 ) -> Result<Self, ValidationError> {
282 if state_names.is_empty() {
283 return Err(ValidationError::EmptySpace {
284 field: "state_names",
285 });
286 }
287 if action_names.is_empty() {
288 return Err(ValidationError::EmptySpace {
289 field: "action_names",
290 });
291 }
292 let expected = state_names.len() * action_names.len();
293 if values.len() != expected {
294 return Err(ValidationError::DimensionMismatch {
295 expected,
296 got: values.len(),
297 });
298 }
299 let n_actions = action_names.len();
300 for (i, &v) in values.iter().enumerate() {
301 if !v.is_finite() {
302 return Err(ValidationError::InvalidLoss {
303 state: i / n_actions,
304 action: i % n_actions,
305 value: v,
306 });
307 }
308 if v < 0.0 {
309 return Err(ValidationError::NegativeLoss {
310 state: i / n_actions,
311 action: i % n_actions,
312 value: v,
313 });
314 }
315 }
316 Ok(Self {
317 state_names,
318 action_names,
319 values,
320 })
321 }
322
323 pub fn get(&self, state: usize, action: usize) -> f64 {
325 self.values[state * self.action_names.len() + action]
326 }
327
328 pub fn n_states(&self) -> usize {
330 self.state_names.len()
331 }
332
333 pub fn n_actions(&self) -> usize {
335 self.action_names.len()
336 }
337
338 pub fn state_names(&self) -> &[String] {
340 &self.state_names
341 }
342
343 pub fn action_names(&self) -> &[String] {
345 &self.action_names
346 }
347
348 pub fn expected_loss(&self, posterior: &Posterior, action: usize) -> f64 {
363 assert_eq!(
364 posterior.probs().len(),
365 self.n_states(),
366 "posterior dimension ({}) must match loss-matrix state count ({})",
367 posterior.probs().len(),
368 self.n_states()
369 );
370 assert!(
371 action < self.n_actions(),
372 "action index ({}) out of range for {} actions",
373 action,
374 self.n_actions()
375 );
376 posterior
377 .probs()
378 .iter()
379 .enumerate()
380 .map(|(s, &p)| p * self.get(s, action))
381 .sum()
382 }
383
384 pub fn expected_losses(&self, posterior: &Posterior) -> BTreeMap<String, f64> {
386 self.action_names
387 .iter()
388 .enumerate()
389 .map(|(a, name)| (name.clone(), self.expected_loss(posterior, a)))
390 .collect()
391 }
392
393 pub fn bayes_action(&self, posterior: &Posterior) -> usize {
397 (0..self.action_names.len())
398 .min_by(|&a, &b| {
399 self.expected_loss(posterior, a)
400 .partial_cmp(&self.expected_loss(posterior, b))
401 .unwrap_or(std::cmp::Ordering::Equal)
402 })
403 .unwrap_or(0)
404 }
405}
406
407const NORMALIZATION_TOLERANCE: f64 = 1e-6;
413
414#[derive(Clone, Debug, Serialize, PartialEq)]
418pub struct Posterior {
419 probs: Vec<f64>,
420}
421
422#[derive(Deserialize)]
423struct PosteriorRepr {
424 probs: Vec<f64>,
425}
426
427impl<'de> Deserialize<'de> for Posterior {
428 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
429 where
430 D: Deserializer<'de>,
431 {
432 let repr = PosteriorRepr::deserialize(deserializer)?;
433 Self::new(repr.probs).map_err(serde::de::Error::custom)
434 }
435}
436
437impl Posterior {
438 pub fn new(probs: Vec<f64>) -> Result<Self, ValidationError> {
442 for (index, &value) in probs.iter().enumerate() {
443 if !value.is_finite() || value < 0.0 {
444 return Err(ValidationError::InvalidPosteriorProbability { index, value });
445 }
446 }
447 let sum: f64 = probs.iter().sum();
448 if (sum - 1.0).abs() > NORMALIZATION_TOLERANCE {
449 return Err(ValidationError::PosteriorNotNormalized { sum });
450 }
451 Ok(Self { probs })
452 }
453
454 #[allow(clippy::cast_precision_loss)]
456 pub fn uniform(n: usize) -> Self {
457 let p = 1.0 / n as f64;
458 Self { probs: vec![p; n] }
459 }
460
461 pub fn probs(&self) -> &[f64] {
463 &self.probs
464 }
465
466 pub fn probs_mut(&mut self) -> &mut [f64] {
468 &mut self.probs
469 }
470
471 pub fn len(&self) -> usize {
473 self.probs.len()
474 }
475
476 pub fn is_empty(&self) -> bool {
478 self.probs.is_empty()
479 }
480
481 pub fn bayesian_update(&mut self, likelihoods: &[f64]) {
490 assert_eq!(likelihoods.len(), self.probs.len());
491 for (p, &l) in self.probs.iter_mut().zip(likelihoods) {
492 *p *= l;
493 }
494 self.normalize();
495 }
496
497 pub fn normalize(&mut self) {
499 let sum: f64 = self.probs.iter().sum();
500 if sum > 0.0 {
501 for p in &mut self.probs {
502 *p /= sum;
503 }
504 }
505 }
506
507 pub fn entropy(&self) -> f64 {
509 self.probs
510 .iter()
511 .filter(|&&p| p > 0.0)
512 .map(|&p| -p * p.log2())
513 .sum()
514 }
515
516 pub fn map_state(&self) -> usize {
520 self.probs
521 .iter()
522 .enumerate()
523 .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
524 .map_or(0, |(i, _)| i)
525 }
526}
527
528#[derive(Clone, Debug, Serialize, PartialEq)]
537pub struct FallbackPolicy {
538 pub calibration_drift_threshold: f64,
540 pub e_process_breach_threshold: f64,
542 pub confidence_width_threshold: f64,
544}
545
546#[derive(Deserialize)]
547#[allow(clippy::struct_field_names)]
548struct FallbackPolicyRepr {
549 calibration_drift_threshold: f64,
550 e_process_breach_threshold: f64,
551 confidence_width_threshold: f64,
552}
553
554impl<'de> Deserialize<'de> for FallbackPolicy {
555 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
556 where
557 D: Deserializer<'de>,
558 {
559 let repr = FallbackPolicyRepr::deserialize(deserializer)?;
560 Self::new(
561 repr.calibration_drift_threshold,
562 repr.e_process_breach_threshold,
563 repr.confidence_width_threshold,
564 )
565 .map_err(serde::de::Error::custom)
566 }
567}
568
569impl FallbackPolicy {
570 pub fn new(
575 calibration_drift_threshold: f64,
576 e_process_breach_threshold: f64,
577 confidence_width_threshold: f64,
578 ) -> Result<Self, ValidationError> {
579 if !calibration_drift_threshold.is_finite()
580 || !(0.0..=1.0).contains(&calibration_drift_threshold)
581 {
582 return Err(ValidationError::ThresholdOutOfRange {
583 field: "calibration_drift_threshold",
584 value: calibration_drift_threshold,
585 });
586 }
587 if !e_process_breach_threshold.is_finite() || e_process_breach_threshold < 0.0 {
588 return Err(ValidationError::ThresholdOutOfRange {
589 field: "e_process_breach_threshold",
590 value: e_process_breach_threshold,
591 });
592 }
593 if !confidence_width_threshold.is_finite() || confidence_width_threshold < 0.0 {
594 return Err(ValidationError::ThresholdOutOfRange {
595 field: "confidence_width_threshold",
596 value: confidence_width_threshold,
597 });
598 }
599 Ok(Self {
600 calibration_drift_threshold,
601 e_process_breach_threshold,
602 confidence_width_threshold,
603 })
604 }
605
606 pub fn should_fallback(&self, calibration_score: f64, e_process: f64, ci_width: f64) -> bool {
608 calibration_score < self.calibration_drift_threshold
609 || e_process > self.e_process_breach_threshold
610 || ci_width > self.confidence_width_threshold
611 }
612}
613
614impl Default for FallbackPolicy {
615 fn default() -> Self {
616 Self {
617 calibration_drift_threshold: 0.7,
618 e_process_breach_threshold: 20.0,
619 confidence_width_threshold: 0.5,
620 }
621 }
622}
623
624#[derive(Debug, Clone, PartialEq, Eq)]
637pub enum UpdatePosteriorError {
638 LengthMismatch {
640 expected: usize,
642 actual: usize,
644 },
645 ObservationOutOfRange {
647 observation: usize,
649 state_count: usize,
651 },
652}
653
654impl core::fmt::Display for UpdatePosteriorError {
655 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
656 match self {
657 Self::LengthMismatch { expected, actual } => write!(
658 f,
659 "posterior length mismatch: expected {expected}, got {actual}"
660 ),
661 Self::ObservationOutOfRange {
662 observation,
663 state_count,
664 } => write!(
665 f,
666 "observation {observation} is out of range for state space of size {state_count}"
667 ),
668 }
669 }
670}
671
672impl std::error::Error for UpdatePosteriorError {}
673
674pub trait DecisionContract {
680 fn name(&self) -> &str;
682
683 fn state_space(&self) -> &[String];
685
686 fn action_set(&self) -> &[String];
688
689 fn loss_matrix(&self) -> &LossMatrix;
691
692 fn update_posterior(
703 &self,
704 posterior: &mut Posterior,
705 state_index: usize,
706 ) -> Result<(), UpdatePosteriorError>;
707
708 fn choose_action(&self, posterior: &Posterior) -> usize;
712
713 fn fallback_action(&self) -> usize;
717
718 fn fallback_policy(&self) -> &FallbackPolicy;
720}
721
722#[derive(Clone, Debug, Serialize, Deserialize)]
731pub struct DecisionAuditEntry {
732 pub decision_id: DecisionId,
734 pub trace_id: TraceId,
736 pub contract_name: String,
738 pub action_chosen: String,
740 pub expected_loss: f64,
742 pub calibration_score: f64,
744 pub fallback_active: bool,
746 pub posterior_snapshot: Vec<f64>,
748 pub expected_loss_by_action: BTreeMap<String, f64>,
750 pub ts_unix_ms: u64,
752}
753
754fn sanitize_posterior_snapshot(probs: &[f64]) -> Vec<f64> {
762 let all_finite_nonneg = !probs.is_empty() && probs.iter().all(|p| p.is_finite() && *p >= 0.0);
763 let sum: f64 = probs.iter().sum();
764 if all_finite_nonneg && (sum - 1.0).abs() <= 1e-6 {
765 probs.to_vec()
766 } else if all_finite_nonneg && sum > 0.0 {
767 probs.iter().map(|p| p / sum).collect()
768 } else {
769 let n = probs.len().max(1);
773 #[allow(clippy::cast_precision_loss)]
774 let uniform = 1.0 / n as f64;
775 vec![uniform; n]
776 }
777}
778
779fn sanitize_loss(loss: f64) -> f64 {
781 if loss.is_finite() && loss >= 0.0 {
782 loss
783 } else {
784 0.0
785 }
786}
787
788impl DecisionAuditEntry {
789 pub fn to_evidence_ledger(&self) -> EvidenceLedger {
801 let chosen_loss = sanitize_loss(self.expected_loss);
802 let mut builder = EvidenceLedgerBuilder::new()
803 .ts_unix_ms(self.ts_unix_ms)
804 .component(&self.contract_name)
805 .action(&self.action_chosen)
806 .posterior(sanitize_posterior_snapshot(&self.posterior_snapshot))
807 .chosen_expected_loss(chosen_loss)
808 .calibration_score(self.calibration_score.clamp(0.0, 1.0))
809 .fallback_active(self.fallback_active);
810
811 for (action, &loss) in &self.expected_loss_by_action {
812 let loss = if *action == self.action_chosen {
815 chosen_loss
816 } else {
817 sanitize_loss(loss)
818 };
819 builder = builder.expected_loss(action, loss);
820 }
821 builder = builder.expected_loss(&self.action_chosen, chosen_loss);
824
825 builder.build().unwrap_or_else(|_| {
826 EvidenceLedgerBuilder::new()
829 .ts_unix_ms(self.ts_unix_ms)
830 .component(if self.contract_name.is_empty() {
831 "unknown"
832 } else {
833 self.contract_name.as_str()
834 })
835 .action(if self.action_chosen.is_empty() {
836 "unknown"
837 } else {
838 self.action_chosen.as_str()
839 })
840 .posterior(vec![1.0])
841 .chosen_expected_loss(0.0)
842 .calibration_score(0.5)
843 .fallback_active(self.fallback_active)
844 .build()
845 .expect("minimal evidence ledger is valid by construction")
846 })
847 }
848}
849
850#[derive(Clone, Debug)]
856pub struct DecisionOutcome {
857 pub action_index: usize,
859 pub action_name: String,
861 pub expected_loss: f64,
863 pub expected_losses: BTreeMap<String, f64>,
865 pub fallback_active: bool,
867 pub audit_entry: DecisionAuditEntry,
869}
870
871#[derive(Clone, Debug)]
880pub struct EvalContext {
881 pub calibration_score: f64,
883 pub e_process: f64,
885 pub ci_width: f64,
887 pub decision_id: DecisionId,
889 pub trace_id: TraceId,
891 pub ts_unix_ms: u64,
893}
894
895pub fn evaluate<C: DecisionContract>(
917 contract: &C,
918 posterior: &Posterior,
919 ctx: &EvalContext,
920) -> Result<DecisionOutcome, ValidationError> {
921 let loss_matrix = contract.loss_matrix();
922 let expected_losses = loss_matrix.expected_losses(posterior);
923
924 let fallback_active = contract.fallback_policy().should_fallback(
925 ctx.calibration_score,
926 ctx.e_process,
927 ctx.ci_width,
928 );
929
930 let action_index = if fallback_active {
931 contract.fallback_action()
932 } else {
933 contract.choose_action(posterior)
934 };
935
936 let action_set = contract.action_set();
941 if action_index >= action_set.len() {
942 return Err(ValidationError::ActionIndexOutOfRange {
943 index: action_index,
944 action_set_len: action_set.len(),
945 from_fallback: fallback_active,
946 });
947 }
948 let action_name = action_set[action_index].clone();
949 let expected_loss = expected_losses[&action_name];
950
951 let audit_entry = DecisionAuditEntry {
952 decision_id: ctx.decision_id,
953 trace_id: ctx.trace_id,
954 contract_name: contract.name().to_string(),
955 action_chosen: action_name.clone(),
956 expected_loss,
957 calibration_score: ctx.calibration_score,
958 fallback_active,
959 posterior_snapshot: posterior.probs().to_vec(),
960 expected_loss_by_action: expected_losses.clone(),
961 ts_unix_ms: ctx.ts_unix_ms,
962 };
963
964 Ok(DecisionOutcome {
965 action_index,
966 action_name,
967 expected_loss,
968 expected_losses,
969 fallback_active,
970 audit_entry,
971 })
972}
973
974#[cfg(test)]
979#[allow(clippy::float_cmp)]
980mod tests {
981 use super::*;
982
983 fn two_state_matrix() -> LossMatrix {
986 LossMatrix::new(
990 vec!["good".into(), "bad".into()],
991 vec!["continue".into(), "stop".into()],
992 vec![0.0, 0.3, 0.8, 0.1],
993 )
994 .unwrap()
995 }
996
997 struct TestContract {
998 states: Vec<String>,
999 actions: Vec<String>,
1000 losses: LossMatrix,
1001 policy: FallbackPolicy,
1002 }
1003
1004 impl TestContract {
1005 fn new() -> Self {
1006 Self {
1007 states: vec!["good".into(), "bad".into()],
1008 actions: vec!["continue".into(), "stop".into()],
1009 losses: two_state_matrix(),
1010 policy: FallbackPolicy::default(),
1011 }
1012 }
1013 }
1014
1015 #[allow(clippy::unnecessary_literal_bound)]
1016 impl DecisionContract for TestContract {
1017 fn name(&self) -> &str {
1018 "test_contract"
1019 }
1020 fn state_space(&self) -> &[String] {
1021 &self.states
1022 }
1023 fn action_set(&self) -> &[String] {
1024 &self.actions
1025 }
1026 fn loss_matrix(&self) -> &LossMatrix {
1027 &self.losses
1028 }
1029 fn update_posterior(
1030 &self,
1031 posterior: &mut Posterior,
1032 observation: usize,
1033 ) -> Result<(), UpdatePosteriorError> {
1034 if posterior.len() != self.states.len() {
1035 return Err(UpdatePosteriorError::LengthMismatch {
1036 expected: self.states.len(),
1037 actual: posterior.len(),
1038 });
1039 }
1040 if observation >= self.states.len() {
1041 return Err(UpdatePosteriorError::ObservationOutOfRange {
1042 observation,
1043 state_count: self.states.len(),
1044 });
1045 }
1046 let mut likelihoods = vec![0.1; self.states.len()];
1048 likelihoods[observation] = 0.9;
1049 posterior.bayesian_update(&likelihoods);
1050 Ok(())
1051 }
1052 fn choose_action(&self, posterior: &Posterior) -> usize {
1053 self.losses.bayes_action(posterior)
1054 }
1055 fn fallback_action(&self) -> usize {
1056 0 }
1058 fn fallback_policy(&self) -> &FallbackPolicy {
1059 &self.policy
1060 }
1061 }
1062
1063 #[test]
1066 fn loss_matrix_creation() {
1067 let m = two_state_matrix();
1068 assert_eq!(m.n_states(), 2);
1069 assert_eq!(m.n_actions(), 2);
1070 assert_eq!(m.get(0, 0), 0.0);
1071 assert_eq!(m.get(0, 1), 0.3);
1072 assert_eq!(m.get(1, 0), 0.8);
1073 assert_eq!(m.get(1, 1), 0.1);
1074 }
1075
1076 #[test]
1077 fn loss_matrix_empty_states_rejected() {
1078 let err = LossMatrix::new(vec![], vec!["a".into()], vec![]).unwrap_err();
1079 assert!(matches!(
1080 err,
1081 ValidationError::EmptySpace {
1082 field: "state_names"
1083 }
1084 ));
1085 }
1086
1087 #[test]
1088 fn loss_matrix_empty_actions_rejected() {
1089 let err = LossMatrix::new(vec!["s".into()], vec![], vec![]).unwrap_err();
1090 assert!(matches!(
1091 err,
1092 ValidationError::EmptySpace {
1093 field: "action_names"
1094 }
1095 ));
1096 }
1097
1098 #[test]
1099 fn loss_matrix_dimension_mismatch() {
1100 let err = LossMatrix::new(
1101 vec!["s1".into(), "s2".into()],
1102 vec!["a1".into()],
1103 vec![0.1], )
1105 .unwrap_err();
1106 assert!(matches!(
1107 err,
1108 ValidationError::DimensionMismatch {
1109 expected: 2,
1110 got: 1
1111 }
1112 ));
1113 }
1114
1115 #[test]
1116 fn loss_matrix_negative_rejected() {
1117 let err = LossMatrix::new(vec!["s".into()], vec!["a".into()], vec![-0.5]).unwrap_err();
1118 assert!(matches!(
1119 err,
1120 ValidationError::NegativeLoss {
1121 state: 0,
1122 action: 0,
1123 ..
1124 }
1125 ));
1126 }
1127
1128 #[test]
1129 fn loss_matrix_non_finite_rejected() {
1130 let err = LossMatrix::new(vec!["s".into()], vec!["a".into()], vec![f64::NAN]).unwrap_err();
1131 assert!(matches!(
1132 err,
1133 ValidationError::InvalidLoss {
1134 state: 0,
1135 action: 0,
1136 value
1137 } if value.is_nan()
1138 ));
1139 }
1140
1141 #[test]
1142 fn loss_matrix_expected_loss() {
1143 let m = two_state_matrix();
1144 let posterior = Posterior::new(vec![0.8, 0.2]).unwrap();
1145 let el_continue = m.expected_loss(&posterior, 0);
1147 assert!((el_continue - 0.16).abs() < 1e-10);
1148 let el_stop = m.expected_loss(&posterior, 1);
1150 assert!((el_stop - 0.26).abs() < 1e-10);
1151 }
1152
1153 #[test]
1154 #[should_panic(expected = "posterior dimension")]
1155 fn expected_loss_panics_on_short_posterior() {
1156 let m = LossMatrix::new(
1159 vec!["s0".into(), "s1".into(), "s2".into()],
1160 vec!["a0".into()],
1161 vec![1.0, 2.0, 3.0],
1162 )
1163 .unwrap();
1164 let short = Posterior::new(vec![0.5, 0.5]).unwrap();
1165 let _ = m.expected_loss(&short, 0);
1166 }
1167
1168 #[test]
1169 #[should_panic(expected = "posterior dimension")]
1170 fn expected_loss_panics_on_long_posterior() {
1171 let m = two_state_matrix();
1174 let long = Posterior::new(vec![0.3, 0.3, 0.4]).unwrap();
1175 let _ = m.expected_loss(&long, 0);
1176 }
1177
1178 #[test]
1179 #[should_panic(expected = "action index")]
1180 fn expected_loss_panics_on_action_out_of_range() {
1181 let m = two_state_matrix();
1185 let p = Posterior::new(vec![0.5, 0.5]).unwrap();
1186 let _ = m.expected_loss(&p, 2);
1187 }
1188
1189 #[test]
1190 fn loss_matrix_bayes_action() {
1191 let m = two_state_matrix();
1192 let mostly_good = Posterior::new(vec![0.9, 0.1]).unwrap();
1194 assert_eq!(m.bayes_action(&mostly_good), 0); let mostly_bad = Posterior::new(vec![0.2, 0.8]).unwrap();
1197 assert_eq!(m.bayes_action(&mostly_bad), 1); }
1199
1200 #[test]
1201 fn loss_matrix_expected_losses_map() {
1202 let m = two_state_matrix();
1203 let posterior = Posterior::uniform(2);
1204 let losses = m.expected_losses(&posterior);
1205 assert_eq!(losses.len(), 2);
1206 assert!(losses.contains_key("continue"));
1207 assert!(losses.contains_key("stop"));
1208 }
1209
1210 #[test]
1211 fn loss_matrix_names() {
1212 let m = two_state_matrix();
1213 assert_eq!(m.state_names(), &["good", "bad"]);
1214 assert_eq!(m.action_names(), &["continue", "stop"]);
1215 }
1216
1217 #[test]
1218 fn loss_matrix_toml_roundtrip() {
1219 let m = two_state_matrix();
1220 let toml_str = toml::to_string(&m).unwrap();
1221 let parsed: LossMatrix = toml::from_str(&toml_str).unwrap();
1222 assert_eq!(m, parsed);
1223 }
1224
1225 #[test]
1226 fn loss_matrix_json_roundtrip() {
1227 let m = two_state_matrix();
1228 let json = serde_json::to_string(&m).unwrap();
1229 let parsed: LossMatrix = serde_json::from_str(&json).unwrap();
1230 assert_eq!(m, parsed);
1231 }
1232
1233 #[test]
1234 fn loss_matrix_json_invalid_value_rejected_at_deserialize() {
1235 let json = r#"{"state_names":["s"],"action_names":["a"],"values":[-0.5]}"#;
1236 let err = serde_json::from_str::<LossMatrix>(json).unwrap_err();
1237 assert!(err.to_string().contains("negative loss"));
1238 }
1239
1240 #[test]
1243 fn posterior_uniform() {
1244 let p = Posterior::uniform(4);
1245 assert_eq!(p.len(), 4);
1246 for &v in p.probs() {
1247 assert!((v - 0.25).abs() < 1e-10);
1248 }
1249 }
1250
1251 #[test]
1252 fn posterior_new_valid() {
1253 let p = Posterior::new(vec![0.3, 0.7]).unwrap();
1254 assert_eq!(p.probs(), &[0.3, 0.7]);
1255 }
1256
1257 #[test]
1258 fn posterior_new_not_normalized() {
1259 let err = Posterior::new(vec![0.5, 0.3]).unwrap_err();
1260 assert!(matches!(
1261 err,
1262 ValidationError::PosteriorNotNormalized { .. }
1263 ));
1264 }
1265
1266 #[test]
1267 fn posterior_new_negative_probability_rejected() {
1268 let err = Posterior::new(vec![-0.1, 1.1]).unwrap_err();
1269 assert!(matches!(
1270 err,
1271 ValidationError::InvalidPosteriorProbability {
1272 index: 0,
1273 value
1274 } if value == -0.1
1275 ));
1276 }
1277
1278 #[test]
1279 fn posterior_new_non_finite_probability_rejected() {
1280 let err = Posterior::new(vec![f64::NAN, 1.0]).unwrap_err();
1281 assert!(matches!(
1282 err,
1283 ValidationError::InvalidPosteriorProbability {
1284 index: 0,
1285 value
1286 } if value.is_nan()
1287 ));
1288 }
1289
1290 #[test]
1291 fn posterior_bayesian_update() {
1292 let mut p = Posterior::uniform(2);
1293 p.bayesian_update(&[0.9, 0.1]);
1295 assert!((p.probs()[0] - 0.9).abs() < 1e-10);
1297 assert!((p.probs()[1] - 0.1).abs() < 1e-10);
1298 }
1299
1300 #[test]
1301 fn posterior_bayesian_update_no_alloc() {
1302 let mut p = Posterior::uniform(3);
1304 let ptr_before = p.probs().as_ptr();
1305 p.bayesian_update(&[0.5, 0.3, 0.2]);
1306 let ptr_after = p.probs().as_ptr();
1307 assert_eq!(ptr_before, ptr_after);
1308 }
1309
1310 #[test]
1311 fn posterior_entropy() {
1312 let p = Posterior::uniform(2);
1314 assert!((p.entropy() - 1.0).abs() < 1e-10);
1315 let det = Posterior::new(vec![1.0, 0.0]).unwrap();
1317 assert!((det.entropy()).abs() < 1e-10);
1318 }
1319
1320 #[test]
1321 fn posterior_map_state() {
1322 let p = Posterior::new(vec![0.1, 0.7, 0.2]).unwrap();
1323 assert_eq!(p.map_state(), 1);
1324 }
1325
1326 #[test]
1327 fn posterior_is_empty() {
1328 let p = Posterior { probs: vec![] };
1329 assert!(p.is_empty());
1330 let p2 = Posterior::uniform(1);
1331 assert!(!p2.is_empty());
1332 }
1333
1334 #[test]
1335 fn posterior_probs_mut() {
1336 let mut p = Posterior::uniform(2);
1337 p.probs_mut()[0] = 0.8;
1338 p.probs_mut()[1] = 0.2;
1339 assert_eq!(p.probs(), &[0.8, 0.2]);
1340 }
1341
1342 #[test]
1345 fn fallback_policy_default() {
1346 let fp = FallbackPolicy::default();
1347 assert_eq!(fp.calibration_drift_threshold, 0.7);
1348 assert_eq!(fp.e_process_breach_threshold, 20.0);
1349 assert_eq!(fp.confidence_width_threshold, 0.5);
1350 }
1351
1352 #[test]
1353 fn fallback_policy_new_valid() {
1354 let fp = FallbackPolicy::new(0.8, 10.0, 0.3).unwrap();
1355 assert_eq!(fp.calibration_drift_threshold, 0.8);
1356 }
1357
1358 #[test]
1359 fn fallback_policy_calibration_out_of_range() {
1360 let err = FallbackPolicy::new(1.5, 10.0, 0.3).unwrap_err();
1361 assert!(matches!(
1362 err,
1363 ValidationError::ThresholdOutOfRange {
1364 field: "calibration_drift_threshold",
1365 ..
1366 }
1367 ));
1368 }
1369
1370 #[test]
1371 fn fallback_policy_negative_e_process() {
1372 let err = FallbackPolicy::new(0.7, -1.0, 0.3).unwrap_err();
1373 assert!(matches!(
1374 err,
1375 ValidationError::ThresholdOutOfRange {
1376 field: "e_process_breach_threshold",
1377 ..
1378 }
1379 ));
1380 }
1381
1382 #[test]
1383 fn fallback_policy_negative_ci_width() {
1384 let err = FallbackPolicy::new(0.7, 10.0, -0.1).unwrap_err();
1385 assert!(matches!(
1386 err,
1387 ValidationError::ThresholdOutOfRange {
1388 field: "confidence_width_threshold",
1389 ..
1390 }
1391 ));
1392 }
1393
1394 #[test]
1395 fn fallback_policy_non_finite_e_process_rejected() {
1396 let err = FallbackPolicy::new(0.7, f64::NAN, 0.3).unwrap_err();
1397 assert!(matches!(
1398 err,
1399 ValidationError::ThresholdOutOfRange {
1400 field: "e_process_breach_threshold",
1401 value
1402 } if value.is_nan()
1403 ));
1404 }
1405
1406 #[test]
1407 fn fallback_policy_non_finite_ci_width_rejected() {
1408 let err = FallbackPolicy::new(0.7, 10.0, f64::INFINITY).unwrap_err();
1409 assert!(matches!(
1410 err,
1411 ValidationError::ThresholdOutOfRange {
1412 field: "confidence_width_threshold",
1413 value
1414 } if value.is_infinite()
1415 ));
1416 }
1417
1418 #[test]
1419 fn fallback_policy_json_invalid_threshold_rejected_at_deserialize() {
1420 let json = r#"{
1421 "calibration_drift_threshold": 0.7,
1422 "e_process_breach_threshold": -1.0,
1423 "confidence_width_threshold": 0.3
1424 }"#;
1425 let err = serde_json::from_str::<FallbackPolicy>(json).unwrap_err();
1426 assert!(err.to_string().contains("threshold"));
1427 }
1428
1429 #[test]
1430 fn fallback_triggered_by_low_calibration() {
1431 let fp = FallbackPolicy::default();
1432 assert!(fp.should_fallback(0.5, 1.0, 0.1)); assert!(!fp.should_fallback(0.9, 1.0, 0.1)); }
1435
1436 #[test]
1437 fn fallback_triggered_by_e_process() {
1438 let fp = FallbackPolicy::default();
1439 assert!(fp.should_fallback(0.9, 25.0, 0.1)); assert!(!fp.should_fallback(0.9, 15.0, 0.1)); }
1442
1443 #[test]
1444 fn fallback_triggered_by_ci_width() {
1445 let fp = FallbackPolicy::default();
1446 assert!(fp.should_fallback(0.9, 1.0, 0.6)); assert!(!fp.should_fallback(0.9, 1.0, 0.3)); }
1449
1450 #[test]
1453 fn contract_implementable_under_50_lines() {
1454 let contract = TestContract::new();
1456 assert_eq!(contract.name(), "test_contract");
1457 assert_eq!(contract.state_space().len(), 2);
1458 assert_eq!(contract.action_set().len(), 2);
1459 }
1460
1461 fn test_ctx(cal: f64, random: u128) -> EvalContext {
1462 EvalContext {
1463 calibration_score: cal,
1464 e_process: 1.0,
1465 ci_width: 0.1,
1466 decision_id: DecisionId::from_parts(1_700_000_000_000, random),
1467 trace_id: TraceId::from_parts(1_700_000_000_000, random),
1468 ts_unix_ms: 1_700_000_000_000,
1469 }
1470 }
1471
1472 #[test]
1473 fn evaluate_normal_decision() {
1474 let contract = TestContract::new();
1475 let posterior = Posterior::new(vec![0.9, 0.1]).unwrap();
1476 let ctx = test_ctx(0.95, 42);
1477
1478 let outcome = evaluate(&contract, &posterior, &ctx)
1479 .expect("legacy test invariant: contract action_index in range");
1480
1481 assert!(!outcome.fallback_active);
1482 assert_eq!(outcome.action_name, "continue"); assert_eq!(outcome.action_index, 0);
1484 assert!(outcome.expected_loss < 0.1);
1485 assert_eq!(outcome.expected_losses.len(), 2);
1486 }
1487
1488 #[test]
1489 fn evaluate_fallback_decision() {
1490 let contract = TestContract::new();
1491 let posterior = Posterior::new(vec![0.2, 0.8]).unwrap();
1492 let ctx = test_ctx(0.5, 43); let outcome = evaluate(&contract, &posterior, &ctx)
1495 .expect("legacy test invariant: contract action_index in range");
1496
1497 assert!(outcome.fallback_active);
1498 assert_eq!(outcome.action_name, "continue"); assert_eq!(outcome.action_index, 0);
1500 }
1501
1502 #[test]
1503 fn evaluate_without_fallback_chooses_optimal() {
1504 let contract = TestContract::new();
1505 let posterior = Posterior::new(vec![0.2, 0.8]).unwrap();
1506 let ctx = test_ctx(0.95, 44); let outcome = evaluate(&contract, &posterior, &ctx)
1509 .expect("legacy test invariant: contract action_index in range");
1510
1511 assert!(!outcome.fallback_active);
1512 assert_eq!(outcome.action_name, "stop"); }
1514
1515 #[test]
1516 fn evaluate_audit_entry_fields() {
1517 let contract = TestContract::new();
1518 let posterior = Posterior::uniform(2);
1519 let ctx = test_ctx(0.85, 99);
1520
1521 let outcome = evaluate(&contract, &posterior, &ctx)
1522 .expect("legacy test invariant: contract action_index in range");
1523
1524 let audit = &outcome.audit_entry;
1525 assert_eq!(audit.decision_id, ctx.decision_id);
1526 assert_eq!(audit.trace_id, ctx.trace_id);
1527 assert_eq!(audit.contract_name, "test_contract");
1528 assert_eq!(audit.calibration_score, 0.85);
1529 assert_eq!(audit.ts_unix_ms, 1_700_000_000_000);
1530 assert_eq!(audit.posterior_snapshot.len(), 2);
1531 }
1532
1533 #[test]
1536 fn audit_entry_to_evidence_ledger() {
1537 let contract = TestContract::new();
1538 let posterior = Posterior::new(vec![0.6, 0.4]).unwrap();
1539 let ctx = test_ctx(0.92, 100);
1540
1541 let outcome = evaluate(&contract, &posterior, &ctx)
1542 .expect("legacy test invariant: contract action_index in range");
1543 let evidence = outcome.audit_entry.to_evidence_ledger();
1544
1545 assert_eq!(evidence.ts_unix_ms, 1_700_000_000_000);
1546 assert_eq!(evidence.component, "test_contract");
1547 assert_eq!(evidence.action, outcome.action_name);
1548 assert_eq!(evidence.calibration_score, 0.92);
1549 assert!(!evidence.fallback_active);
1550 assert_eq!(evidence.posterior, vec![0.6, 0.4]);
1551 assert!(evidence.is_valid());
1552 }
1553
1554 #[test]
1555 fn to_evidence_ledger_sanitizes_degenerate_audit_without_panicking() {
1556 let contract = TestContract::new();
1561 let posterior = Posterior::new(vec![0.6, 0.4]).unwrap();
1562 let ctx = test_ctx(0.5, 100);
1563 let outcome = evaluate(&contract, &posterior, &ctx).expect("valid contract");
1564
1565 let mut audit = outcome.audit_entry;
1566 audit.posterior_snapshot = vec![0.0, 0.0]; audit.expected_loss = -0.0;
1568
1569 let ledger = audit.to_evidence_ledger(); assert!(ledger.is_valid());
1571 let sum: f64 = ledger.posterior.iter().sum();
1572 assert!(
1573 (sum - 1.0).abs() <= 1e-6,
1574 "degenerate posterior should be renormalized, got sum {sum}"
1575 );
1576 }
1577
1578 #[test]
1579 fn audit_entry_serde_roundtrip() {
1580 let contract = TestContract::new();
1581 let posterior = Posterior::uniform(2);
1582 let ctx = test_ctx(0.88, 101);
1583
1584 let outcome = evaluate(&contract, &posterior, &ctx)
1585 .expect("legacy test invariant: contract action_index in range");
1586 let json = serde_json::to_string(&outcome.audit_entry).unwrap();
1587 let parsed: DecisionAuditEntry = serde_json::from_str(&json).unwrap();
1588 assert_eq!(parsed.contract_name, "test_contract");
1589 assert_eq!(parsed.decision_id, ctx.decision_id);
1590 assert_eq!(parsed.trace_id, ctx.trace_id);
1591 }
1592
1593 #[test]
1596 fn contract_update_posterior() {
1597 let contract = TestContract::new();
1598 let mut posterior = Posterior::uniform(2);
1599 contract
1600 .update_posterior(&mut posterior, 0)
1601 .expect("update_posterior should succeed for matching length"); assert!(posterior.probs()[0] > posterior.probs()[1]);
1604 }
1605
1606 #[test]
1609 fn validation_error_display() {
1610 let err = ValidationError::NegativeLoss {
1611 state: 1,
1612 action: 2,
1613 value: -0.5,
1614 };
1615 let msg = format!("{err}");
1616 assert!(msg.contains("-0.5"));
1617 assert!(msg.contains("state=1"));
1618 assert!(msg.contains("action=2"));
1619 }
1620
1621 #[test]
1622 fn dimension_mismatch_display() {
1623 let err = ValidationError::DimensionMismatch {
1624 expected: 6,
1625 got: 4,
1626 };
1627 let msg = format!("{err}");
1628 assert!(msg.contains('6'));
1629 assert!(msg.contains('4'));
1630 }
1631
1632 #[test]
1635 fn fallback_policy_toml_roundtrip() {
1636 let fp = FallbackPolicy::default();
1637 let toml_str = toml::to_string(&fp).unwrap();
1638 let parsed: FallbackPolicy = toml::from_str(&toml_str).unwrap();
1639 assert_eq!(fp, parsed);
1640 }
1641
1642 #[test]
1643 fn fallback_policy_json_roundtrip() {
1644 let fp = FallbackPolicy::default();
1645 let json = serde_json::to_string(&fp).unwrap();
1646 let parsed: FallbackPolicy = serde_json::from_str(&json).unwrap();
1647 assert_eq!(fp, parsed);
1648 }
1649
1650 #[test]
1653 fn argmin_correctness_deterministic_posterior() {
1654 let m = two_state_matrix();
1655 let certain_good = Posterior::new(vec![1.0, 0.0]).unwrap();
1657 assert_eq!(m.bayes_action(&certain_good), 0);
1658 let certain_bad = Posterior::new(vec![0.0, 1.0]).unwrap();
1660 assert_eq!(m.bayes_action(&certain_bad), 1);
1661 }
1662
1663 #[test]
1664 fn argmin_correctness_breakeven_point() {
1665 let m = two_state_matrix();
1666 let above = Posterior::new(vec![0.71, 0.29]).unwrap();
1670 assert_eq!(m.bayes_action(&above), 0);
1671 let below = Posterior::new(vec![0.69, 0.31]).unwrap();
1673 assert_eq!(m.bayes_action(&below), 1);
1674 }
1675
1676 #[test]
1677 fn argmin_three_state_three_action() {
1678 let m = LossMatrix::new(
1680 vec!["s0".into(), "s1".into(), "s2".into()],
1681 vec!["a0".into(), "a1".into(), "a2".into()],
1682 vec![
1683 1.0, 2.0, 3.0, 3.0, 1.0, 2.0, 2.0, 3.0, 1.0, ],
1687 )
1688 .unwrap();
1689 let uniform = Posterior::uniform(3);
1692 let action = m.bayes_action(&uniform);
1693 assert!(action < 3);
1695 let state1 = Posterior::new(vec![0.0, 1.0, 0.0]).unwrap();
1697 assert_eq!(m.bayes_action(&state1), 1);
1698 let state2 = Posterior::new(vec![0.0, 0.0, 1.0]).unwrap();
1700 assert_eq!(m.bayes_action(&state2), 2);
1701 }
1702
1703 #[test]
1706 fn bayesian_update_hand_computed_three_state() {
1707 let mut p = Posterior::new(vec![0.5, 0.3, 0.2]).unwrap();
1712 p.bayesian_update(&[0.1, 0.6, 0.3]);
1713 let expected = [0.05 / 0.29, 0.18 / 0.29, 0.06 / 0.29];
1714 for (i, &e) in expected.iter().enumerate() {
1715 assert!(
1716 (p.probs()[i] - e).abs() < 1e-10,
1717 "state {i}: got {}, expected {e}",
1718 p.probs()[i]
1719 );
1720 }
1721 }
1722
1723 #[test]
1724 fn bayesian_update_successive_convergence() {
1725 let mut p = Posterior::uniform(3);
1727 for _ in 0..20 {
1728 p.bayesian_update(&[0.9, 0.05, 0.05]);
1729 }
1730 assert!(p.probs()[0] > 0.999);
1731 assert!(p.probs()[1] < 0.001);
1732 assert!(p.probs()[2] < 0.001);
1733 }
1734
1735 #[test]
1738 fn end_to_end_pipeline() {
1739 let contract = TestContract::new();
1740 let mut posterior = Posterior::uniform(2);
1741
1742 for _ in 0..5 {
1744 contract
1745 .update_posterior(&mut posterior, 0)
1746 .expect("update_posterior succeeds in end-to-end pipeline");
1747 }
1748 assert!(posterior.probs()[0] > 0.99);
1749
1750 let ctx = test_ctx(0.95, 200);
1752 let outcome = evaluate(&contract, &posterior, &ctx)
1753 .expect("legacy test invariant: contract action_index in range");
1754 assert!(!outcome.fallback_active);
1755 assert_eq!(outcome.action_name, "continue");
1756 assert!(outcome.expected_loss < 0.01);
1757
1758 let evidence = outcome.audit_entry.to_evidence_ledger();
1760 assert_eq!(evidence.component, "test_contract");
1761 assert_eq!(evidence.action, "continue");
1762 assert!(evidence.is_valid());
1763
1764 for _ in 0..20 {
1766 contract
1767 .update_posterior(&mut posterior, 1)
1768 .expect("update_posterior succeeds in end-to-end pipeline");
1769 }
1770 assert!(posterior.probs()[1] > 0.99);
1771
1772 let ctx2 = test_ctx(0.95, 201);
1774 let outcome2 =
1775 evaluate(&contract, &posterior, &ctx2).expect("legacy test invariant: action in range");
1776 assert_eq!(outcome2.action_name, "stop");
1777 }
1778
1779 #[test]
1782 fn concurrent_decision_safety() {
1783 use std::sync::Arc;
1784 use std::thread;
1785
1786 let contract = Arc::new(TestContract::new());
1787 let results: Vec<_> = (0..10)
1788 .map(|i| {
1789 let c = Arc::clone(&contract);
1790 thread::spawn(move || {
1791 let posterior = Posterior::uniform(2);
1792 let ctx = EvalContext {
1793 calibration_score: 0.9,
1794 e_process: 1.0,
1795 ci_width: 0.1,
1796 decision_id: DecisionId::from_parts(1_700_000_000_000, u128::from(i)),
1797 trace_id: TraceId::from_parts(1_700_000_000_000, u128::from(i)),
1798 ts_unix_ms: 1_700_000_000_000 + i,
1799 };
1800 let outcome = evaluate(c.as_ref(), &posterior, &ctx)
1801 .expect("legacy test invariant: action in range");
1802 assert!(!outcome.action_name.is_empty());
1803 assert_eq!(outcome.expected_losses.len(), 2);
1804 let evidence = outcome.audit_entry.to_evidence_ledger();
1805 assert!(evidence.is_valid());
1806 outcome
1807 })
1808 })
1809 .map(|h| h.join().unwrap())
1810 .collect();
1811 assert_eq!(results.len(), 10);
1812 let actions: std::collections::HashSet<_> =
1814 results.iter().map(|r| r.action_name.clone()).collect();
1815 assert_eq!(
1816 actions.len(),
1817 1,
1818 "all threads should choose the same action"
1819 );
1820 }
1821
1822 #[test]
1825 fn cross_crate_franken_kernel_types() {
1826 let did = DecisionId::from_parts(1_700_000_000_000, 42);
1828 assert_eq!(did.timestamp_ms(), 1_700_000_000_000);
1829 let tid = TraceId::from_parts(1_700_000_000_000, 1);
1830 assert_eq!(tid.timestamp_ms(), 1_700_000_000_000);
1831
1832 let contract = TestContract::new();
1834 let posterior = Posterior::uniform(2);
1835 let ctx = EvalContext {
1836 calibration_score: 0.9,
1837 e_process: 1.0,
1838 ci_width: 0.1,
1839 decision_id: did,
1840 trace_id: tid,
1841 ts_unix_ms: 1_700_000_000_000,
1842 };
1843 let outcome = evaluate(&contract, &posterior, &ctx)
1844 .expect("legacy test invariant: contract action_index in range");
1845 assert_eq!(outcome.audit_entry.decision_id, did);
1846 assert_eq!(outcome.audit_entry.trace_id, tid);
1847 }
1848
1849 #[test]
1852 fn posterior_json_roundtrip() {
1853 let p = Posterior::new(vec![0.25, 0.75]).unwrap();
1854 let json = serde_json::to_string(&p).unwrap();
1855 let parsed: Posterior = serde_json::from_str(&json).unwrap();
1856 assert_eq!(p, parsed);
1857 }
1858
1859 #[test]
1860 fn posterior_json_invalid_value_rejected_at_deserialize() {
1861 let json = r#"{"probs":[-0.1,1.1]}"#;
1862 let err = serde_json::from_str::<Posterior>(json).unwrap_err();
1863 assert!(err.to_string().contains("finite and non-negative"));
1864 }
1865
1866 #[test]
1869 fn loss_matrix_3x3_toml_roundtrip() {
1870 let m = LossMatrix::new(
1871 vec!["s0".into(), "s1".into(), "s2".into()],
1872 vec!["a0".into(), "a1".into(), "a2".into()],
1873 vec![0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8],
1874 )
1875 .unwrap();
1876 let toml_str = toml::to_string(&m).unwrap();
1877 let parsed: LossMatrix = toml::from_str(&toml_str).unwrap();
1878 assert_eq!(m, parsed);
1879 }
1880
1881 #[test]
1884 fn decision_outcome_debug() {
1885 let contract = TestContract::new();
1886 let posterior = Posterior::uniform(2);
1887 let ctx = test_ctx(0.9, 300);
1888 let outcome = evaluate(&contract, &posterior, &ctx)
1889 .expect("legacy test invariant: contract action_index in range");
1890 let dbg = format!("{outcome:?}");
1891 assert!(dbg.contains("DecisionOutcome"));
1892 assert!(dbg.contains("action_name"));
1893 }
1894
1895 #[test]
1898 fn fallback_multiple_triggers_simultaneously() {
1899 let fp = FallbackPolicy::default();
1900 assert!(fp.should_fallback(0.3, 30.0, 0.9));
1902 }
1903
1904 #[test]
1905 fn fallback_no_trigger_at_exact_thresholds() {
1906 let fp = FallbackPolicy::default();
1907 assert!(!fp.should_fallback(0.7, 20.0, 0.5));
1909 }
1910
1911 #[test]
1914 fn posterior_entropy_three_state_uniform() {
1915 let p = Posterior::uniform(3);
1916 assert!((p.entropy() - 3.0_f64.log2()).abs() < 1e-10);
1918 }
1919
1920 #[test]
1921 fn posterior_entropy_single_state() {
1922 let p = Posterior::new(vec![1.0]).unwrap();
1923 assert!((p.entropy()).abs() < 1e-10);
1924 }
1925
1926 #[test]
1929 fn validation_error_is_std_error() {
1930 fn assert_error<E: std::error::Error>() {}
1931 assert_error::<ValidationError>();
1932 }
1933
1934 struct OutOfRangeContract {
1941 actions: Vec<String>,
1942 loss: LossMatrix,
1943 out_of_range_index: usize,
1944 from_fallback: bool,
1945 }
1946
1947 impl DecisionContract for OutOfRangeContract {
1948 fn name(&self) -> &'static str {
1949 "OutOfRange"
1950 }
1951 fn state_space(&self) -> &[String] {
1952 &self.actions
1953 }
1954 fn update_posterior(
1955 &self,
1956 _posterior: &mut Posterior,
1957 _observation: usize,
1958 ) -> Result<(), UpdatePosteriorError> {
1959 Ok(())
1960 }
1961 fn action_set(&self) -> &[String] {
1962 &self.actions
1963 }
1964 fn loss_matrix(&self) -> &LossMatrix {
1965 &self.loss
1966 }
1967 fn fallback_policy(&self) -> &FallbackPolicy {
1968 static P: std::sync::OnceLock<FallbackPolicy> = std::sync::OnceLock::new();
1969 P.get_or_init(|| FallbackPolicy::new(0.0, 1e9, 1.0).expect("test fallback policy"))
1970 }
1971 fn choose_action(&self, _posterior: &Posterior) -> usize {
1972 if self.from_fallback {
1973 0
1974 } else {
1975 self.out_of_range_index
1976 }
1977 }
1978 fn fallback_action(&self) -> usize {
1979 self.out_of_range_index
1980 }
1981 }
1982
1983 #[test]
1984 fn g1pzep_out_of_range_choose_action_returns_err() {
1985 let c = OutOfRangeContract {
1986 actions: vec!["a".to_string(), "b".to_string()],
1987 loss: LossMatrix::new(
1988 vec!["s0".to_string(), "s1".to_string()],
1989 vec!["a0".to_string(), "a1".to_string()],
1990 vec![0.0, 1.0, 1.0, 0.0],
1991 )
1992 .expect("loss"),
1993 out_of_range_index: 99,
1994 from_fallback: false,
1995 };
1996 let posterior = Posterior::new(vec![0.5, 0.5]).expect("posterior");
1997 let ctx = EvalContext {
1998 decision_id: DecisionId::from_raw(0),
1999 trace_id: TraceId::from_raw(0),
2000 calibration_score: 1.0,
2001 e_process: 0.0,
2002 ci_width: 0.1,
2003 ts_unix_ms: 0,
2004 };
2005 let err = evaluate(&c, &posterior, &ctx).expect_err("must reject OOB index");
2006 match err {
2007 ValidationError::ActionIndexOutOfRange {
2008 index,
2009 action_set_len,
2010 from_fallback,
2011 } => {
2012 assert_eq!(index, 99);
2013 assert_eq!(action_set_len, 2);
2014 assert!(!from_fallback);
2015 }
2016 other => panic!("wrong variant: {other:?}"),
2017 }
2018 }
2019
2020 #[test]
2021 fn g1pzep_out_of_range_fallback_action_returns_err() {
2022 struct AlwaysFallback {
2025 actions: Vec<String>,
2026 loss: LossMatrix,
2027 policy: FallbackPolicy,
2028 }
2029 impl DecisionContract for AlwaysFallback {
2030 fn name(&self) -> &'static str {
2031 "AlwaysFallback"
2032 }
2033 fn state_space(&self) -> &[String] {
2034 &self.actions
2035 }
2036 fn update_posterior(
2037 &self,
2038 _posterior: &mut Posterior,
2039 _observation: usize,
2040 ) -> Result<(), UpdatePosteriorError> {
2041 Ok(())
2042 }
2043 fn action_set(&self) -> &[String] {
2044 &self.actions
2045 }
2046 fn loss_matrix(&self) -> &LossMatrix {
2047 &self.loss
2048 }
2049 fn fallback_policy(&self) -> &FallbackPolicy {
2050 &self.policy
2051 }
2052 fn choose_action(&self, _posterior: &Posterior) -> usize {
2053 0
2054 }
2055 fn fallback_action(&self) -> usize {
2056 42 }
2058 }
2059 let c = AlwaysFallback {
2060 actions: vec!["x".to_string(), "y".to_string()],
2061 loss: LossMatrix::new(
2062 vec!["s0".to_string(), "s1".to_string()],
2063 vec!["a0".to_string(), "a1".to_string()],
2064 vec![0.0, 1.0, 1.0, 0.0],
2065 )
2066 .expect("loss"),
2067 policy: FallbackPolicy::new(0.99, 1e9, 1.0).expect("policy"),
2068 };
2069 let posterior = Posterior::new(vec![0.5, 0.5]).expect("posterior");
2070 let ctx = EvalContext {
2071 decision_id: DecisionId::from_raw(0),
2072 trace_id: TraceId::from_raw(0),
2073 calibration_score: 0.0, e_process: 0.0,
2075 ci_width: 0.1,
2076 ts_unix_ms: 0,
2077 };
2078 let err = evaluate(&c, &posterior, &ctx).expect_err("must reject OOB fallback");
2079 match err {
2080 ValidationError::ActionIndexOutOfRange {
2081 index,
2082 action_set_len,
2083 from_fallback,
2084 } => {
2085 assert_eq!(index, 42);
2086 assert_eq!(action_set_len, 2);
2087 assert!(from_fallback, "must report fallback origin");
2088 }
2089 other => panic!("wrong variant: {other:?}"),
2090 }
2091 }
2092
2093 #[test]
2094 fn g1pzep_in_range_action_still_succeeds() {
2095 let c = OutOfRangeContract {
2098 actions: vec!["alpha".to_string(), "beta".to_string()],
2099 loss: LossMatrix::new(
2100 vec!["s0".to_string(), "s1".to_string()],
2101 vec!["alpha".to_string(), "beta".to_string()],
2102 vec![0.0, 1.0, 1.0, 0.0],
2103 )
2104 .expect("loss"),
2105 out_of_range_index: 1,
2106 from_fallback: false,
2107 };
2108 let posterior = Posterior::new(vec![0.5, 0.5]).expect("posterior");
2109 let ctx = EvalContext {
2110 decision_id: DecisionId::from_raw(0),
2111 trace_id: TraceId::from_raw(0),
2112 calibration_score: 1.0,
2113 e_process: 0.0,
2114 ci_width: 0.1,
2115 ts_unix_ms: 0,
2116 };
2117 let outcome = evaluate(&c, &posterior, &ctx).expect("in-range index ok");
2118 assert_eq!(outcome.action_index, 1);
2119 assert_eq!(outcome.action_name, "beta");
2120 }
2121}
2122
2123#[cfg(test)]
2128#[allow(clippy::float_cmp)]
2129mod proptest_tests {
2130 use super::*;
2131 use proptest::prelude::*;
2132
2133 fn arb_posterior(n: usize) -> impl Strategy<Value = Posterior> {
2135 proptest::collection::vec(0.01_f64..=1.0, n).prop_map(|mut v| {
2136 let sum: f64 = v.iter().sum();
2137 for p in &mut v {
2138 *p /= sum;
2139 }
2140 Posterior::new(v).unwrap()
2141 })
2142 }
2143
2144 fn arb_loss_matrix(n_states: usize, n_actions: usize) -> impl Strategy<Value = LossMatrix> {
2146 let states: Vec<String> = (0..n_states).map(|i| format!("s{i}")).collect();
2147 let actions: Vec<String> = (0..n_actions).map(|i| format!("a{i}")).collect();
2148 proptest::collection::vec(0.0_f64..=10.0, n_states * n_actions).prop_map(move |values| {
2149 LossMatrix::new(states.clone(), actions.clone(), values).unwrap()
2150 })
2151 }
2152
2153 proptest! {
2156 #![proptest_config(ProptestConfig::with_cases(10_000))]
2157
2158 #[test]
2159 fn bayes_action_minimizes_expected_loss(
2160 matrix in arb_loss_matrix(3, 3),
2161 posterior in arb_posterior(3),
2162 ) {
2163 let chosen = matrix.bayes_action(&posterior);
2164 let chosen_loss = matrix.expected_loss(&posterior, chosen);
2165 for a in 0..matrix.n_actions() {
2166 let other_loss = matrix.expected_loss(&posterior, a);
2167 prop_assert!(
2168 chosen_loss <= other_loss + 1e-10,
2169 "action {chosen} (loss {chosen_loss}) should be <= action {a} (loss {other_loss})"
2170 );
2171 }
2172 }
2173 }
2174
2175 proptest! {
2176 #![proptest_config(ProptestConfig::with_cases(10_000))]
2177
2178 #[test]
2179 fn bayes_action_minimizes_2x2(
2180 matrix in arb_loss_matrix(2, 2),
2181 posterior in arb_posterior(2),
2182 ) {
2183 let chosen = matrix.bayes_action(&posterior);
2184 let chosen_loss = matrix.expected_loss(&posterior, chosen);
2185 for a in 0..matrix.n_actions() {
2186 prop_assert!(chosen_loss <= matrix.expected_loss(&posterior, a) + 1e-10);
2187 }
2188 }
2189 }
2190
2191 proptest! {
2194 #![proptest_config(ProptestConfig::with_cases(10_000))]
2195
2196 #[test]
2197 fn bayesian_update_preserves_normalization(
2198 prior in arb_posterior(4),
2199 likelihoods in proptest::collection::vec(0.01_f64..=1.0, 4usize),
2200 ) {
2201 let mut p = prior;
2202 p.bayesian_update(&likelihoods);
2203 let sum: f64 = p.probs().iter().sum();
2204 prop_assert!(
2205 (sum - 1.0).abs() < 1e-10,
2206 "posterior sum = {sum}, expected 1.0"
2207 );
2208 for &prob in p.probs() {
2209 prop_assert!(prob >= 0.0, "negative probability: {prob}");
2210 }
2211 }
2212 }
2213
2214 proptest! {
2217 #![proptest_config(ProptestConfig::with_cases(10_000))]
2218
2219 #[test]
2220 fn posterior_all_non_negative_after_update(
2221 prior in arb_posterior(3),
2222 likelihoods in proptest::collection::vec(0.0_f64..=1.0, 3usize),
2223 ) {
2224 let mut p = prior;
2225 let lik_sum: f64 = likelihoods.iter().sum();
2227 if lik_sum > 0.0 {
2228 p.bayesian_update(&likelihoods);
2229 for &prob in p.probs() {
2230 prop_assert!(prob >= 0.0, "negative probability: {prob}");
2231 }
2232 }
2233 }
2234 }
2235
2236 proptest! {
2239 #[test]
2240 fn fallback_policy_serde_roundtrip(
2241 cal in 0.0_f64..=1.0,
2242 e_proc in 0.0_f64..=100.0,
2243 ci in 0.0_f64..=10.0,
2244 ) {
2245 let fp = FallbackPolicy::new(cal, e_proc, ci).unwrap();
2246 let json = serde_json::to_string(&fp).unwrap();
2247 let parsed: FallbackPolicy = serde_json::from_str(&json).unwrap();
2248 prop_assert!((fp.calibration_drift_threshold - parsed.calibration_drift_threshold).abs() < 1e-12);
2250 prop_assert!((fp.e_process_breach_threshold - parsed.e_process_breach_threshold).abs() < 1e-12);
2251 prop_assert!((fp.confidence_width_threshold - parsed.confidence_width_threshold).abs() < 1e-12);
2252 }
2253 }
2254
2255 proptest! {
2258 #[test]
2259 fn loss_matrix_serde_roundtrip(
2260 matrix in arb_loss_matrix(2, 3),
2261 ) {
2262 let json = serde_json::to_string(&matrix).unwrap();
2263 let parsed: LossMatrix = serde_json::from_str(&json).unwrap();
2264 prop_assert_eq!(matrix.state_names(), parsed.state_names());
2265 prop_assert_eq!(matrix.action_names(), parsed.action_names());
2266 for s in 0..matrix.n_states() {
2268 for a in 0..matrix.n_actions() {
2269 prop_assert!((matrix.get(s, a) - parsed.get(s, a)).abs() < 1e-12);
2270 }
2271 }
2272 }
2273 }
2274
2275 proptest! {
2278 #![proptest_config(ProptestConfig::with_cases(10_000))]
2279
2280 #[test]
2281 fn expected_loss_within_loss_range(
2282 matrix in arb_loss_matrix(3, 3),
2283 posterior in arb_posterior(3),
2284 ) {
2285 for a in 0..matrix.n_actions() {
2286 let el = matrix.expected_loss(&posterior, a);
2287 let min_loss = (0..matrix.n_states())
2288 .map(|s| matrix.get(s, a))
2289 .fold(f64::INFINITY, f64::min);
2290 let max_loss = (0..matrix.n_states())
2291 .map(|s| matrix.get(s, a))
2292 .fold(f64::NEG_INFINITY, f64::max);
2293 prop_assert!(
2294 el >= min_loss - 1e-10 && el <= max_loss + 1e-10,
2295 "expected loss {el} outside [{min_loss}, {max_loss}]"
2296 );
2297 }
2298 }
2299 }
2300}