Skip to content

Give a simulation its identifiers for a year before its data - #2354

Draft
MaxGhenis wants to merge 12 commits into
mainfrom
fix-earlier-year-identifiers
Draft

MaxGhenis wants to merge 12 commits into
mainfrom
fix-earlier-year-identifiers

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2353

Builds on #2349, which merged as ed7b42f95. This branch starts from #2349's head (a9664455e), so the diff here is this PR's own work.

The gap

  1. Build a Simulation from a frame or dataset for 2025.

  2. set_input some variable for an earlier year, such as 2023.

  3. The simulation now holds no identifiers in 2023:

    • PE-UK turns on carry-over (tax_benefit_system.py:75), and policyengine-core carries an input forward but never back. Core 3.32.27, simulations/simulation.py:1199-1250, says: "A later input does not carry backwards."
    • When only later periods are stored, core returns the default (:1241-1247).
    • So person_id, benunit_id, household_id, person_benunit_id and person_household_id are all zero in 2023.
  4. The exported frame loads as a dataset whose 2023 tables hold the people and groups:

    • input_dataframe.py:404-405: a whole year that a column of another variable names becomes a dataset year;
    • :431-443: each year's tables hold the identifiers;
    • simulation.py:554-559: every column of every year is set as an input.

    So the reloaded simulation reads the real identifiers there.

Four formulas read identifiers, so their 2023 values differed between a simulation and the one its export loads back to:

  • is_benunit_eldest_child.py:22
  • months_since_last_birthday.py:36
  • uc_deduction_random_draw.py:25
  • uc_deduction_type_random_draw.py:25

Two children of 10 in one benefit unit, given values for 2024 at representative weight, before this PR:

Variable (2024) Simulation Reloaded export
is_benunit_eldest_child [True, True] [False, True]
months_since_last_birthday [3.0, 9.0] [9.0, 3.0]
uc_deduction_random_draw 0.883 0.567
uc_deduction_type_random_draw 0.362 0.258

After this PR, both give the right-hand column.

Which semantics, and why

There were two options:

  • A. The simulation holds its identifiers in every year it holds an input.
  • B. The export does not invent identifiers for years the simulation lacks them in.

I chose A:

  • B can't make the reloaded simulation agree. The loader puts each whole year a frame names in the dataset, and a dataset year's tables must hold the identifiers. So B would also need the loader to build dataset years without people, or to delete identifiers after building. It would also make zero identifiers, which are only a side effect of carry-over, part of the format.
  • A matches what the loader already does for frames. The loader comment at input_dataframe.py:423-425 says inputs carry forward but not back, so a year before the data that a frame names identifiers for needs them set too. A applies the same rule to any set_input.
  • A keeps Export the identifiers for every year an input frame names, so it round-trips #2349's property and fixes the values. export(load(export(S))) == export(S) still holds, and the reloaded simulation computes the same values. The export is column-for-column what Export the identifiers for every year an input frame names, so it round-trips #2349 gives: the identifiers for the earlier year are now inputs the simulation holds, not copies the export adds.

The fix

  • New function hold_identifiers_in(simulation, variable_name, period) in policyengine_uk/data/input_dataframe.py:454. Some identifiers are held as an input for some period, but for none that starts by 1 January of the year period starts in. Each of those is set for that year, copied from the period it is held for that starts first. From there it carries forward.
    • It does nothing for an input core ignores, one for a period after its variable's end (_ignored, :447, which mirrors core simulation.py:1736-1740).
    • Each identifier is set through simulation.set_input, which calls the function again for that identifier. That call finds the identifier held, or returns at the same check if core ignored it, so the calls end.
    • No PE-UK variable has an end today, so the guard changes nothing now.
    • An input in or after the first year the identifiers are held changes nothing.
    • A simulation that holds no identifier as an input, such as one built from a situation, is left alone.
    • A branch gets the identifiers on its own branch only.
  • Override Simulation.set_input (simulation.py:254-260) calls core's set_input, then hold_identifiers_in. It sits just after delete_arrays.
  • File ownership: simulation.py belongs to the Autumn Budget W1 session (local_37646626). This PR's changes there are only:
  • Docs and changelog:
    • a docstring paragraph and the with_identifiers_in_each_year docstring (input_dataframe.py);
    • one bullet in docs/book/usage/simulations.md;
    • changelog.d/2353.fixed.md.

