Skip to content

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

Open
MaxGhenis wants to merge 24 commits into
legacy-award-readers-claimant-partnerfrom
disable-simulated-benefits-scoped-awards
Open

MaxGhenis wants to merge 24 commits into
legacy-award-readers-claimant-partnerfrom
disable-simulated-benefits-scoped-awards

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes the disable_simulated_benefits reform (gov.contrib.policyengine.disable_simulated_benefits), which raised an error before setting any benefit, and 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.

Stacked on #2027, with #2025 merged in (both are stacked on #2013). The diff against #2027 therefore also shows #2025's changes. This PR's own change is commit 1f68925; the merge commits only bring in #2025 and #2027's later head.

What was wrong

The reform raised for three separate reasons, each hiding the next:

  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 the *_reported variables 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: sets tax_benefit_system.simulation before applying structural reforms, as core's constructor does.
  • 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 claimant's and partner's reports in the dataset year (is_claimant_or_partner);
    • 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.
  • One formula: claimant_or_partner_award in policyengine_uk/utils/benefit_unit.py is the formula of both claimant_or_partner_* variables.
  • 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.

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. Reading the variable gives the same result as the old limb convention (award > 0) & (~as_reported | scoped > 0): the variable is zero when the award is, the scoped award when the reports explain it, and the 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.
  • 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.

Invariants

