Skip to main content

sbom_tools/diff/
cost.rs

1//! Cost model for diff operations.
2
3use serde::{Deserialize, Serialize};
4
5/// Cost model configuration for semantic diff operations.
6///
7/// Costs are used to determine the minimum-cost alignment between two SBOMs.
8/// Higher costs indicate more significant changes.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CostModel {
11    /// Cost for adding a new component
12    pub component_added: u32,
13    /// Cost for removing a component
14    pub component_removed: u32,
15    /// Cost for patch version change
16    pub version_patch: u32,
17    /// Cost for minor version change
18    pub version_minor: u32,
19    /// Cost for major version change
20    pub version_major: u32,
21    /// Cost for license change
22    pub license_changed: u32,
23    /// Cost for supplier change
24    pub supplier_changed: u32,
25    /// Cost for introducing a vulnerability
26    pub vulnerability_introduced: u32,
27    /// Reward (negative cost) for resolving a vulnerability
28    pub vulnerability_resolved: i32,
29    /// Cost for adding a dependency
30    pub dependency_added: u32,
31    /// Cost for removing a dependency
32    pub dependency_removed: u32,
33    /// Cost for hash mismatch (integrity concern)
34    pub hash_mismatch: u32,
35}
36
37impl Default for CostModel {
38    fn default() -> Self {
39        Self {
40            component_added: 10,
41            component_removed: 10,
42            version_patch: 2,
43            version_minor: 4,
44            version_major: 7,
45            license_changed: 6,
46            supplier_changed: 4,
47            vulnerability_introduced: 15,
48            vulnerability_resolved: -3,
49            dependency_added: 5,
50            dependency_removed: 5,
51            hash_mismatch: 8,
52        }
53    }
54}
55
56impl CostModel {
57    /// Create a security-focused cost model
58    #[must_use]
59    pub fn security_focused() -> Self {
60        Self {
61            vulnerability_introduced: 25,
62            vulnerability_resolved: -5,
63            hash_mismatch: 15,
64            supplier_changed: 8,
65            ..Default::default()
66        }
67    }
68
69    /// Create a compliance-focused cost model
70    #[must_use]
71    pub fn compliance_focused() -> Self {
72        Self {
73            license_changed: 12,
74            supplier_changed: 8,
75            ..Default::default()
76        }
77    }
78
79    /// Get cost for version change based on semver
80    #[must_use]
81    pub const fn version_change_cost(
82        &self,
83        old: &Option<semver::Version>,
84        new: &Option<semver::Version>,
85    ) -> u32 {
86        match (old, new) {
87            (Some(old_ver), Some(new_ver)) => {
88                if old_ver.major != new_ver.major {
89                    self.version_major
90                } else if old_ver.minor != new_ver.minor {
91                    self.version_minor
92                } else if old_ver.patch != new_ver.patch {
93                    self.version_patch
94                } else {
95                    0
96                }
97            }
98            (None, Some(_)) | (Some(_), None) => self.version_minor,
99            (None, None) => 0,
100        }
101    }
102
103    /// Calculate total semantic score from change counts
104    #[allow(clippy::too_many_arguments)]
105    #[must_use]
106    pub const fn calculate_semantic_score(
107        &self,
108        components_added: usize,
109        components_removed: usize,
110        version_changes: usize,
111        license_changes: usize,
112        vulns_introduced: usize,
113        vulns_resolved: usize,
114        deps_added: usize,
115        deps_removed: usize,
116    ) -> f64 {
117        let score = (components_added as u32 * self.component_added)
118            + (components_removed as u32 * self.component_removed)
119            + (version_changes as u32 * self.version_minor)
120            + (license_changes as u32 * self.license_changed)
121            + (vulns_introduced as u32 * self.vulnerability_introduced)
122            + (deps_added as u32 * self.dependency_added)
123            + (deps_removed as u32 * self.dependency_removed);
124
125        let reward = vulns_resolved as i32 * self.vulnerability_resolved;
126
127        (score as i32 + reward) as f64
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_default_cost_model() {
137        let model = CostModel::default();
138        assert_eq!(model.component_added, 10);
139        assert_eq!(model.component_removed, 10);
140        assert_eq!(model.version_patch, 2);
141        assert_eq!(model.version_minor, 4);
142        assert_eq!(model.version_major, 7);
143        assert_eq!(model.license_changed, 6);
144        assert_eq!(model.vulnerability_introduced, 15);
145        assert_eq!(model.vulnerability_resolved, -3);
146        assert_eq!(model.hash_mismatch, 8);
147    }
148
149    #[test]
150    fn test_security_focused_model() {
151        let model = CostModel::security_focused();
152        let default = CostModel::default();
153        assert!(model.vulnerability_introduced > default.vulnerability_introduced);
154        assert!(model.hash_mismatch > default.hash_mismatch);
155        assert!(model.vulnerability_resolved < default.vulnerability_resolved);
156    }
157
158    #[test]
159    fn test_compliance_focused_model() {
160        let model = CostModel::compliance_focused();
161        let default = CostModel::default();
162        assert!(model.license_changed > default.license_changed);
163        assert!(model.supplier_changed > default.supplier_changed);
164    }
165
166    fn ver(major: u64, minor: u64, patch: u64) -> semver::Version {
167        semver::Version::new(major, minor, patch)
168    }
169
170    #[test]
171    fn test_version_change_cost_major() {
172        let model = CostModel::default();
173        let old = Some(ver(1, 0, 0));
174        let new = Some(ver(2, 0, 0));
175        assert_eq!(model.version_change_cost(&old, &new), model.version_major);
176    }
177
178    #[test]
179    fn test_version_change_cost_minor() {
180        let model = CostModel::default();
181        let old = Some(ver(1, 0, 0));
182        let new = Some(ver(1, 1, 0));
183        assert_eq!(model.version_change_cost(&old, &new), model.version_minor);
184    }
185
186    #[test]
187    fn test_version_change_cost_patch() {
188        let model = CostModel::default();
189        let old = Some(ver(1, 0, 0));
190        let new = Some(ver(1, 0, 1));
191        assert_eq!(model.version_change_cost(&old, &new), model.version_patch);
192    }
193
194    #[test]
195    fn test_version_change_cost_same() {
196        let model = CostModel::default();
197        let old = Some(ver(1, 2, 3));
198        let new = Some(ver(1, 2, 3));
199        assert_eq!(model.version_change_cost(&old, &new), 0);
200    }
201
202    #[test]
203    fn test_version_change_cost_none_to_some() {
204        let model = CostModel::default();
205        let new = Some(ver(1, 0, 0));
206        assert_eq!(model.version_change_cost(&None, &new), model.version_minor);
207    }
208
209    #[test]
210    fn test_version_change_cost_some_to_none() {
211        let model = CostModel::default();
212        let old = Some(ver(1, 0, 0));
213        assert_eq!(model.version_change_cost(&old, &None), model.version_minor);
214    }
215
216    #[test]
217    fn test_version_change_cost_none_none() {
218        let model = CostModel::default();
219        assert_eq!(model.version_change_cost(&None, &None), 0);
220    }
221
222    #[test]
223    fn test_semantic_score_zero() {
224        let model = CostModel::default();
225        let score = model.calculate_semantic_score(0, 0, 0, 0, 0, 0, 0, 0);
226        assert_eq!(score, 0.0);
227    }
228
229    #[test]
230    fn test_semantic_score_basic() {
231        let model = CostModel::default();
232        // 2 added (10 each) + 1 removed (10) + 1 version change (4) = 34
233        let score = model.calculate_semantic_score(2, 1, 1, 0, 0, 0, 0, 0);
234        assert_eq!(score, 34.0);
235    }
236
237    #[test]
238    fn test_semantic_score_with_resolved() {
239        let model = CostModel::default();
240        // 1 vuln introduced (15) + 2 resolved (2 * -3 = -6) = 9
241        let score = model.calculate_semantic_score(0, 0, 0, 0, 1, 2, 0, 0);
242        assert_eq!(score, 9.0);
243    }
244}