Skip to content

Uprate only from inputs, so uprated values don't depend on calculation order - #563

Merged
MaxGhenis merged 5 commits into
masterfrom
fix-uprating-order
Oct 5, 2026
Merged

MaxGhenis merged 5 commits into
masterfrom
fix-uprating-order

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

#562 (input-only carry-over) has merged, as 58e23f56. This PR is five commits on master bd459bdf: the uprating fix, three fixes that its review on master asked for, and a changelog-only fifth commit (45f2bfc3, the head). The code is the fourth commit's, c5c7bf4c.

Problem

Simulation._calculate uprates a variable that has uprating and no formula result for a period. It takes the latest earlier period stored in the variable's own unit and scales that value by the index ratio. That period could be one the simulation had calculated itself: an earlier uprated, masked or truncated value. So an uprated value depended on which periods had been calculated first.

Executed on #562 (index +3.7% a year, input for 2012; 2015 asked alone vs after 2013 and 2014):

Variable 2015 asked alone 2015 after 2013, 2014
int [1001, 77] [1116, 85] [1115, 83]: truncation compounds
float [1001.3, 77.7] 1116.6072998046875 1116.607177734375: float32 rounding compounds
float, defined_for False for person 0 in 2013 only [1115.16, 1115.16] [0.0, 1115.16]: the 2013 mask carries into 2015

The same drift happens when one of these, cached at an intermediate period, becomes the value uprated:

  • a default;
  • a monthly input carried into a calculated year;
  • the result of a formula that a reform gives an uprated variable up to an end.

#551's path-independence test held only to rel=1e-5, which hid the bitwise drift.

Fix

Uprate only from inputs (first commit)

Uprate only from an input this branch reads. #562 marks every value the simulation caches as derived. Holder.get_input_periods(branch_name), also from #562, lists the periods whose value get_array(period, branch_name) actually reads is an input. It ranks stored keys in get_array's lookup order (the branch, then its ancestors, then default; memory before disk) and leaves out periods stored only under branches this one cannot read. For variables with uprating, the uprating path keeps only the earlier same-unit periods in that set. With no earlier input, control falls through to #562's carry-over or the default, exactly as when the period is asked alone.

It also fixes a crash that was already on master: a period stored only under a branch this simulation cannot read, for example through Holder.set_input(..., "sibling"), used to be picked as the uprating source and read back as None, which raised TypeError. A regression test covers it.

This commit's production diff is one filtered list in simulation.py, plus a comment in #562's no-input branch. It rebased onto master without conflicts. git range-diff shows it is patch-identical to the commit reviewed before #562 merged (57c0d36b).

A yearly input given through a set_input helper replaces months already calculated (second commit)

A monthly variable stores a yearly input month by month through its set_input helper: set_input_dispatch_by_period copies it to each month for a stock, and set_input_divide_by_period divides it between them for a flow. Both skipped any month that already held a value, including one the simulation had calculated:

  • dispatch kept the calculated month and copied it into the later months as an input;
  • divide took it out of the year's total and divided the rest among the other months.

The month read back as calculated. Since uprating now starts only from inputs, the next January was uprated from an earlier month.

An executed probe compared 108 cases with a fresh simulation given the same input. The cases covered both helpers, carry-over on and off, root, branch and nested branch, and three calculate-first patterns. Mismatches:

  • master: 84;
  • the first commit alone: 96;
  • this PR: 0.

Master matched only where the calculated value happened to equal the input.

The helpers now count only a stored input as present (get_stored_input), so a calculated month is replaced, as an input set for that month alone would replace it. Simulation.set_input also drops the fast-cache entries calculate made for the months a helper writes. Otherwise a month read back from the fast cache would still give the calculated value. This holds for any input a helper spreads over sub-periods that were calculated, such as a two-year input, not only a yearly one.

