1use std::fmt;
53
54use serde::{Deserialize, Serialize};
55
56use crate::integrity_canary::probes::ProbeFinding;
57
58pub const RISK_SUSPECTED_THRESHOLD_DEFAULT: f64 = 0.30;
63
64pub const RISK_CONFIRMED_THRESHOLD_DEFAULT: f64 = 0.65;
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum IntegrityRiskClassification {
88 Clean,
90 Suspected,
93 Confirmed,
96}
97
98impl IntegrityRiskClassification {
99 #[must_use]
101 pub const fn label(self) -> &'static str {
102 match self {
103 Self::Clean => "clean",
104 Self::Suspected => "suspected",
105 Self::Confirmed => "confirmed",
106 }
107 }
108}
109
110impl fmt::Display for IntegrityRiskClassification {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 f.write_str(self.label())
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
123pub struct IntegrityRiskScore {
124 pub value: f64,
126 pub classification: IntegrityRiskClassification,
128 pub contributing_findings: usize,
131 pub skipped_findings: usize,
133}
134
135impl IntegrityRiskScore {
136 #[must_use]
138 pub const fn clean() -> Self {
139 Self {
140 value: 0.0,
141 classification: IntegrityRiskClassification::Clean,
142 contributing_findings: 0,
143 skipped_findings: 0,
144 }
145 }
146
147 #[must_use]
149 pub const fn value(&self) -> f64 {
150 self.value
151 }
152
153 #[must_use]
155 pub const fn classification(&self) -> IntegrityRiskClassification {
156 self.classification
157 }
158
159 #[must_use]
163 pub const fn is_trap_signal(&self) -> bool {
164 !matches!(self.classification, IntegrityRiskClassification::Clean)
165 }
166
167 #[must_use]
170 pub const fn is_confirmed(&self) -> bool {
171 matches!(self.classification, IntegrityRiskClassification::Confirmed)
172 }
173
174 #[must_use]
181 pub fn compute(findings: &[ProbeFinding], policy: &IntegrityCanaryPolicy) -> Self {
182 let mut numerator = 0.0;
183 let mut denominator = 0.0;
184 let mut contributing = 0usize;
185 let mut skipped = 0usize;
186 for f in findings {
187 if f.outcome.contributes() {
188 numerator = f.weight.mul_add(f.outcome.severity(), numerator);
189 denominator += f.weight;
190 contributing += 1;
191 } else {
192 skipped += 1;
193 }
194 }
195 let raw = if denominator <= 0.0 {
196 0.0
197 } else {
198 numerator / denominator
199 };
200 let value = clamp_unit(raw);
201 let classification = policy.classify(value);
202 Self {
203 value,
204 classification,
205 contributing_findings: contributing,
206 skipped_findings: skipped,
207 }
208 }
209
210 #[must_use]
215 pub fn classify_for(value: f64, policy: &IntegrityCanaryPolicy) -> IntegrityRiskClassification {
216 policy.classify(value)
217 }
218}
219
220const fn clamp_unit(value: f64) -> f64 {
221 if value.is_nan() {
226 return 0.0;
227 }
228 value.clamp(0.0, 1.0)
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
239pub struct IntegrityCanaryPolicy {
240 pub suspected_threshold: f64,
242 pub confirmed_threshold: f64,
244}
245
246impl Default for IntegrityCanaryPolicy {
247 fn default() -> Self {
248 Self {
249 suspected_threshold: RISK_SUSPECTED_THRESHOLD_DEFAULT,
250 confirmed_threshold: RISK_CONFIRMED_THRESHOLD_DEFAULT,
251 }
252 }
253}
254
255impl IntegrityCanaryPolicy {
256 #[must_use]
264 pub fn with_thresholds(suspected_threshold: f64, confirmed_threshold: f64) -> Self {
265 #[allow(clippy::expect_used)]
269 Self::try_with_thresholds(suspected_threshold, confirmed_threshold)
270 .expect("integrity canary thresholds must be finite and strictly ordered")
271 }
272
273 pub fn try_with_thresholds(
283 suspected_threshold: f64,
284 confirmed_threshold: f64,
285 ) -> Result<Self, IntegrityCanaryPolicyError> {
286 if suspected_threshold.is_nan() || confirmed_threshold.is_nan() {
287 return Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
288 "thresholds must be finite (suspected={suspected_threshold}, confirmed={confirmed_threshold})"
289 )));
290 }
291 match suspected_threshold.partial_cmp(&confirmed_threshold) {
295 Some(std::cmp::Ordering::Less) => {}
296 _ => {
297 return Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
298 "suspected_threshold ({suspected_threshold}) must be strictly less than confirmed_threshold ({confirmed_threshold})"
299 )));
300 }
301 }
302 Ok(Self {
303 suspected_threshold: clamp_unit(suspected_threshold),
304 confirmed_threshold: clamp_unit(confirmed_threshold),
305 })
306 }
307
308 #[must_use]
311 pub fn classify(&self, value: f64) -> IntegrityRiskClassification {
312 let v = clamp_unit(value);
313 if v.is_nan() {
314 return IntegrityRiskClassification::Clean;
315 }
316 if v >= self.confirmed_threshold {
317 IntegrityRiskClassification::Confirmed
318 } else if v >= self.suspected_threshold {
319 IntegrityRiskClassification::Suspected
320 } else {
321 IntegrityRiskClassification::Clean
322 }
323 }
324
325 pub fn validate(&self) -> Result<(), IntegrityCanaryPolicyError> {
332 if self.suspected_threshold.is_nan() || self.confirmed_threshold.is_nan() {
333 return Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
334 "thresholds must be finite (suspected={}, confirmed={})",
335 self.suspected_threshold, self.confirmed_threshold
336 )));
337 }
338 match self
342 .suspected_threshold
343 .partial_cmp(&self.confirmed_threshold)
344 {
345 Some(std::cmp::Ordering::Less) => Ok(()),
346 _ => Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
347 "suspected_threshold ({}) must be strictly less than confirmed_threshold ({})",
348 self.suspected_threshold, self.confirmed_threshold
349 ))),
350 }
351 }
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum IntegrityCanaryPolicyError {
357 InvalidThresholds(String),
360}
361
362impl fmt::Display for IntegrityCanaryPolicyError {
363 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364 match self {
365 Self::InvalidThresholds(msg) => {
366 write!(f, "integrity canary thresholds invalid: {msg}")
367 }
368 }
369 }
370}
371
372impl std::error::Error for IntegrityCanaryPolicyError {}
373
374#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381pub struct IntegrityCanaryReport {
382 pub score: IntegrityRiskScore,
384 pub policy: IntegrityCanaryPolicy,
388 pub findings: Vec<ProbeFinding>,
390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
395 pub mitigation_hints: Vec<MitigationHint>,
396 #[serde(default, skip_serializing_if = "Vec::is_empty")]
400 pub trap_findings: Vec<ProbeFinding>,
401}
402
403impl IntegrityCanaryReport {
404 #[must_use]
407 pub fn from_findings(findings: Vec<ProbeFinding>) -> Self {
408 Self::with_policy(findings, IntegrityCanaryPolicy::default())
409 }
410
411 #[must_use]
417 pub fn with_policy(findings: Vec<ProbeFinding>, policy: IntegrityCanaryPolicy) -> Self {
418 let score = IntegrityRiskScore::compute(&findings, &policy);
419 let trap_findings: Vec<ProbeFinding> =
420 findings.iter().filter(|f| f.is_trap()).cloned().collect();
421 let mitigation_hints: Vec<MitigationHint> = trap_findings
422 .iter()
423 .filter(|f| !f.mitigation_hint.is_empty())
424 .map(|f| MitigationHint {
425 probe_id: f.id.clone(),
426 outcome: f.outcome,
427 hint: f.mitigation_hint.clone(),
428 })
429 .collect();
430 Self {
431 score,
432 policy,
433 findings,
434 mitigation_hints,
435 trap_findings,
436 }
437 }
438
439 #[must_use]
442 pub const fn is_confirmed(&self) -> bool {
443 self.score.is_confirmed()
444 }
445
446 #[must_use]
450 pub const fn has_trap_signal(&self) -> bool {
451 self.score.is_trap_signal()
452 }
453
454 #[must_use]
456 pub const fn trap_count(&self) -> usize {
457 self.trap_findings.len()
458 }
459
460 #[must_use]
462 pub fn confirmed_count(&self) -> usize {
463 self.findings.iter().filter(|f| f.is_confirmed()).count()
464 }
465}
466
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475pub struct MitigationHint {
476 pub probe_id: String,
478 pub outcome: crate::integrity_canary::probes::IntegrityProbeOutcome,
480 pub hint: String,
482}
483
484#[cfg(test)]
487#[allow(
488 clippy::unwrap_used,
489 clippy::expect_used,
490 clippy::panic,
491 clippy::float_cmp,
492 clippy::indexing_slicing
493)]
494mod tests {
495 use super::*;
496 use crate::integrity_canary::probes::{
497 IntegrityProbe, IntegrityProbeId, IntegrityProbeOutcome, ProbeFinding, all_probes,
498 };
499
500 fn finding(id: &str, weight: f64, outcome: IntegrityProbeOutcome, hint: &str) -> ProbeFinding {
501 ProbeFinding {
502 id: id.to_string(),
503 outcome,
504 weight,
505 evidence: "test".to_string(),
506 mitigation_hint: hint.to_string(),
507 }
508 }
509
510 fn trap_finding(id: &str, weight: f64) -> ProbeFinding {
511 finding(id, weight, IntegrityProbeOutcome::TrapConfirmed, "hint")
512 }
513
514 fn suspected_finding(id: &str, weight: f64) -> ProbeFinding {
515 finding(id, weight, IntegrityProbeOutcome::TrapSuspected, "hint")
516 }
517
518 #[test]
519 fn empty_findings_produces_clean_score() {
520 let report = IntegrityCanaryReport::from_findings(Vec::new());
521 assert_eq!(report.score.value, 0.0);
522 assert_eq!(
523 report.score.classification,
524 IntegrityRiskClassification::Clean
525 );
526 assert!(!report.has_trap_signal());
527 assert!(report.mitigation_hints.is_empty());
528 assert!(report.trap_findings.is_empty());
529 }
530
531 #[test]
532 fn all_clean_findings_produces_zero_score() {
533 let findings = all_probes()
534 .iter()
535 .map(|p| finding(p.id.label(), p.weight, IntegrityProbeOutcome::Clean, ""))
536 .collect();
537 let report = IntegrityCanaryReport::from_findings(findings);
538 assert_eq!(report.score.value, 0.0);
539 assert_eq!(
540 report.score.classification,
541 IntegrityRiskClassification::Clean
542 );
543 assert_eq!(report.score.contributing_findings, 8);
544 assert_eq!(report.score.skipped_findings, 0);
545 }
546
547 #[test]
548 fn all_confirmed_findings_produces_full_score() {
549 let findings = all_probes()
550 .iter()
551 .map(|p| {
552 finding(
553 p.id.label(),
554 p.weight,
555 IntegrityProbeOutcome::TrapConfirmed,
556 "",
557 )
558 })
559 .collect();
560 let report = IntegrityCanaryReport::from_findings(findings);
561 assert!(
562 (report.score.value - 1.0).abs() < 1e-9,
563 "score must be 1.0, got: {}",
564 report.score.value
565 );
566 assert_eq!(
567 report.score.classification,
568 IntegrityRiskClassification::Confirmed
569 );
570 assert_eq!(report.confirmed_count(), 8);
571 assert!(report.is_confirmed());
572 }
573
574 #[test]
575 fn mixed_outcomes_weighted_average() {
576 let mut findings = Vec::new();
580 for (i, p) in all_probes().iter().enumerate() {
581 if i < 4 {
582 findings.push(finding(
583 p.id.label(),
584 p.weight,
585 IntegrityProbeOutcome::Clean,
586 "",
587 ));
588 } else {
589 findings.push(trap_finding(p.id.label(), p.weight));
590 }
591 }
592 let report = IntegrityCanaryReport::from_findings(findings);
593 assert!(
594 (report.score.value - 0.44).abs() < 1e-6,
595 "4 clean + 4 confirmed: score must be 0.44, got: {}",
596 report.score.value
597 );
598 assert_eq!(
599 report.score.classification,
600 IntegrityRiskClassification::Suspected
601 );
602 }
603
604 #[test]
605 fn suspected_findings_yield_half_score() {
606 let findings = all_probes()
609 .iter()
610 .map(|p| suspected_finding(p.id.label(), p.weight))
611 .collect();
612 let report = IntegrityCanaryReport::from_findings(findings);
613 assert!(
614 (report.score.value - 0.5).abs() < 1e-9,
615 "all-suspected must be 0.5, got: {}",
616 report.score.value
617 );
618 }
619
620 #[test]
621 fn single_confirmed_on_largest_probe_gives_weighted_score() {
622 let mut findings = Vec::new();
631 for (i, p) in all_probes().iter().enumerate() {
632 if i == 0 {
633 findings.push(trap_finding(p.id.label(), p.weight));
634 } else {
635 findings.push(finding(
636 p.id.label(),
637 p.weight,
638 IntegrityProbeOutcome::Clean,
639 "",
640 ));
641 }
642 }
643 let report = IntegrityCanaryReport::from_findings(findings);
644 assert!(
645 (report.score.value - 0.20).abs() < 1e-9,
646 "single confirmed trap on the largest-weight probe: score must be 0.20, got: {}",
647 report.score.value
648 );
649 assert_eq!(
650 report.score.classification,
651 IntegrityRiskClassification::Clean,
652 "single confirmed trap on the largest-weight probe must remain Clean (below 0.30)"
653 );
654 assert_eq!(report.confirmed_count(), 1);
655 }
656
657 #[test]
658 fn skipped_findings_excluded_from_denominator() {
659 let mut findings = Vec::new();
663 let mut weights_kept = 0.0;
664 for (i, p) in all_probes().iter().enumerate() {
665 if i < 4 {
666 findings.push(trap_finding(p.id.label(), p.weight));
667 weights_kept += p.weight;
668 } else {
669 findings.push(finding(
670 p.id.label(),
671 p.weight,
672 IntegrityProbeOutcome::Skipped,
673 "",
674 ));
675 }
676 }
677 let report = IntegrityCanaryReport::from_findings(findings);
678 assert!(
679 (report.score.value - 1.0).abs() < 1e-9,
680 "skipped findings must not pull score toward 0, got: {}",
681 report.score.value
682 );
683 assert_eq!(report.score.skipped_findings, 4);
684 assert_eq!(report.score.contributing_findings, 4);
685 let _ = weights_kept; }
687
688 #[test]
689 fn threshold_distinguishes_suspected_from_confirmed() {
690 let findings = vec![
694 finding("a", 0.125, IntegrityProbeOutcome::Clean, ""),
695 finding("b", 0.125, IntegrityProbeOutcome::Clean, ""),
696 finding("c", 0.125, IntegrityProbeOutcome::Clean, ""),
697 finding("d", 0.125, IntegrityProbeOutcome::Clean, ""),
698 suspected_finding("e", 0.125),
699 suspected_finding("f", 0.125),
700 suspected_finding("g", 0.125),
701 suspected_finding("h", 0.125),
702 ];
703 let report = IntegrityCanaryReport::from_findings(findings);
704 assert!(
706 (report.score.value - 0.25).abs() < 1e-9,
707 "score must be 0.25, got: {}",
708 report.score.value
709 );
710 assert_eq!(
712 report.score.classification,
713 IntegrityRiskClassification::Clean
714 );
715
716 let findings = vec![
719 finding("a", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
720 finding("b", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
721 finding("c", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
722 finding("d", 0.125, IntegrityProbeOutcome::Clean, ""),
723 suspected_finding("e", 0.125),
724 suspected_finding("f", 0.125),
725 suspected_finding("g", 0.125),
726 suspected_finding("h", 0.125),
727 ];
728 let report = IntegrityCanaryReport::from_findings(findings);
729 assert!(
730 (report.score.value - 0.625).abs() < 1e-9,
731 "score must be 0.625, got: {}",
732 report.score.value
733 );
734 assert_eq!(
735 report.score.classification,
736 IntegrityRiskClassification::Suspected,
737 "0.625 must be Suspected (above 0.30, below 0.65)"
738 );
739
740 let findings = vec![
742 finding("a", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
743 finding("b", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
744 finding("c", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
745 finding("d", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
746 suspected_finding("e", 0.125),
747 suspected_finding("f", 0.125),
748 suspected_finding("g", 0.125),
749 suspected_finding("h", 0.125),
750 ];
751 let report = IntegrityCanaryReport::from_findings(findings);
752 assert!(
753 (report.score.value - 0.75).abs() < 1e-9,
754 "score must be 0.75, got: {}",
755 report.score.value
756 );
757 assert_eq!(
758 report.score.classification,
759 IntegrityRiskClassification::Confirmed,
760 "0.75 must be Confirmed (above 0.65)"
761 );
762 }
763
764 #[test]
765 fn policy_with_lower_thresholds_tightens_classification() {
766 let policy = IntegrityCanaryPolicy::try_with_thresholds(0.10, 0.40).expect("policy");
767 let findings = vec![finding("a", 1.0, IntegrityProbeOutcome::Clean, "")];
768 let score = IntegrityRiskScore::compute(&findings, &policy);
769 assert_eq!(score.value, 0.0);
770 assert_eq!(score.classification, IntegrityRiskClassification::Clean);
771
772 let findings = vec![finding("a", 1.0, IntegrityProbeOutcome::TrapSuspected, "")];
774 let score = IntegrityRiskScore::compute(&findings, &policy);
775 assert!((score.value - 0.5).abs() < 1e-9);
776 assert_eq!(score.classification, IntegrityRiskClassification::Confirmed);
777 }
778
779 #[test]
780 fn policy_rejects_reversed_or_equal_thresholds() {
781 let err = IntegrityCanaryPolicy::try_with_thresholds(0.50, 0.50).unwrap_err();
782 assert!(matches!(
783 err,
784 IntegrityCanaryPolicyError::InvalidThresholds(_)
785 ));
786 let err = IntegrityCanaryPolicy::try_with_thresholds(0.70, 0.30).unwrap_err();
787 assert!(matches!(
788 err,
789 IntegrityCanaryPolicyError::InvalidThresholds(_)
790 ));
791 }
792
793 #[test]
794 fn policy_rejects_nan_thresholds() {
795 let err = IntegrityCanaryPolicy::try_with_thresholds(f64::NAN, 0.65).unwrap_err();
796 assert!(matches!(
797 err,
798 IntegrityCanaryPolicyError::InvalidThresholds(_)
799 ));
800 let err = IntegrityCanaryPolicy::try_with_thresholds(0.30, f64::NAN).unwrap_err();
801 assert!(matches!(
802 err,
803 IntegrityCanaryPolicyError::InvalidThresholds(_)
804 ));
805 }
806
807 #[test]
808 fn trap_findings_and_hints_populated_for_fired_probes() {
809 let findings = vec![
810 trap_finding("webdriver_descriptor_native", 0.20),
811 ProbeFinding {
812 id: "performance_now_resolution".to_string(),
813 outcome: IntegrityProbeOutcome::TrapSuspected,
814 weight: 0.14,
815 evidence: "deviation detected".to_string(),
816 mitigation_hint: "Apply continuous jitter".to_string(),
817 },
818 finding(
819 "error_to_string_native",
820 0.08,
821 IntegrityProbeOutcome::Clean,
822 "",
823 ),
824 ];
825 let report = IntegrityCanaryReport::from_findings(findings);
826 assert_eq!(report.trap_count(), 2);
827 assert_eq!(report.confirmed_count(), 1);
828 assert_eq!(report.mitigation_hints.len(), 2);
829 let clean_hint = report
831 .mitigation_hints
832 .iter()
833 .find(|h| h.probe_id == "error_to_string_native");
834 assert!(clean_hint.is_none());
835 }
836
837 #[test]
838 fn nan_score_classifies_as_clean() {
839 let policy = IntegrityCanaryPolicy::default();
840 assert_eq!(
841 IntegrityRiskScore::classify_for(f64::NAN, &policy),
842 IntegrityRiskClassification::Clean
843 );
844 assert_eq!(
845 policy.classify(f64::NAN),
846 IntegrityRiskClassification::Clean
847 );
848 }
849
850 #[test]
851 fn score_outside_unit_interval_is_clamped() {
852 let policy = IntegrityCanaryPolicy::default();
853 assert_eq!(policy.classify(1.5), IntegrityRiskClassification::Confirmed);
854 assert_eq!(policy.classify(-0.5), IntegrityRiskClassification::Clean);
855 }
856
857 #[test]
858 fn confirmed_finding_helper_attaches_hint() {
859 let probe = IntegrityProbe::confirmed_finding("test_probe", 0.10, "evidence");
860 let report = IntegrityCanaryReport::from_findings(vec![probe]);
861 assert!(report.is_confirmed());
862 assert_eq!(report.trap_count(), 1);
863 }
864
865 #[test]
866 fn probe_id_label_is_stable_for_trend_seam() {
867 let id = IntegrityProbeId::WebDriverDescriptorNative;
868 assert_eq!(id.label(), "webdriver_descriptor_native");
869 }
870
871 #[test]
872 fn mitigation_hints_carry_outcome_label() {
873 let findings = vec![ProbeFinding {
874 id: "test".to_string(),
875 outcome: IntegrityProbeOutcome::TrapConfirmed,
876 weight: 0.20,
877 evidence: "x".to_string(),
878 mitigation_hint: "apply native descriptor".to_string(),
879 }];
880 let report = IntegrityCanaryReport::from_findings(findings);
881 assert_eq!(report.mitigation_hints.len(), 1);
882 assert_eq!(
883 report.mitigation_hints[0].outcome,
884 IntegrityProbeOutcome::TrapConfirmed
885 );
886 assert_eq!(report.mitigation_hints[0].probe_id, "test");
887 }
888
889 #[test]
890 fn report_serializes_with_snake_case_keys() {
891 let report = IntegrityCanaryReport::from_findings(vec![trap_finding("a", 1.0)]);
892 let json = serde_json::to_string(&report).expect("serialize");
893 assert!(json.contains("\"score\""), "got: {json}");
894 assert!(json.contains("\"classification\""), "got: {json}");
895 assert!(json.contains("\"contributing_findings\""), "got: {json}");
896 assert!(json.contains("\"skipped_findings\""), "got: {json}");
897 assert!(json.contains("\"findings\""), "got: {json}");
898 assert!(json.contains("\"trap_findings\""), "got: {json}");
899 assert!(json.contains("\"mitigation_hints\""), "got: {json}");
900 assert!(json.contains("\"suspected_threshold\""), "got: {json}");
901 assert!(json.contains("\"confirmed_threshold\""), "got: {json}");
902 }
903
904 #[test]
905 fn report_roundtrips_through_json() {
906 let findings = vec![
907 trap_finding("a", 0.5),
908 finding("b", 0.5, IntegrityProbeOutcome::Clean, ""),
909 ];
910 let report = IntegrityCanaryReport::from_findings(findings);
911 let json = serde_json::to_string(&report).expect("serialize");
912 let restored: IntegrityCanaryReport = serde_json::from_str(&json).expect("deserialize");
913 assert_eq!(restored, report);
914 }
915
916 #[test]
917 fn empty_report_omits_helper_fields_in_json() {
918 let report = IntegrityCanaryReport::from_findings(Vec::new());
919 let json = serde_json::to_string(&report).expect("serialize");
920 assert!(
921 !json.contains("mitigation_hints"),
922 "empty report must omit mitigation_hints: {json}"
923 );
924 assert!(
925 !json.contains("trap_findings"),
926 "empty report must omit trap_findings: {json}"
927 );
928 }
929}