diff --git a/changelog.d/2263.fixed.md b/changelog.d/2263.fixed.md new file mode 100644 index 0000000000..ae295fcec0 --- /dev/null +++ b/changelog.d/2263.fixed.md @@ -0,0 +1,3 @@ +Screen income-related ESA and income-based JSA reports separately for people outside the claimant couple, preserving scoped award readers, and use assessed ESA receipt in the 16–19 Bursary Fund own-right signal. + +Use the report-aware ESA callback in the Housing Benefit cap reader, restore ESA/JSA documentation citations and spacing, and clarify bursary test comments. diff --git a/docs/engineering/esa-jsa-report-screening.md b/docs/engineering/esa-jsa-report-screening.md new file mode 100644 index 0000000000..1994eb87ad --- /dev/null +++ b/docs/engineering/esa-jsa-report-screening.md @@ -0,0 +1,42 @@ +# Screening reported ESA and JSA claims + +Income-related ESA and income-based JSA remain bounded models of reported +awards, rather than complete entitlement calculations. The original claimant +and partner share one reported claim. Each member outside that couple who +reports an award has a separate claim: their own existing work screen applies, +and tariff income is deducted separately. The benefit unit's capital remains +an explicit proxy because capital owned by each outside claimant is not observed. + +For example, using ordinary capital of £7,000 and the model's 52-week convention, +tariff income is £208 per claim. A couple reporting £3,000 and two outside people +reporting £1,000 and £500 receive £2,792 + £792 + £292 = £3,876 if every existing +screen passes. The couple's part stays £2,792. A working outside reporter cannot +raise the total; failure of the couple's own screen cannot erase another eligible +person's separate award. These examples presume separate preassessed claims. + +All formula readers use the same original claim groups. A scope mask selects +reports; it does not change claimant or partner roles. Claimant-or-partner awards +exclude outside claims, including in Income Support and an alternate benefit-cap +couple. Formula provenance compares the complete and scoped screened amounts +using the existing stored precision and half-penny convention. Raw-total and +arbitrary entered or reformed value fallbacks, explicit scoped inputs and +neutralisation stay unchanged. Equal input and formula values remain +indistinguishable by design. + +The 16–19 Bursary Fund own-right ESA signal now reads payable-person IR-ESA status +or the person's contributory ESA value. Contributory ESA itself and the unchanged +UC limb retain reported-benefit proxies; an explicit signal input remains +authoritative. This helper does not certify full ESA entitlement or an +institution's bursary decision. Under DfE's 2026–27 guide, section 3.1, students +aged 19 or over cannot enter the defined vulnerable groups, including continuers +and students with an EHC plan. An older student's continuing ESA can test the +helper only, rather than establish a vulnerable-group grant. + +The existing annual closure, 52-week and shared-capital approximations remain. +No population estimate or absence of outside survey reports is claimed. + +The relevant rules are the ESA Regulations 2008, regulations 110 and 118; JSA +Regulations 1996, regulations 107 and 116; Welfare Reform Act 2007, Schedule 1, +paragraph 6; Jobseekers Act 1995, sections 1 and 3; and the DfE 16–19 Bursary Fund +guide for 2026–27, sections 3.1, 4.3 and 4.4. The existing variable references +link to those provisions. diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml index eacc7e084d..8d7978ab3c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_remunerative_work.yaml @@ -1214,10 +1214,9 @@ household: members: [claimant, lodger] output: - # The benefit unit's award applies the claimant's failed screen to every - # report in it, so it is nil. + # The claimant fails, but the outside adult's separate own award pays. esa_income_eligible: false - esa_income: 0 + esa_income: 5_000 claimant_or_partner_esa_income: 0 # The lodger's own claim passes, so they are on it; the claimant is not. is_on_income_related_esa: [false, true] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml index daeb1b21c8..57100b6f23 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_remunerative_work.yaml @@ -437,10 +437,9 @@ household: members: [claimant, lodger] output: - # The benefit unit's award applies the claimant's failed screen to every - # report in it, so it is nil. + # The claimant fails, but the outside adult's separate own award pays. jsa_income_eligible: false - jsa_income: 0 + jsa_income: 3_000 claimant_or_partner_jsa_income: 0 # The lodger's own claim passes, so they are on it; the claimant is not. is_on_income_based_jsa: [false, true] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml index 82cb89df67..efea8cd5c8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/claimant_or_partner_awards.yaml @@ -99,7 +99,7 @@ output: # (10,000 - 6,000) / 250 = 16 steps; 16 x £1 x 52 = £832. esa_income_tariff_income: 832 - esa_income: 7_168 # 3,000 + 5,000 - 832 + esa_income: 6_336 # (3,000 - 832) + (5,000 - 832) claimant_or_partner_esa_income: 2_168 # 3,000 - 832 - name: Capital above the income-related ESA limit removes the claimant-or-partner award @@ -232,7 +232,7 @@ output: # (7,000 - 6,000) / 250 = 4 steps; 4 x £1 x 52 = £208. jsa_income_tariff_income: 208 - jsa_income: 6_792 # 3,000 + 4,000 - 208 + jsa_income: 6_584 # (3,000 - 208) + (4,000 - 208) claimant_or_partner_jsa_income: 2_792 # 3,000 - 208 - name: No claimant-or-partner JSA award once income-based JSA has closed diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml index 63c07bf301..ccd0ffb568 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/legacy_award_readers/passports.yaml @@ -559,10 +559,10 @@ output: # Savings of £7,000 are £1,000 above £6,000: 4 steps of £250, so tariff # income of £4 a week, £208 a year (ESA Regs 2008 reg 118). The student's - # £200 alone gives no award; the other adult's £3,000 gives £2,792. The - # benefit unit's award on both reports, £2,992, is household income but - # does not make the student's report paid. - esa_income: [2_992] + # £200 alone gives no award; the other adult's £3,000 gives £2,792. + # Their separate claims total £0 + £2,792 = £2,792 in household income. + # The other adult's report does not make the student's report paid. + esa_income: [2_792] claimant_or_partner_esa_income: [0] is_on_income_related_esa: [false, false, true] maintenance_loan_entitled_to_benefits: [false, false, true] diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19.yaml index 0cf3e91122..c3141ac5a9 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19.yaml @@ -267,3 +267,65 @@ output: bursary_fund_16_to_19_vulnerable_group_eligible: [false] bursary_fund_16_to_19: [0] + +# Signal regressions only: these do not certify a new lawful legacy claim +# or an education institution's needs assessment/grant decision. +- name: Issue2263 raw IR-ESA screened to nil is not own-right ESA receipt + period: 2025 + input: + age: 17 + current_education: POST_SECONDARY + pip_dl_category: STANDARD + esa_income_reported: 3_000 + employment_income: 20_000 + hours_worked: 2_080 + would_claim_uc: false + output: + is_on_income_related_esa: false + bursary_fund_16_to_19_receives_uc_or_esa_in_own_right: false + +# A continuing preassessed award can supply the helper signal at 20; DfE +# section 3.1 still excludes 19+ students from the defined vulnerable groups. +- name: Issue2263 continuing own-right ESA gives a helper signal only + period: 2025 + input: + age: 20 + esa_income_reported: 3_000 + would_claim_uc: false + output: + is_on_income_related_esa: true + bursary_fund_16_to_19_receives_uc_or_esa_in_own_right: true + +- name: Issue2263 assessed contributory ESA zero overrides its raw report proxy + period: 2025 + input: + age: 20 + esa_contrib_reported: 3_000 + esa_contrib: 0 + output: + bursary_fund_16_to_19_receives_uc_or_esa_in_own_right: false + +- name: Issue2263 positive contributory ESA retains the helper signal + period: 2025 + input: + age: 20 + esa_contrib: 3_000 + output: + bursary_fund_16_to_19_receives_uc_or_esa_in_own_right: true + +- name: Issue2263 explicit own-right false overrides a reported UC signal + period: 2025 + input: + age: 17 + universal_credit_reported: 100 + bursary_fund_16_to_19_receives_uc_or_esa_in_own_right: false + output: + bursary_fund_16_to_19_receives_uc_or_esa_in_own_right: false + +- name: Issue2263 explicit own-right true remains authoritative + period: 2025 + input: + age: 17 + bursary_fund_16_to_19_receives_uc_or_esa_in_own_right: true + output: + bursary_fund_16_to_19_receives_uc_or_esa_in_own_right: true diff --git a/policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py b/policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py index 39c805137f..30b95e0ec6 100644 --- a/policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py +++ b/policyengine_uk/tests/test_claimant_or_partner_awards_entered_directly.py @@ -136,7 +136,7 @@ def test_the_raw_reported_total_is_read_through_the_reports(award): member's report. Here the partner reports £200 and an adult outside the couple £3,000, with savings of £10,000. Tariff income of ceil(4,000 / 250) x £1 x 52 = £832 a year extinguishes the partner's - award. The raw total, £3,200, differs from the screened award, £2,368, + award. The raw total, £3,200, differs from the screened award, £2,168, but is still what the reports give. As the reform uses reported amounts unscreened, the claimant-or-partner award is the partner's raw report, £200.""" @@ -166,7 +166,7 @@ def test_the_raw_reported_total_is_read_through_the_reports(award): "households": {"home": {"members": members, "savings": {YEAR: 10_000}}}, } ) - assert simulation.calculate(award, YEAR)[0] == 2_368 + assert simulation.calculate(award, YEAR)[0] == 2_168 assert scoped(simulation, award) == 0 simulation.set_input(award, YEAR, [3_200]) assert scoped(simulation, award) == 200 diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 1becfb4bc7..46ae03d233 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -43,7 +43,9 @@ ) from policyengine_uk.utils.benefit_unit import claimant_or_partner_award from policyengine_uk.utils.scenario import Scenario -from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award +from policyengine_uk.variables.gov.dwp.esa_income import ( + income_related_esa_award_on_reports, +) DATASET_YEAR = 2025 # The dataset year and two later years. @@ -243,7 +245,7 @@ def test_later_years_need_the_awards_set_explicitly(reformed, year): period(year), "esa_income", "esa_income_reported", - income_related_esa_award, + income_related_esa_award_on_reports, ) assert by_value_alone[excluded] == 3_000 diff --git a/policyengine_uk/tests/test_disable_simulated_benefits_benefit_cap.py b/policyengine_uk/tests/test_disable_simulated_benefits_benefit_cap.py index 3647f78d50..5f12d61500 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits_benefit_cap.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits_benefit_cap.py @@ -19,8 +19,9 @@ partner, in each of the reform's years, the cap's total, both schemes' specified-benefit exemptions and both reductions are the same whether or not a member outside the couple reports ESA or JSA in the dataset year. -2. Differential, reform off: the cap's total equals the code it replaces - (policyengine-uk main at b4dbaf57a) to within a penny, and the couple's +2. Differential, reform off: the cap's total equals the report-reader + structure it replaces, using current separate-claim callbacks, to within + a penny (not literal historical aggregate semantics), and the couple's award the exemptions read equals award_of_members for the couple, except that an award within half a penny of zero is none. 3. For a couple that is not the claimant and partner, award_of_couple is @@ -53,8 +54,12 @@ CLAIMANT_OR_PARTNER_BENEFITS, FAMILY_BENEFITS, ) -from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award -from policyengine_uk.variables.gov.dwp.jsa_income import income_related_jsa_award +from policyengine_uk.variables.gov.dwp.esa_income import ( + income_related_esa_award_on_reports, +) +from policyengine_uk.variables.gov.dwp.jsa_income import ( + income_related_jsa_award_on_reports, +) REFORM_ON = Scenario( applied_before_data_load=True, @@ -70,8 +75,8 @@ "benefit_cap_reduction", ] AWARDS = { - "esa_income": ("esa_income_reported", income_related_esa_award), - "jsa_income": ("jsa_income_reported", income_related_jsa_award), + "esa_income": ("esa_income_reported", income_related_esa_award_on_reports), + "jsa_income": ("jsa_income_reported", income_related_jsa_award_on_reports), } @@ -170,14 +175,14 @@ def replaced_benefit_cap_welfare_benefits(benunit, period): period, "jsa_income", "jsa_income_reported", - income_related_jsa_award, + income_related_jsa_award_on_reports, couple, ) + award_of_members( benunit, period, "esa_income", "esa_income_reported", - income_related_esa_award, + income_related_esa_award_on_reports, couple, ) return income_based + add_for_members( @@ -263,7 +268,7 @@ def test_a_couple_that_is_not_the_claimant_and_partner_reads_the_stored_award(): period, "esa_income", "esa_income_reported", - income_related_esa_award, + income_related_esa_award_on_reports, claimant_only, )[0] ) diff --git a/policyengine_uk/tests/test_income_support_direct_inputs.py b/policyengine_uk/tests/test_income_support_direct_inputs.py index 597c6de878..9cb3a2f195 100644 --- a/policyengine_uk/tests/test_income_support_direct_inputs.py +++ b/policyengine_uk/tests/test_income_support_direct_inputs.py @@ -130,8 +130,9 @@ def test_the_raw_reported_total_is_read_through_the_reports(): award is the raw total of their own reports. Here the partner reports £200 and an adult outside the couple £3,000, with capital of £10,000. Tariff income of ceil(4,000 / 250) x £1 x 52 = £832 a year extinguishes - the partner's award on the formula's reading, so the formula's £2,368 - (£3,200 - £832) does not bar the claim. The raw total, £3,200, pays the + the partner's award on the formula's reading, so the formula's £2,168 + (max(0, £200 - £832) + max(0, £3,000 - £832)) does not bar the claim. + The raw total, £3,200, pays the partner's £200 unscreened, so it does. The excluded adult's £3,000 is theirs on either reading.""" members = ["carer", "partner", "other_adult"] @@ -165,7 +166,7 @@ def test_the_raw_reported_total_is_read_through_the_reports(): "households": {"home": {"members": members}}, } ) - assert simulation.calculate("jsa_income", YEAR)[0] == 2_368 + assert simulation.calculate("jsa_income", YEAR)[0] == 2_168 assert eligible(simulation) simulation.set_input("jsa_income", YEAR, [3_200]) assert not eligible(simulation) @@ -383,12 +384,12 @@ def entered(amount): def test_the_couples_residual_is_no_award_beside_another_members_report(): - # An excluded adult's £3,000 makes jsa_income a real award, but on the + # An excluded adult's £3,000 less £52 tariff makes jsa_income a real award, but on the # couple's own reports (£10.01 + £41.99 against £52 of tariff income) the # award is only a float residual, so it does not bar the claim. A real # penny of award (£52.01 against £52) still does. residual = reporting_family(10.01, 41.99, other_adults=[3_000]) - assert residual.calculate("jsa_income", YEAR)[0] > 2_999 + assert residual.calculate("jsa_income", YEAR)[0] > 2_947 assert eligible(residual) penny = reporting_family(52.01) assert 0.005 < penny.calculate("jsa_income", YEAR)[0] < 0.015 @@ -396,7 +397,7 @@ def test_the_couples_residual_is_no_award_beside_another_members_report(): def test_the_couples_award_tolerance_is_half_a_penny(): - # Beside an excluded adult's £3,000, jsa_income is a real award that the + # Beside an excluded adult's net £2,948, jsa_income is a real award that the # reports explain, so the couple's own award decides. £52.004 against # £52 of tariff income leaves them £0.004, which is no award; £52.006 # leaves £0.006, which bars the claim. @@ -404,7 +405,7 @@ def couple_award(report): return reporting_family(report, other_adults=[3_000]) below, above = couple_award(52.004), couple_award(52.006) - assert below.calculate("jsa_income", YEAR)[0] > 2_999 + assert below.calculate("jsa_income", YEAR)[0] > 2_947 assert eligible(below) - assert above.calculate("jsa_income", YEAR)[0] > 2_999 + assert above.calculate("jsa_income", YEAR)[0] > 2_947 assert not eligible(above) diff --git a/policyengine_uk/tests/test_income_support_esa_entered_directly.py b/policyengine_uk/tests/test_income_support_esa_entered_directly.py index d3433a7052..793074f6fa 100644 --- a/policyengine_uk/tests/test_income_support_esa_entered_directly.py +++ b/policyengine_uk/tests/test_income_support_esa_entered_directly.py @@ -173,7 +173,7 @@ def test_the_plain_reported_total_is_read_through_the_reports(): {"carer": CARER, "partner": partner, "other_adult": EXCLUDED_ADULT}, {"esa_income_assessable_capital": {YEAR: 10_000}}, ) - assert sim.calculate("esa_income", YEAR)[0] == 2_368 + assert sim.calculate("esa_income", YEAR)[0] == 2_168 assert eligible(sim) sim.set_input("esa_income", YEAR, np.array([3_200.0])) assert not eligible(sim) @@ -259,7 +259,7 @@ def test_an_entered_amount_within_half_a_penny_of_zero_is_no_award(): def test_the_couples_residual_is_no_award_beside_another_members_report(): - # An excluded adult's £3,000 makes esa_income a real award, but on the + # An excluded adult's £3,000 less £52 tariff makes esa_income a real award, but on the # couple's own reports (£10.01 + £41.99 against £52 of tariff income) the # award is only a float residual, so it does not bar the claim. A real # penny of award (£52.01 against £52) still does. @@ -273,7 +273,7 @@ def test_the_couples_residual_is_no_award_beside_another_members_report(): residual = simulation( {"carer": carer, "partner": partner, "other_adult": EXCLUDED_ADULT}, capital ) - assert residual.calculate("esa_income", YEAR)[0] > 2_999 + assert residual.calculate("esa_income", YEAR)[0] > 2_947 assert eligible(residual) penny = simulation( {"carer": {**CARER, "esa_income_reported": {YEAR: 52.01}}}, capital @@ -283,7 +283,7 @@ def test_the_couples_residual_is_no_award_beside_another_members_report(): def test_the_couples_award_tolerance_is_half_a_penny(): - # Beside an excluded adult's £3,000, esa_income is a real award that the + # Beside an excluded adult's net £2,948, esa_income is a real award that the # reports explain, so the couple's own award decides. £52.004 against # £52 of tariff income leaves them £0.004, which is no award; £52.006 # leaves £0.006, which bars the claim. @@ -294,9 +294,9 @@ def couple_award(report): return simulation({"carer": carer, "other_adult": EXCLUDED_ADULT}, capital) below, above = couple_award(52.004), couple_award(52.006) - assert below.calculate("esa_income", YEAR)[0] > 2_999 + assert below.calculate("esa_income", YEAR)[0] > 2_947 assert eligible(below) - assert above.calculate("esa_income", YEAR)[0] > 2_999 + assert above.calculate("esa_income", YEAR)[0] > 2_947 assert not eligible(above) diff --git a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py index 24894a7fb4..ff2edaeab4 100644 --- a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py +++ b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py @@ -1,28 +1,14 @@ -"""income_support_eligible reads the claimant-or-partner awards. - -Its income-related ESA and income-based JSA limbs (SSCBA 1992 s.124(1)(h) -and (f)) used to work out the claimant's and partner's award inline, by the -value rule that claimant_or_partner_esa_income and -claimant_or_partner_jsa_income also apply. They now read those variables, so -every reader shares one award, including one entered directly or set by the -disable_simulated_benefits reform. This file checks the switch against the -code it replaced (policyengine-uk main at e89b8abcf, with #2013, #2025 -and #2027 merged; comments removed). - -The two agree in formula mode, for awards entered directly, set later, set on -a branch, and recalculated after deletion. They differ, by design, in one -case: a stored award equal to the plain total of everyone's reports but not -to the formula's award. The old limbs then read the claimant's and partner's -award after the benefit's screen. The variables, and now the limbs, read the -plain total of their reports, the reports paid in full, as the reform that -stores plain totals pays them. That case is checked against its own -expectation. - -The replaced code also ignored a claimant-or-partner award entered directly -(review r3c on #2027). It worked the couple's award out from esa_income and -everyone's reports, so an adult outside the couple could decide whether the -entered award barred the claim. The last tests enter the claimant's and -partner's award and check that it alone decides its limb. +"""Income Support reads the current claimant/partner award value convention. + +The helper switch once matched literal historical inline readers. Separate +outside-claim screening intentionally changes that aggregate formula, so +this file now checks an independent CURRENT per-claim reference. Work and +capital are read using the existing plain-Python legal reference, with the +original couple and separate outside claims; expected full/scoped amounts +never call production award callbacks. Stored float32/half-penny, plain-total, +arbitrary input, zero and explicit scoped-input conventions are retained. +A plain total can differ from a screened-reports reading; that comparison +is a declared counterfactual value convention, not historical-code equality. """ import numpy as np @@ -33,32 +19,93 @@ from policyengine_uk import Simulation from policyengine_uk.model_api import add +from policyengine_uk.tests import legacy_award_work_reference as reference from policyengine_uk.tests.test_income_support_eligibility_properties import ( DIFFERENTIAL_SETTINGS, FAMILIES, input_settings, situation as property_situation, ) -from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award -from policyengine_uk.variables.gov.dwp.jsa_income import income_related_jsa_award YEAR = 2025 HALF_PENNY = 0.005 LIMBS = { "esa_income": ( "esa_income_reported", - income_related_esa_award, + "ESA", "claimant_or_partner_esa_income", ), "jsa_income": ( "jsa_income_reported", - income_related_jsa_award, + "JSA", "claimant_or_partner_jsa_income", ), } -def pre_switch_income_support_eligible(benunit, period, parameters): +def reference_report_awards(benunit, period, award): + """Independent per-claim full and original-couple amounts on open reports.""" + person = benunit.members + names = [ + "age", + "hours_worked", + "employment_income", + "self_employment_income", + "employee_pension_contributions", + "personal_pension_contributions", + "statutory_sick_pay", + "statutory_maternity_pay", + "statutory_paternity_pay", + "receives_carer_benefit", + "care_hours", + "income_support_reported", + "pension_credit_reported", + "esa_income_reported", + "jsa_income_reported", + ] + arrays = {name: person(name, period) for name in names} + roles = person("is_claimant_or_partner", period) + closed_name = ( + "esa_income_report_closed" + if award == "esa_income" + else "person_legacy_benefits_closed" + ) + closed = person(closed_name, period) + capitals = benunit(f"{award}_assessable_capital", period) + parameters = benunit.simulation.tax_benefit_system.parameters(period) + is_esa = award == "esa_income" + rules = parameters.gov.dwp.ESA.income if is_esa else parameters.gov.dwp.JSA.income + screen = reference.esa_screen if is_esa else reference.jsa_screen + full, scoped = [], [] + for unit in range(benunit.count): + ids = np.flatnonzero(benunit.members_entity_id == unit) + members = [] + for i in ids: + member = {name: float(value[i]) for name, value in arrays.items()} + if closed[i]: + member[f"{award}_reported"] = 0 + members.append((i, member)) + couple = [member for i, member in members if roles[i]] + others = [member for i, member in members if not roles[i]] + capital = capitals[unit] + tariff = reference.tariff_income(capital, rules.capital) + active = is_esa or parameters.gov.dwp.JSA.income.active + couple_amount = ( + max(0, sum(member[f"{award}_reported"] for member in couple) - tariff) + if active and screen(couple, [], capital, parameters) + else 0 + ) + outside_amount = sum( + max(0, member[f"{award}_reported"] - tariff) + for member in others + if active and screen([member], [], capital, parameters) + ) + full.append(couple_amount + outside_amount) + scoped.append(couple_amount) + return np.array(full), np.array(scoped) + + +def reference_income_support_eligible(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support person = benunit.members claimant_or_partner = person("is_claimant_or_partner", period) @@ -103,46 +150,8 @@ def pre_switch_income_support_eligible(benunit, period, parameters): & ~works_as_claimant & ~other_member_works ) - esa_income = benunit("esa_income", period) - reported_total = add(benunit, period, ["esa_income_reported"]) - award_on_all_reports = income_related_esa_award(benunit, period, reported_total) - award_on_claimant_or_partner_reports = income_related_esa_award( - benunit, - period, - benunit.sum(person("esa_income_reported", period) * claimant_or_partner), - ) - stored = esa_income.dtype - half_penny = 0.005 - as_reported = np.isclose( - esa_income, award_on_all_reports.astype(stored), rtol=0, atol=half_penny - ) | np.isclose(esa_income, reported_total.astype(stored), rtol=0, atol=half_penny) - income_related_esa = (esa_income > half_penny) & ( - ~as_reported | (award_on_claimant_or_partner_reports > half_penny) - ) - jsa_income = benunit("jsa_income", period) - jsa_reported_total = add(benunit, period, ["jsa_income_reported"]) - jsa_award_on_all_reports = income_related_jsa_award( - benunit, period, jsa_reported_total - ) - jsa_award_on_claimant_or_partner_reports = income_related_jsa_award( - benunit, - period, - benunit.sum(person("jsa_income_reported", period) * claimant_or_partner), - ) - jsa_as_reported = np.isclose( - jsa_income, - jsa_award_on_all_reports.astype(jsa_income.dtype), - rtol=0, - atol=half_penny, - ) | np.isclose( - jsa_income, - jsa_reported_total.astype(jsa_income.dtype), - rtol=0, - atol=half_penny, - ) - income_based_jsa = (jsa_income > half_penny) & ( - ~jsa_as_reported | (jsa_award_on_claimant_or_partner_reports > half_penny) - ) + income_related_esa = reference_limb(benunit, period, "esa_income")[1] + income_based_jsa = reference_limb(benunit, period, "jsa_income")[1] on_pension_credit = benunit( "meets_pension_credit_age_conditions", period ) & benunit("would_claim_pc", period) @@ -156,22 +165,19 @@ def pre_switch_income_support_eligible(benunit, period, parameters): ) -def pre_switch_limb(benunit, period, award): - """One of the replaced inline limbs. The ESA and JSA blocks were the same - code with the names swapped; returns the limb and whether the stored award - equals only the plain total (the case that differs by design).""" - reported, award_on, _ = LIMBS[award] +def reference_limb(benunit, period, award): + """Current per-claim reference, plus the screened-raw counterfactual.""" + reported, _, _ = LIMBS[award] person = benunit.members claimant_or_partner = person("is_claimant_or_partner", period) stored = benunit(award, period) reported_total = add(benunit, period, [reported]) - on_all_reports = award_on(benunit, period, reported_total) + on_all_reports, on_claimant_or_partner_reports = reference_report_awards( + benunit, period, award + ) claimant_or_partner_reported = benunit.sum( person(reported, period) * claimant_or_partner ) - on_claimant_or_partner_reports = award_on( - benunit, period, claimant_or_partner_reported - ) as_formula = np.isclose( stored, on_all_reports.astype(stored.dtype), rtol=0, atol=HALF_PENNY ) @@ -199,7 +205,7 @@ def fresh(simulation, variables): def compare(simulation, year=YEAR): - """Assert the switched gate and limbs against the replaced code.""" + """Check the current independent contract, never literal old-code equality.""" period = as_period(year) benunit = simulation.populations["benunit"] fresh( @@ -208,18 +214,18 @@ def compare(simulation, year=YEAR): ) differs = np.zeros(benunit.count, dtype=bool) for award, (_, _, variable) in LIMBS.items(): - old, intended, plain_total_only = pre_switch_limb(benunit, period, award) + old, intended, plain_total_only = reference_limb(benunit, period, award) new = simulation.calculate(variable, year) > 0 np.testing.assert_array_equal(new, intended, err_msg=award) np.testing.assert_array_equal( new[~plain_total_only], old[~plain_total_only], err_msg=award ) differs |= plain_total_only & (new != old) - old_gate = pre_switch_income_support_eligible( + old_gate = reference_income_support_eligible( benunit, period, simulation.tax_benefit_system.parameters ) new_gate = simulation.calculate("income_support_eligible", year) - np.testing.assert_array_equal(new_gate[~differs], old_gate[~differs]) + np.testing.assert_array_equal(new_gate, old_gate) return differs @@ -356,12 +362,12 @@ def test_a_plain_total_reads_the_claimant_and_partner_reports_in_full(): differs = compare(sim) assert differs.tolist() == [i == OVER_TARIFF for i in range(len(EXAMPLES))] assert sim.calculate(variable, YEAR)[OVER_TARIFF] == 200 - old_gate = pre_switch_income_support_eligible( + old_gate = reference_income_support_eligible( sim.populations["benunit"], as_period(YEAR), sim.tax_benefit_system.parameters, ) - assert old_gate[OVER_TARIFF] + assert not old_gate[OVER_TARIFF] assert not gate(sim)[OVER_TARIFF] @@ -438,9 +444,9 @@ def test_the_limbs_read_every_stored_value_as_intended(drawn): @DIFFERENTIAL_SETTINGS @given(FAMILIES, st.data()) def test_the_switched_gate_matches_the_replaced_code(drawn, data): - # Families from the eligibility properties, with adults outside the - # couple who report awards, and awards calculated or entered directly - # (£0 or £4,000, never a plain total). The gates agree exactly. + # The historical selector name is retained; comparison now uses the + # independent current per-claim contract, not the old scalar total. + # Families have outside reporters and formula or direct0/4000 awards. capital_as_savings, esa_income, jsa_income = data.draw(input_settings(len(drawn))) units = [(*family, extra) for family, extra in drawn] sim = Simulation( diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index a0a5632e9f..f82151672d 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -1249,12 +1249,12 @@ def test_an_outside_adults_own_claim_is_screened_on_their_own_work(): claimant_report, ) assert idle_other.calculate(variable, YEAR)[1], (variable, claimant_report) - # The working claimant's report fails the screen: the benefit unit's - # award is nil, and the claimant is on neither award. + # The working claimant fails; the idle outside adult's own £3,000 + # remains in the aggregate, and the claimant is on neither award. idle_other = _claimant_and_outside_adult( 3_000, claimant_works=True, other_works=False ) - assert idle_other.calculate("esa_income", YEAR)[0] == 0 + assert idle_other.calculate("esa_income", YEAR)[0] == 3_000 assert not idle_other.calculate("is_on_income_related_esa", YEAR)[0] diff --git a/policyengine_uk/tests/test_reported_legacy_award_screening.py b/policyengine_uk/tests/test_reported_legacy_award_screening.py new file mode 100644 index 0000000000..f3e4925118 --- /dev/null +++ b/policyengine_uk/tests/test_reported_legacy_award_screening.py @@ -0,0 +1,319 @@ +"""Separate preassessed ESA/JSA claims keep their own existing report screens. + +The capital premise is the model's shared-benefit-unit proxy, not evidence of +an outside claimant's capital. SOURCE's ordinary £7,000 case has £4 weekly +capital income, £208 under the existing 52-week convention. A couple is one +claim; each outside reporter is a separate claim. No age/PC entitlement rule +or full award calculation is certified here. +""" + +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.utils.scenario import Scenario + +YEAR = 2025 +AWARDS = ["esa_income", "jsa_income"] + + +def family( + award, + couple_report=3_000, + outside_reports=(3_000,), + couple_works=False, + outside_works=None, + capital=0, + closed_outside=False, + alternate_cap_couple=False, + scenario=None, +): + outside_works = outside_works or (False,) * len(outside_reports) + people = { + "claimant": { + "age": 40, + "is_claimant_or_partner": True, + "receives_carer_benefit": True, + "income_support_reported": 1_000, + f"{award}_reported": couple_report, + "esa_income_claimant_remunerative_work": couple_works, + "jsa_remunerative_work_hours": 40 if couple_works else 0, + "person_legacy_benefits_closed": False, + } + } + for i, (report, works) in enumerate(zip(outside_reports, outside_works)): + people[f"outside_{i}"] = { + "age": 30, + "is_claimant_or_partner": False, + "current_education": "NOT_IN_EDUCATION", + f"{award}_reported": report, + "esa_income_claimant_remunerative_work": works, + "jsa_remunerative_work_hours": 40 if works else 0, + "person_legacy_benefits_closed": closed_outside, + } + if alternate_cap_couple: + for name, person in people.items(): + person["is_uc_assessed_claimant"] = name == "outside_0" + members = list(people) + inputs = { + "members": members, + "would_claim_uc": {YEAR: False}, + "would_claim_pc": {YEAR: False}, + "income_support_assessable_capital": {YEAR: 0}, + f"{award}_assessable_capital": {YEAR: capital}, + } + if alternate_cap_couple: + inputs["universal_credit_pre_benefit_cap"] = {YEAR: 1} + return Simulation( + situation={ + "people": { + name: {key: {YEAR: value} for key, value in person.items()} + for name, person in people.items() + }, + "benunits": {"family": inputs}, + "households": {"home": {"members": members}}, + }, + scenario=scenario, + ) + + +def amount(simulation, variable): + return float(simulation.calculate(variable, YEAR)[0]) + + +@pytest.mark.parametrize("award", AWARDS) +def test_working_outside_report_does_not_inflate_total(award): + simulation = family(award, outside_works=(True,)) + assert amount(simulation, award) == 3_000 + assert amount(simulation, f"claimant_or_partner_{award}") == 3_000 + + +@pytest.mark.parametrize("award", AWARDS) +def test_eligible_outside_report_survives_failed_couple(award): + simulation = family(award, couple_works=True) + assert amount(simulation, award) == 3_000 + assert amount(simulation, f"claimant_or_partner_{award}") == 0 + + +@pytest.mark.parametrize("award", AWARDS) +@pytest.mark.parametrize( + "couple_works,outside_works,total,couple", + [ + (False, (False, False), 3_876, 2_792), + (False, (False, True), 3_584, 2_792), + (True, (False, False), 1_084, 0), + ], +) +def test_outside_reports_each_apply_own_tariff_and_work( + award, couple_works, outside_works, total, couple +): + simulation = family( + award, + outside_reports=(1_000, 500), + outside_works=outside_works, + couple_works=couple_works, + capital=7_000, + ) + assert amount(simulation, award) == total + assert amount(simulation, f"claimant_or_partner_{award}") == couple + + +@pytest.mark.parametrize("award", AWARDS) +def test_partitioned_formula_retains_couple_provenance(award): + # The couple's £200 is extinguished by £832 tariff income. Only the + # separate outside claim pays: 3,000 - 832 = 2,168. It cannot bar IS. + simulation = family(award, couple_report=200, capital=10_000) + assert amount(simulation, award) == 2_168 + assert amount(simulation, f"claimant_or_partner_{award}") == 0 + assert simulation.calculate("income_support_eligible", YEAR)[0] + + +@pytest.mark.parametrize("award", AWARDS) +def test_alternate_couple_scope_preserves_original_report_groups(award): + # Selecting a working outside reporter as the cap's assessed UC person + # must not move them into the original ESA/JSA claimant/partner group. + with_report = family( + award, + outside_reports=(1_000,), + outside_works=(True,), + capital=7_000, + alternate_cap_couple=True, + ) + without_report = family( + award, + outside_reports=(0,), + outside_works=(True,), + capital=7_000, + alternate_cap_couple=True, + ) + assert amount(with_report, "benefit_cap_welfare_benefits") == amount( + without_report, "benefit_cap_welfare_benefits" + ) + + +@pytest.mark.parametrize("award", AWARDS) +def test_closed_reports_and_inactive_jsa_are_not_paid(award): + simulation = family(award, closed_outside=True) + assert amount(simulation, award) == 3_000 + if award == "jsa_income": + inactive = family( + award, + scenario=Scenario(parameter_changes={"gov.dwp.JSA.income.active": False}), + ) + assert amount(inactive, award) == 0 + + +def test_alternate_uc_claimant_mask_reaches_specified_benefit_exemption(): + """A supported UC claimant-mask override reaches the actual cap and final UC. + + The different masks exercise the callback interface, rather than assert + a new entitlement rule for a couple claiming as a single person. + """ + import math + + members = ["claimant", "partner"] + people = {} + for name in members: + people[name] = { + "age": {YEAR: 40}, + "is_claimant_or_partner": {YEAR: True}, + "is_uc_claimant": {YEAR: name == "claimant"}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + "employment_income": {YEAR: 0}, + "esa_income_reported": {YEAR: 0}, + "esa_contrib_reported": {YEAR: 0}, + "care_hours": {YEAR: 0}, + "is_disabled_for_benefits": {YEAR: False}, + } + simulation = Simulation( + situation={ + "people": people, + "benunits": { + "family": { + "members": members, + "would_claim_uc": {YEAR: True}, + "would_claim_pc": {YEAR: False}, + "working_tax_credit": {YEAR: 0}, + } + }, + "households": { + "home": { + "members": members, + "region": {YEAR: "NORTH_EAST"}, + "country": {YEAR: "ENGLAND"}, + } + }, + } + ) + assert simulation.calculate("is_claimant_or_partner", YEAR).tolist() == [True, True] + assert simulation.calculate("is_uc_claimant", YEAR).tolist() == [True, False] + assert simulation.calculate("is_uc_assessed_claimant", YEAR).tolist() == [ + True, + False, + ] + assert simulation.calculate("claims_universal_credit", YEAR)[0] + assert amount(simulation, "universal_credit_pre_benefit_cap") > 0 + assert not simulation.calculate("is_uc_benefit_cap_exempt_specified_benefit", YEAR)[ + 0 + ] + cap = amount(simulation, "uc_benefit_cap") + reduction = amount(simulation, "uc_benefit_cap_reduction_after_floor") + final_uc = amount(simulation, "universal_credit") + assert math.isfinite(cap) and cap > 0 + assert math.isfinite(reduction) and reduction >= 0 + assert math.isfinite(final_uc) and final_uc >= 0 + + +def test_hb_specified_benefit_reader_honours_report_callback_contract(monkeypatch): + """HB supplies the report callback needed beyond the same-couple fast path. + + Route the real HB reader through award_of_members to exercise its dormant + callback contract. This does not invent another legal HB claimant mask. + """ + import math + + import numpy as np + + from policyengine_uk.utils.benefit_unit import award_of_members + + people, benunits, households = {}, {}, {} + for unit, report in (("paid_couple", 400), ("nil_couple", 200)): + members = [f"{unit}_claimant", f"{unit}_outside"] + for name, claimant in zip(members, (True, False)): + inputs = { + "age": 40 if claimant else 30, + "is_claimant_or_partner": claimant, + "employment_income": 0, + "hours_worked": 0, + "current_education": "NOT_IN_EDUCATION", + "esa_income_reported": report if claimant else 3_000, + "esa_contrib_reported": 0, + "esa_includes_support_component": True, + "care_hours": 0, + "is_disabled_for_benefits": False, + "person_legacy_benefits_closed": False, + } + people[name] = {key: {YEAR: value} for key, value in inputs.items()} + benunits[unit] = { + "members": members, + "would_claim_uc": {YEAR: False}, + "would_claim_pc": {YEAR: False}, + "esa_income_assessable_capital": {YEAR: 7_000}, + "universal_credit_pre_benefit_cap": {YEAR: 0}, + "housing_benefit_pre_benefit_cap": {YEAR: 25_000}, + "working_tax_credit": {YEAR: 0}, + } + households[unit] = { + "members": members, + "region": {YEAR: "NORTH_EAST"}, + } + simulation = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + # The existing shared-capital proxy gives four £250 steps above £6,000: + # £4 a week, £208 a year. The couple keeps 400 - 208 = 192 or nil; + # each outside claimant keeps 3,000 - 208 = 2,792 on their separate claim. + np.testing.assert_array_equal( + simulation.calculate("is_claimant_or_partner", YEAR), + [True, False, True, False], + ) + np.testing.assert_array_equal( + simulation.calculate("esa_income_report_closed", YEAR), [False] * 4 + ) + np.testing.assert_allclose( + simulation.calculate("esa_income_tariff_income", YEAR), + [208, 208], + rtol=0, + atol=0.01, + ) + np.testing.assert_allclose( + simulation.calculate("claimant_or_partner_esa_income", YEAR), + [192, 0], + rtol=0, + atol=0.01, + ) + np.testing.assert_allclose( + simulation.calculate("esa_income", YEAR), + [2_984, 2_792], + rtol=0, + atol=0.01, + ) + + scoped_awards = [] + + def read_report_contract(benunit, period, award, report, award_on_reports, couple): + scoped = award_of_members( + benunit, period, award, report, award_on_reports, couple + ) + scoped_awards.append(scoped.copy()) + return scoped + + specified = "is_housing_benefit_benefit_cap_exempt_specified_benefit" + reader = simulation.tax_benefit_system.get_variable(specified).get_formula(YEAR) + monkeypatch.setitem(reader.__globals__, "award_of_couple", read_report_contract) + np.testing.assert_array_equal(simulation.calculate(specified, YEAR), [True, False]) + np.testing.assert_allclose(scoped_awards[0], [192, 0], rtol=0, atol=0.01) + caps = simulation.calculate("housing_benefit_benefit_cap", YEAR) + assert math.isinf(caps[0]) and math.isfinite(caps[1]) and caps[1] > 0 + reductions = simulation.calculate("housing_benefit_benefit_cap_reduction", YEAR) + assert reductions[0] == 0 + assert math.isfinite(reductions[1]) and reductions[1] > 0 diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index 330a9ade55..cf966e9929 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -66,8 +66,10 @@ def award_of_members(benunit, period, award, report, award_on_reports, members): """The part of the benefit-unit award ``award`` that ``members`` claim. ``report`` is the person-level report behind the award and - ``award_on_reports(benunit, period, reported)`` the award the formula of - ``award`` gives on a reported amount. Which awards ``award`` holds is + ``award_on_reports(benunit, period, reported, members=None)`` the award the formula of + ``award`` gives on unsummed open person reports. Its optional mask selects + reports while preserving the original claimant/partner roles. Which awards + ``award`` holds is decided by value: when it holds the award its formula gives on all reported awards, this is the same award on the reports of ``members``; when it holds the plain total of the reports (as the @@ -92,10 +94,8 @@ def award_of_members(benunit, period, award, report, award_on_reports, members): # Formula awards omit reports whose own award has closed. Keep the raw # totals above for disable_simulated_benefits and direct-value provenance. open_reported = reported * ~person(REPORT_CLOSED[award], period) - award_on_all_reports = award_on_reports(benunit, period, benunit.sum(open_reported)) - award_on_members_reports = award_on_reports( - benunit, period, benunit.sum(open_reported * members) - ) + award_on_all_reports = award_on_reports(benunit, period, open_reported) + award_on_members_reports = award_on_reports(benunit, period, open_reported, members) # Compare in the precision the award is stored in (float32), so the # formula's own award always matches the award recomputed here. stored = stored_award.dtype @@ -119,8 +119,9 @@ def claimant_or_partner_award(benunit, period, award, reported, award_on_reports ``award`` names the stored award (esa_income or jsa_income), which covers every member of the benefit unit, and ``reported`` the person-level reported amount it is built from. ``award_on_reports(benunit, period, - amount)`` is the award paid on a reported amount: the benefit's screen - and tariff income (income_related_esa_award, income_related_jsa_award). + reports, members=None)`` applies the formula to open person reports: + one claim for the original couple and a separate claim for each outside + reporter, with each claim's existing work/capital screen and tariff. This is award_of_members for the claimant and partner, so which awards the stored value holds is decided by its value, compared in the diff --git a/policyengine_uk/variables/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19_receives_uc_or_esa_in_own_right.py b/policyengine_uk/variables/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19_receives_uc_or_esa_in_own_right.py index bd266d6e46..dac95f8dec 100644 --- a/policyengine_uk/variables/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19_receives_uc_or_esa_in_own_right.py +++ b/policyengine_uk/variables/gov/dfe/bursary_fund_16_to_19/bursary_fund_16_to_19_receives_uc_or_esa_in_own_right.py @@ -6,16 +6,19 @@ class bursary_fund_16_to_19_receives_uc_or_esa_in_own_right(Variable): entity = Person label = "Receives UC or ESA in own right for 16 to 19 Bursary Fund" documentation = ( - "Whether the student receives Universal Credit or Employment and Support Allowance " - "in their own right for 16 to 19 Bursary Fund purposes. This can be set explicitly " - "in simulations; by default it uses person-level reported benefit proxies." + "Whether the student receives UC or ESA in their own right. This can " + "be entered explicitly. By default, income-related ESA uses the " + "person's assessed payable status, contributory ESA its own value " + "(itself a reported-benefit proxy), and UC retains the person-level " + "reported-benefit proxy. This signal does not decide age, education " + "or the institution's assessment of bursary support." ) definition_period = YEAR set_input = set_input_dispatch_by_period def formula(person, period, parameters): - return (person("universal_credit_reported", period) > 0) | ( - person("esa_income_reported", period) - + person("esa_contrib_reported", period) - > 0 + return ( + (person("universal_credit_reported", period) > 0) + | person("is_on_income_related_esa", period) + | (person("esa_contrib", period) > 0) ) diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py b/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py index c4d1aef33a..dc0af1081b 100644 --- a/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py @@ -1,8 +1,12 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.benefit_cap import benefit_cap_couple from policyengine_uk.utils.benefit_unit import award_of_couple -from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award -from policyengine_uk.variables.gov.dwp.jsa_income import income_related_jsa_award +from policyengine_uk.variables.gov.dwp.esa_income import ( + income_related_esa_award_on_reports, +) +from policyengine_uk.variables.gov.dwp.jsa_income import ( + income_related_jsa_award_on_reports, +) # Welfare benefits (Welfare Reform Act 2012 s. 96(10)) the benefit cap counts. # The cap counts those "to which the single person or couple is entitled" (UC @@ -83,14 +87,14 @@ def formula(benunit, period, parameters): period, "jsa_income", "jsa_income_reported", - income_related_jsa_award, + income_related_jsa_award_on_reports, couple, ) + award_of_couple( benunit, period, "esa_income", "esa_income_reported", - income_related_esa_award, + income_related_esa_award_on_reports, couple, ) return income_based + add_for_members( diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py index bda90a24b7..19b3c34ccf 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_esa_income.py @@ -1,6 +1,8 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.benefit_unit import claimant_or_partner_award -from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award +from policyengine_uk.variables.gov.dwp.esa_income import ( + income_related_esa_award_on_reports, +) class claimant_or_partner_esa_income(Variable): @@ -46,5 +48,5 @@ def formula(benunit, period, parameters): period, "esa_income", "esa_income_reported", - income_related_esa_award, + income_related_esa_award_on_reports, ) diff --git a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py index 7571840388..b2337ba52e 100644 --- a/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/claimant_or_partner_jsa_income.py @@ -1,6 +1,8 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.benefit_unit import claimant_or_partner_award -from policyengine_uk.variables.gov.dwp.jsa_income import income_related_jsa_award +from policyengine_uk.variables.gov.dwp.jsa_income import ( + income_related_jsa_award_on_reports, +) class claimant_or_partner_jsa_income(Variable): @@ -46,5 +48,5 @@ def formula(benunit, period, parameters): period, "jsa_income", "jsa_income_reported", - income_related_jsa_award, + income_related_jsa_award_on_reports, ) diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index e382b53e47..f8bd1ff885 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -10,6 +10,32 @@ def income_related_esa_award(benunit, period, reported_award): return where(eligible, max_(0, reported_award - tariff_income), 0) +def income_related_esa_award_on_reports(benunit, period, reported, members=None): + """Screen open person reports, retaining each original claim's ownership. + + The claimant and partner share one claim. Other reporters have separate + claims, each using the existing own-work and shared-capital/tariff proxy. + Selecting members changes only reports, never the original couple or + the work/capital screen. This helper must not read payable-person status, + which itself depends on the aggregate award. + """ + person = benunit.members + rules = benunit.simulation.tax_benefit_system.parameters(period).gov.dwp.ESA + if members is not None: + reported = reported * members + couple = person("is_claimant_or_partner", period) + couple_award = income_related_esa_award( + benunit, period, benunit.sum(reported * couple) + ) + works = person("esa_income_claimant_remunerative_work", period) + capital = benunit("esa_income_assessable_capital", period) + limit = rules.income.capital.limit + own_claim = ~couple & ~works & benunit.project(capital <= limit) + tariff = benunit.project(benunit("esa_income_tariff_income", period)) + own_awards = where(own_claim, max_(0, reported - tariff), 0) + return couple_award + benunit.sum(own_awards) + + class esa_income(Variable): value_type = float entity = BenUnit @@ -27,8 +53,7 @@ class esa_income(Variable): def formula(benunit, period, parameters): person = benunit.members - reported_award = benunit.sum( - person("esa_income_reported", period) - * ~person("esa_income_report_closed", period) + reported_award = person("esa_income_reported", period) * ~person( + "esa_income_report_closed", period ) - return income_related_esa_award(benunit, period, reported_award) + return income_related_esa_award_on_reports(benunit, period, reported_award) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 690416d60e..22f9c6b4b6 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -8,29 +8,23 @@ class esa_income_eligible(Variable): "Whether a reported income-related ESA award passes the capital and work tests" ) documentation = ( - "Bounded screen applied to reported income-related ESA awards: the " - "capital test (Welfare Reform Act 2007 Sch 1 para 6(1)(b); ESA Regs " - "2008 reg 110) and the remunerative work conditions. The claimant " - "must not be engaged in remunerative work (para 6(1)(e); " - "esa_income_claimant_remunerative_work), and must not be a member of " - "a couple the other member of which is (para 6(1)(f)): paid work of " - "24 hours a week or more (reg 42(1)), unless the partner is a carer " - "within IS Regs 1987 Sch 1B para 4 (reg 43(2)(c)), which the model " - "reads as is_carer_for_benefits. The claimant is a member who " - "reports the award, tested against the other member of the couple. " - "When the claimant or partner reports one, only they are candidates: " - "a member outside the family, such as a non-dependent adult, claims " - "in their own right, and is tested on their own work only when " - "neither the claimant nor the partner reports an award. This one " - "screen decides the benefit unit's award (esa_income) on every report " - "in it, so when the claimant or partner reports, another member's " - "report is paid or not with their claim. Whether that member is on " - "the award themselves (is_on_income_related_esa) is tested on their " - "own claim. Reports whose award has ended are left out before " - "choosing candidates (esa_income_report_closed): the owner's legacy " - "benefits have closed, or the owner is the claimant or partner of a " - "family that claims Universal Credit. This is not a full " - "entitlement model." + "Bounded work/capital screen for reported income-related ESA, not a full " + "entitlement model. When an original claimant or partner reports an open " + "award, only they are candidate claimants; otherwise other open reporters " + "are candidates. The claimant must not engage in remunerative work (Welfare " + "Reform Act 2007 Sch 1 para 6(1)(e); " + "esa_income_claimant_remunerative_work). The other member of the couple " + "must not engage in remunerative work (para 6(1)(f)): paid work of 24 hours " + "a week or more (ESA Regs 2008 reg 42(1)), subject to the existing carer " + "exception (reg 43(2)(c); IS Regs 1987 Sch 1B para 4), which the model " + "reads as is_carer_for_benefits. Assessable capital must remain within the " + "existing limit (para 6(1)(b); reg 110). The aggregate esa_income uses this " + "screen for its original couple's part; each outside reporter's claim is " + "paid separately on their own existing work/capital screen and tariff. The " + "benefit unit's capital is an explicit proxy for unobserved outside-claim " + "capital. Reports whose own award is closed are excluded before choosing " + "candidates; ESA also excludes the couple's reports on a UC claim. This " + "variable's candidate rules are unchanged by the separate aggregation." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py index 6ebc6992ed..2481b490be 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py @@ -3,7 +3,9 @@ from policyengine_uk.variables.gov.dwp._legacy_award_payee import ( is_payee_of_couple_award, ) -from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award +from policyengine_uk.variables.gov.dwp.esa_income import ( + income_related_esa_award_on_reports, +) # HB Regs 2006 reg 75F(1) names, for each benefit, whose receipt (or # entitlement) lifts the cap. @@ -112,7 +114,7 @@ def received_by(benefits, members): period, "esa_income", "esa_income_reported", - income_related_esa_award, + income_related_esa_award_on_reports, claimant_or_partner, ) receiving_income_related_esa = benunit.project( diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py index 1b701f64a5..318ee8f10c 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_based_jsa.py @@ -30,7 +30,8 @@ class is_on_income_based_jsa(Variable): "(person_legacy_benefits_closed), and the benefit unit's modelled award must " "be in payment: positive, or nil while jsa_income_eligible fails and " "jsa_income is not neutralised. On the formula path that nil comes only " - "from the claimant's or partner's own claim failing the screen. " + "from the retained value-based override convention. Formula totals " + "include each outside member's separately screened own award. " "Neutralising jsa_income removes the status; a nil jsa_income entered " "directly, or a reform that replaces it with nil, removes it only while " "the screen passes." diff --git a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py index cd3c0173d5..dde494cf3b 100644 --- a/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py +++ b/policyengine_uk/variables/gov/dwp/is_on_income_related_esa.py @@ -29,7 +29,8 @@ class is_on_income_related_esa(Variable): "(person_legacy_benefits_closed), and the benefit unit's modelled award must " "be in payment: positive, or nil while esa_income_eligible fails and " "esa_income is not neutralised. On the formula path that nil comes only " - "from the claimant's or partner's own claim failing the screen. " + "from the retained value-based override convention. Formula totals " + "include each outside member's separately screened own award. " "Neutralising esa_income removes the status; a nil esa_income entered " "directly, or a reform that replaces it with nil, removes it only while " "the screen passes." diff --git a/policyengine_uk/variables/gov/dwp/jsa_income.py b/policyengine_uk/variables/gov/dwp/jsa_income.py index 34a3c0e2a5..26f6eefd43 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income.py @@ -13,6 +13,37 @@ def income_related_jsa_award(benunit, period, reported_award): return where(eligible, max_(0, reported_award - tariff_income), 0) +def income_related_jsa_award_on_reports(benunit, period, reported, members=None): + """Screen open person reports, retaining each original claim's ownership. + + The claimant and partner share one claim. Other reporters have separate + claims, each using the existing own-work and shared-capital/tariff proxy. + Selecting members changes only reports, never the original couple or + the work/capital screen. This helper must not read payable-person status, + which itself depends on the aggregate award. + """ + person = benunit.members + rules = benunit.simulation.tax_benefit_system.parameters(period).gov.dwp.JSA + if not rules.income.active: + return benunit.empty_array() + if members is not None: + reported = reported * members + couple = person("is_claimant_or_partner", period) + couple_award = income_related_jsa_award( + benunit, period, benunit.sum(reported * couple) + ) + works = ( + person("jsa_remunerative_work_hours", period) + >= rules.remunerative_work.claimant_hours + ) + capital = benunit("jsa_income_assessable_capital", period) + limit = rules.income.capital.limit + own_claim = ~couple & ~works & benunit.project(capital <= limit) + tariff = benunit.project(benunit("jsa_income_tariff_income", period)) + own_awards = where(own_claim, max_(0, reported - tariff), 0) + return couple_award + benunit.sum(own_awards) + + class jsa_income(Variable): value_type = float entity = BenUnit @@ -29,8 +60,7 @@ def formula(benunit, period, parameters): if not parameters(period).gov.dwp.JSA.income.active: return benunit.empty_array() person = benunit.members - reported_award = benunit.sum( - person("jsa_income_reported", period) - * ~person("person_legacy_benefits_closed", period) + reported_award = person("jsa_income_reported", period) * ~person( + "person_legacy_benefits_closed", period ) - return income_related_jsa_award(benunit, period, reported_award) + return income_related_jsa_award_on_reports(benunit, period, reported_award) diff --git a/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py b/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py index 8d26015795..94b1ebdc20 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_eligible.py @@ -8,31 +8,23 @@ class jsa_income_eligible(Variable): "Whether a reported income-based JSA award passes the capital and work tests" ) documentation = ( - "Bounded screen applied to reported income-based JSA awards: the " - "capital test (Jobseekers Act 1995 s.13(1) and (2A); JSA Regs 1996 " - "reg 107) and the remunerative work conditions. The claimant must not " - "be engaged in remunerative work, paid work of 16 hours a week or " - "more (s.1(2)(e); reg 51(1)(a)), and the claimant's partner must not " - "work 24 hours or more (s.3(1)(e); reg 51(1)(b)). A couple without " - "children of the prescribed description claim jointly, and each of " - "them is then a claimant held to 16 hours (s.1(2B)(b), s.35(1)). But " - "a member of a joint-claim couple may claim alone when the other " - "works 16 hours or more but less than 24 (reg 3E(1) and (2)(g), since " - "joint claims began on 19 March 2001), and is then tested under s.3, " - "so an award survives in either case unless the other member works " - "24 hours or more. The JSA regulations do not take a carer out of " - "remunerative work for unrelated paid work. The claimant is a member " - "who reports the award. When the claimant or partner reports one, " - "only they are candidates: a member outside the family, such as a " - "non-dependent adult, claims in their own right, and is tested on " - "their own work only when neither the claimant nor the partner " - "reports an award. This one screen decides the benefit unit's award " - "(jsa_income) on every report in it, so when the claimant or partner " - "reports, another member's report is paid or not with their claim. " - "Whether that member is on the award themselves " - "(is_on_income_based_jsa) is tested on their own claim. The award " - "excludes reports whose owner has closed legacy benefits before choosing " - "candidates (person_legacy_benefits_closed). This is not a full entitlement model." + "Bounded work/capital screen for reported income-based JSA, not a full " + "entitlement model. When an original claimant or partner reports an open " + "award, only they are candidate claimants; otherwise other open reporters " + "are candidates. The reporting claimant must work less than 16 hours a week " + "(Jobseekers Act 1995 s.1(2)(e); JSA Regs 1996 reg 51(1)(a)), and the other " + "member of the couple less than 24 hours (s.3(1)(e); reg 51(1)(b)). Each " + "member of a joint-claim couple is tested as a claimant (s.1(2B)(b); " + "s.35(1)), but reg 3E(1) and (2)(g) permits one member to claim alone when " + "the other works 16 hours or more but less than 24, subject to s.3. " + "Assessable capital must remain within the existing limit (s.13(1) and " + "(2A); reg 107). The aggregate jsa_income uses this screen for its original " + "couple's part; each outside reporter's claim is paid separately on their " + "own existing work/capital screen and tariff. The benefit unit's capital is " + "an explicit proxy for unobserved outside-claim capital. Reports whose " + "owner has closed legacy benefits are excluded before choosing candidates " + "(person_legacy_benefits_closed). This variable's candidate rules are " + "unchanged by the separate aggregation." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py index a6acb7f364..d752aad48f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_specified_benefit.py @@ -9,7 +9,9 @@ from policyengine_uk.variables.gov.dwp._legacy_award_payee import ( is_payee_of_couple_award, ) -from policyengine_uk.variables.gov.dwp.esa_income import income_related_esa_award +from policyengine_uk.variables.gov.dwp.esa_income import ( + income_related_esa_award_on_reports, +) from policyengine_uk.variables.gov.dwp.universal_credit.carer_element.uc_carer_element import ( uc_carer_element, ) @@ -144,7 +146,7 @@ def received_by(benefits, members): period, "esa_income", "esa_income_reported", - income_related_esa_award, + income_related_esa_award_on_reports, couple, ) receiving_income_related_esa = benunit.project(