Skip to main content

adler_core/
confidence.rs

1//! Conservative confidence scoring for scan outcomes.
2//!
3//! This is intentionally rule-based and explainable. The goal is not to
4//! claim identity-level certainty; it is to say how trustworthy Adler's
5//! per-site verdict is and why.
6
7use serde::{Deserialize, Serialize};
8
9use crate::check::{MatchKind, UncertainReason};
10use crate::escalation::TransportTier;
11
12/// Confidence attached to a single scan outcome.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct ConfidenceScore {
15    /// 0-100 confidence in the reported verdict.
16    pub score: u8,
17    /// Coarse label for humans and UI badges.
18    pub label: ConfidenceLabel,
19    /// Explainable reasons that contributed to the score.
20    #[serde(default, skip_serializing_if = "Vec::is_empty")]
21    pub reasons: Vec<ConfidenceReason>,
22}
23
24/// Human-facing confidence bucket.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ConfidenceLabel {
28    /// Weak or inconclusive evidence.
29    Low,
30    /// Usable evidence, but not enough to treat the verdict as strong.
31    Medium,
32    /// Strong per-site evidence for the reported verdict.
33    High,
34    /// Reserved for future cases backed by explicit verification.
35    Verified,
36}
37
38/// Machine-readable confidence rationale.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(tag = "kind", rename_all = "snake_case")]
41pub enum ConfidenceReason {
42    /// The site detection rules produced a positive verdict.
43    FoundBySignal,
44    /// The site detection rules produced a negative verdict.
45    NotFoundBySignal,
46    /// Profile metadata was extracted from the found page.
47    ProfileMetadataExtracted {
48        /// Number of normalized profile evidence items.
49        count: usize,
50    },
51    /// Several normalized profile metadata fields were extracted.
52    ProfileMetadataRich {
53        /// Number of normalized profile evidence items.
54        count: usize,
55    },
56    /// Human-readable detection signal evidence was recorded.
57    SignalEvidence {
58        /// Number of signal evidence lines.
59        count: usize,
60    },
61    /// A detection signal confirmed the concrete probed username.
62    ExactUsernameMatch {
63        /// Number of exact username evidence items.
64        count: usize,
65    },
66    /// The same site has been observed as a stable Found result in
67    /// previous scans for this username.
68    HistoricalConsistency {
69        /// Number of previous stable Found observations.
70        count: usize,
71    },
72    /// The result was produced with an operator-supplied authenticated
73    /// access path.
74    AuthenticatedAccess,
75    /// A browser transport produced a clear verdict.
76    BrowserTransport,
77    /// An impersonating HTTP transport produced a clear verdict.
78    ImpersonateTransport,
79    /// A cheap transport was automatically escalated to a heavier transport
80    /// that produced a clear verdict.
81    EscalatedTransport,
82    /// The verdict is based on one weak status-only signal and no supporting
83    /// profile/access evidence.
84    WeakStatusOnly,
85    /// The probe could not produce a found/not-found verdict.
86    UncertainOutcome,
87    /// The site needs an operator session before Adler can judge presence.
88    SessionRequired,
89    /// Transport/access conditions blocked a reliable probe.
90    TransportBlocked,
91}
92
93/// Normalized scoring input built from a [`crate::CheckOutcome`].
94#[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    /// Score an outcome from the pieces available on `CheckOutcome`.
119    #[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    /// Score an outcome from normalized confidence signals.
140    #[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    /// Convert a numeric 0-100 score into a coarse confidence label.
333    #[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}