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}