Skip to main content

asupersync/lab/
fuzz.rs

1//! Deterministic fuzz harness for structured concurrency invariants.
2//!
3//! Uses seed-driven exploration to systematically fuzz scheduling decisions
4//! and verify invariant oracles. When a violation is found, the seed is
5//! minimized to produce a minimal reproducer.
6
7use crate::lab::config::LabConfig;
8use crate::lab::replay::normalize_for_replay;
9use crate::lab::runtime::{InvariantViolation, LabRuntime};
10use std::collections::BTreeMap;
11
12/// Configuration for the deterministic fuzzer.
13#[derive(Debug, Clone)]
14pub struct FuzzConfig {
15    /// Base seed for the fuzz campaign.
16    pub base_seed: u64,
17    /// Number of fuzz iterations.
18    pub iterations: usize,
19    /// Maximum steps per iteration before timeout.
20    pub max_steps: u64,
21    /// Number of simulated workers.
22    pub worker_count: usize,
23    /// Enable seed minimization when a violation is found.
24    pub minimize: bool,
25    /// Maximum minimization attempts per violation.
26    pub minimize_attempts: usize,
27}
28
29impl FuzzConfig {
30    /// Create a new fuzz configuration with the given seed and iteration count.
31    #[must_use]
32    pub fn new(base_seed: u64, iterations: usize) -> Self {
33        Self {
34            base_seed,
35            iterations,
36            max_steps: 100_000,
37            worker_count: 1,
38            minimize: true,
39            minimize_attempts: 96,
40        }
41    }
42
43    /// Set the simulated worker count.
44    #[must_use]
45    pub fn worker_count(mut self, count: usize) -> Self {
46        self.worker_count = count;
47        self
48    }
49
50    /// Set the maximum step count per iteration.
51    #[must_use]
52    pub fn max_steps(mut self, max: u64) -> Self {
53        self.max_steps = max;
54        self
55    }
56
57    /// Enable or disable seed minimization.
58    #[must_use]
59    pub fn minimize(mut self, enabled: bool) -> Self {
60        self.minimize = enabled;
61        self
62    }
63}
64
65/// A fuzz finding: a seed that triggers an invariant violation.
66#[derive(Debug, Clone)]
67pub struct FuzzFinding {
68    /// The seed that triggered the violation.
69    pub seed: u64,
70    /// Steps taken before the violation.
71    pub steps: u64,
72    /// The violations found.
73    pub violations: Vec<InvariantViolation>,
74    /// Certificate hash for the schedule that triggered the violation.
75    pub certificate_hash: u64,
76    /// Canonical normalized trace fingerprint for this failing run.
77    pub trace_fingerprint: u64,
78    /// Minimized seed (if minimization succeeded).
79    pub minimized_seed: Option<u64>,
80}
81
82/// Results of a fuzz campaign.
83#[derive(Debug)]
84pub struct FuzzReport {
85    /// Total iterations run.
86    pub iterations: usize,
87    /// Findings (seeds that triggered violations).
88    pub findings: Vec<FuzzFinding>,
89    /// Violation counts by category.
90    pub violation_counts: BTreeMap<String, usize>,
91    /// Certificate hashes seen (for determinism verification).
92    pub unique_certificates: usize,
93}
94
95/// Deterministic corpus entry for a minimized failing fuzz run.
96#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
97pub struct FuzzRegressionCase {
98    /// Seed that produced the original failure.
99    pub seed: u64,
100    /// Replay seed to use for regression checks (minimized when available).
101    pub replay_seed: u64,
102    /// Scheduler certificate hash from the failing run.
103    pub certificate_hash: u64,
104    /// Canonical normalized trace fingerprint for the failing run.
105    pub trace_fingerprint: u64,
106    /// Stable violation categories observed for this case.
107    pub violation_categories: Vec<String>,
108}
109
110/// Deterministic regression corpus produced by a fuzz campaign.
111#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
112pub struct FuzzRegressionCorpus {
113    /// Schema version for compatibility and migration.
114    pub schema_version: u32,
115    /// Base seed used for this fuzz campaign.
116    pub base_seed: u64,
117    /// Number of iterations executed by the campaign.
118    pub iterations: usize,
119    /// Cases sorted in deterministic replay order.
120    pub cases: Vec<FuzzRegressionCase>,
121}
122
123impl FuzzFinding {
124    /// Promote this fuzz finding into a replayable dual-run scenario.
125    #[must_use]
126    pub fn to_promoted_scenario(
127        &self,
128        surface_id: &str,
129        contract_version: &str,
130    ) -> crate::lab::dual_run::PromotedFuzzScenario {
131        crate::lab::dual_run::promote_fuzz_finding(self, surface_id, contract_version)
132    }
133}
134
135impl FuzzRegressionCase {
136    /// Promote this deterministic regression case into a replayable scenario.
137    #[must_use]
138    pub fn to_promoted_scenario(
139        &self,
140        surface_id: &str,
141        contract_version: &str,
142    ) -> crate::lab::dual_run::PromotedFuzzScenario {
143        crate::lab::dual_run::promote_regression_case(self, surface_id, contract_version)
144    }
145}
146
147impl FuzzRegressionCorpus {
148    /// Promote this deterministic regression corpus into replayable scenarios.
149    #[must_use]
150    pub fn to_promoted_scenarios(
151        &self,
152        surface_id: &str,
153        contract_version: &str,
154    ) -> Vec<crate::lab::dual_run::PromotedFuzzScenario> {
155        crate::lab::dual_run::promote_regression_corpus(self, surface_id, contract_version)
156    }
157}
158
159impl FuzzReport {
160    /// True if any violations were found.
161    #[must_use]
162    pub fn has_findings(&self) -> bool {
163        !self.findings.is_empty()
164    }
165
166    /// Seeds that triggered violations.
167    #[must_use]
168    pub fn finding_seeds(&self) -> Vec<u64> {
169        self.findings.iter().map(|f| f.seed).collect()
170    }
171
172    /// Minimized seeds (where minimization succeeded).
173    #[must_use]
174    pub fn minimized_seeds(&self) -> Vec<u64> {
175        self.findings
176            .iter()
177            .filter_map(|f| f.minimized_seed)
178            .collect()
179    }
180
181    /// Build a deterministic minimized-failure replay corpus.
182    ///
183    /// Cases are sorted by replay seed and stable fingerprints so CI can diff
184    /// corpus snapshots reproducibly.
185    #[must_use]
186    pub fn to_regression_corpus(&self, base_seed: u64) -> FuzzRegressionCorpus {
187        let mut cases: Vec<FuzzRegressionCase> = self
188            .findings
189            .iter()
190            .map(|finding| {
191                let replay_seed = finding.minimized_seed.unwrap_or(finding.seed);
192                FuzzRegressionCase {
193                    seed: finding.seed,
194                    replay_seed,
195                    certificate_hash: finding.certificate_hash,
196                    trace_fingerprint: finding.trace_fingerprint,
197                    violation_categories: sorted_violation_categories(&finding.violations),
198                }
199            })
200            .collect();
201
202        cases.sort_by_key(|case| {
203            (
204                case.replay_seed,
205                case.seed,
206                case.trace_fingerprint,
207                case.certificate_hash,
208            )
209        });
210
211        FuzzRegressionCorpus {
212            schema_version: 1,
213            base_seed,
214            iterations: self.iterations,
215            cases,
216        }
217    }
218
219    /// Promote every raw fuzz finding into replayable dual-run scenarios.
220    #[must_use]
221    pub fn to_promoted_findings(
222        &self,
223        surface_id: &str,
224        contract_version: &str,
225    ) -> Vec<crate::lab::dual_run::PromotedFuzzScenario> {
226        self.findings
227            .iter()
228            .map(|finding| finding.to_promoted_scenario(surface_id, contract_version))
229            .collect()
230    }
231
232    /// Build a deterministic regression corpus and promote it into scenarios.
233    ///
234    /// This is the main fuzz-to-scenario bridge used by higher-level replay
235    /// and differential suites.
236    #[must_use]
237    pub fn to_promoted_regression_scenarios(
238        &self,
239        base_seed: u64,
240        surface_id: &str,
241        contract_version: &str,
242    ) -> Vec<crate::lab::dual_run::PromotedFuzzScenario> {
243        self.to_regression_corpus(base_seed)
244            .to_promoted_scenarios(surface_id, contract_version)
245    }
246}
247
248/// Deterministic fuzz harness.
249///
250/// Runs a test closure under many deterministic seeds, checking invariant
251/// oracles after each run. When a violation is found, the harness optionally
252/// minimizes the seed to find a simpler reproducer.
253pub struct FuzzHarness {
254    config: FuzzConfig,
255}
256
257impl FuzzHarness {
258    /// Create a fuzz harness for the provided configuration.
259    #[must_use]
260    pub fn new(config: FuzzConfig) -> Self {
261        Self { config }
262    }
263
264    /// Run the fuzz campaign.
265    ///
266    /// The `test` closure receives a `LabRuntime` and should set up tasks,
267    /// schedule them, and run to quiescence.
268    pub fn run<F>(&self, test: F) -> FuzzReport
269    where
270        F: Fn(&mut LabRuntime),
271    {
272        let mut findings = Vec::new();
273        let mut violation_counts: BTreeMap<String, usize> = BTreeMap::new();
274        let mut certificate_hashes = std::collections::BTreeSet::new();
275
276        for i in 0..self.config.iterations {
277            let seed = self.config.base_seed.wrapping_add(i as u64);
278            let result = self.run_single(seed, &test);
279
280            certificate_hashes.insert(result.certificate_hash);
281
282            if !result.violations.is_empty() {
283                for v in &result.violations {
284                    let key = violation_category(v);
285                    *violation_counts.entry(key).or_insert(0) += 1;
286                }
287
288                let minimized = if self.config.minimize {
289                    self.minimize_seed(seed, &test)
290                } else {
291                    None
292                };
293
294                let (minimized_seed, certificate_hash, trace_fingerprint) = match minimized {
295                    Some((min_seed, ref min_res)) => (
296                        Some(min_seed),
297                        min_res.certificate_hash,
298                        min_res.trace_fingerprint,
299                    ),
300                    None => (None, result.certificate_hash, result.trace_fingerprint),
301                };
302
303                findings.push(FuzzFinding {
304                    seed,
305                    steps: result.steps,
306                    violations: result.violations,
307                    certificate_hash,
308                    trace_fingerprint,
309                    minimized_seed,
310                });
311            }
312        }
313
314        FuzzReport {
315            iterations: self.config.iterations,
316            findings,
317            violation_counts,
318            unique_certificates: certificate_hashes.len(),
319        }
320    }
321
322    fn run_single<F>(&self, seed: u64, test: &F) -> SingleRunResult
323    where
324        F: Fn(&mut LabRuntime),
325    {
326        let mut lab_config = LabConfig::new(seed);
327        lab_config = lab_config.worker_count(self.config.worker_count);
328        lab_config = lab_config.max_steps(self.config.max_steps);
329
330        let mut runtime = LabRuntime::new(lab_config);
331        test(&mut runtime);
332
333        let steps = runtime.steps();
334        let certificate_hash = runtime.certificate().hash();
335        let trace_events = runtime.trace().snapshot();
336        let normalized = normalize_for_replay(&trace_events);
337        let trace_fingerprint =
338            crate::trace::canonicalize::trace_fingerprint(&normalized.normalized);
339        let violations = runtime.check_invariants();
340
341        SingleRunResult {
342            steps,
343            violations,
344            certificate_hash,
345            trace_fingerprint,
346        }
347    }
348
349    /// Attempt to minimize a failing seed.
350    ///
351    /// Tries nearby seeds (bit-flips and offsets) to find the smallest
352    /// seed that still reproduces the same violation-category set.
353    fn minimize_seed<F>(&self, original_seed: u64, test: &F) -> Option<(u64, SingleRunResult)>
354    where
355        F: Fn(&mut LabRuntime),
356    {
357        let original_result = self.run_single(original_seed, test);
358        if original_result.violations.is_empty() {
359            return None;
360        }
361        let target_categories = sorted_violation_categories(&original_result.violations);
362
363        let mut best_seed = original_seed;
364        let mut best_result = None;
365
366        // Try smaller seeds first (simple reduction).
367        for attempt in 0..self.config.minimize_attempts {
368            let candidate = match attempt {
369                // Try absolute small seeds first.
370                0..=15 => attempt as u64,
371                // Try seeds near the original.
372                16..=31 => original_seed.wrapping_sub((attempt - 15) as u64),
373                // Try bit-flipped variants.
374                _ => original_seed ^ (1u64 << ((attempt - 32) % 64)),
375            };
376
377            if candidate == original_seed {
378                continue;
379            }
380
381            let result = self.run_single(candidate, test);
382            if result.violations.is_empty() {
383                continue;
384            }
385
386            let categories = sorted_violation_categories(&result.violations);
387            if categories == target_categories && candidate < best_seed {
388                best_seed = candidate;
389                best_result = Some(result);
390            }
391        }
392
393        if best_seed == original_seed {
394            None
395        } else {
396            Some((best_seed, best_result.unwrap()))
397        }
398    }
399}
400
401#[derive(Clone, Debug, PartialEq)]
402struct SingleRunResult {
403    steps: u64,
404    violations: Vec<InvariantViolation>,
405    certificate_hash: u64,
406    trace_fingerprint: u64,
407}
408
409fn violation_category(v: &InvariantViolation) -> String {
410    match v {
411        InvariantViolation::ObligationLeak { .. } => "obligation_leak".to_string(),
412        InvariantViolation::TaskLeak { .. } => "task_leak".to_string(),
413        InvariantViolation::ActorLeak { .. } => "actor_leak".to_string(),
414        InvariantViolation::QuiescenceViolation => "quiescence_violation".to_string(),
415        InvariantViolation::Futurelock { .. } => "futurelock".to_string(),
416    }
417}
418
419fn sorted_violation_categories(violations: &[InvariantViolation]) -> Vec<String> {
420    let mut categories: Vec<String> = violations.iter().map(violation_category).collect();
421    categories.sort_unstable();
422    categories.dedup();
423    categories
424}
425
426/// Convenience function: run a quick fuzz campaign with default settings.
427pub fn fuzz_quick<F>(seed: u64, iterations: usize, test: F) -> FuzzReport
428where
429    F: Fn(&mut LabRuntime),
430{
431    let harness = FuzzHarness::new(FuzzConfig::new(seed, iterations));
432    harness.run(test)
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::types::Budget;
439
440    #[test]
441    fn fuzz_no_violations_with_simple_task() {
442        let report = fuzz_quick(42, 10, |runtime| {
443            let region = runtime.state.create_root_region(Budget::INFINITE);
444            let (t, _) = runtime
445                .state
446                .create_task(region, Budget::INFINITE, async { 1 })
447                .expect("t");
448            runtime.scheduler.lock().schedule(t, 0);
449            runtime.run_until_quiescent();
450        });
451
452        assert!(!report.has_findings());
453        assert_eq!(report.iterations, 10);
454        assert!(report.unique_certificates >= 1);
455    }
456
457    #[test]
458    fn fuzz_config_builder() {
459        let config = FuzzConfig::new(0, 100)
460            .worker_count(4)
461            .max_steps(5000)
462            .minimize(false);
463        assert_eq!(config.worker_count, 4);
464        assert_eq!(config.max_steps, 5000);
465        assert!(!config.minimize);
466    }
467
468    #[test]
469    fn fuzz_two_tasks_no_violations() {
470        let report = fuzz_quick(0, 20, |runtime| {
471            let region = runtime.state.create_root_region(Budget::INFINITE);
472            let (t1, _) = runtime
473                .state
474                .create_task(region, Budget::INFINITE, async {})
475                .expect("t1");
476            let (t2, _) = runtime
477                .state
478                .create_task(region, Budget::INFINITE, async {})
479                .expect("t2");
480            {
481                let mut sched = runtime.scheduler.lock();
482                sched.schedule(t1, 0);
483                sched.schedule(t2, 0);
484            }
485            runtime.run_until_quiescent();
486        });
487
488        assert!(!report.has_findings());
489    }
490
491    #[test]
492    fn fuzz_report_seed_accessors() {
493        let report = FuzzReport {
494            iterations: 5,
495            findings: vec![FuzzFinding {
496                seed: 42,
497                steps: 10,
498                violations: vec![],
499                certificate_hash: 123,
500                trace_fingerprint: 456,
501                minimized_seed: Some(3),
502            }],
503            violation_counts: BTreeMap::new(),
504            unique_certificates: 1,
505        };
506
507        assert_eq!(report.finding_seeds(), vec![42]);
508        assert_eq!(report.minimized_seeds(), vec![3]);
509        assert!(report.has_findings());
510    }
511
512    #[test]
513    fn fuzz_deterministic_same_seed_same_result() {
514        let run = |seed: u64| -> usize {
515            let report = fuzz_quick(seed, 5, |runtime| {
516                let region = runtime.state.create_root_region(Budget::INFINITE);
517                let (t, _) = runtime
518                    .state
519                    .create_task(region, Budget::INFINITE, async { 42 })
520                    .expect("t");
521                runtime.scheduler.lock().schedule(t, 0);
522                runtime.run_until_quiescent();
523            });
524            report.unique_certificates
525        };
526
527        let r1 = run(77);
528        let r2 = run(77);
529        assert_eq!(r1, r2);
530    }
531
532    // =========================================================================
533    // Wave 46 – pure data-type trait coverage
534    // =========================================================================
535
536    #[test]
537    fn fuzz_config_debug_clone_defaults() {
538        let cfg = FuzzConfig::new(42, 100);
539        let dbg = format!("{cfg:?}");
540        assert!(dbg.contains("FuzzConfig"), "{dbg}");
541        assert_eq!(cfg.base_seed, 42);
542        assert_eq!(cfg.iterations, 100);
543        assert_eq!(cfg.max_steps, 100_000);
544        assert_eq!(cfg.worker_count, 1);
545        assert!(cfg.minimize);
546        assert_eq!(cfg.minimize_attempts, 96);
547        let cloned = cfg.clone();
548        assert_eq!(cloned.base_seed, cfg.base_seed);
549        assert_eq!(cloned.iterations, cfg.iterations);
550    }
551
552    #[test]
553    fn fuzz_finding_debug_clone() {
554        let finding = FuzzFinding {
555            seed: 99,
556            steps: 500,
557            violations: vec![],
558            certificate_hash: 12345,
559            trace_fingerprint: 67890,
560            minimized_seed: Some(7),
561        };
562        let dbg = format!("{finding:?}");
563        assert!(dbg.contains("FuzzFinding"), "{dbg}");
564        let cloned = finding;
565        assert_eq!(cloned.seed, 99);
566        assert_eq!(cloned.steps, 500);
567        assert_eq!(cloned.certificate_hash, 12345);
568        assert_eq!(cloned.trace_fingerprint, 67890);
569        assert_eq!(cloned.minimized_seed, Some(7));
570    }
571
572    #[test]
573    fn fuzz_report_debug_empty() {
574        let report = FuzzReport {
575            iterations: 0,
576            findings: vec![],
577            violation_counts: BTreeMap::new(),
578            unique_certificates: 0,
579        };
580        let dbg = format!("{report:?}");
581        assert!(dbg.contains("FuzzReport"), "{dbg}");
582        assert!(!report.has_findings());
583        assert!(report.finding_seeds().is_empty());
584        assert!(report.minimized_seeds().is_empty());
585    }
586
587    #[test]
588    fn regression_corpus_is_sorted_and_minimized() {
589        let report = FuzzReport {
590            iterations: 3,
591            findings: vec![
592                FuzzFinding {
593                    seed: 44,
594                    steps: 100,
595                    violations: vec![
596                        InvariantViolation::QuiescenceViolation,
597                        InvariantViolation::QuiescenceViolation,
598                    ],
599                    certificate_hash: 0xB,
600                    trace_fingerprint: 0xBB,
601                    minimized_seed: Some(3),
602                },
603                FuzzFinding {
604                    seed: 13,
605                    steps: 200,
606                    violations: vec![InvariantViolation::Futurelock {
607                        task: crate::types::TaskId::new_for_test(1, 0),
608                        region: crate::types::RegionId::new_for_test(1, 0),
609                        idle_steps: 1,
610                        held: Vec::new(),
611                    }],
612                    certificate_hash: 0xA,
613                    trace_fingerprint: 0xAA,
614                    minimized_seed: None,
615                },
616            ],
617            violation_counts: BTreeMap::new(),
618            unique_certificates: 2,
619        };
620
621        let corpus = report.to_regression_corpus(1234);
622        assert_eq!(corpus.schema_version, 1);
623        assert_eq!(corpus.base_seed, 1234);
624        assert_eq!(corpus.iterations, 3);
625        assert_eq!(corpus.cases.len(), 2);
626
627        // Sorted by replay_seed then deterministic tie-breakers.
628        assert_eq!(corpus.cases[0].seed, 44);
629        assert_eq!(corpus.cases[0].replay_seed, 3);
630        assert_eq!(
631            corpus.cases[0].violation_categories,
632            vec!["quiescence_violation"]
633        );
634
635        assert_eq!(corpus.cases[1].seed, 13);
636        assert_eq!(corpus.cases[1].replay_seed, 13);
637        assert_eq!(corpus.cases[1].violation_categories, vec!["futurelock"]);
638    }
639
640    #[test]
641    fn regression_corpus_replay_seeds_preserve_violation_categories() {
642        let config = FuzzConfig::new(0x6C6F_7265_6D71_6505, 4)
643            .worker_count(2)
644            .max_steps(256)
645            .minimize(true);
646        let harness = FuzzHarness::new(config.clone());
647
648        let scenario = |runtime: &mut LabRuntime| {
649            let root = runtime.state.create_root_region(Budget::INFINITE);
650            for _ in 0..3 {
651                let (task_id, _) = runtime
652                    .state
653                    .create_task(root, Budget::INFINITE, async {})
654                    .expect("create scheduled task");
655                runtime.scheduler.lock().schedule(task_id, 0);
656            }
657            let _unscheduled = runtime
658                .state
659                .create_task(root, Budget::INFINITE, async {})
660                .expect("create unscheduled task");
661            runtime.run_until_quiescent();
662        };
663
664        let report = harness.run(scenario);
665        assert!(report.has_findings(), "expected minimized fuzz findings");
666        let corpus = report.to_regression_corpus(config.base_seed);
667        assert!(
668            !corpus.cases.is_empty(),
669            "regression corpus must include failing replay seeds"
670        );
671
672        for case in &corpus.cases {
673            let first_replay = harness.run_single(case.replay_seed, &scenario);
674            assert!(
675                !first_replay.violations.is_empty(),
676                "replay seed {} should still violate an invariant",
677                case.replay_seed
678            );
679            let replay_categories = sorted_violation_categories(&first_replay.violations);
680            assert_eq!(
681                replay_categories, case.violation_categories,
682                "replay seed {} changed violation categories",
683                case.replay_seed
684            );
685
686            // Deterministic replay seeds must produce stable certificates and traces.
687            let second_replay = harness.run_single(case.replay_seed, &scenario);
688            assert_eq!(
689                first_replay.certificate_hash,
690                second_replay.certificate_hash
691            );
692            assert_eq!(
693                first_replay.trace_fingerprint,
694                second_replay.trace_fingerprint
695            );
696        }
697    }
698
699    #[test]
700    fn minimize_seed_requires_full_violation_category_match() {
701        let harness = FuzzHarness::new(FuzzConfig::new(20, 1));
702        let scenario = |runtime: &mut LabRuntime| {
703            let seed = runtime.config().seed;
704            let region = runtime.state.create_root_region(Budget::INFINITE);
705
706            // Always leave one task unscheduled so every failing seed reports task_leak.
707            let _leaked = runtime
708                .state
709                .create_task(region, Budget::INFINITE, async {})
710                .expect("create leaked task");
711
712            // Only seeds >= 20 also force-close the region while the leaked
713            // task is still live, adding quiescence_violation to the baseline
714            // task_leak category.
715            if seed >= 20 {
716                runtime
717                    .state
718                    .region(region)
719                    .expect("region exists")
720                    .set_state(crate::record::region::RegionState::Closed);
721            }
722        };
723
724        let original = harness.run_single(20, &scenario);
725        assert_eq!(
726            sorted_violation_categories(&original.violations),
727            vec!["quiescence_violation", "task_leak"]
728        );
729
730        let smaller = harness.run_single(19, &scenario);
731        assert_eq!(
732            sorted_violation_categories(&smaller.violations),
733            vec!["task_leak"]
734        );
735
736        let minimized = harness.minimize_seed(20, &scenario);
737        assert_eq!(
738            minimized, None,
739            "smaller seeds do not preserve the original full violation category set"
740        );
741    }
742
743    #[test]
744    fn fuzz_report_promotes_findings_into_replayable_scenarios() {
745        let report = FuzzReport {
746            iterations: 1,
747            findings: vec![FuzzFinding {
748                seed: 0xABCD,
749                steps: 10,
750                violations: vec![InvariantViolation::TaskLeak { count: 1 }],
751                certificate_hash: 0x101,
752                trace_fingerprint: 0x202,
753                minimized_seed: Some(0x55),
754            }],
755            violation_counts: BTreeMap::from([("task_leak".to_string(), 1)]),
756            unique_certificates: 1,
757        };
758
759        let promoted = report.to_promoted_findings("scheduler.surface", "v1");
760        assert_eq!(promoted.len(), 1);
761        assert_eq!(promoted[0].original_seed, 0xABCD);
762        assert_eq!(promoted[0].replay_seed, 0x55);
763        assert_eq!(promoted[0].trace_fingerprint, 0x202);
764        assert_eq!(promoted[0].violation_categories, vec!["task_leak"]);
765    }
766
767    #[test]
768    fn regression_corpus_promotes_cases_with_campaign_lineage() {
769        let corpus = FuzzRegressionCorpus {
770            schema_version: 1,
771            base_seed: 0xCAFE,
772            iterations: 2,
773            cases: vec![FuzzRegressionCase {
774                seed: 0x10,
775                replay_seed: 0x08,
776                certificate_hash: 0x111,
777                trace_fingerprint: 0x222,
778                violation_categories: vec!["task_leak".to_string()],
779            }],
780        };
781
782        let promoted = corpus.to_promoted_scenarios("scheduler.surface", "v1");
783        assert_eq!(promoted.len(), 1);
784        assert_eq!(promoted[0].campaign_base_seed, Some(0xCAFE));
785        assert_eq!(promoted[0].campaign_iteration, Some(0));
786        assert_eq!(promoted[0].original_seed, 0x10);
787        assert_eq!(promoted[0].replay_seed, 0x08);
788        assert_eq!(
789            promoted[0].violation_categories,
790            vec!["task_leak".to_string()]
791        );
792    }
793}