A simulation given all its inputs before it calculates anything behaves as before: the helpers meet no calculated value. A calculated month restored from a dump (which keeps #562's marks) is replaced the same way.

Clones never write over each other's files on disk (third commit)

OnDiskStorage.clone shares the directory and every file stored so far, and put wrote each value to its key's path. So a write through one storage changed what the other read:

  • On disk, a helper input given to a Simulation.clone() (which keeps the branch name) wrote over the source's file. The review of the second commit found this.
  • An own-unit set_input on a clone did the same, already on master.
  • So did the source writing a period again after cloning. The first review found this.

A storage now writes over a file only if it wrote it and has not shared it since. If any storage in its clone family wrote that file, or held it when a clone was made, the value goes to a new file of its own under replaced/, which restore() never reads. Storages not cloned from one another, such as restore() and a separate writer over the same directory, still write over each other's files as before. Pickling counts as cloning, except for files the storage only read back with restore() (see "Not in scope").

Each clone records its own inputs; a storage keeps its own files across restore() (fourth commit)

The review of the third commit found two blocking defects:

  • Simulation.clone() shared _user_input_keys with its source. Already on master, an input set on a clone for a period the source had calculated made the source's apply_reform keep its calculated value there as an input, and the source's input export listed it. Executed: input 2012 = [1001, 77], the source calculates 2013, a clone sets its own 2013 input, the source applies a no-op reform; the source's 2015 is [1116, 84] where a fresh simulation gives [1116, 85]. The second commit added another route: a yearly helper input on the clone replaces a month the source had calculated and records that month's key in the shared set, so the source's apply_reform kept its calculated month as an input and uprated from it. Each clone now gets a copy of the record. apply_reform and the input export look up the recorded keys in the simulation's own holders, so nothing needs the record shared.
  • restore() forgot which files a storage had written (third commit). A storage that was never cloned then moved its next write to replaced/, where a separate reader of the directory did not see it. A storage now remembers, for each key, the file it last wrote and has not shared since (_own_paths). restore(), delete and apply_reform's wipe keep that record; clone() and pickling clear it. Writing a deleted key again therefore reuses its file. Before, each delete-and-write cycle after a clone left one more file (100 cycles left 101 files).

Invariants

The domain: uprated variables of each value type (float, int, and bool masks via defined_for), inputs in each variable's own unit (or any unit for a variable with no set_input helper), and earlier requests that are plain calculations in either unit, ADD or DIVIDE. Over that domain:

  1. Order independence, byte for byte (dtype, shape and bytes, so -0.0 differs from 0.0). What a simulation returns for a period equals what a fresh simulation with the same inputs returns for that period alone. This holds when reading from the simulation, from a branch, or from a nested branch forked after those requests, including a branch that sets its own input. It holds with auto-carry-over on and off.
  2. Reference rule. The result is the latest earlier own-unit input × index(period) / index(input period), cast to the variable's type and masked by its own defined_for. With no such input, it is Carry over only inputs, the latest at or before the requested period #562's carry-over input or the default.
  3. Inputs still count. An input at an intermediate period is still uprated from. That covers an input set over a period already calculated, one set on a branch over a period the parent calculated, and one stored only under a branch while the parent calculates the same period.

For yearly inputs given through a set_input helper to a monthly stock or flow, stored in memory or on disk, after calculations inside that year:

  1. Helper inputs. Every month of the year then holds an input. Every month of the year, and any other month, is byte for byte what a fresh simulation returns when given the same inputs in the same order. This holds from the simulation, a branch or a nested branch, with auto-carry-over on and off.
  2. Clone isolation. The input changes nothing stored by a clone of the simulation taken just before it, and the clone's apply_reform still keeps only the clone's own inputs. At the storage level, a write through one storage never changes what a storage clone()d from it, or one it was cloned from, reads, unless one of them has since called restore(). For copies made by pickling or deepcopy, it holds only for files the storage or its clone family wrote before the copy (see "Not in scope").

Tests

  • tests/core/test_uprating_order.py: 97 regressions with no Hypothesis dependency, so the smoke job runs them too.
    • 33 cover uprating: int truncation, float32 and monthly rounding, defined_for masks and all-false defaults, cached non-zero defaults, cross-unit carried inputs, a reform formula with end, branches and nested branches, branch-only inputs, and over-correction guards.
    • 56 cover helper inputs: both helpers × carry-over on and off × root, branch and nested branch × four calculate-first patterns. They also check uprating from December and keeping an input already set in the year.
    • 8 cover a helper input set on a clone and the source's apply_reform: both helpers × carry-over on and off × memory and disk.
  • tests/core/test_uprating_order_property.py: two derandomized Hypothesis properties. Each docstring states its domain.
    • The first runs 500 examples plus 9 @example rows and covers invariants 1 and 2 byte for byte. Half the requests target the variable checked, and branches may set their own input.
    • The second runs 300 examples plus 4 rows and covers invariant 4. It covers invariant 5 for a clone taken before the input, in memory and on disk, including the clone's apply_reform afterwards.
  • tests/core/test_on_disk_storage_clones.py: 17 tests.
    • The review's sequence, for both helpers and carry-over on and off.
    • An own-unit set_input on a clone, and the source rewriting a period.
    • Storage-level cases: clones and clones of clones; writes over a storage's own files; independent storages over one directory; restore() reading only usual file names; a restored storage's clone; a never-cloned storage written after restore(); deleting and writing a key again after a clone; deepcopy and pickle; and pickles from before this change.
  • tests/fixtures/uprating_order.py: the variables, the reference rule (which rejects inputs it doesn't model) and assert_bitwise_equal. It now also has uprated_monthly_stock, a stock with a month-by-month index, so which month a value is uprated from shows. It reuses Carry over only inputs, the latest at or before the requested period #562's simulation helpers.
  • Fix variable uprating when the uprating parameter is undefined, and pick the latest earlier period #551's test_result_does_not_depend_on_intermediate_years_computed: now asserts exact equality instead of rel=1e-5.

Each commit's production code, run with this head's test_uprating_order.py, test_uprating_order_property.py, test_on_disk_storage_clones.py and variables/test_variable_uprating.py (349 tests):

Code Failures
master bd459bdf (with #562) 112: 23 of 33 uprating regressions, 56 of 56 helper regressions, 8 of 8 clone regressions, both properties, 8 of 8 tightened #551 cases, 15 of 17 disk tests
first commit 14785411 80: 56 of 56 helper regressions, 8 of 8 clone regressions, the helper property, 15 of 17 disk tests
second commit 168a710a 24: 8 of 8 clone regressions, the helper property (on disk, with a clone), 15 of 17 disk tests
third commit 1ee63a80 12: 8 of 8 clone regressions, the helper property (the clone's inputs after a reform), 3 of 17 disk tests (writing after restore(), reusing a file after delete, and the old-pickle test, whose setup reads the renamed _own_paths field)
fourth commit c5c7bf4c (this PR's code) 0

The tests that pass on earlier code check behavior it already had: the over-correction guards (inputs still count), an uprated value cached as derived (#562 marks it), the cross-unit case with carry-over off, storages not cloned from one another still writing over each other's files, and, before the third commit, a never-cloned storage still writing its own file after restore().

Mutation check and ablations.

  • The uprating filter (mutants.py): all 6 mutants are killed, and the property alone kills all 6. The mutants are: no filter, a filter that ignores the branch, an inverted filter, max over the unfiltered list, dropping the uprating guard, and uprating from the earliest input.

  • The second commit's helper regressions:

    • with neither part: 56 of 56 fail;
    • with the helper change alone: 52 fail (the four that pass don't read December back);
    • with the fast-cache change alone: 56 fail.
  • The helper property, generated examples only (explicit rows removed). It fails:

    • without the second commit;
    • with either half of the second commit alone;
    • on the second commit: the source's input changes a month its clone stored on disk;
    • on the third commit: the clone's inputs change after a no-op reform.

    On the fourth commit it passes all 300 generated examples.

Local runs on c5c7bf4c (this PR's code):

  • Full suite: 1485 passed, 4 skipped, 1 xfailed.
  • Country-template YAML: 39 passed.
  • ruff format --check: clean.
  • The repro behind the table above: all three variables bitwise equal.
  • The helper probe (108 cases: three index and default setups × stock and flow × carry-over on and off × root, branch and nested branch × three calculate-first patterns): 0 mismatches with a fresh simulation; 84 on master.

CI: 18 of 18 checks pass on c5c7bf4c and on the head, 45f2bfc3, Windows included.

Independent reviews.

  • Before Carry over only inputs, the latest at or before the requested period #562 merged: a hard-tier review (GPT-6.1 Sol) on an earlier head and a final-commit review (Opus, on 2120f78f) each returned APPROVE WITH NITS and found no defect introduced by the PR. Their documentation and test nits are fixed in the first commit, except an optional one: get_input_periods runs twice for an uprated variable with no earlier input when carry-over is on. The pre-existing defects they reported are fixed here or listed under "Not in scope".
  • On master: four hard-tier rounds (GPT-6.1 Sol), each on the head at the time:
    1. On the first commit: REQUEST CHANGES. A yearly helper input over a calculated month was mishandled, and this PR made it worse (fixed in the second commit). Its four other findings are pre-existing. One, a source rewriting a period on disk after cloning, which changed what its clone read, is fixed in the third commit; the other three are under "Not in scope".
    2. On the second commit: REQUEST CHANGES. A helper input on an on-disk clone wrote over the source's file (fixed in the third commit). Its two other findings, a stale yearly ADD and a day-unit input, are under "Not in scope".
    3. On the third commit: REQUEST CHANGES. The shared input record and restore() forgetting a storage's own files were blocking; replacement files piled up over delete-and-write cycles (all three fixed in the fourth commit). Its pre-existing findings are under "Not in scope".
    4. On the fourth commit, c5c7bf4c: APPROVE. Every blocking round-3 repro (findings 1, 2 and 4) is resolved, and its pre-existing finding-3 cases reproduce the same on master and on this code. It also ran 16 input-record scenarios (branches, nested branches, clones, a subclass clone, both input exports, reforms on sources, branches and clones) and 48 file-ownership cases across clone, deepcopy, pickle, delete and restore(). It rebuilt its round-3 clone-family state machine and ran it: 400 examples and 280,298 value and provenance checks. The seven related test files passed (237 tests).

Measurements

The confirmation pass and the final-head check ran the first commit's filter (get_input_periods), before the rebase onto master. The first pass (1e88ee16) ran an earlier form, not holder.is_derived(p, branch), which differs only for a period stored only under a branch this one cannot read, where it raised TypeError. The full-eCPS 2025-2026 row comes from the first pass only.

The other three commits change results only in the cases they fix:

  • an input given through a set_input helper after a period it covers was calculated (or restored from a dump as calculated): a yearly or multi-year input, or a day input to a monthly variable;
  • a write to an on-disk key whose file a clone, or a copy made by pickling, also reads;
  • apply_reform, or an export of inputs, on a simulation after an input was set on its clone, or on the clone after an input was set on the source.

The PE-UK and PE-US runs were not repeated for them.

These are real PE-UK and PE-US microsimulations, with no scaling. Cores compared: master b78b0ba9, #562 and this change.

Single-year simulations (what policyengine.py and the APIs build): no output changes.

Run master vs #562 vs this change
PE-UK 2026 41/41 arrays bitwise identical. No uprating decision has an earlier stored period: PE-UK loads every year through 2030 as inputs.
PE-UK 2032 41/41 identical. 97 uprating decisions; master and this change pick the same source in all of them (the 2030 input).
PE-US 2026, 3,000 households, with MTR branches 31/31 identical. 197 decisions pick a different source, and none changes a value: they are monthly variables with no input, where master chains a cached default.
PE-US 2026, full eCPS #562 → this change: 30/30 identical. 99 of 198 decisions pick a different source; none changes a value. (Master → #562 differs in spm_unit_net_income for 14 SPM units: #562's state_fips/FPG fix, see #562.)

Multi-year in one simulation: this change removes uprated-input drift.

Run #562 → this change Single-year vs multi-year (same year)
PE-UK 2025-2030 41/41 identical every year. 130 decisions pick a different source, 0 change a value. —
PE-UK 2031-2033 2032: 24 of 41 arrays change, by 1e-7 to 1e-9 relative. Examples: household net income +£41k on £2.03tn, income tax +£14k on £427bn, weights 5e-8. 2033 is similar. These are uprated inputs: weights, employment, self-employment, pension, savings and dividend income. They were chained from cached 2031 values; they now come from the 2030 input. 2032 inputs now match single-year 2032 exactly. Master/#562 differ in 8 input arrays.
PE-US 2025-2026, 3,000 households 2026: 28 of 30 arrays change, by 1e-7 to 1e-8 relative. Example: income tax +$258k on $1,962bn. The cause is the same: inputs uprated through cached 2025 values. 26/30 arrays match single-year 2026 exactly; income tax is identical. Master matches 0/30 and #562 1/30.
PE-US 2025-2026, full eCPS 2026: 28 of 30 arrays change, by 1e-7 to 1e-8 relative. Examples: income tax +$242k on $2,141bn; household net income +$0.73m on $14.46tn; weights 1.6e-8. Asked alone vs after 2025: income tax differs for 28 tax units (#562: 30,957), household net income for 30 households (#562: 23,034), state income tax for 3 (#562: 24,131). All inputs and weights match exactly. The rest is 24 tax units whose itemization flips in PE-US's persistent branches (the same 24 on #562).

The multi-year differences that remain are not uprating:

  • PE-UK 2032+ (12 arrays, incl. child benefit −2.3%, UC, MTR, poverty). PE-UK's current_education formula copies last year's value only if last year happens to be cached, and otherwise imputes from age. That's a PE-UK order dependence. Separate task.
  • PE-US 2026 (itemizing outputs: one tax unit's itemization flips, ctc_limiting_tax_liability and tax_liability_if_itemizing for 63-120 units). These are computed in PE-US's persistent itemizing/not_itemizing branches, created in 2025 and reused in 2026. policyengine-us#9738 (get_branch_for_period) re-forks them.
  • PE-US 2027 after 2025-2026 raises ParameterNotFoundError (md...informal.rates.UNKNOWN) on Carry over only inputs, the latest at or before the requested period #562 and this change. It doesn't raise on master, but master's 2026 is wrong there (income tax $33bn, the Carry over only values stored at the variable's definition period #557 age/12 bug). The cause is in PE-US: its county formula reads the default branch's latest period from inside a reused branch. The carry-over session is filing the PE-US fix.
  • spm_unit_is_in_spm_poverty raises SPMInputError on every core, master included. This is the known eCPS SPM composition defect, us: name the SPM composition defect in seconds, and refuse it by name in the release microcosm#948. It isn't measured here.

Not in scope

These are pre-existing on master. The reviews found most of them. None is made worse here, except the day-unit case noted below.

  • Stale cached targets after a later set_input: Make set_input on a branch drop values calculated from the input it replaces #560 covers branches. For the root simulation, apart from the months a helper writes (second commit), values calculated from an input stay cached when it changes, as on master: later periods, dependent variables and a yearly ADD sum. This includes:
    • a yearly ADD sum cached before a helper input to that year, which stays cached at the year (the months, and ADD itself, are recomputed);
    • a month calculated before a day-unit input to a monthly helper variable, which stays in the fast cache. Day inputs are outside the helpers' documented domain (periods larger than the variable's). On master, dispatch ignored such an input after the month was calculated, and divide raised Inconsistent input. Now both store it, as a fresh simulation would, but calculate for that month still returns the cached value.
  • apply_reform's replay of user inputs:
    • it can promote a value recalculated over a deleted input to an input;
    • after a dump is restored, it drops the restored inputs.
  • Dumps of a branch:
    • they write the default branch's values;
    • a period stored only under an unreadable sibling dumps as None, which fails to restore.
  • Subsample promotes calculated values to inputs, and STOCK/DIVIDE caches: task_857ef4c2.
  • On-disk copies and restores (the third review):
    • two copies made by pickling or deepcopy that each store a key neither had before the copy write the same file;
    • a clone that calls restore() reads files its source still writes over;
    • subsampling a simulation after cloning it, on disk, can change the arrays its clone reads;
    • a key that a storage read back with restore() but never wrote: after a pickle or deepcopy, either copy writing it changes what the other reads.
  • Equal-start inputs: for a variable with no set_input helper given inputs for year:2012:2 and 2012, the uprating source is whichever was set first, as on master: task_5fbde569.
  • Other on-disk storage defects and underscore branch names: Give each disk-backed holder storage its own directory #558, Keep branch period reads, dumps, and disk deletion consistent #552, task_a9863c4b. Give each disk-backed holder storage its own directory #558 gives each disk-backed storage its own directory. It overlaps this PR's third commit, so it needs a rebase after this merges.
  • Other holder issues: restore_simulation drops the input registry, Holder.set_input has a stale fast cache, and Enum uprating raises. These are a new task.
  • Old dumps: dumps written before Carry over only inputs, the latest at or before the requested period #562 carry no provenance marks, so a restored calculated period is treated as an input.

axiom: n/a: engine fix to uprating source selection and input storage in policyengine-core; no policy rule changes

🤖 Generated with Claude Code

Simulation._calculate uprated a variable from its latest earlier stored
period in its own unit, including periods the simulation had calculated
itself, so the result depended on calculation order. Executed on
fix-carry-over-order (index +3.7%/yr, input at 2012, 2015 asked alone vs
after 2013 and 2014):

- int: [1116, 85] alone, [1115, 83] after (truncation compounds)
- float: float32 rounding compounds
- defined_for false for one person in 2013 only: [1115.16, 1115.16]
  alone, [0.0, 1115.16] after (the 2013 mask carries into 2015)

Also: a cached default, a monthly input carried into a calculated year,
and a reform formula's result before its end each became an uprating
source.

For variables with uprating, keep only the earlier same-unit periods in
Holder.get_input_periods(branch_name) (the carry-over PR's branch-aware
provenance): periods whose value this branch reads is an input. That
also drops periods stored only under a branch this one cannot read,
which read back as None and raised TypeError. With no earlier input, the
carry-over path or the default applies, as when the period is asked
alone.

Tests: tests/core/test_uprating_order.py (regressions, no hypothesis),
tests/core/test_uprating_order_property.py (derandomized Hypothesis:
any requests, branches and branch inputs, carry-over on/off == alone,
byte for byte, and == reference rule), shared fixtures in
tests/fixtures/uprating_order.py. #551's path-independence test is now
exact instead of rel=1e-5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 4 commits October 4, 2026 12:03
set_input_dispatch_by_period and set_input_divide_by_period skipped any
month that held a stored value, including one the simulation had
calculated and cached as derived. Given a yearly input after a month of
that year was calculated:
- dispatch kept the calculated month and copied it into the later months
  as an input;
- divide took it out of the year's total and divided the rest among the
  other months.
The month read back as calculated, and since uprating now starts only
from inputs, the next month was uprated from an earlier month (review
finding P1 on #563, review/review_final_rebase.md).

The helpers now treat only a stored input as present (get_stored_input):
a derived month is replaced, as an input set for that month alone would
replace it. Simulation.set_input also drops the fast-cache entries for
the months a helper writes, which otherwise returned the calculated
value.

Executed probe (p1_helper_probe.py: stock and flow, carry-over on/off,
root/branch/nested, three calculate-first patterns): 108 cases,
mismatches vs a fresh simulation given the same input were 84 on master,
96 on #563 before this commit, 0 after.

Tests: regressions for both helpers in test_uprating_order.py, and a
derandomized Hypothesis property (300 examples) in
test_uprating_order_property.py. A fresh simulation given the same
inputs in the same order returns the same bytes for every month of the
input's year and for another month, and every month of the year is an
input. The fixtures add uprated_monthly_stock, with a month-by-month
index. Ablations: the regressions fail 56/56 without either change, and
52/56 with the helper change alone. The property's generated examples
alone fail without the change and with either half alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OnDiskStorage.clone shares its directory and every file stored so far,
and put wrote each value to its key's path. So a write through one
storage changed what the other read. The clone docstring noted it.
- Simulation.clone() keeps the branch name. A set_input on the clone
  wrote over the source's file, and with the previous commit so did a
  yearly helper input replacing a calculated month (review of 168a710,
  review/review_final_rebase2.md): the source's December became the
  clone's input.
- The source writing a period again after cloning (delete, then
  calculate) changed the clone's value (round-1 review, finding P3).

A storage now writes over a file only if it wrote it and has not shared
it since (_own_files). Otherwise, if any storage in its clone family
wrote or stored that file (_family_files, one set per family), the
value goes to a new file of its own under replaced/, which restore()
never reads. Storages not cloned from one another, such as restore() and
a separate writer, still write over each other's files as before.
Pickling counts as cloning. A state pickled before this change treats
every file it stores as shared.

Tests: tests/core/test_on_disk_storage_clones.py covers the review's
sequence for both helpers and both carry-over modes, own-unit set_input
on a clone, the source rewriting a period, and storage-level cases:
- clones, a clone of a clone and in-place writes of own files;
- independent storages over one directory, and restore() reading only
  usual names;
- a restored storage's clone, deepcopy/pickle, and old pickles.
On 168a710, 14 of the 15 fail; the one that passes is the
independent-storage guard. The helper property in
test_uprating_order_property.py now also runs on disk, and with a clone
taken before the input whose stored months must not change. With
explicit rows removed and no shrinking, it fails on 168a710.

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

Round-3 review of #563 (review/review_final_rebase3.md), two blocking
findings:

1. Simulation.clone() shared _user_input_keys with its source. A yearly
   helper input on the clone now replaces a month the source had
   calculated, and it recorded that month's key in the shared set. The
   source's apply_reform then kept its own calculated month as an input
   and uprated from it. The shared record predates this PR, but the
   helper change made it reachable this way. Each clone now gets a copy
   of the record. Nothing read the record across simulations:
   apply_reform and the input export look up keys in the simulation's
   own holders.

2. restore() cleared the storage's own files, so a storage that was
   never cloned moved its next write to replaced/, where a separate
   reader of the directory no longer saw it. A storage now remembers,
   for each key, the file it last wrote and has not shared since
   (_own_paths, replacing _own_files). restore() keeps it, and so do
   delete and apply_reform's wipe. Writing a deleted key again therefore
   reuses that file instead of making a new one each time (the review's
   P3: 100 delete/write cycles after cloning had left 101 files).

Tests:
- test_uprating_order.py: test_an_input_set_on_a_clone_is_not_replayed_by_its_source
  (both helpers, carry-over on/off, memory and disk; 8/8 fail on
  1ee63a8).
- test_on_disk_storage_clones.py: restore-then-write and
  delete-then-write reuse (both fail on 1ee63a8). The restore test now
  uses a month period, because Windows file names cannot hold the ':'
  of month:2012-03:3; that is what failed Windows CI on 1ee63a8.
- The helper property: the clone also applies a no-op reform, and its
  input periods must not change. Its generated examples alone fail on
  1ee63a8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fourth commit (c5c7bf4) gave each Simulation.clone() a copy of
_user_input_keys instead of sharing the source's set, which changes what
apply_reform keeps as an input after an input on the other simulation.
The fragment only described the on-disk file change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 5, 2026 03:40
@MaxGhenis
MaxGhenis merged commit d20b856 into master Oct 5, 2026
18 checks passed
@MaxGhenis
MaxGhenis deleted the fix-uprating-order branch October 5, 2026 03:41
MaxGhenis added a commit that referenced this pull request Oct 6, 2026
Brings in #562 (carry over only inputs), #563 and #582 (uprate only from
inputs), #578 (shared-key sets), #574 (per-call parameter tracing) and #584.

One input record instead of two: the storages' `_inputs` (memory) and
`_derived` (disk) from #562 replace this branch's `_input_keys`, so an input
is a value stored without `derived=True`, as on master. Sequence numbers stay
alongside. Disk storage takes master's `_path_to_write` (a clone never writes
over a file it shares) in place of this branch's per-store file names, so the
process tokens and restore ordering go. Dumps write master's
`derived_periods.txt` (branch-aware) and restore inputs first, then calculated
values under one later number. `apply_reform` marks every value set_input did
not store as derived and drops it, keeping master's semantics without wiping
and replaying. Tests that expected a calculated result to carry over now check
that the result is cached, since only inputs carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit to MaxGhenis/policyengine-core that referenced this pull request Oct 6, 2026
Conflicts resolved by keeping master's bookkeeping:
- holder.py: master's derived put (PolicyEngine#562) and branch helpers; this PR's
  fast-cache eviction after every storage write and delete.
- simulation.py: master's input-only uprating (PolicyEngine#563/PolicyEngine#582); this PR's
  calculate-time value-type check runs just before an input is uprated.
- simulation_dumper.py: both records for now (inputs.txt and master's
  derived_periods.txt); a value marked derived restores as derived.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit to MaxGhenis/policyengine-core that referenced this pull request Oct 6, 2026
Brings in PolicyEngine#562, PolicyEngine#563, PolicyEngine#582 (input/derived storage marks, input-only
carry-over and uprating), PolicyEngine#574, PolicyEngine#584 (parameter tracing and copy guards),
PolicyEngine#578 (shared keys only while sharing) and PolicyEngine#585 (temporary storage
directories).

Conflict in holders/holder.py, resolved by keeping master's bookkeeping:
- _set keeps master's ``derived`` mark (stored with the value, and a
  derived value is never redirected to the running set_input's branch)
  and this branch's ``is_input``, which decides what the record names.
- put_in_cache keeps master's guard (a derived value never replaces a
  readable input) and passes ``is_input=False``.
- master's ``Holder._stores`` (storage ``has``) replaces this branch's
  equivalent ``_stores``.

simulation.py merged cleanly but copied the record in ``clone`` twice
(PolicyEngine#562 added the same copy); kept master's copy and added the
``_user_input_contexts`` reset next to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 7, 2026
Reconcile #552 with master after the merge:

- Holder.get_known_periods(branch_name) uses master's _readable_branches
  (added by #562) instead of a second copy of the lookup, and lists each
  period once. get_array is master's again.
- _dump_holder reads the dumped simulation's branch from the holder, as
  #560 does, and keeps master's derived_periods.txt with each value's mark
  read on that branch.
- The _calculate comment says what scoping still changes now that
  uprating and carry-over read only inputs (#562/#563): a later period
  stored under an unreadable branch no longer leaves a default uncached.

Tests: the isolation grid now also compares what the branch then reads
(periods and derived marks); a period stored under several readable
branches is listed once; a restored branch dump calculates what the branch
calculates; and a Hypothesis differential checks get_known_periods against
get_array and get_input_periods against is_derived for random values on a
five-branch lineage, in memory and on disk.

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>
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