Skip to main content

adler_core/
history.rs

1//! History-derived confidence signals shared by persisted read surfaces.
2
3use std::collections::BTreeMap;
4
5use crate::{CheckOutcome, MatchKind, ProfileEvidenceKind};
6
7/// Borrowed scan metadata needed to compare one scan against prior scans.
8#[derive(Debug, Clone, Copy)]
9pub struct HistoricalScanRef<'a> {
10    /// Stable scan identifier.
11    pub scan_id: &'a str,
12    /// Username this scan observed.
13    pub username: &'a str,
14    /// Scan start timestamp in Unix epoch milliseconds.
15    pub created_at_ms: u64,
16    /// Per-site outcomes in this scan.
17    pub outcomes: &'a [CheckOutcome],
18}
19
20/// Count previous stable Found observations per current Found site.
21///
22/// A site is stable while previous scans for the same username keep returning
23/// Found with the same normalized profile evidence signature. Scans that did
24/// not mention the site are ignored; explicit non-Found outcomes and evidence
25/// changes break the consecutive window.
26#[must_use]
27pub fn historical_consistency_counts<'a>(
28    current: HistoricalScanRef<'a>,
29    related_scans: impl IntoIterator<Item = HistoricalScanRef<'a>>,
30) -> BTreeMap<String, usize> {
31    let mut prior_scans: Vec<_> = related_scans
32        .into_iter()
33        .filter(|scan| scan.username == current.username)
34        .filter(|scan| scan.scan_id != current.scan_id)
35        .filter(|scan| scan_is_before(*scan, current))
36        .collect();
37    prior_scans.sort_by(|left, right| {
38        right
39            .created_at_ms
40            .cmp(&left.created_at_ms)
41            .then_with(|| right.scan_id.cmp(left.scan_id))
42    });
43
44    current
45        .outcomes
46        .iter()
47        .filter(|outcome| outcome.kind == MatchKind::Found)
48        .filter_map(|outcome| {
49            let count = stable_found_history_count(outcome, &prior_scans);
50            (count >= 2).then(|| (outcome.site.clone(), count))
51        })
52        .collect()
53}
54
55pub(crate) fn scan_is_before(left: HistoricalScanRef<'_>, right: HistoricalScanRef<'_>) -> bool {
56    (left.created_at_ms, left.scan_id) < (right.created_at_ms, right.scan_id)
57}
58
59fn stable_found_history_count(
60    current: &CheckOutcome,
61    prior_scans: &[HistoricalScanRef<'_>],
62) -> usize {
63    let current_signature = profile_evidence_signature(current);
64    let mut count = 0;
65
66    for scan in prior_scans {
67        let Some(previous) = scan
68            .outcomes
69            .iter()
70            .find(|outcome| outcome.site == current.site)
71        else {
72            continue;
73        };
74
75        if previous.kind != MatchKind::Found {
76            break;
77        }
78
79        if profile_evidence_signature(previous) != current_signature {
80            break;
81        }
82
83        count += 1;
84    }
85
86    count
87}
88
89pub(crate) fn profile_evidence_signature(
90    outcome: &CheckOutcome,
91) -> Vec<(u8, Option<String>, String)> {
92    let mut signature: Vec<_> = outcome
93        .profile_evidence
94        .iter()
95        .map(|evidence| {
96            (
97                profile_evidence_kind_rank(evidence.kind),
98                evidence.field.clone(),
99                evidence.value.clone(),
100            )
101        })
102        .collect();
103    signature.sort();
104    signature
105}
106
107const fn profile_evidence_kind_rank(kind: ProfileEvidenceKind) -> u8 {
108    match kind {
109        ProfileEvidenceKind::Username => 0,
110        ProfileEvidenceKind::DisplayName => 1,
111        ProfileEvidenceKind::Bio => 2,
112        ProfileEvidenceKind::AvatarUrl => 3,
113        ProfileEvidenceKind::AvatarHash => 4,
114        ProfileEvidenceKind::ExternalLink => 5,
115        ProfileEvidenceKind::Location => 6,
116        ProfileEvidenceKind::JoinedDate => 7,
117        ProfileEvidenceKind::ProfileTitle => 8,
118        ProfileEvidenceKind::MetaDescription => 9,
119        ProfileEvidenceKind::ExtractedField => 10,
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use std::collections::BTreeMap;
126
127    use super::*;
128    use crate::{CheckOutcome, ConfidenceScore, ProfileEvidence};
129
130    fn scan<'a>(
131        scan_id: &'a str,
132        created_at_ms: u64,
133        outcomes: &'a [CheckOutcome],
134    ) -> HistoricalScanRef<'a> {
135        HistoricalScanRef {
136            scan_id,
137            username: "alice",
138            created_at_ms,
139            outcomes,
140        }
141    }
142
143    fn outcome(site: &str, kind: MatchKind) -> CheckOutcome {
144        CheckOutcome {
145            site: site.to_owned(),
146            url: format!("https://{site}.example/alice"),
147            kind,
148            reason: None,
149            elapsed_ms: 10,
150            enrichment: BTreeMap::new(),
151            evidence: Vec::new(),
152            profile_evidence: Vec::new(),
153            confidence: ConfidenceScore::default(),
154            transport: None,
155            escalations: 0,
156        }
157    }
158
159    fn found_with_website(site: &str, value: &str, observed_at_ms: Option<u64>) -> CheckOutcome {
160        let mut outcome = outcome(site, MatchKind::Found);
161        outcome
162            .profile_evidence
163            .push(ProfileEvidence::from_enrichment_with_source(
164                site,
165                &outcome.url,
166                "website",
167                value,
168                observed_at_ms,
169                None,
170            ));
171        outcome
172    }
173
174    #[test]
175    fn two_prior_stable_found_observations_count() {
176        let current = [found_with_website("GitHub", "https://alice.dev", Some(3))];
177        let previous = [found_with_website("GitHub", "https://alice.dev", Some(2))];
178        let older = [found_with_website("GitHub", "https://alice.dev", Some(1))];
179
180        let counts = historical_consistency_counts(
181            scan("current", 30, &current),
182            [scan("previous", 20, &previous), scan("older", 10, &older)],
183        );
184
185        assert_eq!(counts.get("GitHub"), Some(&2));
186    }
187
188    #[test]
189    fn one_prior_found_is_below_threshold() {
190        let current = [found_with_website("GitHub", "https://alice.dev", None)];
191        let previous = [found_with_website("GitHub", "https://alice.dev", None)];
192
193        let counts = historical_consistency_counts(
194            scan("current", 20, &current),
195            [scan("previous", 10, &previous)],
196        );
197
198        assert!(counts.is_empty());
199    }
200
201    #[test]
202    fn non_found_interrupts_history_window() {
203        let current = [found_with_website("GitHub", "https://alice.dev", None)];
204        let previous = [outcome("GitHub", MatchKind::NotFound)];
205        let older = [found_with_website("GitHub", "https://alice.dev", None)];
206        let oldest = [found_with_website("GitHub", "https://alice.dev", None)];
207
208        let counts = historical_consistency_counts(
209            scan("current", 40, &current),
210            [
211                scan("previous", 30, &previous),
212                scan("older", 20, &older),
213                scan("oldest", 10, &oldest),
214            ],
215        );
216
217        assert!(counts.is_empty());
218    }
219
220    #[test]
221    fn missing_filtered_scan_is_ignored() {
222        let current = [found_with_website("GitHub", "https://alice.dev", None)];
223        let missing = [found_with_website("GitLab", "https://alice.dev", None)];
224        let older = [found_with_website("GitHub", "https://alice.dev", None)];
225        let oldest = [found_with_website("GitHub", "https://alice.dev", None)];
226
227        let counts = historical_consistency_counts(
228            scan("current", 40, &current),
229            [
230                scan("missing", 30, &missing),
231                scan("older", 20, &older),
232                scan("oldest", 10, &oldest),
233            ],
234        );
235
236        assert_eq!(counts.get("GitHub"), Some(&2));
237    }
238
239    #[test]
240    fn profile_evidence_change_breaks_history_window() {
241        let current = [found_with_website("GitHub", "https://alice.dev", None)];
242        let previous = [found_with_website("GitHub", "https://other.example", None)];
243        let older = [found_with_website("GitHub", "https://alice.dev", None)];
244        let oldest = [found_with_website("GitHub", "https://alice.dev", None)];
245
246        let counts = historical_consistency_counts(
247            scan("current", 40, &current),
248            [
249                scan("previous", 30, &previous),
250                scan("older", 20, &older),
251                scan("oldest", 10, &oldest),
252            ],
253        );
254
255        assert!(counts.is_empty());
256    }
257}