1use 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#[derive(Debug, Clone)]
19pub struct MessageSignal {
20 pub id: MessageId,
21 pub author: SubjectId,
22 pub channel: Hash32,
23 pub at_ms: u64,
25 pub text: String,
26 pub mentions: u32,
29}
30
31#[derive(Debug, Clone)]
33pub struct MemberSignal {
34 pub subject: SubjectId,
35 pub joined_at_ms: Option<u64>,
38 pub roles: Vec<Hash32>,
40 pub is_staff: bool,
42 pub lifetime_messages: u64,
47 pub first_post_ms: Option<u64>,
52}
53
54#[derive(Debug, Clone)]
57pub struct Signals {
58 pub owner: SubjectId,
59 pub members: Vec<MemberSignal>,
60 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum EvalMode {
77 Member,
78 Admin,
79}
80
81#[derive(Debug, Clone)]
85pub struct LoadedPolicy {
86 pub hash: Hash32,
87 pub policy: Policy,
88 pub activated_at: Option<u64>,
89}
90
91pub fn evaluate(signals: &Signals, policies: &[LoadedPolicy], overrides: &[Override], now_ms: u64) -> ModerationReport {
95 evaluate_as(signals, policies, overrides, now_ms, EvalMode::Admin)
96}
97
98pub fn evaluate_as(
100 signals: &Signals,
101 policies: &[LoadedPolicy],
102 overrides: &[Override],
103 now_ms: u64,
104 mode: EvalMode,
105) -> ModerationReport {
106 let mut reports: Vec<PolicyReport> =
107 policies.iter().map(|p| evaluate_one(signals, p, overrides, now_ms, mode)).collect();
108 reports.sort_by(|a, b| a.policy_hash.0.cmp(&b.policy_hash.0));
109
110 let mut observed_from = u64::MAX;
114 let mut observed_to = 0u64;
115 let mut per_channel: BTreeMap<[u8; 32], (u32, u64, u64)> = BTreeMap::new();
116 for lp in policies {
117 for m in clamp_corpus(&signals.messages, &lp.policy, now_ms) {
118 observed_from = observed_from.min(m.at_ms);
119 observed_to = observed_to.max(m.at_ms);
120 let e = per_channel.entry(m.channel.0).or_insert((0, u64::MAX, 0));
121 e.0 += 1;
122 e.1 = e.1.min(m.at_ms);
123 e.2 = e.2.max(m.at_ms);
124 }
125 }
126 let mut channels: Vec<ChannelCoverage> = signals
127 .channels
128 .iter()
129 .map(|c| {
130 let (messages, from, to) = per_channel.get(&c.0).copied().unwrap_or((0, 0, 0));
131 ChannelCoverage { channel: *c, messages, from: if from == u64::MAX { 0 } else { from }, to }
132 })
133 .collect();
134 channels.sort_by(|a, b| a.channel.0.cmp(&b.channel.0));
135 let mut relays = signals.relays.clone();
136 relays.sort_by(|a, b| a.url.as_bytes().cmp(b.url.as_bytes()));
137
138 let widest_hours = policies.iter().map(|p| p.policy.window.hours).max().unwrap_or(0);
139 let complete = !relays.is_empty()
140 && relays.iter().all(|r| r.eose)
141 && signals.confirmed_from <= now_ms.saturating_sub(widest_hours.saturating_mul(3_600_000));
142
143 ModerationReport {
144 engine_version: caps::ENGINE_VERSION,
145 bundle_version: caps::BUNDLE_VERSION,
146 roster_version: signals.roster_version,
147 override_hash: override_hash(overrides),
148 evaluated_at: now_ms,
149 window: WindowCoverage {
150 requested_from: signals.requested_from,
151 requested_to: signals.requested_to,
152 confirmed_from: signals.confirmed_from,
153 confirmed_to: signals.confirmed_to,
154 observed_from: if observed_from == u64::MAX { 0 } else { observed_from },
155 observed_to,
156 channels,
157 relays,
158 complete,
159 },
160 policies: reports,
161 }
162}
163
164fn 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 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 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 let mut convicted_by_rule: BTreeMap<&str, BTreeSet<[u8; 32]>> = BTreeMap::new();
223
224 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 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 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 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 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 for (bytes, shield) in &shields {
305 if *shield != Shield::None && !subjects.iter().any(|s| s.subject.0 == *bytes) {
306 subjects.push(SubjectReport {
307 subject: SubjectId(*bytes),
308 shield: *shield,
309 confidence: 0,
310 proven: 0,
311 band: super::combine::band(0),
312 convictions: vec![],
313 });
314 }
315 }
316 subjects.sort_by(|a, b| a.subject.0.cmp(&b.subject.0));
317
318 let retained: BTreeSet<[u8; 32]> =
320 subjects.iter().flat_map(|s| s.convictions.iter()).flat_map(|c| c.citations.iter()).map(|c| c.0).collect();
321 all_citations.retain(|c| retained.contains(&c.id.0));
322 all_citations.sort_by(|a, b| a.id.0.cmp(&b.id.0));
323 all_citations.dedup_by(|a, b| a.id == b.id);
324
325 let floor = now_ms.saturating_sub(policy.window.hours.saturating_mul(3_600_000));
326 PolicyReport {
327 policy_hash: lp.hash,
328 inert: None,
329 activated_at: lp.activated_at,
330 coverage_complete: signals.confirmed_from <= floor && signals.relays.iter().all(|r| r.eose),
331 rule_status,
332 subjects,
333 subjects_truncated: 0,
334 citations: all_citations,
335 }
336}
337
338fn 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 if member.subject == signals.owner || member.is_staff {
391 out.insert(b, Shield::Protected);
392 continue;
393 }
394 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 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 if mode == EvalMode::Member && !rule.matcher.is_stateless() {
440 out.state = RuleState::RequiresHistory;
441 return out;
442 }
443
444 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 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
526fn 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 Shield::Trusted => rung_pierces == 0 && !rule.pierces_trusted,
532 Shield::None | Shield::Indeterminate => false,
533 }
534}
535
536fn push_direct_with(
537 rule: &Rule,
538 lp: &LoadedPolicy,
539 subject: SubjectId,
540 mut citations: Vec<Citation>,
541 out: &mut RuleOutcome,
542) {
543 citations.sort_by(|a, b| a.id.0.cmp(&b.id.0));
544 citations.dedup_by(|a, b| a.id == b.id);
545 let citation_count = citations.len() as u32;
546 let earliest = citations.iter().map(|c| c.at).min().unwrap_or(0);
547 let latest = citations.iter().map(|c| c.at).max().unwrap_or(0);
548 citations.truncate(caps::MAX_CITATIONS_PER_CONVICTION);
549 let ids: Vec<CitationId> = citations.iter().map(|c| c.id).collect();
550 out.citations.extend(citations);
551 push_direct(rule, lp, subject, out);
552 if let Some(c) = out.convictions.last_mut() {
553 c.citations = ids;
554 c.citation_count = citation_count;
555 c.earliest_citation_at = earliest;
556 c.latest_citation_at = latest;
557 }
558}
559
560fn push_direct(rule: &Rule, lp: &LoadedPolicy, subject: SubjectId, out: &mut RuleOutcome) {
561 let severity = rule.severity.expect("validated: direct form carries a severity");
562 let weight = rule.weight.expect("validated: direct form carries a weight");
563 out.convictions.push(Conviction {
564 id: conviction_id(&lp.hash, &rule.id, Scope::Whole, 0, &subject),
565 subject,
566 rule_id: rule.id.clone(),
567 scope: Scope::Whole,
568 rung: 0,
569 hits: 1,
570 severity,
571 basis: rule.matcher.basis(),
572 tier_weight: weight,
573 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 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 let mut per_message: BTreeMap<([u8; 32], [u8; 32]), (u32, u64, Vec<Citation>)> = BTreeMap::new();
616
617 for m in corpus {
618 if exempt_channels.contains(&m.channel.0) || subject_exempt(&m.author) {
622 continue;
623 }
624 let (hits, citations) = match &rule.matcher {
625 Match::Keyword { normalize: n, .. } => {
626 let text = normalize::apply(&m.text, *n, codes);
627 let hits = cancel_exempt_hits(&text, keyword_hits(&text, &folded_patterns), exempts);
628 let cits = hits
629 .iter()
630 .map(|h| {
631 let target = CitationTarget::Message { message_id: m.id };
632 let matched: String = text
636 .get(h.start..h.end)
637 .unwrap_or_default()
638 .chars()
639 .take(caps::MAX_DETAIL_LEN)
640 .collect();
641 Citation {
642 id: citation_id(&lp.hash, &rule.id, Scope::PerMessage, &m.author, &target, Some(h.span())),
643 target,
644 at: m.at_ms,
645 span: Some(h.span()),
646 detail: (!matched.is_empty()).then_some(matched),
647 suppressed: false,
648 }
649 })
650 .collect::<Vec<_>>();
651 (hits.len() as u32, cits)
652 }
653 Match::Link { patterns } => {
654 let domains = link_hits(&m.text, patterns, exempts);
655 let target = CitationTarget::Message { message_id: m.id };
656 let cits = if domains.is_empty() {
658 vec![]
659 } else {
660 vec![Citation {
661 id: citation_id(&lp.hash, &rule.id, Scope::PerMessage, &m.author, &target, None),
662 target,
663 at: m.at_ms,
664 span: None,
665 detail: Some(domains.join(", ")),
666 suppressed: false,
667 }]
668 };
669 (domains.len() as u32, cits)
670 }
671 Match::Mentions {} => {
672 let target = CitationTarget::Message { message_id: m.id };
673 let cits = if m.mentions == 0 {
674 vec![]
675 } else {
676 vec![Citation {
677 id: citation_id(&lp.hash, &rule.id, Scope::PerMessage, &m.author, &target, None),
678 target,
679 at: m.at_ms,
680 span: None,
681 detail: None,
682 suppressed: false,
683 }]
684 };
685 (m.mentions, cits)
686 }
687 _ => (0, vec![]),
689 };
690 if hits > 0 {
691 per_message.insert((m.author.0, m.id.0), (hits, m.at_ms, citations));
692 }
693 }
694
695 let mut by_subject: BTreeMap<[u8; 32], Vec<(u32, Vec<Citation>)>> = BTreeMap::new();
698 for ((subject, _), (hits, _at, cits)) in per_message {
699 by_subject.entry(subject).or_default().push((hits, cits));
700 }
701
702 for (subject_bytes, messages) in by_subject {
703 let subject = SubjectId(subject_bytes);
704 let density: u32 = messages.iter().map(|(h, _)| *h).max().unwrap_or(0);
706 let persistence: u32 = messages.iter().map(|(h, _)| *h).sum();
707
708 for (scope, rungs, hits) in [
709 (Scope::PerMessage, &tiers.per_message, density),
710 (Scope::PerWindow, &tiers.per_window, persistence),
711 ] {
712 if rungs.is_empty() {
713 continue;
714 }
715 let Some((idx, rung)) = rungs.iter().enumerate().filter(|(_, g)| hits >= g.hits).next_back() else {
717 continue;
718 };
719 if gated(shields, &subject, rule, usize::from(rung.pierces_trusted)) {
720 continue;
721 }
722 let citations: Vec<Citation> = match scope {
726 Scope::PerMessage => {
727 messages.iter().filter(|(h, _)| *h >= rung.hits).flat_map(|(_, c)| c.iter().cloned()).collect()
728 }
729 _ => messages.iter().flat_map(|(_, c)| c.iter().cloned()).collect(),
730 };
731 push_tiered(rule, lp, subject, scope, idx as u8, hits, rung, citations, out);
732 }
733 }
734}
735
736#[allow(clippy::too_many_arguments)]
748fn cohort_rule(
749 rule: &Rule,
750 corpus: &[&MessageSignal],
751 codes: &EmojiCodes,
752 exempt_channels: &BTreeSet<[u8; 32]>,
753 shields: &BTreeMap<[u8; 32], Shield>,
754 lp: &LoadedPolicy,
755 subject_exempt: &dyn Fn(&SubjectId) -> bool,
756 signals: &Signals,
757 min: u32,
758 quiet_max: u32,
759 short_factor: u32,
760 thin_ratio: Ratio,
761 out: &mut RuleOutcome,
762) {
763 let mut clusters: BTreeMap<String, BTreeMap<[u8; 32], Vec<&MessageSignal>>> = BTreeMap::new();
765 for m in corpus {
766 let key = normalize::skeleton(&m.text, codes);
767 if key.is_empty() {
768 continue;
769 }
770 clusters.entry(key).or_default().entry(m.author.0).or_default().push(m);
771 }
772 let mut volume: BTreeMap<[u8; 32], u32> = BTreeMap::new();
774 for m in corpus {
775 *volume.entry(m.author.0).or_insert(0) += 1;
776 }
777
778 let mut best: BTreeMap<[u8; 32], (usize, String)> = BTreeMap::new();
781 for (key, authors) in &clusters {
782 let need = if key.chars().count() < caps::MIN_SKELETON_LEN {
783 min.saturating_mul(short_factor)
784 } else {
785 min
786 } as usize;
787 if authors.len() < need {
788 continue;
789 }
790 let thin = authors.keys().filter(|a| volume.get(*a).copied().unwrap_or(0) <= quiet_max).count();
791 if (thin as u64) * (thin_ratio.denom as u64) < (authors.len() as u64) * (thin_ratio.num as u64) {
792 continue;
793 }
794 for author in authors.keys() {
795 let slot = best.entry(*author).or_insert((0, String::new()));
796 if authors.len() > slot.0 {
797 *slot = (authors.len(), key.clone());
798 }
799 }
800 }
801
802 for (author_bytes, (size, key)) in best {
803 let subject = SubjectId(author_bytes);
804 if subject_exempt(&subject) || gated(shields, &subject, rule, usize::from(rule.pierces_trusted)) {
805 continue;
806 }
807 let messages = clusters.get(&key).and_then(|a| a.get(&author_bytes)).cloned().unwrap_or_default();
808 let citable: Vec<&MessageSignal> =
811 messages.iter().copied().filter(|m| !exempt_channels.contains(&m.channel.0)).collect();
812 if citable.is_empty() {
813 continue;
814 }
815 let citations: Vec<Citation> = citable
816 .iter()
817 .map(|m| {
818 let target = CitationTarget::Message { message_id: m.id };
819 Citation {
820 id: citation_id(&lp.hash, &rule.id, Scope::Whole, &subject, &target, None),
821 target,
822 at: m.at_ms,
823 span: None,
824 detail: None,
825 suppressed: false,
826 }
827 })
828 .collect();
829 let sample = clusters
833 .get(&key)
834 .map(|authors| {
835 let mut candidates: Vec<&MessageSignal> = authors
836 .iter()
837 .filter(|(a, _)| !subject_exempt(&SubjectId(**a)))
838 .flat_map(|(_, ms)| ms.iter().copied())
839 .filter(|m| !exempt_channels.contains(&m.channel.0))
840 .collect();
841 candidates.sort_by(|a, b| a.id.0.cmp(&b.id.0));
842 candidates.first().map(|m| m.text.clone()).unwrap_or_default()
843 })
844 .unwrap_or_default();
845 let mut peers: Vec<SubjectId> = clusters
846 .get(&key)
847 .map(|a| a.keys().filter(|k| **k != author_bytes).map(|k| SubjectId(*k)).collect())
848 .unwrap_or_default();
849 peers.sort_by(|a, b| a.0.cmp(&b.0));
850 peers.truncate(caps::COHORT_SAMPLE_CAP);
851 use sha2::Digest;
852 let mut hasher = sha2::Sha256::default();
853 hasher.update(key.as_bytes());
854 let evidence = vec![Evidence::Cohort {
855 skeleton_hash: Hash32(hasher.finalize().into()),
856 sample: sample.chars().take(caps::MAX_SAMPLE_LEN).collect(),
857 size: size as u32,
858 peers,
859 }];
860 let before = out.convictions.len();
861 push_direct_with(rule, lp, subject, citations, out);
862 if let Some(c) = out.convictions.get_mut(before) {
863 c.evidence = evidence;
864 }
865 }
866 let _ = signals;
867}
868
869#[allow(clippy::too_many_arguments)]
874fn join_burst_rule(
875 rule: &Rule,
876 signals: &Signals,
877 shields: &BTreeMap<[u8; 32], Shield>,
878 lp: &LoadedPolicy,
879 subject_exempt: &dyn Fn(&SubjectId) -> bool,
880 gap_secs: u64,
881 min: u32,
882 out: &mut RuleOutcome,
883) {
884 let mut joins: Vec<(u64, SubjectId)> = signals
887 .members
888 .iter()
889 .filter(|m| m.subject != signals.owner)
890 .filter_map(|m| m.joined_at_ms.map(|at| (at, m.subject)))
891 .collect();
892 joins.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1 .0.cmp(&b.1 .0)));
893 let gap_ms = gap_secs.saturating_mul(1000);
894 let (mut best_start, mut best_len) = (0usize, 0usize);
895 let mut start = 0usize;
896 for end in 0..joins.len() {
897 while joins[end].0.saturating_sub(joins[start].0) > gap_ms {
898 start += 1;
899 }
900 if end + 1 - start > best_len {
903 best_len = end + 1 - start;
904 best_start = start;
905 }
906 }
907 if best_len < min as usize {
908 return;
909 }
910 let window = &joins[best_start..best_start + best_len];
911 let evidence = vec![Evidence::Burst {
912 from: window.first().map(|(at, _)| *at).unwrap_or(0),
913 to: window.last().map(|(at, _)| *at).unwrap_or(0),
914 size: best_len as u32,
915 }];
916 for (_, subject) in window {
917 if subject_exempt(subject) || gated(shields, subject, rule, usize::from(rule.pierces_trusted)) {
918 continue;
919 }
920 let before = out.convictions.len();
921 push_direct(rule, lp, *subject, out);
922 if let Some(c) = out.convictions.get_mut(before) {
923 c.evidence = evidence.clone();
924 }
925 }
926}
927
928#[allow(clippy::too_many_arguments)]
937fn window_rule(
938 rule: &Rule,
939 corpus: &[&MessageSignal],
940 codes: &EmojiCodes,
941 exempt_channels: &BTreeSet<[u8; 32]>,
942 shields: &BTreeMap<[u8; 32], Shield>,
943 lp: &LoadedPolicy,
944 subject_exempt: &dyn Fn(&SubjectId) -> bool,
945 out: &mut RuleOutcome,
946) {
947 let tiers = rule.tiers.as_ref().expect("validated: window rules carry tiers");
948 let rungs = &tiers.per_window;
949 if rungs.is_empty() {
950 return;
951 }
952 let mut by_author: BTreeMap<[u8; 32], Vec<&MessageSignal>> = BTreeMap::new();
955 for m in corpus {
956 if exempt_channels.contains(&m.channel.0) || subject_exempt(&m.author) {
957 continue;
958 }
959 by_author.entry(m.author.0).or_default().push(m);
960 }
961
962 for (subject_bytes, messages) in by_author {
963 let subject = SubjectId(subject_bytes);
964 let (hits, cited, evidence) = match &rule.matcher {
965 Match::Repeat { normalize: n, within_secs } => {
966 let mut groups: BTreeMap<String, Vec<&MessageSignal>> = BTreeMap::new();
967 for m in &messages {
968 let key = normalize::apply(&m.text, *n, codes);
969 if key.is_empty() {
970 continue;
971 }
972 groups.entry(key).or_default().push(m);
973 }
974 let scored: Vec<(String, u32, Vec<&MessageSignal>)> = groups
980 .into_iter()
981 .map(|(key, mut ms)| {
982 let Some(secs) = within_secs else {
983 let n = ms.len() as u32;
984 return (key, n, ms);
985 };
986 ms.sort_by_key(|m| m.at_ms);
987 let span = secs.saturating_mul(1000);
988 let (mut best, mut from) = (0usize, 0u64);
989 for (i, m) in ms.iter().enumerate() {
990 let start = m.at_ms;
993 let count =
994 ms[i..].iter().take_while(|x| x.at_ms < start.saturating_add(span)).count();
995 if count > best {
996 best = count;
997 from = start;
998 }
999 }
1000 let cited: Vec<&MessageSignal> = ms
1001 .into_iter()
1002 .filter(|m| m.at_ms >= from && m.at_ms < from.saturating_add(span))
1003 .collect();
1004 (key, best as u32, cited)
1005 })
1006 .collect();
1007 let Some((_key, n_hits, winners)) =
1009 scored.into_iter().max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(&a.0)))
1010 else {
1011 continue;
1012 };
1013 (n_hits, winners, vec![])
1014 }
1015 Match::Rate { per_secs } => {
1016 let mut times: Vec<u64> = messages.iter().map(|m| m.at_ms).collect();
1017 times.sort_unstable();
1018 let span = per_secs.saturating_mul(1000);
1019 let (mut best, mut best_from) = (0usize, 0u64);
1020 for (i, &start) in times.iter().enumerate() {
1021 let count = times[i..].iter().take_while(|&&t| t < start.saturating_add(span)).count();
1024 if count > best {
1025 best = count;
1026 best_from = start;
1027 }
1028 }
1029 let cited: Vec<&MessageSignal> = messages
1030 .iter()
1031 .copied()
1032 .filter(|m| m.at_ms >= best_from && m.at_ms < best_from.saturating_add(span))
1033 .collect();
1034 (
1035 best as u32,
1036 cited,
1037 vec![Evidence::Rate { window_secs: *per_secs, count: best as u32, from: best_from }],
1038 )
1039 }
1040 _ => continue,
1041 };
1042
1043 let Some((idx, rung)) = rungs.iter().enumerate().filter(|(_, g)| hits >= g.hits).next_back() else {
1044 continue;
1045 };
1046 if gated(shields, &subject, rule, usize::from(rung.pierces_trusted)) {
1047 continue;
1048 }
1049 let citations: Vec<Citation> = cited
1050 .iter()
1051 .map(|m| {
1052 let target = CitationTarget::Message { message_id: m.id };
1053 Citation {
1054 id: citation_id(&lp.hash, &rule.id, Scope::PerWindow, &subject, &target, None),
1055 target,
1056 at: m.at_ms,
1057 span: None,
1058 detail: None,
1059 suppressed: false,
1060 }
1061 })
1062 .collect();
1063 let before = out.convictions.len();
1064 push_tiered(rule, lp, subject, Scope::PerWindow, idx as u8, hits, rung, citations, out);
1065 if let Some(c) = out.convictions.get_mut(before) {
1066 c.evidence = evidence;
1067 }
1068 }
1069}
1070
1071#[allow(clippy::too_many_arguments)]
1072fn push_tiered(
1073 rule: &Rule,
1074 lp: &LoadedPolicy,
1075 subject: SubjectId,
1076 scope: Scope,
1077 rung_idx: u8,
1078 hits: u32,
1079 rung: &Rung,
1080 mut citations: Vec<Citation>,
1081 out: &mut RuleOutcome,
1082) {
1083 citations.sort_by(|a, b| a.id.0.cmp(&b.id.0));
1084 citations.dedup_by(|a, b| a.id == b.id);
1085 let citation_count = citations.len() as u32;
1088 let earliest = citations.iter().map(|c| c.at).min().unwrap_or(0);
1089 let latest = citations.iter().map(|c| c.at).max().unwrap_or(0);
1090 let retroactive = match lp.activated_at {
1093 None => Retroactive::Unknown,
1094 Some(activated) => {
1095 if citations.is_empty() {
1096 Retroactive::Unknown
1097 } else if latest >= activated {
1098 Retroactive::No
1099 } else {
1100 Retroactive::Yes
1101 }
1102 }
1103 };
1104 citations.truncate(caps::MAX_CITATIONS_PER_CONVICTION);
1105 let ids: Vec<CitationId> = citations.iter().map(|c| c.id).collect();
1106 out.citations.extend(citations);
1107 out.convictions.push(Conviction {
1108 id: conviction_id(&lp.hash, &rule.id, scope, rung_idx, &subject),
1109 subject,
1110 rule_id: rule.id.clone(),
1111 scope,
1112 rung: rung_idx,
1113 hits,
1114 severity: rung.severity,
1115 basis: rule.matcher.basis(),
1116 tier_weight: rung.weight,
1117 retroactive,
1118 suppressed: false,
1119 folded: false,
1120 folded_into: None,
1121 combined: false,
1122 proven_combined: false,
1123 citations: ids,
1124 citation_count,
1125 earliest_citation_at: earliest,
1126 latest_citation_at: latest,
1127 family: rule.family.clone(),
1128 evidence: vec![],
1129 });
1130}
1131
1132pub fn refusing_rules(policy: &Policy) -> Vec<&Rule> {
1135 policy.rules.iter().filter(|r| r.enforcement == Enforcement::Refuse).collect()
1136}
1137
1138pub fn merged_exempt<'a>(policy: &'a Exempt, rule: &'a Exempt) -> Vec<&'a ExemptPatterns> {
1140 policy.patterns.iter().chain(rule.patterns.iter()).collect()
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145 use super::super::document::*;
1146 use super::*;
1147
1148 const HOUR: u64 = 3_600_000;
1149 const NOW: u64 = 1_800_000_000_000;
1150
1151 fn sid(b: u8) -> SubjectId {
1152 SubjectId([b; 32])
1153 }
1154 fn mid(b: u8) -> MessageId {
1155 MessageId([b; 32])
1156 }
1157 fn ch(b: u8) -> Hash32 {
1158 Hash32([b; 32])
1159 }
1160
1161 fn msg(id: u8, author: u8, at: u64, text: &str) -> MessageSignal {
1162 MessageSignal { id: mid(id), author: sid(author), channel: ch(0x0a), at_ms: at, text: text.into(), mentions: 0 }
1163 }
1164
1165 fn member(b: u8, joined_ago_h: Option<u64>) -> MemberSignal {
1166 MemberSignal {
1167 subject: sid(b),
1168 joined_at_ms: joined_ago_h.map(|h| NOW - h * HOUR),
1169 roles: vec![],
1170 is_staff: false,
1171 lifetime_messages: 0,
1172 first_post_ms: None,
1173 }
1174 }
1175
1176 fn signals(members: Vec<MemberSignal>, messages: Vec<MessageSignal>) -> Signals {
1177 Signals {
1178 owner: sid(0xf0),
1179 members,
1180 messages,
1181 channels: vec![ch(0x0a)],
1182 relays: vec![RelayCoverage { url: "wss://r".into(), eose: true, events: 10 }],
1183 requested_from: NOW - 24 * HOUR,
1184 requested_to: NOW,
1185 confirmed_from: 0,
1186 confirmed_to: NOW,
1187 roster_version: Hash32([0x77; 32]),
1188 }
1189 }
1190
1191 fn loaded(policy: Policy) -> LoadedPolicy {
1192 LoadedPolicy { hash: Hash32([0x11; 32]), policy, activated_at: None }
1193 }
1194
1195 fn policy_with(rules: Vec<Rule>) -> Policy {
1196 Policy {
1197 format: FORMAT,
1198 requires: vec![],
1199 name: "p".into(),
1200 emoji_codes: vec![],
1201 shields: Shields::default(),
1202 window: Window::default(),
1203 exempt: Exempt::default(),
1204 rules,
1205 }
1206 }
1207
1208 fn link_rule() -> Rule {
1209 Rule {
1210 id: "links".into(),
1211 matcher: Match::Link { patterns: vec![] },
1212 tiers: Some(Tiers {
1213 per_message: vec![Rung { hits: 1, severity: Severity::Severe, weight: 70, pierces_trusted: false }],
1214 per_window: vec![Rung { hits: 3, severity: Severity::Severe, weight: 90, pierces_trusted: false }],
1215 }),
1216 severity: None,
1217 weight: None,
1218 pierces_trusted: false,
1219 family: None,
1220 armed_by: None,
1221 exempt: Exempt {
1222 patterns: vec![ExemptPatterns {
1223 kind: Some(ExemptKind::Domain),
1224 values: vec!["vectorapp.io".into()],
1225 }],
1226 ..Default::default()
1227 },
1228 enforcement: Enforcement::Advisory,
1229 }
1230 }
1231
1232 fn only(report: &ModerationReport) -> &PolicyReport {
1233 &report.policies[0]
1234 }
1235
1236 fn subject<'a>(pr: &'a PolicyReport, b: u8) -> Option<&'a SubjectReport> {
1237 pr.subjects.iter().find(|s| s.subject == sid(b))
1238 }
1239
1240 #[test]
1243 fn strict_link_blocker_reproduces_the_reference_vector() {
1244 let s = signals(
1245 vec![member(1, Some(2))],
1246 vec![
1247 msg(0xb1, 1, NOW - 3 * HOUR, "grab it at bit.ly/a"),
1248 msg(0xb2, 1, NOW - 2 * HOUR, "also tr.ee/b"),
1249 msg(0xb3, 1, NOW - HOUR, "and shm.to/c"),
1250 ],
1251 );
1252 let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1253 let pr = only(&r);
1254 let sub = subject(pr, 1).expect("the linker is reported");
1255 assert_eq!((sub.confidence, sub.proven), (97, 97), "both scopes, both Deterministic");
1256 assert_eq!(sub.band, Band::Alert);
1257 let scopes: Vec<Scope> = sub.convictions.iter().map(|c| c.scope).collect();
1258 assert_eq!(scopes, vec![Scope::PerMessage, Scope::PerWindow], "density and persistence both convict");
1259 assert_eq!(pr.citations.len(), 3, "one citation per offending message");
1260 assert!(pr.citations.iter().all(|c| c.span.is_none()), "a link hit is a domain, not a text span");
1261 }
1262
1263 #[test]
1264 fn an_allowlisted_domain_never_produces_a_hit() {
1265 let s = signals(
1266 vec![member(1, Some(2))],
1267 vec![msg(0xb1, 1, NOW - HOUR, "read https://vectorapp.io/blog and https://vectorapp.io/x")],
1268 );
1269 let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1270 assert!(subject(only(&r), 1).is_none(), "no conviction, and no subject to report");
1271 assert!(only(&r).citations.is_empty());
1272 }
1273
1274 #[test]
1277 fn an_exempt_channel_contributes_no_hits() {
1278 let mut s = signals(
1279 vec![member(1, Some(2))],
1280 vec![msg(0xb1, 1, NOW - HOUR, "bit.ly/a"), msg(0xb2, 1, NOW - HOUR, "tr.ee/b")],
1281 );
1282 s.messages[1].channel = ch(0x0b);
1283 let mut p = policy_with(vec![link_rule()]);
1284 p.exempt.channels = vec![crate::simd::hex::bytes_to_hex_32(&[0x0b; 32])];
1285 let r = evaluate(&s, &[loaded(p)], &[], NOW);
1286 let sub = subject(only(&r), 1).unwrap();
1287 assert_eq!(sub.convictions.len(), 1, "only the density rung is reached; two links would have been needed");
1288 assert_eq!(sub.convictions[0].scope, Scope::PerMessage);
1289 assert_eq!(only(&r).citations.len(), 1, "the exempt channel's message is never cited");
1290 }
1291
1292 #[test]
1293 fn the_soft_swear_ladder_lands_in_review_not_alert() {
1294 let keyword = Rule {
1295 id: "swears".into(),
1296 matcher: Match::Keyword { patterns: vec!["darn".into()], normalize: Normalize::Fold },
1297 tiers: Some(Tiers {
1298 per_message: vec![Rung { hits: 1, severity: Severity::Minor, weight: 10, pierces_trusted: false }],
1299 per_window: vec![Rung { hits: 10, severity: Severity::Severe, weight: 70, pierces_trusted: false }],
1300 }),
1301 severity: None,
1302 weight: None,
1303 pierces_trusted: false,
1304 family: None,
1305 armed_by: None,
1306 exempt: Exempt::default(),
1307 enforcement: Enforcement::Advisory,
1308 };
1309 let messages: Vec<MessageSignal> =
1310 (0..10).map(|i| msg(0xc0 + i as u8, 1, NOW - (i + 1) * HOUR, "darn it")).collect();
1311 let s = signals(vec![member(1, Some(2))], messages);
1312 let r = evaluate(&s, &[loaded(policy_with(vec![keyword]))], &[], NOW);
1313 let sub = subject(only(&r), 1).unwrap();
1314 assert_eq!(sub.confidence, 73);
1316 assert_eq!(sub.band, Band::Flagged);
1317 }
1318
1319 #[test]
1322 fn shields_gate_exactly_as_declared() {
1323 let mut s = signals(
1324 vec![
1325 member(1, Some(24 * 30)), member(2, None), MemberSignal { subject: sid(3), joined_at_ms: Some(NOW - HOUR), roles: vec![], is_staff: true, lifetime_messages: 0, first_post_ms: None },
1328 ],
1329 vec![],
1330 );
1331 const WORDS: [&str; 20] = [
1335 "morning all", "shipping today", "nice one", "agreed", "on it", "good catch", "thanks",
1336 "will review", "merged", "looks fine", "same here", "no idea", "try again", "fixed now",
1337 "welcome aboard", "see you", "sounds good", "my turn", "any thoughts", "done",
1338 ];
1339 for (i, w) in WORDS.iter().enumerate() {
1340 s.messages.push(msg(0x40 + i as u8, 1, NOW - (i as u64 + 1) * HOUR, w));
1341 }
1342 s.messages.push(msg(0xd1, 1, NOW - HOUR, "bit.ly/a"));
1343 s.messages.push(msg(0xd2, 2, NOW - HOUR, "bit.ly/b"));
1344 s.messages.push(msg(0xd3, 3, NOW - HOUR, "bit.ly/c"));
1345
1346 let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1347 let pr = only(&r);
1348 assert_eq!(subject(pr, 1).unwrap().shield, Shield::Trusted);
1349 assert!(subject(pr, 1).unwrap().convictions.is_empty(), "a non-piercing rule cannot touch Trusted");
1350 assert_eq!(subject(pr, 2).unwrap().shield, Shield::None);
1353 assert!(!subject(pr, 2).unwrap().convictions.is_empty(), "no shield, ordinary conviction");
1354 assert_eq!(subject(pr, 3).unwrap().shield, Shield::Protected);
1355 assert!(subject(pr, 3).unwrap().convictions.is_empty(), "staff are never convicted");
1356
1357 let mut piercing = link_rule();
1359 piercing.tiers.as_mut().unwrap().per_message[0].pierces_trusted = true;
1360 piercing.pierces_trusted = true;
1361 let r = evaluate(&s, &[loaded(policy_with(vec![piercing]))], &[], NOW);
1362 assert!(!subject(only(&r), 1).unwrap().convictions.is_empty(), "a piercing rung reaches Trusted");
1363 assert!(subject(only(&r), 3).unwrap().convictions.is_empty(), "but nothing pierces Protected");
1364 }
1365
1366 #[test]
1368 fn trust_comes_from_roles_tenure_or_sustained_activity() {
1369 let day = 24 * HOUR;
1370 let with_role =
1373 MemberSignal { subject: sid(1), joined_at_ms: Some(NOW - day), roles: vec![ch(0xaa)], is_staff: false, lifetime_messages: 0, first_post_ms: None };
1374 let veteran = member(2, Some(24 * 40));
1375 let active = member(3, Some(24 * 8));
1376 let lurker = member(4, Some(24 * 8));
1377 let loud_newcomer = member(5, Some(1));
1378
1379 let mut s = signals(vec![with_role, veteran, active, lurker, loud_newcomer], vec![]);
1380 s.messages.push(msg(0x50, 2, NOW - HOUR, "still here"));
1381 const VARIED: [&str; 5] = ["morning all", "shipping today", "nice one", "agreed", "on it"];
1382 for (i, w) in VARIED.iter().enumerate() {
1383 s.messages.push(msg(0x60 + i as u8, 3, NOW - (i as u64 + 1) * HOUR, w));
1384 s.messages.push(msg(0x70 + i as u8, 5, NOW - (i as u64 + 1) * HOUR, w));
1385 }
1386
1387 let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1388 let pr = only(&r);
1389 let shield = |b: u8| subject(pr, b).map(|x| x.shield);
1390 assert_eq!(shield(1), Some(Shield::Trusted), "a granted role vouches");
1391 assert_eq!(shield(2), Some(Shield::Trusted), "long tenure plus any activity");
1392 assert_eq!(shield(3), Some(Shield::Trusted), "tenure with volume and variety");
1393 assert_eq!(shield(4), None, "a silent week earns nothing");
1394 assert_eq!(shield(5), None, "and chatter cannot buy standing in a day");
1395 }
1396
1397 #[test]
1400 fn a_quiet_week_does_not_cost_a_regular_their_standing() {
1401 let mut veteran = member(6, Some(24 * 10));
1402 veteran.lifetime_messages = 900;
1403 let mut newcomer = member(7, Some(2));
1404 newcomer.lifetime_messages = 900; let mut s = signals(vec![veteran, newcomer], vec![]);
1406 for (i, w) in ["morning all", "shipping today", "nice one"].iter().enumerate() {
1408 s.messages.push(msg(0x80 + i as u8, 6, NOW - (i as u64 + 1) * HOUR, w));
1409 s.messages.push(msg(0x90 + i as u8, 7, NOW - (i as u64 + 1) * HOUR, w));
1410 }
1411 let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1412 let pr = only(&r);
1413 assert_eq!(subject(pr, 6).map(|x| x.shield), Some(Shield::Trusted), "ten days and a long history");
1414 assert_eq!(subject(pr, 7).map(|x| x.shield), None, "tenure still gates: two hours buys nothing");
1415 }
1416
1417 #[test]
1420 fn an_early_member_who_lurks_for_months_keeps_their_standing() {
1421 let mut lurker = member(8, Some(24 * 200));
1422 lurker.lifetime_messages = 400; let s = signals(vec![lurker], vec![]); let r = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1425 assert_eq!(subject(only(&r), 8).map(|x| x.shield), Some(Shield::Trusted));
1426 }
1427
1428 fn cohort_rule_doc() -> Rule {
1432 Rule {
1433 id: "cohort".into(),
1434 matcher: Match::Cohort { min: 3, quiet_max: 2, short_factor: 3, thin_ratio: None },
1435 tiers: None,
1436 severity: Some(Severity::Severe),
1437 weight: Some(85),
1438 pierces_trusted: false,
1439 family: None,
1440 armed_by: None,
1441 exempt: Exempt::default(),
1442 enforcement: Enforcement::Advisory,
1443 }
1444 }
1445
1446 #[test]
1449 fn a_hundred_fresh_accounts_saying_one_thing_is_a_raid() {
1450 let mut members: Vec<MemberSignal> = (0..100).map(|i| member(i as u8 + 10, Some(1))).collect();
1451 let mut messages: Vec<MessageSignal> =
1452 (0..100).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "hello world")).collect();
1453 members.push(member(2, Some(24 * 60)));
1455 members.last_mut().unwrap().lifetime_messages = 800;
1456 messages.push(msg(0xf1, 2, NOW - 2 * HOUR, "hello world"));
1457 for (i, w) in ["morning all", "shipping today", "nice one"].iter().enumerate() {
1458 messages.push(msg(0xf2 + i as u8, 2, NOW - (i as u64 + 3) * HOUR, w));
1459 }
1460
1461 let s = signals(members, messages);
1462 let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc()]))], &[], NOW);
1463 let pr = only(&r);
1464 let convicted: Vec<&SubjectReport> =
1465 pr.subjects.iter().filter(|x| x.convictions.iter().any(|c| !c.suppressed)).collect();
1466 assert_eq!(convicted.len(), 100, "every raider, and only the raiders");
1467 assert!(convicted.iter().all(|x| x.confidence == 85 && x.band == Band::Alert));
1468 assert!(convicted.iter().all(|x| x.proven == 0), "a cohort is inference, not proof");
1470 assert_eq!(subject(pr, 2).map(|x| x.shield), Some(Shield::Trusted), "the regular is shielded, not convicted");
1471 assert!(subject(pr, 2).unwrap().convictions.is_empty());
1472
1473 let ev = &convicted[0].convictions[0].evidence;
1474 assert!(
1475 matches!(ev.first(), Some(Evidence::Cohort { size: 101, .. })),
1476 "the exhibit names the true cluster size: {ev:?}"
1477 );
1478 }
1479
1480 #[test]
1483 fn someone_already_removed_is_never_convicted_again() {
1484 let members: Vec<MemberSignal> = (0..3).map(|i| member(i as u8 + 10, Some(1))).collect();
1486 let messages: Vec<MessageSignal> =
1487 (0..20).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "free airdrop claim now")).collect();
1488 let s = signals(members, messages);
1489 let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc()]))], &[], NOW);
1490 let pr = only(&r);
1491 assert_eq!(pr.subjects.len(), 3, "the seventeen who are gone are not re-litigated");
1492 assert!(pr.subjects.iter().all(|x| !x.convictions.is_empty()), "the three still here are convicted");
1493 let ev = &pr.subjects[0].convictions[0].evidence;
1496 assert!(matches!(ev.first(), Some(Evidence::Cohort { size: 20, .. })), "{ev:?}");
1497 }
1498
1499 #[test]
1503 fn a_past_raid_does_not_arm_a_rule_against_the_living() {
1504 let mut burst = Rule {
1505 id: "burst".into(),
1506 matcher: Match::JoinBurst { gap_secs: 600, min: 5 },
1507 tiers: None,
1508 severity: Some(Severity::Major),
1509 weight: Some(40),
1510 pierces_trusted: false,
1511 family: None,
1512 armed_by: Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] }),
1513 exempt: Exempt::default(),
1514 enforcement: Enforcement::Advisory,
1515 };
1516 let members: Vec<MemberSignal> = (0..6)
1519 .map(|i| MemberSignal {
1520 subject: sid(i as u8 + 40),
1521 joined_at_ms: Some(NOW - HOUR + i * 30_000),
1522 roles: vec![],
1523 is_staff: false,
1524 lifetime_messages: 0,
1525 first_post_ms: None,
1526 })
1527 .collect();
1528 let messages: Vec<MessageSignal> =
1529 (0..20).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "free airdrop claim now")).collect();
1530 let s = signals(members, messages);
1531 let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc(), burst.clone()]))], &[], NOW);
1532 assert_eq!(
1533 only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count(),
1534 0,
1535 "the cohort is entirely gone, so it arms nothing against the living"
1536 );
1537
1538 burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] });
1540 let mut members: Vec<MemberSignal> = (0..6)
1541 .map(|i| MemberSignal {
1542 subject: sid(i as u8 + 40),
1543 joined_at_ms: Some(NOW - HOUR + i * 30_000),
1544 roles: vec![],
1545 is_staff: false,
1546 lifetime_messages: 0,
1547 first_post_ms: None,
1548 })
1549 .collect();
1550 members.extend((0..20).map(|i| member(i as u8 + 10, Some(1))));
1551 let messages: Vec<MessageSignal> =
1552 (0..20).map(|i| msg(0x10 + i as u8, i as u8 + 10, NOW - HOUR, "free airdrop claim now")).collect();
1553 let s = signals(members, messages);
1554 let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc(), burst]))], &[], NOW);
1555 let convicted = only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count();
1556 assert!(convicted >= 20, "a live cohort arms it: {convicted}");
1557 }
1558
1559 #[test]
1562 fn a_community_catchphrase_is_not_a_cohort() {
1563 let members: Vec<MemberSignal> = (0..20)
1564 .map(|i| {
1565 let mut m = member(i as u8 + 10, Some(24 * 60));
1566 m.lifetime_messages = 500;
1567 m
1568 })
1569 .collect();
1570 let mut messages: Vec<MessageSignal> = Vec::new();
1571 for i in 0..20u8 {
1572 messages.push(msg(0x20 + i, i + 10, NOW - HOUR, "good morning everyone"));
1574 for (j, w) in ["shipping today", "nice one", "agreed", "on it"].iter().enumerate() {
1575 messages.push(msg(0x60 + i * 4 + j as u8, i + 10, NOW - (j as u64 + 2) * HOUR, w));
1576 }
1577 }
1578 let s = signals(members, messages);
1579 let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc()]))], &[], NOW);
1580 let convicted = only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count();
1581 assert_eq!(convicted, 0, "twenty chatty regulars sharing a greeting are not a raid");
1582 }
1583
1584 #[test]
1587 fn an_invite_link_burst_convicts_nobody_on_its_own() {
1588 let mut burst = Rule {
1589 id: "burst".into(),
1590 matcher: Match::JoinBurst { gap_secs: 600, min: 5 },
1591 tiers: None,
1592 severity: Some(Severity::Major),
1593 weight: Some(40),
1594 pierces_trusted: false,
1595 family: None,
1596 armed_by: None,
1597 exempt: Exempt::default(),
1598 enforcement: Enforcement::Advisory,
1599 };
1600 let members: Vec<MemberSignal> = (0..10)
1602 .map(|i| MemberSignal {
1603 subject: sid(i as u8 + 10),
1604 joined_at_ms: Some(NOW - HOUR + i * 30_000),
1605 roles: vec![],
1606 is_staff: false,
1607 lifetime_messages: 0,
1608 first_post_ms: None,
1609 })
1610 .collect();
1611 let s = signals(members, vec![]);
1612
1613 let r = evaluate(&s, &[loaded(policy_with(vec![burst.clone()]))], &[], NOW);
1616 assert_eq!(only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count(), 10);
1617
1618 burst.armed_by = Some(ArmedBy { rule: "cohort".into(), scope: ArmScope::Community, min_subjects: Some(3), also: vec![] });
1620 let r = evaluate(&s, &[loaded(policy_with(vec![cohort_rule_doc(), burst]))], &[], NOW);
1621 assert_eq!(
1622 only(&r).subjects.iter().filter(|x| !x.convictions.is_empty()).count(),
1623 0,
1624 "no cohort, no conviction: an invite link is not an attack"
1625 );
1626 }
1627
1628 #[test]
1629 fn a_member_evaluates_only_stateless_rules() {
1630 let stateful = Rule {
1631 id: "quiet".into(),
1632 matcher: Match::MessagesLte { n: 2 },
1633 tiers: None,
1634 severity: Some(Severity::Notice),
1635 weight: Some(10),
1636 pierces_trusted: false,
1637 family: None,
1638 armed_by: None,
1639 exempt: Exempt::default(),
1640 enforcement: Enforcement::Advisory,
1641 };
1642 let s = signals(vec![member(1, Some(2))], vec![msg(0xb1, 1, NOW - HOUR, "bit.ly/a")]);
1643 let policies = [loaded(policy_with(vec![link_rule(), stateful]))];
1644
1645 let as_member = evaluate_as(&s, &policies, &[], NOW, EvalMode::Member);
1646 let pr = &as_member.policies[0];
1647 let state = |id: &str| pr.rule_status.iter().find(|r| r.rule_id == id).map(|r| r.state);
1648 assert_eq!(state("links"), Some(RuleState::Evaluated), "a link filter needs no history");
1649 assert_eq!(state("quiet"), Some(RuleState::RequiresHistory), "counting a member's past does");
1650 let sub = subject(pr, 1).unwrap();
1651 assert_eq!(sub.convictions.len(), 1, "the link conviction stands on its own");
1652
1653 let as_admin = evaluate_as(&s, &policies, &[], NOW, EvalMode::Admin);
1655 let pr = &as_admin.policies[0];
1656 assert!(pr.rule_status.iter().all(|r| r.state == RuleState::Evaluated));
1657 assert_eq!(subject(pr, 1).unwrap().convictions.len(), 2);
1658 }
1659
1660 #[test]
1661 fn fresh_account_aggravators_fold_to_one() {
1662 let tenure = Rule {
1663 id: "tenure".into(),
1664 matcher: Match::TenureLt { secs: 24 * 3600 },
1665 tiers: None,
1666 severity: Some(Severity::Notice),
1667 weight: Some(20),
1668 pierces_trusted: false,
1669 family: None,
1670 armed_by: None,
1671 exempt: Exempt::default(),
1672 enforcement: Enforcement::Advisory,
1673 };
1674 let quiet = Rule {
1675 id: "quiet".into(),
1676 matcher: Match::MessagesLte { n: 2 },
1677 tiers: None,
1678 severity: Some(Severity::Notice),
1679 weight: Some(10),
1680 pierces_trusted: false,
1681 family: None,
1682 armed_by: None,
1683 exempt: Exempt::default(),
1684 enforcement: Enforcement::Advisory,
1685 };
1686 let s = signals(vec![member(1, Some(2))], vec![msg(0xb1, 1, NOW - HOUR, "hello world")]);
1687 let r = evaluate(&s, &[loaded(policy_with(vec![tenure, quiet]))], &[], NOW);
1688 let sub = subject(only(&r), 1).unwrap();
1689 assert_eq!(sub.confidence, 20);
1692 let folded = sub.convictions.iter().find(|c| c.rule_id == "quiet").unwrap();
1693 assert!(folded.folded && !folded.combined);
1694 assert_eq!(sub.convictions.iter().filter(|c| c.combined).count(), 1);
1695 }
1696
1697 #[test]
1698 fn unknown_tenure_is_never_a_conviction() {
1699 let tenure = Rule {
1700 id: "tenure".into(),
1701 matcher: Match::TenureLt { secs: 24 * 3600 },
1702 tiers: None,
1703 severity: Some(Severity::Notice),
1704 weight: Some(20),
1705 pierces_trusted: false,
1706 family: None,
1707 armed_by: None,
1708 exempt: Exempt::default(),
1709 enforcement: Enforcement::Advisory,
1710 };
1711 let s = signals(vec![member(9, None)], vec![]);
1712 let r = evaluate(&s, &[loaded(policy_with(vec![tenure]))], &[], NOW);
1713 let pr = only(&r);
1714 let status = pr.rule_status.iter().find(|s| s.rule_id == "tenure").unwrap();
1715 assert_eq!(status.unknown_subjects, vec![sid(9)], "per-subject unknown, not a community-wide block");
1716 assert!(subject(pr, 9).unwrap().convictions.is_empty());
1717 }
1718
1719 #[test]
1722 fn indeterminate_tenure_never_gates_a_conviction() {
1723 let quiet = Rule {
1724 id: "quiet".into(),
1725 matcher: Match::MessagesLte { n: 2 },
1726 tiers: None,
1727 severity: Some(Severity::Notice),
1728 weight: Some(10),
1729 pierces_trusted: false,
1730 family: None,
1731 armed_by: None,
1732 exempt: Exempt::default(),
1733 enforcement: Enforcement::Advisory,
1734 };
1735 let s = signals(vec![member(7, None)], vec![]);
1736 let r = evaluate(&s, &[loaded(policy_with(vec![quiet]))], &[], NOW);
1737 let sub = subject(only(&r), 7).unwrap();
1738 assert_eq!(sub.shield, Shield::Indeterminate, "no join, no posts: unknowable");
1739 assert_eq!(sub.confidence, 10, "and judged exactly as if unshielded");
1740 }
1741
1742 fn window_rule_policy(m: Match, rungs: Vec<Rung>) -> Policy {
1743 policy_with(vec![Rule {
1744 id: "w".into(),
1745 matcher: m,
1746 tiers: Some(Tiers { per_message: vec![], per_window: rungs }),
1747 severity: None,
1748 weight: None,
1749 pierces_trusted: false,
1750 family: None,
1751 armed_by: None,
1752 exempt: Exempt::default(),
1753 enforcement: Enforcement::Advisory,
1754 }])
1755 }
1756
1757 #[test]
1760 fn repeat_catches_one_account_pasting_the_same_thing() {
1761 let mut messages: Vec<MessageSignal> = (0..5)
1762 .map(|i| msg(0xa0 + i as u8, 1, NOW - (i + 1) * HOUR, "CLAIM YOUR FREE AIRDROP NOW"))
1763 .collect();
1764 messages.push(msg(0xb0, 1, NOW - 6 * HOUR, "claim your free airdrop now!!!"));
1766 messages.push(msg(0xb1, 1, NOW - 7 * HOUR, "hello everyone"));
1767 let s = signals(vec![member(1, Some(2))], messages);
1768 let p = window_rule_policy(
1769 Match::Repeat { normalize: Normalize::Skeleton, within_secs: None },
1770 vec![Rung { hits: 3, severity: Severity::Major, weight: 50, pierces_trusted: false }],
1771 );
1772 let r = evaluate(&s, &[loaded(p)], &[], NOW);
1773 let sub = subject(only(&r), 1).unwrap();
1774 let c = &sub.convictions[0];
1775 assert_eq!(c.scope, Scope::PerWindow);
1776 assert_eq!(c.hits, 6, "case and punctuation are one line of attacker code, so they do not vary the shape");
1777 assert_eq!(c.citation_count, 6, "every repetition is cited, not just the ones past the rung");
1778 assert_eq!(sub.confidence, 50);
1779 }
1780
1781 #[test]
1782 fn rate_finds_the_densest_span_and_cites_only_it() {
1783 let mut messages: Vec<MessageSignal> =
1785 (0..5).map(|i| msg(0xc0 + i as u8, 1, NOW - 60 * 60 * 1000 + i * 10_000, "spam")).collect();
1786 messages.push(msg(0xcf, 1, NOW - 10 * 60 * 1000, "unrelated"));
1787 let s = signals(vec![member(1, Some(2))], messages);
1788 let p = window_rule_policy(
1789 Match::Rate { per_secs: 60 },
1790 vec![Rung { hits: 5, severity: Severity::Major, weight: 55, pierces_trusted: false }],
1791 );
1792 let r = evaluate(&s, &[loaded(p)], &[], NOW);
1793 let sub = subject(only(&r), 1).unwrap();
1794 let c = &sub.convictions[0];
1795 assert_eq!(c.hits, 5, "five inside one minute");
1796 assert_eq!(c.citation_count, 5, "the straggler is not part of the burst");
1797 assert!(
1798 matches!(c.evidence.first(), Some(Evidence::Rate { count: 5, window_secs: 60, .. })),
1799 "the span is reported as evidence: {:?}",
1800 c.evidence
1801 );
1802 }
1803
1804 #[test]
1805 fn a_pardon_suppresses_the_conviction_and_its_citations() {
1806 let s = signals(
1807 vec![member(1, Some(2))],
1808 vec![
1809 msg(0xb1, 1, NOW - 3 * HOUR, "bit.ly/a"),
1810 msg(0xb2, 1, NOW - 2 * HOUR, "tr.ee/b"),
1811 msg(0xb3, 1, NOW - HOUR, "shm.to/c"),
1812 ],
1813 );
1814 let policy = policy_with(vec![link_rule()]);
1815 let lp = loaded(policy);
1816 let pardon = Override {
1817 target: OverrideTarget::Rule {
1818 policy_hash: lp.hash,
1819 rule_id: "links".into(),
1820 scope: Scope::PerMessage,
1821 subject: sid(1),
1822 },
1823 issuer: sid(0xf0),
1824 issued_at: NOW - HOUR,
1825 expires_at: NOW + HOUR,
1826 };
1827 let r = evaluate(&s, &[lp], &[pardon], NOW);
1828 let pr = only(&r);
1829 let sub = subject(pr, 1).unwrap();
1830 let pardoned = sub.convictions.iter().find(|c| c.scope == Scope::PerMessage).unwrap();
1831 assert!(pardoned.suppressed && !pardoned.combined, "reported, never combined");
1832 assert!(pr.citations.iter().any(|c| c.suppressed), "a pardon reaches the content consumer too");
1835 assert!(!sub.convictions.iter().find(|c| c.scope == Scope::PerWindow).unwrap().suppressed);
1837 }
1838
1839 #[test]
1840 fn an_inert_policy_reports_why_and_convicts_nothing() {
1841 let mut p = policy_with(vec![link_rule()]);
1842 p.requires = vec!["quarantine".into()];
1843 let r = evaluate(&signals(vec![member(1, Some(2))], vec![]), &[loaded(p)], &[], NOW);
1844 let pr = only(&r);
1845 assert!(matches!(&pr.inert, Some(InertReason::UnknownRequiredKey { key }) if key == "quarantine"));
1846 assert!(pr.subjects.is_empty() && pr.rule_status.is_empty());
1847 }
1848
1849 #[test]
1850 fn evaluation_is_pure_and_ordering_is_canonical() {
1851 let s = signals(
1852 vec![member(3, Some(2)), member(1, Some(2)), member(2, Some(2))],
1853 vec![
1854 msg(0xb3, 3, NOW - HOUR, "bit.ly/c"),
1855 msg(0xb1, 1, NOW - 2 * HOUR, "bit.ly/a"),
1856 msg(0xb2, 2, NOW - 3 * HOUR, "bit.ly/b"),
1857 ],
1858 );
1859 let a = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1860 let b = evaluate(&s, &[loaded(policy_with(vec![link_rule()]))], &[], NOW);
1861 assert_eq!(a, b, "same inputs, same report");
1862 let ids: Vec<[u8; 32]> = only(&a).subjects.iter().map(|s| s.subject.0).collect();
1863 let mut sorted = ids.clone();
1864 sorted.sort();
1865 assert_eq!(ids, sorted, "subjects sort by canonical id");
1866 let cits: Vec<[u8; 32]> = only(&a).citations.iter().map(|c| c.id.0).collect();
1867 let mut cs = cits.clone();
1868 cs.sort();
1869 assert_eq!(cits, cs, "citations sort by canonical id");
1870 }
1871
1872 #[test]
1873 fn the_window_clamps_by_recency_and_count() {
1874 let mut p = policy_with(vec![link_rule()]);
1875 p.window = Window { hours: 2, max_messages: 2 };
1876 let s = signals(
1877 vec![member(1, Some(48))],
1878 vec![
1879 msg(0xb0, 1, NOW - 10 * HOUR, "bit.ly/old"), msg(0xb1, 1, NOW - 90 * 60 * 1000, "bit.ly/a"),
1881 msg(0xb2, 1, NOW - 60 * 60 * 1000, "bit.ly/b"),
1882 msg(0xb3, 1, NOW - 30 * 60 * 1000, "bit.ly/c"),
1883 ],
1884 );
1885 let r = evaluate(&s, &[loaded(p)], &[], NOW);
1886 assert_eq!(only(&r).citations.len(), 2, "newest two inside the window; the older two never enter the corpus");
1887 }
1888
1889 fn word_rule(pattern: &str) -> Rule {
1890 Rule {
1891 id: "words".into(),
1892 matcher: Match::Keyword { patterns: vec![pattern.into()], normalize: Normalize::Fold },
1893 tiers: Some(Tiers {
1894 per_message: vec![Rung { hits: 1, severity: Severity::Minor, weight: 10, pierces_trusted: false }],
1895 per_window: vec![],
1896 }),
1897 severity: None,
1898 weight: None,
1899 pierces_trusted: false,
1900 family: None,
1901 armed_by: None,
1902 exempt: Exempt::default(),
1903 enforcement: Enforcement::Advisory,
1904 }
1905 }
1906
1907 fn hits_for(pattern: &str, text: &str) -> usize {
1908 let s = signals(vec![member(1, Some(2))], vec![msg(0xd1, 1, NOW - HOUR, text)]);
1909 let r = evaluate(&s, &[loaded(policy_with(vec![word_rule(pattern)]))], &[], NOW);
1910 only(&r).subjects.len()
1911 }
1912
1913 #[test]
1917 fn a_pattern_matches_in_the_same_case_space_as_the_text() {
1918 for pattern in ["Vector", "VECTOR", "vector", "VeCtOr"] {
1919 assert_eq!(hits_for(pattern, "we should ship vector today"), 1, "pattern {pattern:?} caught nothing");
1920 }
1921 }
1922
1923 #[test]
1926 fn folding_a_pattern_keeps_its_word_anchoring() {
1927 assert_eq!(hits_for("Art", "we should start today"), 0, "\"Art\" must not match inside \"start\"");
1928 assert_eq!(hits_for("*Art*", "we should start today"), 1, "an explicit wildcard still matches inside");
1929 }
1930
1931 fn repeat_policy(within_secs: Option<u64>) -> Policy {
1932 let mut p = window_rule_policy(
1933 Match::Repeat { normalize: Normalize::Skeleton, within_secs },
1934 vec![
1935 Rung { hits: 4, severity: Severity::Major, weight: 50, pierces_trusted: false },
1936 Rung { hits: 8, severity: Severity::Severe, weight: 85, pierces_trusted: false },
1937 ],
1938 );
1939 p.window = Window { hours: 168, max_messages: 4000 };
1942 p
1943 }
1944
1945 fn repeat_hits(within_secs: Option<u64>, at_hours: &[u64]) -> usize {
1946 let msgs: Vec<MessageSignal> = at_hours
1947 .iter()
1948 .enumerate()
1949 .map(|(i, h)| msg(0xe0 + i as u8, 1, NOW - h * HOUR, "gm"))
1950 .collect();
1951 let s = signals(vec![member(1, Some(1))], msgs);
1953 let r = evaluate(&s, &[loaded(repeat_policy(within_secs))], &[], NOW);
1954 only(&r).subjects.len()
1955 }
1956
1957 #[test]
1961 fn a_daily_greeting_is_not_a_repeat_burst() {
1962 let mornings: Vec<u64> = (0..8).map(|d| d * 24 + 1).collect();
1963 assert_eq!(repeat_hits(None, &mornings), 1, "the unbounded rule convicts a regular");
1964 assert_eq!(
1965 repeat_hits(Some(super::super::harness::REPEAT_BURST_SECS), &mornings),
1966 0,
1967 "a burst-bounded rule must leave a daily greeting alone"
1968 );
1969 }
1970
1971 #[test]
1973 fn a_burst_of_the_same_line_still_convicts() {
1974 let burst: Vec<u64> = vec![1, 1, 1, 1, 1, 1, 1, 1];
1975 assert_eq!(
1976 repeat_hits(Some(super::super::harness::REPEAT_BURST_SECS), &burst),
1977 1,
1978 "eight identical messages in one span must still be caught"
1979 );
1980 }
1981
1982 #[test]
1989 fn one_anomaly_asks_for_a_look_and_two_convict() {
1990 const MIN: u64 = 60 * 1000;
1991 const VARIED: [&str; 12] = [
1992 "hey everyone", "glad to be here", "what is this place",
1993 "found you through a podcast", "nice community", "hello all",
1994 "just looking around", "someone linked me here", "good morning",
1995 "how does this work", "reading the pins now", "thanks for the invite",
1996 ];
1997 let wave = |n: u64, span: u64, same_text: bool| -> (usize, u32) {
2000 let members: Vec<MemberSignal> = (1..=n)
2001 .map(|i| {
2002 let mut m = member(i as u8, Some(1));
2003 m.joined_at_ms = Some(NOW - (span * MIN) + (i * span * MIN / n));
2004 m.first_post_ms = None;
2005 m.lifetime_messages = 0;
2006 m
2007 })
2008 .collect();
2009 let msgs: Vec<MessageSignal> = (1..=n)
2010 .map(|i| {
2011 let text = if same_text {
2012 "join our airdrop".to_string()
2013 } else {
2014 VARIED[(i as usize - 1) % VARIED.len()].to_string()
2015 };
2016 msg(0xa0 + i as u8, i as u8, NOW - 2 * MIN, &text)
2017 })
2018 .collect();
2019 let s = signals(members, msgs);
2020 let r = evaluate(&s, &[loaded(crate::community::policy::harness::default_policy())], &[], NOW);
2021 let p = only(&r);
2022 (p.subjects.len(), p.subjects.first().map(|x| x.confidence).unwrap_or(0))
2023 };
2024
2025 assert_eq!(wave(5, 10, false), (0, 0), "an ordinary invite wave must convict nobody");
2027
2028 let (caught, conf) = wave(12, 5, false);
2030 assert_eq!(caught, 12);
2031 assert!((25..50).contains(&conf), "a lone join flood reached {conf}; it must stay a look, not a verdict");
2032
2033 let (caught, conf) = wave(12, 5, true);
2035 assert_eq!(caught, 12);
2036 assert!(conf >= 75, "a scripted flood only reached {conf}; two sources must convict");
2037
2038 let (caught, conf) = wave(20, 60, true);
2040 assert_eq!(caught, 20);
2041 assert!(conf >= 75, "a scripted wave only reached {conf}");
2042 }
2043}