Repository navigation
Apply the Income Support remunerative work and JSA conditions (s.124(1)(c), (f)) - #2025
Merged
Merged
Conversation
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>
This was referenced Oct 1, 2026
MaxGhenis
added a commit
that referenced
this pull request
Oct 1, 2026
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>
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>
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>
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>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
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>
…gates' into is-remunerative-work-jsa-conditions
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
Takes policyengine_uk/simulation.py and policyengine_uk/utils/inputs.py byte-identical from 48abd88 on #2025 (is-remunerative-work-jsa-conditions). In policyengine-core 3.32.9, delete_arrays keeps the deleted arrays' _user_input_keys entries, and clone shares the record. A formula value could then look like a direct input, after a deletion or an input on a clone or on a parent after a branch. The UK Simulation now gives each clone its own copy of the record and drops the entries for deleted arrays, and entered_directly asks about the stored value the simulation actually reads. test_claimant_or_partner_awards_entered_directly.py mirrors #2025's lifecycle cases on claimant_or_partner_esa_income and claimant_or_partner_jsa_income. 6 of its 12 cases fail without the Simulation changes. #2025's own test file asserts its s.124(1)(f) JSA bar, which this branch does not have, so it is not copied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
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>
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>
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>
…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
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
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>
… 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
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>
…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>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
Review r3 of this PR, and #2025's review round 5: - Sch 1B para 2 covers a placed "child", which is a person under 16 (SSCBA s.137(1)). The gate now requires the placed member to be under 16 (is_child_for_child_benefit). YAML pins the boundary: a placed 15 year old counts, a placed 16 year old does not. The property generator draws placed members aged up to 17, and the oracle applies the limit. - A zero esa_income could still bar the claim. Reports of £10.01 and £41.99 sum a little above £52 in stored precision, so after £52 of tariff income the formula leaves a sub-penny residual. A zero entry, or a neutralised esa_income, then matched that residual within half a penny and was read through the reports. The bar now also requires esa_income > 0. - The documentation now says the exception covers any stored value that is not the formula's own result: entered directly, or replaced, scaled or removed by a reform. The comparison is within half a penny after rounding to the stored precision. A tolerance test pins £100.004 against £100.006. - The para 2 comment says the looked-after flag also marks children living away in care, whom para 2 does not cover. 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>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
…t cap left unscoped Review r2 of #2027 (subfleet job 20261002-044711) and #2013/#2025's latest value-rule refinements (0953c8a, 081ec14): - claimant_or_partner_{esa,jsa}_income: a stored zero is zero. References are compared at the stored float32 precision. When the stored award is the plain total of the reports (the disable_simulated_benefits reform), the claimant's and partner's plain reported total passes through unscreened, so a claimant's own £200 at £10,000 capital is no longer zeroed. Entering these variables directly is the explicit way to state ownership. - is_on_income_related_esa / _based_jsa: another member's own report counts only while the benefit unit's modelled award is positive, so a reform that removes the benefit takes the status with it. is_on_income_support: likewise only while Income Support is active and not neutralised. - is_benefit_cap_exempt_health_disability is reverted to the benefit unit's esa_income. benefit_cap_reduction (out of scope) still caps every member's ESA and JSA, so scoping the exemption alone would cap the claimant with another adult's own award. Both go to the benefit-cap follow-up. - The property generator fixes claims_all_entitled_benefits per benefit unit (it sums reports across the simulation). SCP comments name the kinship route. The childcare citation is reg 1(2), not 2(1). Changelog wording matches the paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
Brings #2025's stored-award convention wording, its tolerance tests and test_direct_awards_match_the_reference_deterministically. In test_income_support_eligibility_properties.py the monotonicity waiver for a lost calculated award is kept and the new deterministic test follows it; reference_eligibility's signature is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 2, 2026
vahid-ahmadi
reviewed
Oct 5, 2026
vahid-ahmadi
left a comment
Collaborator
There was a problem hiding this comment.
Thanks Max. This reads s.124(1)(c) and (f) correctly:
- 16 hours for the claimant (IS Regs reg 5(1)) and 24 for the partner (reg 5(1A): "as though for the words '16 hours' there were substituted the words '24 hours'");
- carers are taken out of remunerative work whether they are the claimant or the partner (reg 6(4)(c): "a person to whom paragraph 4 of Schedule 1B applies");
- the partner's JSA bars the claim only if it is income-based ("the other member of the couple is not, and the couple are not, entitled to an income-based jobseeker's allowance").
Verified at 081ec14:
- Law: the point-in-time texts of reg 5 give 24 hours on 6 April 1992, 16 from 7 April 1992, and (1A) in force at 7 October 1996, so both parameter histories are right. (The current page's F1 note on reg 5(1) is mislabelled, but the values hold.)
- Tests: the income_support YAML passes 86/86. Lifecycle 16/16, direct inputs 13/13, properties 4/4, parameters 3/3.
- Probes for 2025, comparing #2013's head with this one:
- A lone parent with a 3-year-old is still eligible at 15 hours. At 16 hours she becomes ineligible (#2013 left her eligible).
- A carer claimant whose partner works 23 hours is eligible; at 24 hours, ineligible.
- A partner who is a carer and works 30 hours does not bar the claim.
- A partner on contribution-based JSA leaves Income Support at £10,663.43, the same as on #2013.
- Merges cleanly with #2027 and #2075. The body gives aggregates only.
- Should fix (inherited from #2013, but this PR carries the same gate): #2013 conflicts with main in
income_support_eligible.pyandincome_support_carer_route.yaml. The cause is #2001, which merged on 4 Oct. In the rebase:income_support_eligible.py:101should take main'shas_attained_state_pension_credit_qualifying_age, notis_SP_age.- Decide the s.124(1)(g) limb. Main keeps
on_pension_credit(main:52-61); the stack drops it. - Then re-run the 23 cases and the three properties.
- Nit: #2001 and #1896 have both merged, so update the stack line in the body: this is now stacked only on #2013.
Brings in #2013 (a094911) and everything else main added. Two conflicts: - income_support_eligible.py: keeps this branch's rule (in the claimant conjunction, no remunerative work at claimant_hours, no contribution-based JSA, and no other member of the couple in remunerative work at partner_hours; carers never in remunerative work; income-based JSA of the claimant or partner, read by value, bars the claim). Takes main's age test (has_attained_state_pension_credit_qualifying_age), its s.124(1)(g) bar (meets_pension_credit_age_conditions and would_claim_pc) and its half-penny ESA zero guard, with their comments; the documentation states all of them. - JSA limb: an amount within half a penny of zero is no award, as main now does for ESA (#2013's after-merge review, finding 3; review r6b finding 4 here). Reports that exactly match the tariff income leave a float residual that otherwise barred the claim. Three new tests in test_income_support_direct_inputs.py pin the formula's residual, an entered £0.003/£0.006, and the couple's residual beside another member's report; the changelog says so. - test_income_support_eligibility_properties.py: the oracle takes main's attained_qualifying_age and on_pension_credit as well as this branch's entered JSA and work conditions; an entered JSA within half a penny of zero is no award; the docstring states both sets of conditions; the deterministic test passes on_pension_credit=False. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The monotonicity docstrings now say why more JSA cannot help through s.124(1)(g): reported income-based JSA ends the inferred SI 2019/37 saving only for a couple in which neither member reports Income Support, and such a family has no award holder. - The JSA formula's-residual test cross-references the couple's-residual test, as main's ESA test does, and notes the residual comes from core summing the reports in float64. - New tests pin the couple's award tolerance at half a penny for JSA and ESA (£52.004 is no award, £52.006 bars the claim). Each fails when its guard is set to > 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Confirmatory review (job 20261008-184614-is-work-jsa-review-after-merge-r2, finding 1): reported income-based JSA ends only the Housing Benefit route to the inferred SI 2019/37 saving; reported Pension Credit keeps the saving on its own (has_mixed_age_couple_pension_credit_saving). Docstring only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 9, 2026
MaxGhenis
added a commit
that referenced
this pull request
Oct 9, 2026
#2025 merged as 4ed8713. Conflict: test_income_support_eligibility_properties.py, whose docstrings now carry both main's s.124(1)(g) Pension Credit reading and this branch's remunerative work tests and lost-award waiver. The Class 4 lower profits limit merged cleanly: this branch's 2023-04-06 £12,570 (SSCBA 1992 s.15(3)(a)) plus main's 2026-31 entries, all £12,570. Main (#1919) now charges ni_class_4_main on ni_class_4_profits: after capital allowances, the trading allowance and Sch 2 loss relief. ESA reg 99(3)(b) charges the notional Class 4 on reg 99(4)'s chargeable income, the reg 98 profit, from which reg 98(5)(a), (b), (d) and (11) bar capital expenditure, depreciation and losses from another trade or an earlier period. So esa_exempt_work_earnings now computes it on the profit, under the model's Class 4 age rule (ni_class_4_liable), with two YAML cases (capital allowances, another trade's loss) where the two differ across the higher limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis
added a commit
that referenced
this pull request
Oct 10, 2026
…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>
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 #2024
Summary
income_support_eligibledid not model two of the SSCBA 1992 s.124(1) conditions:This PR adds both, for the claimant and partner only. #2013 (with #2001 and #1896) has merged, and this PR now targets
main. It keeps #2013's structure: the claimant is whichever of the claimant and partner has the existing award (UC (TP) Regs 2014 reg 6A(1); C&P Regs 1987 reg 4(4)). It also keeps the conditions main has added since: the claimant's age test on the State Pension Credit qualifying age (s.124(1)(aa)) and the s.124(1)(g) bar where the couple is taken to be on State Pension Credit.Law and the rule
(c) Remunerative work.
(f) JSA.
Income-based JSA follows #2013's income-related ESA pattern. It is the award on the claimant's and partner's reported amounts, after the same capital screen as
jsa_income, through a newincome_related_jsa_awardhelper thatjsa_incomenow also uses. Ajsa_incomethat holds an award the reported amounts do not give is taken to be theirs. That means neither that award on everyone's reports nor their plain total, whichdisable_simulated_benefitssets. It covers an award entered directly (when the simulation is built, later, or on a branch) and a reform that removes or replacesjsa_income. The rule reads only the stored value, so no input-provenance tracking is involved. A stored award (entered directly or replaced by a reform) equal to what the reports give (to within half a penny, compared at the float32 precision the award is stored in) is read through the reports, which say whose it is. An award within half a penny of zero is no award, as main now does for ESA. So a stored zero never bars the claim, and nor does the float residual of reports that exactly match the tariff income (£10.01 + £41.99 against £52).Changes
income_support_eligible: the two claimant limbs (no remunerative work at 16 hours, no contribution-based JSA) join Apply the Income Support conditions to the claimant and partner only #2013's per-personclaimantconjunction, along with the other member's remunerative work at 24 hours. Income-based JSA of either partner bars the claim next to income-related ESA and main's s.124(1)(g) Pension Credit bar.New parameters
gov.dwp.income_support.eligibility.remunerative_work.claimant_hoursandpartner_hours, with their history from legislation.gov.uk point-in-time texts of reg 5:New
income_support_remunerative_work_hours(Person):weekly_hours, a usual week's hours in paid jobs. The Enhanced FRShours_workedis FRSTOTHOURS(usual weekly hours across current jobs) × 52, andweekly_hoursdivides by 52. Enter it directly for fluctuating hours, which reg 5(2) and (3B) average over a cycle or recent weeks, and to leave out the reg 6(1) employments that reg 5(6) excludes from the count.jsa_income: the screen moves intoincome_related_jsa_award, unchanged. The JSAactivecheck is inside the helper, so every caller agrees withjsa_income.jsa_incomekeeps its own early return, so an inactive scheme still reads no reports. The parallel passport-scoping branch (Count only the claimant's and partner's legacy awards in means tests and passports #2027) carries a byte-identical copy.No new
Simulationmachinery. Review rounds 1-3 tried deciding direct entry from core's record of inputs (_user_input_keys), and that proved unsound in policyengine-core 3.32.9: core-built simulations, disk clones, default-named branches, ETERNITY periods, an input-free crash, and O(keys) deletes. Apply the Income Support conditions to the claimant and partner only #2013 and this PR replace it with the value rule above, for ESA and JSA alike; Count only the claimant's and partner's legacy awards in means tests and passports #2027 follows. The core bookkeeping bugs are _user_input_keys drifts from stored values after delete_arrays and clone policyengine-core#559; a fix is open as Add total wealth #561.Which reg 5 and 6 rules the model applies
is_carer_for_benefits, the same proxy the gate uses for the para 4 categoryincome_support_remunerative_work_hoursdirectly for cycle or recent-period averagesInvariants
These are property-tested. Invariants 1 and 3 hold for every input except at the value convention's boundary. A stored award (entered directly or replaced by a reform) equal (to within half a penny, at the precision it is stored in) to what the reported amounts give is read through them, so another member's report, or the claimant's own, can change how such an award is read. The property generators stay off that boundary, and example tests pin it with the intended results.
income_support_eligible, whatever that member's work or JSA. The existing Apply the Income Support conditions to the claimant and partner only #2013 property now also draws hours, JSA and a directly enteredjsa_income.income_support_eligibleequals an independently written family-by-family reading of the gate. The oracle intest_income_support_eligibility_properties.pynow reads (c) and (f), with the JSA tariff and capital limit from the parameters, alongside main's qualifying age and Pension Credit proxy, which it reads from the model as the gate does.jsa_incomeis unchanged by the helper refactor. On the Enhanced FRS,jsa_incomeand every other saved array (92 in the run against current main) are identical in every year 2025-2030.set_inputat any time, deletion, clones and branches. This is covered by example tests, not a property:test_income_support_direct_inputs.py(17 tests), including the half-penny tolerance endpoints (£100.004 vs £100.006 against a direct £100), a stored zero that never bars the claim, three half-penny zero cases (the formula's own residual; £0.003 vs £0.006 entered; the couple's residual beside another member's £3,000) and the couple's award tolerance (£52.004 vs £52.006 against £52 of tariff income). It also covers a lateset_input, aset_inputafter the gate was cached, a branch, a late zero overriding reports, an input for another year, a neutralisedjsa_income, the raw reported total, large reports compared at storage precision, and the two convention-boundary transitions (an excluded adult's report, or the claimant's own, explaining a direct award);test_entered_directly_lifecycle.py(16 tests): eight cases for both awards, with an excluded adult reporting the award, including an entered award equal to the reports (read through them, as intended).Tests
income_support_work_and_jsa.yaml, each hand-computed from the law. At f159918 (after merging main twice; the head, 198adea, changes only a docstring since), thefinance/benefitandgovYAML folders pass: 2,474 cases (2,439 at de0ece0, before main's Model a Universal Credit claim by a member of a couple as a single person (UC Regs 2013 reg 3(3)) #2084 added its Universal Credit single-claim cases). Main's s.124(1)(g) and qualifying-age cases inincome_support_claimant_partner_gates.yamlpass with this PR's conditions.jsa_income.jsa_incomeentered for another year fails.test_income_support_esa_entered_directly.pypasses here too, with one new test that pins the ESA couple's award tolerance the same way. Setting either couple's-award guard (JSA or ESA) to> 0fails its new tolerance test at the eligibility assertion.test_direct_awards_match_the_reference_deterministicallychecks both direct-award modes against the oracle for a fixed carer, because random draws reach them only some of the time. It kills a mutant that ignores direct ESA, which the 25-example differential let survive in review round 5.test_legacy_benefit_parameters.pychecks both thresholds on either side of 7 April 1992 and 7 October 1996.policyengine_uk/testsexceptmicrosimulation/): 2,052 passed, 19 skipped at 48abd88; 2,036 at 6337f31; 2,024 at fcbec88. At 081ec14, the targeted files (properties, direct awards, lifecycle, ESA, parameters, carer premium, code health) passed: 1,745 tests. At f159918, the properties, direct-award, lifecycle, ESA and parameter files pass: 60 tests (58 at de0ece0 and 195c719, before the two tolerance tests). The value rule removed theSimulationoverrides 48abd88 had added, so the head touches no shared simulation code.Impact (Enhanced FRS 2024-25, real PolicyEngine runs)
Against current main. This PR at 195c719 was run against main at the merged commit (9f92d53), each package state in its own environment on a fresh private copy of the dataset (sha256 e433e532…). Every year 2025-2030 changes by £0, with all 92 saved arrays identical in every year. The same comparison at the first merge of main (de0ece0 against f1a9a3c) also gave £0, with all 92 arrays identical. The head (198adea) changes only test files since 195c719, so this run covers its code. That covers Income Support (£0.51bn in 2025; the model pays it until 2025-26), income-related ESA and income-based JSA, Housing Benefit, CTR, Universal Credit, Pension Credit, household net income and poverty.
Earlier runs. Four package states: #2013's head (8d8673a), (c) only, (f) only, and this PR (fcbec88). After review, the heads were re-run twice: #2013 at 06e186b against this PR at 6337f31, and #2013 at 97b7a1c against this PR at bfff081 (the value rule). Each step and each re-run was £0 in every year, with all 91 saved arrays identical.
Why nothing moves (2025, base run on main):
The JSON summaries are aggregates. The dataset copies and per-record arrays stay private.
Axiom
axiom: TheAxiomFoundation/rulespec-uk#403 queued (addendum)
The addendum queues s.124(1)(c) and (f), IS Regs regs 5 and 6 and JSA 1995 s.1(4). It has outputs, a
review_findingand 24 companion tests: 22 match the YAML cases, and 2 cover the reg 5(4) trade-dispute precedence that PolicyEngine does not model. None of those citations is in the corpus yet. JSA Regs reg 116, which PolicyEngine's income-based JSA screen uses, is encoded-correct (uk/regulations/uksi/1996/207/116.yaml).Review
Round 1 (Subfleet, GPT-6.1 Sol): REQUEST CHANGES.
jsa_incomewas detected throughSimulation.input_variables. That missed late or branch inputs and leaked across years. Fixed withentered_directlyand regression tests, since superseded by the value rule (round 3). The same defect in Apply the Income Support conditions to the claimant and partner only #2013's ESA fallback was fixed there and merged here.review_finding, with companions 50-51.jsa_income's early return is restored.Round 2 (same reviewer): findings 2, 4, 5 and 6 fixed; 1 and 3 partly fixed, with two new defects found.
Simulation.delete_arrays.Simulation.clone, withentered_directlychecking the provenance of the stored value read; both were superseded by the value rule in round 3.Round 3 (same reviewer): REQUEST CHANGES.
Round 4 (same reviewer): REQUEST CHANGES. Round-3 findings 1, 2, 4 and 6 resolved, 5 moot; finding 3 (disk clones share files) is a core storage bug, now PolicyEngine/policyengine-core#558. All round-4 findings are addressed:
Round 5 (same reviewer): APPROVE WITH NITS. No runtime defects; the precision fix is confirmed in both limbs and the merges lost nothing from #2013. The nits are addressed:
After round 5: merged #2013's 0953c8a, which limits Sch 1B para 2 to placed children under 16 and guards the ESA bar so a stored zero never bars the claim. The JSA limb now has the same guard, pinned by
test_a_stored_zero_never_bars_the_claim, which fails on the previous gate.Round 6b (confirmatory, delta da1a798..081ec14): APPROVE WITH NITS. No runtime defect, and nothing from #2013's merge was lost. The two PR-body nits (a stale Hypothesis statistic; two sentences that described only entered awards) are fixed above. Its informational finding 4, that the zero guard caught only an exact zero so a float residual of the reports still barred the claim, is fixed by the half-penny rule below.
After merging main (de0ece0): two conflicts, in the gate and the property module. Kept this PR's rule and main's qualifying-age test, s.124(1)(g) bar and half-penny ESA zero guard (#2013's after-merge review). The JSA limb now treats an amount within half a penny of zero as no award too, with three new tests. That is the one change beyond a mechanical merge, and an independent review of it is recorded below.
After merging main again (195c719): main's #2084 (a Universal Credit single claim by a member of a couple) merged without conflicts. It changes no Income Support, JSA or ESA code. The tests and the impact comparison above were rerun at that commit.
vahid-ahmadi's review (at 081ec14): both points are done. The merge of main (de0ece0) takes main's
has_attained_state_pension_credit_qualifying_agefor the claimant's age test and keeps main's s.124(1)(g)on_pension_creditbar. The 23 YAML cases and the property tests pass with them, and the summary no longer describes a stack.After-merge review (Subfleet job 20261008-123448-is-work-jsa-review-after-merge, Opus 5.5, at de0ece0): APPROVE WITH NITS. No runtime defect. Nothing from either side of the merge was lost, and the JSA half-penny guard has the ESA limb's form and is right in law and in code. Its findings are addressed:
> 0fails its test.activecheck, not from this delta, and belongs with a separate follow-up that fixes thedisable_simulated_benefitsreform.Confirmatory review (Subfleet job 20261008-184614-is-work-jsa-review-after-merge-r2, GPT-6.1 Sol, delta 195c719..f159918): APPROVE WITH NITS, safe to merge.
test_income_support_direct_inputs.pyandtest_income_support_esa_entered_directly.py(37 passed) and both mutants (each fails atassert eligible(below)after its preconditions pass).🤖 Generated with Claude Code