Skip to main content

adler_core/
identity.rs

1//! Stable identity-clustering models built from structured profile evidence.
2//!
3//! This module intentionally lives alongside the older [`crate::correlate`]
4//! report. The legacy report remains useful for compact CLI output, while
5//! these serde-compatible types are the foundation for Web, MCP, and future
6//! investigation reports.
7
8use 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/// A positive profile observation suitable for identity-level reasoning.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct ObservedProfile {
39    /// Site name that produced the positive result.
40    pub site: String,
41    /// Username or handle being investigated.
42    pub username: String,
43    /// Concrete profile URL that was observed.
44    pub url: String,
45    /// Structured profile facts extracted from the result.
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub evidence: Vec<ProfileEvidence>,
48    /// Per-profile verdict confidence.
49    pub confidence: ConfidenceScore,
50    /// Earliest observation timestamp among the profile evidence items.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub observed_at_ms: Option<u64>,
53}
54
55impl ObservedProfile {
56    /// Build an observed profile from a `Found` outcome with structured
57    /// profile evidence.
58    #[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/// A deterministic candidate group of profiles that likely belong together.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct IdentityCluster {
84    /// Stable deterministic identifier within this clustering result.
85    pub id: String,
86    /// Observed profiles included in the cluster.
87    pub members: Vec<ObservedProfile>,
88    /// Cluster-level confidence in `0..=100`, separate from per-profile
89    /// verdict confidence.
90    pub confidence: u8,
91    /// Evidence reasons that linked the cluster members.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub reasons: Vec<ClusterReason>,
94    /// Whether the cluster is linked only by weak or ambiguous evidence.
95    pub uncertain: bool,
96}
97
98/// Deterministic reasons used to link observed profiles.
99#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
100#[serde(tag = "kind", rename_all = "snake_case")]
101pub enum ClusterReason {
102    /// Profiles share the same normalized display name.
103    SharedDisplayName {
104        /// Normalized shared display name.
105        value: String,
106    },
107    /// Profiles share a normalized phrase from their biography text.
108    SharedBioPhrase {
109        /// Shared normalized biography phrase.
110        phrase: String,
111    },
112    /// Profiles share the same normalized external link.
113    SharedExternalLink {
114        /// Normalized external URL or link value.
115        value: String,
116    },
117    /// Profiles share the same normalized location.
118    SharedLocation {
119        /// Normalized shared location value.
120        value: String,
121    },
122    /// Profiles share the same normalized avatar URL.
123    SharedAvatarUrl {
124        /// Normalized avatar URL.
125        value: String,
126    },
127    /// Profiles share the same normalized avatar perceptual hash.
128    SharedAvatarHash {
129        /// Normalized avatar perceptual hash.
130        value: String,
131    },
132    /// Profiles historically appeared together in repeated scans.
133    HistoricalCoOccurrence,
134}
135
136/// Build deterministic identity clusters from scan outcomes.
137///
138/// Only `Found` outcomes with structured `profile_evidence` are considered.
139/// The supplied `username` is copied into each observed profile, but username
140/// equality is never used as a linking signal.
141#[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/// Build deterministic identity clusters with historical co-occurrence support.
158///
159/// Historical co-occurrence is deliberately conservative: it can reinforce a
160/// pair that already links on current profile evidence, but it never creates a
161/// cluster by itself and never lifts a below-threshold current link over the
162/// threshold. This keeps repeated scans of the same username from becoming an
163/// identity signal on their own.
164#[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, &current),
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, &current),
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, &current),
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, &current),
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}