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.
106pub fn count() -> u64 {
107    CALL_COUNT.with(|c| c.get())
108}
109
110/// Reseed the xorshift64 RNG and reset the call counter.
111pub fn reseed(seed: u64) {
112    // xorshift64 requires non-zero state
113    RNG_STATE.with(|c| c.set(if seed == 0 { 1 } else { seed }));
114    CALL_COUNT.with(|c| c.set(0));
115}
116
117/// Advance the xorshift64 RNG and return the next value.
118pub fn next_random() -> u64 {
119    CALL_COUNT.with(|c| c.set(c.get() + 1));
120    RNG_STATE.with(|c| {
121        let mut s = c.get();
122        s ^= s << 13;
123        s ^= s >> 7;
124        s ^= s << 17;
125        c.set(s);
126        s
127    })
128}
129
130/// Return a random integer in `0..divisor`.
131pub fn random_below(divisor: u32) -> u32 {
132    (next_random() % divisor as u64) as u32
133}
134
135// ---------------------------------------------------------------------------
136// Scenario 1: Maze cascade
137// ---------------------------------------------------------------------------
138
139/// Cascading probability gates with dependent locks.
140///
141/// 3 locks, each requiring 2 probability gates at P~0.3. Lock dependencies
142/// form a chain: lock 1 requires lock 0, lock 2 requires lock 0 + lock 1.
143/// Each gate success is a distinct fork point, creating a cascade where
144/// adaptive forking amplifies the probability at each level.
145///
146/// Brute-force probability: (0.3^2)^3 ~ 7x10^-4.
147/// With adaptive forking the cascade amplifies through 7 fork points.
148pub fn run_adaptive_maze_cascade() -> Result<(), AdaptiveTestError> {
149    crate::set_rng_hooks(count, reseed);
150    reseed(42);
151
152    crate::init(ExplorationConfig {
153        max_depth: 8,
154        timelines_per_split: 4,
155        global_energy: 150,
156        adaptive: Some(AdaptiveConfig {
157            batch_size: 4,
158            min_timelines: 4,
159            max_timelines: 20,
160            per_mark_energy: 15,
161            warm_min_timelines: None,
162        }),
163        parallelism: None,
164    })?;
165
166    // Entry gate — always triggers, guarantees the adaptive path fires
167    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_entry");
168
169    // Lock 0: two gates at P~0.3
170    let g0a = random_below(10) < 3;
171    if g0a {
172        crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_g0a");
173        let g0b = random_below(10) < 3;
174        if g0b {
175            crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_lock0");
176
177            // Lock 1: requires lock 0, two gates at P~0.3
178            let g1a = random_below(10) < 3;
179            if g1a {
180                crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_g1a");
181                let g1b = random_below(10) < 3;
182                if g1b {
183                    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_lock1");
184
185                    // Lock 2: requires lock 0 + lock 1, two gates at P~0.3
186                    let g2a = random_below(10) < 3;
187                    if g2a {
188                        crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "maze_g2a");
189                        let g2b = random_below(10) < 3;
190                        if g2b {
191                            crate::assertion_bool(
192                                crate::AssertKind::Sometimes,
193                                true,
194                                true,
195                                "maze_lock2",
196                            );
197
198                            // Bug: all 3 locks open!
199                            if crate::explorer_is_child() {
200                                crate::exit_child(42);
201                            }
202                        }
203                    }
204                }
205            }
206        }
207    }
208
209    // Children must exit to avoid running parent assertions
210    if crate::explorer_is_child() {
211        crate::exit_child(0);
212    }
213
214    // Parent: read stats before cleanup frees shared memory
215    let stats = crate::exploration_stats().ok_or(AdaptiveTestError::StatsUnavailable)?;
216    crate::cleanup();
217
218    if stats.total_timelines == 0 {
219        return Err(AdaptiveTestError::NoForks {
220            total: stats.total_timelines,
221        });
222    }
223    if stats.fork_points == 0 {
224        return Err(AdaptiveTestError::NoForkPoints {
225            points: stats.fork_points,
226        });
227    }
228
229    Ok(())
230}
231
232// ---------------------------------------------------------------------------
233// Scenario 2: Dungeon floors
234// ---------------------------------------------------------------------------
235
236/// Progressive multi-floor exploration.
237///
238/// 5 floors, each with a key gate at P=0.2. Must find key on floor N to
239/// attempt floor N+1 (linear chain). Each floor key discovery is a fork
240/// point, so the adaptive explorer cascades deeper with each floor reached.
241///
242/// Brute-force probability: 0.2^5 ~ 3.2x10^-4.
243/// Fork cascade amplifies at each floor.
244pub fn run_adaptive_dungeon_floors() -> Result<(), AdaptiveTestError> {
245    crate::set_rng_hooks(count, reseed);
246    reseed(7777);
247
248    crate::init(ExplorationConfig {
249        max_depth: 7,
250        timelines_per_split: 4,
251        global_energy: 200,
252        adaptive: Some(AdaptiveConfig {
253            batch_size: 4,
254            min_timelines: 4,
255            max_timelines: 25,
256            per_mark_energy: 20,
257            warm_min_timelines: None,
258        }),
259        parallelism: None,
260    })?;
261
262    // Entry — always triggers, starts the exploration
263    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "dungeon_entry");
264
265    let mut floors_cleared = 0u32;
266
267    for floor in 0..5 {
268        // Key gate: P=0.2
269        let has_key = random_below(5) == 0;
270        if !has_key {
271            break;
272        }
273
274        // Each floor gets its own fork point name
275        let name = match floor {
276            0 => "dungeon_f0",
277            1 => "dungeon_f1",
278            2 => "dungeon_f2",
279            3 => "dungeon_f3",
280            4 => "dungeon_f4",
281            _ => break,
282        };
283        crate::assertion_bool(crate::AssertKind::Sometimes, true, true, name);
284        floors_cleared = floor + 1;
285    }
286
287    // Treasure found: all 5 keys collected
288    if floors_cleared == 5 && crate::explorer_is_child() {
289        crate::exit_child(42);
290    }
291
292    if crate::explorer_is_child() {
293        crate::exit_child(0);
294    }
295
296    let stats = crate::exploration_stats().ok_or(AdaptiveTestError::StatsUnavailable)?;
297    crate::cleanup();
298
299    if stats.total_timelines == 0 {
300        return Err(AdaptiveTestError::NoForks {
301            total: stats.total_timelines,
302        });
303    }
304    if stats.fork_points == 0 {
305        return Err(AdaptiveTestError::NoForkPoints {
306            points: stats.fork_points,
307        });
308    }
309
310    Ok(())
311}
312
313// ---------------------------------------------------------------------------
314// Scenario 3: Energy budget
315// ---------------------------------------------------------------------------
316
317/// Verify that the 3-level energy budget (global, per-mark, realloc pool)
318/// constrains adaptive forking. Uses low energy values to ensure bounds.
319///
320/// 3 always-true gates maximize energy consumption. The global energy cap
321/// of 8 limits total forks regardless of per-mark budgets.
322pub fn run_adaptive_energy_budget() -> Result<(), AdaptiveTestError> {
323    crate::set_rng_hooks(count, reseed);
324    reseed(99);
325
326    crate::init(ExplorationConfig {
327        max_depth: 3,
328        timelines_per_split: 4,
329        global_energy: 8,
330        adaptive: Some(AdaptiveConfig {
331            batch_size: 2,
332            min_timelines: 2,
333            max_timelines: 6,
334            per_mark_energy: 3,
335            warm_min_timelines: None,
336        }),
337        parallelism: None,
338    })?;
339
340    // All gates always fire — maximizes energy consumption
341    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "energy_a");
342    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "energy_b");
343    crate::assertion_bool(crate::AssertKind::Sometimes, true, true, "energy_c");
344
345    if crate::explorer_is_child() {
346        crate::exit_child(0);
347    }
348
349    let stats = crate::exploration_stats().ok_or(AdaptiveTestError::StatsUnavailable)?;
350    crate::cleanup();
351
352    if stats.total_timelines > 8 {
353        return Err(AdaptiveTestError::EnergyExceeded {
354            total: stats.total_timelines,
355            limit: 8,
356        });
357    }
358    if stats.global_energy < 0 {
359        return Err(AdaptiveTestError::EnergyNegative {
360            energy: stats.global_energy,
361        });
362    }
363    if stats.realloc_pool_remaining < 0 {
364        return Err(AdaptiveTestError::PoolNegative {
365            pool: stats.realloc_pool_remaining,
366        });
367    }
368
369    Ok(())
370}