1use std::collections::{BTreeMap, BTreeSet};
9
10use serde::{Deserialize, Serialize};
11
12use crate::check::{CheckOutcome, MatchKind};
13use crate::confidence::ConfidenceScore;
14use crate::history::{HistoricalScanRef, profile_evidence_signature, scan_is_before};
15use crate::profile::{ProfileEvidence, ProfileEvidenceKind};
16
17const LINK_THRESHOLD: u8 = 60;
18const EXTERNAL_LINK_SCORE: u8 = 90;
19const AVATAR_URL_SCORE: u8 = 85;
20const AVATAR_HASH_SCORE: u8 = 45;
21const DISPLAY_NAME_SCORE: u8 = 60;
22const BIO_PHRASE_SCORE: u8 = 65;
23const LOCATION_SCORE: u8 = 45;
24const HISTORICAL_CO_OCCURRENCE_SCORE: u8 = 10;
25const MAX_CLUSTER_CONFIDENCE: u8 = 95;
26const MIN_DISPLAY_NAME_CHARS: usize = 8;
27const MIN_DISPLAY_NAME_TOKENS: usize = 2;
28const MIN_BIO_PHRASE_TOKENS: usize = 3;
29const MIN_HISTORICAL_CO_OCCURRENCES: usize = 2;
30
31const BIO_STOP_WORDS: &[&str] = &[
32 "about", "and", "are", "for", "from", "has", "have", "into", "that", "the", "this", "was",
33 "were", "with", "you", "your",
34];
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ObservedProfile {
39 pub site: String,
41 pub username: String,
43 pub url: String,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
47 pub evidence: Vec<ProfileEvidence>,
48 pub confidence: ConfidenceScore,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub observed_at_ms: Option<u64>,
53}
54
55impl ObservedProfile {
56 #[must_use]
59 pub fn from_outcome(username: &str, outcome: &CheckOutcome) -> Option<Self> {
60 if outcome.kind != MatchKind::Found || outcome.profile_evidence.is_empty() {
61 return None;
62 }
63
64 let observed_at_ms = outcome
65 .profile_evidence
66 .iter()
67 .filter_map(|evidence| evidence.source.observed_at_ms)
68 .min();
69
70 Some(Self {
71 site: outcome.site.clone(),
72 username: username.to_owned(),
73 url: outcome.url.clone(),
74 evidence: outcome.profile_evidence.clone(),
75 confidence: outcome.confidence.clone(),
76 observed_at_ms,
77 })
78 }
79}
80
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct IdentityCluster {
84 pub id: String,
86 pub members: Vec<ObservedProfile>,
88 pub confidence: u8,
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub reasons: Vec<ClusterReason>,
94 pub uncertain: bool,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
100#[serde(tag = "kind", rename_all = "snake_case")]
101pub enum ClusterReason {
102 SharedDisplayName {
104 value: String,
106 },
107 SharedBioPhrase {
109 phrase: String,
111 },
112 SharedExternalLink {
114 value: String,
116 },
117 SharedLocation {
119 value: String,
121 },
122 SharedAvatarUrl {
124 value: String,
126 },
127 SharedAvatarHash {
129 value: String,
131 },
132 HistoricalCoOccurrence,
134}
135
136#[must_use]
142pub fn build_identity_clusters(username: &str, outcomes: &[CheckOutcome]) -> Vec<IdentityCluster> {
143 let mut profiles: Vec<ObservedProfile> = outcomes
144 .iter()
145 .filter_map(|outcome| ObservedProfile::from_outcome(username, outcome))
146 .collect();
147
148 profiles.sort_by(|left, right| {
149 left.site
150 .cmp(&right.site)
151 .then_with(|| left.url.cmp(&right.url))
152 });
153
154 cluster_observed_profiles(&profiles, &BTreeMap::new())
155}
156
157#[must_use]
165pub fn build_identity_clusters_with_history<'a>(
166 current: HistoricalScanRef<'a>,
167 related_scans: impl IntoIterator<Item = HistoricalScanRef<'a>>,
168) -> Vec<IdentityCluster> {
169 let mut profiles: Vec<ObservedProfile> = current
170 .outcomes
171 .iter()
172 .filter_map(|outcome| ObservedProfile::from_outcome(current.username, outcome))
173 .collect();
174
175 profiles.sort_by(|left, right| {
176 left.site
177 .cmp(&right.site)
178 .then_with(|| left.url.cmp(&right.url))
179 });
180
181 let historical_pairs = historical_co_occurrence_pairs(current, related_scans);
182 cluster_observed_profiles(&profiles, &historical_pairs)
183}
184
185fn cluster_observed_profiles(
186 profiles: &[ObservedProfile],
187 historical_pairs: &BTreeMap<SitePair, usize>,
188) -> Vec<IdentityCluster> {
189 if profiles.len() < 2 {
190 return Vec::new();
191 }
192
193 let mut union_find = UnionFind::new(profiles.len());
194 let mut links = Vec::new();
195
196 for left in 0..profiles.len() {
197 for right in (left + 1)..profiles.len() {
198 if let Some(link) = profile_link(
199 left,
200 right,
201 &profiles[left],
202 &profiles[right],
203 historical_pairs
204 .get(&SitePair::new(&profiles[left].site, &profiles[right].site))
205 .copied()
206 .unwrap_or(0),
207 ) {
208 union_find.union(left, right);
209 links.push(link);
210 }
211 }
212 }
213
214 let mut by_root: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
215 for index in 0..profiles.len() {
216 by_root
217 .entry(union_find.find(index))
218 .or_default()
219 .push(index);
220 }
221
222 let mut clusters = Vec::new();
223 for member_indices in by_root.values().filter(|members| members.len() >= 2) {
224 let members_set: BTreeSet<usize> = member_indices.iter().copied().collect();
225 let cluster_links: Vec<&ProfileLink> = links
226 .iter()
227 .filter(|link| members_set.contains(&link.left) && members_set.contains(&link.right))
228 .collect();
229
230 if cluster_links.is_empty() {
231 continue;
232 }
233
234 let reasons = cluster_reasons(&cluster_links);
235 let mut members: Vec<ObservedProfile> = member_indices
236 .iter()
237 .map(|&index| profiles[index].clone())
238 .collect();
239 members.sort_by(|left, right| {
240 left.site
241 .cmp(&right.site)
242 .then_with(|| left.url.cmp(&right.url))
243 });
244
245 clusters.push(IdentityCluster {
246 id: String::new(),
247 members,
248 confidence: cluster_confidence(&cluster_links),
249 reasons,
250 uncertain: cluster_links.iter().any(|link| !link.strong),
251 });
252 }
253
254 clusters.sort_by(|left, right| {
255 right
256 .confidence
257 .cmp(&left.confidence)
258 .then_with(|| member_order(left).cmp(member_order(right)))
259 });
260
261 for (index, cluster) in clusters.iter_mut().enumerate() {
262 cluster.id = format!("identity-{index:04}", index = index + 1);
263 }
264
265 clusters
266}
267
268fn cluster_reasons(links: &[&ProfileLink]) -> Vec<ClusterReason> {
269 links
270 .iter()
271 .flat_map(|link| link.reasons.iter().cloned())
272 .collect::<BTreeSet<_>>()
273 .into_iter()
274 .collect()
275}
276
277fn cluster_confidence(links: &[&ProfileLink]) -> u8 {
278 let sum: u32 = links.iter().map(|link| u32::from(link.score)).sum();
279 let count = u32::try_from(links.len()).unwrap_or(u32::MAX);
280 let rounded = (sum + (count / 2)) / count;
281 u8::try_from(rounded).unwrap_or(MAX_CLUSTER_CONFIDENCE)
282}
283
284fn member_order(cluster: &IdentityCluster) -> impl Iterator<Item = (&String, &String)> {
285 cluster
286 .members
287 .iter()
288 .map(|member| (&member.site, &member.url))
289}
290
291#[derive(Debug, Clone)]
292struct ProfileLink {
293 left: usize,
294 right: usize,
295 score: u8,
296 reasons: Vec<ClusterReason>,
297 strong: bool,
298}
299
300fn profile_link(
301 left: usize,
302 right: usize,
303 left_profile: &ObservedProfile,
304 right_profile: &ObservedProfile,
305 historical_co_occurrences: usize,
306) -> Option<ProfileLink> {
307 let mut signals = Vec::new();
308
309 if let Some(value) = shared_value(
310 left_profile,
311 right_profile,
312 ProfileEvidenceKind::ExternalLink,
313 normalize_url,
314 ) {
315 signals.push(LinkSignal::strong(
316 EXTERNAL_LINK_SCORE,
317 ClusterReason::SharedExternalLink { value },
318 ));
319 }
320
321 if let Some(value) = shared_value(
322 left_profile,
323 right_profile,
324 ProfileEvidenceKind::AvatarUrl,
325 normalize_url,
326 ) {
327 signals.push(LinkSignal::strong(
328 AVATAR_URL_SCORE,
329 ClusterReason::SharedAvatarUrl { value },
330 ));
331 }
332
333 if let Some(value) = shared_value(
334 left_profile,
335 right_profile,
336 ProfileEvidenceKind::AvatarHash,
337 normalize_hash,
338 ) {
339 signals.push(LinkSignal::weak(
340 AVATAR_HASH_SCORE,
341 ClusterReason::SharedAvatarHash { value },
342 ));
343 }
344
345 if let Some(value) = shared_value(
346 left_profile,
347 right_profile,
348 ProfileEvidenceKind::DisplayName,
349 normalize_text,
350 )
351 .filter(|value| conservative_display_name(value))
352 {
353 signals.push(LinkSignal::weak(
354 DISPLAY_NAME_SCORE,
355 ClusterReason::SharedDisplayName { value },
356 ));
357 }
358
359 if let Some(phrase) = shared_bio_phrase(left_profile, right_profile) {
360 signals.push(LinkSignal::weak(
361 BIO_PHRASE_SCORE,
362 ClusterReason::SharedBioPhrase { phrase },
363 ));
364 }
365
366 if let Some(value) = shared_value(
367 left_profile,
368 right_profile,
369 ProfileEvidenceKind::Location,
370 normalize_text,
371 ) {
372 signals.push(LinkSignal::weak(
373 LOCATION_SCORE,
374 ClusterReason::SharedLocation { value },
375 ));
376 }
377
378 if signals.is_empty() {
379 return None;
380 }
381
382 let strong = signals.iter().any(|signal| signal.strong);
383 let base_score = signals
384 .iter()
385 .map(|signal| signal.score)
386 .fold(0_u8, u8::saturating_add)
387 .min(MAX_CLUSTER_CONFIDENCE);
388 if base_score < LINK_THRESHOLD {
389 return None;
390 }
391
392 if historical_co_occurrences >= MIN_HISTORICAL_CO_OCCURRENCES {
393 signals.push(LinkSignal::weak(
394 HISTORICAL_CO_OCCURRENCE_SCORE,
395 ClusterReason::HistoricalCoOccurrence,
396 ));
397 }
398
399 let score = signals
400 .iter()
401 .map(|signal| signal.score)
402 .fold(0_u8, u8::saturating_add)
403 .min(MAX_CLUSTER_CONFIDENCE);
404
405 Some(ProfileLink {
406 left,
407 right,
408 score,
409 reasons: signals.into_iter().map(|signal| signal.reason).collect(),
410 strong,
411 })
412}
413
414#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
415struct SitePair(String, String);
416
417impl SitePair {
418 fn new(left: &str, right: &str) -> Self {
419 if left <= right {
420 Self(left.to_owned(), right.to_owned())
421 } else {
422 Self(right.to_owned(), left.to_owned())
423 }
424 }
425}
426
427fn historical_co_occurrence_pairs<'a>(
428 current: HistoricalScanRef<'a>,
429 related_scans: impl IntoIterator<Item = HistoricalScanRef<'a>>,
430) -> BTreeMap<SitePair, usize> {
431 let mut prior_scans: Vec<_> = related_scans
432 .into_iter()
433 .filter(|scan| scan.username == current.username)
434 .filter(|scan| scan.scan_id != current.scan_id)
435 .filter(|scan| scan_is_before(*scan, current))
436 .collect();
437 prior_scans.sort_by(|left, right| {
438 right
439 .created_at_ms
440 .cmp(&left.created_at_ms)
441 .then_with(|| right.scan_id.cmp(left.scan_id))
442 });
443
444 let current_found: Vec<&CheckOutcome> = current
445 .outcomes
446 .iter()
447 .filter(|outcome| outcome.kind == MatchKind::Found)
448 .filter(|outcome| has_identity_evidence(outcome))
449 .collect();
450 let mut pairs = BTreeMap::new();
451
452 for left in 0..current_found.len() {
453 for right in (left + 1)..current_found.len() {
454 let left_outcome = current_found[left];
455 let right_outcome = current_found[right];
456 let count =
457 stable_pair_history_count(left_outcome, right_outcome, prior_scans.as_slice());
458 if count >= MIN_HISTORICAL_CO_OCCURRENCES {
459 pairs.insert(
460 SitePair::new(&left_outcome.site, &right_outcome.site),
461 count,
462 );
463 }
464 }
465 }
466
467 pairs
468}
469
470fn stable_pair_history_count(
471 left_current: &CheckOutcome,
472 right_current: &CheckOutcome,
473 prior_scans: &[HistoricalScanRef<'_>],
474) -> usize {
475 let left_signature = profile_evidence_signature(left_current);
476 let right_signature = profile_evidence_signature(right_current);
477 let mut count = 0;
478
479 for scan in prior_scans {
480 let left_previous = scan
481 .outcomes
482 .iter()
483 .find(|outcome| outcome.site == left_current.site);
484 let right_previous = scan
485 .outcomes
486 .iter()
487 .find(|outcome| outcome.site == right_current.site);
488
489 match (left_previous, right_previous) {
490 (Some(left), Some(right))
491 if left.kind == MatchKind::Found
492 && right.kind == MatchKind::Found
493 && profile_evidence_signature(left) == left_signature
494 && profile_evidence_signature(right) == right_signature =>
495 {
496 count += 1;
497 }
498 (Some(left), Some(right))
499 if left.kind == MatchKind::Found && right.kind == MatchKind::Found =>
500 {
501 break;
502 }
503 (Some(outcome), _) if outcome.kind != MatchKind::Found => break,
504 (_, Some(outcome)) if outcome.kind != MatchKind::Found => break,
505 (Some(outcome), None)
506 if outcome.kind == MatchKind::Found
507 && profile_evidence_signature(outcome) != left_signature =>
508 {
509 break;
510 }
511 (None, Some(outcome))
512 if outcome.kind == MatchKind::Found
513 && profile_evidence_signature(outcome) != right_signature =>
514 {
515 break;
516 }
517 _ => {}
518 }
519 }
520
521 count
522}
523
524fn has_identity_evidence(outcome: &CheckOutcome) -> bool {
525 outcome
526 .profile_evidence
527 .iter()
528 .any(|evidence| evidence.kind != ProfileEvidenceKind::Username)
529}
530
531#[derive(Debug, Clone)]
532struct LinkSignal {
533 score: u8,
534 reason: ClusterReason,
535 strong: bool,
536}
537
538impl LinkSignal {
539 const fn strong(score: u8, reason: ClusterReason) -> Self {
540 Self {
541 score,
542 reason,
543 strong: true,
544 }
545 }
546
547 const fn weak(score: u8, reason: ClusterReason) -> Self {
548 Self {
549 score,
550 reason,
551 strong: false,
552 }
553 }
554}
555
556fn shared_value(
557 left_profile: &ObservedProfile,
558 right_profile: &ObservedProfile,
559 kind: ProfileEvidenceKind,
560 normalize: fn(&str) -> String,
561) -> Option<String> {
562 let left_values = normalized_values(left_profile, kind, normalize);
563 let right_values = normalized_values(right_profile, kind, normalize);
564 left_values.intersection(&right_values).next().cloned()
565}
566
567fn normalized_values(
568 profile: &ObservedProfile,
569 kind: ProfileEvidenceKind,
570 normalize: fn(&str) -> String,
571) -> BTreeSet<String> {
572 profile
573 .evidence
574 .iter()
575 .filter(|evidence| evidence.kind == kind)
576 .map(|evidence| normalize(&evidence.value))
577 .filter(|value| !value.is_empty())
578 .collect()
579}
580
581fn shared_bio_phrase(
582 left_profile: &ObservedProfile,
583 right_profile: &ObservedProfile,
584) -> Option<String> {
585 let left_phrases = bio_phrases(left_profile);
586 let right_phrases = bio_phrases(right_profile);
587 left_phrases.intersection(&right_phrases).next().cloned()
588}
589
590fn bio_phrases(profile: &ObservedProfile) -> BTreeSet<String> {
591 profile
592 .evidence
593 .iter()
594 .filter(|evidence| evidence.kind == ProfileEvidenceKind::Bio)
595 .flat_map(|evidence| phrase_windows(&evidence.value))
596 .collect()
597}
598
599fn phrase_windows(value: &str) -> BTreeSet<String> {
600 let tokens = significant_tokens(value);
601 if tokens.len() < MIN_BIO_PHRASE_TOKENS {
602 return BTreeSet::new();
603 }
604 tokens
605 .windows(MIN_BIO_PHRASE_TOKENS)
606 .map(|window| window.join(" "))
607 .collect()
608}
609
610fn significant_tokens(value: &str) -> Vec<String> {
611 normalize_text(value)
612 .split_whitespace()
613 .filter(|token| token.chars().count() >= 3)
614 .filter(|token| !BIO_STOP_WORDS.contains(token))
615 .map(str::to_owned)
616 .collect()
617}
618
619fn conservative_display_name(value: &str) -> bool {
620 value.split_whitespace().count() >= MIN_DISPLAY_NAME_TOKENS
621 && value.chars().filter(|ch| ch.is_alphanumeric()).count() >= MIN_DISPLAY_NAME_CHARS
622}
623
624fn normalize_text(value: &str) -> String {
625 let mut normalized = String::with_capacity(value.len());
626 for ch in value.chars() {
627 if ch.is_alphanumeric() {
628 for lower in ch.to_lowercase() {
629 normalized.push(lower);
630 }
631 } else {
632 normalized.push(' ');
633 }
634 }
635 normalized.split_whitespace().collect::<Vec<_>>().join(" ")
636}
637
638fn normalize_url(value: &str) -> String {
639 let trimmed = value.trim();
640 if trimmed.is_empty() {
641 return String::new();
642 }
643
644 let Ok(parsed) = url::Url::parse(trimmed) else {
645 return normalize_text(trimmed);
646 };
647
648 let Some(host) = parsed.host_str() else {
649 return parsed.to_string().trim_end_matches('/').to_lowercase();
650 };
651
652 let scheme = parsed.scheme().to_lowercase();
653 let host = host.to_lowercase();
654 let port = parsed
655 .port()
656 .map(|port| format!(":{port}"))
657 .unwrap_or_default();
658 let path = parsed.path().trim_end_matches('/');
659 let path = if path.is_empty() { "/" } else { path };
660 let query = parsed
661 .query()
662 .map(|query| format!("?{query}"))
663 .unwrap_or_default();
664
665 format!("{scheme}://{host}{port}{path}{query}")
666}
667
668fn normalize_hash(value: &str) -> String {
669 value.trim().to_ascii_lowercase()
670}
671
672struct UnionFind {
673 parent: Vec<usize>,
674}
675
676impl UnionFind {
677 fn new(len: usize) -> Self {
678 Self {
679 parent: (0..len).collect(),
680 }
681 }
682
683 fn find(&mut self, node: usize) -> usize {
684 let mut root = node;
685 while self.parent[root] != root {
686 root = self.parent[root];
687 }
688
689 let mut current = node;
690 while self.parent[current] != root {
691 let parent = self.parent[current];
692 self.parent[current] = root;
693 current = parent;
694 }
695
696 root
697 }
698
699 fn union(&mut self, left: usize, right: usize) {
700 let left_root = self.find(left);
701 let right_root = self.find(right);
702 if left_root != right_root {
703 self.parent[left_root] = right_root;
704 }
705 }
706}
707
708#[cfg(test)]
709mod tests {
710 use std::collections::BTreeMap;
711
712 use super::*;
713 use crate::{ConfidenceScore, TransportTier};
714
715 fn found(site: &str, fields: &[(&str, &str)]) -> CheckOutcome {
716 let url = format!("https://{}.example/alice", site.to_lowercase());
717 let profile_evidence = fields
718 .iter()
719 .map(|(field, value)| ProfileEvidence::from_enrichment(site, &url, field, value))
720 .collect();
721 let mut outcome = CheckOutcome {
722 site: site.to_owned(),
723 url,
724 kind: MatchKind::Found,
725 reason: None,
726 elapsed_ms: 10,
727 enrichment: BTreeMap::new(),
728 evidence: vec!["HTTP 200 (status_found)".to_owned()],
729 profile_evidence,
730 confidence: ConfidenceScore::default(),
731 transport: Some(TransportTier::Http),
732 escalations: 0,
733 };
734 outcome.refresh_confidence();
735 outcome
736 }
737
738 fn historical_scan<'a>(
739 scan_id: &'a str,
740 created_at_ms: u64,
741 outcomes: &'a [CheckOutcome],
742 ) -> HistoricalScanRef<'a> {
743 HistoricalScanRef {
744 scan_id,
745 username: "alice",
746 created_at_ms,
747 outcomes,
748 }
749 }
750
751 #[test]
752 fn shared_external_link_clusters_profiles() {
753 let clusters = build_identity_clusters(
754 "alice",
755 &[
756 found(
757 "GitHub",
758 &[("name", "Alice Example"), ("website", "https://Alice.dev/")],
759 ),
760 found("GitLab", &[("website", "https://alice.dev")]),
761 ],
762 );
763
764 assert_eq!(clusters.len(), 1);
765 assert_eq!(clusters[0].confidence, 90);
766 assert!(!clusters[0].uncertain);
767 assert_eq!(clusters[0].members[0].site, "GitHub");
768 assert_eq!(clusters[0].members[1].site, "GitLab");
769 assert!(
770 clusters[0]
771 .reasons
772 .contains(&ClusterReason::SharedExternalLink {
773 value: "https://alice.dev/".to_owned(),
774 })
775 );
776 }
777
778 #[test]
779 fn shared_display_name_clusters_only_above_conservative_threshold() {
780 let strong_name = build_identity_clusters(
781 "alice",
782 &[
783 found("GitHub", &[("name", "Alice Example")]),
784 found("Mastodon", &[("name", "alice example")]),
785 ],
786 );
787 assert_eq!(strong_name.len(), 1);
788 assert!(strong_name[0].uncertain);
789 assert_eq!(strong_name[0].confidence, 60);
790 assert!(
791 strong_name[0]
792 .reasons
793 .contains(&ClusterReason::SharedDisplayName {
794 value: "alice example".to_owned(),
795 })
796 );
797
798 let weak_name = build_identity_clusters(
799 "alice",
800 &[
801 found("GitHub", &[("name", "Alice")]),
802 found("Mastodon", &[("name", "alice")]),
803 ],
804 );
805 assert!(weak_name.is_empty());
806 }
807
808 #[test]
809 fn username_only_matches_do_not_cluster() {
810 let clusters =
811 build_identity_clusters("alice", &[found("GitHub", &[]), found("GitLab", &[])]);
812
813 assert!(clusters.is_empty());
814 }
815
816 #[test]
817 fn username_evidence_only_matches_do_not_cluster() {
818 let mut github = found("GitHub", &[]);
819 github.profile_evidence = vec![ProfileEvidence::from_signal_username(
820 "GitHub",
821 &github.url,
822 "alice",
823 Some(100),
824 None,
825 )];
826 github.refresh_confidence();
827
828 let mut gitlab = found("GitLab", &[]);
829 gitlab.profile_evidence = vec![ProfileEvidence::from_signal_username(
830 "GitLab",
831 &gitlab.url,
832 "alice",
833 Some(100),
834 None,
835 )];
836 gitlab.refresh_confidence();
837
838 let clusters = build_identity_clusters("alice", &[github, gitlab]);
839
840 assert!(clusters.is_empty());
841 }
842
843 #[test]
844 fn avatar_hash_only_matches_do_not_cluster() {
845 let mut github = found("GitHub", &[]);
846 github.profile_evidence = vec![ProfileEvidence::from_avatar_hash(
847 "GitHub",
848 &github.url,
849 "dhash64_v1:0123456789abcdef",
850 Some(100),
851 None,
852 )];
853 github.refresh_confidence();
854
855 let mut gitlab = found("GitLab", &[]);
856 gitlab.profile_evidence = vec![ProfileEvidence::from_avatar_hash(
857 "GitLab",
858 &gitlab.url,
859 "DHASH64_V1:0123456789ABCDEF",
860 Some(100),
861 None,
862 )];
863 gitlab.refresh_confidence();
864
865 let clusters = build_identity_clusters("alice", &[github, gitlab]);
866
867 assert!(clusters.is_empty());
868 }
869
870 #[test]
871 fn avatar_hash_with_shared_location_produces_uncertain_cluster() {
872 let mut github = found("GitHub", &[("location", "Berlin, Germany")]);
873 github
874 .profile_evidence
875 .push(ProfileEvidence::from_avatar_hash(
876 "GitHub",
877 &github.url,
878 "dhash64_v1:0123456789abcdef",
879 Some(100),
880 None,
881 ));
882 github.refresh_confidence();
883
884 let mut gitlab = found("GitLab", &[("location", "berlin germany")]);
885 gitlab
886 .profile_evidence
887 .push(ProfileEvidence::from_avatar_hash(
888 "GitLab",
889 &gitlab.url,
890 "DHASH64_V1:0123456789ABCDEF",
891 Some(100),
892 None,
893 ));
894 gitlab.refresh_confidence();
895
896 let clusters = build_identity_clusters("alice", &[github, gitlab]);
897
898 assert_eq!(clusters.len(), 1);
899 assert!(clusters[0].uncertain);
900 assert_eq!(clusters[0].confidence, 90);
901 assert!(
902 clusters[0]
903 .reasons
904 .contains(&ClusterReason::SharedAvatarHash {
905 value: "dhash64_v1:0123456789abcdef".to_owned(),
906 })
907 );
908 assert!(
909 clusters[0]
910 .reasons
911 .contains(&ClusterReason::SharedLocation {
912 value: "berlin germany".to_owned(),
913 })
914 );
915 }
916
917 #[test]
918 fn unrelated_profiles_remain_separate() {
919 let clusters = build_identity_clusters(
920 "alice",
921 &[
922 found(
923 "GitHub",
924 &[("name", "Alice Example"), ("website", "https://alice.dev")],
925 ),
926 found(
927 "Twitch",
928 &[("name", "Bob Example"), ("website", "https://bob.example")],
929 ),
930 ],
931 );
932
933 assert!(clusters.is_empty());
934 }
935
936 #[test]
937 fn ambiguous_bio_phrase_links_are_marked_uncertain() {
938 let clusters = build_identity_clusters(
939 "alice",
940 &[
941 found("GitHub", &[("bio", "Rust systems researcher and builder")]),
942 found("GitLab", &[("bio", "Rust systems researcher in Berlin")]),
943 ],
944 );
945
946 assert_eq!(clusters.len(), 1);
947 assert!(clusters[0].uncertain);
948 assert_eq!(clusters[0].confidence, 65);
949 assert!(
950 clusters[0]
951 .reasons
952 .contains(&ClusterReason::SharedBioPhrase {
953 phrase: "rust systems researcher".to_owned(),
954 })
955 );
956 }
957
958 #[test]
959 fn cluster_with_any_weak_edge_is_uncertain() {
960 let clusters = build_identity_clusters(
961 "alice",
962 &[
963 found("GitHub", &[("website", "https://alice.dev")]),
964 found(
965 "GitLab",
966 &[("website", "https://alice.dev"), ("name", "Alice Example")],
967 ),
968 found("Mastodon", &[("name", "Alice Example")]),
969 ],
970 );
971
972 assert_eq!(clusters.len(), 1);
973 assert_eq!(clusters[0].members.len(), 3);
974 assert!(clusters[0].uncertain);
975 assert!(
976 clusters[0]
977 .reasons
978 .contains(&ClusterReason::SharedExternalLink {
979 value: "https://alice.dev/".to_owned(),
980 })
981 );
982 assert!(
983 clusters[0]
984 .reasons
985 .contains(&ClusterReason::SharedDisplayName {
986 value: "alice example".to_owned(),
987 })
988 );
989 }
990
991 #[test]
992 fn observed_profile_uses_earliest_evidence_timestamp() {
993 let mut outcome = found("GitHub", &[("name", "Alice Example"), ("bio", "Rust")]);
994 outcome.profile_evidence[0].source.observed_at_ms = Some(200);
995 outcome.profile_evidence[1].source.observed_at_ms = Some(100);
996
997 let observed = ObservedProfile::from_outcome("alice", &outcome).unwrap();
998
999 assert_eq!(observed.observed_at_ms, Some(100));
1000 assert_eq!(observed.username, "alice");
1001 }
1002
1003 #[test]
1004 fn not_found_outcomes_are_ignored_even_with_profile_evidence() {
1005 let mut outcome = found("GitHub", &[("name", "Alice Example")]);
1006 outcome.kind = MatchKind::NotFound;
1007
1008 assert!(build_identity_clusters("alice", &[outcome]).is_empty());
1009 }
1010
1011 #[test]
1012 fn cluster_reason_serializes_as_snake_case_tagged_data() {
1013 let reason = ClusterReason::SharedAvatarUrl {
1014 value: "https://cdn.example/avatar.png".to_owned(),
1015 };
1016 let json = serde_json::to_value(reason).unwrap();
1017
1018 assert_eq!(json["kind"], "shared_avatar_url");
1019 assert_eq!(json["value"], "https://cdn.example/avatar.png");
1020 }
1021
1022 #[test]
1023 fn historical_co_occurrence_reinforces_existing_current_link() {
1024 let current = [
1025 found("GitHub", &[("website", "https://alice.dev")]),
1026 found("GitLab", &[("website", "https://alice.dev")]),
1027 ];
1028 let previous = [
1029 found("GitHub", &[("website", "https://alice.dev")]),
1030 found("GitLab", &[("website", "https://alice.dev")]),
1031 ];
1032 let older = [
1033 found("GitHub", &[("website", "https://alice.dev")]),
1034 found("GitLab", &[("website", "https://alice.dev")]),
1035 ];
1036
1037 let clusters = build_identity_clusters_with_history(
1038 historical_scan("current", 30, ¤t),
1039 [
1040 historical_scan("previous", 20, &previous),
1041 historical_scan("older", 10, &older),
1042 ],
1043 );
1044
1045 assert_eq!(clusters.len(), 1);
1046 assert_eq!(clusters[0].confidence, 95);
1047 assert!(!clusters[0].uncertain);
1048 assert!(
1049 clusters[0]
1050 .reasons
1051 .contains(&ClusterReason::HistoricalCoOccurrence)
1052 );
1053 }
1054
1055 #[test]
1056 fn historical_co_occurrence_does_not_create_standalone_merge() {
1057 let current = [
1058 found("GitHub", &[("website", "https://alice.dev")]),
1059 found("GitLab", &[("website", "https://gitlab.example/alice")]),
1060 ];
1061 let previous = [
1062 found("GitHub", &[("website", "https://alice.dev")]),
1063 found("GitLab", &[("website", "https://gitlab.example/alice")]),
1064 ];
1065 let older = [
1066 found("GitHub", &[("website", "https://alice.dev")]),
1067 found("GitLab", &[("website", "https://gitlab.example/alice")]),
1068 ];
1069
1070 let clusters = build_identity_clusters_with_history(
1071 historical_scan("current", 30, ¤t),
1072 [
1073 historical_scan("previous", 20, &previous),
1074 historical_scan("older", 10, &older),
1075 ],
1076 );
1077
1078 assert!(clusters.is_empty());
1079 }
1080
1081 #[test]
1082 fn historical_co_occurrence_resets_on_explicit_non_found() {
1083 let current = [
1084 found("GitHub", &[("website", "https://alice.dev")]),
1085 found("GitLab", &[("website", "https://alice.dev")]),
1086 ];
1087 let previous = [found("GitHub", &[("website", "https://alice.dev")]), {
1088 let mut outcome = found("GitLab", &[("website", "https://alice.dev")]);
1089 outcome.kind = MatchKind::NotFound;
1090 outcome
1091 }];
1092 let older = [
1093 found("GitHub", &[("website", "https://alice.dev")]),
1094 found("GitLab", &[("website", "https://alice.dev")]),
1095 ];
1096 let oldest = [
1097 found("GitHub", &[("website", "https://alice.dev")]),
1098 found("GitLab", &[("website", "https://alice.dev")]),
1099 ];
1100
1101 let clusters = build_identity_clusters_with_history(
1102 historical_scan("current", 40, ¤t),
1103 [
1104 historical_scan("previous", 30, &previous),
1105 historical_scan("older", 20, &older),
1106 historical_scan("oldest", 10, &oldest),
1107 ],
1108 );
1109
1110 assert_eq!(clusters.len(), 1);
1111 assert_eq!(clusters[0].confidence, 90);
1112 assert!(
1113 !clusters[0]
1114 .reasons
1115 .contains(&ClusterReason::HistoricalCoOccurrence)
1116 );
1117 }
1118
1119 #[test]
1120 fn historical_co_occurrence_resets_on_profile_evidence_change() {
1121 let current = [
1122 found("GitHub", &[("website", "https://alice.dev")]),
1123 found("GitLab", &[("website", "https://alice.dev")]),
1124 ];
1125 let previous = [
1126 found("GitHub", &[("website", "https://alice.dev")]),
1127 found("GitLab", &[("website", "https://other.example")]),
1128 ];
1129 let older = [
1130 found("GitHub", &[("website", "https://alice.dev")]),
1131 found("GitLab", &[("website", "https://alice.dev")]),
1132 ];
1133 let oldest = [
1134 found("GitHub", &[("website", "https://alice.dev")]),
1135 found("GitLab", &[("website", "https://alice.dev")]),
1136 ];
1137
1138 let clusters = build_identity_clusters_with_history(
1139 historical_scan("current", 40, ¤t),
1140 [
1141 historical_scan("previous", 30, &previous),
1142 historical_scan("older", 20, &older),
1143 historical_scan("oldest", 10, &oldest),
1144 ],
1145 );
1146
1147 assert_eq!(clusters.len(), 1);
1148 assert_eq!(clusters[0].confidence, 90);
1149 assert!(
1150 !clusters[0]
1151 .reasons
1152 .contains(&ClusterReason::HistoricalCoOccurrence)
1153 );
1154 }
1155}