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}