Skip to main content

stygian_browser/transport_realism/
scoring.rs

1//! Scoring logic for [`TransportProfile`] × [`TransportObservation`].
2//!
3//! The [`score`] function is the single entry point that produces a
4//! [`TransportRealismReport`] from a profile + observation pair. The
5//! report carries a [`TransportCompatibility`] (the headline score,
6//! confidence, and coverage markers) plus the structured mismatch
7//! list the caller can attach to downstream telemetry.
8//!
9//! The function is fully deterministic — no I/O, no clock reads —
10//! so unit tests can exercise the full state space without spinning
11//! up a browser.
12
13use serde::{Deserialize, Serialize};
14
15use crate::tls_validation::compare_http2_settings;
16
17use super::observations::{TransportObservation, compare_header_order};
18use super::profile::TransportProfile;
19
20/// Number of HTTP/2 checks the [`TransportProfile`][super::TransportProfile] supports.
21///
22/// Kept as a const so callers can pre-allocate fixed-size result
23/// vectors and so the scoring function is easy to reason about.
24pub const HTTP2_CHECK_KIND_COUNT: usize = 3;
25
26/// Stable identifier for a single HTTP/2 check.
27///
28/// Used in the [`TransportCompatibility::checks`][super::TransportCompatibility::checks]
29/// section so downstream telemetry can attribute scores to a
30/// specific check kind without depending on enum-variant order.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum Http2CheckKind {
34    /// HTTP/2 SETTINGS frame fingerprint.
35    Settings,
36    /// HTTP/2 pseudo-header order (`:method`/`:authority`/`:scheme`/`:path`).
37    PseudoHeaderOrder,
38    /// HTTP/2 regular header order (after pseudo-headers).
39    HeaderOrder,
40}
41
42impl Http2CheckKind {
43    /// Stable string label.
44    #[must_use]
45    pub const fn as_str(self) -> &'static str {
46        match self {
47            Self::Settings => "http2_settings",
48            Self::PseudoHeaderOrder => "http2_pseudo_header_order",
49            Self::HeaderOrder => "http2_header_order",
50        }
51    }
52}
53
54/// Per-check result attached to a [`TransportCompatibility`][super::TransportCompatibility].
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct Http2CheckResult {
57    /// Which check was evaluated.
58    pub kind: Http2CheckKind,
59    /// `true` when the observation matched the reference.
60    pub matched: bool,
61    /// Sub-score in `[0.0, 1.0]`. `0.0` when the observation was not
62    /// supplied.
63    pub score: f64,
64    /// Number of items (settings / pseudo-headers / headers) the
65    /// observation supplied.
66    pub observed_count: usize,
67    /// Number of items the reference carried.
68    pub expected_count: usize,
69    /// Stable position-match ratio in `[0.0, 1.0]`.
70    pub position_match_ratio: f64,
71}
72
73/// Per-target compatibility score with confidence/coverage markers.
74///
75/// # Example
76///
77/// ```
78/// use stygian_browser::transport_realism::{
79///     score, TransportObservation, TransportProfile,
80/// };
81///
82/// let profile = TransportProfile::default();
83/// let observation = TransportObservation::chrome_136_reference();
84/// let report = score(&profile, &observation);
85/// assert!(report.compatibility.is_high_confidence());
86/// assert!(report.compatibility.is_well_covered());
87/// ```
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89pub struct TransportCompatibility {
90    /// Per-check breakdown.
91    pub checks: Vec<Http2CheckResult>,
92    /// Per-target compatibility score in `[0.0, 1.0]`.
93    pub score: f64,
94    /// Confidence marker in `[0.0, 1.0]`. Reflects how reliable
95    /// the score is given the supplied observations and the
96    /// profile's expectations. `0.0` when no HTTP/2 observations
97    /// were available.
98    pub confidence: f64,
99    /// Coverage marker in `[0.0, 1.0]`. Reflects what fraction of
100    /// the profile's expected checks were actually observed.
101    /// `0.0` when no HTTP/2 observations were available.
102    pub coverage: f64,
103    /// Number of checks that matched the reference.
104    pub matched_count: usize,
105    /// Total number of checks the profile expected.
106    pub total_checks: usize,
107    /// Human-readable mismatch descriptions (empty when all checks
108    /// matched).
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub mismatches: Vec<String>,
111}
112
113impl TransportCompatibility {
114    /// `true` when coverage is at least `0.5` (i.e. the observation
115    /// captured at least half of the expected HTTP/2 checks).
116    #[must_use]
117    pub fn is_well_covered(&self) -> bool {
118        self.coverage >= 0.5
119    }
120
121    /// `true` when confidence is at least `0.5` (i.e. the score is
122    /// derived from enough observation signal to trust).
123    #[must_use]
124    pub fn is_high_confidence(&self) -> bool {
125        self.confidence >= 0.5
126    }
127
128    /// `true` when score is at least `0.95` — a strong match.
129    #[must_use]
130    pub fn is_strong_match(&self) -> bool {
131        self.score >= 0.95
132    }
133}
134
135/// Top-level transport-realism report attached to acquisition
136/// results.
137///
138/// Carries the [`TransportCompatibility`] score plus the originating
139/// [`TransportProfile`][super::TransportProfile] identity so
140/// downstream policy mapping (T83 / T85 / T89 / T93) can attribute
141/// the score to a specific profile without re-parsing the
142/// `AcquisitionRequest`.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct TransportRealismReport {
145    /// Profile name carried on the originating request (free-form).
146    pub profile_name: String,
147    /// Per-target compatibility score.
148    pub compatibility: TransportCompatibility,
149}
150
151impl TransportRealismReport {
152    /// `true` when the report represents a strong match against the
153    /// profile.
154    #[must_use]
155    pub fn is_strong_match(&self) -> bool {
156        self.compatibility.is_strong_match()
157    }
158
159    /// `true` when the report is derived from enough HTTP/2
160    /// observation signal to be trusted.
161    #[must_use]
162    pub fn is_high_confidence(&self) -> bool {
163        self.compatibility.is_high_confidence()
164    }
165}
166
167/// Score the supplied `observation` against the `profile`.
168///
169/// Returns a deterministic [`TransportRealismReport`]. When `profile`
170/// has at least one HTTP/2 expectation enabled and `observation`
171/// carries no HTTP/2 signal, the function returns a report whose
172/// `compatibility.score` is
173/// [`super::DEFAULT_SCORE_WHEN_HTTP2_UNAVAILABLE`],
174/// `compatibility.confidence` is
175/// [`super::DEFAULT_CONFIDENCE_WHEN_HTTP2_UNAVAILABLE`], and
176/// `compatibility.coverage` is
177/// [`super::DEFAULT_COVERAGE_WHEN_HTTP2_UNAVAILABLE`]. The mismatch
178/// list always carries a `"http2_observations_unavailable"` entry so
179/// downstream automation can detect the "no signal" path
180/// deterministically.
181///
182/// # Example
183///
184/// ```
185/// use stygian_browser::transport_realism::{
186///     score, TransportObservation, TransportProfile,
187/// };
188///
189/// let profile = TransportProfile::default();
190/// let observation = TransportObservation::chrome_136_reference();
191/// let report = score(&profile, &observation);
192/// assert!(report.compatibility.score > 0.95);
193/// ```
194#[must_use]
195pub fn score(
196    profile: &TransportProfile,
197    observation: &TransportObservation,
198) -> TransportRealismReport {
199    let total_checks = profile.expected_http2_check_count();
200
201    // Fast path: no HTTP/2 expectations enabled → report a perfect
202    // score and full coverage (the profile explicitly opted out of
203    // every check).
204    if total_checks == 0 {
205        return TransportRealismReport {
206            profile_name: profile.name.clone(),
207            compatibility: TransportCompatibility {
208                checks: Vec::new(),
209                score: 1.0,
210                confidence: 1.0,
211                coverage: 1.0,
212                matched_count: 0,
213                total_checks: 0,
214                mismatches: Vec::new(),
215            },
216        };
217    }
218
219    // Fast path: no HTTP/2 observations were supplied at all. The
220    // score collapses to the documented "no signal" defaults.
221    if !observation.has_http2() {
222        return TransportRealismReport {
223            profile_name: profile.name.clone(),
224            compatibility: TransportCompatibility {
225                checks: Vec::new(),
226                score: super::DEFAULT_SCORE_WHEN_HTTP2_UNAVAILABLE,
227                confidence: super::DEFAULT_CONFIDENCE_WHEN_HTTP2_UNAVAILABLE,
228                coverage: super::DEFAULT_COVERAGE_WHEN_HTTP2_UNAVAILABLE,
229                matched_count: 0,
230                total_checks,
231                mismatches: vec!["http2_observations_unavailable".to_string()],
232            },
233        };
234    }
235
236    score_with_observations(profile, observation, total_checks)
237}
238
239/// Score when observations are available. Extracted so the top-level
240/// `score` function stays under the clippy `too_many_lines` ceiling.
241fn score_with_observations(
242    profile: &TransportProfile,
243    observation: &TransportObservation,
244    total_checks: usize,
245) -> TransportRealismReport {
246    let mut state = ScoringState::default();
247
248    if profile.expectations.contains(TransportProfile::SETTINGS) {
249        score_settings_check(profile, observation, &mut state);
250    }
251    if profile
252        .expectations
253        .contains(TransportProfile::PSEUDO_HEADER_ORDER)
254    {
255        score_pseudo_header_check(profile, observation, &mut state);
256    }
257    if profile
258        .expectations
259        .contains(TransportProfile::HEADER_ORDER)
260    {
261        score_header_order_check(profile, observation, &mut state);
262    }
263
264    state.finalize(profile, total_checks)
265}
266
267/// Mutable accumulator for the scoring loop. Keeps `score` and its
268/// helpers under the clippy line ceilings.
269#[derive(Default)]
270struct ScoringState {
271    checks: Vec<Http2CheckResult>,
272    mismatches: Vec<String>,
273    observed_count: usize,
274    matched_count: usize,
275    sum_scores: f64,
276    sum_weights: f64,
277}
278
279impl ScoringState {
280    /// Record the SETTINGS-frame check result.
281    fn record(&mut self, check: Http2CheckResult, weight: f64, observed: bool, matched: bool) {
282        if observed {
283            self.observed_count += 1;
284        }
285        if matched {
286            self.matched_count += 1;
287        }
288        self.sum_scores = weight.mul_add(check.score, self.sum_scores);
289        self.sum_weights += weight;
290        self.checks.push(check);
291    }
292
293    /// Push a mismatch description.
294    fn push_mismatch(&mut self, description: String) {
295        self.mismatches.push(description);
296    }
297
298    /// Finalize the report.
299    fn finalize(self, profile: &TransportProfile, total_checks: usize) -> TransportRealismReport {
300        let observed_count = self.observed_count;
301        let final_score = if self.sum_weights > 0.0 {
302            (self.sum_scores / self.sum_weights).clamp(0.0, 1.0)
303        } else {
304            0.0
305        };
306        #[allow(clippy::cast_precision_loss)]
307        let coverage = if total_checks == 0 {
308            1.0
309        } else {
310            observed_count as f64 / total_checks as f64
311        };
312        let confidence = coverage.clamp(0.0, 1.0);
313
314        let mut mismatches = self.mismatches;
315        if profile.require_http2_observations && observed_count < total_checks {
316            mismatches.push("require_http2_observations_unmet".to_string());
317        }
318
319        TransportRealismReport {
320            profile_name: profile.name.clone(),
321            compatibility: TransportCompatibility {
322                checks: self.checks,
323                score: round4(final_score),
324                confidence: round4(confidence),
325                coverage: round4(coverage),
326                matched_count: self.matched_count,
327                total_checks,
328                mismatches,
329            },
330        }
331    }
332}
333
334/// Score the HTTP/2 SETTINGS frame and push the result onto `state`.
335fn score_settings_check(
336    profile: &TransportProfile,
337    observation: &TransportObservation,
338    state: &mut ScoringState,
339) {
340    let (matched, check_score, observed_count_opt, position_ratio) =
341        score_http2_settings(profile, observation);
342    let observed = observed_count_opt.is_some();
343    if !matched {
344        state.push_mismatch(format!(
345            "{}: fingerprint mismatch",
346            Http2CheckKind::Settings.as_str()
347        ));
348    }
349    state.record(
350        Http2CheckResult {
351            kind: Http2CheckKind::Settings,
352            matched,
353            score: check_score,
354            observed_count: observed_count_opt.unwrap_or(0),
355            expected_count: profile.expected_http2_settings.len(),
356            position_match_ratio: position_ratio,
357        },
358        0.5,
359        observed,
360        matched,
361    );
362}
363
364/// Score the HTTP/2 pseudo-header order and push the result.
365fn score_pseudo_header_check(
366    profile: &TransportProfile,
367    observation: &TransportObservation,
368    state: &mut ScoringState,
369) {
370    let Some(observed) = observation.http2_pseudo_header_order.as_deref() else {
371        state.push_mismatch(format!(
372            "{}: observation missing",
373            Http2CheckKind::PseudoHeaderOrder.as_str()
374        ));
375        state.record(
376            Http2CheckResult {
377                kind: Http2CheckKind::PseudoHeaderOrder,
378                matched: false,
379                score: 0.0,
380                observed_count: 0,
381                expected_count: profile.expected_pseudo_header_order.len(),
382                position_match_ratio: 0.0,
383            },
384            0.2,
385            false,
386            false,
387        );
388        return;
389    };
390    let m = compare_header_order(
391        &profile
392            .expected_pseudo_header_order
393            .iter()
394            .map(String::as_str)
395            .collect::<Vec<_>>(),
396        observed,
397    );
398    let matched = m.matched_positions == m.reference_length && m.reference_length > 0;
399    let position_ratio = m.position_match_ratio();
400    if !matched {
401        state.push_mismatch(format!(
402            "{}: order mismatch ({}/{} matched)",
403            Http2CheckKind::PseudoHeaderOrder.as_str(),
404            m.matched_positions,
405            m.reference_length
406        ));
407    }
408    state.record(
409        Http2CheckResult {
410            kind: Http2CheckKind::PseudoHeaderOrder,
411            matched,
412            score: position_ratio,
413            observed_count: m.observed_length,
414            expected_count: m.reference_length,
415            position_match_ratio: position_ratio,
416        },
417        0.2,
418        true,
419        matched,
420    );
421}
422
423/// Score the HTTP/2 regular header order and push the result.
424fn score_header_order_check(
425    profile: &TransportProfile,
426    observation: &TransportObservation,
427    state: &mut ScoringState,
428) {
429    let Some(observed) = observation.http2_header_order.as_deref() else {
430        state.push_mismatch(format!(
431            "{}: observation missing",
432            Http2CheckKind::HeaderOrder.as_str()
433        ));
434        state.record(
435            Http2CheckResult {
436                kind: Http2CheckKind::HeaderOrder,
437                matched: false,
438                score: 0.0,
439                observed_count: 0,
440                expected_count: profile.expected_header_order.len(),
441                position_match_ratio: 0.0,
442            },
443            0.3,
444            false,
445            false,
446        );
447        return;
448    };
449    let m = compare_header_order(
450        &profile
451            .expected_header_order
452            .iter()
453            .map(String::as_str)
454            .collect::<Vec<_>>(),
455        observed,
456    );
457    let matched = m.matched_positions == m.reference_length && m.reference_length > 0;
458    let position_ratio = m.position_match_ratio();
459    if !matched {
460        state.push_mismatch(format!(
461            "{}: order mismatch ({}/{} matched)",
462            Http2CheckKind::HeaderOrder.as_str(),
463            m.matched_positions,
464            m.reference_length
465        ));
466    }
467    state.record(
468        Http2CheckResult {
469            kind: Http2CheckKind::HeaderOrder,
470            matched,
471            score: position_ratio,
472            observed_count: m.observed_length,
473            expected_count: m.reference_length,
474            position_match_ratio: position_ratio,
475        },
476        0.3,
477        true,
478        matched,
479    );
480}
481
482/// Compare an observed HTTP/2 SETTINGS frame against the profile
483/// reference and return `(matched, score, observed_count,
484/// position_ratio)`.
485///
486/// The comparison delegates to the same logic
487/// [`crate::tls_validation::compare_http2_settings`] uses for
488/// matching the JA3 reference SETTINGS — this module reuses the
489/// helper rather than duplicating the comparison rules.
490fn score_http2_settings(
491    profile: &TransportProfile,
492    observation: &TransportObservation,
493) -> (bool, f64, Option<usize>, f64) {
494    let Some(observed) = observation.http2_settings.as_deref() else {
495        return (false, 0.0, None, 0.0);
496    };
497    let (matched, issues) = compare_http2_settings(&profile.expected_http2_settings, observed);
498    let position_ratio = if profile.expected_http2_settings.is_empty() {
499        1.0
500    } else {
501        let expected_ids: std::collections::HashSet<u32> = profile
502            .expected_http2_settings
503            .iter()
504            .map(|(id, _)| *id)
505            .collect();
506        let observed_ids: std::collections::HashSet<u32> =
507            observed.iter().map(|(id, _)| *id).collect();
508        let intersection = expected_ids.intersection(&observed_ids).count();
509        #[allow(clippy::cast_precision_loss)]
510        let ratio = intersection as f64 / expected_ids.len() as f64;
511        ratio
512    };
513    let score = if matched {
514        1.0
515    } else {
516        #[allow(clippy::cast_precision_loss)]
517        let discount = (issues.len() as f64) * 0.1;
518        (1.0 - discount).max(0.0)
519    };
520    (
521        matched,
522        round4(score),
523        Some(observed.len()),
524        round4(position_ratio),
525    )
526}
527
528/// Round to 4 decimal places to keep JSON serialisation deterministic
529/// across platforms (different IEEE-754 implementations can produce
530/// different representations of the same f64 otherwise).
531fn round4(v: f64) -> f64 {
532    (v * 10_000.0).round() / 10_000.0
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use crate::tls_validation::{CHROME_136_HTTP2_SETTINGS, CHROME_136_JA4};
539    use crate::transport_realism::observations::{
540        HEADER_ORDER_CHROME_136, PSEUDO_HEADER_ORDER_CHROME_136,
541    };
542
543    fn chrome_obs() -> TransportObservation {
544        TransportObservation::chrome_136_reference()
545    }
546
547    fn approx_eq(a: f64, b: f64) -> bool {
548        (a - b).abs() < 1e-9
549    }
550
551    #[test]
552    fn chrome_136_observation_against_chrome_136_profile_scores_high() {
553        let profile = TransportProfile::default();
554        let report = score(&profile, &chrome_obs());
555        assert!(
556            report.compatibility.score > 0.95,
557            "chrome 136 reference vs chrome 136 observation should be a strong match, got: {}",
558            report.compatibility.score
559        );
560        assert_eq!(report.compatibility.matched_count, 3);
561        assert_eq!(report.compatibility.total_checks, 3);
562        assert!(report.compatibility.mismatches.is_empty());
563        assert!(report.compatibility.is_high_confidence());
564        assert!(report.compatibility.is_well_covered());
565        assert!(report.is_strong_match());
566    }
567
568    #[test]
569    fn mismatched_settings_score_below_one() {
570        let profile = TransportProfile::default();
571        let observed = TransportObservation {
572            http2_settings: Some(vec![(1, 1), (2, 0)]),
573            ..chrome_obs()
574        };
575        let report = score(&profile, &observed);
576        assert!(
577            report.compatibility.score < 1.0,
578            "settings mismatch must reduce score, got: {}",
579            report.compatibility.score
580        );
581        assert!(
582            report
583                .compatibility
584                .mismatches
585                .iter()
586                .any(|m| m.contains(Http2CheckKind::Settings.as_str())),
587            "settings mismatch must be reported, got: {:?}",
588            report.compatibility.mismatches
589        );
590    }
591
592    #[test]
593    fn mismatched_header_order_reduces_score() {
594        let profile = TransportProfile::default();
595        let observed = TransportObservation {
596            http2_header_order: Some(vec!["host".into(), "accept".into()]),
597            ..chrome_obs()
598        };
599        let report = score(&profile, &observed);
600        assert!(report.compatibility.score < 1.0);
601        assert!(
602            report
603                .compatibility
604                .mismatches
605                .iter()
606                .any(|m| m.contains(Http2CheckKind::HeaderOrder.as_str())),
607            "header order mismatch must be reported"
608        );
609    }
610
611    #[test]
612    fn missing_http2_observations_uses_known_default_markers() {
613        let profile = TransportProfile::default();
614        let report = score(&profile, &TransportObservation::default());
615        assert!(
616            approx_eq(
617                report.compatibility.score,
618                super::super::DEFAULT_SCORE_WHEN_HTTP2_UNAVAILABLE
619            ),
620            "score default mismatch, got: {}",
621            report.compatibility.score
622        );
623        assert!(
624            approx_eq(
625                report.compatibility.confidence,
626                super::super::DEFAULT_CONFIDENCE_WHEN_HTTP2_UNAVAILABLE
627            ),
628            "confidence default mismatch, got: {}",
629            report.compatibility.confidence
630        );
631        assert!(
632            approx_eq(
633                report.compatibility.coverage,
634                super::super::DEFAULT_COVERAGE_WHEN_HTTP2_UNAVAILABLE
635            ),
636            "coverage default mismatch, got: {}",
637            report.compatibility.coverage
638        );
639        assert!(
640            report
641                .compatibility
642                .mismatches
643                .iter()
644                .any(|m| m == "http2_observations_unavailable"),
645            "missing observations must emit the deterministic mismatch tag, got: {:?}",
646            report.compatibility.mismatches
647        );
648        assert!(!report.is_strong_match());
649    }
650
651    #[test]
652    fn require_http2_observations_surfaces_partial_observation() {
653        let profile = TransportProfile::default().with_require_http2_observations(true);
654        let observed = TransportObservation {
655            http2_settings: Some(CHROME_136_HTTP2_SETTINGS.to_vec()),
656            ..TransportObservation::default()
657        };
658        let report = score(&profile, &observed);
659        assert!(
660            report
661                .compatibility
662                .mismatches
663                .iter()
664                .any(|m| m == "require_http2_observations_unmet"),
665            "partial observation must surface unmet requirement, got: {:?}",
666            report.compatibility.mismatches
667        );
668        assert!(report.compatibility.coverage < 1.0);
669    }
670
671    #[test]
672    fn profile_with_no_expectations_reports_perfect_score() {
673        let profile = TransportProfile::default().with_expectation_bits(0);
674        let report = score(&profile, &TransportObservation::default());
675        assert!(approx_eq(report.compatibility.score, 1.0));
676        assert!(approx_eq(report.compatibility.confidence, 1.0));
677        assert!(approx_eq(report.compatibility.coverage, 1.0));
678        assert_eq!(report.compatibility.total_checks, 0);
679    }
680
681    #[test]
682    fn profile_name_is_propagated_to_report() {
683        let profile = TransportProfile::default().with_name("firefox-130");
684        let report = score(&profile, &chrome_obs());
685        assert_eq!(report.profile_name, "firefox-130");
686    }
687
688    #[test]
689    fn references_used_in_tests_are_stable() {
690        // Surface unexpected removal of the references this module
691        // depends on as a compile-time test failure.
692        assert!(CHROME_136_JA4.starts_with('t'));
693        assert!(CHROME_136_HTTP2_SETTINGS.iter().any(|(id, _)| *id == 4));
694        assert!(HEADER_ORDER_CHROME_136.contains(&"host"));
695        assert!(PSEUDO_HEADER_ORDER_CHROME_136.contains(&":method"));
696    }
697
698    #[test]
699    fn per_check_kind_results_carry_kind_label() {
700        let profile = TransportProfile::default();
701        let report = score(&profile, &chrome_obs());
702        for result in &report.compatibility.checks {
703            assert!(!result.kind.as_str().is_empty());
704        }
705    }
706}