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    /// Deterministic entropy seed reused across iterations.
18    ///
19    /// This is intentionally decoupled from the per-iteration schedule seed so
20    /// a fuzz campaign can vary scheduler decisions without also mutating any
21    /// entropy-driven behavior inside the lab runtime.
22    pub entropy_seed: u64,
23    /// Number of fuzz iterations.
24    pub iterations: usize,
25    /// Maximum steps per iteration before timeout.
26    pub max_steps: u64,
27    /// Number of simulated workers.
28    pub worker_count: usize,
29    /// Enable seed minimization when a violation is found.
30    pub minimize: bool,
31    /// Maximum minimization attempts per violation.
32    pub minimize_attempts: usize,
33}
34
35impl FuzzConfig {
36    /// Create a new fuzz configuration with the given seed and iteration count.
37    #[must_use]
38    pub fn new(base_seed: u64, iterations: usize) -> Self {
39        Self {
40            base_seed,
41            entropy_seed: base_seed,
42            iterations,
43            max_steps: 100_000,
44            worker_count: 1,
45            minimize: true,
46            minimize_attempts: 96,
47        }
48    }
49
50    /// Set the simulated worker count.
51    #[must_use]
52    pub fn worker_count(mut self, count: usize) -> Self {
53        self.worker_count = count;
54        self
55    }
56
57    /// Set the deterministic entropy seed reused across iterations.
58    #[must_use]
59    pub fn entropy_seed(mut self, seed: u64) -> Self {
60        self.entropy_seed = seed;
61        self
62    }
63
64    /// Set the maximum step count per iteration.
65    #[must_use]
66    pub fn max_steps(mut self, max: u64) -> Self {
67        self.max_steps = max;
68        self
69    }
70
71    /// Enable or disable seed minimization.
72    #[must_use]
73    pub fn minimize(mut self, enabled: bool) -> Self {
74        self.minimize = enabled;
75        self
76    }
77}
78
79/// A fuzz finding: a seed that triggers an invariant violation.
80#[derive(Debug, Clone)]
81pub struct FuzzFinding {
82    /// The seed that triggered the violation.
83    pub seed: u64,
84    /// Deterministic entropy seed used for the failing replay run.
85    pub entropy_seed: u64,
86    /// Steps taken by the replay seed that this finding describes.
87    ///
88    /// When minimization succeeds this corresponds to the minimized replay
89    /// seed, not the original campaign seed.
90    pub steps: u64,
91    /// The violation details for the replay seed that this finding describes.
92    ///
93    /// When minimization succeeds these violations come from the minimized
94    /// replay seed so they stay consistent with the stored certificate hash
95    /// and trace fingerprint.
96    pub violations: Vec<InvariantViolation>,
97    /// Certificate hash for the replay seed's schedule.
98    pub certificate_hash: u64,
99    /// Canonical normalized trace fingerprint for the replay seed's failing run.
100    pub trace_fingerprint: u64,
101    /// Minimized seed (if minimization succeeded).
102    pub minimized_seed: Option<u64>,
103}
104
105/// Results of a fuzz campaign.
106#[derive(Debug)]
107pub struct FuzzReport {
108    /// Total iterations run.
109    pub iterations: usize,
110    /// Deterministic entropy seed reused across the campaign.
111    pub entropy_seed: u64,
112    /// Findings (seeds that triggered violations).
113    pub findings: Vec<FuzzFinding>,
114    /// Violation counts by category.
115    pub violation_counts: BTreeMap<String, usize>,
116    /// Certificate hashes seen (for determinism verification).
117    pub unique_certificates: usize,
118}
119
120/// Deterministic corpus entry for a minimized failing fuzz run.
121#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
122pub struct FuzzRegressionCase {
123    /// Seed that produced the original failure.
124    pub seed: u64,
125    /// Replay seed to use for regression checks (minimized when available).
126    pub replay_seed: u64,
127    /// Deterministic entropy seed required to replay this case faithfully.
128    pub entropy_seed: u64,
129    /// Scheduler certificate hash from the failing run.
130    pub certificate_hash: u64,
131    /// Canonical normalized trace fingerprint for the failing run.
132    pub trace_fingerprint: u64,
133    /// Stable violation categories observed for this case.
134    pub violation_categories: Vec<String>,
135}
136
137/// Deterministic regression corpus produced by a fuzz campaign.
138#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
139pub struct FuzzRegressionCorpus {
140    /// Schema version for compatibility and migration.
141    pub schema_version: u32,
142    /// Base seed used for this fuzz campaign.
143    pub base_seed: u64,
144    /// Deterministic entropy seed reused across this fuzz campaign.
145    pub entropy_seed: u64,
146    /// Number of iterations executed by the campaign.
147    pub iterations: usize,
148    /// Cases sorted in deterministic replay order.
149    pub cases: Vec<FuzzRegressionCase>,
150}
151
152impl FuzzFinding {
153    /// Promote this fuzz finding into a replayable dual-run scenario.
154    #[must_use]
155    pub fn to_promoted_scenario(
156        &self,
157        surface_id: &str,
158        contract_version: &str,
159    ) -> crate::lab::dual_run::PromotedFuzzScenario {
160        crate::lab::dual_run::promote_fuzz_finding(self, surface_id, contract_version)
161    }
162}
163
164impl FuzzRegressionCase {
165    /// Promote this deterministic regression case into a replayable scenario.
166    #[must_use]
167    pub fn to_promoted_scenario(
168        &self,
169        surface_id: &str,
170        contract_version: &str,
171    ) -> crate::lab::dual_run::PromotedFuzzScenario {
172        crate::lab::dual_run::promote_regression_case(self, surface_id, contract_version)
173    }
174}
175
176impl FuzzRegressionCorpus {
177    /// Promote this deterministic regression corpus into replayable scenarios.
178    #[must_use]
179    pub fn to_promoted_scenarios(
180        &self,
181        surface_id: &str,
182        contract_version: &str,
183    ) -> Vec<crate::lab::dual_run::PromotedFuzzScenario> {
184        crate::lab::dual_run::promote_regression_corpus(self, surface_id, contract_version)
185    }
186}
187
188impl FuzzReport {
189    /// True if any violations were found.
190    #[must_use]
191    pub fn has_findings(&self) -> bool {
192        !self.findings.is_empty()
193    }
194
195    /// Seeds that triggered violations.
196    #[must_use]
197    pub fn finding_seeds(&self) -> Vec<u64> {
198        self.findings.iter().map(|f| f.seed).collect()
199    }
200
201    /// Minimized seeds (where minimization succeeded).
202    #[must_use]
203    pub fn minimized_seeds(&self) -> Vec<u64> {
204        self.findings
205            .iter()
206            .filter_map(|f| f.minimized_seed)
207            .collect()
208    }
209
210    /// Build a deterministic minimized-failure replay corpus.
211    ///
212    /// Cases are sorted by replay seed and stable fingerprints so CI can diff
213    /// corpus snapshots reproducibly.
214    #[must_use]
215    pub fn to_regression_corpus(&self, base_seed: u64) -> FuzzRegressionCorpus {
216        let mut cases: Vec<FuzzRegressionCase> = self
217            .findings
218            .iter()
219            .map(|finding| {
220                let replay_seed = finding.minimized_seed.unwrap_or(finding.seed);
221                FuzzRegressionCase {
222                    seed: finding.seed,
223                    replay_seed,
224                    entropy_seed: finding.entropy_seed,
225                    certificate_hash: finding.certificate_hash,
226                    trace_fingerprint: finding.trace_fingerprint,
227                    violation_categories: sorted_violation_categories(&finding.violations),
228                }
229            })
230            .collect();
231
232        cases.sort_by_key(|case| {
233            (
234                case.replay_seed,
235                case.seed,
236                case.trace_fingerprint,
237                case.certificate_hash,
238            )
239        });
240
241        FuzzRegressionCorpus {
242            schema_version: 1,
243            base_seed,
244            entropy_seed: self.entropy_seed,
245            iterations: self.iterations,
246            cases,
247        }
248    }
249
250    /// Promote every raw fuzz finding into replayable dual-run scenarios.
251    #[must_use]
252    pub fn to_promoted_findings(
253        &self,
254        surface_id: &str,
255        contract_version: &str,
256    ) -> Vec<crate::lab::dual_run::PromotedFuzzScenario> {
257        self.findings
258            .iter()
259            .map(|finding| finding.to_promoted_scenario(surface_id, contract_version))
260            .collect()
261    }
262
263    /// Build a deterministic regression corpus and promote it into scenarios.
264    ///
265    /// This is the main fuzz-to-scenario bridge used by higher-level replay
266    /// and differential suites.
267    #[must_use]
268    pub fn to_promoted_regression_scenarios(
269        &self,
270        base_seed: u64,
271        surface_id: &str,
272        contract_version: &str,
273    ) -> Vec<crate::lab::dual_run::PromotedFuzzScenario> {
274        self.to_regression_corpus(base_seed)
275            .to_promoted_scenarios(surface_id, contract_version)
276    }
277}
278
279/// Deterministic fuzz harness.
280///
281/// Runs a test closure under many deterministic seeds, checking invariant
282/// oracles after each run. When a violation is found, the harness optionally
283/// minimizes the seed to find a simpler reproducer.
284pub struct FuzzHarness {
285    config: FuzzConfig,
286}
287
288impl FuzzHarness {
289    /// Create a fuzz harness for the provided configuration.
290    #[must_use]
291    pub fn new(config: FuzzConfig) -> Self {
292        Self { config }
293    }
294
295    /// Run the fuzz campaign.
296    ///
297    /// The `test` closure receives a `LabRuntime` and should set up tasks,
298    /// schedule them, and run to quiescence.
299    pub fn run<F>(&self, test: F) -> FuzzReport
300    where
301        F: Fn(&mut LabRuntime),
302    {
303        let mut findings = Vec::new();
304        let mut violation_counts: BTreeMap<String, usize> = BTreeMap::new();
305        let mut certificate_hashes = std::collections::BTreeSet::new();
306
307        for i in 0..self.config.iterations {
308            let seed = self.config.base_seed.wrapping_add(i as u64);
309            let result = self.run_single(seed, &test);
310
311            certificate_hashes.insert(result.certificate_hash);
312
313            if !result.violations.is_empty() {
314                for v in &result.violations {
315                    let key = violation_category(v);
316                    *violation_counts.entry(key).or_insert(0) += 1;
317                }
318
319                let minimized = if self.config.minimize {
320                    self.minimize_seed(seed, &test)
321                } else {
322                    None
323                };
324
325                let (minimized_seed, steps, violations, certificate_hash, trace_fingerprint) =
326                    match minimized {
327                        Some((min_seed, ref min_res)) => (
328                            Some(min_seed),
329                            min_res.steps,
330                            min_res.violations.clone(),
331                            min_res.certificate_hash,
332                            min_res.trace_fingerprint,
333                        ),
334                        None => (
335                            None,
336                            result.steps,
337                            result.violations.clone(),
338                            result.certificate_hash,
339                            result.trace_fingerprint,
340                        ),
341                    };
342
343                findings.push(FuzzFinding {
344                    seed,
345                    entropy_seed: self.config.entropy_seed,
346                    steps,
347                    violations,
348                    certificate_hash,
349                    trace_fingerprint,
350                    minimized_seed,
351                });
352            }
353        }
354
355        FuzzReport {
356            iterations: self.config.iterations,
357            entropy_seed: self.config.entropy_seed,
358            findings,
359            violation_counts,
360            unique_certificates: certificate_hashes.len(),
361        }
362    }
363
364    fn run_single<F>(&self, seed: u64, test: &F) -> SingleRunResult
365    where
366        F: Fn(&mut LabRuntime),
367    {
368        let mut lab_config = LabConfig::new(seed);
369        lab_config = lab_config.worker_count(self.config.worker_count);
370        lab_config = lab_config.entropy_seed(self.config.entropy_seed);
371        lab_config = lab_config.max_steps(self.config.max_steps);
372
373        let mut runtime = LabRuntime::new(lab_config);
374
375        // br-asupersync-ipejce: catch panics from the test closure
376        // and convert them into a recorded TestPanic finding so the
377        // campaign keeps searching. Without this, the first panic
378        // (the most interesting outcome of any fuzz campaign)
379        // aborts the whole search budget and attributes the crash
380        // to the harness rather than the asupersync invariant
381        // violation it actually represents.
382        let panic_message = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
383            test(&mut runtime);
384        }))
385        .err()
386        .map(|payload| {
387            // Extract the canonical &str / String panic payload.
388            if let Some(s) = payload.downcast_ref::<&'static str>() {
389                (*s).to_string()
390            } else if let Some(s) = payload.downcast_ref::<String>() {
391                s.clone()
392            } else {
393                "<unknown panic payload>".to_string()
394            }
395        });
396
397        let steps = runtime.steps();
398        let certificate_hash = runtime.certificate().hash();
399        let trace_events = runtime.trace().snapshot();
400        let normalized = normalize_for_replay(&trace_events);
401        let trace_fingerprint =
402            crate::trace::canonicalize::trace_fingerprint(&normalized.normalized);
403        let mut violations = runtime.check_invariants();
404        if let Some(message) = panic_message {
405            violations.push(InvariantViolation::TestPanic { message });
406        }
407
408        SingleRunResult {
409            steps,
410            violations,
411            certificate_hash,
412            trace_fingerprint,
413        }
414    }
415
416    /// Attempt to minimize a failing seed.
417    ///
418    /// Tries nearby seeds (bit-flips and offsets) to find the smallest
419    /// seed that still reproduces the same violation-category set.
420    fn minimize_seed<F>(&self, original_seed: u64, test: &F) -> Option<(u64, SingleRunResult)>
421    where
422        F: Fn(&mut LabRuntime),
423    {
424        let original_result = self.run_single(original_seed, test);
425        if original_result.violations.is_empty() {
426            return None;
427        }
428        let target_categories = sorted_violation_categories(&original_result.violations);
429
430        let mut best_seed = original_seed;
431        let mut best_result = None;
432
433        // Try smaller seeds first (simple reduction).
434        for attempt in 0..self.config.minimize_attempts {
435            let candidate = match attempt {
436                // Try absolute small seeds first.
437                0..=15 => attempt as u64,
438                // Try seeds near the original.
439                16..=31 => original_seed.wrapping_sub((attempt - 15) as u64),
440                // Try bit-flipped variants.
441                _ => original_seed ^ (1u64 << ((attempt - 32) % 64)),
442            };
443
444            if candidate == original_seed {
445                continue;
446            }
447
448            let result = self.run_single(candidate, test);
449            if result.violations.is_empty() {
450                continue;
451            }
452
453            let categories = sorted_violation_categories(&result.violations);
454            if categories == target_categories && candidate < best_seed {
455                best_seed = candidate;
456                best_result = Some(result);
457            }
458        }
459
460        if best_seed == original_seed {
461            None
462        } else {
463            Some((
464                best_seed,
465                best_result.expect("best result should exist when updating best_seed"),
466            ))
467        }
468    }
469}
470
471#[derive(Clone, Debug, PartialEq)]
472struct SingleRunResult {
473    steps: u64,
474    violations: Vec<InvariantViolation>,
475    certificate_hash: u64,
476    trace_fingerprint: u64,
477}
478
479fn violation_category(v: &InvariantViolation) -> String {
480    match v {
481        InvariantViolation::ObligationLeak { .. } => "obligation_leak".to_string(),
482        InvariantViolation::TaskLeak { .. } => "task_leak".to_string(),
483        InvariantViolation::ActorLeak { .. } => "actor_leak".to_string(),
484        InvariantViolation::QuiescenceViolation => "quiescence_violation".to_string(),
485        InvariantViolation::Futurelock { .. } => "futurelock".to_string(),
486        InvariantViolation::CancellationProtocol { .. } => "cancellation_protocol".to_string(),
487        InvariantViolation::TestPanic { .. } => "test_panic".to_string(),
488    }
489}
490
491fn sorted_violation_categories(violations: &[InvariantViolation]) -> Vec<String> {
492    let mut categories: Vec<String> = violations.iter().map(violation_category).collect();
493    categories.sort_unstable();
494    categories.dedup();
495    categories
496}
497
498/// Convenience function: run a quick fuzz campaign with default settings.
499pub fn fuzz_quick<F>(seed: u64, iterations: usize, test: F) -> FuzzReport
500where
501    F: Fn(&mut LabRuntime),
502{
503    let harness = FuzzHarness::new(FuzzConfig::new(seed, iterations));
504    harness.run(test)
505}
506
507#[cfg(test)]
508mod tests {
509    #![allow(
510        clippy::pedantic,
511        clippy::nursery,
512        clippy::expect_fun_call,
513        clippy::map_unwrap_or,
514        clippy::cast_possible_wrap,
515        clippy::future_not_send
516    )]
517    use super::*;
518    use crate::types::Budget;
519
520    #[test]
521    fn fuzz_no_violations_with_simple_task() {
522        let report = fuzz_quick(42, 10, |runtime| {
523            let region = runtime.state.create_root_region(Budget::INFINITE);
524            let (t, _) = runtime
525                .state
526                .create_task(region, Budget::INFINITE, async { 1 })
527                .expect("t");
528            runtime.scheduler.lock().schedule(t, 0);
529            runtime.run_until_quiescent();
530        });
531
532        assert!(!report.has_findings());
533        assert_eq!(report.iterations, 10);
534        assert!(report.unique_certificates >= 1);
535    }
536
537    #[test]
538    fn fuzz_config_builder() {
539        let config = FuzzConfig::new(0, 100)
540            .worker_count(4)
541            .max_steps(5000)
542            .minimize(false);
543        assert_eq!(config.worker_count, 4);
544        assert_eq!(config.max_steps, 5000);
545        assert!(!config.minimize);
546    }
547
548    #[test]
549    fn fuzz_two_tasks_no_violations() {
550        let report = fuzz_quick(0, 20, |runtime| {
551            let region = runtime.state.create_root_region(Budget::INFINITE);
552            let (t1, _) = runtime
553                .state
554                .create_task(region, Budget::INFINITE, async {})
555                .expect("t1");
556            let (t2, _) = runtime
557                .state
558                .create_task(region, Budget::INFINITE, async {})
559                .expect("t2");
560            {
561                let mut sched = runtime.scheduler.lock();
562                sched.schedule(t1, 0);
563                sched.schedule(t2, 0);
564            }
565            runtime.run_until_quiescent();
566        });
567
568        assert!(!report.has_findings());
569    }
570
571    #[test]
572    fn fuzz_report_seed_accessors() {
573        let report = FuzzReport {
574            iterations: 5,
575            entropy_seed: 99,
576            findings: vec![FuzzFinding {
577                seed: 42,
578                entropy_seed: 99,
579                steps: 10,
580                violations: vec![],
581                certificate_hash: 123,
582                trace_fingerprint: 456,
583                minimized_seed: Some(3),
584            }],
585            violation_counts: BTreeMap::new(),
586            unique_certificates: 1,
587        };
588
589        assert_eq!(report.finding_seeds(), vec![42]);
590        assert_eq!(report.minimized_seeds(), vec![3]);
591        assert!(report.has_findings());
592    }
593
594    #[test]
595    fn fuzz_deterministic_same_seed_same_result() {
596        let run = |seed: u64| -> usize {
597            let report = fuzz_quick(seed, 5, |runtime| {
598                let region = runtime.state.create_root_region(Budget::INFINITE);
599                let (t, _) = runtime
600                    .state
601                    .create_task(region, Budget::INFINITE, async { 42 })
602                    .expect("t");
603                runtime.scheduler.lock().schedule(t, 0);
604                runtime.run_until_quiescent();
605            });
606            report.unique_certificates
607        };
608
609        let r1 = run(77);
610        let r2 = run(77);
611        assert_eq!(r1, r2);
612    }
613
614    // =========================================================================
615    // Wave 46 – pure data-type trait coverage
616    // =========================================================================
617
618    #[test]
619    fn fuzz_config_debug_clone_defaults() {
620        let cfg = FuzzConfig::new(42, 100);
621        let dbg = format!("{cfg:?}");
622        assert!(dbg.contains("FuzzConfig"), "{dbg}");
623        assert_eq!(cfg.base_seed, 42);
624        assert_eq!(cfg.entropy_seed, 42);
625        assert_eq!(cfg.iterations, 100);
626        assert_eq!(cfg.max_steps, 100_000);
627        assert_eq!(cfg.worker_count, 1);
628        assert!(cfg.minimize);
629        assert_eq!(cfg.minimize_attempts, 96);
630        let cloned = cfg.clone();
631        assert_eq!(cloned.base_seed, cfg.base_seed);
632        assert_eq!(cloned.iterations, cfg.iterations);
633    }
634
635    #[test]
636    fn fuzz_finding_debug_clone() {
637        let finding = FuzzFinding {
638            seed: 99,
639            entropy_seed: 7,
640            steps: 500,
641            violations: vec![],
642            certificate_hash: 12345,
643            trace_fingerprint: 67890,
644            minimized_seed: Some(7),
645        };
646        let dbg = format!("{finding:?}");
647        assert!(dbg.contains("FuzzFinding"), "{dbg}");
648        let cloned = finding;
649        assert_eq!(cloned.seed, 99);
650        assert_eq!(cloned.entropy_seed, 7);
651        assert_eq!(cloned.steps, 500);
652        assert_eq!(cloned.certificate_hash, 12345);
653        assert_eq!(cloned.trace_fingerprint, 67890);
654        assert_eq!(cloned.minimized_seed, Some(7));
655    }
656
657    #[test]
658    fn fuzz_harness_keeps_entropy_seed_stable_across_iterations() {
659        let observed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
660        let captured = std::sync::Arc::clone(&observed);
661        let harness = FuzzHarness::new(FuzzConfig::new(7, 3));
662
663        harness.run(move |runtime| {
664            captured
665                .lock()
666                .expect("lock observed seeds")
667                .push((runtime.config().seed, runtime.config().entropy_seed));
668        });
669
670        let observed = observed.lock().expect("lock observed seeds");
671        assert_eq!(
672            observed.as_slice(),
673            &[(7, 7), (8, 7), (9, 7)],
674            "campaign iterations must vary schedule seed without mutating entropy seed"
675        );
676    }
677
678    #[test]
679    fn fuzz_report_debug_empty() {
680        let report = FuzzReport {
681            iterations: 0,
682            entropy_seed: 55,
683            findings: vec![],
684            violation_counts: BTreeMap::new(),
685            unique_certificates: 0,
686        };
687        let dbg = format!("{report:?}");
688        assert!(dbg.contains("FuzzReport"), "{dbg}");
689        assert!(!report.has_findings());
690        assert!(report.finding_seeds().is_empty());
691        assert!(report.minimized_seeds().is_empty());
692    }
693
694    #[test]
695    fn regression_corpus_is_sorted_and_minimized() {
696        let report = FuzzReport {
697            iterations: 3,
698            entropy_seed: 0x7777,
699            findings: vec![
700                FuzzFinding {
701                    seed: 44,
702                    entropy_seed: 0x7777,
703                    steps: 100,
704                    violations: vec![
705                        InvariantViolation::QuiescenceViolation,
706                        InvariantViolation::QuiescenceViolation,
707                    ],
708                    certificate_hash: 0xB,
709                    trace_fingerprint: 0xBB,
710                    minimized_seed: Some(3),
711                },
712                FuzzFinding {
713                    seed: 13,
714                    entropy_seed: 0x7777,
715                    steps: 200,
716                    violations: vec![InvariantViolation::Futurelock {
717                        task: crate::types::TaskId::new_for_test(1, 0),
718                        region: crate::types::RegionId::new_for_test(1, 0),
719                        idle_steps: 1,
720                        held: Vec::new(),
721                        last_checkpoint_message: None,
722                    }],
723                    certificate_hash: 0xA,
724                    trace_fingerprint: 0xAA,
725                    minimized_seed: None,
726                },
727            ],
728            violation_counts: BTreeMap::new(),
729            unique_certificates: 2,
730        };
731
732        let corpus = report.to_regression_corpus(1234);
733        assert_eq!(corpus.schema_version, 1);
734        assert_eq!(corpus.base_seed, 1234);
735        assert_eq!(corpus.entropy_seed, 0x7777);
736        assert_eq!(corpus.iterations, 3);
737        assert_eq!(corpus.cases.len(), 2);
738
739        // Sorted by replay_seed then deterministic tie-breakers.
740        assert_eq!(corpus.cases[0].seed, 44);
741        assert_eq!(corpus.cases[0].replay_seed, 3);
742        assert_eq!(
743            corpus.cases[0].violation_categories,
744            vec!["quiescence_violation"]
745        );
746
747        assert_eq!(corpus.cases[1].seed, 13);
748        assert_eq!(corpus.cases[1].replay_seed, 13);
749        assert_eq!(corpus.cases[1].violation_categories, vec!["futurelock"]);
750    }
751
752    #[test]
753    fn regression_corpus_replay_seeds_preserve_violation_categories() {
754        let config = FuzzConfig::new(0x6C6F_7265_6D71_6505, 4)
755            .worker_count(2)
756            .max_steps(256)
757            .minimize(true);
758        let harness = FuzzHarness::new(config.clone());
759
760        let scenario = |runtime: &mut LabRuntime| {
761            let root = runtime.state.create_root_region(Budget::INFINITE);
762            for _ in 0..3 {
763                let (task_id, _) = runtime
764                    .state
765                    .create_task(root, Budget::INFINITE, async {})
766                    .expect("create scheduled task");
767                runtime.scheduler.lock().schedule(task_id, 0);
768            }
769            let _unscheduled = runtime
770                .state
771                .create_task(root, Budget::INFINITE, async {})
772                .expect("create unscheduled task");
773            runtime.run_until_quiescent();
774        };
775
776        let report = harness.run(scenario);
777        assert!(report.has_findings(), "expected minimized fuzz findings");
778        let corpus = report.to_regression_corpus(config.base_seed);
779        assert!(
780            !corpus.cases.is_empty(),
781            "regression corpus must include failing replay seeds"
782        );
783
784        for case in &corpus.cases {
785            let first_replay = harness.run_single(case.replay_seed, &scenario);
786            assert!(
787                !first_replay.violations.is_empty(),
788                "replay seed {} should still violate an invariant",
789                case.replay_seed
790            );
791            let replay_categories = sorted_violation_categories(&first_replay.violations);
792            assert_eq!(
793                replay_categories, case.violation_categories,
794                "replay seed {} changed violation categories",
795                case.replay_seed
796            );
797
798            // Deterministic replay seeds must produce stable certificates and traces.
799            let second_replay = harness.run_single(case.replay_seed, &scenario);
800            assert_eq!(
801                first_replay.certificate_hash,
802                second_replay.certificate_hash
803            );
804            assert_eq!(
805                first_replay.trace_fingerprint,
806                second_replay.trace_fingerprint
807            );
808        }
809    }
810
811    #[test]
812    fn minimize_seed_requires_full_violation_category_match() {
813        let harness = FuzzHarness::new(FuzzConfig::new(20, 1));
814        let scenario = |runtime: &mut LabRuntime| {
815            let seed = runtime.config().seed;
816            let region = runtime.state.create_root_region(Budget::INFINITE);
817
818            // Always leave one task unscheduled so every failing seed reports task_leak.
819            let _leaked = runtime
820                .state
821                .create_task(region, Budget::INFINITE, async {})
822                .expect("create leaked task");
823
824            // Only seeds >= 20 also force-close the region while the leaked
825            // task is still live, adding quiescence_violation to the baseline
826            // task_leak category.
827            if seed >= 20 {
828                runtime
829                    .state
830                    .region(region)
831                    .expect("region exists")
832                    .set_state(crate::record::region::RegionState::Closed);
833            }
834        };
835
836        let original = harness.run_single(20, &scenario);
837        assert_eq!(
838            sorted_violation_categories(&original.violations),
839            vec!["quiescence_violation", "task_leak"]
840        );
841
842        let smaller = harness.run_single(19, &scenario);
843        assert_eq!(
844            sorted_violation_categories(&smaller.violations),
845            vec!["task_leak"]
846        );
847
848        let minimized = harness.minimize_seed(20, &scenario);
849        assert_eq!(
850            minimized, None,
851            "smaller seeds do not preserve the original full violation category set"
852        );
853    }
854
855    #[test]
856    fn fuzz_report_promotes_findings_into_replayable_scenarios() {
857        let report = FuzzReport {
858            iterations: 1,
859            entropy_seed: 0x44,
860            findings: vec![FuzzFinding {
861                seed: 0xABCD,
862                entropy_seed: 0x44,
863                steps: 10,
864                violations: vec![InvariantViolation::TaskLeak { count: 1 }],
865                certificate_hash: 0x101,
866                trace_fingerprint: 0x202,
867                minimized_seed: Some(0x55),
868            }],
869            violation_counts: BTreeMap::from([("task_leak".to_string(), 1)]),
870            unique_certificates: 1,
871        };
872
873        let promoted = report.to_promoted_findings("scheduler.surface", "v1");
874        assert_eq!(promoted.len(), 1);
875        assert_eq!(promoted[0].original_seed, 0xABCD);
876        assert_eq!(promoted[0].replay_seed, 0x55);
877        assert_eq!(promoted[0].trace_fingerprint, 0x202);
878        assert_eq!(promoted[0].violation_categories, vec!["task_leak"]);
879    }
880
881    #[test]
882    fn regression_corpus_promotes_cases_with_campaign_lineage() {
883        let corpus = FuzzRegressionCorpus {
884            schema_version: 1,
885            base_seed: 0xCAFE,
886            entropy_seed: 0x77,
887            iterations: 2,
888            cases: vec![FuzzRegressionCase {
889                seed: 0x10,
890                replay_seed: 0x08,
891                entropy_seed: 0x77,
892                certificate_hash: 0x111,
893                trace_fingerprint: 0x222,
894                violation_categories: vec!["task_leak".to_string()],
895            }],
896        };
897
898        let promoted = corpus.to_promoted_scenarios("scheduler.surface", "v1");
899        assert_eq!(promoted.len(), 1);
900        assert_eq!(promoted[0].campaign_base_seed, Some(0xCAFE));
901        assert_eq!(promoted[0].campaign_iteration, Some(0));
902        assert_eq!(promoted[0].original_seed, 0x10);
903        assert_eq!(promoted[0].replay_seed, 0x08);
904        assert_eq!(
905            promoted[0].identity.seed_plan.entropy_seed_override,
906            Some(0x77)
907        );
908        assert_eq!(
909            promoted[0].violation_categories,
910            vec!["task_leak".to_string()]
911        );
912    }
913
914    #[test]
915    fn minimized_findings_keep_violation_payload_consistent_with_replay_seed() {
916        let harness = FuzzHarness::new(FuzzConfig::new(20, 1));
917        let scenario = |runtime: &mut LabRuntime| {
918            let seed = runtime.config().seed;
919            let region = runtime.state.create_root_region(Budget::INFINITE);
920
921            let leak_count = if seed >= 20 { 2 } else { 1 };
922            for _ in 0..leak_count {
923                let _leaked = runtime
924                    .state
925                    .create_task(region, Budget::INFINITE, async {})
926                    .expect("create leaked task");
927            }
928        };
929
930        let report = harness.run(scenario);
931        let finding = report
932            .findings
933            .first()
934            .expect("campaign should surface a minimized finding");
935        assert_eq!(finding.minimized_seed, Some(0));
936
937        let replay = harness.run_single(0, &scenario);
938        assert_eq!(finding.steps, replay.steps);
939        assert_eq!(finding.violations, replay.violations);
940        assert_eq!(finding.certificate_hash, replay.certificate_hash);
941        assert_eq!(finding.trace_fingerprint, replay.trace_fingerprint);
942        assert_eq!(
943            finding.violations,
944            vec![InvariantViolation::TaskLeak { count: 1 }]
945        );
946    }
947
948    #[test]
949    fn promoted_regression_scenarios_preserve_entropy_seed_override() {
950        let report = FuzzReport {
951            iterations: 1,
952            entropy_seed: 0xBADA,
953            findings: vec![FuzzFinding {
954                seed: 0x20,
955                entropy_seed: 0xBADA,
956                steps: 4,
957                violations: vec![InvariantViolation::TaskLeak { count: 1 }],
958                certificate_hash: 0xAB,
959                trace_fingerprint: 0xCD,
960                minimized_seed: Some(0x02),
961            }],
962            violation_counts: BTreeMap::from([("task_leak".to_string(), 1)]),
963            unique_certificates: 1,
964        };
965
966        let promoted = report.to_promoted_regression_scenarios(0x20, "scheduler.surface", "v1");
967        assert_eq!(promoted.len(), 1);
968        assert_eq!(
969            promoted[0].identity.seed_plan.entropy_seed_override,
970            Some(0xBADA)
971        );
972    }
973
974    #[test]
975    fn ipejce_panicking_test_closure_recorded_as_finding_not_aborting_campaign() {
976        // br-asupersync-ipejce: a fuzz target that panics on input
977        // must show up as a TestPanic finding rather than aborting
978        // the entire campaign. The campaign continues to subsequent
979        // seeds and records each panic separately.
980        let cfg = FuzzConfig::new(0xDEADBEEF, 1).worker_count(1).max_steps(16);
981        let campaign = super::FuzzHarness::new(cfg);
982        let panic_message = "deliberate test failure";
983        let result = campaign.run_single(0xDEADBEEF, &|_runtime: &mut LabRuntime| {
984            panic!("{}", panic_message); // ubs:ignore - test helper
985        });
986        // The panic was caught and recorded; the campaign did NOT
987        // abort.  `result.violations` contains the TestPanic with
988        // the original payload.
989        let saw_panic = result.violations.iter().any(|v| {
990            matches!(
991                v,
992                InvariantViolation::TestPanic { message } if message.contains(panic_message)
993            )
994        });
995        assert!(
996            saw_panic,
997            "TestPanic.message should preserve the panic payload: {:?}",
998            result.violations
999        );
1000    }
1001}