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
1 change: 1 addition & 0 deletions changelog.d/hub-review-followups-2026-10-25.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Corrected comments, docstrings and citations from reviews of recently merged pull requests, and added property tests for claimant roles and the Childcare Offer for Wales income sources.
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ metadata:
href: https://www.legislation.gov.uk/uksi/2006/213/regulation/6
- title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 6(1)
href: https://www.legislation.gov.uk/uksi/2006/214/regulation/6
- title: The Housing Benefit Regulations (Northern Ireland) 2006 reg. 6(1)
href: https://www.legislation.gov.uk/nisr/2006/405/regulation/6
6 changes: 3 additions & 3 deletions policyengine_uk/parameters/gov/economic_assumptions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,9 +237,9 @@ and so do the Universal Credit standard allowance and health element indices
built from it. Housing Benefit non-dependant deduction income bands, DfE
funding rates, the Childcare Offer for Wales hourly rate and the council tax
high value surcharge follow the lagged calendar-year CPI projection through
`indices.obr.lagged_cpi`, pending their own uprating models. The deduction amounts continue to follow September CPI.
The savings credit threshold and Housing
Benefit pension-age allowances are within the s150 review, under
`indices.obr.lagged_cpi`, pending their own uprating models. The deduction
amounts continue to follow September CPI. The savings credit threshold and
Housing Benefit pension-age allowances are within the s150 review, under
[s150(1)(l) and (h)](https://www.legislation.gov.uk/ukpga/1992/5/section/150),
even though their increases in practice are linked to earnings rather than
September CPI.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,9 @@ values:
# Maternity Grant, lump_sum_income) or to the household (cost of living
# payments, the energy rebate, bill credits), because question 10 works out
# annual income from regular pay and the bonuses a parent expects, read here
# as a test of recurring income (question 9's "all payments" points the
# other way); and loans, which are repaid (student_loans).
# as a test of recurring income (question 8's current-tax-year test and
# question 9's "all payments" point the other way); and loans, which are
# repaid (student_loans).
#
# Data limits, immaterial near £100,000: for a Welsh student the FRS
# education_grants residual can still hold Childcare Grant and Disabled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# A non-worker or 15-hour worker with banding income pays 20.40 * 52 =
# 1,060.80 in 2026-27. One with no banding income keeps the scale's zero:
# the gate only lowers a deduction until #2007 models the exemptions in
# reg 74(6)-(10) and reg 55(6)-(9) with the flat charge.
# reg 74(6), (7), (8) and (10) and reg 55(6)-(9) with the flat charge.
# A 16-hour worker's wages of 9,000 alone are below 192/week; adding
# IIDB or child maintenance of 12,000 gives 403.85/week, so 105.20 * 52.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# Annual model periods use the fiscal-year parameter amounts.

- name: Scottish Child Payment projects to 29.20 a week in April 2027
# 28.20 x 1.035 = 29.187 -> 29.20, then 29.20 x 365.25 / 7 = 1523.614286.
# 28.20 x 1.035 = 29.187 -> 29.20, then 29.20 x 52 = 1518.40.
period: 2027
absolute_error_margin: 0.01
input:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -484,8 +484,8 @@
wales_childcare_offer: 0

# current_education's default formula puts an 18- or 19-year-old in tertiary
# education when nothing is supplied, so a parent of that age
# counts as a parent in education (guidance para 71). Survey data supplies
# education when nothing is supplied, so a parent of that age counts as a
# parent in education (guidance para 71). Survey data supplies
# current_education, so this default matters only for household calculations.
- name: A 19-year-old lone parent with no education input counts as in education by default
period: 2026
Expand Down
129 changes: 116 additions & 13 deletions policyengine_uk/tests/test_child_and_adult_definitions_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@
claimant's child either way (the any-age gap).
Before/after families share one Simulation to avoid repeated
model construction, but occupy distinct households and benefit units.
5. Supplied claimant/partner role vectors, valid or not, are returned
unchanged on any benefit unit. Widening an unflagged member's gap below
the claimant, with the claimant, flags, other members and HBAI adulthood
fixed, never makes a member the presumption excludes the partner.
Identified parents take priority over the presumption; no other presumed
child is a claimant or partner. The two-other-parents exception to the
head being the claimant is kept.

Scope: these are tests of the ENCODED statutory limbs, not full entitlement.
Education status proxies full-time non-advanced study: hours, employment
Expand Down Expand Up @@ -249,33 +256,44 @@ def claimants_or_partners(family, any_age_gap=20):
# couple instead. Age ties go to the earlier member. any_age_gap=None is
# the rule without the any-age gap, kept for the differential test.
n = len(family)
adult = [not hbai_dependent_child(p, family) for p in family]
ages = [p["age"] for p in family]
heads = [i for i in range(n) if family[i]["is_benunit_head"] and adult[i]]
adults = [i for i in range(n) if adult[i]]
if not adults:
claimant, adult, presumed = presumption(family, any_age_gap)
if claimant is None:
return [False] * n
pool = heads or adults
claimant = max(pool, key=lambda i: (ages[i], -i))
ages = [p["age"] for p in family]
parent = [adult[i] and family[i]["is_parent"] for i in range(n)]
other_parents = [i for i in range(n) if parent[i] and i != claimant]
if len(other_parents) >= 2 and not parent[claimant]:
couple = sorted(other_parents, key=lambda i: (-ages[i], i))[:2]
return [i in couple for i in range(n)]
pool = other_parents or [
i for i in range(n) if adult[i] and i != claimant and not presumed[i]
]
partner = max(pool, key=lambda i: (ages[i], -i)) if pool else None
return [i == claimant or i == partner for i in range(n)]


def presumption(family, any_age_gap=20):
# The reference claimant (None without an HBAI adult), HBAI adulthood and
# the members presumed to be the claimant's child, before identified
# parents take priority.
n = len(family)
adult = [not hbai_dependent_child(p, family) for p in family]
ages = [p["age"] for p in family]
heads = [i for i in range(n) if family[i]["is_benunit_head"] and adult[i]]
adults = [i for i in range(n) if adult[i]]
if not adults:
return None, adult, [False] * n
claimant = max(heads or adults, key=lambda i: (ages[i], -i))
young_child = any(ages[j] < 20 and ages[claimant] - ages[j] >= 16 for j in range(n))
flag_unexplained = parent[claimant] and not young_child
flag_unexplained = family[claimant]["is_parent"] and not young_child

def presumed_child(i):
gap = ages[claimant] - ages[i]
if any_age_gap is not None and gap >= any_age_gap:
return True
return (ages[i] < 20 or flag_unexplained) and gap >= 16

pool = other_parents or [
i for i in adults if i != claimant and not presumed_child(i)
]
partner = max(pool, key=lambda i: (ages[i], -i)) if pool else None
return [i == claimant or i == partner for i in range(n)]
return claimant, adult, [presumed_child(i) for i in range(n)]


def legacy_child_or_young_person(p, claimant):
Expand Down Expand Up @@ -992,3 +1010,88 @@ def test_wide_gap_couples_by_head_order_and_with_supplied_roles():
supplied = simulate(families)
supplied.set_input("is_claimant_or_partner", YEAR, [True] * 4)
np.testing.assert_array_equal(supplied.calculate("is_couple", YEAR), [True] * 2)


@PROPERTY_SETTINGS
@given(arbitrary_benefit_units(), st.data())
def test_supplied_roles_are_returned_unchanged(families, data):
# Any Boolean role vector, valid or not, on any benefit unit: a supplied
# is_claimant_or_partner is an input, so the model never recomputes it.
size = sum(map(len, families))
roles = data.draw(st.lists(st.booleans(), min_size=size, max_size=size))
sim = simulate(families)
sim.set_input("is_claimant_or_partner", YEAR, roles)
assert_values(sim, "is_claimant_or_partner", roles)


@st.composite
def widened_gap_pairs(draw):
# An adult head (the claimant), flagged or not, an unflagged member aged
# 20+ at least 16 years younger, and any other members. The pair is the
# unit before and after the member is made younger, holding everything
# else fixed.
pairs = []
for _ in range(draw(st.integers(1, 8))):
claimant_age = draw(st.integers(36, 90))
family = [
person(claimant_age, is_parent=draw(st.booleans()), is_benunit_head=True)
]
for _ in range(draw(st.integers(0, 3))):
family.append(
person(
draw(st.integers(0, claimant_age)),
current_education=draw(st.sampled_from(EDUCATIONS)),
is_in_approved_training=draw(st.booleans()),
is_parent=draw(st.booleans()),
)
)
j = draw(st.integers(1, len(family)))
age = draw(st.integers(20, claimant_age - 16))
family.insert(j, person(age))
younger = [dict(p) for p in family]
younger[j]["age"] = draw(st.integers(0, age - 1))
pairs.append((family, younger, j))
return pairs


@PROPERTY_SETTINGS
@given(widened_gap_pairs())
def test_a_wider_gap_never_makes_a_presumed_child_the_partner(pairs):
# Metamorphic: an unflagged member made younger, so their gap below the
# claimant widens, with the claimant, every flag, every other member and
# everyone's HBAI adulthood fixed. A member the presumption excludes from
# the partner pool stays excluded, so never becomes the partner. The
# two-other-parents exception is not asserted away: it never selects an
# unflagged member either.
cases = []
for family, younger, j in pairs:
before, after = presumption(family), presumption(younger)
held = before[0] == after[0] == 0 and before[1] == after[1]
if held and before[2][j]:
cases.append((family, younger, j))
if not cases:
return
combined = [unit for family, younger, _ in cases for unit in (family, younger)]
roles = simulate(combined).calculate("is_claimant_or_partner", YEAR)
offset = 0
for family, younger, j in cases:
assert presumption(younger)[2][j]
assert not roles[offset + j], family
assert not roles[offset + len(family) + j], younger
offset += 2 * len(family)


@PROPERTY_SETTINGS
@given(arbitrary_benefit_units())
def test_effective_children_and_partners_are_disjoint(families):
# An identified parent (an HBAI adult flagged is_parent) takes priority
# over the presumption. Every other presumed child is an effective child,
# and no effective child is ever the partner (nor the claimant).
roles = simulate(families).calculate("is_claimant_or_partner", YEAR)
offset = 0
for family in families:
_, adult, presumed = presumption(family)
for i, p in enumerate(family):
effective_child = presumed[i] and not (adult[i] and p["is_parent"])
assert not (effective_child and roles[offset + i]), family
offset += len(family)
Original file line number Diff line number Diff line change
Expand Up @@ -370,8 +370,9 @@ def test_housing_benefit_disregard_is_the_statutory_weekly_sum_in_every_year(

def test_disabled_working_age_single_gets_the_special_hb_disregard():
# The property above reads special eligibility from the model, so pin it
# here. With no hours the additional sum cannot apply, and tax credits
# have ended by 2026, so the working tax credit route is off.
# here. A disabled working-age single has the disability premium, which
# gives the £20 (SI 2006/213 Sch 4 para 3); with no hours the additional
# sum cannot apply.
unit = dict(
shape="single",
nation="ENGLAND",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
the model's own liability variables, so it does not depend on the formulas
under test.

Invariants, for any pay, salary sacrifice, age, statutory sick pay and
employer pension contribution:
Invariants, for any pay, salary sacrifice, age, statutory sick pay, employer
pension contribution, full-year Category A director status and apprentice
status:

1. Difference of complete liabilities: the employee measure equals
ni_class_1_employee with the excess less ni_class_1_employee
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,9 @@ def test_income_tax_never_falls_as_statutory_paternity_pay_rises(population):
)
+ results.family_total("CB_HITC", i, LOW)
)
# This margin relies on even-pound base incomes: PAY's
# 388.64 -> 389 pair cannot cross a personal-allowance
# taper-rounding step.
assert gain <= family["high"] - family["low"] + TOLERANCE, family


Expand Down
19 changes: 17 additions & 2 deletions policyengine_uk/tests/test_wales_childcare_offer_income_sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
would just lose its case; the lists are also pinned to the reviewed sources
below, so a removal fails here too. Each source is also checked not to add
another listed source (through adds or subtracts, at any depth), which would
count it twice. That check does not see a formula that reads another listed
source.
count it twice, and the sources are checked to expand to disjoint variables.
Those checks do not see a formula that reads another listed source.
"""

from pathlib import Path
Expand Down Expand Up @@ -156,3 +156,18 @@ def test_each_list_holds_the_reviewed_sources_once(name):
def test_no_listed_source_adds_another(entity, source):
listed = {other for _, other in CASES}
assert not components(source) & listed


def test_listed_sources_expand_to_disjoint_variables():
# Each source's closure is the source and every variable it adds or
# subtracts, at any depth. Overlapping closures would count the shared
# income twice, even where neither source adds the other.
closures = {source: {source} | components(source) for _, source in CASES}
overlaps = {
(first, second): sorted(closures[first] & closures[second])
for i, first in enumerate(closures)
for second in list(closures)[i + 1 :]
if closures[first] & closures[second]
}
assert len(closures) == 40
assert not overlaps, overlaps
6 changes: 3 additions & 3 deletions policyengine_uk/utils/pensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ def relevant_uk_earnings(person, period, hmrc):
"""FA 2004 s.189(2) relevant UK earnings.

Employment income chargeable to income tax is net taxable earnings
(ITEPA 2003 ss.9(2) and 11): pay and employer-paid taxable SSP/SMP, which
the model holds separately from wages, less the allowable expenses in
s.327(3). s.327(5) also lists the deductions for pension contributions
(ITEPA 2003 ss.9(2) and 11): pay and employer-paid taxable SSP, SMP and
SPP (employment_benefits), which the model holds separately from wages,
less the allowable expenses in s.327(3). s.327(5) also lists the deductions for pension contributions
(FA 2004 ss.188-194), but those are the relief that these earnings limit,
so they are not taken off; the limit would otherwise depend on itself.
Trading income is the profit chargeable under ITTOIA 2005 Part 2, before
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ class housing_benefit_applicable_income(Variable):
"https://www.legislation.gov.uk/uksi/2006/214/regulation/23",
"https://www.legislation.gov.uk/uksi/2006/214/regulation/29",
"https://www.legislation.gov.uk/nisr/2006/405/regulation/22",
"https://www.legislation.gov.uk/nisr/2006/405/schedule/6/paragraph/64",
"https://www.legislation.gov.uk/nisr/2009/276/regulation/3/made",
"https://www.legislation.gov.uk/nisr/2006/405/schedule/6/paragraph/32",
"https://www.legislation.gov.uk/nisr/2006/406/regulation/21",
"https://www.legislation.gov.uk/nisr/2006/406/regulation/27",
Expand All @@ -147,7 +147,6 @@ class housing_benefit_applicable_income(Variable):
"https://www.legislation.gov.uk/uksi/2006/213/regulation/40",
"https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/16",
"https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/47",
"https://www.legislation.gov.uk/uksi/2006/214/regulation/29",
"https://www.legislation.gov.uk/uksi/2006/214/regulation/26",
"https://www.legislation.gov.uk/uksi/2006/214/regulation/27",
"https://www.legislation.gov.uk/nisr/2006/406/regulation/24",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ class housing_benefit_applicable_income_disregard(Variable):
def formula(benunit, period, parameters):
p = parameters(period).gov.dwp.housing_benefit.means_test.income_disregard
net_earnings = benunit("housing_benefit_net_earnings", period)
# Working age Sch 4 paras 4, 7 and 10; pension age Sch 4 paras 2 and 7.
# Working age Sch 4 paras 3, 4, 7 and 10; pension age Sch 4 paras 2,
# 4, 5 and 7. Other full-amount £20 routes are pre-assessed
# (housing_benefit_special_earnings_disregard_eligible).
standard = benunit("housing_benefit_special_earnings_disregard", period)
# Working age Sch 4 para 18 (NI Sch 5 para 18), from 5 October 2026:
# specified or temporary accommodation. It applies alongside paras 3
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

class housing_benefit_special_earnings_disregard_eligible(Variable):
"""Implement the working-age premium route and the modelled pension-age
paragraph 5 qualifying routes.
carer (paragraph 4) and paragraph 5 qualifying routes.

GB SI 2006/214 Sch. 4 and NI SR 2006/406 Sch. 5 para. 5(1)(a)
list the following receipts implemented here:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ def formula(person, period, parameters):
# instead of an income band. Reg 6(6) excludes income-related
# recipients from remunerative work. The gate only lowers a
# deduction: a non-worker with no banding income keeps the scale's
# zero, because the exemptions in reg 74(6)-(10) and reg 55(6)-(9)
# (a claimant on Attendance Allowance; a non-dependant on Pension
# Credit, or under 25 on Income Support; a full-time student) are not
# modelled, and the income-related benefits are not banding income,
# so many such non-dependants have none.
# zero, because the exemptions in reg 74(6), (7), (8) and (10),
# and reg 55(6)-(9) (a claimant on Attendance Allowance; a
# non-dependant on Pension Credit, or under 25 on Income Support;
# a full-time student) are not modelled, and the income-related
# benefits are not banding income, so many such non-dependants
# have none.
# #2007 adds the exemptions and the flat charge together.
deduction = where(
in_remunerative_work,
Expand Down
Loading