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 ML model metadata change
26    pub ml_model_changed: u32,
27    /// Cost for dataset metadata change
28    pub dataset_changed: u32,
29    /// Cost for ML approach change (e.g. supervised -> reinforcement-learning)
30    pub ml_approach_changed: u32,
31    /// Cost for ML architecture change (family or name, e.g. cnn -> transformer)
32    pub ml_architecture_changed: u32,
33    /// Cost for ML task change (e.g. nlp -> computer-vision)
34    pub ml_task_changed: u32,
35    /// Cost for ML quantization change (e.g. fp32 -> int4); a model-swap signal
36    pub ml_quantization_changed: u32,
37    /// Cost for ML model-card URL change
38    pub ml_model_card_changed: u32,
39    /// Cost for adding a training dataset to a model
40    pub ml_training_dataset_added: u32,
41    /// Cost for removing a training dataset from a model (provenance loss; HIGH)
42    pub ml_training_dataset_removed: u32,
43    /// Cost for dataset type change (e.g. training -> validation)
44    pub dataset_type_changed: u32,
45    /// Cost for dataset governance owners change
46    pub dataset_governance_changed: u32,
47    /// Cost for adding a dataset sensitivity classification (e.g. gaining PII; HIGH)
48    pub dataset_sensitivity_added: u32,
49    /// Cost for removing a dataset sensitivity classification
50    pub dataset_sensitivity_removed: u32,
51    /// Cost for introducing a vulnerability
52    pub vulnerability_introduced: u32,
53    /// Reward (negative cost) for resolving a vulnerability
54    pub vulnerability_resolved: i32,
55    /// Cost for adding a dependency
56    pub dependency_added: u32,
57    /// Cost for removing a dependency
58    pub dependency_removed: u32,
59    /// Cost for hash mismatch (integrity concern)
60    pub hash_mismatch: u32,
61    /// Cost for crypto algorithm family change
62    pub crypto_algorithm_changed: u32,
63    /// Cost for crypto security downgrade (weaker algorithm or lower security level)
64    pub crypto_downgrade: u32,
65    /// Cost for crypto key rotation
66    pub crypto_key_rotated: u32,
67    /// Cost for certificate expiry date change
68    pub crypto_cert_expiry_changed: u32,
69    /// Cost for protocol version change
70    pub crypto_protocol_changed: u32,
71    /// Cost for quantum security level change
72    pub crypto_quantum_level_changed: u32,
73}
74
75impl Default for CostModel {
76    fn default() -> Self {
77        Self {
78            component_added: 10,
79            component_removed: 10,
80            version_patch: 2,
81            version_minor: 4,
82            version_major: 7,
83            license_changed: 6,
84            supplier_changed: 4,
85            ml_model_changed: 6,
86            dataset_changed: 5,
87            ml_approach_changed: 6,
88            ml_architecture_changed: 6,
89            ml_task_changed: 5,
90            ml_quantization_changed: 8,
91            ml_model_card_changed: 3,
92            ml_training_dataset_added: 5,
93            ml_training_dataset_removed: 12,
94            dataset_type_changed: 5,
95            dataset_governance_changed: 4,
96            dataset_sensitivity_added: 14,
97            dataset_sensitivity_removed: 6,
98            vulnerability_introduced: 15,
99            vulnerability_resolved: -3,
100            dependency_added: 5,
101            dependency_removed: 5,
102            hash_mismatch: 8,
103            crypto_algorithm_changed: 8,
104            crypto_downgrade: 20,
105            crypto_key_rotated: 3,
106            crypto_cert_expiry_changed: 5,
107            crypto_protocol_changed: 6,
108            crypto_quantum_level_changed: 10,
109        }
110    }
111}
112
113impl CostModel {
114    /// Create a security-focused cost model
115    #[must_use]
116    pub fn security_focused() -> Self {
117        Self {
118            vulnerability_introduced: 25,
119            vulnerability_resolved: -5,
120            hash_mismatch: 15,
121            supplier_changed: 8,
122            crypto_downgrade: 30,
123            crypto_quantum_level_changed: 15,
124            crypto_algorithm_changed: 12,
125            // Model-swap and data-governance escalations are security-relevant:
126            // gaining a sensitivity classification (e.g. PII), losing training-data
127            // provenance, or silently re-quantizing a model all warrant higher cost.
128            dataset_sensitivity_added: 22,
129            ml_training_dataset_removed: 18,
130            ml_quantization_changed: 12,
131            ..Default::default()
132        }
133    }
134
135    /// Create a compliance-focused cost model
136    #[must_use]
137    pub fn compliance_focused() -> Self {
138        Self {
139            license_changed: 12,
140            supplier_changed: 8,
141            ..Default::default()
142        }
143    }
144
145    /// Get cost for version change based on semver
146    #[must_use]
147    pub const fn version_change_cost(
148        &self,
149        old: &Option<semver::Version>,
150        new: &Option<semver::Version>,
151    ) -> u32 {
152        match (old, new) {
153            (Some(old_ver), Some(new_ver)) => {
154                if old_ver.major != new_ver.major {
155                    self.version_major
156                } else if old_ver.minor != new_ver.minor {
157                    self.version_minor
158                } else if old_ver.patch != new_ver.patch {
159                    self.version_patch
160                } else {
161                    0
162                }
163            }
164            (None, Some(_)) | (Some(_), None) => self.version_minor,
165            (None, None) => 0,
166        }
167    }
168
169    /// Calculate total semantic score from change counts
170    #[allow(clippy::too_many_arguments)]
171    #[must_use]
172    pub const fn calculate_semantic_score(
173        &self,
174        components_added: usize,
175        components_removed: usize,
176        version_changes: usize,
177        license_changes: usize,
178        vulns_introduced: usize,
179        vulns_resolved: usize,
180        deps_added: usize,
181        deps_removed: usize,
182    ) -> f64 {
183        let score = (components_added as u32 * self.component_added)
184            + (components_removed as u32 * self.component_removed)
185            + (version_changes as u32 * self.version_minor)
186            + (license_changes as u32 * self.license_changed)
187            + (vulns_introduced as u32 * self.vulnerability_introduced)
188            + (deps_added as u32 * self.dependency_added)
189            + (deps_removed as u32 * self.dependency_removed);
190
191        let reward = vulns_resolved as i32 * self.vulnerability_resolved;
192
193        (score as i32 + reward) as f64
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    #[test]
202    fn test_default_cost_model() {
203        let model = CostModel::default();
204        assert_eq!(model.component_added, 10);
205        assert_eq!(model.component_removed, 10);
206        assert_eq!(model.version_patch, 2);
207        assert_eq!(model.version_minor, 4);
208        assert_eq!(model.version_major, 7);
209        assert_eq!(model.license_changed, 6);
210        assert_eq!(model.supplier_changed, 4);
211        assert_eq!(model.ml_model_changed, 6);
212        assert_eq!(model.dataset_changed, 5);
213        assert_eq!(model.ml_approach_changed, 6);
214        assert_eq!(model.ml_architecture_changed, 6);
215        assert_eq!(model.ml_task_changed, 5);
216        assert_eq!(model.ml_quantization_changed, 8);
217        assert_eq!(model.ml_model_card_changed, 3);
218        assert_eq!(model.ml_training_dataset_added, 5);
219        assert_eq!(model.ml_training_dataset_removed, 12);
220        assert_eq!(model.dataset_type_changed, 5);
221        assert_eq!(model.dataset_governance_changed, 4);
222        assert_eq!(model.dataset_sensitivity_added, 14);
223        assert_eq!(model.dataset_sensitivity_removed, 6);
224        assert_eq!(model.vulnerability_introduced, 15);
225        assert_eq!(model.vulnerability_resolved, -3);
226        assert_eq!(model.hash_mismatch, 8);
227        assert_eq!(model.crypto_algorithm_changed, 8);
228        assert_eq!(model.crypto_downgrade, 20);
229        assert_eq!(model.crypto_key_rotated, 3);
230        assert_eq!(model.crypto_cert_expiry_changed, 5);
231        assert_eq!(model.crypto_protocol_changed, 6);
232        assert_eq!(model.crypto_quantum_level_changed, 10);
233    }
234
235    #[test]
236    fn test_security_focused_model() {
237        let model = CostModel::security_focused();
238        let default = CostModel::default();
239        assert!(model.vulnerability_introduced > default.vulnerability_introduced);
240        assert!(model.hash_mismatch > default.hash_mismatch);
241        assert!(model.vulnerability_resolved < default.vulnerability_resolved);
242        assert!(model.crypto_downgrade > default.crypto_downgrade);
243        assert!(model.crypto_quantum_level_changed > default.crypto_quantum_level_changed);
244        assert!(model.dataset_sensitivity_added > default.dataset_sensitivity_added);
245        assert!(model.ml_training_dataset_removed > default.ml_training_dataset_removed);
246        assert!(model.ml_quantization_changed > default.ml_quantization_changed);
247    }
248
249    #[test]
250    fn test_compliance_focused_model() {
251        let model = CostModel::compliance_focused();
252        let default = CostModel::default();
253        assert!(model.license_changed > default.license_changed);
254        assert!(model.supplier_changed > default.supplier_changed);
255    }
256
257    fn ver(major: u64, minor: u64, patch: u64) -> semver::Version {
258        semver::Version::new(major, minor, patch)
259    }
260
261    #[test]
262    fn test_version_change_cost_major() {
263        let model = CostModel::default();
264        let old = Some(ver(1, 0, 0));
265        let new = Some(ver(2, 0, 0));
266        assert_eq!(model.version_change_cost(&old, &new), model.version_major);
267    }
268
269    #[test]
270    fn test_version_change_cost_minor() {
271        let model = CostModel::default();
272        let old = Some(ver(1, 0, 0));
273        let new = Some(ver(1, 1, 0));
274        assert_eq!(model.version_change_cost(&old, &new), model.version_minor);
275    }
276
277    #[test]
278    fn test_version_change_cost_patch() {
279        let model = CostModel::default();
280        let old = Some(ver(1, 0, 0));
281        let new = Some(ver(1, 0, 1));
282        assert_eq!(model.version_change_cost(&old, &new), model.version_patch);
283    }
284
285    #[test]
286    fn test_version_change_cost_same() {
287        let model = CostModel::default();
288        let old = Some(ver(1, 2, 3));
289        let new = Some(ver(1, 2, 3));
290        assert_eq!(model.version_change_cost(&old, &new), 0);
291    }
292
293    #[test]
294    fn test_version_change_cost_none_to_some() {
295        let model = CostModel::default();
296        let new = Some(ver(1, 0, 0));
297        assert_eq!(model.version_change_cost(&None, &new), model.version_minor);
298    }
299
300    #[test]
301    fn test_version_change_cost_some_to_none() {
302        let model = CostModel::default();
303        let old = Some(ver(1, 0, 0));
304        assert_eq!(model.version_change_cost(&old, &None), model.version_minor);
305    }
306
307    #[test]
308    fn test_version_change_cost_none_none() {
309        let model = CostModel::default();
310        assert_eq!(model.version_change_cost(&None, &None), 0);
311    }
312
313    #[test]
314    fn test_semantic_score_zero() {
315        let model = CostModel::default();
316        let score = model.calculate_semantic_score(0, 0, 0, 0, 0, 0, 0, 0);
317        assert_eq!(score, 0.0);
318    }
319
320    #[test]
321    fn test_semantic_score_basic() {
322        let model = CostModel::default();
323        // 2 added (10 each) + 1 removed (10) + 1 version change (4) = 34
324        let score = model.calculate_semantic_score(2, 1, 1, 0, 0, 0, 0, 0);
325        assert_eq!(score, 34.0);
326    }
327
328    #[test]
329    fn test_semantic_score_with_resolved() {
330        let model = CostModel::default();
331        // 1 vuln introduced (15) + 2 resolved (2 * -3 = -6) = 9
332        let score = model.calculate_semantic_score(0, 0, 0, 0, 1, 2, 0, 0);
333        assert_eq!(score, 9.0);
334    }
335}