Skip to content

Evaluate recursions over periods in full when they reach an input or the start of a formula - #572

Draft
MaxGhenis wants to merge 10 commits into
masterfrom
fix-spiral-anchored-recursion
Draft

MaxGhenis wants to merge 10 commits into
masterfrom
fix-spiral-anchored-recursion

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Finite anchored period recursions are evaluated in full regardless of previously calculated periods, tracing, or persistent cache policy. Unanchored recursions still use the existing depth limit, and values reached by a cut are discarded when the calculation ends.

Repair and invariants

  • Deeper periods are scheduled iteratively. Once a deferred key starts evaluation, recursion cannot defer it again; its pending calculation is retried after deeper dependencies finish. For a fixed registered branch graph with finitely many reachable calculation keys, scheduling makes progress without a cache-dependent 10,000-deferral budget.
  • Only actual readable inputs of the variable's own period unit, or formula boundaries in the direction of recursion, authorize deeper evaluation. Deleted inputs and derived replacements cannot remain anchors through stale registry entries.
  • Outstanding scheduling requests are recorded before they are raised and checked outside formula exception handlers before publishing a result.
  • Deferred periods retain the caller scope required by requires_computation_after.
  • A temporary cache holds completed, cut-free deferred results independently of tracing, _do_not_store, and cache blacklisting.
  • Spiral cleanup removes exact derived entries from their owner; independent descendant inputs and contained periods survive. Clones drop copied cut values before becoming usable. Existing explicit invalidation behavior remains separate.
  • Flat traces prefer accepted completed calculations over abandoned or provisional attempts, retaining their dependencies and parameter accesses ahead of later cache reads.

Round-2 findings

All eight review findings are addressed: descendant input deletion; swallowed scheduling exceptions; lost prerequisite caller; invalid clone snapshots; carry-over from calculated values (settled by master's input-only provenance); None retry traces; tracing/cache-policy differences; and cache-dependent deferral exhaustion. The inherited memory sharing, input/derived metadata, disk directory lifetime, clone aliases, system entities and reform baselines from master are preserved.

The original 200-example recurrence order property is retained. Added differential properties compare finite forward/backward recurrences with an independent iterative NumPy evaluator across tracing, storage policies, guarded formulas and caller scope; additional properties cover cut copies, descendant inputs and deleted input provenance. Exact deletion is compared across copied memory, shared memory and disk snapshots.

Validation

Targeted, serial single-file checks: 245 passed in total.

File under tests/core/ Result
test_spiral_order.py 109 passed; includes a cold/warm 10,002-month anchored chain
test_spiral_order_property.py 1 passed, original 200 generated examples retained
test_spiral_scheduler_property.py 4 passed, 60/40/24/24 generated examples plus explicit examples
test_spiral_storage.py 12 passed
test_spiral_trace_retries.py 6 passed
test_cycles.py 5 passed
test_fast_cache_guards.py 5 passed
test_clone_invalidated_caches.py 14 passed
test_carry_over_order.py 4 focused provenance cases passed, 60 deselected
test_docs_deployment.py 5 passed
test_simulation_copy_pickle.py 78 passed after the final master merge
test_simulation_copy_pickle_property.py 2 passed after the final master merge, 60/40 generated examples

The final merge leaves the repaired scheduler/storage/tracer source and spiral tests unchanged. The eight deleted-input cases also passed again through the offline uv run launcher before the final clean master merge. Ruff format/check passed for all 19 repaired or incoming Python files, and git diff --check passed. Final copy checks emitted only the existing NumPy generic-timedelta deprecation warning.

Repaired head: f34b54c569c6d700ca18046e8ff106c0379ce5e3.

Baseline checks on the history-preserving merge 2ed6c34db0f5e6eae1b9dee6397c5c034938bb93 reproduced 32 failures, with 42 passing and 27 deselected cases. Carry-over cases passed there because master's input-only provenance already repairs them. The additional deleted-input regression failed in all eight cases before the provenance fix.

The fresh dependency install failed on PyPI DNS. Successful checks use the existing read-only Python 3.13 environment, with this checkout first on PYTHONPATH; the final focused provenance and copy checks launch it through offline, frozen, no-sync uv run. The first run of the added recurrence differential file hit Hypothesis's input-generation timing health check (one failed, three passed); only too_slow was suppressed for its two bounded generators, with every example count and assertion retained, and all four tests then passed. No full suite, country tests, partner baselines, documentation build or new microsimulation was run. Earlier full-suite and impact results are historical evidence only.

Composition and status

This remains draft and held for Max's core-semantics merge decision. Current master 0946ba3d826b9a9bfece08b28008b52a37b554c4, including its copy/pickle repairs, is merged without rewriting history. Independent source review of the exact repaired head found no introduced P1/P2; current-head green CI and downstream impact remain required before landing. #576 is absent from this master; the second lander must connect this PR's new Holder.delete_array to #576's _evict_fast_cache, preserving input protection and exact spiral cleanup. #566 is planned to fold into #576. No decision ID is associated with this bounded repair.

Conditional formula-only base cases and defined_for masks still use the existing cut behavior. Mixed-direction finite recurrence graphs have less differential coverage than same-direction anchored graphs; their existing regressions and the original order property are preserved.

axiom: n/a: core engine repair, no policy encoding.

Historical downstream evidence

The following measurements belong to the original PR head and its original scratch merge. They have not been repeated on f34b54c569c6d700ca18046e8ff106c0379ce5e3; current-head impact and partner gates remain pending.

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.

Run Outputs compared Result
policyengine-us 6c5170fd, eCPS 2024, 3,000-household subsample, 2026 (income tax, itemizing and SALT branches, state taxes, CTC, EITC, SNAP, household and SPM net income, weights, marginal tax rates) 25 arrays bitwise identical
same, 2035 25 arrays bitwise identical
policyengine-uk 7b9fc379, enhanced FRS 2024-25, full sample, 2026 (income tax, NI, UC and legacy benefits, Pension Credit, HB, council tax, HBAI income, poverty, marginal tax rates) 36 arrays bitwise identical

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_worked in 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 no weeks_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):

