use crate::error::{Error, Result};
use crate::moderation::policy::StrikePolicy;
use crate::moderation::reasons::{ReasonDef, ReasonVocabulary};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StrikeApplication {
pub applied: u32,
pub was_dampened: bool,
pub base_weight: u32,
}
pub fn calculate(
current_strike_count: u32,
reason: &ReasonDef,
policy: &StrikePolicy,
position_in_window: u32,
) -> StrikeApplication {
let base_weight = reason.base_weight;
if reason.severe {
return StrikeApplication {
applied: base_weight,
was_dampened: false,
base_weight,
};
}
if current_strike_count >= policy.good_standing_threshold {
return StrikeApplication {
applied: base_weight,
was_dampened: false,
base_weight,
};
}
let curve_index = position_in_window.saturating_sub(1) as usize;
if curve_index < policy.dampening_curve.len() {
let curve_value = policy.dampening_curve[curve_index];
let applied = curve_value.min(base_weight);
return StrikeApplication {
applied,
was_dampened: true,
base_weight,
};
}
StrikeApplication {
applied: base_weight,
was_dampened: false,
base_weight,
}
}
pub fn resolve_primary_reason(
reason_codes: &[String],
vocabulary: &ReasonVocabulary,
) -> Result<ReasonDef> {
if reason_codes.is_empty() {
return Err(Error::Signing(
"recordAction: reason_codes must be non-empty".to_string(),
));
}
let mut defs: Vec<&ReasonDef> = Vec::with_capacity(reason_codes.len());
for code in reason_codes {
let def = vocabulary
.lookup(code)
.ok_or_else(|| Error::ReasonNotFound(code.clone()))?;
defs.push(def);
}
if defs.iter().any(|d| d.severe) {
let pick = defs
.iter()
.filter(|d| d.severe)
.max_by_key(|d| d.base_weight)
.expect("at least one severe by the any() check");
return Ok((*pick).clone());
}
let pick = defs
.iter()
.enumerate()
.rev()
.max_by_key(|(_, d)| d.base_weight)
.map(|(_, d)| *d)
.expect("non-empty checked above");
Ok(pick.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::moderation::policy::DecayFunction;
fn reason(id: &str, weight: u32, severe: bool) -> ReasonDef {
ReasonDef {
identifier: id.to_string(),
base_weight: weight,
severe,
description: "test fixture".to_string(),
}
}
fn policy(threshold: u32, curve: Vec<u32>) -> StrikePolicy {
StrikePolicy {
good_standing_threshold: threshold,
dampening_curve: curve,
decay: DecayFunction::Linear,
decay_window_days: 90,
suspension_freezes_decay: true,
cache_freshness_window_seconds: 3600,
}
}
#[test]
fn severe_in_good_standing_returns_full_base() {
let r = reason("threats", 12, true);
let p = policy(3, vec![1, 2]);
let out = calculate(0, &r, &p, 1);
assert_eq!(out.applied, 12);
assert!(!out.was_dampened);
assert_eq!(out.base_weight, 12);
}
#[test]
fn severe_out_of_good_standing_returns_full_base() {
let r = reason("csam", 999, true);
let p = policy(3, vec![1, 2]);
let out = calculate(50, &r, &p, 5);
assert_eq!(out.applied, 999);
assert!(!out.was_dampened);
assert_eq!(out.base_weight, 999);
}
#[test]
fn severe_with_threshold_zero_returns_full_base() {
let r = reason("threats", 8, true);
let p = policy(0, vec![]);
let out = calculate(0, &r, &p, 1);
assert_eq!(out.applied, 8);
assert!(!out.was_dampened);
}
#[test]
fn first_offense_in_good_standing_uses_curve_zero() {
let r = reason("spam", 4, false);
let p = policy(3, vec![1, 2]);
let out = calculate(0, &r, &p, 1);
assert_eq!(out.applied, 1);
assert!(out.was_dampened);
assert_eq!(out.base_weight, 4);
}
#[test]
fn second_offense_in_good_standing_uses_curve_one() {
let r = reason("spam", 4, false);
let p = policy(3, vec![1, 2]);
let out = calculate(2, &r, &p, 2);
assert_eq!(out.applied, 2);
assert!(out.was_dampened);
assert_eq!(out.base_weight, 4);
}
#[test]
fn at_threshold_is_out_of_good_standing() {
let r = reason("spam", 4, false);
let p = policy(3, vec![1, 2]);
let out = calculate(3, &r, &p, 3);
assert_eq!(out.applied, 4);
assert!(!out.was_dampened);
}
#[test]
fn well_past_threshold_returns_full_base() {
let r = reason("spam", 4, false);
let p = policy(3, vec![1, 2]);
let out = calculate(10, &r, &p, 1);
assert_eq!(out.applied, 4);
assert!(!out.was_dampened);
}
#[test]
fn position_beyond_curve_in_good_standing_returns_full_base() {
let r = reason("spam", 4, false);
let p = policy(3, vec![1, 2]);
let out = calculate(2, &r, &p, 3);
assert_eq!(out.applied, 4);
assert!(!out.was_dampened);
}
#[test]
fn curve_value_above_base_caps_at_base() {
let r = reason("spam", 4, false);
let p = policy(3, vec![10, 20]);
let out = calculate(0, &r, &p, 2);
assert_eq!(out.applied, 4);
assert!(out.was_dampened);
assert_eq!(out.base_weight, 4);
}
#[test]
fn curve_value_equal_to_base_caps_at_base() {
let r = reason("spam", 4, false);
let p = policy(3, vec![4, 5]);
let out = calculate(0, &r, &p, 1);
assert_eq!(out.applied, 4);
assert!(out.was_dampened);
}
#[test]
fn threshold_zero_non_severe_returns_full_base() {
let r = reason("spam", 4, false);
let p = policy(0, vec![]);
let out = calculate(0, &r, &p, 1);
assert_eq!(out.applied, 4);
assert!(!out.was_dampened);
}
#[test]
fn threshold_zero_with_high_count_returns_full_base() {
let r = reason("spam", 4, false);
let p = policy(0, vec![]);
let out = calculate(100, &r, &p, 50);
assert_eq!(out.applied, 4);
assert!(!out.was_dampened);
}
#[test]
fn threshold_one_first_offense_position_past_empty_curve() {
let r = reason("spam", 4, false);
let p = policy(1, vec![]);
let out = calculate(0, &r, &p, 1);
assert_eq!(out.applied, 4);
assert!(!out.was_dampened);
}
#[test]
fn output_is_deterministic_for_same_inputs() {
let r = reason("spam", 4, false);
let p = policy(3, vec![1, 2]);
let a = calculate(1, &r, &p, 2);
let b = calculate(1, &r, &p, 2);
assert_eq!(a, b);
}
fn vocab_with(entries: &[(&str, u32, bool)]) -> ReasonVocabulary {
let map: serde_json::Map<String, serde_json::Value> = entries
.iter()
.map(|(id, w, severe)| {
(
id.to_string(),
serde_json::json!({
"base_weight": w,
"severe": severe,
"description": "test fixture",
}),
)
})
.collect();
let v = serde_json::json!({
"service_did": "did:web:labeler.example",
"service_endpoint": "https://labeler.example",
"db_path": "/var/lib/cairn/cairn.db",
"signing_key_path": "/etc/cairn/signing-key.hex",
"moderation_reasons": serde_json::Value::Object(map),
});
let cfg: crate::config::Config = serde_json::from_value(v).expect("config deserializes");
ReasonVocabulary::from_config(&cfg).expect("from_config")
}
#[test]
fn resolve_single_non_severe_reason_returns_it() {
let v = vocab_with(&[("spam", 4, false)]);
let pick = resolve_primary_reason(&["spam".into()], &v).unwrap();
assert_eq!(pick.identifier, "spam");
assert_eq!(pick.base_weight, 4);
}
#[test]
fn resolve_two_non_severe_picks_highest_base_weight() {
let v = vocab_with(&[("spam", 2, false), ("hate", 4, false)]);
let pick = resolve_primary_reason(&["spam".into(), "hate".into()], &v).unwrap();
assert_eq!(pick.identifier, "hate");
}
#[test]
fn resolve_severe_wins_over_higher_weight_non_severe() {
let v = vocab_with(&[("hate", 100, false), ("threats", 8, true)]);
let pick = resolve_primary_reason(&["hate".into(), "threats".into()], &v).unwrap();
assert_eq!(pick.identifier, "threats");
assert!(pick.severe);
}
#[test]
fn resolve_two_severe_picks_highest_base_weight_among_severe() {
let v = vocab_with(&[("threats", 12, true), ("csam", 999, true)]);
let pick = resolve_primary_reason(&["threats".into(), "csam".into()], &v).unwrap();
assert_eq!(pick.identifier, "csam");
}
#[test]
fn resolve_tie_on_base_weight_picks_first_listed() {
let v = vocab_with(&[("aaa", 4, false), ("bbb", 4, false)]);
let pick = resolve_primary_reason(&["aaa".into(), "bbb".into()], &v).unwrap();
assert_eq!(pick.identifier, "aaa");
let pick = resolve_primary_reason(&["bbb".into(), "aaa".into()], &v).unwrap();
assert_eq!(pick.identifier, "bbb");
}
#[test]
fn resolve_unknown_reason_returns_reason_not_found() {
let v = vocab_with(&[("spam", 2, false)]);
let err = resolve_primary_reason(&["nope".into()], &v).expect_err("unknown reason");
match err {
Error::ReasonNotFound(id) => assert_eq!(id, "nope"),
other => panic!("expected ReasonNotFound, got {other:?}"),
}
}
#[test]
fn resolve_empty_codes_errors() {
let v = vocab_with(&[("spam", 2, false)]);
let err = resolve_primary_reason(&[], &v).expect_err("empty codes");
assert!(matches!(err, Error::Signing(_)));
}
#[test]
fn base_weight_is_copied_through_unchanged() {
let cases: &[(u32, bool, u32, u32, Vec<u32>)] = &[
(0, true, 7, 3, vec![1, 2]), (5, false, 4, 3, vec![1, 2]), (0, false, 4, 3, vec![1, 2]), (0, false, 4, 1, vec![]), (0, false, 4, 0, vec![]), ];
for (current, severe, base, threshold, curve) in cases {
let r = reason("x", *base, *severe);
let p = policy(*threshold, curve.clone());
let out = calculate(*current, &r, &p, 1);
assert_eq!(out.base_weight, *base);
}
}
}