Conversation
max_spiral_loops capped how many uncached frames of one variable the stack could hold, returning the default where the cap fell. Since it counted uncached frames only, a recursion's result depended on what had been calculated before: with an input of 1 in 2010 and recursive(t) = recursive(t - 1) + 1, 2021 was 10 in a new simulation and 12 after calculating 2020. policyengine-us weeks_worked (formula_2025 reading the year before, input in 2024) is cut this way from 2035 on. A recursion that reaches the limit while heading towards an input, or a period with no formula, now unwinds to the outermost calculate, which calculates the deepest period first and starts again; the chain is evaluated all the way down, max_spiral_loops frames at a time. One with no anchor is still cut, but no value calculated while a cut is on the stack outlives that calculation (values above a cut used to be kept), spiral purges delete exactly the period under the calculating branch's name (they deleted contained periods under the default branch), and each clone has its own invalidated set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 2, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
max_spiral_loopscaps how many frames of one variable the calculation stack holds; beyond it_check_for_cycleraisedSpiralErrorand the deepest frame got the variable's default. The cap counts uncached frames only, so a recursion over periods gave a different result depending on what had been calculated before:recursive(t) = recursive(t - 1) + 1(formula_2010), input 1 in 2010max_spiral_loops = 1, 2012The true values are 3 and 12. On real data, policyengine-us
weeks_worked(formula_2025: last year's value; eCPS input in 2024) is cut in a new 2035 simulation and returns the default from 2025, which feeds SPM work expenses. Found while reviewing #562 (soundness review, finding 6).The rule
endgoing forward; or an input of the variable's own unit), the outermostcalculatecalculates that deepest period first, with an empty stack, and then starts its own request again. Deferred periods are kept in a list, so a chain of any length is evaluatedmax_spiral_loopsframes at a time without growing the Python stack. The control-flow exception,_DeeperPeriodFirst, derives fromBaseExceptionso a formula catchingExceptiondoesn't swallow it.adds/subtractsat its period ends a recursion and is never treated as a spiral.test_spiral_cache) and gave later calculations results that depended on order. They are still reused within the calculation that made them.default, so a branch kept its cut values), including branches made since. Each clone has its owninvalidated_caches(master's branches shared the parent's set object, so a branch's cut could later delete the parent's values).Known residual, same as master: a recursion whose only base case is a condition inside a formula, or a
defined_formask, is still cut at the cap.Invariants
max_spiral_loopsand any earlier requests, a request returns what it returns in a new simulation. Property test:tests/core/test_spiral_order_property.py(Hypothesis, 200 examples: one or two yearly variables, lags 0-2 years, dated or undated formulas, inputs, limits 1, 2, 3, 10). It fails on master. A broader scratch exploration over the same space found 80 order-dependent systems in 2,000 on master and 0 in 7,500 with this change.CycleError; recursions with no anchor still return the same cut value as master on a new simulation.Downstream impact
Real runs on master b78b0ba and on all three of #571/#572/#573 merged together (scratch merge 37aadada), each output compared array by array (
compare.py). The PE-US 2026 run was also repeated on each branch alone, and the 2035 run on #572 alone; all identical too.A probe of the PE-US 2026 and 2035 and PE-UK 2026 and 2042 runs on master found one recursion cut by the limit: PE-US
weeks_workedin 2035 (below).PE-US
weeks_worked(formula_2025: last year's value) is the one recursion the probe saw reach the limit on real data: a new 2035 simulation cuts it at 2025. The eCPS file carries noweeks_worked, so every year is the default 0 and the cut changes nothing above. Where the input exists it does. A real policyengine-us run on a household with one worker (40 weeks entered for 2024):weeks_worked,spm_unit_work_expensesSo household calculations from 2035 on (ten years after the 2025 formula start) change, to the value calculating year by year gives.
Tests
tests/core/test_spiral_order.py: 19 regressions (anchored by input, by a dated formula, byend, through another variable; unanchored cut; values reached by and above a cut not kept; skipped input does not loop; cycles;except Exceptionformulas; the deferral cap; branches traced and untraced, called from formulas, made after a cut; a branch's cut leaving the parent's input alone). 14 fail on master; the other 5 pin behaviour kept (unanchored cut value, cycles, a skipped input).tests/core/test_cycles.py::test_spiral_cachenow asserts the value above a cut is not kept, and that a later request returns the same value.tests/core/test_fast_cache_guards.py: its stub holder gainsdelete_array.API
New:
Holder.delete_array(period, branch_name)(exact period),InMemoryStorage.delete_exact,OnDiskStorage.delete_exact, module constantMAX_SPIRAL_DEFERRALS.calculatenow delegates to_calculate_traced.invalidate_spiral_variablesmarks every frame on the stack. Country packages: PE-UK'sSimulation.__init__setsinvalidated_cachesitself (unaffected); PE-UStools/branched_simulation.py(unused) replaces_check_for_cycle.Composition
#566 also edits
purge_cache_of_invalid_values(fast-cache eviction line); the conflict is textual. Merges cleanly with the ADD/DIVIDE and subsample PRs from the same review.axiom: n/a: core engine, no policy encoding
🤖 Generated with Claude Code