You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
forbid_randomness (added in #500, policyengine_core/simulations/randomness_guard.py) enforces a per-formula invariant by mutating process-global state: it setattrs every public callable of numpy.random and the stdlib random module to a raiser while a formula runs, and it tracks re-entrancy with module-global, non-thread-local counters and no lock:
The guard is installed around the formula call in Simulation._run_formula (simulation.py:1111). Because the swap targets the shared numpy.random/random module objects, any thread in the process sees it — including threads that are not running a formula.
This is safe for single-threaded use and for cross-OS-process fan-out (separate memory), but incorrect under intra-process concurrency: threaded web workers, async request handlers, or Modal containers serving concurrent inputs, where multiple simulations are created and computed in one process at once.
Failure modes
Let thread A be inside a formula (guard installed) and thread B be doing anything else in the same process:
False positive on legitimate setup randomness.Simulation.__init__ calls np.random.seed(0) (simulation.py:162; also axes seeding :241, dataset sampling :1449/:1665-1667/:1674) — legitimate non-formula randomness that Forbid randomness inside variable formulas #500 explicitly kept. If thread B constructs a simulation while A is mid-formula, B's np.random.seed(0) hits A's raiser and throws NonDeterministicFormulaError, even though B never ran a formula. This is the "the slower concurrent sim finds np.random swapped out from under it" symptom.
Misattributed error. The raiser names _variable_stack[-1], which is a process-global stack. So the error blames whatever formula happens to be on top of the shared stack — typically an innocent variable that never touched randomness.
False negative / early restore._depth is shared and unguarded. If A and B are both in formulas and B's __exit__ drives _depth to 0 while A is still running, _restore() fires and un-patches the module mid-formula, so genuine formula randomness in A goes undetected. Interleaved +=/-= on _depth can also desync (early restore, or never-restore).
Field evidence
cliff-watch#38 (2026-07-03): "Intermittent 'rules-engine formulas must be deterministic' errors from /api/series." The guard blamed slcsp_age_0 and age_head — formulas that do not call randomness — and the failures were intermittent and not reproducible on demand. Both are hallmarks of failure modes (1)+(2): a real seed call elsewhere (setup or another request) tripping the shared guard, misattributed to the stack-top formula.
policyengine-household-api#1575 / #1576 (2026-06-25): a deploy had to pin policyengine-core<3.26.7 after the guard raised on is_ssi_recipient_for_medicaid during concurrent deployed customer-input tests.
Scope
Intra-process concurrency only (threads / async / concurrent container inputs). Separate OS processes (pytest-xdist, one-sim-per-container fan-out) do not share the module and are unaffected.
Directions (not prescribing here)
Thread-local re-entrancy state — make _depth/_variable_stackthreading.local(). Necessary but not sufficient: _install/_restore still mutate the shared module, so thread B's setup seed is still exposed to thread A's installed patch.
Stop mutating the shared module. Options: install a permanent, thread-aware shim once at import that consults a thread-local "in-formula" flag (no per-formula install/restore); or move enforcement to a static check at variable registration (inspect formula bytecode/globals for numpy.random/random references) so bad formulas fail fast and deterministically at load with zero runtime module mutation.
Combination: static gate as the primary, deterministic enforcement + optional thread-safe runtime shim.
Separate from the model-side cleanup (removing np.random from policyengine-us formulas) and the service pins; this issue is specifically the guard's concurrency-safety defect. Related: policyengine-us#8753 (stale-uv.lock CI gap that let this reach production).
Summary
forbid_randomness(added in #500,policyengine_core/simulations/randomness_guard.py) enforces a per-formula invariant by mutating process-global state: itsetattrs every public callable ofnumpy.randomand the stdlibrandommodule to a raiser while a formula runs, and it tracks re-entrancy with module-global, non-thread-local counters and no lock:The guard is installed around the formula call in
Simulation._run_formula(simulation.py:1111). Because the swap targets the sharednumpy.random/randommodule objects, any thread in the process sees it — including threads that are not running a formula.This is safe for single-threaded use and for cross-OS-process fan-out (separate memory), but incorrect under intra-process concurrency: threaded web workers,
asyncrequest handlers, or Modal containers serving concurrent inputs, where multiple simulations are created and computed in one process at once.Failure modes
Let thread A be inside a formula (guard installed) and thread B be doing anything else in the same process:
False positive on legitimate setup randomness.
Simulation.__init__callsnp.random.seed(0)(simulation.py:162; also axes seeding:241, dataset sampling:1449/:1665-1667/:1674) — legitimate non-formula randomness that Forbid randomness inside variable formulas #500 explicitly kept. If thread B constructs a simulation while A is mid-formula, B'snp.random.seed(0)hits A's raiser and throwsNonDeterministicFormulaError, even though B never ran a formula. This is the "the slower concurrent sim findsnp.randomswapped out from under it" symptom.Misattributed error. The raiser names
_variable_stack[-1], which is a process-global stack. So the error blames whatever formula happens to be on top of the shared stack — typically an innocent variable that never touched randomness.False negative / early restore.
_depthis shared and unguarded. If A and B are both in formulas and B's__exit__drives_depthto 0 while A is still running,_restore()fires and un-patches the module mid-formula, so genuine formula randomness in A goes undetected. Interleaved+=/-=on_depthcan also desync (early restore, or never-restore).Field evidence
/api/series." The guard blamedslcsp_age_0andage_head— formulas that do not call randomness — and the failures were intermittent and not reproducible on demand. Both are hallmarks of failure modes (1)+(2): a real seed call elsewhere (setup or another request) tripping the shared guard, misattributed to the stack-top formula.policyengine-core<3.26.7after the guard raised onis_ssi_recipient_for_medicaidduring concurrent deployed customer-input tests.Scope
Intra-process concurrency only (threads / async / concurrent container inputs). Separate OS processes (pytest-xdist, one-sim-per-container fan-out) do not share the module and are unaffected.
Directions (not prescribing here)
_depth/_variable_stackthreading.local(). Necessary but not sufficient:_install/_restorestill mutate the shared module, so thread B's setup seed is still exposed to thread A's installed patch.numpy.random/randomreferences) so bad formulas fail fast and deterministically at load with zero runtime module mutation.Separate from the model-side cleanup (removing
np.randomfrompolicyengine-usformulas) and the service pins; this issue is specifically the guard's concurrency-safety defect. Related: policyengine-us#8753 (stale-uv.lockCI gap that let this reach production).