Skip to main content

cairn_mod/moderation/
policy.rs

1//! Strike policy for the v1.4 graduated-action moderation model
2//! (#48, §F20).
3//!
4//! Drives the dampening curve, decay function, and
5//! suspension-freezes-decay behavior. Loaded once at config-load
6//! time from `[strike_policy]`; read-only thereafter — operators
7//! restart `cairn serve` to change the policy, matching `[labeler]`
8//! and `[moderation_reasons]` posture.
9//!
10//! # Convention: curve length = threshold − 1
11//!
12//! v1.4 settles on the following dampening interpretation (resolved
13//! during #48 — the kickoff issue body and session brief described
14//! two slightly different shapes, and the session brief's
15//! "Convention B" wins):
16//!
17//! - `good_standing_threshold` is the strike count at or below which
18//!   a subject is considered in good standing. With threshold = 3,
19//!   subjects at 0/1/2/3 strikes are still in good standing.
20//! - `dampening_curve[N]` is the weight applied to the subject's
21//!   `(N+1)`-th in-window offense **while still in good standing**.
22//! - The `(threshold)`-th in-window offense (one past the curve's
23//!   end) gets the reason's full `base_weight` — no entry in the
24//!   curve, no dampening.
25//!
26//! So for the default (`threshold = 3`, `curve = [1, 2]`):
27//!
28//! | offense # | applied  | running strikes |
29//! |-----------|----------|-----------------|
30//! | 1st       | `curve[0]` = 1 | 1           |
31//! | 2nd       | `curve[1]` = 2 | 3 (at threshold; still good standing) |
32//! | 3rd       | full base    | 3 + base (past threshold; out of good standing) |
33//!
34//! That makes the curve length always `max(0, threshold − 1)`. With
35//! `threshold = 0` (operator opt-out of dampening), the curve must
36//! be empty — every offense gets full base weight from the start.
37//! With `threshold = 1`, the curve must also be empty — the very
38//! first offense pushes the user past the threshold.
39//!
40//! # decay_window_days lives on the policy, not the variant
41//!
42//! Both `DecayFunction::Linear` and `DecayFunction::Exponential`
43//! consume the same `decay_window_days` field. The exact
44//! interpretation per variant is the decay calculator's concern
45//! (#50) — for v1.4, linear is "decay from full at action time to
46//! zero over the window" and exponential is "half-life such that
47//! contribution reaches zero at the window boundary in practice."
48//! If a future design needs different windows per variant,
49//! restructure then.
50//!
51//! # Severe reasons bypass the policy entirely
52//!
53//! When a reason in `[moderation_reasons]` is marked `severe`, the
54//! strike calculator (#49) applies its `base_weight` directly with
55//! no consultation of the dampening curve, regardless of the
56//! subject's standing. The policy's other fields (decay,
57//! suspension-freezes-decay) still apply to severe-reason actions
58//! — only dampening is skipped.
59
60use serde::Deserialize;
61
62use crate::error::{Error, Result};
63
64/// Resolved strike policy. Use [`StrikePolicy::from_config`] at
65/// config-load time and pass the resolved policy into the strike
66/// calculator (#49) and decay calculator (#50).
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct StrikePolicy {
69    /// Strike count at or below which a subject is in good
70    /// standing. New offenses while in good standing get dampened
71    /// per [`Self::dampening_curve`]. `0` means no good standing
72    /// — every offense gets full weight from the first.
73    pub good_standing_threshold: u32,
74    /// Per-position dampening weights for in-good-standing
75    /// offenses. Length is always `max(0, good_standing_threshold − 1)`
76    /// — see the module docs for the rationale.
77    pub dampening_curve: Vec<u32>,
78    /// How each action's contribution decays over time. The exact
79    /// math per variant is the decay calculator's concern (#50);
80    /// see the module docs.
81    pub decay: DecayFunction,
82    /// Window over which `decay` operates, in days. Same value used
83    /// by both [`DecayFunction::Linear`] and
84    /// [`DecayFunction::Exponential`]; the variant decides how to
85    /// interpret it.
86    pub decay_window_days: u32,
87    /// When `true`, decay halts while the subject has an active
88    /// `indef_suspension` action. Revocation is then the only path
89    /// back to good standing. Default `true`.
90    pub suspension_freezes_decay: bool,
91    /// How long the [`subject_strike_state`](
92    /// crate::moderation::cache) cache row remains "fresh" before a
93    /// reader should recompute via the decay calculator. Used by
94    /// the lazy recompute-on-read helper (#55); has no effect on
95    /// the v1.4 read endpoints, which always recompute from
96    /// source-of-truth regardless. Default 3600 (1 hour).
97    pub cache_freshness_window_seconds: u32,
98}
99
100/// Decay shape applied to an action's strike contribution as time
101/// passes. Per-variant interpretation lives in the decay calculator
102/// (#50); the policy just declares which shape is in use.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
104#[serde(rename_all = "lowercase")]
105pub enum DecayFunction {
106    /// Linear ramp from full contribution at the action's effective
107    /// time to zero at `effective_time + decay_window_days`. Simple
108    /// and predictable; the v1.4 default.
109    Linear,
110    /// Exponential decay over the same window. The decay calculator
111    /// (#50) defines the exact half-life relationship to
112    /// `decay_window_days`.
113    Exponential,
114}
115
116impl StrikePolicy {
117    /// Build the policy from `cfg.strike_policy`. When the field is
118    /// `None` (operator declared no `[strike_policy]` block),
119    /// returns [`Self::defaults`]. When the field is present (even
120    /// with only some sub-fields specified), all unspecified
121    /// sub-fields take their `serde(default)` values, then the
122    /// resolved values are validated together.
123    ///
124    /// Validation can fire even when only some fields were declared —
125    /// e.g., an operator who sets `good_standing_threshold = 5` but
126    /// forgets `dampening_curve` will get the default `[1, 2]` curve
127    /// and a clear "expected length 4, got 2" error.
128    pub fn from_config(cfg: &crate::config::Config) -> Result<Self> {
129        let Some(toml) = cfg.strike_policy.as_ref() else {
130            return Ok(Self::defaults());
131        };
132        Self::validated_from_toml(toml)
133    }
134
135    /// The shipped default policy: threshold 3, curve [1, 2], linear
136    /// decay over 90 days, suspensions freeze decay,
137    /// cache_freshness_window 1 hour. Matches the v1.4 design
138    /// conversation: "good standing for me would be ≤3 strikes;
139    /// 1st offense = 1 strike, 2nd = 2 strikes, 3rd = full base."
140    pub fn defaults() -> Self {
141        Self {
142            good_standing_threshold: 3,
143            dampening_curve: vec![1, 2],
144            decay: DecayFunction::Linear,
145            decay_window_days: 90,
146            suspension_freezes_decay: true,
147            cache_freshness_window_seconds: 3600,
148        }
149    }
150
151    fn validated_from_toml(toml: &crate::config::StrikePolicyToml) -> Result<Self> {
152        // Curve length convention: max(0, threshold - 1). See the
153        // module docs for the worked example.
154        let expected_curve_len = toml.good_standing_threshold.saturating_sub(1) as usize;
155        if toml.dampening_curve.len() != expected_curve_len {
156            return Err(Error::Signing(format!(
157                "config: [strike_policy] dampening_curve has length {} but good_standing_threshold = {} requires length {} (curve length = max(0, threshold - 1) — see crate::moderation::policy module docs)",
158                toml.dampening_curve.len(),
159                toml.good_standing_threshold,
160                expected_curve_len,
161            )));
162        }
163
164        // Each curve entry is positive and the sequence is strictly
165        // ascending. Strict (rather than non-strict) because a
166        // non-ascending entry contradicts dampening's escalation
167        // principle — operators who want flat punishment can declare
168        // a constant `base_weight` on the reason side instead.
169        let mut prev: Option<u32> = None;
170        for (i, &v) in toml.dampening_curve.iter().enumerate() {
171            if v < 1 {
172                return Err(Error::Signing(format!(
173                    "config: [strike_policy] dampening_curve[{i}] = {v} must be >= 1"
174                )));
175            }
176            if let Some(p) = prev
177                && v <= p
178            {
179                return Err(Error::Signing(format!(
180                    "config: [strike_policy] dampening_curve[{i}] = {v} must be strictly greater than the previous entry ({p}) — the curve must be strictly ascending"
181                )));
182            }
183            prev = Some(v);
184        }
185
186        if toml.decay_window_days < 1 {
187            return Err(Error::Signing(format!(
188                "config: [strike_policy] decay_window_days = {} must be >= 1",
189                toml.decay_window_days
190            )));
191        }
192
193        if toml.cache_freshness_window_seconds < 1 {
194            return Err(Error::Signing(format!(
195                "config: [strike_policy] cache_freshness_window_seconds = {} must be >= 1",
196                toml.cache_freshness_window_seconds
197            )));
198        }
199
200        Ok(Self {
201            good_standing_threshold: toml.good_standing_threshold,
202            dampening_curve: toml.dampening_curve.clone(),
203            decay: toml.decay_function,
204            decay_window_days: toml.decay_window_days,
205            suspension_freezes_decay: toml.suspension_freezes_decay,
206            cache_freshness_window_seconds: toml.cache_freshness_window_seconds,
207        })
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::config::Config;
215
216    fn config_with_policy(value: serde_json::Value) -> Config {
217        let mut v = serde_json::json!({
218            "service_did": "did:web:labeler.example",
219            "service_endpoint": "https://labeler.example",
220            "db_path": "/var/lib/cairn/cairn.db",
221            "signing_key_path": "/etc/cairn/signing-key.hex",
222        });
223        if !value.is_null() {
224            v["strike_policy"] = value;
225        }
226        serde_json::from_value(v).expect("config deserializes")
227    }
228
229    // ---------- defaults ----------
230
231    #[test]
232    fn defaults_match_design_conversation() {
233        let p = StrikePolicy::defaults();
234        assert_eq!(p.good_standing_threshold, 3);
235        assert_eq!(p.dampening_curve, vec![1, 2]);
236        assert_eq!(p.decay, DecayFunction::Linear);
237        assert_eq!(p.decay_window_days, 90);
238        assert!(p.suspension_freezes_decay);
239        assert_eq!(p.cache_freshness_window_seconds, 3600);
240    }
241
242    #[test]
243    fn defaults_curve_length_matches_threshold_minus_one() {
244        let p = StrikePolicy::defaults();
245        let expected = p.good_standing_threshold.saturating_sub(1) as usize;
246        assert_eq!(p.dampening_curve.len(), expected);
247    }
248
249    // ---------- absent block → defaults ----------
250
251    #[test]
252    fn absent_strike_policy_block_loads_defaults() {
253        let cfg = config_with_policy(serde_json::Value::Null);
254        let p = StrikePolicy::from_config(&cfg).expect("from_config");
255        assert_eq!(p, StrikePolicy::defaults());
256    }
257
258    // ---------- empty block → defaults via serde fallbacks ----------
259
260    #[test]
261    fn empty_strike_policy_block_loads_defaults_via_serde_fallbacks() {
262        // Bare `[strike_policy]` header with no sub-fields. Each
263        // serde-default fires; the resolved policy is identical to
264        // Self::defaults().
265        let cfg = config_with_policy(serde_json::json!({}));
266        let p = StrikePolicy::from_config(&cfg).expect("from_config");
267        assert_eq!(p, StrikePolicy::defaults());
268    }
269
270    // ---------- explicit declarations ----------
271
272    #[test]
273    fn fully_declared_policy_reflects_operator_values() {
274        let cfg = config_with_policy(serde_json::json!({
275            "good_standing_threshold": 5,
276            "dampening_curve": [1, 2, 3, 4],
277            "decay_function": "exponential",
278            "decay_window_days": 30,
279            "suspension_freezes_decay": false,
280        }));
281        let p = StrikePolicy::from_config(&cfg).expect("from_config");
282        assert_eq!(p.good_standing_threshold, 5);
283        assert_eq!(p.dampening_curve, vec![1, 2, 3, 4]);
284        assert_eq!(p.decay, DecayFunction::Exponential);
285        assert_eq!(p.decay_window_days, 30);
286        assert!(!p.suspension_freezes_decay);
287    }
288
289    #[test]
290    fn threshold_zero_with_empty_curve_is_valid() {
291        // Operator opt-out of dampening: every offense gets full
292        // weight immediately. Curve length = max(0, 0 - 1) = 0.
293        let cfg = config_with_policy(serde_json::json!({
294            "good_standing_threshold": 0,
295            "dampening_curve": [],
296        }));
297        let p = StrikePolicy::from_config(&cfg).expect("from_config");
298        assert_eq!(p.good_standing_threshold, 0);
299        assert!(p.dampening_curve.is_empty());
300    }
301
302    #[test]
303    fn threshold_one_with_empty_curve_is_valid() {
304        // Threshold 1 means even the first offense pushes past good
305        // standing, so the curve covers zero in-good-standing
306        // offenses. Length = max(0, 1 - 1) = 0.
307        let cfg = config_with_policy(serde_json::json!({
308            "good_standing_threshold": 1,
309            "dampening_curve": [],
310        }));
311        let p = StrikePolicy::from_config(&cfg).expect("from_config");
312        assert_eq!(p.good_standing_threshold, 1);
313        assert!(p.dampening_curve.is_empty());
314    }
315
316    #[test]
317    fn partial_declaration_uses_serde_defaults_for_other_fields() {
318        // Operator declares only the threshold; serde defaults fill
319        // in the curve, decay, and suspension flag. With matching
320        // default values, validation passes.
321        let cfg = config_with_policy(serde_json::json!({
322            "good_standing_threshold": 3,
323        }));
324        let p = StrikePolicy::from_config(&cfg).expect("from_config");
325        assert_eq!(p, StrikePolicy::defaults());
326    }
327
328    // ---------- validation: curve length vs threshold ----------
329
330    #[test]
331    fn curve_too_long_for_threshold_rejected() {
332        // threshold = 3 requires curve length 2; got length 3.
333        let cfg = config_with_policy(serde_json::json!({
334            "good_standing_threshold": 3,
335            "dampening_curve": [1, 2, 3],
336        }));
337        let err = StrikePolicy::from_config(&cfg).unwrap_err();
338        let msg = format!("{err}");
339        assert!(msg.contains("length 3"));
340        assert!(msg.contains("requires length 2"));
341    }
342
343    #[test]
344    fn curve_too_short_for_threshold_rejected() {
345        // threshold = 5 requires curve length 4; got length 2.
346        let cfg = config_with_policy(serde_json::json!({
347            "good_standing_threshold": 5,
348            "dampening_curve": [1, 2],
349        }));
350        let err = StrikePolicy::from_config(&cfg).unwrap_err();
351        let msg = format!("{err}");
352        assert!(msg.contains("length 2"));
353        assert!(msg.contains("requires length 4"));
354    }
355
356    #[test]
357    fn partial_threshold_with_default_curve_surfaces_clear_mismatch() {
358        // Operator sets threshold = 5 but forgets the curve. serde
359        // defaults the curve to [1, 2] (length 2), but threshold = 5
360        // requires length 4. The validator surfaces this as a clear
361        // mismatch error.
362        let cfg = config_with_policy(serde_json::json!({
363            "good_standing_threshold": 5,
364        }));
365        let err = StrikePolicy::from_config(&cfg).unwrap_err();
366        let msg = format!("{err}");
367        assert!(msg.contains("requires length 4"));
368    }
369
370    // ---------- validation: curve values ----------
371
372    #[test]
373    fn non_ascending_curve_rejected() {
374        let cfg = config_with_policy(serde_json::json!({
375            "good_standing_threshold": 4,
376            "dampening_curve": [1, 2, 1],
377        }));
378        let err = StrikePolicy::from_config(&cfg).unwrap_err();
379        assert!(format!("{err}").contains("strictly ascending"));
380    }
381
382    #[test]
383    fn flat_curve_rejected_strict_ascending() {
384        // [1, 1] is non-strict ascending — rejected because flat
385        // dampening contradicts the escalation principle.
386        let cfg = config_with_policy(serde_json::json!({
387            "good_standing_threshold": 3,
388            "dampening_curve": [1, 1],
389        }));
390        let err = StrikePolicy::from_config(&cfg).unwrap_err();
391        assert!(format!("{err}").contains("strictly ascending"));
392    }
393
394    #[test]
395    fn zero_curve_entry_rejected() {
396        let cfg = config_with_policy(serde_json::json!({
397            "good_standing_threshold": 3,
398            "dampening_curve": [0, 2],
399        }));
400        let err = StrikePolicy::from_config(&cfg).unwrap_err();
401        assert!(format!("{err}").contains(">= 1"));
402    }
403
404    // ---------- validation: decay ----------
405
406    #[test]
407    fn unknown_decay_function_rejected_at_deserialize() {
408        // serde rejects unknown enum variants at deserialize time;
409        // the error surfaces as a config-load failure before
410        // validate() runs. Tests that this path errors.
411        let cfg_result = serde_json::from_value::<Config>(serde_json::json!({
412            "service_did": "did:web:labeler.example",
413            "service_endpoint": "https://labeler.example",
414            "db_path": "/var/lib/cairn/cairn.db",
415            "signing_key_path": "/etc/cairn/signing-key.hex",
416            "strike_policy": {
417                "decay_function": "logarithmic"
418            }
419        }));
420        assert!(
421            cfg_result.is_err(),
422            "unknown decay_function variant must fail to deserialize"
423        );
424    }
425
426    #[test]
427    fn zero_decay_window_rejected() {
428        let cfg = config_with_policy(serde_json::json!({
429            "decay_window_days": 0,
430        }));
431        let err = StrikePolicy::from_config(&cfg).unwrap_err();
432        assert!(format!("{err}").contains(">= 1"));
433    }
434
435    #[test]
436    fn large_decay_window_accepted() {
437        // 365 days is a reasonable operator choice for low-volume
438        // labelers; no upper bound enforced.
439        let cfg = config_with_policy(serde_json::json!({
440            "decay_window_days": 365,
441        }));
442        let p = StrikePolicy::from_config(&cfg).expect("from_config");
443        assert_eq!(p.decay_window_days, 365);
444    }
445
446    // ---------- decay_function string forms ----------
447
448    #[test]
449    fn linear_string_deserializes() {
450        let cfg = config_with_policy(serde_json::json!({
451            "decay_function": "linear",
452        }));
453        let p = StrikePolicy::from_config(&cfg).expect("from_config");
454        assert_eq!(p.decay, DecayFunction::Linear);
455    }
456
457    #[test]
458    fn exponential_string_deserializes() {
459        let cfg = config_with_policy(serde_json::json!({
460            "decay_function": "exponential",
461        }));
462        let p = StrikePolicy::from_config(&cfg).expect("from_config");
463        assert_eq!(p.decay, DecayFunction::Exponential);
464    }
465
466    // ---------- cache freshness window (#55) ----------
467
468    #[test]
469    fn cache_freshness_window_operator_override_accepted() {
470        let cfg = config_with_policy(serde_json::json!({
471            "cache_freshness_window_seconds": 300,
472        }));
473        let p = StrikePolicy::from_config(&cfg).expect("from_config");
474        assert_eq!(p.cache_freshness_window_seconds, 300);
475    }
476
477    #[test]
478    fn zero_cache_freshness_window_rejected() {
479        let cfg = config_with_policy(serde_json::json!({
480            "cache_freshness_window_seconds": 0,
481        }));
482        let err = StrikePolicy::from_config(&cfg).unwrap_err();
483        assert!(format!("{err}").contains("cache_freshness_window_seconds"));
484    }
485}