1use crate::model::{CompletenessDeclaration, NormalizedSbom, SbomFormat};
7use serde::{Deserialize, Serialize};
8
9use super::compliance::{ComplianceChecker, ComplianceLevel, ComplianceResult};
10use super::metrics::{
11 AuditabilityMetrics, CompletenessMetrics, CompletenessWeights, DependencyMetrics,
12 HashQualityMetrics, IdentifierMetrics, LicenseMetrics, LifecycleMetrics, ProvenanceMetrics,
13 VulnerabilityMetrics,
14};
15
16pub const SCORING_ENGINE_VERSION: &str = "2.0";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[non_exhaustive]
22pub enum ScoringProfile {
23 Minimal,
25 Standard,
27 Security,
29 LicenseCompliance,
31 Cra,
33 Comprehensive,
35}
36
37impl ScoringProfile {
38 #[must_use]
40 pub const fn compliance_level(&self) -> ComplianceLevel {
41 match self {
42 Self::Minimal => ComplianceLevel::Minimum,
43 Self::Standard | Self::LicenseCompliance => ComplianceLevel::Standard,
44 Self::Security => ComplianceLevel::NtiaMinimum,
45 Self::Cra => ComplianceLevel::CraPhase2,
46 Self::Comprehensive => ComplianceLevel::Comprehensive,
47 }
48 }
49
50 const fn weights(self) -> ScoringWeights {
55 match self {
56 Self::Minimal => ScoringWeights {
57 completeness: 0.35,
58 identifiers: 0.20,
59 licenses: 0.10,
60 vulnerabilities: 0.05,
61 dependencies: 0.10,
62 integrity: 0.05,
63 provenance: 0.10,
64 lifecycle: 0.05,
65 },
66 Self::Standard => ScoringWeights {
67 completeness: 0.25,
68 identifiers: 0.20,
69 licenses: 0.12,
70 vulnerabilities: 0.08,
71 dependencies: 0.10,
72 integrity: 0.08,
73 provenance: 0.10,
74 lifecycle: 0.07,
75 },
76 Self::Security => ScoringWeights {
77 completeness: 0.12,
78 identifiers: 0.18,
79 licenses: 0.05,
80 vulnerabilities: 0.20,
81 dependencies: 0.10,
82 integrity: 0.15,
83 provenance: 0.10,
84 lifecycle: 0.10,
85 },
86 Self::LicenseCompliance => ScoringWeights {
87 completeness: 0.15,
88 identifiers: 0.12,
89 licenses: 0.35,
90 vulnerabilities: 0.05,
91 dependencies: 0.10,
92 integrity: 0.05,
93 provenance: 0.10,
94 lifecycle: 0.08,
95 },
96 Self::Cra => ScoringWeights {
97 completeness: 0.12,
98 identifiers: 0.18,
99 licenses: 0.08,
100 vulnerabilities: 0.15,
101 dependencies: 0.12,
102 integrity: 0.12,
103 provenance: 0.15,
104 lifecycle: 0.08,
105 },
106 Self::Comprehensive => ScoringWeights {
107 completeness: 0.15,
108 identifiers: 0.13,
109 licenses: 0.13,
110 vulnerabilities: 0.10,
111 dependencies: 0.12,
112 integrity: 0.12,
113 provenance: 0.13,
114 lifecycle: 0.12,
115 },
116 }
117 }
118}
119
120#[derive(Debug, Clone)]
122struct ScoringWeights {
123 completeness: f32,
124 identifiers: f32,
125 licenses: f32,
126 vulnerabilities: f32,
127 dependencies: f32,
128 integrity: f32,
129 provenance: f32,
130 lifecycle: f32,
131}
132
133impl ScoringWeights {
134 fn as_array(&self) -> [f32; 8] {
136 [
137 self.completeness,
138 self.identifiers,
139 self.licenses,
140 self.vulnerabilities,
141 self.dependencies,
142 self.integrity,
143 self.provenance,
144 self.lifecycle,
145 ]
146 }
147
148 fn renormalize(&self, available: &[bool; 8]) -> [f32; 8] {
153 let raw = self.as_array();
154 let total_available: f32 = raw
155 .iter()
156 .zip(available)
157 .filter(|&(_, a)| *a)
158 .map(|(w, _)| w)
159 .sum();
160
161 if total_available <= 0.0 {
162 return [0.0; 8];
163 }
164
165 let scale = 1.0 / total_available;
166 let mut result = [0.0_f32; 8];
167 for (i, (&w, &avail)) in raw.iter().zip(available).enumerate() {
168 result[i] = if avail { w * scale } else { 0.0 };
169 }
170 result
171 }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[non_exhaustive]
177pub enum QualityGrade {
178 A,
180 B,
182 C,
184 D,
186 F,
188}
189
190impl QualityGrade {
191 #[must_use]
193 pub const fn from_score(score: f32) -> Self {
194 match score as u32 {
195 90..=100 => Self::A,
196 80..=89 => Self::B,
197 70..=79 => Self::C,
198 60..=69 => Self::D,
199 _ => Self::F,
200 }
201 }
202
203 #[must_use]
205 pub const fn letter(&self) -> &'static str {
206 match self {
207 Self::A => "A",
208 Self::B => "B",
209 Self::C => "C",
210 Self::D => "D",
211 Self::F => "F",
212 }
213 }
214
215 #[must_use]
217 pub const fn description(&self) -> &'static str {
218 match self {
219 Self::A => "Excellent",
220 Self::B => "Good",
221 Self::C => "Fair",
222 Self::D => "Poor",
223 Self::F => "Failing",
224 }
225 }
226}
227
228#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct Recommendation {
231 pub priority: u8,
233 pub category: RecommendationCategory,
235 pub message: String,
237 pub impact: f32,
239 pub affected_count: usize,
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
245#[non_exhaustive]
246pub enum RecommendationCategory {
247 Completeness,
248 Identifiers,
249 Licenses,
250 Vulnerabilities,
251 Dependencies,
252 Compliance,
253 Integrity,
254 Provenance,
255 Lifecycle,
256}
257
258impl RecommendationCategory {
259 #[must_use]
260 pub const fn name(&self) -> &'static str {
261 match self {
262 Self::Completeness => "Completeness",
263 Self::Identifiers => "Identifiers",
264 Self::Licenses => "Licenses",
265 Self::Vulnerabilities => "Vulnerabilities",
266 Self::Dependencies => "Dependencies",
267 Self::Compliance => "Compliance",
268 Self::Integrity => "Integrity",
269 Self::Provenance => "Provenance",
270 Self::Lifecycle => "Lifecycle",
271 }
272 }
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277#[must_use]
278pub struct QualityReport {
279 pub scoring_engine_version: String,
281 pub overall_score: f32,
283 pub grade: QualityGrade,
285 pub profile: ScoringProfile,
287
288 pub completeness_score: f32,
291 pub identifier_score: f32,
293 pub license_score: f32,
295 pub vulnerability_score: Option<f32>,
297 pub dependency_score: f32,
299 pub integrity_score: f32,
301 pub provenance_score: f32,
303 pub lifecycle_score: Option<f32>,
305
306 pub completeness_metrics: CompletenessMetrics,
309 pub identifier_metrics: IdentifierMetrics,
311 pub license_metrics: LicenseMetrics,
313 pub vulnerability_metrics: VulnerabilityMetrics,
315 pub dependency_metrics: DependencyMetrics,
317 pub hash_quality_metrics: HashQualityMetrics,
319 pub provenance_metrics: ProvenanceMetrics,
321 pub auditability_metrics: AuditabilityMetrics,
323 pub lifecycle_metrics: LifecycleMetrics,
325
326 pub compliance: ComplianceResult,
328 pub recommendations: Vec<Recommendation>,
330}
331
332#[derive(Debug, Clone)]
334pub struct QualityScorer {
335 profile: ScoringProfile,
337 completeness_weights: CompletenessWeights,
339}
340
341impl QualityScorer {
342 #[must_use]
344 pub fn new(profile: ScoringProfile) -> Self {
345 Self {
346 profile,
347 completeness_weights: CompletenessWeights::default(),
348 }
349 }
350
351 #[must_use]
353 pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
354 self.completeness_weights = weights;
355 self
356 }
357
358 pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
360 let total_components = sbom.components.len();
361 let is_cyclonedx = sbom.document.format == SbomFormat::CycloneDx;
362
363 let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
365 let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
366 let license_metrics = LicenseMetrics::from_sbom(sbom);
367 let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
368 let dependency_metrics = DependencyMetrics::from_sbom(sbom);
369 let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
370 let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
371 let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
372 let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
373
374 let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
376 let identifier_score = identifier_metrics.quality_score(total_components);
377 let license_score = license_metrics.quality_score(total_components);
378 let vulnerability_score = vulnerability_metrics.documentation_score();
379 let dependency_score = dependency_metrics.quality_score(total_components);
380 let integrity_score = hash_quality_metrics.quality_score(total_components);
381 let provenance_raw = provenance_metrics.quality_score(is_cyclonedx);
382 let auditability_raw = auditability_metrics.quality_score(total_components);
383 let provenance_score = provenance_raw * 0.6 + auditability_raw * 0.4;
385 let lifecycle_score = lifecycle_metrics.quality_score();
386
387 let vuln_available = vulnerability_score.is_some();
389 let lifecycle_available = lifecycle_score.is_some();
390 let available = [
391 true, true, true, vuln_available, true, true, true, lifecycle_available, ];
400
401 let weights = self.profile.weights();
403 let norm = weights.renormalize(&available);
404 let scores = [
405 completeness_score,
406 identifier_score,
407 license_score,
408 vulnerability_score.unwrap_or(0.0),
409 dependency_score,
410 integrity_score,
411 provenance_score,
412 lifecycle_score.unwrap_or(0.0),
413 ];
414
415 let mut overall_score: f32 = scores.iter().zip(norm.iter()).map(|(s, w)| s * w).sum();
416 overall_score = overall_score.min(100.0);
417
418 overall_score = self.apply_score_caps(
420 overall_score,
421 &lifecycle_metrics,
422 &dependency_metrics,
423 &hash_quality_metrics,
424 total_components,
425 );
426
427 let compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
429 let compliance = compliance_checker.check(sbom);
430
431 let recommendations = self.generate_recommendations(
433 &completeness_metrics,
434 &identifier_metrics,
435 &license_metrics,
436 &dependency_metrics,
437 &hash_quality_metrics,
438 &provenance_metrics,
439 &lifecycle_metrics,
440 &compliance,
441 total_components,
442 );
443
444 QualityReport {
445 scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
446 overall_score,
447 grade: QualityGrade::from_score(overall_score),
448 profile: self.profile,
449 completeness_score,
450 identifier_score,
451 license_score,
452 vulnerability_score,
453 dependency_score,
454 integrity_score,
455 provenance_score,
456 lifecycle_score,
457 completeness_metrics,
458 identifier_metrics,
459 license_metrics,
460 vulnerability_metrics,
461 dependency_metrics,
462 hash_quality_metrics,
463 provenance_metrics,
464 auditability_metrics,
465 lifecycle_metrics,
466 compliance,
467 recommendations,
468 }
469 }
470
471 fn apply_score_caps(
473 &self,
474 mut score: f32,
475 lifecycle: &LifecycleMetrics,
476 deps: &DependencyMetrics,
477 hashes: &HashQualityMetrics,
478 total_components: usize,
479 ) -> f32 {
480 let is_security_profile =
481 matches!(self.profile, ScoringProfile::Security | ScoringProfile::Cra);
482
483 if is_security_profile && lifecycle.eol_components > 0 {
485 score = score.min(69.0);
486 }
487
488 if deps.cycle_count > 0
490 && matches!(
491 self.profile,
492 ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
493 )
494 {
495 score = score.min(89.0);
496 }
497
498 if matches!(self.profile, ScoringProfile::Security)
500 && total_components > 0
501 && hashes.components_with_any_hash == 0
502 {
503 score = score.min(79.0);
504 }
505
506 if matches!(self.profile, ScoringProfile::Security)
508 && hashes.components_with_weak_only > 0
509 && hashes.components_with_strong_hash == 0
510 {
511 score = score.min(89.0);
512 }
513
514 score
515 }
516
517 #[allow(clippy::too_many_arguments)]
518 fn generate_recommendations(
519 &self,
520 completeness: &CompletenessMetrics,
521 identifiers: &IdentifierMetrics,
522 licenses: &LicenseMetrics,
523 dependencies: &DependencyMetrics,
524 hashes: &HashQualityMetrics,
525 provenance: &ProvenanceMetrics,
526 lifecycle: &LifecycleMetrics,
527 compliance: &ComplianceResult,
528 total_components: usize,
529 ) -> Vec<Recommendation> {
530 let mut recommendations = Vec::new();
531
532 if compliance.error_count > 0 {
534 recommendations.push(Recommendation {
535 priority: 1,
536 category: RecommendationCategory::Compliance,
537 message: format!(
538 "Fix {} compliance error(s) to meet {} requirements",
539 compliance.error_count,
540 compliance.level.name()
541 ),
542 impact: 20.0,
543 affected_count: compliance.error_count,
544 });
545 }
546
547 if lifecycle.eol_components > 0 {
549 recommendations.push(Recommendation {
550 priority: 1,
551 category: RecommendationCategory::Lifecycle,
552 message: format!(
553 "{} component(s) have reached end-of-life — upgrade or replace",
554 lifecycle.eol_components
555 ),
556 impact: 15.0,
557 affected_count: lifecycle.eol_components,
558 });
559 }
560
561 let missing_versions = total_components
563 - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
564 if missing_versions > 0 {
565 recommendations.push(Recommendation {
566 priority: 1,
567 category: RecommendationCategory::Completeness,
568 message: "Add version information to all components".to_string(),
569 impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
570 affected_count: missing_versions,
571 });
572 }
573
574 if hashes.components_with_weak_only > 0 {
576 recommendations.push(Recommendation {
577 priority: 2,
578 category: RecommendationCategory::Integrity,
579 message: "Upgrade weak hashes (MD5/SHA-1) to SHA-256 or stronger".to_string(),
580 impact: 10.0,
581 affected_count: hashes.components_with_weak_only,
582 });
583 }
584
585 if identifiers.missing_all_identifiers > 0 {
587 recommendations.push(Recommendation {
588 priority: 2,
589 category: RecommendationCategory::Identifiers,
590 message: "Add PURL or CPE identifiers to components".to_string(),
591 impact: (identifiers.missing_all_identifiers as f32
592 / total_components.max(1) as f32)
593 * 20.0,
594 affected_count: identifiers.missing_all_identifiers,
595 });
596 }
597
598 let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
600 if invalid_ids > 0 {
601 recommendations.push(Recommendation {
602 priority: 2,
603 category: RecommendationCategory::Identifiers,
604 message: "Fix malformed PURL/CPE identifiers".to_string(),
605 impact: 10.0,
606 affected_count: invalid_ids,
607 });
608 }
609
610 if !provenance.has_tool_creator {
612 recommendations.push(Recommendation {
613 priority: 2,
614 category: RecommendationCategory::Provenance,
615 message: "Add SBOM creation tool information".to_string(),
616 impact: 8.0,
617 affected_count: 0,
618 });
619 }
620
621 if dependencies.cycle_count > 0 {
623 recommendations.push(Recommendation {
624 priority: 3,
625 category: RecommendationCategory::Dependencies,
626 message: format!(
627 "{} dependency cycle(s) detected — review dependency graph",
628 dependencies.cycle_count
629 ),
630 impact: 10.0,
631 affected_count: dependencies.cycle_count,
632 });
633 }
634
635 if let Some(level) = &dependencies.complexity_level {
637 match level {
638 super::metrics::ComplexityLevel::VeryHigh => {
639 recommendations.push(Recommendation {
640 priority: 2,
641 category: RecommendationCategory::Dependencies,
642 message:
643 "Dependency structure is very complex — review for unnecessary transitive dependencies"
644 .to_string(),
645 impact: 8.0,
646 affected_count: dependencies.total_dependencies,
647 });
648 }
649 super::metrics::ComplexityLevel::High => {
650 recommendations.push(Recommendation {
651 priority: 3,
652 category: RecommendationCategory::Dependencies,
653 message:
654 "Dependency structure is complex — consider reducing hub dependencies or flattening deep chains"
655 .to_string(),
656 impact: 5.0,
657 affected_count: dependencies.total_dependencies,
658 });
659 }
660 _ => {}
661 }
662 }
663
664 let missing_licenses = total_components - licenses.with_declared;
666 if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
667 {
668 recommendations.push(Recommendation {
669 priority: 3,
670 category: RecommendationCategory::Licenses,
671 message: "Add license information to components".to_string(),
672 impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
673 affected_count: missing_licenses,
674 });
675 }
676
677 if licenses.noassertion_count > 0 {
679 recommendations.push(Recommendation {
680 priority: 3,
681 category: RecommendationCategory::Licenses,
682 message: "Replace NOASSERTION with actual license information".to_string(),
683 impact: 5.0,
684 affected_count: licenses.noassertion_count,
685 });
686 }
687
688 if total_components > 0 {
690 let missing_vcs = total_components.saturating_sub(
691 ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize,
692 );
693 if missing_vcs > total_components / 2 {
694 recommendations.push(Recommendation {
695 priority: 3,
696 category: RecommendationCategory::Provenance,
697 message: "Add VCS (source repository) URLs to components".to_string(),
698 impact: 5.0,
699 affected_count: missing_vcs,
700 });
701 }
702 }
703
704 if licenses.non_standard_licenses > 0 {
706 recommendations.push(Recommendation {
707 priority: 4,
708 category: RecommendationCategory::Licenses,
709 message: "Use SPDX license identifiers for better interoperability".to_string(),
710 impact: 3.0,
711 affected_count: licenses.non_standard_licenses,
712 });
713 }
714
715 if lifecycle.outdated_components > 0 {
717 recommendations.push(Recommendation {
718 priority: 4,
719 category: RecommendationCategory::Lifecycle,
720 message: format!(
721 "{} component(s) are outdated — newer versions available",
722 lifecycle.outdated_components
723 ),
724 impact: 5.0,
725 affected_count: lifecycle.outdated_components,
726 });
727 }
728
729 if provenance.completeness_declaration == CompletenessDeclaration::Unknown
731 && matches!(
732 self.profile,
733 ScoringProfile::Cra | ScoringProfile::Comprehensive
734 )
735 {
736 recommendations.push(Recommendation {
737 priority: 4,
738 category: RecommendationCategory::Provenance,
739 message: "Add compositions section with aggregate completeness declaration"
740 .to_string(),
741 impact: 5.0,
742 affected_count: 0,
743 });
744 }
745
746 if total_components > 1 && dependencies.total_dependencies == 0 {
748 recommendations.push(Recommendation {
749 priority: 4,
750 category: RecommendationCategory::Dependencies,
751 message: "Add dependency relationships between components".to_string(),
752 impact: 10.0,
753 affected_count: total_components,
754 });
755 }
756
757 if dependencies.orphan_components > 1
759 && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
760 {
761 recommendations.push(Recommendation {
762 priority: 4,
763 category: RecommendationCategory::Dependencies,
764 message: "Review orphan components that have no dependency relationships"
765 .to_string(),
766 impact: 5.0,
767 affected_count: dependencies.orphan_components,
768 });
769 }
770
771 let missing_suppliers = total_components
773 - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
774 if missing_suppliers > 0
775 && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
776 {
777 recommendations.push(Recommendation {
778 priority: 5,
779 category: RecommendationCategory::Completeness,
780 message: "Add supplier information to components".to_string(),
781 impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
782 affected_count: missing_suppliers,
783 });
784 }
785
786 let missing_hashes = total_components
788 - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
789 if missing_hashes > 0
790 && matches!(
791 self.profile,
792 ScoringProfile::Security | ScoringProfile::Comprehensive
793 )
794 {
795 recommendations.push(Recommendation {
796 priority: 5,
797 category: RecommendationCategory::Integrity,
798 message: "Add cryptographic hashes for integrity verification".to_string(),
799 impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
800 affected_count: missing_hashes,
801 });
802 }
803
804 if !provenance.has_signature
806 && matches!(
807 self.profile,
808 ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
809 )
810 {
811 recommendations.push(Recommendation {
812 priority: 5,
813 category: RecommendationCategory::Integrity,
814 message: "Consider adding a digital signature to the SBOM".to_string(),
815 impact: 3.0,
816 affected_count: 0,
817 });
818 }
819
820 recommendations.sort_by(|a, b| {
822 a.priority.cmp(&b.priority).then_with(|| {
823 b.impact
824 .partial_cmp(&a.impact)
825 .unwrap_or(std::cmp::Ordering::Equal)
826 })
827 });
828
829 recommendations
830 }
831}
832
833impl Default for QualityScorer {
834 fn default() -> Self {
835 Self::new(ScoringProfile::Standard)
836 }
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842
843 #[test]
844 fn test_grade_from_score() {
845 assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
846 assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
847 assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
848 assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
849 assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
850 }
851
852 #[test]
853 fn test_scoring_profile_compliance_level() {
854 assert_eq!(
855 ScoringProfile::Minimal.compliance_level(),
856 ComplianceLevel::Minimum
857 );
858 assert_eq!(
859 ScoringProfile::Security.compliance_level(),
860 ComplianceLevel::NtiaMinimum
861 );
862 assert_eq!(
863 ScoringProfile::Comprehensive.compliance_level(),
864 ComplianceLevel::Comprehensive
865 );
866 }
867
868 #[test]
869 fn test_scoring_weights_sum_to_one() {
870 let profiles = [
871 ScoringProfile::Minimal,
872 ScoringProfile::Standard,
873 ScoringProfile::Security,
874 ScoringProfile::LicenseCompliance,
875 ScoringProfile::Cra,
876 ScoringProfile::Comprehensive,
877 ];
878 for profile in &profiles {
879 let w = profile.weights();
880 let sum: f32 = w.as_array().iter().sum();
881 assert!(
882 (sum - 1.0).abs() < 0.01,
883 "{profile:?} weights sum to {sum}, expected 1.0"
884 );
885 }
886 }
887
888 #[test]
889 fn test_renormalize_all_available() {
890 let w = ScoringProfile::Standard.weights();
891 let available = [true; 8];
892 let norm = w.renormalize(&available);
893 let sum: f32 = norm.iter().sum();
894 assert!((sum - 1.0).abs() < 0.001);
895 }
896
897 #[test]
898 fn test_renormalize_lifecycle_unavailable() {
899 let w = ScoringProfile::Standard.weights();
900 let mut available = [true; 8];
901 available[7] = false; let norm = w.renormalize(&available);
903 let sum: f32 = norm.iter().sum();
904 assert!((sum - 1.0).abs() < 0.001);
905 assert_eq!(norm[7], 0.0);
906 }
907
908 #[test]
909 fn test_scoring_engine_version() {
910 assert_eq!(SCORING_ENGINE_VERSION, "2.0");
911 }
912}