#2076 also overrides set_input: combine them

#2076 adds its own Simulation.set_input override, which calls forget_model_value.

  • Git doesn't conflict. git merge-tree c10e7e3a1 <#2076 head> exits 0, and the merged class defines set_input twice. Python keeps the later definition, so one PR's behaviour would be lost without an error.
  • Lint might not catch it. ruff check reports this as F811, but when this PR was written Lint ran only ruff format --check. Run ruff check in Lint, with F811 and F821 on for the package #2355 adds ruff check --select F811,F821.
  • The guard. This PR adds policyengine_uk/tests/code_health/test_simulation_methods_defined_once.py. It fails when Simulation or Microsimulation defines a method twice (property setters, deleters and overload stubs aside). I checked that it fails on the tree merged with Lift the benefit cap only for the support component and the people HB reg 75F and UC reg 83 name, and cap only the family's benefits #2076.
  • The fix, for whichever PR lands second: combine the two overrides into one that calls super().set_input(...), then forget_model_value(self, variable_name, period), then hold_identifiers_in(self, variable_name, period).

#2349 review nits folded in

The UK hub passed these on from #2349's Sol review:

  1. The claims that columns sort by period, and that the frame loads back to the same frame, are now scoped to the default export. With include_computed_variables=True, core exports periods in storage order.
  2. A new Hypothesis test, test_the_export_and_the_loader_find_the_same_years, checks that the loader's years and the export helper's years agree.
    • It covers frames with identifier, value, lone-person_id and month columns in different years.
    • Mutation-checked: an export that skips years named only by an identifier fails it.
  3. The docstring says the helper returns df itself when it adds nothing, and otherwise a new frame.
  4. New test: an export whose person_id differs between years, before or after the data, is still refused on load.

Invariants

