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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct ModeDifferentialCorpus {
13 pub corpus_id: String,
15 pub snapshots: Vec<NormalizedFingerprintSnapshot>,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ModeComparison {
22 pub baseline: SnapshotMode,
24 pub candidate: SnapshotMode,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
30pub struct ModeDifferentialThresholds {
31 pub max_changed: usize,
33 pub max_added: usize,
35 pub max_removed: usize,
37 pub max_total: usize,
39}
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct ModeDifferentialPairResult {
44 pub corpus_id: String,
46 pub baseline_mode: SnapshotMode,
48 pub candidate_mode: SnapshotMode,
50 pub drift: SnapshotDriftReport,
52 pub changed: usize,
54 pub added: usize,
56 pub removed: usize,
58 pub total: usize,
60 pub failed_thresholds: bool,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ModeDifferentialRunReport {
67 pub pair_results: Vec<ModeDifferentialPairResult>,
69 pub failing_pairs: usize,
71 pub failed: bool,
73}
74
75#[derive(Debug, Error, PartialEq, Eq)]
77pub enum ModeDifferentialError {
78 #[error("corpus '{corpus_id}' is missing snapshot for mode {mode:?}")]
80 MissingMode {
81 corpus_id: String,
83 mode: SnapshotMode,
85 },
86 #[error("corpus '{corpus_id}' contains duplicate snapshot for mode {mode:?}")]
88 DuplicateMode {
89 corpus_id: String,
91 mode: SnapshotMode,
93 },
94 #[error("snapshot comparison failed: {0}")]
96 Snapshot(#[from] SnapshotCollectionError),
97}
98
99pub 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}