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_key(|a| a.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_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
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_key(|a| a.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_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
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_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 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 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 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 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 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 _ => (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 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 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 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 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#[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 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 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 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 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 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#[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 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 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#[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 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 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 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 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 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 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 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
1133pub fn refusing_rules(policy: &Policy) -> Vec<&Rule> {
1136 policy.rules.iter().filter(|r| r.enforcement == Enforcement::Refuse).collect()
1137}
1138
1139pub 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 #[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 #[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 assert_eq!(sub.confidence, 73);
1317 assert_eq!(sub.band, Band::Flagged);
1318 }
1319
1320 #[test]
1323 fn shields_gate_exactly_as_declared() {
1324 let mut s = signals(
1325 vec![
1326 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 },
1329 ],
1330 vec![],
1331 );
1332 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 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 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 #[test]
1369 fn trust_comes_from_roles_tenure_or_sustained_activity() {
1370 let day = 24 * HOUR;
1371 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 #[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; let mut s = signals(vec![veteran, newcomer], vec![]);
1407 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 #[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; let s = signals(vec![lurker], vec![]); 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 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 #[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 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 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 #[test]
1484 fn someone_already_removed_is_never_convicted_again() {
1485 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 let ev = &pr.subjects[0].convictions[0].evidence;
1497 assert!(matches!(ev.first(), Some(Evidence::Cohort { size: 20, .. })), "{ev:?}");
1498 }
1499
1500 #[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 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 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 #[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 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 #[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 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 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 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 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 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 #[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 #[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 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 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 assert!(pr.citations.iter().any(|c| c.suppressed), "a pardon reaches the content consumer too");
1836 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"), 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 #[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 #[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 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 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 #[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 #[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 #[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 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 assert_eq!(wave(5, 10, false), (0, 0), "an ordinary invite wave must convict nobody");
2028
2029 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 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 let (caught, conf) = wave(20, 60, true);
2041 assert_eq!(caught, 20);
2042 assert!(conf >= 75, "a scripted wave only reached {conf}");
2043 }
2044}