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/2338.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Counted the Scottish Child Payment, Healthy Start vouchers and the Two Child Limit Payment in government spending, so `gov_balance` no longer leaves out benefits that households receive.
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Every benefit that government pays a household is government spending.

household_benefits adds HOUSEHOLD_BENEFIT_VARIABLES to household net income.
gov_balance subtracts gov_spending, which adds GOV_SPENDING_VARIABLES. A
benefit in the first list but not the second raises household income at no
cost to the government, so gov_balance overstates the public finances by the
amount paid. The Scottish Child Payment, Healthy Start vouchers and the Two
Child Limit Payment were left out in that way.

Invariant: every entry of HOUSEHOLD_BENEFIT_VARIABLES is in
GOV_SPENDING_VARIABLES, or is in NOT_PAID_BY_GOVERNMENT below with the reason
government does not pay it.
"""

from policyengine_uk import CountryTaxBenefitSystem
from policyengine_uk.variables.gov.gov_spending import GOV_SPENDING_VARIABLES
from policyengine_uk.variables.household.income.household_benefits import (
HOUSEHOLD_BENEFIT_VARIABLES,
)

SYSTEM = CountryTaxBenefitSystem()

# Household benefits that government does not pay, each with the reason.
# gov_spending leaves these out. Empty: government pays every benefit in
# HOUSEHOLD_BENEFIT_VARIABLES. Add an entry only for a benefit that someone
# other than government funds; a benefit government pays goes in
# GOV_SPENDING_VARIABLES instead.
NOT_PAID_BY_GOVERNMENT = {}

# The benefits this invariant found missing from government spending.
ADDED_TO_GOVERNMENT_SPENDING = [
"healthy_start_vouchers",
"scottish_child_payment",
"two_child_limit_payment",
]


def left_out_of_government_spending(benefits, spending, exclusions):
"""Household benefits that are neither government spending nor excluded."""
return sorted(set(benefits) - set(spending) - set(exclusions))


def test_every_household_benefit_is_government_spending_or_excluded():
left_out = left_out_of_government_spending(
HOUSEHOLD_BENEFIT_VARIABLES, GOV_SPENDING_VARIABLES, NOT_PAID_BY_GOVERNMENT
)
assert not left_out, (
f"household_benefits counts {left_out} but gov_spending does not. Add "
"each to GOV_SPENDING_VARIABLES, or to NOT_PAID_BY_GOVERNMENT with the "
"reason government does not pay it."
)


def test_exclusions_are_household_benefits_outside_government_spending():
for name, reason in NOT_PAID_BY_GOVERNMENT.items():
assert name in SYSTEM.variables, f"{name} is not a variable"
assert name in HOUSEHOLD_BENEFIT_VARIABLES, (
f"{name} is excluded but is not a household benefit"
)
assert name not in GOV_SPENDING_VARIABLES, (
f"{name} is excluded as not paid by government but is in gov_spending"
)
assert isinstance(reason, str) and reason.strip(), (
f"{name} needs a reason government does not pay it"
)


def test_the_check_reports_a_benefit_left_out_of_government_spending():
# The lists as they were before the three benefits were added.
before = [
name
for name in GOV_SPENDING_VARIABLES
if name not in ADDED_TO_GOVERNMENT_SPENDING
]
assert left_out_of_government_spending(
HOUSEHOLD_BENEFIT_VARIABLES, before, NOT_PAID_BY_GOVERNMENT
) == sorted(ADDED_TO_GOVERNMENT_SPENDING)
# An exclusion with a reason takes a benefit out of the report.
assert (
left_out_of_government_spending(
["paid_by_government", "paid_by_employer"],
["paid_by_government"],
{"paid_by_employer": "the employer pays it"},
)
== []
)
75 changes: 75 additions & 0 deletions policyengine_uk/tests/policy/baseline/gov/gov_spending.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Government spending counts every benefit that government pays a household.
# The Scottish Child Payment, Healthy Start vouchers and the Two Child Limit
# Payment were in household_benefits but not in gov_spending, so gov_balance
# left out their cost.
#
# 2026-27 rates: UC standard allowance 424.90 a month (single, 25 or over) and
# child element 303.94 a month; Child Benefit 27.05 a week for the eldest
# child; Scottish Child Payment 28.20 a week.

- name: Scottish Child Payment and Healthy Start vouchers are government spending
period: 2026
absolute_error_margin: 0.01
input:
people:
parent:
age: 30
child:
age: 3
# An amount the household reports; the model does not calculate it.
healthy_start_vouchers: 221
benunits:
benunit:
members: [parent, child]
households:
household:
members: [parent, child]
region: SCOTLAND
output:
# 28.20 x 52.
scottish_child_payment: [0, 1_466.40]
# 424.90 x 12 + 303.94 x 12.
universal_credit: 8_746.08
# 27.05 x 52.
child_benefit: 1_406.60
# 8,746.08 + 1,406.60 + 1,466.40 + 221.
household_benefits: 11_840.08
# The same four payments. Before this fix 10,152.68: without the
# Scottish Child Payment and the vouchers.
gov_spending: 11_840.08

- name: The Two Child Limit Payment is government spending when a reform turns it on
period: 2025
absolute_error_margin: 0.01
input:
gov.social_security_scotland.two_child_limit_payment.in_effect: true
people:
parent:
age: 30
child_1:
age: 5
uc_is_child_limit_affected: false
child_2:
age: 3
uc_is_child_limit_affected: false
child_3:
age: 1
uc_is_child_limit_affected: true
benunits:
benunit:
members: [parent, child_1, child_2, child_3]
# Entered directly, so that only the payments under test are computed.
universal_credit: 0
child_benefit: 0
households:
household:
members: [parent, child_1, child_2, child_3]
region: SCOTLAND
output:
# The UC child element for the one affected child: 292.81 x 12.
two_child_limit_payment: 3_513.72
# No Universal Credit, so no Scottish Child Payment.
scottish_child_payment: [0, 0, 0, 0]
household_benefits: 3_513.72
# Before this fix 0.
gov_spending: 3_513.72
221 changes: 221 additions & 0 deletions policyengine_uk/tests/test_gov_spending_properties.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
"""Property-based tests that government spending counts household benefits.

