Skip to main content

franken_decision/
lib.rs

1//! Decision Contract schema and runtime for FrankenSuite (bd-3ai21).
2//!
3//! The third leg of the foundation tripod alongside `franken_kernel` (types)
4//! and `franken_evidence` (audit ledger). Every FrankenSuite project that
5//! makes runtime decisions uses this crate's contract schema.
6//!
7//! # Core abstractions
8//!
9//! - [`DecisionContract`] — trait defining state space, actions, losses, and
10//!   posterior updates. Implementable in <50 lines.
11//! - [`LossMatrix`] — non-negative loss values indexed by (state, action),
12//!   serializable to TOML for runtime reconfiguration.
13//! - [`Posterior`] — discrete probability distribution with O(|S|)
14//!   no-allocation Bayesian updates.
15//! - [`FallbackPolicy`] — calibration drift, e-process breach, and
16//!   confidence interval width thresholds.
17//! - [`DecisionAuditEntry`] — links decisions to [`EvidenceLedger`] entries.
18//!
19//! # Example
20//!
21//! ```
22//! use franken_decision::{
23//!     DecisionContract, EvalContext, FallbackPolicy, LossMatrix, Posterior,
24//!     UpdatePosteriorError, evaluate,
25//! };
26//! use franken_kernel::DecisionId;
27//!
28//! // Define a simple 2-state, 2-action contract.
29//! struct MyContract {
30//!     states: Vec<String>,
31//!     actions: Vec<String>,
32//!     losses: LossMatrix,
33//!     policy: FallbackPolicy,
34//! }
35//!
36//! impl DecisionContract for MyContract {
37//!     fn name(&self) -> &'static str { "example" }
38//!     fn state_space(&self) -> &[String] { &self.states }
39//!     fn action_set(&self) -> &[String] { &self.actions }
40//!     fn loss_matrix(&self) -> &LossMatrix { &self.losses }
41//!     fn update_posterior(
42//!         &self,
43//!         posterior: &mut Posterior,
44//!         observation: usize,
45//!     ) -> Result<(), UpdatePosteriorError> {
46//!         let likelihoods = [0.9, 0.1];
47//!         posterior.bayesian_update(&likelihoods);
48//!         Ok(())
49//!     }
50//!     fn choose_action(&self, posterior: &Posterior) -> usize {
51//!         self.losses.bayes_action(posterior)
52//!     }
53//!     fn fallback_action(&self) -> usize { 0 }
54//!     fn fallback_policy(&self) -> &FallbackPolicy { &self.policy }
55//! }
56//!
57//! let contract = MyContract {
58//!     states: vec!["good".into(), "bad".into()],
59//!     actions: vec!["continue".into(), "stop".into()],
60//!     losses: LossMatrix::new(
61//!         vec!["good".into(), "bad".into()],
62//!         vec!["continue".into(), "stop".into()],
63//!         vec![0.0, 0.3, 0.8, 0.1],
64//!     ).unwrap(),
65//!     policy: FallbackPolicy::default(),
66//! };
67//!
68//! let posterior = Posterior::uniform(2);
69//! let decision_id = DecisionId::from_parts(1_700_000_000_000, 42);
70//! let trace_id = franken_kernel::TraceId::from_parts(1_700_000_000_000, 1);
71//!
72//! let ctx = EvalContext {
73//!     calibration_score: 0.9,
74//!     e_process: 0.5,
75//!     ci_width: 0.1,
76//!     decision_id,
77//!     trace_id,
78//!     ts_unix_ms: 1_700_000_000_000,
79//! };
80//! let outcome = evaluate(&contract, &posterior, &ctx).expect("legacy test invariant: contract action_index in range");
81//! assert!(!outcome.fallback_active);
82//! ```
83
84#![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// ---------------------------------------------------------------------------
94// Validation errors
95// ---------------------------------------------------------------------------
96
97/// Validation errors for decision types.
98#[derive(Clone, Debug, PartialEq)]
99pub enum ValidationError {
100    /// Loss matrix contains a non-finite value.
101    InvalidLoss {
102        /// State index of the invalid entry.
103        state: usize,
104        /// Action index of the invalid entry.
105        action: usize,
106        /// The invalid value.
107        value: f64,
108    },
109    /// Loss matrix contains a negative value.
110    NegativeLoss {
111        /// State index of the negative entry.
112        state: usize,
113        /// Action index of the negative entry.
114        action: usize,
115        /// The negative value.
116        value: f64,
117    },
118    /// Loss matrix value count does not match dimensions.
119    DimensionMismatch {
120        /// Expected number of values (states * actions).
121        expected: usize,
122        /// Actual number of values provided.
123        got: usize,
124    },
125    /// Posterior probabilities do not sum to ~1.0.
126    PosteriorNotNormalized {
127        /// Actual sum of the posterior.
128        sum: f64,
129    },
130    /// Posterior contains a negative or non-finite probability.
131    InvalidPosteriorProbability {
132        /// Index of the invalid probability.
133        index: usize,
134        /// The invalid value.
135        value: f64,
136    },
137    /// Posterior length does not match state space size.
138    PosteriorLengthMismatch {
139        /// Expected length.
140        expected: usize,
141        /// Actual length.
142        got: usize,
143    },
144    /// State space or action set is empty.
145    EmptySpace {
146        /// Which space is empty.
147        field: &'static str,
148    },
149    /// Threshold value is out of valid range.
150    ThresholdOutOfRange {
151        /// Which threshold.
152        field: &'static str,
153        /// The invalid value.
154        value: f64,
155    },
156    /// br-asupersync-g1pzep: a `DecisionContract` returned an
157    /// `action_index` outside the bounds of its `action_set()`. This
158    /// is a contract-implementation bug — `choose_action` /
159    /// `fallback_action` must return an index in `0..action_set().len()`.
160    /// Pre-fix the `evaluate()` function indexed into `action_set` with
161    /// the returned value via `[index]`, which panicked the runtime on
162    /// any out-of-bounds value (panic-DoS shape: a malicious or buggy
163    /// contract drops every Cx running through it).
164    ActionIndexOutOfRange {
165        /// The contract-returned index.
166        index: usize,
167        /// The size of the action_set.
168        action_set_len: usize,
169        /// Whether the index came from the fallback path or the
170        /// normal `choose_action` path.
171        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// ---------------------------------------------------------------------------
238// LossMatrix
239// ---------------------------------------------------------------------------
240
241/// A loss matrix indexed by (state, action) pairs.
242///
243/// Stored in row-major order: `values[state * n_actions + action]`.
244/// All values must be non-negative. Serializable to TOML/JSON for
245/// runtime reconfiguration.
246#[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    /// Create a new loss matrix.
273    ///
274    /// `values` must have exactly `state_names.len() * action_names.len()`
275    /// elements, all non-negative. Laid out in row-major order:
276    /// `values[s * n_actions + a]` is the loss for state `s`, action `a`.
277    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    /// Get the loss for a specific (state, action) pair.
324    pub fn get(&self, state: usize, action: usize) -> f64 {
325        self.values[state * self.action_names.len() + action]
326    }
327
328    /// Number of states.
329    pub fn n_states(&self) -> usize {
330        self.state_names.len()
331    }
332
333    /// Number of actions.
334    pub fn n_actions(&self) -> usize {
335        self.action_names.len()
336    }
337
338    /// State labels.
339    pub fn state_names(&self) -> &[String] {
340        &self.state_names
341    }
342
343    /// Action labels.
344    pub fn action_names(&self) -> &[String] {
345        &self.action_names
346    }
347
348    /// Compute expected loss for a specific action given a posterior.
349    ///
350    /// `E[loss|a] = sum_s posterior(s) * loss(s, a)`
351    ///
352    /// # Panics
353    ///
354    /// Panics if `posterior` does not have exactly [`Self::n_states`] entries,
355    /// or if `action` is not less than [`Self::n_actions`]. A longer posterior
356    /// (or an out-of-range `action` at the last state) would index past the loss
357    /// values (OOB in [`Self::get`]); a shorter posterior would silently sum only
358    /// the leading states, and an out-of-range `action` at an earlier state would
359    /// silently read a later state's loss row — both returning a
360    /// plausible-but-wrong expected loss. All are caught here as fail-loud
361    /// precondition violations, matching the crate's existing fail-loud style.
362    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    /// Compute expected losses for all actions as a name-indexed map.
385    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    /// Choose the Bayes-optimal action (minimum expected loss).
394    ///
395    /// Returns the action index. Ties are broken by lowest index.
396    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
407// ---------------------------------------------------------------------------
408// Posterior
409// ---------------------------------------------------------------------------
410
411/// Tolerance for posterior normalization checks.
412const NORMALIZATION_TOLERANCE: f64 = 1e-6;
413
414/// A discrete probability distribution over states.
415///
416/// Supports in-place Bayesian updates in O(|S|) with no allocation.
417#[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    /// Create from explicit probabilities.
439    ///
440    /// Probabilities must sum to ~1.0 (within tolerance) and be non-negative.
441    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    /// Create a uniform prior over `n` states.
455    #[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    /// Probability values (immutable).
462    pub fn probs(&self) -> &[f64] {
463        &self.probs
464    }
465
466    /// Mutable access to probability values for in-place updates.
467    pub fn probs_mut(&mut self) -> &mut [f64] {
468        &mut self.probs
469    }
470
471    /// Number of states in the distribution.
472    pub fn len(&self) -> usize {
473        self.probs.len()
474    }
475
476    /// Whether the distribution is empty.
477    pub fn is_empty(&self) -> bool {
478        self.probs.is_empty()
479    }
480
481    /// Bayesian update: multiply by likelihoods and renormalize.
482    ///
483    /// `likelihoods[s]` = P(observation | state = s).
484    /// Runs in O(|S|) with no allocation.
485    ///
486    /// # Panics
487    ///
488    /// Panics if `likelihoods.len() != self.len()`.
489    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    /// Renormalize probabilities to sum to 1.0.
498    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    /// Shannon entropy: -sum p * log2(p).
508    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    /// Index of the most probable state (MAP estimate).
517    ///
518    /// Ties are broken by lowest index.
519    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// ---------------------------------------------------------------------------
529// FallbackPolicy
530// ---------------------------------------------------------------------------
531
532/// Conditions under which to activate fallback heuristics.
533///
534/// A decision engine should switch to [`DecisionContract::fallback_action`]
535/// when any threshold is breached.
536#[derive(Clone, Debug, Serialize, PartialEq)]
537pub struct FallbackPolicy {
538    /// Activate fallback if calibration score drops below this value.
539    pub calibration_drift_threshold: f64,
540    /// Activate fallback if e-process statistic exceeds this value.
541    pub e_process_breach_threshold: f64,
542    /// Activate fallback if confidence interval width exceeds this value.
543    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    /// Create a new fallback policy.
571    ///
572    /// `calibration_drift_threshold` must be in [0, 1].
573    /// Other thresholds must be non-negative.
574    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    /// Check if fallback should be activated based on current metrics.
607    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// ---------------------------------------------------------------------------
625// DecisionContract trait
626// ---------------------------------------------------------------------------
627
628/// Error returned by [`DecisionContract::update_posterior`] when the input
629/// is structurally invalid.
630///
631/// br-asupersync-u5uhpt: prior versions of `update_posterior` silently
632/// dropped the observation when the posterior length didn't match the
633/// declared state space. This variant surfaces that condition as a typed
634/// error so callers can re-initialise (or fall back) instead of letting a
635/// stale posterior drive subsequent decisions.
636#[derive(Debug, Clone, PartialEq, Eq)]
637pub enum UpdatePosteriorError {
638    /// The supplied posterior does not match the contract's state space.
639    LengthMismatch {
640        /// The state-space cardinality required by the contract.
641        expected: usize,
642        /// The length of the posterior the caller provided.
643        actual: usize,
644    },
645    /// The observation index is outside `0..state_space().len()`.
646    ObservationOutOfRange {
647        /// The observation index supplied by the caller.
648        observation: usize,
649        /// The state-space cardinality.
650        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
674/// A contract defining the decision-making framework for a component.
675///
676/// Implementors define the state space, action set, loss matrix, and
677/// posterior update logic. The [`evaluate`] function orchestrates the
678/// full decision pipeline and produces an auditable outcome.
679pub trait DecisionContract {
680    /// Human-readable contract name (e.g., "scheduler", "load_balancer").
681    fn name(&self) -> &str;
682
683    /// Ordered labels for the state space.
684    fn state_space(&self) -> &[String];
685
686    /// Ordered labels for the action set.
687    fn action_set(&self) -> &[String];
688
689    /// The loss matrix for this contract.
690    fn loss_matrix(&self) -> &LossMatrix;
691
692    /// Update the posterior given an observation at `state_index`.
693    ///
694    /// # Errors
695    ///
696    /// Returns [`UpdatePosteriorError::LengthMismatch`] if the posterior
697    /// length differs from `state_space().len()` and
698    /// [`UpdatePosteriorError::ObservationOutOfRange`] if the observation
699    /// index falls outside the state space (br-asupersync-u5uhpt). A
700    /// failed update leaves `posterior` unchanged so callers can recover
701    /// without observing a partially-applied update.
702    fn update_posterior(
703        &self,
704        posterior: &mut Posterior,
705        state_index: usize,
706    ) -> Result<(), UpdatePosteriorError>;
707
708    /// Choose the optimal action given the current posterior.
709    ///
710    /// Returns an action index into [`action_set`](Self::action_set).
711    fn choose_action(&self, posterior: &Posterior) -> usize;
712
713    /// The fallback action when the model is unreliable.
714    ///
715    /// Returns an action index into [`action_set`](Self::action_set).
716    fn fallback_action(&self) -> usize;
717
718    /// Policy governing fallback activation.
719    fn fallback_policy(&self) -> &FallbackPolicy;
720}
721
722// ---------------------------------------------------------------------------
723// DecisionAuditEntry
724// ---------------------------------------------------------------------------
725
726/// Structured audit record linking a decision to the evidence ledger.
727///
728/// Captures the full context of a runtime decision for offline analysis
729/// and replay.
730#[derive(Clone, Debug, Serialize, Deserialize)]
731pub struct DecisionAuditEntry {
732    /// Unique identifier for this decision.
733    pub decision_id: DecisionId,
734    /// Trace context for distributed tracing.
735    pub trace_id: TraceId,
736    /// Name of the decision contract that was evaluated.
737    pub contract_name: String,
738    /// The action that was chosen.
739    pub action_chosen: String,
740    /// Expected loss of the chosen action.
741    pub expected_loss: f64,
742    /// Current calibration score at decision time.
743    pub calibration_score: f64,
744    /// Whether the fallback heuristic was active.
745    pub fallback_active: bool,
746    /// Snapshot of the posterior at decision time.
747    pub posterior_snapshot: Vec<f64>,
748    /// Expected loss for each candidate action.
749    pub expected_loss_by_action: BTreeMap<String, f64>,
750    /// Unix timestamp in milliseconds.
751    pub ts_unix_ms: u64,
752}
753
754/// Sanitize a posterior snapshot to the evidence-ledger invariants: non-empty,
755/// finite, non-negative, and summing to ~1.0. A decision whose posterior
756/// collapsed (empty / all-zero / non-finite — which `Posterior::probs()` can
757/// yield for an ill-conditioned posterior) would otherwise make the diagnostic
758/// [`DecisionAuditEntry::to_evidence_ledger`] fail `EvidenceLedger::validate`
759/// (as `PosteriorNotNormalized`) and abort the runtime; sanitizing keeps the
760/// audit a faithful-but-valid record instead of a panic.
761fn 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        // Empty / all-zero / non-finite: uniform over the same state count
770        // (min length 1 so the snapshot is never empty). State counts are far
771        // below 2^52, so the count-to-f64 conversion is exact.
772        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
779/// Clamp a loss to the evidence-ledger invariant (finite, non-negative).
780fn 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    /// Convert to an [`EvidenceLedger`] entry for structured tracing.
790    ///
791    /// The evidence ledger is a *diagnostic* record and must never panic. A
792    /// decision whose posterior collapsed (an all-zero snapshot sums to `0`,
793    /// which `EvidenceLedger::validate` rejects as `PosteriorNotNormalized`)
794    /// previously aborted the runtime here via `.expect()`. We now sanitize the
795    /// fields to the ledger's invariants — a normalized/finite/non-negative
796    /// posterior, a calibration score clamped to `[0, 1]`, finite non-negative
797    /// losses whose chosen entry matches `chosen_expected_loss` — so the build
798    /// always succeeds; a residual failure degrades to a minimal valid entry
799    /// rather than panicking.
800    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            // The chosen action's mapped loss must equal `chosen_expected_loss`
813            // (the ledger validates this); force agreement, sanitize the rest.
814            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        // Guarantee the chosen action is present in the loss map with the
822        // matching loss even if the caller omitted it.
823        builder = builder.expected_loss(&self.action_chosen, chosen_loss);
824
825        builder.build().unwrap_or_else(|_| {
826            // Provably-valid minimal fallback (never fails validation): a
827            // single-state uniform posterior, mid calibration, zero loss.
828            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// ---------------------------------------------------------------------------
851// DecisionOutcome
852// ---------------------------------------------------------------------------
853
854/// Result of evaluating a decision contract.
855#[derive(Clone, Debug)]
856pub struct DecisionOutcome {
857    /// Index of the chosen action.
858    pub action_index: usize,
859    /// Name of the chosen action.
860    pub action_name: String,
861    /// Expected loss of the chosen action.
862    pub expected_loss: f64,
863    /// Expected losses for all candidate actions.
864    pub expected_losses: BTreeMap<String, f64>,
865    /// Whether fallback was activated.
866    pub fallback_active: bool,
867    /// Full audit entry for this decision.
868    pub audit_entry: DecisionAuditEntry,
869}
870
871// ---------------------------------------------------------------------------
872// EvalContext
873// ---------------------------------------------------------------------------
874
875/// Runtime context for a single decision evaluation.
876///
877/// Bundles the monitoring metrics and tracing identifiers needed by
878/// [`evaluate`].
879#[derive(Clone, Debug)]
880pub struct EvalContext {
881    /// Current calibration score.
882    pub calibration_score: f64,
883    /// Current e-process statistic.
884    pub e_process: f64,
885    /// Current confidence interval width.
886    pub ci_width: f64,
887    /// Unique identifier for this decision.
888    pub decision_id: DecisionId,
889    /// Trace context for distributed tracing.
890    pub trace_id: TraceId,
891    /// Unix timestamp in milliseconds.
892    pub ts_unix_ms: u64,
893}
894
895// ---------------------------------------------------------------------------
896// Evaluate
897// ---------------------------------------------------------------------------
898
899/// Evaluate a decision contract and produce a full audit trail.
900///
901/// This is the primary entry point for making auditable decisions.
902/// It computes expected losses, checks fallback conditions, and produces
903/// a [`DecisionOutcome`] with a linked [`DecisionAuditEntry`].
904///
905/// # Errors
906///
907/// br-asupersync-g1pzep: returns
908/// [`ValidationError::ActionIndexOutOfRange`] when the contract's
909/// `choose_action` or `fallback_action` returns an index that is not a
910/// valid offset into the contract's `action_set()`. Pre-fix, this
911/// shape panicked the runtime via the unchecked `[index]` indexing —
912/// a malicious or buggy contract could drop every Cx that ran through
913/// it. The fail-closed surface returns the offending index, the
914/// observed action_set length, and which path produced the bad index
915/// so operators can attribute the contract bug.
916pub 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    // br-asupersync-g1pzep: bounds-check the contract-returned index
937    // BEFORE indexing into the action_set. The action_set is captured
938    // by reference here so we can borrow the resolved name without
939    // re-borrowing inside the index expression.
940    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// ---------------------------------------------------------------------------
975// Tests
976// ---------------------------------------------------------------------------
977
978#[cfg(test)]
979#[allow(clippy::float_cmp)]
980mod tests {
981    use super::*;
982
983    // -- Helpers --
984
985    fn two_state_matrix() -> LossMatrix {
986        // States: [good, bad], Actions: [continue, stop]
987        // loss(good, continue) = 0.0, loss(good, stop) = 0.3
988        // loss(bad, continue)  = 0.8, loss(bad, stop)  = 0.1
989        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            // Simple likelihood model: observed state gets high likelihood.
1047            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 // "continue"
1057        }
1058        fn fallback_policy(&self) -> &FallbackPolicy {
1059            &self.policy
1060        }
1061    }
1062
1063    // -- LossMatrix tests --
1064
1065    #[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], // needs 2 values
1104        )
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        // E[loss|continue] = 0.8*0.0 + 0.2*0.8 = 0.16
1146        let el_continue = m.expected_loss(&posterior, 0);
1147        assert!((el_continue - 0.16).abs() < 1e-10);
1148        // E[loss|stop] = 0.8*0.3 + 0.2*0.1 = 0.26
1149        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        // A 3-state matrix with a 2-entry posterior previously summed only the
1157        // leading states and returned a plausible-but-wrong value with no error.
1158        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        // A 2-state matrix with a 3-entry posterior previously panicked OOB in
1172        // get(); now it fails with a clear dimension message instead.
1173        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        // A 2-action matrix queried with action index 2: previously read a later
1182        // state's loss row (silent wrong value) or panicked OOB; now fails with a
1183        // clear action-range message.
1184        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        // When mostly good, continue is optimal.
1193        let mostly_good = Posterior::new(vec![0.9, 0.1]).unwrap();
1194        assert_eq!(m.bayes_action(&mostly_good), 0); // continue
1195        // When mostly bad, stop is optimal.
1196        let mostly_bad = Posterior::new(vec![0.2, 0.8]).unwrap();
1197        assert_eq!(m.bayes_action(&mostly_bad), 1); // stop
1198    }
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    // -- Posterior tests --
1241
1242    #[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        // Likelihood: state 0 very likely given observation.
1294        p.bayesian_update(&[0.9, 0.1]);
1295        // After update: p(0) = 0.5*0.9 / (0.5*0.9 + 0.5*0.1) = 0.9
1296        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        // Verify the update works in-place by checking pointer stability.
1303        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        // Uniform over 2 states: entropy = 1.0 bit.
1313        let p = Posterior::uniform(2);
1314        assert!((p.entropy() - 1.0).abs() < 1e-10);
1315        // Deterministic: entropy = 0.
1316        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    // -- FallbackPolicy tests --
1343
1344    #[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)); // cal < 0.7
1433        assert!(!fp.should_fallback(0.9, 1.0, 0.1)); // cal OK
1434    }
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)); // e_process > 20
1440        assert!(!fp.should_fallback(0.9, 15.0, 0.1)); // e_process OK
1441    }
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)); // ci > 0.5
1447        assert!(!fp.should_fallback(0.9, 1.0, 0.3)); // ci OK
1448    }
1449
1450    // -- DecisionContract + evaluate tests --
1451
1452    #[test]
1453    fn contract_implementable_under_50_lines() {
1454        // The TestContract impl above is 22 lines — well under 50.
1455        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"); // low loss when mostly good
1483        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); // low calibration triggers fallback
1493
1494        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"); // fallback action = 0
1499        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); // good calibration, no fallback
1507
1508        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"); // optimal when mostly bad
1513    }
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    // -- DecisionAuditEntry → EvidenceLedger --
1534
1535    #[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        // Regression: a collapsed (all-zero) posterior snapshot plus a `-0.0`
1557        // chosen loss — the shape produced on native-only backend routing —
1558        // must NOT abort the runtime via `.expect()`. The conversion sanitizes
1559        // to a valid, normalized ledger instead of panicking.
1560        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]; // collapsed / all-zero
1567        audit.expected_loss = -0.0;
1568
1569        let ledger = audit.to_evidence_ledger(); // must not panic
1570        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    // -- Update posterior via contract --
1594
1595    #[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"); // observe "good"
1602        // After update: state 0 should be more probable.
1603        assert!(posterior.probs()[0] > posterior.probs()[1]);
1604    }
1605
1606    // -- Validation error display --
1607
1608    #[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    // -- FallbackPolicy serde --
1633
1634    #[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    // -- argmin correctness with known posteriors --
1651
1652    #[test]
1653    fn argmin_correctness_deterministic_posterior() {
1654        let m = two_state_matrix();
1655        // Fully certain state=good: E[continue]=0.0, E[stop]=0.3 → continue wins.
1656        let certain_good = Posterior::new(vec![1.0, 0.0]).unwrap();
1657        assert_eq!(m.bayes_action(&certain_good), 0);
1658        // Fully certain state=bad: E[continue]=0.8, E[stop]=0.1 → stop wins.
1659        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        // Find crossover: at p(good)=x, E[continue]=0.8(1-x) and E[stop]=0.3x+0.1(1-x).
1667        // Crossover: 0.8-0.8x = 0.3x+0.1-0.1x → 0.8-0.8x = 0.2x+0.1 → 0.7=x → x=0.7
1668        // At p(good)=0.71, continue is better.
1669        let above = Posterior::new(vec![0.71, 0.29]).unwrap();
1670        assert_eq!(m.bayes_action(&above), 0);
1671        // At p(good)=0.69, stop is better.
1672        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        // 3 states, 3 actions: verify argmin in a bigger space.
1679        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, // state 0
1684                3.0, 1.0, 2.0, // state 1
1685                2.0, 3.0, 1.0, // state 2
1686            ],
1687        )
1688        .unwrap();
1689        // Uniform posterior: E[a0]=2.0, E[a1]=2.0, E[a2]=2.0 → all tied.
1690        // Rust's min_by returns the last equal element, so index 2.
1691        let uniform = Posterior::uniform(3);
1692        let action = m.bayes_action(&uniform);
1693        // Any action is valid since all expected losses are equal.
1694        assert!(action < 3);
1695        // Posterior concentrated on state 1: a1 has loss 1.0 → a1 wins.
1696        let state1 = Posterior::new(vec![0.0, 1.0, 0.0]).unwrap();
1697        assert_eq!(m.bayes_action(&state1), 1);
1698        // Posterior concentrated on state 2: a2 has loss 1.0 → a2 wins.
1699        let state2 = Posterior::new(vec![0.0, 0.0, 1.0]).unwrap();
1700        assert_eq!(m.bayes_action(&state2), 2);
1701    }
1702
1703    // -- Bayesian update hand-computed --
1704
1705    #[test]
1706    fn bayesian_update_hand_computed_three_state() {
1707        // Prior: [0.5, 0.3, 0.2]
1708        // Likelihoods: [0.1, 0.6, 0.3]
1709        // Unnorm: [0.05, 0.18, 0.06]  sum=0.29
1710        // Posterior: [0.05/0.29, 0.18/0.29, 0.06/0.29]
1711        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        // Repeated observations of state 0 should drive posterior toward certainty.
1726        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    // -- End-to-end decision pipeline --
1736
1737    #[test]
1738    fn end_to_end_pipeline() {
1739        let contract = TestContract::new();
1740        let mut posterior = Posterior::uniform(2);
1741
1742        // Feed 5 "good" observations: posterior should shift toward state 0.
1743        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        // Make a decision: should be "continue" (low loss when good).
1751        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        // Verify evidence ledger entry.
1759        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        // Now feed "bad" observations to shift posterior.
1765        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        // Decision should now be "stop".
1773        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    // -- Concurrent decision safety --
1780
1781    #[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        // All should agree on the same action for uniform posterior.
1813        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    // -- Cross-crate type verification --
1823
1824    #[test]
1825    fn cross_crate_franken_kernel_types() {
1826        // Verify DecisionId and TraceId are the franken_kernel versions.
1827        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        // Verify they work correctly in DecisionAuditEntry.
1833        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    // -- Posterior serde roundtrips --
1850
1851    #[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    // -- LossMatrix 3x3 TOML --
1867
1868    #[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    // -- DecisionOutcome debug --
1882
1883    #[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    // -- Fallback all three triggers --
1896
1897    #[test]
1898    fn fallback_multiple_triggers_simultaneously() {
1899        let fp = FallbackPolicy::default();
1900        // All three conditions breached simultaneously.
1901        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        // Exactly at thresholds: cal=0.7 (not < 0.7), e=20 (not > 20), ci=0.5 (not > 0.5).
1908        assert!(!fp.should_fallback(0.7, 20.0, 0.5));
1909    }
1910
1911    // -- Entropy edge cases --
1912
1913    #[test]
1914    fn posterior_entropy_three_state_uniform() {
1915        let p = Posterior::uniform(3);
1916        // entropy = log2(3) ≈ 1.585
1917        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    // -- ValidationError is std::error::Error --
1927
1928    #[test]
1929    fn validation_error_is_std_error() {
1930        fn assert_error<E: std::error::Error>() {}
1931        assert_error::<ValidationError>();
1932    }
1933
1934    // ====================================================================
1935    // br-asupersync-g1pzep: bounds-check on contract action_index.
1936    // ====================================================================
1937
1938    /// Test contract that returns an out-of-range action_index. Used
1939    /// to drive the panic-DoS surface that the bounds check fixes.
1940    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        // Fallback path: drive the policy to fire by setting
2023        // calibration_score below the threshold.
2024        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 // out of range — action_set has only 2 entries
2057            }
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, // below 0.99 threshold → fallback fires
2074            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        // Sanity: legitimate in-range index still produces an Ok
2096        // outcome with the named action.
2097        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// ---------------------------------------------------------------------------
2124// Property-based tests (proptest)
2125// ---------------------------------------------------------------------------
2126
2127#[cfg(test)]
2128#[allow(clippy::float_cmp)]
2129mod proptest_tests {
2130    use super::*;
2131    use proptest::prelude::*;
2132
2133    /// Generate a valid probability vector of length `n`.
2134    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    /// Generate a valid loss matrix of given dimensions.
2145    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    // -- Argmin: chosen action minimizes expected loss for any valid posterior --
2154
2155    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    // -- Posterior update preserves normalization --
2192
2193    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    // -- Posterior: all elements non-negative after update --
2215
2216    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            // Only update if likelihoods have positive sum (avoid degenerate case).
2226            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    // -- FallbackPolicy serde roundtrip --
2237
2238    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            // Use approximate comparison due to f64 JSON round-trip precision.
2249            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    // -- LossMatrix serde roundtrip --
2256
2257    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            // Use approximate comparison for f64 values.
2267            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    // -- Expected loss is a convex combination --
2276
2277    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}