These hold for every input:

  1. Identifiers in each input year. A simulation that holds identifiers as inputs holds them in every year it is given an input for. They are its people's and groups' own identifiers, whether held as inputs or carried forward.
  2. Differential. In each year a frame-built simulation is given inputs for, it computes the same value as the simulation its export loads back to. This holds for every identifier and every variable whose formula reads one.
  3. Round trip (Export the identifiers for every year an input frame names, so it round-trips #2349). export(load(export(S))) == export(S).
  4. No-op on datasets. A simulation built from a dataset, as H5 files load, holds the identifiers as inputs in exactly the dataset's years, whether the years are stored ascending or descending.
  5. No-op without identifiers. A simulation that holds no identifier as an input is given none.

Tests (policyengine_uk/tests/test_dataframe_dataset.py)

Property (invariants 1 to 3): test_a_reloaded_simulation_reads_the_same_identifiers_in_each_year_given_inputs (Hypothesis).

  • Random populations, all four identifier-column layouts, input years from 2022 to 2027, and weights of 1 or 1,000,000. The million weight reaches the Universal Credit draws' representative-data branch.
  • It finds every variable whose formula reads an identifier by scanning formula source, and asserts the four known readers are among them.
  • Explicit examples:
    • Hypothesis's shrunk counterexample: one person and inputs for 2022, where months_since_last_birthday was 6.48 against 5.59;
    • the twins above at a million weight, where all four readers differed.

Deterministic:

  • a month input two years before the data: identifiers are given for its year only, carry to the next, and none come for later years;
  • a lone identifier before the data brings the others;
  • a branch holds its own;
  • a situation gets none (invariant 5);
  • datasets in ascending and descending year order (invariant 4);
  • an input core ignores gives no identifiers, and an identifier core would ignore is left out. With the _ignored check removed, both tests fail and the second hits RecursionError; I ran it to confirm.

Also added:

  • test_the_export_and_the_loader_find_the_same_years (Hypothesis, see the nits above);
  • test_an_export_whose_identifiers_differ_between_years_is_refused;
  • tests/code_health/test_simulation_methods_defined_once.py.
Check Result
New property test on #2349's head, before the fix fails: shrunk to one person, months_since_last_birthday 6.48 vs 5.59 in 2022
New tests at the test-only commit 426df7b49 (mutation check) 4 fail (property via both explicit examples, month, lone identifier, branch); the 3 no-op tests pass
test_dataframe_dataset.py, the code-health test, test_uc_rebalancing.py and test_input_records.py at d2f48c86f, -n 3 112 passed
test_dataframe_dataset.py at the first fix commit c48f3a9a5, -n 3 85 passed
test_uc_rebalancing.py, test_input_records.py, test_claimant_or_partner_awards_entered_directly.py, test_entered_directly_lifecycle.py, code_health/test_cached_arrays_not_written_in_place.py, test_simulation_dataset_sources.py 105 passed
ruff format / ruff check on changed files clean

I didn't run the full make test or make documentation locally; CI runs both.

H5-built Microsimulations are unaffected

By reading the code: the hook acts only through set_input. It does something only when an input starts in a year before every period an identifier is held for.

  • A dataset build sets every year's tables, each with all five identifiers (simulation.py:554-559). In the first year loaded, no identifier is held when the first column is set. From then on each year holds them, whatever order the years are loaded in.
  • These set_input callers set years the data covers:
    • move_values;
    • the rebalancing in uc_reform.py, for 2026 to 2029.
  • These set the year being calculated: the labour supply and marginal rate branches, and dynamics/.
    • For a year the data covers, the hook does nothing.
    • For a year before the data, only the branch gets the identifiers (the parent holds no input there). That is the intended behaviour, which test_a_branch_given_an_input_before_the_data_holds_the_identifiers_itself pins.

By running it: h5_check.py builds a Microsimulation from an H5 file twice. One build removes the override, which is #2349's behaviour. The other counts hook calls that change any identifier input. Each run then calculates 14 aggregates for 2025, 2026 and 2029:

  • household net income (and HBAI);
  • income tax and National Insurance;
  • Universal Credit, Child Benefit, Pension Credit, Housing Benefit and State Pension;
  • council tax;
  • the four identifier readers.

It then compares a sha256 over every input array before and after the calculations.

Dataset Hook calls Calls that changed an identifier Input digest (off vs on) Aggregates differing
enhanced_frs_2023_24_tiny.h5 (years 2023 to 2030) 1,256 0 identical, 1,232 inputs (174546b7…) 0 of 42
enhanced_frs_2024_25.h5, uk-data 1.58.0 pending
enhanced_frs_2023_24.h5, uk-data main pending

The full-dataset runs are queued on the machine's shared heavy-run lock, as a launchd job that writes to ~/reviews/uk-earlier-year-ids/h5/. I'll fill in these rows when they finish.

Cost: the hook takes about 35 µs per set_input call when it does nothing. That is about 45 ms for the ~1,256 calls of an eight-year build, whatever the number of households. Tiny builds took a median of 1.41 s with the hook against 1.29 s without (4 each, on a loaded host).

Review

Both reviews so far were served by Opus 5.5: Subfleet promoted each hard-tier review from GPT-6.1 Sol when no Codex lane admitted it.

Opus 5.5: APPROVE at 38bfb0d6d (~/reviews/uk-earlier-year-ids/review/opus-review-38bfb0d6d.md), with four minor findings:

  1. The hook ran for an input core ignores, and could recurse without end for an ended identifier. Fixed in d2f48c86f with tests.
  2. A branch given an input before the data holds identifiers its parent doesn't. This is intended, and a test pins it.
  3. The docstring overstated what the dataset holds. Narrowed in d2f48c86f, corrected in 11b806732.
  4. The instrumented H5 run's build times aren't a clean comparison. Agreed. The cost figures above come from bench_hook.py, not from that run.

Opus 5.5: REQUEST_CHANGES at f9ddc20a2 (opus-review-f9ddc20a2.md). It found nothing wrong in the runtime code, but four false or imprecise claims, all fixed in 11b806732 and this description:

  1. The recipe for combining with Lift the benefit cap only for the support component and the people HB reg 75F and UC reg 83 name, and cap only the family's benefits #2076 used the old signature. It now says hold_identifiers_in(self, variable_name, period).
  2. The check that skipped an ended identifier did nothing, and the claims that it prevented a recursion were false. I ran it: both tests pass with that check deleted, so I deleted it. The _ignored check on the input itself is what ends the calls; with it removed too, the ended-identifier case hits RecursionError.
  3. The docstring still overstated what the dataset holds. Corrected.
  4. "Other set_input callers set years the data covers" was imprecise. Reworded above.

It also noted, as information only, that _ignored doesn't mirror core storing nothing for a neutralised variable (holder.py:462-466). An input for a neutralised variable in an earlier year still gives identifiers, which keeps the round trip. It judged a neutralised identifier that already holds inputs unreachable in PE-UK.

GPT-6.1 Sol review of the current head: pending, pinned to a Codex lane so it can't fall back.

axiom: n/a: simulation input and export plumbing, no policy change

🤖 Generated with Claude Code

Reproducer: policyengine_uk/tests/code_health/test_simulation_methods_defined_once.py::test_each_method_of_the_simulation_classes_is_defined_once, policyengine_uk/tests/code_health/test_simulation_methods_defined_once.py::test_a_method_defined_twice_is_found, policyengine_uk/tests/test_dataframe_dataset.py::test_a_reloaded_simulation_reads_the_same_identifiers_in_each_year_given_inputs, policyengine_uk/tests/test_dataframe_dataset.py::test_an_input_before_the_data_gives_the_identifiers_for_its_year, policyengine_uk/tests/test_dataframe_dataset.py::test_an_identifier_given_alone_before_the_data_brings_the_others, policyengine_uk/tests/test_dataframe_dataset.py::test_a_branch_given_an_input_before_the_data_holds_the_identifiers_itself

MaxGhenis and others added 9 commits October 11, 2026 12:12
Simulation.to_input_dataframe() now gives the person, benefit unit and
household identifiers for each whole year the frame gives other values
for. The dataset a frame loads as holds them in each of those years, so
a simulation built from the frame records them there and exported them,
while the simulation that made the frame held them in a year after its
data only by carrying them forward. Since #2251 every simulation records
uc_receives_new_claimant_health_element for 2026 to 2029, so every frame
export failed the round trip that #2270's property test checks: 18
columns exported, 38 re-exported.

Adds the minimal counterexample as an explicit example, regression
tests for the rebalancing's years and for inputs set by hand before,
among and after them, a unit test of the helper, and a property that a
frame over several years, given inputs for other years once built,
exports the same frame again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The loader sets all five identifiers for each whole year an identifier
column names, as well as for each year of the dataset, so an export that
gives one identifier for a year alone (person_id set by hand for 2032)
still re-exported four more columns. The helper now adds every
identifier the frame gives for each whole year a model-variable column
names, identifier columns included.

Also corrects the account of a year before the data, where inputs do
not carry back and the exporting simulation's identifiers are zero, and
states that the ordering matches the default (sorted) export. Adds an
identifier-only case to the regression and the property.

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

A simulation built from a frame and given values for a year before its
data holds no identifiers there, because policyengine-core carries
inputs forward but never back. The frame it exports loads back to a
simulation that holds the real identifiers in that year, so formulas
that read them compute differently there.

The property checks that the simulation and its reloaded export compute
the same values in each year given inputs for, both for the identifiers
and for every variable whose formula reads one, and that both hold the
people's own identifiers there. The examples are Hypothesis's shrunk
counterexample (one person, an input three years before the data) and two
children of one age, for whom all four identifier readers differed.
Further tests cover a month input, a lone identifier, a branch, a
situation, and datasets loaded as H5 files load.

Refs #2353.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
policyengine-core carries an input forward but never back, so a
simulation given an input for a year before every year it holds its
identifiers for held none there: every person, benefit unit and
household identifier read as zero. The frame it exports loads as a
dataset that holds the people and groups in that year, so the reloaded
simulation read the real identifiers there, and is_benunit_eldest_child,
months_since_last_birthday and the Universal Credit deduction draws gave
different values.

Simulation.set_input now calls hold_identifiers_in, which gives each
identifier the simulation holds as an input for some period, but for
none starting by the first day of the year the input starts in, for that
year, copied from the period it is held for that starts first. From
there it carries forward. An input in or after the first year the
identifiers are held changes nothing, and a simulation that holds no
identifier as an input, such as one built from a situation, is left
alone. Datasets load each year with its identifiers, so loading one
gives none for any other year.

The change to simulation.py is the set_input override and one import.

Fixes #2353.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the review of #2349 (the export helper this builds on):
- Limit the claims that the columns sort by period, and that the frame
  loads back to the same frame, to the default export. With
  include_computed_variables=True, core exports each variable's periods
  in the order they are stored.
- Say that with_identifiers_in_each_year returns the frame itself when
  it adds nothing, and otherwise a new frame.
- Test that the loader and the export find the same years for frames
  that name identifiers, values and months for different years. The
  round trip relies on their agreeing, and each works the years out
  separately.
- Test that an export whose person_id differs between years, before or
  after the data, is still refused when loaded.

#2076 also overrides Simulation.set_input, just after delete_arrays.
Put this override in the same place, so that if both PRs land git
reports a conflict to resolve, rather than one override silently
replacing the other in the class body.

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

Without it, every identifier was named for the same years, so the test
passed even with an export that ignored years only an identifier names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous commit put this PR's set_input override beside
delete_arrays expecting git to conflict with #2076's override there. It
does not: git merges the two (`git merge-tree` exits 0), and the class
then defines set_input twice. The later definition, this PR's, replaces
#2076's, so its forget_model_value call would be lost without an error.
CI checks formatting only, not `ruff check`, whose F811 reports this.

This code-health test fails when Simulation or Microsimulation defines a
method more than once (property setters, deleters and overload stubs
aside). It fails on the tree merged with #2076, so whichever of the two
lands second must combine the overrides into one: call core's set_input,
then forget_model_value, then hold_identifiers_in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 3 commits October 11, 2026 17:10
From the review of 38bfb0d: policyengine-core's set_input stores
nothing for a period after the variable ends, but the override still
gave the identifiers for that year. If an identifier itself had ended,
setting it would store nothing, and hold_identifiers_in would call
set_input again without end.

hold_identifiers_in now takes the input's variable and returns when core
ignores the input, and skips an identifier core would ignore for the
year. No variable in policyengine-uk has an end date today, so nothing
changes now; the tests give one for a single test.

Also corrects its docstring: the reloaded simulation holds the people
and groups in a year when the exported frame gives a whole year's value
for it, not in each year the frame gives values for.

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

#2355 adds ruff check --select F811,F821 to Lint, so 'CI checks
formatting only' would go stale once it lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From the review of f9ddc20:

- The check that skipped an identifier core would not store did nothing.
  hold_identifiers_in sets each identifier through simulation.set_input,
  which calls it again for that identifier, and the check that an input
  core ignores gives no identifiers already returns there. With the
  skipped check deleted both tests still pass; with the remaining check
  removed as well, the ended-identifier case raises RecursionError (both
  executed). So the last commit's claim, and the test's comment, that the
  deleted check prevented the recursion were wrong. The docstring now
  says how the calls end.
- The docstring said each whole year a column names is a year of the
  dataset. A year only an identifier column names is not: the loader
  sets the identifiers there as inputs.

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

Copy link
Copy Markdown
Collaborator Author

CI run cancelled to free runners (UK hub, 2026-10-11 23:30Z). The PolicyEngine org is at GitHub's limit of 60 concurrent jobs, with about 360 queued. Fleet ops asked the hub to let the Budget-critical #2305 take runners first. This PR's queued run had not started, so I cancelled it. Nothing is wrong with the PR.

Please don't re-run it or push just to retrigger CI. The hub re-runs this run itself once #2305 has runners. The record is ~/reviews/uk-hub/ci-yield-2026-10-11.json; see notice 17 in ~/reviews/uk-hub/notices.md.

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.

A simulation given an input for a year before its data holds no identifiers there

1 participant