1use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13pub const SCHEMA_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct ScoringPolicy {
20 pub schema_version: u32,
22 pub attribution: AttributionMultipliers,
24 pub verification: VerificationMultipliers,
26 pub freshness: FreshnessParams,
28 pub deprecation: DeprecationParams,
30 pub version_match: VersionMatchMultipliers,
32 pub blend: BlendWeights,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct AttributionMultipliers {
40 pub foundation: f64,
42 pub partner: f64,
44 pub third_party: f64,
46 pub community: f64,
48 pub unknown: f64,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct VerificationMultipliers {
56 pub verified_by_foundation: f64,
58 pub verified_by_partner: f64,
60 pub verified_by_other: f64,
62 pub unverified: f64,
64}
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct FreshnessParams {
70 pub half_life_days: f64,
72 pub fallback_age_source: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct DeprecationParams {
81 pub penalty_multiplier: f64,
83}
84
85#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct VersionMatchMultipliers {
90 pub satisfies: f64,
92 pub neutral: f64,
94 pub floor: f64,
96 pub patch_step: f64,
98 pub minor_step: f64,
100}
101
102#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct BlendWeights {
106 pub trust_weight: f64,
108 pub relevance_weight: f64,
110}
111
112impl Default for ScoringPolicy {
113 fn default() -> Self {
114 Self {
115 schema_version: SCHEMA_VERSION,
116 attribution: AttributionMultipliers {
117 foundation: 1.00,
118 partner: 0.85,
119 third_party: 0.60,
120 community: 0.40,
121 unknown: 0.30,
122 },
123 verification: VerificationMultipliers {
124 verified_by_foundation: 1.00,
125 verified_by_partner: 0.90,
126 verified_by_other: 0.80,
127 unverified: 0.70,
128 },
129 freshness: FreshnessParams {
130 half_life_days: 180.0,
131 fallback_age_source: "ingested_at".into(),
132 },
133 deprecation: DeprecationParams { penalty_multiplier: 0.30 },
134 version_match: VersionMatchMultipliers {
135 satisfies: 1.15,
136 neutral: 1.00,
137 floor: 0.30,
138 patch_step: 0.05,
139 minor_step: 0.15,
140 },
141 blend: BlendWeights {
142 trust_weight: 0.55,
143 relevance_weight: 0.45,
144 },
145 }
146 }
147}
148
149impl ScoringPolicy {
150 pub fn parse(body: &str) -> Result<Self, ScoringPolicyError> {
159 let policy: Self =
160 toml::from_str(body).map_err(|e| ScoringPolicyError::Parse(e.to_string()))?;
161 if policy.schema_version != SCHEMA_VERSION {
162 return Err(ScoringPolicyError::SchemaVersionMismatch {
163 found: policy.schema_version,
164 expected: SCHEMA_VERSION,
165 });
166 }
167 policy.validate_finite()?;
168 Ok(policy)
169 }
170
171 fn validate_finite(&self) -> Result<(), ScoringPolicyError> {
172 let weights: [(&str, f64); 18] = [
173 ("attribution.foundation", self.attribution.foundation),
174 ("attribution.partner", self.attribution.partner),
175 ("attribution.third_party", self.attribution.third_party),
176 ("attribution.community", self.attribution.community),
177 ("attribution.unknown", self.attribution.unknown),
178 ("verification.verified_by_foundation", self.verification.verified_by_foundation),
179 ("verification.verified_by_partner", self.verification.verified_by_partner),
180 ("verification.verified_by_other", self.verification.verified_by_other),
181 ("verification.unverified", self.verification.unverified),
182 ("freshness.half_life_days", self.freshness.half_life_days),
183 ("deprecation.penalty_multiplier", self.deprecation.penalty_multiplier),
184 ("version_match.satisfies", self.version_match.satisfies),
185 ("version_match.neutral", self.version_match.neutral),
186 ("version_match.floor", self.version_match.floor),
187 ("version_match.patch_step", self.version_match.patch_step),
188 ("version_match.minor_step", self.version_match.minor_step),
189 ("blend.trust_weight", self.blend.trust_weight),
190 ("blend.relevance_weight", self.blend.relevance_weight),
191 ];
192 for (name, w) in weights {
193 if !w.is_finite() || w < 0.0 {
194 return Err(ScoringPolicyError::NonFiniteWeight {
195 field: name.to_owned(),
196 value: w,
197 });
198 }
199 }
200 if self.freshness.half_life_days <= 0.0 {
201 return Err(ScoringPolicyError::NonFiniteWeight {
202 field: "freshness.half_life_days".into(),
203 value: self.freshness.half_life_days,
204 });
205 }
206 Ok(())
207 }
208}
209
210#[derive(Debug, Error)]
212pub enum ScoringPolicyError {
213 #[error("failed to parse scoring policy: {0}")]
215 Parse(String),
216 #[error("scoring policy schema_version={found}; expected {expected}")]
218 SchemaVersionMismatch {
219 found: u32,
221 expected: u32,
223 },
224 #[error("scoring policy field `{field}` has non-finite or negative value {value}")]
226 NonFiniteWeight {
227 field: String,
229 value: f64,
231 },
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn defaults_are_well_formed() {
240 let p = ScoringPolicy::default();
241 assert_eq!(p.schema_version, 1);
242 assert!((p.blend.trust_weight + p.blend.relevance_weight - 1.0).abs() < 1e-9);
243 assert!(p.attribution.foundation > p.attribution.community);
244 }
245
246 #[test]
247 fn round_trips_through_toml() {
248 let body = toml::to_string(&ScoringPolicy::default()).unwrap();
249 let back = ScoringPolicy::parse(&body).unwrap();
250 assert_eq!(back, ScoringPolicy::default());
251 }
252
253 #[test]
254 fn rejects_schema_mismatch() {
255 let p = ScoringPolicy {
256 schema_version: 99,
257 ..ScoringPolicy::default()
258 };
259 let body = toml::to_string(&p).unwrap();
260 let err = ScoringPolicy::parse(&body).unwrap_err();
261 assert!(matches!(
262 err,
263 ScoringPolicyError::SchemaVersionMismatch { found: 99, expected: 1 }
264 ));
265 }
266
267 #[test]
268 fn rejects_negative_weight() {
269 let p = ScoringPolicy {
270 attribution: AttributionMultipliers {
271 foundation: -1.0,
272 ..ScoringPolicy::default().attribution
273 },
274 ..ScoringPolicy::default()
275 };
276 let body = toml::to_string(&p).unwrap();
277 let err = ScoringPolicy::parse(&body).unwrap_err();
278 assert!(matches!(err, ScoringPolicyError::NonFiniteWeight { .. }));
279 }
280
281 #[test]
282 fn rejects_unknown_key() {
283 let mut body = toml::to_string(&ScoringPolicy::default()).unwrap();
285 body.push_str("\nbogus_top_level_key = 42\n");
286 let err = ScoringPolicy::parse(&body).unwrap_err();
287 assert!(matches!(err, ScoringPolicyError::Parse(_)));
288 }
289
290 #[test]
291 fn rejects_negative_neutral_or_floor() {
292 for mutate in [
293 |p: &mut ScoringPolicy| p.version_match.neutral = -0.1,
294 |p: &mut ScoringPolicy| p.version_match.floor = -0.1,
295 ] {
296 let mut p = ScoringPolicy::default();
297 mutate(&mut p);
298 assert!(p.validate_finite().is_err());
299 }
300 }
301
302 #[test]
303 fn version_match_knobs_v2() {
304 let p = ScoringPolicy::default();
305 assert!((p.version_match.satisfies - 1.15).abs() < 1e-12);
306 assert!((p.version_match.neutral - 1.00).abs() < 1e-12);
307 assert!((p.version_match.floor - 0.30).abs() < 1e-12);
308 assert!((p.version_match.patch_step - 0.05).abs() < 1e-12);
309 assert!((p.version_match.minor_step - 0.15).abs() < 1e-12);
310 }
311
312 #[test]
313 fn rejects_legacy_unsatisfied_key() {
314 let body = toml::to_string(&ScoringPolicy::default())
320 .unwrap()
321 .replace("[version_match]", "[version_match]\nunsatisfied = 0.7");
322 let err = ScoringPolicy::parse(&body).unwrap_err();
323 assert!(
324 matches!(err, ScoringPolicyError::Parse(_)),
325 "stale `unsatisfied` key must fail loudly: {err:?}"
326 );
327 }
328
329 #[test]
330 fn rejects_zero_half_life() {
331 let p = ScoringPolicy {
332 freshness: FreshnessParams {
333 half_life_days: 0.0,
334 ..ScoringPolicy::default().freshness
335 },
336 ..ScoringPolicy::default()
337 };
338 let body = toml::to_string(&p).unwrap();
339 let err = ScoringPolicy::parse(&body).unwrap_err();
340 assert!(matches!(err, ScoringPolicyError::NonFiniteWeight { .. }));
341 }
342}