1#![allow(clippy::cast_precision_loss)]
26
27use std::collections::BTreeSet;
28
29use crate::check::{CheckOutcome, MatchKind};
30
31pub const LINK_THRESHOLD: f64 = 0.5;
33const MIN_TOKEN_LEN: usize = 2;
35
36#[derive(Debug, Clone)]
38pub struct Cluster {
39 pub members: Vec<String>,
41 pub confidence: f64,
43 pub shared_name: Option<String>,
45}
46
47#[derive(Debug, Clone, Default)]
49pub struct CorrelationReport {
50 pub clusters: Vec<Cluster>,
52 pub unlinked: Vec<String>,
54 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#[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 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 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 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 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 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 assert!((jaccard(&a, &b) - 0.5).abs() < 1e-9);
346 }
347}