Skip to main content

stygian_browser/integrity_canary/
report.rs

1//! Integrity canary risk score + report schema.
2//!
3//! Turns a list of [`ProbeFinding`] records into an aggregate
4//! [`IntegrityRiskScore`] and a stable
5//! [`IntegrityRiskClassification`] with documented **Suspected** /
6//! **Confirmed** thresholds.
7//!
8//! ## Risk score formula
9//!
10//! The aggregate score is a **weighted average** of per-finding
11//! severity contributions:
12//!
13//! ```text
14//! score = Σ(weight_i × severity_i) / Σ(weight_i)
15//! ```
16//!
17//! where `severity_i` is the documented
18//! [`IntegrityProbeOutcome::severity`] of the finding (Clean=0.0,
19//! TrapSuspected=0.5, TrapConfirmed=1.0). **Skipped findings are
20//! excluded from both the numerator and the denominator** so that
21//! partial probe coverage does not silently deflate the score.
22//!
23//! The score is clamped to `[0.0, 1.0]`. When every probe is
24//! skipped (no signal), the score is `0.0` and the classification
25//! is [`IntegrityRiskClassification::Clean`].
26//!
27//! ## Suspected vs Confirmed thresholds
28//!
29//! [`IntegrityCanaryPolicy::default`] ships with two thresholds:
30//!
31//! - [`RISK_SUSPECTED_THRESHOLD_DEFAULT`] = `0.30`
32//! - [`RISK_CONFIRMED_THRESHOLD_DEFAULT`] = `0.65`
33//!
34//! Classification:
35//!
36//! | Score range | Classification |
37//! |---|---|
38//! | `[0.0, 0.30)`  | [`Clean`]       |
39//! | `[0.30, 0.65)` | [`Suspected`]   |
40//! | `[0.65, 1.0]`  | [`Confirmed`]   |
41//!
42//! Callers can override either threshold via
43//! [`IntegrityCanaryPolicy::with_thresholds`] — the
44//! `suspected_threshold` MUST be strictly less than the
45//! `confirmed_threshold` (validation is enforced by
46//! [`IntegrityCanaryPolicy::validate`]).
47//!
48//! [`Clean`]: IntegrityRiskClassification::Clean
49//! [`Suspected`]: IntegrityRiskClassification::Suspected
50//! [`Confirmed`]: IntegrityRiskClassification::Confirmed
51
52use std::fmt;
53
54use serde::{Deserialize, Serialize};
55
56use crate::integrity_canary::probes::ProbeFinding;
57
58/// Default lower bound of the **Suspected** risk band.
59///
60/// Below this threshold the aggregate is classified as
61/// [`IntegrityRiskClassification::Clean`].
62pub const RISK_SUSPECTED_THRESHOLD_DEFAULT: f64 = 0.30;
63
64/// Default lower bound of the **Confirmed** risk band.
65///
66/// At or above this threshold the aggregate is classified as
67/// [`IntegrityRiskClassification::Confirmed`].
68pub const RISK_CONFIRMED_THRESHOLD_DEFAULT: f64 = 0.65;
69
70/// Aggregate risk classification.
71///
72/// Three bands, mapped from the score via
73/// [`IntegrityCanaryPolicy::classify`]:
74///
75/// - [`Clean`](Self::Clean) — score below the suspected threshold.
76/// - [`Suspected`](Self::Suspected) — score at or above the
77///   suspected threshold but below the confirmed threshold.
78/// - [`Confirmed`](Self::Confirmed) — score at or above the
79///   confirmed threshold.
80///
81/// `Suspected` is the explicit "anti-bot may be probing for stealth
82/// artefacts but is not yet blocking" band. `Confirmed` is the
83/// "anti-bot has enough signal to block — refresh the session"
84/// band.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
86#[serde(rename_all = "snake_case")]
87pub enum IntegrityRiskClassification {
88    /// Score is below the suspected threshold. No trap signal.
89    Clean,
90    /// Score is at or above the suspected threshold but below the
91    /// confirmed threshold. Ambiguous trap signal.
92    Suspected,
93    /// Score is at or above the confirmed threshold. Deterministic
94    /// trap signal — treat as a stealth regression.
95    Confirmed,
96}
97
98impl IntegrityRiskClassification {
99    /// Stable `snake_case` label used in telemetry.
100    #[must_use]
101    pub const fn label(self) -> &'static str {
102        match self {
103            Self::Clean => "clean",
104            Self::Suspected => "suspected",
105            Self::Confirmed => "confirmed",
106        }
107    }
108}
109
110impl fmt::Display for IntegrityRiskClassification {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(self.label())
113    }
114}
115
116/// Aggregate integrity risk score in `[0.0, 1.0]`.
117///
118/// Stored as an `f64` so JSON / TOML serialization round-trips
119/// without precision loss. The companion [`Self::classification`]
120/// field records the threshold-derived classification so consumers
121/// can branch on the enum without recomputing it.
122#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
123pub struct IntegrityRiskScore {
124    /// Numeric risk score in `[0.0, 1.0]`.
125    pub value: f64,
126    /// Threshold-derived classification.
127    pub classification: IntegrityRiskClassification,
128    /// Number of findings that contributed to the numerator
129    /// (i.e. non-skipped findings).
130    pub contributing_findings: usize,
131    /// Number of skipped findings excluded from the denominator.
132    pub skipped_findings: usize,
133}
134
135impl IntegrityRiskScore {
136    /// A deterministic clean score (0.0, Clean, zero findings).
137    #[must_use]
138    pub const fn clean() -> Self {
139        Self {
140            value: 0.0,
141            classification: IntegrityRiskClassification::Clean,
142            contributing_findings: 0,
143            skipped_findings: 0,
144        }
145    }
146
147    /// Numeric value in `[0.0, 1.0]`.
148    #[must_use]
149    pub const fn value(&self) -> f64 {
150        self.value
151    }
152
153    /// Threshold-derived classification.
154    #[must_use]
155    pub const fn classification(&self) -> IntegrityRiskClassification {
156        self.classification
157    }
158
159    /// `true` when the classification is
160    /// [`IntegrityRiskClassification::Suspected`] or
161    /// [`IntegrityRiskClassification::Confirmed`].
162    #[must_use]
163    pub const fn is_trap_signal(&self) -> bool {
164        !matches!(self.classification, IntegrityRiskClassification::Clean)
165    }
166
167    /// `true` when the classification is
168    /// [`IntegrityRiskClassification::Confirmed`].
169    #[must_use]
170    pub const fn is_confirmed(&self) -> bool {
171        matches!(self.classification, IntegrityRiskClassification::Confirmed)
172    }
173
174    /// Compute the score + classification for a slice of
175    /// [`ProbeFinding`]s under the supplied [`IntegrityCanaryPolicy`].
176    ///
177    /// `findings` is iterated twice — once for the numerator /
178    /// denominator, once for the skipped count — so the function is
179    /// O(n) and trivially deterministic.
180    #[must_use]
181    pub fn compute(findings: &[ProbeFinding], policy: &IntegrityCanaryPolicy) -> Self {
182        let mut numerator = 0.0;
183        let mut denominator = 0.0;
184        let mut contributing = 0usize;
185        let mut skipped = 0usize;
186        for f in findings {
187            if f.outcome.contributes() {
188                numerator = f.weight.mul_add(f.outcome.severity(), numerator);
189                denominator += f.weight;
190                contributing += 1;
191            } else {
192                skipped += 1;
193            }
194        }
195        let raw = if denominator <= 0.0 {
196            0.0
197        } else {
198            numerator / denominator
199        };
200        let value = clamp_unit(raw);
201        let classification = policy.classify(value);
202        Self {
203            value,
204            classification,
205            contributing_findings: contributing,
206            skipped_findings: skipped,
207        }
208    }
209
210    /// Map a raw score to its classification under the supplied
211    /// policy. Exposed so callers can re-classify a score without
212    /// recomputing it (e.g. after overriding policy thresholds at
213    /// runtime).
214    #[must_use]
215    pub fn classify_for(value: f64, policy: &IntegrityCanaryPolicy) -> IntegrityRiskClassification {
216        policy.classify(value)
217    }
218}
219
220const fn clamp_unit(value: f64) -> f64 {
221    // NaN handling first because f64::clamp returns NaN for NaN
222    // input, but our contract maps NaN to 0.0 explicitly. After the
223    // NaN short-circuit, `f64::clamp(0.0, 1.0)` is the cleanest
224    // expression of the unit-interval mapping.
225    if value.is_nan() {
226        return 0.0;
227    }
228    value.clamp(0.0, 1.0)
229}
230
231/// Configurable thresholds for the canary risk bands.
232///
233/// The defaults match the documented
234/// [`RISK_SUSPECTED_THRESHOLD_DEFAULT`] and
235/// [`RISK_CONFIRMED_THRESHOLD_DEFAULT`] constants. Override via
236/// [`IntegrityCanaryPolicy::with_thresholds`] when callers need
237/// stricter or more permissive gating.
238#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
239pub struct IntegrityCanaryPolicy {
240    /// Lower bound of the **Suspected** band.
241    pub suspected_threshold: f64,
242    /// Lower bound of the **Confirmed** band.
243    pub confirmed_threshold: f64,
244}
245
246impl Default for IntegrityCanaryPolicy {
247    fn default() -> Self {
248        Self {
249            suspected_threshold: RISK_SUSPECTED_THRESHOLD_DEFAULT,
250            confirmed_threshold: RISK_CONFIRMED_THRESHOLD_DEFAULT,
251        }
252    }
253}
254
255impl IntegrityCanaryPolicy {
256    /// Build a policy with custom thresholds.
257    ///
258    /// # Panics
259    ///
260    /// Panics if `suspected_threshold` is `NaN`, `confirmed_threshold`
261    /// is `NaN`, or `suspected_threshold >= confirmed_threshold` —
262    /// use [`Self::try_with_thresholds`] for a fallible variant.
263    #[must_use]
264    pub fn with_thresholds(suspected_threshold: f64, confirmed_threshold: f64) -> Self {
265        // Panics on invalid thresholds by design — the public doc
266        // explicitly steers callers to `try_with_thresholds` for a
267        // fallible path. This is the documented programmer-error guard.
268        #[allow(clippy::expect_used)]
269        Self::try_with_thresholds(suspected_threshold, confirmed_threshold)
270            .expect("integrity canary thresholds must be finite and strictly ordered")
271    }
272
273    /// Build a policy with custom thresholds, returning an error
274    /// when the inputs are invalid.
275    ///
276    /// # Errors
277    ///
278    /// Returns an `IntegrityCanaryPolicyError::InvalidThresholds`
279    /// when either threshold is `NaN` or when the suspected
280    /// threshold is greater than or equal to the confirmed
281    /// threshold.
282    pub fn try_with_thresholds(
283        suspected_threshold: f64,
284        confirmed_threshold: f64,
285    ) -> Result<Self, IntegrityCanaryPolicyError> {
286        if suspected_threshold.is_nan() || confirmed_threshold.is_nan() {
287            return Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
288                "thresholds must be finite (suspected={suspected_threshold}, confirmed={confirmed_threshold})"
289            )));
290        }
291        // `partial_cmp` is the documented escape hatch from
292        // `clippy::neg_cmp_op_on_partial_ord` for floats — explicit
293        // about the fact that two NaN values are not comparable.
294        match suspected_threshold.partial_cmp(&confirmed_threshold) {
295            Some(std::cmp::Ordering::Less) => {}
296            _ => {
297                return Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
298                    "suspected_threshold ({suspected_threshold}) must be strictly less than confirmed_threshold ({confirmed_threshold})"
299                )));
300            }
301        }
302        Ok(Self {
303            suspected_threshold: clamp_unit(suspected_threshold),
304            confirmed_threshold: clamp_unit(confirmed_threshold),
305        })
306    }
307
308    /// Map a `value` in `[0.0, 1.0]` to its classification under
309    /// this policy's thresholds.
310    #[must_use]
311    pub fn classify(&self, value: f64) -> IntegrityRiskClassification {
312        let v = clamp_unit(value);
313        if v.is_nan() {
314            return IntegrityRiskClassification::Clean;
315        }
316        if v >= self.confirmed_threshold {
317            IntegrityRiskClassification::Confirmed
318        } else if v >= self.suspected_threshold {
319            IntegrityRiskClassification::Suspected
320        } else {
321            IntegrityRiskClassification::Clean
322        }
323    }
324
325    /// Validate the policy (used by deserialisation paths).
326    ///
327    /// # Errors
328    ///
329    /// Returns `IntegrityCanaryPolicyError::InvalidThresholds`
330    /// when `suspected_threshold >= confirmed_threshold`.
331    pub fn validate(&self) -> Result<(), IntegrityCanaryPolicyError> {
332        if self.suspected_threshold.is_nan() || self.confirmed_threshold.is_nan() {
333            return Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
334                "thresholds must be finite (suspected={}, confirmed={})",
335                self.suspected_threshold, self.confirmed_threshold
336            )));
337        }
338        // `partial_cmp` is the documented escape hatch from
339        // `clippy::neg_cmp_op_on_partial_ord` for floats — explicit
340        // about the fact that two NaN values are not comparable.
341        match self
342            .suspected_threshold
343            .partial_cmp(&self.confirmed_threshold)
344        {
345            Some(std::cmp::Ordering::Less) => Ok(()),
346            _ => Err(IntegrityCanaryPolicyError::InvalidThresholds(format!(
347                "suspected_threshold ({}) must be strictly less than confirmed_threshold ({})",
348                self.suspected_threshold, self.confirmed_threshold
349            ))),
350        }
351    }
352}
353
354/// Errors produced by integrity-canary policy construction.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum IntegrityCanaryPolicyError {
357    /// `suspected_threshold` and `confirmed_threshold` are
358    /// invalid (NaN or out of order).
359    InvalidThresholds(String),
360}
361
362impl fmt::Display for IntegrityCanaryPolicyError {
363    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
364        match self {
365            Self::InvalidThresholds(msg) => {
366                write!(f, "integrity canary thresholds invalid: {msg}")
367            }
368        }
369    }
370}
371
372impl std::error::Error for IntegrityCanaryPolicyError {}
373
374/// Aggregate integrity canary report.
375///
376/// Produced by [`IntegrityCanaryReport::from_findings`] (and the
377/// `with_policy` variant) and attached to
378/// [`crate::diagnostic::DiagnosticReport`] via
379/// [`crate::diagnostic::DiagnosticReport::with_integrity_canary`].
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381pub struct IntegrityCanaryReport {
382    /// Aggregate score and classification.
383    pub score: IntegrityRiskScore,
384    /// Policy used to derive the classification. Always populated
385    /// so consumers can inspect the thresholds without having to
386    /// thread the policy through their own state.
387    pub policy: IntegrityCanaryPolicy,
388    /// Individual probe findings in evaluation order.
389    pub findings: Vec<ProbeFinding>,
390    /// Aggregated mitigation hints (one entry per finding that
391    /// fired with a `Suspected` or `Confirmed` outcome). Surfaced
392    /// as a separate field so consumers can render hints without
393    /// re-iterating `findings`.
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub mitigation_hints: Vec<MitigationHint>,
396    /// Trap findings (`Suspected` or `Confirmed`) only — a thin
397    /// view over `findings` for callers that only care about
398    /// fired traps.
399    #[serde(default, skip_serializing_if = "Vec::is_empty")]
400    pub trap_findings: Vec<ProbeFinding>,
401}
402
403impl IntegrityCanaryReport {
404    /// Build a report from a list of findings using the default
405    /// policy.
406    #[must_use]
407    pub fn from_findings(findings: Vec<ProbeFinding>) -> Self {
408        Self::with_policy(findings, IntegrityCanaryPolicy::default())
409    }
410
411    /// Build a report from a list of findings + a custom policy.
412    ///
413    /// Computes the aggregate score, classifies it under `policy`,
414    /// and populates the [`Self::mitigation_hints`] and
415    /// [`Self::trap_findings`] helper fields.
416    #[must_use]
417    pub fn with_policy(findings: Vec<ProbeFinding>, policy: IntegrityCanaryPolicy) -> Self {
418        let score = IntegrityRiskScore::compute(&findings, &policy);
419        let trap_findings: Vec<ProbeFinding> =
420            findings.iter().filter(|f| f.is_trap()).cloned().collect();
421        let mitigation_hints: Vec<MitigationHint> = trap_findings
422            .iter()
423            .filter(|f| !f.mitigation_hint.is_empty())
424            .map(|f| MitigationHint {
425                probe_id: f.id.clone(),
426                outcome: f.outcome,
427                hint: f.mitigation_hint.clone(),
428            })
429            .collect();
430        Self {
431            score,
432            policy,
433            findings,
434            mitigation_hints,
435            trap_findings,
436        }
437    }
438
439    /// `true` when the aggregate classification is
440    /// [`IntegrityRiskClassification::Confirmed`].
441    #[must_use]
442    pub const fn is_confirmed(&self) -> bool {
443        self.score.is_confirmed()
444    }
445
446    /// `true` when the aggregate classification is
447    /// [`IntegrityRiskClassification::Suspected`] or
448    /// [`IntegrityRiskClassification::Confirmed`].
449    #[must_use]
450    pub const fn has_trap_signal(&self) -> bool {
451        self.score.is_trap_signal()
452    }
453
454    /// Number of findings that fired with a trap outcome.
455    #[must_use]
456    pub const fn trap_count(&self) -> usize {
457        self.trap_findings.len()
458    }
459
460    /// Number of findings that produced a `Confirmed` outcome.
461    #[must_use]
462    pub fn confirmed_count(&self) -> usize {
463        self.findings.iter().filter(|f| f.is_confirmed()).count()
464    }
465}
466
467/// Per-probe mitigation hint surfaced in the diagnostic payload.
468///
469/// Hints are derived from the
470/// [`IntegrityProbe::mitigation_hint`][crate::integrity_canary::probes::IntegrityProbe::mitigation_hint]
471/// field at report-construction time so they stay in sync with the
472/// catalogue. Consumers can render this field directly without
473/// re-iterating over [`IntegrityCanaryReport::findings`].
474#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
475pub struct MitigationHint {
476    /// Probe identifier (`snake_case` label).
477    pub probe_id: String,
478    /// Resolved outcome (`Suspected` or `Confirmed`).
479    pub outcome: crate::integrity_canary::probes::IntegrityProbeOutcome,
480    /// Actionable mitigation text.
481    pub hint: String,
482}
483
484// ─── Tests ────────────────────────────────────────────────────────────────────
485
486#[cfg(test)]
487#[allow(
488    clippy::unwrap_used,
489    clippy::expect_used,
490    clippy::panic,
491    clippy::float_cmp,
492    clippy::indexing_slicing
493)]
494mod tests {
495    use super::*;
496    use crate::integrity_canary::probes::{
497        IntegrityProbe, IntegrityProbeId, IntegrityProbeOutcome, ProbeFinding, all_probes,
498    };
499
500    fn finding(id: &str, weight: f64, outcome: IntegrityProbeOutcome, hint: &str) -> ProbeFinding {
501        ProbeFinding {
502            id: id.to_string(),
503            outcome,
504            weight,
505            evidence: "test".to_string(),
506            mitigation_hint: hint.to_string(),
507        }
508    }
509
510    fn trap_finding(id: &str, weight: f64) -> ProbeFinding {
511        finding(id, weight, IntegrityProbeOutcome::TrapConfirmed, "hint")
512    }
513
514    fn suspected_finding(id: &str, weight: f64) -> ProbeFinding {
515        finding(id, weight, IntegrityProbeOutcome::TrapSuspected, "hint")
516    }
517
518    #[test]
519    fn empty_findings_produces_clean_score() {
520        let report = IntegrityCanaryReport::from_findings(Vec::new());
521        assert_eq!(report.score.value, 0.0);
522        assert_eq!(
523            report.score.classification,
524            IntegrityRiskClassification::Clean
525        );
526        assert!(!report.has_trap_signal());
527        assert!(report.mitigation_hints.is_empty());
528        assert!(report.trap_findings.is_empty());
529    }
530
531    #[test]
532    fn all_clean_findings_produces_zero_score() {
533        let findings = all_probes()
534            .iter()
535            .map(|p| finding(p.id.label(), p.weight, IntegrityProbeOutcome::Clean, ""))
536            .collect();
537        let report = IntegrityCanaryReport::from_findings(findings);
538        assert_eq!(report.score.value, 0.0);
539        assert_eq!(
540            report.score.classification,
541            IntegrityRiskClassification::Clean
542        );
543        assert_eq!(report.score.contributing_findings, 8);
544        assert_eq!(report.score.skipped_findings, 0);
545    }
546
547    #[test]
548    fn all_confirmed_findings_produces_full_score() {
549        let findings = all_probes()
550            .iter()
551            .map(|p| {
552                finding(
553                    p.id.label(),
554                    p.weight,
555                    IntegrityProbeOutcome::TrapConfirmed,
556                    "",
557                )
558            })
559            .collect();
560        let report = IntegrityCanaryReport::from_findings(findings);
561        assert!(
562            (report.score.value - 1.0).abs() < 1e-9,
563            "score must be 1.0, got: {}",
564            report.score.value
565        );
566        assert_eq!(
567            report.score.classification,
568            IntegrityRiskClassification::Confirmed
569        );
570        assert_eq!(report.confirmed_count(), 8);
571        assert!(report.is_confirmed());
572    }
573
574    #[test]
575    fn mixed_outcomes_weighted_average() {
576        // 4 clean + 4 confirmed: numerator = sum of weights of the 4
577        // confirmed probes (the last 4: 0.08 + 0.10 + 0.14 + 0.12 = 0.44).
578        // Denominator = sum of all 8 weights = 1.0. Score = 0.44.
579        let mut findings = Vec::new();
580        for (i, p) in all_probes().iter().enumerate() {
581            if i < 4 {
582                findings.push(finding(
583                    p.id.label(),
584                    p.weight,
585                    IntegrityProbeOutcome::Clean,
586                    "",
587                ));
588            } else {
589                findings.push(trap_finding(p.id.label(), p.weight));
590            }
591        }
592        let report = IntegrityCanaryReport::from_findings(findings);
593        assert!(
594            (report.score.value - 0.44).abs() < 1e-6,
595            "4 clean + 4 confirmed: score must be 0.44, got: {}",
596            report.score.value
597        );
598        assert_eq!(
599            report.score.classification,
600            IntegrityRiskClassification::Suspected
601        );
602    }
603
604    #[test]
605    fn suspected_findings_yield_half_score() {
606        // All 8 probes suspect: numerator = Σ(weight × 0.5), denominator = Σ(weight) = 1.0
607        // Result = 0.5
608        let findings = all_probes()
609            .iter()
610            .map(|p| suspected_finding(p.id.label(), p.weight))
611            .collect();
612        let report = IntegrityCanaryReport::from_findings(findings);
613        assert!(
614            (report.score.value - 0.5).abs() < 1e-9,
615            "all-suspected must be 0.5, got: {}",
616            report.score.value
617        );
618    }
619
620    #[test]
621    fn single_confirmed_on_largest_probe_gives_weighted_score() {
622        // Single confirmed on webdriver_descriptor_native (weight 0.20):
623        // numerator = 0.20, denominator = 1.0 (all 8 probes counted),
624        // score = 0.20. This is intentionally Clean — a single
625        // confirmed trap on the largest-weight probe is below the
626        // 0.30 suspected threshold. The Suspected/Confirmed bands
627        // are calibrated so a single probe can never push the
628        // score into Suspected by itself; callers need to see
629        // multiple fires to escalate.
630        let mut findings = Vec::new();
631        for (i, p) in all_probes().iter().enumerate() {
632            if i == 0 {
633                findings.push(trap_finding(p.id.label(), p.weight));
634            } else {
635                findings.push(finding(
636                    p.id.label(),
637                    p.weight,
638                    IntegrityProbeOutcome::Clean,
639                    "",
640                ));
641            }
642        }
643        let report = IntegrityCanaryReport::from_findings(findings);
644        assert!(
645            (report.score.value - 0.20).abs() < 1e-9,
646            "single confirmed trap on the largest-weight probe: score must be 0.20, got: {}",
647            report.score.value
648        );
649        assert_eq!(
650            report.score.classification,
651            IntegrityRiskClassification::Clean,
652            "single confirmed trap on the largest-weight probe must remain Clean (below 0.30)"
653        );
654        assert_eq!(report.confirmed_count(), 1);
655    }
656
657    #[test]
658    fn skipped_findings_excluded_from_denominator() {
659        // 4 confirmed (total weight = X) + 4 skipped.
660        // Numerator = X*1.0, Denominator = X (skipped excluded).
661        // score = 1.0 — same as if no skipped findings existed.
662        let mut findings = Vec::new();
663        let mut weights_kept = 0.0;
664        for (i, p) in all_probes().iter().enumerate() {
665            if i < 4 {
666                findings.push(trap_finding(p.id.label(), p.weight));
667                weights_kept += p.weight;
668            } else {
669                findings.push(finding(
670                    p.id.label(),
671                    p.weight,
672                    IntegrityProbeOutcome::Skipped,
673                    "",
674                ));
675            }
676        }
677        let report = IntegrityCanaryReport::from_findings(findings);
678        assert!(
679            (report.score.value - 1.0).abs() < 1e-9,
680            "skipped findings must not pull score toward 0, got: {}",
681            report.score.value
682        );
683        assert_eq!(report.score.skipped_findings, 4);
684        assert_eq!(report.score.contributing_findings, 4);
685        let _ = weights_kept; // silence unused warning if compiler reorders
686    }
687
688    #[test]
689    fn threshold_distinguishes_suspected_from_confirmed() {
690        // Score ≈ 0.40: above 0.30 (suspected) but below 0.65 (confirmed).
691        // Build findings summing to numerator=0.40, denominator=1.0.
692        // 8 findings at weight 0.125 (close to even), 4 clean + 4 suspected.
693        let findings = vec![
694            finding("a", 0.125, IntegrityProbeOutcome::Clean, ""),
695            finding("b", 0.125, IntegrityProbeOutcome::Clean, ""),
696            finding("c", 0.125, IntegrityProbeOutcome::Clean, ""),
697            finding("d", 0.125, IntegrityProbeOutcome::Clean, ""),
698            suspected_finding("e", 0.125),
699            suspected_finding("f", 0.125),
700            suspected_finding("g", 0.125),
701            suspected_finding("h", 0.125),
702        ];
703        let report = IntegrityCanaryReport::from_findings(findings);
704        // Numerator = 4 × 0.125 × 0.5 = 0.25. Denominator = 8 × 0.125 = 1.0. Score = 0.25.
705        assert!(
706            (report.score.value - 0.25).abs() < 1e-9,
707            "score must be 0.25, got: {}",
708            report.score.value
709        );
710        // Below the suspected threshold (0.30) → Clean.
711        assert_eq!(
712            report.score.classification,
713            IntegrityRiskClassification::Clean
714        );
715
716        // Now bump 3 of the clean findings to confirmed (extra 0.125 × 1.0 = 0.375).
717        // New numerator = 0.25 + 0.375 = 0.625. Still below 0.65 → Suspected.
718        let findings = vec![
719            finding("a", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
720            finding("b", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
721            finding("c", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
722            finding("d", 0.125, IntegrityProbeOutcome::Clean, ""),
723            suspected_finding("e", 0.125),
724            suspected_finding("f", 0.125),
725            suspected_finding("g", 0.125),
726            suspected_finding("h", 0.125),
727        ];
728        let report = IntegrityCanaryReport::from_findings(findings);
729        assert!(
730            (report.score.value - 0.625).abs() < 1e-9,
731            "score must be 0.625, got: {}",
732            report.score.value
733        );
734        assert_eq!(
735            report.score.classification,
736            IntegrityRiskClassification::Suspected,
737            "0.625 must be Suspected (above 0.30, below 0.65)"
738        );
739
740        // Flip one more clean → confirmed. New score = 0.75 → Confirmed.
741        let findings = vec![
742            finding("a", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
743            finding("b", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
744            finding("c", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
745            finding("d", 0.125, IntegrityProbeOutcome::TrapConfirmed, ""),
746            suspected_finding("e", 0.125),
747            suspected_finding("f", 0.125),
748            suspected_finding("g", 0.125),
749            suspected_finding("h", 0.125),
750        ];
751        let report = IntegrityCanaryReport::from_findings(findings);
752        assert!(
753            (report.score.value - 0.75).abs() < 1e-9,
754            "score must be 0.75, got: {}",
755            report.score.value
756        );
757        assert_eq!(
758            report.score.classification,
759            IntegrityRiskClassification::Confirmed,
760            "0.75 must be Confirmed (above 0.65)"
761        );
762    }
763
764    #[test]
765    fn policy_with_lower_thresholds_tightens_classification() {
766        let policy = IntegrityCanaryPolicy::try_with_thresholds(0.10, 0.40).expect("policy");
767        let findings = vec![finding("a", 1.0, IntegrityProbeOutcome::Clean, "")];
768        let score = IntegrityRiskScore::compute(&findings, &policy);
769        assert_eq!(score.value, 0.0);
770        assert_eq!(score.classification, IntegrityRiskClassification::Clean);
771
772        // Score 0.45 → above confirmed threshold (0.40) with strict thresholds.
773        let findings = vec![finding("a", 1.0, IntegrityProbeOutcome::TrapSuspected, "")];
774        let score = IntegrityRiskScore::compute(&findings, &policy);
775        assert!((score.value - 0.5).abs() < 1e-9);
776        assert_eq!(score.classification, IntegrityRiskClassification::Confirmed);
777    }
778
779    #[test]
780    fn policy_rejects_reversed_or_equal_thresholds() {
781        let err = IntegrityCanaryPolicy::try_with_thresholds(0.50, 0.50).unwrap_err();
782        assert!(matches!(
783            err,
784            IntegrityCanaryPolicyError::InvalidThresholds(_)
785        ));
786        let err = IntegrityCanaryPolicy::try_with_thresholds(0.70, 0.30).unwrap_err();
787        assert!(matches!(
788            err,
789            IntegrityCanaryPolicyError::InvalidThresholds(_)
790        ));
791    }
792
793    #[test]
794    fn policy_rejects_nan_thresholds() {
795        let err = IntegrityCanaryPolicy::try_with_thresholds(f64::NAN, 0.65).unwrap_err();
796        assert!(matches!(
797            err,
798            IntegrityCanaryPolicyError::InvalidThresholds(_)
799        ));
800        let err = IntegrityCanaryPolicy::try_with_thresholds(0.30, f64::NAN).unwrap_err();
801        assert!(matches!(
802            err,
803            IntegrityCanaryPolicyError::InvalidThresholds(_)
804        ));
805    }
806
807    #[test]
808    fn trap_findings_and_hints_populated_for_fired_probes() {
809        let findings = vec![
810            trap_finding("webdriver_descriptor_native", 0.20),
811            ProbeFinding {
812                id: "performance_now_resolution".to_string(),
813                outcome: IntegrityProbeOutcome::TrapSuspected,
814                weight: 0.14,
815                evidence: "deviation detected".to_string(),
816                mitigation_hint: "Apply continuous jitter".to_string(),
817            },
818            finding(
819                "error_to_string_native",
820                0.08,
821                IntegrityProbeOutcome::Clean,
822                "",
823            ),
824        ];
825        let report = IntegrityCanaryReport::from_findings(findings);
826        assert_eq!(report.trap_count(), 2);
827        assert_eq!(report.confirmed_count(), 1);
828        assert_eq!(report.mitigation_hints.len(), 2);
829        // Clean findings never carry a mitigation hint entry.
830        let clean_hint = report
831            .mitigation_hints
832            .iter()
833            .find(|h| h.probe_id == "error_to_string_native");
834        assert!(clean_hint.is_none());
835    }
836
837    #[test]
838    fn nan_score_classifies_as_clean() {
839        let policy = IntegrityCanaryPolicy::default();
840        assert_eq!(
841            IntegrityRiskScore::classify_for(f64::NAN, &policy),
842            IntegrityRiskClassification::Clean
843        );
844        assert_eq!(
845            policy.classify(f64::NAN),
846            IntegrityRiskClassification::Clean
847        );
848    }
849
850    #[test]
851    fn score_outside_unit_interval_is_clamped() {
852        let policy = IntegrityCanaryPolicy::default();
853        assert_eq!(policy.classify(1.5), IntegrityRiskClassification::Confirmed);
854        assert_eq!(policy.classify(-0.5), IntegrityRiskClassification::Clean);
855    }
856
857    #[test]
858    fn confirmed_finding_helper_attaches_hint() {
859        let probe = IntegrityProbe::confirmed_finding("test_probe", 0.10, "evidence");
860        let report = IntegrityCanaryReport::from_findings(vec![probe]);
861        assert!(report.is_confirmed());
862        assert_eq!(report.trap_count(), 1);
863    }
864
865    #[test]
866    fn probe_id_label_is_stable_for_trend_seam() {
867        let id = IntegrityProbeId::WebDriverDescriptorNative;
868        assert_eq!(id.label(), "webdriver_descriptor_native");
869    }
870
871    #[test]
872    fn mitigation_hints_carry_outcome_label() {
873        let findings = vec![ProbeFinding {
874            id: "test".to_string(),
875            outcome: IntegrityProbeOutcome::TrapConfirmed,
876            weight: 0.20,
877            evidence: "x".to_string(),
878            mitigation_hint: "apply native descriptor".to_string(),
879        }];
880        let report = IntegrityCanaryReport::from_findings(findings);
881        assert_eq!(report.mitigation_hints.len(), 1);
882        assert_eq!(
883            report.mitigation_hints[0].outcome,
884            IntegrityProbeOutcome::TrapConfirmed
885        );
886        assert_eq!(report.mitigation_hints[0].probe_id, "test");
887    }
888
889    #[test]
890    fn report_serializes_with_snake_case_keys() {
891        let report = IntegrityCanaryReport::from_findings(vec![trap_finding("a", 1.0)]);
892        let json = serde_json::to_string(&report).expect("serialize");
893        assert!(json.contains("\"score\""), "got: {json}");
894        assert!(json.contains("\"classification\""), "got: {json}");
895        assert!(json.contains("\"contributing_findings\""), "got: {json}");
896        assert!(json.contains("\"skipped_findings\""), "got: {json}");
897        assert!(json.contains("\"findings\""), "got: {json}");
898        assert!(json.contains("\"trap_findings\""), "got: {json}");
899        assert!(json.contains("\"mitigation_hints\""), "got: {json}");
900        assert!(json.contains("\"suspected_threshold\""), "got: {json}");
901        assert!(json.contains("\"confirmed_threshold\""), "got: {json}");
902    }
903
904    #[test]
905    fn report_roundtrips_through_json() {
906        let findings = vec![
907            trap_finding("a", 0.5),
908            finding("b", 0.5, IntegrityProbeOutcome::Clean, ""),
909        ];
910        let report = IntegrityCanaryReport::from_findings(findings);
911        let json = serde_json::to_string(&report).expect("serialize");
912        let restored: IntegrityCanaryReport = serde_json::from_str(&json).expect("deserialize");
913        assert_eq!(restored, report);
914    }
915
916    #[test]
917    fn empty_report_omits_helper_fields_in_json() {
918        let report = IntegrityCanaryReport::from_findings(Vec::new());
919        let json = serde_json::to_string(&report).expect("serialize");
920        assert!(
921            !json.contains("mitigation_hints"),
922            "empty report must omit mitigation_hints: {json}"
923        );
924        assert!(
925            !json.contains("trap_findings"),
926            "empty report must omit trap_findings: {json}"
927        );
928    }
929}