1use crate::model::{CanonicalId, Component, ComponentRef, DependencyEdge, VulnerabilityRef};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7fn severity_rank(s: &str) -> u8 {
12 match s.to_lowercase().as_str() {
13 "critical" => 4,
14 "high" => 3,
15 "medium" => 2,
16 "low" => 1,
17 _ => 0,
18 }
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23#[must_use]
24pub struct DiffResult {
25 pub summary: DiffSummary,
27 pub components: ChangeSet<ComponentChange>,
29 pub dependencies: ChangeSet<DependencyChange>,
31 pub licenses: LicenseChanges,
33 pub vulnerabilities: VulnerabilityChanges,
35 pub semantic_score: f64,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
44 pub metadata_changes: Vec<MetadataChange>,
45 #[serde(default)]
47 pub graph_changes: Vec<DependencyGraphChange>,
48 #[serde(default)]
50 pub graph_summary: Option<GraphChangeSummary>,
51 #[serde(default)]
53 pub rules_applied: usize,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub quality_delta: Option<QualityDelta>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub match_metrics: Option<MatchMetrics>,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub ml_regressions: Vec<MlRegression>,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct MlRegression {
67 pub component: String,
68 pub metric: String,
69 pub previous_value: f64,
70 pub new_value: f64,
71}
72
73#[must_use]
78pub fn ml_metric_higher_is_better(metric: &str) -> Option<bool> {
79 let metric = metric.split('@').next().unwrap_or(metric);
80 match metric {
81 "accuracy" | "f1" | "f1_score" | "precision" | "recall" | "auc" | "roc_auc" | "bleu"
82 | "rouge" => Some(true),
83 "loss" | "error" | "error_rate" | "perplexity" | "latency" | "latency_ms" => Some(false),
84 _ => None,
85 }
86}
87
88impl DiffResult {
89 pub fn new() -> Self {
91 Self {
92 summary: DiffSummary::default(),
93 components: ChangeSet::new(),
94 dependencies: ChangeSet::new(),
95 licenses: LicenseChanges::default(),
96 vulnerabilities: VulnerabilityChanges::default(),
97 semantic_score: 0.0,
98 metadata_changes: Vec::new(),
99 graph_changes: Vec::new(),
100 graph_summary: None,
101 rules_applied: 0,
102 quality_delta: None,
103 match_metrics: None,
104 ml_regressions: Vec::new(),
105 }
106 }
107
108 pub fn calculate_summary(&mut self) {
110 self.summary.components_added = self.components.added.len();
111 self.summary.components_removed = self.components.removed.len();
112 self.summary.components_modified = self
115 .components
116 .modified
117 .iter()
118 .filter(|c| c.change_type != ChangeType::Unchanged)
119 .count();
120
121 self.summary.dependencies_added = self.dependencies.added.len();
122 self.summary.dependencies_removed = self.dependencies.removed.len();
123 self.summary.graph_changes_count = self.graph_changes.len();
124 self.summary.metadata_changes_count = self.metadata_changes.len();
125
126 self.summary.total_changes = self.summary.components_added
127 + self.summary.components_removed
128 + self.summary.components_modified
129 + self.summary.dependencies_added
130 + self.summary.dependencies_removed
131 + self.summary.graph_changes_count
132 + self.summary.metadata_changes_count;
133
134 self.summary.vulnerabilities_introduced = self.vulnerabilities.introduced.len();
135 self.summary.vulnerabilities_resolved = self.vulnerabilities.resolved.len();
136 self.summary.vulnerabilities_persistent = self.vulnerabilities.persistent.len();
137
138 self.summary.licenses_added = self.licenses.new_licenses.len();
139 self.summary.licenses_removed = self.licenses.removed_licenses.len();
140 }
141
142 #[must_use]
150 pub fn has_changes(&self) -> bool {
151 self.summary.total_changes > 0
152 || !self.components.added.is_empty()
153 || !self.components.removed.is_empty()
154 || self
155 .components
156 .modified
157 .iter()
158 .any(|c| c.change_type != ChangeType::Unchanged)
159 || !self.dependencies.is_empty()
160 || !self.graph_changes.is_empty()
161 || !self.metadata_changes.is_empty()
162 || !self.vulnerabilities.introduced.is_empty()
163 || !self.vulnerabilities.resolved.is_empty()
164 }
165
166 pub fn refresh_derived_day_counts(&mut self) -> bool {
173 let today = chrono::Utc::now().date_naive();
174 let mut changed = false;
175 for detail in self
176 .vulnerabilities
177 .introduced
178 .iter_mut()
179 .chain(self.vulnerabilities.resolved.iter_mut())
180 .chain(self.vulnerabilities.persistent.iter_mut())
181 {
182 changed |= detail.refresh_day_counts(today);
183 }
184 changed
185 }
186
187 #[must_use]
189 pub fn day_counts_stale(&self) -> bool {
190 let today = chrono::Utc::now().date_naive();
191 self.vulnerabilities
192 .introduced
193 .iter()
194 .chain(self.vulnerabilities.resolved.iter())
195 .chain(self.vulnerabilities.persistent.iter())
196 .any(|detail| {
197 let mut probe = detail.clone();
198 probe.refresh_day_counts(today)
199 })
200 }
201
202 #[must_use]
204 pub fn find_component_by_id(&self, id: &CanonicalId) -> Option<&ComponentChange> {
205 let id_str = id.value();
206 self.components
207 .added
208 .iter()
209 .chain(self.components.removed.iter())
210 .chain(self.components.modified.iter())
211 .find(|c| c.id == id_str)
212 }
213
214 #[must_use]
216 pub fn find_component_by_id_str(&self, id_str: &str) -> Option<&ComponentChange> {
217 self.components
218 .added
219 .iter()
220 .chain(self.components.removed.iter())
221 .chain(self.components.modified.iter())
222 .find(|c| c.id == id_str)
223 }
224
225 #[must_use]
227 pub fn all_component_changes(&self) -> Vec<&ComponentChange> {
228 self.components
229 .added
230 .iter()
231 .chain(self.components.removed.iter())
232 .chain(self.components.modified.iter())
233 .collect()
234 }
235
236 #[must_use]
238 pub fn find_vulns_for_component(
239 &self,
240 component_id: &CanonicalId,
241 ) -> Vec<&VulnerabilityDetail> {
242 let id_str = component_id.value();
243 self.vulnerabilities
244 .introduced
245 .iter()
246 .chain(self.vulnerabilities.resolved.iter())
247 .chain(self.vulnerabilities.persistent.iter())
248 .filter(|v| v.component_id == id_str)
249 .collect()
250 }
251
252 #[must_use]
254 pub fn build_component_id_index(&self) -> HashMap<String, &ComponentChange> {
255 self.components
256 .added
257 .iter()
258 .chain(&self.components.removed)
259 .chain(&self.components.modified)
260 .map(|c| (c.id.clone(), c))
261 .collect()
262 }
263
264 pub fn filter_by_severity(&mut self, min_severity: &str) {
266 let min_sev = severity_rank(min_severity);
267
268 self.vulnerabilities
269 .introduced
270 .retain(|v| severity_rank(&v.severity) >= min_sev);
271 self.vulnerabilities
272 .resolved
273 .retain(|v| severity_rank(&v.severity) >= min_sev);
274 self.vulnerabilities
275 .persistent
276 .retain(|v| severity_rank(&v.severity) >= min_sev);
277
278 self.calculate_summary();
280 }
281
282 pub fn filter_by_vex(&mut self) {
286 self.vulnerabilities
287 .introduced
288 .retain(VulnerabilityDetail::is_vex_actionable);
289 self.vulnerabilities
290 .resolved
291 .retain(VulnerabilityDetail::is_vex_actionable);
292 self.vulnerabilities
293 .persistent
294 .retain(VulnerabilityDetail::is_vex_actionable);
295
296 self.calculate_summary();
297 }
298}
299
300impl Default for DiffResult {
301 fn default() -> Self {
302 Self::new()
303 }
304}
305
306#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311pub struct QualityDelta {
312 pub overall_score_delta: f32,
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub old_grade: Option<crate::quality::QualityGrade>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub new_grade: Option<crate::quality::QualityGrade>,
320 pub category_deltas: Vec<CategoryDelta>,
322 pub regressions: Vec<String>,
324 pub improvements: Vec<String>,
326 pub violation_count_delta: i32,
328}
329
330#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
332pub struct CategoryDelta {
333 pub category: String,
335 pub old_score: f32,
337 pub new_score: f32,
339 pub delta: f32,
341}
342
343fn cbom_slot_scores(report: &crate::quality::QualityReport) -> Option<[Option<f32>; 8]> {
354 if report.profile != crate::quality::ScoringProfile::Cbom
355 || !report.cryptography_metrics.has_data()
356 {
357 return None;
358 }
359 let cm = &report.cryptography_metrics;
360 Some([
361 Some(cm.crypto_completeness_score()),
362 Some(cm.crypto_identifier_score()),
363 Some(cm.algorithm_strength_score()),
364 Some(cm.crypto_dependency_score()),
365 Some(cm.crypto_lifecycle_score()),
366 cm.pqc_readiness_score(),
367 Some(report.provenance_score),
368 Some(report.license_score),
369 ])
370}
371
372impl QualityDelta {
373 #[must_use]
382 pub fn from_reports(
383 old: &crate::quality::QualityReport,
384 new: &crate::quality::QualityReport,
385 ) -> Self {
386 let category_deltas: Vec<CategoryDelta> =
387 match (cbom_slot_scores(old), cbom_slot_scores(new)) {
388 (Some(old_slots), Some(new_slots)) => {
389 crate::quality::CryptographyMetrics::cbom_category_names()
390 .iter()
391 .zip(old_slots.iter().zip(new_slots.iter()))
392 .filter_map(|(name, (old_s, new_s))| match (old_s, new_s) {
395 (Some(o), Some(n)) => Some(CategoryDelta {
396 category: (*name).to_string(),
397 old_score: *o,
398 new_score: *n,
399 delta: n - o,
400 }),
401 _ => None,
402 })
403 .collect()
404 }
405 _ => Self::standard_category_deltas(old, new),
408 };
409
410 let regressions: Vec<String> = category_deltas
411 .iter()
412 .filter(|d| d.delta < -1.0)
413 .map(|d| d.category.clone())
414 .collect();
415
416 let improvements: Vec<String> = category_deltas
417 .iter()
418 .filter(|d| d.delta > 1.0)
419 .map(|d| d.category.clone())
420 .collect();
421
422 let old_violations = old.compliance.error_count + old.compliance.warning_count;
424 let new_violations = new.compliance.error_count + new.compliance.warning_count;
425
426 Self {
427 overall_score_delta: new.overall_score - old.overall_score,
428 old_grade: Some(old.grade),
429 new_grade: Some(new.grade),
430 category_deltas,
431 regressions,
432 improvements,
433 violation_count_delta: new_violations as i32 - old_violations as i32,
434 }
435 }
436
437 fn standard_category_deltas(
440 old: &crate::quality::QualityReport,
441 new: &crate::quality::QualityReport,
442 ) -> Vec<CategoryDelta> {
443 let categories = [
444 (
445 "Completeness",
446 old.completeness_score,
447 new.completeness_score,
448 ),
449 ("Identifiers", old.identifier_score, new.identifier_score),
450 ("Licenses", old.license_score, new.license_score),
451 ("Dependencies", old.dependency_score, new.dependency_score),
452 ("Integrity", old.integrity_score, new.integrity_score),
453 ("Provenance", old.provenance_score, new.provenance_score),
454 ];
455
456 let mut category_deltas: Vec<CategoryDelta> = categories
457 .iter()
458 .map(|(name, old_s, new_s)| CategoryDelta {
459 category: (*name).to_string(),
460 old_score: *old_s,
461 new_score: *new_s,
462 delta: new_s - old_s,
463 })
464 .collect();
465
466 if let (Some(old_v), Some(new_v)) = (old.vulnerability_score, new.vulnerability_score) {
468 category_deltas.push(CategoryDelta {
469 category: "VulnDocs".to_string(),
470 old_score: old_v,
471 new_score: new_v,
472 delta: new_v - old_v,
473 });
474 }
475 if let (Some(old_l), Some(new_l)) = (old.lifecycle_score, new.lifecycle_score) {
476 category_deltas.push(CategoryDelta {
477 category: "Lifecycle".to_string(),
478 old_score: old_l,
479 new_score: new_l,
480 delta: new_l - old_l,
481 });
482 }
483 category_deltas
484 }
485}
486
487#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
491pub struct MatchMetrics {
492 pub exact_matches: usize,
495 pub fuzzy_matches: usize,
497 pub rule_matches: usize,
499 pub unmatched_old: usize,
501 pub unmatched_new: usize,
503 pub avg_match_score: f64,
505 pub min_match_score: f64,
507}
508
509#[derive(Debug, Clone, Default, Serialize, Deserialize)]
511pub struct DiffSummary {
512 pub total_changes: usize,
513 pub components_added: usize,
514 pub components_removed: usize,
515 pub components_modified: usize,
516 pub dependencies_added: usize,
517 pub dependencies_removed: usize,
518 pub graph_changes_count: usize,
519 pub metadata_changes_count: usize,
520 pub vulnerabilities_introduced: usize,
521 pub vulnerabilities_resolved: usize,
522 pub vulnerabilities_persistent: usize,
523 pub licenses_added: usize,
524 pub licenses_removed: usize,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize)]
529pub struct ChangeSet<T> {
530 pub added: Vec<T>,
531 pub removed: Vec<T>,
532 pub modified: Vec<T>,
533}
534
535impl<T> ChangeSet<T> {
536 #[must_use]
537 pub const fn new() -> Self {
538 Self {
539 added: Vec::new(),
540 removed: Vec::new(),
541 modified: Vec::new(),
542 }
543 }
544
545 #[must_use]
546 pub fn is_empty(&self) -> bool {
547 self.added.is_empty() && self.removed.is_empty() && self.modified.is_empty()
548 }
549
550 #[must_use]
551 pub fn total(&self) -> usize {
552 self.added.len() + self.removed.len() + self.modified.len()
553 }
554}
555
556impl<T> Default for ChangeSet<T> {
557 fn default() -> Self {
558 Self::new()
559 }
560}
561
562#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct MatchInfo {
567 pub score: f64,
569 pub method: String,
571 pub reason: String,
573 #[serde(skip_serializing_if = "Vec::is_empty")]
575 pub score_breakdown: Vec<MatchScoreComponent>,
576 #[serde(skip_serializing_if = "Vec::is_empty")]
578 pub normalizations: Vec<String>,
579 #[serde(skip_serializing_if = "Option::is_none")]
581 pub confidence_interval: Option<ConfidenceInterval>,
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
589pub struct ConfidenceInterval {
590 pub lower: f64,
592 pub upper: f64,
594 pub level: f64,
596}
597
598impl ConfidenceInterval {
599 #[must_use]
601 pub const fn new(lower: f64, upper: f64, level: f64) -> Self {
602 Self {
603 lower: lower.clamp(0.0, 1.0),
604 upper: upper.clamp(0.0, 1.0),
605 level,
606 }
607 }
608
609 #[must_use]
613 pub fn from_score_and_error(score: f64, std_error: f64) -> Self {
614 let margin = 1.96 * std_error;
615 Self::new(score - margin, score + margin, 0.95)
616 }
617
618 #[must_use]
622 pub fn from_tier(score: f64, tier: &str) -> Self {
623 let margin = match tier {
624 "ExactIdentifier" => 0.0,
625 "EquivalenceRule" => 0.02,
627 "Alias" => 0.02,
628 "EcosystemRule" | "NameIdentity" => 0.03,
629 "CustomRule" => 0.05,
630 "Fuzzy" => 0.08,
631 "CrossEcosystem" => 0.10,
633 _ => 0.10,
634 };
635 Self::new(score - margin, score + margin, 0.95)
636 }
637
638 #[must_use]
640 pub fn width(&self) -> f64 {
641 self.upper - self.lower
642 }
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize)]
647pub struct MatchScoreComponent {
648 pub name: String,
650 pub weight: f64,
652 pub raw_score: f64,
654 pub weighted_score: f64,
656 pub description: String,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize)]
662pub struct ComponentChange {
663 pub id: String,
665 #[serde(skip)]
667 pub canonical_id: Option<CanonicalId>,
668 #[serde(skip)]
670 pub component_ref: Option<ComponentRef>,
671 #[serde(skip)]
673 pub old_canonical_id: Option<CanonicalId>,
674 pub name: String,
676 pub old_version: Option<String>,
678 pub new_version: Option<String>,
680 pub ecosystem: Option<String>,
682 #[serde(default, skip_serializing_if = "Option::is_none")]
690 pub component_type: Option<String>,
691 pub change_type: ChangeType,
693 pub field_changes: Vec<FieldChange>,
695 pub cost: u32,
697 #[serde(skip_serializing_if = "Option::is_none")]
699 pub match_info: Option<MatchInfo>,
700}
701
702fn resolved_component_type(component: &Component) -> String {
712 use crate::model::CryptoAssetType;
713 if let Some(cp) = &component.crypto_properties {
714 if cp.asset_type == CryptoAssetType::RelatedCryptoMaterial
715 && let Some(mat) = &cp.related_crypto_material_properties
716 {
717 return mat.material_type.to_string();
718 }
719 return cp.asset_type.to_string();
720 }
721 component.component_type.to_string()
722}
723
724impl ComponentChange {
725 pub fn added(component: &Component, cost: u32) -> Self {
727 Self {
728 id: component.canonical_id.to_string(),
729 canonical_id: Some(component.canonical_id.clone()),
730 component_ref: Some(ComponentRef::from_component(component)),
731 old_canonical_id: None,
732 name: component.name.clone(),
733 old_version: None,
734 new_version: component.version.clone(),
735 ecosystem: component
736 .ecosystem
737 .as_ref()
738 .map(std::string::ToString::to_string),
739 component_type: Some(resolved_component_type(component)),
740 change_type: ChangeType::Added,
741 field_changes: Vec::new(),
742 cost,
743 match_info: None,
744 }
745 }
746
747 pub fn removed(component: &Component, cost: u32) -> Self {
749 Self {
750 id: component.canonical_id.to_string(),
751 canonical_id: Some(component.canonical_id.clone()),
752 component_ref: Some(ComponentRef::from_component(component)),
753 old_canonical_id: Some(component.canonical_id.clone()),
754 name: component.name.clone(),
755 old_version: component.version.clone(),
756 new_version: None,
757 ecosystem: component
758 .ecosystem
759 .as_ref()
760 .map(std::string::ToString::to_string),
761 component_type: Some(resolved_component_type(component)),
762 change_type: ChangeType::Removed,
763 field_changes: Vec::new(),
764 cost,
765 match_info: None,
766 }
767 }
768
769 pub fn unchanged(old: &Component, new: &Component) -> Self {
773 Self {
774 id: new.canonical_id.to_string(),
775 canonical_id: Some(new.canonical_id.clone()),
776 component_ref: Some(ComponentRef::from_component(new)),
777 old_canonical_id: Some(old.canonical_id.clone()),
778 name: new.name.clone(),
779 old_version: old.version.clone(),
780 new_version: new.version.clone(),
781 ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
782 component_type: Some(resolved_component_type(new)),
785 change_type: ChangeType::Unchanged,
786 field_changes: Vec::new(),
787 cost: 0,
788 match_info: None,
789 }
790 }
791
792 pub fn modified(
794 old: &Component,
795 new: &Component,
796 field_changes: Vec<FieldChange>,
797 cost: u32,
798 ) -> Self {
799 Self {
800 id: new.canonical_id.to_string(),
801 canonical_id: Some(new.canonical_id.clone()),
802 component_ref: Some(ComponentRef::from_component(new)),
803 old_canonical_id: Some(old.canonical_id.clone()),
804 name: new.name.clone(),
805 old_version: old.version.clone(),
806 new_version: new.version.clone(),
807 ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
808 component_type: Some(resolved_component_type(new)),
809 change_type: ChangeType::Modified,
810 field_changes,
811 cost,
812 match_info: None,
813 }
814 }
815
816 pub fn modified_with_match(
818 old: &Component,
819 new: &Component,
820 field_changes: Vec<FieldChange>,
821 cost: u32,
822 match_info: MatchInfo,
823 ) -> Self {
824 Self {
825 id: new.canonical_id.to_string(),
826 canonical_id: Some(new.canonical_id.clone()),
827 component_ref: Some(ComponentRef::from_component(new)),
828 old_canonical_id: Some(old.canonical_id.clone()),
829 name: new.name.clone(),
830 old_version: old.version.clone(),
831 new_version: new.version.clone(),
832 ecosystem: new.ecosystem.as_ref().map(std::string::ToString::to_string),
833 component_type: Some(resolved_component_type(new)),
834 change_type: ChangeType::Modified,
835 field_changes,
836 cost,
837 match_info: Some(match_info),
838 }
839 }
840
841 #[must_use]
843 pub fn with_match_info(mut self, match_info: MatchInfo) -> Self {
844 self.match_info = Some(match_info);
845 self
846 }
847
848 #[must_use]
850 pub fn get_canonical_id(&self) -> CanonicalId {
851 self.canonical_id.clone().unwrap_or_else(|| {
852 CanonicalId::from_name_version(
853 &self.name,
854 self.new_version.as_deref().or(self.old_version.as_deref()),
855 )
856 })
857 }
858
859 #[must_use]
861 pub fn get_component_ref(&self) -> ComponentRef {
862 self.component_ref.clone().unwrap_or_else(|| {
863 ComponentRef::with_version(
864 self.get_canonical_id(),
865 &self.name,
866 self.new_version
867 .clone()
868 .or_else(|| self.old_version.clone()),
869 )
870 })
871 }
872}
873
874impl MatchInfo {
875 #[must_use]
877 pub fn from_explanation(explanation: &crate::matching::MatchExplanation) -> Self {
878 let method = format!("{:?}", explanation.tier);
879 let ci = ConfidenceInterval::from_tier(explanation.score, &method);
880 Self {
881 score: explanation.score,
882 method,
883 reason: explanation.reason.clone(),
884 score_breakdown: explanation
885 .score_breakdown
886 .iter()
887 .map(|c| MatchScoreComponent {
888 name: c.name.clone(),
889 weight: c.weight,
890 raw_score: c.raw_score,
891 weighted_score: c.weighted_score,
892 description: c.description.clone(),
893 })
894 .collect(),
895 normalizations: explanation.normalizations_applied.clone(),
896 confidence_interval: Some(ci),
897 }
898 }
899
900 #[must_use]
902 pub fn simple(score: f64, method: &str, reason: &str) -> Self {
903 let ci = ConfidenceInterval::from_tier(score, method);
904 Self {
905 score,
906 method: method.to_string(),
907 reason: reason.to_string(),
908 score_breakdown: Vec::new(),
909 normalizations: Vec::new(),
910 confidence_interval: Some(ci),
911 }
912 }
913
914 #[must_use]
916 pub const fn with_confidence_interval(mut self, ci: ConfidenceInterval) -> Self {
917 self.confidence_interval = Some(ci);
918 self
919 }
920}
921
922#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
924pub enum ChangeType {
925 Added,
926 Removed,
927 Modified,
928 Unchanged,
929}
930
931#[derive(Debug, Clone, Serialize, Deserialize)]
933pub struct FieldChange {
934 pub field: String,
935 pub old_value: Option<String>,
936 pub new_value: Option<String>,
937}
938
939#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
941#[serde(rename_all = "lowercase")]
942pub enum MetadataChangeKind {
943 Added,
945 Removed,
947 Modified,
949}
950
951impl std::fmt::Display for MetadataChangeKind {
952 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
953 let s = match self {
954 Self::Added => "added",
955 Self::Removed => "removed",
956 Self::Modified => "modified",
957 };
958 f.write_str(s)
959 }
960}
961
962#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
970pub struct MetadataChange {
971 pub field: String,
974 pub old_value: Option<String>,
976 pub new_value: Option<String>,
978 pub kind: MetadataChangeKind,
980}
981
982impl MetadataChange {
983 #[must_use]
987 pub fn from_values(
988 field: impl Into<String>,
989 old_value: Option<String>,
990 new_value: Option<String>,
991 ) -> Option<Self> {
992 if old_value == new_value {
993 return None;
994 }
995 let kind = match (&old_value, &new_value) {
996 (None, Some(_)) => MetadataChangeKind::Added,
997 (Some(_), None) => MetadataChangeKind::Removed,
998 _ => MetadataChangeKind::Modified,
999 };
1000 Some(Self {
1001 field: field.into(),
1002 old_value,
1003 new_value,
1004 kind,
1005 })
1006 }
1007}
1008
1009#[derive(Debug, Clone, Serialize, Deserialize)]
1011pub struct DependencyChange {
1012 pub from: String,
1014 pub to: String,
1016 pub relationship: String,
1018 #[serde(default, skip_serializing_if = "Option::is_none")]
1020 pub scope: Option<String>,
1021 pub change_type: ChangeType,
1023}
1024
1025impl DependencyChange {
1026 #[must_use]
1027 pub fn added(edge: &DependencyEdge) -> Self {
1028 Self {
1029 from: edge.from.to_string(),
1030 to: edge.to.to_string(),
1031 relationship: edge.relationship.to_string(),
1032 scope: edge.scope.as_ref().map(std::string::ToString::to_string),
1033 change_type: ChangeType::Added,
1034 }
1035 }
1036
1037 #[must_use]
1038 pub fn removed(edge: &DependencyEdge) -> Self {
1039 Self {
1040 from: edge.from.to_string(),
1041 to: edge.to.to_string(),
1042 relationship: edge.relationship.to_string(),
1043 scope: edge.scope.as_ref().map(std::string::ToString::to_string),
1044 change_type: ChangeType::Removed,
1045 }
1046 }
1047}
1048
1049#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1051pub struct LicenseChanges {
1052 pub new_licenses: Vec<LicenseChange>,
1054 pub removed_licenses: Vec<LicenseChange>,
1056 pub conflicts: Vec<LicenseConflict>,
1058 pub component_changes: Vec<ComponentLicenseChange>,
1060}
1061
1062#[derive(Debug, Clone, Serialize, Deserialize)]
1064pub struct LicenseChange {
1065 pub license: String,
1067 pub components: Vec<String>,
1069 pub family: String,
1071}
1072
1073#[derive(Debug, Clone, Serialize, Deserialize)]
1075pub struct LicenseConflict {
1076 pub license_a: String,
1077 pub license_b: String,
1078 pub component: String,
1079 pub description: String,
1080}
1081
1082#[derive(Debug, Clone, Serialize, Deserialize)]
1084pub struct ComponentLicenseChange {
1085 pub component_id: String,
1086 pub component_name: String,
1087 pub old_licenses: Vec<String>,
1088 pub new_licenses: Vec<String>,
1089}
1090
1091#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1093pub struct VexStatusChange {
1094 pub vuln_id: String,
1096 pub component_name: String,
1098 #[serde(default, skip_serializing_if = "Option::is_none")]
1100 pub old_state: Option<crate::model::VexState>,
1101 #[serde(default, skip_serializing_if = "Option::is_none")]
1103 pub new_state: Option<crate::model::VexState>,
1104}
1105
1106#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1108pub struct VulnerabilityChanges {
1109 pub introduced: Vec<VulnerabilityDetail>,
1111 pub resolved: Vec<VulnerabilityDetail>,
1113 pub persistent: Vec<VulnerabilityDetail>,
1115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1117 pub vex_changes: Vec<VexStatusChange>,
1118}
1119
1120impl VulnerabilityChanges {
1121 #[must_use]
1123 pub fn introduced_by_severity(&self) -> HashMap<String, usize> {
1124 let mut counts = HashMap::with_capacity(5);
1126 for vuln in &self.introduced {
1127 *counts.entry(vuln.severity.clone()).or_insert(0) += 1;
1128 }
1129 counts
1130 }
1131
1132 #[must_use]
1134 pub fn critical_and_high_introduced(&self) -> Vec<&VulnerabilityDetail> {
1135 self.introduced
1136 .iter()
1137 .filter(|v| v.severity == "Critical" || v.severity == "High")
1138 .collect()
1139 }
1140
1141 pub fn vex_summary(&self) -> VexCoverageSummary {
1143 let all_vulns: Vec<&VulnerabilityDetail> = self
1144 .introduced
1145 .iter()
1146 .chain(&self.resolved)
1147 .chain(&self.persistent)
1148 .collect();
1149
1150 let total = all_vulns.len();
1151 let mut with_vex = 0;
1152 let mut by_state: HashMap<crate::model::VexState, usize> = HashMap::with_capacity(4);
1153 let mut actionable = 0;
1154
1155 for vuln in &all_vulns {
1156 if let Some(ref state) = vuln.vex_state {
1157 with_vex += 1;
1158 *by_state.entry(state.clone()).or_insert(0) += 1;
1159 }
1160 if vuln.is_vex_actionable() {
1161 actionable += 1;
1162 }
1163 }
1164
1165 let introduced_without_vex = self
1167 .introduced
1168 .iter()
1169 .filter(|v| v.vex_state.is_none())
1170 .count();
1171
1172 let persistent_without_vex = self
1173 .persistent
1174 .iter()
1175 .filter(|v| v.vex_state.is_none())
1176 .count();
1177
1178 VexCoverageSummary {
1179 total_vulns: total,
1180 with_vex,
1181 without_vex: total - with_vex,
1182 actionable,
1183 coverage_pct: if total > 0 {
1184 (with_vex as f64 / total as f64) * 100.0
1185 } else {
1186 100.0
1187 },
1188 by_state,
1189 introduced_without_vex,
1190 persistent_without_vex,
1191 }
1192 }
1193}
1194
1195#[derive(Debug, Clone, Serialize, Deserialize)]
1197#[must_use]
1198pub struct VexCoverageSummary {
1199 pub total_vulns: usize,
1201 pub with_vex: usize,
1203 pub without_vex: usize,
1205 pub actionable: usize,
1207 pub coverage_pct: f64,
1209 pub by_state: HashMap<crate::model::VexState, usize>,
1211 pub introduced_without_vex: usize,
1213 pub persistent_without_vex: usize,
1215}
1216
1217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1219pub enum SlaStatus {
1220 Overdue(i64),
1222 DueSoon(i64),
1224 OnTrack(i64),
1226 NoDueDate,
1228}
1229
1230impl SlaStatus {
1231 #[must_use]
1233 pub fn display(&self, days_since_published: Option<i64>) -> String {
1234 match self {
1235 Self::Overdue(days) => format!("{days}d late"),
1236 Self::DueSoon(days) | Self::OnTrack(days) => format!("{days}d left"),
1237 Self::NoDueDate => {
1238 days_since_published.map_or_else(|| "-".to_string(), |age| format!("{age}d old"))
1239 }
1240 }
1241 }
1242
1243 #[must_use]
1245 pub const fn is_overdue(&self) -> bool {
1246 matches!(self, Self::Overdue(_))
1247 }
1248
1249 #[must_use]
1251 pub const fn is_due_soon(&self) -> bool {
1252 matches!(self, Self::DueSoon(_))
1253 }
1254}
1255
1256#[derive(Debug, Clone, Serialize, Deserialize)]
1258pub struct VulnerabilityDetail {
1259 pub id: String,
1261 pub source: String,
1263 pub severity: String,
1265 pub cvss_score: Option<f32>,
1267 pub component_id: String,
1269 #[serde(skip)]
1271 pub component_canonical_id: Option<CanonicalId>,
1272 #[serde(skip)]
1274 pub component_ref: Option<ComponentRef>,
1275 pub component_name: String,
1277 pub version: Option<String>,
1279 pub cwes: Vec<String>,
1281 pub description: Option<String>,
1283 pub remediation: Option<String>,
1285 #[serde(default)]
1287 pub is_kev: bool,
1288 #[serde(default)]
1290 pub is_ransomware: bool,
1291 #[serde(default, skip_serializing_if = "Option::is_none")]
1293 pub epss_score: Option<f64>,
1294 #[serde(default)]
1296 pub component_depth: Option<u32>,
1297 #[serde(default)]
1299 pub published_date: Option<String>,
1300 #[serde(default)]
1302 pub kev_due_date: Option<String>,
1303 #[serde(default)]
1305 pub days_since_published: Option<i64>,
1306 #[serde(default)]
1308 pub days_until_due: Option<i64>,
1309 #[serde(default, skip_serializing_if = "Option::is_none")]
1311 pub vex_state: Option<crate::model::VexState>,
1312 #[serde(default, skip_serializing_if = "Option::is_none")]
1314 pub vex_justification: Option<crate::model::VexJustification>,
1315 #[serde(default, skip_serializing_if = "Option::is_none")]
1317 pub vex_impact_statement: Option<String>,
1318}
1319
1320impl VulnerabilityDetail {
1321 #[must_use]
1326 pub const fn is_vex_actionable(&self) -> bool {
1327 !matches!(
1328 self.vex_state,
1329 Some(crate::model::VexState::NotAffected | crate::model::VexState::Fixed)
1330 )
1331 }
1332
1333 pub fn from_ref(vuln: &VulnerabilityRef, component: &Component) -> Self {
1335 let days_since_published = vuln.published.map(|dt| {
1337 let today = chrono::Utc::now().date_naive();
1338 (today - dt.date_naive()).num_days()
1339 });
1340
1341 let published_date = vuln.published.map(|dt| dt.format("%Y-%m-%d").to_string());
1343
1344 let (kev_due_date, days_until_due) = vuln.kev_info.as_ref().map_or((None, None), |kev| {
1351 let today = chrono::Utc::now().date_naive();
1352 (
1353 Some(kev.due_date.format("%Y-%m-%d").to_string()),
1354 Some((kev.due_date.date_naive() - today).num_days()),
1355 )
1356 });
1357
1358 Self {
1359 id: vuln.id.clone(),
1360 source: vuln.source.to_string(),
1361 severity: vuln
1362 .severity
1363 .as_ref()
1364 .map_or_else(|| "Unknown".to_string(), std::string::ToString::to_string),
1365 cvss_score: vuln.max_cvss_score(),
1366 component_id: component.canonical_id.to_string(),
1367 component_canonical_id: Some(component.canonical_id.clone()),
1368 component_ref: Some(ComponentRef::from_component(component)),
1369 component_name: component.name.clone(),
1370 version: component.version.clone(),
1371 cwes: vuln.cwes.clone(),
1372 description: vuln.description.clone(),
1373 remediation: vuln.remediation.as_ref().map(|r| {
1374 format!(
1375 "{}: {}",
1376 r.remediation_type,
1377 r.description.as_deref().unwrap_or("")
1378 )
1379 }),
1380 is_kev: vuln.is_kev,
1381 is_ransomware: vuln.is_ransomware_related(),
1382 epss_score: vuln.epss_score,
1383 component_depth: None,
1384 published_date,
1385 kev_due_date,
1386 days_since_published,
1387 days_until_due,
1388 vex_state: {
1389 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1390 vex_source.map(|v| v.status.clone())
1391 },
1392 vex_justification: {
1393 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1394 vex_source.and_then(|v| v.justification.clone())
1395 },
1396 vex_impact_statement: {
1397 let vex_source = vuln.vex_status.as_ref().or(component.vex_status.as_ref());
1398 vex_source.and_then(|v| v.impact_statement.clone())
1399 },
1400 }
1401 }
1402
1403 pub(crate) fn refresh_day_counts(&mut self, today: chrono::NaiveDate) -> bool {
1411 let mut changed = false;
1412 if let Some(published) = self.published_date.as_deref()
1413 && let Ok(date) = chrono::NaiveDate::parse_from_str(published, "%Y-%m-%d")
1414 {
1415 let days = (today - date).num_days();
1416 if self.days_since_published != Some(days) {
1417 self.days_since_published = Some(days);
1418 changed = true;
1419 }
1420 }
1421 if let Some(due) = self.kev_due_date.as_deref()
1422 && let Ok(date) = chrono::NaiveDate::parse_from_str(due, "%Y-%m-%d")
1423 {
1424 let days = (date - today).num_days();
1425 if self.days_until_due != Some(days) {
1426 self.days_until_due = Some(days);
1427 changed = true;
1428 }
1429 }
1430 changed
1431 }
1432
1433 #[must_use]
1435 pub fn from_ref_with_depth(
1436 vuln: &VulnerabilityRef,
1437 component: &Component,
1438 depth: Option<u32>,
1439 ) -> Self {
1440 let mut detail = Self::from_ref(vuln, component);
1441 detail.component_depth = depth;
1442 detail
1443 }
1444
1445 #[must_use]
1451 pub fn sla_status(&self) -> SlaStatus {
1452 if let Some(days) = self.days_until_due {
1454 if days < 0 {
1455 return SlaStatus::Overdue(-days);
1456 } else if days <= 3 {
1457 return SlaStatus::DueSoon(days);
1458 }
1459 return SlaStatus::OnTrack(days);
1460 }
1461
1462 if let Some(age_days) = self.days_since_published {
1464 let sla_days = match self.severity.to_lowercase().as_str() {
1465 "critical" => 1,
1466 "high" => 7,
1467 "medium" => 30,
1468 "low" => 90,
1469 _ => return SlaStatus::NoDueDate,
1470 };
1471 let remaining = sla_days - age_days;
1472 if remaining < 0 {
1473 return SlaStatus::Overdue(-remaining);
1474 } else if remaining <= 3 {
1475 return SlaStatus::DueSoon(remaining);
1476 }
1477 return SlaStatus::OnTrack(remaining);
1478 }
1479
1480 SlaStatus::NoDueDate
1481 }
1482
1483 #[must_use]
1485 pub fn get_component_id(&self) -> CanonicalId {
1486 self.component_canonical_id.clone().unwrap_or_else(|| {
1487 CanonicalId::from_name_version(&self.component_name, self.version.as_deref())
1488 })
1489 }
1490
1491 #[must_use]
1493 pub fn get_component_ref(&self) -> ComponentRef {
1494 self.component_ref.clone().unwrap_or_else(|| {
1495 ComponentRef::with_version(
1496 self.get_component_id(),
1497 &self.component_name,
1498 self.version.clone(),
1499 )
1500 })
1501 }
1502}
1503
1504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1510pub struct DependencyGraphChange {
1511 pub component_id: CanonicalId,
1513 pub component_name: String,
1515 pub change: DependencyChangeType,
1517 pub impact: GraphChangeImpact,
1519}
1520
1521#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1523#[non_exhaustive]
1524pub enum DependencyChangeType {
1525 DependencyAdded {
1527 dependency_id: CanonicalId,
1528 dependency_name: String,
1529 },
1530
1531 DependencyRemoved {
1533 dependency_id: CanonicalId,
1534 dependency_name: String,
1535 },
1536
1537 RelationshipChanged {
1539 dependency_id: CanonicalId,
1540 dependency_name: String,
1541 old_relationship: String,
1542 new_relationship: String,
1543 old_scope: Option<String>,
1544 new_scope: Option<String>,
1545 },
1546
1547 Reparented {
1549 dependency_id: CanonicalId,
1550 dependency_name: String,
1551 old_parent_id: CanonicalId,
1552 old_parent_name: String,
1553 new_parent_id: CanonicalId,
1554 new_parent_name: String,
1555 },
1556
1557 DepthChanged {
1559 old_depth: u32, new_depth: u32,
1561 },
1562}
1563
1564impl DependencyChangeType {
1565 #[must_use]
1567 pub const fn kind(&self) -> &'static str {
1568 match self {
1569 Self::DependencyAdded { .. } => "added",
1570 Self::DependencyRemoved { .. } => "removed",
1571 Self::RelationshipChanged { .. } => "relationship_changed",
1572 Self::Reparented { .. } => "reparented",
1573 Self::DepthChanged { .. } => "depth_changed",
1574 }
1575 }
1576}
1577
1578#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1580pub enum GraphChangeImpact {
1581 Low,
1583 Medium,
1585 High,
1587 Critical,
1589}
1590
1591impl GraphChangeImpact {
1592 #[must_use]
1593 pub const fn as_str(&self) -> &'static str {
1594 match self {
1595 Self::Low => "low",
1596 Self::Medium => "medium",
1597 Self::High => "high",
1598 Self::Critical => "critical",
1599 }
1600 }
1601
1602 #[must_use]
1604 pub fn from_label(s: &str) -> Self {
1605 match s.to_lowercase().as_str() {
1606 "critical" => Self::Critical,
1607 "high" => Self::High,
1608 "medium" => Self::Medium,
1609 _ => Self::Low,
1610 }
1611 }
1612}
1613
1614impl std::fmt::Display for GraphChangeImpact {
1615 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1616 write!(f, "{}", self.as_str())
1617 }
1618}
1619
1620#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1622pub struct GraphChangeSummary {
1623 pub total_changes: usize,
1624 pub dependencies_added: usize,
1625 pub dependencies_removed: usize,
1626 pub relationship_changed: usize,
1627 pub reparented: usize,
1628 pub depth_changed: usize,
1629 pub by_impact: GraphChangesByImpact,
1630}
1631
1632impl GraphChangeSummary {
1633 #[must_use]
1635 pub fn from_changes(changes: &[DependencyGraphChange]) -> Self {
1636 let mut summary = Self {
1637 total_changes: changes.len(),
1638 ..Default::default()
1639 };
1640
1641 for change in changes {
1642 match &change.change {
1643 DependencyChangeType::DependencyAdded { .. } => summary.dependencies_added += 1,
1644 DependencyChangeType::DependencyRemoved { .. } => summary.dependencies_removed += 1,
1645 DependencyChangeType::RelationshipChanged { .. } => {
1646 summary.relationship_changed += 1;
1647 }
1648 DependencyChangeType::Reparented { .. } => summary.reparented += 1,
1649 DependencyChangeType::DepthChanged { .. } => summary.depth_changed += 1,
1650 }
1651
1652 match change.impact {
1653 GraphChangeImpact::Low => summary.by_impact.low += 1,
1654 GraphChangeImpact::Medium => summary.by_impact.medium += 1,
1655 GraphChangeImpact::High => summary.by_impact.high += 1,
1656 GraphChangeImpact::Critical => summary.by_impact.critical += 1,
1657 }
1658 }
1659
1660 summary
1661 }
1662}
1663
1664#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1665pub struct GraphChangesByImpact {
1666 pub low: usize,
1667 pub medium: usize,
1668 pub high: usize,
1669 pub critical: usize,
1670}
1671
1672#[cfg(test)]
1673mod quality_delta_tests {
1674 use super::QualityDelta;
1675 use crate::model::{
1676 AlgorithmProperties, Component, ComponentType, CryptoAssetType, CryptoPrimitive,
1677 CryptoProperties, NormalizedSbom,
1678 };
1679 use crate::quality::{CryptographyMetrics, QualityScorer, ScoringProfile};
1680
1681 fn cbom_sbom(family: &str, quantum_level: u8) -> NormalizedSbom {
1684 let mut sbom = NormalizedSbom::default();
1685 let mut comp = Component::new(family.to_string(), format!("crypto/{family}"));
1686 comp.component_type = ComponentType::Cryptographic;
1687 comp.crypto_properties = Some(
1688 CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
1689 AlgorithmProperties::new(CryptoPrimitive::Hash)
1690 .with_algorithm_family(family.to_string())
1691 .with_nist_quantum_security_level(quantum_level),
1692 ),
1693 );
1694 comp.calculate_content_hash();
1695 sbom.add_component(comp);
1696 sbom
1697 }
1698
1699 fn plain_sbom(version: &str) -> NormalizedSbom {
1701 let mut sbom = NormalizedSbom::default();
1702 let mut comp = Component::new("lodash".to_string(), format!("pkg:npm/lodash@{version}"));
1703 comp.version = Some(version.to_string());
1704 comp.calculate_content_hash();
1705 sbom.add_component(comp);
1706 sbom
1707 }
1708
1709 #[test]
1713 fn cbom_pair_uses_crypto_category_names() {
1714 let scorer = QualityScorer::new(ScoringProfile::Cbom);
1715 let old_report = scorer.score(&cbom_sbom("SHA-2", 1));
1716 let new_report = scorer.score(&cbom_sbom("MD5", 0));
1717
1718 let delta = QualityDelta::from_reports(&old_report, &new_report);
1719
1720 let names: Vec<&str> = delta
1721 .category_deltas
1722 .iter()
1723 .map(|d| d.category.as_str())
1724 .collect();
1725 assert_eq!(
1726 names,
1727 CryptographyMetrics::cbom_category_names().to_vec(),
1728 "CBOM delta must use the scorer's crypto category names"
1729 );
1730 for name in delta.regressions.iter().chain(delta.improvements.iter()) {
1733 assert!(
1734 CryptographyMetrics::cbom_category_names().contains(&name.as_str()),
1735 "unexpected non-CBOM category `{name}` in a CBOM delta"
1736 );
1737 }
1738 assert!(
1741 delta.regressions.iter().any(|r| r == "Algo Strength"),
1742 "expected an Algo Strength regression, got {:?}",
1743 delta.regressions
1744 );
1745 }
1746
1747 #[test]
1750 fn standard_pair_keeps_generic_category_names() {
1751 let scorer = QualityScorer::new(ScoringProfile::Standard);
1752 let old_report = scorer.score(&plain_sbom("1.0.0"));
1753 let new_report = scorer.score(&plain_sbom("2.0.0"));
1754
1755 let delta = QualityDelta::from_reports(&old_report, &new_report);
1756
1757 let names: Vec<&str> = delta
1758 .category_deltas
1759 .iter()
1760 .map(|d| d.category.as_str())
1761 .collect();
1762 for expected in [
1763 "Completeness",
1764 "Identifiers",
1765 "Licenses",
1766 "Dependencies",
1767 "Integrity",
1768 "Provenance",
1769 ] {
1770 assert!(
1771 names.contains(&expected),
1772 "missing `{expected}` in {names:?}"
1773 );
1774 }
1775 assert!(
1776 !names.iter().any(|n| n.starts_with("Crypto")),
1777 "generic delta must not carry CBOM names: {names:?}"
1778 );
1779 }
1780
1781 #[test]
1785 fn mixed_profile_pair_falls_back_to_generic_names() {
1786 let old_report = QualityScorer::new(ScoringProfile::Standard).score(&plain_sbom("1.0.0"));
1787 let new_report = QualityScorer::new(ScoringProfile::Cbom).score(&cbom_sbom("SHA-2", 1));
1788
1789 let delta = QualityDelta::from_reports(&old_report, &new_report);
1790
1791 assert!(
1792 delta
1793 .category_deltas
1794 .iter()
1795 .any(|d| d.category == "Completeness"),
1796 "mixed pair must keep generic categories"
1797 );
1798 }
1799}
1800
1801#[cfg(test)]
1802mod ml_metric_direction_tests {
1803 use super::ml_metric_higher_is_better;
1804
1805 #[test]
1807 fn ml_metric_direction_table() {
1808 assert_eq!(ml_metric_higher_is_better("accuracy"), Some(true));
1809 assert_eq!(
1810 ml_metric_higher_is_better("accuracy@validation"),
1811 Some(true)
1812 );
1813 assert_eq!(ml_metric_higher_is_better("loss"), Some(false));
1814 assert_eq!(ml_metric_higher_is_better("latency_ms"), Some(false));
1815 assert_eq!(ml_metric_higher_is_better("custom_metric"), None);
1816 }
1817}