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    /// `component` is empty.
227    EmptyComponent,
228    /// `action` is empty.
229    EmptyAction,
230}
231
232impl fmt::Display for ValidationError {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        match self {
235            Self::PosteriorNotNormalized { sum } => {
236                write!(f, "posterior sums to {sum}, expected ~1.0")
237            }
238            Self::PosteriorEmpty => write!(f, "posterior must not be empty"),
239            Self::InvalidPosteriorProbability { index, value } => {
240                write!(
241                    f,
242                    "posterior[{index}] must be finite and non-negative, got {value}"
243                )
244            }
245            Self::InvalidExpectedLoss { action, value } => {
246                write!(
247                    f,
248                    "expected_loss for '{action}' must be finite, got {value}"
249                )
250            }
251            Self::CalibrationOutOfRange { value } => {
252                write!(f, "calibration_score {value} not in [0, 1]")
253            }
254            Self::NegativeExpectedLoss { action, value } => {
255                write!(f, "expected_loss for '{action}' is negative: {value}")
256            }
257            Self::NegativeChosenExpectedLoss { value } => {
258                write!(f, "chosen_expected_loss is negative: {value}")
259            }
260            Self::InvalidChosenExpectedLoss { value } => {
261                write!(f, "chosen_expected_loss must be finite, got {value}")
262            }
263            Self::ChosenActionMissingExpectedLoss { action } => {
264                write!(
265                    f,
266                    "expected_loss_by_action is missing the chosen action '{action}'"
267                )
268            }
269            Self::ChosenExpectedLossMismatch {
270                action,
271                chosen,
272                mapped,
273            } => {
274                write!(
275                    f,
276                    "chosen_expected_loss {chosen} disagrees with expected_loss_by_action['{action}']={mapped}"
277                )
278            }
279            Self::EmptyComponent => write!(f, "component must not be empty"),
280            Self::EmptyAction => write!(f, "action must not be empty"),
281        }
282    }
283}
284
285impl std::error::Error for ValidationError {}
286
287impl EvidenceLedger {
288    /// Validate all invariants and return any violations.
289    ///
290    /// - `posterior` must be non-empty and sum to ~1.0 (tolerance 1e-6).
291    /// - Posterior entries must be finite and non-negative.
292    /// - `calibration_score` must be in [0, 1].
293    /// - All expected losses must be finite and non-negative.
294    /// - `component` and `action` must be non-empty.
295    pub fn validate(&self) -> Vec<ValidationError> {
296        let mut errors = Vec::new();
297
298        if self.component.is_empty() {
299            errors.push(ValidationError::EmptyComponent);
300        }
301        if self.action.is_empty() {
302            errors.push(ValidationError::EmptyAction);
303        }
304
305        if self.posterior.is_empty() {
306            errors.push(ValidationError::PosteriorEmpty);
307        } else {
308            let mut posterior_has_invalid_entry = false;
309            for (index, &value) in self.posterior.iter().enumerate() {
310                if !value.is_finite() || value < 0.0 {
311                    errors.push(ValidationError::InvalidPosteriorProbability { index, value });
312                    posterior_has_invalid_entry = true;
313                }
314            }
315            if !posterior_has_invalid_entry {
316                let sum: f64 = self.posterior.iter().sum();
317                if (sum - 1.0).abs() > 1e-6 {
318                    errors.push(ValidationError::PosteriorNotNormalized { sum });
319                }
320            }
321        }
322
323        if !(0.0..=1.0).contains(&self.calibration_score) {
324            errors.push(ValidationError::CalibrationOutOfRange {
325                value: self.calibration_score,
326            });
327        }
328
329        let chosen_expected_loss_valid = if !self.chosen_expected_loss.is_finite() {
330            errors.push(ValidationError::InvalidChosenExpectedLoss {
331                value: self.chosen_expected_loss,
332            });
333            false
334        } else if self.chosen_expected_loss < 0.0 {
335            errors.push(ValidationError::NegativeChosenExpectedLoss {
336                value: self.chosen_expected_loss,
337            });
338            false
339        } else {
340            true
341        };
342
343        for (action, &loss) in &self.expected_loss_by_action {
344            if !loss.is_finite() {
345                errors.push(ValidationError::InvalidExpectedLoss {
346                    action: action.clone(),
347                    value: loss,
348                });
349            } else if loss < 0.0 {
350                errors.push(ValidationError::NegativeExpectedLoss {
351                    action: action.clone(),
352                    value: loss,
353                });
354            }
355        }
356
357        if let Some(&mapped) = self.expected_loss_by_action.get(&self.action) {
358            if chosen_expected_loss_valid
359                && mapped.is_finite()
360                && mapped >= 0.0
361                && (mapped - self.chosen_expected_loss).abs() > 1e-12
362            {
363                errors.push(ValidationError::ChosenExpectedLossMismatch {
364                    action: self.action.clone(),
365                    chosen: self.chosen_expected_loss,
366                    mapped,
367                });
368            }
369        } else if !self.expected_loss_by_action.is_empty() {
370            errors.push(ValidationError::ChosenActionMissingExpectedLoss {
371                action: self.action.clone(),
372            });
373        }
374
375        errors
376    }
377
378    /// Returns `true` if this entry passes all validation checks.
379    pub fn is_valid(&self) -> bool {
380        self.validate().is_empty()
381    }
382}
383
384// ---------------------------------------------------------------------------
385// Builder
386// ---------------------------------------------------------------------------
387
388/// Builder error returned when a required field is missing.
389#[derive(Clone, Debug, PartialEq)]
390pub enum BuilderError {
391    /// A required field was not set.
392    MissingField {
393        /// Name of the missing field.
394        field: &'static str,
395    },
396    /// The constructed entry failed validation.
397    Validation(Vec<ValidationError>),
398}
399
400impl fmt::Display for BuilderError {
401    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
402        match self {
403            Self::MissingField { field } => {
404                write!(f, "EvidenceLedger builder missing required field: {field}")
405            }
406            Self::Validation(errors) => {
407                write!(f, "EvidenceLedger validation failed: ")?;
408                for (i, e) in errors.iter().enumerate() {
409                    if i > 0 {
410                        write!(f, "; ")?;
411                    }
412                    write!(f, "{e}")?;
413                }
414                Ok(())
415            }
416        }
417    }
418}
419
420impl std::error::Error for BuilderError {}
421
422/// Ergonomic builder for [`EvidenceLedger`] entries.
423///
424/// All fields except `fallback_active` (defaults to `false`) are required.
425#[derive(Clone, Debug, Default)]
426#[must_use]
427pub struct EvidenceLedgerBuilder {
428    ts_unix_ms: Option<u64>,
429    component: Option<String>,
430    action: Option<String>,
431    posterior: Option<Vec<f64>>,
432    expected_loss_by_action: BTreeMap<String, f64>,
433    chosen_expected_loss: Option<f64>,
434    calibration_score: Option<f64>,
435    fallback_active: bool,
436    top_features: Vec<(String, f64)>,
437}
438
439impl EvidenceLedgerBuilder {
440    /// Create a new builder with all fields unset.
441    pub fn new() -> Self {
442        Self::default()
443    }
444
445    /// Set the millisecond Unix timestamp.
446    pub fn ts_unix_ms(mut self, ts: u64) -> Self {
447        self.ts_unix_ms = Some(ts);
448        self
449    }
450
451    /// Set the producing component/subsystem name.
452    pub fn component(mut self, component: impl Into<String>) -> Self {
453        self.component = Some(component.into());
454        self
455    }
456
457    /// Set the chosen action.
458    pub fn action(mut self, action: impl Into<String>) -> Self {
459        self.action = Some(action.into());
460        self
461    }
462
463    /// Set the posterior probability distribution.
464    pub fn posterior(mut self, posterior: Vec<f64>) -> Self {
465        self.posterior = Some(posterior);
466        self
467    }
468
469    /// Add an expected-loss entry for a candidate action.
470    pub fn expected_loss(mut self, action: impl Into<String>, loss: f64) -> Self {
471        self.expected_loss_by_action.insert(action.into(), loss);
472        self
473    }
474
475    /// Set the expected loss of the chosen action.
476    pub fn chosen_expected_loss(mut self, loss: f64) -> Self {
477        self.chosen_expected_loss = Some(loss);
478        self
479    }
480
481    /// Set the calibration score (must be in [0, 1]).
482    pub fn calibration_score(mut self, score: f64) -> Self {
483        self.calibration_score = Some(score);
484        self
485    }
486
487    /// Set whether the fallback heuristic was active.
488    pub fn fallback_active(mut self, active: bool) -> Self {
489        self.fallback_active = active;
490        self
491    }
492
493    /// Add a top-feature entry (feature name + importance weight).
494    pub fn top_feature(mut self, name: impl Into<String>, weight: f64) -> Self {
495        self.top_features.push((name.into(), weight));
496        self
497    }
498
499    /// Consume the builder and produce a validated [`EvidenceLedger`].
500    ///
501    /// Returns [`BuilderError::MissingField`] if any required field is unset,
502    /// or [`BuilderError::Validation`] if invariants are violated.
503    pub fn build(self) -> Result<EvidenceLedger, BuilderError> {
504        let entry = EvidenceLedger {
505            ts_unix_ms: self.ts_unix_ms.ok_or(BuilderError::MissingField {
506                field: "ts_unix_ms",
507            })?,
508            component: self
509                .component
510                .ok_or(BuilderError::MissingField { field: "component" })?,
511            action: self
512                .action
513                .ok_or(BuilderError::MissingField { field: "action" })?,
514            posterior: self
515                .posterior
516                .ok_or(BuilderError::MissingField { field: "posterior" })?,
517            expected_loss_by_action: self.expected_loss_by_action,
518            chosen_expected_loss: self
519                .chosen_expected_loss
520                .ok_or(BuilderError::MissingField {
521                    field: "chosen_expected_loss",
522                })?,
523            calibration_score: self.calibration_score.ok_or(BuilderError::MissingField {
524                field: "calibration_score",
525            })?,
526            fallback_active: self.fallback_active,
527            top_features: self.top_features,
528        };
529
530        let errors = entry.validate();
531        if errors.is_empty() {
532            Ok(entry)
533        } else {
534            Err(BuilderError::Validation(errors))
535        }
536    }
537}
538
539// ---------------------------------------------------------------------------
540// Tests
541// ---------------------------------------------------------------------------
542
543#[cfg(test)]
544#[allow(clippy::float_cmp)]
545mod tests {
546    use super::*;
547
548    fn valid_builder() -> EvidenceLedgerBuilder {
549        EvidenceLedgerBuilder::new()
550            .ts_unix_ms(1_700_000_000_000)
551            .component("scheduler")
552            .action("preempt")
553            .posterior(vec![0.7, 0.2, 0.1])
554            .expected_loss("preempt", 0.05)
555            .expected_loss("continue", 0.3)
556            .expected_loss("defer", 0.15)
557            .chosen_expected_loss(0.05)
558            .calibration_score(0.92)
559            .fallback_active(false)
560            .top_feature("queue_depth", 0.45)
561            .top_feature("priority_gap", 0.30)
562    }
563
564    fn expect_validation(result: Result<EvidenceLedger, BuilderError>) -> Vec<ValidationError> {
565        match result.unwrap_err() {
566            BuilderError::Validation(errors) => errors,
567            BuilderError::MissingField { field } => {
568                panic!("expected Validation error, got MissingField({field})")
569            }
570        }
571    }
572
573    #[test]
574    fn builder_produces_valid_entry() {
575        let entry = valid_builder().build().expect("should build");
576        assert!(entry.is_valid());
577        assert_eq!(entry.ts_unix_ms, 1_700_000_000_000);
578        assert_eq!(entry.component, "scheduler");
579        assert_eq!(entry.action, "preempt");
580        assert_eq!(entry.posterior, vec![0.7, 0.2, 0.1]);
581        assert!(!entry.fallback_active);
582        assert_eq!(entry.top_features.len(), 2);
583    }
584
585    #[test]
586    fn serde_roundtrip_json() {
587        let entry = valid_builder().build().unwrap();
588        let json = serde_json::to_string(&entry).unwrap();
589        let parsed: EvidenceLedger = serde_json::from_str(&json).unwrap();
590        assert_eq!(entry.ts_unix_ms, parsed.ts_unix_ms);
591        assert_eq!(entry.component, parsed.component);
592        assert_eq!(entry.action, parsed.action);
593        assert_eq!(entry.posterior, parsed.posterior);
594        assert_eq!(entry.calibration_score, parsed.calibration_score);
595        assert_eq!(entry.chosen_expected_loss, parsed.chosen_expected_loss);
596        assert_eq!(entry.fallback_active, parsed.fallback_active);
597        assert_eq!(entry.top_features, parsed.top_features);
598    }
599
600    #[test]
601    fn serde_uses_short_field_names() {
602        let entry = valid_builder().build().unwrap();
603        let json = serde_json::to_string(&entry).unwrap();
604        assert!(json.contains("\"ts\":"));
605        assert!(json.contains("\"c\":"));
606        assert!(json.contains("\"a\":"));
607        assert!(json.contains("\"p\":"));
608        assert!(json.contains("\"el\":"));
609        assert!(json.contains("\"cel\":"));
610        assert!(json.contains("\"cal\":"));
611        assert!(json.contains("\"fb\":"));
612        assert!(json.contains("\"tf\":"));
613        // Must NOT contain long field names.
614        assert!(!json.contains("\"ts_unix_ms\":"));
615        assert!(!json.contains("\"component\":"));
616        assert!(!json.contains("\"posterior\":"));
617    }
618
619    #[test]
620    fn validation_posterior_not_normalized() {
621        let errors = expect_validation(
622            valid_builder()
623                .posterior(vec![0.5, 0.2, 0.1]) // sums to 0.8
624                .build(),
625        );
626        assert!(
627            errors
628                .iter()
629                .any(|e| matches!(e, ValidationError::PosteriorNotNormalized { .. }))
630        );
631    }
632
633    #[test]
634    fn validation_posterior_empty() {
635        let errors = expect_validation(valid_builder().posterior(vec![]).build());
636        assert!(
637            errors
638                .iter()
639                .any(|e| matches!(e, ValidationError::PosteriorEmpty))
640        );
641    }
642
643    #[test]
644    fn validation_negative_posterior_probability() {
645        let errors = expect_validation(valid_builder().posterior(vec![-0.1, 0.2, 0.9]).build());
646        assert!(errors.iter().any(|e| matches!(
647            e,
648            ValidationError::InvalidPosteriorProbability { index: 0, value }
649                if *value == -0.1
650        )));
651    }
652
653    #[test]
654    fn validation_non_finite_posterior_probability() {
655        let errors = expect_validation(valid_builder().posterior(vec![f64::NAN, 0.2, 0.8]).build());
656        assert!(errors.iter().any(|e| matches!(
657            e,
658            ValidationError::InvalidPosteriorProbability { index: 0, value }
659                if value.is_nan()
660        )));
661    }
662
663    #[test]
664    fn validation_calibration_out_of_range() {
665        let errors = expect_validation(valid_builder().calibration_score(1.5).build());
666        assert!(
667            errors
668                .iter()
669                .any(|e| matches!(e, ValidationError::CalibrationOutOfRange { .. }))
670        );
671    }
672
673    #[test]
674    fn validation_negative_expected_loss() {
675        let errors = expect_validation(valid_builder().expected_loss("bad_action", -0.1).build());
676        assert!(
677            errors
678                .iter()
679                .any(|e| matches!(e, ValidationError::NegativeExpectedLoss { .. }))
680        );
681    }
682
683    #[test]
684    fn validation_non_finite_expected_loss() {
685        let errors = expect_validation(
686            valid_builder()
687                .expected_loss("bad_action", f64::NAN)
688                .build(),
689        );
690        assert!(errors.iter().any(|e| matches!(
691            e,
692            ValidationError::InvalidExpectedLoss { action, value }
693                if action == "bad_action" && value.is_nan()
694        )));
695    }
696
697    #[test]
698    fn validation_negative_chosen_expected_loss() {
699        let errors = expect_validation(valid_builder().chosen_expected_loss(-0.01).build());
700        assert!(
701            errors
702                .iter()
703                .any(|e| matches!(e, ValidationError::NegativeChosenExpectedLoss { .. }))
704        );
705    }
706
707    #[test]
708    fn validation_non_finite_chosen_expected_loss() {
709        let errors = expect_validation(valid_builder().chosen_expected_loss(f64::INFINITY).build());
710        assert!(errors.iter().any(|e| matches!(
711            e,
712            ValidationError::InvalidChosenExpectedLoss { value } if value.is_infinite()
713        )));
714    }
715
716    #[test]
717    fn validation_missing_chosen_action_expected_loss() {
718        let errors = expect_validation(valid_builder().action("restart").build());
719        assert!(
720            errors
721                .iter()
722                .any(|e| matches!(e, ValidationError::ChosenActionMissingExpectedLoss { .. }))
723        );
724    }
725
726    #[test]
727    fn validation_chosen_expected_loss_mismatch() {
728        let errors = expect_validation(valid_builder().expected_loss("preempt", 0.20).build());
729        assert!(
730            errors
731                .iter()
732                .any(|e| matches!(e, ValidationError::ChosenExpectedLossMismatch { .. }))
733        );
734    }
735
736    #[test]
737    fn validation_empty_component() {
738        let errors = expect_validation(valid_builder().component("").build());
739        assert!(
740            errors
741                .iter()
742                .any(|e| matches!(e, ValidationError::EmptyComponent))
743        );
744    }
745
746    #[test]
747    fn validation_empty_action() {
748        let errors = expect_validation(valid_builder().action("").build());
749        assert!(
750            errors
751                .iter()
752                .any(|e| matches!(e, ValidationError::EmptyAction))
753        );
754    }
755
756    #[test]
757    fn builder_missing_required_field() {
758        let result = EvidenceLedgerBuilder::new()
759            .component("x")
760            .action("y")
761            .posterior(vec![1.0])
762            .chosen_expected_loss(0.0)
763            .calibration_score(0.5)
764            .build();
765        let err = result.unwrap_err();
766        assert!(matches!(
767            err,
768            BuilderError::MissingField {
769                field: "ts_unix_ms"
770            }
771        ));
772    }
773
774    #[test]
775    fn builder_default_fallback_is_false() {
776        let entry = valid_builder().build().unwrap();
777        assert!(!entry.fallback_active);
778    }
779
780    #[test]
781    fn builder_fallback_active_true() {
782        let entry = valid_builder().fallback_active(true).build().unwrap();
783        assert!(entry.fallback_active);
784    }
785
786    #[test]
787    fn posterior_tolerance_accepts_near_one() {
788        // Sum = 1.0 - 5e-7 (within 1e-6 tolerance).
789        let entry = valid_builder()
790            .posterior(vec![0.5, 0.3, 0.199_999_5])
791            .build();
792        assert!(entry.is_ok());
793    }
794
795    #[test]
796    fn posterior_tolerance_rejects_beyond() {
797        // Sum = 0.9 (well outside tolerance).
798        let result = valid_builder().posterior(vec![0.5, 0.3, 0.1]).build();
799        assert!(result.is_err());
800    }
801
802    #[test]
803    fn derive_clone_and_debug() {
804        let entry = valid_builder().build().unwrap();
805        let cloned = entry.clone();
806        assert_eq!(format!("{entry:?}"), format!("{cloned:?}"));
807    }
808
809    #[test]
810    fn jsonl_compact_output() {
811        let entry = valid_builder().build().unwrap();
812        let line = serde_json::to_string(&entry).unwrap();
813        // JSONL: single line, no embedded newlines.
814        assert!(!line.contains('\n'));
815        // Should be reasonably compact (under 300 bytes for this test entry).
816        assert!(
817            line.len() < 300,
818            "JSONL line too large: {} bytes",
819            line.len()
820        );
821    }
822
823    #[test]
824    fn compact_json_snapshot() {
825        let entry = valid_builder().build().unwrap();
826        let line = serde_json::to_string(&entry).unwrap();
827        insta::assert_snapshot!("evidence_ledger_compact_json", line);
828    }
829
830    #[test]
831    fn deserialize_from_known_json() {
832        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]]}"#;
833        let entry: EvidenceLedger = serde_json::from_str(json).unwrap();
834        assert_eq!(entry.ts_unix_ms, 1_700_000_000_000);
835        assert_eq!(entry.component, "test");
836        assert_eq!(entry.action, "act");
837        assert_eq!(entry.posterior, vec![0.6, 0.4]);
838        assert_eq!(entry.calibration_score, 0.8);
839        assert!(!entry.fallback_active);
840        assert_eq!(entry.top_features, vec![("feat".to_string(), 0.9)]);
841    }
842
843    #[test]
844    fn deserialize_invalid_json_rejected() {
845        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]]}"#;
846        let err = serde_json::from_str::<EvidenceLedger>(json).unwrap_err();
847        assert!(err.to_string().contains("invalid evidence ledger"));
848    }
849
850    #[test]
851    fn validation_error_display() {
852        let err = ValidationError::PosteriorNotNormalized { sum: 0.5 };
853        let msg = format!("{err}");
854        assert!(msg.contains("0.5"));
855        assert!(msg.contains("~1.0"));
856    }
857
858    #[test]
859    fn builder_error_display() {
860        let err = BuilderError::MissingField { field: "component" };
861        let msg = format!("{err}");
862        assert!(msg.contains("component"));
863    }
864}