Skip to main content

stygian_charon/
differential.rs

1use serde::{Deserialize, Serialize};
2use thiserror::Error;
3
4use crate::snapshot::NormalizedFingerprintSnapshot;
5use crate::snapshot::{
6    SnapshotCollectionError, SnapshotDeterminismOptions, SnapshotDriftReport, SnapshotMode,
7    SnapshotSignalDriftKind, compare_snapshot_signal_drift,
8};
9
10/// One corpus entry containing mode-specific snapshots captured from identical input.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct ModeDifferentialCorpus {
13    /// Stable corpus identifier (for example, a fixture or target slug).
14    pub corpus_id: String,
15    /// Snapshots captured across modes for this corpus.
16    pub snapshots: Vec<NormalizedFingerprintSnapshot>,
17}
18
19/// One pairwise mode comparison to execute for every corpus.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ModeComparison {
22    /// Baseline mode.
23    pub baseline: SnapshotMode,
24    /// Candidate mode.
25    pub candidate: SnapshotMode,
26}
27
28/// Thresholds used to determine whether a mode differential run fails.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
30pub struct ModeDifferentialThresholds {
31    /// Maximum allowed changed fields in a pairwise comparison.
32    pub max_changed: usize,
33    /// Maximum allowed added fields in a pairwise comparison.
34    pub max_added: usize,
35    /// Maximum allowed removed fields in a pairwise comparison.
36    pub max_removed: usize,
37    /// Maximum allowed total diffs in a pairwise comparison.
38    pub max_total: usize,
39}
40
41/// Detailed result for one corpus/mode pair comparison.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct ModeDifferentialPairResult {
44    /// Corpus id that was compared.
45    pub corpus_id: String,
46    /// Baseline mode.
47    pub baseline_mode: SnapshotMode,
48    /// Candidate mode.
49    pub candidate_mode: SnapshotMode,
50    /// Full drift details.
51    pub drift: SnapshotDriftReport,
52    /// Number of changed fields.
53    pub changed: usize,
54    /// Number of added fields.
55    pub added: usize,
56    /// Number of removed fields.
57    pub removed: usize,
58    /// Number of total diffs.
59    pub total: usize,
60    /// `true` when this pair exceeds configured thresholds.
61    pub failed_thresholds: bool,
62}
63
64/// Aggregated mode differential run output.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ModeDifferentialRunReport {
67    /// Pairwise results for all corpora and configured comparisons.
68    pub pair_results: Vec<ModeDifferentialPairResult>,
69    /// Number of pairwise comparisons that exceeded thresholds.
70    pub failing_pairs: usize,
71    /// `true` when any pair exceeded configured thresholds.
72    pub failed: bool,
73}
74
75/// Errors returned by the mode differential runner.
76#[derive(Debug, Error, PartialEq, Eq)]
77pub enum ModeDifferentialError {
78    /// Required mode is missing from a corpus.
79    #[error("corpus '{corpus_id}' is missing snapshot for mode {mode:?}")]
80    MissingMode {
81        /// Corpus identifier.
82        corpus_id: String,
83        /// Missing mode.
84        mode: SnapshotMode,
85    },
86    /// A corpus contains duplicate snapshots for the same mode.
87    #[error("corpus '{corpus_id}' contains duplicate snapshot for mode {mode:?}")]
88    DuplicateMode {
89        /// Corpus identifier.
90        corpus_id: String,
91        /// Duplicated mode.
92        mode: SnapshotMode,
93    },
94    /// Snapshot collection/comparison failed.
95    #[error("snapshot comparison failed: {0}")]
96    Snapshot(#[from] SnapshotCollectionError),
97}
98
99/// Run pairwise mode differential comparisons across corpora.
100///
101/// This runner executes the same set of mode comparisons for every corpus,
102/// computes signal-level drift with deterministic normalization, and evaluates
103/// each pair against configurable thresholds suitable for CI gates.
104///
105/// # Errors
106///
107/// Returns [`ModeDifferentialError`] if a corpus contains duplicate modes,
108/// is missing a required mode from `comparisons`, or snapshot comparison fails.
109pub fn run_mode_differential_regression(
110    corpora: &[ModeDifferentialCorpus],
111    comparisons: &[ModeComparison],
112    options: &SnapshotDeterminismOptions,
113    thresholds: ModeDifferentialThresholds,
114) -> Result<ModeDifferentialRunReport, ModeDifferentialError> {
115    let mut pair_results = Vec::new();
116    let mut failing_pairs = 0_usize;
117
118    for corpus in corpora {
119        validate_unique_modes(corpus)?;
120
121        for comparison in comparisons {
122            let baseline = find_mode_snapshot(corpus, comparison.baseline)?;
123            let candidate = find_mode_snapshot(corpus, comparison.candidate)?;
124
125            let drift = compare_snapshot_signal_drift(baseline, candidate, options)?;
126            let (changed, added, removed) = count_diffs(&drift);
127            let total = drift.diffs.len();
128            let failed_thresholds = changed > thresholds.max_changed
129                || added > thresholds.max_added
130                || removed > thresholds.max_removed
131                || total > thresholds.max_total;
132
133            if failed_thresholds {
134                failing_pairs = failing_pairs.saturating_add(1);
135            }
136
137            pair_results.push(ModeDifferentialPairResult {
138                corpus_id: corpus.corpus_id.clone(),
139                baseline_mode: comparison.baseline,
140                candidate_mode: comparison.candidate,
141                drift,
142                changed,
143                added,
144                removed,
145                total,
146                failed_thresholds,
147            });
148        }
149    }
150
151    Ok(ModeDifferentialRunReport {
152        pair_results,
153        failing_pairs,
154        failed: failing_pairs > 0,
155    })
156}
157
158fn validate_unique_modes(corpus: &ModeDifferentialCorpus) -> Result<(), ModeDifferentialError> {
159    let mut seen = Vec::new();
160    for snapshot in &corpus.snapshots {
161        if seen.contains(&snapshot.mode) {
162            return Err(ModeDifferentialError::DuplicateMode {
163                corpus_id: corpus.corpus_id.clone(),
164                mode: snapshot.mode,
165            });
166        }
167        seen.push(snapshot.mode);
168    }
169    Ok(())
170}
171
172fn find_mode_snapshot(
173    corpus: &ModeDifferentialCorpus,
174    mode: SnapshotMode,
175) -> Result<&NormalizedFingerprintSnapshot, ModeDifferentialError> {
176    corpus
177        .snapshots
178        .iter()
179        .find(|snapshot| snapshot.mode == mode)
180        .ok_or_else(|| ModeDifferentialError::MissingMode {
181            corpus_id: corpus.corpus_id.clone(),
182            mode,
183        })
184}
185
186fn count_diffs(drift: &SnapshotDriftReport) -> (usize, usize, usize) {
187    let mut changed = 0_usize;
188    let mut added = 0_usize;
189    let mut removed = 0_usize;
190
191    for diff in &drift.diffs {
192        match diff.kind {
193            SnapshotSignalDriftKind::Changed => changed = changed.saturating_add(1),
194            SnapshotSignalDriftKind::Added => added = added.saturating_add(1),
195            SnapshotSignalDriftKind::Removed => removed = removed.saturating_add(1),
196        }
197    }
198
199    (changed, added, removed)
200}
201
202#[cfg(test)]
203#[allow(clippy::expect_used)]
204mod tests {
205    use super::*;
206
207    fn parse_snapshot(path: &str) -> NormalizedFingerprintSnapshot {
208        serde_json::from_str::<NormalizedFingerprintSnapshot>(path)
209            .expect("example snapshot should deserialize")
210    }
211
212    #[test]
213    fn mode_differential_runner_reports_failures_against_thresholds() {
214        let mut browser = parse_snapshot(include_str!(
215            "../docs/examples/fingerprint-snapshot-v1-browser.json"
216        ));
217        browser.signals.user_agent = "different-agent".to_string();
218
219        let corpus = ModeDifferentialCorpus {
220            corpus_id: "fixture-a".to_string(),
221            snapshots: vec![
222                parse_snapshot(include_str!(
223                    "../docs/examples/fingerprint-snapshot-v1-http.json"
224                )),
225                browser,
226            ],
227        };
228
229        let report = run_mode_differential_regression(
230            &[corpus],
231            &[ModeComparison {
232                baseline: SnapshotMode::Http,
233                candidate: SnapshotMode::Browser,
234            }],
235            &SnapshotDeterminismOptions::default(),
236            ModeDifferentialThresholds::default(),
237        )
238        .expect("runner should execute");
239
240        assert!(report.failed);
241        assert_eq!(report.failing_pairs, 1);
242        assert_eq!(report.pair_results.len(), 1);
243        let first = report
244            .pair_results
245            .first()
246            .expect("expected exactly one pair result");
247        assert!(first.failed_thresholds);
248        assert!(first.total > 0);
249    }
250
251    #[test]
252    fn mode_differential_runner_errors_on_missing_mode() {
253        let corpus = ModeDifferentialCorpus {
254            corpus_id: "fixture-b".to_string(),
255            snapshots: vec![parse_snapshot(include_str!(
256                "../docs/examples/fingerprint-snapshot-v1-http.json"
257            ))],
258        };
259
260        let err = run_mode_differential_regression(
261            &[corpus],
262            &[ModeComparison {
263                baseline: SnapshotMode::Http,
264                candidate: SnapshotMode::Browser,
265            }],
266            &SnapshotDeterminismOptions::default(),
267            ModeDifferentialThresholds::default(),
268        )
269        .expect_err("missing mode must fail");
270
271        assert_eq!(
272            err,
273            ModeDifferentialError::MissingMode {
274                corpus_id: "fixture-b".to_string(),
275                mode: SnapshotMode::Browser,
276            }
277        );
278    }
279
280    #[test]
281    fn mode_differential_runner_errors_on_duplicate_mode() {
282        let http = parse_snapshot(include_str!(
283            "../docs/examples/fingerprint-snapshot-v1-http.json"
284        ));
285        let corpus = ModeDifferentialCorpus {
286            corpus_id: "fixture-c".to_string(),
287            snapshots: vec![http.clone(), http],
288        };
289
290        let err = run_mode_differential_regression(
291            &[corpus],
292            &[ModeComparison {
293                baseline: SnapshotMode::Http,
294                candidate: SnapshotMode::Browser,
295            }],
296            &SnapshotDeterminismOptions::default(),
297            ModeDifferentialThresholds::default(),
298        )
299        .expect_err("duplicate mode must fail");
300
301        assert_eq!(
302            err,
303            ModeDifferentialError::DuplicateMode {
304                corpus_id: "fixture-c".to_string(),
305                mode: SnapshotMode::Http,
306            }
307        );
308    }
309}