Skip to content

Count only inputs as already set when a set_input helper splits a longer period - #581

Draft
MaxGhenis wants to merge 8 commits into
masterfrom
fix-set-input-helper-order
Draft

MaxGhenis wants to merge 8 commits into
masterfrom
fix-set-input-helper-order

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #580.

Summary

set_input_divide_by_period and set_input_dispatch_by_period spread an input given for a longer period over a variable's months (or years). They treated any value stored for a sub-period as already set, including values the simulation had calculated (a cached default, a formula result, a carried-over or uprated value). So the same input gave different values depending on what had been calculated before it was set.

Executed on master b78b0ba, with CountryTaxBenefitSystem plus formula-less person variables and one person:

Variable (helper) Steps New simulation After calculating first
flow_m: float, MONTH (divide) calculate("2013-01"), then set_input("2013", [1200]); read May and January 100, 100 109.09, 0
flow_m calculate("2013"), then set_input("2013", [1200]) accepted ValueError: Inconsistent input
stock_m: int, MONTH (dispatch) calculate("2013-01"), then set_input("2013", [7]); read May 7 0
stock_m calculate("2013"), then set_input("2013", [7]); read December and the year 7, 7 0, 0
flow_y: float, YEAR (divide) calculate("2013"), then set_input("month:2013-01:24", [2400]); read 2013 and 2014 1200, 1200 0, 2400
flow_y calculate("2013-05"), then set_input("month:2013-01:24", [2400]); read 2013-05 100 0

(repro/helper_order.py in the evidence folder; with this branch every "after calculating first" cell equals the "new simulation" cell.)

The helpers now tell inputs from calculated values using the simulation's record of inputs (Simulation._user_input_keys). That record has to be right for them to be right. Where master gets it wrong, this PR either fixes it or names the open PR that does (see "Merge order" below).

restore_simulation. It stored every restored value with put_in_cache, so a restored simulation had an empty record and the helpers would have replaced restored inputs. The dumper now writes which values were inputs (inputs.txt), and restore records exactly those; a dump without the file restores every value as an input. This is #576's simulation_dumper.py change, byte for byte: the two PRs merge without conflict on that file, and whichever lands second changes nothing there.

The rule

