Skip to main content

vector_core/community/policy/
engine.rs

1//! `evaluate` — the pure conviction engine (§1, §3.2, §5).
2//!
3//! Signals in, report out. No I/O, no keys, no capability checks, no clock: the
4//! caller passes `now`. Two clients holding the same inputs reach byte-identical
5//! conclusions, which is the whole reason the engine convicts and never
6//! sentences.
7
8use super::combine::run_pipeline;
9use super::document::{ArmScope, Enforcement, Exempt, ExemptPatterns, Match, Policy, Ratio, Rule, Rung};
10use super::matchers::{cancel_exempt_hits, keyword_hits, link_hits};
11use super::normalize::{self, EmojiCodes};
12use super::types::*;
13use std::collections::{BTreeMap, BTreeSet};
14
15// ── Inputs ───────────────────────────────────────────────────────────────────
16
17/// One message in the evaluation corpus.
18#[derive(Debug, Clone)]
19pub struct MessageSignal {
20    pub id: MessageId,
21    pub author: SubjectId,
22    pub channel: Hash32,
23    /// Inner ms timestamp — the corpus orders and clamps on this.
24    pub at_ms: u64,
25    pub text: String,
26    /// p-tags: the only thing that counts as a mention (inline `@name` is
27    /// renderer-dependent).
28    pub mentions: u32,
29}
30
31/// What the caller knows about one member before any judgement.
32#[derive(Debug, Clone)]
33pub struct MemberSignal {
34    pub subject: SubjectId,
35    /// Guestbook join, ms. `None` = unknown; with no first post either, tenure
36    /// is Indeterminate and the member is never convicted on tenure.
37    pub joined_at_ms: Option<u64>,
38    /// Roles held, by eid — shields key on permissions, never on names.
39    pub roles: Vec<Hash32>,
40    /// Holds a role carrying any moderation permission bit.
41    pub is_staff: bool,
42    /// Everything they have ever said here, not just inside the window.
43    /// Standing is historical by nature: a regular of two years who had a quiet
44    /// week is still a regular, and a window-only count would make them re-earn
45    /// their standing every week. A declared input, so the engine stays pure.
46    pub lifetime_messages: u64,
47    /// Their FIRST post ever, ms. Tenure is the oldest trace of someone, and
48    /// the window can only see a few days of it — a member whose Join was lost
49    /// (or who joined long after they started posting) would otherwise have
50    /// their tenure capped at the window's width and read as a newcomer.
51    pub first_post_ms: Option<u64>,
52}
53
54/// Everything `evaluate` reads. The caller owns fetching, decryption and the
55/// clock; the engine owns only arithmetic.
56#[derive(Debug, Clone)]
57pub struct Signals {
58    pub owner: SubjectId,
59    pub members: Vec<MemberSignal>,
60    /// The corpus, newest-first clamping applied per policy window inside.
61    pub messages: Vec<MessageSignal>,
62    pub channels: Vec<Hash32>,
63    pub relays: Vec<RelayCoverage>,
64    pub requested_from: u64,
65    pub requested_to: u64,
66    pub confirmed_from: u64,
67    pub confirmed_to: u64,
68    pub roster_version: Hash32,
69}
70
71/// Who is evaluating. A member's client runs only stateless rules — word and
72/// link filters over the message in front of it — because everything else needs
73/// history it may not hold and authority it does not have. Admins and
74/// moderation bots run everything.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum EvalMode {
77    Member,
78    Admin,
79}
80
81/// A policy plus the hash of the exact bytes it arrived as. The hash is never
82/// recomputed from a re-serialization — a client that re-encodes JSON has
83/// changed the policy.
84#[derive(Debug, Clone)]
85pub struct LoadedPolicy {
86    pub hash: Hash32,
87    pub policy: Policy,
88    pub activated_at: Option<u64>,
89}
90
91// ── Evaluation ───────────────────────────────────────────────────────────────
92
93/// Evaluate every policy against the signals. Pure: same inputs, same bytes.
94pub fn evaluate(signals: &Signals, policies: &[LoadedPolicy], overrides: &[Override], now_ms: u64) -> ModerationReport {
95    evaluate_as(signals, policies, overrides, now_ms, EvalMode::Admin)
96}
97
98/// [`evaluate`] with an explicit evaluator role.
99pub fn evaluate_as(
100    signals: &Signals,
101    policies: &[LoadedPolicy],
102    overrides: &[Override],
103    now_ms: u64,
104    mode: EvalMode,
105) -> ModerationReport {
106    let mut reports: Vec<PolicyReport> =
107        policies.iter().map(|p| evaluate_one(signals, p, overrides, now_ms, mode)).collect();
108    reports.sort_by(|a, b| a.policy_hash.0.cmp(&b.policy_hash.0));
109
110    // Report-level coverage spans the UNION of every evaluated policy's clamped
111    // corpus: policies declare different windows, so one field cannot describe
112    // just one of them.
113    let mut observed_from = u64::MAX;
114    let mut observed_to = 0u64;
115    let mut per_channel: BTreeMap<[u8; 32], (u32, u64, u64)> = BTreeMap::new();
116    for lp in policies {
117        for m in clamp_corpus(&signals.messages, &lp.policy, now_ms) {
118            observed_from = observed_from.min(m.at_ms);
119            observed_to = observed_to.max(m.at_ms);
120            let e = per_channel.entry(m.channel.0).or_insert((0, u64::MAX, 0));
121            e.0 += 1;
122            e.1 = e.1.min(m.at_ms);
123            e.2 = e.2.max(m.at_ms);
124        }
125    }
126    let mut channels: Vec<ChannelCoverage> = signals
127        .channels
128        .iter()
129        .map(|c| {
130            let (messages, from, to) = per_channel.get(&c.0).copied().unwrap_or((0, 0, 0));
131            ChannelCoverage { channel: *c, messages, from: if from == u64::MAX { 0 } else { from }, to }
132        })
133        .collect();
134    channels.sort_by(|a, b| a.channel.0.cmp(&b.channel.0));
135    let mut relays = signals.relays.clone();
136    relays.sort_by(|a, b| a.url.as_bytes().cmp(b.url.as_bytes()));
137
138    let widest_hours = policies.iter().map(|p| p.policy.window.hours).max().unwrap_or(0);
139    let complete = !relays.is_empty()
140        && relays.iter().all(|r| r.eose)
141        && signals.confirmed_from <= now_ms.saturating_sub(widest_hours.saturating_mul(3_600_000));
142
143    ModerationReport {
144        engine_version: caps::ENGINE_VERSION,
145        bundle_version: caps::BUNDLE_VERSION,
146        roster_version: signals.roster_version,
147        override_hash: override_hash(overrides),
148        evaluated_at: now_ms,
149        window: WindowCoverage {
150            requested_from: signals.requested_from,
151            requested_to: signals.requested_to,
152            confirmed_from: signals.confirmed_from,
153            confirmed_to: signals.confirmed_to,
154            observed_from: if observed_from == u64::MAX { 0 } else { observed_from },
155            observed_to,
156            channels,
157            relays,
158            complete,
159        },
160        policies: reports,
161    }
162}
163
164/// The corpus a policy reads: newest `max_messages` within `[now - hours, now)`,
165/// ordered by inner timestamp, ties by message id ascending.
166fn clamp_corpus<'a>(messages: &'a [MessageSignal], policy: &Policy, now_ms: u64) -> Vec<&'a MessageSignal> {
167    let floor = now_ms.saturating_sub(policy.window.hours.saturating_mul(3_600_000));
168    let mut in_window: Vec<&MessageSignal> = messages.iter().filter(|m| m.at_ms >= floor && m.at_ms < now_ms).collect();
169    in_window.sort_by(|a, b| a.at_ms.cmp(&b.at_ms).then_with(|| a.id.0.cmp(&b.id.0)));
170    if in_window.len() > policy.window.max_messages {
171        in_window.drain(..in_window.len() - policy.window.max_messages);
172    }
173    in_window
174}
175
176struct RuleOutcome {
177    convictions: Vec<Conviction>,
178    citations: Vec<Citation>,
179    unknown: Vec<SubjectId>,
180    state: RuleState,
181}
182
183fn evaluate_one(
184    signals: &Signals,
185    lp: &LoadedPolicy,
186    overrides: &[Override],
187    now_ms: u64,
188    mode: EvalMode,
189) -> PolicyReport {
190    // An INERT policy evaluated NOTHING, and says so: an empty subject list must
191    // never read as "everyone is clean".
192    if let Err(reason) = lp.policy.validate() {
193        return PolicyReport {
194            policy_hash: lp.hash,
195            inert: Some(reason),
196            activated_at: lp.activated_at,
197            coverage_complete: false,
198            rule_status: vec![],
199            subjects: vec![],
200            subjects_truncated: 0,
201            citations: vec![],
202        };
203    }
204
205    let policy = &lp.policy;
206    let corpus = clamp_corpus(&signals.messages, policy, now_ms);
207    let codes = EmojiCodes::from_policy(policy.emoji_codes.iter());
208    let exempt_channels: BTreeSet<[u8; 32]> = policy
209        .exempt
210        .channels
211        .iter()
212        .filter_map(|c| crate::simd::hex::hex_to_bytes_32_checked(c))
213        .collect();
214
215    // Shields gate BEFORE conviction, and only Protected/Trusted gate.
216    let shields = compute_shields(signals, policy, &corpus, &codes, now_ms);
217
218    let mut all_convictions: Vec<Conviction> = Vec::new();
219    let mut all_citations: Vec<Citation> = Vec::new();
220    let mut rule_status: Vec<RuleStatus> = Vec::new();
221    // armed_by counts convictions BEFORE suppression, folding and top-N.
222    let mut convicted_by_rule: BTreeMap<&str, BTreeSet<[u8; 32]>> = BTreeMap::new();
223
224    // Only the people who are HERE can be convicted, or arm anything. The
225    // corpus outlives membership — a banned raider's messages stay in local
226    // storage long after the rotation removed them — so without this the engine
227    // re-litigates people a moderator cannot act on, and last night's attack
228    // keeps arming rules against whoever is still here today. Scores are
229    // present tense; so is the evidence behind them.
230    let member_set: BTreeSet<[u8; 32]> = signals.members.iter().map(|m| m.subject.0).collect();
231
232    for rule in &policy.rules {
233        let mut out = evaluate_rule(rule, policy, signals, &corpus, &codes, &exempt_channels, &shields, lp, now_ms, mode);
234        out.convictions.retain(|c| member_set.contains(&c.subject.0));
235        let mut subjects: BTreeSet<[u8; 32]> = BTreeSet::new();
236        for c in &out.convictions {
237            subjects.insert(c.subject.0);
238        }
239        convicted_by_rule.insert(rule.id.as_str(), subjects);
240        rule_status.push(RuleStatus { rule_id: rule.id.clone(), state: out.state, unknown_subjects: out.unknown });
241        all_convictions.extend(out.convictions);
242        all_citations.extend(out.citations);
243    }
244
245    // Drop convictions whose rule was armed by another that did not fire.
246    all_convictions.retain(|c| {
247        let Some(rule) = policy.rules.iter().find(|r| r.id == c.rule_id) else { return true };
248        let Some(arm) = &rule.armed_by else { return true };
249        // Any one of the named rules arms it: an aggravator that hangs off a
250        // single detector goes silent for every raid that detector cannot see.
251        std::iter::once(&arm.rule).chain(arm.also.iter()).any(|id| {
252            match convicted_by_rule.get(id.as_str()) {
253                Some(s) => match arm.scope {
254                    ArmScope::Subject => s.contains(&c.subject.0),
255                    ArmScope::Community => s.len() as u32 >= arm.min_subjects.unwrap_or(1),
256                },
257                None => false,
258            }
259        })
260    });
261
262    // Pardons: reported, never combined, and their citations are suppressed too
263    // (otherwise a pardon leaves the content consumer still hiding forgiven
264    // messages).
265    let mut pardoned: BTreeSet<[u8; 32]> = BTreeSet::new();
266    for c in &mut all_convictions {
267        if overrides.iter().any(|o| o.matches(c, &lp.hash, now_ms)) {
268            c.suppressed = true;
269            for cid in &c.citations {
270                pardoned.insert(cid.0);
271            }
272        }
273    }
274    for cit in &mut all_citations {
275        if pardoned.contains(&cit.id.0) {
276            cit.suppressed = true;
277        }
278    }
279
280    // Group by subject, run the frozen pipeline, keep what the report must be
281    // able to justify.
282    let mut by_subject: BTreeMap<[u8; 32], Vec<Conviction>> = BTreeMap::new();
283    for c in all_convictions {
284        by_subject.entry(c.subject.0).or_default().push(c);
285    }
286    let mut subjects: Vec<SubjectReport> = Vec::new();
287    for (subject_bytes, mut convictions) in by_subject {
288        let subject = SubjectId(subject_bytes);
289        let score = run_pipeline(&mut convictions);
290        convictions.sort_by(|a, b| a.rule_id.cmp(&b.rule_id).then_with(|| a.scope.cmp(&b.scope)));
291        truncate_convictions(&mut convictions);
292        subjects.push(SubjectReport {
293            subject,
294            shield: shields.get(&subject_bytes).copied().unwrap_or(Shield::None),
295            confidence: score.confidence,
296            proven: score.proven,
297            band: score.band,
298            convictions,
299        });
300    }
301    // A subject is emitted with any conviction OR a non-None shield: a shielded
302    // subject is confidence-0 by construction, so omitting them would delete the
303    // field that explains their immunity.
304    for (bytes, shield) in &shields {
305        if *shield != Shield::None && !subjects.iter().any(|s| s.subject.0 == *bytes) {
306            subjects.push(SubjectReport {
307                subject: SubjectId(*bytes),
308                shield: *shield,
309                confidence: 0,
310                proven: 0,
311                band: super::combine::band(0),
312                convictions: vec![],
313            });
314        }
315    }
316    subjects.sort_by(|a, b| a.subject.0.cmp(&b.subject.0));
317
318    // Citations are a SET holding exactly what retained convictions reference.
319    let retained: BTreeSet<[u8; 32]> =
320        subjects.iter().flat_map(|s| s.convictions.iter()).flat_map(|c| c.citations.iter()).map(|c| c.0).collect();
321    all_citations.retain(|c| retained.contains(&c.id.0));
322    all_citations.sort_by(|a, b| a.id.0.cmp(&b.id.0));
323    all_citations.dedup_by(|a, b| a.id == b.id);
324
325    let floor = now_ms.saturating_sub(policy.window.hours.saturating_mul(3_600_000));
326    PolicyReport {
327        policy_hash: lp.hash,
328        inert: None,
329        activated_at: lp.activated_at,
330        coverage_complete: signals.confirmed_from <= floor && signals.relays.iter().all(|r| r.eose),
331        rule_status,
332        subjects,
333        subjects_truncated: 0,
334        citations: all_citations,
335    }
336}
337
338/// Retain every conviction that entered EITHER pipeline, then fill to the cap
339/// from the rest in retain order — a report must always justify both scores.
340fn truncate_convictions(convictions: &mut Vec<Conviction>) {
341    if convictions.len() <= caps::MAX_CONVICTIONS_STORED_PER_SUBJECT {
342        return;
343    }
344    let mut order: Vec<usize> = (0..convictions.len()).collect();
345    order.sort_by(|&a, &b| {
346        let (ca, cb) = (&convictions[a], &convictions[b]);
347        let ea = ca.combined || ca.proven_combined;
348        let eb = cb.combined || cb.proven_combined;
349        eb.cmp(&ea)
350            .then_with(|| cb.tier_weight.cmp(&ca.tier_weight))
351            .then_with(|| ca.rule_id.cmp(&cb.rule_id))
352            .then_with(|| ca.scope.cmp(&cb.scope))
353    });
354    let exempt = order.iter().filter(|&&i| convictions[i].combined || convictions[i].proven_combined).count();
355    let keep = exempt.max(caps::MAX_CONVICTIONS_STORED_PER_SUBJECT);
356    let keep_set: BTreeSet<usize> = order.into_iter().take(keep).collect();
357    let mut i = 0usize;
358    convictions.retain(|_| {
359        let k = keep_set.contains(&i);
360        i += 1;
361        k
362    });
363}
364
365fn compute_shields(
366    signals: &Signals,
367    policy: &Policy,
368    corpus: &[&MessageSignal],
369    codes: &EmojiCodes,
370    now_ms: u64,
371) -> BTreeMap<[u8; 32], Shield> {
372    let bar = policy.shields.trusted;
373    let mut volume: BTreeMap<[u8; 32], u64> = BTreeMap::new();
374    let mut variety: BTreeMap<[u8; 32], BTreeSet<String>> = BTreeMap::new();
375    let mut first_post: BTreeMap<[u8; 32], u64> = BTreeMap::new();
376    for m in corpus {
377        *volume.entry(m.author.0).or_insert(0) += 1;
378        let sk = normalize::skeleton(&m.text, codes);
379        if !sk.is_empty() {
380            variety.entry(m.author.0).or_default().insert(sk);
381        }
382        let e = first_post.entry(m.author.0).or_insert(u64::MAX);
383        *e = (*e).min(m.at_ms);
384    }
385    let mut out = BTreeMap::new();
386    for member in &signals.members {
387        let b = member.subject.0;
388        // Protected: the owner, plus holders of a moderation permission bit —
389        // never mere role membership.
390        if member.subject == signals.owner || member.is_staff {
391            out.insert(b, Shield::Protected);
392            continue;
393        }
394        // Tenure = now minus the OLDEST trace of them: their Join, their first
395        // post ever, or the earliest thing they said inside the window —
396        // whichever reaches furthest back. Unknown only when nothing does.
397        let window_first = first_post.get(&b).copied().filter(|v| *v != u64::MAX);
398        let candidates = [member.joined_at_ms, member.first_post_ms, window_first];
399        let oldest = candidates.into_iter().flatten().min();
400        let Some(oldest) = oldest else {
401            out.insert(b, Shield::Indeterminate);
402            continue;
403        };
404        let tenure = now_ms.saturating_sub(oldest) / 1000;
405        let vol = volume.get(&b).copied().unwrap_or(0);
406        let var = variety.get(&b).map(|s| s.len() as u64).unwrap_or(0);
407        // Three paths to standing (§5.3): a role the community granted, long
408        // tenure with any activity, or tenure with volume and variety. Every
409        // path carries a tenure floor, so none of them is farmable in a day.
410        // Volume is LIFETIME (standing is historical); variety is the window's
411        // (how someone speaks now is what tells a person from a script).
412        let lifetime = member.lifetime_messages.max(vol);
413        let by_role = bar.roles_trust && !member.roles.is_empty();
414        let by_veteran = tenure >= bar.veteran_secs && lifetime >= 1;
415        let by_active = tenure >= bar.tenure_secs && lifetime >= bar.messages && var >= bar.distinct;
416        out.insert(b, if by_role || by_veteran || by_active { Shield::Trusted } else { Shield::None });
417    }
418    out
419}
420
421#[allow(clippy::too_many_arguments)]
422fn evaluate_rule(
423    rule: &Rule,
424    policy: &Policy,
425    signals: &Signals,
426    corpus: &[&MessageSignal],
427    codes: &EmojiCodes,
428    exempt_channels: &BTreeSet<[u8; 32]>,
429    shields: &BTreeMap<[u8; 32], Shield>,
430    lp: &LoadedPolicy,
431    _now_ms: u64,
432    mode: EvalMode,
433) -> RuleOutcome {
434    let mut out =
435        RuleOutcome { convictions: vec![], citations: vec![], unknown: vec![], state: RuleState::Evaluated };
436
437    // A member's client judges only what it can judge from one message. Saying
438    // so is the point: a silent skip would read as "clean".
439    if mode == EvalMode::Member && !rule.matcher.is_stateless() {
440        out.state = RuleState::RequiresHistory;
441        return out;
442    }
443
444    // A declared-but-unimplemented normalizer is unevaluated, never approximated.
445    if let Match::Keyword { normalize: n, .. } | Match::Regex { normalize: n, .. } | Match::Repeat { normalize: n, .. } =
446        &rule.matcher
447    {
448        if !normalize::is_available(*n) {
449            out.state = RuleState::UnknownType;
450            return out;
451        }
452    }
453
454    let exempt_roles: BTreeSet<[u8; 32]> = policy
455        .exempt
456        .roles
457        .iter()
458        .chain(rule.exempt.roles.iter())
459        .filter_map(|r| crate::simd::hex::hex_to_bytes_32_checked(r))
460        .collect();
461    let rule_exempt_channels: BTreeSet<[u8; 32]> = rule
462        .exempt
463        .channels
464        .iter()
465        .filter_map(|c| crate::simd::hex::hex_to_bytes_32_checked(c))
466        .chain(exempt_channels.iter().copied())
467        .collect();
468    let exempts: Vec<&ExemptPatterns> = policy.exempt.patterns.iter().chain(rule.exempt.patterns.iter()).collect();
469
470    let subject_exempt = |s: &SubjectId| -> bool {
471        signals
472            .members
473            .iter()
474            .find(|m| m.subject == *s)
475            .is_some_and(|m| m.roles.iter().any(|r| exempt_roles.contains(&r.0)))
476    };
477
478    match &rule.matcher {
479        Match::Keyword { .. } | Match::Regex { .. } | Match::Link { .. } | Match::Mentions {} => {
480            content_rule(rule, policy, corpus, codes, &rule_exempt_channels, &exempts, shields, lp, &subject_exempt, &mut out);
481        }
482        Match::TenureLt { secs } => {
483            for member in &signals.members {
484                if subject_exempt(&member.subject) || gated(shields, &member.subject, rule, 0) {
485                    continue;
486                }
487                match member.joined_at_ms {
488                    // Unknown tenure is per-subject unknown — never a conviction.
489                    None => out.unknown.push(member.subject),
490                    Some(joined) => {
491                        let age = _now_ms.saturating_sub(joined) / 1000;
492                        if age < *secs {
493                            push_direct(rule, lp, member.subject, &mut out);
494                        }
495                    }
496                }
497            }
498        }
499        Match::MessagesLte { n } => {
500            for member in &signals.members {
501                if subject_exempt(&member.subject) || gated(shields, &member.subject, rule, 0) {
502                    continue;
503                }
504                let count = corpus.iter().filter(|m| m.author == member.subject).count() as u32;
505                if count <= *n {
506                    push_direct(rule, lp, member.subject, &mut out);
507                }
508            }
509        }
510        Match::Repeat { .. } | Match::Rate { .. } => {
511            window_rule(rule, corpus, codes, &rule_exempt_channels, shields, lp, &subject_exempt, &mut out);
512        }
513        Match::Cohort { min, quiet_max, short_factor, thin_ratio } => {
514            cohort_rule(
515                rule, corpus, codes, &rule_exempt_channels, shields, lp, &subject_exempt, signals,
516                *min, *quiet_max, *short_factor, thin_ratio.unwrap_or(Ratio { num: 1, denom: 2 }), &mut out,
517            );
518        }
519        Match::JoinBurst { gap_secs, min } => {
520            join_burst_rule(rule, signals, shields, lp, &subject_exempt, *gap_secs, *min, &mut out);
521        }
522    }
523    out
524}
525
526/// Does a shield stop this rule convicting this subject?
527fn gated(shields: &BTreeMap<[u8; 32], Shield>, subject: &SubjectId, rule: &Rule, rung_pierces: usize) -> bool {
528    match shields.get(&subject.0).copied().unwrap_or(Shield::None) {
529        Shield::Protected => true,
530        // Trusted gates EXCEPT against a rung declaring pierces_trusted.
531        Shield::Trusted => rung_pierces == 0 && !rule.pierces_trusted,
532        Shield::None | Shield::Indeterminate => false,
533    }
534}
535
536fn push_direct_with(
537    rule: &Rule,
538    lp: &LoadedPolicy,
539    subject: SubjectId,
540    mut citations: Vec<Citation>,
541    out: &mut RuleOutcome,
542) {
543    citations.sort_by(|a, b| a.id.0.cmp(&b.id.0));
544    citations.dedup_by(|a, b| a.id == b.id);
545    let citation_count = citations.len() as u32;
546    let earliest = citations.iter().map(|c| c.at).min().unwrap_or(0);
547    let latest = citations.iter().map(|c| c.at).max().unwrap_or(0);
548    citations.truncate(caps::MAX_CITATIONS_PER_CONVICTION);
549    let ids: Vec<CitationId> = citations.iter().map(|c| c.id).collect();
550    out.citations.extend(citations);
551    push_direct(rule, lp, subject, out);
552    if let Some(c) = out.convictions.last_mut() {
553        c.citations = ids;
554        c.citation_count = citation_count;
555        c.earliest_citation_at = earliest;
556        c.latest_citation_at = latest;
557    }
558}
559
560fn push_direct(rule: &Rule, lp: &LoadedPolicy, subject: SubjectId, out: &mut RuleOutcome) {
561    let severity = rule.severity.expect("validated: direct form carries a severity");
562    let weight = rule.weight.expect("validated: direct form carries a weight");
563    out.convictions.push(Conviction {
564        id: conviction_id(&lp.hash, &rule.id, Scope::Whole, 0, &subject),
565        subject,
566        rule_id: rule.id.clone(),
567        scope: Scope::Whole,
568        rung: 0,
569        hits: 1,
570        severity,
571        basis: rule.matcher.basis(),
572        tier_weight: weight,
573        // Citation-less by nature: no content timestamps to compare against a
574        // policy activation, so the honest answer is Unknown.
575        retroactive: Retroactive::Unknown,
576        suppressed: false,
577        folded: false,
578        folded_into: None,
579        combined: false,
580        proven_combined: false,
581        citations: vec![],
582        citation_count: 0,
583        earliest_citation_at: 0,
584        latest_citation_at: 0,
585        family: rule.matcher.builtin_family().map(|s| s.to_string()).or_else(|| rule.family.clone()),
586        evidence: vec![],
587    });
588}
589
590#[allow(clippy::too_many_arguments)]
591fn content_rule(
592    rule: &Rule,
593    _policy: &Policy,
594    corpus: &[&MessageSignal],
595    codes: &EmojiCodes,
596    exempt_channels: &BTreeSet<[u8; 32]>,
597    exempts: &[&ExemptPatterns],
598    shields: &BTreeMap<[u8; 32], Shield>,
599    lp: &LoadedPolicy,
600    subject_exempt: &dyn Fn(&SubjectId) -> bool,
601    out: &mut RuleOutcome,
602) {
603    let tiers = rule.tiers.as_ref().expect("validated: content rules carry tiers");
604    // The needle has to live in the same space as the haystack. Message text is
605    // normalized below; a pattern that is not gets compared against text it can
606    // never equal, so "Vector" silently matched nothing while "vector" matched.
607    // Hoisted out of the corpus loop: this does not vary per message.
608    let folded_patterns: Vec<String> = match &rule.matcher {
609        Match::Keyword { patterns, normalize: n } => {
610            patterns.iter().map(|p| normalize::apply(p, *n, codes)).collect()
611        }
612        _ => Vec::new(),
613    };
614    // (subject, message) -> hits, plus the citations each produced.
615    let mut per_message: BTreeMap<([u8; 32], [u8; 32]), (u32, u64, Vec<Citation>)> = BTreeMap::new();
616
617    for m in corpus {
618        // Exempt content is barred from being CITED, but stays in every corpus
619        // statistic — exemptions change who can be accused, never what the
620        // community looks like.
621        if exempt_channels.contains(&m.channel.0) || subject_exempt(&m.author) {
622            continue;
623        }
624        let (hits, citations) = match &rule.matcher {
625            Match::Keyword { normalize: n, .. } => {
626                let text = normalize::apply(&m.text, *n, codes);
627                let hits = cancel_exempt_hits(&text, keyword_hits(&text, &folded_patterns), exempts);
628                let cits = hits
629                    .iter()
630                    .map(|h| {
631                        let target = CitationTarget::Message { message_id: m.id };
632                        // Carry WHAT matched. A moderator reading "matched rule
633                        // words" learns nothing; reading the word itself can
634                        // judge in a second whether the rule is right.
635                        let matched: String = text
636                            .get(h.start..h.end)
637                            .unwrap_or_default()
638                            .chars()
639                            .take(caps::MAX_DETAIL_LEN)
640                            .collect();
641                        Citation {
642                            id: citation_id(&lp.hash, &rule.id, Scope::PerMessage, &m.author, &target, Some(h.span())),
643                            target,
644                            at: m.at_ms,
645                            span: Some(h.span()),
646                            detail: (!matched.is_empty()).then_some(matched),
647                            suppressed: false,
648                        }
649                    })
650                    .collect::<Vec<_>>();
651                (hits.len() as u32, cits)
652            }
653            Match::Link { patterns } => {
654                let domains = link_hits(&m.text, patterns, exempts);
655                let target = CitationTarget::Message { message_id: m.id };
656                // A link hit is a domain, not a text range: no span.
657                let cits = if domains.is_empty() {
658                    vec![]
659                } else {
660                    vec![Citation {
661                        id: citation_id(&lp.hash, &rule.id, Scope::PerMessage, &m.author, &target, None),
662                        target,
663                        at: m.at_ms,
664                        span: None,
665                        detail: Some(domains.join(", ")),
666                        suppressed: false,
667                    }]
668                };
669                (domains.len() as u32, cits)
670            }
671            Match::Mentions {} => {
672                let target = CitationTarget::Message { message_id: m.id };
673                let cits = if m.mentions == 0 {
674                    vec![]
675                } else {
676                    vec![Citation {
677                        id: citation_id(&lp.hash, &rule.id, Scope::PerMessage, &m.author, &target, None),
678                        target,
679                        at: m.at_ms,
680                        span: None,
681                        detail: None,
682                        suppressed: false,
683                    }]
684                };
685                (m.mentions, cits)
686            }
687            // Regex needs the pinned crate build; declared, not yet wired.
688            _ => (0, vec![]),
689        };
690        if hits > 0 {
691            per_message.insert((m.author.0, m.id.0), (hits, m.at_ms, citations));
692        }
693    }
694
695    // Per-subject: every message's own hit count, kept individually so a
696    // density rung can cite EVERY message that reached it.
697    let mut by_subject: BTreeMap<[u8; 32], Vec<(u32, Vec<Citation>)>> = BTreeMap::new();
698    for ((subject, _), (hits, _at, cits)) in per_message {
699        by_subject.entry(subject).or_default().push((hits, cits));
700    }
701
702    for (subject_bytes, messages) in by_subject {
703        let subject = SubjectId(subject_bytes);
704        // Density = the densest single message; persistence = the window sum.
705        let density: u32 = messages.iter().map(|(h, _)| *h).max().unwrap_or(0);
706        let persistence: u32 = messages.iter().map(|(h, _)| *h).sum();
707
708        for (scope, rungs, hits) in [
709            (Scope::PerMessage, &tiers.per_message, density),
710            (Scope::PerWindow, &tiers.per_window, persistence),
711        ] {
712            if rungs.is_empty() {
713                continue;
714            }
715            // The highest rung reached: rungs validate strictly ascending.
716            let Some((idx, rung)) = rungs.iter().enumerate().filter(|(_, g)| hits >= g.hits).next_back() else {
717                continue;
718            };
719            if gated(shields, &subject, rule, usize::from(rung.pierces_trusted)) {
720                continue;
721            }
722            // A density conviction cites every message whose OWN hit count meets
723            // the rung that fired; a persistence conviction cites the whole
724            // contributing window.
725            let citations: Vec<Citation> = match scope {
726                Scope::PerMessage => {
727                    messages.iter().filter(|(h, _)| *h >= rung.hits).flat_map(|(_, c)| c.iter().cloned()).collect()
728                }
729                _ => messages.iter().flat_map(|(_, c)| c.iter().cloned()).collect(),
730            };
731            push_tiered(rule, lp, subject, scope, idx as u8, hits, rung, citations, out);
732        }
733    }
734}
735
736/// Cross-account clustering: many identities posting one shape.
737///
738/// A cluster convicts on TWO counts, never one. Size alone would hang a
739/// community catchphrase on whoever repeats it, so the cluster must also be
740/// THIN — a script gives each identity a line or two, while regulars who share
741/// a greeting have hundreds of other messages between them. A short shape is
742/// cheap coincidence on top of that, so it clears a higher size bar.
743///
744/// Statistics read the FULL corpus: exempt members and channels still count
745/// toward cluster size and thinness (exemptions change who can be accused,
746/// never what the community looks like), they simply cannot be convicted.
747#[allow(clippy::too_many_arguments)]
748fn cohort_rule(
749    rule: &Rule,
750    corpus: &[&MessageSignal],
751    codes: &EmojiCodes,
752    exempt_channels: &BTreeSet<[u8; 32]>,
753    shields: &BTreeMap<[u8; 32], Shield>,
754    lp: &LoadedPolicy,
755    subject_exempt: &dyn Fn(&SubjectId) -> bool,
756    signals: &Signals,
757    min: u32,
758    quiet_max: u32,
759    short_factor: u32,
760    thin_ratio: Ratio,
761    out: &mut RuleOutcome,
762) {
763    // skeleton -> the distinct authors who posted it, plus their messages.
764    let mut clusters: BTreeMap<String, BTreeMap<[u8; 32], Vec<&MessageSignal>>> = BTreeMap::new();
765    for m in corpus {
766        let key = normalize::skeleton(&m.text, codes);
767        if key.is_empty() {
768            continue;
769        }
770        clusters.entry(key).or_default().entry(m.author.0).or_default().push(m);
771    }
772    // Window volume per author — the thinness input.
773    let mut volume: BTreeMap<[u8; 32], u32> = BTreeMap::new();
774    for m in corpus {
775        *volume.entry(m.author.0).or_insert(0) += 1;
776    }
777
778    // Each subject is convicted once (scope Whole) on the LARGEST cluster they
779    // belong to; ties break by clustering key ascending.
780    let mut best: BTreeMap<[u8; 32], (usize, String)> = BTreeMap::new();
781    for (key, authors) in &clusters {
782        let need = if key.chars().count() < caps::MIN_SKELETON_LEN {
783            min.saturating_mul(short_factor)
784        } else {
785            min
786        } as usize;
787        if authors.len() < need {
788            continue;
789        }
790        let thin = authors.keys().filter(|a| volume.get(*a).copied().unwrap_or(0) <= quiet_max).count();
791        if (thin as u64) * (thin_ratio.denom as u64) < (authors.len() as u64) * (thin_ratio.num as u64) {
792            continue;
793        }
794        for author in authors.keys() {
795            let slot = best.entry(*author).or_insert((0, String::new()));
796            if authors.len() > slot.0 {
797                *slot = (authors.len(), key.clone());
798            }
799        }
800    }
801
802    for (author_bytes, (size, key)) in best {
803        let subject = SubjectId(author_bytes);
804        if subject_exempt(&subject) || gated(shields, &subject, rule, usize::from(rule.pierces_trusted)) {
805            continue;
806        }
807        let messages = clusters.get(&key).and_then(|a| a.get(&author_bytes)).cloned().unwrap_or_default();
808        // Exempt content is barred from being cited even though it counted
809        // toward the cluster's shape.
810        let citable: Vec<&MessageSignal> =
811            messages.iter().copied().filter(|m| !exempt_channels.contains(&m.channel.0)).collect();
812        if citable.is_empty() {
813            continue;
814        }
815        let citations: Vec<Citation> = citable
816            .iter()
817            .map(|m| {
818                let target = CitationTarget::Message { message_id: m.id };
819                Citation {
820                    id: citation_id(&lp.hash, &rule.id, Scope::Whole, &subject, &target, None),
821                    target,
822                    at: m.at_ms,
823                    span: None,
824                    detail: None,
825                    suppressed: false,
826                }
827            })
828            .collect();
829        // The sample is the lowest inner message id in the cluster, excluding
830        // exempt members and channels: `peers` already omits them, and a sample
831        // would leak the same accusation the report may not make.
832        let sample = clusters
833            .get(&key)
834            .map(|authors| {
835                let mut candidates: Vec<&MessageSignal> = authors
836                    .iter()
837                    .filter(|(a, _)| !subject_exempt(&SubjectId(**a)))
838                    .flat_map(|(_, ms)| ms.iter().copied())
839                    .filter(|m| !exempt_channels.contains(&m.channel.0))
840                    .collect();
841                candidates.sort_by(|a, b| a.id.0.cmp(&b.id.0));
842                candidates.first().map(|m| m.text.clone()).unwrap_or_default()
843            })
844            .unwrap_or_default();
845        let mut peers: Vec<SubjectId> = clusters
846            .get(&key)
847            .map(|a| a.keys().filter(|k| **k != author_bytes).map(|k| SubjectId(*k)).collect())
848            .unwrap_or_default();
849        peers.sort_by(|a, b| a.0.cmp(&b.0));
850        peers.truncate(caps::COHORT_SAMPLE_CAP);
851        use sha2::Digest;
852        let mut hasher = sha2::Sha256::default();
853        hasher.update(key.as_bytes());
854        let evidence = vec![Evidence::Cohort {
855            skeleton_hash: Hash32(hasher.finalize().into()),
856            sample: sample.chars().take(caps::MAX_SAMPLE_LEN).collect(),
857            size: size as u32,
858            peers,
859        }];
860        let before = out.convictions.len();
861        push_direct_with(rule, lp, subject, citations, out);
862        if let Some(c) = out.convictions.get_mut(before) {
863            c.evidence = evidence;
864        }
865    }
866    let _ = signals;
867}
868
869/// The densest run of joins inside one window. Deliberately weak alone: a
870/// freshly-posted invite link is SUPPOSED to bring a burst of quiet newcomers,
871/// so this is normally `armed_by` a cohort rule and convicts nobody until
872/// something else already has.
873#[allow(clippy::too_many_arguments)]
874fn join_burst_rule(
875    rule: &Rule,
876    signals: &Signals,
877    shields: &BTreeMap<[u8; 32], Shield>,
878    lp: &LoadedPolicy,
879    subject_exempt: &dyn Fn(&SubjectId) -> bool,
880    gap_secs: u64,
881    min: u32,
882    out: &mut RuleOutcome,
883) {
884    // The owner is never part of a burst, and an unknown join is not evidence
885    // of one.
886    let mut joins: Vec<(u64, SubjectId)> = signals
887        .members
888        .iter()
889        .filter(|m| m.subject != signals.owner)
890        .filter_map(|m| m.joined_at_ms.map(|at| (at, m.subject)))
891        .collect();
892    joins.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1 .0.cmp(&b.1 .0)));
893    let gap_ms = gap_secs.saturating_mul(1000);
894    let (mut best_start, mut best_len) = (0usize, 0usize);
895    let mut start = 0usize;
896    for end in 0..joins.len() {
897        while joins[end].0.saturating_sub(joins[start].0) > gap_ms {
898            start += 1;
899        }
900        // Strictly greater: on a tie the EARLIEST window wins, which is where a
901        // raid starts.
902        if end + 1 - start > best_len {
903            best_len = end + 1 - start;
904            best_start = start;
905        }
906    }
907    if best_len < min as usize {
908        return;
909    }
910    let window = &joins[best_start..best_start + best_len];
911    let evidence = vec![Evidence::Burst {
912        from: window.first().map(|(at, _)| *at).unwrap_or(0),
913        to: window.last().map(|(at, _)| *at).unwrap_or(0),
914        size: best_len as u32,
915    }];
916    for (_, subject) in window {
917        if subject_exempt(subject) || gated(shields, subject, rule, usize::from(rule.pierces_trusted)) {
918            continue;
919        }
920        let before = out.convictions.len();
921        push_direct(rule, lp, *subject, out);
922        if let Some(c) = out.convictions.get_mut(before) {
923            c.evidence = evidence.clone();
924        }
925    }
926}
927
928/// Window-level rules: one conviction per subject, scope PerWindow.
929///
930///  * `repeat` — the most-repeated normalized text by that author; ties break by
931///    the normalized key ascending, so the citation set is reproducible. This is
932///    the copy-paste counter that catches one account spamming, where `cohort`
933///    catches many accounts sharing one line.
934///  * `rate` — the densest half-open `[t, t + per_secs)` span over the author's
935///    own message timestamps; ties take the EARLIEST span.
936#[allow(clippy::too_many_arguments)]
937fn window_rule(
938    rule: &Rule,
939    corpus: &[&MessageSignal],
940    codes: &EmojiCodes,
941    exempt_channels: &BTreeSet<[u8; 32]>,
942    shields: &BTreeMap<[u8; 32], Shield>,
943    lp: &LoadedPolicy,
944    subject_exempt: &dyn Fn(&SubjectId) -> bool,
945    out: &mut RuleOutcome,
946) {
947    let tiers = rule.tiers.as_ref().expect("validated: window rules carry tiers");
948    let rungs = &tiers.per_window;
949    if rungs.is_empty() {
950        return;
951    }
952    // Citable content only: exempt content still counts toward the corpus
953    // statistics every other rule reads, but it can never be cited here.
954    let mut by_author: BTreeMap<[u8; 32], Vec<&MessageSignal>> = BTreeMap::new();
955    for m in corpus {
956        if exempt_channels.contains(&m.channel.0) || subject_exempt(&m.author) {
957            continue;
958        }
959        by_author.entry(m.author.0).or_default().push(m);
960    }
961
962    for (subject_bytes, messages) in by_author {
963        let subject = SubjectId(subject_bytes);
964        let (hits, cited, evidence) = match &rule.matcher {
965            Match::Repeat { normalize: n, within_secs } => {
966                let mut groups: BTreeMap<String, Vec<&MessageSignal>> = BTreeMap::new();
967                for m in &messages {
968                    let key = normalize::apply(&m.text, *n, codes);
969                    if key.is_empty() {
970                        continue;
971                    }
972                    groups.entry(key).or_default().push(m);
973                }
974                // With a span, each group counts only its densest run inside it,
975                // and the winner is the tightest burst rather than the biggest
976                // pile. "gm" once a morning is seven hits across a week and one
977                // hit in any half hour, which is the difference between a
978                // regular and a spammer.
979                let scored: Vec<(String, u32, Vec<&MessageSignal>)> = groups
980                    .into_iter()
981                    .map(|(key, mut ms)| {
982                        let Some(secs) = within_secs else {
983                            let n = ms.len() as u32;
984                            return (key, n, ms);
985                        };
986                        ms.sort_by_key(|m| m.at_ms);
987                        let span = secs.saturating_mul(1000);
988                        let (mut best, mut from) = (0usize, 0u64);
989                        for (i, m) in ms.iter().enumerate() {
990                            // Half-open [start, start + span), starts taken from
991                            // the author's own timestamps — same walk as `Rate`.
992                            let start = m.at_ms;
993                            let count =
994                                ms[i..].iter().take_while(|x| x.at_ms < start.saturating_add(span)).count();
995                            if count > best {
996                                best = count;
997                                from = start;
998                            }
999                        }
1000                        let cited: Vec<&MessageSignal> = ms
1001                            .into_iter()
1002                            .filter(|m| m.at_ms >= from && m.at_ms < from.saturating_add(span))
1003                            .collect();
1004                        (key, best as u32, cited)
1005                    })
1006                    .collect();
1007                // Most repeated wins; ties by the normalized key ascending.
1008                let Some((_key, n_hits, winners)) =
1009                    scored.into_iter().max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
1010                else {
1011                    continue;
1012                };
1013                (n_hits, winners, vec![])
1014            }
1015            Match::Rate { per_secs } => {
1016                let mut times: Vec<u64> = messages.iter().map(|m| m.at_ms).collect();
1017                times.sort_unstable();
1018                let span = per_secs.saturating_mul(1000);
1019                let (mut best, mut best_from) = (0usize, 0u64);
1020                for (i, &start) in times.iter().enumerate() {
1021                    // Half-open [start, start + span); candidate starts are the
1022                    // author's own timestamps, never every millisecond.
1023                    let count = times[i..].iter().take_while(|&&t| t < start.saturating_add(span)).count();
1024                    if count > best {
1025                        best = count;
1026                        best_from = start;
1027                    }
1028                }
1029                let cited: Vec<&MessageSignal> = messages
1030                    .iter()
1031                    .copied()
1032                    .filter(|m| m.at_ms >= best_from && m.at_ms < best_from.saturating_add(span))
1033                    .collect();
1034                (
1035                    best as u32,
1036                    cited,
1037                    vec![Evidence::Rate { window_secs: *per_secs, count: best as u32, from: best_from }],
1038                )
1039            }
1040            _ => continue,
1041        };
1042
1043        let Some((idx, rung)) = rungs.iter().enumerate().filter(|(_, g)| hits >= g.hits).next_back() else {
1044            continue;
1045        };
1046        if gated(shields, &subject, rule, usize::from(rung.pierces_trusted)) {
1047            continue;
1048        }
1049        let citations: Vec<Citation> = cited
1050            .iter()
1051            .map(|m| {
1052                let target = CitationTarget::Message { message_id: m.id };
1053                Citation {
1054                    id: citation_id(&lp.hash, &rule.id, Scope::PerWindow, &subject, &target, None),
1055                    target,
1056                    at: m.at_ms,
1057                    span: None,
1058                    detail: None,
1059                    suppressed: false,
1060                }
1061            })
1062            .collect();
1063        let before = out.convictions.len();
1064        push_tiered(rule, lp, subject, Scope::PerWindow, idx as u8, hits, rung, citations, out);
1065        if let Some(c) = out.convictions.get_mut(before) {
1066            c.evidence = evidence;
1067        }
1068    }
1069}
1070
1071#[allow(clippy::too_many_arguments)]
1072fn push_tiered(
1073    rule: &Rule,
1074    lp: &LoadedPolicy,
1075    subject: SubjectId,
1076    scope: Scope,
1077    rung_idx: u8,
1078    hits: u32,
1079    rung: &Rung,
1080    mut citations: Vec<Citation>,
1081    out: &mut RuleOutcome,
1082) {
1083    citations.sort_by(|a, b| a.id.0.cmp(&b.id.0));
1084    citations.dedup_by(|a, b| a.id == b.id);
1085    // Counts and timestamps come from the FULL set, so a truncated exhibit list
1086    // never reads as fewer offenses nor flips `retroactive`.
1087    let citation_count = citations.len() as u32;
1088    let earliest = citations.iter().map(|c| c.at).min().unwrap_or(0);
1089    let latest = citations.iter().map(|c| c.at).max().unwrap_or(0);
1090    // Pre-Phase-4 there is no signed activation, so retroactivity is Unknown —
1091    // never `No`, which a locally-set activation time would fake.
1092    let retroactive = match lp.activated_at {
1093        None => Retroactive::Unknown,
1094        Some(activated) => {
1095            if citations.is_empty() {
1096                Retroactive::Unknown
1097            } else if latest >= activated {
1098                Retroactive::No
1099            } else {
1100                Retroactive::Yes
1101            }
1102        }
1103    };
1104    citations.truncate(caps::MAX_CITATIONS_PER_CONVICTION);
1105    let ids: Vec<CitationId> = citations.iter().map(|c| c.id).collect();
1106    out.citations.extend(citations);
1107    out.convictions.push(Conviction {
1108        id: conviction_id(&lp.hash, &rule.id, scope, rung_idx, &subject),
1109        subject,
1110        rule_id: rule.id.clone(),
1111        scope,
1112        rung: rung_idx,
1113        hits,
1114        severity: rung.severity,
1115        basis: rule.matcher.basis(),
1116        tier_weight: rung.weight,
1117        retroactive,
1118        suppressed: false,
1119        folded: false,
1120        folded_into: None,
1121        combined: false,
1122        proven_combined: false,
1123        citations: ids,
1124        citation_count,
1125        earliest_citation_at: earliest,
1126        latest_citation_at: latest,
1127        family: rule.family.clone(),
1128        evidence: vec![],
1129    });
1130}
1131
1132/// Which rules a policy would refuse a send under (cooperative prevention).
1133/// Only Deterministic rules reach here — the validator already refused the rest.
1134pub fn refusing_rules(policy: &Policy) -> Vec<&Rule> {
1135    policy.rules.iter().filter(|r| r.enforcement == Enforcement::Refuse).collect()
1136}
1137
1138/// Union of a policy-level and rule-level exemption block.
1139pub fn merged_exempt<'a>(policy: &'a Exempt, rule: &'a Exempt) -> Vec<&'a ExemptPatterns> {
1140    policy.patterns.iter().chain(rule.patterns.iter()).collect()
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::super::document::*;
1146    use super::*;
1147
1148    const HOUR: u64 = 3_600_000;
1149    const NOW: u64 = 1_800_000_000_000;
1150
1151    fn sid(b: u8) -> SubjectId {
1152        SubjectId([b; 32])
1153    }
1154    fn mid(b: u8) -> MessageId {
1155        MessageId([b; 32])
1156    }
1157    fn ch(b: u8) -> Hash32 {
1158        Hash32([b; 32])
1159    }
1160
1161    fn msg(id: u8, author: u8, at: u64, text: &str) -> MessageSignal {
1162        MessageSignal { id: mid(id), author: sid(author), channel: ch(0x0a), at_ms: at, text: text.into(), mentions: 0 }
1163    }
1164
1165    fn member(b: u8, joined_ago_h: Option<u64>) -> MemberSignal {
1166        MemberSignal {
1167            subject: sid(b),
1168            joined_at_ms: joined_ago_h.map(|h| NOW - h * HOUR),
1169            roles: vec![],
1170            is_staff: false,
1171            lifetime_messages: 0,
1172            first_post_ms: None,
1173        }
1174    }
1175
1176    fn signals(members: Vec<MemberSignal>, messages: Vec<MessageSignal>) -> Signals {
1177        Signals {
1178            owner: sid(0xf0),
1179            members,
1180            messages,
1181            channels: vec![ch(0x0a)],
1182            relays: vec![RelayCoverage { url: "wss://r".into(), eose: true, events: 10 }],
1183            requested_from: NOW - 24 * HOUR,
1184            requested_to: NOW,
1185            confirmed_from: 0,
1186            confirmed_to: NOW,
1187            roster_version: Hash32([0x77; 32]),
1188        }
1189    }
1190
1191    fn loaded(policy: Policy) -> LoadedPolicy {
1192        LoadedPolicy { hash: Hash32([0x11; 32]), policy, activated_at: None }
1193    }
1194
1195    fn policy_with(rules: Vec<Rule>) -> Policy {
1196        Policy {
1197            format: FORMAT,
1198            requires: vec![],
1199            name: "p".into(),
1200            emoji_codes: vec![],
1201            shields: Shields::default(),
1202            window: Window::default(),
1203            exempt: Exempt::default(),
1204            rules,
1205        }
1206    }
1207
1208    fn link_rule() -> Rule {
1209        Rule {
1210            id: "links".into(),
1211            matcher: Match::Link { patterns: vec![] },
1212            tiers: Some(Tiers {
1213                per_message: vec![Rung { hits: 1, severity: Severity::Severe, weight: 70, pierces_trusted: false }],
1214                per_window: vec![Rung { hits: 3, severity: Severity::Severe, weight: 90, pierces_trusted: false }],
1215            }),
1216            severity: None,
1217            weight: None,
1218            pierces_trusted: false,
1219            family: None,
1220            armed_by: None,
1221            exempt: Exempt {
1222                patterns: vec![ExemptPatterns {
1223                    kind: Some(ExemptKind::Domain),
1224                    values: vec!["vectorapp.io".into()],
1225                }],
1226                ..Default::default()
1227            },
1228            enforcement: Enforcement::Advisory,
1229        }
1230    }
1231
1232    fn only(report: &ModerationReport) -> &PolicyReport {
1233        &report.policies[0]
1234    }
1235
1236    fn subject<'a>(pr: &'a PolicyReport, b: u8) -> Option<&'a SubjectReport> {
1237        pr.subjects.iter().find(|s| s.subject == sid(b))
1238    }
1239
1240    /// The design's Strict Link Blocker: three links fire BOTH scopes, and the
1241    /// numbers must match the frozen reference (70 + 90 -> 970pm -> 97).
1242    #[test]
1243    fn strict_link_blocker_reproduces_the_reference_vector() {
1244        let s = signals(
1245            vec![member(1, Some(2))],
1246            vec![
1247                msg(0xb1, 1, NOW - 3 * HOUR, "grab it at bit.ly/a"),
1248                msg(0xb2, 1, NOW - 2 * HOUR, "also tr.ee/b"),
1249                msg(0xb3, 1, NOW - HOUR, "and shm.to/c"),
1250            ],
1251        );
1252        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1253        let pr = only(&r);
1254        let sub = subject(pr, 1).expect("the linker is reported");
1255        assert_eq!((sub.confidence, sub.proven), (97, 97), "both scopes, both Deterministic");
1256        assert_eq!(sub.band, Band::Alert);
1257        let scopes: Vec<Scope> = sub.convictions.iter().map(|c| c.scope).collect();
1258        assert_eq!(scopes, vec![Scope::PerMessage, Scope::PerWindow], "density and persistence both convict");
1259        assert_eq!(pr.citations.len(), 3, "one citation per offending message");
1260        assert!(pr.citations.iter().all(|c| c.span.is_none()), "a link hit is a domain, not a text span");
1261    }
1262
1263    #[test]
1264    fn an_allowlisted_domain_never_produces_a_hit() {
1265        let s = signals(
1266            vec![member(1, Some(2))],
1267            vec![msg(0xb1, 1, NOW - HOUR, "read https://vectorapp.io/blog and https://vectorapp.io/x")],
1268        );
1269        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1270        assert!(subject(only(&r), 1).is_none(), "no conviction, and no subject to report");
1271        assert!(only(&r).citations.is_empty());
1272    }
1273
1274    /// Exempt content is barred from being CITED but stays in every corpus
1275    /// statistic — the rung arithmetic counts citable hits only.
1276    #[test]
1277    fn an_exempt_channel_contributes_no_hits() {
1278        let mut s = signals(
1279            vec![member(1, Some(2))],
1280            vec![msg(0xb1, 1, NOW - HOUR, "bit.ly/a"), msg(0xb2, 1, NOW - HOUR, "tr.ee/b")],
1281        );
1282        s.messages[1].channel = ch(0x0b);
1283        let mut p = policy_with(vec![link_rule()]);
1284        p.exempt.channels = vec![crate::simd::hex::bytes_to_hex_32(&[0x0b; 32])];
1285        let r = evaluate(&s, &[loaded(p)], &[], NOW);
1286        let sub = subject(only(&r), 1).unwrap();
1287        assert_eq!(sub.convictions.len(), 1, "only the density rung is reached; two links would have been needed");
1288        assert_eq!(sub.convictions[0].scope, Scope::PerMessage);
1289        assert_eq!(only(&r).citations.len(), 1, "the exempt channel's message is never cited");
1290    }
1291
1292    #[test]
1293    fn the_soft_swear_ladder_lands_in_review_not_alert() {
1294        let keyword = Rule {
1295            id: "swears".into(),
1296            matcher: Match::Keyword { patterns: vec!["darn".into()], normalize: Normalize::Fold },
1297            tiers: Some(Tiers {
1298                per_message: vec![Rung { hits: 1, severity: Severity::Minor, weight: 10, pierces_trusted: false }],
1299                per_window: vec![Rung { hits: 10, severity: Severity::Severe, weight: 70, pierces_trusted: false }],
1300            }),
1301            severity: None,
1302            weight: None,
1303            pierces_trusted: false,
1304            family: None,
1305            armed_by: None,
1306            exempt: Exempt::default(),
1307            enforcement: Enforcement::Advisory,
1308        };
1309        let messages: Vec<MessageSignal> =
1310            (0..10).map(|i| msg(0xc0 + i as u8, 1, NOW - (i + 1) * HOUR, "darn it")).collect();
1311        let s = signals(vec![member(1, Some(2))], messages);
1312        let r = evaluate(&s, &[loaded(policy_with(vec![keyword]))], &[], NOW);
1313        let sub = subject(only(&r), 1).unwrap();
1314        // 10 + 70 -> 730pm -> 73: the soft policy peaks at human review.
1315        assert_eq!(sub.confidence, 73);
1316        assert_eq!(sub.band, Band::Flagged);
1317    }
1318
1319    /// Shields gate before conviction: Protected is absolute, Trusted yields
1320    /// only to a piercing rung, Indeterminate never gates.
1321    #[test]
1322    fn shields_gate_exactly_as_declared() {
1323        let mut s = signals(
1324            vec![
1325                member(1, Some(24 * 30)), // long tenure, will be Trusted
1326                member(2, None),          // no join, no posts elsewhere: Indeterminate
1327                MemberSignal { subject: sid(3), joined_at_ms: Some(NOW - HOUR), roles: vec![], is_staff: true, lifetime_messages: 0, first_post_ms: None },
1328            ],
1329            vec![],
1330        );
1331        // Give the veteran enough varied history to clear the trust bar. The
1332        // text must be genuinely varied: `skeleton` strips digits, so numbered
1333        // fixtures collapse to ONE distinct shape and clear nothing.
1334        const WORDS: [&str; 20] = [
1335            "morning all", "shipping today", "nice one", "agreed", "on it", "good catch", "thanks",
1336            "will review", "merged", "looks fine", "same here", "no idea", "try again", "fixed now",
1337            "welcome aboard", "see you", "sounds good", "my turn", "any thoughts", "done",
1338        ];
1339        for (i, w) in WORDS.iter().enumerate() {
1340            s.messages.push(msg(0x40 + i as u8, 1, NOW - (i as u64 + 1) * HOUR, w));
1341        }
1342        s.messages.push(msg(0xd1, 1, NOW - HOUR, "bit.ly/a"));
1343        s.messages.push(msg(0xd2, 2, NOW - HOUR, "bit.ly/b"));
1344        s.messages.push(msg(0xd3, 3, NOW - HOUR, "bit.ly/c"));
1345
1346        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1347        let pr = only(&r);
1348        assert_eq!(subject(pr, 1).unwrap().shield, Shield::Trusted);
1349        assert!(subject(pr, 1).unwrap().convictions.is_empty(), "a non-piercing rule cannot touch Trusted");
1350        // A member with no Join but who HAS posted has a known tenure (the
1351        // oldest trace is their first post), so they are simply un-Trusted.
1352        assert_eq!(subject(pr, 2).unwrap().shield, Shield::None);
1353        assert!(!subject(pr, 2).unwrap().convictions.is_empty(), "no shield, ordinary conviction");
1354        assert_eq!(subject(pr, 3).unwrap().shield, Shield::Protected);
1355        assert!(subject(pr, 3).unwrap().convictions.is_empty(), "staff are never convicted");
1356
1357        // The same evidence WITH a piercing rung reaches the Trusted member.
1358        let mut piercing = link_rule();
1359        piercing.tiers.as_mut().unwrap().per_message[0].pierces_trusted = true;
1360        piercing.pierces_trusted = true;
1361        let r = evaluate(&s, &[loaded(policy_with(vec![piercing]))], &[], NOW);
1362        assert!(!subject(only(&r), 1).unwrap().convictions.is_empty(), "a piercing rung reaches Trusted");
1363        assert!(subject(only(&r), 3).unwrap().convictions.is_empty(), "but nothing pierces Protected");
1364    }
1365
1366    /// Standing has three doors, and none of them opens in a day.
1367    #[test]
1368    fn trust_comes_from_roles_tenure_or_sustained_activity() {
1369        let day = 24 * HOUR;
1370        // A role the community granted is a vouch: trusted, though NOT immune
1371        // (that is Protected, and it keys on moderation permissions).
1372        let with_role =
1373            MemberSignal { subject: sid(1), joined_at_ms: Some(NOW - day), roles: vec![ch(0xaa)], is_staff: false, lifetime_messages: 0, first_post_ms: None };
1374        let veteran = member(2, Some(24 * 40));
1375        let active = member(3, Some(24 * 8));
1376        let lurker = member(4, Some(24 * 8));
1377        let loud_newcomer = member(5, Some(1));
1378
1379        let mut s = signals(vec![with_role, veteran, active, lurker, loud_newcomer], vec![]);
1380        s.messages.push(msg(0x50, 2, NOW - HOUR, "still here"));
1381        const VARIED: [&str; 5] = ["morning all", "shipping today", "nice one", "agreed", "on it"];
1382        for (i, w) in VARIED.iter().enumerate() {
1383            s.messages.push(msg(0x60 + i as u8, 3, NOW - (i as u64 + 1) * HOUR, w));
1384            s.messages.push(msg(0x70 + i as u8, 5, NOW - (i as u64 + 1) * HOUR, w));
1385        }
1386
1387        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1388        let pr = only(&r);
1389        let shield = |b: u8| subject(pr, b).map(|x| x.shield);
1390        assert_eq!(shield(1), Some(Shield::Trusted), "a granted role vouches");
1391        assert_eq!(shield(2), Some(Shield::Trusted), "long tenure plus any activity");
1392        assert_eq!(shield(3), Some(Shield::Trusted), "tenure with volume and variety");
1393        assert_eq!(shield(4), None, "a silent week earns nothing");
1394        assert_eq!(shield(5), None, "and chatter cannot buy standing in a day");
1395    }
1396
1397    /// A regular of months who had a quiet week keeps their standing: volume is
1398    /// lifetime, not "what have you said for me lately".
1399    #[test]
1400    fn a_quiet_week_does_not_cost_a_regular_their_standing() {
1401        let mut veteran = member(6, Some(24 * 10));
1402        veteran.lifetime_messages = 900;
1403        let mut newcomer = member(7, Some(2));
1404        newcomer.lifetime_messages = 900; // history it could not possibly have
1405        let mut s = signals(vec![veteran, newcomer], vec![]);
1406        // Three distinct shapes this week is all the variety bar asks for.
1407        for (i, w) in ["morning all", "shipping today", "nice one"].iter().enumerate() {
1408            s.messages.push(msg(0x80 + i as u8, 6, NOW - (i as u64 + 1) * HOUR, w));
1409            s.messages.push(msg(0x90 + i as u8, 7, NOW - (i as u64 + 1) * HOUR, w));
1410        }
1411        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1412        let pr = only(&r);
1413        assert_eq!(subject(pr, 6).map(|x| x.shield), Some(Shield::Trusted), "ten days and a long history");
1414        assert_eq!(subject(pr, 7).map(|x| x.shield), None, "tenure still gates: two hours buys nothing");
1415    }
1416
1417    /// The fifth member to join, very active early, then quiet for months.
1418    /// Still trusted: standing is what you built, not what you said this week.
1419    #[test]
1420    fn an_early_member_who_lurks_for_months_keeps_their_standing() {
1421        let mut lurker = member(8, Some(24 * 200));
1422        lurker.lifetime_messages = 400; // all of it long ago
1423        let s = signals(vec![lurker], vec![]); // nothing at all in the window
1424        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1425        assert_eq!(subject(only(&r), 8).map(|x| x.shield), Some(Shield::Trusted));
1426    }
1427
1428    /// A member's client judges word and link filters on the message in front
1429    /// of it, and nothing that needs history. It says which is which, because a
1430    /// silent skip would read as "clean".
1431    fn cohort_rule_doc() -> Rule {
1432        Rule {
1433            id: "cohort".into(),
1434            matcher: Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
1435            tiers: None,
1436            severity: Some(Severity::Severe),
1437            weight: Some(85),
1438            pierces_trusted: false,
1439            family: None,
1440            armed_by: None,
1441            exempt: Exempt::default(),
1442            enforcement: Enforcement::Advisory,
1443        }
1444    }
1445
1446    /// The shape of the 2026-08-19 raid: a hundred fresh identities, one line
1447    /// each. This is the test the whole engine exists to pass.
1448    #[test]
1449    fn a_hundred_fresh_accounts_saying_one_thing_is_a_raid() {
1450        let mut members: Vec<MemberSignal> = (0..100).map(|i| member(i as u8 + 10, Some(1))).collect();
1451        let mut messages: Vec<MessageSignal> =
1452            (0..100).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "hello world")).collect();
1453        // A regular with real history, saying the same words in passing.
1454        members.push(member(2, Some(24 * 60)));
1455        members.last_mut().unwrap().lifetime_messages = 800;
1456        messages.push(msg(0xf1, 2, NOW - 2 * HOUR, "hello world"));
1457        for (i, w) in ["morning all", "shipping today", "nice one"].iter().enumerate() {
1458            messages.push(msg(0xf2 + i as u8, 2, NOW - (i as u64 + 3) * HOUR, w));
1459        }
1460
1461        let s = signals(members, messages);
1462        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc()]))], &[], NOW);
1463        let pr = only(&r);
1464        let convicted: Vec<&SubjectReport> =
1465            pr.subjects.iter().filter(|x| x.convictions.iter().any(|c| !c.suppressed)).collect();
1466        assert_eq!(convicted.len(), 100, "every raider, and only the raiders");
1467        assert!(convicted.iter().all(|x| x.confidence == 85 && x.band == Band::Alert));
1468        // Heuristic evidence never reaches `proven`: a human still decides.
1469        assert!(convicted.iter().all(|x| x.proven == 0), "a cohort is inference, not proof");
1470        assert_eq!(subject(pr, 2).map(|x| x.shield), Some(Shield::Trusted), "the regular is shielded, not convicted");
1471        assert!(subject(pr, 2).unwrap().convictions.is_empty());
1472
1473        let ev = &convicted[0].convictions[0].evidence;
1474        assert!(
1475            matches!(ev.first(), Some(Evidence::Cohort { size: 101, .. })),
1476            "the exhibit names the true cluster size: {ev:?}"
1477        );
1478    }
1479
1480    /// The corpus outlives membership: a banned raider's messages sit in local
1481    /// storage long after the rotation removed them.
1482    #[test]
1483    fn someone_already_removed_is_never_convicted_again() {
1484        // Twenty identities posted one line each; only three are still members.
1485        let members: Vec<MemberSignal> = (0..3).map(|i| member(i as u8 + 10, Some(1))).collect();
1486        let messages: Vec<MessageSignal> =
1487            (0..20).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "free airdrop claim now")).collect();
1488        let s = signals(members, messages);
1489        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc()]))], &[], NOW);
1490        let pr = only(&r);
1491        assert_eq!(pr.subjects.len(), 3, "the seventeen who are gone are not re-litigated");
1492        assert!(pr.subjects.iter().all(|x| !x.convictions.is_empty()), "the three still here are convicted");
1493        // The cluster's true size still counts all twenty: exemption and
1494        // absence change who can be accused, never what the community saw.
1495        let ev = &pr.subjects[0].convictions[0].evidence;
1496        assert!(matches!(ev.first(), Some(Evidence::Cohort { size: 20, .. })), "{ev:?}");
1497    }
1498
1499    /// Arming asks whether a raid is happening HERE and NOW, not whether one
1500    /// ever happened: a cohort of accounts that are all gone must not arm a
1501    /// burst rule against whoever is still present.
1502    #[test]
1503    fn a_past_raid_does_not_arm_a_rule_against_the_living() {
1504        let mut burst = Rule {
1505            id: "burst".into(),
1506            matcher: Match::JoinBurst { gap_secs: 600, min: 5 },
1507            tiers: None,
1508            severity: Some(Severity::Major),
1509            weight: Some(40),
1510            pierces_trusted: false,
1511            family: None,
1512            armed_by: Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] }),
1513            exempt: Exempt::default(),
1514            enforcement: Enforcement::Advisory,
1515        };
1516        // Twenty raiders posted one line each and were removed; six ordinary
1517        // members remain, all of whom joined in one burst.
1518        let members: Vec<MemberSignal> = (0..6)
1519            .map(|i| MemberSignal {
1520                subject: sid(i as u8 + 40),
1521                joined_at_ms: Some(NOW - HOUR + i * 30_000),
1522                roles: vec![],
1523                is_staff: false,
1524                lifetime_messages: 0,
1525                first_post_ms: None,
1526            })
1527            .collect();
1528        let messages: Vec<MessageSignal> =
1529            (0..20).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "free airdrop claim now")).collect();
1530        let s = signals(members, messages);
1531        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc(), burst.clone()]))], &[], NOW);
1532        assert_eq!(
1533            only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count(),
1534            0,
1535            "the cohort is entirely gone, so it arms nothing against the living"
1536        );
1537
1538        // The same burst DOES fire when the cohort is among the current members.
1539        burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] });
1540        let mut members: Vec<MemberSignal> = (0..6)
1541            .map(|i| MemberSignal {
1542                subject: sid(i as u8 + 40),
1543                joined_at_ms: Some(NOW - HOUR + i * 30_000),
1544                roles: vec![],
1545                is_staff: false,
1546                lifetime_messages: 0,
1547                first_post_ms: None,
1548            })
1549            .collect();
1550        members.extend((0..20).map(|i| member(i as u8 + 10, Some(1))));
1551        let messages: Vec<MessageSignal> =
1552            (0..20).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "free airdrop claim now")).collect();
1553        let s = signals(members, messages);
1554        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc(), burst]))], &[], NOW);
1555        let convicted = only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count();
1556        assert!(convicted >= 20, "a live cohort arms it: {convicted}");
1557    }
1558
1559    /// Size alone must never convict: a catchphrase everyone repeats is a
1560    /// community, and the thinness bar is what tells it from a script.
1561    #[test]
1562    fn a_community_catchphrase_is_not_a_cohort() {
1563        let members: Vec<MemberSignal> = (0..20)
1564            .map(|i| {
1565                let mut m = member(i as u8 + 10, Some(24 * 60));
1566                m.lifetime_messages = 500;
1567                m
1568            })
1569            .collect();
1570        let mut messages: Vec<MessageSignal> = Vec::new();
1571        for i in 0..20u8 {
1572            // Everyone says it — and everyone also says plenty else.
1573            messages.push(msg(0x20 + i, i + 10, NOW - HOUR, "good morning everyone"));
1574            for (j, w) in ["shipping today", "nice one", "agreed", "on it"].iter().enumerate() {
1575                messages.push(msg(0x60 + i * 4 + j as u8, i + 10, NOW - (j as u64 + 2) * HOUR, w));
1576            }
1577        }
1578        let s = signals(members, messages);
1579        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc()]))], &[], NOW);
1580        let convicted = only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count();
1581        assert_eq!(convicted, 0, "twenty chatty regulars sharing a greeting are not a raid");
1582    }
1583
1584    /// A burst of quiet newcomers is what a freshly-posted invite link LOOKS
1585    /// like, so it convicts nobody until a cohort already has.
1586    #[test]
1587    fn an_invite_link_burst_convicts_nobody_on_its_own() {
1588        let mut burst = Rule {
1589            id: "burst".into(),
1590            matcher: Match::JoinBurst { gap_secs: 600, min: 5 },
1591            tiers: None,
1592            severity: Some(Severity::Major),
1593            weight: Some(40),
1594            pierces_trusted: false,
1595            family: None,
1596            armed_by: None,
1597            exempt: Exempt::default(),
1598            enforcement: Enforcement::Advisory,
1599        };
1600        // Ten people joined within a few minutes and said nothing alike.
1601        let members: Vec<MemberSignal> = (0..10)
1602            .map(|i| MemberSignal {
1603                subject: sid(i as u8 + 10),
1604                joined_at_ms: Some(NOW - HOUR + i * 30_000),
1605                roles: vec![],
1606                is_staff: false,
1607                lifetime_messages: 0,
1608                first_post_ms: None,
1609            })
1610            .collect();
1611        let s = signals(members, vec![]);
1612
1613        // Unarmed, the burst convicts them all — which is why the built-in
1614        // policy never ships it that way.
1615        let r = evaluate(&s, &[loaded(policy_with(vec![burst.clone()]))], &[], NOW);
1616        assert_eq!(only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count(), 10);
1617
1618        // Armed by a cohort that never fired, it convicts nobody.
1619        burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] });
1620        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc(), burst]))], &[], NOW);
1621        assert_eq!(
1622            only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count(),
1623            0,
1624            "no cohort, no conviction: an invite link is not an attack"
1625        );
1626    }
1627
1628    #[test]
1629    fn a_member_evaluates_only_stateless_rules() {
1630        let stateful = Rule {
1631            id: "quiet".into(),
1632            matcher: Match::MessagesLte { n: 2 },
1633            tiers: None,
1634            severity: Some(Severity::Notice),
1635            weight: Some(10),
1636            pierces_trusted: false,
1637            family: None,
1638            armed_by: None,
1639            exempt: Exempt::default(),
1640            enforcement: Enforcement::Advisory,
1641        };
1642        let s = signals(vec![member(1, Some(2))], vec![msg(0xb1, 1, NOW - HOUR, "bit.ly/a")]);
1643        let policies = [loaded(policy_with(vec![link_rule(), stateful]))];
1644
1645        let as_member = evaluate_as(&s, &policies, &[], NOW, EvalMode::Member);
1646        let pr = &as_member.policies[0];
1647        let state = |id: &str| pr.rule_status.iter().find(|r| r.rule_id == id).map(|r| r.state);
1648        assert_eq!(state("links"), Some(RuleState::Evaluated), "a link filter needs no history");
1649        assert_eq!(state("quiet"), Some(RuleState::RequiresHistory), "counting a member's past does");
1650        let sub = subject(pr, 1).unwrap();
1651        assert_eq!(sub.convictions.len(), 1, "the link conviction stands on its own");
1652
1653        // The same evidence, evaluated by an admin, runs everything.
1654        let as_admin = evaluate_as(&s, &policies, &[], NOW, EvalMode::Admin);
1655        let pr = &as_admin.policies[0];
1656        assert!(pr.rule_status.iter().all(|r| r.state == RuleState::Evaluated));
1657        assert_eq!(subject(pr, 1).unwrap().convictions.len(), 2);
1658    }
1659
1660    #[test]
1661    fn fresh_account_aggravators_fold_to_one() {
1662        let tenure = Rule {
1663            id: "tenure".into(),
1664            matcher: Match::TenureLt { secs: 24 * 3600 },
1665            tiers: None,
1666            severity: Some(Severity::Notice),
1667            weight: Some(20),
1668            pierces_trusted: false,
1669            family: None,
1670            armed_by: None,
1671            exempt: Exempt::default(),
1672            enforcement: Enforcement::Advisory,
1673        };
1674        let quiet = Rule {
1675            id: "quiet".into(),
1676            matcher: Match::MessagesLte { n: 2 },
1677            tiers: None,
1678            severity: Some(Severity::Notice),
1679            weight: Some(10),
1680            pierces_trusted: false,
1681            family: None,
1682            armed_by: None,
1683            exempt: Exempt::default(),
1684            enforcement: Enforcement::Advisory,
1685        };
1686        let s = signals(vec![member(1, Some(2))], vec![msg(0xb1, 1, NOW - HOUR, "hello world")]);
1687        let r = evaluate(&s, &[loaded(policy_with(vec![tenure, quiet]))], &[], NOW);
1688        let sub = subject(only(&r), 1).unwrap();
1689        // Two correlated proxies for "new account" fold to the strongest: 20,
1690        // not 20-OR-10 = 28.
1691        assert_eq!(sub.confidence, 20);
1692        let folded = sub.convictions.iter().find(|c| c.rule_id == "quiet").unwrap();
1693        assert!(folded.folded && !folded.combined);
1694        assert_eq!(sub.convictions.iter().filter(|c| c.combined).count(), 1);
1695    }
1696
1697    #[test]
1698    fn unknown_tenure_is_never_a_conviction() {
1699        let tenure = Rule {
1700            id: "tenure".into(),
1701            matcher: Match::TenureLt { secs: 24 * 3600 },
1702            tiers: None,
1703            severity: Some(Severity::Notice),
1704            weight: Some(20),
1705            pierces_trusted: false,
1706            family: None,
1707            armed_by: None,
1708            exempt: Exempt::default(),
1709            enforcement: Enforcement::Advisory,
1710        };
1711        let s = signals(vec![member(9, None)], vec![]);
1712        let r = evaluate(&s, &[loaded(policy_with(vec![tenure]))], &[], NOW);
1713        let pr = only(&r);
1714        let status = pr.rule_status.iter().find(|s| s.rule_id == "tenure").unwrap();
1715        assert_eq!(status.unknown_subjects, vec![sid(9)], "per-subject unknown, not a community-wide block");
1716        assert!(subject(pr, 9).unwrap().convictions.is_empty());
1717    }
1718
1719    /// Indeterminate means "we could not establish tenure" — no join AND no
1720    /// posts — and it is informational: the subject is judged as if unshielded.
1721    #[test]
1722    fn indeterminate_tenure_never_gates_a_conviction() {
1723        let quiet = Rule {
1724            id: "quiet".into(),
1725            matcher: Match::MessagesLte { n: 2 },
1726            tiers: None,
1727            severity: Some(Severity::Notice),
1728            weight: Some(10),
1729            pierces_trusted: false,
1730            family: None,
1731            armed_by: None,
1732            exempt: Exempt::default(),
1733            enforcement: Enforcement::Advisory,
1734        };
1735        let s = signals(vec![member(7, None)], vec![]);
1736        let r = evaluate(&s, &[loaded(policy_with(vec![quiet]))], &[], NOW);
1737        let sub = subject(only(&r), 7).unwrap();
1738        assert_eq!(sub.shield, Shield::Indeterminate, "no join, no posts: unknowable");
1739        assert_eq!(sub.confidence, 10, "and judged exactly as if unshielded");
1740    }
1741
1742    fn window_rule_policy(m: Match, rungs: Vec<Rung>) -> Policy {
1743        policy_with(vec![Rule {
1744            id: "w".into(),
1745            matcher: m,
1746            tiers: Some(Tiers { per_message: vec![], per_window: rungs }),
1747            severity: None,
1748            weight: None,
1749            pierces_trusted: false,
1750            family: None,
1751            armed_by: None,
1752            exempt: Exempt::default(),
1753            enforcement: Enforcement::Advisory,
1754        }])
1755    }
1756
1757    /// One account pasting the same line over and over — the counterpart to
1758    /// `cohort`, which catches many accounts sharing one line.
1759    #[test]
1760    fn repeat_catches_one_account_pasting_the_same_thing() {
1761        let mut messages: Vec<MessageSignal> = (0..5)
1762            .map(|i| msg(0xa0 + i as u8, 1, NOW - (i + 1) * HOUR, "CLAIM YOUR FREE AIRDROP NOW"))
1763            .collect();
1764        // Variations a spammer reaches for first: case, punctuation, digits.
1765        messages.push(msg(0xb0, 1, NOW - 6 * HOUR, "claim your free airdrop now!!!"));
1766        messages.push(msg(0xb1, 1, NOW - 7 * HOUR, "hello everyone"));
1767        let s = signals(vec![member(1, Some(2))], messages);
1768        let p = window_rule_policy(
1769            Match::Repeat { normalize: Normalize::Skeleton, within_secs: None },
1770            vec![Rung { hits: 3, severity: Severity::Major, weight: 50, pierces_trusted: false }],
1771        );
1772        let r = evaluate(&s, &[loaded(p)], &[], NOW);
1773        let sub = subject(only(&r), 1).unwrap();
1774        let c = &sub.convictions[0];
1775        assert_eq!(c.scope, Scope::PerWindow);
1776        assert_eq!(c.hits, 6, "case and punctuation are one line of attacker code, so they do not vary the shape");
1777        assert_eq!(c.citation_count, 6, "every repetition is cited, not just the ones past the rung");
1778        assert_eq!(sub.confidence, 50);
1779    }
1780
1781    #[test]
1782    fn rate_finds_the_densest_span_and_cites_only_it() {
1783        // Five in a burst, then one much later: the burst is the evidence.
1784        let mut messages: Vec<MessageSignal> =
1785            (0..5).map(|i| msg(0xc0 + i as u8, 1, NOW - 60 * 60 * 1000 + i * 10_000, "spam")).collect();
1786        messages.push(msg(0xcf, 1, NOW - 10 * 60 * 1000, "unrelated"));
1787        let s = signals(vec![member(1, Some(2))], messages);
1788        let p = window_rule_policy(
1789            Match::Rate { per_secs: 60 },
1790            vec![Rung { hits: 5, severity: Severity::Major, weight: 55, pierces_trusted: false }],
1791        );
1792        let r = evaluate(&s, &[loaded(p)], &[], NOW);
1793        let sub = subject(only(&r), 1).unwrap();
1794        let c = &sub.convictions[0];
1795        assert_eq!(c.hits, 5, "five inside one minute");
1796        assert_eq!(c.citation_count, 5, "the straggler is not part of the burst");
1797        assert!(
1798            matches!(c.evidence.first(), Some(Evidence::Rate { count: 5, window_secs: 60, .. })),
1799            "the span is reported as evidence: {:?}",
1800            c.evidence
1801        );
1802    }
1803
1804    #[test]
1805    fn a_pardon_suppresses_the_conviction_and_its_citations() {
1806        let s = signals(
1807            vec![member(1, Some(2))],
1808            vec![
1809                msg(0xb1, 1, NOW - 3 * HOUR, "bit.ly/a"),
1810                msg(0xb2, 1, NOW - 2 * HOUR, "tr.ee/b"),
1811                msg(0xb3, 1, NOW - HOUR, "shm.to/c"),
1812            ],
1813        );
1814        let policy = policy_with(vec![link_rule()]);
1815        let lp = loaded(policy);
1816        let pardon = Override {
1817            target: OverrideTarget::Rule {
1818                policy_hash: lp.hash,
1819                rule_id: "links".into(),
1820                scope: Scope::PerMessage,
1821                subject: sid(1),
1822            },
1823            issuer: sid(0xf0),
1824            issued_at: NOW - HOUR,
1825            expires_at: NOW + HOUR,
1826        };
1827        let r = evaluate(&s, &[lp], &[pardon], NOW);
1828        let pr = only(&r);
1829        let sub = subject(pr, 1).unwrap();
1830        let pardoned = sub.convictions.iter().find(|c| c.scope == Scope::PerMessage).unwrap();
1831        assert!(pardoned.suppressed && !pardoned.combined, "reported, never combined");
1832        // The pardoned conviction's citations are suppressed for the content
1833        // consumer; the surviving per-window conviction keeps citing them.
1834        assert!(pr.citations.iter().any(|c| c.suppressed), "a pardon reaches the content consumer too");
1835        // The per-window conviction was NOT pardoned (scope is part of the key).
1836        assert!(!sub.convictions.iter().find(|c| c.scope == Scope::PerWindow).unwrap().suppressed);
1837    }
1838
1839    #[test]
1840    fn an_inert_policy_reports_why_and_convicts_nothing() {
1841        let mut p = policy_with(vec![link_rule()]);
1842        p.requires = vec!["quarantine".into()];
1843        let r = evaluate(&signals(vec![member(1, Some(2))], vec![]), &[loaded(p)], &[], NOW);
1844        let pr = only(&r);
1845        assert!(matches!(&pr.inert, Some(InertReason::UnknownRequiredKey { key }) if key == "quarantine"));
1846        assert!(pr.subjects.is_empty() && pr.rule_status.is_empty());
1847    }
1848
1849    #[test]
1850    fn evaluation_is_pure_and_ordering_is_canonical() {
1851        let s = signals(
1852            vec![member(3, Some(2)), member(1, Some(2)), member(2, Some(2))],
1853            vec![
1854                msg(0xb3, 3, NOW - HOUR, "bit.ly/c"),
1855                msg(0xb1, 1, NOW - 2 * HOUR, "bit.ly/a"),
1856                msg(0xb2, 2, NOW - 3 * HOUR, "bit.ly/b"),
1857            ],
1858        );
1859        let a = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1860        let b = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1861        assert_eq!(a, b, "same inputs, same report");
1862        let ids: Vec<[u8; 32]> = only(&a).subjects.iter().map(|s| s.subject.0).collect();
1863        let mut sorted = ids.clone();
1864        sorted.sort();
1865        assert_eq!(ids, sorted, "subjects sort by canonical id");
1866        let cits: Vec<[u8; 32]> = only(&a).citations.iter().map(|c| c.id.0).collect();
1867        let mut cs = cits.clone();
1868        cs.sort();
1869        assert_eq!(cits, cs, "citations sort by canonical id");
1870    }
1871
1872    #[test]
1873    fn the_window_clamps_by_recency_and_count() {
1874        let mut p = policy_with(vec![link_rule()]);
1875        p.window = Window { hours: 2, max_messages: 2 };
1876        let s = signals(
1877            vec![member(1, Some(48))],
1878            vec![
1879                msg(0xb0, 1, NOW - 10 * HOUR, "bit.ly/old"), // outside the window
1880                msg(0xb1, 1, NOW - 90 * 60 * 1000, "bit.ly/a"),
1881                msg(0xb2, 1, NOW - 60 * 60 * 1000, "bit.ly/b"),
1882                msg(0xb3, 1, NOW - 30 * 60 * 1000, "bit.ly/c"),
1883            ],
1884        );
1885        let r = evaluate(&s, &[loaded(p)], &[], NOW);
1886        assert_eq!(only(&r).citations.len(), 2, "newest two inside the window; the older two never enter the corpus");
1887    }
1888
1889    fn word_rule(pattern: &str) -> Rule {
1890        Rule {
1891            id: "words".into(),
1892            matcher: Match::Keyword { patterns: vec![pattern.into()], normalize: Normalize::Fold },
1893            tiers: Some(Tiers {
1894                per_message: vec![Rung { hits: 1, severity: Severity::Minor, weight: 10, pierces_trusted: false }],
1895                per_window: vec![],
1896            }),
1897            severity: None,
1898            weight: None,
1899            pierces_trusted: false,
1900            family: None,
1901            armed_by: None,
1902            exempt: Exempt::default(),
1903            enforcement: Enforcement::Advisory,
1904        }
1905    }
1906
1907    fn hits_for(pattern: &str, text: &str) -> usize {
1908        let s = signals(vec![member(1, Some(2))], vec![msg(0xd1, 1, NOW - HOUR, text)]);
1909        let r = evaluate(&s, &[loaded(policy_with(vec![word_rule(pattern)]))], &[], NOW);
1910        only(&r).subjects.len()
1911    }
1912
1913    /// A capitalised word matched nothing at all: the corpus was casefolded and
1914    /// the pattern was not, so the two could never meet. Silent, and the filter
1915    /// looked like it was simply finding nothing in a clean community.
1916    #[test]
1917    fn a_pattern_matches_in_the_same_case_space_as_the_text() {
1918        for pattern in ["Vector", "VECTOR", "vector", "VeCtOr"] {
1919            assert_eq!(hits_for(pattern, "we should ship vector today"), 1, "pattern {pattern:?} caught nothing");
1920        }
1921    }
1922
1923    /// Casefolding the pattern must not quietly turn a whole-word rule into a
1924    /// substring one: token anchoring is the whole reason folding is safe.
1925    #[test]
1926    fn folding_a_pattern_keeps_its_word_anchoring() {
1927        assert_eq!(hits_for("Art", "we should start today"), 0, "\"Art\" must not match inside \"start\"");
1928        assert_eq!(hits_for("*Art*", "we should start today"), 1, "an explicit wildcard still matches inside");
1929    }
1930
1931    fn repeat_policy(within_secs: Option<u64>) -> Policy {
1932        let mut p = window_rule_policy(
1933            Match::Repeat { normalize: Normalize::Skeleton, within_secs },
1934            vec![
1935                Rung { hits: 4, severity: Severity::Major, weight: 50, pierces_trusted: false },
1936                Rung { hits: 8, severity: Severity::Severe, weight: 85, pierces_trusted: false },
1937            ],
1938        );
1939        // The shipped window: the point is that a WEEK of history is in scope
1940        // and the burst span is what narrows it.
1941        p.window = Window { hours: 168, max_messages: 4000 };
1942        p
1943    }
1944
1945    fn repeat_hits(within_secs: Option<u64>, at_hours: &[u64]) -> usize {
1946        let msgs: Vec<MessageSignal> = at_hours
1947            .iter()
1948            .enumerate()
1949            .map(|(i, h)| msg(0xe0 + i as u8, 1, NOW - h * HOUR, "gm"))
1950            .collect();
1951        // No shield: a brand-new member is exactly who this must not misjudge.
1952        let s = signals(vec![member(1, Some(1))], msgs);
1953        let r = evaluate(&s, &[loaded(repeat_policy(within_secs))], &[], NOW);
1954        only(&r).subjects.len()
1955    }
1956
1957    /// A regular saying "gm" once a morning is not a spammer, and counting
1958    /// across the whole seven-day window could not tell the two apart: eight
1959    /// mornings read exactly like eight messages in a minute.
1960    #[test]
1961    fn a_daily_greeting_is_not_a_repeat_burst() {
1962        let mornings: Vec<u64> = (0..8).map(|d| d * 24 + 1).collect();
1963        assert_eq!(repeat_hits(None, &mornings), 1, "the unbounded rule convicts a regular");
1964        assert_eq!(
1965            repeat_hits(Some(super::super::harness::REPEAT_BURST_SECS), &mornings),
1966            0,
1967            "a burst-bounded rule must leave a daily greeting alone"
1968        );
1969    }
1970
1971    /// The thing it is actually for: the same line over and over inside minutes.
1972    #[test]
1973    fn a_burst_of_the_same_line_still_convicts() {
1974        let burst: Vec<u64> = vec![1, 1, 1, 1, 1, 1, 1, 1];
1975        assert_eq!(
1976            repeat_hits(Some(super::super::harness::REPEAT_BURST_SECS), &burst),
1977            1,
1978            "eight identical messages in one span must still be caught"
1979        );
1980    }
1981
1982    /// The engine convicts on EVIDENCE, and one anomaly is not evidence.
1983    ///
1984    /// A join flood alone asks for a look; a join flood that is also one script
1985    /// across many accounts is two independent sources and convicts. The gap
1986    /// between them is what keeps a popular invite link from being staged for
1987    /// removal, so it is pinned here rather than left to the weights.
1988    #[test]
1989    fn one_anomaly_asks_for_a_look_and_two_convict() {
1990        const MIN: u64 = 60 * 1000;
1991        const VARIED: [&str; 12] = [
1992            "hey everyone", "glad to be here", "what is this place",
1993            "found you through a podcast", "nice community", "hello all",
1994            "just looking around", "someone linked me here", "good morning",
1995            "how does this work", "reading the pins now", "thanks for the invite",
1996        ];
1997        // `n` strangers arriving across `span` minutes, brand new and silent but
1998        // for one message each.
1999        let wave = |n: u64, span: u64, same_text: bool| -> (usize, u32) {
2000            let members: Vec<MemberSignal> = (1..=n)
2001                .map(|i| {
2002                    let mut m = member(i as u8, Some(1));
2003                    m.joined_at_ms = Some(NOW - (span * MIN) + (i * span * MIN / n));
2004                    m.first_post_ms = None;
2005                    m.lifetime_messages = 0;
2006                    m
2007                })
2008                .collect();
2009            let msgs: Vec<MessageSignal> = (1..=n)
2010                .map(|i| {
2011                    let text = if same_text {
2012                        "join our airdrop".to_string()
2013                    } else {
2014                        VARIED[(i as usize - 1) % VARIED.len()].to_string()
2015                    };
2016                    msg(0xa0 + i as u8, i as u8, NOW - 2 * MIN, &text)
2017                })
2018                .collect();
2019            let s = signals(members, msgs);
2020            let r = evaluate(&s, &[loaded(crate::community::policy::harness::default_policy())], &[], NOW);
2021            let p = only(&r);
2022            (p.subjects.len(), p.subjects.first().map(|x| x.confidence).unwrap_or(0))
2023        };
2024
2025        // A good afternoon on an invite link: no anomaly at all.
2026        assert_eq!(wave(5, 10, false), (0, 0), "an ordinary invite wave must convict nobody");
2027
2028        // One anomaly — they arrived fast, but they are saying different things.
2029        let (caught, conf) = wave(12, 5, false);
2030        assert_eq!(caught, 12);
2031        assert!((25..50).contains(&conf), "a lone join flood reached {conf}; it must stay a look, not a verdict");
2032
2033        // Two anomalies — arrived fast AND reading from one script.
2034        let (caught, conf) = wave(12, 5, true);
2035        assert_eq!(caught, 12);
2036        assert!(conf >= 75, "a scripted flood only reached {conf}; two sources must convict");
2037
2038        // One anomaly again, the other way round: one script, arriving slowly.
2039        let (caught, conf) = wave(20, 60, true);
2040        assert_eq!(caught, 20);
2041        assert!(conf >= 75, "a scripted wave only reached {conf}");
2042    }
2043}