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