Skip to main content

stygian_charon/change_feed/
delta.rs

1//! Change-feed delta inputs (T88).
2//!
3//! The change detector consumes three families of regression
4//! signal — canary (T92 integrity probes + T84 trend),
5//! proxy intelligence (T86), and extraction reliability
6//! (T87). Each family contributes a single
7//! [`ChangeDeltaInput`] describing the **delta** the
8//! detector should weigh.
9//!
10//! ## Why abstract deltas?
11//!
12//! The canary probe pack lives in `stygian-browser`, the
13//! proxy intelligence store lives in `stygian-proxy`, and
14//! the extraction reliability scorer lives in
15//! `stygian-plugin`. None of those crates may be a build
16//! dependency of `stygian-charon` (the `change_feed` module
17//! sits in `stygian-charon`). To keep the layering clean,
18//! callers convert their upstream reports into the
19//! delta types defined here at the boundary. The detector
20//! never reaches back into the source crates — the input
21//! is a flat, serialisable, `Copy`-friendly record.
22//!
23//! ## Delta fields
24//!
25//! Each delta carries:
26//! - a **weight** in `[0.0, 1.0]` expressing how alarming
27//!   the source signal thinks the regression is
28//!   (`0.0` = perfectly clean, `1.0` = worst-case), and
29//! - an **affected target** (a domain string) so the
30//!   emitted event can be routed back to the runbook
31//!   without an extra lookup.
32//!
33//! Optional fields let callers attach richer context
34//! (vendor hint, target class, severity tag) without
35//! forcing every input to populate them.
36
37use std::collections::BTreeMap;
38
39use serde::{Deserialize, Serialize};
40
41use crate::types::TargetClass;
42use crate::vendor_classifier::VendorId;
43
44/// Severity tier attached to a delta by its source.
45///
46/// The tier is a **coarse** label — the deterministic
47/// weight field is the value the classifier actually
48/// quantises against. The tier is preserved on the emitted
49/// [`ChangeEvent`][crate::change_feed::ChangeEvent] so
50/// downstream runbook consumers do not have to invert the
51/// weight to recover the source's intent.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum DeltaSeverity {
55    /// Source signal is clean / below the documented
56    /// floor; nothing to act on.
57    Clean,
58    /// Source signal is in the advisory band; the source
59    /// crate recommends watching the target.
60    Advisory,
61    /// Source signal is in the warning band; the source
62    /// crate recommends an active response.
63    Warning,
64    /// Source signal is in the critical band; the source
65    /// crate has triggered its own hard-gate or emergency
66    /// path.
67    Critical,
68}
69
70impl DeltaSeverity {
71    /// Stable lower-case wire label.
72    ///
73    /// # Example
74    ///
75    /// ```
76    /// use stygian_charon::change_feed::DeltaSeverity;
77    ///
78    /// assert_eq!(DeltaSeverity::Advisory.label(), "advisory");
79    /// assert_eq!(DeltaSeverity::Critical.label(), "critical");
80    /// ```
81    #[must_use]
82    pub const fn label(self) -> &'static str {
83        match self {
84            Self::Clean => "clean",
85            Self::Advisory => "advisory",
86            Self::Warning => "warning",
87            Self::Critical => "critical",
88        }
89    }
90}
91
92/// Source channel for a [`ChangeDeltaInput`].
93///
94/// The discriminant order is part of the deterministic
95/// tie-break rule used when two deltas share the same
96/// affected target. Lower discriminant wins, so
97/// `Canary` is consulted before `Proxy`, which is
98/// consulted before `Extraction`. This matches the
99/// "canary is the primary signal" precedence implied by
100/// T84 / T92.
101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case")]
103pub enum DeltaSource {
104    /// Canary regression — JS integrity probe (T92)
105    /// score drop, T84 trend regression, or both.
106    Canary,
107    /// Proxy intelligence score regression (T86).
108    Proxy,
109    /// Extraction reliability regression (T87).
110    Extraction,
111}
112
113impl DeltaSource {
114    /// Stable lower-case wire label.
115    ///
116    /// # Example
117    ///
118    /// ```
119    /// use stygian_charon::change_feed::DeltaSource;
120    ///
121    /// assert_eq!(DeltaSource::Canary.label(), "canary");
122    /// assert_eq!(DeltaSource::Proxy.label(), "proxy");
123    /// assert_eq!(DeltaSource::Extraction.label(), "extraction");
124    /// ```
125    #[must_use]
126    pub const fn label(self) -> &'static str {
127        match self {
128            Self::Canary => "canary",
129            Self::Proxy => "proxy",
130            Self::Extraction => "extraction",
131        }
132    }
133}
134
135/// A single regression delta consumed by the
136/// [`ChangeDetector`][crate::change_feed::ChangeDetector].
137///
138/// The delta is the **boundary type** between the
139/// upstream sources (T92 / T84 / T86 / T87) and the
140/// detector. The source crate converts its own report
141/// into one or more `ChangeDeltaInput`s; the detector
142/// does not reach into the source crates.
143///
144/// # Field semantics
145///
146/// - `source` — which upstream family produced this delta.
147/// - `affected_target` — domain the delta applies to
148///   (e.g. `"example.com"`).
149/// - `weight` — unit-interval severity score
150///   (`0.0` = clean, `1.0` = worst-case). The
151///   detector quantises this through its configurable
152///   thresholds to derive the classification band.
153/// - `severity` — coarse tier the source attached
154///   (clean / advisory / warning / critical). Preserved
155///   on the emitted event for downstream consumers.
156/// - `target_class` — optional `TargetClass` so the
157///   detector can attach a target-class-aware
158///   mitigation hint.
159/// - `vendor_hint` — optional `VendorId` the source
160///   recognised on this target; preserved on the event.
161/// - `summary` — short human-readable description of
162///   the regression (one line). Used as the
163///   `delta_summary.headline` field on the emitted
164///   event.
165/// - `evidence` — optional structured evidence
166///   (e.g. probe IDs, score deltas). Preserved verbatim
167///   on the event payload.
168///
169/// # Example
170///
171/// ```
172/// use stygian_charon::change_feed::{ChangeDeltaInput, DeltaSeverity, DeltaSource};
173/// use stygian_charon::vendor_classifier::VendorId;
174///
175/// let delta = ChangeDeltaInput::new(
176///     DeltaSource::Canary,
177///     "example.com",
178///     0.55,
179///     DeltaSeverity::Warning,
180///     "integrity probe webdriver regressed 0.18",
181/// );
182/// assert_eq!(delta.source, DeltaSource::Canary);
183/// assert_eq!(delta.affected_target, "example.com");
184/// assert!(delta.vendor_hint.is_none());
185/// let delta = delta.with_vendor(VendorId::Cloudflare);
186/// assert_eq!(delta.vendor_hint, Some(VendorId::Cloudflare));
187/// ```
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
189pub struct ChangeDeltaInput {
190    /// Source channel that produced this delta.
191    pub source: DeltaSource,
192    /// Affected target (domain).
193    pub affected_target: String,
194    /// Unit-interval severity (`0.0` = clean,
195    /// `1.0` = worst-case). Used by the detector
196    /// to derive the classification band.
197    pub weight: f64,
198    /// Coarse severity tier the source attached.
199    pub severity: DeltaSeverity,
200    /// Optional target class — when set, the
201    /// detector uses it to choose the runbook
202    /// mitigation hint.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub target_class: Option<TargetClass>,
205    /// Optional vendor hint — when set, the detector
206    /// surfaces it on the emitted event.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub vendor_hint: Option<VendorId>,
209    /// Short human-readable summary of the
210    /// regression. Surfaced as the `headline`
211    /// field of the delta summary on the emitted
212    /// event.
213    pub summary: String,
214    /// Optional structured evidence (probe IDs,
215    /// score deltas, etc.). Preserved verbatim on
216    /// the event payload under `evidence.<key>`.
217    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
218    pub evidence: BTreeMap<String, String>,
219}
220
221impl ChangeDeltaInput {
222    /// Build a delta with the required fields. The
223    /// weight is clamped to `[0.0, 1.0]` and `NaN` is
224    /// treated as `0.0` so a single bad source
225    /// cannot poison the detector.
226    #[must_use]
227    pub fn new(
228        source: DeltaSource,
229        affected_target: impl Into<String>,
230        weight: f64,
231        severity: DeltaSeverity,
232        summary: impl Into<String>,
233    ) -> Self {
234        Self {
235            source,
236            affected_target: affected_target.into(),
237            weight: sanitise_weight(weight),
238            severity,
239            target_class: None,
240            vendor_hint: None,
241            summary: summary.into(),
242            evidence: BTreeMap::new(),
243        }
244    }
245
246    /// Attach a target class to the delta.
247    #[must_use]
248    pub const fn with_target_class(mut self, target_class: TargetClass) -> Self {
249        self.target_class = Some(target_class);
250        self
251    }
252
253    /// Attach a vendor hint to the delta.
254    #[must_use]
255    pub const fn with_vendor(mut self, vendor: VendorId) -> Self {
256        self.vendor_hint = Some(vendor);
257        self
258    }
259
260    /// Attach a structured evidence key/value pair.
261    /// Existing keys are overwritten.
262    #[must_use]
263    pub fn with_evidence(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
264        self.evidence.insert(key.into(), value.into());
265        self
266    }
267
268    /// Whether the delta is considered "noisy" by
269    /// the source (i.e. `severity == Clean`). The
270    /// detector still classifies a noisy delta as
271    /// `Noise` even when the weight is non-zero —
272    /// the severity tag is the source's own veto.
273    #[must_use]
274    pub const fn is_clean(&self) -> bool {
275        matches!(self.severity, DeltaSeverity::Clean)
276    }
277}
278
279const fn sanitise_weight(weight: f64) -> f64 {
280    if weight.is_nan() {
281        0.0
282    } else {
283        weight.clamp(0.0, 1.0)
284    }
285}
286
287#[cfg(test)]
288#[allow(
289    clippy::unwrap_used,
290    clippy::expect_used,
291    clippy::panic,
292    clippy::indexing_slicing
293)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn new_clamps_weight_to_unit_interval() {
299        let delta = ChangeDeltaInput::new(
300            DeltaSource::Canary,
301            "example.com",
302            2.5,
303            DeltaSeverity::Warning,
304            "test",
305        );
306        assert!((delta.weight - 1.0).abs() < 1e-9);
307
308        let delta = ChangeDeltaInput::new(
309            DeltaSource::Canary,
310            "example.com",
311            -0.5,
312            DeltaSeverity::Warning,
313            "test",
314        );
315        assert!(delta.weight.abs() < 1e-9);
316
317        let delta = ChangeDeltaInput::new(
318            DeltaSource::Canary,
319            "example.com",
320            f64::NAN,
321            DeltaSeverity::Warning,
322            "test",
323        );
324        assert!(delta.weight.abs() < 1e-9);
325    }
326
327    #[test]
328    fn new_round_trips_through_serde_json() {
329        let delta = ChangeDeltaInput::new(
330            DeltaSource::Proxy,
331            "example.com",
332            0.30,
333            DeltaSeverity::Advisory,
334            "score dropped 0.10",
335        )
336        .with_target_class(TargetClass::Api)
337        .with_vendor(VendorId::Cloudflare)
338        .with_evidence("baseline_score", "0.85")
339        .with_evidence("current_score", "0.75");
340        let json = serde_json::to_string(&delta).expect("serialise");
341        let parsed: ChangeDeltaInput = serde_json::from_str(&json).expect("deserialise");
342        assert_eq!(delta, parsed);
343    }
344
345    #[test]
346    fn with_evidence_overwrites_existing_key() {
347        let delta = ChangeDeltaInput::new(
348            DeltaSource::Extraction,
349            "example.com",
350            0.40,
351            DeltaSeverity::Advisory,
352            "reliability drop",
353        )
354        .with_evidence("baseline", "0.95")
355        .with_evidence("baseline", "0.90");
356        assert_eq!(delta.evidence.get("baseline"), Some(&"0.90".to_string()));
357    }
358
359    #[test]
360    fn clean_severity_short_circuits_via_is_clean() {
361        let clean = ChangeDeltaInput::new(
362            DeltaSource::Canary,
363            "example.com",
364            0.0,
365            DeltaSeverity::Clean,
366            "ok",
367        );
368        assert!(clean.is_clean());
369        let noisy = ChangeDeltaInput::new(
370            DeltaSource::Canary,
371            "example.com",
372            0.10,
373            DeltaSeverity::Advisory,
374            "blip",
375        );
376        assert!(!noisy.is_clean());
377    }
378
379    #[test]
380    fn source_labels_are_stable() {
381        assert_eq!(DeltaSource::Canary.label(), "canary");
382        assert_eq!(DeltaSource::Proxy.label(), "proxy");
383        assert_eq!(DeltaSource::Extraction.label(), "extraction");
384    }
385
386    #[test]
387    fn severity_labels_are_stable() {
388        assert_eq!(DeltaSeverity::Clean.label(), "clean");
389        assert_eq!(DeltaSeverity::Advisory.label(), "advisory");
390        assert_eq!(DeltaSeverity::Warning.label(), "warning");
391        assert_eq!(DeltaSeverity::Critical.label(), "critical");
392    }
393}