Skip to content

Fix disable_simulated_benefits and read one claimant-or-partner award - #2075

Open
MaxGhenis wants to merge 12 commits into
mainfrom
disable-simulated-benefits-scoped-awards
Open

MaxGhenis wants to merge 12 commits into
mainfrom
disable-simulated-benefits-scoped-awards

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2264

Summary

Fixes the disable_simulated_benefits reform (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 the claimant_or_partner_* variables and both income-related limbs of income_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 main at e89b8ab; the first still raises on main at 6d89338):

  1. Simulation.__init__ does not call core's constructor, so tax_benefit_system.simulation was never set and the reform's self.simulation raised AttributeError. The public service budget reform (adjust_budgets) reads the same attribute.
  2. The dataset year is text for a situation's dataset and a number for a multi-year dataset, and range(time_period, time_period + 10) raised TypeError on text.
  3. Five listed benefits have no <name>_reported variable: 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_reported and jsa_income_reported are uprated. claimant_or_partner_esa_income, claimant_or_partner_jsa_income (#2027) and the Income Support limbs (#2013, #2025) decide whose award esa_income / jsa_income holds 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 link: Simulation.__init__ sets tax_benefit_system.simulation before applying structural reforms, as core's constructor does. Since policyengine-core 3.32.27, core's Simulation.clone links a cloned system to the clone, so this PR no longer overrides clone. Simulation.apply_reform links 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.
  • Inputs a later reform sets: input_variables is taken when a simulation is built. reset_calculations and core's derivative delete 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_reform now adds every variable the reform sets an input for: one with a new input record, or one the reform set through set_input that 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 as reform=, 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_input records the names only for the simulation applying the reform, never for a clone or branch made meanwhile. The parameter route (disable_simulated_benefits switched on in a scenario) runs before input_variables is taken and is unchanged.
  • Reform:
    • reads the dataset year as a number from either dataset type;
    • drops the five benefits with no reported amount;
    • sets claimant_or_partner_esa_income and claimant_or_partner_jsa_income for 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;
    • sets income_support from the claimant's and partner's reports alone. In the model income_support is the claimant's family's award, and the passports read it as theirs, so another member's report would otherwise passport the claimant.
  • Neutralization: neutralizing esa_income or jsa_income also 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.
  • One formula: claimant_or_partner_award in policyengine_uk/utils/benefit_unit.py is the formula of both claimant_or_partner_* variables. It treats an award within half a penny of zero, stored or worked out, as no award, the rule income_support_eligible gained in Apply the Income Support conditions to the claimant and partner only #2013's after-merge review.
  • Income Support gate: the income-related ESA limb (s.124(1)(h)) is claimant_or_partner_esa_income > 0 and the income-based JSA limb (s.124(1)(f)) is claimant_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_income and the reports. It cannot see a value the reform set on claimant_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 as main'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

  • Plain total of the reports. When esa_income / jsa_income equals 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.
  • Directly entered claimant-or-partner awards now reach the Income Support gate.
  • Float residuals. Reports that exactly match the tariff income leave a residual of about £0.000002 in the stored award. income_support_eligible already 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's is_on_income_related_esa and is_on_income_based_jsa, in_receipt_of_income_support_jsa_ib_or_esa_ir, the council tax reduction passport, is_scp_eligible and 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.
  • Neutralizing an award removes its claimant-or-partner part, including one entered directly.
  • Another member's Income Support (for example a non-dependent adult claiming in their own right) is left out of income_support, and so of household income, under the reform. That is what the model does with the reform off: would_claim_IS and income_support_eligible read only the claimant's and partner's reports. ESA and JSA differ: esa_income and jsa_income count every member's award either way. The reform docstring, the changelog and a test that checks both modes state this.
  • Caching. The gate reads a calculated variable, so code that changes esa_income / jsa_income after calculating the gate has to clear claimant_or_partner_* as well as income_support_eligible. The three test helpers that recalculate the gate do so.
  • Reform amounts. As before, the reform holds amounts at their dataset-year level; it does not uprate them, and because they are inputs, a reform that changes a benefit's parameters does not change them.
  • Inputs a later reform sets, including inputs it replaces, are now kept by reset_calculations, derivative and the marginal rate formulas, and exported as inputs by UKSingleYearDataset.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_income and jsa_income equal the sum of every member's dataset-year report, and claimant_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;
  • the stored values are the same in every year (the reform does not uprate), except that Income Support and the claimant-or-partner awards change in a year in which who is the claimant or partner changes;
  • income_support_eligible is false whenever either claimant-or-partner award is positive (the limbs only bar);
  • the council tax reduction passport equals "Income Support, or either claimant-or-partner award, is positive".

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_allowance is not in BENEFITS, and the one dataset-year Winter Fuel Payment report is set as pawhp in Scotland while PAWHP is active and as winter_fuel_allowance otherwise. 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 head 8b278e303, both clean checkouts. Aggregates only. The later commit 0d5326898 changes only which variables apply_reform names in input_variables for a reform applied after the build. These runs apply the reform in the constructor, which sets input_variables after its structural reforms, so they are unaffected.

  • Reform off (the default): £0. All 114 compared benefit-unit, person and household arrays are identical in every year 2025-2030. They include 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.
  • Reform on. On main at 6d89338 it still raises AttributeError: 'CountryTaxBenefitSystem' object has no attribute 'simulation'; on this branch it runs. In each year 2025-2030:
    • the claimant-or-partner ESA and JSA awards equal the stored awards in every benefit unit (1,084 with ESA, 38 with JSA), as they must when no other member reports them;
    • the stored awards are the same in every year;
    • no benefit unit is Income Support eligible while either claimant-or-partner award is positive.

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):
    • a six-family situation over 2025, 2026 and 2027. It has an adult outside the couple reporting ESA and JSA, the claimant's own ESA, JSA and screened-out ESA, and another member's Income Support;
    • it checks the stored and claimant-or-partner awards, both Income Support limbs, the council tax reduction passport and each person's 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;
    • a multi-year dataset;
    • a conservation and bounds property over random families;
    • review regressions:
      • the reform applied to a clone and to a branch sets their inputs and none of the parent's;
      • the reform applied later (as reform=, to a clone, to a branch) keeps what it set through derivative and reset_calculations, and leaves the parent's input_variables alone;
      • the same when the reform replaces claimant-or-partner awards set after the build (applied to the simulation itself, a clone or a branch), without naming an input it did not set;
      • 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 (13), the differential test against main's gate:
    • examples: formula mode, awards entered in the situation, set after calculating, set on a branch and on a nested branch, and 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 (examples and a property).
  • The 8 targeted pytest files (the two above plus the gate, direct-input, entered-directly, lifecycle and legacy-reader files): 139 passed at d10ef154c, to be re-run on the head before merge. At 0d5326898, the new test (3 routes), the three later-reform tests and the clone-or-branch test: 7 passed.
  • YAML: tests/policy/baseline/finance/benefit/family and .../gov/local_authorities/council_tax_reduction, 1,176 passed at d10ef154c, to be re-run on the head before merge.
  • Mutants, each caught by the targeted tests:
    • at the head:
      • apply_reform naming only new input records (set_input recording 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, and derivative is nonzero;
    • at d10ef154c (the code they mutate is unchanged since, apart from the naming above):
      • apply_reform without binding the system to the simulation applying it (the clone-or-branch test and all three later-reform tests fail);
      • apply_reform without naming the reform's inputs (all three later-reform tests fail, at the reset values and at derivative);
    • at 135b384be (the code they mutate and the tests that catch them are unchanged since):
      • the old ESA gate (esa_income > 0);
      • no per-year claimant-or-partner awards;
      • Income Support summed over all members;
      • no simulation link at all;
      • attendance_allowance back in the list;
      • the helper without the half-penny rule (8 tests fail);
      • neutralizing an award without its parts;
      • roles from the dataset year only.

Reviews

  • Review r1 (REQUEST_CHANGES on 9e88021):
    1. Clone link: fixed (Simulation link above).
    2. Scoped awards outliving abolition: fixed for neutralization (above). Parameter changes cannot remove the reform's amounts by design, since they are inputs. PolicyEngine UK has no gov.abolitions switches.
    3. Income Support cash: answered under Behaviour changes. The reform matches the model with the reform off, and the exclusion is stated in the code, the changelog and a test.
    4. Fixed roles: the reform now reads roles in each year (regression added). Roles never change between years in the Enhanced FRS.
    5. Wording: the docstrings say most reported variables are uprated, the invariants above are qualified, and this body opens with Fixes #2264.
  • Vahid Ahmadi's review (7cd3c34):
    1. The conflict with the base is resolved by merging main, keeping the helper-based formula and main's half-penny sentence.
    2. Another member's Income Support: as r1 3.
    3. The Enhanced FRS figures above were re-run on the current head.
    4. Count the Pension Age Winter Heating Payment in household and HBAI income #2047: see the overlap section.
  • Review after merge (REQUEST_CHANGES on 135b384):
    1. Resets deleting the reform's inputs: fixed (Inputs a later reform sets, above). The regression covers 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, and derivative is nonzero.
    2. Same amount every year: qualified by who is the claimant or partner, in the reform docstring and the invariants.
    3. Residual scope: the changelog and Behaviour changes now say the half-penny rule applies to the claimant's and partner's award, and that a member outside the couple is still read on their own report.
    4. Mutant status: the half-penny mutant fails 8 tests; the list above is current.
  • Review after merge r3 (REQUEST_CHANGES on 8b278e3):
    1. A reform replacing inputs set after the build lost them on a reset: fixed (Inputs a later reform sets, above). apply_reform also names what the reform set through set_input; regression added for the simulation itself, a clone and a branch.
    2. Marginal rate formulas: the apply_reform comment and this body now say they keep formula-free input variables as well.

axiom: n/a: model reform

🤖 Generated with Claude Code

MaxGhenis and others added 3 commits October 2, 2026 07:22
…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 vahid-ahmadi left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 raises AttributeError: '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:190 matches core 3.32.17, which sets self.tax_benefit_system.simulation = self in its own constructor.
    • Each UK Simulation builds its own CountryTaxBenefitSystem, 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.
  • Ten-year hold. A couple with a non-dependant who reports £4,000 of ESA. With the reform, esa_income is £4,000 in both 2025 and 2027. claimant_or_partner_esa_income is £0 in both years, Income Support eligibility is false, and only the non-dependant is on ESA. Without the reform, esa_income uprates 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_income is £7,000 and the claimant-or-partner award is £4,000 in 2025, 2026 and 2027.

  • Directly entered award. A single adult with esa_income of £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/family and 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:

  1. Should fix: the branch conflicts with its own base. #2027 moved to fc1ff55d1, which conflicts with this branch in variables/gov/dwp/claimant_or_partner_esa_income.py and claimant_or_partner_jsa_income.py. Only the documentation strings 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-based formula, and move that half-penny sentence into the new documentation (it is already in the claimant_or_partner_award docstring).

  2. 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) sets income_support from the claimant's and partner's reports only. A non-dependant's own ESA and JSA stay in esa_income and jsa_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 and household_benefits £0. The same non-dependant reporting £3,000 of ESA gives household_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_benefits but that the passports don't read), or state the exclusion in the reform docstring and the changelog. Then the excluded_income_support: 0 expectation in test_disable_simulated_benefits.py reads as intended rather than as a loss.
  3. 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't main, so please let it run once the stack retargets.

  4. Nit: sequencing with #2047. It rewrites the same BENEFITS block in disable_simulated_benefits.py and parses the year as int(simulation.dataset.time_period), which would hit the text-year TypeError this PR fixes. Your body already covers the winter-heating split. Please also keep this PR's int(str(...)[:4]) when resolving. I checked that this branch on its own doesn't count anything twice: pawhp isn't in household_benefits here.

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.

@MaxGhenis
MaxGhenis changed the base branch from legacy-award-readers-claimant-partner to main October 9, 2026 22:23
MaxGhenis and others added 7 commits October 10, 2026 12:17
…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>
…able-simulated-benefits-scoped-awards

No conflicts.

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>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Status at head 0d5326898 (scheduled lane, 2026-10-11):

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

disable_simulated_benefits raises before setting any benefit

2 participants