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, 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, Deserialize, 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// ---------------------------------------------------------------------------
103// Validation
104// ---------------------------------------------------------------------------
105
106/// Validation error for an [`EvidenceLedger`] entry.
107#[derive(Clone, Debug, PartialEq)]
108pub enum ValidationError {
109    /// `posterior` does not sum to ~1.0. Contains the actual sum.
110    PosteriorNotNormalized {
111        /// Actual sum of the posterior vector.
112        sum: f64,
113    },
114    /// `posterior` is empty.
115    PosteriorEmpty,
116    /// `calibration_score` is outside [0, 1].
117    CalibrationOutOfRange {
118        /// The out-of-range value.
119        value: f64,
120    },
121    /// An expected-loss value is negative.
122    NegativeExpectedLoss {
123        /// The action whose loss is negative.
124        action: String,
125        /// The negative loss value.
126        value: f64,
127    },
128    /// `chosen_expected_loss` is negative.
129    NegativeChosenExpectedLoss {
130        /// The negative loss value.
131        value: f64,
132    },
133    /// `expected_loss_by_action` is populated but does not include the chosen action.
134    ChosenActionMissingExpectedLoss {
135        /// The chosen action that is missing from the map.
136        action: String,
137    },
138    /// `chosen_expected_loss` disagrees with the chosen action's mapped loss.
139    ChosenExpectedLossMismatch {
140        /// The chosen action whose loss disagrees.
141        action: String,
142        /// The value recorded in `chosen_expected_loss`.
143        chosen: f64,
144        /// The value recorded in `expected_loss_by_action`.
145        mapped: f64,
146    },
147    /// `component` is empty.
148    EmptyComponent,
149    /// `action` is empty.
150    EmptyAction,
151}
152
153impl fmt::Display for ValidationError {
154    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155        match self {
156            Self::PosteriorNotNormalized { sum } => {
157                write!(f, "posterior sums to {sum}, expected ~1.0")
158            }
159            Self::PosteriorEmpty => write!(f, "posterior must not be empty"),
160            Self::CalibrationOutOfRange { value } => {
161                write!(f, "calibration_score {value} not in [0, 1]")
162            }
163            Self::NegativeExpectedLoss { action, value } => {
164                write!(f, "expected_loss for '{action}' is negative: {value}")
165            }
166            Self::NegativeChosenExpectedLoss { value } => {
167                write!(f, "chosen_expected_loss is negative: {value}")
168            }
169            Self::ChosenActionMissingExpectedLoss { action } => {
170                write!(
171                    f,
172                    "expected_loss_by_action is missing the chosen action '{action}'"
173                )
174            }
175            Self::ChosenExpectedLossMismatch {
176                action,
177                chosen,
178                mapped,
179            } => {
180                write!(
181                    f,
182                    "chosen_expected_loss {chosen} disagrees with expected_loss_by_action['{action}']={mapped}"
183                )
184            }
185            Self::EmptyComponent => write!(f, "component must not be empty"),
186            Self::EmptyAction => write!(f, "action must not be empty"),
187        }
188    }
189}
190
191impl std::error::Error for ValidationError {}
192
193impl EvidenceLedger {
194    /// Validate all invariants and return any violations.
195    ///
196    /// - `posterior` must be non-empty and sum to ~1.0 (tolerance 1e-6).
197    /// - `calibration_score` must be in [0, 1].
198    /// - All expected losses must be non-negative.
199    /// - `component` and `action` must be non-empty.
200    pub fn validate(&self) -> Vec<ValidationError> {
201        let mut errors = Vec::new();
202
203        if self.component.is_empty() {
204            errors.push(ValidationError::EmptyComponent);
205        }
206        if self.action.is_empty() {
207            errors.push(ValidationError::EmptyAction);
208        }
209
210        if self.posterior.is_empty() {
211            errors.push(ValidationError::PosteriorEmpty);
212        } else {
213            let sum: f64 = self.posterior.iter().sum();
214            if (sum - 1.0).abs() > 1e-6 {
215                errors.push(ValidationError::PosteriorNotNormalized { sum });
216            }
217        }
218
219        if !(0.0..=1.0).contains(&self.calibration_score) {
220            errors.push(ValidationError::CalibrationOutOfRange {
221                value: self.calibration_score,
222            });
223        }
224
225        if self.chosen_expected_loss < 0.0 {
226            errors.push(ValidationError::NegativeChosenExpectedLoss {
227                value: self.chosen_expected_loss,
228            });
229        }
230
231        for (action, &loss) in &self.expected_loss_by_action {
232            if loss < 0.0 {
233                errors.push(ValidationError::NegativeExpectedLoss {
234                    action: action.clone(),
235                    value: loss,
236                });
237            }
238        }
239
240        if let Some(&mapped) = self.expected_loss_by_action.get(&self.action) {
241            if (mapped - self.chosen_expected_loss).abs() > 1e-12 {
242                errors.push(ValidationError::ChosenExpectedLossMismatch {
243                    action: self.action.clone(),
244                    chosen: self.chosen_expected_loss,
245                    mapped,
246                });
247            }
248        } else if !self.expected_loss_by_action.is_empty() {
249            errors.push(ValidationError::ChosenActionMissingExpectedLoss {
250                action: self.action.clone(),
251            });
252        }
253
254        errors
255    }
256
257    /// Returns `true` if this entry passes all validation checks.
258    pub fn is_valid(&self) -> bool {
259        self.validate().is_empty()
260    }
261}
262
263// ---------------------------------------------------------------------------
264// Builder
265// ---------------------------------------------------------------------------
266
267/// Builder error returned when a required field is missing.
268#[derive(Clone, Debug, PartialEq)]
269pub enum BuilderError {
270    /// A required field was not set.
271    MissingField {
272        /// Name of the missing field.
273        field: &'static str,
274    },
275    /// The constructed entry failed validation.
276    Validation(Vec<ValidationError>),
277}
278
279impl fmt::Display for BuilderError {
280    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
281        match self {
282            Self::MissingField { field } => {
283                write!(f, "EvidenceLedger builder missing required field: {field}")
284            }
285            Self::Validation(errors) => {
286                write!(f, "EvidenceLedger validation failed: ")?;
287                for (i, e) in errors.iter().enumerate() {
288                    if i > 0 {
289                        write!(f, "; ")?;
290                    }
291                    write!(f, "{e}")?;
292                }
293                Ok(())
294            }
295        }
296    }
297}
298
299impl std::error::Error for BuilderError {}
300
301/// Ergonomic builder for [`EvidenceLedger`] entries.
302///
303/// All fields except `fallback_active` (defaults to `false`) are required.
304#[derive(Clone, Debug, Default)]
305#[must_use]
306pub struct EvidenceLedgerBuilder {
307    ts_unix_ms: Option<u64>,
308    component: Option<String>,
309    action: Option<String>,
310    posterior: Option<Vec<f64>>,
311    expected_loss_by_action: BTreeMap<String, f64>,
312    chosen_expected_loss: Option<f64>,
313    calibration_score: Option<f64>,
314    fallback_active: bool,
315    top_features: Vec<(String, f64)>,
316}
317
318impl EvidenceLedgerBuilder {
319    /// Create a new builder with all fields unset.
320    pub fn new() -> Self {
321        Self::default()
322    }
323
324    /// Set the millisecond Unix timestamp.
325    pub fn ts_unix_ms(mut self, ts: u64) -> Self {
326        self.ts_unix_ms = Some(ts);
327        self
328    }
329
330    /// Set the producing component/subsystem name.
331    pub fn component(mut self, component: impl Into<String>) -> Self {
332        self.component = Some(component.into());
333        self
334    }
335
336    /// Set the chosen action.
337    pub fn action(mut self, action: impl Into<String>) -> Self {
338        self.action = Some(action.into());
339        self
340    }
341
342    /// Set the posterior probability distribution.
343    pub fn posterior(mut self, posterior: Vec<f64>) -> Self {
344        self.posterior = Some(posterior);
345        self
346    }
347
348    /// Add an expected-loss entry for a candidate action.
349    pub fn expected_loss(mut self, action: impl Into<String>, loss: f64) -> Self {
350        self.expected_loss_by_action.insert(action.into(), loss);
351        self
352    }
353
354    /// Set the expected loss of the chosen action.
355    pub fn chosen_expected_loss(mut self, loss: f64) -> Self {
356        self.chosen_expected_loss = Some(loss);
357        self
358    }
359
360    /// Set the calibration score (must be in [0, 1]).
361    pub fn calibration_score(mut self, score: f64) -> Self {
362        self.calibration_score = Some(score);
363        self
364    }
365
366    /// Set whether the fallback heuristic was active.
367    pub fn fallback_active(mut self, active: bool) -> Self {
368        self.fallback_active = active;
369        self
370    }
371
372    /// Add a top-feature entry (feature name + importance weight).
373    pub fn top_feature(mut self, name: impl Into<String>, weight: f64) -> Self {
374        self.top_features.push((name.into(), weight));
375        self
376    }
377
378    /// Consume the builder and produce a validated [`EvidenceLedger`].
379    ///
380    /// Returns [`BuilderError::MissingField`] if any required field is unset,
381    /// or [`BuilderError::Validation`] if invariants are violated.
382    pub fn build(self) -> Result<EvidenceLedger, BuilderError> {
383        let entry = EvidenceLedger {
384            ts_unix_ms: self.ts_unix_ms.ok_or(BuilderError::MissingField {
385                field: "ts_unix_ms",
386            })?,
387            component: self
388                .component
389                .ok_or(BuilderError::MissingField { field: "component" })?,
390            action: self
391                .action
392                .ok_or(BuilderError::MissingField { field: "action" })?,
393            posterior: self
394                .posterior
395                .ok_or(BuilderError::MissingField { field: "posterior" })?,
396            expected_loss_by_action: self.expected_loss_by_action,
397            chosen_expected_loss: self
398                .chosen_expected_loss
399                .ok_or(BuilderError::MissingField {
400                    field: "chosen_expected_loss",
401                })?,
402            calibration_score: self.calibration_score.ok_or(BuilderError::MissingField {
403                field: "calibration_score",
404            })?,
405            fallback_active: self.fallback_active,
406            top_features: self.top_features,
407        };
408
409        let errors = entry.validate();
410        if errors.is_empty() {
411            Ok(entry)
412        } else {
413            Err(BuilderError::Validation(errors))
414        }
415    }
416}
417
418// ---------------------------------------------------------------------------
419// Tests
420// ---------------------------------------------------------------------------
421
422#[cfg(test)]
423#[allow(clippy::float_cmp)]
424mod tests {
425    use super::*;
426
427    fn valid_builder() -> EvidenceLedgerBuilder {
428        EvidenceLedgerBuilder::new()
429            .ts_unix_ms(1_700_000_000_000)
430            .component("scheduler")
431            .action("preempt")
432            .posterior(vec![0.7, 0.2, 0.1])
433            .expected_loss("preempt", 0.05)
434            .expected_loss("continue", 0.3)
435            .expected_loss("defer", 0.15)
436            .chosen_expected_loss(0.05)
437            .calibration_score(0.92)
438            .fallback_active(false)
439            .top_feature("queue_depth", 0.45)
440            .top_feature("priority_gap", 0.30)
441    }
442
443    fn expect_validation(result: Result<EvidenceLedger, BuilderError>) -> Vec<ValidationError> {
444        match result.unwrap_err() {
445            BuilderError::Validation(errors) => errors,
446            BuilderError::MissingField { field } => {
447                panic!("expected Validation error, got MissingField({field})")
448            }
449        }
450    }
451
452    #[test]
453    fn builder_produces_valid_entry() {
454        let entry = valid_builder().build().expect("should build");
455        assert!(entry.is_valid());
456        assert_eq!(entry.ts_unix_ms, 1_700_000_000_000);
457        assert_eq!(entry.component, "scheduler");
458        assert_eq!(entry.action, "preempt");
459        assert_eq!(entry.posterior, vec![0.7, 0.2, 0.1]);
460        assert!(!entry.fallback_active);
461        assert_eq!(entry.top_features.len(), 2);
462    }
463
464    #[test]
465    fn serde_roundtrip_json() {
466        let entry = valid_builder().build().unwrap();
467        let json = serde_json::to_string(&entry).unwrap();
468        let parsed: EvidenceLedger = serde_json::from_str(&json).unwrap();
469        assert_eq!(entry.ts_unix_ms, parsed.ts_unix_ms);
470        assert_eq!(entry.component, parsed.component);
471        assert_eq!(entry.action, parsed.action);
472        assert_eq!(entry.posterior, parsed.posterior);
473        assert_eq!(entry.calibration_score, parsed.calibration_score);
474        assert_eq!(entry.chosen_expected_loss, parsed.chosen_expected_loss);
475        assert_eq!(entry.fallback_active, parsed.fallback_active);
476        assert_eq!(entry.top_features, parsed.top_features);
477    }
478
479    #[test]
480    fn serde_uses_short_field_names() {
481        let entry = valid_builder().build().unwrap();
482        let json = serde_json::to_string(&entry).unwrap();
483        assert!(json.contains("\"ts\":"));
484        assert!(json.contains("\"c\":"));
485        assert!(json.contains("\"a\":"));
486        assert!(json.contains("\"p\":"));
487        assert!(json.contains("\"el\":"));
488        assert!(json.contains("\"cel\":"));
489        assert!(json.contains("\"cal\":"));
490        assert!(json.contains("\"fb\":"));
491        assert!(json.contains("\"tf\":"));
492        // Must NOT contain long field names.
493        assert!(!json.contains("\"ts_unix_ms\":"));
494        assert!(!json.contains("\"component\":"));
495        assert!(!json.contains("\"posterior\":"));
496    }
497
498    #[test]
499    fn validation_posterior_not_normalized() {
500        let errors = expect_validation(
501            valid_builder()
502                .posterior(vec![0.5, 0.2, 0.1]) // sums to 0.8
503                .build(),
504        );
505        assert!(
506            errors
507                .iter()
508                .any(|e| matches!(e, ValidationError::PosteriorNotNormalized { .. }))
509        );
510    }
511
512    #[test]
513    fn validation_posterior_empty() {
514        let errors = expect_validation(valid_builder().posterior(vec![]).build());
515        assert!(
516            errors
517                .iter()
518                .any(|e| matches!(e, ValidationError::PosteriorEmpty))
519        );
520    }
521
522    #[test]
523    fn validation_calibration_out_of_range() {
524        let errors = expect_validation(valid_builder().calibration_score(1.5).build());
525        assert!(
526            errors
527                .iter()
528                .any(|e| matches!(e, ValidationError::CalibrationOutOfRange { .. }))
529        );
530    }
531
532    #[test]
533    fn validation_negative_expected_loss() {
534        let errors = expect_validation(valid_builder().expected_loss("bad_action", -0.1).build());
535        assert!(
536            errors
537                .iter()
538                .any(|e| matches!(e, ValidationError::NegativeExpectedLoss { .. }))
539        );
540    }
541
542    #[test]
543    fn validation_negative_chosen_expected_loss() {
544        let errors = expect_validation(valid_builder().chosen_expected_loss(-0.01).build());
545        assert!(
546            errors
547                .iter()
548                .any(|e| matches!(e, ValidationError::NegativeChosenExpectedLoss { .. }))
549        );
550    }
551
552    #[test]
553    fn validation_missing_chosen_action_expected_loss() {
554        let errors = expect_validation(valid_builder().action("restart").build());
555        assert!(
556            errors
557                .iter()
558                .any(|e| matches!(e, ValidationError::ChosenActionMissingExpectedLoss { .. }))
559        );
560    }
561
562    #[test]
563    fn validation_chosen_expected_loss_mismatch() {
564        let errors = expect_validation(valid_builder().expected_loss("preempt", 0.20).build());
565        assert!(
566            errors
567                .iter()
568                .any(|e| matches!(e, ValidationError::ChosenExpectedLossMismatch { .. }))
569        );
570    }
571
572    #[test]
573    fn validation_empty_component() {
574        let errors = expect_validation(valid_builder().component("").build());
575        assert!(
576            errors
577                .iter()
578                .any(|e| matches!(e, ValidationError::EmptyComponent))
579        );
580    }
581
582    #[test]
583    fn validation_empty_action() {
584        let errors = expect_validation(valid_builder().action("").build());
585        assert!(
586            errors
587                .iter()
588                .any(|e| matches!(e, ValidationError::EmptyAction))
589        );
590    }
591
592    #[test]
593    fn builder_missing_required_field() {
594        let result = EvidenceLedgerBuilder::new()
595            .component("x")
596            .action("y")
597            .posterior(vec![1.0])
598            .chosen_expected_loss(0.0)
599            .calibration_score(0.5)
600            .build();
601        let err = result.unwrap_err();
602        assert!(matches!(
603            err,
604            BuilderError::MissingField {
605                field: "ts_unix_ms"
606            }
607        ));
608    }
609
610    #[test]
611    fn builder_default_fallback_is_false() {
612        let entry = valid_builder().build().unwrap();
613        assert!(!entry.fallback_active);
614    }
615
616    #[test]
617    fn builder_fallback_active_true() {
618        let entry = valid_builder().fallback_active(true).build().unwrap();
619        assert!(entry.fallback_active);
620    }
621
622    #[test]
623    fn posterior_tolerance_accepts_near_one() {
624        // Sum = 1.0 - 5e-7 (within 1e-6 tolerance).
625        let entry = valid_builder()
626            .posterior(vec![0.5, 0.3, 0.199_999_5])
627            .build();
628        assert!(entry.is_ok());
629    }
630
631    #[test]
632    fn posterior_tolerance_rejects_beyond() {
633        // Sum = 0.9 (well outside tolerance).
634        let result = valid_builder().posterior(vec![0.5, 0.3, 0.1]).build();
635        assert!(result.is_err());
636    }
637
638    #[test]
639    fn derive_clone_and_debug() {
640        let entry = valid_builder().build().unwrap();
641        let cloned = entry.clone();
642        assert_eq!(format!("{entry:?}"), format!("{cloned:?}"));
643    }
644
645    #[test]
646    fn jsonl_compact_output() {
647        let entry = valid_builder().build().unwrap();
648        let line = serde_json::to_string(&entry).unwrap();
649        // JSONL: single line, no embedded newlines.
650        assert!(!line.contains('\n'));
651        // Should be reasonably compact (under 300 bytes for this test entry).
652        assert!(
653            line.len() < 300,
654            "JSONL line too large: {} bytes",
655            line.len()
656        );
657    }
658
659    #[test]
660    fn deserialize_from_known_json() {
661        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]]}"#;
662        let entry: EvidenceLedger = serde_json::from_str(json).unwrap();
663        assert_eq!(entry.ts_unix_ms, 1_700_000_000_000);
664        assert_eq!(entry.component, "test");
665        assert_eq!(entry.action, "act");
666        assert_eq!(entry.posterior, vec![0.6, 0.4]);
667        assert_eq!(entry.calibration_score, 0.8);
668        assert!(!entry.fallback_active);
669        assert_eq!(entry.top_features, vec![("feat".to_string(), 0.9)]);
670    }
671
672    #[test]
673    fn validation_error_display() {
674        let err = ValidationError::PosteriorNotNormalized { sum: 0.5 };
675        let msg = format!("{err}");
676        assert!(msg.contains("0.5"));
677        assert!(msg.contains("~1.0"));
678    }
679
680    #[test]
681    fn builder_error_display() {
682        let err = BuilderError::MissingField { field: "component" };
683        let msg = format!("{err}");
684        assert!(msg.contains("component"));
685    }
686}