use serde::Deserialize;
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StrikePolicy {
pub good_standing_threshold: u32,
pub dampening_curve: Vec<u32>,
pub decay: DecayFunction,
pub decay_window_days: u32,
pub suspension_freezes_decay: bool,
pub cache_freshness_window_seconds: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum DecayFunction {
Linear,
Exponential,
}
impl StrikePolicy {
pub fn from_config(cfg: &crate::config::Config) -> Result<Self> {
let Some(toml) = cfg.strike_policy.as_ref() else {
return Ok(Self::defaults());
};
Self::validated_from_toml(toml)
}
pub fn defaults() -> Self {
Self {
good_standing_threshold: 3,
dampening_curve: vec![1, 2],
decay: DecayFunction::Linear,
decay_window_days: 90,
suspension_freezes_decay: true,
cache_freshness_window_seconds: 3600,
}
}
fn validated_from_toml(toml: &crate::config::StrikePolicyToml) -> Result<Self> {
let expected_curve_len = toml.good_standing_threshold.saturating_sub(1) as usize;
if toml.dampening_curve.len() != expected_curve_len {
return Err(Error::Signing(format!(
"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)",
toml.dampening_curve.len(),
toml.good_standing_threshold,
expected_curve_len,
)));
}
let mut prev: Option<u32> = None;
for (i, &v) in toml.dampening_curve.iter().enumerate() {
if v < 1 {
return Err(Error::Signing(format!(
"config: [strike_policy] dampening_curve[{i}] = {v} must be >= 1"
)));
}
if let Some(p) = prev
&& v <= p
{
return Err(Error::Signing(format!(
"config: [strike_policy] dampening_curve[{i}] = {v} must be strictly greater than the previous entry ({p}) — the curve must be strictly ascending"
)));
}
prev = Some(v);
}
if toml.decay_window_days < 1 {
return Err(Error::Signing(format!(
"config: [strike_policy] decay_window_days = {} must be >= 1",
toml.decay_window_days
)));
}
if toml.cache_freshness_window_seconds < 1 {
return Err(Error::Signing(format!(
"config: [strike_policy] cache_freshness_window_seconds = {} must be >= 1",
toml.cache_freshness_window_seconds
)));
}
Ok(Self {
good_standing_threshold: toml.good_standing_threshold,
dampening_curve: toml.dampening_curve.clone(),
decay: toml.decay_function,
decay_window_days: toml.decay_window_days,
suspension_freezes_decay: toml.suspension_freezes_decay,
cache_freshness_window_seconds: toml.cache_freshness_window_seconds,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
fn config_with_policy(value: serde_json::Value) -> Config {
let mut 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",
});
if !value.is_null() {
v["strike_policy"] = value;
}
serde_json::from_value(v).expect("config deserializes")
}
#[test]
fn defaults_match_design_conversation() {
let p = StrikePolicy::defaults();
assert_eq!(p.good_standing_threshold, 3);
assert_eq!(p.dampening_curve, vec![1, 2]);
assert_eq!(p.decay, DecayFunction::Linear);
assert_eq!(p.decay_window_days, 90);
assert!(p.suspension_freezes_decay);
assert_eq!(p.cache_freshness_window_seconds, 3600);
}
#[test]
fn defaults_curve_length_matches_threshold_minus_one() {
let p = StrikePolicy::defaults();
let expected = p.good_standing_threshold.saturating_sub(1) as usize;
assert_eq!(p.dampening_curve.len(), expected);
}
#[test]
fn absent_strike_policy_block_loads_defaults() {
let cfg = config_with_policy(serde_json::Value::Null);
let p = StrikePolicy::from_config(&cfg).expect("from_config");
assert_eq!(p, StrikePolicy::defaults());
}
#[test]
fn empty_strike_policy_block_loads_defaults_via_serde_fallbacks() {
let cfg = config_with_policy(serde_json::json!({}));
let p = StrikePolicy::from_config(&cfg).expect("from_config");
assert_eq!(p, StrikePolicy::defaults());
}
#[test]
fn fully_declared_policy_reflects_operator_values() {
let cfg = config_with_policy(serde_json::json!({
"good_standing_threshold": 5,
"dampening_curve": [1, 2, 3, 4],
"decay_function": "exponential",
"decay_window_days": 30,
"suspension_freezes_decay": false,
}));
let p = StrikePolicy::from_config(&cfg).expect("from_config");
assert_eq!(p.good_standing_threshold, 5);
assert_eq!(p.dampening_curve, vec![1, 2, 3, 4]);
assert_eq!(p.decay, DecayFunction::Exponential);
assert_eq!(p.decay_window_days, 30);
assert!(!p.suspension_freezes_decay);
}
#[test]
fn threshold_zero_with_empty_curve_is_valid() {
let cfg = config_with_policy(serde_json::json!({
"good_standing_threshold": 0,
"dampening_curve": [],
}));
let p = StrikePolicy::from_config(&cfg).expect("from_config");
assert_eq!(p.good_standing_threshold, 0);
assert!(p.dampening_curve.is_empty());
}
#[test]
fn threshold_one_with_empty_curve_is_valid() {
let cfg = config_with_policy(serde_json::json!({
"good_standing_threshold": 1,
"dampening_curve": [],
}));
let p = StrikePolicy::from_config(&cfg).expect("from_config");
assert_eq!(p.good_standing_threshold, 1);
assert!(p.dampening_curve.is_empty());
}
#[test]
fn partial_declaration_uses_serde_defaults_for_other_fields() {
let cfg = config_with_policy(serde_json::json!({
"good_standing_threshold": 3,
}));
let p = StrikePolicy::from_config(&cfg).expect("from_config");
assert_eq!(p, StrikePolicy::defaults());
}
#[test]
fn curve_too_long_for_threshold_rejected() {
let cfg = config_with_policy(serde_json::json!({
"good_standing_threshold": 3,
"dampening_curve": [1, 2, 3],
}));
let err = StrikePolicy::from_config(&cfg).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("length 3"));
assert!(msg.contains("requires length 2"));
}
#[test]
fn curve_too_short_for_threshold_rejected() {
let cfg = config_with_policy(serde_json::json!({
"good_standing_threshold": 5,
"dampening_curve": [1, 2],
}));
let err = StrikePolicy::from_config(&cfg).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("length 2"));
assert!(msg.contains("requires length 4"));
}
#[test]
fn partial_threshold_with_default_curve_surfaces_clear_mismatch() {
let cfg = config_with_policy(serde_json::json!({
"good_standing_threshold": 5,
}));
let err = StrikePolicy::from_config(&cfg).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("requires length 4"));
}
#[test]
fn non_ascending_curve_rejected() {
let cfg = config_with_policy(serde_json::json!({
"good_standing_threshold": 4,
"dampening_curve": [1, 2, 1],
}));
let err = StrikePolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("strictly ascending"));
}
#[test]
fn flat_curve_rejected_strict_ascending() {
let cfg = config_with_policy(serde_json::json!({
"good_standing_threshold": 3,
"dampening_curve": [1, 1],
}));
let err = StrikePolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("strictly ascending"));
}
#[test]
fn zero_curve_entry_rejected() {
let cfg = config_with_policy(serde_json::json!({
"good_standing_threshold": 3,
"dampening_curve": [0, 2],
}));
let err = StrikePolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains(">= 1"));
}
#[test]
fn unknown_decay_function_rejected_at_deserialize() {
let cfg_result = serde_json::from_value::<Config>(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",
"strike_policy": {
"decay_function": "logarithmic"
}
}));
assert!(
cfg_result.is_err(),
"unknown decay_function variant must fail to deserialize"
);
}
#[test]
fn zero_decay_window_rejected() {
let cfg = config_with_policy(serde_json::json!({
"decay_window_days": 0,
}));
let err = StrikePolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains(">= 1"));
}
#[test]
fn large_decay_window_accepted() {
let cfg = config_with_policy(serde_json::json!({
"decay_window_days": 365,
}));
let p = StrikePolicy::from_config(&cfg).expect("from_config");
assert_eq!(p.decay_window_days, 365);
}
#[test]
fn linear_string_deserializes() {
let cfg = config_with_policy(serde_json::json!({
"decay_function": "linear",
}));
let p = StrikePolicy::from_config(&cfg).expect("from_config");
assert_eq!(p.decay, DecayFunction::Linear);
}
#[test]
fn exponential_string_deserializes() {
let cfg = config_with_policy(serde_json::json!({
"decay_function": "exponential",
}));
let p = StrikePolicy::from_config(&cfg).expect("from_config");
assert_eq!(p.decay, DecayFunction::Exponential);
}
#[test]
fn cache_freshness_window_operator_override_accepted() {
let cfg = config_with_policy(serde_json::json!({
"cache_freshness_window_seconds": 300,
}));
let p = StrikePolicy::from_config(&cfg).expect("from_config");
assert_eq!(p.cache_freshness_window_seconds, 300);
}
#[test]
fn zero_cache_freshness_window_rejected() {
let cfg = config_with_policy(serde_json::json!({
"cache_freshness_window_seconds": 0,
}));
let err = StrikePolicy::from_config(&cfg).unwrap_err();
assert!(format!("{err}").contains("cache_freshness_window_seconds"));
}
}