From 02076029b62d8f42b6c271ce016d3fd4a320f218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:28:08 +0100 Subject: [PATCH] Expand Savings Credit regression coverage and metadata --- .../2121-savings-credit-followup.changed.md | 1 + .../savings_credit/savings_credit.yaml | 40 +++++ .../tests/test_savings_credit_eligibility.py | 168 +++++++++++++++++- .../savings_credit/savings_credit.py | 17 +- 4 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 changelog.d/2121-savings-credit-followup.changed.md diff --git a/changelog.d/2121-savings-credit-followup.changed.md b/changelog.d/2121-savings-credit-followup.changed.md new file mode 100644 index 0000000000..7114380da5 --- /dev/null +++ b/changelog.d/2121-savings-credit-followup.changed.md @@ -0,0 +1 @@ +- Expanded Savings Credit regression coverage for protected mixed-age couples and clarified computed-amount metadata. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml index f43589f7f5..cab1abeb8d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml @@ -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 diff --git a/policyengine_uk/tests/test_savings_credit_eligibility.py b/policyengine_uk/tests/test_savings_credit_eligibility.py index 04a2275d1c..7f3334a696 100644 --- a/policyengine_uk/tests/test_savings_credit_eligibility.py +++ b/policyengine_uk/tests/test_savings_credit_eligibility.py @@ -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 @@ -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}) ) @@ -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) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py index 62b6eb3e2c..13c0c4b645 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py @@ -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"