Repository navigation
Keep the record of set_input values in step with storage - #561
Merged
Merged
Conversation
Simulation._user_input_keys records each (variable, branch, period) stored through set_input. _invalidate_all_caches (run by apply_reform) keeps the values it names, to_input_dataframe exports them, and country packages read it to tell an entered value from a calculated one. It drifted from storage in two ways (#559): - delete_arrays deleted the values but kept their entries, so a formula result calculated later for the same period counted as an input: it survived apply_reform and was exported. Holder.delete_arrays, which Simulation.delete_arrays calls for each branch it deletes from, now drops the entries for the variable, that branch and the periods in-memory storage deletes (all of them for an eternal variable). Code that deletes through the holder, as country packages do when they move an input to another variable, is covered too. Disk storage deletes only the period asked for, so the entry for a value it still holds is kept. - clone (so also get_branch) shared the record between simulations that store their values separately, so an input set on a clone, on a branch's parent after the branch was made, or on the original after cloning was recorded for both. The copy now gets its own record, and its own empty list of running set_input calls. Tests: example regressions (11 of 12 fail before the fix; the twelfth guards against dropping too much), and a Hypothesis property that runs random set_input / calculate / delete_arrays / clone / get_branch / _invalidate_all_caches sequences against a reference model of each simulation's inputs. Fixes #559 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to the review of the first commit: - Holder.delete_arrays no longer looks through the whole record. It compares the periods memory stores for the branch before and after the deletion and discards exactly those entries, so its cost does not grow with the record. Country marginal-rate code deletes every variable on a branch: with 9,000 entries and 3,024 variables the loop took 0.73 s with the scan and takes 0.018 s now (0.004 s without any pruning). A holder with disk storage, which cannot list its periods for every branch name, still looks through the record for the variable's entries in the deleted periods and drops those whose value neither storage holds. - Holder._set records the period as storage keys the value: eternity for an eternal variable whatever period it was set for, and a Period for a handler that passes a string. Each entry names one stored value, so a string-period input is exported and deleting an eternal input drops its entry. - put_in_cache stores with is_input=False (the same keyword as #560), so values a custom set_input handler calculates are formula results, which apply_reform recalculates, not inputs. - subsample starts the record again before it rebuilds the simulation, so it records only what the rebuild stores. Tests: a guard that a memory-only deletion never iterates the record; the eternal, string-period, calculating-handler, disk and subsample cases; the property's model now includes a handler that calculates and stores months under string periods. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s as stored Round-2 review fixes: - An entry is dropped only when neither storage still holds its value, so an input deleted from memory that survives on disk stays an input. - Twelve months starting on the first of a month are recorded as the year storage keys them under, so deleting that year drops the entry. - Deleting compares the keys each storage holds before and after: it no longer goes through the record or loads files for a disk-backed holder. - The property model is seeded from the situation, checks to_input_dataframe itself, and a second property covers memory and disk storage. - Regression for the carry-over case found in the review of #562. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 2, 2026
MaxGhenis
added a commit
that referenced
this pull request
Oct 3, 2026
…ly for read branches Review round 3: - Holder._set no longer drops a record entry when a calculated value is stored over an input. A calculate_add at a variable's own period stores the unchanged input and lost its record; a clone, which shares its source's record until #561, deleted the source's entries; and ETERNITY variables keep entries under several periods. The case the cleanup was for, calculate_add caching a sum over an input, is what #571 stops. - Master now gives a storage that shares nothing a frozenset for _shared (#578). The drop step deleted from it directly; it now leaves that to InMemoryStorage._stop_sharing_dropped_keys. - The helpers evicted calculate's fast cache for a sub-period stored under any branch name, including one the simulation does not read. They now evict only for branches it reads, as #576's holder-write property requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 6, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eriods safely Preserve replayed-input tuples and master carry-over marks; add r3b soundness witnesses and value-aware disk properties. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seed existing recorded input locations before writes, retain positional derived semantics, and validate period aliases and early-year replay and deletion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
|
US + core hub merge audit, core#561 at
|
MaxGhenis
added a commit
that referenced
this pull request
Oct 9, 2026
…ger period (#581) * Count only inputs as already set when a set_input helper splits a longer period set_input_divide_by_period and set_input_dispatch_by_period treated any value stored for a sub-period as already set, including values the simulation had calculated (a cached default or formula result). The same annual input then gave different months depending on what was calculated first: a divided input skipped calculated months and shared the rest among the others, or failed as inconsistent after the year had been read; a dispatched input reused a calculated month for every later month. The helpers now read the simulation's record of inputs (_user_input_keys): a recorded sub-period keeps its input (and, for dispatch, passes it on to the later sub-periods, as before), and a calculated one is replaced. After storing, they drop the variable's calculated values over overlapping periods (the sums calculate_add caches, the twelfths calculate_divide caches) under the input's branch and the branches it reads through, record what they store as inputs, and evict calculate's fast cache for those periods. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Evict calculate's fast cache for the periods a set_input helper drops calculate does not put sums or twelfths in its fast cache today, so this is defensive: a value held there for a period whose stored value the helper drops goes with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Restore dumped values as inputs; test nested branches and rolling years A dump does not record which values were inputs, and restore_simulation stored every value with put_in_cache, so a restored simulation had an empty input record and the helpers replaced restored inputs (review finding 1). Restore now stores each value as a recorded input, the rule #576 applies to dumps without an inputs.txt. Tests: a restored month keeps its value under a yearly input (divide and dispatch); an input on a nested branch drops the sum its parent branch calculated; an input stored for twelve months from March is kept. The order-independence property now creates up to two nested branches, with calculations at each level. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the property test off disk file names Windows rejects On disk, a value calculated for a period like year:2013:2 is stored in a file named after the period, and Windows rejects ":" in file names (policyengine-core#526). The Windows CI jobs failed on such a read in the on-disk examples. In disk mode the property now skips those periods. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Record which dumped values were inputs; forget an input a calculation replaces Review round 2: 1. Restoring every dumped value as an input froze calculated values through a later reform. The dumper now writes, next to each variable's arrays, the periods whose value was an input (inputs.txt), and restore records exactly those; a dump without the file restores every value as an input. This is policyengine-core#576's dumper change, taken byte for byte so the two PRs merge without conflict. 2. A value stored by put_in_cache over an input (say a calculate_add sum over an input set for twelve months) left the input's record entry in place, so the helpers and apply_reform kept treating the calculated value as an input. Holder._set now drops the entry, in both of its forms for twelve months from the first of a month, when it stores a value outside set_input. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test a calculated value replacing an input with put_in_cache, not calculate_add With policyengine-core#571, calculate_add no longer caches a sum over an input a plain read finds, so the input is kept and the test's premise (master's calculate_add overwriting it) did not hold. Storing the calculated value with put_in_cache replaces the input with or without #571. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the record cleanup on calculated writes; evict the fast cache only for read branches Review round 3: - Holder._set no longer drops a record entry when a calculated value is stored over an input. A calculate_add at a variable's own period stores the unchanged input and lost its record; a clone, which shares its source's record until #561, deleted the source's entries; and ETERNITY variables keep entries under several periods. The case the cleanup was for, calculate_add caching a sum over an input, is what #571 stops. - Master now gives a storage that shares nothing a frozenset for _shared (#578). The drop step deleted from it directly; it now leaves that to InMemoryStorage._stop_sharing_dropped_keys. - The helpers evicted calculate's fast cache for a sub-period stored under any branch name, including one the simulation does not read. They now evict only for branches it reads, as #576's holder-write property requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 10, 2026
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.
Fixes #559.
Problem and behavior
Deleting an input could leave
_user_input_keyspointing at a later calculation for the same variable, branch, and period.apply_reformthen preserved that calculation as an input, andto_input_dataframeexported it. Copies also shared the record despite having separate storage, and calculatingset_inputhandlers could register their intermediate calculations.The mixed-storage witness makes the provenance problem explicit: January rent is calculated to disk as 0, then supplied in memory as 500. Deleting the year removes the memory input while disk storage retains the calculation. The disk 0 must not become a replayable input. A genuine disk input, however, must survive deletion of a newer memory input.
This PR changes outputs when a reform follows input deletion and the stale record would previously have preserved a calculation or default. Country constructor flows that move inputs such as
employment_incomemake that behavior relevant to downstream validation.Change
(variable, branch, Period)record, and track the memory/disk tiers that received an actual input inHolder._user_input_storage._record_input_storageupdates provenance when a tier is written;_stores_user_inputchecks surviving input locations. A cached value in another tier cannot stand in for a deleted input.Holder.delete_arrayscompares storage keys before and after deletion, then removes a recorded slot when no input tier survives.Simulation.delete_arraysapplies this to the visible branches. Deletion does not load disk arrays to make that decision._seed_input_storagehandles inputs registered without tier metadata, including Restore inputs as inputs, evict the fast cache on holder writes, reject non-numeric uprating and defined_for #576's restore path. Existing storage input marks can seed an already-recorded slot; they do not register an ordinary cache value for export or reform replay.set_inputcontexts. Rebuild the input record during subsampling.put_in_cachewrites from input registration and input-context branch redirection. Custom handlers store replayable inputs throughholder._setorholder.set_input. TheHolder.set_inputdocstring and changelog describe this handler contract and behavior change.The storage-level
derivedmark remains distinct from replay provenance._setpreserves the existing positionalderivedargument and addsis_inputafter it. The record's tuple shape remains compatible with downstream readers such as PE-US_rebind_holders.Invariants and regression coverage
For the tested
set_input, calculation, deletion, clone/branch, and cache-invalidation operations:These claims apply to the operations and storage behavior exercised by the tests; they are not a claim about every possible sequence. In particular, they do not guarantee preservation through pre-existing input-overwriting behavior such as
calculate_addon a two-month input. This PR does not repair that separate storage behavior.The new external-cache differential checks post-reform records, exports, supplied values, and repeated reform against a fresh simulation, including optional cloning. Its focused invariants are:
These assertions do not prove that an export made before reform ignores an arbitrary external cache overwrite. Direct storage mutations that bypass holder provenance remain outside that contract.
The existing #576 persistence contract is distinct: dump/restore deliberately registers every non-derived dumped value as a new leaf input, including an external cache value that live reform replay would drop. The restore differential preserves round-trip values, marks, and pre-reform answers, then checks live replay against an independent supplied-write reference and restored replay against the dump's non-derived value snapshot. These post-reform checks apply to every generated trace, including an explicit same-value input/cache overwrite; cross-simulation equality applies when those independent expected input snapshots agree. The #576 dump/restore input-registration contract is unchanged.
Existing example, property, and differential coverage is retained:
test_user_input_keys_match_reference_modelexercises families of simulations with memory storage, including handler calculations, exports, clones, and branches: 300 sequences of up to 30 operations.test_user_input_keys_follow_memory_and_disk_storagetracks supplied values independently in each tier and checks records, reads, and actual exports: 200 sequences of up to 25 operations, with an explicit mixed-storage counterexample.test_period_normalization_matches_storage_stringschecks normalization against storage strings and the existing parser where applicable, with explicit calendar and rolling twelve-month examples.test_tier_provenance_replays_the_same_inputs_as_a_fresh_simulationcompares mixed-storage deletion and reform replay against a fresh simulation containing only the surviving supplied inputs.Fix-round regression modules:
tests/core/test_input_tier_invalidation.py: genuine disk inputs versus unrecorded memory caches, overwritten-only record removal, register-only restored inputs, optional cloning, and the fresh-simulation differential across repeated reform (60 generated examples plus the explicit disk-input witness).tests/core/test_registered_input_deletion.py: register-only deletion for early years, plus a differential withset_inputacross period units, sizes, aliases, storage tiers, and underscored branches. Deleting the slot removes the record; a later cache write is purged by reform. At the reviewed source head, the newly added regressions produced 38 failed, 16 passed before the repair.tests/core/test_disk_period_filenames.py: compound and early-year disk periods under emulated Windows filename restrictions, replacement/clone paths, legacy restore and current-file precedence. Its memory/disk differential compares logical branch-period keys, values, and live derived marks after writes; source values remain independent of clone writes, and restore reads the original file values. Restore retains its existing behavior of clearing derived marks. At the reviewed source head, these newly added regressions produced 22 failed, 3 passed before the repair; the legacy/separator-free restore controls passed.All three modules were exercised against the reviewed implementation at
7559e6952657d9c1af276c8268017ce09561d28bbefore their repairs and rerun afterward:The filename controls and modern-year deletion controls passed before the fixes. These three after-repair results were obtained at
a4f384d77ed0607b6e74e5d269dcbf09c979c50d; tier invalidation also passed all five tests after base reconciliation. The new property/differential invariants are stated above; existing coverage was retained.Validation
Final head:
5a69ca8d835484fc582a6c638c231da7c9af2001.The final head includes canonical
masterat648f4e467d3c20afd206ce5f9be7f89752809257through a merge commit, preserving history and the required #576-before-#561 order. The holder conflict was reconciled by retaining both #576's unconditional fast-cache eviction and #561's supplied-input provenance updates. Incoming restore, copy/pickle, and numeric-validation behavior was retained. The intermediate repair head conflicted with the updated base, so GitHub did not start its PR workflow; the final head is mergeable and started a fresh workflow.Each file was run directly, in the foreground, one at a time with
uv run --no-sync --python 3.13 pytest -q --tb=short; the three initial regression modules additionally used--noconftest. The replay-index check selected the single named test below.PYTHONPATHwas set to the checkout root andPYTHONNOUSERSITE=1; uv selected a fresh isolated environment and a writable local cache. No lock wrapper, folder run, full suite, or microsimulation was used.Environment: macOS arm64, CPython 3.13.9, NumPy 2.4.2, pytest 9.1.1, Hypothesis 6.168.3, Ruff 0.15.5, Plotly 5.24.1. Dependencies were installed into the isolated environment, not borrowed through another environment's
.pthfile. Import-origin and search-path checks confirmed core loads from the checkout and test/runtime packages from that environment. Editable package metadata was refreshed to core 3.32.25 after the base update;uv pip checkverified all 65 installed packages are compatible.In addition to the three regression modules above, these focused checks passed on the combined code tree (production code at
f38247002d2c81939be9d848ff61447a71123eed, with the subsequent test-only follow-up now committed in the final head):tests/core/test_user_input_keys.pytests/core/test_user_input_keys_property.pytests/core/test_user_input_keys_soundness.pytests/core/test_apply_reform_preserves_user_inputs.pytests/core/test_on_disk_storage_clones.pytests/core/test_storage_input_index.py::test_apply_reform_keeps_only_the_replayed_inputstests/core/test_holder_write_fast_cache.pytests/core/test_input_tier_invalidation.py(rerun after reconciliation)tests/core/test_restore_input_registry_property.pytests/core/test_restore_input_provenance.pytests/core/test_simulation_copy_pickle_property.pyThe retained restore property first failed after base reconciliation on the explicit-input then identical-cache overwrite trace. Its old key/mark-only comparison assumed equal replay provenance. The corrected independent model passes with the same 200-example budget and operation strategies, retains round-trip and fresh-input comparisons, and adds unconditional live and restored replay checks plus a fresh serialized-leaf reference for every post-reform request. The two deterministic controls pin both same-value and changed-value overwrites across repeated reform. This fixes the test oracle while preserving #576's documented persistence behavior.
Focused Ruff lint and format checks passed for all eight edited Python files;
git diff --checkpassed. No mutation campaign or performance benchmark was rerun.At the reviewed head
7559e6952657d9c1af276c8268017ce09561d28b, CI run 37847864480 has 15 successful checks and four failed Windows Test jobs, for Python 3.11–3.14. All four Windows jobs fail exactly the same eight early-year multi-day cases: disk storage, the simulation/holder entry points, and years 1, 99, 999, and 1000. The traceback reachesnumpy.savewith a basename such asdefault_day:1-03-17:2.npyand raisesOSError: [Errno 22] Invalid argument. Colon is reserved in Windows filenames. Microsoft file naming conventions.The storage repair now encodes colons as semicolons only in the physical filename's period suffix, including replacement and clone-write filenames. Logical storage keys remain unchanged. Restore reads legacy colon filenames, decodes the new representation without changing branch semicolons or underscores, and prefers the current portable file when a legacy file remains beside an updated value. Valid serialized periods contain no semicolons, so this encoding is reversible without lengthening filenames. Existing live readers still indexed to a legacy file see its previous value until they restore again. Filename compatibility is one-way: current Core restores legacy files, but older Core readers do not decode the new compound filename encoding.
The portable-filename regression module passes all 25 cases locally. The existing early-year cases are retained; native Windows verification is reported separately below.
Final-head CI at
5a69ca8d83passed: all 19 checks succeeded. These include lint, the changelog check, the test matrix on ubuntu and native Windows for Python 3.11–3.14, and "Test Core and country packages" on both platforms. The native Windows jobs that failed at7559e695withOSError: [Errno 22]on colon filenames now pass.A separate description evidence check failed seven targeted assertions against the posted body at the reviewed head: tier tracking, qualified sequence claims, finished review status, the explicit Impact section, the fresh-impact HOLD, the Windows failures, and removal of the stale performance table. These are documentation-only checks of the description, not production regression tests or evidence that population-impact gates have passed.
The completed description passes all nine targeted evidence assertions, including the Impact/HOLD, current tier design, qualified invariants, CI disclosure, removal of stale performance claims, and absence of local paths or unfinished placeholders.
No new performance benchmark or mutation campaign was run in this fix round. The former performance table and mutation totals were measured on earlier heads and do not measure the current tier-tracking implementation. Writes after a holder has stored an input do period normalization and provenance bookkeeping. Seeding register-only inputs before overwrite also adds normalization on the external non-derived cache-write path when the simulation has registered inputs; the added condition does not change ordinary core writes with
derived=True. This description makes no measured cost claim for those writes. The focused no-disk-read deletion regressions remain, but they do not establish performance for all recording paths.Full suites, documentation builds, country-wide tests, and microsimulations were not run locally in this fix round; CI and the hub own those gates. Documentation changes cover the handler contract in
Holder.set_inputand the existing input changelog fragment, and the portable filename/legacy restore contract in the storage class andrestoredocstrings plus a storage changelog fragment. The API reference renders those source docstrings; no generated documentation is hand-edited. Documentation build validation remains a CI gate.Documentation review: impact medium, confidence medium. The durable source documentation describes the changed handler and persistence contracts; no new public import or developer workflow needs separate documentation. Focused Ruff and description checks validate the edited text and code formatting. Documentation builds were not run locally. The older-reader filename limitation is explicit above, and the PR-only review and impact judgments stay in this description.
Review status
The round-3 code approval didn't supersede the soundness r3b UNSOUND verdict at
7aeaac0e95dee171cb15a371f1b425849d6a4436. The mixed-tier provenance and early-year repairs were then reviewed at7559e6952657d9c1af276c8268017ce09561d28b. That delta review found no P1 and requested description, impact and CI corrections.The round-2 delta review at
5a69ca8d83(static read, Opus via Subfleet): APPROVE. All three earlier P2s are resolved. Its P3s were the stale CI text (fixed here) and two limits that are already disclosed: the compound-period filename format, which older Core can't read, and an external-cache export edge case before a reform.Impact
Hub A/B microsimulation, PE-US, current head. The hub ran PolicyEngine-US main
8b8ae4cc12on the default dataset twice for each year. One run used Core at the merge base648f4e467d; the other used this PR's head5a69ca8d83. The runs went one at a time under the shared heavy-job lock.household_net_incomeincome_taxstate_income_taxhousehold_benefitsEvery compared array is identical. Run time was also unchanged: 178s on both cores for 2025, and 187s (base) against 172s (PR) for 2026. The peak footprint was the same on both cores, at 11,264 MB (2025) and 12,288 MB (2026).
Not run: a PE-UK microsimulation. The PR changes country-agnostic holder bookkeeping. PE-UK is exercised only by CI's "Test Core and country packages" jobs, which pass. No partner baseline files change.
The historical results (PE-UK eFRS 2026, 36/36 arrays identical; PE-US eCPS, 3,000 households and marginal rates, 25/25 identical) predate the tier-tracking logic. The PE-US A/B above replaces them for this head.
Dependencies and rulings
axiom: n/a: simulation infrastructure; no policy rule or statute.