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/2121-savings-credit-followup.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Expanded Savings Credit regression coverage for protected mixed-age couples and clarified computed-amount metadata.
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,43 @@
minimum_guarantee: 12_376
output:
savings_credit: 684.216


# Explicit SI 2019/37 art.4 saving INPUT CONTRACT, not an inference from current
# receipt or proof of the separate 2016 Savings Credit continuity conditions.
# Current 2026 model-rate premises: married couple/default Marriage Allowance
# of 1,260 gives tax (20,000-12,570-1,260)*0.2=1,234 and income 18,766.
# Guarantee: 363.25*52=18,889; threshold: 329.75*52=17,147.
# SC=min(0.6*(18,889-17,147),0.6*(18,766-17,147))=971.40; GC=123.
# This hand control is not a new external audit of 2026 tax or uprating rates.
- name: Protected mixed-age couple keeps the computed Savings Credit component
period: 2026
absolute_error_margin: 0.01
input:
people:
person1:
age: 80
is_claimant_or_partner: true
state_pension: 20_000
person2:
age: 60
is_claimant_or_partner: true
benunits:
benunit:
members: [person1, person2]
has_mixed_age_couple_pension_credit_saving: true
would_claim_pc: true
households:
household:
members: [person1, person2]
country: ENGLAND
savings: 0
output:
income_tax: [1_234, 0]
pension_credit_income: 18_766
savings_credit_income: 18_766
standard_minimum_guarantee: 18_889
savings_credit: 971.40
guarantee_credit: 123
pension_credit_entitlement: 1_094.40
pension_credit: 1_094.40
168 changes: 166 additions & 2 deletions policyengine_uk/tests/test_savings_credit_eligibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
mixed-age Savings Credit continuity is inferred from reported Pension Credit.
"""

from copy import deepcopy

from hypothesis import example, given, settings
from hypothesis import strategies as st
import numpy as np
import pytest

Expand Down Expand Up @@ -55,9 +59,9 @@ def situation():
return {"people": people, "benunits": benunits, "households": households}


def ungated_simulation():
def ungated_simulation(data=None):
"""Retain the former amount formula for a downstream differential control."""
sim = Simulation(situation=situation())
sim = Simulation(situation=situation() if data is None else data)
sim.tax_benefit_system.update_variable(
type("savings_credit", (savings_credit,), {"defined_for": None})
)
Expand Down Expand Up @@ -150,3 +154,163 @@ def test_direct_savings_credit_input_keeps_its_existing_override_behavior():
assert sim.calculate("pension_credit_entitlement", YEAR)[0] == 0
assert sim.calculate("pension_credit", YEAR)[0] == 0
assert sim.calculate("housing_benefit", YEAR)[0] == 0


# These are baseline input-contract invariants, not a proof of lawful historical
# Savings Credit continuity or population impacts. All components are computed;
# only the documented saving/closure scenario flags are supplied as inputs.
CONSUMER_OUTPUTS = [
"guarantee_credit",
"pension_credit_entitlement",
"pension_credit",
"in_receipt_of_savings_credit_only",
"housing_benefit_applicable_income",
"housing_benefit_assessable_capital",
"housing_benefit_tariff_income",
"housing_benefit",
"council_tax_reduction_applicable_income",
"council_tax_reduction_assessable_capital",
"council_tax_reduction",
"targeted_childcare_entitlement",
]


def _control_unit(*, protected=False, closed=False, claim=False, both_old=False):
return {
"adults": [
(80, 9_000 if both_old else (0 if closed else 20_000), 0, 0),
(78 if both_old else 60, 9_000 if both_old else 0, 0, 0),
],
"protected": protected,
"closed": closed,
"claim_at_closure": claim,
"claim_pc": True,
"claim_uc": False,
"rent": 6_240,
"capital": 0,
"expected_left": protected and closed and claim,
}


@st.composite
def _unit_cases(draw):
shape = draw(st.sampled_from(["mixed", "both_old", "working"]))
ages = {
"mixed": (draw(st.integers(80, 90)), draw(st.integers(25, 60))),
"both_old": (draw(st.integers(80, 90)), draw(st.integers(80, 90))),
"working": (draw(st.integers(25, 60)), draw(st.integers(25, 60))),
}[shape]
return {
"adults": [
(
age,
draw(st.integers(0, 25_000)) if age >= 80 else 0,
draw(st.integers(0, 30_000)),
draw(st.integers(0, 10_000)),
)
for age in ages
],
"protected": draw(st.booleans()),
"closed": draw(st.booleans()),
"claim_at_closure": draw(st.booleans()),
"claim_pc": draw(st.booleans()),
"claim_uc": draw(st.booleans()),
"rent": draw(st.integers(0, 12_000)),
"capital": draw(st.integers(0, 20_000)),
}


def _generated_situation(units):
people, benunits, households = {}, {}, {}
for i, unit in enumerate(units):
members = []
for j, (age, pension, earnings, private_pension) in enumerate(unit["adults"]):
name = f"person{i}_{j}"
members.append(name)
people[name] = {
"age": {YEAR: age},
"is_claimant_or_partner": {YEAR: True},
"state_pension": {YEAR: pension},
"employment_income": {YEAR: earnings},
"private_pension_income": {YEAR: private_pension},
}
benunits[f"b{i}"] = {
"members": members,
"has_mixed_age_couple_pension_credit_saving": {YEAR: unit["protected"]},
"legacy_benefits_closed": {YEAR: unit["closed"]},
"would_claim_uc_at_legacy_closure": {YEAR: unit["claim_at_closure"]},
"would_claim_pc": {YEAR: unit["claim_pc"]},
"would_claim_uc": {YEAR: unit["claim_uc"]},
"would_claim_housing_benefit": {YEAR: True},
}
households[f"h{i}"] = {
"members": members,
"country": {YEAR: "ENGLAND"},
"local_authority": {YEAR: "MAIDSTONE"},
"tenure_type": {YEAR: "RENT_FROM_COUNCIL"},
"rent": {YEAR: unit["rent"]},
"council_tax": {YEAR: 2_000},
"savings": {YEAR: unit["capital"]},
}
return {"people": people, "benunits": benunits, "households": households}


def _assert_computed_component_invariants(sim):
# SPCA 2002 ss.1(2)-(4), 3 and 4(1A): shared entitlement qualifies the
# computed component. An explicit saving flag selects the existing route;
# it does not prove the separate pre-2016 Savings Credit history.
amount = sim.calculate("savings_credit", YEAR)
eligible = sim.calculate("is_pension_credit_eligible", YEAR)
assert np.all((amount <= 0) | eligible), (
"positive computed SC requires shared eligibility"
)
np.testing.assert_allclose(
sim.calculate("pension_credit_entitlement", YEAR),
sim.calculate("guarantee_credit", YEAR) + amount,
atol=0.01,
rtol=0,
)


@settings(max_examples=25, derandomize=True, database=None, deadline=None)
@given(units=st.lists(_unit_cases(), min_size=1, max_size=6))
@example(units=[_control_unit()])
@example(units=[_control_unit(protected=True)])
@example(units=[_control_unit(both_old=True)])
@example(units=[_control_unit(protected=True, closed=True, claim=True)])
def test_savings_credit_generated_invariants(units):
data = _generated_situation(units)
fixed = Simulation(situation=deepcopy(data))
former = ungated_simulation(deepcopy(data))
_assert_computed_component_invariants(fixed)
for i, unit in enumerate(units):
if "expected_left" in unit:
assert (
bool(fixed.calculate("left_pension_route_at_legacy_closure", YEAR)[i])
== unit["expected_left"]
)
# Deliberately exclude raw Savings Credit: its unprotected off-route amount
# is precisely the value the shared gate changes. Consumers keep their gates.
for variable in CONSUMER_OUTPUTS:
np.testing.assert_allclose(
fixed.calculate(variable, YEAR),
former.calculate(variable, YEAR),
atol=0.01,
rtol=0,
err_msg=variable,
)


def test_ungated_counterexample_detects_positive_component_without_entitlement():
data = _generated_situation([_control_unit()])
fixed = Simulation(situation=deepcopy(data))
former = ungated_simulation(deepcopy(data))
_assert_computed_component_invariants(fixed)
assert former.calculate("savings_credit", YEAR)[0] > 0
assert not former.calculate("is_pension_credit_eligible", YEAR)[0]
# This negative control proves the invariant would detect losing the gate.
# Current main already has the gate; this is not a claimed production RED.
with pytest.raises(
AssertionError, match="positive computed SC requires shared eligibility"
):
_assert_computed_component_invariants(former)
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,19 @@ class savings_credit(Variable):
definition_period = YEAR
value_type = float
unit = GBP
reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/3"
reference = (
"https://www.legislation.gov.uk/ukpga/2002/16/section/1",
"https://www.legislation.gov.uk/ukpga/2002/16/section/3",
"https://www.legislation.gov.uk/ukpga/2002/16/section/4",
"https://www.legislation.gov.uk/uksi/2019/37/article/4",
)
documentation = (
"The savings credit element of State Pension Credit, zero unless the "
"benefit unit meets the shared Pension Credit entitlement conditions. "
"This is the computed component before Pension Credit take-up; a "
"positive amount does not by itself establish an award in payment."
"The computed savings credit element of State Pension Credit, zero unless "
"the benefit unit meets the shared Pension Credit entitlement conditions. "
"An explicitly supplied input overrides the computed component. The "
"component is calculated before Pension Credit take-up, which applies "
"later to total Pension Credit entitlement; a positive component does "
"not by itself establish an award in payment."
)
defined_for = "is_pension_credit_eligible"

Expand Down
Loading