1#![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#[derive(Clone, Debug, Serialize, PartialEq)]
62pub struct EvidenceLedger {
63 #[serde(rename = "ts")]
65 pub ts_unix_ms: u64,
66
67 #[serde(rename = "c")]
69 pub component: String,
70
71 #[serde(rename = "a")]
73 pub action: String,
74
75 #[serde(rename = "p")]
78 pub posterior: Vec<f64>,
79
80 #[serde(rename = "el")]
82 pub expected_loss_by_action: BTreeMap<String, f64>,
83
84 #[serde(rename = "cel")]
86 pub chosen_expected_loss: f64,
87
88 #[serde(rename = "cal")]
91 pub calibration_score: f64,
92
93 #[serde(rename = "fb")]
95 pub fallback_active: bool,
96
97 #[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#[derive(Clone, Debug, PartialEq)]
168pub enum ValidationError {
169 PosteriorNotNormalized {
171 sum: f64,
173 },
174 PosteriorEmpty,
176 InvalidPosteriorProbability {
178 index: usize,
180 value: f64,
182 },
183 InvalidExpectedLoss {
185 action: String,
187 value: f64,
189 },
190 CalibrationOutOfRange {
192 value: f64,
194 },
195 NegativeExpectedLoss {
197 action: String,
199 value: f64,
201 },
202 NegativeChosenExpectedLoss {
204 value: f64,
206 },
207 InvalidChosenExpectedLoss {
209 value: f64,
211 },
212 ChosenActionMissingExpectedLoss {
214 action: String,
216 },
217 ChosenExpectedLossMismatch {
219 action: String,
221 chosen: f64,
223 mapped: f64,
225 },
226 EmptyComponent,
228 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 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 pub fn is_valid(&self) -> bool {
380 self.validate().is_empty()
381 }
382}
383
384#[derive(Clone, Debug, PartialEq)]
390pub enum BuilderError {
391 MissingField {
393 field: &'static str,
395 },
396 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#[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 pub fn new() -> Self {
442 Self::default()
443 }
444
445 pub fn ts_unix_ms(mut self, ts: u64) -> Self {
447 self.ts_unix_ms = Some(ts);
448 self
449 }
450
451 pub fn component(mut self, component: impl Into<String>) -> Self {
453 self.component = Some(component.into());
454 self
455 }
456
457 pub fn action(mut self, action: impl Into<String>) -> Self {
459 self.action = Some(action.into());
460 self
461 }
462
463 pub fn posterior(mut self, posterior: Vec<f64>) -> Self {
465 self.posterior = Some(posterior);
466 self
467 }
468
469 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 pub fn chosen_expected_loss(mut self, loss: f64) -> Self {
477 self.chosen_expected_loss = Some(loss);
478 self
479 }
480
481 pub fn calibration_score(mut self, score: f64) -> Self {
483 self.calibration_score = Some(score);
484 self
485 }
486
487 pub fn fallback_active(mut self, active: bool) -> Self {
489 self.fallback_active = active;
490 self
491 }
492
493 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 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#[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 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]) .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 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 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 assert!(!line.contains('\n'));
815 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}