Skip to main content

adler_core/
correlate.rs

1//! Cross-account correlation from enrichment fields.
2//!
3//! A username search returns many `Found` accounts for the *same handle* —
4//! but the same handle can belong to different people on different sites.
5//! This module groups accounts that share enough profile signal (name, bio)
6//! to plausibly be the same identity, so an analyst can tell
7//! "all these are clearly one person" from "this handle is just popular".
8//!
9//! Signals are text-only by design in this legacy CLI correlation report.
10//! The newer `IdentityCluster` model handles typed evidence such as avatar
11//! URL equality and opt-in avatar perceptual hashes. Each pair of accounts
12//! that both carry profile data is scored 0..1:
13//!
14//! - **name**: 1.0 if normalised-equal, else token Jaccard.
15//! - **bio**: token Jaccard.
16//! - combined = mean of the signals present in both.
17//!
18//! Pairs at or above [`LINK_THRESHOLD`] are linked; connected accounts form
19//! a cluster (union-find). Cluster confidence is the mean linking score.
20//! Confidence is a heuristic triage aid, not proof.
21
22// All `usize as f64` casts here are over small counts (token-set sizes,
23// cluster/edge counts) used to form ratios; the 52-bit mantissa is never a
24// concern at these magnitudes.
25#![allow(clippy::cast_precision_loss)]
26
27use std::collections::BTreeSet;
28
29use crate::check::{CheckOutcome, MatchKind};
30
31/// Minimum pairwise score to link two accounts.
32pub const LINK_THRESHOLD: f64 = 0.5;
33/// Drop tokens shorter than this when building word sets (cuts noise).
34const MIN_TOKEN_LEN: usize = 2;
35
36/// A group of accounts that likely belong to the same person.
37#[derive(Debug, Clone)]
38pub struct Cluster {
39    /// Site names of the member accounts.
40    pub members: Vec<String>,
41    /// Mean linking score across the cluster's edges, in `0..=1`.
42    pub confidence: f64,
43    /// A normalised name shared by the whole cluster, if any.
44    pub shared_name: Option<String>,
45}
46
47/// Result of correlating a scan's outcomes.
48#[derive(Debug, Clone, Default)]
49pub struct CorrelationReport {
50    /// Clusters of size ≥ 2 (actual cross-site links).
51    pub clusters: Vec<Cluster>,
52    /// Found accounts that carry profile data but linked to nothing.
53    pub unlinked: Vec<String>,
54    /// Found accounts with no profile data to correlate on.
55    pub without_profile: Vec<String>,
56}
57
58struct Node<'a> {
59    site: &'a str,
60    name: Option<String>,
61    name_tokens: BTreeSet<String>,
62    bio_tokens: BTreeSet<String>,
63}
64
65/// Correlate `Found` accounts by their enrichment fields.
66#[must_use]
67pub fn correlate(outcomes: &[CheckOutcome]) -> CorrelationReport {
68    let mut report = CorrelationReport::default();
69
70    let mut nodes: Vec<Node<'_>> = Vec::new();
71    for outcome in outcomes.iter().filter(|o| o.kind == MatchKind::Found) {
72        let name = outcome.enrichment.get("name");
73        let bio = outcome.enrichment.get("bio");
74        if name.is_none() && bio.is_none() {
75            report.without_profile.push(outcome.site.clone());
76            continue;
77        }
78        nodes.push(Node {
79            site: &outcome.site,
80            name: name.map(|n| normalize(n)),
81            name_tokens: name.map(|n| tokenize(n)).unwrap_or_default(),
82            bio_tokens: bio.map(|b| tokenize(b)).unwrap_or_default(),
83        });
84    }
85
86    let mut uf = UnionFind::new(nodes.len());
87    // Accumulate edge scores per root so we can average per cluster.
88    let mut edges: Vec<(usize, usize, f64)> = Vec::new();
89    for a in 0..nodes.len() {
90        for b in (a + 1)..nodes.len() {
91            let score = pair_score(&nodes[a], &nodes[b]);
92            if score >= LINK_THRESHOLD {
93                uf.union(a, b);
94                edges.push((a, b, score));
95            }
96        }
97    }
98
99    // Group node indices by union-find root.
100    let mut by_root: std::collections::HashMap<usize, Vec<usize>> =
101        std::collections::HashMap::new();
102    for i in 0..nodes.len() {
103        by_root.entry(uf.find(i)).or_default().push(i);
104    }
105
106    for (root, members) in by_root {
107        if members.len() < 2 {
108            // Singleton with profile data → unlinked.
109            for &i in &members {
110                report.unlinked.push(nodes[i].site.to_owned());
111            }
112            continue;
113        }
114        let scores: Vec<f64> = edges
115            .iter()
116            .filter(|(a, _, _)| uf_root_eq(&mut uf, *a, root))
117            .map(|(_, _, s)| *s)
118            .collect();
119        let confidence = if scores.is_empty() {
120            0.0
121        } else {
122            scores.iter().sum::<f64>() / scores.len() as f64
123        };
124        let mut member_sites: Vec<String> =
125            members.iter().map(|&i| nodes[i].site.to_owned()).collect();
126        member_sites.sort_unstable();
127        report.clusters.push(Cluster {
128            members: member_sites,
129            confidence,
130            shared_name: shared_name(&members, &nodes),
131        });
132    }
133
134    report.clusters.sort_by(|a, b| {
135        b.confidence
136            .partial_cmp(&a.confidence)
137            .unwrap_or(std::cmp::Ordering::Equal)
138            .then_with(|| a.members.cmp(&b.members))
139    });
140    report.unlinked.sort_unstable();
141    report.without_profile.sort_unstable();
142    report
143}
144
145fn uf_root_eq(uf: &mut UnionFind, node: usize, root: usize) -> bool {
146    uf.find(node) == root
147}
148
149fn shared_name(members: &[usize], nodes: &[Node<'_>]) -> Option<String> {
150    let first = nodes[members[0]].name.clone()?;
151    if first.is_empty() {
152        return None;
153    }
154    members
155        .iter()
156        .all(|&i| nodes[i].name.as_deref() == Some(first.as_str()))
157        .then_some(first)
158}
159
160fn pair_score(a: &Node<'_>, b: &Node<'_>) -> f64 {
161    let mut signals: Vec<f64> = Vec::new();
162    if a.name.is_some() && b.name.is_some() {
163        let name_sim = if a.name == b.name {
164            1.0
165        } else {
166            jaccard(&a.name_tokens, &b.name_tokens)
167        };
168        signals.push(name_sim);
169    }
170    if !a.bio_tokens.is_empty() && !b.bio_tokens.is_empty() {
171        signals.push(jaccard(&a.bio_tokens, &b.bio_tokens));
172    }
173    if signals.is_empty() {
174        0.0
175    } else {
176        signals.iter().sum::<f64>() / signals.len() as f64
177    }
178}
179
180fn normalize(s: &str) -> String {
181    s.split_whitespace()
182        .collect::<Vec<_>>()
183        .join(" ")
184        .to_lowercase()
185}
186
187fn tokenize(s: &str) -> BTreeSet<String> {
188    s.split(|c: char| !c.is_alphanumeric())
189        .filter(|t| t.chars().count() >= MIN_TOKEN_LEN)
190        .map(str::to_lowercase)
191        .collect()
192}
193
194fn jaccard(a: &BTreeSet<String>, b: &BTreeSet<String>) -> f64 {
195    if a.is_empty() && b.is_empty() {
196        return 0.0;
197    }
198    let inter = a.intersection(b).count();
199    let union = a.union(b).count();
200    if union == 0 {
201        0.0
202    } else {
203        inter as f64 / union as f64
204    }
205}
206
207struct UnionFind {
208    parent: Vec<usize>,
209}
210
211impl UnionFind {
212    fn new(n: usize) -> Self {
213        Self {
214            parent: (0..n).collect(),
215        }
216    }
217
218    fn find(&mut self, x: usize) -> usize {
219        let mut root = x;
220        while self.parent[root] != root {
221            root = self.parent[root];
222        }
223        // Path compression.
224        let mut cur = x;
225        while self.parent[cur] != root {
226            let next = self.parent[cur];
227            self.parent[cur] = root;
228            cur = next;
229        }
230        root
231    }
232
233    fn union(&mut self, a: usize, b: usize) {
234        let (ra, rb) = (self.find(a), self.find(b));
235        if ra != rb {
236            self.parent[ra] = rb;
237        }
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use std::collections::BTreeMap;
245
246    fn found(site: &str, fields: &[(&str, &str)]) -> CheckOutcome {
247        let mut enrichment = BTreeMap::new();
248        for (k, v) in fields {
249            enrichment.insert((*k).to_owned(), (*v).to_owned());
250        }
251        CheckOutcome {
252            site: site.into(),
253            url: format!("https://{site}.example/u"),
254            kind: MatchKind::Found,
255            reason: None,
256            elapsed_ms: 1,
257            enrichment,
258            evidence: Vec::new(),
259            profile_evidence: Vec::new(),
260            confidence: crate::ConfidenceScore::default(),
261            transport: None,
262            escalations: 0,
263        }
264    }
265
266    #[test]
267    fn links_accounts_with_matching_name_and_bio() {
268        let outcomes = vec![
269            found(
270                "GitHub",
271                &[("name", "Alice Liddell"), ("bio", "Rust systems hacker")],
272            ),
273            found(
274                "GitLab",
275                &[("name", "Alice Liddell"), ("bio", "systems hacker, Rust")],
276            ),
277        ];
278        let report = correlate(&outcomes);
279        assert_eq!(report.clusters.len(), 1);
280        let c = &report.clusters[0];
281        assert_eq!(c.members, ["GitHub", "GitLab"]);
282        assert!(c.confidence > 0.7, "confidence {}", c.confidence);
283        assert_eq!(c.shared_name.as_deref(), Some("alice liddell"));
284    }
285
286    #[test]
287    fn does_not_link_different_people_sharing_a_handle() {
288        let outcomes = vec![
289            found(
290                "GitHub",
291                &[("name", "Alice Liddell"), ("bio", "Rust systems hacker")],
292            ),
293            found(
294                "Twitch",
295                &[("name", "Bob Jones"), ("bio", "pro gamer and streamer")],
296            ),
297        ];
298        let report = correlate(&outcomes);
299        assert!(report.clusters.is_empty(), "should not link: {report:?}");
300        assert_eq!(report.unlinked.len(), 2);
301    }
302
303    #[test]
304    fn accounts_without_profile_are_separated() {
305        let outcomes = vec![
306            found("GitHub", &[("name", "Alice Liddell")]),
307            found("Vimeo", &[]),
308            found("HackerNews", &[]),
309        ];
310        let report = correlate(&outcomes);
311        assert!(report.clusters.is_empty());
312        assert_eq!(report.unlinked, ["GitHub"]);
313        assert_eq!(report.without_profile, ["HackerNews", "Vimeo"]);
314    }
315
316    #[test]
317    fn transitive_links_form_one_cluster() {
318        let outcomes = vec![
319            found("A", &[("bio", "loves rust and coffee")]),
320            found("B", &[("bio", "rust and coffee enthusiast")]),
321            found("C", &[("bio", "coffee and rust forever")]),
322        ];
323        let report = correlate(&outcomes);
324        assert_eq!(report.clusters.len(), 1);
325        assert_eq!(report.clusters[0].members.len(), 3);
326    }
327
328    #[test]
329    fn ignores_not_found_outcomes() {
330        let mut nf = found("GitLab", &[("name", "Alice Liddell")]);
331        nf.kind = MatchKind::NotFound;
332        let outcomes = vec![found("GitHub", &[("name", "Alice Liddell")]), nf];
333        let report = correlate(&outcomes);
334        // Only one Found-with-profile node → no cluster, one unlinked.
335        assert!(report.clusters.is_empty());
336        assert_eq!(report.unlinked, ["GitHub"]);
337    }
338
339    #[test]
340    fn jaccard_basics() {
341        let a = tokenize("rust and coffee");
342        let b = tokenize("rust and tea");
343        // tokens (len>=2): {rust, and, coffee} vs {rust, and, tea}
344        // inter {rust, and}=2, union 4 → 0.5
345        assert!((jaccard(&a, &b) - 0.5).abs() < 1e-9);
346    }
347}