1#![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#[derive(Clone, Debug, Serialize, Deserialize, 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(Clone, Debug, PartialEq)]
108pub enum ValidationError {
109 PosteriorNotNormalized {
111 sum: f64,
113 },
114 PosteriorEmpty,
116 CalibrationOutOfRange {
118 value: f64,
120 },
121 NegativeExpectedLoss {
123 action: String,
125 value: f64,
127 },
128 NegativeChosenExpectedLoss {
130 value: f64,
132 },
133 ChosenActionMissingExpectedLoss {
135 action: String,
137 },
138 ChosenExpectedLossMismatch {
140 action: String,
142 chosen: f64,
144 mapped: f64,
146 },
147 EmptyComponent,
149 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 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 pub fn is_valid(&self) -> bool {
259 self.validate().is_empty()
260 }
261}
262
263#[derive(Clone, Debug, PartialEq)]
269pub enum BuilderError {
270 MissingField {
272 field: &'static str,
274 },
275 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#[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 pub fn new() -> Self {
321 Self::default()
322 }
323
324 pub fn ts_unix_ms(mut self, ts: u64) -> Self {
326 self.ts_unix_ms = Some(ts);
327 self
328 }
329
330 pub fn component(mut self, component: impl Into<String>) -> Self {
332 self.component = Some(component.into());
333 self
334 }
335
336 pub fn action(mut self, action: impl Into<String>) -> Self {
338 self.action = Some(action.into());
339 self
340 }
341
342 pub fn posterior(mut self, posterior: Vec<f64>) -> Self {
344 self.posterior = Some(posterior);
345 self
346 }
347
348 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 pub fn chosen_expected_loss(mut self, loss: f64) -> Self {
356 self.chosen_expected_loss = Some(loss);
357 self
358 }
359
360 pub fn calibration_score(mut self, score: f64) -> Self {
362 self.calibration_score = Some(score);
363 self
364 }
365
366 pub fn fallback_active(mut self, active: bool) -> Self {
368 self.fallback_active = active;
369 self
370 }
371
372 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 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#[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 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]) .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 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 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 assert!(!line.contains('\n'));
651 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}