Skip to main content

vector_core/community/
raid.rs

1//! Raid assessment: which members read as people, and which read as spawned identities.
2//!
3//! A sybil raid never looks like spam from one account. It looks like one message each
4//! from a hundred fresh accounts, so per-sender rate limits and per-account reputation
5//! both miss it entirely. What survives is the COHORT: many distinct senders posting the
6//! same shape inside one window, from identities that were minted minutes ago. This module
7//! folds those signals into a per-member verdict plus the reasons behind it, so a moderator
8//! sees why before acting — the output drives a rotation's retain set, and a wrong call
9//! there evicts a real member.
10//!
11//! Conviction rests on cohort evidence, never on freshness alone: a community that just
12//! posted an invite link is *supposed* to see a burst of quiet new accounts.
13
14use std::collections::{HashMap, HashSet};
15
16/// Below this length a shared message is too generic to convict on ("gm", "lol", "+1"),
17/// so a cohort of that shape needs [`RaidParams::short_text_factor`] times the members.
18const MIN_SKELETON_LEN: usize = 8;
19
20/// Collapse a message to the shape a raid script repeats. Case, punctuation, spacing and
21/// digit variation are all one line of attacker code to randomise, so none of them may
22/// carry weight.
23///
24/// A `:shortcode:` that renders as an image is dropped rather than flattened to its name:
25/// three people answering with the same reaction is a community, not a cohort, and leaving
26/// the name in convicts them on a skeleton nobody typed. See [`skeleton_with`] for the
27/// resolution that decides which codes are images.
28pub fn skeleton(text: &str) -> String {
29    skeleton_with(text, &HashSet::new())
30}
31
32/// [`skeleton`] against a set of shortcodes that actually render as images. A `:code:`
33/// outside the set reaches the reader as literal text, so it stays in the skeleton —
34/// otherwise wrapping the payload in colons hides a cohort in plain sight.
35pub fn skeleton_with(text: &str, known: &HashSet<String>) -> String {
36    strip_shortcodes(text, known)
37        .chars()
38        .filter(|c| c.is_alphanumeric() && !c.is_numeric())
39        .flat_map(|c| c.to_lowercase())
40        .collect()
41}
42
43/// Remove `:name:` runs that stand in for an image: one this account can render, or a
44/// short name a unicode set plausibly carries. A long unresolved run is prose in a
45/// costume — `:buycheapcoinsnow:` renders as itself — so its text is kept. A lone `:`
46/// or an unclosed code is left alone.
47fn strip_shortcodes(text: &str, known: &HashSet<String>) -> String {
48    let mut out = String::with_capacity(text.len());
49    let mut rest = text;
50    while let Some(open) = rest.find(':') {
51        out.push_str(&rest[..open]);
52        let after = &rest[open + 1..];
53        let close = after.find(':').filter(|end| {
54            *end > 0 && after[..*end].chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '+')
55        });
56        match close {
57            Some(end) => {
58                let name = &after[..end];
59                if !known.contains(name) && name.chars().count() >= MIN_SKELETON_LEN {
60                    out.push_str(name);
61                }
62                rest = &after[end + 1..];
63            }
64            None => {
65                out.push(':');
66                rest = after;
67            }
68        }
69    }
70    out.push_str(rest);
71    out
72}
73
74/// Tunables for [`assess`]. The defaults are the shape of a real raid; a moderator can
75/// widen them from the panel when a wave is slower or quieter than the norm.
76#[derive(Debug, Clone)]
77pub struct RaidParams {
78    /// A join newer than this reads as "just now".
79    pub fresh_join_secs: u64,
80    /// Tenure at which a member is trusted on age alone (with some volume).
81    pub trusted_tenure_secs: u64,
82    /// Messages that, with tenure or variety, make a member trusted.
83    pub trusted_messages: u64,
84    /// Distinct message shapes that read as real conversation.
85    pub trusted_distinct: u64,
86    /// At or below this, a member has effectively not spoken.
87    pub quiet_messages: u64,
88    /// Distinct senders sharing one skeleton before it counts as a cohort.
89    pub min_cohort: usize,
90    /// Multiplier on `min_cohort` for skeletons under [`MIN_SKELETON_LEN`].
91    pub short_text_factor: usize,
92    /// How far apart two joins may be and still belong to the same burst.
93    pub burst_gap_secs: u64,
94    /// Joins in one window before it reads as a burst rather than a busy afternoon.
95    pub min_burst: usize,
96    /// Shortcodes that render as images here; anything else in colons is read as text.
97    pub known_shortcodes: HashSet<String>,
98}
99
100impl Default for RaidParams {
101    fn default() -> Self {
102        Self {
103            fresh_join_secs: 24 * 60 * 60,
104            trusted_tenure_secs: 7 * 24 * 60 * 60,
105            trusted_messages: 5,
106            trusted_distinct: 3,
107            quiet_messages: 2,
108            min_cohort: 3,
109            short_text_factor: 3,
110            burst_gap_secs: 10 * 60,
111            min_burst: 5,
112            known_shortcodes: HashSet::new(),
113        }
114    }
115}
116
117/// What the caller knows about one member before any judgement is applied.
118#[derive(Debug, Clone, Default)]
119pub struct MemberSignals {
120    pub npub: String,
121    /// Guestbook join, ms. `0` = unknown (predates the store, or the Join was lost).
122    pub joined_at_ms: u64,
123    /// Invite label they joined through, when the Guestbook carried the attribution.
124    pub invite_label: Option<String>,
125    pub messages: u64,
126    /// First and last posting times, seconds. `0` when they have never posted.
127    pub first_secs: u64,
128    pub last_secs: u64,
129    /// Their texts from the recent window only — the cohort evidence.
130    pub texts: Vec<String>,
131    pub is_owner: bool,
132    pub is_admin: bool,
133    pub is_me: bool,
134}
135
136/// Where a member lands. Only [`Verdict::Suspect`] is pre-selected for removal.
137#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
138#[serde(rename_all = "lowercase")]
139pub enum Verdict {
140    /// The owner, yourself, or a role-holder: never removable from this panel.
141    Protected,
142    /// Real history behind them. Kept by default, and never auto-selected.
143    Trusted,
144    /// Not enough either way — the moderator decides.
145    Neutral,
146    /// Cohort evidence convicts them.
147    Suspect,
148}
149
150/// One member's verdict with the evidence that produced it.
151#[derive(Debug, Clone, serde::Serialize)]
152pub struct MemberAssessment {
153    pub npub: String,
154    pub verdict: Verdict,
155    /// Ordering weight only — the verdict is decided by rules, not by a threshold.
156    pub score: u32,
157    pub reasons: Vec<String>,
158    pub joined_at_ms: u64,
159    pub invite_label: Option<String>,
160    pub messages: u64,
161    /// Distinct message shapes seen in the recent window.
162    pub distinct: u64,
163    /// Other members who posted a shape this member also posted.
164    pub cohort: u64,
165    /// Seconds between their first trace (join or first post) and `now`.
166    pub tenure_secs: u64,
167    pub last_secs: u64,
168    pub is_owner: bool,
169    pub is_admin: bool,
170    pub is_me: bool,
171}
172
173/// Cap on the npubs carried per cohort. The panel shows the count and the sample; a
174/// 500-strong cluster would otherwise put 30KB of bech32 through the IPC boundary for
175/// nothing.
176const COHORT_SAMPLE_CAP: usize = 24;
177
178/// A duplicate-text cluster: the evidence a raid actually leaves behind.
179#[derive(Debug, Clone, serde::Serialize)]
180pub struct Cohort {
181    /// A representative verbatim message, for display.
182    pub sample: String,
183    /// The true size, which `members` may be a truncated sample of.
184    pub size: usize,
185    pub members: Vec<String>,
186}
187
188/// The whole picture for one community.
189#[derive(Debug, Clone, serde::Serialize)]
190pub struct RaidReport {
191    pub members: Vec<MemberAssessment>,
192    pub suspects: usize,
193    pub trusted: usize,
194    pub protected: usize,
195    pub cohorts: Vec<Cohort>,
196    /// Members whose joins landed inside the convicted burst window.
197    pub burst_size: usize,
198    /// Start/end of that window, ms. Absent when no burst was found.
199    pub burst_from_ms: u64,
200    pub burst_to_ms: u64,
201    /// True when the cohort evidence is strong enough to call this a raid rather
202    /// than a busy day.
203    pub raid_detected: bool,
204}
205
206/// Judge every member against the cohort evidence. `now_secs` is the clock the
207/// caller reads once, so a long assessment can't drift mid-pass.
208pub fn assess(signals: &[MemberSignals], now_secs: u64, p: &RaidParams) -> RaidReport {
209    // ── Cohorts: skeleton → the distinct members who posted it ───────────────
210    let mut by_skeleton: HashMap<String, HashSet<String>> = HashMap::new();
211    let mut sample_of: HashMap<String, String> = HashMap::new();
212    for s in signals {
213        for t in &s.texts {
214            let sk = skeleton_with(t, &p.known_shortcodes);
215            if sk.is_empty() {
216                continue;
217            }
218            by_skeleton.entry(sk.clone()).or_default().insert(s.npub.clone());
219            sample_of.entry(sk).or_insert_with(|| t.clone());
220        }
221    }
222    let volume: HashMap<&str, u64> = signals.iter().map(|s| (s.npub.as_str(), s.messages)).collect();
223    // A cohort convicts on two counts, not one. Size alone would hang a community
224    // catchphrase on whoever repeats it, so the cluster must ALSO be thin: a script
225    // gives each identity one line, while regulars who happen to share a greeting have
226    // hundreds of other messages between them. A short skeleton is cheap coincidence on
227    // top of that, so it clears a higher size bar.
228    let convicting = |sk: &str, members: &HashSet<String>| -> bool {
229        let need = if sk.chars().count() < MIN_SKELETON_LEN {
230            p.min_cohort.saturating_mul(p.short_text_factor)
231        } else {
232            p.min_cohort
233        };
234        if members.len() < need {
235            return false;
236        }
237        let thin = members
238            .iter()
239            .filter(|n| volume.get(n.as_str()).copied().unwrap_or(0) <= p.quiet_messages)
240            .count();
241        thin * 2 >= members.len()
242    };
243
244    let mut cohorts: Vec<Cohort> = Vec::new();
245    // npub → the largest convicting cohort they belong to (excluding themselves).
246    let mut cohort_of: HashMap<String, u64> = HashMap::new();
247    for (sk, members) in &by_skeleton {
248        if !convicting(sk, members) {
249            continue;
250        }
251        let peers = members.len().saturating_sub(1) as u64;
252        for m in members {
253            let e = cohort_of.entry(m.clone()).or_insert(0);
254            *e = (*e).max(peers);
255        }
256        let mut list: Vec<String> = members.iter().cloned().collect();
257        list.sort();
258        let size = list.len();
259        list.truncate(COHORT_SAMPLE_CAP);
260        cohorts.push(Cohort { sample: sample_of.get(sk).cloned().unwrap_or_default(), size, members: list });
261    }
262    cohorts.sort_by(|a, b| b.size.cmp(&a.size));
263
264    // ── Join burst: the densest run of joins within `burst_gap_secs` ─────────
265    let mut joins: Vec<(u64, &str)> = signals
266        .iter()
267        .filter(|s| s.joined_at_ms > 0 && !s.is_owner)
268        .map(|s| (s.joined_at_ms, s.npub.as_str()))
269        .collect();
270    joins.sort_by_key(|(at, _)| *at);
271    let gap_ms = p.burst_gap_secs.saturating_mul(1000);
272    let mut best: (usize, usize) = (0, 0); // [start, end) into `joins`
273    let mut start = 0usize;
274    for end in 0..joins.len() {
275        while joins[end].0.saturating_sub(joins[start].0) > gap_ms {
276            start += 1;
277        }
278        if end + 1 - start > best.1 - best.0 {
279            best = (start, end + 1);
280        }
281    }
282    let burst: HashSet<&str> = if best.1 - best.0 >= p.min_burst {
283        joins[best.0..best.1].iter().map(|(_, n)| *n).collect()
284    } else {
285        HashSet::new()
286    };
287    let (burst_from_ms, burst_to_ms) = if burst.is_empty() {
288        (0, 0)
289    } else {
290        (joins[best.0].0, joins[best.1 - 1].0)
291    };
292    // A burst only convicts its silent members once the noisy ones are already
293    // convicted — otherwise a freshly-posted invite link reads as an attack.
294    let burst_convicted = burst.iter().filter(|n| cohort_of.contains_key(**n)).count() >= p.min_cohort;
295
296    // ── Per-member verdicts ──────────────────────────────────────────────────
297    let now_ms = now_secs.saturating_mul(1000);
298    let mut members: Vec<MemberAssessment> = Vec::with_capacity(signals.len());
299    for s in signals {
300        let distinct: u64 = s.texts.iter().map(|t| skeleton_with(t, &p.known_shortcodes)).filter(|sk| !sk.is_empty()).collect::<HashSet<_>>().len() as u64;
301        let cohort = cohort_of.get(&s.npub).copied().unwrap_or(0);
302        let in_burst = burst.contains(s.npub.as_str());
303        // Oldest trace wins: a member whose Join was lost still has their first post.
304        let first_seen_secs = match (s.joined_at_ms / 1000, s.first_secs) {
305            (0, f) => f,
306            (j, 0) => j,
307            (j, f) => j.min(f),
308        };
309        let tenure_secs = if first_seen_secs == 0 { 0 } else { now_secs.saturating_sub(first_seen_secs) };
310        let fresh = s.joined_at_ms > 0 && now_ms.saturating_sub(s.joined_at_ms) <= p.fresh_join_secs.saturating_mul(1000);
311
312        let mut reasons: Vec<String> = Vec::new();
313        let mut score: u32 = 0;
314
315        let verdict = if s.is_owner || s.is_me || s.is_admin {
316            if s.is_owner {
317                reasons.push("Community owner".into());
318            } else if s.is_me {
319                reasons.push("You".into());
320            } else {
321                reasons.push("Holds a role".into());
322            }
323            Verdict::Protected
324        } else {
325            if cohort > 0 {
326                score += 40 + (cohort.min(50) as u32);
327                reasons.push(format!("Posted the same message as {cohort} other member{}", if cohort == 1 { "" } else { "s" }));
328            }
329            if in_burst && burst_convicted {
330                score += 15;
331                reasons.push(format!("Joined in a burst of {}", burst.len()));
332            }
333            if fresh {
334                score += 10;
335                reasons.push("Joined in the last 24h".into());
336            }
337            if s.messages == 0 {
338                score += 5;
339                reasons.push("Has never posted".into());
340            } else if s.messages <= p.quiet_messages {
341                score += 5;
342                reasons.push(format!("Only {} message{}", s.messages, if s.messages == 1 { "" } else { "s" }));
343            }
344            if s.messages >= 2 && distinct == 1 {
345                score += 10;
346                reasons.push("Repeats one message".into());
347            }
348
349            let long_tenure = tenure_secs >= p.trusted_tenure_secs;
350            let talks = s.messages >= p.trusted_messages;
351            let varied = distinct >= p.trusted_distinct;
352
353            // Trust is checked BEFORE the cohort, so a member with real history is
354            // never unticked by default. The asymmetry is deliberate: a missed raider
355            // is one click to add, an evicted two-year member is not recoverable.
356            if (long_tenure && talks) || (talks && varied) {
357                // The row already prints the numbers; this says what they earned.
358                reasons.clear();
359                reasons.push(if long_tenure && talks {
360                    "Long-standing, active member".to_string()
361                } else {
362                    "Varied conversation history".to_string()
363                });
364                Verdict::Trusted
365            } else if cohort > 0 || (in_burst && burst_convicted && s.messages == 0) {
366                Verdict::Suspect
367            } else {
368                Verdict::Neutral
369            }
370        };
371
372        members.push(MemberAssessment {
373            npub: s.npub.clone(),
374            verdict,
375            score,
376            reasons,
377            joined_at_ms: s.joined_at_ms,
378            invite_label: s.invite_label.clone(),
379            messages: s.messages,
380            distinct,
381            cohort,
382            tenure_secs,
383            last_secs: s.last_secs,
384            is_owner: s.is_owner,
385            is_admin: s.is_admin,
386            is_me: s.is_me,
387        });
388    }
389
390    // Suspects first, then by evidence weight — the panel's default reading order
391    // is "what do I need to look at".
392    members.sort_by(|a, b| {
393        let rank = |v: Verdict| match v {
394            Verdict::Suspect => 0,
395            Verdict::Neutral => 1,
396            Verdict::Trusted => 2,
397            Verdict::Protected => 3,
398        };
399        rank(a.verdict).cmp(&rank(b.verdict)).then(b.score.cmp(&a.score)).then(a.npub.cmp(&b.npub))
400    });
401
402    let suspects = members.iter().filter(|m| m.verdict == Verdict::Suspect).count();
403    let trusted = members.iter().filter(|m| m.verdict == Verdict::Trusted).count();
404    let protected = members.iter().filter(|m| m.verdict == Verdict::Protected).count();
405    RaidReport {
406        raid_detected: suspects >= p.min_cohort && !cohorts.is_empty(),
407        members,
408        suspects,
409        trusted,
410        protected,
411        cohorts,
412        burst_size: burst.len(),
413        burst_from_ms,
414        burst_to_ms,
415    }
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    const HOUR: u64 = 3_600;
423    const DAY: u64 = 86_400;
424
425    fn member(npub: &str) -> MemberSignals {
426        MemberSignals { npub: npub.to_string(), ..Default::default() }
427    }
428
429    /// now = a fixed clock so every case reads the same age.
430    const NOW: u64 = 1_700_000_000;
431
432    fn raider(npub: &str, at_secs: u64, text: &str) -> MemberSignals {
433        MemberSignals {
434            npub: npub.to_string(),
435            joined_at_ms: at_secs * 1000,
436            messages: 1,
437            first_secs: at_secs,
438            last_secs: at_secs,
439            texts: vec![text.to_string()],
440            ..Default::default()
441        }
442    }
443
444    fn regular(npub: &str, joined_days_ago: u64, messages: u64, texts: &[&str]) -> MemberSignals {
445        let joined = NOW - joined_days_ago * DAY;
446        MemberSignals {
447            npub: npub.to_string(),
448            joined_at_ms: joined * 1000,
449            messages,
450            first_secs: joined,
451            last_secs: NOW - HOUR,
452            texts: texts.iter().map(|t| t.to_string()).collect(),
453            ..Default::default()
454        }
455    }
456
457    #[test]
458    fn skeleton_ignores_the_cheap_variations() {
459        assert_eq!(skeleton("Hello World!"), skeleton("hello,   world"));
460        assert_eq!(skeleton("hello world 123"), skeleton("HELLO WORLD 4567"));
461        assert_ne!(skeleton("hello world"), skeleton("goodbye world"));
462    }
463
464    #[test]
465    fn a_payload_wrapped_in_colons_still_has_a_skeleton() {
466        assert_eq!(skeleton(":buycheapcoinsnow:"), "buycheapcoinsnow");
467        // Short codes read as emoji even unresolved; a rendering pack silences the rest.
468        assert!(skeleton(":fire:").is_empty());
469        assert!(skeleton(":+1:").is_empty());
470        assert_eq!(skeleton("nice one :fire:"), skeleton("nice one"));
471        let known = HashSet::from(["buycheapcoinsnow".to_string()]);
472        assert!(skeleton_with(":buycheapcoinsnow:", &known).is_empty());
473    }
474
475    #[test]
476    fn a_cohort_hiding_behind_shortcode_colons_still_convicts() {
477        let mut signals: Vec<MemberSignals> = (0..40)
478            .map(|i| raider(&format!("npub_raider_{i}"), NOW - 60, ":freeairdropclaimnow:"))
479            .collect();
480        signals.push(regular("npub_owner", 400, 900, &["morning all", "shipping today", "nice one"]));
481        signals[40].is_owner = true;
482
483        let r = assess(&signals, NOW, &RaidParams::default());
484        assert!(r.raid_detected, "colons must not blind the cohort");
485        assert_eq!(r.suspects, 40);
486    }
487
488    #[test]
489    fn a_hundred_fresh_npubs_saying_one_thing_is_a_raid() {
490        let mut signals: Vec<MemberSignals> = (0..100)
491            .map(|i| raider(&format!("npub_raider_{i}"), NOW - 60, "hello world"))
492            .collect();
493        signals.push(regular("npub_owner", 400, 900, &["morning all", "shipping today", "nice one"]));
494        signals[100].is_owner = true;
495
496        let r = assess(&signals, NOW, &RaidParams::default());
497        assert!(r.raid_detected, "cohort of 100 identical posts must convict");
498        assert_eq!(r.suspects, 100, "every raider, and only the raiders");
499        assert_eq!(r.members.iter().filter(|m| m.verdict == Verdict::Protected).count(), 1);
500        assert_eq!(r.cohorts.len(), 1);
501        assert_eq!(r.cohorts[0].size, 100, "the true size survives the display cap");
502    }
503
504    #[test]
505    fn a_quiet_newcomer_alone_is_not_convicted() {
506        let signals = vec![
507            regular("npub_old", 400, 900, &["morning all", "shipping today", "nice one"]),
508            MemberSignals {
509                npub: "npub_new".into(),
510                joined_at_ms: (NOW - 600) * 1000,
511                messages: 1,
512                first_secs: NOW - 500,
513                last_secs: NOW - 500,
514                texts: vec!["hey everyone, glad to be here".into()],
515                ..Default::default()
516            },
517        ];
518        let r = assess(&signals, NOW, &RaidParams::default());
519        assert_eq!(r.suspects, 0, "freshness alone must never convict");
520        assert_eq!(r.members.iter().find(|m| m.npub == "npub_new").unwrap().verdict, Verdict::Neutral);
521    }
522
523    #[test]
524    fn an_invite_link_burst_of_real_people_is_not_a_raid() {
525        // Twenty newcomers inside ten minutes, each saying something of their own.
526        let greetings = [
527            "hi there", "glad to join", "hello from berlin", "whats up", "found this via nostr",
528            "hey all", "good to be here", "long time lurker", "anyone here into rust", "morning",
529            "just joined, what is this about", "greetings", "hello hello", "howdy folks",
530            "nice community", "reading the pins now", "is there a channel for dev talk",
531            "coming over from the other group", "hola", "yo",
532        ];
533        let signals: Vec<MemberSignals> = (0..20)
534            .map(|i| {
535                let at = NOW - 300 + i * 10;
536                MemberSignals {
537                    npub: format!("npub_person_{i}"),
538                    joined_at_ms: at * 1000,
539                    messages: 1,
540                    first_secs: at,
541                    last_secs: at,
542                    texts: vec![greetings[i as usize].to_string()],
543                    ..Default::default()
544                }
545            })
546            .collect();
547        let r = assess(&signals, NOW, &RaidParams::default());
548        assert_eq!(r.suspects, 0, "a burst with no duplicate text is just a busy afternoon");
549        assert!(!r.raid_detected);
550    }
551
552    #[test]
553    fn silent_members_of_a_convicted_burst_are_caught_too() {
554        let mut signals: Vec<MemberSignals> = (0..10)
555            .map(|i| raider(&format!("npub_loud_{i}"), NOW - 120 + i, "buy cheap coins now"))
556            .collect();
557        // Same wave, never spoke — the sleeper accounts a text-only rule misses.
558        for i in 0..5 {
559            let at = NOW - 100 + i;
560            signals.push(MemberSignals {
561                npub: format!("npub_silent_{i}"),
562                joined_at_ms: at * 1000,
563                ..Default::default()
564            });
565        }
566        let r = assess(&signals, NOW, &RaidParams::default());
567        assert_eq!(r.suspects, 15, "the whole wave, loud and silent");
568    }
569
570    #[test]
571    fn short_shared_text_needs_a_bigger_cohort() {
572        // "gm" from four people is a morning, not an attack.
573        let signals: Vec<MemberSignals> =
574            (0..4).map(|i| raider(&format!("npub_{i}"), NOW - 60 + i, "gm")).collect();
575        let r = assess(&signals, NOW, &RaidParams::default());
576        assert_eq!(r.suspects, 0, "min_cohort * short_text_factor = 9 needed for a 2-char skeleton");
577
578        let many: Vec<MemberSignals> =
579            (0..12).map(|i| raider(&format!("npub_{i}"), NOW - 60 + i, "gm")).collect();
580        assert_eq!(assess(&many, NOW, &RaidParams::default()).suspects, 12);
581    }
582
583    #[test]
584    fn tenure_and_variety_earn_trust_that_survives_a_raid() {
585        let mut signals: Vec<MemberSignals> = (0..20)
586            .map(|i| raider(&format!("npub_raider_{i}"), NOW - 60, "hello world"))
587            .collect();
588        // This veteran ALSO said "hello world" once, so they sit inside the convicted
589        // cohort. Tenure still wins: the default selection must not evict them.
590        signals.push(regular("npub_veteran", 90, 400, &["morning", "on it", "shipped", "hello world"]));
591        let r = assess(&signals, NOW, &RaidParams::default());
592        let vet = r.members.iter().find(|m| m.npub == "npub_veteran").unwrap();
593        assert_eq!(vet.verdict, Verdict::Trusted, "cohort membership must not outrank real history");
594        assert!(vet.cohort > 0, "they are genuinely in the cohort — the panel just keeps them ticked");
595        assert!(vet.reasons.iter().any(|s| s.contains("Long-standing")));
596        assert_eq!(r.suspects, 20, "the raiders, and only the raiders");
597    }
598
599    #[test]
600    fn role_holders_and_self_are_never_selectable() {
601        let mut admin = member("npub_admin");
602        admin.is_admin = true;
603        admin.joined_at_ms = (NOW - 60) * 1000;
604        admin.texts = vec!["hello world".into()];
605        admin.messages = 1;
606        let mut me = member("npub_me");
607        me.is_me = true;
608        let mut signals = vec![admin, me];
609        signals.extend((0..10).map(|i| raider(&format!("npub_r{i}"), NOW - 60, "hello world")));
610
611        let r = assess(&signals, NOW, &RaidParams::default());
612        assert_eq!(r.members.iter().find(|m| m.npub == "npub_admin").unwrap().verdict, Verdict::Protected);
613        assert_eq!(r.members.iter().find(|m| m.npub == "npub_me").unwrap().verdict, Verdict::Protected);
614        assert_eq!(r.suspects, 10, "protection wins over cohort evidence");
615    }
616
617    #[test]
618    fn suspects_sort_ahead_of_everyone_else() {
619        let mut signals = vec![regular("npub_old", 400, 900, &["a", "b", "c", "d", "e"])];
620        signals.extend((0..5).map(|i| raider(&format!("npub_r{i}"), NOW - 60, "free airdrop here")));
621        let r = assess(&signals, NOW, &RaidParams::default());
622        assert!(r.members[0].verdict == Verdict::Suspect);
623        assert_eq!(r.members.last().unwrap().npub, "npub_old");
624    }
625
626    #[test]
627    fn a_community_catchphrase_shared_by_regulars_is_not_a_cohort() {
628        // Five long-standing members who all greet with the same line. Same skeleton,
629        // same count as a small raid — but every one of them has real volume behind it.
630        let signals: Vec<MemberSignals> = (0..5)
631            .map(|i| regular(&format!("npub_regular_{i}"), 200, 400, &["gm vectorians", "on it", "shipped it", "nice"]))
632            .collect();
633        let r = assess(&signals, NOW, &RaidParams::default());
634        assert!(r.cohorts.is_empty(), "a shared phrase between chatty members is not evidence");
635        assert_eq!(r.suspects, 0);
636    }
637
638    #[test]
639    fn one_line_identities_still_convict_beside_a_chatty_member() {
640        // The raid wave, plus one regular who happens to use the same words. The cohort
641        // is still overwhelmingly thin, so it convicts — and the regular stays trusted.
642        let mut signals: Vec<MemberSignals> = (0..30)
643            .map(|i| raider(&format!("npub_r{i}"), NOW - 90 + i, "claim your free airdrop"))
644            .collect();
645        signals.push(regular("npub_regular", 200, 400, &["claim your free airdrop", "lol", "sure", "ok"]));
646        let r = assess(&signals, NOW, &RaidParams::default());
647        assert_eq!(r.suspects, 30);
648        assert_eq!(r.members.iter().find(|m| m.npub == "npub_regular").unwrap().verdict, Verdict::Trusted);
649    }
650
651    #[test]
652    fn shared_emoji_reactions_never_form_a_cohort() {
653        // Three regulars answering with the same custom emoji is a community, not a raid.
654        let signals: Vec<MemberSignals> = (0..6)
655            .map(|i| raider(&format!("npub_{i}"), NOW - 300 + i, ":vector_logo:"))
656            .collect();
657        let p = RaidParams { known_shortcodes: HashSet::from(["vector_logo".to_string()]), ..Default::default() };
658        let r = assess(&signals, NOW, &p);
659        assert_eq!(r.suspects, 0);
660        assert!(r.cohorts.is_empty(), "an emoji-only message carries no convicting content");
661    }
662
663    #[test]
664    fn a_shortcode_does_not_hide_the_text_around_it() {
665        assert_eq!(skeleton("free airdrop :fire: click here"), skeleton("free airdrop click here"));
666        assert_eq!(skeleton(":smile:"), "");
667        // A bare colon is punctuation, not an unterminated shortcode.
668        assert_eq!(skeleton("note: buy now"), skeleton("note buy now"));
669        assert!(!skeleton("ratio 3:1 wins").is_empty());
670    }
671
672    #[test]
673    fn an_empty_community_assesses_cleanly() {
674        let r = assess(&[], NOW, &RaidParams::default());
675        assert_eq!(r.suspects, 0);
676        assert!(!r.raid_detected);
677        assert_eq!(r.burst_size, 0);
678    }
679}