1use std::collections::BTreeMap;
4
5use crate::{CheckOutcome, MatchKind, ProfileEvidenceKind};
6
7#[derive(Debug, Clone, Copy)]
9pub struct HistoricalScanRef<'a> {
10 pub scan_id: &'a str,
12 pub username: &'a str,
14 pub created_at_ms: u64,
16 pub outcomes: &'a [CheckOutcome],
18}
19
20#[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, ¤t),
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, ¤t),
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, ¤t),
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, ¤t),
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, ¤t),
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}