Skip to main content

cairn_mod/moderation/
strike.rs

1//! Strike calculator for the v1.4 graduated-action moderation
2//! model (#49, §F20).
3//!
4//! Pure function: takes the subject's current strike count, the
5//! reason being applied, the policy, and the action's 1-indexed
6//! position within the current good-standing window — returns the
7//! [`StrikeApplication`] to record on the new `subject_actions`
8//! row (#46 schema's `strike_value_base` / `strike_value_applied` /
9//! `was_dampened` columns).
10//!
11//! Frozen at action time. The recorder (#51) will compute
12//! `position_in_window` from history and call into here; the
13//! returned [`StrikeApplication`] then becomes part of the
14//! immutable audit trail. Because dampening can change as
15//! operators tune `[strike_policy]`, freezing the resolved value
16//! at action time means historical actions don't retroactively
17//! shift weight when policy is edited.
18//!
19//! Closest precedent in the codebase is [`crate::audit::hash`]
20//! (#39) — same pure-function shape, no I/O, no async, heavy
21//! unit-test coverage.
22//!
23//! # Decision rules
24//!
25//! 1. **Severe reason** → full `base_weight`, `was_dampened = false`.
26//!    Bypasses the dampening curve regardless of standing.
27//! 2. **Out of good standing** (`current_strike_count >=
28//!    policy.good_standing_threshold`) → full `base_weight`,
29//!    `was_dampened = false`.
30//! 3. **In good standing**, position covered by the curve →
31//!    `applied = min(curve[position - 1], base_weight)`,
32//!    `was_dampened = true`. The cap means dampening can lower
33//!    a strike value below the curve entry but never *raise* it
34//!    above the reason's declared base weight.
35//! 4. **In good standing**, position past the curve's end → full
36//!    `base_weight`, `was_dampened = false`. With #48's curve-length
37//!    convention (`max(0, threshold - 1)`) and a strictly-ascending
38//!    curve, this branch is normally unreachable in good standing
39//!    because each in-window offense adds at least 1 to the count
40//!    and pushes the user past the threshold. It's still handled
41//!    defensively for unusual operator policies.
42//!
43//! # `was_dampened` semantics
44//!
45//! `was_dampened = true` means "the curve was consulted" (rule 3).
46//! This stays `true` even when the cap clamps `applied` back to
47//! `base_weight` — e.g. a curve of `[10, 20]` with a reason of
48//! `base_weight = 4` resolves to `applied = 4` on first offense,
49//! which numerically equals base but conceptually fired the curve
50//! path. The recorder uses this flag for forensics ("did dampening
51//! affect this action?"), not as a synonym for `applied < base`.
52//!
53//! # Position determination is the caller's job
54//!
55//! `position_in_window` is an input, not a computation here. It's
56//! the 1-indexed count of this action within the subject's current
57//! good-standing window — the caller (the recorder, #51, with help
58//! from the decay calculator, #50) walks history to compute it
59//! before invoking [`calculate`]. Keeping that out of this module
60//! preserves the no-I/O contract.
61
62use crate::error::{Error, Result};
63use crate::moderation::policy::StrikePolicy;
64use crate::moderation::reasons::{ReasonDef, ReasonVocabulary};
65
66/// Result of one strike calculation. The three fields are stored
67/// verbatim on the new `subject_actions` row (#46) and never
68/// recomputed — recomputing later would let policy edits rewrite
69/// historical action records, which the design explicitly forbids.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub struct StrikeApplication {
72    /// The strike value to add to the subject's running count.
73    /// Always >= 1 because both `base_weight` and curve entries
74    /// are validated as >= 1 at config-load time.
75    pub applied: u32,
76    /// `true` iff the dampening curve was consulted (i.e., severe
77    /// = false AND in good standing AND position covered by the
78    /// curve). See module docs — this stays `true` even when the
79    /// cap clamps `applied` back to `base_weight`.
80    pub was_dampened: bool,
81    /// Copy of the reason's `base_weight` for convenience. The
82    /// recorder writes this to `strike_value_base` so audit
83    /// readers don't have to re-look up the reason vocabulary at
84    /// read time (and so the value is preserved if the operator
85    /// later removes the reason from the vocabulary).
86    pub base_weight: u32,
87}
88
89/// Compute the strike to apply for a new action. See module docs
90/// for the decision rules and `was_dampened` semantics.
91///
92/// Pure function: no I/O, no DB, no async. Same input always
93/// yields the same output.
94pub fn calculate(
95    current_strike_count: u32,
96    reason: &ReasonDef,
97    policy: &StrikePolicy,
98    position_in_window: u32,
99) -> StrikeApplication {
100    let base_weight = reason.base_weight;
101
102    if reason.severe {
103        return StrikeApplication {
104            applied: base_weight,
105            was_dampened: false,
106            base_weight,
107        };
108    }
109
110    if current_strike_count >= policy.good_standing_threshold {
111        return StrikeApplication {
112            applied: base_weight,
113            was_dampened: false,
114            base_weight,
115        };
116    }
117
118    // In good standing: consult the curve. position_in_window is
119    // 1-indexed; the curve is 0-indexed.
120    let curve_index = position_in_window.saturating_sub(1) as usize;
121    if curve_index < policy.dampening_curve.len() {
122        let curve_value = policy.dampening_curve[curve_index];
123        // Cap: the dampening curve must not raise the applied
124        // strike above the reason's declared base. See module docs.
125        let applied = curve_value.min(base_weight);
126        return StrikeApplication {
127            applied,
128            was_dampened: true,
129            base_weight,
130        };
131    }
132
133    // Position past the curve's end. With #48's convention this is
134    // normally unreachable in good standing (each prior offense
135    // would have pushed `current_strike_count` past the threshold),
136    // but we handle it defensively for unusual operator curves.
137    StrikeApplication {
138        applied: base_weight,
139        was_dampened: false,
140        base_weight,
141    }
142}
143
144/// Resolve a multi-reason action down to a single dominant reason
145/// for strike calculation. Per the v1.4 design: severe always wins
146/// over non-severe regardless of base_weight; among same-severity
147/// reasons, highest base_weight wins; ties on base_weight resolve
148/// to the first-listed reason (stable for deterministic recording).
149///
150/// Errors:
151/// - [`Error::ReasonNotFound`] when any identifier in `reason_codes`
152///   is not declared in `vocabulary`.
153/// - [`Error::Signing`] generic catch-all when `reason_codes` is
154///   empty (the recorder pre-validates non-empty; this is
155///   defense-in-depth).
156///
157/// Returns a *cloned* [`ReasonDef`] so the caller doesn't borrow
158/// from `vocabulary` for longer than the resolver call. The
159/// vocabulary is small (≤ tens of entries) and ReasonDef is cheap
160/// to clone.
161pub fn resolve_primary_reason(
162    reason_codes: &[String],
163    vocabulary: &ReasonVocabulary,
164) -> Result<ReasonDef> {
165    if reason_codes.is_empty() {
166        return Err(Error::Signing(
167            "recordAction: reason_codes must be non-empty".to_string(),
168        ));
169    }
170
171    // Resolve each identifier to a ReasonDef. Missing → ReasonNotFound.
172    let mut defs: Vec<&ReasonDef> = Vec::with_capacity(reason_codes.len());
173    for code in reason_codes {
174        let def = vocabulary
175            .lookup(code)
176            .ok_or_else(|| Error::ReasonNotFound(code.clone()))?;
177        defs.push(def);
178    }
179
180    // Severe wins: any severe reason promotes the resolution to
181    // "highest-base-weight among severe." Ties on base_weight
182    // resolve to the first severe reason in the input list.
183    if defs.iter().any(|d| d.severe) {
184        let pick = defs
185            .iter()
186            .filter(|d| d.severe)
187            .max_by_key(|d| d.base_weight)
188            .expect("at least one severe by the any() check");
189        return Ok((*pick).clone());
190    }
191
192    // No severe: highest base_weight wins; ties → first-listed
193    // (max_by_key on iter().enumerate() with reversed index keeps
194    // the earliest index on tie because max_by_key picks the LAST
195    // maximum equal element — invert with .rev() so first wins).
196    let pick = defs
197        .iter()
198        .enumerate()
199        .rev()
200        .max_by_key(|(_, d)| d.base_weight)
201        .map(|(_, d)| *d)
202        .expect("non-empty checked above");
203    Ok(pick.clone())
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::moderation::policy::DecayFunction;
210
211    // ---------- fixture builders ----------
212
213    fn reason(id: &str, weight: u32, severe: bool) -> ReasonDef {
214        ReasonDef {
215            identifier: id.to_string(),
216            base_weight: weight,
217            severe,
218            description: "test fixture".to_string(),
219        }
220    }
221
222    fn policy(threshold: u32, curve: Vec<u32>) -> StrikePolicy {
223        StrikePolicy {
224            good_standing_threshold: threshold,
225            dampening_curve: curve,
226            decay: DecayFunction::Linear,
227            decay_window_days: 90,
228            suspension_freezes_decay: true,
229            cache_freshness_window_seconds: 3600,
230        }
231    }
232
233    // ---------- severe reason: bypass dampening always ----------
234
235    #[test]
236    fn severe_in_good_standing_returns_full_base() {
237        let r = reason("threats", 12, true);
238        let p = policy(3, vec![1, 2]);
239        let out = calculate(0, &r, &p, 1);
240        assert_eq!(out.applied, 12);
241        assert!(!out.was_dampened);
242        assert_eq!(out.base_weight, 12);
243    }
244
245    #[test]
246    fn severe_out_of_good_standing_returns_full_base() {
247        let r = reason("csam", 999, true);
248        let p = policy(3, vec![1, 2]);
249        let out = calculate(50, &r, &p, 5);
250        assert_eq!(out.applied, 999);
251        assert!(!out.was_dampened);
252        assert_eq!(out.base_weight, 999);
253    }
254
255    #[test]
256    fn severe_with_threshold_zero_returns_full_base() {
257        // threshold=0 means there's no good standing window, but
258        // severe reasons bypass that check anyway.
259        let r = reason("threats", 8, true);
260        let p = policy(0, vec![]);
261        let out = calculate(0, &r, &p, 1);
262        assert_eq!(out.applied, 8);
263        assert!(!out.was_dampened);
264    }
265
266    // ---------- non-severe in good standing: dampens ----------
267
268    #[test]
269    fn first_offense_in_good_standing_uses_curve_zero() {
270        let r = reason("spam", 4, false);
271        let p = policy(3, vec![1, 2]);
272        let out = calculate(0, &r, &p, 1);
273        assert_eq!(out.applied, 1);
274        assert!(out.was_dampened);
275        assert_eq!(out.base_weight, 4);
276    }
277
278    #[test]
279    fn second_offense_in_good_standing_uses_curve_one() {
280        // current_count=2 (after a prior offense that landed
281        // curve[0]+1 plus another small action; in good standing
282        // because 2 < threshold=3). position_in_window=2 → curve[1].
283        let r = reason("spam", 4, false);
284        let p = policy(3, vec![1, 2]);
285        let out = calculate(2, &r, &p, 2);
286        assert_eq!(out.applied, 2);
287        assert!(out.was_dampened);
288        assert_eq!(out.base_weight, 4);
289    }
290
291    // ---------- non-severe out of good standing: full base ----------
292
293    #[test]
294    fn at_threshold_is_out_of_good_standing() {
295        // current_count=3, threshold=3 → out of good standing per
296        // #48's worked example ("3rd offense ... past threshold").
297        let r = reason("spam", 4, false);
298        let p = policy(3, vec![1, 2]);
299        let out = calculate(3, &r, &p, 3);
300        assert_eq!(out.applied, 4);
301        assert!(!out.was_dampened);
302    }
303
304    #[test]
305    fn well_past_threshold_returns_full_base() {
306        let r = reason("spam", 4, false);
307        let p = policy(3, vec![1, 2]);
308        let out = calculate(10, &r, &p, 1);
309        assert_eq!(out.applied, 4);
310        assert!(!out.was_dampened);
311    }
312
313    // ---------- non-severe in good standing, position past curve ----------
314
315    #[test]
316    fn position_beyond_curve_in_good_standing_returns_full_base() {
317        // Unusual but permissible setup: threshold=3, curve=[1, 2],
318        // current_count=2 (in good standing), position_in_window=3
319        // (past curve's end). Defensive branch: applied=base,
320        // was_dampened=false. Under normal counting this wouldn't
321        // happen — position 3 implies two prior in-window offenses
322        // that should have pushed current_count to >= threshold —
323        // but the calculator handles it cleanly.
324        let r = reason("spam", 4, false);
325        let p = policy(3, vec![1, 2]);
326        let out = calculate(2, &r, &p, 3);
327        assert_eq!(out.applied, 4);
328        assert!(!out.was_dampened);
329    }
330
331    // ---------- the cap: curve > base ----------
332
333    #[test]
334    fn curve_value_above_base_caps_at_base() {
335        // Operator curve says "20 strikes for 2nd offense", but the
336        // reason itself only weighs 4. Apply min(20, 4) = 4. The
337        // curve was consulted, so was_dampened = true even though
338        // applied numerically equals base.
339        let r = reason("spam", 4, false);
340        let p = policy(3, vec![10, 20]);
341        let out = calculate(0, &r, &p, 2);
342        assert_eq!(out.applied, 4);
343        assert!(out.was_dampened);
344        assert_eq!(out.base_weight, 4);
345    }
346
347    #[test]
348    fn curve_value_equal_to_base_caps_at_base() {
349        // curve[0] = 4, base = 4. min(4, 4) = 4. was_dampened still
350        // true: the curve was consulted. The recorder uses this
351        // flag to mean "dampening fired", not "applied < base".
352        let r = reason("spam", 4, false);
353        let p = policy(3, vec![4, 5]);
354        let out = calculate(0, &r, &p, 1);
355        assert_eq!(out.applied, 4);
356        assert!(out.was_dampened);
357    }
358
359    // ---------- threshold = 0: no good-standing window ----------
360
361    #[test]
362    fn threshold_zero_non_severe_returns_full_base() {
363        // Operator opted out of dampening entirely. current_count=0
364        // is already >= threshold=0, so out-of-good-standing fires
365        // and applied = base_weight. position_in_window is ignored.
366        let r = reason("spam", 4, false);
367        let p = policy(0, vec![]);
368        let out = calculate(0, &r, &p, 1);
369        assert_eq!(out.applied, 4);
370        assert!(!out.was_dampened);
371    }
372
373    #[test]
374    fn threshold_zero_with_high_count_returns_full_base() {
375        let r = reason("spam", 4, false);
376        let p = policy(0, vec![]);
377        let out = calculate(100, &r, &p, 50);
378        assert_eq!(out.applied, 4);
379        assert!(!out.was_dampened);
380    }
381
382    // ---------- threshold = 1: empty curve, in-good-standing ----------
383
384    #[test]
385    fn threshold_one_first_offense_position_past_empty_curve() {
386        // threshold=1 + curve=[] means good standing covers exactly
387        // current_count=0, but the curve has no positions. The 1st
388        // offense (current_count=0, position=1) hits the
389        // "in good standing but position past curve" branch and
390        // gets full base, was_dampened=false. Documented edge case
391        // from the brief.
392        let r = reason("spam", 4, false);
393        let p = policy(1, vec![]);
394        let out = calculate(0, &r, &p, 1);
395        assert_eq!(out.applied, 4);
396        assert!(!out.was_dampened);
397    }
398
399    // ---------- determinism / output shape ----------
400
401    #[test]
402    fn output_is_deterministic_for_same_inputs() {
403        let r = reason("spam", 4, false);
404        let p = policy(3, vec![1, 2]);
405        let a = calculate(1, &r, &p, 2);
406        let b = calculate(1, &r, &p, 2);
407        assert_eq!(a, b);
408    }
409
410    // ---------- multi-reason resolver ----------
411
412    fn vocab_with(entries: &[(&str, u32, bool)]) -> ReasonVocabulary {
413        // Build a vocabulary by serializing through the operator
414        // path: Config → ReasonVocabulary::from_config. Roundtripping
415        // exercises the same parser path the recorder will use.
416        let map: serde_json::Map<String, serde_json::Value> = entries
417            .iter()
418            .map(|(id, w, severe)| {
419                (
420                    id.to_string(),
421                    serde_json::json!({
422                        "base_weight": w,
423                        "severe": severe,
424                        "description": "test fixture",
425                    }),
426                )
427            })
428            .collect();
429        let v = serde_json::json!({
430            "service_did": "did:web:labeler.example",
431            "service_endpoint": "https://labeler.example",
432            "db_path": "/var/lib/cairn/cairn.db",
433            "signing_key_path": "/etc/cairn/signing-key.hex",
434            "moderation_reasons": serde_json::Value::Object(map),
435        });
436        let cfg: crate::config::Config = serde_json::from_value(v).expect("config deserializes");
437        ReasonVocabulary::from_config(&cfg).expect("from_config")
438    }
439
440    #[test]
441    fn resolve_single_non_severe_reason_returns_it() {
442        let v = vocab_with(&[("spam", 4, false)]);
443        let pick = resolve_primary_reason(&["spam".into()], &v).unwrap();
444        assert_eq!(pick.identifier, "spam");
445        assert_eq!(pick.base_weight, 4);
446    }
447
448    #[test]
449    fn resolve_two_non_severe_picks_highest_base_weight() {
450        let v = vocab_with(&[("spam", 2, false), ("hate", 4, false)]);
451        let pick = resolve_primary_reason(&["spam".into(), "hate".into()], &v).unwrap();
452        assert_eq!(pick.identifier, "hate");
453    }
454
455    #[test]
456    fn resolve_severe_wins_over_higher_weight_non_severe() {
457        // Severe rule: severe always wins regardless of base_weight.
458        // hate (non-severe, weight 100) vs threats (severe, weight 8)
459        // → threats wins.
460        let v = vocab_with(&[("hate", 100, false), ("threats", 8, true)]);
461        let pick = resolve_primary_reason(&["hate".into(), "threats".into()], &v).unwrap();
462        assert_eq!(pick.identifier, "threats");
463        assert!(pick.severe);
464    }
465
466    #[test]
467    fn resolve_two_severe_picks_highest_base_weight_among_severe() {
468        let v = vocab_with(&[("threats", 12, true), ("csam", 999, true)]);
469        let pick = resolve_primary_reason(&["threats".into(), "csam".into()], &v).unwrap();
470        assert_eq!(pick.identifier, "csam");
471    }
472
473    #[test]
474    fn resolve_tie_on_base_weight_picks_first_listed() {
475        // Two non-severe at the same weight. First-listed wins for
476        // deterministic recording.
477        let v = vocab_with(&[("aaa", 4, false), ("bbb", 4, false)]);
478        let pick = resolve_primary_reason(&["aaa".into(), "bbb".into()], &v).unwrap();
479        assert_eq!(pick.identifier, "aaa");
480
481        let pick = resolve_primary_reason(&["bbb".into(), "aaa".into()], &v).unwrap();
482        assert_eq!(pick.identifier, "bbb");
483    }
484
485    #[test]
486    fn resolve_unknown_reason_returns_reason_not_found() {
487        let v = vocab_with(&[("spam", 2, false)]);
488        let err = resolve_primary_reason(&["nope".into()], &v).expect_err("unknown reason");
489        match err {
490            Error::ReasonNotFound(id) => assert_eq!(id, "nope"),
491            other => panic!("expected ReasonNotFound, got {other:?}"),
492        }
493    }
494
495    #[test]
496    fn resolve_empty_codes_errors() {
497        let v = vocab_with(&[("spam", 2, false)]);
498        let err = resolve_primary_reason(&[], &v).expect_err("empty codes");
499        assert!(matches!(err, Error::Signing(_)));
500    }
501
502    // ---------- shared helpers ----------
503
504    #[test]
505    fn base_weight_is_copied_through_unchanged() {
506        // Across all four code paths, `base_weight` on the output
507        // should always equal `reason.base_weight` exactly.
508        let cases: &[(u32, bool, u32, u32, Vec<u32>)] = &[
509            (0, true, 7, 3, vec![1, 2]),  // severe
510            (5, false, 4, 3, vec![1, 2]), // out of good standing
511            (0, false, 4, 3, vec![1, 2]), // in good standing, in curve
512            (0, false, 4, 1, vec![]),     // in good standing, past curve
513            (0, false, 4, 0, vec![]),     // threshold zero
514        ];
515        for (current, severe, base, threshold, curve) in cases {
516            let r = reason("x", *base, *severe);
517            let p = policy(*threshold, curve.clone());
518            let out = calculate(*current, &r, &p, 1);
519            assert_eq!(out.base_weight, *base);
520        }
521    }
522}