Skip to content

Drop every contained period from the fast cache when deleting or setting a period - #566

Draft
MaxGhenis wants to merge 1 commit into
masterfrom
fix-delete-arrays-fast-cache
Draft

MaxGhenis wants to merge 1 commit into
masterfrom
fix-delete-arrays-fast-cache

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Holder.delete_arrays(period) deletes every stored period that period contains. Deleting "2012" also deletes "2012-01" to "2012-12", and any period deletes an ETERNITY variable's one value. set_input handlers likewise store every month of an annual input.

The simulation's _fast_cache (checked at the top of calculate) was evicted by popping only the exact (variable, period) key. So calculate kept returning values storage had deleted or replaced.

Found by the adversarial review (GPT-6.1 Sol, 2026-10-01) of #562. It reproduces on master 7950c01 and b78b0ba. Each case below returns the stale value on master and the right one here:

Path Case master This PR
delete_arrays Monthly float input with auto-carry-over: set_input("m","2012-01",[100]), calculate("m","2012-02"), delete_arrays("m","2012"), set_input("m","2012-01",[200]). get_array("m","2012-02") is None. calculate("m","2012-02") is 100 200, as in a fresh simulation
delete_arrays ETERNITY formula 2 × e: calculate for 2013, delete_arrays it for 2012, set e to 7 10 14
set_input ETERNITY input calculated for 2012 (default 0), then set_input(..., "2013", [5]). Storage holds 5. 0 5
set_input Cache-blacklisted monthly variable with set_input_divide_by_period: February calculated (0), then set_input(..., "2012", [1200]). Storage holds 100. 0 100
purge_cache_of_invalid_values Monthly formula m + 1 calculated for 2012-02, then (name, "2012") invalidated and purged, then m set to 20 11 21

Fix

Simulation._evict_fast_cache(variable_name, period=None) drops each of the variable's fast-cache entries whose period period contains, using the same Period.contains test as InMemoryStorage.delete. It drops all of the variable's entries when period is None or the variable is defined for ETERNITY. delete_arrays, set_input and purge_cache_of_invalid_values call it instead of popping one key.

  • Scope. The fast cache belongs to one simulation object (clone, and so get_branch, start a new one), so the scope matches delete_arrays: this simulation, not its branches.
  • Cost. With an empty cache it returns at once, so building a simulation from a dataset does no extra work. Otherwise it scans the cache once per call. delete_arrays without a period already rebuilt the whole dict.
  • No new eviction triggers. Every entry it drops is one storage no longer holds or has just replaced.

Invariants (Hypothesis properties in test_fast_cache_contained_periods_property.py)

  1. The fast cache never changes what calculate returns. Random sequences of set_input, calculate and delete_arrays run on two simulations; one empties its fast cache before every calculate. Every result agrees (value, or error type), and so does every stored value at the end.
    • Variables: a monthly input with a formula on it, a monthly input split from annual values, a yearly input, and an ETERNITY input with a formula.
    • Periods: months, years and ETERNITY.
    • Sequences span all variables or focus on one of them.
  2. delete_arrays drops from the fast cache exactly what it drops from storage. Afterwards the variable's fast-cache periods equal its stored periods, for any mix of day, month, year and multi-unit periods. Other variables' entries are untouched, so nothing is evicted needlessly.

Tests

  • tests/core/test_fast_cache_contained_periods.py: 7 example tests.
    • 6 fail on master b78b0ba.
    • The 7th pins that deleting a month keeps the rest of the year cached.
  • tests/core/test_fast_cache_contained_periods_property.py: the two properties.
    • Both fail on master. Shrunk example: calculate a monthly input for 2012-01 (default 0), delete_arrays it for ETERNITY, calculate again. master answers from the stale entry and never stores the value again.
    • The module starts with pytest.importorskip("hypothesis"), because the smoke job installs no dev dependencies.
  • Shared variables and builder: tests/fixtures/fast_cache_contained_periods.py.
  • Mutation check (each mutant applied to a copy and run against the new tests plus test_fast_cache.py and test_fast_cache_guards.py): all 7 mutants killed.
    • Exact-key pop in each of the three call sites.
    • No ETERNITY rule.
    • Reversed containment.
    • No eviction when a period is given.
    • Evicting the whole variable (killed by the two "keeps what it does not contain" tests and property 2).
  • Full suite (Python 3.13): 1,152 passed, 4 skipped, 1 xfailed. Country-template YAML tests: 39 passed. uvx ruff format --check . and uvx ruff check . pass.
  • PE-US/PE-UK single-year A/B (core master b78b0ba vs this branch, same country code and data): outputs are bitwise identical, so no published number moves.
    • PE-UK (main c7e826ea5, Enhanced FRS 2026): 36 of 36 arrays identical, including marginal_tax_rate; user CPU 9.49 s vs 9.45 s.
    • PE-US (main fbe24ad1b3, Enhanced CPS 2026, 3,000-household subsample, with marginal_tax_rate, which creates a branch per adult and calls delete_arrays and set_input on it): 25 of 25 arrays identical; income tax $1,962.456bn on both.
    • Eviction cost in the PE-US run: 12,460 calls, 0.002 s in total. The cache was empty in all but 202 calls and never held more than one entry when scanned.
    • Wall times on the shared host were too noisy to compare.

Not in this PR

Overlap with other PRs

axiom: n/a: core cache infrastructure, no policy change

🤖 Generated with Claude Code

Holder storage deletes every period the deleted one contains (deleting
"2012" also deletes "2012-02"), and an ETERNITY variable keeps one value
for every period. Simulation.delete_arrays, set_input and
purge_cache_of_invalid_values popped only the exact (variable, period)
key from _fast_cache, so calculate kept returning values storage had
deleted or replaced: a month after its year was deleted, an ETERNITY
value set or deleted at another period, months of an annual input split
by a set_input handler, and months of a spiral-invalidated year.

_evict_fast_cache drops each of the variable's entries whose period the
given period contains, using the same Period.contains test as
InMemoryStorage.delete, and all of them for ETERNITY variables or no
period.

Found by the adversarial review of #562 (GPT-6.1 Sol, 2026-10-01);
reproduces on master 7950c01 and b78b0ba.

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