use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScoringPolicy {
pub schema_version: u32,
pub attribution: AttributionMultipliers,
pub verification: VerificationMultipliers,
pub freshness: FreshnessParams,
pub deprecation: DeprecationParams,
pub version_match: VersionMatchMultipliers,
pub blend: BlendWeights,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AttributionMultipliers {
pub foundation: f64,
pub partner: f64,
pub third_party: f64,
pub community: f64,
pub unknown: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VerificationMultipliers {
pub verified_by_foundation: f64,
pub verified_by_partner: f64,
pub verified_by_other: f64,
pub unverified: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FreshnessParams {
pub half_life_days: f64,
pub fallback_age_source: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeprecationParams {
pub penalty_multiplier: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VersionMatchMultipliers {
pub satisfies: f64,
pub neutral: f64,
pub floor: f64,
pub patch_step: f64,
pub minor_step: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BlendWeights {
pub trust_weight: f64,
pub relevance_weight: f64,
}
impl Default for ScoringPolicy {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
attribution: AttributionMultipliers {
foundation: 1.00,
partner: 0.85,
third_party: 0.60,
community: 0.40,
unknown: 0.30,
},
verification: VerificationMultipliers {
verified_by_foundation: 1.00,
verified_by_partner: 0.90,
verified_by_other: 0.80,
unverified: 0.70,
},
freshness: FreshnessParams {
half_life_days: 180.0,
fallback_age_source: "ingested_at".into(),
},
deprecation: DeprecationParams { penalty_multiplier: 0.30 },
version_match: VersionMatchMultipliers {
satisfies: 1.15,
neutral: 1.00,
floor: 0.30,
patch_step: 0.05,
minor_step: 0.15,
},
blend: BlendWeights {
trust_weight: 0.55,
relevance_weight: 0.45,
},
}
}
}
impl ScoringPolicy {
pub fn parse(body: &str) -> Result<Self, ScoringPolicyError> {
let policy: Self =
toml::from_str(body).map_err(|e| ScoringPolicyError::Parse(e.to_string()))?;
if policy.schema_version != SCHEMA_VERSION {
return Err(ScoringPolicyError::SchemaVersionMismatch {
found: policy.schema_version,
expected: SCHEMA_VERSION,
});
}
policy.validate_finite()?;
Ok(policy)
}
fn validate_finite(&self) -> Result<(), ScoringPolicyError> {
let weights: [(&str, f64); 18] = [
("attribution.foundation", self.attribution.foundation),
("attribution.partner", self.attribution.partner),
("attribution.third_party", self.attribution.third_party),
("attribution.community", self.attribution.community),
("attribution.unknown", self.attribution.unknown),
("verification.verified_by_foundation", self.verification.verified_by_foundation),
("verification.verified_by_partner", self.verification.verified_by_partner),
("verification.verified_by_other", self.verification.verified_by_other),
("verification.unverified", self.verification.unverified),
("freshness.half_life_days", self.freshness.half_life_days),
("deprecation.penalty_multiplier", self.deprecation.penalty_multiplier),
("version_match.satisfies", self.version_match.satisfies),
("version_match.neutral", self.version_match.neutral),
("version_match.floor", self.version_match.floor),
("version_match.patch_step", self.version_match.patch_step),
("version_match.minor_step", self.version_match.minor_step),
("blend.trust_weight", self.blend.trust_weight),
("blend.relevance_weight", self.blend.relevance_weight),
];
for (name, w) in weights {
if !w.is_finite() || w < 0.0 {
return Err(ScoringPolicyError::NonFiniteWeight {
field: name.to_owned(),
value: w,
});
}
}
if self.freshness.half_life_days <= 0.0 {
return Err(ScoringPolicyError::NonFiniteWeight {
field: "freshness.half_life_days".into(),
value: self.freshness.half_life_days,
});
}
Ok(())
}
}
#[derive(Debug, Error)]
pub enum ScoringPolicyError {
#[error("failed to parse scoring policy: {0}")]
Parse(String),
#[error("scoring policy schema_version={found}; expected {expected}")]
SchemaVersionMismatch {
found: u32,
expected: u32,
},
#[error("scoring policy field `{field}` has non-finite or negative value {value}")]
NonFiniteWeight {
field: String,
value: f64,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn defaults_are_well_formed() {
let p = ScoringPolicy::default();
assert_eq!(p.schema_version, 1);
assert!((p.blend.trust_weight + p.blend.relevance_weight - 1.0).abs() < 1e-9);
assert!(p.attribution.foundation > p.attribution.community);
}
#[test]
fn round_trips_through_toml() {
let body = toml::to_string(&ScoringPolicy::default()).unwrap();
let back = ScoringPolicy::parse(&body).unwrap();
assert_eq!(back, ScoringPolicy::default());
}
#[test]
fn rejects_schema_mismatch() {
let p = ScoringPolicy {
schema_version: 99,
..ScoringPolicy::default()
};
let body = toml::to_string(&p).unwrap();
let err = ScoringPolicy::parse(&body).unwrap_err();
assert!(matches!(
err,
ScoringPolicyError::SchemaVersionMismatch { found: 99, expected: 1 }
));
}
#[test]
fn rejects_negative_weight() {
let p = ScoringPolicy {
attribution: AttributionMultipliers {
foundation: -1.0,
..ScoringPolicy::default().attribution
},
..ScoringPolicy::default()
};
let body = toml::to_string(&p).unwrap();
let err = ScoringPolicy::parse(&body).unwrap_err();
assert!(matches!(err, ScoringPolicyError::NonFiniteWeight { .. }));
}
#[test]
fn rejects_unknown_key() {
let mut body = toml::to_string(&ScoringPolicy::default()).unwrap();
body.push_str("\nbogus_top_level_key = 42\n");
let err = ScoringPolicy::parse(&body).unwrap_err();
assert!(matches!(err, ScoringPolicyError::Parse(_)));
}
#[test]
fn rejects_negative_neutral_or_floor() {
for mutate in [
|p: &mut ScoringPolicy| p.version_match.neutral = -0.1,
|p: &mut ScoringPolicy| p.version_match.floor = -0.1,
] {
let mut p = ScoringPolicy::default();
mutate(&mut p);
assert!(p.validate_finite().is_err());
}
}
#[test]
fn version_match_knobs_v2() {
let p = ScoringPolicy::default();
assert!((p.version_match.satisfies - 1.15).abs() < 1e-12);
assert!((p.version_match.neutral - 1.00).abs() < 1e-12);
assert!((p.version_match.floor - 0.30).abs() < 1e-12);
assert!((p.version_match.patch_step - 0.05).abs() < 1e-12);
assert!((p.version_match.minor_step - 0.15).abs() < 1e-12);
}
#[test]
fn rejects_legacy_unsatisfied_key() {
let body = toml::to_string(&ScoringPolicy::default())
.unwrap()
.replace("[version_match]", "[version_match]\nunsatisfied = 0.7");
let err = ScoringPolicy::parse(&body).unwrap_err();
assert!(
matches!(err, ScoringPolicyError::Parse(_)),
"stale `unsatisfied` key must fail loudly: {err:?}"
);
}
#[test]
fn rejects_zero_half_life() {
let p = ScoringPolicy {
freshness: FreshnessParams {
half_life_days: 0.0,
..ScoringPolicy::default().freshness
},
..ScoringPolicy::default()
};
let body = toml::to_string(&p).unwrap();
let err = ScoringPolicy::parse(&body).unwrap_err();
assert!(matches!(err, ScoringPolicyError::NonFiniteWeight { .. }));
}
}