A sub-period counts as already set only if its value was stored as an input, meaning it is in the record. Entries are (variable, branch, period); an input set for twelve months from the first of a month is stored under the year, so both forms are recognised.

  • A recorded input is kept, as before:

    • divide subtracts it from the value given and shares the rest among the other sub-periods;
    • dispatch applies it to the sub-periods after it (see below).
  • A calculated value is replaced, as if nothing were stored.

  • Calculated values over overlapping periods are dropped after storing. These are the same variable's values over periods that overlap the input period:

    • the sums calculate_add caches (a monthly variable's year);
    • the twelfths calculate_divide caches (a yearly variable's month);
    • calculated own-period values a branch would read before the new input.

    The search covers the input's branch name and the names its simulation reads through (its ancestors, then default), in that simulation's own memory and disk storage. Inputs are never dropped, and the simulation a branch was created from keeps its values.

  • Bookkeeping. What the helpers store is recorded even when a helper is called directly. calculate's fast cache drops the replaced and dropped periods, but only for a branch the simulation reads: a value stored under a branch it does not read changes nothing it returned.

  • No record. A simulation without one (_user_input_keys missing), or a holder without a simulation, keeps master's behaviour: every stored value counts as set.

The dispatch helper's "reuse the existing array" branch (the TODO)

This branch is kept for inputs, and the docstring now documents it. A sub-period that already holds an input keeps it, and that input, not the value given for the longer period, is applied to the later sub-periods that have none. With 3 set for March, setting 7 for the year gives 7 in January and February and 3 from March to December: a stock's value known at a month holds for the rest of the period.

That is the existing behaviour for inputs set on top of inputs, which the issue requires to keep working. Changing it would change results for sequences with no calculation at all, a separate semantic question. The asymmetry stays: the reverse order gives 7 in every month but March. A calculated value is no longer reused this way; that was the bug, where one calculated January set every later month to the default.

Invariants

Each holds for any variables, inputs and calculation requests in the property tests' scope (tests/core/test_set_input_helper_order_property.py):

  1. Order independence. Take inputs I1, then any lists of plain calculate requests (on the simulation and on up to two nested branches, a list per level), then inputs I2 on the last of these. The result matches setting I1 and I2 on a simulation that calculated nothing:
  2. Inputs only. Those values equal what a model holding inputs alone gives (Reference in tests/fixtures/set_input_helper_order.py), with the two helper rules above applied to inputs.
  3. Conservation (divide). A divided input is accepted whenever a sub-period has no input yet. Once accepted, the variable's values over its sub-periods add up to the value given, and a plain read of that period returns it. Both qualifications below predate this PR:
    • the sum holds to float32 tolerance within the tested magnitudes (inputs up to 1,200); with an existing input of 1e9, float32 rounding loses more than that;
    • an input already stored at a longer period inside the input period (twelve months set as one value) is kept, so reading that period returns that input.
  4. Inputs are kept. A helper never changes an input already set for one of the variable's own periods. Afterwards, every sub-period of the input period holds a recorded input.
  5. Restore keeps the record. These are example tests. A restored simulation records exactly the dumped simulation's inputs: an input set later over a longer period keeps the restored inputs, and apply_reform recalculates restored calculated values.

Outside the properties, because they are other order dependences with their own PRs or issues:

Tests

  • tests/core/test_set_input_helper_order.py, 39 example tests. On master cbfdedf, 26 fail and 13 pass. The 13 that pass pin behaviour that must not change:

    • month inputs kept under a yearly input;
    • a situation giving both a month and its year;
    • a contradicting total refused;
    • values outside the input period kept;
    • inputs stored at a longer period kept, from January or another month;
    • dispatch on top of inputs (2 tests);
    • the no-record fallback;
    • the fast cache kept for an input under a branch the simulation does not read;
    • restored values kept and restored calculated values recalculated (3 tests).

    The restore and fast-cache tests pass on master and fail on this branch's helpers without the matching change.

  • tests/core/test_set_input_helper_order_property.py, invariants 1-4, Hypothesis with 500, 300 and 300 examples. The module starts with pytest.importorskip("hypothesis") for the smoke job. All three fail on master within the first examples. Soak runs passed: 8,000/4,000/4,000 examples at af877e8 and 6,000/3,000/3,000 at 1f5e3ec. The helper logic they exercise has not changed since, apart from the fast-cache condition, which the example test above covers.

  • Mutation check (mutation/run.py): 25/25 mutants killed at 917f0c2. They include the two mutants the review found surviving at 22a9b31 (twelve-month inputs recognised only from January; intermediate ancestor branches skipped), the restore change, and the fast-cache condition.

  • Full suite at 917f0c2 (merged with master cbfdedf): 1,208 passed, 4 skipped, 1 xfailed. Country-template YAML tests: 39 passed.

  • Windows: on disk, a value calculated for a period like year:2013:2 goes in a file named after the period, and Windows rejects : in file names (Storage keys need a structured (branch, period) scheme: str(period) is lossy and separators collide with branch names #526). The property's on-disk examples skip such periods. With : blocked in file names the way Windows does it, the property fails at 1f5e3ec and passes at f7ac58b.

Review

Independent adversarial review, GPT-6.1 Sol, four rounds (review/ in the evidence folder):

Merge order: #561 first; #558 and #571 before or with this

The cases below come from the review, executed on master, on this branch at 917f0c2, and on this branch merged with #561, #571 or #558 (review/review_lifecycle.py, review/round2_scripts/replacement_probe.py; outputs review/lifecycle_917f0c23.out, compose/round4/replacement_561_571.out):

Case master this branch with #561 with #558
A clone shares its source's record (#559); calculated January, then an annual input on the source wrong (January 0) same as master right same as master
A custom set_input handler calculates while storing wrong same as master right same as master
Input deleted with delete_arrays, recalculated, then an annual input wrong same as master right same as master
Twelve-month input deleted (or set on a clone), year recalculated, then an annual input Inconsistent input stale year, months right right same as this branch
Disk-backed clone sets an annual input over a month its source calculated source unchanged source's file overwritten same as this branch source unchanged

How it composes with the other open core PRs

Each PR head was scratch-merged with this branch at 917f0c2, and the full suite was run on the merge (compose/round4/):

PR Merge Full suite on the merge
#576 (restore input record, holder fast-cache eviction) clean 1,268 passed, 4 skipped, 1 xfailed
#561 (_user_input_keys drift) clean 1,239 passed, 4 skipped, 1 xfailed
#571 (ADD/DIVIDE caches) clean 1,224 passed, 4 skipped, 1 xfailed
#558 (disk storage per holder) clean 1,240 passed, 4 skipped, 1 xfailed
#566 (fast cache by containment) clean 1,217 passed, 4 skipped, 1 xfailed
#562, #563, #560, #552 conflicts see below

Downstream

A/B, real runs. Core master cbfdedf against this branch at 917f0c2, with the same country code and data, every array compared byte for byte:

Run Arrays Result Helper events with the fix
policyengine-uk c7e826ea, enhanced FRS 2024-25 (sha256 03fe15e4…), full sample, 2026 36 bitwise identical 0 helper calls
policyengine-us fbe24ad1, enhanced CPS 2024 (sha256 0a6b961a…), 3,000-household subsample, 2026 (income tax and the itemizing / SALT branches, state taxes, CTC, EITC, SNAP, net income, MTRs) 25 bitwise identical 2 helper calls, 24 sub-periods stored, 0 calculated values replaced, 0 dropped

The same comparison at 22a9b31 and 29410c3, against master b78b0ba (the PE-US run on another enhanced CPS 2024 file), was also bitwise identical.

On these runs the only code that differs from master is the helpers' body, which runs twice in PE-US and not at all in PE-UK. Timings were within run-to-run noise on a loaded host:

  • PE-UK: 7.4 s on both.
  • PE-US: 50.9 s on master against 68.1 s with the fix. At 29410c3 it was 102.8 s against 90.9 s, the other way round.

Code that sets inputs over longer periods. Every non-test set_input call was listed, with the definition period of the variable it sets (downstream/set_input_sites.py; it makes no changes to the repos it reads):

Repo Ref Calls
policyengine-us upstream/main 615a0f2d 38
policyengine-uk origin/main 84da2863 30
policyengine-canada upstream/master 389648ad 1

Every call that names its variable passes a period of that variable's own unit, so no helper runs. The calls whose variable is a parameter fall into these groups:

  • Dataset and situation loaders (PE-UK simulation.py build_from_*, PE-US system.py, policyengine.py main 6a9c878 us/model.py). These set inputs on freshly built populations before anything is calculated.
  • Behavioural-response copies (PE-UK move_values and dynamics, PE-US behavioral_response_measurements, tob_revenue_*). All set YEAR variables at years.
  • PE-UK reform and budget loops. These name none of PE-UK's 8 MONTH variables.
  • PE-UK's 37 explicit set_input = set_input_dispatch_by_period attributes. All are on YEAR variables.
  • PE-UK utils/dependencies.py calculate_dependency_contributions. This is the only site where a helper meets calculated values: it zeroes and then restores a dependency with set_input(var, year, ...). For a MONTH float dependency it fails either way: on master the zeroing call raises Inconsistent input, and with this branch the restoring call does (repro/uk_dependencies_pattern.py). Nothing relied on the old behaviour. Fixing the utility is a separate PE-UK task.

No downstream code calls dump_simulation or restore_simulation.

Checks

  • CI on the head commit.
  • ruff format and ruff check clean.
  • Not run: multi-year country runs.

Evidence (scripts, outputs, A/B arrays, mutation results, compositions, reviews): ~/reviews/core-set-input-helper-order-2026-10-02/ on the author's machine.

axiom: n/a: core engine input handling, no policy encoding

🤖 Generated with Claude Code

MaxGhenis and others added 8 commits October 2, 2026 18:08
…ger 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>
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>
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>
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>
… 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>
…culate_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>
…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 branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

set_input helpers count calculated sub-period values as inputs, so an input over a longer period depends on what was calculated first

1 participant