Repository navigation
Conversation
Since 3.24.0 (#457, "bug H1") auto-carry-over took the latest-starting known period of any unit. A yearly variable also caches month values: calculate_divide stores a twelfth of the year's value. So once any month after January had been requested, a yearly input known only for that year carried a twelfth of its value into every later year. policyengine-us requests age by month through monthly_age, so a Microsimulation that calculated 2024 and then 2025 on a single-year dataset aged everyone to a twelfth of their age in 2025. On a 3,000-household Enhanced CPS subsample, 2025 federal income tax came to $31.6bn, against $1,846.9bn in a simulation that calculated only 2025. Filter known periods to the definition period's unit before carrying over, as the uprating path beside it already does. A MONTH variable's year-sum cache (calculate_add) is excluded the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the first version found that filtering known periods to the definition period's unit also dropped authoritative inputs. A variable with no set_input helper, and every DAY variable, stores an input at whatever period it is given (a YEAR stock set for 2024-12, a MONTH stock set for 2024, a DAY stock set for 2024-12), and master carried those forward. The first version returned the default instead. The values that must not carry over are only the derived caches: calculate_divide's twelfth of a YEAR flow at a month, and calculate_add's sum of a MONTH flow at a year. STOCK paths cache nothing in another unit. So restore master's known-period handling and, in the carry-over branch, accept a period in another unit only when it was set as an input on a branch this simulation reads (_user_input_keys, filtered to visible branches, now shared with the input-export code through _get_user_input_periods). Tests: the four off-unit input cases, directly and through a branch and a nested branch. On master the 41 derived-cache cases fail; on the first version the 8 off-unit input cases fail; all 242 pass here. The docstring no longer claims the STOCK path caches month values, and names the control families. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review (Codex lane) on 9e5bc21: REQUEST_CHANGES. Responses, addressed in 99b0ca0:
Re-review requested on 99b0ca0. |
The partial re-review of 99b0ca0 found cases where a derived month cache shared its period with a recorded input and so still carried over: a same-named branch in another subtree, Simulation.clone() (which shares _user_input_keys), a parent input set after branching, an input deleted and then derived again, and derivative(). The input-key scan also costs O(input keys) per carry-over (about 19 ms at 22,000 keys). Mark the values core derives by rescaling instead: calculate_divide's twelfth of a yearly flow at a month, and calculate_add's sum over more than one sub-period. Holder.put_in_cache(..., derived=True) records the period; any later write to it (Holder._set) clears the mark; Holder.clone copies the marks. Carry-over skips marked periods and nothing else, so inputs at any unit, and off-unit values cached without the mark, carry over as on master. The uprating path is unchanged. Tests add the five leak scenarios and two that pin inputs winning over a derived cache. On master 46 of 249 cases fail, on 9e5bc21 12, on 99b0ca0 6; all pass here. Full suite 1344 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Re-review of 99b0ca0 (an Opus lane) was cut off by the lane's usage limit before it wrote a verdict. Its saved probes confirmed the STOCK repro fixed and the test matrix (242 / 41 / 8), and found no fresh-path difference from master in 150 random trials. It also found leak cases where a derived month cache sat at a period recorded as an input, so it still carried over:
The recorded-input scan also cost about 19 ms per carry-over at 22,000 input keys. 504e983 replaces the recorded-input match with derived-cache marks:
The five leak scenarios are now tests, plus an input written over a derived cache and an unmarked off-unit On the PE-US bench (3,000 eCPS households), year 2 equals the 2025-only run bitwise, and single-year 2024 and 2025 are bitwise identical to released core. The policyengine.py |
The third review of 504e983 found that per-period derived marks go stale or get lost on paths that write storage without Holder._set or drop entries per branch: apply_reform's input replay, dump/restore, branch-scoped Holder.delete_arrays exposing an ancestor entry, and direct storage puts. Making provenance follow every storage entry would spread across both storages, the dumper and #556's shared arrays. Use a stateless rule instead: carry over the latest period at the variable's own definition-period unit, and fall back to periods in other units only when there is none at its own unit. Derived caches (calculate_divide's twelfth at a month, calculate_add's sum at a year) always coexist with the own-unit value they came from, so they are never picked. Inputs that exist only at another unit (a variable with no set_input helper, every DAY variable) carry over as on master. Where a helper-less variable holds both an own-unit value and an input at another unit, the own-unit value now wins, as before 3.24.0; no country package has such a variable. holder.py is back to master. Tests: the lifecycle reproductions (reform replay, dump/restore, branch-scoped deletion both ways) join the leak scenarios. 253 cases; failures on master 49, 9e5bc21 11, 99b0ca0 8, 504e983 7, here 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Third review (on 504e983, an Opus lane): REQUEST_CHANGES. Per-period derived marks go stale or get lost on paths that bypass
The rest of that review confirmed:
bab4325 drops the marks for a stateless rule: carry over the latest own-unit period, and fall back to other units only when there is none.
The lifecycle reproductions are now tests. Failures on the 253 cases: master 49, 9e5bc21 11, 99b0ca0 8, 504e983 7, here 0. Merged with master (#556); CI runs the full suite. I'll rerun the PE-US bench on this version later today, when the shared host is free. |
|
Overlap: #562 (draft, from another session) changes the same carry-over block. It carries over only inputs, the latest at or before the requested period, using derived marks held in the storages, and it keeps this PR's own-unit preference among inputs. All 253 tests in this PR's file pass on #562's head a80a3b9. Max decides whether this PR lands first or is closed in favour of #562, so I'm holding this merge until that ruling. I'll keep the branch green and reviewed in the meantime. |
The fourth review of bab4325 found that preferring own-unit periods for every variable made variables without a set_input helper (DAY variables, set_input = None) depend on request order where master did not: a default cached for 2025, or a calculate_add sum stored for 2024, now beat a later input at another unit. A set_input helper stores every input at the definition period (Holder.set_input routes other periods to it; both helpers store at the definition unit or raise), so for a helper variable a value at another unit is never an input: it is calculate_divide's twelfth, calculate_add's sum, or an explicit put_in_cache. Drop those from the known periods for helper variables, at the start of the no-formula branch; master's carry-over code is otherwise unchanged. Variables without a helper keep master's rule (latest period of any unit). No variable in policyengine-us, -uk or -canada lacks a helper. Tests rewritten around that split (235 cases): helper variables pin order independence, the leak and lifecycle scenarios, no backward carry-over, and never carrying another unit; helper-less variables pin master's behaviour, including the review's four order cases and the known limitation that they still carry a cached twelfth. The 17 tautological empty-request cases are gone. Master fails 48 (all helper-variable fix cases) and passes every helper-less case; bab4325 fails 4; mutants (no early return, min for max, filter all, filter none, inverted) are killed. Docstring, code comment and changelog no longer overstate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Fourth review (bab4325, Opus lane): REQUEST_CHANGES, narrower than before.
7af22b1 adopts the review's tested suggestion. For a variable with a The test file is rewritten around that split (235 cases):
Description, changelog and comments are corrected. A fifth review is queued. |
|
Like-for-like PE-US benchmark: core #557 vs #562. Setup: Enhanced CPS 2024, 3,000-household subsample (fixed seed), policyengine-us main 20ccd5ac with and without policyengine-us#9738. Each cell compares all 24 benchmark arrays bitwise (income tax, itemization, both branch liabilities, CTC, EITC, DE/ID/VA and state income tax, household net income, SNAP and more).
So on this model the two core PRs are interchangeable. Both remove the age/12 error, both leave single-year results bitwise unchanged, and both need PE-US #9738 for the second year to match exactly. They differ in scope:
Scripts and arrays: |
The fifth review of 7af22b1 found that dropping other-unit periods for helper variables loses genuine inputs on supported paths: a reform that changes a variable's definition period (YEAR<->MONTH) or installs a helper after an off-unit input, an automatic reform baseline, a restore into a system with other definitions, and custom helpers. In each the off-unit input is the variable's only data, and master carries it. Prefer own-unit periods for a variable with a (truthy) set_input helper and fall back to every period when there are none, in the carry-over branch only; master's code is otherwise unchanged. The fifth review's 32-case premise harness now differs from master in one case, restore_builtin_same_system, where master carries the derived twelfth (10) and this carries the year input (120). Tests (244): the reform, helper-installation and cross-system restore paths keep their input; a helper variable with nothing at its own unit uses another unit, as before; set_input = False counts as no helper; mutant killers for FLOW-only, divide-helper-only, early return on unfiltered periods, no fallback and no gating. The known limit (a helper variable with both units carries its own unit) is pinned. Failures: master 51, bab4325 4, 7af22b1 6, here 0; every listed mutant fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Fifth review (7af22b1, Opus lane): REQUEST_CHANGES, blocking on genuine-input loss. A helper variable can hold a real input at another unit, and there the strict filter dropped it where master keeps it. Paths:
The review also flagged overstated claims and six surviving mutants. Its other findings: earlier scenarios clean, every difference intended, and #552 composes (866/866). 97d4582 uses the fourth review's original suggestion: a preference with fallback for helper variables (truthy Tests now have 244 cases: master 51 / bab4325 4 / 7af22b1 6 / here 0 failures, and every listed mutant is killed. Claims are rewritten, and the remaining limits are stated and pinned. A sixth review is queued. |
|
Closing as superseded by #562, per Max's ruling on decision d820 (2026-10-02). #562 carries over only inputs, with derived marks held in the storages. It covers everything this PR did:
It lands with or after PolicyEngine/policyengine-us#9738. This PR's investigation and benchmarks stay in its comments for reference. |
…562) * Carry over only inputs, the latest at or before the requested period Auto-carry-over took the latest-starting stored period of any kind and returned the default if it started after the requested period. Any later stored period therefore hid an earlier input: a later input (inputs for 2012 and 2014 gave the default for 2013), or a later period the simulation had already calculated (2014 calculated first made 2013 the default). And values the simulation calculated carried like inputs: a twelfth cached at a month by calculate_divide (the policyengine-us monthly_age bug #557 fixes), a value masked by defined_for, a default cached where defined_for was false everywhere, or a formula result from before the formula's end. Results depended on which periods were calculated first. The rule now: a period takes the input stored for the latest-starting period that starts no later than it (on a tie, the one ending last), preferring the variable's own definition-period unit; with none, the default. Values the simulation calculates are stored with derived=True: the mark lives in the storage with the value (InMemoryStorage and OnDiskStorage keep it per key, every put sets or clears it, it counts only while the key is stored, and clones copy it), so deletions, direct writes and #556's shared arrays cannot leave it stale. Holder.is_derived(period, branch_name) reports the mark of the value get_array reads, so a value one branch calculated never hides another branch's input. A derived value never replaces an input the branch reads (calculate_add/calculate_divide used to overwrite inputs stored in another unit), calculate_add caches only sums over several sub-periods, and dump/restore keeps the marks. Storages gain has(), which neither reads nor copies an array. Supersedes #557's carry-over change: its 253 tests pass here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep uprating, input helpers, dumps and deletions consistent with input-only carry-over Round-2 review findings: - A default cached for a period with no earlier input became the base the uprating path uprated later periods from; the default is not cached for variables with uprating (as before). - A value calculated inside a set_input helper was written to the input's branch and recorded as a user input, bypassing the guard that keeps inputs; derived writes now stay on the branch they were calculated on. - dump_simulation reads each value's mark from the same branch and period as the value it dumps, so #552's branch dumps keep matching marks. - Carry-over checks candidates latest first and stops at the first input, instead of resolving the storing branch for every known period. - Storages drop the marks of deleted keys. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test that deleting a whole branch drops its derived marks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cache the default only where master cached a carried value With no input at or before the period but a later period stored, master returned the default without caching it; caching it changed what formulas that test whether a value is stored see (policyengine-uk's maintenance loan and current_education formulas check get_array(period.last_year)), and gave the uprating path a calculated base. Return it uncached there, as master did; cache it (derived) otherwise, as master cached the value it carried. This replaces the uprating-only special case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read input periods in one pass over readable keys; clear marks with the caches Round-3 review findings: - A period stored only under a branch this one cannot read was taken for an input (own-unit preference ranked it first) and read back as NaN. Holder.get_input_periods(branch_name) now lists, in one pass over the stored keys, the periods whose readable value is an input, taking for each period the key get_array reads first; carry-over picks from those. This also replaces the per-period walk up the branch chain. - apply_reform's cache wipe kept the storages' marks, and rebuilding a disk index brought a stale mark back onto a new input; the wipe now clears the marks and OnDiskStorage.restore starts without any. - Storages pickled before the marks existed failed to clone, put or delete; __setstate__ now defaults them (and #556's shared-key set). - The public carry-over rule now states the own-unit preference and that an input for the period itself is read back as stored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Test memory-over-disk precedence, in-memory mark clearing and index rebuilds Closes three mutants the suite did not kill. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Break equal-extent carry-over ties by unit, then period Two inputs covering the same days in units other than the variable's (month:2013-01:2 and day:2013-01-01:59) resolved to whichever was stored first. Prefer the larger unit, then the period's string form, so the choice depends only on the inputs (round-4 review). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Record a storage's inputs, not its derived values, and share them with clones Measured on a policyengine-us household (policyengine-us 2.18.3, one household_net_income calculation, 6,185 storages, 30,925 holder clones across 5 branches), master with #578 retains 20.94 MB per simulation. With derived marks as a set per storage that holds a derived value, the merge retained 24.35 MB: 1,514 sets in the simulation and a copy of its source's marks in every holder clone. That is 2.6 times the 1.3 MB per simulation that ran policyengine-us's CI out of memory (#577). 5,201 of the 5,204 stored values are derived, so record the inputs instead: is_derived is "stored and not an input". The 3 storages holding inputs have a set (648 bytes); the rest read _NO_INPUTS. A storage and its clones share one frozenset of inputs until one of them changes its own. Retained memory is now 21.25 MB per simulation, +0.31 MB on master: one more 8-byte attribute slot in each of the 37,110 storage objects. A pickle records that inputs were recorded; a state from before (no marker) counts every stored value as an input, as it did then. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Carry over an input that ends after the last representable date The carry-over sort key evaluated period.stop for every candidate input. A period that ends after 9999-12-31 (day:9999-12-30:3, or day:2012-01-01:4000000) has no stop: computing it raises OverflowError. Master carried such inputs ([10, 20]); this branch raised. Sort by _end_order, which puts such a period after every period that has a stop, so it still ends last. Found by the review of #582, which uses the same key for uprating. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Order period ends by day number, so ends after 9999-12-31 compare too 6ddbaf9 sorted every period whose stop cannot be computed after those that have one, but equal to each other, so of two inputs ending after 9999-12-31 the string form decided: day:9999-12-30:9 was carried over day:9999-12-30:10, and 24 months over 800 days from the same day (delta review, P2). Month and year periods past 9999 also raised ValueError, or returned a stop no date can hold, rather than OverflowError. _end_order is now the number of the day after the period's last day, counted as date.toordinal counts, by integer arithmetic on the same calendar. It equals stop + 1 day wherever stop has a value and orders every other period by its true end. Tests: the review's two cases in both set orders; a property that _end_order agrees with Period.stop where it has a value and with numpy's calendar everywhere (1,000 examples, sizes to 5,000,000); a longer period from the same day ends later. Also asserts that a pickled state without a set of shared keys gets none (a surviving mutant). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Problem
Since 3.24.0 (#457, "bug H1"), auto-carry-over in
Simulation._calculatetakes the latest-starting known period of any unit:max(known_periods, key=lambda p: p.start). A YEAR flow variable requested for a month caches a twelfth of the year's value at that month (calculate_divide). So once any month after January has been requested, a YEAR input known only for that year carries a twelfth of its value into every later year.Before #457,
sorted(known_periods)[-1]happened to pick the latest year for a YEAR variable, because"month" < "year".Where it bites
policyengine-us
monthly_agecallsperson("age", month). Illinois CCAP and others reach it while computing taxes and benefits. In a policyengine-usMicrosimulationon a single-year (variable-centric) dataset such as the Enhanced CPS, calculating 2024 and then 2025 gives every person a twelfth of their age in 2025. That flips dependency, filing status, AGI and everything downstream.Measured on a 3,000-household Enhanced CPS subsample, with policyengine-us main and core master 7950c01:
On the full Enhanced CPS, 2025 income tax was $3.9bn after calculating 2024 first. With this fix and policyengine-us#9738, a 2024-then-2025 run gives $2,013.7bn for 2025, bitwise identical in all 24 benchmark arrays to a 2025-only run.
The trigger needs all of these:
auto_carry_over_input_variables = True(US, UK, Canada); a FLOW YEAR input (float by default; an int declared FLOW also qualifies) with no formula and nouprating; and a month request in an earlier year within the same simulation or a branch cloned from it. int, bool and enum variables default to STOCK, and the automatic STOCK conversions return the year's or the month's value without caching it, so in practice those variables were unaffected. (An explicitcalculate_addon a STOCK variable does cache a sum.)Fix
For a variable with a (truthy)
set_inputhelper, carry-over prefers known periods at the variable's definition unit. It falls back to every known period only when there are none at that unit. The change is in the carry-over branch ofSimulation._calculateonly; the rest of master's code, including the uprating path, is unchanged. Variables without a helper keep master's rule, the latest period of any unit; these are DAY variables and any variable with a falsyset_input, such asset_input = None.Why prefer the own unit. The built-in helpers store inputs at the definition period. So beside own-unit values, a value at another unit is ordinarily a cache derived from them:
calculate_divide's twelfth at a month, orcalculate_add's sum at a larger period. Carrying either forward rescales the variable.Why fall back. A value at another unit can still be a genuine input, and there it is the variable's only data. This happens when an input was stored before a reform changed the variable's definition period or gave it a helper, after an automatic reform baseline, after a restore into a system with other definitions, or with a custom helper. Master carries those inputs, and so does this.
Known limits, pinned in the tests.
No non-ETERNITY variable in policyengine-us, policyengine-uk or policyengine-canada lacks a helper. ETERNITY variables have none, but they never carry across periods.
Five designs on this branch were reviewed and replaced:
9e5bc21e99b0ca09clone()and similar paths504e983eapply_reformreplay, dump/restore, branch-scoped deletion and direct writesbab4325a7af22b16On the fifth review's 32-case storage-premise harness, this head differs from master in one case only: after a same-system restore, master carries the derived twelfth (10) and this carries the year input (120).
Invariants
For variables with a helper:
derivative(), dump/restore, an inherited twelfth, an explicit divide on a stock, a dispatch-helper flow, and an anchored-year request.For variables without a helper:
apply_reform, and after a branch's own cache is deleted. The latest of several wins, and a later input beats an earlier cached default or sum.Everywhere:
create_datasetsloop is also bitwise identical (179 input arrays per year, 2024-2026).Tests
tests/core/test_carry_over_definition_period.pyhas 244 cases. Failures by version: master 51,bab4325a4,7af22b166, this head 0.Mutants of the rule all fail at least one case:
is not Nonegating instead of truthiness, preference for FLOW only, for the divide helper only, the early return on unfiltered periods, no fallback, no gating, andminformax.The MONTH order cases are controls. A month input's latest month always outranks its year sum, so they pass on master as well.
Run locally: the test file passes 244 cases, and
ruff format --checkandruff checkare clean. CI runs the full suite.Related
holder.get_known_periods(self.branch_name), then this preference. A reviewer ran the combination with 866/866 targeted cases passing on the previous head.ctc_limiting_tax_liabilityand one tax unit's income tax still differed in 2025.axiom: n/a: engine fix to carry-over in policyengine-core; no policy rule changes
🤖 Generated with Claude Code