For every benefit unit and each of the reform's ten years:

  • esa_income and jsa_income equal the sum of every member's dataset-year report, and claimant_or_partner_* equals the sum of the claimant's and partner's (conservation);
  • 0 <= claimant_or_partner_* <= esa_income / jsa_income (bounds), in the reform and in the formula (to the rule's half penny);
  • the values are the same in every year (the reform does not uprate);
  • 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 (copied from #2025 at 081ec14), except in the plain-total case above, which is checked against its own expectation.

Overlap with #2047

#2047 (PAWHP in household income, on #2038) 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, 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 2cb91b036 (#2027 at f551e54 with #2025 at 081ec14 merged, without this PR) against this PR's head 9e88021a4. Aggregates only.

  • Reform off (the default): £0. All 113 compared benefit-unit, person and household arrays are identical in every year, including income_support_eligible, both claimant-or-partner awards, every passport and household net income. No benefit unit holds a plain-total esa_income or jsa_income, and no member other than the claimant or partner reports ESA, JSA or Income Support (74 benefit units have such a member).
  • Reform on. On the base 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. Whether it should use each year's reported amounts instead is a separate question (follow-up task).

Tests

  • New test_disable_simulated_benefits.py (30 with the file below):
    • a six-family situation over 2025, 2026 and 2027, with an adult outside the couple reporting ESA and JSA, and the claimant's own ESA, JSA, 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, and 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.
  • New test_income_support_gate_reads_claimant_or_partner_awards.py, the differential test against the replaced code:
    • 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.
  • Existing pytest files for the gate, the direct inputs, the lifecycle and the legacy readers (85): pass.
  • YAML: tests/policy/baseline/finance/benefit/family and .../gov/local_authorities/council_tax_reduction, 509 passed.
  • Mutants, each caught by the new reform test:
    • the old gate;
    • no per-year claimant-or-partner awards;
    • Income Support summed over all members;
    • the old Simulation (no link);
    • attendance_allowance back in the list.

axiom: n/a: model reform

🤖 Generated with Claude Code

MaxGhenis and others added 23 commits October 1, 2026 17:58
SSCBA 1992 s.124(1)(c) bars Income Support when the claimant is engaged in
remunerative work or the other member of a couple is: paid work of 16 hours
a week or more for the claimant (IS Regs 1987 reg 5(1)) and 24 for the
partner (reg 5(1A)). A person within Sch 1B para 4, a carer, is not treated
as engaged in it (reg 6(4)(c)). The partner's threshold applies to whoever
is not the claimant, so the gate tests it for each candidate claimant.

s.124(1)(f) bars it when the claimant is entitled to a jobseeker's allowance
of either kind, or the partner (or the couple) to income-based JSA. The
partner's contribution-based JSA does not bar it. Income-based JSA follows
the income-related ESA pattern: the award on the claimant's and partner's
reported amounts after the JSA capital test, through a new
income_related_jsa_award helper that jsa_income also uses, or a jsa_income
entered directly, taken to be theirs.

- Parameters gov.dwp.income_support.eligibility.remunerative_work
  claimant_hours (24, 16 from 7 April 1992) and partner_hours (24, 16 from
  7 April 1992, 24 from 7 October 1996), from legislation.gov.uk
  point-in-time texts of reg 5.
- income_support_remunerative_work_hours: weekly_hours, enterable directly
  to leave out the employments reg 6(1) excludes (reg 5(6)).
- 17 YAML cases hand-computed from the law; 6 fail on the previous gate.
- The Hypothesis differential oracle now reads (c) and (f), and both
  properties draw hours, JSA and direct jsa_income.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
s.124(1)(c) and (f) only bar a claim, so raising any claimant's or
partner's hours or JSA cannot turn an ineligible family eligible. The new
Hypothesis property builds each family twice, the second time with more
paid work or JSA, and checks eligibility never rises.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2013 (b1977d9) primes half the generated families at the edge of
eligibility and draws a direct esa_income half the time. Resolve against
this branch's JSA generators: a direct jsa_income is now also drawn half
the time, and a primed claimant has no paid work or JSA, so it sits at the
edge of eligibility under (c) and (f) as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r1 of #2025 (finding 1): the gate detected a direct jsa_income
through Simulation.input_variables. That list is fixed at construction and
ignores the period, so it missed an input set later with set_input or on a
branch, and treated an input for one year as an input for every year. An
excluded member's report then counted in a year with no direct input.

entered_directly (policyengine_uk/utils/inputs.py) reads core's record of
explicit inputs, (variable, branch, period) in _user_input_keys, for the
branches the simulation can see, and requires the value still be stored.
#2013 adopts the same helper for esa_income.

Also from the review:
- jsa_income returns zero before reading reports when income-based JSA is
  not active, as before the helper was introduced (finding 4);
- income_support_remunerative_work_hours says weekly_hours is a usual
  week, not the reg 5(2)/(3B) average for fluctuating hours (finding 3);
- YAML cases for a partner at 23 hours, inferred roles with an excluded
  grandparent, two caring award holders with contribution-based JSA, and a
  direct jsa_income for another year; pytest regressions for late,
  branch and other-year inputs (finding 5).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gates' into is-remunerative-work-jsa-conditions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r2 of #2025: entered_directly read core's _user_input_keys, but in
policyengine-core 3.32.9 that record drifts from storage.

- clone() copies the holders' stored arrays but shares the record, so an
  input set on a clone, or on a parent after a branch was made, was
  recorded against arrays it never reached. An excluded adult's report
  then barred Income Support on the original or the earlier branch.
- delete_arrays() removes arrays but keeps their record, so a formula
  result calculated later for the same period looked like an input.

The UK Simulation (and so Microsimulation) now gives each clone its own
copy of the record and drops the entries for deleted arrays. Both changes
only remove stale or foreign entries; they also correct what core's own
_invalidate_all_caches and to_input_dataframe read from the record.

entered_directly now asks about the stored value the simulation reads: the
first visible branch (own, parents', default) that stores one, as the
holder reads. It no longer falls back silently when core's record is
missing.

test_entered_directly_lifecycle.py covers both awards (jsa_income,
esa_income) with an excluded adult reporting the award. Six of its sixteen
cases fail without the Simulation changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r2 of #2025: reg 5(2) takes expected weekly hours or, for
fluctuating hours, a complete recognisable cycle or the five weeks before
the claim (or a more accurate period), and reg 5(7) counts paid meal
breaks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gates' into is-remunerative-work-jsa-conditions
Review r3 of #2025 (finding 7): a school-year cycle leaves out school
holidays and other periods the person is not required to work; reg
5(2)(b)(ii)'s five weeks run to the claim or a superseding decision; paid
refreshment time counts with meal breaks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2013 (97b7a1c) replaces entered_directly with a value rule for
income-related ESA, deletes policyengine_uk/utils/inputs.py, restores
policyengine_uk/simulation.py (no clone/delete_arrays overrides), and adds
IS Sch 1B para 2 (a single claimant with a placed child).

Review r3 of #2025 showed provenance via core's _user_input_keys is
unsound in policyengine-core 3.32.9: core-built simulations skip the UK
overrides, disk clones share files, default-named branches and ETERNITY
diverge, input-free simulations crashed, and delete_arrays became O(keys).

Income-based JSA now follows the same rule. jsa_income counts as the
couple's award only when it differs from both the award on everyone's
reported amounts after the capital test and their plain total (which
disable_simulated_benefits sets); otherwise the claimant's and partner's
reports decide. An entered award, or a reform that removes or replaces
jsa_income, therefore applies, whatever the lifecycle that stored it.

Conflicts: the gate's documentation and its ESA/JSA block, and the
property test (both sides kept: #2013's shapes, placed children and para
2; this branch's work and JSA limbs; direct values {0, 4000} for both
awards, with explained_by_reports taking the JSA capital rules).
test_income_support_direct_inputs.py and test_entered_directly_lifecycle.py
are rewritten to the value rule, including the raw reported total and a
neutralised jsa_income.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gates' into is-remunerative-work-jsa-conditions
The award on the reported amounts is whatever screen jsa_income applies
(jsa_income_eligible), so the comment stays true when that screen gains
conditions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gates' into is-remunerative-work-jsa-conditions
Matches #2013's wording for income_related_esa_award (44d59d9).

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

Review r4 of #2025:
- jsa_income is stored as float32, while the report sum and the helper's
  award are float64. With large reports (£65,536.01 + £65,536.00) the
  formula's own award looked entered and barred the claim. Both references
  are now cast to the stored dtype before comparison.
- Under the value convention, another member's report can change how a
  directly entered award is read. The variable documentation and the
  property module now state that boundary and the half-penny tolerance
  instead of claiming invariance for every input.
  test_income_support_direct_inputs.py pins the intended transitions: an
  excluded adult's report equal to a direct award, and a claimant's report
  that explains it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gates' into is-remunerative-work-jsa-conditions

# Conflicts:
#	policyengine_uk/tests/test_income_support_eligibility_properties.py
#	policyengine_uk/variables/gov/dwp/income_support_eligible.py
…d direct-award modes

Review r5 of #2025 (APPROVE WITH NITS):
- The documentation and property module describe the convention for a
  stored esa_income or jsa_income however it was supplied (entered or
  replaced by a reform), compared within half a penny after rounding to
  the stored precision.
- test_the_half_penny_tolerance_endpoints pins £100.004 (explained) and
  £100.006 (the couple's) against a direct £100.
- test_direct_awards_match_the_reference_deterministically checks both
  direct-award modes against the oracle for a fixed carer; it kills a
  mutant that ignores direct ESA, which the 25-example differential let
  survive.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2013 (0953c8a) limits Sch 1B para 2 to placed children under 16 and
guards the ESA bar so a stored zero never bars the claim. The documentation
conflict is resolved with wording covering both awards. JSA gets the same
guard: a stored jsa_income of zero within half a penny of a sub-penny award
on the reports no longer bars the claim. test_a_stored_zero_never_bars_the_claim
pins it for an entered zero and a removed (neutralised) award; it fails on
the previous gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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

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.

1 participant