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}