Skip to main content

asupersync/lab/
explorer.rs

1//! DPOR-style schedule exploration engine.
2//!
3//! The explorer runs a test program under multiple schedules (seeds) and
4//! tracks which Mazurkiewicz trace equivalence classes have been covered.
5//! Two runs that differ only in the order of independent events belong to
6//! the same equivalence class and need not both be explored.
7//!
8//! # Algorithm (Phase 0: seed-sweep)
9//!
10//! 1. For each seed in `[base_seed .. base_seed + max_runs)`:
11//!    a. Construct a `LabRuntime` with that seed
12//!    b. Run the test closure
13//!    c. Record the trace and compute its Foata fingerprint
14//!    d. Check invariants; log any violations
15//! 2. Report: total runs, unique equivalence classes, violations found
16//!
17//! Future phases will add backtrack-point analysis and sleep sets for
18//! targeted exploration (true DPOR), but seed-sweep already catches many
19//! concurrency bugs by varying the scheduler's RNG.
20
21use crate::lab::config::LabConfig;
22use crate::lab::runtime::{InvariantViolation, LabRuntime};
23use crate::trace::boundary::SquareComplex;
24use crate::trace::canonicalize::{trace_fingerprint, TraceMonoid};
25use crate::trace::dpor::detect_races;
26use crate::trace::event::TraceEvent;
27use crate::trace::event_structure::TracePoset;
28use crate::trace::scoring::{
29    score_persistence, seed_fingerprint, ClassId, EvidenceLedger, TopologicalScore,
30};
31use serde::Serialize;
32use std::collections::hash_map::DefaultHasher;
33use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet, VecDeque};
34use std::hash::{Hash, Hasher};
35use std::io;
36use std::path::Path;
37
38const DEFAULT_SATURATION_WINDOW: usize = 10;
39const DEFAULT_UNEXPLORED_LIMIT: usize = 5;
40const DEFAULT_DERIVED_SEEDS: usize = 4;
41
42/// Exploration mode: baseline seed-sweep or topology-prioritized.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
44pub enum ExplorationMode {
45    /// Linear seed sweep (default).
46    #[default]
47    Baseline,
48    /// Topology-prioritized: uses H1 persistence to score and prioritize seeds.
49    TopologyPrioritized,
50}
51
52/// Configuration for the schedule explorer.
53#[derive(Debug, Clone)]
54pub struct ExplorerConfig {
55    /// Starting seed. Runs use seeds `base_seed`, `base_seed + 1`, etc.
56    pub base_seed: u64,
57    /// Maximum number of exploration runs.
58    pub max_runs: usize,
59    /// Maximum steps per run before the runtime gives up.
60    pub max_steps_per_run: u64,
61    /// Number of simulated workers.
62    pub worker_count: usize,
63    /// Enable trace recording for canonicalization.
64    pub record_traces: bool,
65}
66
67impl Default for ExplorerConfig {
68    fn default() -> Self {
69        Self {
70            base_seed: 0,
71            max_runs: 100,
72            max_steps_per_run: 100_000,
73            worker_count: 1,
74            record_traces: true,
75        }
76    }
77}
78
79impl ExplorerConfig {
80    /// Create a config with the given base seed and run count.
81    #[must_use]
82    pub fn new(base_seed: u64, max_runs: usize) -> Self {
83        Self {
84            base_seed,
85            max_runs,
86            ..Default::default()
87        }
88    }
89
90    /// Set the number of simulated workers.
91    #[must_use]
92    pub fn worker_count(mut self, n: usize) -> Self {
93        self.worker_count = n;
94        self
95    }
96
97    /// Set the max steps per run.
98    #[must_use]
99    pub fn max_steps(mut self, n: u64) -> Self {
100        self.max_steps_per_run = n;
101        self
102    }
103}
104
105/// Result of a single exploration run.
106#[derive(Debug)]
107pub struct RunResult {
108    /// The seed used for this run.
109    pub seed: u64,
110    /// Number of steps taken.
111    pub steps: u64,
112    /// Foata fingerprint of the trace (equivalence class ID).
113    pub fingerprint: u64,
114    /// Whether this was the first run in its equivalence class.
115    pub is_new_class: bool,
116    /// Invariant violations detected.
117    pub violations: Vec<InvariantViolation>,
118    /// Schedule certificate hash (determinism witness).
119    pub certificate_hash: u64,
120}
121
122/// A violation found during exploration, with reproducer info.
123#[derive(Debug)]
124pub struct ViolationReport {
125    /// The seed that triggered the violation.
126    pub seed: u64,
127    /// Steps taken before the violation.
128    pub steps: u64,
129    /// The violations found.
130    pub violations: Vec<InvariantViolation>,
131    /// Fingerprint of the trace that produced the violation.
132    pub fingerprint: u64,
133}
134
135/// Coverage metrics for the exploration.
136#[derive(Debug, Clone, Serialize)]
137pub struct CoverageMetrics {
138    /// Number of distinct equivalence classes discovered.
139    pub equivalence_classes: usize,
140    /// Total runs performed.
141    pub total_runs: usize,
142    /// Number of runs that discovered a new equivalence class.
143    pub new_class_discoveries: usize,
144    /// Per-class run counts (fingerprint -> count).
145    pub class_run_counts: HashMap<u64, usize>,
146    /// Novelty histogram: novelty score -> run count.
147    pub novelty_histogram: BTreeMap<u32, usize>,
148    /// Saturation signals (deterministic summary).
149    pub saturation: SaturationMetrics,
150}
151
152impl CoverageMetrics {
153    /// Fraction of runs that discovered a new equivalence class.
154    #[must_use]
155    #[allow(clippy::cast_precision_loss)]
156    pub fn discovery_rate(&self) -> f64 {
157        if self.total_runs == 0 {
158            return 0.0;
159        }
160        self.new_class_discoveries as f64 / self.total_runs as f64
161    }
162
163    /// True if at least `window` runs hit existing classes (coarse saturation signal).
164    #[must_use]
165    pub fn is_saturated(&self, window: usize) -> bool {
166        if self.total_runs < window {
167            return false;
168        }
169        self.total_runs - self.new_class_discoveries >= window
170    }
171}
172
173/// Saturation signals for exploration coverage.
174#[derive(Debug, Clone, Serialize)]
175pub struct SaturationMetrics {
176    /// Window size used for saturation detection.
177    pub window: usize,
178    /// True if coverage is saturated under the window heuristic.
179    pub saturated: bool,
180    /// Total runs that hit existing classes.
181    pub existing_class_hits: usize,
182    /// Runs since the last new class (None if no runs yet).
183    pub runs_since_last_new_class: Option<usize>,
184}
185
186/// Ranked unexplored seed entry (for explainability).
187#[derive(Debug, Clone)]
188pub struct UnexploredSeed {
189    /// Seed value.
190    pub seed: u64,
191    /// Optional topological score (present for topology-prioritized mode).
192    pub score: Option<TopologicalScore>,
193}
194
195fn novelty_histogram_from_flags(results: &[RunResult]) -> BTreeMap<u32, usize> {
196    let mut histogram = BTreeMap::new();
197    for r in results {
198        let novelty = u32::from(r.is_new_class);
199        *histogram.entry(novelty).or_insert(0) += 1;
200    }
201    histogram
202}
203
204fn novelty_histogram_from_ledgers(ledgers: &[EvidenceLedger]) -> BTreeMap<u32, usize> {
205    let mut histogram = BTreeMap::new();
206    for ledger in ledgers {
207        *histogram.entry(ledger.score.novelty).or_insert(0) += 1;
208    }
209    histogram
210}
211
212fn saturation_metrics(
213    results: &[RunResult],
214    total_runs: usize,
215    new_class_discoveries: usize,
216) -> SaturationMetrics {
217    let existing_class_hits = total_runs.saturating_sub(new_class_discoveries);
218    let runs_since_last_new_class = if results.is_empty() {
219        None
220    } else {
221        let last_new = results.iter().rposition(|r| r.is_new_class);
222        Some(last_new.map_or(results.len(), |idx| results.len() - 1 - idx))
223    };
224    let window = DEFAULT_SATURATION_WINDOW;
225    let saturated = if total_runs < window {
226        false
227    } else {
228        existing_class_hits >= window
229    };
230    SaturationMetrics {
231        window,
232        saturated,
233        existing_class_hits,
234        runs_since_last_new_class,
235    }
236}
237
238/// Summary report after exploration completes.
239#[derive(Debug)]
240pub struct ExplorationReport {
241    /// Total runs performed.
242    pub total_runs: usize,
243    /// Unique equivalence classes discovered.
244    pub unique_classes: usize,
245    /// All violations found (with reproducer seeds).
246    pub violations: Vec<ViolationReport>,
247    /// Coverage metrics.
248    pub coverage: CoverageMetrics,
249    /// Top-ranked unexplored seeds (if any remain).
250    pub top_unexplored: Vec<UnexploredSeed>,
251    /// Per-run results.
252    pub runs: Vec<RunResult>,
253}
254
255impl ExplorationReport {
256    /// True if any violations were found.
257    #[must_use]
258    pub fn has_violations(&self) -> bool {
259        !self.violations.is_empty()
260    }
261
262    /// Seeds that triggered violations (for reproduction).
263    #[must_use]
264    pub fn violation_seeds(&self) -> Vec<u64> {
265        self.violations.iter().map(|v| v.seed).collect()
266    }
267
268    /// Verify that runs with the same fingerprint produced the same certificate.
269    ///
270    /// Returns pairs of (seed_a, seed_b) where the traces are in the same
271    /// equivalence class but produced different certificates (divergence).
272    #[must_use]
273    pub fn certificate_divergences(&self) -> Vec<(u64, u64)> {
274        let mut by_class: HashMap<u64, Vec<&RunResult>> = HashMap::new();
275        for r in &self.runs {
276            by_class.entry(r.fingerprint).or_default().push(r);
277        }
278        let mut divergences = Vec::new();
279        for runs in by_class.values() {
280            if runs.len() < 2 {
281                continue;
282            }
283            let reference = runs[0].certificate_hash;
284            for r in &runs[1..] {
285                if r.certificate_hash != reference {
286                    divergences.push((runs[0].seed, r.seed));
287                }
288            }
289        }
290        divergences
291    }
292
293    /// True if all runs within the same equivalence class produced identical certificates.
294    #[must_use]
295    pub fn certificates_consistent(&self) -> bool {
296        self.certificate_divergences().is_empty()
297    }
298
299    /// Convert to a JSON-serializable summary (no heavy per-run violation payloads).
300    #[must_use]
301    pub fn to_json_summary(&self) -> ExplorationReportJson {
302        ExplorationReportJson {
303            total_runs: self.total_runs,
304            unique_classes: self.unique_classes,
305            violations: self
306                .violations
307                .iter()
308                .map(ViolationReport::summary)
309                .collect(),
310            violation_seeds: self.violation_seeds(),
311            coverage: self.coverage.clone(),
312            top_unexplored: self
313                .top_unexplored
314                .iter()
315                .map(UnexploredSeedJson::from_seed)
316                .collect(),
317            runs: self.runs.iter().map(RunResult::summary).collect(),
318            certificate_divergences: self.certificate_divergences(),
319        }
320    }
321
322    /// Serialize the summary report to JSON.
323    pub fn to_json_string(&self) -> Result<String, serde_json::Error> {
324        serde_json::to_string(&self.to_json_summary())
325    }
326
327    /// Serialize the summary report to pretty JSON.
328    pub fn to_json_pretty(&self) -> Result<String, serde_json::Error> {
329        serde_json::to_string_pretty(&self.to_json_summary())
330    }
331
332    /// Write the summary report as JSON to a file.
333    ///
334    /// When `pretty` is true, pretty-printed JSON is emitted.
335    pub fn write_json_summary<P: AsRef<Path>>(&self, path: P, pretty: bool) -> io::Result<()> {
336        let json = if pretty {
337            self.to_json_pretty().map_err(json_err)?
338        } else {
339            self.to_json_string().map_err(json_err)?
340        };
341        std::fs::write(path, json)
342    }
343}
344
345fn json_err(err: serde_json::Error) -> io::Error {
346    io::Error::other(err)
347}
348
349/// JSON-safe summary for an exploration report.
350#[derive(Debug, Serialize)]
351pub struct ExplorationReportJson {
352    /// Total runs performed.
353    pub total_runs: usize,
354    /// Unique equivalence classes discovered.
355    pub unique_classes: usize,
356    /// Violation summaries (stringified to keep output stable).
357    pub violations: Vec<ViolationSummary>,
358    /// Seeds that triggered violations.
359    pub violation_seeds: Vec<u64>,
360    /// Coverage metrics.
361    pub coverage: CoverageMetrics,
362    /// Top-ranked unexplored seeds (if any remain).
363    pub top_unexplored: Vec<UnexploredSeedJson>,
364    /// Per-run summaries (no heavy violation payloads).
365    pub runs: Vec<RunSummary>,
366    /// Certificate divergences within equivalence classes.
367    pub certificate_divergences: Vec<(u64, u64)>,
368}
369
370/// JSON-safe summary for a single run.
371#[derive(Debug, Serialize)]
372pub struct RunSummary {
373    /// Seed used for the run.
374    pub seed: u64,
375    /// Steps executed before completion.
376    pub steps: u64,
377    /// Foata fingerprint for the run's trace.
378    pub fingerprint: u64,
379    /// True if this run discovered a new equivalence class.
380    pub is_new_class: bool,
381    /// Number of invariant violations observed.
382    pub violation_count: usize,
383    /// Hash of the schedule certificate for determinism checks.
384    pub certificate_hash: u64,
385}
386
387impl RunResult {
388    fn summary(&self) -> RunSummary {
389        RunSummary {
390            seed: self.seed,
391            steps: self.steps,
392            fingerprint: self.fingerprint,
393            is_new_class: self.is_new_class,
394            violation_count: self.violations.len(),
395            certificate_hash: self.certificate_hash,
396        }
397    }
398}
399
400/// JSON-safe summary for a violation report.
401#[derive(Debug, Serialize)]
402pub struct ViolationSummary {
403    /// Seed that triggered the violation.
404    pub seed: u64,
405    /// Steps taken before the violation was observed.
406    pub steps: u64,
407    /// Foata fingerprint for the violating trace.
408    pub fingerprint: u64,
409    /// Stringified violation details (stable, human-readable).
410    pub violations: Vec<String>,
411}
412
413impl ViolationReport {
414    fn summary(&self) -> ViolationSummary {
415        ViolationSummary {
416            seed: self.seed,
417            steps: self.steps,
418            fingerprint: self.fingerprint,
419            violations: self.violations.iter().map(ToString::to_string).collect(),
420        }
421    }
422}
423
424/// JSON-safe wrapper for optional topological scores.
425#[derive(Debug, Serialize)]
426pub struct TopologicalScoreJson {
427    /// Novelty score (new homology classes).
428    pub novelty: u32,
429    /// Sum of persistence interval lengths.
430    pub persistence_sum: u64,
431    /// Deterministic fingerprint tie-break.
432    pub fingerprint: u64,
433}
434
435impl From<TopologicalScore> for TopologicalScoreJson {
436    fn from(score: TopologicalScore) -> Self {
437        Self {
438            novelty: score.novelty,
439            persistence_sum: score.persistence_sum,
440            fingerprint: score.fingerprint,
441        }
442    }
443}
444
445/// JSON-safe unexplored seed entry.
446#[derive(Debug, Serialize)]
447pub struct UnexploredSeedJson {
448    /// Seed value pending exploration.
449    pub seed: u64,
450    /// Optional topological score (if available).
451    pub score: Option<TopologicalScoreJson>,
452}
453
454impl UnexploredSeedJson {
455    fn from_seed(seed: &UnexploredSeed) -> Self {
456        Self {
457            seed: seed.seed,
458            score: seed.score.map(TopologicalScoreJson::from),
459        }
460    }
461}
462
463/// The schedule exploration engine.
464///
465/// Runs a test under multiple seeds, tracking equivalence classes and
466/// detecting invariant violations.
467pub struct ScheduleExplorer {
468    config: ExplorerConfig,
469    explored_seeds: HashSet<u64>,
470    known_fingerprints: HashSet<u64>,
471    class_counts: HashMap<u64, usize>,
472    results: Vec<RunResult>,
473    violations: Vec<ViolationReport>,
474    new_class_count: usize,
475}
476
477impl ScheduleExplorer {
478    /// Create a new explorer with the given configuration.
479    #[must_use]
480    pub fn new(config: ExplorerConfig) -> Self {
481        Self {
482            config,
483            explored_seeds: HashSet::new(),
484            known_fingerprints: HashSet::new(),
485            class_counts: HashMap::new(),
486            results: Vec::new(),
487            violations: Vec::new(),
488            new_class_count: 0,
489        }
490    }
491
492    /// Explore the test under multiple schedules.
493    ///
494    /// The `test` closure receives a freshly constructed `LabRuntime` for
495    /// each run. It should set up tasks, schedule them, and call
496    /// `run_until_quiescent()` (or equivalent).
497    ///
498    /// # Example
499    ///
500    /// ```ignore
501    /// use asupersync::lab::explorer::{ExplorerConfig, ScheduleExplorer};
502    /// use asupersync::types::Budget;
503    ///
504    /// let mut explorer = ScheduleExplorer::new(ExplorerConfig::new(42, 50));
505    /// let report = explorer.explore(|runtime| {
506    ///     let region = runtime.state.create_root_region(Budget::INFINITE);
507    ///     // ... set up concurrent tasks ...
508    ///     runtime.run_until_quiescent();
509    /// });
510    ///
511    /// assert!(!report.has_violations(), "Found bugs: {:?}", report.violation_seeds());
512    /// println!("Explored {} classes in {} runs", report.unique_classes, report.total_runs);
513    /// ```
514    pub fn explore<F>(&mut self, test: F) -> ExplorationReport
515    where
516        F: Fn(&mut LabRuntime),
517    {
518        for run_idx in 0..self.config.max_runs {
519            let seed = self.config.base_seed.wrapping_add(run_idx as u64);
520            self.run_once(seed, &test);
521        }
522
523        self.build_report()
524    }
525
526    /// Run a single exploration with the given seed.
527    fn run_once<F>(&mut self, seed: u64, test: &F)
528    where
529        F: Fn(&mut LabRuntime),
530    {
531        if !self.explored_seeds.insert(seed) {
532            return;
533        }
534
535        // Build config for this run.
536        let mut lab_config = LabConfig::new(seed);
537        lab_config = lab_config.worker_count(self.config.worker_count);
538        if let Some(max) = Some(self.config.max_steps_per_run) {
539            lab_config = lab_config.max_steps(max);
540        }
541        if self.config.record_traces {
542            lab_config = lab_config.with_default_replay_recording();
543        }
544
545        let mut runtime = LabRuntime::new(lab_config);
546
547        // Run the test.
548        test(&mut runtime);
549
550        let steps = runtime.steps();
551
552        // Compute trace fingerprint.
553        let trace_events: Vec<TraceEvent> = runtime.trace().snapshot();
554        let fingerprint = if trace_events.is_empty() {
555            // Use seed as fingerprint if no trace events (recording disabled).
556            seed
557        } else {
558            trace_fingerprint(&trace_events)
559        };
560
561        let is_new_class = self.known_fingerprints.insert(fingerprint);
562        if is_new_class {
563            self.new_class_count += 1;
564        }
565        *self.class_counts.entry(fingerprint).or_insert(0) += 1;
566
567        // Check invariants.
568        let violations = runtime.check_invariants();
569
570        if !violations.is_empty() {
571            self.violations.push(ViolationReport {
572                seed,
573                steps,
574                violations: violations.clone(),
575                fingerprint,
576            });
577        }
578
579        let certificate_hash = runtime.certificate().hash();
580
581        self.results.push(RunResult {
582            seed,
583            steps,
584            fingerprint,
585            is_new_class,
586            violations,
587            certificate_hash,
588        });
589    }
590
591    /// Build the final report.
592    fn build_report(&self) -> ExplorationReport {
593        let total_runs = self.results.len();
594        let novelty_histogram = novelty_histogram_from_flags(&self.results);
595        let saturation = saturation_metrics(&self.results, total_runs, self.new_class_count);
596        ExplorationReport {
597            total_runs,
598            unique_classes: self.known_fingerprints.len(),
599            violations: self.violations.clone(),
600            coverage: CoverageMetrics {
601                equivalence_classes: self.known_fingerprints.len(),
602                total_runs,
603                new_class_discoveries: self.new_class_count,
604                class_run_counts: self.class_counts.clone(),
605                novelty_histogram,
606                saturation,
607            },
608            top_unexplored: Vec::new(),
609            runs: Vec::new(), // Omit per-run details from report to save memory.
610        }
611    }
612
613    /// Access per-run results directly.
614    #[must_use]
615    pub fn results(&self) -> &[RunResult] {
616        &self.results
617    }
618
619    /// Access the current coverage metrics.
620    #[must_use]
621    pub fn coverage(&self) -> CoverageMetrics {
622        let total_runs = self.results.len();
623        let novelty_histogram = novelty_histogram_from_flags(&self.results);
624        let saturation = saturation_metrics(&self.results, total_runs, self.new_class_count);
625        CoverageMetrics {
626            equivalence_classes: self.known_fingerprints.len(),
627            total_runs,
628            new_class_discoveries: self.new_class_count,
629            class_run_counts: self.class_counts.clone(),
630            novelty_histogram,
631            saturation,
632        }
633    }
634}
635
636/// DPOR-guided schedule exploration.
637///
638/// Instead of random seed-sweep, this explorer uses race detection to identify
639/// backtrack points and generate targeted schedules. Each run's trace is
640/// analyzed for races, and alternative schedules (derived from backtrack points)
641/// are added to a work queue. The trace monoid is used for equivalence class
642/// deduplication: schedules that produce equivalent traces are not re-explored.
643///
644/// # Algorithm
645///
646/// 1. Run the initial schedule (base seed)
647/// 2. Detect races in the trace via `detect_races()`
648/// 3. For each backtrack point, derive a new seed that permutes the schedule
649/// 4. Check if the resulting trace's equivalence class is already known
650/// 5. If new, explore further; if known, prune
651/// 6. Repeat until work queue is empty or budget is exhausted
652///
653/// # Coverage Guarantees
654///
655/// DPOR explores at least one representative schedule per Mazurkiewicz
656/// equivalence class reachable from the initial schedule through single-race
657/// reversals. This is sound (no false negatives) but not complete for deeply
658/// nested race chains without iterative deepening.
659pub struct DporExplorer {
660    config: ExplorerConfig,
661    /// Seeds pending exploration (derived from backtrack points).
662    work_queue: VecDeque<u64>,
663    /// Explored seeds.
664    explored_seeds: HashSet<u64>,
665    /// Known equivalence classes (fingerprint → monoid element).
666    known_classes: HashMap<u64, TraceMonoid>,
667    /// Per-class run counts.
668    class_counts: HashMap<u64, usize>,
669    /// All run results.
670    results: Vec<RunResult>,
671    /// Violations found.
672    violations: Vec<ViolationReport>,
673    /// Total races found across all runs.
674    total_races: usize,
675    /// Total HB-races across all runs.
676    total_hb_races: usize,
677    /// Backtrack points generated.
678    total_backtrack_points: usize,
679    /// Backtrack points pruned by equivalence class deduplication.
680    pruned_backtrack_points: usize,
681    /// Backtrack points pruned by sleep set.
682    sleep_pruned: usize,
683    /// Sleep set for deduplicating backtrack points across runs.
684    sleep_set: crate::trace::dpor::SleepSet,
685    /// Per-run estimated class counts (for coverage trend analysis).
686    per_run_estimated_classes: Vec<usize>,
687}
688
689/// Extended coverage metrics for DPOR exploration.
690#[derive(Debug, Clone, Serialize)]
691pub struct DporCoverageMetrics {
692    /// Base coverage metrics.
693    pub base: CoverageMetrics,
694    /// Total races detected across all runs (immediate, O(n³)).
695    pub total_races: usize,
696    /// Total HB-races detected across all runs (vector-clock based).
697    pub total_hb_races: usize,
698    /// Total backtrack points generated.
699    pub total_backtrack_points: usize,
700    /// Backtrack points pruned by equivalence deduplication.
701    pub pruned_backtrack_points: usize,
702    /// Backtrack points pruned by sleep set.
703    pub sleep_pruned: usize,
704    /// Ratio of useful exploration (new classes / total runs).
705    pub efficiency: f64,
706    /// Per-run estimated class counts (trend: should plateau at saturation).
707    pub estimated_class_trend: Vec<usize>,
708}
709
710impl DporExplorer {
711    /// Create a new DPOR explorer with the given configuration.
712    #[must_use]
713    pub fn new(config: ExplorerConfig) -> Self {
714        let mut work_queue = VecDeque::new();
715        work_queue.push_back(config.base_seed);
716        Self {
717            config,
718            work_queue,
719            explored_seeds: HashSet::new(),
720            known_classes: HashMap::new(),
721            class_counts: HashMap::new(),
722            results: Vec::new(),
723            violations: Vec::new(),
724            total_races: 0,
725            total_hb_races: 0,
726            total_backtrack_points: 0,
727            pruned_backtrack_points: 0,
728            sleep_pruned: 0,
729            sleep_set: crate::trace::dpor::SleepSet::new(),
730            per_run_estimated_classes: Vec::new(),
731        }
732    }
733
734    /// Run DPOR-guided exploration.
735    ///
736    /// The `test` closure receives a freshly constructed `LabRuntime` for each
737    /// run. Exploration continues until the work queue is empty or `max_runs`
738    /// is reached.
739    pub fn explore<F>(&mut self, test: F) -> ExplorationReport
740    where
741        F: Fn(&mut LabRuntime),
742    {
743        while let Some(seed) = self.work_queue.pop_front() {
744            if self.results.len() >= self.config.max_runs {
745                break;
746            }
747            if !self.explored_seeds.insert(seed) {
748                continue;
749            }
750
751            let (trace_events, run_result) = self.run_once(seed, &test);
752
753            // Detect races and generate backtrack points.
754            if trace_events.is_empty() {
755                self.per_run_estimated_classes.push(1);
756            } else {
757                let analysis = detect_races(&trace_events);
758                self.total_races += analysis.race_count();
759                self.total_backtrack_points += analysis.backtrack_points.len();
760
761                // Also run HB-race detection for coverage metrics.
762                let hb_report = crate::trace::dpor::detect_hb_races(&trace_events);
763                self.total_hb_races += hb_report.race_count();
764
765                // Record per-run estimated class count.
766                let est = crate::trace::dpor::estimated_classes(&trace_events);
767                self.per_run_estimated_classes.push(est);
768
769                // For each backtrack point, derive a new seed.
770                // We use a deterministic derivation: seed XOR hash of the
771                // divergence index. This ensures the same backtrack point
772                // always generates the same seed.
773                for bp in &analysis.backtrack_points {
774                    // Sleep set optimization: skip backtrack points we've
775                    // already explored (same race structure at same position).
776                    if self.sleep_set.contains(bp, &trace_events) {
777                        self.sleep_pruned += 1;
778                        continue;
779                    }
780                    self.sleep_set.insert(bp, &trace_events);
781
782                    let derived_seed =
783                        seed ^ (bp.divergence_index as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
784
785                    if self.explored_seeds.contains(&derived_seed) {
786                        self.pruned_backtrack_points += 1;
787                        continue;
788                    }
789
790                    // Check if the derived seed would likely produce a known
791                    // equivalence class by checking the monoid fingerprint of
792                    // the prefix up to the divergence point.
793                    let prefix = &trace_events[..bp.divergence_index.min(trace_events.len())];
794                    let prefix_fp = trace_fingerprint(prefix);
795                    if self.known_classes.contains_key(&prefix_fp) && prefix.len() > 1 {
796                        // Prefix already explored; the full trace might still
797                        // be different, but we deprioritize it.
798                        self.work_queue.push_back(derived_seed);
799                    } else {
800                        // Unknown prefix — high priority.
801                        self.work_queue.push_front(derived_seed);
802                    }
803                }
804            }
805
806            self.results.push(run_result);
807        }
808
809        self.build_report()
810    }
811
812    /// Run a single schedule and return (trace_events, run_result).
813    fn run_once<F>(&mut self, seed: u64, test: &F) -> (Vec<TraceEvent>, RunResult)
814    where
815        F: Fn(&mut LabRuntime),
816    {
817        let mut lab_config = LabConfig::new(seed);
818        lab_config = lab_config.worker_count(self.config.worker_count);
819        if let Some(max) = Some(self.config.max_steps_per_run) {
820            lab_config = lab_config.max_steps(max);
821        }
822        if self.config.record_traces {
823            lab_config = lab_config.with_default_replay_recording();
824        }
825
826        let mut runtime = LabRuntime::new(lab_config);
827        test(&mut runtime);
828
829        let steps = runtime.steps();
830        let trace_events: Vec<TraceEvent> = runtime.trace().snapshot();
831
832        let monoid = TraceMonoid::from_events(&trace_events);
833        let fingerprint = monoid.class_fingerprint();
834
835        let is_new_class = !self.known_classes.contains_key(&fingerprint);
836        if is_new_class {
837            self.known_classes.insert(fingerprint, monoid);
838        }
839        *self.class_counts.entry(fingerprint).or_insert(0) += 1;
840
841        let violations = runtime.check_invariants();
842        if !violations.is_empty() {
843            self.violations.push(ViolationReport {
844                seed,
845                steps,
846                violations: violations.clone(),
847                fingerprint,
848            });
849        }
850
851        let certificate_hash = runtime.certificate().hash();
852
853        let result = RunResult {
854            seed,
855            steps,
856            fingerprint,
857            is_new_class,
858            violations,
859            certificate_hash,
860        };
861
862        (trace_events, result)
863    }
864
865    fn build_report(&self) -> ExplorationReport {
866        let total_runs = self.results.len();
867        let new_class_discoveries = self.results.iter().filter(|r| r.is_new_class).count();
868        let novelty_histogram = novelty_histogram_from_flags(&self.results);
869        let saturation = saturation_metrics(&self.results, total_runs, new_class_discoveries);
870        let top_unexplored = self
871            .work_queue
872            .iter()
873            .take(DEFAULT_UNEXPLORED_LIMIT)
874            .map(|seed| UnexploredSeed {
875                seed: *seed,
876                score: None,
877            })
878            .collect();
879        ExplorationReport {
880            total_runs,
881            unique_classes: self.known_classes.len(),
882            violations: self.violations.clone(),
883            coverage: CoverageMetrics {
884                equivalence_classes: self.known_classes.len(),
885                total_runs,
886                new_class_discoveries,
887                class_run_counts: self.class_counts.clone(),
888                novelty_histogram,
889                saturation,
890            },
891            top_unexplored,
892            runs: Vec::new(),
893        }
894    }
895
896    /// Returns DPOR-specific coverage metrics.
897    #[must_use]
898    #[allow(clippy::cast_precision_loss)]
899    pub fn dpor_coverage(&self) -> DporCoverageMetrics {
900        let new_class_count = self.results.iter().filter(|r| r.is_new_class).count();
901        let total = self.results.len();
902        let novelty_histogram = novelty_histogram_from_flags(&self.results);
903        let saturation = saturation_metrics(&self.results, total, new_class_count);
904        DporCoverageMetrics {
905            base: CoverageMetrics {
906                equivalence_classes: self.known_classes.len(),
907                total_runs: total,
908                new_class_discoveries: new_class_count,
909                class_run_counts: self.class_counts.clone(),
910                novelty_histogram,
911                saturation,
912            },
913            total_races: self.total_races,
914            total_hb_races: self.total_hb_races,
915            total_backtrack_points: self.total_backtrack_points,
916            pruned_backtrack_points: self.pruned_backtrack_points,
917            sleep_pruned: self.sleep_pruned,
918            efficiency: if total == 0 {
919                0.0
920            } else {
921                new_class_count as f64 / total as f64
922            },
923            estimated_class_trend: self.per_run_estimated_classes.clone(),
924        }
925    }
926}
927
928/// Topology-prioritized exploration engine.
929///
930/// Uses H1 persistent homology to score traces and prioritize seeds that
931/// exhibit novel concurrency patterns (new homology classes). Seeds are
932/// drawn from a priority queue ordered by [`TopologicalScore`].
933///
934/// # Algorithm
935///
936/// 1. Start with `max_runs` seeds in the queue, all scored at zero.
937/// 2. Pop the highest-scored seed, run it, compute the trace's square
938///    complex and H1 persistence.
939/// 3. Score the persistence pairs against previously seen classes.
940/// 4. Record the result; the next seed's score reflects how novel its
941///    trace was (new classes discovered, persistence interval lengths).
942/// 5. Repeat until the queue is empty or budget is exhausted.
943///
944/// Both modes (baseline and topology-prioritized) remain deterministic
945/// given the same configuration and test closure.
946pub struct TopologyExplorer {
947    config: ExplorerConfig,
948    /// Priority queue: (score, seed). Highest score popped first.
949    frontier: BinaryHeap<(TopologicalScore, u64)>,
950    /// Explored seeds.
951    explored_seeds: HashSet<u64>,
952    /// Known equivalence classes (fingerprint → run count).
953    known_fingerprints: HashSet<u64>,
954    class_counts: HashMap<u64, usize>,
955    /// Seen persistence classes for novelty detection.
956    seen_classes: HashSet<ClassId>,
957    /// Per-run results.
958    results: Vec<RunResult>,
959    /// Violations found.
960    violations: Vec<ViolationReport>,
961    /// Per-run evidence ledgers.
962    ledgers: Vec<EvidenceLedger>,
963    new_class_count: usize,
964}
965
966impl TopologyExplorer {
967    /// Create a new topology explorer with the given configuration.
968    #[must_use]
969    pub fn new(config: ExplorerConfig) -> Self {
970        let mut frontier = BinaryHeap::new();
971        // Seed the frontier with initial seeds, all scored at zero.
972        for i in 0..config.max_runs {
973            let seed = config.base_seed.wrapping_add(i as u64);
974            let fp = seed_fingerprint(seed);
975            frontier.push((TopologicalScore::zero(fp), seed));
976        }
977        Self {
978            config,
979            frontier,
980            explored_seeds: HashSet::new(),
981            known_fingerprints: HashSet::new(),
982            class_counts: HashMap::new(),
983            seen_classes: HashSet::new(),
984            results: Vec::new(),
985            violations: Vec::new(),
986            ledgers: Vec::new(),
987            new_class_count: 0,
988        }
989    }
990
991    /// Run topology-prioritized exploration.
992    ///
993    /// The `test` closure receives a freshly constructed `LabRuntime` for each run.
994    pub fn explore<F>(&mut self, test: F) -> ExplorationReport
995    where
996        F: Fn(&mut LabRuntime),
997    {
998        while let Some((_score, seed)) = self.frontier.pop() {
999            if self.results.len() >= self.config.max_runs {
1000                break;
1001            }
1002            if !self.explored_seeds.insert(seed) {
1003                continue;
1004            }
1005            self.run_once(seed, &test);
1006        }
1007        self.build_report()
1008    }
1009
1010    fn run_once<F>(&mut self, seed: u64, test: &F)
1011    where
1012        F: Fn(&mut LabRuntime),
1013    {
1014        let mut lab_config = LabConfig::new(seed);
1015        lab_config = lab_config.worker_count(self.config.worker_count);
1016        if let Some(max) = Some(self.config.max_steps_per_run) {
1017            lab_config = lab_config.max_steps(max);
1018        }
1019        if self.config.record_traces {
1020            lab_config = lab_config.with_default_replay_recording();
1021        }
1022
1023        let mut runtime = LabRuntime::new(lab_config);
1024        test(&mut runtime);
1025
1026        let steps = runtime.steps();
1027        let trace_events: Vec<TraceEvent> = runtime.trace().snapshot();
1028
1029        let fingerprint = if trace_events.is_empty() {
1030            seed
1031        } else {
1032            trace_fingerprint(&trace_events)
1033        };
1034
1035        let is_new_class = self.known_fingerprints.insert(fingerprint);
1036        if is_new_class {
1037            self.new_class_count += 1;
1038        }
1039        *self.class_counts.entry(fingerprint).or_insert(0) += 1;
1040
1041        // Compute topological score from the trace's square complex.
1042        let fp = seed_fingerprint(seed);
1043        let ledger = if trace_events.len() >= 2 {
1044            let poset = TracePoset::from_trace(&trace_events);
1045            let complex = SquareComplex::from_trace_poset(&poset);
1046            let d2 = complex.boundary_2();
1047
1048            // Build the combined boundary matrix for H1 computation.
1049            // We reduce ∂₁ to find 1-cycles, and use ∂₂ to identify which are boundaries.
1050            // For scoring, we use the ∂₁ matrix reduction directly — paired columns
1051            // in the reduction of ∂₁ give H₀ pairs, and unpaired edge columns give
1052            // 1-cycle candidates. Then reducing ∂₂ kills some of those cycles.
1053            //
1054            // Simplified approach: reduce ∂₂ to get H₁ persistence pairs directly.
1055            let reduced = d2.reduce();
1056            let pairs = reduced.persistence_pairs();
1057            score_persistence(&pairs, &mut self.seen_classes, fp)
1058        } else {
1059            use crate::trace::gf2::PersistencePairs;
1060            let pairs = PersistencePairs {
1061                pairs: vec![],
1062                unpaired: vec![],
1063            };
1064            score_persistence(&pairs, &mut self.seen_classes, fp)
1065        };
1066
1067        self.enqueue_derived_seeds(seed, &ledger);
1068        self.ledgers.push(ledger);
1069
1070        let violations = runtime.check_invariants();
1071        if !violations.is_empty() {
1072            self.violations.push(ViolationReport {
1073                seed,
1074                steps,
1075                violations: violations.clone(),
1076                fingerprint,
1077            });
1078        }
1079
1080        let certificate_hash = runtime.certificate().hash();
1081
1082        self.results.push(RunResult {
1083            seed,
1084            steps,
1085            fingerprint,
1086            is_new_class,
1087            violations,
1088            certificate_hash,
1089        });
1090    }
1091
1092    fn enqueue_derived_seeds(&mut self, seed: u64, ledger: &EvidenceLedger) {
1093        if ledger.entries.is_empty() {
1094            return;
1095        }
1096        if ledger.score.novelty == 0 && ledger.score.persistence_sum == 0 {
1097            return;
1098        }
1099        let mut pushed = 0usize;
1100        for (idx, entry) in ledger.entries.iter().enumerate() {
1101            if pushed >= DEFAULT_DERIVED_SEEDS {
1102                break;
1103            }
1104            let derived_seed = derive_seed(seed, entry.class, idx as u64);
1105            if self.explored_seeds.contains(&derived_seed) {
1106                continue;
1107            }
1108            let mut score = ledger.score;
1109            score.fingerprint = seed_fingerprint(derived_seed);
1110            self.frontier.push((score, derived_seed));
1111            pushed += 1;
1112        }
1113    }
1114
1115    fn build_report(&self) -> ExplorationReport {
1116        let total_runs = self.results.len();
1117        let novelty_histogram = novelty_histogram_from_ledgers(&self.ledgers);
1118        let saturation = saturation_metrics(&self.results, total_runs, self.new_class_count);
1119        ExplorationReport {
1120            total_runs,
1121            unique_classes: self.known_fingerprints.len(),
1122            violations: self.violations.clone(),
1123            coverage: CoverageMetrics {
1124                equivalence_classes: self.known_fingerprints.len(),
1125                total_runs,
1126                new_class_discoveries: self.new_class_count,
1127                class_run_counts: self.class_counts.clone(),
1128                novelty_histogram,
1129                saturation,
1130            },
1131            top_unexplored: self.top_unexplored(DEFAULT_UNEXPLORED_LIMIT),
1132            runs: Vec::new(),
1133        }
1134    }
1135
1136    /// Access per-run results.
1137    #[must_use]
1138    pub fn results(&self) -> &[RunResult] {
1139        &self.results
1140    }
1141
1142    /// Access per-run evidence ledgers.
1143    #[must_use]
1144    pub fn ledgers(&self) -> &[EvidenceLedger] {
1145        &self.ledgers
1146    }
1147
1148    /// Access the current coverage metrics.
1149    #[must_use]
1150    pub fn coverage(&self) -> CoverageMetrics {
1151        let total_runs = self.results.len();
1152        let novelty_histogram = novelty_histogram_from_ledgers(&self.ledgers);
1153        let saturation = saturation_metrics(&self.results, total_runs, self.new_class_count);
1154        CoverageMetrics {
1155            equivalence_classes: self.known_fingerprints.len(),
1156            total_runs,
1157            new_class_discoveries: self.new_class_count,
1158            class_run_counts: self.class_counts.clone(),
1159            novelty_histogram,
1160            saturation,
1161        }
1162    }
1163
1164    fn top_unexplored(&self, limit: usize) -> Vec<UnexploredSeed> {
1165        let mut heap = self.frontier.clone();
1166        let mut out = Vec::new();
1167        while out.len() < limit {
1168            let Some((score, seed)) = heap.pop() else {
1169                break;
1170            };
1171            out.push(UnexploredSeed {
1172                seed,
1173                score: Some(score),
1174            });
1175        }
1176        out
1177    }
1178}
1179
1180fn derive_seed(seed: u64, class: ClassId, index: u64) -> u64 {
1181    let mut hasher = DefaultHasher::new();
1182    seed.hash(&mut hasher);
1183    class.birth.hash(&mut hasher);
1184    class.death.hash(&mut hasher);
1185    index.hash(&mut hasher);
1186    hasher.finish()
1187}
1188
1189// ViolationReport needs Clone for build_report.
1190impl Clone for ViolationReport {
1191    fn clone(&self) -> Self {
1192        Self {
1193            seed: self.seed,
1194            steps: self.steps,
1195            violations: self.violations.clone(),
1196            fingerprint: self.fingerprint,
1197        }
1198    }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204    use crate::types::Budget;
1205    use serde_json::Value as JsonValue;
1206    use std::fs;
1207    use tempfile::NamedTempFile;
1208
1209    #[test]
1210    fn explore_single_task_no_violations() {
1211        let mut explorer = ScheduleExplorer::new(ExplorerConfig::new(42, 5));
1212        let report = explorer.explore(|runtime| {
1213            let region = runtime.state.create_root_region(Budget::INFINITE);
1214            let (task_id, _handle) = runtime
1215                .state
1216                .create_task(region, Budget::INFINITE, async { 42 })
1217                .expect("create task");
1218            runtime.scheduler.lock().unwrap().schedule(task_id, 0);
1219            runtime.run_until_quiescent();
1220        });
1221
1222        assert!(!report.has_violations());
1223        assert_eq!(report.total_runs, 5);
1224        // Each seed produces distinct RNG values in the trace, so fingerprints
1225        // differ even for a single task. This is correct: the full trace
1226        // (including RNG) distinguishes runs. Schedule-level equivalence
1227        // will be handled by DPOR's filtered independence relation.
1228        assert!(report.unique_classes >= 1);
1229    }
1230
1231    #[test]
1232    fn explore_two_independent_tasks_discovers_classes() {
1233        let mut explorer = ScheduleExplorer::new(ExplorerConfig::new(0, 20));
1234        let report = explorer.explore(|runtime| {
1235            let region = runtime.state.create_root_region(Budget::INFINITE);
1236            let (t1, _) = runtime
1237                .state
1238                .create_task(region, Budget::INFINITE, async {})
1239                .expect("t1");
1240            let (t2, _) = runtime
1241                .state
1242                .create_task(region, Budget::INFINITE, async {})
1243                .expect("t2");
1244            {
1245                let mut sched = runtime.scheduler.lock().unwrap();
1246                sched.schedule(t1, 0);
1247                sched.schedule(t2, 0);
1248            }
1249            runtime.run_until_quiescent();
1250        });
1251
1252        assert!(!report.has_violations());
1253        assert_eq!(report.total_runs, 20);
1254        // Two independent no-yield tasks may produce different traces
1255        // depending on scheduling order, but the trace events are simple
1256        // enough that we might get 1 or 2 classes.
1257        assert!(report.unique_classes >= 1);
1258    }
1259
1260    #[test]
1261    fn coverage_metrics_track_discovery() {
1262        let mut explorer = ScheduleExplorer::new(ExplorerConfig::new(100, 10));
1263        let report = explorer.explore(|runtime| {
1264            let region = runtime.state.create_root_region(Budget::INFINITE);
1265            let (t1, _) = runtime
1266                .state
1267                .create_task(region, Budget::INFINITE, async {})
1268                .expect("t1");
1269            runtime.scheduler.lock().unwrap().schedule(t1, 0);
1270            runtime.run_until_quiescent();
1271        });
1272
1273        let cov = &report.coverage;
1274        assert_eq!(cov.total_runs, 10);
1275        assert!(cov.equivalence_classes >= 1);
1276        assert!(cov.new_class_discoveries >= 1);
1277        // Discovery rate should be between 0 and 1 inclusive.
1278        assert!(cov.discovery_rate() > 0.0);
1279        assert!(cov.discovery_rate() <= 1.0);
1280        let hist_total: usize = cov.novelty_histogram.values().copied().sum();
1281        assert_eq!(hist_total, cov.total_runs);
1282        assert_eq!(cov.saturation.window, DEFAULT_SATURATION_WINDOW);
1283    }
1284
1285    #[test]
1286    fn violation_seeds_are_recorded() {
1287        // This test just verifies the reporting mechanism works.
1288        // We don't inject real violations here; we just check the API.
1289        let mut explorer = ScheduleExplorer::new(ExplorerConfig::new(42, 3));
1290        let report = explorer.explore(|runtime| {
1291            let _region = runtime.state.create_root_region(Budget::INFINITE);
1292            runtime.run_until_quiescent();
1293        });
1294
1295        // No violations expected.
1296        assert!(report.violation_seeds().is_empty());
1297    }
1298
1299    #[test]
1300    fn explorer_config_builder() {
1301        let config = ExplorerConfig::new(42, 50)
1302            .worker_count(4)
1303            .max_steps(10_000);
1304        assert_eq!(config.base_seed, 42);
1305        assert_eq!(config.max_runs, 50);
1306        assert_eq!(config.worker_count, 4);
1307        assert_eq!(config.max_steps_per_run, 10_000);
1308    }
1309
1310    #[test]
1311    fn discovery_rate_correct() {
1312        let mut novelty_histogram = BTreeMap::new();
1313        novelty_histogram.insert(0, 7);
1314        novelty_histogram.insert(1, 3);
1315        let saturation = SaturationMetrics {
1316            window: DEFAULT_SATURATION_WINDOW,
1317            saturated: false,
1318            existing_class_hits: 7,
1319            runs_since_last_new_class: Some(7),
1320        };
1321        let metrics = CoverageMetrics {
1322            equivalence_classes: 3,
1323            total_runs: 10,
1324            new_class_discoveries: 3,
1325            class_run_counts: HashMap::new(),
1326            novelty_histogram,
1327            saturation,
1328        };
1329        assert!((metrics.discovery_rate() - 0.3).abs() < 1e-10);
1330    }
1331
1332    // ── DPOR Explorer tests ─────────────────────────────────────────────
1333
1334    #[test]
1335    fn dpor_explore_single_task_no_violations() {
1336        let mut explorer = DporExplorer::new(ExplorerConfig::new(42, 10));
1337        let report = explorer.explore(|runtime| {
1338            let region = runtime.state.create_root_region(Budget::INFINITE);
1339            let (task_id, _handle) = runtime
1340                .state
1341                .create_task(region, Budget::INFINITE, async { 42 })
1342                .expect("create task");
1343            runtime.scheduler.lock().unwrap().schedule(task_id, 0);
1344            runtime.run_until_quiescent();
1345        });
1346
1347        assert!(!report.has_violations());
1348        assert!(report.unique_classes >= 1);
1349    }
1350
1351    #[test]
1352    fn dpor_explore_two_tasks_discovers_classes() {
1353        let mut explorer = DporExplorer::new(ExplorerConfig::new(0, 20));
1354        let report = explorer.explore(|runtime| {
1355            let region = runtime.state.create_root_region(Budget::INFINITE);
1356            let (t1, _) = runtime
1357                .state
1358                .create_task(region, Budget::INFINITE, async {})
1359                .expect("t1");
1360            let (t2, _) = runtime
1361                .state
1362                .create_task(region, Budget::INFINITE, async {})
1363                .expect("t2");
1364            {
1365                let mut sched = runtime.scheduler.lock().unwrap();
1366                sched.schedule(t1, 0);
1367                sched.schedule(t2, 0);
1368            }
1369            runtime.run_until_quiescent();
1370        });
1371
1372        assert!(!report.has_violations());
1373        assert!(report.unique_classes >= 1);
1374    }
1375
1376    #[test]
1377    fn dpor_coverage_metrics_populated() {
1378        let mut explorer = DporExplorer::new(ExplorerConfig::new(42, 5));
1379        let _report = explorer.explore(|runtime| {
1380            let region = runtime.state.create_root_region(Budget::INFINITE);
1381            let (t1, _) = runtime
1382                .state
1383                .create_task(region, Budget::INFINITE, async {})
1384                .expect("t1");
1385            runtime.scheduler.lock().unwrap().schedule(t1, 0);
1386            runtime.run_until_quiescent();
1387        });
1388
1389        let metrics = explorer.dpor_coverage();
1390        assert!(metrics.base.total_runs >= 1);
1391        assert!(metrics.base.equivalence_classes >= 1);
1392        // Efficiency should be between 0 and 1.
1393        assert!(metrics.efficiency >= 0.0);
1394        assert!(metrics.efficiency <= 1.0);
1395    }
1396
1397    #[test]
1398    fn dpor_explorer_respects_max_runs() {
1399        let mut explorer = DporExplorer::new(ExplorerConfig::new(0, 3));
1400        let report = explorer.explore(|runtime| {
1401            let _region = runtime.state.create_root_region(Budget::INFINITE);
1402            runtime.run_until_quiescent();
1403        });
1404
1405        assert!(report.total_runs <= 3);
1406    }
1407
1408    // ── Certificate integration tests ───────────────────────────────────
1409
1410    #[test]
1411    fn certificate_hash_populated_in_run_results() {
1412        let mut explorer = ScheduleExplorer::new(ExplorerConfig::new(42, 3));
1413        let report = explorer.explore(|runtime| {
1414            let region = runtime.state.create_root_region(Budget::INFINITE);
1415            let (t, _) = runtime
1416                .state
1417                .create_task(region, Budget::INFINITE, async { 1 })
1418                .expect("t");
1419            runtime.scheduler.lock().unwrap().schedule(t, 0);
1420            runtime.run_until_quiescent();
1421        });
1422
1423        // Every run should have a non-zero certificate hash (tasks were polled).
1424        for r in &report.runs {
1425            assert_ne!(r.certificate_hash, 0, "seed {} had zero cert hash", r.seed);
1426        }
1427    }
1428
1429    #[test]
1430    fn same_seed_produces_same_certificate() {
1431        let run = |seed: u64| -> u64 {
1432            let mut explorer = ScheduleExplorer::new(ExplorerConfig::new(seed, 1));
1433            explorer.explore(|runtime| {
1434                let region = runtime.state.create_root_region(Budget::INFINITE);
1435                let (t, _) = runtime
1436                    .state
1437                    .create_task(region, Budget::INFINITE, async { 99 })
1438                    .expect("t");
1439                runtime.scheduler.lock().unwrap().schedule(t, 0);
1440                runtime.run_until_quiescent();
1441            });
1442            let first = explorer
1443                .results()
1444                .first()
1445                .expect("explorer should record at least one run");
1446            first.certificate_hash
1447        };
1448
1449        let h1 = run(77);
1450        let h2 = run(77);
1451        assert_eq!(h1, h2, "same seed should yield same certificate");
1452    }
1453
1454    #[test]
1455    fn different_seeds_may_produce_different_certificates() {
1456        let run = |seed: u64| -> u64 {
1457            let mut explorer = ScheduleExplorer::new(ExplorerConfig::new(seed, 1));
1458            explorer.explore(|runtime| {
1459                let region = runtime.state.create_root_region(Budget::INFINITE);
1460                let (t1, _) = runtime
1461                    .state
1462                    .create_task(region, Budget::INFINITE, async {})
1463                    .expect("t1");
1464                let (t2, _) = runtime
1465                    .state
1466                    .create_task(region, Budget::INFINITE, async {})
1467                    .expect("t2");
1468                {
1469                    let mut sched = runtime.scheduler.lock().unwrap();
1470                    sched.schedule(t1, 0);
1471                    sched.schedule(t2, 0);
1472                }
1473                runtime.run_until_quiescent();
1474            });
1475            let first = explorer
1476                .results()
1477                .first()
1478                .expect("explorer should record at least one run");
1479            first.certificate_hash
1480        };
1481
1482        // With two tasks and different seeds, the scheduling order may differ.
1483        // Collect several seeds and check we see at least 1 unique hash.
1484        let hashes: HashSet<u64> = (0..10).map(run).collect();
1485        assert!(!hashes.is_empty());
1486    }
1487
1488    #[test]
1489    fn certificates_consistent_with_single_task() {
1490        let mut explorer = ScheduleExplorer::new(ExplorerConfig::new(0, 5));
1491        let report = explorer.explore(|runtime| {
1492            let region = runtime.state.create_root_region(Budget::INFINITE);
1493            let (t, _) = runtime
1494                .state
1495                .create_task(region, Budget::INFINITE, async { 42 })
1496                .expect("t");
1497            runtime.scheduler.lock().unwrap().schedule(t, 0);
1498            runtime.run_until_quiescent();
1499        });
1500
1501        // certificate_divergences checks within same fingerprint class.
1502        // Even if no two runs share a fingerprint, no divergences is correct.
1503        assert!(report.certificates_consistent());
1504    }
1505
1506    #[test]
1507    fn dpor_certificate_hash_populated() {
1508        let mut explorer = DporExplorer::new(ExplorerConfig::new(42, 5));
1509        let report = explorer.explore(|runtime| {
1510            let region = runtime.state.create_root_region(Budget::INFINITE);
1511            let (t, _) = runtime
1512                .state
1513                .create_task(region, Budget::INFINITE, async { 1 })
1514                .expect("t");
1515            runtime.scheduler.lock().unwrap().schedule(t, 0);
1516            runtime.run_until_quiescent();
1517        });
1518
1519        for r in &report.runs {
1520            assert_ne!(r.certificate_hash, 0, "seed {} had zero cert hash", r.seed);
1521        }
1522    }
1523
1524    #[test]
1525    fn json_summary_includes_core_fields() {
1526        let mut explorer = ScheduleExplorer::new(ExplorerConfig::new(7, 2));
1527        let report = explorer.explore(|runtime| {
1528            let region = runtime.state.create_root_region(Budget::INFINITE);
1529            let (task_id, _handle) = runtime
1530                .state
1531                .create_task(region, Budget::INFINITE, async {})
1532                .expect("create task");
1533            runtime.scheduler.lock().unwrap().schedule(task_id, 0);
1534            runtime.run_until_quiescent();
1535        });
1536
1537        let json = report.to_json_string().expect("json");
1538        let value: JsonValue = serde_json::from_str(&json).expect("parse");
1539        assert!(value.get("total_runs").is_some());
1540        assert!(value.get("unique_classes").is_some());
1541        assert!(value.get("coverage").is_some());
1542        assert!(value.get("violations").is_some());
1543        assert!(value.get("violation_seeds").is_some());
1544    }
1545
1546    #[test]
1547    fn json_summary_can_be_written() {
1548        let mut explorer = ScheduleExplorer::new(ExplorerConfig::new(11, 1));
1549        let report = explorer.explore(|runtime| {
1550            let _region = runtime.state.create_root_region(Budget::INFINITE);
1551            runtime.run_until_quiescent();
1552        });
1553
1554        let tmp = NamedTempFile::new().expect("tmp");
1555        report
1556            .write_json_summary(tmp.path(), false)
1557            .expect("write json");
1558        let contents = fs::read_to_string(tmp.path()).expect("read");
1559        let value: JsonValue = serde_json::from_str(&contents).expect("parse");
1560        assert!(value.get("coverage").is_some());
1561    }
1562}