Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/2263.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 42 additions & 0 deletions docs/engineering/esa-jsa-report-screening.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions policyengine_uk/tests/test_disable_simulated_benefits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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),
}


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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]
)
Expand Down
17 changes: 9 additions & 8 deletions policyengine_uk/tests/test_income_support_direct_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -383,28 +384,28 @@ 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
assert not eligible(penny)


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.
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)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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)


Expand Down
Loading
Loading