Skip to main content

franken_evidence/
lib.rs

1//! Canonical EvidenceLedger schema for FrankenSuite decision tracing (bd-qaaxt.1).
2//!
3//! Every FrankenSuite decision produces an [`EvidenceLedger`] entry explaining
4//! *what* was decided, *why*, and *how confident* the system was.  All
5//! FrankenSuite projects import this crate — no forking allowed.
6//!
7//! # Schema
8//!
9//! ```text
10//! EvidenceLedger
11//! ├── ts_unix_ms          : u64       (millisecond timestamp)
12//! ├── component           : String    (producing subsystem)
13//! ├── action              : String    (decision taken)
14//! ├── posterior            : Vec<f64>  (probability distribution, sums to ~1.0)
15//! ├── expected_loss_by_action : BTreeMap<String, f64>  (loss per candidate action)
16//! ├── chosen_expected_loss : f64      (loss of the selected action)
17//! ├── calibration_score   : f64       (calibration quality, [0, 1])
18//! ├── fallback_active     : bool      (true if fallback heuristic fired)
19//! └── top_features        : Vec<(String, f64)>  (most influential features)
20//! ```
21//!
22//! # Builder
23//!
24//! ```
25//! use franken_evidence::EvidenceLedgerBuilder;
26//!
27//! let entry = EvidenceLedgerBuilder::new()
28//!     .ts_unix_ms(1700000000000)
29//!     .component("scheduler")
30//!     .action("preempt")
31//!     .posterior(vec![0.7, 0.2, 0.1])
32//!     .expected_loss("preempt", 0.05)
33//!     .expected_loss("continue", 0.3)
34//!     .expected_loss("defer", 0.15)
35//!     .chosen_expected_loss(0.05)
36//!     .calibration_score(0.92)
37//!     .fallback_active(false)
38//!     .top_feature("queue_depth", 0.45)
39//!     .top_feature("priority_gap", 0.30)
40//!     .build()
41//!     .expect("valid entry");
42//! ```
43
44#![forbid(unsafe_code)]
45
46pub mod export;
47pub mod render;
48
49use std::collections::BTreeMap;
50use std::fmt;
51
52use serde::{Deserialize, Deserializer, Serialize};
53
54// ---------------------------------------------------------------------------
55// Core struct
56// ---------------------------------------------------------------------------
57
58/// A single evidence-ledger entry recording a FrankenSuite decision.
59///
60/// All fields use short serde names for compact JSONL serialization.
61#[derive(Clone, Debug, Serialize, PartialEq)]
62pub struct EvidenceLedger {
63    /// Millisecond Unix timestamp of the decision.
64    #[serde(rename = "ts")]
65    pub ts_unix_ms: u64,
66
67    /// Subsystem that produced the evidence (e.g. "scheduler", "supervisor").
68    #[serde(rename = "c")]
69    pub component: String,
70
71    /// Action that was chosen (e.g. "preempt", "restart").
72    #[serde(rename = "a")]
73    pub action: String,
74
75    /// Posterior probability distribution over candidate outcomes.
76    /// Must sum to approximately 1.0 (tolerance: 1e-6).
77    #[serde(rename = "p")]
78    pub posterior: Vec<f64>,
79
80    /// Expected loss for each candidate action.
81    #[serde(rename = "el")]
82    pub expected_loss_by_action: BTreeMap<String, f64>,
83
84    /// Expected loss of the *chosen* action.
85    #[serde(rename = "cel")]
86    pub chosen_expected_loss: f64,
87
88    /// Calibration quality score in [0, 1].
89    /// 1.0 = perfectly calibrated predictions.
90    #[serde(rename = "cal")]
91    pub calibration_score: f64,
92
93    /// Whether a fallback heuristic was used instead of the primary model.
94    #[serde(rename = "fb")]
95    pub fallback_active: bool,
96
97    /// Most influential features for this decision, sorted by importance.
98    #[serde(rename = "tf")]
99    pub top_features: Vec<(String, f64)>,
100}
101
102#[derive(Deserialize)]
103struct EvidenceLedgerRepr {
104    #[serde(rename = "ts")]
105    ts_unix_ms: u64,
106    #[serde(rename = "c")]
107    component: String,
108    #[serde(rename = "a")]
109    action: String,
110    #[serde(rename = "p")]
111    posterior: Vec<f64>,
112    #[serde(rename = "el")]
113    expected_loss_by_action: BTreeMap<String, f64>,
114    #[serde(rename = "cel")]
115    chosen_expected_loss: f64,
116    #[serde(rename = "cal")]
117    calibration_score: f64,
118    #[serde(rename = "fb")]
119    fallback_active: bool,
120    #[serde(rename = "tf")]
121    top_features: Vec<(String, f64)>,
122}
123
124impl From<EvidenceLedgerRepr> for EvidenceLedger {
125    fn from(repr: EvidenceLedgerRepr) -> Self {
126        Self {
127            ts_unix_ms: repr.ts_unix_ms,
128            component: repr.component,
129            action: repr.action,
130            posterior: repr.posterior,
131            expected_loss_by_action: repr.expected_loss_by_action,
132            chosen_expected_loss: repr.chosen_expected_loss,
133            calibration_score: repr.calibration_score,
134            fallback_active: repr.fallback_active,
135            top_features: repr.top_features,
136        }
137    }
138}
139
140impl<'de> Deserialize<'de> for EvidenceLedger {
141    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
142    where
143        D: Deserializer<'de>,
144    {
145        let entry = Self::from(EvidenceLedgerRepr::deserialize(deserializer)?);
146        let errors = entry.validate();
147        if errors.is_empty() {
148            Ok(entry)
149        } else {
150            Err(serde::de::Error::custom(format!(
151                "invalid evidence ledger: {}",
152                errors
153                    .iter()
154                    .map(std::string::ToString::to_string)
155                    .collect::<Vec<_>>()
156                    .join("; ")
157            )))
158        }
159    }
160}
161
162// ---------------------------------------------------------------------------
163// Validation
164// ---------------------------------------------------------------------------
165
166/// Validation error for an [`EvidenceLedger`] entry.
167#[derive(Clone, Debug, PartialEq)]
168pub enum ValidationError {
169    /// `posterior` does not sum to ~1.0. Contains the actual sum.
170    PosteriorNotNormalized {
171        /// Actual sum of the posterior vector.
172        sum: f64,
173    },
174    /// `posterior` is empty.
175    PosteriorEmpty,
176    /// `posterior` contains a negative or non-finite probability.
177    InvalidPosteriorProbability {
178        /// Index of the invalid probability.
179        index: usize,
180        /// The invalid probability value.
181        value: f64,
182    },
183    /// An expected-loss value is non-finite.
184    InvalidExpectedLoss {
185        /// The action whose loss is invalid.
186        action: String,
187        /// The invalid loss value.
188        value: f64,
189    },
190    /// `calibration_score` is outside [0, 1].
191    CalibrationOutOfRange {
192        /// The out-of-range value.
193        value: f64,
194    },
195    /// An expected-loss value is negative.
196    NegativeExpectedLoss {
197        /// The action whose loss is negative.
198        action: String,
199        /// The negative loss value.
200        value: f64,
201    },
202    /// `chosen_expected_loss` is negative.
203    NegativeChosenExpectedLoss {
204        /// The negative loss value.
205        value: f64,
206    },
207    /// `chosen_expected_loss` is non-finite.
208    InvalidChosenExpectedLoss {
209        /// The invalid loss value.
210        value: f64,
211    },
212    /// `expected_loss_by_action` is populated but does not include the chosen action.
213    ChosenActionMissingExpectedLoss {
214        /// The chosen action that is missing from the map.
215        action: String,
216    },
217    /// `chosen_expected_loss` disagrees with the chosen action's mapped loss.
218    ChosenExpectedLossMismatch {
219        /// The chosen action whose loss disagrees.
220        action: String,
221        /// The value recorded in `chosen_expected_loss`.
222        chosen: f64,
223        /// The value recorded in `expected_loss_by_action`.
224        mapped: f64,
225    },
226    /// A `top_features` weight is non-finite.
227    ///
228    /// Non-finite weights must be rejected before an entry is content-hashed:
229    /// `serde_json` serializes NaN/Infinity as `null`, so two distinct
230    /// non-finite entries would otherwise collapse onto the same
231    /// `artifact_hash` (FrankenEngine bd-zjkyu).
232    InvalidTopFeatureWeight {
233        /// The feature whose weight is non-finite.
234        name: String,
235        /// The non-finite weight value.
236        value: f64,
237    },
238    /// `component` is empty.
239    EmptyComponent,
240    /// `action` is empty.
241    EmptyAction,
242}
243
244impl fmt::Display for ValidationError {
245    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246        match self {
247            Self::PosteriorNotNormalized { sum } => {
248                write!(f, "posterior sums to {sum}, expected ~1.0")
249            }
250            Self::PosteriorEmpty => write!(f, "posterior must not be empty"),
251            Self::InvalidPosteriorProbability { index, value } => {
252                write!(
253                    f,
254                    "posterior[{index}] must be finite and non-negative, got {value}"
255                )
256            }
257            Self::InvalidExpectedLoss { action, value } => {
258                write!(
259                    f,
260                    "expected_loss for '{action}' must be finite, got {value}"
261                )
262            }
263            Self::CalibrationOutOfRange { value } => {
264                write!(f, "calibration_score {value} not in [0, 1]")
265            }
266            Self::NegativeExpectedLoss { action, value } => {
267                write!(f, "expected_loss for '{action}' is negative: {value}")
268            }
269            Self::NegativeChosenExpectedLoss { value } => {
270                write!(f, "chosen_expected_loss is negative: {value}")
271            }
272            Self::InvalidChosenExpectedLoss { value } => {
273                write!(f, "chosen_expected_loss must be finite, got {value}")
274            }
275            Self::ChosenActionMissingExpectedLoss { action } => {
276                write!(
277                    f,
278                    "expected_loss_by_action is missing the chosen action '{action}'"
279                )
280            }
281            Self::ChosenExpectedLossMismatch {
282                action,
283                chosen,
284                mapped,
285            } => {
286                write!(
287                    f,
288                    "chosen_expected_loss {chosen} disagrees with expected_loss_by_action['{action}']={mapped}"
289                )
290            }
291            Self::InvalidTopFeatureWeight { name, value } => {
292                write!(
293                    f,
294                    "top_feature weight for '{name}' must be finite, got {value}"
295                )
296            }
297            Self::EmptyComponent => write!(f, "component must not be empty"),
298            Self::EmptyAction => write!(f, "action must not be empty"),
299        }
300    }
301}
302
303impl std::error::Error for ValidationError {}
304
305impl EvidenceLedger {
306    /// Validate all invariants and return any violations.
307    ///
308    /// - `posterior` must be non-empty and sum to ~1.0 (tolerance 1e-6).
309    /// - Posterior entries must be finite and non-negative.
310    /// - `calibration_score` must be in [0, 1].
311    /// - All expected losses must be finite and non-negative.
312    /// - `component` and `action` must be non-empty.
313    /// - All `top_features` weights must be finite (`serde_json` maps
314    ///   NaN/Infinity to `null`, which would collapse distinct entries onto
315    ///   one content hash — FrankenEngine bd-zjkyu).
316    pub fn validate(&self) -> Vec<ValidationError> {
317        let mut errors = Vec::new();
318
319        if self.component.is_empty() {
320            errors.push(ValidationError::EmptyComponent);
321        }
322        if self.action.is_empty() {
323            errors.push(ValidationError::EmptyAction);
324        }
325
326        if self.posterior.is_empty() {
327            errors.push(ValidationError::PosteriorEmpty);
328        } else {
329            let mut posterior_has_invalid_entry = false;
330            for (index, &value) in self.posterior.iter().enumerate() {
331                if !value.is_finite() || value < 0.0 {
332                    errors.push(ValidationError::InvalidPosteriorProbability { index, value });
333                    posterior_has_invalid_entry = true;
334                }
335            }
336            if !posterior_has_invalid_entry {
337                let sum: f64 = self.posterior.iter().sum();
338                if (sum - 1.0).abs() > 1e-6 {
339                    errors.push(ValidationError::PosteriorNotNormalized { sum });
340                }
341            }
342        }
343
344        if !(0.0..=1.0).contains(&self.calibration_score) {
345            errors.push(ValidationError::CalibrationOutOfRange {
346                value: self.calibration_score,
347            });
348        }
349
350        let chosen_expected_loss_valid = if !self.chosen_expected_loss.is_finite() {
351            errors.push(ValidationError::InvalidChosenExpectedLoss {
352                value: self.chosen_expected_loss,
353            });
354            false
355        } else if self.chosen_expected_loss < 0.0 {
356            errors.push(ValidationError::NegativeChosenExpectedLoss {
357                value: self.chosen_expected_loss,
358            });
359            false
360        } else {
361            true
362        };
363
364        for (action, &loss) in &self.expected_loss_by_action {
365            if !loss.is_finite() {
366                errors.push(ValidationError::InvalidExpectedLoss {
367                    action: action.clone(),
368                    value: loss,
369                });
370            } else if loss < 0.0 {
371                errors.push(ValidationError::NegativeExpectedLoss {
372                    action: action.clone(),
373                    value: loss,
374                });
375            }
376        }
377
378        for (name, weight) in &self.top_features {
379            if !weight.is_finite() {
380                errors.push(ValidationError::InvalidTopFeatureWeight {
381                    name: name.clone(),
382                    value: *weight,
383                });
384            }
385        }
386
387        if let Some(&mapped) = self.expected_loss_by_action.get(&self.action) {
388            if chosen_expected_loss_valid
389                && mapped.is_finite()
390                && mapped >= 0.0
391                && (mapped - self.chosen_expected_loss).abs() > 1e-12
392            {
393                errors.push(ValidationError::ChosenExpectedLossMismatch {
394                    action: self.action.clone(),
395                    chosen: self.chosen_expected_loss,
396                    mapped,
397                });
398            }
399        } else if !self.expected_loss_by_action.is_empty() {
400            errors.push(ValidationError::ChosenActionMissingExpectedLoss {
401                action: self.action.clone(),
402            });
403        }
404
405        errors
406    }
407
408    /// Returns `true` if this entry passes all validation checks.
409    pub fn is_valid(&self) -> bool {
410        self.validate().is_empty()
411    }
412}
413
414// ---------------------------------------------------------------------------
415// Builder
416// ---------------------------------------------------------------------------
417
418/// Builder error returned when a required field is missing.
419#[derive(Clone, Debug, PartialEq)]
420pub enum BuilderError {
421    /// A required field was not set.
422    MissingField {
423        /// Name of the missing field.
424        field: &'static str,
425    },
426    /// The constructed entry failed validation.
427    Validation(Vec<ValidationError>),
428}
429
430impl fmt::Display for BuilderError {
431    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432        match self {
433            Self::MissingField { field } => {
434                write!(f, "EvidenceLedger builder missing required field: {field}")
435            }
436            Self::Validation(errors) => {
437                write!(f, "EvidenceLedger validation failed: ")?;
438                for (i, e) in errors.iter().enumerate() {
439                    if i > 0 {
440                        write!(f, "; ")?;
441                    }
442                    write!(f, "{e}")?;
443                }
444                Ok(())
445            }
446        }
447    }
448}
449
450impl std::error::Error for BuilderError {}
451
452/// Ergonomic builder for [`EvidenceLedger`] entries.
453///
454/// All fields except `fallback_active` (defaults to `false`) are required.
455#[derive(Clone, Debug, Default)]
456#[must_use]
457pub struct EvidenceLedgerBuilder {
458    ts_unix_ms: Option<u64>,
459    component: Option<String>,
460    action: Option<String>,
461    posterior: Option<Vec<f64>>,
462    expected_loss_by_action: BTreeMap<String, f64>,
463    chosen_expected_loss: Option<f64>,
464    calibration_score: Option<f64>,
465    fallback_active: bool,
466    top_features: Vec<(String, f64)>,
467}
468
469impl EvidenceLedgerBuilder {
470    /// Create a new builder with all fields unset.
471    pub fn new() -> Self {
472        Self::default()
473    }
474
475    /// Set the millisecond Unix timestamp.
476    pub fn ts_unix_ms(mut self, ts: u64) -> Self {
477        self.ts_unix_ms = Some(ts);
478        self
479    }
480
481    /// Set the producing component/subsystem name.
482    pub fn component(mut self, component: impl Into<String>) -> Self {
483        self.component = Some(component.into());
484        self
485    }
486
487    /// Set the chosen action.
488    pub fn action(mut self, action: impl Into<String>) -> Self {
489        self.action = Some(action.into());
490        self
491    }
492
493    /// Set the posterior probability distribution.
494    pub fn posterior(mut self, posterior: Vec<f64>) -> Self {
495        self.posterior = Some(posterior);
496        self
497    }
498
499    /// Add an expected-loss entry for a candidate action.
500    pub fn expected_loss(mut self, action: impl Into<String>, loss: f64) -> Self {
501        self.expected_loss_by_action.insert(action.into(), loss);
502        self
503    }
504
505    /// Set the expected loss of the chosen action.
506    pub fn chosen_expected_loss(mut self, loss: f64) -> Self {
507        self.chosen_expected_loss = Some(loss);
508        self
509    }
510
511    /// Set the calibration score (must be in [0, 1]).
512    pub fn calibration_score(mut self, score: f64) -> Self {
513        self.calibration_score = Some(score);
514        self
515    }
516
517    /// Set whether the fallback heuristic was active.
518    pub fn fallback_active(mut self, active: bool) -> Self {
519        self.fallback_active = active;
520        self
521    }
522
523    /// Add a top-feature entry (feature name + importance weight).
524    pub fn top_feature(mut self, name: impl Into<String>, weight: f64) -> Self {
525        self.top_features.push((name.into(), weight));
526        self
527    }
528
529    /// Consume the builder and produce a validated [`EvidenceLedger`].
530    ///
531    /// Returns [`BuilderError::MissingField`] if any required field is unset,
532    /// or [`BuilderError::Validation`] if invariants are violated.
533    pub fn build(self) -> Result<EvidenceLedger, BuilderError> {
534        let entry = EvidenceLedger {
535            ts_unix_ms: self.ts_unix_ms.ok_or(BuilderError::MissingField {
536                field: "ts_unix_ms",
537            })?,
538            component: self
539                .component
540                .ok_or(BuilderError::MissingField { field: "component" })?,
541            action: self
542                .action
543                .ok_or(BuilderError::MissingField { field: "action" })?,
544            posterior: self
545                .posterior
546                .ok_or(BuilderError::MissingField { field: "posterior" })?,
547            expected_loss_by_action: self.expected_loss_by_action,
548            chosen_expected_loss: self
549                .chosen_expected_loss
550                .ok_or(BuilderError::MissingField {
551                    field: "chosen_expected_loss",
552                })?,
553            calibration_score: self.calibration_score.ok_or(BuilderError::MissingField {
554                field: "calibration_score",
555            })?,
556            fallback_active: self.fallback_active,
557            top_features: self.top_features,
558        };
559
560        let errors = entry.validate();
561        if errors.is_empty() {
562            Ok(entry)
563        } else {
564            Err(BuilderError::Validation(errors))
565        }
566    }
567}
568
569// ---------------------------------------------------------------------------
570// Tests
571// ---------------------------------------------------------------------------
572
573#[cfg(test)]
574#[allow(clippy::float_cmp)]
575mod tests {
576    use super::*;
577
578    fn valid_builder() -> EvidenceLedgerBuilder {
579        EvidenceLedgerBuilder::new()
580            .ts_unix_ms(1_700_000_000_000)
581            .component("scheduler")
582            .action("preempt")
583            .posterior(vec![0.7, 0.2, 0.1])
584            .expected_loss("preempt", 0.05)
585            .expected_loss("continue", 0.3)
586            .expected_loss("defer", 0.15)
587            .chosen_expected_loss(0.05)
588            .calibration_score(0.92)
589            .fallback_active(false)
590            .top_feature("queue_depth", 0.45)
591            .top_feature("priority_gap", 0.30)
592    }
593
594    fn expect_validation(result: Result<EvidenceLedger, BuilderError>) -> Vec<ValidationError> {
595        match result.unwrap_err() {
596            BuilderError::Validation(errors) => errors,
597            BuilderError::MissingField { field } => {
598                panic!("expected Validation error, got MissingField({field})")
599            }
600        }
601    }
602
603    #[test]
604    fn validate_rejects_non_finite_top_feature_weights() {
605        // serde_json serializes NaN/Infinity as `null`, so two distinct
606        // non-finite entries would collapse onto one content hash if they
607        // reached hashing (FrankenEngine bd-zjkyu).
608        for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
609            let errors = expect_validation(valid_builder().top_feature("poisoned", bad).build());
610            assert!(
611                errors.iter().any(|error| matches!(
612                    error,
613                    ValidationError::InvalidTopFeatureWeight { name, .. }
614                        if name == "poisoned"
615                )),
616                "expected InvalidTopFeatureWeight for {bad}, got {errors:?}"
617            );
618        }
619    }
620
621    #[test]
622    fn validate_accepts_finite_top_feature_weights() {
623        let entry = valid_builder()
624            .top_feature("negative_is_fine", -0.25)
625            .top_feature("zero_is_fine", 0.0)
626            .build()
627            .expect("finite weights of any sign are valid");
628        assert!(entry.is_valid());
629    }
630
631    #[test]
632    fn builder_produces_valid_entry() {
633        let entry = valid_builder().build().expect("should build");
634        assert!(entry.is_valid());
635        assert_eq!(entry.ts_unix_ms, 1_700_000_000_000);
636        assert_eq!(entry.component, "scheduler");
637        assert_eq!(entry.action, "preempt");
638        assert_eq!(entry.posterior, vec![0.7, 0.2, 0.1]);
639        assert!(!entry.fallback_active);
640        assert_eq!(entry.top_features.len(), 2);
641    }
642
643    #[test]
644    fn serde_roundtrip_json() {
645        let entry = valid_builder().build().unwrap();
646        let json = serde_json::to_string(&entry).unwrap();
647        let parsed: EvidenceLedger = serde_json::from_str(&json).unwrap();
648        assert_eq!(entry.ts_unix_ms, parsed.ts_unix_ms);
649        assert_eq!(entry.component, parsed.component);
650        assert_eq!(entry.action, parsed.action);
651        assert_eq!(entry.posterior, parsed.posterior);
652        assert_eq!(entry.calibration_score, parsed.calibration_score);
653        assert_eq!(entry.chosen_expected_loss, parsed.chosen_expected_loss);
654        assert_eq!(entry.fallback_active, parsed.fallback_active);
655        assert_eq!(entry.top_features, parsed.top_features);
656    }
657
658    #[test]
659    fn serde_uses_short_field_names() {
660        let entry = valid_builder().build().unwrap();
661        let json = serde_json::to_string(&entry).unwrap();
662        assert!(json.contains("\"ts\":"));
663        assert!(json.contains("\"c\":"));
664        assert!(json.contains("\"a\":"));
665        assert!(json.contains("\"p\":"));
666        assert!(json.contains("\"el\":"));
667        assert!(json.contains("\"cel\":"));
668        assert!(json.contains("\"cal\":"));
669        assert!(json.contains("\"fb\":"));
670        assert!(json.contains("\"tf\":"));
671        // Must NOT contain long field names.
672        assert!(!json.contains("\"ts_unix_ms\":"));
673        assert!(!json.contains("\"component\":"));
674        assert!(!json.contains("\"posterior\":"));
675    }
676
677    #[test]
678    fn validation_posterior_not_normalized() {
679        let errors = expect_validation(
680            valid_builder()
681                .posterior(vec![0.5, 0.2, 0.1]) // sums to 0.8
682                .build(),
683        );
684        assert!(
685            errors
686                .iter()
687                .any(|e| matches!(e, ValidationError::PosteriorNotNormalized { .. }))
688        );
689    }
690
691    #[test]
692    fn validation_posterior_empty() {
693        let errors = expect_validation(valid_builder().posterior(vec![]).build());
694        assert!(
695            errors
696                .iter()
697                .any(|e| matches!(e, ValidationError::PosteriorEmpty))
698        );
699    }
700
701    #[test]
702    fn validation_negative_posterior_probability() {
703        let errors = expect_validation(valid_builder().posterior(vec![-0.1, 0.2, 0.9]).build());
704        assert!(errors.iter().any(|e| matches!(
705            e,
706            ValidationError::InvalidPosteriorProbability { index: 0, value }
707                if *value == -0.1
708        )));
709    }
710
711    #[test]
712    fn validation_non_finite_posterior_probability() {
713        let errors = expect_validation(valid_builder().posterior(vec![f64::NAN, 0.2, 0.8]).build());
714        assert!(errors.iter().any(|e| matches!(
715            e,
716            ValidationError::InvalidPosteriorProbability { index: 0, value }
717                if value.is_nan()
718        )));
719    }
720
721    #[test]
722    fn validation_calibration_out_of_range() {
723        let errors = expect_validation(valid_builder().calibration_score(1.5).build());
724        assert!(
725            errors
726                .iter()
727                .any(|e| matches!(e, ValidationError::CalibrationOutOfRange { .. }))
728        );
729    }
730
731    #[test]
732    fn validation_negative_expected_loss() {
733        let errors = expect_validation(valid_builder().expected_loss("bad_action", -0.1).build());
734        assert!(
735            errors
736                .iter()
737                .any(|e| matches!(e, ValidationError::NegativeExpectedLoss { .. }))
738        );
739    }
740
741    #[test]
742    fn validation_non_finite_expected_loss() {
743        let errors = expect_validation(
744            valid_builder()
745                .expected_loss("bad_action", f64::NAN)
746                .build(),
747        );
748        assert!(errors.iter().any(|e| matches!(
749            e,
750            ValidationError::InvalidExpectedLoss { action, value }
751                if action == "bad_action" && value.is_nan()
752        )));
753    }
754
755    #[test]
756    fn validation_negative_chosen_expected_loss() {
757        let errors = expect_validation(valid_builder().chosen_expected_loss(-0.01).build());
758        assert!(
759            errors
760                .iter()
761                .any(|e| matches!(e, ValidationError::NegativeChosenExpectedLoss { .. }))
762        );
763    }
764
765    #[test]
766    fn validation_non_finite_chosen_expected_loss() {
767        let errors = expect_validation(valid_builder().chosen_expected_loss(f64::INFINITY).build());
768        assert!(errors.iter().any(|e| matches!(
769            e,
770            ValidationError::InvalidChosenExpectedLoss { value } if value.is_infinite()
771        )));
772    }
773
774    #[test]
775    fn validation_missing_chosen_action_expected_loss() {
776        let errors = expect_validation(valid_builder().action("restart").build());
777        assert!(
778            errors
779                .iter()
780                .any(|e| matches!(e, ValidationError::ChosenActionMissingExpectedLoss { .. }))
781        );
782    }
783
784    #[test]
785    fn validation_chosen_expected_loss_mismatch() {
786        let errors = expect_validation(valid_builder().expected_loss("preempt", 0.20).build());
787        assert!(
788            errors
789                .iter()
790                .any(|e| matches!(e, ValidationError::ChosenExpectedLossMismatch { .. }))
791        );
792    }
793
794    #[test]
795    fn validation_empty_component() {
796        let errors = expect_validation(valid_builder().component("").build());
797        assert!(
798            errors
799                .iter()
800                .any(|e| matches!(e, ValidationError::EmptyComponent))
801        );
802    }
803
804    #[test]
805    fn validation_empty_action() {
806        let errors = expect_validation(valid_builder().action("").build());
807        assert!(
808            errors
809                .iter()
810                .any(|e| matches!(e, ValidationError::EmptyAction))
811        );
812    }
813
814    #[test]
815    fn builder_missing_required_field() {
816        let result = EvidenceLedgerBuilder::new()
817            .component("x")
818            .action("y")
819            .posterior(vec![1.0])
820            .chosen_expected_loss(0.0)
821            .calibration_score(0.5)
822            .build();
823        let err = result.unwrap_err();
824        assert!(matches!(
825            err,
826            BuilderError::MissingField {
827                field: "ts_unix_ms"
828            }
829        ));
830    }
831
832    #[test]
833    fn builder_default_fallback_is_false() {
834        let entry = valid_builder().build().unwrap();
835        assert!(!entry.fallback_active);
836    }
837
838    #[test]
839    fn builder_fallback_active_true() {
840        let entry = valid_builder().fallback_active(true).build().unwrap();
841        assert!(entry.fallback_active);
842    }
843
844    #[test]
845    fn posterior_tolerance_accepts_near_one() {
846        // Sum = 1.0 - 5e-7 (within 1e-6 tolerance).
847        let entry = valid_builder()
848            .posterior(vec![0.5, 0.3, 0.199_999_5])
849            .build();
850        assert!(entry.is_ok());
851    }
852
853    #[test]
854    fn posterior_tolerance_rejects_beyond() {
855        // Sum = 0.9 (well outside tolerance).
856        let result = valid_builder().posterior(vec![0.5, 0.3, 0.1]).build();
857        assert!(result.is_err());
858    }
859
860    #[test]
861    fn derive_clone_and_debug() {
862        let entry = valid_builder().build().unwrap();
863        let cloned = entry.clone();
864        assert_eq!(format!("{entry:?}"), format!("{cloned:?}"));
865    }
866
867    #[test]
868    fn jsonl_compact_output() {
869        let entry = valid_builder().build().unwrap();
870        let line = serde_json::to_string(&entry).unwrap();
871        // JSONL: single line, no embedded newlines.
872        assert!(!line.contains('\n'));
873        // Should be reasonably compact (under 300 bytes for this test entry).
874        assert!(
875            line.len() < 300,
876            "JSONL line too large: {} bytes",
877            line.len()
878        );
879    }
880
881    #[test]
882    fn compact_json_snapshot() {
883        let entry = valid_builder().build().unwrap();
884        let line = serde_json::to_string(&entry).unwrap();
885        insta::assert_snapshot!("evidence_ledger_compact_json", line);
886    }
887
888    #[test]
889    fn deserialize_from_known_json() {
890        let json = r#"{"ts":1700000000000,"c":"test","a":"act","p":[0.6,0.4],"el":{"act":0.1},"cel":0.1,"cal":0.8,"fb":false,"tf":[["feat",0.9]]}"#;
891        let entry: EvidenceLedger = serde_json::from_str(json).unwrap();
892        assert_eq!(entry.ts_unix_ms, 1_700_000_000_000);
893        assert_eq!(entry.component, "test");
894        assert_eq!(entry.action, "act");
895        assert_eq!(entry.posterior, vec![0.6, 0.4]);
896        assert_eq!(entry.calibration_score, 0.8);
897        assert!(!entry.fallback_active);
898        assert_eq!(entry.top_features, vec![("feat".to_string(), 0.9)]);
899    }
900
901    #[test]
902    fn deserialize_invalid_json_rejected() {
903        let json = r#"{"ts":1700000000000,"c":"test","a":"act","p":[0.6,0.4],"el":{"act":-0.1},"cel":-0.1,"cal":0.8,"fb":false,"tf":[["feat",0.9]]}"#;
904        let err = serde_json::from_str::<EvidenceLedger>(json).unwrap_err();
905        assert!(err.to_string().contains("invalid evidence ledger"));
906    }
907
908    #[test]
909    fn validation_error_display() {
910        let err = ValidationError::PosteriorNotNormalized { sum: 0.5 };
911        let msg = format!("{err}");
912        assert!(msg.contains("0.5"));
913        assert!(msg.contains("~1.0"));
914    }
915
916    #[test]
917    fn builder_error_display() {
918        let err = BuilderError::MissingField { field: "component" };
919        let msg = format!("{err}");
920        assert!(msg.contains("component"));
921    }
922}