diff --git a/changelog.d/2338.fixed.md b/changelog.d/2338.fixed.md new file mode 100644 index 0000000000..ec98dcdd0c --- /dev/null +++ b/changelog.d/2338.fixed.md @@ -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. diff --git a/policyengine_uk/tests/code_health/test_household_benefits_in_government_spending.py b/policyengine_uk/tests/code_health/test_household_benefits_in_government_spending.py new file mode 100644 index 0000000000..e4a60cea73 --- /dev/null +++ b/policyengine_uk/tests/code_health/test_household_benefits_in_government_spending.py @@ -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"}, + ) + == [] + ) diff --git a/policyengine_uk/tests/policy/baseline/gov/gov_spending.yaml b/policyengine_uk/tests/policy/baseline/gov/gov_spending.yaml new file mode 100644 index 0000000000..850d997186 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/gov_spending.yaml @@ -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 diff --git a/policyengine_uk/tests/test_gov_spending_properties.py b/policyengine_uk/tests/test_gov_spending_properties.py new file mode 100644 index 0000000000..dea15731bb --- /dev/null +++ b/policyengine_uk/tests/test_gov_spending_properties.py @@ -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) diff --git a/policyengine_uk/variables/gov/gov_spending.py b/policyengine_uk/variables/gov/gov_spending.py index ca27cf2a72..211a6f8d21 100644 --- a/policyengine_uk/variables/gov/gov_spending.py +++ b/policyengine_uk/variables/gov/gov_spending.py @@ -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",