gov_spending adds every entry of GOV_SPENDING_VARIABLES and gov_balance is
gov_tax less gov_spending. household_benefits adds every entry of
HOUSEHOLD_BENEFIT_VARIABLES to household net income. The Scottish Child
Payment, Healthy Start vouchers and the Two Child Limit Payment were in the
second list but not the first, so households received them at no cost to the
government and gov_balance was overstated by the amount paid.

Invariants, for any generated population of households:

1. Sum identity: gov_spending equals the sum, over the distinct names in
GOV_SPENDING_VARIABLES, of each variable summed to the household.
2. Balance identity: gov_balance equals gov_tax less gov_spending.
3. Conservation: what households receive in benefits, less any benefit that
government does not pay (NOT_PAID_BY_GOVERNMENT), equals gov_spending less
the spending that is not a household benefit. So a pound of benefit from
government is a pound of government spending.
4. Metamorphic: paying a household d more of the Scottish Child Payment,
Healthy Start vouchers or the Two Child Limit Payment raises
household_benefits and gov_spending by d each, lowers gov_balance by d and
leaves gov_tax unchanged.

Invariants 3 and 4 fail on the earlier list whenever a household receives one
of the three payments.

The three payments are entered directly for every member, so the properties
test the lists and not the payments' own eligibility rules.
"""

import numpy as np
from hypothesis import HealthCheck, given, settings
from hypothesis import strategies as st

from policyengine_uk import Simulation
from policyengine_uk.tests.code_health.test_household_benefits_in_government_spending import (
NOT_PAID_BY_GOVERNMENT,
)
from policyengine_uk.variables.gov.gov_spending import GOV_SPENDING_VARIABLES
from policyengine_uk.variables.household.income.household_benefits import (
HOUSEHOLD_BENEFIT_VARIABLES,
)

YEAR = 2026
PROPERTY_SETTINGS = settings(
max_examples=10,
deadline=None,
derandomize=True,
suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large],
)
SPENDING = list(dict.fromkeys(GOV_SPENDING_VARIABLES))
BENEFITS = list(dict.fromkeys(HOUSEHOLD_BENEFIT_VARIABLES))
# Spending that is not a benefit a household receives.
NOT_HOUSEHOLD_BENEFITS = [name for name in SPENDING if name not in BENEFITS]
# The payments that were missing from government spending, with the entity
# each is paid to.
PAYMENTS = {
"scottish_child_payment": "child",
"healthy_start_vouchers": "child",
"two_child_limit_payment": "benunit",
}
REGIONS = ["SCOTLAND", "NORTH_EAST", "LONDON", "WALES", "NORTHERN_IRELAND"]
TENURES = ["RENT_FROM_COUNCIL", "RENT_PRIVATELY", "OWNED_OUTRIGHT"]
# Float32 household totals of tens of thousands of pounds are exact to about
# a penny.
TOLERANCE = 0.05

money = st.floats(0, 60_000, allow_nan=False, allow_infinity=False)
payment = st.one_of(st.just(0.0), st.floats(1, 4_000, allow_nan=False))


@st.composite
def households(draw):
return dict(
adults=[
dict(
age=draw(st.integers(18, 80)),
employment_income=draw(st.one_of(st.just(0.0), money)),
)
for _ in range(draw(st.integers(1, 2)))
],
children=[
dict(
age=draw(st.integers(0, 15)),
scottish_child_payment=draw(payment),
healthy_start_vouchers=draw(payment),
)
for _ in range(draw(st.integers(0, 3)))
],
two_child_limit_payment=draw(payment),
would_claim_uc=draw(st.booleans()),
region=draw(st.sampled_from(REGIONS)),
tenure=draw(st.sampled_from(TENURES)),
rent=draw(st.floats(0, 15_000, allow_nan=False)),
)


def situation(units, bumped=None, bump=0.0, start=0):
"""The households, paying `bump` more of the `bumped` payment.

