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 InvalidTopFeatureWeight {
233 name: String,
235 value: f64,
237 },
238 EmptyComponent,
240 EmptyAction,
242}
243
244impl fmt::Display for ValidationError {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 match self {
247 Self::PosteriorNotNormalized { sum } => {
248 write!(f, "posterior sums to {sum}, expected ~1.0")
249 }
250 Self::PosteriorEmpty => write!(f, "posterior must not be empty"),
251 Self::InvalidPosteriorProbability { index, value } => {
252 write!(
253 f,
254 "posterior[{index}] must be finite and non-negative, got {value}"
255 )
256 }
257 Self::InvalidExpectedLoss { action, value } => {
258 write!(
259 f,
260 "expected_loss for '{action}' must be finite, got {value}"
261 )
262 }
263 Self::CalibrationOutOfRange { value } => {
264 write!(f, "calibration_score {value} not in [0, 1]")
265 }
266 Self::NegativeExpectedLoss { action, value } => {
267 write!(f, "expected_loss for '{action}' is negative: {value}")
268 }
269 Self::NegativeChosenExpectedLoss { value } => {
270 write!(f, "chosen_expected_loss is negative: {value}")
271 }
272 Self::InvalidChosenExpectedLoss { value } => {
273 write!(f, "chosen_expected_loss must be finite, got {value}")
274 }
275 Self::ChosenActionMissingExpectedLoss { action } => {
276 write!(
277 f,
278 "expected_loss_by_action is missing the chosen action '{action}'"
279 )
280 }
281 Self::ChosenExpectedLossMismatch {
282 action,
283 chosen,
284 mapped,
285 } => {
286 write!(
287 f,
288 "chosen_expected_loss {chosen} disagrees with expected_loss_by_action['{action}']={mapped}"
289 )
290 }
291 Self::InvalidTopFeatureWeight { name, value } => {
292 write!(
293 f,
294 "top_feature weight for '{name}' must be finite, got {value}"
295 )
296 }
297 Self::EmptyComponent => write!(f, "component must not be empty"),
298 Self::EmptyAction => write!(f, "action must not be empty"),
299 }
300 }
301}
302
303impl std::error::Error for ValidationError {}
304
305impl EvidenceLedger {
306 pub fn validate(&self) -> Vec<ValidationError> {
317 let mut errors = Vec::new();
318
319 if self.component.is_empty() {
320 errors.push(ValidationError::EmptyComponent);
321 }
322 if self.action.is_empty() {
323 errors.push(ValidationError::EmptyAction);
324 }
325
326 if self.posterior.is_empty() {
327 errors.push(ValidationError::PosteriorEmpty);
328 } else {
329 let mut posterior_has_invalid_entry = false;
330 for (index, &value) in self.posterior.iter().enumerate() {
331 if !value.is_finite() || value < 0.0 {
332 errors.push(ValidationError::InvalidPosteriorProbability { index, value });
333 posterior_has_invalid_entry = true;
334 }
335 }
336 if !posterior_has_invalid_entry {
337 let sum: f64 = self.posterior.iter().sum();
338 if (sum - 1.0).abs() > 1e-6 {
339 errors.push(ValidationError::PosteriorNotNormalized { sum });
340 }
341 }
342 }
343
344 if !(0.0..=1.0).contains(&self.calibration_score) {
345 errors.push(ValidationError::CalibrationOutOfRange {
346 value: self.calibration_score,
347 });
348 }
349
350 let chosen_expected_loss_valid = if !self.chosen_expected_loss.is_finite() {
351 errors.push(ValidationError::InvalidChosenExpectedLoss {
352 value: self.chosen_expected_loss,
353 });
354 false
355 } else if self.chosen_expected_loss < 0.0 {
356 errors.push(ValidationError::NegativeChosenExpectedLoss {
357 value: self.chosen_expected_loss,
358 });
359 false
360 } else {
361 true
362 };
363
364 for (action, &loss) in &self.expected_loss_by_action {
365 if !loss.is_finite() {
366 errors.push(ValidationError::InvalidExpectedLoss {
367 action: action.clone(),
368 value: loss,
369 });
370 } else if loss < 0.0 {
371 errors.push(ValidationError::NegativeExpectedLoss {
372 action: action.clone(),
373 value: loss,
374 });
375 }
376 }
377
378 for (name, weight) in &self.top_features {
379 if !weight.is_finite() {
380 errors.push(ValidationError::InvalidTopFeatureWeight {
381 name: name.clone(),
382 value: *weight,
383 });
384 }
385 }
386
387 if let Some(&mapped) = self.expected_loss_by_action.get(&self.action) {
388 if chosen_expected_loss_valid
389 && mapped.is_finite()
390 && mapped >= 0.0
391 && (mapped - self.chosen_expected_loss).abs() > 1e-12
392 {
393 errors.push(ValidationError::ChosenExpectedLossMismatch {
394 action: self.action.clone(),
395 chosen: self.chosen_expected_loss,
396 mapped,
397 });
398 }
399 } else if !self.expected_loss_by_action.is_empty() {
400 errors.push(ValidationError::ChosenActionMissingExpectedLoss {
401 action: self.action.clone(),
402 });
403 }
404
405 errors
406 }
407
408 pub fn is_valid(&self) -> bool {
410 self.validate().is_empty()
411 }
412}
413
414#[derive(Clone, Debug, PartialEq)]
420pub enum BuilderError {
421 MissingField {
423 field: &'static str,
425 },
426 Validation(Vec<ValidationError>),
428}
429
430impl fmt::Display for BuilderError {
431 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432 match self {
433 Self::MissingField { field } => {
434 write!(f, "EvidenceLedger builder missing required field: {field}")
435 }
436 Self::Validation(errors) => {
437 write!(f, "EvidenceLedger validation failed: ")?;
438 for (i, e) in errors.iter().enumerate() {
439 if i > 0 {
440 write!(f, "; ")?;
441 }
442 write!(f, "{e}")?;
443 }
444 Ok(())
445 }
446 }
447 }
448}
449
450impl std::error::Error for BuilderError {}
451
452#[derive(Clone, Debug, Default)]
456#[must_use]
457pub struct EvidenceLedgerBuilder {
458 ts_unix_ms: Option<u64>,
459 component: Option<String>,
460 action: Option<String>,
461 posterior: Option<Vec<f64>>,
462 expected_loss_by_action: BTreeMap<String, f64>,
463 chosen_expected_loss: Option<f64>,
464 calibration_score: Option<f64>,
465 fallback_active: bool,
466 top_features: Vec<(String, f64)>,
467}
468
469impl EvidenceLedgerBuilder {
470 pub fn new() -> Self {
472 Self::default()
473 }
474
475 pub fn ts_unix_ms(mut self, ts: u64) -> Self {
477 self.ts_unix_ms = Some(ts);
478 self
479 }
480
481 pub fn component(mut self, component: impl Into<String>) -> Self {
483 self.component = Some(component.into());
484 self
485 }
486
487 pub fn action(mut self, action: impl Into<String>) -> Self {
489 self.action = Some(action.into());
490 self
491 }
492
493 pub fn posterior(mut self, posterior: Vec<f64>) -> Self {
495 self.posterior = Some(posterior);
496 self
497 }
498
499 pub fn expected_loss(mut self, action: impl Into<String>, loss: f64) -> Self {
501 self.expected_loss_by_action.insert(action.into(), loss);
502 self
503 }
504
505 pub fn chosen_expected_loss(mut self, loss: f64) -> Self {
507 self.chosen_expected_loss = Some(loss);
508 self
509 }
510
511 pub fn calibration_score(mut self, score: f64) -> Self {
513 self.calibration_score = Some(score);
514 self
515 }
516
517 pub fn fallback_active(mut self, active: bool) -> Self {
519 self.fallback_active = active;
520 self
521 }
522
523 pub fn top_feature(mut self, name: impl Into<String>, weight: f64) -> Self {
525 self.top_features.push((name.into(), weight));
526 self
527 }
528
529 pub fn build(self) -> Result<EvidenceLedger, BuilderError> {
534 let entry = EvidenceLedger {
535 ts_unix_ms: self.ts_unix_ms.ok_or(BuilderError::MissingField {
536 field: "ts_unix_ms",
537 })?,
538 component: self
539 .component
540 .ok_or(BuilderError::MissingField { field: "component" })?,
541 action: self
542 .action
543 .ok_or(BuilderError::MissingField { field: "action" })?,
544 posterior: self
545 .posterior
546 .ok_or(BuilderError::MissingField { field: "posterior" })?,
547 expected_loss_by_action: self.expected_loss_by_action,
548 chosen_expected_loss: self
549 .chosen_expected_loss
550 .ok_or(BuilderError::MissingField {
551 field: "chosen_expected_loss",
552 })?,
553 calibration_score: self.calibration_score.ok_or(BuilderError::MissingField {
554 field: "calibration_score",
555 })?,
556 fallback_active: self.fallback_active,
557 top_features: self.top_features,
558 };
559
560 let errors = entry.validate();
561 if errors.is_empty() {
562 Ok(entry)
563 } else {
564 Err(BuilderError::Validation(errors))
565 }
566 }
567}
568
569#[cfg(test)]
574#[allow(clippy::float_cmp)]
575mod tests {
576 use super::*;
577
578 fn valid_builder() -> EvidenceLedgerBuilder {
579 EvidenceLedgerBuilder::new()
580 .ts_unix_ms(1_700_000_000_000)
581 .component("scheduler")
582 .action("preempt")
583 .posterior(vec![0.7, 0.2, 0.1])
584 .expected_loss("preempt", 0.05)
585 .expected_loss("continue", 0.3)
586 .expected_loss("defer", 0.15)
587 .chosen_expected_loss(0.05)
588 .calibration_score(0.92)
589 .fallback_active(false)
590 .top_feature("queue_depth", 0.45)
591 .top_feature("priority_gap", 0.30)
592 }
593
594 fn expect_validation(result: Result<EvidenceLedger, BuilderError>) -> Vec<ValidationError> {
595 match result.unwrap_err() {
596 BuilderError::Validation(errors) => errors,
597 BuilderError::MissingField { field } => {
598 panic!("expected Validation error, got MissingField({field})")
599 }
600 }
601 }
602
603 #[test]
604 fn validate_rejects_non_finite_top_feature_weights() {
605 for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
609 let errors = expect_validation(valid_builder().top_feature("poisoned", bad).build());
610 assert!(
611 errors.iter().any(|error| matches!(
612 error,
613 ValidationError::InvalidTopFeatureWeight { name, .. }
614 if name == "poisoned"
615 )),
616 "expected InvalidTopFeatureWeight for {bad}, got {errors:?}"
617 );
618 }
619 }
620
621 #[test]
622 fn validate_accepts_finite_top_feature_weights() {
623 let entry = valid_builder()
624 .top_feature("negative_is_fine", -0.25)
625 .top_feature("zero_is_fine", 0.0)
626 .build()
627 .expect("finite weights of any sign are valid");
628 assert!(entry.is_valid());
629 }
630
631 #[test]
632 fn builder_produces_valid_entry() {
633 let entry = valid_builder().build().expect("should build");
634 assert!(entry.is_valid());
635 assert_eq!(entry.ts_unix_ms, 1_700_000_000_000);
636 assert_eq!(entry.component, "scheduler");
637 assert_eq!(entry.action, "preempt");
638 assert_eq!(entry.posterior, vec![0.7, 0.2, 0.1]);
639 assert!(!entry.fallback_active);
640 assert_eq!(entry.top_features.len(), 2);
641 }
642
643 #[test]
644 fn serde_roundtrip_json() {
645 let entry = valid_builder().build().unwrap();
646 let json = serde_json::to_string(&entry).unwrap();
647 let parsed: EvidenceLedger = serde_json::from_str(&json).unwrap();
648 assert_eq!(entry.ts_unix_ms, parsed.ts_unix_ms);
649 assert_eq!(entry.component, parsed.component);
650 assert_eq!(entry.action, parsed.action);
651 assert_eq!(entry.posterior, parsed.posterior);
652 assert_eq!(entry.calibration_score, parsed.calibration_score);
653 assert_eq!(entry.chosen_expected_loss, parsed.chosen_expected_loss);
654 assert_eq!(entry.fallback_active, parsed.fallback_active);
655 assert_eq!(entry.top_features, parsed.top_features);
656 }
657
658 #[test]
659 fn serde_uses_short_field_names() {
660 let entry = valid_builder().build().unwrap();
661 let json = serde_json::to_string(&entry).unwrap();
662 assert!(json.contains("\"ts\":"));
663 assert!(json.contains("\"c\":"));
664 assert!(json.contains("\"a\":"));
665 assert!(json.contains("\"p\":"));
666 assert!(json.contains("\"el\":"));
667 assert!(json.contains("\"cel\":"));
668 assert!(json.contains("\"cal\":"));
669 assert!(json.contains("\"fb\":"));
670 assert!(json.contains("\"tf\":"));
671 assert!(!json.contains("\"ts_unix_ms\":"));
673 assert!(!json.contains("\"component\":"));
674 assert!(!json.contains("\"posterior\":"));
675 }
676
677 #[test]
678 fn validation_posterior_not_normalized() {
679 let errors = expect_validation(
680 valid_builder()
681 .posterior(vec![0.5, 0.2, 0.1]) .build(),
683 );
684 assert!(
685 errors
686 .iter()
687 .any(|e| matches!(e, ValidationError::PosteriorNotNormalized { .. }))
688 );
689 }
690
691 #[test]
692 fn validation_posterior_empty() {
693 let errors = expect_validation(valid_builder().posterior(vec![]).build());
694 assert!(
695 errors
696 .iter()
697 .any(|e| matches!(e, ValidationError::PosteriorEmpty))
698 );
699 }
700
701 #[test]
702 fn validation_negative_posterior_probability() {
703 let errors = expect_validation(valid_builder().posterior(vec![-0.1, 0.2, 0.9]).build());
704 assert!(errors.iter().any(|e| matches!(
705 e,
706 ValidationError::InvalidPosteriorProbability { index: 0, value }
707 if *value == -0.1
708 )));
709 }
710
711 #[test]
712 fn validation_non_finite_posterior_probability() {
713 let errors = expect_validation(valid_builder().posterior(vec![f64::NAN, 0.2, 0.8]).build());
714 assert!(errors.iter().any(|e| matches!(
715 e,
716 ValidationError::InvalidPosteriorProbability { index: 0, value }
717 if value.is_nan()
718 )));
719 }
720
721 #[test]
722 fn validation_calibration_out_of_range() {
723 let errors = expect_validation(valid_builder().calibration_score(1.5).build());
724 assert!(
725 errors
726 .iter()
727 .any(|e| matches!(e, ValidationError::CalibrationOutOfRange { .. }))
728 );
729 }
730
731 #[test]
732 fn validation_negative_expected_loss() {
733 let errors = expect_validation(valid_builder().expected_loss("bad_action", -0.1).build());
734 assert!(
735 errors
736 .iter()
737 .any(|e| matches!(e, ValidationError::NegativeExpectedLoss { .. }))
738 );
739 }
740
741 #[test]
742 fn validation_non_finite_expected_loss() {
743 let errors = expect_validation(
744 valid_builder()
745 .expected_loss("bad_action", f64::NAN)
746 .build(),
747 );
748 assert!(errors.iter().any(|e| matches!(
749 e,
750 ValidationError::InvalidExpectedLoss { action, value }
751 if action == "bad_action" && value.is_nan()
752 )));
753 }
754
755 #[test]
756 fn validation_negative_chosen_expected_loss() {
757 let errors = expect_validation(valid_builder().chosen_expected_loss(-0.01).build());
758 assert!(
759 errors
760 .iter()
761 .any(|e| matches!(e, ValidationError::NegativeChosenExpectedLoss { .. }))
762 );
763 }
764
765 #[test]
766 fn validation_non_finite_chosen_expected_loss() {
767 let errors = expect_validation(valid_builder().chosen_expected_loss(f64::INFINITY).build());
768 assert!(errors.iter().any(|e| matches!(
769 e,
770 ValidationError::InvalidChosenExpectedLoss { value } if value.is_infinite()
771 )));
772 }
773
774 #[test]
775 fn validation_missing_chosen_action_expected_loss() {
776 let errors = expect_validation(valid_builder().action("restart").build());
777 assert!(
778 errors
779 .iter()
780 .any(|e| matches!(e, ValidationError::ChosenActionMissingExpectedLoss { .. }))
781 );
782 }
783
784 #[test]
785 fn validation_chosen_expected_loss_mismatch() {
786 let errors = expect_validation(valid_builder().expected_loss("preempt", 0.20).build());
787 assert!(
788 errors
789 .iter()
790 .any(|e| matches!(e, ValidationError::ChosenExpectedLossMismatch { .. }))
791 );
792 }
793
794 #[test]
795 fn validation_empty_component() {
796 let errors = expect_validation(valid_builder().component("").build());
797 assert!(
798 errors
799 .iter()
800 .any(|e| matches!(e, ValidationError::EmptyComponent))
801 );
802 }
803
804 #[test]
805 fn validation_empty_action() {
806 let errors = expect_validation(valid_builder().action("").build());
807 assert!(
808 errors
809 .iter()
810 .any(|e| matches!(e, ValidationError::EmptyAction))
811 );
812 }
813
814 #[test]
815 fn builder_missing_required_field() {
816 let result = EvidenceLedgerBuilder::new()
817 .component("x")
818 .action("y")
819 .posterior(vec![1.0])
820 .chosen_expected_loss(0.0)
821 .calibration_score(0.5)
822 .build();
823 let err = result.unwrap_err();
824 assert!(matches!(
825 err,
826 BuilderError::MissingField {
827 field: "ts_unix_ms"
828 }
829 ));
830 }
831
832 #[test]
833 fn builder_default_fallback_is_false() {
834 let entry = valid_builder().build().unwrap();
835 assert!(!entry.fallback_active);
836 }
837
838 #[test]
839 fn builder_fallback_active_true() {
840 let entry = valid_builder().fallback_active(true).build().unwrap();
841 assert!(entry.fallback_active);
842 }
843
844 #[test]
845 fn posterior_tolerance_accepts_near_one() {
846 let entry = valid_builder()
848 .posterior(vec![0.5, 0.3, 0.199_999_5])
849 .build();
850 assert!(entry.is_ok());
851 }
852
853 #[test]
854 fn posterior_tolerance_rejects_beyond() {
855 let result = valid_builder().posterior(vec![0.5, 0.3, 0.1]).build();
857 assert!(result.is_err());
858 }
859
860 #[test]
861 fn derive_clone_and_debug() {
862 let entry = valid_builder().build().unwrap();
863 let cloned = entry.clone();
864 assert_eq!(format!("{entry:?}"), format!("{cloned:?}"));
865 }
866
867 #[test]
868 fn jsonl_compact_output() {
869 let entry = valid_builder().build().unwrap();
870 let line = serde_json::to_string(&entry).unwrap();
871 assert!(!line.contains('\n'));
873 assert!(
875 line.len() < 300,
876 "JSONL line too large: {} bytes",
877 line.len()
878 );
879 }
880
881 #[test]
882 fn compact_json_snapshot() {
883 let entry = valid_builder().build().unwrap();
884 let line = serde_json::to_string(&entry).unwrap();
885 insta::assert_snapshot!("evidence_ledger_compact_json", line);
886 }
887
888 #[test]
889 fn deserialize_from_known_json() {
890 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]]}"#;
891 let entry: EvidenceLedger = serde_json::from_str(json).unwrap();
892 assert_eq!(entry.ts_unix_ms, 1_700_000_000_000);
893 assert_eq!(entry.component, "test");
894 assert_eq!(entry.action, "act");
895 assert_eq!(entry.posterior, vec![0.6, 0.4]);
896 assert_eq!(entry.calibration_score, 0.8);
897 assert!(!entry.fallback_active);
898 assert_eq!(entry.top_features, vec![("feat".to_string(), 0.9)]);
899 }
900
901 #[test]
902 fn deserialize_invalid_json_rejected() {
903 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]]}"#;
904 let err = serde_json::from_str::<EvidenceLedger>(json).unwrap_err();
905 assert!(err.to_string().contains("invalid evidence ledger"));
906 }
907
908 #[test]
909 fn validation_error_display() {
910 let err = ValidationError::PosteriorNotNormalized { sum: 0.5 };
911 let msg = format!("{err}");
912 assert!(msg.contains("0.5"));
913 assert!(msg.contains("~1.0"));
914 }
915
916 #[test]
917 fn builder_error_display() {
918 let err = BuilderError::MissingField { field: "component" };
919 let msg = format!("{err}");
920 assert!(msg.contains("component"));
921 }
922}