1use crate::lab::config::LabConfig;
8use crate::lab::replay::normalize_for_replay;
9use crate::lab::runtime::{InvariantViolation, LabRuntime};
10use std::collections::BTreeMap;
11
12#[derive(Debug, Clone)]
14pub struct FuzzConfig {
15 pub base_seed: u64,
17 pub iterations: usize,
19 pub max_steps: u64,
21 pub worker_count: usize,
23 pub minimize: bool,
25 pub minimize_attempts: usize,
27}
28
29impl FuzzConfig {
30 #[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 #[must_use]
45 pub fn worker_count(mut self, count: usize) -> Self {
46 self.worker_count = count;
47 self
48 }
49
50 #[must_use]
52 pub fn max_steps(mut self, max: u64) -> Self {
53 self.max_steps = max;
54 self
55 }
56
57 #[must_use]
59 pub fn minimize(mut self, enabled: bool) -> Self {
60 self.minimize = enabled;
61 self
62 }
63}
64
65#[derive(Debug, Clone)]
67pub struct FuzzFinding {
68 pub seed: u64,
70 pub steps: u64,
72 pub violations: Vec<InvariantViolation>,
74 pub certificate_hash: u64,
76 pub trace_fingerprint: u64,
78 pub minimized_seed: Option<u64>,
80}
81
82#[derive(Debug)]
84pub struct FuzzReport {
85 pub iterations: usize,
87 pub findings: Vec<FuzzFinding>,
89 pub violation_counts: BTreeMap<String, usize>,
91 pub unique_certificates: usize,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
97pub struct FuzzRegressionCase {
98 pub seed: u64,
100 pub replay_seed: u64,
102 pub certificate_hash: u64,
104 pub trace_fingerprint: u64,
106 pub violation_categories: Vec<String>,
108}
109
110#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
112pub struct FuzzRegressionCorpus {
113 pub schema_version: u32,
115 pub base_seed: u64,
117 pub iterations: usize,
119 pub cases: Vec<FuzzRegressionCase>,
121}
122
123impl FuzzFinding {
124 #[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 #[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 #[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 #[must_use]
162 pub fn has_findings(&self) -> bool {
163 !self.findings.is_empty()
164 }
165
166 #[must_use]
168 pub fn finding_seeds(&self) -> Vec<u64> {
169 self.findings.iter().map(|f| f.seed).collect()
170 }
171
172 #[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 #[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 #[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 #[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
248pub struct FuzzHarness {
254 config: FuzzConfig,
255}
256
257impl FuzzHarness {
258 #[must_use]
260 pub fn new(config: FuzzConfig) -> Self {
261 Self { config }
262 }
263
264 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 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 for attempt in 0..self.config.minimize_attempts {
368 let candidate = match attempt {
369 0..=15 => attempt as u64,
371 16..=31 => original_seed.wrapping_sub((attempt - 15) as u64),
373 _ => 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
426pub 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 #[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 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 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 let _leaked = runtime
708 .state
709 .create_task(region, Budget::INFINITE, async {})
710 .expect("create leaked task");
711
712 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}