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>
MaxGhenis added a commit that referenced this pull request Oct 7, 2026
…ths (#576)

Copy the three #566 fixture/test files unchanged from c825cab without its production change or fragment. Add disk clone/branch and shared-key cache regressions plus an on-disk cache/reference differential property, preserving master provenance and eviction. Keep #566 open and its branch unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 8, 2026
…ct non-numeric uprating and defined_for (#576)

* Restore inputs as inputs, evict the fast cache on holder writes, reject non-numeric uprating and defined_for

Three bugs found by the review of #563, all present on master b78b0ba:

- restore_simulation put every value back with put_in_cache, which records
  no input, so apply_reform on a restored simulation dropped its inputs.
  dump_simulation now lists each variable's input periods in inputs.txt and
  restore_simulation records exactly those in _user_input_keys.
- Holder.set_input, put_in_cache and delete_arrays left the simulation's
  fast cache untouched, so calculate kept returning the replaced value.
  Every holder write and delete now drops the entries it changes, in the
  holder's own simulation and only for branches that simulation reads.
- An Enum, str or date variable with uprating, and a variable defined_for
  one, raised TypeError in the middle of a calculation. Both are now
  rejected when the variables are registered.

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

* Make the legacy-dump test strip every sidecar file, not only inputs.txt

A dump written before inputs were recorded holds the arrays and nothing
else, so the test stays right when another change adds its own sidecar.

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

* Define the fast-cache eviction helper at the end of Holder

Next to delete_arrays, git merged it with other changes to that method
without a conflict but left their lines inside the new helper.

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

* Keep the branch fast-cache tests valid if a branch input also drops what was calculated from it

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

* Address review: branch inputs in dumps, replace rollback, calculate-time checks, legacy-dump warning

- Test that an input set on a branch (which shares the input record) does
  not mark the default branch's calculated value as an input, with a branch
  step in the restore property.
- replace_variable keeps the existing variable when the replacement is
  rejected.
- calculate repeats the uprating check before it uprates, for an uprating
  assigned on a class that declares uprating itself; the defined_for message
  is tested for str and date as well as Enum.
- The message for an inherited uprating points to replace_variable.
- restore_simulation warns when a dump does not record its inputs.
- Changelog: say that such systems no longer load, including a group
  variable defined_for a person Enum, which master masked on summed indices.

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

* Test the branch-input case by its outcome, not by whether the record is shared

policyengine-core#561 makes a branch copy the input record instead of
sharing it; the test now holds either way.

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

* Address review round 2: check defined_for by type before mapping; pin three cache paths

- calculate checks the defined_for variable's type before calculating and
  mapping it. Mapped to a group entity, an Enum's indices were summed into
  numbers and str or date values failed inside the mapping, so the
  values-based check missed a cross-entity defined_for set after
  registration.
- Regression tests for three cache paths no test pinned (each one a
  surviving mutant in GPT-6.1 Sol's review): a write under an ancestor
  branch name in a nested branch, a write to disk storage, and an ETERNITY
  value requested without a period.

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

* Use derived-period dump provenance and rebuild input replay records (#576)

Write a format marker so modern all-input dumps can be distinguished from ambiguous legacy dumps. Register non-derived restored values through the input context without dispatching helpers again; preserve calculated marks. Cover cached storage inputs, stale input records, Enum replay and differential round trips, and remove the obsolete calculated-month helper exception now fixed on master.

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

* Fold #566 regression tests and cover current-master holder storage paths (#576)

Copy the three #566 fixture/test files unchanged from c825cab without its production change or fragment. Add disk clone/branch and shared-key cache regressions plus an on-disk cache/reference differential property, preserving master provenance and eviction. Keep #566 open and its branch unchanged.

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

* Describe master input-only uprating in the variable API (#576)

Correct the inherited attribute documentation to use the latest visible earlier input rather than a calculated or defaulted cache entry. No runtime change.

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

---------

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