Conversation
SSCBA 1992 s.124(1) sets each Income Support condition for the claimant and, in paras (c), (f), (g) and (h), for the other member of a couple. income_support_eligible applied three of them to every benefit-unit member: - (aa) no member could be over state pension age, so an excluded elderly adult barred a caring claimant; - (h) any member's income-related ESA barred the claim (esa_income sums every member's reported award); - any member's reported Income Support counted as an existing award. A couple choose which of them claims (Claims and Payments Regs 1987 reg 4(3)), so the benefit unit is now eligible when the claimant or the partner could claim: under state pension age, in a prescribed category (carer, or lone parent of a young child) and with no ESA of either kind. Neither may have income-related ESA after the ESA capital test, and one of them must report an existing award (UC (TP) Regs 2014 reg 6A(1)). The fix is at the gate, not in esa_income's summation: esa_income is also the benefit unit's income-related ESA income for household income, HBAI, government spending and the benefit cap, where an excluded member's own award is still real income. An award entered directly as esa_income, with no reported awards, is taken to be the claimant's or partner's. Two behaviour changes follow from reading the conditions as the law names them: a mixed-age couple can claim through the younger partner (s.124(1)(g) bars it only if the partner is entitled to State Pension Credit, which SPCA 2002 s.4(1A) rules out), and the claimant's own contributory ESA now bars the claim (s.124(1)(h)). Tests: 17 YAML cases (8 fail on the previous formula), a Hypothesis property that adding an excluded member never changes eligibility, and a differential test against a direct per-family reading of s.124(1). Both properties fail on the previous formula. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…sa_income Review r1 of this PR found three ways a member outside the claimant and partner, or the wrong partner, still decided eligibility: - An excluded member's reported ESA switched off the fallback for an esa_income entered directly. The gate now asks the simulation whether esa_income is an input (simulation.input_variables, as marginal_tax_rate_on_capital_gains does). If it is, it reads it as the claimant's or partner's award. Otherwise it applies the esa_income calculation, now the shared income_related_esa_award helper, to the claimant's and partner's reported amounts. - The award and the conditions could sit on different partners. No new claim can be made (UC (TP) Regs 2014 reg 6A(1)), and a partner who takes over an award makes a claim (Claims and Payments Regs 1987 reg 4(4)). So the claimant is the partner who reports the existing award, and that person must satisfy (aa), (e) and the first limb of (h). - youngest_child_age_for_legacy_benefits counted a child placed by a local authority, who is not a member of the claimant's household (IS Regs 1987 reg 16(4)). It now uses is_child_or_young_person_for_legacy_benefits. #2001's "partner is the carer" case now puts the award on the caring partner. YAML: 21 cases, 5 of which fail on this PR's previous head and 10 on #2001's. The Hypothesis generators add placed children, esa_income entered directly and household savings, and the differential test follows the award holder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ligibility With 12 random families, the invariance property never reached a placed young child in a non-caring lone parent's family, or a directly entered esa_income beside an added member's ESA, so it passed on this PR's previous head. Half the families are now a caring claimant with an award, or such a lone parent with only children over 5. Placed children and direct esa_income are drawn half the time, and the invariance property runs 25 examples. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 1, 2026
#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>
Simulation.input_variables is fixed when the simulation is built and ignores the period. Three synthetic cases showed the gate getting it wrong: - an esa_income set with set_input after construction did not bar IS; - the same on a branch did not bar IS either; - an esa_income input for 2024 made the 2025 gate read esa_income as entered directly, even though 2025's value came from the formula over every member's reports, an excluded adult's included. The #2025 review found this on that PR's JSA copy of the pattern. policyengine_uk/utils/inputs.py (entered_directly, identical to #2025's copy) reads core's own record of explicit inputs (_user_input_keys: variable, branch, period), the record that to_input_dataframe reads. It counts an input set on this branch or one it inherits from, provided the value is still stored. test_income_support_esa_entered_directly.py covers all three cases; each fails on the previous gate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
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>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
…was input claimant_or_partner_esa_income and claimant_or_partner_jsa_income took esa_income / jsa_income as the claimant's or partner's award whenever the name was in Simulation.input_variables. That list is fixed at construction and ignores the period: an input for 2024 alone made 2025 read the benefit-unit total, so another member's 2025 award counted, and an award set later with set_input or on a branch was ignored. Both now use entered_directly (policyengine_uk/utils/inputs.py, byte-identical to #2013 and #2025), which reads core's record of each input's variable, branch and period. jsa_income.py is re-copied from #2025 (6337f31): the formula returns early while income-based JSA is inactive, before reading reports. Tests: YAML cases for an award entered for an earlier year only, and Python tests for a late set_input, a set_input after the value was cached, a branch and a late zero entry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cherry-picked from #2025 (48abd88). simulation.py and utils/inputs.py are identical to that commit. Its lifecycle test file is parametrised over jsa_income too, and this PR has no JSA gate, so it stays in #2025; the ESA cases are mirrored in test_income_support_esa_entered_directly.py. 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. An input set on a clone, or on a parent after a branch was made, was recorded against arrays it never reached, so an excluded adult's reported ESA then barred Income Support on the original or on 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. entered_directly now asks about the stored value the simulation reads: the first visible branch (its own, then its parents', then the default) that stores one, which is the order the holder reads in. The 11 ESA cases pass. With the new inputs.py on the previous simulation.py, 3 of them fail: deletion, clone, and an earlier branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 2, 2026
MaxGhenis
marked this pull request as draft
October 2, 2026 06:12
Review r2 of this PR, and review r3 of #2025, showed that deciding "entered directly" from core's record of inputs (_user_input_keys) is unsound in policyengine-core 3.32.9. The record drifts across delete_arrays, clone, disk storage and core-built simulations, and a UK Simulation built with no inputs has no record, so the gate raised AttributeError. The record is no longer read: utils/inputs.py is deleted and simulation.py is restored to its state before this PR. The gate now reads the value of esa_income. If the value is what the reported awards give, either the award after the capital test (the formula) or their plain total (disable_simulated_benefits), the gate reads the claimant's and partner's reports. Any other value (an award entered directly, or a reform that replaces or neutralises esa_income) is taken to be theirs. Two consequences are intended and tested: abolishing income-related ESA removes the bar, and an entered award exactly equal to what the reports give is read through them. The rule is the same as #2025's for JSA. IS Regs 1987 Sch 1B para 2 is now a prescribed category: "A single claimant or a lone parent with whom a child is placed" by a local authority, with is_looked_after_by_local_authority as the proxy. A lone parent of an 8-year-old with a placed 3-year-old is eligible through para 2, not para 1 (the placed child is outside the household, reg 16(4)). The YAML case now expects true; new cases cover a single claimant with only a placed child, and a couple, who are not covered. The documentation calls the award-holder rule a model convention (if both partners report IS, either can be the claimant). The youngest-child documentation cites reg 16(4)-(6) and calls the flag a proxy. Property tests: - the invariance property adds only adults outside the family, primed as over state pension age, ESA, an IS report or a carer; - placed children are family members, read through para 2 in the oracle; - a new split-couple shape: the award holder fails a condition and the qualifying partner has no award; - direct ESA entries (0 or 4,000; 4,000 is never a report total) are drawn more often. The oracle's signature is unchanged; the caller applies the value rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
#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>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
Takes is-remunerative-work-jsa-conditions at bfff081 (with #2013 at 97b7a1c). In test_income_support_eligibility_properties.py, #2025's version is kept and the work tests are re-applied: the reference's award on the claimant's and partner's reports, and explained_by_reports' award on everyone's reports, pass the ESA and JSA work screens; monotonicity allows a gain only where a calculated award was lost. Excluded members are now adults only, so the invariance property needs no joint-claim hold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
#2013 and #2025 replaced entered_directly and the Simulation overrides with a value rule, after #2025's round-3 review found the record of direct inputs unsound in policyengine-core 3.32.9: - a Simulation without inputs has no record; - the YAML runner and SimulationBuilder bypass the UK Simulation; - disk-backed clones share files; - branch names and ETERNITY periods diverge; - delete_arrays becomes O(keys). This merge takes their removal of policyengine_uk/utils/inputs.py and their simulation.py (identical to 63b633c's). claimant_or_partner_esa_income and claimant_or_partner_jsa_income now mirror the gate. Where esa_income / jsa_income holds what the reported awards give, either after the capital test (the formula) or as their plain total (disable_simulated_benefits), they use the claimant's and partner's reported awards. Otherwise they take the stored value to be the claimant's or partner's: an award entered directly, or a reform that replaces or removes it. An entered award equal to either amount is read through the reports. test_claimant_or_partner_awards_entered_directly.py gains cases for an entered award equal to the reports, the raw total with capital (partner £200, other adult £3,000, savings £10,000: £0), and a neutralised award. The lifecycle cases (deletion, clone, branches) keep their expectations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
At 12 derandomized examples it never met a directly entered ESA award in an otherwise eligible family, so mutants that ignore the direct award (M4) or let the award and the conditions sit on different partners (M1) passed it. At 25 it fails both. The invariance property already ran 25. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
…pers Comment-only. In esa_income.py and jsa_income.py the helpers keep #2025's docstrings ("fails the screen in esa_income_eligible" / "jsa_income_eligible"), so they stay identical to #2025 and #2013; the variables' documentation names the capital and remunerative work tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
…acy-award-readers-claimant-partner
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
…t claim #2025's review round 4 found two things that apply to this ESA limb. - esa_income is stored as float32, but the award and plain total the gate recomputes are float64. With two reports of £65,536.01 and £65,536.00, the stored award (£131,072.00) is 0.0078 below the float64 total, so the formula's own award looked entered and barred the claim. The gate now casts both to the stored dtype before comparing. The new test fails on the previous gate. - Under the value rule, a directly entered award is read through the reports when it equals what they give, so another member's report can change how an entry is read. For example, £4,000 entered with no reports bars the claim; once an excluded adult reports £4,000, the claim is eligible. That is the declared convention. The variable documentation and the invariance bullet in the property docstring now say so, and a test pins the transition. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
Brings #2013's 3402629 and #2025's round-4 changes: the IS gate compares direct awards in the stored dtype, documents the value convention's boundary, and adds direct-input tests. Clean merge; the property file's docstring and the work-screen reference sit side by side. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2023
Summary
income_support_eligibleapplied three Income Support conditions to every member of the benefit unit. SSCBA 1992 s.124(1) names only the claimant and, for some conditions, the partner. This PR applies each condition to the people the law names. It is stacked on #2001, which is stacked on #1896, so it stays a draft until they merge.The independent review of #2001 found the three defects (finding 2). They reproduce on #2001's head (63b633c). In each case a caring single claimant has one extra adult in the benefit unit with
is_claimant_or_partner: false:What changed
The claimant is the partner with the existing award. Three provisions combine:
The model takes the award holder to be the claimant or partner who reports Income Support. That is a model convention: if both report it, either can be the claimant. That person must:
is_looked_after_by_local_authorityas the proxy;In addition:
esa_incomecapital test. Both calculations share one helper,income_related_esa_award.esa_income:disable_simulated_benefitsreform).esa_income(abolishing income-related ESA) removes the bar.esa_incomeis stored in. So another member's report can change how a direct entry is read: £4,000 entered with no reports bars the claim, but if an excluded adult also reports £4,000 it does not.esa_incomewas a simulation input, using core's record of inputs (_user_input_keys). Reviews of this PR and Apply the Income Support remunerative work and JSA conditions (s.124(1)(c), (f)) #2025 showed that record goes wrong acrossdelete_arrays,clone, disk storage and core-built simulations. A UKSimulationbuilt with no inputs (for example from aScenarioalone) has no record at all, and the gate raisedAttributeError. That approach is gone, andsimulation.pyis unchanged by this PR.youngest_child_age_for_legacy_benefitsnow leaves out a child flaggedis_looked_after_by_local_authority. That flag is the model's proxy for a child placed with the family, or living away in a local authority's care, who is not a member of the claimant's household (IS Regs 1987 reg 16(4)-(6)). Such a child is no longer a "young child" for para 1, but brings a single claimant within para 2.Four behaviour changes come from reading the conditions as written. All four move £0 on the Enhanced FRS (below).
is_pension_credit_eligiblealready requires every claimant and partner to be over state pension age; it does not model the SI 2019/37 art. 4 savings. So (g) holds in the model whenever the claimant is under that age.income_support_eligible→is_pension_credit_eligible→is_guarantee_credit_eligible→pension_credit_income→working_tax_credit→wtc_entitlement→tax_credits→working_tax_credit_pre_minimum→tax_credits_reduction→tax_credits_applicable_income→income_support→income_support_entitlement→income_support_eligible.Fix at the gate, not in
esa_income's summationesa_incomesums every member'sesa_income_reported. I checked all of its readers (git grep -nw esa_income -- policyengine_uk) before deciding where to make the fix:esa,household_benefits,hbai_benefits,hbai_household_net_income,gov_spending,pre_budget_change_household_benefits, thedisable_simulated_benefitsreform andbenefit_cap_reduction.housing_benefit_applicable_incomeandcouncil_tax_reduction_applicable_income, throughadd_for_members, which adds benefit-unit variables as they are;council_tax_reduction_relevant_income_based_benefit;_legacy.py;tax_credits_applicable_income;is_scp_eligible;maintenance_loan_entitled_to_benefits;is_benefit_cap_exempt_health_disability;is_benefit_cap_exempt_earningsandis_benefit_cap_exempt_othercompute anesa_incomesum but do not use it in their result.Restricting the sum would remove an excluded member's award from household income. So the fix is in the Income Support gate. The other means-test and passport readers keep their current behaviour; whether each should count only the claimant's and partner's award depends on its own rules, and is a follow-up.
would_claim_ISstill reads every member's report. That cannot change eligibility, because the gate now needs the claimant's or partner's own report.Not changed: s.124(1)(c) (remunerative work) and (f) (JSA) are not gates here. #2025, stacked on this PR, adds them.
Invariants
No excluded-adult effect. Adding an adult who is neither the claimant, the partner nor a young person in the family never changes
income_support_eligible. Their age, ESA, Income Support and caring do not matter. There is one declared exception: a directly enteredesa_incomethat the new adult's report makes equal to what the reports give. That entry is then read through the reports, and a test pins it.Matches a family-by-family reading of the gate (differential test).
income_support_eligibleis true exactly when:esa_incomethe reports do not explain);This checks the bounded model gate, not legal entitlement. Caring is
is_carer_for_benefits(receipt or 35+ hours), ESA and Income Support are reported awards, placement is a proxy flag, the other Sch 1B categories are not modelled, and the means test is out of scope.Tests
YAML.
income_support_claimant_partner_gates.yamlhas 23 cases, and Pay the legacy carer premium per qualifying claimant or partner; IS carer route through them only #2001'sincome_support_carer_route.yamlputs the award on the caring partner.test_income_support_esa_entered_directly.py(15 tests) covers:esa_incomeentered in the situation, set after construction, set on a branch or a clone, or entered for another year;esa_income, and the plain reported total with a binding capital test;Hypothesis:
test_income_support_eligibility_properties.py. Both properties run 25 examples, about 70 seconds together.Invariance. The added adult is not in education. Most are primed with what used to bar or open the claim when every member counted: over state pension age, income-related ESA, an Income Support report, or caring. The test checks that the model gives that adult no role in the family.
Differential. It reads the gate family by family. Families include:
Mutation check. Four deliberately broken gates, with properties run without shrinking:
esa_incomeignoredAt 12 examples, the differential passed the first three mutants, so it now runs 25. Logs:
~/reviews/is-eligibility-gates-2026-10-01/mutants/(RESULTS.md).Full suites at 97b7a1c. YAML: 1,514 passed. pytest: 2,032 passed, 45 skipped. Later commits only change:
The targeted suites pass at the head.
Enhanced FRS impact: £0 in every year
These are real microsimulation runs on private copies of the Enhanced FRS 2024-25 (sha256
e433e532…). Each package state was run for 2025 to 2030:Every step changes nothing. Income Support, Housing Benefit, council tax reduction, Universal Credit, Pension Credit,
esa_income, household net income and poverty are identical, and no benefit unit's eligibility changes. Income Support is £0.51bn in 2025 and £0 from 2026, when the model stops paying it.Why nothing moves (base run, 2025):
is_looked_after_by_local_authorityoresa_incomecolumn, so para 2 and the direct-ESA rule cannot move it.So this PR corrects household calculations and non-standard benefit units; it does not move published totals.
The scripts are in
~/reviews/is-eligibility-gates-2026-10-01/impact/. Only the JSON summaries and the figures here are aggregates; the dataset copies and the per-record arrays the scripts save stay private.Axiom
axiom: TheAxiomFoundation/rulespec-uk#403 queued (addendum, updated for the award-holder rule and Sch 1B para 2). It adds the following to the s.124 module that issue already queues, with companion cases 17-33:
SPCA 2002 s.1(6) qualifying age is encoded-correct in
uk/statutes/ukpga/2002/16/1.yaml. The corpus does not yet hold s.124, SPCA s.4, WRA 2007 s.1 or UC (TP) reg 6A, so the encoder path is blocked until they are ingested.Follow-ups
esa_income,jsa_incomeandincome_support(the means tests and passports listed above, pluswould_claim_IS) still count an excluded member's award. Each needs checking against its own rules.Merge
This PR is stacked on #2001, which is stacked on #1896. PE-UK CI runs only on PRs into
main, so checks start once both have merged and this PR is retargeted (gh pr edit 2013 --base main, thengh pr ready 2013).🤖 Generated with Claude Code