Skip to main content

moonpool_explorer/simulations/
adaptive.rs

1//! Adaptive exploration scenario functions.
2//!
3//! These scenarios exercise the adaptive code path (`dispatch_split` ->
4//! `adaptive_split_on_discovery`) with coverage-yield-driven batching
5//! and 3-level energy budgets.
6//!
7//! Each scenario provides a minimal xorshift64 RNG via thread-local storage,
8//! wired into moonpool-explorer through `set_rng_hooks`. Since scenarios use
9//! `fork()`, each must run in its own process (nextest default).
10
11use std::cell::Cell;
12use std::fmt;
13
14use crate::{AdaptiveConfig, ExplorationConfig};
15
16/// Errors from adaptive exploration test scenarios.
17#[derive(Debug)]
18pub enum AdaptiveTestError {
19    /// Exploration initialization failed.
20    Init(std::io::Error),
21    /// Exploration stats were not available after cleanup.
22    StatsUnavailable,
23    /// Expected forked children but none were produced.
24    NoForks {
25        /// Actual timeline count.
26        total: u64,
27    },
28    /// Expected fork points but none were triggered.
29    NoForkPoints {
30        /// Actual fork point count.
31        points: u64,
32    },
33    /// Global energy cap was exceeded.
34    EnergyExceeded {
35        /// Actual timeline count.
36        total: u64,
37        /// Maximum expected.
38        limit: u64,
39    },
40    /// Global energy went negative.
41    EnergyNegative {
42        /// Remaining global energy.
43        energy: i64,
44    },
45    /// Reallocation pool went negative.
46    PoolNegative {
47        /// Remaining pool energy.
48        pool: i64,
49    },
50}
51
52impl fmt::Display for AdaptiveTestError {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            Self::Init(e) => write!(f, "init failed: {e}"),
56            Self::StatsUnavailable => write!(f, "stats unavailable"),
57            Self::NoForks { total } => {
58                write!(f, "expected forked children, got total_timelines={total}")
59            }
60            Self::NoForkPoints { points } => {
61                write!(f, "expected fork points, got fork_points={points}")
62            }
63            Self::EnergyExceeded { total, limit } => {
64                write!(
65                    f,
66                    "energy limit exceeded: total_timelines={total} (expected <= {limit})"
67                )
68            }
69            Self::EnergyNegative { energy } => {
70                write!(f, "energy went negative: global_energy={energy}")
71            }
72            Self::PoolNegative { pool } => {
73                write!(f, "realloc pool went negative: realloc_pool={pool}")
74            }
75        }
76    }
77}
78
79impl std::error::Error for AdaptiveTestError {
80    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
81        match self {
82            Self::Init(e) => Some(e),
83            _ => None,
84        }
85    }
86}
87
88impl From<std::io::Error> for AdaptiveTestError {
89    fn from(e: std::io::Error) -> Self {
90        Self::Init(e)
91    }
92}
93
94// ---------------------------------------------------------------------------
95// Shared xorshift64 RNG infrastructure
96// ---------------------------------------------------------------------------
97
98thread_local! {
99    /// Current xorshift64 RNG state.
100    pub static RNG_STATE: Cell<u64> = const { Cell::new(1) };
101    /// Number of RNG calls since last reseed.
102    pub static CALL_COUNT: Cell<u64> = const { Cell::new(0) };
103}
104
105/// Return the current RNG call count.
106#[must_use]
107pub fn count() -> u64 {
108    CALL_COUNT.with(std::cell::Cell::get)
109}
110
111/// Reseed the xorshift64 RNG and reset the call counter.
112pub fn reseed(seed: u64) {
113    // xorshift64 requires non-zero state
114    RNG_STATE.with(|c| c.set(if seed == 0 { 1 } else { seed }));
115    CALL_COUNT.with(|c| c.set(0));
116}
117
118/// Advance the xorshift64 RNG and return the next value.
119#[must_use]
120pub fn next_random() -> u64 {
121    CALL_COUNT.with(|c| c.set(c.get() + 1));
122    RNG_STATE.with(|c| {
123        let mut s = c.get();
124        s ^= s << 13;
125        s ^= s >> 7;
126        s ^= s << 17;
127        c.set(s);
128        s
129    })
130}
131
132/// Return a random integer in `0..divisor`.
133#[must_use]
134pub fn random_below(divisor: u32) -> u32 {
135    // `next_random() % u64::from(divisor)` is always < divisor (≤ u32::MAX),
136    // so the cast is lossless; `unwrap_or` keeps us out of the panic path.
137    u32::try_from(next_random() % u64::from(divisor)).unwrap_or(0)
138}
139
140// ---------------------------------------------------------------------------
141// Scenario 1: Maze cascade
142// ---------------------------------------------------------------------------
143
144/// Cascading probability gates with dependent locks.
145///
146/// 3 locks, each requiring 2 probability gates at P~0.3. Lock dependencies
147/// form a chain: lock 1 requires lock 0, lock 2 requires lock 0 + lock 1.
148/// Each gate success is a distinct fork point, creating a cascade where
149/// adaptive forking amplifies the probability at each level.
150///
151/// Brute-force probability: (0.3^2)^3 ~ 7x10^-4.
152/// With adaptive forking the cascade amplifies through 7 fork points.
153///
154/// # Errors
155///
156/// Returns an [`AdaptiveTestError`] if exploration initialization fails,
157/// stats cannot be read, or the exploration produces zero forks/fork-points.
158pub fn run_adaptive_maze_cascade() -> Result<(), AdaptiveTestError> {
159    crate::set_rng_hooks(count, reseed);
160    reseed(42);
161
162    crate::init(&ExplorationConfig {
163        max_depth: 8,
164        timelines_per_split: 4,
165        global_energy: 150,
166        adaptive: Some(AdaptiveConfig {
167            batch_size: 4,
168            min_timelines: 4,
169            max_timelines: 20,
170            per_mark_energy: 15,
171            warm_min_timelines: None,
172        }),
173        parallelism: None,
174    })?;
175
176    // Entry gate — always triggers, guarantees the adaptive path fires
177    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_entry");
178
179    // Lock 0: two gates at P~0.3
180    let g0a = random_below(10) < 3;
181    if g0a {
182        crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_g0a");
183        let g0b = random_below(10) < 3;
184        if g0b {
185            crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_lock0");
186
187            // Lock 1: requires lock 0, two gates at P~0.3
188            let g1a = random_below(10) < 3;
189            if g1a {
190                crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_g1a");
191                let g1b = random_below(10) < 3;
192                if g1b {
193                    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_lock1");
194
195                    // Lock 2: requires lock 0 + lock 1, two gates at P~0.3
196                    let g2a = random_below(10) < 3;
197                    if g2a {
198                        crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_g2a");
199                        let g2b = random_below(10) < 3;
200                        if g2b {
201                            crate::assertion_bool(
202                                crate::AssertKind::Sometimes,
203                                true,
204                                true,
205                                "maze_lock2",
206                            );
207
208                            // Bug: all 3 locks open!
209                            if crate::explorer_is_child() {
210                                crate::exit_child(42);
211                            }
212                        }
213                    }
214                }
215            }
216        }
217    }
218
219    // Children must exit to avoid running parent assertions
220    if crate::explorer_is_child() {
221        crate::exit_child(0);
222    }
223
224    // Parent: read stats before cleanup frees shared memory
225    let stats = crate::exploration_stats().ok_or(AdaptiveTestError::StatsUnavailable)?;
226    crate::cleanup();
227
228    if stats.total_timelines == 0 {
229        return Err(AdaptiveTestError::NoForks {
230            total: stats.total_timelines,
231        });
232    }
233    if stats.fork_points == 0 {
234        return Err(AdaptiveTestError::NoForkPoints {
235            points: stats.fork_points,
236        });
237    }
238
239    Ok(())
240}
241
242// ---------------------------------------------------------------------------
243// Scenario 2: Dungeon floors
244// ---------------------------------------------------------------------------
245
246/// Progressive multi-floor exploration.
247///
248/// 5 floors, each with a key gate at P=0.2. Must find key on floor N to
249/// attempt floor N+1 (linear chain). Each floor key discovery is a fork
250/// point, so the adaptive explorer cascades deeper with each floor reached.
251///
252/// Brute-force probability: 0.2^5 ~ 3.2x10^-4.
253/// Fork cascade amplifies at each floor.
254///
255/// # Errors
256///
257/// Returns an [`AdaptiveTestError`] if exploration initialization fails,
258/// stats cannot be read, or the exploration produces zero forks/fork-points.
259pub fn run_adaptive_dungeon_floors() -> Result<(), AdaptiveTestError> {
260    crate::set_rng_hooks(count, reseed);
261    reseed(7777);
262
263    crate::init(&ExplorationConfig {
264        max_depth: 7,
265        timelines_per_split: 4,
266        global_energy: 200,
267        adaptive: Some(AdaptiveConfig {
268            batch_size: 4,
269            min_timelines: 4,
270            max_timelines: 25,
271            per_mark_energy: 20,
272            warm_min_timelines: None,
273        }),
274        parallelism: None,
275    })?;
276
277    // Entry — always triggers, starts the exploration
278    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "dungeon_entry");
279
280    let mut floors_cleared = 0u32;
281
282    for floor in 0..5 {
283        // Key gate: P=0.2
284        let has_key = random_below(5) == 0;
285        if !has_key {
286            break;
287        }
288
289        // Each floor gets its own fork point name
290        let name = match floor {
291            0 => "dungeon_f0",
292            1 => "dungeon_f1",
293            2 => "dungeon_f2",
294            3 => "dungeon_f3",
295            4 => "dungeon_f4",
296            _ => break,
297        };
298        crate::assertion_bool(crate::AssertKind::Sometimes, true, true, name);
299        floors_cleared = floor + 1;
300    }
301
302    // Treasure found: all 5 keys collected
303    if floors_cleared == 5 && crate::explorer_is_child() {
304        crate::exit_child(42);
305    }
306
307    if crate::explorer_is_child() {
308        crate::exit_child(0);
309    }
310
311    let stats = crate::exploration_stats().ok_or(AdaptiveTestError::StatsUnavailable)?;
312    crate::cleanup();
313
314    if stats.total_timelines == 0 {
315        return Err(AdaptiveTestError::NoForks {
316            total: stats.total_timelines,
317        });
318    }
319    if stats.fork_points == 0 {
320        return Err(AdaptiveTestError::NoForkPoints {
321            points: stats.fork_points,
322        });
323    }
324
325    Ok(())
326}
327
328// ---------------------------------------------------------------------------
329// Scenario 3: Energy budget
330// ---------------------------------------------------------------------------
331
332/// Verify that the 3-level energy budget (global, per-mark, realloc pool)
333/// constrains adaptive forking. Uses low energy values to ensure bounds.
334///
335/// 3 always-true gates maximize energy consumption. The global energy cap
336/// of 8 limits total forks regardless of per-mark budgets.
337///
338/// # Errors
339///
340/// Returns an [`AdaptiveTestError`] if exploration initialization fails,
341/// stats cannot be read, or the energy invariants are violated (total
342/// timelines exceed cap, global energy negative, or pool negative).
343pub fn run_adaptive_energy_budget() -> Result<(), AdaptiveTestError> {
344    crate::set_rng_hooks(count, reseed);
345    reseed(99);
346
347    crate::init(&ExplorationConfig {
348        max_depth: 3,
349        timelines_per_split: 4,
350        global_energy: 8,
351        adaptive: Some(AdaptiveConfig {
352            batch_size: 2,
353            min_timelines: 2,
354            max_timelines: 6,
355            per_mark_energy: 3,
356            warm_min_timelines: None,
357        }),
358        parallelism: None,
359    })?;
360
361    // All gates always fire — maximizes energy consumption
362    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "energy_a");
363    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "energy_b");
364    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "energy_c");
365
366    if crate::explorer_is_child() {
367        crate::exit_child(0);
368    }
369
370    let stats = crate::exploration_stats().ok_or(AdaptiveTestError::StatsUnavailable)?;
371    crate::cleanup();
372
373    if stats.total_timelines > 8 {
374        return Err(AdaptiveTestError::EnergyExceeded {
375            total: stats.total_timelines,
376            limit: 8,
377        });
378    }
379    if stats.global_energy < 0 {
380        return Err(AdaptiveTestError::EnergyNegative {
381            energy: stats.global_energy,
382        });
383    }
384    if stats.realloc_pool_remaining < 0 {
385        return Err(AdaptiveTestError::PoolNegative {
386            pool: stats.realloc_pool_remaining,
387        });
388    }
389
390    Ok(())
391}