Skip to main content

lexicon_spec/
scoring.rs

1use serde::{Deserialize, Serialize};
2
3use crate::common::{DimensionCategory, ScoreSource};
4use crate::version::SchemaVersion;
5
6/// The scoring model defines how repository health is measured.
7///
8/// Each dimension contributes a weighted score. Dimensions can be
9/// required (must pass), scored (contributes to total), or advisory
10/// (informational only).
11///
12/// Stored at `specs/scoring/model.toml`.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct ScoreModel {
15    pub schema_version: SchemaVersion,
16    #[serde(default)]
17    pub dimensions: Vec<ScoreDimension>,
18    #[serde(default)]
19    pub thresholds: ScoreThresholds,
20}
21
22/// A single scoring dimension.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ScoreDimension {
25    /// Unique identifier, e.g. "correctness", "conformance-coverage".
26    pub id: String,
27    /// Human-readable label.
28    pub label: String,
29    /// Weight in the total score calculation.
30    pub weight: u32,
31    /// Whether this dimension is required, scored, or advisory.
32    #[serde(default)]
33    pub category: DimensionCategory,
34    /// Where the dimension's value comes from.
35    pub source: ScoreSource,
36}
37
38/// Thresholds for the overall score.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ScoreThresholds {
41    /// Score at or above this value is "pass" (0.0 - 1.0).
42    pub pass: f64,
43    /// Score at or above this value but below pass is "warn".
44    pub warn: f64,
45}
46
47impl Default for ScoreThresholds {
48    fn default() -> Self {
49        Self {
50            pass: 0.8,
51            warn: 0.6,
52        }
53    }
54}
55
56impl ScoreModel {
57    /// Create a default scoring model with standard dimensions.
58    pub fn default_model() -> Self {
59        Self {
60            schema_version: SchemaVersion::CURRENT,
61            dimensions: vec![
62                ScoreDimension {
63                    id: "correctness".to_string(),
64                    label: "Correctness".to_string(),
65                    weight: 30,
66                    category: DimensionCategory::Required,
67                    source: ScoreSource::Gate,
68                },
69                ScoreDimension {
70                    id: "conformance-coverage".to_string(),
71                    label: "Conformance Coverage".to_string(),
72                    weight: 20,
73                    category: DimensionCategory::Scored,
74                    source: ScoreSource::TestSuite,
75                },
76                ScoreDimension {
77                    id: "behavior-pass-rate".to_string(),
78                    label: "Behavior Pass Rate".to_string(),
79                    weight: 10,
80                    category: DimensionCategory::Scored,
81                    source: ScoreSource::TestSuite,
82                },
83                ScoreDimension {
84                    id: "lint-quality".to_string(),
85                    label: "Lint Quality".to_string(),
86                    weight: 10,
87                    category: DimensionCategory::Scored,
88                    source: ScoreSource::Gate,
89                },
90                ScoreDimension {
91                    id: "doc-completeness".to_string(),
92                    label: "Documentation Completeness".to_string(),
93                    weight: 10,
94                    category: DimensionCategory::Advisory,
95                    source: ScoreSource::Manual,
96                },
97                ScoreDimension {
98                    id: "panic-safety".to_string(),
99                    label: "Panic Safety".to_string(),
100                    weight: 5,
101                    category: DimensionCategory::Scored,
102                    source: ScoreSource::Gate,
103                },
104                ScoreDimension {
105                    id: "contract-coverage".to_string(),
106                    label: "Contract Coverage".to_string(),
107                    weight: 10,
108                    category: DimensionCategory::Scored,
109                    source: ScoreSource::Coverage,
110                },
111                ScoreDimension {
112                    id: "api-drift".to_string(),
113                    label: "API Drift".to_string(),
114                    weight: 5,
115                    category: DimensionCategory::Advisory,
116                    source: ScoreSource::Gate,
117                },
118            ],
119            thresholds: ScoreThresholds::default(),
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_score_model_toml_roundtrip() {
130        let model = ScoreModel::default_model();
131        let toml_str = toml::to_string_pretty(&model).unwrap();
132        let parsed: ScoreModel = toml::from_str(&toml_str).unwrap();
133        assert_eq!(parsed.dimensions.len(), 8);
134        assert_eq!(parsed.thresholds.pass, 0.8);
135    }
136
137    #[test]
138    fn test_weights_sum() {
139        let model = ScoreModel::default_model();
140        let total: u32 = model.dimensions.iter().map(|d| d.weight).sum();
141        assert_eq!(total, 100);
142    }
143}