Repository navigation
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
|
Fixes #2264
Summary
Fixes the
disable_simulated_benefitsreform (gov.contrib.policyengine.disable_simulated_benefits), which raised an error before setting any benefit. It also gives the claimant's and partner's income-related ESA and income-based JSA one reading, shared by theclaimant_or_partner_*variables and both income-related limbs ofincome_support_eligible.#2013, #2025 and #2027 have merged, so this PR is now based on
main(last merged in at 6d89338).What was wrong
The reform raised for three separate reasons, each hiding the next (all three checked on
mainat e89b8ab; the first still raises onmainat 6d89338):Simulation.__init__does not call core's constructor, sotax_benefit_system.simulationwas never set and the reform'sself.simulationraisedAttributeError. The public service budget reform (adjust_budgets) reads the same attribute.range(time_period, time_period + 10)raisedTypeErroron text.<name>_reportedvariable: Attendance Allowance, both DLA components and both PIP components. Since Move disability benefit reported amount mapping to UK data #1656 the data gives their receipt as a rate category and the model pays that category's rate, so there is no reported amount to copy.Once it runs, a second defect appears in later years. The reform holds each benefit at its dataset-year amount for ten years, while
esa_income_reportedandjsa_income_reportedare uprated.claimant_or_partner_esa_income,claimant_or_partner_jsa_income(#2027) and the Income Support limbs (#2013, #2025) 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, was taken to be the claimant's or partner's.What changes
Simulation.__init__setstax_benefit_system.simulationbefore applying structural reforms, as core's constructor does. Since policyengine-core 3.32.27, core'sSimulation.clonelinks a cloned system to the clone, so this PR no longer overridesclone.Simulation.apply_reformlinks the system to the simulation applying the reform while it runs, so a clone or branch (which shares its parent's system) that applies the reform sets its own inputs, never its parent's.input_variablesis taken when a simulation is built.reset_calculationsand core'sderivativedelete the values of every variable it does not name, and the marginal rate formulas those of every such variable that has a formula.Simulation.apply_reformnow adds every variable the reform sets an input for: one with a new input record, or one the reform set throughset_inputthat is still an input of the simulation (a reform replacing an input set since the build adds no new record). So a reform applied after the simulation is built (a Reform class passed asreform=, or one applied to a built simulation, a clone or a branch) keeps what it set through a reset. It assigns a new list, because a clone or branch shares its parent's.set_inputrecords the names only for the simulation applying the reform, never for a clone or branch made meanwhile. The parameter route (disable_simulated_benefitsswitched on in a scenario) runs beforeinput_variablesis taken and is unchanged.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, so another member's report would otherwise passport the claimant.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 treats an award within half a penny of zero, stored or worked out, as no award, the ruleincome_support_eligiblegained in Apply the Income Support conditions to the claimant and partner only #2013's after-merge review.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. With the limbs reading the variables, nothing needs the as-reported masks, so the helper returns the award alone.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: 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 targeted childcare entitlement. A member outside the couple is still read on their own report (own_report_is_paid), without the half-penny rule, as before: an adult outside the couple whose own report leaves a sub-half-penny award is still on it. No benefit unit in the Enhanced FRS has a stored ESA or JSA award between £0 and £1, so this moves nothing there.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. The reform docstring, the changelog and a test that checks both modes state this.esa_income/jsa_incomeafter calculating the gate has to clearclaimant_or_partner_*as well asincome_support_eligible. The three test helpers that recalculate the gate do so.reset_calculations,derivativeand the marginal rate formulas, and exported as inputs byUKSingleYearDataset.from_simulation, as the parameter route's always were.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;income_support_eligibleis false whenever either claimant-or-partner award is positive (the limbs only bar);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 6d89338, 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.Overlap with #2047
#2047 (PAWHP in household income; #2038 has merged, #2047 has not) carries the same simulation link and AA/DLA/PIP removal so its own stack works. Whichever lands second keeps both PRs' rules: this PR's claimant-or-partner scoping (now read per year), its
int(str(...)[:4])dataset year, and #2047's winter-heating split. Under that split,winter_fuel_allowanceis not in BENEFITS, and the one dataset-year Winter Fuel Payment report is set aspawhpin Scotland while PAWHP is active and aswinter_fuel_allowanceotherwise. Without it, Scottish households would be counted twice.Enhanced FRS
Real runs on a private copy of the Enhanced FRS 2024-25, 2025-2030. Base
6d89338ab(main, without this PR) against this PR's head8b278e303, both clean checkouts. Aggregates only. The later commit0d5326898changes only which variablesapply_reformnames ininput_variablesfor a reform applied after the build. These runs apply the reform in the constructor, which setsinput_variablesafter its structural reforms, so they are unaffected.income_support_eligible, both claimant-or-partner awards, every passport 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.mainat 6d89338 it still raisesAttributeError: 'CountryTaxBenefitSystem' object has no attribute 'simulation'; on this branch it runs. In each year 2025-2030: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: household benefits are £7.5bn below the reform-off run in 2025 and £32.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(36):is_on_*status in each year. 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) keeps what it set throughderivativeandreset_calculations, and leaves the parent'sinput_variablesalone;test_income_support_gate_reads_claimant_or_partner_awards.py(13), the differential test againstmain's gate:d10ef154c, to be re-run on the head before merge. At0d5326898, the new test (3 routes), the three later-reform tests and the clone-or-branch test: 7 passed.tests/policy/baseline/finance/benefit/familyand.../gov/local_authorities/council_tax_reduction, 1,176 passed atd10ef154c, to be re-run on the head before merge.apply_reformnaming only new input records (set_inputrecording nothing): the new test fails on the simulation-itself and clone routes, and passes on the branch route, whose records are new. Without its naming assertion, on those two routes a reset changes the claimant-or-partner ESA and flips the Income Support gate, andderivativeis nonzero;d10ef154c(the code they mutate is unchanged since, apart from the naming above):apply_reformwithout binding the system to the simulation applying it (the clone-or-branch test and all three later-reform tests fail);apply_reformwithout naming the reform's inputs (all three later-reform tests fail, at the reset values and atderivative);135b384be(the code they mutate and the tests that catch them are unchanged since):esa_income > 0);attendance_allowanceback in the list;Reviews
gov.abolitionsswitches.Fixes #2264.main, keeping the helper-based formula andmain's half-penny sentence.reform=, a clone and a branch, and fails on each without the fix: a reset changes six arrays the reform set and flips the Income Support gate, andderivativeis nonzero.apply_reformalso names what the reform set throughset_input; regression added for the simulation itself, a clone and a branch.apply_reformcomment and this body now say they keep formula-free input variables as well.axiom: n/a: model reform
🤖 Generated with Claude Code