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_key(|a| a.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_key(|a| a.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_key(|a| a.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_key(|a| a.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_key(|a| a.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    type MessageHits = (u32, u64, Vec<Citation>);
616    let mut per_message: BTreeMap<([u8; 32], [u8; 32]), MessageHits> = BTreeMap::new();
617
618    for m in corpus {
619        // Exempt content is barred from being CITED, but stays in every corpus
620        // statistic — exemptions change who can be accused, never what the
621        // community looks like.
622        if exempt_channels.contains(&m.channel.0) || subject_exempt(&m.author) {
623            continue;
624        }
625        let (hits, citations) = match &rule.matcher {
626            Match::Keyword { normalize: n, .. } => {
627                let text = normalize::apply(&m.text, *n, codes);
628                let hits = cancel_exempt_hits(&text, keyword_hits(&text, &folded_patterns), exempts);
629                let cits = hits
630                    .iter()
631                    .map(|h| {
632                        let target = CitationTarget::Message { message_id: m.id };
633                        // Carry WHAT matched. A moderator reading "matched rule
634                        // words" learns nothing; reading the word itself can
635                        // judge in a second whether the rule is right.
636                        let matched: String = text
637                            .get(h.start..h.end)
638                            .unwrap_or_default()
639                            .chars()
640                            .take(caps::MAX_DETAIL_LEN)
641                            .collect();
642                        Citation {
643                            id: citation_id(&lp.hash, &rule.id, Scope::PerMessage, &m.author, &target, Some(h.span())),
644                            target,
645                            at: m.at_ms,
646                            span: Some(h.span()),
647                            detail: (!matched.is_empty()).then_some(matched),
648                            suppressed: false,
649                        }
650                    })
651                    .collect::<Vec<_>>();
652                (hits.len() as u32, cits)
653            }
654            Match::Link { patterns } => {
655                let domains = link_hits(&m.text, patterns, exempts);
656                let target = CitationTarget::Message { message_id: m.id };
657                // A link hit is a domain, not a text range: no span.
658                let cits = if domains.is_empty() {
659                    vec![]
660                } else {
661                    vec![Citation {
662                        id: citation_id(&lp.hash, &rule.id, Scope::PerMessage, &m.author, &target, None),
663                        target,
664                        at: m.at_ms,
665                        span: None,
666                        detail: Some(domains.join(", ")),
667                        suppressed: false,
668                    }]
669                };
670                (domains.len() as u32, cits)
671            }
672            Match::Mentions {} => {
673                let target = CitationTarget::Message { message_id: m.id };
674                let cits = if m.mentions == 0 {
675                    vec![]
676                } else {
677                    vec![Citation {
678                        id: citation_id(&lp.hash, &rule.id, Scope::PerMessage, &m.author, &target, None),
679                        target,
680                        at: m.at_ms,
681                        span: None,
682                        detail: None,
683                        suppressed: false,
684                    }]
685                };
686                (m.mentions, cits)
687            }
688            // Regex needs the pinned crate build; declared, not yet wired.
689            _ => (0, vec![]),
690        };
691        if hits > 0 {
692            per_message.insert((m.author.0, m.id.0), (hits, m.at_ms, citations));
693        }
694    }
695
696    // Per-subject: every message's own hit count, kept individually so a
697    // density rung can cite EVERY message that reached it.
698    let mut by_subject: BTreeMap<[u8; 32], Vec<(u32, Vec<Citation>)>> = BTreeMap::new();
699    for ((subject, _), (hits, _at, cits)) in per_message {
700        by_subject.entry(subject).or_default().push((hits, cits));
701    }
702
703    for (subject_bytes, messages) in by_subject {
704        let subject = SubjectId(subject_bytes);
705        // Density = the densest single message; persistence = the window sum.
706        let density: u32 = messages.iter().map(|(h, _)| *h).max().unwrap_or(0);
707        let persistence: u32 = messages.iter().map(|(h, _)| *h).sum();
708
709        for (scope, rungs, hits) in [
710            (Scope::PerMessage, &tiers.per_message, density),
711            (Scope::PerWindow, &tiers.per_window, persistence),
712        ] {
713            if rungs.is_empty() {
714                continue;
715            }
716            // The highest rung reached: rungs validate strictly ascending.
717            let Some((idx, rung)) = rungs.iter().enumerate().rfind(|(_, g)| hits >= g.hits) else {
718                continue;
719            };
720            if gated(shields, &subject, rule, usize::from(rung.pierces_trusted)) {
721                continue;
722            }
723            // A density conviction cites every message whose OWN hit count meets
724            // the rung that fired; a persistence conviction cites the whole
725            // contributing window.
726            let citations: Vec<Citation> = match scope {
727                Scope::PerMessage => {
728                    messages.iter().filter(|(h, _)| *h >= rung.hits).flat_map(|(_, c)| c.iter().cloned()).collect()
729                }
730                _ => messages.iter().flat_map(|(_, c)| c.iter().cloned()).collect(),
731            };
732            push_tiered(rule, lp, subject, scope, idx as u8, hits, rung, citations, out);
733        }
734    }
735}
736
737/// Cross-account clustering: many identities posting one shape.
738///
739/// A cluster convicts on TWO counts, never one. Size alone would hang a
740/// community catchphrase on whoever repeats it, so the cluster must also be
741/// THIN — a script gives each identity a line or two, while regulars who share
742/// a greeting have hundreds of other messages between them. A short shape is
743/// cheap coincidence on top of that, so it clears a higher size bar.
744///
745/// Statistics read the FULL corpus: exempt members and channels still count
746/// toward cluster size and thinness (exemptions change who can be accused,
747/// never what the community looks like), they simply cannot be convicted.
748#[allow(clippy::too_many_arguments)]
749fn cohort_rule(
750    rule: &Rule,
751    corpus: &[&MessageSignal],
752    codes: &EmojiCodes,
753    exempt_channels: &BTreeSet<[u8; 32]>,
754    shields: &BTreeMap<[u8; 32], Shield>,
755    lp: &LoadedPolicy,
756    subject_exempt: &dyn Fn(&SubjectId) -> bool,
757    signals: &Signals,
758    min: u32,
759    quiet_max: u32,
760    short_factor: u32,
761    thin_ratio: Ratio,
762    out: &mut RuleOutcome,
763) {
764    // skeleton -> the distinct authors who posted it, plus their messages.
765    let mut clusters: BTreeMap<String, BTreeMap<[u8; 32], Vec<&MessageSignal>>> = BTreeMap::new();
766    for m in corpus {
767        let key = normalize::skeleton(&m.text, codes);
768        if key.is_empty() {
769            continue;
770        }
771        clusters.entry(key).or_default().entry(m.author.0).or_default().push(m);
772    }
773    // Window volume per author — the thinness input.
774    let mut volume: BTreeMap<[u8; 32], u32> = BTreeMap::new();
775    for m in corpus {
776        *volume.entry(m.author.0).or_insert(0) += 1;
777    }
778
779    // Each subject is convicted once (scope Whole) on the LARGEST cluster they
780    // belong to; ties break by clustering key ascending.
781    let mut best: BTreeMap<[u8; 32], (usize, String)> = BTreeMap::new();
782    for (key, authors) in &clusters {
783        let need = if key.chars().count() < caps::MIN_SKELETON_LEN {
784            min.saturating_mul(short_factor)
785        } else {
786            min
787        } as usize;
788        if authors.len() < need {
789            continue;
790        }
791        let thin = authors.keys().filter(|a| volume.get(*a).copied().unwrap_or(0) <= quiet_max).count();
792        if (thin as u64) * (thin_ratio.denom as u64) < (authors.len() as u64) * (thin_ratio.num as u64) {
793            continue;
794        }
795        for author in authors.keys() {
796            let slot = best.entry(*author).or_insert((0, String::new()));
797            if authors.len() > slot.0 {
798                *slot = (authors.len(), key.clone());
799            }
800        }
801    }
802
803    for (author_bytes, (size, key)) in best {
804        let subject = SubjectId(author_bytes);
805        if subject_exempt(&subject) || gated(shields, &subject, rule, usize::from(rule.pierces_trusted)) {
806            continue;
807        }
808        let messages = clusters.get(&key).and_then(|a| a.get(&author_bytes)).cloned().unwrap_or_default();
809        // Exempt content is barred from being cited even though it counted
810        // toward the cluster's shape.
811        let citable: Vec<&MessageSignal> =
812            messages.iter().copied().filter(|m| !exempt_channels.contains(&m.channel.0)).collect();
813        if citable.is_empty() {
814            continue;
815        }
816        let citations: Vec<Citation> = citable
817            .iter()
818            .map(|m| {
819                let target = CitationTarget::Message { message_id: m.id };
820                Citation {
821                    id: citation_id(&lp.hash, &rule.id, Scope::Whole, &subject, &target, None),
822                    target,
823                    at: m.at_ms,
824                    span: None,
825                    detail: None,
826                    suppressed: false,
827                }
828            })
829            .collect();
830        // The sample is the lowest inner message id in the cluster, excluding
831        // exempt members and channels: `peers` already omits them, and a sample
832        // would leak the same accusation the report may not make.
833        let sample = clusters
834            .get(&key)
835            .map(|authors| {
836                let mut candidates: Vec<&MessageSignal> = authors
837                    .iter()
838                    .filter(|(a, _)| !subject_exempt(&SubjectId(**a)))
839                    .flat_map(|(_, ms)| ms.iter().copied())
840                    .filter(|m| !exempt_channels.contains(&m.channel.0))
841                    .collect();
842                candidates.sort_by_key(|a| a.id.0);
843                candidates.first().map(|m| m.text.clone()).unwrap_or_default()
844            })
845            .unwrap_or_default();
846        let mut peers: Vec<SubjectId> = clusters
847            .get(&key)
848            .map(|a| a.keys().filter(|k| **k != author_bytes).map(|k| SubjectId(*k)).collect())
849            .unwrap_or_default();
850        peers.sort_by_key(|a| a.0);
851        peers.truncate(caps::COHORT_SAMPLE_CAP);
852        use sha2::Digest;
853        let mut hasher = sha2::Sha256::default();
854        hasher.update(key.as_bytes());
855        let evidence = vec![Evidence::Cohort {
856            skeleton_hash: Hash32(hasher.finalize().into()),
857            sample: sample.chars().take(caps::MAX_SAMPLE_LEN).collect(),
858            size: size as u32,
859            peers,
860        }];
861        let before = out.convictions.len();
862        push_direct_with(rule, lp, subject, citations, out);
863        if let Some(c) = out.convictions.get_mut(before) {
864            c.evidence = evidence;
865        }
866    }
867    let _ = signals;
868}
869
870/// The densest run of joins inside one window. Deliberately weak alone: a
871/// freshly-posted invite link is SUPPOSED to bring a burst of quiet newcomers,
872/// so this is normally `armed_by` a cohort rule and convicts nobody until
873/// something else already has.
874#[allow(clippy::too_many_arguments)]
875fn join_burst_rule(
876    rule: &Rule,
877    signals: &Signals,
878    shields: &BTreeMap<[u8; 32], Shield>,
879    lp: &LoadedPolicy,
880    subject_exempt: &dyn Fn(&SubjectId) -> bool,
881    gap_secs: u64,
882    min: u32,
883    out: &mut RuleOutcome,
884) {
885    // The owner is never part of a burst, and an unknown join is not evidence
886    // of one.
887    let mut joins: Vec<(u64, SubjectId)> = signals
888        .members
889        .iter()
890        .filter(|m| m.subject != signals.owner)
891        .filter_map(|m| m.joined_at_ms.map(|at| (at, m.subject)))
892        .collect();
893    joins.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1 .0.cmp(&b.1 .0)));
894    let gap_ms = gap_secs.saturating_mul(1000);
895    let (mut best_start, mut best_len) = (0usize, 0usize);
896    let mut start = 0usize;
897    for end in 0..joins.len() {
898        while joins[end].0.saturating_sub(joins[start].0) > gap_ms {
899            start += 1;
900        }
901        // Strictly greater: on a tie the EARLIEST window wins, which is where a
902        // raid starts.
903        if end + 1 - start > best_len {
904            best_len = end + 1 - start;
905            best_start = start;
906        }
907    }
908    if best_len < min as usize {
909        return;
910    }
911    let window = &joins[best_start..best_start + best_len];
912    let evidence = vec![Evidence::Burst {
913        from: window.first().map(|(at, _)| *at).unwrap_or(0),
914        to: window.last().map(|(at, _)| *at).unwrap_or(0),
915        size: best_len as u32,
916    }];
917    for (_, subject) in window {
918        if subject_exempt(subject) || gated(shields, subject, rule, usize::from(rule.pierces_trusted)) {
919            continue;
920        }
921        let before = out.convictions.len();
922        push_direct(rule, lp, *subject, out);
923        if let Some(c) = out.convictions.get_mut(before) {
924            c.evidence = evidence.clone();
925        }
926    }
927}
928
929/// Window-level rules: one conviction per subject, scope PerWindow.
930///
931///  * `repeat` — the most-repeated normalized text by that author; ties break by
932///    the normalized key ascending, so the citation set is reproducible. This is
933///    the copy-paste counter that catches one account spamming, where `cohort`
934///    catches many accounts sharing one line.
935///  * `rate` — the densest half-open `[t, t + per_secs)` span over the author's
936///    own message timestamps; ties take the EARLIEST span.
937#[allow(clippy::too_many_arguments)]
938fn window_rule(
939    rule: &Rule,
940    corpus: &[&MessageSignal],
941    codes: &EmojiCodes,
942    exempt_channels: &BTreeSet<[u8; 32]>,
943    shields: &BTreeMap<[u8; 32], Shield>,
944    lp: &LoadedPolicy,
945    subject_exempt: &dyn Fn(&SubjectId) -> bool,
946    out: &mut RuleOutcome,
947) {
948    let tiers = rule.tiers.as_ref().expect("validated: window rules carry tiers");
949    let rungs = &tiers.per_window;
950    if rungs.is_empty() {
951        return;
952    }
953    // Citable content only: exempt content still counts toward the corpus
954    // statistics every other rule reads, but it can never be cited here.
955    let mut by_author: BTreeMap<[u8; 32], Vec<&MessageSignal>> = BTreeMap::new();
956    for m in corpus {
957        if exempt_channels.contains(&m.channel.0) || subject_exempt(&m.author) {
958            continue;
959        }
960        by_author.entry(m.author.0).or_default().push(m);
961    }
962
963    for (subject_bytes, messages) in by_author {
964        let subject = SubjectId(subject_bytes);
965        let (hits, cited, evidence) = match &rule.matcher {
966            Match::Repeat { normalize: n, within_secs } => {
967                let mut groups: BTreeMap<String, Vec<&MessageSignal>> = BTreeMap::new();
968                for m in &messages {
969                    let key = normalize::apply(&m.text, *n, codes);
970                    if key.is_empty() {
971                        continue;
972                    }
973                    groups.entry(key).or_default().push(m);
974                }
975                // With a span, each group counts only its densest run inside it,
976                // and the winner is the tightest burst rather than the biggest
977                // pile. "gm" once a morning is seven hits across a week and one
978                // hit in any half hour, which is the difference between a
979                // regular and a spammer.
980                let scored: Vec<(String, u32, Vec<&MessageSignal>)> = groups
981                    .into_iter()
982                    .map(|(key, mut ms)| {
983                        let Some(secs) = within_secs else {
984                            let n = ms.len() as u32;
985                            return (key, n, ms);
986                        };
987                        ms.sort_by_key(|m| m.at_ms);
988                        let span = secs.saturating_mul(1000);
989                        let (mut best, mut from) = (0usize, 0u64);
990                        for (i, m) in ms.iter().enumerate() {
991                            // Half-open [start, start + span), starts taken from
992                            // the author's own timestamps — same walk as `Rate`.
993                            let start = m.at_ms;
994                            let count =
995                                ms[i..].iter().take_while(|x| x.at_ms < start.saturating_add(span)).count();
996                            if count > best {
997                                best = count;
998                                from = start;
999                            }
1000                        }
1001                        let cited: Vec<&MessageSignal> = ms
1002                            .into_iter()
1003                            .filter(|m| m.at_ms >= from && m.at_ms < from.saturating_add(span))
1004                            .collect();
1005                        (key, best as u32, cited)
1006                    })
1007                    .collect();
1008                // Most repeated wins; ties by the normalized key ascending.
1009                let Some((_key, n_hits, winners)) =
1010                    scored.into_iter().max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
1011                else {
1012                    continue;
1013                };
1014                (n_hits, winners, vec![])
1015            }
1016            Match::Rate { per_secs } => {
1017                let mut times: Vec<u64> = messages.iter().map(|m| m.at_ms).collect();
1018                times.sort_unstable();
1019                let span = per_secs.saturating_mul(1000);
1020                let (mut best, mut best_from) = (0usize, 0u64);
1021                for (i, &start) in times.iter().enumerate() {
1022                    // Half-open [start, start + span); candidate starts are the
1023                    // author's own timestamps, never every millisecond.
1024                    let count = times[i..].iter().take_while(|&&t| t < start.saturating_add(span)).count();
1025                    if count > best {
1026                        best = count;
1027                        best_from = start;
1028                    }
1029                }
1030                let cited: Vec<&MessageSignal> = messages
1031                    .iter()
1032                    .copied()
1033                    .filter(|m| m.at_ms >= best_from && m.at_ms < best_from.saturating_add(span))
1034                    .collect();
1035                (
1036                    best as u32,
1037                    cited,
1038                    vec![Evidence::Rate { window_secs: *per_secs, count: best as u32, from: best_from }],
1039                )
1040            }
1041            _ => continue,
1042        };
1043
1044        let Some((idx, rung)) = rungs.iter().enumerate().rfind(|(_, g)| hits >= g.hits) else {
1045            continue;
1046        };
1047        if gated(shields, &subject, rule, usize::from(rung.pierces_trusted)) {
1048            continue;
1049        }
1050        let citations: Vec<Citation> = cited
1051            .iter()
1052            .map(|m| {
1053                let target = CitationTarget::Message { message_id: m.id };
1054                Citation {
1055                    id: citation_id(&lp.hash, &rule.id, Scope::PerWindow, &subject, &target, None),
1056                    target,
1057                    at: m.at_ms,
1058                    span: None,
1059                    detail: None,
1060                    suppressed: false,
1061                }
1062            })
1063            .collect();
1064        let before = out.convictions.len();
1065        push_tiered(rule, lp, subject, Scope::PerWindow, idx as u8, hits, rung, citations, out);
1066        if let Some(c) = out.convictions.get_mut(before) {
1067            c.evidence = evidence;
1068        }
1069    }
1070}
1071
1072#[allow(clippy::too_many_arguments)]
1073fn push_tiered(
1074    rule: &Rule,
1075    lp: &LoadedPolicy,
1076    subject: SubjectId,
1077    scope: Scope,
1078    rung_idx: u8,
1079    hits: u32,
1080    rung: &Rung,
1081    mut citations: Vec<Citation>,
1082    out: &mut RuleOutcome,
1083) {
1084    citations.sort_by_key(|a| a.id.0);
1085    citations.dedup_by(|a, b| a.id == b.id);
1086    // Counts and timestamps come from the FULL set, so a truncated exhibit list
1087    // never reads as fewer offenses nor flips `retroactive`.
1088    let citation_count = citations.len() as u32;
1089    let earliest = citations.iter().map(|c| c.at).min().unwrap_or(0);
1090    let latest = citations.iter().map(|c| c.at).max().unwrap_or(0);
1091    // Pre-Phase-4 there is no signed activation, so retroactivity is Unknown —
1092    // never `No`, which a locally-set activation time would fake.
1093    let retroactive = match lp.activated_at {
1094        None => Retroactive::Unknown,
1095        Some(activated) => {
1096            if citations.is_empty() {
1097                Retroactive::Unknown
1098            } else if latest >= activated {
1099                Retroactive::No
1100            } else {
1101                Retroactive::Yes
1102            }
1103        }
1104    };
1105    citations.truncate(caps::MAX_CITATIONS_PER_CONVICTION);
1106    let ids: Vec<CitationId> = citations.iter().map(|c| c.id).collect();
1107    out.citations.extend(citations);
1108    out.convictions.push(Conviction {
1109        id: conviction_id(&lp.hash, &rule.id, scope, rung_idx, &subject),
1110        subject,
1111        rule_id: rule.id.clone(),
1112        scope,
1113        rung: rung_idx,
1114        hits,
1115        severity: rung.severity,
1116        basis: rule.matcher.basis(),
1117        tier_weight: rung.weight,
1118        retroactive,
1119        suppressed: false,
1120        folded: false,
1121        folded_into: None,
1122        combined: false,
1123        proven_combined: false,
1124        citations: ids,
1125        citation_count,
1126        earliest_citation_at: earliest,
1127        latest_citation_at: latest,
1128        family: rule.family.clone(),
1129        evidence: vec![],
1130    });
1131}
1132
1133/// Which rules a policy would refuse a send under (cooperative prevention).
1134/// Only Deterministic rules reach here — the validator already refused the rest.
1135pub fn refusing_rules(policy: &Policy) -> Vec<&Rule> {
1136    policy.rules.iter().filter(|r| r.enforcement == Enforcement::Refuse).collect()
1137}
1138
1139/// Union of a policy-level and rule-level exemption block.
1140pub fn merged_exempt<'a>(policy: &'a Exempt, rule: &'a Exempt) -> Vec<&'a ExemptPatterns> {
1141    policy.patterns.iter().chain(rule.patterns.iter()).collect()
1142}
1143
1144#[cfg(test)]
1145mod tests {
1146    use super::super::document::*;
1147    use super::*;
1148
1149    const HOUR: u64 = 3_600_000;
1150    const NOW: u64 = 1_800_000_000_000;
1151
1152    fn sid(b: u8) -> SubjectId {
1153        SubjectId([b; 32])
1154    }
1155    fn mid(b: u8) -> MessageId {
1156        MessageId([b; 32])
1157    }
1158    fn ch(b: u8) -> Hash32 {
1159        Hash32([b; 32])
1160    }
1161
1162    fn msg(id: u8, author: u8, at: u64, text: &str) -> MessageSignal {
1163        MessageSignal { id: mid(id), author: sid(author), channel: ch(0x0a), at_ms: at, text: text.into(), mentions: 0 }
1164    }
1165
1166    fn member(b: u8, joined_ago_h: Option<u64>) -> MemberSignal {
1167        MemberSignal {
1168            subject: sid(b),
1169            joined_at_ms: joined_ago_h.map(|h| NOW - h * HOUR),
1170            roles: vec![],
1171            is_staff: false,
1172            lifetime_messages: 0,
1173            first_post_ms: None,
1174        }
1175    }
1176
1177    fn signals(members: Vec<MemberSignal>, messages: Vec<MessageSignal>) -> Signals {
1178        Signals {
1179            owner: sid(0xf0),
1180            members,
1181            messages,
1182            channels: vec![ch(0x0a)],
1183            relays: vec![RelayCoverage { url: "wss://r".into(), eose: true, events: 10 }],
1184            requested_from: NOW - 24 * HOUR,
1185            requested_to: NOW,
1186            confirmed_from: 0,
1187            confirmed_to: NOW,
1188            roster_version: Hash32([0x77; 32]),
1189        }
1190    }
1191
1192    fn loaded(policy: Policy) -> LoadedPolicy {
1193        LoadedPolicy { hash: Hash32([0x11; 32]), policy, activated_at: None }
1194    }
1195
1196    fn policy_with(rules: Vec<Rule>) -> Policy {
1197        Policy {
1198            format: FORMAT,
1199            requires: vec![],
1200            name: "p".into(),
1201            emoji_codes: vec![],
1202            shields: Shields::default(),
1203            window: Window::default(),
1204            exempt: Exempt::default(),
1205            rules,
1206        }
1207    }
1208
1209    fn link_rule() -> Rule {
1210        Rule {
1211            id: "links".into(),
1212            matcher: Match::Link { patterns: vec![] },
1213            tiers: Some(Tiers {
1214                per_message: vec![Rung { hits: 1, severity: Severity::Severe, weight: 70, pierces_trusted: false }],
1215                per_window: vec![Rung { hits: 3, severity: Severity::Severe, weight: 90, pierces_trusted: false }],
1216            }),
1217            severity: None,
1218            weight: None,
1219            pierces_trusted: false,
1220            family: None,
1221            armed_by: None,
1222            exempt: Exempt {
1223                patterns: vec![ExemptPatterns {
1224                    kind: Some(ExemptKind::Domain),
1225                    values: vec!["vectorapp.io".into()],
1226                }],
1227                ..Default::default()
1228            },
1229            enforcement: Enforcement::Advisory,
1230        }
1231    }
1232
1233    fn only(report: &ModerationReport) -> &PolicyReport {
1234        &report.policies[0]
1235    }
1236
1237    fn subject<'a>(pr: &'a PolicyReport, b: u8) -> Option<&'a SubjectReport> {
1238        pr.subjects.iter().find(|s| s.subject == sid(b))
1239    }
1240
1241    /// The design's Strict Link Blocker: three links fire BOTH scopes, and the
1242    /// numbers must match the frozen reference (70 + 90 -> 970pm -> 97).
1243    #[test]
1244    fn strict_link_blocker_reproduces_the_reference_vector() {
1245        let s = signals(
1246            vec![member(1, Some(2))],
1247            vec![
1248                msg(0xb1, 1, NOW - 3 * HOUR, "grab it at bit.ly/a"),
1249                msg(0xb2, 1, NOW - 2 * HOUR, "also tr.ee/b"),
1250                msg(0xb3, 1, NOW - HOUR, "and shm.to/c"),
1251            ],
1252        );
1253        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1254        let pr = only(&r);
1255        let sub = subject(pr, 1).expect("the linker is reported");
1256        assert_eq!((sub.confidence, sub.proven), (97, 97), "both scopes, both Deterministic");
1257        assert_eq!(sub.band, Band::Alert);
1258        let scopes: Vec<Scope> = sub.convictions.iter().map(|c| c.scope).collect();
1259        assert_eq!(scopes, vec![Scope::PerMessage, Scope::PerWindow], "density and persistence both convict");
1260        assert_eq!(pr.citations.len(), 3, "one citation per offending message");
1261        assert!(pr.citations.iter().all(|c| c.span.is_none()), "a link hit is a domain, not a text span");
1262    }
1263
1264    #[test]
1265    fn an_allowlisted_domain_never_produces_a_hit() {
1266        let s = signals(
1267            vec![member(1, Some(2))],
1268            vec![msg(0xb1, 1, NOW - HOUR, "read https://vectorapp.io/blog and https://vectorapp.io/x")],
1269        );
1270        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1271        assert!(subject(only(&r), 1).is_none(), "no conviction, and no subject to report");
1272        assert!(only(&r).citations.is_empty());
1273    }
1274
1275    /// Exempt content is barred from being CITED but stays in every corpus
1276    /// statistic — the rung arithmetic counts citable hits only.
1277    #[test]
1278    fn an_exempt_channel_contributes_no_hits() {
1279        let mut s = signals(
1280            vec![member(1, Some(2))],
1281            vec![msg(0xb1, 1, NOW - HOUR, "bit.ly/a"), msg(0xb2, 1, NOW - HOUR, "tr.ee/b")],
1282        );
1283        s.messages[1].channel = ch(0x0b);
1284        let mut p = policy_with(vec![link_rule()]);
1285        p.exempt.channels = vec![crate::simd::hex::bytes_to_hex_32(&[0x0b; 32])];
1286        let r = evaluate(&s, &[loaded(p)], &[], NOW);
1287        let sub = subject(only(&r), 1).unwrap();
1288        assert_eq!(sub.convictions.len(), 1, "only the density rung is reached; two links would have been needed");
1289        assert_eq!(sub.convictions[0].scope, Scope::PerMessage);
1290        assert_eq!(only(&r).citations.len(), 1, "the exempt channel's message is never cited");
1291    }
1292
1293    #[test]
1294    fn the_soft_swear_ladder_lands_in_review_not_alert() {
1295        let keyword = Rule {
1296            id: "swears".into(),
1297            matcher: Match::Keyword { patterns: vec!["darn".into()], normalize: Normalize::Fold },
1298            tiers: Some(Tiers {
1299                per_message: vec![Rung { hits: 1, severity: Severity::Minor, weight: 10, pierces_trusted: false }],
1300                per_window: vec![Rung { hits: 10, severity: Severity::Severe, weight: 70, pierces_trusted: false }],
1301            }),
1302            severity: None,
1303            weight: None,
1304            pierces_trusted: false,
1305            family: None,
1306            armed_by: None,
1307            exempt: Exempt::default(),
1308            enforcement: Enforcement::Advisory,
1309        };
1310        let messages: Vec<MessageSignal> =
1311            (0..10).map(|i| msg(0xc0 + i as u8, 1, NOW - (i + 1) * HOUR, "darn it")).collect();
1312        let s = signals(vec![member(1, Some(2))], messages);
1313        let r = evaluate(&s, &[loaded(policy_with(vec![keyword]))], &[], NOW);
1314        let sub = subject(only(&r), 1).unwrap();
1315        // 10 + 70 -> 730pm -> 73: the soft policy peaks at human review.
1316        assert_eq!(sub.confidence, 73);
1317        assert_eq!(sub.band, Band::Flagged);
1318    }
1319
1320    /// Shields gate before conviction: Protected is absolute, Trusted yields
1321    /// only to a piercing rung, Indeterminate never gates.
1322    #[test]
1323    fn shields_gate_exactly_as_declared() {
1324        let mut s = signals(
1325            vec![
1326                member(1, Some(24 * 30)), // long tenure, will be Trusted
1327                member(2, None),          // no join, no posts elsewhere: Indeterminate
1328                MemberSignal { subject: sid(3), joined_at_ms: Some(NOW - HOUR), roles: vec![], is_staff: true, lifetime_messages: 0, first_post_ms: None },
1329            ],
1330            vec![],
1331        );
1332        // Give the veteran enough varied history to clear the trust bar. The
1333        // text must be genuinely varied: `skeleton` strips digits, so numbered
1334        // fixtures collapse to ONE distinct shape and clear nothing.
1335        const WORDS: [&str; 20] = [
1336            "morning all", "shipping today", "nice one", "agreed", "on it", "good catch", "thanks",
1337            "will review", "merged", "looks fine", "same here", "no idea", "try again", "fixed now",
1338            "welcome aboard", "see you", "sounds good", "my turn", "any thoughts", "done",
1339        ];
1340        for (i, w) in WORDS.iter().enumerate() {
1341            s.messages.push(msg(0x40 + i as u8, 1, NOW - (i as u64 + 1) * HOUR, w));
1342        }
1343        s.messages.push(msg(0xd1, 1, NOW - HOUR, "bit.ly/a"));
1344        s.messages.push(msg(0xd2, 2, NOW - HOUR, "bit.ly/b"));
1345        s.messages.push(msg(0xd3, 3, NOW - HOUR, "bit.ly/c"));
1346
1347        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1348        let pr = only(&r);
1349        assert_eq!(subject(pr, 1).unwrap().shield, Shield::Trusted);
1350        assert!(subject(pr, 1).unwrap().convictions.is_empty(), "a non-piercing rule cannot touch Trusted");
1351        // A member with no Join but who HAS posted has a known tenure (the
1352        // oldest trace is their first post), so they are simply un-Trusted.
1353        assert_eq!(subject(pr, 2).unwrap().shield, Shield::None);
1354        assert!(!subject(pr, 2).unwrap().convictions.is_empty(), "no shield, ordinary conviction");
1355        assert_eq!(subject(pr, 3).unwrap().shield, Shield::Protected);
1356        assert!(subject(pr, 3).unwrap().convictions.is_empty(), "staff are never convicted");
1357
1358        // The same evidence WITH a piercing rung reaches the Trusted member.
1359        let mut piercing = link_rule();
1360        piercing.tiers.as_mut().unwrap().per_message[0].pierces_trusted = true;
1361        piercing.pierces_trusted = true;
1362        let r = evaluate(&s, &[loaded(policy_with(vec![piercing]))], &[], NOW);
1363        assert!(!subject(only(&r), 1).unwrap().convictions.is_empty(), "a piercing rung reaches Trusted");
1364        assert!(subject(only(&r), 3).unwrap().convictions.is_empty(), "but nothing pierces Protected");
1365    }
1366
1367    /// Standing has three doors, and none of them opens in a day.
1368    #[test]
1369    fn trust_comes_from_roles_tenure_or_sustained_activity() {
1370        let day = 24 * HOUR;
1371        // A role the community granted is a vouch: trusted, though NOT immune
1372        // (that is Protected, and it keys on moderation permissions).
1373        let with_role =
1374            MemberSignal { subject: sid(1), joined_at_ms: Some(NOW - day), roles: vec![ch(0xaa)], is_staff: false, lifetime_messages: 0, first_post_ms: None };
1375        let veteran = member(2, Some(24 * 40));
1376        let active = member(3, Some(24 * 8));
1377        let lurker = member(4, Some(24 * 8));
1378        let loud_newcomer = member(5, Some(1));
1379
1380        let mut s = signals(vec![with_role, veteran, active, lurker, loud_newcomer], vec![]);
1381        s.messages.push(msg(0x50, 2, NOW - HOUR, "still here"));
1382        const VARIED: [&str; 5] = ["morning all", "shipping today", "nice one", "agreed", "on it"];
1383        for (i, w) in VARIED.iter().enumerate() {
1384            s.messages.push(msg(0x60 + i as u8, 3, NOW - (i as u64 + 1) * HOUR, w));
1385            s.messages.push(msg(0x70 + i as u8, 5, NOW - (i as u64 + 1) * HOUR, w));
1386        }
1387
1388        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1389        let pr = only(&r);
1390        let shield = |b: u8| subject(pr, b).map(|x| x.shield);
1391        assert_eq!(shield(1), Some(Shield::Trusted), "a granted role vouches");
1392        assert_eq!(shield(2), Some(Shield::Trusted), "long tenure plus any activity");
1393        assert_eq!(shield(3), Some(Shield::Trusted), "tenure with volume and variety");
1394        assert_eq!(shield(4), None, "a silent week earns nothing");
1395        assert_eq!(shield(5), None, "and chatter cannot buy standing in a day");
1396    }
1397
1398    /// A regular of months who had a quiet week keeps their standing: volume is
1399    /// lifetime, not "what have you said for me lately".
1400    #[test]
1401    fn a_quiet_week_does_not_cost_a_regular_their_standing() {
1402        let mut veteran = member(6, Some(24 * 10));
1403        veteran.lifetime_messages = 900;
1404        let mut newcomer = member(7, Some(2));
1405        newcomer.lifetime_messages = 900; // history it could not possibly have
1406        let mut s = signals(vec![veteran, newcomer], vec![]);
1407        // Three distinct shapes this week is all the variety bar asks for.
1408        for (i, w) in ["morning all", "shipping today", "nice one"].iter().enumerate() {
1409            s.messages.push(msg(0x80 + i as u8, 6, NOW - (i as u64 + 1) * HOUR, w));
1410            s.messages.push(msg(0x90 + i as u8, 7, NOW - (i as u64 + 1) * HOUR, w));
1411        }
1412        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1413        let pr = only(&r);
1414        assert_eq!(subject(pr, 6).map(|x| x.shield), Some(Shield::Trusted), "ten days and a long history");
1415        assert_eq!(subject(pr, 7).map(|x| x.shield), None, "tenure still gates: two hours buys nothing");
1416    }
1417
1418    /// The fifth member to join, very active early, then quiet for months.
1419    /// Still trusted: standing is what you built, not what you said this week.
1420    #[test]
1421    fn an_early_member_who_lurks_for_months_keeps_their_standing() {
1422        let mut lurker = member(8, Some(24 * 200));
1423        lurker.lifetime_messages = 400; // all of it long ago
1424        let s = signals(vec![lurker], vec![]); // nothing at all in the window
1425        let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1426        assert_eq!(subject(only(&r), 8).map(|x| x.shield), Some(Shield::Trusted));
1427    }
1428
1429    /// A member's client judges word and link filters on the message in front
1430    /// of it, and nothing that needs history. It says which is which, because a
1431    /// silent skip would read as "clean".
1432    fn cohort_rule_doc() -> Rule {
1433        Rule {
1434            id: "cohort".into(),
1435            matcher: Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
1436            tiers: None,
1437            severity: Some(Severity::Severe),
1438            weight: Some(85),
1439            pierces_trusted: false,
1440            family: None,
1441            armed_by: None,
1442            exempt: Exempt::default(),
1443            enforcement: Enforcement::Advisory,
1444        }
1445    }
1446
1447    /// The shape of the 2026-08-19 raid: a hundred fresh identities, one line
1448    /// each. This is the test the whole engine exists to pass.
1449    #[test]
1450    fn a_hundred_fresh_accounts_saying_one_thing_is_a_raid() {
1451        let mut members: Vec<MemberSignal> = (0..100).map(|i| member(i as u8 + 10, Some(1))).collect();
1452        let mut messages: Vec<MessageSignal> =
1453            (0..100).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "hello world")).collect();
1454        // A regular with real history, saying the same words in passing.
1455        members.push(member(2, Some(24 * 60)));
1456        members.last_mut().unwrap().lifetime_messages = 800;
1457        messages.push(msg(0xf1, 2, NOW - 2 * HOUR, "hello world"));
1458        for (i, w) in ["morning all", "shipping today", "nice one"].iter().enumerate() {
1459            messages.push(msg(0xf2 + i as u8, 2, NOW - (i as u64 + 3) * HOUR, w));
1460        }
1461
1462        let s = signals(members, messages);
1463        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc()]))], &[], NOW);
1464        let pr = only(&r);
1465        let convicted: Vec<&SubjectReport> =
1466            pr.subjects.iter().filter(|x| x.convictions.iter().any(|c| !c.suppressed)).collect();
1467        assert_eq!(convicted.len(), 100, "every raider, and only the raiders");
1468        assert!(convicted.iter().all(|x| x.confidence == 85 && x.band == Band::Alert));
1469        // Heuristic evidence never reaches `proven`: a human still decides.
1470        assert!(convicted.iter().all(|x| x.proven == 0), "a cohort is inference, not proof");
1471        assert_eq!(subject(pr, 2).map(|x| x.shield), Some(Shield::Trusted), "the regular is shielded, not convicted");
1472        assert!(subject(pr, 2).unwrap().convictions.is_empty());
1473
1474        let ev = &convicted[0].convictions[0].evidence;
1475        assert!(
1476            matches!(ev.first(), Some(Evidence::Cohort { size: 101, .. })),
1477            "the exhibit names the true cluster size: {ev:?}"
1478        );
1479    }
1480
1481    /// The corpus outlives membership: a banned raider's messages sit in local
1482    /// storage long after the rotation removed them.
1483    #[test]
1484    fn someone_already_removed_is_never_convicted_again() {
1485        // Twenty identities posted one line each; only three are still members.
1486        let members: Vec<MemberSignal> = (0..3).map(|i| member(i as u8 + 10, Some(1))).collect();
1487        let messages: Vec<MessageSignal> =
1488            (0..20).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "free airdrop claim now")).collect();
1489        let s = signals(members, messages);
1490        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc()]))], &[], NOW);
1491        let pr = only(&r);
1492        assert_eq!(pr.subjects.len(), 3, "the seventeen who are gone are not re-litigated");
1493        assert!(pr.subjects.iter().all(|x| !x.convictions.is_empty()), "the three still here are convicted");
1494        // The cluster's true size still counts all twenty: exemption and
1495        // absence change who can be accused, never what the community saw.
1496        let ev = &pr.subjects[0].convictions[0].evidence;
1497        assert!(matches!(ev.first(), Some(Evidence::Cohort { size: 20, .. })), "{ev:?}");
1498    }
1499
1500    /// Arming asks whether a raid is happening HERE and NOW, not whether one
1501    /// ever happened: a cohort of accounts that are all gone must not arm a
1502    /// burst rule against whoever is still present.
1503    #[test]
1504    fn a_past_raid_does_not_arm_a_rule_against_the_living() {
1505        let mut burst = Rule {
1506            id: "burst".into(),
1507            matcher: Match::JoinBurst { gap_secs: 600, min: 5 },
1508            tiers: None,
1509            severity: Some(Severity::Major),
1510            weight: Some(40),
1511            pierces_trusted: false,
1512            family: None,
1513            armed_by: Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] }),
1514            exempt: Exempt::default(),
1515            enforcement: Enforcement::Advisory,
1516        };
1517        // Twenty raiders posted one line each and were removed; six ordinary
1518        // members remain, all of whom joined in one burst.
1519        let members: Vec<MemberSignal> = (0..6)
1520            .map(|i| MemberSignal {
1521                subject: sid(i as u8 + 40),
1522                joined_at_ms: Some(NOW - HOUR + i * 30_000),
1523                roles: vec![],
1524                is_staff: false,
1525                lifetime_messages: 0,
1526                first_post_ms: None,
1527            })
1528            .collect();
1529        let messages: Vec<MessageSignal> =
1530            (0..20).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "free airdrop claim now")).collect();
1531        let s = signals(members, messages);
1532        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc(), burst.clone()]))], &[], NOW);
1533        assert_eq!(
1534            only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count(),
1535            0,
1536            "the cohort is entirely gone, so it arms nothing against the living"
1537        );
1538
1539        // The same burst DOES fire when the cohort is among the current members.
1540        burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] });
1541        let mut members: Vec<MemberSignal> = (0..6)
1542            .map(|i| MemberSignal {
1543                subject: sid(i as u8 + 40),
1544                joined_at_ms: Some(NOW - HOUR + i * 30_000),
1545                roles: vec![],
1546                is_staff: false,
1547                lifetime_messages: 0,
1548                first_post_ms: None,
1549            })
1550            .collect();
1551        members.extend((0..20).map(|i| member(i as u8 + 10, Some(1))));
1552        let messages: Vec<MessageSignal> =
1553            (0..20).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "free airdrop claim now")).collect();
1554        let s = signals(members, messages);
1555        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc(), burst]))], &[], NOW);
1556        let convicted = only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count();
1557        assert!(convicted >= 20, "a live cohort arms it: {convicted}");
1558    }
1559
1560    /// Size alone must never convict: a catchphrase everyone repeats is a
1561    /// community, and the thinness bar is what tells it from a script.
1562    #[test]
1563    fn a_community_catchphrase_is_not_a_cohort() {
1564        let members: Vec<MemberSignal> = (0..20)
1565            .map(|i| {
1566                let mut m = member(i as u8 + 10, Some(24 * 60));
1567                m.lifetime_messages = 500;
1568                m
1569            })
1570            .collect();
1571        let mut messages: Vec<MessageSignal> = Vec::new();
1572        for i in 0..20u8 {
1573            // Everyone says it — and everyone also says plenty else.
1574            messages.push(msg(0x20 + i, i + 10, NOW - HOUR, "good morning everyone"));
1575            for (j, w) in ["shipping today", "nice one", "agreed", "on it"].iter().enumerate() {
1576                messages.push(msg(0x60 + i * 4 + j as u8, i + 10, NOW - (j as u64 + 2) * HOUR, w));
1577            }
1578        }
1579        let s = signals(members, messages);
1580        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc()]))], &[], NOW);
1581        let convicted = only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count();
1582        assert_eq!(convicted, 0, "twenty chatty regulars sharing a greeting are not a raid");
1583    }
1584
1585    /// A burst of quiet newcomers is what a freshly-posted invite link LOOKS
1586    /// like, so it convicts nobody until a cohort already has.
1587    #[test]
1588    fn an_invite_link_burst_convicts_nobody_on_its_own() {
1589        let mut burst = Rule {
1590            id: "burst".into(),
1591            matcher: Match::JoinBurst { gap_secs: 600, min: 5 },
1592            tiers: None,
1593            severity: Some(Severity::Major),
1594            weight: Some(40),
1595            pierces_trusted: false,
1596            family: None,
1597            armed_by: None,
1598            exempt: Exempt::default(),
1599            enforcement: Enforcement::Advisory,
1600        };
1601        // Ten people joined within a few minutes and said nothing alike.
1602        let members: Vec<MemberSignal> = (0..10)
1603            .map(|i| MemberSignal {
1604                subject: sid(i as u8 + 10),
1605                joined_at_ms: Some(NOW - HOUR + i * 30_000),
1606                roles: vec![],
1607                is_staff: false,
1608                lifetime_messages: 0,
1609                first_post_ms: None,
1610            })
1611            .collect();
1612        let s = signals(members, vec![]);
1613
1614        // Unarmed, the burst convicts them all — which is why the built-in
1615        // policy never ships it that way.
1616        let r = evaluate(&s, &[loaded(policy_with(vec![burst.clone()]))], &[], NOW);
1617        assert_eq!(only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count(), 10);
1618
1619        // Armed by a cohort that never fired, it convicts nobody.
1620        burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] });
1621        let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc(), burst]))], &[], NOW);
1622        assert_eq!(
1623            only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count(),
1624            0,
1625            "no cohort, no conviction: an invite link is not an attack"
1626        );
1627    }
1628
1629    #[test]
1630    fn a_member_evaluates_only_stateless_rules() {
1631        let stateful = Rule {
1632            id: "quiet".into(),
1633            matcher: Match::MessagesLte { n: 2 },
1634            tiers: None,
1635            severity: Some(Severity::Notice),
1636            weight: Some(10),
1637            pierces_trusted: false,
1638            family: None,
1639            armed_by: None,
1640            exempt: Exempt::default(),
1641            enforcement: Enforcement::Advisory,
1642        };
1643        let s = signals(vec![member(1, Some(2))], vec![msg(0xb1, 1, NOW - HOUR, "bit.ly/a")]);
1644        let policies = [loaded(policy_with(vec![link_rule(), stateful]))];
1645
1646        let as_member = evaluate_as(&s, &policies, &[], NOW, EvalMode::Member);
1647        let pr = &as_member.policies[0];
1648        let state = |id: &str| pr.rule_status.iter().find(|r| r.rule_id == id).map(|r| r.state);
1649        assert_eq!(state("links"), Some(RuleState::Evaluated), "a link filter needs no history");
1650        assert_eq!(state("quiet"), Some(RuleState::RequiresHistory), "counting a member's past does");
1651        let sub = subject(pr, 1).unwrap();
1652        assert_eq!(sub.convictions.len(), 1, "the link conviction stands on its own");
1653
1654        // The same evidence, evaluated by an admin, runs everything.
1655        let as_admin = evaluate_as(&s, &policies, &[], NOW, EvalMode::Admin);
1656        let pr = &as_admin.policies[0];
1657        assert!(pr.rule_status.iter().all(|r| r.state == RuleState::Evaluated));
1658        assert_eq!(subject(pr, 1).unwrap().convictions.len(), 2);
1659    }
1660
1661    #[test]
1662    fn fresh_account_aggravators_fold_to_one() {
1663        let tenure = Rule {
1664            id: "tenure".into(),
1665            matcher: Match::TenureLt { secs: 24 * 3600 },
1666            tiers: None,
1667            severity: Some(Severity::Notice),
1668            weight: Some(20),
1669            pierces_trusted: false,
1670            family: None,
1671            armed_by: None,
1672            exempt: Exempt::default(),
1673            enforcement: Enforcement::Advisory,
1674        };
1675        let quiet = Rule {
1676            id: "quiet".into(),
1677            matcher: Match::MessagesLte { n: 2 },
1678            tiers: None,
1679            severity: Some(Severity::Notice),
1680            weight: Some(10),
1681            pierces_trusted: false,
1682            family: None,
1683            armed_by: None,
1684            exempt: Exempt::default(),
1685            enforcement: Enforcement::Advisory,
1686        };
1687        let s = signals(vec![member(1, Some(2))], vec![msg(0xb1, 1, NOW - HOUR, "hello world")]);
1688        let r = evaluate(&s, &[loaded(policy_with(vec![tenure, quiet]))], &[], NOW);
1689        let sub = subject(only(&r), 1).unwrap();
1690        // Two correlated proxies for "new account" fold to the strongest: 20,
1691        // not 20-OR-10 = 28.
1692        assert_eq!(sub.confidence, 20);
1693        let folded = sub.convictions.iter().find(|c| c.rule_id == "quiet").unwrap();
1694        assert!(folded.folded && !folded.combined);
1695        assert_eq!(sub.convictions.iter().filter(|c| c.combined).count(), 1);
1696    }
1697
1698    #[test]
1699    fn unknown_tenure_is_never_a_conviction() {
1700        let tenure = Rule {
1701            id: "tenure".into(),
1702            matcher: Match::TenureLt { secs: 24 * 3600 },
1703            tiers: None,
1704            severity: Some(Severity::Notice),
1705            weight: Some(20),
1706            pierces_trusted: false,
1707            family: None,
1708            armed_by: None,
1709            exempt: Exempt::default(),
1710            enforcement: Enforcement::Advisory,
1711        };
1712        let s = signals(vec![member(9, None)], vec![]);
1713        let r = evaluate(&s, &[loaded(policy_with(vec![tenure]))], &[], NOW);
1714        let pr = only(&r);
1715        let status = pr.rule_status.iter().find(|s| s.rule_id == "tenure").unwrap();
1716        assert_eq!(status.unknown_subjects, vec![sid(9)], "per-subject unknown, not a community-wide block");
1717        assert!(subject(pr, 9).unwrap().convictions.is_empty());
1718    }
1719
1720    /// Indeterminate means "we could not establish tenure" — no join AND no
1721    /// posts — and it is informational: the subject is judged as if unshielded.
1722    #[test]
1723    fn indeterminate_tenure_never_gates_a_conviction() {
1724        let quiet = Rule {
1725            id: "quiet".into(),
1726            matcher: Match::MessagesLte { n: 2 },
1727            tiers: None,
1728            severity: Some(Severity::Notice),
1729            weight: Some(10),
1730            pierces_trusted: false,
1731            family: None,
1732            armed_by: None,
1733            exempt: Exempt::default(),
1734            enforcement: Enforcement::Advisory,
1735        };
1736        let s = signals(vec![member(7, None)], vec![]);
1737        let r = evaluate(&s, &[loaded(policy_with(vec![quiet]))], &[], NOW);
1738        let sub = subject(only(&r), 7).unwrap();
1739        assert_eq!(sub.shield, Shield::Indeterminate, "no join, no posts: unknowable");
1740        assert_eq!(sub.confidence, 10, "and judged exactly as if unshielded");
1741    }
1742
1743    fn window_rule_policy(m: Match, rungs: Vec<Rung>) -> Policy {
1744        policy_with(vec![Rule {
1745            id: "w".into(),
1746            matcher: m,
1747            tiers: Some(Tiers { per_message: vec![], per_window: rungs }),
1748            severity: None,
1749            weight: None,
1750            pierces_trusted: false,
1751            family: None,
1752            armed_by: None,
1753            exempt: Exempt::default(),
1754            enforcement: Enforcement::Advisory,
1755        }])
1756    }
1757
1758    /// One account pasting the same line over and over — the counterpart to
1759    /// `cohort`, which catches many accounts sharing one line.
1760    #[test]
1761    fn repeat_catches_one_account_pasting_the_same_thing() {
1762        let mut messages: Vec<MessageSignal> = (0..5)
1763            .map(|i| msg(0xa0 + i as u8, 1, NOW - (i + 1) * HOUR, "CLAIM YOUR FREE AIRDROP NOW"))
1764            .collect();
1765        // Variations a spammer reaches for first: case, punctuation, digits.
1766        messages.push(msg(0xb0, 1, NOW - 6 * HOUR, "claim your free airdrop now!!!"));
1767        messages.push(msg(0xb1, 1, NOW - 7 * HOUR, "hello everyone"));
1768        let s = signals(vec![member(1, Some(2))], messages);
1769        let p = window_rule_policy(
1770            Match::Repeat { normalize: Normalize::Skeleton, within_secs: None },
1771            vec![Rung { hits: 3, severity: Severity::Major, weight: 50, pierces_trusted: false }],
1772        );
1773        let r = evaluate(&s, &[loaded(p)], &[], NOW);
1774        let sub = subject(only(&r), 1).unwrap();
1775        let c = &sub.convictions[0];
1776        assert_eq!(c.scope, Scope::PerWindow);
1777        assert_eq!(c.hits, 6, "case and punctuation are one line of attacker code, so they do not vary the shape");
1778        assert_eq!(c.citation_count, 6, "every repetition is cited, not just the ones past the rung");
1779        assert_eq!(sub.confidence, 50);
1780    }
1781
1782    #[test]
1783    fn rate_finds_the_densest_span_and_cites_only_it() {
1784        // Five in a burst, then one much later: the burst is the evidence.
1785        let mut messages: Vec<MessageSignal> =
1786            (0..5).map(|i| msg(0xc0 + i as u8, 1, NOW - 60 * 60 * 1000 + i * 10_000, "spam")).collect();
1787        messages.push(msg(0xcf, 1, NOW - 10 * 60 * 1000, "unrelated"));
1788        let s = signals(vec![member(1, Some(2))], messages);
1789        let p = window_rule_policy(
1790            Match::Rate { per_secs: 60 },
1791            vec![Rung { hits: 5, severity: Severity::Major, weight: 55, pierces_trusted: false }],
1792        );
1793        let r = evaluate(&s, &[loaded(p)], &[], NOW);
1794        let sub = subject(only(&r), 1).unwrap();
1795        let c = &sub.convictions[0];
1796        assert_eq!(c.hits, 5, "five inside one minute");
1797        assert_eq!(c.citation_count, 5, "the straggler is not part of the burst");
1798        assert!(
1799            matches!(c.evidence.first(), Some(Evidence::Rate { count: 5, window_secs: 60, .. })),
1800            "the span is reported as evidence: {:?}",
1801            c.evidence
1802        );
1803    }
1804
1805    #[test]
1806    fn a_pardon_suppresses_the_conviction_and_its_citations() {
1807        let s = signals(
1808            vec![member(1, Some(2))],
1809            vec![
1810                msg(0xb1, 1, NOW - 3 * HOUR, "bit.ly/a"),
1811                msg(0xb2, 1, NOW - 2 * HOUR, "tr.ee/b"),
1812                msg(0xb3, 1, NOW - HOUR, "shm.to/c"),
1813            ],
1814        );
1815        let policy = policy_with(vec![link_rule()]);
1816        let lp = loaded(policy);
1817        let pardon = Override {
1818            target: OverrideTarget::Rule {
1819                policy_hash: lp.hash,
1820                rule_id: "links".into(),
1821                scope: Scope::PerMessage,
1822                subject: sid(1),
1823            },
1824            issuer: sid(0xf0),
1825            issued_at: NOW - HOUR,
1826            expires_at: NOW + HOUR,
1827        };
1828        let r = evaluate(&s, &[lp], &[pardon], NOW);
1829        let pr = only(&r);
1830        let sub = subject(pr, 1).unwrap();
1831        let pardoned = sub.convictions.iter().find(|c| c.scope == Scope::PerMessage).unwrap();
1832        assert!(pardoned.suppressed && !pardoned.combined, "reported, never combined");
1833        // The pardoned conviction's citations are suppressed for the content
1834        // consumer; the surviving per-window conviction keeps citing them.
1835        assert!(pr.citations.iter().any(|c| c.suppressed), "a pardon reaches the content consumer too");
1836        // The per-window conviction was NOT pardoned (scope is part of the key).
1837        assert!(!sub.convictions.iter().find(|c| c.scope == Scope::PerWindow).unwrap().suppressed);
1838    }
1839
1840    #[test]
1841    fn an_inert_policy_reports_why_and_convicts_nothing() {
1842        let mut p = policy_with(vec![link_rule()]);
1843        p.requires = vec!["quarantine".into()];
1844        let r = evaluate(&signals(vec![member(1, Some(2))], vec![]), &[loaded(p)], &[], NOW);
1845        let pr = only(&r);
1846        assert!(matches!(&pr.inert, Some(InertReason::UnknownRequiredKey { key }) if key == "quarantine"));
1847        assert!(pr.subjects.is_empty() && pr.rule_status.is_empty());
1848    }
1849
1850    #[test]
1851    fn evaluation_is_pure_and_ordering_is_canonical() {
1852        let s = signals(
1853            vec![member(3, Some(2)), member(1, Some(2)), member(2, Some(2))],
1854            vec![
1855                msg(0xb3, 3, NOW - HOUR, "bit.ly/c"),
1856                msg(0xb1, 1, NOW - 2 * HOUR, "bit.ly/a"),
1857                msg(0xb2, 2, NOW - 3 * HOUR, "bit.ly/b"),
1858            ],
1859        );
1860        let a = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1861        let b = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1862        assert_eq!(a, b, "same inputs, same report");
1863        let ids: Vec<[u8; 32]> = only(&a).subjects.iter().map(|s| s.subject.0).collect();
1864        let mut sorted = ids.clone();
1865        sorted.sort();
1866        assert_eq!(ids, sorted, "subjects sort by canonical id");
1867        let cits: Vec<[u8; 32]> = only(&a).citations.iter().map(|c| c.id.0).collect();
1868        let mut cs = cits.clone();
1869        cs.sort();
1870        assert_eq!(cits, cs, "citations sort by canonical id");
1871    }
1872
1873    #[test]
1874    fn the_window_clamps_by_recency_and_count() {
1875        let mut p = policy_with(vec![link_rule()]);
1876        p.window = Window { hours: 2, max_messages: 2 };
1877        let s = signals(
1878            vec![member(1, Some(48))],
1879            vec![
1880                msg(0xb0, 1, NOW - 10 * HOUR, "bit.ly/old"), // outside the window
1881                msg(0xb1, 1, NOW - 90 * 60 * 1000, "bit.ly/a"),
1882                msg(0xb2, 1, NOW - 60 * 60 * 1000, "bit.ly/b"),
1883                msg(0xb3, 1, NOW - 30 * 60 * 1000, "bit.ly/c"),
1884            ],
1885        );
1886        let r = evaluate(&s, &[loaded(p)], &[], NOW);
1887        assert_eq!(only(&r).citations.len(), 2, "newest two inside the window; the older two never enter the corpus");
1888    }
1889
1890    fn word_rule(pattern: &str) -> Rule {
1891        Rule {
1892            id: "words".into(),
1893            matcher: Match::Keyword { patterns: vec![pattern.into()], normalize: Normalize::Fold },
1894            tiers: Some(Tiers {
1895                per_message: vec![Rung { hits: 1, severity: Severity::Minor, weight: 10, pierces_trusted: false }],
1896                per_window: vec![],
1897            }),
1898            severity: None,
1899            weight: None,
1900            pierces_trusted: false,
1901            family: None,
1902            armed_by: None,
1903            exempt: Exempt::default(),
1904            enforcement: Enforcement::Advisory,
1905        }
1906    }
1907
1908    fn hits_for(pattern: &str, text: &str) -> usize {
1909        let s = signals(vec![member(1, Some(2))], vec![msg(0xd1, 1, NOW - HOUR, text)]);
1910        let r = evaluate(&s, &[loaded(policy_with(vec![word_rule(pattern)]))], &[], NOW);
1911        only(&r).subjects.len()
1912    }
1913
1914    /// A capitalised word matched nothing at all: the corpus was casefolded and
1915    /// the pattern was not, so the two could never meet. Silent, and the filter
1916    /// looked like it was simply finding nothing in a clean community.
1917    #[test]
1918    fn a_pattern_matches_in_the_same_case_space_as_the_text() {
1919        for pattern in ["Vector", "VECTOR", "vector", "VeCtOr"] {
1920            assert_eq!(hits_for(pattern, "we should ship vector today"), 1, "pattern {pattern:?} caught nothing");
1921        }
1922    }
1923
1924    /// Casefolding the pattern must not quietly turn a whole-word rule into a
1925    /// substring one: token anchoring is the whole reason folding is safe.
1926    #[test]
1927    fn folding_a_pattern_keeps_its_word_anchoring() {
1928        assert_eq!(hits_for("Art", "we should start today"), 0, "\"Art\" must not match inside \"start\"");
1929        assert_eq!(hits_for("*Art*", "we should start today"), 1, "an explicit wildcard still matches inside");
1930    }
1931
1932    fn repeat_policy(within_secs: Option<u64>) -> Policy {
1933        let mut p = window_rule_policy(
1934            Match::Repeat { normalize: Normalize::Skeleton, within_secs },
1935            vec![
1936                Rung { hits: 4, severity: Severity::Major, weight: 50, pierces_trusted: false },
1937                Rung { hits: 8, severity: Severity::Severe, weight: 85, pierces_trusted: false },
1938            ],
1939        );
1940        // The shipped window: the point is that a WEEK of history is in scope
1941        // and the burst span is what narrows it.
1942        p.window = Window { hours: 168, max_messages: 4000 };
1943        p
1944    }
1945
1946    fn repeat_hits(within_secs: Option<u64>, at_hours: &[u64]) -> usize {
1947        let msgs: Vec<MessageSignal> = at_hours
1948            .iter()
1949            .enumerate()
1950            .map(|(i, h)| msg(0xe0 + i as u8, 1, NOW - h * HOUR, "gm"))
1951            .collect();
1952        // No shield: a brand-new member is exactly who this must not misjudge.
1953        let s = signals(vec![member(1, Some(1))], msgs);
1954        let r = evaluate(&s, &[loaded(repeat_policy(within_secs))], &[], NOW);
1955        only(&r).subjects.len()
1956    }
1957
1958    /// A regular saying "gm" once a morning is not a spammer, and counting
1959    /// across the whole seven-day window could not tell the two apart: eight
1960    /// mornings read exactly like eight messages in a minute.
1961    #[test]
1962    fn a_daily_greeting_is_not_a_repeat_burst() {
1963        let mornings: Vec<u64> = (0..8).map(|d| d * 24 + 1).collect();
1964        assert_eq!(repeat_hits(None, &mornings), 1, "the unbounded rule convicts a regular");
1965        assert_eq!(
1966            repeat_hits(Some(super::super::harness::REPEAT_BURST_SECS), &mornings),
1967            0,
1968            "a burst-bounded rule must leave a daily greeting alone"
1969        );
1970    }
1971
1972    /// The thing it is actually for: the same line over and over inside minutes.
1973    #[test]
1974    fn a_burst_of_the_same_line_still_convicts() {
1975        let burst: Vec<u64> = vec![1, 1, 1, 1, 1, 1, 1, 1];
1976        assert_eq!(
1977            repeat_hits(Some(super::super::harness::REPEAT_BURST_SECS), &burst),
1978            1,
1979            "eight identical messages in one span must still be caught"
1980        );
1981    }
1982
1983    /// The engine convicts on EVIDENCE, and one anomaly is not evidence.
1984    ///
1985    /// A join flood alone asks for a look; a join flood that is also one script
1986    /// across many accounts is two independent sources and convicts. The gap
1987    /// between them is what keeps a popular invite link from being staged for
1988    /// removal, so it is pinned here rather than left to the weights.
1989    #[test]
1990    fn one_anomaly_asks_for_a_look_and_two_convict() {
1991        const MIN: u64 = 60 * 1000;
1992        const VARIED: [&str; 12] = [
1993            "hey everyone", "glad to be here", "what is this place",
1994            "found you through a podcast", "nice community", "hello all",
1995            "just looking around", "someone linked me here", "good morning",
1996            "how does this work", "reading the pins now", "thanks for the invite",
1997        ];
1998        // `n` strangers arriving across `span` minutes, brand new and silent but
1999        // for one message each.
2000        let wave = |n: u64, span: u64, same_text: bool| -> (usize, u32) {
2001            let members: Vec<MemberSignal> = (1..=n)
2002                .map(|i| {
2003                    let mut m = member(i as u8, Some(1));
2004                    m.joined_at_ms = Some(NOW - (span * MIN) + (i * span * MIN / n));
2005                    m.first_post_ms = None;
2006                    m.lifetime_messages = 0;
2007                    m
2008                })
2009                .collect();
2010            let msgs: Vec<MessageSignal> = (1..=n)
2011                .map(|i| {
2012                    let text = if same_text {
2013                        "join our airdrop".to_string()
2014                    } else {
2015                        VARIED[(i as usize - 1) % VARIED.len()].to_string()
2016                    };
2017                    msg(0xa0 + i as u8, i as u8, NOW - 2 * MIN, &text)
2018                })
2019                .collect();
2020            let s = signals(members, msgs);
2021            let r = evaluate(&s, &[loaded(crate::community::policy::harness::default_policy())], &[], NOW);
2022            let p = only(&r);
2023            (p.subjects.len(), p.subjects.first().map(|x| x.confidence).unwrap_or(0))
2024        };
2025
2026        // A good afternoon on an invite link: no anomaly at all.
2027        assert_eq!(wave(5, 10, false), (0, 0), "an ordinary invite wave must convict nobody");
2028
2029        // One anomaly — they arrived fast, but they are saying different things.
2030        let (caught, conf) = wave(12, 5, false);
2031        assert_eq!(caught, 12);
2032        assert!((25..50).contains(&conf), "a lone join flood reached {conf}; it must stay a look, not a verdict");
2033
2034        // Two anomalies — arrived fast AND reading from one script.
2035        let (caught, conf) = wave(12, 5, true);
2036        assert_eq!(caught, 12);
2037        assert!(conf >= 75, "a scripted flood only reached {conf}; two sources must convict");
2038
2039        // One anomaly again, the other way round: one script, arriving slowly.
2040        let (caught, conf) = wave(20, 60, true);
2041        assert_eq!(caught, 20);
2042        assert!(conf >= 75, "a scripted wave only reached {conf}");
2043    }
2044}