A child payment rises for every child; the benefit unit payment rises
once. Households are numbered from `start`.
"""
people, benunits, homes = {}, {}, {}
for i, unit in enumerate(units, start):
names = []
for j, adult in enumerate(unit["adults"]):
name = f"a{i}_{j}"
people[name] = {
"age": {YEAR: adult["age"]},
"employment_income": {YEAR: adult["employment_income"]},
"scottish_child_payment": {YEAR: 0.0},
"healthy_start_vouchers": {YEAR: 0.0},
}
names.append(name)
for j, child in enumerate(unit["children"]):
name = f"c{i}_{j}"
people[name] = {
"age": {YEAR: child["age"]},
"employment_income": {YEAR: 0.0},
}
for paid in ["scottish_child_payment", "healthy_start_vouchers"]:
people[name][paid] = {
YEAR: child[paid] + (bump if paid == bumped else 0.0)
}
names.append(name)
benunits[f"b{i}"] = {
"members": names,
"would_claim_uc": {YEAR: unit["would_claim_uc"]},
"two_child_limit_payment": {
YEAR: unit["two_child_limit_payment"]
+ (bump if bumped == "two_child_limit_payment" else 0.0)
},
}
homes[f"h{i}"] = {
"members": names,
"region": {YEAR: unit["region"]},
"tenure_type": {YEAR: unit["tenure"]},
"rent": {YEAR: unit["rent"]},
}
return {"people": people, "benunits": benunits, "households": homes}


def calc(sim, name):
return np.asarray(sim.calculate(name, YEAR, map_to="household"), dtype=float)


def household_sum(sim, names):
return sum((calc(sim, name) for name in names), np.zeros(1))


@PROPERTY_SETTINGS
@given(st.lists(households(), min_size=1, max_size=12))
def test_government_spending_counts_every_benefit_government_pays(units):
sim = Simulation(situation=situation(units))
spending = calc(sim, "gov_spending")
# Invariant 1.
np.testing.assert_allclose(
spending, household_sum(sim, SPENDING), atol=TOLERANCE, rtol=1e-6
)
# Invariant 2.
np.testing.assert_allclose(
calc(sim, "gov_balance"),
calc(sim, "gov_tax") - spending,
atol=TOLERANCE,
rtol=1e-6,
)
# Invariant 3.
np.testing.assert_allclose(
calc(sim, "household_benefits")
- household_sum(sim, list(NOT_PAID_BY_GOVERNMENT)),
spending - household_sum(sim, NOT_HOUSEHOLD_BENEFITS),
atol=TOLERANCE,
rtol=1e-6,
)
# The payments under test are in the population, so invariant 3 can fail.
entered = sum(
child[name]
for unit in units
for child in unit["children"]
for name in ["scottish_child_payment", "healthy_start_vouchers"]
) + sum(unit["two_child_limit_payment"] for unit in units)
np.testing.assert_allclose(
household_sum(sim, list(PAYMENTS)).sum(), entered, rtol=1e-5, atol=TOLERANCE
)


@PROPERTY_SETTINGS
@given(
st.lists(households(), min_size=1, max_size=12),
st.sampled_from(list(PAYMENTS)),
st.floats(1, 3_000, allow_nan=False),
)
def test_a_pound_more_of_a_payment_is_a_pound_of_government_spending(
units, bumped, bump
):
# One simulation holds every household twice: as drawn, then with the
# extra payment.
as_drawn = situation(units)
with_bump = situation(units, bumped=bumped, bump=bump, start=len(units))
sim = Simulation(
situation={
entity: {**as_drawn[entity], **with_bump[entity]} for entity in as_drawn
}
)
recipients = np.array(
[len(unit["children"]) if PAYMENTS[bumped] == "child" else 1 for unit in units]
)
paid = recipients * bump

def change(name):
values = calc(sim, name)
return values[len(units) :] - values[: len(units)]

np.testing.assert_allclose(change(bumped), paid, rtol=1e-5, atol=TOLERANCE)
np.testing.assert_allclose(
change("household_benefits"), paid, rtol=1e-5, atol=TOLERANCE
)
np.testing.assert_allclose(change("gov_spending"), paid, rtol=1e-5, atol=TOLERANCE)
np.testing.assert_allclose(change("gov_tax"), 0, atol=TOLERANCE)
np.testing.assert_allclose(change("gov_balance"), -paid, rtol=1e-5, atol=TOLERANCE)
3 changes: 3 additions & 0 deletions policyengine_uk/variables/gov/gov_spending.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
"dfe_education_spending",
"dft_subsidy_spending",
"nhs_spending",
"healthy_start_vouchers",
"two_child_limit_payment",
"scottish_child_payment",
"carer_support_payment",
"scottish_carer_supplement",
"disability_basic_income",
Expand Down
Loading