Repository navigation
Conversation
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>
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>
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 |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #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
Build a
Simulationfrom a frame or dataset for 2025.set_inputsome variable for an earlier year, such as 2023.The simulation now holds no identifiers in 2023:
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.":1241-1247).person_id,benunit_id,household_id,person_benunit_idandperson_household_idare all zero in 2023.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:22months_since_last_birthday.py:36uc_deduction_random_draw.py:25uc_deduction_type_random_draw.py:25Two children of 10 in one benefit unit, given values for 2024 at representative weight, before this PR:
is_benunit_eldest_child[True, True][False, True]months_since_last_birthday[3.0, 9.0][9.0, 3.0]uc_deduction_random_draw0.8830.567uc_deduction_type_random_draw0.3620.258After this PR, both give the right-hand column.
Which semantics, and why
There were two options:
I chose A:
input_dataframe.py:423-425says 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 anyset_input.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
hold_identifiers_in(simulation, variable_name, period)inpolicyengine_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 yearperiodstarts 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.end(_ignored,:447, which mirrors coresimulation.py:1736-1740).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.endtoday, so the guard changes nothing now.Simulation.set_input(simulation.py:254-260) calls core'sset_input, thenhold_identifiers_in. It sits just afterdelete_arrays.simulation.pybelongs to the Autumn Budget W1 session (local_37646626). This PR's changes there are only:to_input_dataframedocstring (see below).with_identifiers_in_each_yeardocstring (input_dataframe.py);docs/book/usage/simulations.md;changelog.d/2353.fixed.md.#2076 also overrides
set_input: combine them#2076 adds its own
Simulation.set_inputoverride, which callsforget_model_value.git merge-tree c10e7e3a1 <#2076 head>exits 0, and the merged class definesset_inputtwice. Python keeps the later definition, so one PR's behaviour would be lost without an error.ruff checkreports this as F811, but when this PR was written Lint ran onlyruff format --check. Run ruff check in Lint, with F811 and F821 on for the package #2355 addsruff check --select F811,F821.policyengine_uk/tests/code_health/test_simulation_methods_defined_once.py. It fails whenSimulationorMicrosimulationdefines 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.super().set_input(...), thenforget_model_value(self, variable_name, period), thenhold_identifiers_in(self, variable_name, period).#2349 review nits folded in
The UK hub passed these on from #2349's Sol review:
include_computed_variables=True, core exports periods in storage order.test_the_export_and_the_loader_find_the_same_years, checks that the loader's years and the export helper's years agree.person_idand month columns in different years.dfitself when it adds nothing, and otherwise a new frame.person_iddiffers between years, before or after the data, is still refused on load.Invariants
These hold for every input:
export(load(export(S))) == export(S).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).months_since_last_birthdaywas 6.48 against 5.59;Deterministic:
_ignoredcheck removed, both tests fail and the second hitsRecursionError; 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.months_since_last_birthday6.48 vs 5.59 in 2022426df7b49(mutation check)test_dataframe_dataset.py, the code-health test,test_uc_rebalancing.pyandtest_input_records.pyatd2f48c86f,-n 3test_dataframe_dataset.pyat the first fix commitc48f3a9a5,-n 3test_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.pyruff format/ruff checkon changed filesI didn't run the full
make testormake documentationlocally; 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.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.set_inputcallers set years the data covers:move_values;uc_reform.py, for 2026 to 2029.dynamics/.test_a_branch_given_an_input_before_the_data_holds_the_identifiers_itselfpins.By running it:
h5_check.pybuilds 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:It then compares a sha256 over every input array before and after the calculations.
enhanced_frs_2023_24_tiny.h5(years 2023 to 2030)174546b7…)enhanced_frs_2024_25.h5, uk-data 1.58.0enhanced_frs_2023_24.h5, uk-datamainThe 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_inputcall 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:d2f48c86fwith tests.d2f48c86f, corrected in11b806732.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 in11b806732and this description:hold_identifiers_in(self, variable_name, period)._ignoredcheck on the input itself is what ends the calls; with it removed too, the ended-identifier case hitsRecursionError.set_inputcallers set years the data covers" was imprecise. Reworded above.It also noted, as information only, that
_ignoreddoesn'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