2035 New simulation After calculating 2030
master: weeks_worked, spm_unit_work_expenses 0, $0 40, $1,800.14
this branch 40, $1,800.14 40, $1,800.14

So household calculations from 2035 on (ten years after the 2025 formula start) change, to the value calculating year by year gives.

Generated with Claude Code.

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>
MaxGhenis and others added 9 commits October 6, 2026 07:30
Conflicts:
- holders/holder.py (put_in_cache): kept master's derived guard (a
  derived value never replaces a readable input) and derived=True
  storage, then the PR's _note_cached_value call after the store.
- simulations/simulation.py (clone): kept master's storage-directory
  and _user_input_keys copying, then the PR's per-clone
  invalidated_caches.

Semantic integration: delete_exact now uses master's bookkeeping. On
memory, _stop_sharing (master's _shared is a frozenset while nothing is
shared, so .discard raised AttributeError) and _unmark_input; on disk,
the _derived mark is discarded with the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Branch and copy cleanup (findings 1 and 4). A cut in a simulation's
calculation no longer deletes its branch-name key from every
descendant, which deleted a descendant's own input stored under the
same name. A copy (clone or branch) instead drops, when it is made, the
values the simulation it copies is waiting to purge: every such value
is marked by the time it is cached, and a copy never sees what is
cached after. Purges delete only values the simulation calculated
(derived), never an input.

Swallowed requests (2). Each request to finish a deeper period first is
recorded as outstanding when raised. A formula that returns while one
is outstanding (a bare except caught it) has its value dropped and the
request raised again; an error raised while one is outstanding is
treated as the request.

requires_computation_after (3). A deferred period keeps the names of
the variables that needed it, so its caller still counts while it is
calculated with an empty stack.

Tracing and storage (6, 7). The flat trace prefers a completed node to
an abandoned one. Deferred values are kept for the outermost
calculation in its own cache, which _calculate reads, so deferral works
the same traced or not and when the holder stores nothing; values
calculated under a cut are marked for purging when written to the fast
cache too.

Termination (8). MAX_SPIRAL_DEFERRALS is gone: it cut long chains
depending on what was cached. Each deferred period starts at most once
per outermost calculation, and only on the requested simulation or its
registered branches (a copy made in a formula is made anew on every
retry), so the calculation still ends.

Finding 5 is settled by master's #562 (only inputs carry over); its
regression test passes on the merge commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Keep master clone aliases, system entities, independent baselines, and explicit input/contained-period invalidations. Distinguish spiral cleanup from explicit invalidation, and prefer accepted trace results over provisional cut retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retain the original recurrence order generator. Compare finite anchored systems against an independent iterative NumPy evaluator across caller authorization, bare exception handlers, tracing, cache policies, copies and descendant inputs. Compare exact-deletion snapshots and provenance across copied memory, shared memory and disk storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Describe the shared behavior without claiming disk delete uses contained-period deletion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Use readable storage provenance instead of stale input registry keys. Preserve replay bookkeeping and inherited inputs. Add traced and untraced deletion regressions and a bounded provenance property; clarify deferred-key retry invariants.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preserve the canonical docs release job without changing scheduler, holder, storage or tracer behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Retain all example counts and semantic assertions. Suppress only the slow-generation health check for the bounded two-variable generator after a 47-second draw interrupted otherwise passing properties on the shared host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Preserve the incoming Population protocol guards, EnumArray serialization, release artifacts, and focused copy coverage alongside deferred recursion scheduling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant