1#![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#[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: HashMap<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 EmptyComponent,
135 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 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 pub fn is_valid(&self) -> bool {
215 self.validate().is_empty()
216 }
217}
218
219#[derive(Clone, Debug, PartialEq)]
225pub enum BuilderError {
226 MissingField {
228 field: &'static str,
230 },
231 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#[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 pub fn new() -> Self {
277 Self::default()
278 }
279
280 pub fn ts_unix_ms(mut self, ts: u64) -> Self {
282 self.ts_unix_ms = Some(ts);
283 self
284 }
285
286 pub fn component(mut self, component: impl Into<String>) -> Self {
288 self.component = Some(component.into());
289 self
290 }
291
292 pub fn action(mut self, action: impl Into<String>) -> Self {
294 self.action = Some(action.into());
295 self
296 }
297
298 pub fn posterior(mut self, posterior: Vec<f64>) -> Self {
300 self.posterior = Some(posterior);
301 self
302 }
303
304 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 pub fn chosen_expected_loss(mut self, loss: f64) -> Self {
312 self.chosen_expected_loss = Some(loss);
313 self
314 }
315
316 pub fn calibration_score(mut self, score: f64) -> Self {
318 self.calibration_score = Some(score);
319 self
320 }
321
322 pub fn fallback_active(mut self, active: bool) -> Self {
324 self.fallback_active = active;
325 self
326 }
327
328 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 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#[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 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]) .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 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 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 assert!(!line.contains('\n'));
573 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}