Skip to main content

stygian_browser/integrity_canary/
trend.rs

1//! Canary trend-detection seam (T84 hookup).
2//!
3//! T84 will add the production canary hard-gate that emits
4//! governance-grade CI summaries from per-run canary observations.
5//! To keep the T92 surface stable, this module exposes the
6//! **observation shape** that T84 will consume without forcing
7//! future canary infrastructure to know about the probe catalogue
8//! or the scoring formula.
9//!
10//! [`CanaryTrendObservation::from_report`] is the canonical
11//! constructor: callers pass the just-built
12//! [`crate::integrity_canary::report::IntegrityCanaryReport`] and
13//! receive a deterministic, JSON-stable record keyed by a
14//! signature hash so two reports with the same findings produce
15//! byte-identical observations (the trend signal collapses to a
16//! single bucket per signature).
17//!
18//! The seam is **default-on** and lives next to the probe set so
19//! existing consumers that wire `IntegrityCanaryReport` into the
20//! diagnostic payload can immediately stream the same record to a
21//! future canary aggregator without further changes.
22
23use std::fmt;
24
25use serde::{Deserialize, Serialize};
26
27use crate::freshness::signature_hash;
28use crate::integrity_canary::report::{
29    IntegrityCanaryReport, IntegrityRiskClassification, IntegrityRiskScore,
30};
31
32/// Coarse trend severity band.
33///
34/// Mirrors [`IntegrityRiskClassification`] but uses a stable
35/// telemetry label set so a future T84 aggregator can branch on
36/// the band without importing the `integrity_canary` module. The
37/// labels are deliberately ASCII-lowercase with underscores (no
38/// `snake_case` rename needed — the serde tag handles it).
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum TrendSeverity {
42    /// Score below the suspected threshold — no trend signal.
43    Clean,
44    /// Score at or above the suspected threshold but below the
45    /// confirmed threshold — soft signal, watch the next runs.
46    Suspected,
47    /// Score at or above the confirmed threshold — hard signal,
48    /// refresh the session.
49    Confirmed,
50}
51
52impl TrendSeverity {
53    /// Stable `snake_case` label used in telemetry.
54    #[must_use]
55    pub const fn label(self) -> &'static str {
56        match self {
57            Self::Clean => "clean",
58            Self::Suspected => "suspected",
59            Self::Confirmed => "confirmed",
60        }
61    }
62
63    /// Resolve the trend severity for a [`IntegrityRiskScore`].
64    #[must_use]
65    pub const fn from_score(score: &IntegrityRiskScore) -> Self {
66        match score.classification {
67            IntegrityRiskClassification::Confirmed => Self::Confirmed,
68            IntegrityRiskClassification::Suspected => Self::Suspected,
69            IntegrityRiskClassification::Clean => Self::Clean,
70        }
71    }
72}
73
74impl fmt::Display for TrendSeverity {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.write_str(self.label())
77    }
78}
79
80/// Deterministic, JSON-stable trend observation built from an
81/// [`IntegrityCanaryReport`].
82///
83/// The `signature` field is the canonical trend key — two
84/// observations with the same signature describe the same finding
85/// set (same probe ids + outcomes + weights), so a future trend
86/// aggregator can collapse identical signals without recomputing
87/// them.
88///
89/// # Example
90///
91/// ```
92/// use stygian_browser::integrity_canary::{
93///     CanaryTrendObservation, IntegrityCanaryReport, IntegrityProbe,
94/// };
95///
96/// let finding = IntegrityProbe::confirmed_finding(
97///     "webdriver_descriptor_native",
98///     0.20,
99///     "data property leak",
100/// );
101/// let report = IntegrityCanaryReport::from_findings(vec![finding]);
102/// let obs = CanaryTrendObservation::from_report(&report);
103/// assert!(obs.signature.starts_with("fnv64:"));
104/// assert!(obs.score > 0.0);
105/// ```
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
107pub struct CanaryTrendObservation {
108    /// Stable `fnv64:<hex>` signature over (probe ids + outcomes +
109    /// weights). Two reports with the same findings produce the
110    /// same signature, so the trend aggregator can bucket by this
111    /// key directly.
112    pub signature: String,
113    /// Aggregate risk score in `[0.0, 1.0]`.
114    pub score: f64,
115    /// Coarse severity band derived from the score.
116    pub severity: TrendSeverity,
117    /// Number of findings that contributed to the score.
118    pub contributing_findings: usize,
119    /// Number of findings that were skipped.
120    pub skipped_findings: usize,
121    /// Number of trap findings (`Suspected` or `Confirmed`).
122    pub trap_count: usize,
123    /// Number of confirmed findings.
124    pub confirmed_count: usize,
125    /// Distinct probe ids that fired with a trap outcome, in
126    /// evaluation order.
127    pub fired_probe_ids: Vec<String>,
128    /// Captured observation timestamp (Unix epoch ms). Populated
129    /// via [`crate::freshness::unix_epoch_ms`] when the observation
130    /// is built; serialised as a `u64` for downstream automation.
131    pub captured_at_epoch_ms: u64,
132}
133
134impl CanaryTrendObservation {
135    /// Build a trend observation from an [`IntegrityCanaryReport`].
136    ///
137    /// The signature is computed over the probe ids, outcomes, and
138    /// weights — two reports with identical findings produce
139    /// identical signatures so the trend aggregator can dedupe
140    /// without recomputing.
141    #[must_use]
142    pub fn from_report(report: &IntegrityCanaryReport) -> Self {
143        let fired_probe_ids: Vec<String> =
144            report.trap_findings.iter().map(|f| f.id.clone()).collect();
145
146        let mut signature_parts: Vec<String> = Vec::with_capacity(report.findings.len() * 3);
147        for f in &report.findings {
148            signature_parts.push(f.id.clone());
149            signature_parts.push(f.outcome.label().to_string());
150            signature_parts.push(format!("{:.6}", f.weight));
151        }
152        let borrowed: Vec<&str> = signature_parts.iter().map(String::as_str).collect();
153        let signature = signature_hash(&borrowed);
154
155        Self {
156            signature,
157            score: report.score.value,
158            severity: TrendSeverity::from_score(&report.score),
159            contributing_findings: report.score.contributing_findings,
160            skipped_findings: report.score.skipped_findings,
161            trap_count: report.trap_count(),
162            confirmed_count: report.confirmed_count(),
163            fired_probe_ids,
164            captured_at_epoch_ms: crate::freshness::unix_epoch_ms(),
165        }
166    }
167
168    /// `true` when the observation carries a trap signal
169    /// (Suspected or Confirmed).
170    #[must_use]
171    pub const fn has_trap_signal(&self) -> bool {
172        !matches!(self.severity, TrendSeverity::Clean)
173    }
174
175    /// `true` when the observation is a Confirmed trend entry.
176    #[must_use]
177    pub const fn is_confirmed(&self) -> bool {
178        matches!(self.severity, TrendSeverity::Confirmed)
179    }
180}
181
182// ─── Tests ────────────────────────────────────────────────────────────────────
183
184#[cfg(test)]
185#[allow(
186    clippy::unwrap_used,
187    clippy::expect_used,
188    clippy::panic,
189    clippy::indexing_slicing
190)]
191mod tests {
192    use super::*;
193    use crate::integrity_canary::probes::{IntegrityProbe, IntegrityProbeOutcome, ProbeFinding};
194    use crate::integrity_canary::report::{
195        IntegrityCanaryPolicy, IntegrityCanaryReport, IntegrityRiskClassification,
196    };
197
198    #[test]
199    fn observation_signature_is_deterministic_for_same_findings() {
200        let report = IntegrityCanaryReport::from_findings(vec![
201            IntegrityProbe::confirmed_finding("a", 0.5, "x"),
202            IntegrityProbe::confirmed_finding("b", 0.5, "y"),
203        ]);
204        let obs_a = CanaryTrendObservation::from_report(&report);
205        let obs_b = CanaryTrendObservation::from_report(&report);
206        // captured_at_epoch_ms differs at the millisecond level in
207        // some runs; verify signature + score equality only.
208        assert_eq!(obs_a.signature, obs_b.signature);
209        assert!((obs_a.score - obs_b.score).abs() < 1e-9);
210    }
211
212    #[test]
213    fn observation_signature_changes_with_finding_set() {
214        let report_a =
215            IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
216                "a", 0.5, "x",
217            )]);
218        let report_b =
219            IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
220                "b", 0.5, "y",
221            )]);
222        let obs_a = CanaryTrendObservation::from_report(&report_a);
223        let obs_b = CanaryTrendObservation::from_report(&report_b);
224        assert_ne!(obs_a.signature, obs_b.signature);
225    }
226
227    #[test]
228    fn observation_severity_tracks_classification() {
229        // Clean report → Clean trend
230        let report = IntegrityCanaryReport::from_findings(Vec::new());
231        let obs = CanaryTrendObservation::from_report(&report);
232        assert_eq!(obs.severity, TrendSeverity::Clean);
233        assert!(!obs.has_trap_signal());
234        assert!(!obs.is_confirmed());
235
236        // Confirmed report → Confirmed trend
237        let report = IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
238            "a", 1.0, "x",
239        )]);
240        let obs = CanaryTrendObservation::from_report(&report);
241        assert_eq!(obs.severity, TrendSeverity::Confirmed);
242        assert!(obs.has_trap_signal());
243        assert!(obs.is_confirmed());
244    }
245
246    #[test]
247    fn observation_carries_fired_probe_ids_in_evaluation_order() {
248        let findings = vec![
249            ProbeFinding {
250                id: "probe_one".to_string(),
251                outcome: IntegrityProbeOutcome::TrapConfirmed,
252                weight: 0.20,
253                evidence: "x".to_string(),
254                mitigation_hint: String::new(),
255            },
256            ProbeFinding {
257                id: "probe_two".to_string(),
258                outcome: IntegrityProbeOutcome::TrapSuspected,
259                weight: 0.15,
260                evidence: "y".to_string(),
261                mitigation_hint: String::new(),
262            },
263            ProbeFinding {
264                id: "probe_three".to_string(),
265                outcome: IntegrityProbeOutcome::Clean,
266                weight: 0.10,
267                evidence: "z".to_string(),
268                mitigation_hint: String::new(),
269            },
270        ];
271        let report = IntegrityCanaryReport::from_findings(findings);
272        let obs = CanaryTrendObservation::from_report(&report);
273        assert_eq!(
274            obs.fired_probe_ids,
275            vec!["probe_one".to_string(), "probe_two".to_string()]
276        );
277        assert_eq!(obs.confirmed_count, 1);
278        assert_eq!(obs.trap_count, 2);
279    }
280
281    #[test]
282    fn observation_skipped_count_reflects_findings() {
283        let findings = vec![
284            ProbeFinding {
285                id: "a".to_string(),
286                outcome: IntegrityProbeOutcome::Skipped,
287                weight: 0.5,
288                evidence: "x".to_string(),
289                mitigation_hint: String::new(),
290            },
291            ProbeFinding {
292                id: "b".to_string(),
293                outcome: IntegrityProbeOutcome::TrapConfirmed,
294                weight: 0.5,
295                evidence: "y".to_string(),
296                mitigation_hint: String::new(),
297            },
298        ];
299        let report = IntegrityCanaryReport::from_findings(findings);
300        let obs = CanaryTrendObservation::from_report(&report);
301        assert_eq!(obs.skipped_findings, 1);
302        assert_eq!(obs.contributing_findings, 1);
303        assert_eq!(obs.confirmed_count, 1);
304    }
305
306    #[test]
307    fn observation_handles_strict_thresholds() {
308        let policy = IntegrityCanaryPolicy::try_with_thresholds(0.10, 0.20).expect("policy");
309        let findings = vec![ProbeFinding {
310            id: "a".to_string(),
311            outcome: IntegrityProbeOutcome::TrapSuspected,
312            weight: 1.0,
313            evidence: "x".to_string(),
314            mitigation_hint: String::new(),
315        }];
316        let report = IntegrityCanaryReport::with_policy(findings, policy);
317        let obs = CanaryTrendObservation::from_report(&report);
318        // 0.5 (suspected severity) is above the strict confirmed threshold (0.20).
319        assert_eq!(obs.severity, TrendSeverity::Confirmed);
320    }
321
322    #[test]
323    fn observation_serializes_with_snake_case_keys() {
324        let report = IntegrityCanaryReport::from_findings(vec![IntegrityProbe::confirmed_finding(
325            "a", 0.5, "x",
326        )]);
327        let obs = CanaryTrendObservation::from_report(&report);
328        let json = serde_json::to_string(&obs).expect("serialize");
329        assert!(json.contains("\"signature\""), "got: {json}");
330        assert!(json.contains("\"score\""), "got: {json}");
331        assert!(json.contains("\"severity\""), "got: {json}");
332        assert!(json.contains("\"trap_count\""), "got: {json}");
333        assert!(json.contains("\"confirmed_count\""), "got: {json}");
334        assert!(json.contains("\"fired_probe_ids\""), "got: {json}");
335        assert!(json.contains("\"captured_at_epoch_ms\""), "got: {json}");
336    }
337
338    #[test]
339    fn trend_severity_labels_are_stable() {
340        assert_eq!(TrendSeverity::Clean.label(), "clean");
341        assert_eq!(TrendSeverity::Suspected.label(), "suspected");
342        assert_eq!(TrendSeverity::Confirmed.label(), "confirmed");
343    }
344
345    #[test]
346    fn trend_severity_from_score_round_trips_classification() {
347        let mut score = IntegrityRiskScore::clean();
348        assert_eq!(TrendSeverity::from_score(&score), TrendSeverity::Clean);
349        score.classification = IntegrityRiskClassification::Suspected;
350        assert_eq!(TrendSeverity::from_score(&score), TrendSeverity::Suspected);
351        score.classification = IntegrityRiskClassification::Confirmed;
352        assert_eq!(TrendSeverity::from_score(&score), TrendSeverity::Confirmed);
353    }
354}