Repository navigation
Fix disable_simulated_benefits and read one claimant-or-partner award - #2075
Conversation
…ons' into disable-simulated-benefits-scoped-awards
The reform raised before setting anything: the simulation never linked itself to its tax-benefit system, the dataset year is text for a situation and a number for a multi-year dataset, and Attendance Allowance, DLA and PIP have no reported amount since #1656. It holds each benefit at its dataset-year amount while the reported amounts uprate, so in later years the value rule read the whole of esa_income or jsa_income as the claimant's or partner's. The reform now sets claimant_or_partner_esa_income and claimant_or_partner_jsa_income for each year from their own dataset-year reports, and Income Support from theirs alone. income_support_eligible reads those two variables for its income-related ESA and income-based JSA limbs, as the means tests and passports do. One helper, claimant_or_partner_award, is the formula of both variables. Where the stored award equals only the plain total of the reports, the gate now reads the plain total of the claimant's and partner's reports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rtner' into disable-simulated-benefits-scoped-awards
…rtner' into disable-simulated-benefits-scoped-awards
Review r3c on #2027 found that #2013's income_support_eligible ignored a directly entered claimant_or_partner_esa_income: it worked the couple's award out from esa_income and everyone's reports, so an adult outside the couple could flip eligibility. #2075's gate reads the claimant-or-partner awards as they stand; these tests pin that down. - A carer otherwise eligible for Income Support, with an outside adult reporting 2,000, 3,000 or 5,000 of the award and 3,000 of esa_income or jsa_income entered for the benefit unit: - 3,000 entered as the claimant's and partner's award: never eligible, no Income Support; - 0 entered as theirs: always eligible. Both for ESA and for JSA, in 2025 (income-based JSA still active). - Property: with the claimant-or-partner awards entered, redrawing the outside adult's ESA and JSA reports never changes the gate, and the gate equals the gate with both awards entered as zero, barred exactly where either is positive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vahid-ahmadi
left a comment
There was a problem hiding this comment.
Thanks Max. I reviewed this at head 7cd3c34df. I looked only at this PR's own change (1f689253a and the two merges of #2027), not at the #2025 code it brings in.
What I checked:
-
The fix works. On #2027's head (
fc1ff55d1), turning on the reform with a situation still raisesAttributeError: 'CountryTaxBenefitSystem' object has no attribute 'simulation'. On this branch it runs, for situations with periods keyed by year and with scalar values.- The new link at
simulation.py:190matches core 3.32.17, which setsself.tax_benefit_system.simulation = selfin its own constructor. - Each UK
Simulationbuilds its ownCountryTaxBenefitSystem, so the link can't leak between simulations. I built two reformed simulations in one process: they kept their own ESA (£4,000 and £1,000 in 2027), and their system objects were not the same.
- The new link at
-
Ten-year hold. A couple with a non-dependant who reports £4,000 of ESA. With the reform,
esa_incomeis £4,000 in both 2025 and 2027.claimant_or_partner_esa_incomeis £0 in both years, Income Support eligibility is false, and only the non-dependant is on ESA. Without the reform,esa_incomeuprates to £4,173.82 in 2027 and the couple's award stays at £0. -
Claimant and non-dependant both reporting (£4,000 and £3,000). Under the reform,
esa_incomeis £7,000 and the claimant-or-partner award is £4,000 in 2025, 2026 and 2027. -
Directly entered award. A single adult with
esa_incomeof £5,000 entered directly gets a claimant-or-partner award of £5,000, and the Income Support gate is barred. That is the same as on #2027. -
Law. SSCBA 1992 s.124(1)(f) and (h), read on legislation.gov.uk:
(f) he is not entitled to a jobseeker's allowance and, if he is a member of a couple, the other member of the couple is not, and the couple are not, entitled to an income-based jobseeker's allowance
(h) he is not entitled to an employment and support allowance and, if he is a member of a couple, the other member of the couple is not entitled to an income-related employment and support allowance
The limbs read the claimant's and partner's income-related awards. The claimant's own contributory ESA and JSA are still barred separately (
income_support_eligible.py:126-135), so both limbs are covered. -
Tests.
- The seven changed pytest files pass, each run in its own process: 83 tests.
- YAML:
finance/benefit/familyand the council tax reduction folder, 511 passed. - I also ran main's read-only-cache test (
code_health/test_cached_arrays_not_written_in_place.py) against this branch. It fails 6 of 35, exactly as it does on #2027's head (pip_reform.py,uc_reform.py), so the failures come from the stack and not from this PR.
-
Calculator inputs don't change. I ran 9 common API households, with and without roles, against #2027's head: a single earner who rents, a couple with two children, a lone parent, a pensioner couple, ESA or JSA reported, a non-dependant with ESA, and a lone parent on Income Support.
household_net_income, UC, IS, HB, PC, ESA, JSA and CTR are identical to the penny.
Findings:
-
Should fix: the branch conflicts with its own base. #2027 moved to
fc1ff55d1, which conflicts with this branch invariables/gov/dwp/claimant_or_partner_esa_income.pyandclaimant_or_partner_jsa_income.py. Only thedocumentationstrings conflict: #2027 added "Values are compared to within half a penny after rounding to the precision esa_income is stored in". Fix: merge #2027 again, keep this branch's helper-basedformula, and move that half-penny sentence into the new documentation (it is already in theclaimant_or_partner_awarddocstring). -
Should fix: under the reform, another member's Income Support report leaves household income.
CLAIMANT_OR_PARTNER_ONLY = ["income_support"](reforms/policyengine/disable_simulated_benefits.py:53, used at :104) setsincome_supportfrom the claimant's and partner's reports only. A non-dependant's own ESA and JSA stay inesa_incomeandjsa_income, but their Income Support disappears.- Probe, reform on, 2025: a claimant plus a non-dependant who reports £3,000 of Income Support gives
income_support£0 andhousehold_benefits£0. The same non-dependant reporting £3,000 of ESA giveshousehold_benefits£3,000. - Your eFRS run says no such member reports Income Support, so this changes nothing on the data. It does break the "full awards stay in household income" rule for calculator situations.
- Fix: either carry the other members' Income Support reports into household income, as ESA and JSA do (for example a separate other-member amount that counts in
household_benefitsbut that the passports don't read), or state the exclusion in the reform docstring and the changelog. Then theexcluded_income_support: 0expectation intest_disable_simulated_benefits.pyreads as intended rather than as a loss.
- Probe, reform on, 2025: a claimant plus a non-dependant who reports £3,000 of Income Support gives
-
Nit: the eFRS figures were run on
9e88021a4, not the current head. The latest merge of #2027 came after that run. A one-line note that the later merge only brings in #2027 would do. CI hasn't run because the base isn'tmain, so please let it run once the stack retargets. -
Nit: sequencing with #2047. It rewrites the same
BENEFITSblock indisable_simulated_benefits.pyand parses the year asint(simulation.dataset.time_period), which would hit the text-yearTypeErrorthis PR fixes. Your body already covers the winter-heating split. Please also keep this PR'sint(str(...)[:4])when resolving. I checked that this branch on its own doesn't count anything twice:pawhpisn't inhousehold_benefitshere.
The PR body has aggregates and unweighted benefit-unit counts only. It has no record ids, weights or record-level amounts.
Happy to approve once it merges cleanly onto #2027.
…mulated-benefits-scoped-awards
Conflicts:
- claimant_or_partner_{esa,jsa}_income documentation: main's wording (young
persons with their own award, the half-penny comparison) plus this PR's
(the Income Support gate reads it; disable_simulated_benefits sets it for
each year).
- income_support_eligible: keep main's Pension Credit bar (s.124(1)(g)) and
the SPC qualifying age; the ESA and JSA limbs read the claimant-or-partner
awards as before (> 0).
Main's gate also gained a half-penny rule after #2013's after-merge review
(6eb8db7): an award within half a penny of zero never bars the claim, so
the float residual left by reports that exactly match the tariff income is
no award. To keep that rule while the limbs read the shared variables,
claimant_or_partner_award now gives zero when the stored award or the
claimant's and partner's part of it is within half a penny of zero. Every
reader of the two variables (passports, means tests, the gate) now reads
the residual the same way; on the formula path the gate equals main's in
every case except the declared plain-total one.
The differential test's copy of the replaced code is now main's gate at
e89b8ab (half-penny limbs, has_attained_state_pension_credit_qualifying_age,
on_pension_credit), and pre_switch_limb uses the same half-penny thresholds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r1 (REQUEST_CHANGES on 9e88021, ~/reviews/disable-simulated-benefits-2026-10-02/subfleet/review_r1.md) and Vahid Ahmadi's review on 7cd3c34. 1. A clone or branch applying a reform set its parent's inputs: a cloned system copies the link to the parent, and a branch shares the parent's system. Simulation.clone now links a cloned system to the clone, and Simulation.apply_reform links the system to the simulation applying the reform while it runs, then restores the link. Regression: the reform applied to a clone and to a branch sets their inputs and none of the parent's. 2. A reform that neutralized esa_income or jsa_income left the claimant-or-partner awards the reform had set as inputs, so passports and the Income Support bar outlived the benefit. Neutralizing either award now neutralizes its claimant-or-partner part (CountryTaxBenefitSystem.variable_parts), whether the reform runs first or second, and for an award entered directly. Parameter changes cannot remove the reform's amounts by design: they are inputs. 3. Income Support from another member's own report (r1 #3, Vahid #2): left out of income_support with the reform on, as it is with the reform off, where would_claim_IS and income_support_eligible read only the claimant's and partner's reports. Stated in the reform, the changelog and a test that checks both modes. 4. Roles: the reform now reads is_claimant_or_partner in each year, holding only the amounts at the dataset year, so every reader in a year sees the same couple as the award. Regression: a claimant who stops being the claimant in a later year. On the Enhanced FRS roles never change between years (ages are carried over), so this moves nothing there. 5. Wording: most *_reported variables are uprated (not ssmg_reported or council_tax_benefit_reported); the multi-year dataset test keeps its roles fixed in every year. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mulated-benefits-scoped-awards Conflict in simulation.py: main dropped the clone override because policyengine-core 3.32.27 copies the input record per clone (#2262). Core's clone now also links a cloned tax-benefit system to the clone, which was the only other thing this branch's override did, so the override goes. The apply_reform override (link while applying, restore after) stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts-scoped-awards No conflicts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers the after-merge review of 135b384. - simulation.py: input_variables is taken when the simulation is built, and reset_calculations, derivative and the marginal rate formulas delete every variable it does not name. A reform applied afterwards (a Reform class passed as reform=, or applied to a clone or branch) supplied inputs it did not name, so a reset deleted the claimant-or-partner awards and the formula read an outside adult's ESA as the couple's. apply_reform now names the variables whose inputs the reform recorded, assigning a new list because a clone or branch shares its parent's. - Test: on all three routes the reform's inputs are named, the parent's list is untouched, derivative and a reset leave every value the reform set and the Income Support gate as they were. Without the fix each route fails (the reset changes six reform-set arrays and flips the gate; the derivative is nonzero). - Reform docstring: the amounts are the same in every year except Income Support and the claimant-or-partner awards, which follow who is the claimant or partner. - Changelog: the half-penny rule applies to the claimant's and partner's award; a member outside the couple is still read on their own report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Answers review r3 of 8b278e3. - simulation.py: apply_reform named only variables with a new input record. An input set after the build is recorded but not named in input_variables, and a reform that replaces it adds no record, so a reset deleted what the reform set: the excluded adult's claimant-or-partner ESA went from 0 to 3,000 and the Income Support gate flipped. set_input now records, while apply_reform runs, the variables the reform sets on the simulation applying it (a ContextVar, so a clone or branch made meanwhile records nothing and nested reforms restore the outer record), and apply_reform names those that are still inputs of that simulation, as well as those with new records. - The comment and changelog say the marginal rate formulas also keep variables without a formula, and that replaced inputs survive too. - Test: a reform that replaces claimant-or-partner awards set after the build, applied to the simulation itself, a clone and a branch (the control: a branch's records are new), keeps them through derivative and a reset, and does not name an input set after the build that it did not set. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Status at head
|
…ted-benefits-scoped-awards
Review r4 (REQUEST_CHANGES on 0d53268) found the set_input tracker both too broad and too narrow. It named a variable when a reform called Simulation.set_input, even when core stored nothing (past the variable's end, a set_input helper skipping months that hold inputs) or the reform deleted what it stored, and then any surviving input of that variable was kept through a reset. It missed inputs stored through Holder.set_input, from another thread, or while another simulation applied a reform. While apply_reform runs, the simulation's _user_input_keys is now an _InputKeyLog: a set that also logs every key core's Holder._set adds, which it does once it has stored an input, on every route. apply_reform names each variable with a logged key the simulation still holds after the reform, under a branch name it reads. A reform applied within another logs for both. The ContextVar and the set_input override are gone. New test file covers each storing route, each non-storing case r4 found, clones and branches made during a reform, a raising reform, and a property test against a model of what the reform leaves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The build makes a holder for every variable, and a holder keeps the definition it was made with, so core stored the input for a variable the reform neutralized and no warning was raised. Neutralize a variable added after the build, whose holder the reform's write makes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r5 (REQUEST_CHANGES on 5f1c878): core can replace a simulation's _user_input_keys while a reform is applied to it (a reform applied to its parent rebuilds a branch's record; rebinding the system rebuilds it), and the log was then lost, so a later input was not named. And an _InputKeyLog could not be copied or unpickled, so a simulation could not be deep-copied or pickled while a reform ran. _user_input_keys is now a property on Simulation, kept in __dict__ as core keeps it. While apply_reform applies a reform, a set assigned to it becomes a log. The active logs are kept by simulation id outside the simulation, because core's clone copies __dict__. _InputKeyLog reduces to a plain set for copy and pickle. Docs say a reset keeps or clears whole variables, and what a raising reform names. Tests: a branch's reform after its parent applies one, a record rebuilt mid-reform, copies of the record during a reform; the property now also checks that a named variable keeps its other inputs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… apart The differential property drew six derandomised populations, and nothing made one of them include a household that receives PAWHP. Two explicit examples now always run: a single Scottish pensioner in 2025 and a Scottish couple on Pension Credit in 2024. The fixed check that used to build its own population now checks that each example receives PAWHP. A new check fails if a benefit is both in disable_simulated_benefits' BENEFITS and excluded from it. winter_fuel_allowance in particular must stay out of BENEFITS: the winter heating split sets it, and a generic entry as well would be harmless only while the split runs after the generic loop (relevant when #2075 meets this branch). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
A note from #2047 for this PR's generic list, from #2047's review (finding 3), re-run on #2047's merge of Under Probe (synthetic data: two people aged 40,
A suggested fix, following #2047's winter heating split: for a Scottish household from 2025, the report sets Also for whichever of #2075 and #2047 lands second: keep #2047's winter heating split block after the generic loop, take |
Review r6 (REQUEST_CHANGES on 587c0e7): 1. Main's #2119 closes income-based JSA and Income Support from April 2026 and then moves a family reporting either off all its legacy benefits (legacy_benefits_closed), ending its ESA, JSA and Income Support. Under disable_simulated_benefits, which pays every reported award in every year, an adult outside the couple was then paid ESA and JSA but not on them, since own_report_is_paid reads esa_income_eligible. The reform now holds legacy_benefits_closed false for its ten years (NOT_MOVED), as it already holds Universal Credit and the legacy awards at their reports, so every reader of the closure agrees with the awards it pays. New test: with the reform off every test family is moved from 2026; with it on, none is, in any of the ten years. The clone-or-branch test now compares the parent's ESA with an unreformed simulation's, which the closure ends from 2026, instead of expecting the excluded adult's uprated report. 2. From policyengine-core 3.33.0 derivative keeps every input, named or not (3.32.x deleted every variable input_variables does not name). The comments, the test docstring and the changelog now say reset_calculations deletes unnamed variables, as derivative did before 3.33.0. 3. _InputKeyLog docstring: a deep copy or pickle of a simulation logs nothing; a shallow copy shares the record, as it shares the holders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…2274) into disable-simulated-benefits-scoped-awards Conflicts: - disable_simulated_benefits.py: #2047 (PAWHP in household income) moved BENEFITS to a module constant without winter_fuel_allowance and added the winter heating split after the benefit loop. Kept the split as #2047 wrote it, and the branch's per-year loop, constants (PRE_MINIMUM, CLAIMANT_OR_PARTNER_AWARDS, CLAIMANT_OR_PARTNER_ONLY, NOT_MOVED) and dataset-year parsing; the split uses the same years range. - simulation.py: both sides set tax_benefit_system.simulation before the structural reforms (kept one, with the branch's comment). Main's #2274 overrides apply_reform to refresh the capital gains projection; the branch overrides it to link the system and name the inputs a reform stores. Git kept both definitions, the second hiding the first. They are now one method that refreshes the projection after core applies the reform, as main's did, inside the branch's logging and linking. - utils/benefit_unit.py and claimant_or_partner_{esa,jsa}_income.py: #2076 generalised the scoped-award formula to award_of_members(..., members), which the benefit cap readers use. claimant_or_partner_award is now award_of_members for the claimant and partner plus the half-penny rule, so both variables keep one helper as their formula and the values are unchanged. award_of_members reads its tolerance from HALF_PENNY (0.005, as before). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r7 (REQUEST_CHANGES on 5366cf0, documentation only): - Finding 2: the NOT_MOVED comment and the changelog said everything that reads the closure agrees with the awards the reform pays. Holding legacy_benefits_closed false removes only the closure's own effects; the model's other rules still apply, so a family the model has claim Universal Credit anyway (would_claim_uc) is still not eligible for Housing Benefit or tax credits while the reform pays what it reports. The comment, the reform docstring and the changelog now say so. The comment also says the migration notice ends a family's legacy awards whether or not it then claims Universal Credit, unless DWP sent it to Pension Credit. - Finding 1 (the PR body's derivative claims) is fixed in the PR body. - Finding 3 (holding the closure false is defensible) needs no change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e reform #2031 (merged into this branch from main in 6f0148a) has an adult outside the couple on income-based JSA, on their own claim, only while the scheme is open (gov.dwp.JSA.income.active, false from 2026). Under disable_simulated_benefits the reform still pays the JSA they report in every year, and the claimant and partner stay on the award the reform holds for them, but the outside member's own status now ends from 2026. A probe of the merge (a couple with an adult outside it who reports JSA) gives that adult is_on_income_based_jsa true in 2025 and false in 2026 and 2027, where test_each_person_is_on_their_own_award expected true in every year. The reform holds only the move off legacy benefits (NOT_MOVED), and the model's other rules apply (review r7's qualification). Overriding the scheme's closure for the reform would need a parameter change, which a branch would share with its parent, or statuses calculated during construction. So the test now expects the outside member on the JSA they report only in the years the model has the scheme open, and checks that 2025 is open and 2026 and 2027 are not. The NOT_MOVED comment, the changelog and test_no_family_is_moved's comment say so. No change to the model. £0 on the Enhanced FRS either way: no member outside the couple reports ESA, JSA or Income Support there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2047's winter heating split (merged in 6f0148a) reads gov.social_security_scotland.pawhp.active through the parameters the reform class was built from. When the parameter switches the reform on, that is the applying simulation's own tree (create_structural_reforms_from_parameters is given self.tax_benefit_system.parameters), so nothing changes there. A reform class built from other parameters and applied later (the PR's clone, branch and reform= tests build it from a stand-in that only switches it on) raised AttributeError: 'types.SimpleNamespace' object has no attribute 'social_security_scotland' (a probe applying the class to a situation). The split now reads the switch from the system of the simulation applying the reform, which is also the tree a later parameter change would be in. The rule itself (#2047's) is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vahid-ahmadi
left a comment
There was a problem hiding this comment.
Fine once main is merged in and the Scottish carer double count is fixed.
@MaxGhenis, this is a follow-up to my review at 7cd3c34df. I looked at what changed up to the head 5366cf0c3.
My earlier points are settled. The base conflict was resolved. Another member's Income Support is now left out on purpose, and the docstring, changelog and a test that checks both modes say so. The eFRS figures were re-run. The #2047 sequencing is written up in the body. The new pieces since then look right to me:
- the per-year roles;
variable_partsneutralization (tax_benefit_system.py:169-181);apply_reformnaming the inputs a reform stores;- holding
legacy_benefits_closedfalse under the reform (reforms/policyengine/disable_simulated_benefits.py:62-71).
That last hold is consistent with a reform that pays every reported legacy award in every year.
-
must: the branch conflicts with main again. Against main
714b1438c,git merge-treereports conflicts in five files:reforms/policyengine/disable_simulated_benefits.pysimulation.pyutils/benefit_unit.pyclaimant_or_partner_esa_income.pyclaimant_or_partner_jsa_income.py
#2047 and #2261 have both merged.
- Main's reform now has #2047's winter heating split and
int(simulation.dataset.time_period). Keep the split block after the generic loop. Takewinter_fuel_allowanceout ofBENEFITS(it is still at line 30 here). Keep this branch'sint(str(...)[:4])and a singletax_benefit_system.simulationlink. - #2261 ends an outside member's own ESA or JSA status once legacy benefits close. Please check that its reader and
NOT_MOVEDstill agree after the merge. The new "no family is moved in any of the ten years" test should cover it if it still passes.
-
should: a Scottish carer's Carer's Allowance is counted twice under the reform.
carers_allowanceis inBENEFITS(line 13), so it is set from the report in every country, whilecarer_support_paymentstays simulated. I reproduced your 13 October note at this head. Two people aged 40, 40 care hours,carers_allowance_reported£4,000 in 2025, one in Scotland and one in London:Year carers_allowance[Scot, Lon]carer_support_payment[Scot, Lon]reform on 2025 [4,000, 4,000] [4,331.60, 0] reform on 2026 [4,000, 4,000] [4,495.40, 0] reform off 2025 [0, 4,331.60] [4,331.60, 0] This has been on main since #2047 (main's reform already runs on a numeric dataset year and lists
carers_allowance). This PR rewrites the list, though, so please fix it here or file it as an issue. Your suggested split along the lines of the winter heating one, plus a conservation test, would do. -
should: overlaps with other open PRs.
- #2193 adds its own
Simulation.apply_reformtosimulation.py(bindingtax_benefit_system.simulation, then calling core). The twoapply_reformmethods will conflict. This PR's version also sets that link, so it covers what #2193's does. - #2382 adds
war_widows_pensiontoBENEFITS, together with its_reportedvariable. Main's list and this PR's rewritten list will conflict on that line. Keep the entry when merging, so thattest_every_listed_benefit_has_what_the_reform_reads_and_setscovers it.
- #2193 adds its own
-
nit:
apply_reformnow relies on core internals. It uses_user_input_keys,_get_visible_branch_namesand a module-level_reform_logskeyed byid(self), with a property wrapped round core's attribute (simulation.py:304-382). The tests cover it well. But a core release that renames or restructures_user_input_keyswould break it silently, unless a test pins that the attribute still exists. A short follow-up issue to move "inputs a later reform sets survive a reset" into policyengine-core would make this lighter. -
nit: the PR body's test counts are from
d10ef154c("to be re-run on the head before merge"). The 5366cf0 commit (legacy closure hold) isn't listed under Reviews or Mutants yet.
Repo process: the .fixed.md fragment fits. The axiom: line is present, and the Reproducer: line was added by the Fix evidence check. CI at this head is green on every job.
What I checked
- Read the body, the two newer comments, and the diff against the merge base
1d3abecef, with the reform,simulation.pyandtax_benefit_system.pyin full. - Ran the 6 changed pytest files at the head: 122 passed.
ruff checkandruff format --checkon the 13 changed Python files: clean.- Ran the Scottish carer probe above at the head.
- Checked the five conflicts with
git merge-treeagainst main714b1438c. I didn't attempt the merge. - I didn't re-run the YAML folders or the eFRS runs, and I didn't mutation-test the new legacy closure hold.
🤖 Generated with Claude Code
…ted-benefits-scoped-awards
…form disable_simulated_benefits set carers_allowance from carers_allowance_reported in every country and left carer_support_payment simulated, so from 2025, when the model pays a carer in Scotland Carer Support Payment in place of Carer's Allowance, a Scottish carer was paid the report and a simulated Carer Support Payment (Vahid Ahmadi's review of 5366cf0, finding 2; #2047's note). Like #2047's winter heating split, the report now becomes a Scottish carer's carer_support_payment from CARER_SUPPORT_PAYMENT_FROM (2025, the year both pre-overlap formulas switch on) and their carers_allowance before, and everyone else's carers_allowance. The pre-overlap entitlements stay the model's for both schemes. New tests (test_disable_simulated_benefits_carers.py): the reported case, conservation and placement in every year on both simulation classes and the reform= route, a differential against the model's own scheme choice with the reform off, household_benefits counting the report once, and a Hypothesis property. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22
left a comment
There was a problem hiding this comment.
Review status: COMPLETE
Review severity: REQUEST_CHANGES
Confirmed critical count: 1
Reviewed head: ab800e8bb340eea07299b79038361f874557c192
The new carer-report split has one blocking counterexample. The shared input-lifecycle and claimant/partner benefit paths otherwise passed the scoped checks.
C1 — Route the held carer report using the current year's country (OPEN)
Location: policyengine_uk/reforms/policyengine/disable_simulated_benefits.py:188 (dataset-year country read at168–170, reused in the year loop at191–194).
The new carer split calculates household country once at the dataset year, then uses that fixed geography for all ten years. Both country and region are YEAR-defined, and an explicit situation or multi-year dataset can put the carer in Scotland in a later year. The reform then places the held report under the wrong scheme and loses the Scottish Carer Supplement, despite its declared per-year scheme-placement contract.
Executed at this head: one adult aged40 in2025 and41 in2026,40 weekly care hours in both years, £4,000 carers_allowance_reported in2025, London in2025 and Scotland in2026; no earnings or overlapping-benefit reports. Interpret the later region as a settled ordinary-residence move within the UK, with the qualifying care and other conditions continuing. The reform is enabled before data loading. In2026, the actual country is SCOTLAND and the model's ordinary pre-overlap calculations already select CSP (£4,495.40) and CA (£0), but the reform sets:
| 2026 output | Actual reform | Current-year routing control |
|---|---|---|
| Carer's Allowance | £4,000 | £0 |
| Carer Support Payment | £0 | £4,000 |
| Scottish Carer Supplement | £0 | £608.40 |
| Household benefits | £4,000 | £4,608.40 |
| HBAI household net income | £4,000 | £4,608.40 |
| Household net income | £3,819.96 | £4,428.35 |
The control changes only the held report's2026 placement (CA0/CSP4000), retaining the same £4,000 held total, original report and demographic/income inputs. It does not force eligibility. Both worlds have zero income tax; the supplement difference reaches final household income. The reform's new routing therefore loses the full £608.40 modeled annual supplement in this reachable case.
The source reviewer confirmed that SSI2023/302 reg6(1)(a) tests ordinary residence in Scotland on the relevant day, rather than in the survey year. Reg14A supplies the supplement for weeks CSP is payable, with the payable/overlap distinction in reg16. The settled UK-resident move and continuing care/no-overlap conditions establish the scenario premise; the code also demonstrates the model's own current-year scheme choice.
Read and project the carer's country inside the year loop, then retain the dataset-year reported amount under that year's scheme. Add a regression with year-varying region inputs through the supplement and final household income. This finding targets the newly added carer split; it does not extend this review into inherited winter-payment geography.
Validation
- All 147 cases in the eight changed Python test modules passed. The combined local batch had 1,586 passes (1,340 YAML and 246 Python).
- It also had 66 configured-capital-gains errors caused by the reviewer harness rejecting a constructor keyword, before the changed code ran. All had the same error in an unchanged constructor branch. One representative passed after correcting the harness; the other 65 were not rerun locally. This is not an all-pass local result.
- The reviewed head's full hosted CI, including Test / Result, passed. Local validation used an existing Core 3.33.0 environment with Hypothesis 6.168.2; it does not reproduce locked Core 3.33.2 locally.
- The carer case above reaches final household income. A separate four-household-year SCP control passed; 13 source originals were validated. Ruff and diff checks passed. Private Enhanced FRS results were not replayed.
The axiom: n/a exemption is defensible for this model reform and emulator award mapping; no missing-parity blocker is counted. This review covers the current head rather than the older head still named in the PR body.
juaristi22's review at ab800e8 (C1): the carer split read the household's country in the dataset year only and used it for all ten years. country is a YEAR variable, and a situation or multi-year dataset can move a household into Scotland in a later year; the reform then paid that carer's report as Carer's Allowance and lost the Scottish Carer Supplement (2026: household income £4,000 against £4,608.40 for a carer who stayed in Scotland). Both splits now read country in each of the reform's years. The winter heating split (from #2047) had the same dataset-year read and gets the same fix. On a projected single-year dataset, such as the Enhanced FRS, the region is the same in every year, so nothing changes there. Tests: a carer and a pensioner who move into or out of Scotland, each year compared with a household that stayed (through the supplement and household income), on the scenario and reform= routes; Hypothesis properties with each household's region drawn per year in a multi-year dataset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22
left a comment
There was a problem hiding this comment.
Review status: COMPLETE — re-review at d274f35. Approval; previous C1 resolved, no new findings.
The held carer amount now follows each year's country for CA/CSP placement. Both settled move directions preserve the £4,000 report, add/remove the £608.40 Scottish supplement as appropriate, and carry that difference through household benefits and final income. The related winter routing uses the same year-specific geography. Annual residence remains a model input proxy; this does not certify midweek residence history.
One bounded current-head batch passed all 47 checks: the two full affected modules, selected input/reset/role/neutralization and Income Support controls, plus four independent literal C1 controls covering both scenario/reform routes, request order and reset. Source and code checks were independent. Main e7610ac is integrated; unchanged lifecycle/scoped-award sections retain their earlier reviewed provenance. The trusted-base Fix evidence check passed.
Local Python 3.13.14/Core 3.32.27 differs from locked Core 3.33.2. This is a focused rerun, not a new full-suite/private-FRS result; the earlier larger batch's 66 external harness errors remain recorded. The existing model/emulator-mapping Axiom exemption remains qualified, with no encoded-parity claim.
At the fresh activity check, public feedback was unchanged and no current-head public review claim was present. Four hosted Python shards and required Test/Result remain pending. Coordinate overlaps with #2193/#2382 when either lands; this approval does not establish merge readiness.
juaristi22
left a comment
There was a problem hiding this comment.
Review status: COMPLETE
Review severity: APPROVE
Reviewed head: 871e46de991b37841930e13bc3f08160f4cc70ca.
Incremental review of the merge of main b4dbaf57a: no new findings; C1 remains resolved. Fifteen of 16 branch files are unchanged from the previously approved head. All previous functions in the shared helper retain their ASTs; its added child helper matches main.
Independent policy and code review checked the affected integration. All 14 current-head controls passed, covering clone/branch/reset behavior, scoped Income Support awards, both reform routes and the original carer-country case through final income. Thirty-three unchanged carer/winter properties retain their earlier passing results: 47 distinct cases of retained coverage, with 14 freshly executed. All 14 cached source originals and 13 extracts validated.
The annual residence and qualifying-week limits, held-report semantics, and older private-data/harness qualifications remain. Local execution used Core 3.32.27 rather than locked 3.33.2; no private population or encoded-parity result is claimed. The previously supported emulator-mapping Axiom exemption remains applicable.
Fresh CI check: no current failures; pending Test / Python 1/4, Test / Python 2/4, Test / Python 3/4, Test / Python 4/4, Test / Result (absent). Landing coordination with #2193/#2382 remains; this approval does not establish merge readiness.
Review r11 (Opus and GPT-6.1 Sol) on 871e46d: main's benefit cap readers (#2076, #2089) called award_of_members on esa_income and jsa_income. Under disable_simulated_benefits the held total matches neither of its readings after the dataset year, so another member's held award was read as the couple's: it raised benefit_cap_welfare_benefits and could exempt a claimant with an ESA support component who receives no ESA. - utils/benefit_unit.py: award_of_couple reads claimant_or_partner_* wherever the rule's couple is the claimant and partner, and award_of_members for any other couple (a UC couple with is_uc_claimant entered). CLAIMANT_OR_PARTNER_AWARDS moves here; the reform imports it. - benefit_cap_welfare_benefits and both specified-benefit exemptions read award_of_couple. - The reform's docstring says it reads its inputs when it is applied, so a region a scenario's after-load modifier or set_input changes later is not followed; applying the reform after the change reads it (r11 Sol finding 2, Opus nit 4). New test for that route. - Changelog: the first bullet no longer claims what #2047 shipped (r11 Opus finding 2); new bullet for the benefit cap. - New test_disable_simulated_benefits_benefit_cap.py: metamorphic (another member's report never changes the cap, reform on), differential against main's replaced reader (reform off), the fallback couple, and an entered award reaching the cap. Each reader's mutant fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22
left a comment
There was a problem hiding this comment.
Incremental re-review of 593259fd8d4c: COMPLETE, no new findings; C1 remains resolved.
The new per-benefit-unit scoped ESA/JSA routing preserves the different-couple fallback and the documented application-time lifecycle. Independent policy/source and code/test reviews agree. Accepted 119 distinct cases: all 114 repository cases passed initially; one reviewer total omitted unchanged Child Benefit, and that corrected diagnostic alone passed. The original failed diagnostic is retained separately. Fourteen cached source originals and 13 extracts passed integrity checks; prior policy qualifications remain.
Required hosted CI is still pending. Coordination with #2193/#2382 and integration before landing remain. The annual-residence, held-cash and private-data limitations are unchanged; this approval does not certify encoded parity or merge readiness.
Local validation used Core 3.32.27 rather than locked 3.33.2. Tests ran against captured main 1bdc7a0; main subsequently advanced to 3f8d4bd with release packaging/version-only changes, separately checked as outside this review scope. No tests on the new main combination are claimed.
Review r12 nits on 593259f (both reviewers; no code change requested): - award_of_members' docstring said readers read award_of_couple; only the benefit cap does, the means tests, passports and Income Support gate read the claimant_or_partner_* variables directly. - award_of_couple's fallback also covers an entered is_uc_assessed_claimant or more than two members entered as claimant or partner, and still misreads another member's held award under the reform; the docstring and changelog say so. - The metamorphic invariant in the new test's docstring is for a couple that is the claimant and partner. - variable_parts is built from CLAIMANT_OR_PARTNER_AWARDS rather than repeating the map (same value). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22
left a comment
There was a problem hiding this comment.
Review status: COMPLETE
Review severity: APPROVE
Reviewed head: c2e069da969e782af04e814c309e2691f8c3bae4.
Incremental re-review: no new findings; C1 remains resolved. The shared award-to-part mapping expands to the same two neutralization targets, and the other executable ASTs are unchanged after docstrings. The fallback qualifications match the existing claimant and UC-couple selectors.
Eleven current-head checks passed, covering neutralization, directly supplied awards, final statuses and cap routing. Prior evidence remains valid for unchanged behavior: 119 retained cases with five overlaps give 125 distinct selected cases. All 14 cached official source originals and 13 extracts passed integrity checks.
Local execution used minimum-supported Core 3.32.27 rather than locked 3.33.2. Private FRS, population and encoded-parity claims were not rerun or certified. Current-head CI passed, including required Lint and Test / Result. Coordination with #2193/#2382 and integration with current main remain landing gates; this approval does not establish merge readiness.
|
For the legacy award reader switches (task_6c880fe4; launchd
So |
Fixes #2264
Summary
Fixes the
disable_simulated_benefitsreform (gov.contrib.policyengine.disable_simulated_benefits) so that it pays each family the benefits it reported, read the same way by every rule that asks whether the claimant or partner is on income-related ESA or income-based JSA. It also gives the claimant's and partner's ESA and JSA awards one reading, shared by theclaimant_or_partner_*variables, both income-related limbs ofincome_support_eligibleand the benefit cap.This PR is based on
main(last merged in at b4dbaf5, which includes #2047, #2031, #2261, #2076, #2274, #2085, #2399, #2277, #2405, #2242 and #2089). Head: c2e069d.What was wrong
The reform raised before setting anything, for three reasons, each hiding the next:
Simulation.__init__never settax_benefit_system.simulation, a situation's dataset year is text, and five listed benefits (Attendance Allowance, both DLA components, both PIP components) have no<name>_reportedvariable. #2047 (merged 2026-10-13) fixed all three onmainwith the same changes this PR carried, so onmainthe reform now runs on the Enhanced FRS. What remains:esa_income_reportedandjsa_income_reportedare uprated.claimant_or_partner_esa_income,claimant_or_partner_jsa_income(Count only the claimant's and partner's legacy awards in means tests and passports #2027) and the Income Support limbs decide whose awardesa_income/jsa_incomeholds by comparing it with the award on the current period's reports and with their plain total. In a later year neither comparison holds, so the whole award, another member's included, is taken to be the claimant's or partner's.income_support, which the passports read as the claimant's.legacy_benefits_closed): their eligibility and the "on" statuses read from it end, and the Universal Credit claim and Housing Benefit and tax credit eligibility follow, while the reform still pays every reported award.reform=, or applied to a built simulation, a clone or a branch) reaches the simulation through the system's link, which on a branch points at the parent, andreset_calculationsdeletes what it stored, becauseinput_variablesis taken at the build.carers_allowancefromcarers_allowance_reportedin every country and leavescarer_support_paymentsimulated. From 2025 the model pays a carer in Scotland Carer Support Payment in place of Carer's Allowance, andcarer_support_payment_pre_overlapstill pays a Scottish carer whose Carer's Allowance is reported. So a Scottish carer reporting £4,000 got £4,000 of Carer's Allowance and £4,331.60 of Carer Support Payment in 2025 (Vahid Ahmadi's review of 5366cf0, and Count the Pension Age Winter Heating Payment in household and HBAI income #2047's note on this PR). Count the Pension Age Winter Heating Payment in household and HBAI income #2047's winter heating split also reads where the household lives in the dataset year only, so a household that moves into or out of Scotland in a later year is paid under the wrong scheme (juaristi22's review of ab800e8 found this for this PR's first carer split).benefit_cap_welfare_benefits) and the ESA support component exemptions from the Universal Credit and Housing Benefit caps (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, Give Universal Credit and Housing Benefit their own benefit cap and LHA shared-accommodation rules #2089) work out the couple's award fromesa_income/jsa_incomeby value (award_of_members), as in item 1. In a later year the reform's held total matches neither reading, so an award held for a non-dependent adult, claimed in their own right, counted as the couple's: it raised the cap's total and could exempt a claimant with a support component who receives no ESA (review r11 on this PR, by two reviewers).What changes
Simulation.__init__setstax_benefit_system.simulationbefore applying structural reforms, asmaindoes since Count the Pension Age Winter Heating Payment in household and HBAI income #2047 (this PR only rewords its comment).Simulation.apply_reformlinks the system to the simulation applying the reform while it runs, then restores the link, so a clone or branch that applies the reform sets its own inputs, never its parent's.apply_reformis one method: it also refreshes the capital gains projection after core applies the reform, as Add versioned capital gains projection scenarios #2274's override did.input_variablesis taken when a simulation is built, andreset_calculationsdeletes the values of every variable it does not name (as core'sderivativedid before policyengine-core 3.33.0; from 3.33.0derivativekeeps every input), and the marginal rate formulas those of every such variable that has a formula. Core records each input in the simulation's_user_input_keysas (variable, branch, period):Holder._setadds the key once it has stored the value, and again when it replaces one, on every route (Simulation.set_input,Holder.set_input, aset_inputhelper, another thread). WhileSimulation.apply_reformruns, that record is an_InputKeyLog, a set that also logs each key added._user_input_keysis a property onSimulation(kept in__dict__, as core keeps it), so a record core replaces mid-reform (a branch's, rebuilt when a reform is applied to its parent, or one rebuilt when the system is rebound) is logged too. The active logs are kept by simulation id outside the simulation, because core'sclonecopies__dict__, and a deep copy or pickle of the record is a plain set. Afterwardsapply_reformadds toinput_variablesevery variable with a logged key that the simulation still holds, under a branch name it reads (its own, its parent branches' ordefault). So a reform applied after the build keeps through a reset every input it stores and leaves in place, including one that replaces an input set since the build. It names nothing it did not store: a value core does not store (Simulation.set_inputpast the variable's end, a neutralized variable, a month aset_inputhelper skips because it already holds an input), an input the reform stored and then deleted, or anything stored on a clone or branch made during the reform. An input set since the build that the reform did not store is still reset, as before, unless the reform stores an input of the same variable:input_variablesnames whole variables, so a reset keeps every input of a named variable. A reform applied within another to the same simulation (a tuple's parts, or a reform that applies one) logs for both. If the reform raises,apply_reformnames nothing it stored itself (a reform it applied first has named its own). It assigns a new list, because a clone or branch shares its parent's. The parameter route (disable_simulated_benefitsswitched on in a scenario) runs beforeinput_variablesis taken and is unchanged.winter_fuel_allowanceis not inBENEFITS, and the one dataset-year winter heating report is set aspawhpin Scotland while PAWHP is paid and aswinter_fuel_allowanceotherwise; it now reads each year's country, below):main'sint()reads a plain year, text or number, the same way);carer_support_paymentfromCARER_SUPPORT_PAYMENT_FROM(2025, the year from whichcarers_allowance_pre_overlapandcarer_support_payment_pre_overlapmove a Scottish carer to Carer Support Payment) and theircarers_allowancebefore then, and everyone else'scarers_allowance. Neither benefit is simulated beside the report. This split and the winter heating split read where the household lives in each year (countryis a YEAR variable), so a household that moves into or out of Scotland is paid under that year's scheme.carers_allowanceis no longer inBENEFITS. The entitlements before the overlapping-benefit reduction (the two*_pre_overlapvariables) stay the model's, for both schemes, as they did for Carer's Allowance;gov.social_security_scotland.pawhp.active) from the system of the simulation applying it. When the parameter switches the reform on that is the tree it was built from; a reform class built from other parameters and applied later used to raiseAttributeErrorthere;claimant_or_partner_esa_incomeandclaimant_or_partner_jsa_incomefor each of the ten years, from the dataset-year reports of whoever is the claimant or partner that year (is_claimant_or_partner). Only the amounts are held at the dataset year; the roles are read in each year, as every reader of the awards reads them;income_supportfrom the claimant's and partner's reports alone. In the modelincome_supportis the claimant's family's award, and the passports read it as theirs;legacy_benefits_closedfalse for its ten years (NOT_MOVED): it pays every listed benefit, the legacy ones and Universal Credit alike, at its dataset-year report in every year, so it moves no family off legacy benefits. Only the closure is held; the model's other rules still apply. A family the model has claim Universal Credit anyway (would_claim_uc) is still not eligible for Housing Benefit or tax credits, and an adult outside the couple who reports income-based JSA is on it, as their own claim, only in the years the model has the scheme open (gov.dwp.JSA.income.active, to 2025; Apply the income-related ESA and income-based JSA remunerative work conditions #2031's rule), though the reform pays it in every year. The claimant and partner are on the award the reform holds for them in every year;main's order, unchanged here). The amounts it sets do not follow a change made after that, by such a modifier orset_input, asmain's reform did not; to have it read one, make the change in the situation or dataset, or apply the reform after it (Simulation.apply_reform).esa_incomeorjsa_incomealso neutralizes its claimant-or-partner award (CountryTaxBenefitSystem.variable_parts). An award the reform set, or one entered directly, no longer outlives the benefit it is part of, whichever order the reforms are applied in.claimant_or_partner_awardinpolicyengine_uk/utils/benefit_unit.pyis the formula of bothclaimant_or_partner_*variables. It is 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'saward_of_membersfor the claimant and partner, plus the rule that an award within half a penny of zero, stored or worked out, is no award (the ruleincome_support_eligiblegained in Apply the Income Support conditions to the claimant and partner only #2013's after-merge review).benefit_cap_welfare_benefits,is_uc_benefit_cap_exempt_specified_benefitandis_housing_benefit_benefit_cap_exempt_specified_benefitread the couple's income-related ESA and income-based JSA through a new helper,award_of_couple: theclaimant_or_partner_*variable wherever the cap's couple is the claimant and partner (the Housing Benefit exemption's couple always is; the cap's Universal Credit couple is unlessis_uc_claimantoris_uc_assessed_claimantis entered, or more than two members are entered as claimant or partner), andaward_of_membersfor that couple otherwise.CLAIMANT_OR_PARTNER_AWARDS, the map from each award to its claimant-or-partner variable, moves toutils/benefit_unit.py, and the reform imports it.claimant_or_partner_esa_income > 0and the income-based JSA limb (s.124(1)(f)) isclaimant_or_partner_jsa_income > 0. The gate now reads what the means tests and passports read, including an award entered for those variables directly or set by the reform. Main's other conditions (remunerative work, contributory JSA, the Pension Credit bar of s.124(1)(g), the State Pension Credit qualifying age) are unchanged.Why the limbs read the variables and do not call the helper
A helper recomputes the claimant's and partner's award from
esa_incomeand the reports. It cannot see a value the reform set onclaimant_or_partner_esa_income, so limbs that called it would keep the later-year defect. With the formula deriving the award, reading the variable gives the same result asmain's limbs,(award > ½p) & (~as_reported | scoped > ½p), except in the plain-total case below. Where the value is formula-derived, the variable is zero when the stored award is within half a penny of zero, the scoped award when the reports explain the stored award, and the stored award itself otherwise.Behaviour changes to note
esa_income/jsa_incomeequals the plain total of everyone's reports but not the formula's award, the gate used to read the claimant's and partner's award after the capital screen. It now reads the plain total of their reports, as Count only the claimant's and partner's legacy awards in means tests and passports #2027's variables already did. Three existing tests pinned the old reading and now state the new one.income_support_eligiblealready read it as no award; the claimant-or-partner variables now do too. So it is no longer the claimant's or partner's award for any reader of those variables, among them the Housing Benefit, tax credit and council tax reduction means tests, the claimant's and partner'sis_on_income_related_esaandis_on_income_based_jsa,in_receipt_of_income_support_jsa_ib_or_esa_ir, the council tax reduction passport,is_scp_eligibleand the childcare entitlements. The benefit cap now reads it as no award too, wherever its couple is the claimant and partner. A member outside the couple is still read on their own report (own_report_is_paid), without the half-penny rule. No benefit unit in the Enhanced FRS has a stored ESA or JSA award between £0 and £1, so this moves nothing there.award_of_membersitself is unchanged.carer_support_payment_pre_overlap, the model's entitlement, with the reform on as with it off.income_support, and so of household income, under the reform. That is what the model does with the reform off:would_claim_ISandincome_support_eligibleread only the claimant's and partner's reports. ESA and JSA differ:esa_incomeandjsa_incomecount every member's award either way.esa_income/jsa_incomeafter calculating them has to clearclaimant_or_partner_*as well as the gate or the cap's variables. The three test helpers that recalculate the gate do so.is_uc_claimantoris_uc_assessed_claimantis entered, or more than two members are entered as claimant or partner), another member's held ESA or JSA no longer enters the cap's total or lifts the cap in any year. For any other Universal Credit couple the cap still readsesa_incomeandjsa_incomeby value, so under the reform it still takes another member's held award to be the couple's after the dataset year (the Enhanced FRS enters none of these inputs, andis_claimant_or_partner's formula flags at most two members). The cap's own rules are unchanged.reset_calculationsand the marginal rate formulas, and exported as inputs byUKSingleYearDataset.from_simulation, as the parameter route's always were. Under policyengine-core 3.33 (the lock),derivativekeeps every input either way.Invariants
For every benefit unit and each of the reform's ten years, with non-negative reports:
esa_incomeandjsa_incomeequal the sum of every member's dataset-year report, andclaimant_or_partner_*equals the sum of that year's claimant's and partner's (conservation);0 <= claimant_or_partner_* <= esa_income / jsa_income(bounds). For formula-derived values this holds to the rule's half penny. A value entered directly is read as entered;legacy_benefits_closedis false in every one of the reform's years;income_support_eligibleis false whenever either claimant-or-partner award is positive (the limbs only bar);Under the reform, in each of its years and wherever the cap's couple is the claimant and partner, the benefit cap's total, both schemes' specified-benefit exemptions and both cap reductions are the same whether or not a member outside the couple reports ESA or JSA (metamorphic). With it off, the cap's total equals the reader it replaces to within a penny, and the couple's award the exemptions read equals
award_of_membersfor the couple except that an award within half a penny of zero is none (differential).For every person and each of the reform's ten years:
carers_allowance + carer_support_paymentequals the person's dataset-year Carer's Allowance report (conservation), and the report iscarer_support_paymentexactly in Scotland from 2025 (placement), which is where the model with the reform off pays Carer Support Payment rather than Carer's Allowance (differential). For every household,winter_fuel_allowance + pawhpequals its winter heating report (#2047's invariant).For a reform applied to a built simulation: it adds to
input_variablesexactly the variables it leaves an input of its own for, and never changes a parent's list; a reset keeps each of those inputs, and every variable it does not add, and the build did not name, recalculates as if nothing had been set since the build.Differential: in formula mode, and for awards entered directly, set later, set on a branch or recalculated after deletion, the new limbs and gate equal the code they replace. The copy of that code is
main's gate at e89b8ab, which is unchanged at b4dbaf5, including its half-penny limbs and Pension Credit bar. The one exception is the plain-total case above, which is checked against its own expectation.Enhanced FRS
Real runs on a private copy of the Enhanced FRS 2024-25, 2025-2030. Base
b4dbaf57a(main, without this PR) against this PR's headc2e069da9and the previous head593259fd8, all clean checkouts, reform off and on. Aggregates only. Scripts and outputs:impact/r2026-10-17(the base, and871e46de9),impact/r2026-10-17b(593259fd8) andimpact/r2026-10-17c(c2e069da9) in the author's review folder. Every figure below holds at both heads: atc2e069da9the reform-off run again matchesmainin all 120 arrays, and with the reform on all 120 arrays equal those at593259fd8in every year.income_support_eligible, both claimant-or-partner awards, every passport,carers_allowance,carer_support_payment, the benefit cap's total, exemptions and reductions, and household net income. 74 benefit units have a member who is neither the claimant, the partner nor a child or young person they are responsible for; none of those members reports ESA, JSA or Income Support.871e46de9, all 120 arrays are identical in every year, as expected with no member outside the couple reporting ESA or JSA. The carer and winter heating arrays are identical to the run atd274f35eb, and no household's country changes across the years (runr2026-10-16).main's reform. Since Count the Pension Age Winter Heating Payment in household and HBAI income #2047,main's reform runs too. Household incomes differ only in the 119 households with a carer in Scotland who reports Carer's Allowance.main's reform pays them that report as Carer's Allowance (£506m in 2025, £516m in 2030) beside a simulated Carer Support Payment; this branch pays the report once, as Carer Support Payment. So household benefits are £513m lower in 2025 and £590m lower in 2030, and household net income £452m and £515m lower (both benefits are taxable, so household tax is £61m to £74m lower, in 55 households). The same double count had entered the means tests' income: council tax reduction applicable income is £443m lower in 2025 (121 benefit units), Housing Benefit applicable income £260m lower (99) and tax credits applicable income £485m lower (115). The benefit cap's total of welfare benefits is £331m higher in 2025 (106 benefit units) and £378m higher in 2030 (130); household incomes do not move with it outside the 119 households above, as the reform holds Universal Credit and Housing Benefit at their reports. The benefit cap reduction and some people's poverty status also move, each for a small number of records.As before the fix, the reform holds each amount at its dataset-year cash level, so its later-year totals fall behind the baseline's: with the reform on, household benefits are £8.1bn below the reform-off run in 2025 and £37.6bn below in 2030 (informational only). Whether it should use each year's reported amounts instead is a separate question (follow-up task).
Tests
test_disable_simulated_benefits.py:is_on_*status in each year (the outside adult's own JSA status only while the model has the scheme open). It also shows that a value-only reading would take the excluded adult's ESA as the couple's in later years;reform=, to a clone, to a branch) it keeps what it set throughreset_calculationsand leaves the parent'sinput_variablesalone, also when it replaces claimant-or-partner awards set after the build; neutralizing ESA and JSA after the reform, before it, and with an award entered directly; another member's Income Support left out with the reform on and off; a claimant who stops being the claimant in a later year.test_income_support_gate_reads_claimant_or_partner_awards.py, the differential test againstmain's gate: examples (formula mode, awards entered in the situation, set after calculating, set on a branch and on a nested branch, recalculated after deletion); a property over stored values at and around both readings (±£0.004, ±£0.006); the full old and new gates on the eligibility property families; an entered claimant-or-partner award alone decides its limb, whatever an adult outside the couple reports.test_reform_inputs_survive_reset.py, onapply_reform's naming: a reform that stores an input keeps it throughreset_calculationson every storing route; a reform names nothing it did not store; a deep copy or pickle of the record made during a reform is a plain set; and a property against a model of what the reform leaves.test_disable_simulated_benefits_carers.py: the reported case (a carer in Scotland and one in London, £4,000 each); conservation and placement in every year with the dataset year 2023 and 2025, forSimulationandMicrosimulationand for the reform passed asreform=; the differential against the model's own scheme choice with the reform off;household_benefitsandhbai_household_net_incomerising by exactly the report plus the Scottish Carer Supplement; and a Hypothesis property over regions, reports, care hours and dataset years.test_disable_simulated_benefits_benefit_cap.py: under the reform, on four capped families (Universal Credit and Housing Benefit, with and without the claimant's support component), the cap's total, both exemptions and all reductions are the same in 2025-2027 whether or not the other adult reports ESA and JSA, and the cap bites in each; with the reform off, a Hypothesis differential againstmain's replacedbenefit_cap_welfare_benefitsand againstaward_of_membersfor the exemptions' couples; the fallback for a couple that is not the claimant and partner; an entered claimant-or-partner award reaching the cap. With the three readers reverted, 4 of its 6 tests fail; with each reverted alone, at least one does.reform=routes, through the supplement and household income), a property with each household's region drawn per year, and a carer moved to Scotland byset_inputbefore the reform is applied, who is paid Carer Support Payment from the move.test_disable_simulated_benefits_winter_heating.py(withcarers_allowanceadded to its list of reported benefits set outsideBENEFITS, and moving pensioners) and Add versioned capital gains projection scenarios #2274'stest_capital_gains_projection.py(which coversapply_reform's projection refresh) pass, as domain's benefit cap property files.main's five benefit cap property files): 380 passed at 593259f (21 files). At c2e069d, which changes only docstrings, the changelog and how the samevariable_partsmap is built: 116 passed in the six files on the reform, the gate, the entered awards, neutralization, the reset and the benefit cap.tests/policy/baseline/finance/benefit/family,.../gov/local_authorities/council_tax_reductionandmain's benefit cap YAML (gov/dwp/benefit_cap,benefit_cap.yaml,benefit_cap_by_scheme.yaml,carers_allowance_overlapping_benefits.yaml): 1,511 passed at 593259f.ruff format --check(the repo's ruff and the unpinneduvx ruffCI runs) andruff checkclean.Reviews
main; Income Support as r1; Enhanced FRS re-run on the head; Count the Pension Age Winter Heating Payment in household and HBAI income #2047 has since merged and its split is kept.test_reform_inputs_survive_reset.py).legacy_benefits_closedfalse (NOT_MOVED), with a test that no family is moved with the reform on.derivativeunder core 3.33: the comments, the test docstring, the changelog and this body now sayreset_calculationsdeletes unnamed variables, asderivativedid before 3.33.0._InputKeyLogdocstring says a deep copy or pickle logs nothing.derivativeclaims: fixed above.mainat a98f8f0 brought Apply the income-related ESA and income-based JSA remunerative work conditions #2031's rule that an outside member's own income-based JSA status needs the scheme open. The reform follows it (above); holding the scheme open instead would need a parameter change, which a branch shares with its parent.main: resolved by mergingmainat a98f8f0, then dd64849, 7e10ccd, a94c65d, e7610ac and b4dbaf5. Count the Pension Age Winter Heating Payment in household and HBAI income #2047's split is kept after the loop,winter_fuel_allowanceis out ofBENEFITS, the dataset year isint(str(...)[:4]), and there is onetax_benefit_system.simulationlink and oneapply_reform. Read only the parent's and partner's ESA in extended childcare, and test two more legacy award readers #2261's reader andNOT_MOVEDagree:test_no_family_is_moved_off_its_legacy_benefitsandtest_each_person_is_on_their_own_awardpass at the head, so the outside member stays on the income-related ESA the reform pays in every year.apply_reformsets the same link, so whichever of Let Scenario structural reforms read the simulation, and add baseline regression tests #2193 and this PR lands second keeps one method. Give War Widow's Pension its own CPI-uprated variable ahead of freezing BSP #2382'swar_widows_pensionentry should be kept inBENEFITSby whichever lands second, sotest_every_listed_benefit_has_what_the_reform_reads_and_setscovers it.apply_reformand core internals: a core release that renamed or restructured_user_input_keyswould not pass silently. Core would stop writing to the logged record,apply_reformwould name nothing, and the later-reform tests (test_a_reform_applied_later_keeps_what_it_sets_through_a_reset,test_reform_inputs_survive_reset.py) would fail. Moving "inputs a later reform stores survive a reset" into policyengine-core is a follow-up: Keep the inputs a reform applied after the build stores through reset_calculations policyengine-core#617.award_of_couple), with metamorphic and differential tests.set_input, is not followed. Answered: the reform reads its inputs when it is applied, asmain's reform does, and the constructor applies it before that modifier (main's order, unchanged here). Following a later change would need the reform to replace formulas instead of setting inputs, or the constructor's order to change for every reform. The docstring and changelog now say when it reads its inputs, and a test covers applying the reform after the change.winter_fuel_allowance,pawhp,council_tax_benefit) the reform sets, so no household figure depends on it;mainapplies the same rule with the reform on.award_of_members' docstring named the wrong readers ofaward_of_couple; the fallback's docstring and the changelog now name every input that reaches it; the new test's docstring carries the same qualifier;variable_partsis built fromCLAIMANT_OR_PARTNER_AWARDS(same value).main(utils/benefit_cap.py,benefit_cap_welfare_benefits,is_uc_benefit_cap_single_claimant_rate) still say only an enteredis_uc_claimantseparates the two couples, which this PR's docstring now states in full, and the twoclaimant_or_partner_*variables' documentation does not yet name the benefit cap among their readers (both follow-ups, documentation only); the float residual bullet's list of readers was short (now completed).axiom: n/a: model reform
🤖 Generated with Claude Code
Reproducer:
policyengine_uk/tests/test_disable_simulated_benefits.py::test_every_listed_benefit_has_what_the_reform_reads_and_sets,policyengine_uk/tests/test_disable_simulated_benefits.py::test_the_reform_is_off_by_default,policyengine_uk/tests/test_disable_simulated_benefits.py::test_the_reform_sets_the_awards_for_ten_years,policyengine_uk/tests/test_disable_simulated_benefits.py::test_the_stored_awards_are_the_dataset_years_reports,policyengine_uk/tests/test_disable_simulated_benefits.py::test_later_years_need_the_awards_set_explicitly,policyengine_uk/tests/test_disable_simulated_benefits.py::test_the_claimant_or_partner_awards_are_their_own_reports