Skip to main content

mnm_core/
scoring_policy.rs

1//! Scoring-policy TOML loader (Phase-2 stub; full validation lands in Phase 9 / US6).
2//!
3//! Per spec §"Scoring policy TOML schema": loaded once at server startup from
4//! `MIDNIGHT_MANUAL_SCORING_POLICY`. If the env is unset the compiled-in
5//! defaults below are used. Invalid TOML fails the load (Constitution VI fail-fast).
6//!
7//! Phase 9 wires this into [`crate::types::Chunk`] scoring; until then it sits
8//! here so callers can already type their config and unit-test the defaults.
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13/// Canonical schema version for scoring-policy TOML.
14pub const SCHEMA_VERSION: u32 = 1;
15
16/// Full scoring-policy shape — every section is independently overridable.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18#[serde(deny_unknown_fields)]
19pub struct ScoringPolicy {
20    /// Schema sentinel. Always `1` in v1.
21    pub schema_version: u32,
22    /// Attribution-based trust multipliers.
23    pub attribution: AttributionMultipliers,
24    /// Verification-based trust multipliers.
25    pub verification: VerificationMultipliers,
26    /// Freshness decay parameters.
27    pub freshness: FreshnessParams,
28    /// Deprecation penalty.
29    pub deprecation: DeprecationParams,
30    /// Version-target match multipliers.
31    pub version_match: VersionMatchMultipliers,
32    /// Trust × relevance blend weights.
33    pub blend: BlendWeights,
34}
35
36/// `[attribution]` multipliers (highest trust to lowest).
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct AttributionMultipliers {
40    /// Foundation-attributed content.
41    pub foundation: f64,
42    /// Partner-attributed content.
43    pub partner: f64,
44    /// Third-party-attributed content.
45    pub third_party: f64,
46    /// Community-attributed content.
47    pub community: f64,
48    /// Unknown attribution.
49    pub unknown: f64,
50}
51
52/// `[verification]` — multipliers based on who verified the content.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54#[serde(deny_unknown_fields)]
55pub struct VerificationMultipliers {
56    /// Verified by the Midnight Foundation.
57    pub verified_by_foundation: f64,
58    /// Verified by a partner.
59    pub verified_by_partner: f64,
60    /// Verified by any other principal.
61    pub verified_by_other: f64,
62    /// Unverified content.
63    pub unverified: f64,
64}
65
66/// `[freshness]` — exponential-decay model parameters.
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68#[serde(deny_unknown_fields)]
69pub struct FreshnessParams {
70    /// Half-life in days (default 180).
71    pub half_life_days: f64,
72    /// Which timestamp to use when `document.source_modified_at` is null.
73    /// `"ingested_at"` falls back to the `source_version.ingested_at` row.
74    pub fallback_age_source: String,
75}
76
77/// `[deprecation]` — penalty when `provenance.deprecation.is_deprecated = true`.
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79#[serde(deny_unknown_fields)]
80pub struct DeprecationParams {
81    /// Multiplier (default 0.30 → -70%).
82    pub penalty_multiplier: f64,
83}
84
85/// `[version_match]` — multipliers when query-side version filters are checked
86/// against the chunk's declared version constraints (spec §3.4).
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(deny_unknown_fields)]
89pub struct VersionMatchMultipliers {
90    /// Chunk's target satisfies the query constraint.
91    pub satisfies: f64,
92    /// No constraint provided / target absent / unknowable. Neutral.
93    pub neutral: f64,
94    /// Lower clamp on the permissive near-miss penalty (replaces `unsatisfied`).
95    pub floor: f64,
96    /// Penalty subtracted per patch-level distance step (permissive mode).
97    pub patch_step: f64,
98    /// Penalty subtracted per minor-level distance step (permissive mode).
99    pub minor_step: f64,
100}
101
102/// `[blend]` — exponents in the geometric-mean blend `trust^w_t * relevance^w_r`.
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct BlendWeights {
106    /// Trust-side exponent (default 0.55).
107    pub trust_weight: f64,
108    /// Relevance-side exponent (default 0.45).
109    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    /// Parse a scoring-policy TOML body.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`ScoringPolicyError::Parse`] if the TOML is malformed,
155    /// [`ScoringPolicyError::SchemaVersionMismatch`] if the schema sentinel
156    /// disagrees, or [`ScoringPolicyError::NonFiniteWeight`] if any numeric
157    /// weight is not a finite, non-negative `f64`.
158    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/// All the ways scoring-policy parsing can fail.
211#[derive(Debug, Error)]
212pub enum ScoringPolicyError {
213    /// TOML body did not parse against the [`ScoringPolicy`] shape.
214    #[error("failed to parse scoring policy: {0}")]
215    Parse(String),
216    /// `schema_version` did not match [`SCHEMA_VERSION`].
217    #[error("scoring policy schema_version={found}; expected {expected}")]
218    SchemaVersionMismatch {
219        /// The schema version we found.
220        found: u32,
221        /// The version we expected.
222        expected: u32,
223    },
224    /// A numeric weight was non-finite or negative.
225    #[error("scoring policy field `{field}` has non-finite or negative value {value}")]
226    NonFiniteWeight {
227        /// Dotted-path name of the offending field.
228        field: String,
229        /// The offending value.
230        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        // Acceptance #11: unknown keys fail the load (fail-fast, Constitution VIII).
284        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        // Hard cutover: a stale policy TOML still carrying `unsatisfied` must fail
315        // loudly at startup. Inject the stale key INTO the existing
316        // `[version_match]` table so the failure is raised by
317        // `deny_unknown_fields` (the real guard) rather than an incidental
318        // duplicate-table-header error.
319        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}