1use serde::{Deserialize, Serialize};
8
9use crate::check::{MatchKind, UncertainReason};
10use crate::escalation::TransportTier;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ConfidenceScore {
15 pub score: u8,
17 pub label: ConfidenceLabel,
19 #[serde(default, skip_serializing_if = "Vec::is_empty")]
21 pub reasons: Vec<ConfidenceReason>,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ConfidenceLabel {
28 Low,
30 Medium,
32 High,
34 Verified,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(tag = "kind", rename_all = "snake_case")]
41pub enum ConfidenceReason {
42 FoundBySignal,
44 NotFoundBySignal,
46 ProfileMetadataExtracted {
48 count: usize,
50 },
51 ProfileMetadataRich {
53 count: usize,
55 },
56 SignalEvidence {
58 count: usize,
60 },
61 ExactUsernameMatch {
63 count: usize,
65 },
66 HistoricalConsistency {
69 count: usize,
71 },
72 AuthenticatedAccess,
75 BrowserTransport,
77 ImpersonateTransport,
79 EscalatedTransport,
82 WeakStatusOnly,
85 UncertainOutcome,
87 SessionRequired,
89 TransportBlocked,
91}
92
93#[derive(Debug, Clone)]
95pub(crate) struct ConfidenceSignals {
96 pub(crate) kind: MatchKind,
97 pub(crate) reason: Option<UncertainReason>,
98 pub(crate) signal_evidence_count: usize,
99 pub(crate) profile_evidence_count: usize,
100 pub(crate) username_evidence_count: usize,
101 pub(crate) historical_consistency_count: usize,
102 pub(crate) authenticated_access: bool,
103 pub(crate) transport: Option<TransportTier>,
104 pub(crate) escalations: u8,
105}
106
107impl Default for ConfidenceScore {
108 fn default() -> Self {
109 Self {
110 score: 0,
111 label: ConfidenceLabel::Low,
112 reasons: Vec::new(),
113 }
114 }
115}
116
117impl ConfidenceScore {
118 #[must_use]
120 pub fn from_parts(
121 kind: MatchKind,
122 reason: Option<&UncertainReason>,
123 signal_evidence_count: usize,
124 profile_evidence_count: usize,
125 ) -> Self {
126 Self::from_signals(&ConfidenceSignals {
127 kind,
128 reason: reason.cloned(),
129 signal_evidence_count,
130 profile_evidence_count,
131 username_evidence_count: 0,
132 historical_consistency_count: 0,
133 authenticated_access: false,
134 transport: None,
135 escalations: 0,
136 })
137 }
138
139 #[must_use]
141 pub(crate) fn from_signals(signals: &ConfidenceSignals) -> Self {
142 let mut score = ConfidenceAccumulator::new(base_score(signals.kind));
143 score.add_reason(base_reason(signals.kind));
144
145 apply_evidence_rules(signals, &mut score);
146 apply_profile_rules(signals, &mut score);
147 apply_verification_rules(signals, &mut score);
148 apply_access_rules(signals, &mut score);
149 apply_cap_rules(signals, &mut score);
150 apply_uncertain_reason_rules(signals, &mut score);
151
152 score.finish()
153 }
154}
155
156struct ConfidenceAccumulator {
157 score: u8,
158 reasons: Vec<ConfidenceReason>,
159}
160
161impl ConfidenceAccumulator {
162 const fn new(score: u8) -> Self {
163 Self {
164 score,
165 reasons: Vec::new(),
166 }
167 }
168
169 fn add_score(&mut self, value: u8) {
170 self.score = self.score.saturating_add(value);
171 }
172
173 fn add_reason(&mut self, reason: ConfidenceReason) {
174 self.reasons.push(reason);
175 }
176
177 fn add_score_and_reason(&mut self, value: u8, reason: ConfidenceReason) {
178 self.add_score(value);
179 self.add_reason(reason);
180 }
181
182 fn cap_score(&mut self, cap: u8) {
183 self.score = self.score.min(cap);
184 }
185
186 fn force_score(&mut self, value: u8) {
187 self.score = value;
188 }
189
190 fn finish(mut self) -> ConfidenceScore {
191 self.cap_score(100);
192 ConfidenceScore {
193 score: self.score,
194 label: ConfidenceLabel::from_score(self.score),
195 reasons: self.reasons,
196 }
197 }
198}
199
200const fn base_score(kind: MatchKind) -> u8 {
201 match kind {
202 MatchKind::Found => 65,
203 MatchKind::NotFound => 60,
204 MatchKind::Uncertain => 15,
205 }
206}
207
208const fn base_reason(kind: MatchKind) -> ConfidenceReason {
209 match kind {
210 MatchKind::Found => ConfidenceReason::FoundBySignal,
211 MatchKind::NotFound => ConfidenceReason::NotFoundBySignal,
212 MatchKind::Uncertain => ConfidenceReason::UncertainOutcome,
213 }
214}
215
216fn apply_evidence_rules(signals: &ConfidenceSignals, score: &mut ConfidenceAccumulator) {
217 if signals.signal_evidence_count > 0 {
218 score.add_score_and_reason(
219 10,
220 ConfidenceReason::SignalEvidence {
221 count: signals.signal_evidence_count,
222 },
223 );
224 }
225}
226
227fn apply_profile_rules(signals: &ConfidenceSignals, score: &mut ConfidenceAccumulator) {
228 if signals.profile_evidence_count > 0 {
229 score.add_score_and_reason(
230 10,
231 ConfidenceReason::ProfileMetadataExtracted {
232 count: signals.profile_evidence_count,
233 },
234 );
235 }
236
237 if signals.profile_evidence_count >= 3 {
238 score.add_score_and_reason(
239 5,
240 ConfidenceReason::ProfileMetadataRich {
241 count: signals.profile_evidence_count,
242 },
243 );
244 }
245}
246
247fn apply_verification_rules(signals: &ConfidenceSignals, score: &mut ConfidenceAccumulator) {
248 if signals.kind == MatchKind::Found && signals.username_evidence_count > 0 {
249 score.add_score_and_reason(
250 10,
251 ConfidenceReason::ExactUsernameMatch {
252 count: signals.username_evidence_count,
253 },
254 );
255 }
256
257 if signals.kind == MatchKind::Found && signals.historical_consistency_count >= 2 {
258 score.add_score_and_reason(
259 4,
260 ConfidenceReason::HistoricalConsistency {
261 count: signals.historical_consistency_count,
262 },
263 );
264 }
265}
266
267fn apply_access_rules(signals: &ConfidenceSignals, score: &mut ConfidenceAccumulator) {
268 if signals.authenticated_access && signals.kind != MatchKind::Uncertain {
269 score.add_score_and_reason(10, ConfidenceReason::AuthenticatedAccess);
270 }
271
272 if signals.kind == MatchKind::Uncertain {
273 return;
274 }
275
276 match signals.transport {
277 Some(TransportTier::Browser) => {
278 score.add_score_and_reason(5, ConfidenceReason::BrowserTransport);
279 }
280 Some(TransportTier::Impersonate) => {
281 score.add_score_and_reason(5, ConfidenceReason::ImpersonateTransport);
282 }
283 Some(TransportTier::Http) | None => {}
284 }
285
286 if signals.escalations > 0 {
287 score.add_score_and_reason(10, ConfidenceReason::EscalatedTransport);
288 }
289}
290
291fn apply_cap_rules(signals: &ConfidenceSignals, score: &mut ConfidenceAccumulator) {
292 if is_weak_status_only(signals) {
293 score.cap_score(70);
294 score.add_reason(ConfidenceReason::WeakStatusOnly);
295 }
296}
297
298fn apply_uncertain_reason_rules(signals: &ConfidenceSignals, score: &mut ConfidenceAccumulator) {
299 let Some(reason) = &signals.reason else {
300 return;
301 };
302
303 match reason {
304 UncertainReason::SessionRequired => {
305 score.force_score(0);
306 score.add_reason(ConfidenceReason::SessionRequired);
307 }
308 UncertainReason::CloudflareChallenge
309 | UncertainReason::Captcha
310 | UncertainReason::RateLimited
311 | UncertainReason::BrowserBudget
312 | UncertainReason::BrowserFailed(_)
313 | UncertainReason::GeoUnavailable => {
314 score.cap_score(20);
315 score.add_reason(ConfidenceReason::TransportBlocked);
316 }
317 _ => {}
318 }
319}
320
321fn is_weak_status_only(signals: &ConfidenceSignals) -> bool {
322 matches!(signals.kind, MatchKind::Found | MatchKind::NotFound)
323 && signals.signal_evidence_count == 1
324 && signals.profile_evidence_count == 0
325 && signals.username_evidence_count == 0
326 && !signals.authenticated_access
327 && signals.escalations == 0
328 && matches!(signals.transport, Some(TransportTier::Http) | None)
329}
330
331impl ConfidenceLabel {
332 #[must_use]
334 pub const fn from_score(score: u8) -> Self {
335 match score {
336 90..=100 => Self::Verified,
337 75..=89 => Self::High,
338 40..=74 => Self::Medium,
339 _ => Self::Low,
340 }
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn found_with_signal_and_profile_metadata_scores_high() {
350 let score = ConfidenceScore::from_parts(MatchKind::Found, None, 1, 2);
351 assert_eq!(score.score, 85);
352 assert_eq!(score.label, ConfidenceLabel::High);
353 assert!(matches!(
354 score.reasons.as_slice(),
355 [
356 ConfidenceReason::FoundBySignal,
357 ConfidenceReason::SignalEvidence { count: 1 },
358 ConfidenceReason::ProfileMetadataExtracted { count: 2 },
359 ]
360 ));
361 }
362
363 #[test]
364 fn status_only_found_is_capped_as_medium_confidence() {
365 let score = ConfidenceScore::from_signals(&ConfidenceSignals {
366 kind: MatchKind::Found,
367 reason: None,
368 signal_evidence_count: 1,
369 profile_evidence_count: 0,
370 username_evidence_count: 0,
371 historical_consistency_count: 0,
372 authenticated_access: false,
373 transport: Some(TransportTier::Http),
374 escalations: 0,
375 });
376
377 assert_eq!(score.score, 70);
378 assert_eq!(score.label, ConfidenceLabel::Medium);
379 assert!(
380 score
381 .reasons
382 .iter()
383 .any(|r| matches!(r, ConfidenceReason::WeakStatusOnly))
384 );
385 }
386
387 #[test]
388 fn authenticated_found_scores_higher_than_unauthenticated() {
389 let base = ConfidenceScore::from_signals(&ConfidenceSignals {
390 kind: MatchKind::Found,
391 reason: None,
392 signal_evidence_count: 1,
393 profile_evidence_count: 1,
394 username_evidence_count: 0,
395 historical_consistency_count: 0,
396 authenticated_access: false,
397 transport: Some(TransportTier::Http),
398 escalations: 0,
399 });
400 let authed = ConfidenceScore::from_signals(&ConfidenceSignals {
401 authenticated_access: true,
402 ..ConfidenceSignals {
403 kind: MatchKind::Found,
404 reason: None,
405 signal_evidence_count: 1,
406 profile_evidence_count: 1,
407 username_evidence_count: 0,
408 historical_consistency_count: 0,
409 authenticated_access: false,
410 transport: Some(TransportTier::Http),
411 escalations: 0,
412 }
413 });
414
415 assert!(authed.score > base.score);
416 assert!(
417 authed
418 .reasons
419 .iter()
420 .any(|r| matches!(r, ConfidenceReason::AuthenticatedAccess))
421 );
422 }
423
424 #[test]
425 fn escalated_browser_success_records_transport_reasons() {
426 let score = ConfidenceScore::from_signals(&ConfidenceSignals {
427 kind: MatchKind::Found,
428 reason: None,
429 signal_evidence_count: 1,
430 profile_evidence_count: 0,
431 username_evidence_count: 0,
432 historical_consistency_count: 0,
433 authenticated_access: false,
434 transport: Some(TransportTier::Browser),
435 escalations: 1,
436 });
437
438 assert!(
439 score
440 .reasons
441 .iter()
442 .any(|r| matches!(r, ConfidenceReason::BrowserTransport))
443 );
444 assert!(
445 score
446 .reasons
447 .iter()
448 .any(|r| matches!(r, ConfidenceReason::EscalatedTransport))
449 );
450 }
451
452 #[test]
453 fn transport_blocked_outcome_remains_low_confidence() {
454 let score = ConfidenceScore::from_signals(&ConfidenceSignals {
455 kind: MatchKind::Uncertain,
456 reason: Some(UncertainReason::GeoUnavailable),
457 signal_evidence_count: 0,
458 profile_evidence_count: 0,
459 username_evidence_count: 0,
460 historical_consistency_count: 0,
461 authenticated_access: false,
462 transport: Some(TransportTier::Http),
463 escalations: 0,
464 });
465
466 assert_eq!(score.label, ConfidenceLabel::Low);
467 assert!(score.score <= 20);
468 assert!(
469 score
470 .reasons
471 .iter()
472 .any(|r| matches!(r, ConfidenceReason::TransportBlocked))
473 );
474 }
475
476 #[test]
477 fn session_required_is_low_confidence_about_presence() {
478 let score = ConfidenceScore::from_parts(
479 MatchKind::Uncertain,
480 Some(&UncertainReason::SessionRequired),
481 0,
482 0,
483 );
484 assert_eq!(score.score, 0);
485 assert_eq!(score.label, ConfidenceLabel::Low);
486 assert!(
487 score
488 .reasons
489 .iter()
490 .any(|r| matches!(r, ConfidenceReason::SessionRequired))
491 );
492 }
493
494 #[test]
495 fn exact_username_match_boosts_found_without_profile_metadata_reason() {
496 let score = ConfidenceScore::from_signals(&ConfidenceSignals {
497 kind: MatchKind::Found,
498 reason: None,
499 signal_evidence_count: 1,
500 profile_evidence_count: 0,
501 username_evidence_count: 1,
502 historical_consistency_count: 0,
503 authenticated_access: false,
504 transport: Some(TransportTier::Http),
505 escalations: 0,
506 });
507
508 assert_eq!(score.score, 85);
509 assert_eq!(score.label, ConfidenceLabel::High);
510 assert!(
511 score
512 .reasons
513 .iter()
514 .any(|r| matches!(r, ConfidenceReason::ExactUsernameMatch { count: 1 }))
515 );
516 assert!(!score.reasons.iter().any(|r| matches!(
517 r,
518 ConfidenceReason::ProfileMetadataExtracted { .. }
519 | ConfidenceReason::ProfileMetadataRich { .. }
520 )));
521 }
522
523 #[test]
524 fn historical_consistency_boosts_found_after_two_prior_observations() {
525 let score = ConfidenceScore::from_signals(&ConfidenceSignals {
526 kind: MatchKind::Found,
527 reason: None,
528 signal_evidence_count: 1,
529 profile_evidence_count: 1,
530 username_evidence_count: 0,
531 historical_consistency_count: 2,
532 authenticated_access: false,
533 transport: Some(TransportTier::Http),
534 escalations: 0,
535 });
536
537 assert_eq!(score.score, 89);
538 assert_eq!(score.label, ConfidenceLabel::High);
539 assert!(
540 score
541 .reasons
542 .iter()
543 .any(|r| matches!(r, ConfidenceReason::HistoricalConsistency { count: 2 }))
544 );
545 }
546
547 #[test]
548 fn weak_status_only_remains_medium_with_history() {
549 let score = ConfidenceScore::from_signals(&ConfidenceSignals {
550 kind: MatchKind::Found,
551 reason: None,
552 signal_evidence_count: 1,
553 profile_evidence_count: 0,
554 username_evidence_count: 0,
555 historical_consistency_count: 3,
556 authenticated_access: false,
557 transport: Some(TransportTier::Http),
558 escalations: 0,
559 });
560
561 assert_eq!(score.score, 70);
562 assert_eq!(score.label, ConfidenceLabel::Medium);
563 assert!(
564 score
565 .reasons
566 .iter()
567 .any(|r| matches!(r, ConfidenceReason::HistoricalConsistency { count: 3 }))
568 );
569 assert!(
570 score
571 .reasons
572 .iter()
573 .any(|r| matches!(r, ConfidenceReason::WeakStatusOnly))
574 );
575 }
576}