diff --git a/changelog.d/2092.fixed.md b/changelog.d/2092.fixed.md new file mode 100644 index 0000000000..0751fead70 --- /dev/null +++ b/changelog.d/2092.fixed.md @@ -0,0 +1 @@ +Made Pension Credit and pension-age Housing Benefit projections non-negative and safe under reforms, validated their common enacted base year from the YAML as loaded, and preserved Guarantee Credit boundary continuity. Forecast thresholds and protected Housing Benefit allowances follow the reformed guarantee and maximum as uprated, as on main. diff --git a/docs/book/programs/gov/dwp/pension-credit.ipynb b/docs/book/programs/gov/dwp/pension-credit.ipynb index 1601aa9236..92b9625acb 100644 --- a/docs/book/programs/gov/dwp/pension-credit.ipynb +++ b/docs/book/programs/gov/dwp/pension-credit.ipynb @@ -15,13 +15,19 @@ " additional support to those who have some savings or a second pension.\n", "\n", "\n", - "Pension Credit parameters can be found in `policyengine_uk/parameters/gov/dwp/pension_credit` and logic in `policyengine_uk/variables/dwp/pension_credit.py`.\n", + "Pension Credit parameters can be found in `policyengine_uk/parameters/gov/dwp/pension_credit` and logic in `policyengine_uk/variables/gov/dwp/pension_credit/`.\n", "\n", "## Legislation\n", "\n", "The legal framework for Guarantee Credit is primarily defined in the legislation provided on the [The State Pension Credit Regulations 2002](https://www.legislation.gov.uk/uksi/2002/1792/regulation/6) legislation website. \n", "The legal framework for Savings Credit is primarily defined in the legislation provided on the [State Pension Credit Act 2002](https://www.legislation.gov.uk/ukpga/2002/16/section/3) legislation website. \n", "\n", + "## Uprating\n", + "\n", + "After the last enacted rates, the standard minimum guarantee follows the previous May–July earnings increase, with no decrease when earnings fall, under [Social Security Administration Act 1992 section 150A](https://www.legislation.gov.uk/ukpga/1992/5/section/150A). Maximum Savings Credit follows September CPI with a zero floor. The model derives the Savings Credit threshold from these amounts and the live phase-in rate, with a zero floor on the threshold. Each higher pension-age Housing Benefit allowance with an unreformed own base equals the projected guarantee plus projected maximum Savings Credit; the post-April-2021 lower allowances follow earnings. Published amounts, including rounding, remain unchanged in their enacted years.\n", + "\n", + "The projections start from the common last year with enacted guarantees, thresholds and higher Housing Benefit allowances. Forecast thresholds and protected Housing Benefit allowances follow the guarantee and maximum as general uprating projects their reformed paths, as on main, unless their own base-year value is also reformed. Threshold forecasts use the live phase-in rate; a zero rate keeps the preceding threshold and pays no Savings Credit. Explicit threshold and allowance reforms anchor their own projections, and higher allowances at or below the guarantee, including zero, can still be projected. See the [Pension Credit parameter documentation](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/dwp/pension_credit/README.md) for the base-year checks and reform rules. These are forecast conventions, rather than announced future rates.\n", + "\n", "## Pension Credit Rate Changes\n", "\n", "Some key components of Guarantee Credit include:\n", diff --git a/docs/book/programs/gov/dwp/state-pension.md b/docs/book/programs/gov/dwp/state-pension.md index 0430650f94..e0d1c200ec 100644 --- a/docs/book/programs/gov/dwp/state-pension.md +++ b/docs/book/programs/gov/dwp/state-pension.md @@ -257,6 +257,12 @@ statutory-basis forecast minus the calendar-year growth stored in OBR does not forecast the ONS AWE series; its measure is wages and salaries per employee, and Q2 is the quarter nearest May to July. +The State Pension triple lock consumes both statutory inputs. The shared +`indices.earnings_uprating` series also projects the Pension Credit standard +minimum guarantee, with a zero floor on earnings growth. See +[Pension Credit uprating](./pension-credit.ipynb#uprating) for its Savings Credit +and pension-age Housing Benefit projections and reform behaviour. + After the EFO horizon the gap is zero, so the inputs follow calendar-year growth. Smooth forecasts pay the higher of earnings, CPI and 2.5% each year, so the baseline has none of the extra cost the triple lock builds up when diff --git a/myst.yml b/myst.yml index 5d58330cbd..c3a8673b63 100644 --- a/myst.yml +++ b/myst.yml @@ -38,6 +38,7 @@ project: - title: Programmes children: - file: docs/book/programs/contrib/ubi-center/carbon-tax.ipynb + - file: docs/book/programs/gov/dwp/pension-credit.ipynb - pattern: docs/book/programs/gov/*/*.md site: options: diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py index 51b3d487b3..7982580f44 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/create_earnings_limit_projection.py @@ -15,24 +15,46 @@ April 2026 rate and the hours at or above 16, no projected year is below £204. Run after general uprating and before fiscal-year conversion, on both the -main tree and its baseline copy, as the Savings Credit threshold projection -is. Reprocessing a reform then reads its changed national living wage or -hours. The exceptions edit the processed parameters, so they do not move the +main tree and its baseline copy. Reprocessing a reform then reads its changed +national living wage or hours. The exceptions edit the processed parameters, so they do not move the limit: ``Scenario.apply`` on an already-built simulation, and a reform-dict key naming a single day or month. """ import math +from typing import Any, Callable -from policyengine_core.parameters import ParameterNode +from policyengine_core.parameters import Parameter, ParameterNode +from policyengine_core.parameters.parameter_at_instant import ParameterAtInstant from policyengine_core.periods import instant from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( - extend_after_last_stated, last_projected_year, ) +def extend_after_last_stated( + parameter: Parameter, + last_year: int, + derive: Callable[[str, Any], Any], +) -> None: + """Add a value for each year after the parameter's last stated value. + + ``derive(sample, previous)`` gives the year's value from the other + parameters at its 30 April ``sample``; ``previous`` is the value in force + before it. Each value applies from 1 January, as uprating's do. + """ + last = max(parameter.values_list, key=lambda entry: entry.instant_str) + previous = last.value + for year in range(int(last.instant_str[:4]) + 1, last_year + 1): + previous = derive(f"{year}-04-30", previous) + parameter.values_list.append( + ParameterAtInstant(parameter.name, f"{year}-01-01", data=previous) + ) + parameter.values_list.sort(key=lambda entry: entry.instant_str, reverse=True) + parameter.mark_as_modified() + + def national_living_wage_limit(hours: float, national_living_wage: float) -> float: """``hours`` at the national living wage, rounded up to the next pound. diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml index c49f581293..92560fcf9f 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged.yaml @@ -1,4 +1,6 @@ -description: Personal allowance for Housing Benefit for a couple where one or both members attained State Pension age before 1 April 2021 (before that date, every couple with a member over State Pension age except, until 6 December 2018, one where both members were under 65). +description: >- + Personal allowance for Housing Benefit for a couple where one or both members attained State Pension age before 1 April 2021 (before that date, every couple with a member over State Pension age except, until 6 December 2018, one where both members were under 65). + Values after the last enacted rate are forecasts, not enacted future rates. values: # 2015-16 to 2019-20: the rate set by the instrument cited at each value # (to 2018-19, the aged-65-or-over rate; younger claimants got @@ -36,17 +38,27 @@ values: 2022-04-06: 294.9 2023-04-01: 324.7 2024-04-01: 352 - 2025-04-01: 366 + 2025-04-01: + value: 366 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025, Schedule 6 paragraph 1(2)(b) + href: https://www.legislation.gov.uk/uksi/2025/295/schedule/6/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025, Schedule 6 paragraph 1(2)(b) + href: https://www.legislation.gov.uk/nisr/2025/64/schedule/6/made + - title: Benefit and pension rates 2025 to 2026, Housing Benefit personal allowances + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 383.35 metadata: period: week unit: currency-GBP label: Housing Benefit couple pension-age personal allowance - # Not uprated as a whole: the allowance is the Pension Credit standard + # Component-weighted forecast: the allowance is the Pension Credit standard # minimum guarantee plus the maximum Savings Credit (HB A3/2021), which # rise by earnings and September CPI. create_pension_age_allowances.py - # derives the years after the published amounts as that sum. + # builds an allowance-specific index from that sum after published amounts. + uprating: gov.dwp.housing_benefit.allowances.protected_pension_age_uprating.couple reference: - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 (SI 2006/214), Schedule 3, paragraph 1(2)(b) href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py index a715f291fd..5fccd1adba 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/create_pension_age_allowances.py @@ -1,43 +1,324 @@ -"""Project the higher pension-age Housing Benefit personal allowances. - -For claimants who reached State Pension age before 1 April 2021, SI 2006/214 -Schedule 3 paragraph 1(1)(b) and (2)(b) set a personal allowance made up of -the Pension Credit standard minimum guarantee plus the maximum Savings Credit -(DWP circular HB A3/2021, paragraphs 3-5). Every published amount from -2019-20 to 2026-27 is that sum rounded up to the next 5p. The guarantee rises -with earnings and the maximum with September CPI, so neither index alone -projects the allowance. Keep the published allowances and derive later years -as the guarantee plus the maximum, each already uprated by its own index. - -Run after general uprating and before fiscal-year conversion, on both the -main tree and its baseline copy, as the Savings Credit threshold projection -is. Reprocessing a reform then reads its changed guarantee and maximum. +"""Build a separate forecast index for each protected pension-age HB allowance. + +Keep main's forecast identity: guarantee plus published maximum Savings +Credit (DWP A3/2021 paras 3–5). Published HB amounts include independent +rounding and remain exact. The maximum Savings Credit part is the maximum +parameter's own projection, so a reform to the maximum, from any year, +reaches the allowances, as it did when they were derived after general +uprating. Reformed allowance bases use their own retained component; a base +below its guarantee follows earnings without a retained component. A +guarantee reform moves the forecast allowances along its reformed uprated +path, as on main. These indices run before general uprating. """ -from policyengine_core.parameters import ParameterNode +from sys import float_info + +from policyengine_core.parameters import Parameter, ParameterNode from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( - extend_after_last_stated, + enacted_maximum, last_projected_year, + preserve_zero_anchor, + projected_maximum_values, + uprated_values, +) +from policyengine_uk.parameters.gov.dwp.pension_credit.pension_age_projection_base import ( + projection_base_year, +) +from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( + CPI_OBSERVATION_MONTH_DAY, ) -# The Pension Credit amounts each allowance is built from: a lone parent's -# are a single person's. -RELATIONS = {"single": "SINGLE", "couple": "COUPLE", "lone_parent": "SINGLE"} +# Each protected allowance, with the standard minimum guarantee it holds. +ALLOWANCE_GUARANTEES = {"single": "SINGLE", "lone_parent": "SINGLE", "couple": "COUPLE"} + + +def base_instant(base_year: int) -> str: + return f"{base_year}-04-01" + + +def project_retained_uplift(amount: float, cpi_growth: float) -> float: + """Project the retained Savings Credit uplift without a cash reduction.""" + return amount * (1 + max(cpi_growth, 0)) -def project_pension_age_allowances(parameters: ParameterNode) -> ParameterNode: - """Fill the years after each higher allowance's last stated value.""" - pc = parameters.gov.dwp.pension_credit - allowances = parameters.gov.dwp.housing_benefit.allowances - last_year = last_projected_year(parameters) - for kind, relation in RELATIONS.items(): - guarantee = pc.guarantee_credit.minimum_guarantee.children[relation] - maximum = pc.savings_credit.maximum.children[relation] - def derive(sample, previous): - return guarantee(sample) + maximum(sample) +def protected_allowance_index_values( + base_allowance: float, + guarantee_component: float, + earnings_index: Parameter, + cpi_september: Parameter, + final_observation_year: int, + *, + base_year: int, + retained_maximum: float | None = None, + price_index: Parameter | None = None, + maxima: dict[int, float] | None = None, + guarantees: dict[int, float] | None = None, +) -> dict[str, float]: + """Build a component-weighted index from the last enacted allowance. + + ``maxima`` gives the maximum Savings Credit in each forecast year, which + then replaces the retained component. Without it, the retained component + is projected by non-negative September CPI. + ``guarantees`` gives the guarantee's reformed uprated path; without it, + the own-base fallback projects the guarantee component by earnings. + """ + retained_uplift = ( + base_allowance - guarantee_component + if retained_maximum is None + else retained_maximum + ) + if base_allowance < guarantee_component or retained_uplift < 0: + raise ValueError( + "The protected Housing Benefit allowance cannot be lower than " + "the corresponding Pension Credit guarantee." + ) + start = base_instant(base_year) + if base_allowance <= 0: + # The index scales the base, so a reform's zero allowance stays zero. + return {start: 1.0} + + base_earnings_index = float(earnings_index(start)) + values = {start: 1.0} + for year in range(base_year + 1, final_observation_year + 2): + observation_year = year - 1 + guarantee = ( + guarantees[year] + if guarantees is not None + else guarantee_component + * (float(earnings_index(f"{year}-04-01")) / base_earnings_index) + ) + growth = ( + float(price_index(f"{year}-04-01")) + / float(price_index(f"{year - 1}-04-01")) + - 1 + if price_index is not None + else float(cpi_september(f"{observation_year}-{CPI_OBSERVATION_MONTH_DAY}")) + ) + retained_uplift = ( + maxima[year] + if maxima is not None + else project_retained_uplift(retained_uplift, growth) + ) + # Core divides by the index at later explicit anchors. A zero + # guarantee and maximum must therefore leave a positive index. + values[f"{year}-04-01"] = min( + max((guarantee + retained_uplift) / base_allowance, float_info.min), + float_info.max, + ) + return values + + +def _projection_parameter( + allowance: str, + base_allowance: float, + guarantee_component: float, + earnings_index: Parameter, + cpi_september: Parameter, + final_observation_year: int, + base_year: int, + retained_maximum: float | None, + price_index: Parameter, + maxima: dict[int, float] | None = None, + guarantees: dict[int, float] | None = None, +) -> Parameter: + retained_uplift = ( + base_allowance - guarantee_component + if retained_maximum is None + else retained_maximum + ) + relationship_label = allowance.replace("_", "-") + fiscal_year = f"{base_year}-{(base_year + 1) % 100:02d}" + guarantee_source = ( + "the reformed Pension Credit guarantee, as uprated from its last explicit value" + if guarantees is not None + else f"a £{guarantee_component:g} Pension Credit guarantee component" + ) + savings_credit_component = ( + "the maximum Savings Credit, as uprated from its last explicit value. " + "The guarantee component follows its reformed uprated path; " + "the maximum follows non-negative September CPI." + if maxima is not None + else f"a £{retained_uplift:g} Savings Credit component. " + "The components follow the shared statutory " + "earnings index and non-negative September CPI, respectively." + ) + return Parameter( + ( + "gov.dwp.housing_benefit.allowances." + f"protected_pension_age_uprating.{allowance}" + ), + data={ + "description": ( + f"Forecast index for the protected {relationship_label} " + f"pension-age Housing Benefit allowance. The £{base_allowance:g} " + f"{fiscal_year} base is retained exactly. Forecasts combine a " + f"{guarantee_source} " + f"and {savings_credit_component} " + "Projected values are not enacted rates." + ), + "values": protected_allowance_index_values( + base_allowance, + guarantee_component, + earnings_index, + cpi_september, + final_observation_year, + base_year=base_year, + retained_maximum=retained_maximum, + price_index=price_index, + maxima=maxima, + guarantees=guarantees, + ), + "metadata": { + "unit": "/1", + "label": ( + f"Protected {relationship_label} pension-age Housing " + "Benefit allowance forecast index" + ), + "reference": [ + { + "title": ( + "Social Security Administration Act 1992 section 150" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150" + ), + }, + { + "title": ( + "Social Security Administration Act 1992 section 150A" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150A" + ), + }, + { + "title": ( + "A14/2025 Housing Benefit uprating for the " + "financial year ending March 2027, paragraphs " + "5, 6 and 20" + ), + "href": ( + "https://www.gov.uk/government/publications/" + "housing-benefit-adjudication-circulars-2025/" + "a142025-housing-benefit-uprating-for-the-" + "financial-year-ending-march-2027" + ), + }, + { + "title": ( + "ONS KAC3 average weekly earnings, whole economy, total pay" + ), + "href": ( + "https://www.ons.gov.uk/" + "employmentandlabourmarket/peopleinwork/" + "earningsandworkinghours/timeseries/kac3/lms" + ), + }, + { + "title": "ONS D7G7 Consumer Prices Index annual rate", + "href": ( + "https://www.ons.gov.uk/economy/" + "inflationandpriceindices/timeseries/d7g7/mm23" + ), + }, + ], + }, + }, + ) + + +def project_pension_age_allowances( + parameters: ParameterNode, + unreformed: ParameterNode | None = None, + enacted: ParameterNode | None = None, +) -> ParameterNode: + """Add a protected-allowance forecast index for each allowance. + + Forecasts follow the reformed guarantee and maximum as general uprating + projects them, retaining the enacted allowance as the index denominator. + If its own allowance base changes, the index uses the reformed pair. + A reformed allowance below its + guarantee keeps no retained Savings Credit, so it is projected whole with + the guarantee's earnings path, as an allowance equal to its guarantee is. + The enacted pair is still validated, so a rates update that puts an + allowance below its guarantee fails. Earnings and CPI inputs come from + the reform's parameter tree. + + The maximum Savings Credit part is read from the reform's tree as general + uprating will project it, so a reform to the maximum from the base year + or any later year moves each allowance whose own base is unchanged. A + reformed allowance base takes precedence: its own retained component is + projected, as before. + """ + allowances = parameters.gov.dwp.housing_benefit.allowances + projection_base = ( + enacted + if enacted is not None + else (parameters if unreformed is None else unreformed) + ) + base_year = projection_base_year(projection_base) + start = base_instant(base_year) + base_allowances = projection_base.gov.dwp.housing_benefit.allowances + reformed_guarantees = ( + parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + ) + guarantees = ( + projection_base.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + ) + earnings_index = parameters.gov.economic_assumptions.indices.earnings_uprating + cpi_september = ( + parameters.gov.economic_assumptions.statutory_uprating_inputs.cpi_september + ) + projection = ParameterNode( + name=("gov.dwp.housing_benefit.allowances.protected_pension_age_uprating"), + data={}, + ) + allowances.add_child("protected_pension_age_uprating", projection) - extend_after_last_stated(allowances.children[kind].aged, last_year, derive) - allowances.clear_parent_cache() + final_observation_year = last_projected_year(parameters) - 1 + price_index = parameters.gov.economic_assumptions.indices.september_cpi_uprating + for allowance_name, relationship in ALLOWANCE_GUARANTEES.items(): + allowance = float(getattr(base_allowances, allowance_name).aged(start)) + guarantee = float(getattr(guarantees, relationship)(start)) + allowance_parameter = getattr(allowances, allowance_name).aged + reformed_allowance = float(allowance_parameter(start)) + if max(entry.instant_str for entry in allowance_parameter.values_list) < start: + # Keep a pre-base reform's amount through the common base, where + # its generated forecast index begins. + allowance_parameter.update(start=start, value=reformed_allowance) + retained_maximum = enacted_maximum(projection_base, relationship, base_year) + maxima = projected_maximum_values( + parameters, relationship, base_year, final_observation_year + 1 + ) + guarantee_path = uprated_values( + getattr(reformed_guarantees, relationship), + parameters, + range(base_year + 1, final_observation_year + 2), + ) + if reformed_allowance != allowance: + retained_maximum = None + maxima = None + guarantee_path = None + allowance = reformed_allowance + guarantee = min( + float(getattr(reformed_guarantees, relationship)(start)), + reformed_allowance, + ) + index = _projection_parameter( + allowance_name, + allowance, + guarantee, + earnings_index, + cpi_september, + final_observation_year, + base_year, + retained_maximum, + price_index, + maxima, + guarantee_path, + ) + projection.add_child(allowance_name, index) + preserve_zero_anchor(allowance_parameter, index) return parameters diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml index 754212cccc..8d70145c5f 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged.yaml @@ -1,5 +1,6 @@ -description: Personal allowance for Housing Benefit for a lone parent who attained State Pension age before 1 April 2021 (before that date, every pension-age lone parent except, until 6 December 2018, one under 65). - +description: >- + Personal allowance for Housing Benefit for a lone parent who attained State Pension age before 1 April 2021 (before that date, every pension-age lone parent except, until 6 December 2018, one under 65). + Values after the last enacted rate are forecasts, not enacted future rates. values: # 2015-16 to 2019-20: the rate set by the instrument cited at each value # (to 2018-19, the aged-65-or-over rate; younger claimants got @@ -37,17 +38,27 @@ values: 2022-04-06: 197.1 2023-04-01: 217 2024-04-01: 235.2 - 2025-04-01: 244.4 + 2025-04-01: + value: 244.4 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025, Schedule 6 paragraph 1(1)(b) + href: https://www.legislation.gov.uk/uksi/2025/295/schedule/6/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025, Schedule 6 paragraph 1(1)(b) + href: https://www.legislation.gov.uk/nisr/2025/64/schedule/6/made + - title: Benefit and pension rates 2025 to 2026, Housing Benefit personal allowances + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 256 metadata: period: week unit: currency-GBP label: Housing Benefit lone parent pension-age personal allowance - # Not uprated as a whole: the allowance is the Pension Credit standard + # Component-weighted forecast: the allowance is the Pension Credit standard # minimum guarantee plus the maximum Savings Credit (HB A3/2021), which # rise by earnings and September CPI. create_pension_age_allowances.py - # derives the years after the published amounts as that sum. + # builds an allowance-specific index from that sum after published amounts. + uprating: gov.dwp.housing_benefit.allowances.protected_pension_age_uprating.lone_parent reference: - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 (SI 2006/214), Schedule 3, paragraph 1(1)(b) href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml index a1485eaa16..d6546a11a1 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged.yaml @@ -1,4 +1,6 @@ -description: Personal allowance for Housing Benefit for a single claimant who attained State Pension age before 1 April 2021 (before that date, every single pension-age claimant except, until 6 December 2018, one under 65). +description: >- + Personal allowance for Housing Benefit for a single claimant who attained State Pension age before 1 April 2021 (before that date, every single pension-age claimant except, until 6 December 2018, one under 65). + Values after the last enacted rate are forecasts, not enacted future rates. values: # 2015-16 to 2019-20: the rate set by the instrument cited at each value # (to 2018-19, the aged-65-or-over rate; younger claimants got @@ -36,17 +38,27 @@ values: 2022-04-06: 197.1 2023-04-01: 217 2024-04-01: 235.2 - 2025-04-01: 244.4 + 2025-04-01: + value: 244.4 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025, Schedule 6 paragraph 1(1)(b) + href: https://www.legislation.gov.uk/uksi/2025/295/schedule/6/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025, Schedule 6 paragraph 1(1)(b) + href: https://www.legislation.gov.uk/nisr/2025/64/schedule/6/made + - title: Benefit and pension rates 2025 to 2026, Housing Benefit personal allowances + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 256 metadata: period: week unit: currency-GBP label: Housing Benefit single pension-age personal allowance - # Not uprated as a whole: the allowance is the Pension Credit standard + # Component-weighted forecast: the allowance is the Pension Credit standard # minimum guarantee plus the maximum Savings Credit (HB A3/2021), which # rise by earnings and September CPI. create_pension_age_allowances.py - # derives the years after the published amounts as that sum. + # builds an allowance-specific index from that sum after published amounts. + uprating: gov.dwp.housing_benefit.allowances.protected_pension_age_uprating.single reference: - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 (SI 2006/214), Schedule 3, paragraph 1(1)(b) href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/README.md b/policyengine_uk/parameters/gov/dwp/pension_credit/README.md index 385e422a94..9d93beb815 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/README.md +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/README.md @@ -6,21 +6,54 @@ using the same statutory input and published precision as the State Pension triple lock. This models the earnings minimum in [SSAA 1992 s150A(2)](https://www.legislation.gov.uk/ukpga/1992/5/section/150A), with no decrease when earnings fall. There is no CPI or 2.5% floor in this -projection. The three lower Housing Benefit pension-age personal -allowances, which equal the guarantee, follow the same increase. +projection. The shared index is +`gov.economic_assumptions.indices.earnings_uprating`. The three lower Housing +Benefit pension-age personal allowances (`aged_from_cutoff`), for those who +reached the qualifying age on or after 1 April 2021, follow the same increase. The Savings Credit maximum rises with September CPI, following [HCWS1101](https://questions-statements.parliament.uk/written-statements/detail/2025-11-26/hcws1101). -Projected thresholds are derived after uprating: -`threshold = standard minimum guarantee - maximum / phase_in`. +The maximum has a non-negative CPI floor. Projected thresholds are +derived from the component indices before general uprating: +`threshold = max(0, standard minimum guarantee - maximum / phase_in)`. At the 60% phase-in rate, this ensures that the formula's maximum award follows prices even when the guarantee follows earnings. Published -thresholds are retained, including their rounding. A zero phase-in rate pays -no Savings Credit at any threshold, so that year keeps the threshold in force -before it. +thresholds are retained, including their rounding. If the live phase-in +rate is zero, the projection retains the previous threshold rather than +dividing by zero. The three higher Housing Benefit pension-age personal allowances, for those -who reached State Pension age before April 2021, are the guarantee plus the -maximum Savings Credit (HB A3/2021). After their published amounts they are -derived as that sum, so the guarantee part follows earnings and the maximum -follows September CPI. +who reached the qualifying age before 1 April 2021, are based on the guarantee +plus maximum Savings Credit +([DWP HB A3/2021, paragraphs 3–5](https://www.gov.uk/government/publications/housing-benefit-adjudication-circulars-2021/a32021-housing-benefit-person-who-have-attained-the-qualifying-age-for-state-pension-credit-amendment-regulations-2021)). +Published allowances are retained exactly, including their rounding. In the +forecast with an unreformed own base, each higher allowance equals the projected guarantee plus +the projected maximum, so its guarantee part follows earnings and its maximum +part follows September CPI. + +## Projection base and reforms + +The projection base comes from the YAML as loaded: the last year with explicit enacted values for both +guarantees, both Savings Credit thresholds and the three higher Housing +Benefit allowances. All seven parameters must reach the same year. If they +do not, parameter loading raises an error listing each parameter and its last +enacted year. Reform dates do not move this base. + +A reform to the Pension Credit guarantee moves the forecast Savings Credit +threshold and protected Housing Benefit allowances, as on main. Each index +reads the guarantee and maximum as general uprating projects their reformed +paths, including reforms from the base year or any later year and reforms to +their uprating indices. Threshold forecasts also read the live phase-in rate; +at zero they keep the preceding threshold, as on main, and pay no Savings +Credit. The enacted base-year amounts retain their own rounding. A threshold or +allowance whose own base-year value is reformed projects its own reformed gap +instead. Explicit threshold and allowance reforms remain anchors for their +own later projections. Each higher allowance has its own index, including the +lone-parent allowance. A base-year allowance at or below its guarantee follows +earnings, so reducing an allowance below the guarantee or setting it to zero +does not prevent the tree building. Projected thresholds and higher allowances +are floored at zero. + +These rules describe forecasts after the enacted rates, rather than announced +future benefit amounts. The public [Pension Credit documentation](https://policyengine.github.io/policyengine-uk/programs/gov/dwp/pension-credit.html#uprating) +summarises them alongside the programme's eligibility and rate information. diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml index 9eef083e79..5b7abb1245 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee.yaml @@ -1,4 +1,8 @@ -description: Minimum income guarantee (basic amount) for Pension Credit. +description: >- + Minimum income guarantee (basic amount) for Pension Credit. Values after the + last enacted rate use the non-negative May-July average weekly earnings input + from the annual statutory review as a forecast floor; they are not enacted + future rates. SINGLE: values: 2015-04-06: 151.2 @@ -11,7 +15,16 @@ SINGLE: 2022-04-01: 182.6 2023-04-01: 201.05 2024-04-01: 218.15 - 2025-04-01: 227.1 + 2025-04-01: + value: 227.1 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025 art. 29 + href: https://www.legislation.gov.uk/uksi/2025/295/article/29/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025 art. 29 + href: https://www.legislation.gov.uk/nisr/2025/64/article/29/made + - title: Benefit and pension rates 2025 to 2026 + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 238 metadata: # Project at the earnings floor in Social Security Administration Act 1992 @@ -29,7 +42,16 @@ COUPLE: 2022-04-01: 278.7 2023-04-01: 306.85 2024-04-01: 332.95 - 2025-04-01: 346.6 + 2025-04-01: + value: 346.6 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025 art. 29 + href: https://www.legislation.gov.uk/uksi/2025/295/article/29/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025 art. 29 + href: https://www.legislation.gov.uk/nisr/2025/64/article/29/made + - title: Benefit and pension rates 2025 to 2026 + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 363.25 metadata: # Project at the earnings floor in Social Security Administration Act 1992 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/pension_age_projection_base.py b/policyengine_uk/parameters/gov/dwp/pension_credit/pension_age_projection_base.py new file mode 100644 index 0000000000..ee5c2bf89f --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/pension_age_projection_base.py @@ -0,0 +1,48 @@ +"""The enacted year the pension-age forecast indices start from. + +The Savings Credit threshold and protected pension-age Housing Benefit +allowance indices are built from the standard minimum guarantee, threshold and +allowances of the last year with enacted rates. That year is the latest one +with an explicit value, so entering the next year's rates moves the base with +them. Every one of these parameters must reach the same year: an uprating +update that misses one would otherwise build the indices from a projected +amount, so it fails here instead. +""" + +from policyengine_core.parameters import Parameter, ParameterNode + +BASE_PARAMETERS = ( + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE", + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.COUPLE", + "gov.dwp.pension_credit.savings_credit.threshold.SINGLE", + "gov.dwp.pension_credit.savings_credit.threshold.COUPLE", + "gov.dwp.housing_benefit.allowances.single.aged", + "gov.dwp.housing_benefit.allowances.lone_parent.aged", + "gov.dwp.housing_benefit.allowances.couple.aged", +) + + +def last_enacted_year(parameter: Parameter) -> int: + """The year of the parameter's latest explicit value.""" + return max(int(entry.instant_str[:4]) for entry in parameter.values_list) + + +def projection_base_year(parameters: ParameterNode) -> int: + """The last year with enacted rates for every projected parameter. + + Callers supply the YAML as loaded, before any modifier or dated reform: + later reform values anchor uprating, not a new projection base. The + consistency guard therefore validates YAML rates updates alone. + """ + years = { + path: last_enacted_year(parameters.get_child(path)) for path in BASE_PARAMETERS + } + if len(set(years.values())) > 1: + listed = ", ".join(f"{path} {year}" for path, year in years.items()) + raise ValueError( + "The pension-age projections need the standard minimum guarantee, " + "the Savings Credit threshold and the pension-age Housing Benefit " + "allowances enacted to the same year. Their last explicit values " + f"are in: {listed}." + ) + return years[BASE_PARAMETERS[0]] diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py index 854b38458d..7b6aec17a8 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/create_savings_credit_threshold.py @@ -1,69 +1,485 @@ -"""Project Savings Credit thresholds from the guarantee and maximum award. +"""Build Savings Credit threshold indices from the statutory uprating bases. -The standard minimum guarantee rises with earnings, while the Savings Credit -maximum rises with September CPI (HCWS1101). Under the 60% phase-in rule, -maximum = phase_in * (standard minimum guarantee - threshold), so the -threshold is the guarantee less maximum / phase_in. Keep the published -thresholds, including their rounding, and derive only subsequent years. - -A zero phase-in rate pays no Savings Credit at any threshold, and the -maximum fixes none, so a year with a zero rate keeps the threshold in force -before it. - -Run after general uprating and before fiscal-year conversion, on both the -main tree and its baseline copy. Reprocessing a reform then reads its changed -guarantee, maximum and phase-in rate. +The maximum Savings Credit is the phase-in rate multiplied by the difference +between the standard minimum guarantee and the Savings Credit threshold. DWP +uprates the guarantee with earnings and the maximum with CPI. The threshold is +therefore a derived residual; applying CPI to it directly, or freezing it, +does not preserve the statutory relationship. Each generated index is applied +by the standard parameter-uprating operation, so explicit threshold values and +reforms remain authoritative anchors. The indices start from the last year +with enacted rates (pension_age_projection_base.py). The maximum in each +forecast year is the maximum parameter's own projection, so a reform to it, +from any year, reaches the threshold as it did when the threshold was derived +after general uprating. """ -from typing import Any, Callable +from decimal import ROUND_HALF_UP, Decimal, localcontext +from sys import float_info +from typing import Any, Iterable from policyengine_core.parameters import Parameter, ParameterNode -from policyengine_core.parameters.parameter_at_instant import ParameterAtInstant +from policyengine_core.parameters.operations.uprate_parameters import ( + get_uprating_dependency_name, + uprate_parameters, +) + +from policyengine_uk.parameters.gov.dwp.pension_credit.pension_age_projection_base import ( + last_enacted_year, + projection_base_year, +) +from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( + CPI_OBSERVATION_MONTH_DAY, +) + + +CURRENCY_PRECISION = Decimal("0.01") + + +def projection_instant(year: int) -> str: + """Return the date sampled by fiscal-year parameter conversion.""" + return f"{year}-04-30" + + +def round_currency(amount: float) -> float: + """Round a published weekly amount to the nearest penny, halves up.""" + value = Decimal(repr(float(amount))) + with localcontext() as context: + context.prec = max(28, value.adjusted() + 4) + return float(value.quantize(CURRENCY_PRECISION, ROUND_HALF_UP)) + + +def project_maximum_savings_credit(amount: float, cpi_growth: float) -> float: + """Project maximum Savings Credit without reducing its cash amount.""" + return amount * (1 + max(cpi_growth, 0)) + + +def savings_credit_threshold( + guarantee: float, + maximum_savings_credit: float, + phase_in_rate: float, +) -> float: + """Solve the statutory maximum-Savings-Credit formula for its threshold.""" + if phase_in_rate <= 0: + raise ValueError("The Savings Credit phase-in rate must be positive.") + return guarantee - maximum_savings_credit / phase_in_rate + + +def _propagated_uprating(parameter: Parameter) -> Any: + """The uprating metadata a parameter has once metadata is propagated. + + The nearest ancestor that propagates an uprating to its descendants + overrides the parameter's own (propagate_parameter_metadata). + """ + node = getattr(parameter, "parent", None) + while node is not None: + metadata = node.metadata or {} + if metadata.get("propagate_metadata_to_children") and "uprating" in metadata: + return metadata["uprating"] + node = getattr(node, "parent", None) + return parameter.metadata.get("uprating") + + +def _add_at_path(root: ParameterNode, path: str, parameter: Parameter) -> None: + node = root + *ancestors, key = path.split(".") + for name in ancestors: + if name not in node.children: + node.add_child( + name, ParameterNode(name=f"{node.name}.{name}".lstrip("."), data={}) + ) + node = node.children[name] + node.add_child(key, parameter) + + +def uprated_values( + parameter: Parameter, parameters: ParameterNode, years: Iterable[int] +) -> dict[int, float]: + """A parameter's value in each year once general uprating has run. + + Uprates private copies of the parameter and of each index its uprating + reads, by Core's own rule, before general uprating has run on the tree. + Every explicit value, a reform's included, therefore anchors the years + after it exactly as it will on the parameter itself. + """ + copies = ParameterNode(data={}) + copied = set() + path, source = parameter.name, parameter + # Stop at a repeated path: Core reports the cycle when it uprates. + while source is not None and path not in copied: + copied.add(path) + copy = source.clone() + uprating = _propagated_uprating(source) + if uprating is not None: + copy.metadata["uprating"] = uprating + _add_at_path(copies, path, copy) + path = get_uprating_dependency_name(copy) + source = parameters.get_child(path) if path is not None else None + uprate_parameters(copies) + projected = copies.get_child(parameter.name) + return {year: float(projected(projection_instant(year))) for year in years} + + +def projected_maximum_values( + parameters: ParameterNode, relationship: str, base_year: int, final_year: int +) -> dict[int, float]: + """The maximum Savings Credit in each forecast year, never below zero.""" + maximum = getattr( + parameters.gov.dwp.pension_credit.savings_credit.maximum, relationship + ) + years = range(base_year + 1, final_year + 1) + return { + year: max(0, value) + for year, value in uprated_values(maximum, parameters, years).items() + } + + +def threshold_index_values( + base_guarantee: float, + base_threshold: float, + phase_in_rate: Parameter, + earnings_index: Parameter, + cpi_september: Parameter, + final_observation_year: int, + *, + base_year: int, + base_maximum: float | None = None, + price_index: Parameter | None = None, + maxima: dict[int, float] | None = None, + guarantees: dict[int, float] | None = None, +) -> dict[str, float]: + """Build a forecast index from the last enacted threshold. + + ``maxima`` gives the maximum Savings Credit in each forecast year. Without + it, the base maximum is projected by non-negative September CPI. + ``guarantees`` gives the guarantee's reformed uprated path; without it, + the own-base fallback projects the base guarantee by earnings. + """ + base_instant = f"{base_year}-04-01" + if base_threshold <= 0: + # The index scales the base, so a reform's zero threshold stays zero. + return {base_instant: 1.0} + base_phase_in_rate = float(phase_in_rate(projection_instant(base_year))) + maximum = ( + max(0, round_currency(base_phase_in_rate * (base_guarantee - base_threshold))) + if base_maximum is None + else base_maximum + ) + base_earnings_index = float(earnings_index(base_instant)) + values = {base_instant: 1.0} + + for year in range(base_year + 1, final_observation_year + 2): + observation_year = year - 1 + growth = ( + float(price_index(f"{year}-04-01")) + / float(price_index(f"{year - 1}-04-01")) + - 1 + if price_index is not None + else float(cpi_september(f"{observation_year}-{CPI_OBSERVATION_MONTH_DAY}")) + ) + maximum = ( + maxima[year] + if maxima is not None + else project_maximum_savings_credit(maximum, growth) + ) + guarantee = ( + guarantees[year] + if guarantees is not None + else base_guarantee + * (float(earnings_index(f"{year}-04-01")) / base_earnings_index) + ) + phase_in = float(phase_in_rate(projection_instant(year))) + threshold = ( + max(0, savings_credit_threshold(guarantee, maximum, phase_in)) + if phase_in > 0 + else values[f"{year - 1}-04-01"] * base_threshold + ) + # An exact zero index cannot anchor a later explicit reform (Core + # divides by the index at that anchor). Keep a positive, finite + # index even when the non-negative residual reaches zero or the + # reformed base is subnormal; ordinary monetary bases are unchanged. + ratio = threshold / base_threshold + values[f"{year}-04-01"] = min(max(ratio, float_info.min), float_info.max) + + return values + + +def _projection_parameter( + relationship: str, + base_guarantee: float, + base_threshold: float, + phase_in_rate: Parameter, + earnings_index: Parameter, + cpi_september: Parameter, + final_observation_year: int, + base_year: int, + base_maximum: float, + price_index: Parameter, + maxima: dict[int, float] | None = None, + guarantees: dict[int, float] | None = None, +) -> Parameter: + relationship_label = relationship.lower() + return Parameter( + (f"gov.dwp.pension_credit.savings_credit.threshold_uprating.{relationship}"), + data={ + "description": ( + f"Forecast index for the {relationship_label} Savings Credit " + "threshold. It derives the threshold from the reformed, uprated " + "standard minimum guarantee and a maximum Savings Credit amount " + "projected by non-negative September CPI. Explicit threshold " + "values remain authoritative. Projected values are not enacted " + "rates." + ), + "values": threshold_index_values( + base_guarantee, + base_threshold, + phase_in_rate, + earnings_index, + cpi_september, + final_observation_year, + base_year=base_year, + base_maximum=base_maximum, + price_index=price_index, + maxima=maxima, + guarantees=guarantees, + ), + "metadata": { + "unit": "/1", + "label": ( + f"Pension Credit Savings Credit {relationship_label} " + "threshold forecast index" + ), + "reference": [ + { + "title": "State Pension Credit Act 2002 section 3", + "href": ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/3" + ), + }, + { + "title": ( + "Social Security Administration Act 1992 section 150" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150" + ), + }, + { + "title": ( + "Social Security Administration Act 1992 section 150A" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/5/section/150A" + ), + }, + { + "title": ( + "Social Security Administration (Northern Ireland) " + "Act 1992 section 132" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/8/section/132" + ), + }, + { + "title": ( + "Social Security Administration (Northern Ireland) " + "Act 1992 section 132A" + ), + "href": ( + "https://www.legislation.gov.uk/ukpga/1992/8/section/132A" + ), + }, + { + "title": ( + "A14/2025 Housing Benefit uprating for the financial " + "year ending March 2027" + ), + "href": ( + "https://www.gov.uk/government/publications/" + "housing-benefit-adjudication-circulars-2025/" + "a142025-housing-benefit-uprating-for-the-financial-" + "year-ending-march-2027" + ), + }, + { + "title": ( + "ONS KAC3 average weekly earnings, whole economy, total pay" + ), + "href": ( + "https://www.ons.gov.uk/employmentandlabourmarket/" + "peopleinwork/earningsandworkinghours/timeseries/" + "kac3/lms" + ), + }, + { + "title": "ONS D7G7 Consumer Prices Index annual rate", + "href": ( + "https://www.ons.gov.uk/economy/" + "inflationandpriceindices/timeseries/d7g7/mm23" + ), + }, + ], + }, + }, + ) def last_projected_year(parameters: ParameterNode) -> int: - """The last year of the earnings index, which the projections run to.""" + """Last year of main's earnings projection.""" index = parameters.gov.economic_assumptions.indices.earnings_uprating return max(int(entry.instant_str[:4]) for entry in index.values_list) -def extend_after_last_stated( - parameter: Parameter, - last_year: int, - derive: Callable[[str, Any], Any], -) -> None: - """Add a value for each year after the parameter's last stated value. +def enacted_maximum( + parameters: ParameterNode, relationship: str, base_year: int +) -> float: + """Use the published maximum; a newer complete rates entry resets its base.""" + pc = parameters.gov.dwp.pension_credit + maximum = getattr(pc.savings_credit.maximum, relationship) + if max(int(entry.instant_str[:4]) for entry in maximum.values_list) >= base_year: + return max(0, float(maximum(projection_instant(base_year)))) + guarantee = getattr(pc.guarantee_credit.minimum_guarantee, relationship) + threshold = getattr(pc.savings_credit.threshold, relationship) + at = projection_instant(base_year) + return max( + 0, + round_currency( + float(pc.savings_credit.rate.phase_in(at)) + * (float(guarantee(at)) - float(threshold(at))) + ), + ) + + +def preserve_zero_anchor(parameter: Parameter, index: Parameter) -> None: + """Keep a latest explicit zero anchor zero under multiplicative uprating. - ``derive(sample, previous)`` gives the year's value from the other - parameters at its 30 April ``sample``; ``previous`` is the value in force - before it. Each value applies from 1 January, as uprating's do. + Core divides each future index by the index at the explicit anchor before + multiplying by its amount. Even finite positive indices can have an + infinite ratio, turning a zero anchor into ``0 * inf = NaN``. A constant + index after that anchor preserves its zero without changing earlier index + history or the parameter's explicit timeline. """ - last = max(parameter.values_list, key=lambda entry: entry.instant_str) - previous = last.value - for year in range(int(last.instant_str[:4]) + 1, last_year + 1): - previous = derive(f"{year}-04-30", previous) - parameter.values_list.append( - ParameterAtInstant(parameter.name, f"{year}-01-01", data=previous) - ) - parameter.values_list.sort(key=lambda entry: entry.instant_str, reverse=True) - parameter.mark_as_modified() + latest = max(parameter.values_list, key=lambda entry: entry.instant_str) + if latest.value == 0: + index.update(start=latest.instant_str, value=index(latest.instant_str)) -def project_savings_credit_threshold(parameters: ParameterNode) -> ParameterNode: - """Fill the years after each threshold's last stated value.""" - pc = parameters.gov.dwp.pension_credit - sc = pc.savings_credit - last_year = last_projected_year(parameters) - for relation, threshold in sc.threshold.children.items(): - guarantee = pc.guarantee_credit.minimum_guarantee.children[relation] - maximum = sc.maximum.children[relation] - - def derive(sample, previous): - phase_in = sc.rate.phase_in(sample) - if phase_in == 0: - return previous - return guarantee(sample) - maximum(sample) / phase_in - - extend_after_last_stated(threshold, last_year, derive) - sc.threshold.clear_parent_cache() +def project_savings_credit_threshold( + parameters: ParameterNode, + unreformed: ParameterNode | None = None, + enacted: ParameterNode | None = None, +) -> ParameterNode: + """Add forecast indices for each Savings Credit threshold. + + Forecasts follow the reformed guarantee's uprated path and the live + phase-in rate, as on main. A zero phase-in rate keeps the preceding + threshold: no Savings Credit is paid, so there is no residual to solve. + The enacted threshold remains the index denominator. A reformed own + threshold base instead projects its own reformed gap. Earnings and CPI + inputs come from the reform's tree. + + The maximum Savings Credit is read from the reform's tree as general + uprating will project it, so a reform to the maximum from the base year + or any later year moves the projected threshold. A reformed threshold + base takes precedence: its own reformed gap is projected, as before. + """ + savings_credit = parameters.gov.dwp.pension_credit.savings_credit + projection_base = ( + enacted + if enacted is not None + else (parameters if unreformed is None else unreformed) + ) + base_year = projection_base_year(projection_base) + base_instant = projection_instant(base_year) + enacted_savings_credit = projection_base.gov.dwp.pension_credit.savings_credit + base_thresholds = enacted_savings_credit.threshold + guarantees = ( + projection_base.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + ) + reformed_guarantees = ( + parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + ) + earnings_index = parameters.gov.economic_assumptions.indices.earnings_uprating + cpi_september = ( + parameters.gov.economic_assumptions.statutory_uprating_inputs.cpi_september + ) + projection = ParameterNode( + name=("gov.dwp.pension_credit.savings_credit.threshold_uprating"), + data={}, + ) + savings_credit.add_child("threshold_uprating", projection) + relationship_parameters = { + "SINGLE": (guarantees.SINGLE, base_thresholds.SINGLE), + "COUPLE": (guarantees.COUPLE, base_thresholds.COUPLE), + } + final_observation_year = last_projected_year(parameters) - 1 + # gov.benefit_uprating_cpi is a YAML alias until parameter propagation. + # Read its concrete generated September-CPI index at this generation stage. + price_index = parameters.gov.economic_assumptions.indices.september_cpi_uprating + for relationship, ( + guarantee_parameter, + threshold_parameter, + ) in relationship_parameters.items(): + maximum = enacted_maximum(projection_base, relationship, base_year) + maximum_parameter = getattr(savings_credit.maximum, relationship) + enacted_maximum_parameter = getattr( + enacted_savings_credit.maximum, relationship + ) + # Enter the maximum a rates update without one implies, but never + # over a changed maximum timeline, even if its anchors all precede + # the new base. Entries compare their names, dates and values. + if ( + last_enacted_year(maximum_parameter) < base_year + and maximum_parameter.values_list == enacted_maximum_parameter.values_list + ): + maximum_parameter.update(start=f"{base_year}-04-01", value=maximum) + maxima = projected_maximum_values( + parameters, relationship, base_year, final_observation_year + 1 + ) + guarantee_path = uprated_values( + getattr(reformed_guarantees, relationship), + parameters, + range(base_year + 1, final_observation_year + 2), + ) + reformed_threshold = getattr(savings_credit.threshold, relationship) + if ( + max(entry.instant_str for entry in reformed_threshold.values_list) + < base_instant + ): + # A pre-base reform can remove the newer enacted anchor. Start + # its forecast at the common base, where the index is defined. + reformed_threshold.update( + start=base_instant, value=reformed_threshold(base_instant) + ) + if reformed_threshold(base_instant) != threshold_parameter(base_instant): + threshold_parameter = reformed_threshold + guarantee_parameter = getattr(reformed_guarantees, relationship) + maxima = None + guarantee_path = None + maximum = max( + 0, + round_currency( + float(enacted_savings_credit.rate.phase_in(base_instant)) + * ( + float(guarantee_parameter(base_instant)) + - float(threshold_parameter(base_instant)) + ) + ), + ) + index = _projection_parameter( + relationship, + float(guarantee_parameter(base_instant)), + float(threshold_parameter(base_instant)), + savings_credit.rate.phase_in, + earnings_index, + cpi_september, + final_observation_year, + base_year, + maximum, + price_index, + maxima, + guarantee_path, + ) + projection.add_child(relationship, index) + preserve_zero_anchor(reformed_threshold, index) return parameters diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/maximum.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/maximum.yaml index 52b6028c9b..9f5704aa51 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/maximum.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/maximum.yaml @@ -1,4 +1,4 @@ -description: Maximum weekly Savings Credit, used to derive projected income thresholds. +description: Maximum weekly Savings Credit, used to derive projected income thresholds and higher pension-age Housing Benefit allowances. SINGLE: values: 2026-04-01: 17.96 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml index 95a66f4196..d58d6adff4 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/threshold.yaml @@ -1,4 +1,12 @@ -description: Income threshold at which Savings Credit is eligible. +description: >- + Income threshold at which Savings Credit is eligible. Values after the last + enacted rate are derived so that the earnings-projected standard minimum + guarantee and the CPI-projected maximum Savings Credit remain consistent + with the statutory 60% formula; they are not enacted future rates. Forecast + thresholds and protected pension-age Housing Benefit allowances follow the + reformed guarantee and maximum as uprated, as on main, unless their own + base-year value is reformed. The live phase-in rate determines the threshold; + a zero rate keeps the preceding threshold and pays no Savings Credit. SINGLE: values: 2015-04-06: 126.5 @@ -11,10 +19,20 @@ SINGLE: 2022-04-01: 158.47 2023-04-01: 174.49 2024-04-01: 189.8 - 2025-04-01: 198.27 + 2025-04-01: + value: 198.27 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025 art. 29 + href: https://www.legislation.gov.uk/uksi/2025/295/article/29/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025 art. 29 + href: https://www.legislation.gov.uk/nisr/2025/64/article/29/made + - title: Benefit and pension rates 2025 to 2026 + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 208.07 metadata: label: Pension Credit Savings Credit income threshold (single) + uprating: gov.dwp.pension_credit.savings_credit.threshold_uprating.SINGLE COUPLE: values: 2015-04-06: 201.8 @@ -27,14 +45,24 @@ COUPLE: 2022-04-01: 251.7 2023-04-01: 277.12 2024-04-01: 301.22 - 2025-04-01: 314.34 + 2025-04-01: + value: 314.34 + metadata: + reference: + - title: The Social Security Benefits Up-rating Order 2025 art. 29 + href: https://www.legislation.gov.uk/uksi/2025/295/article/29/made + - title: The Social Security Benefits Up-rating Order (Northern Ireland) 2025 art. 29 + href: https://www.legislation.gov.uk/nisr/2025/64/article/29/made + - title: Benefit and pension rates 2025 to 2026 + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2025-to-2026/benefit-and-pension-rates-2025-to-2026 2026-04-01: 329.75 metadata: label: Pension Credit Savings Credit income threshold (couple) + uprating: gov.dwp.pension_credit.savings_credit.threshold_uprating.COUPLE metadata: unit: currency-GBP period: week - # Projected after uprating by create_savings_credit_threshold.py: + # Forecast indices built by create_savings_credit_threshold.py: # standard minimum guarantee - Savings Credit maximum / phase-in rate. # Published thresholds keep their own rounding; only projections are derived. propagate_metadata_to_children: true diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index e5decfdb1a..9a9fdb349e 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -138,6 +138,8 @@ def _install_parameters(system, parameters: ParameterNode) -> None: add_triple_lock, add_september_cpi_uprating, create_economic_assumption_indices, + project_savings_credit_threshold, + project_pension_age_allowances, add_threshold_indexation, add_uc_standard_allowance_uprating, add_uc_health_element_uprating, @@ -148,15 +150,22 @@ def _install_parameters(system, parameters: ParameterNode) -> None: def _add_generated_parameters( parameters: ParameterNode, generated: Optional[List[ParameterAssignment]] = None, + unreformed: Optional[ParameterNode] = None, + enacted: Optional[ParameterNode] = None, ) -> ParameterNode: """Run the generation steps, writing changes to generated parameters. Each change in ``generated`` is written onto its parameter just after the step that generates it, so the later steps, and uprating, read it. + ``enacted`` supplies the YAML as loaded, before any modifier edits, for + the Pension Credit and Housing Benefit projection bases. """ pending = list(generated or []) for step in GENERATION_STEPS: - parameters = step(parameters) + if step in (project_savings_credit_threshold, project_pension_age_allowances): + parameters = step(parameters, unreformed=unreformed, enacted=enacted) + else: + parameters = step(parameters) if pending: defines = tree_defines(parameters) for assignment in [a for a in pending if defines(a.path)]: @@ -176,6 +185,7 @@ def process_parameter_tree( unreformed: Optional[ParameterNode] = None, annualise: bool = True, generated: Optional[List[ParameterAssignment]] = None, + enacted: Optional[ParameterNode] = None, ) -> Tuple[ParameterNode, Dict[str, Timeline]]: """Project and annualise a statutory parameter tree. @@ -186,13 +196,13 @@ def process_parameter_tree( - triple lock calculations for state pensions - earnings and September CPI uprating - economic assumption indices + - Pension Credit threshold and protected Housing Benefit forecast indices - income tax and NICs threshold indexation after the freeze - Universal Credit standard allowance uprating (UC Act 2025 s. 1) - Universal Credit LCWRA element uprating (UC Act 2025 s. 3 freeze) - parameter uprating and backdating - - projections derived from other parameters after uprating: the - Savings Credit threshold, the pension-age Housing Benefit allowances - and the Carer's Allowance earnings limit + - the Carer's Allowance earnings limit, projected from the uprated + national living wage - conversion to fiscal year parameters Returns the tree and each parameter's timeline after projection (for gov @@ -208,16 +218,19 @@ def process_parameter_tree( ``generated`` holds the reform's changes to generated parameters, each written just after the step that generates it (see ``_add_generated_parameters``). + ``enacted`` retains the YAML projection bases separately from runtime + modifier edits. When omitted, the input tree supplies those bases, so + an incomplete YAML rates update is still rejected. """ validate_ni_scale_compatibility(parameters) - parameters = _add_generated_parameters(parameters, generated) + parameters = _add_generated_parameters(parameters, generated, unreformed, enacted) # Create baseline parameters for reform comparisons if annualise: baseline = ( parameters.clone() if unreformed is None - else _add_generated_parameters(unreformed) + else _add_generated_parameters(unreformed, enacted=enacted) ) parameters.add_child("baseline", baseline) @@ -226,12 +239,12 @@ def process_parameter_tree( propagate_parameter_metadata(parameters) with canonical_ni_uprating_references(parameters): uprate_parameters(parameters) - project_savings_credit_threshold(parameters) - project_pension_age_allowances(parameters) + # The Savings Credit threshold and pension-age Housing Benefit + # allowances are generation steps (GENERATION_STEPS); the Carer's + # Allowance earnings limit is still projected from the uprated + # national living wage here. project_carers_allowance_earnings_limit(parameters) if annualise: - project_savings_credit_threshold(parameters.baseline) - project_pension_age_allowances(parameters.baseline) project_carers_allowance_earnings_limit(parameters.baseline) parameters = backdate_parameters(parameters, BACKDATED_FROM) # Prior law's projected path for every parameter a reform can change: the @@ -308,6 +321,8 @@ def reset_parameters(self) -> None: self._parameters_before_reset = current self.load_parameters(self.parameters_dir) _install_parameters(self, add_removed_parameter_aliases(self.parameters)) + # Keep the YAML anchors before a simulation modifier can edit them. + self._enacted_parameters = self.parameters.clone() def process_parameters(self) -> None: """Process and transform parameters with UK-specific adjustments. @@ -497,7 +512,9 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: # reloaded parameters are part of it. self._raw_parameters = raw.clone() if not assignments: - parameters, self._projected_timelines = process_parameter_tree(raw) + parameters, self._projected_timelines = process_parameter_tree( + raw, enacted=self._enacted_parameters + ) _install_parameters(self, parameters) return # Changes to parameters processing generates are written as each is @@ -511,11 +528,17 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: # Not generated either: the error the lookup gives. raw.get_child(assignment.path) unreformed = raw.clone() - projected = prior_law_projection(on_timeline, unreformed, statutory, generated) + projected = prior_law_projection( + on_timeline, + unreformed, + statutory, + generated, + enacted=self._enacted_parameters, + ) for assignment in on_timeline: write_assignment(raw.get_child(assignment.path), assignment, projected) parameters, self._projected_timelines = process_parameter_tree( - raw, unreformed, generated=generated + raw, unreformed, generated=generated, enacted=self._enacted_parameters ) _install_parameters(self, parameters) @@ -551,6 +574,7 @@ def __init__(self, capital_gains_projection: CapitalGainsProjection | None = Non global _processed_parameters_cache, _statutory_parameters_cache global _projected_timelines_cache if _processed_parameters_cache is not None: + self._enacted_parameters = _statutory_parameters_cache.clone() # Fast path: clone pre-processed parameters rather than re-running # the full pipeline (saves ~0.5s from convert_to_fiscal_year_parameters). # Reforms rebuild the parameters from the statutory tree through @@ -699,6 +723,7 @@ def prior_law_projection( unreformed: ParameterNode, statutory: bool = True, generated: Optional[List[ParameterAssignment]] = None, + enacted: Optional[ParameterNode] = None, ) -> Callable[[str], Optional[Timeline]]: """Prior law's projected timeline, by parameter name, for a reform. @@ -713,7 +738,8 @@ def prior_law_projection( an error. ``unreformed`` is the tree the changes are written onto; ``statutory`` says it is the YAML as loaded, whose projection without changes is cached. ``generated`` holds the reform's changes to generated - parameters, which every pass makes too. + parameters, which every pass makes too. ``enacted`` keeps the YAML bases + separate from modifier edits through these intermediate projections too. """ needing = sorted( { @@ -728,7 +754,11 @@ def prior_law_projection( projected_timelines() if statutory and not generated else process_parameter_tree( - unreformed.clone(), annualise=False, generated=generated + unreformed.clone(), + unreformed=unreformed, + annualise=False, + generated=generated, + enacted=enacted, )[1] ) environment = {path: start.get(path) for path in needing} @@ -747,7 +777,11 @@ def lookup(name: str) -> Optional[Timeline]: tree.get_child(assignment.path), assignment, lookup ) updated[path] = process_parameter_tree( - tree, annualise=False, generated=generated + tree, + unreformed=unreformed, + annualise=False, + generated=generated, + enacted=enacted, )[1].get(path) if _same_timelines(updated, environment): return lookup diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/protected_pension_age_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/protected_pension_age_rates.yaml new file mode 100644 index 0000000000..82e5edb3c1 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/protected_pension_age_rates.yaml @@ -0,0 +1,55 @@ +# Protected pre-April-2021 pension-age rates: SI 2025/295 Sch 6, table +# paras 1(1)(b) and 1(2)(b); NI SR 2025/64 Sch 6 gives the same amounts. +# https://www.legislation.gov.uk/uksi/2025/295/schedule/6/made +# https://www.legislation.gov.uk/nisr/2025/64/schedule/6/made +# Taper: SI 2006/214 reg 51 (65% of excess applicable income). +# https://www.legislation.gov.uk/uksi/2006/214/regulation/51 +# Eligibility, assessed income, premiums and rent deductions are supplied to +# isolate the personal allowance's effect on the tapered award. + +- name: Protected single pensioner receives the enacted 2025 allowance + period: 2025 + absolute_error_margin: 0.01 + input: + age: 80 + is_claimant_or_partner: true + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + benefits_premiums: 0 + housing_benefit_applicable_income: 13_000 + benunit_rent: 10_000 + LHA_eligible: false + housing_benefit_meals_deduction: 0 + housing_benefit_non_dep_deductions: 0 + output: + housing_benefit_applicable_amount: 12_708.80 # £244.40 * 52 + housing_benefit_entitlement: 9_810.72 # £10,000 - 0.65 * (£13,000 - £12,708.80) + +- name: Protected pensioner couple receives the enacted 2025 allowance + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + is_claimant_or_partner: true + partner: + age: 80 + is_claimant_or_partner: true + benunits: + benunit: + members: [claimant, partner] + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + benefits_premiums: 0 + housing_benefit_applicable_income: 19_500 + benunit_rent: 10_000 + LHA_eligible: false + housing_benefit_meals_deduction: 0 + housing_benefit_non_dep_deductions: 0 + households: + household: + members: [claimant, partner] + output: + housing_benefit_applicable_amount: 19_032 # £366 * 52 + housing_benefit_entitlement: 9_695.80 # £10,000 - 0.65 * (£19,500 - £19,032) diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/standard_minimum_guarantee.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/standard_minimum_guarantee.yaml index 841ba5b6bc..e83acff406 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/standard_minimum_guarantee.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/guarantee_credit/standard_minimum_guarantee.yaml @@ -52,3 +52,31 @@ members: [claimant, partner] output: standard_minimum_guarantee: 19_625.671 # 377.41675 * 52 + +- name: Case 3, 2025 single-person standard minimum guarantee uses the published rate. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + benunits: + benunit: + members: [person1] + output: + standard_minimum_guarantee: 11_809.2 + +- name: Case 4, 2025 couple standard minimum guarantee uses the published rate. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + person2: + age: 78 + benunits: + benunit: + members: [person1, person2] + output: + standard_minimum_guarantee: 18_023.2 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml index 8e32aebaa7..b5f6d59b0d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.yaml @@ -99,3 +99,70 @@ output: # 12,000 > 208.07 * 52, but no claimant reached SPA before the cutoff. is_savings_credit_eligible: false + +- name: Case 6, 2025 single claimant below the published threshold is ineligible. + period: 2025 + input: + people: + person: + age: 80 + state_pension_age: 60 + benunits: + benunit: + members: [person] + relation_type: SINGLE + # One penny below 198.27 * 52. + savings_credit_income: 10_310.03 + output: + is_savings_credit_eligible: false + +- name: Case 7, 2025 couple below the published threshold is ineligible. + period: 2025 + input: + people: + person: + age: 80 + state_pension_age: 60 + person2: + age: 78 + state_pension_age: 60 + benunits: + benunit: + members: [person, person2] + relation_type: COUPLE + savings_credit_income: 16_000 + output: + is_savings_credit_eligible: false + +- name: Case 8, 2025 single claimant above the published threshold is eligible. + period: 2025 + input: + people: + person: + age: 80 + state_pension_age: 60 + benunits: + benunit: + members: [person] + relation_type: SINGLE + # One penny above 198.27 * 52. + savings_credit_income: 10_310.05 + output: + is_savings_credit_eligible: true + +- name: Case 9, 2027 uses the threshold derived from the two projection bases. + period: 2027 + input: + people: + person: + age: 82 + state_pension_age: 60 + benunits: + benunit: + members: [person] + relation_type: SINGLE + savings_credit_income: 11_220 + output: + # Plain CPI: 208.07 * 1.035 * 52 = 11,198.33, below this income. + # Derived: (247.282 - 18.588570 / 0.6) * 52 = 11,247.65, above it. + is_savings_credit_eligible: false 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 4c39f288ca..b3b51606d4 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 @@ -26,6 +26,33 @@ output: savings_credit: 684.216 +- name: Case 3, 2025 single claimant uses the corrected threshold. + period: 2025 + absolute_error_margin: 0.01 + input: + age: 80 + is_savings_credit_eligible: true + relation_type: SINGLE + savings_credit_income: 11_000 + pension_credit_income: 11_000 + output: + is_pension_credit_eligible: true + # 0.6 * (11,000 - 198.27 * 52). + savings_credit: 413.976 + +- name: Case 4, 2025 couple uses the corrected threshold. + period: 2025 + absolute_error_margin: 0.01 + input: + age: 80 + is_savings_credit_eligible: true + relation_type: COUPLE + savings_credit_income: 17_000 + pension_credit_income: 17_000 + output: + is_pension_credit_eligible: true + # 0.6 * (17,000 - 314.34 * 52). + savings_credit: 392.592 # 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. diff --git a/policyengine_uk/tests/test_dated_reform_routes.py b/policyengine_uk/tests/test_dated_reform_routes.py index 729327bb0a..dec77f72fe 100644 --- a/policyengine_uk/tests/test_dated_reform_routes.py +++ b/policyengine_uk/tests/test_dated_reform_routes.py @@ -1213,7 +1213,7 @@ def test_prior_law_that_never_settles_is_an_error(monkeypatch): calls = {"n": 0} - def unsettled(tree, unreformed=None, annualise=True, generated=None): + def unsettled(tree, unreformed=None, annualise=True, generated=None, enacted=None): calls["n"] += 1 entry = ParameterAtInstant( UC_SINGLE_OLD, "2026-04-01", data={"value": float(calls["n"])} diff --git a/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py new file mode 100644 index 0000000000..5d2ee9059f --- /dev/null +++ b/policyengine_uk/tests/test_housing_benefit_protected_allowance_uprating.py @@ -0,0 +1,212 @@ +import pytest + +from policyengine_uk import Simulation, system +from policyengine_uk.parameters.gov.dwp.housing_benefit.allowances.create_pension_age_allowances import ( + project_retained_uplift, +) + + +def _parameters(): + return system.parameters + + +@pytest.mark.parametrize( + ( + "allowance_name", + "index_name", + "base_allowance", + "expected_2027_allowance", + ), + [ + ("single", "single", 256.0, 238 * 1.039 + 17.96 * 1.035), + ("lone_parent", "lone_parent", 256.0, 238 * 1.039 + 17.96 * 1.035), + ("couple", "couple", 383.35, 363.25 * 1.039 + 20.10 * 1.035), + ], +) +def test_protected_allowances_use_relationship_specific_component_projections( + allowance_name, + index_name, + base_allowance, + expected_2027_allowance, +): + """The 2026 bases are guarantee plus retained Savings Credit uplift. + + The retained uplift rises by September 2026 CPI, 3.5% (ONS, 21 October + 2026); the guarantee by the statutory earnings floor. + """ + allowances = _parameters().gov.dwp.housing_benefit.allowances + index = getattr(allowances.protected_pension_age_uprating, index_name) + allowance = getattr(allowances, allowance_name).aged + expected = base_allowance * index("2027-04-01") / index("2026-04-01") + + assert allowance("2026-04-01") == base_allowance + assert expected == pytest.approx(expected_2027_allowance, abs=0.001) + assert allowance("2027-04-01") == pytest.approx(expected) + assert allowance("2027-04-01") != pytest.approx( + base_allowance + * _parameters().gov.benefit_uprating_cpi("2027-04-01") + / _parameters().gov.benefit_uprating_cpi("2026-04-01") + ) + + +def test_protected_allowances_cover_guarantee_and_maximum_savings_credit(): + parameters = _parameters() + allowances = parameters.gov.dwp.housing_benefit.allowances + pension_credit = parameters.gov.dwp.pension_credit + guarantee = pension_credit.guarantee_credit.minimum_guarantee + threshold = pension_credit.savings_credit.threshold + + for year in range(2027, 2040): + instant = f"{year}-04-01" + phase_in_rate = float(pension_credit.savings_credit.rate.phase_in(instant)) + for relationship, allowance_name in ( + ("SINGLE", "single"), + ("SINGLE", "lone_parent"), + ("COUPLE", "couple"), + ): + weekly_guarantee = float(getattr(guarantee, relationship)(instant)) + weekly_threshold = float(getattr(threshold, relationship)(instant)) + maximum_savings_credit = phase_in_rate * ( + weekly_guarantee - weekly_threshold + ) + protected_allowance = float( + getattr(allowances, allowance_name).aged(instant) + ) + assert protected_allowance == pytest.approx( + weekly_guarantee + maximum_savings_credit, abs=1e-9 + ), (relationship, year) + + +def test_retained_uplift_projection_does_not_reduce_the_cash_amount(): + assert project_retained_uplift(18, -0.02) == 18 + + +def _boundary_simulation(couple: bool, private_pension: float) -> Simulation: + year = 2035 + names = ["claimant", "partner"] if couple else ["claimant"] + people = { + name: { + "age": {year: 85}, + "private_pension_income": { + year: private_pension if name == "claimant" else 0 + }, + } + for name in names + } + return Simulation( + situation={ + "people": people, + "benunits": {"benunit": {"members": names}}, + "households": { + "household": { + "members": names, + "country": {year: "ENGLAND"}, + "local_authority": {year: "MAIDSTONE"}, + "tenure_type": {year: "RENT_FROM_COUNCIL"}, + "rent": {year: 10_000}, + "council_tax": {year: 2_000}, + "savings": {year: 0}, + } + }, + } + ) + + +def _boundary_calculation(couple: bool, private_pension: float) -> dict[str, float]: + simulation = _boundary_simulation(couple, private_pension) + return { + variable: float(simulation.calculate(variable, 2035)[0]) + for variable in ( + "guarantee_credit", + "housing_benefit", + "council_tax_benefit", + "household_net_income", + "tv_licence", + ) + } + + +@pytest.mark.parametrize("couple", [False, True]) +def test_net_income_does_not_fall_when_guarantee_credit_ends(couple): + """Test £1 income steps across each modeled 2035 guarantee boundary. + + A normal marginal withdrawal may occur, but there must be no discontinuous + loss of Housing Benefit or Council Tax Reduction when Guarantee Credit ends. + """ + # Locate the actual cutoff, including income-tax deductions. Forecasts and + # tax thresholds can change independently of this discontinuity regression. + income_below, income_above = 0, 100_000 + assert ( + _boundary_simulation(couple, income_below).calculate("guarantee_credit", 2035)[ + 0 + ] + > 0 + ) + assert ( + _boundary_simulation(couple, income_above).calculate("guarantee_credit", 2035)[ + 0 + ] + == 0 + ) + while income_above - income_below > 1: + midpoint = (income_below + income_above) // 2 + if ( + _boundary_simulation(couple, midpoint).calculate("guarantee_credit", 2035)[ + 0 + ] + > 0 + ): + income_below = midpoint + else: + income_above = midpoint + + below = _boundary_calculation(couple, income_below) + above = _boundary_calculation(couple, income_above) + + assert below["guarantee_credit"] > 0 + assert above["guarantee_credit"] == 0 + assert above["housing_benefit"] >= below["housing_benefit"] - 1 + assert above["council_tax_benefit"] >= below["council_tax_benefit"] - 1 + assert above["household_net_income"] + above["tv_licence"] >= ( + below["household_net_income"] + below["tv_licence"] - 0.01 + ) + + +@pytest.mark.parametrize( + ("allowance_name", "weekly_amount"), + [("single", 244.40), ("lone_parent", 244.40), ("couple", 366.00)], +) +def test_protected_allowances_match_enacted_2025_amounts(allowance_name, weekly_amount): + """Pin the published protected-cohort rates, not a computed CPI estimate. + + SI 2025/295 Sch 6 and NI SR 2025/64 Sch 6, table paras 1(1)(b) + and 1(2)(b), prescribe these amounts independently of the model. + """ + allowance = getattr( + _parameters().gov.dwp.housing_benefit.allowances, allowance_name + ).aged + assert allowance("2025-04-30") == weekly_amount + + +@pytest.mark.parametrize( + ("allowance_name", "relationship"), + [("single", "SINGLE"), ("lone_parent", "SINGLE"), ("couple", "COUPLE")], +) +def test_later_cohort_allowances_equal_the_guarantee_in_every_year( + allowance_name, relationship +): + """The allowances for those reaching State Pension age from April 2021 + equal the standard minimum guarantee in every enacted year (SI 2006/214 + Sch 3 para 1(1)(c) and (2)(c)), and project by the same earnings index, + so they stay equal.""" + parameters = _parameters() + allowance = getattr( + parameters.gov.dwp.housing_benefit.allowances, allowance_name + ).aged_from_cutoff + guarantee = getattr( + parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee, + relationship, + ) + for year in range(2021, 2041): + instant = f"{year}-04-30" + assert allowance(instant) == pytest.approx(guarantee(instant)), year diff --git a/policyengine_uk/tests/test_parameter_cache_apis.py b/policyengine_uk/tests/test_parameter_cache_apis.py index 08b6e5a822..786dd8ee9b 100644 --- a/policyengine_uk/tests/test_parameter_cache_apis.py +++ b/policyengine_uk/tests/test_parameter_cache_apis.py @@ -38,6 +38,7 @@ def small_system(): system.dated_assignments = [] system.legacy_overrides = [] system._projected_timelines = {} + system._enacted_parameters = tbs_module.statutory_parameters() system._raw_parameters = None system._parameters_before_reset = None system.capital_gains_projection = None diff --git a/policyengine_uk/tests/test_pension_age_projection_invariants.py b/policyengine_uk/tests/test_pension_age_projection_invariants.py new file mode 100644 index 0000000000..74f7095c75 --- /dev/null +++ b/policyengine_uk/tests/test_pension_age_projection_invariants.py @@ -0,0 +1,658 @@ +"""Projection contracts across forecasts, enacted history and combined reforms. + +The differential reference below freezes the two builders merged in #2576 +(main eb0b8087). It deliberately keeps their generation-after-uprating order. +For unreformed forecasts the reconciled builders must reproduce those +amounts, including main’s earnings and CPI floors. Main read the maximum +Savings Credit after uprating, so for any path of maximum reforms they must +reproduce main too, apart from the additional threshold residual floor. +Guarantee reforms retain main's behavior too; the residual floor has its own +properties. +""" + +from typing import Any, Callable +from math import isfinite + +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters import Parameter, ParameterNode +from policyengine_core.parameters.operations.propagate_parameter_metadata import ( + propagate_parameter_metadata, +) +from policyengine_core.parameters.operations.uprate_parameters import uprate_parameters +from policyengine_core.parameters.parameter_at_instant import ParameterAtInstant + +from policyengine_uk.tests.test_pension_credit_uprating import ( + FIRST_PROJECTION_YEAR, + HB_NAME, + HB_RELATIONS, + LAST_INDEX_YEAR, + RAW_HB, + RAW_SC, + RAW_SMG, + SC_NAME, + SMG_NAME, + growth, + higher_allowances, + paths, + phase_in_paths, + project_synthetic_parameters, + synthetic_parameter_tree, + synthetic_parameters, +) +from policyengine_uk.tests.test_savings_credit_threshold_projection import ( + NEXT_YEAR_RATES, +) + + +PROJECTED_PATHS = ( + *(f"{SMG_NAME}.{relation}" for relation in ("SINGLE", "COUPLE")), + *( + f"{SC_NAME}.{kind}.{relation}" + for kind in ("threshold", "maximum") + for relation in ("SINGLE", "COUPLE") + ), + *( + f"{HB_NAME}.{kind}.{cohort}" + for kind in HB_RELATIONS + for cohort in ("aged", "aged_from_cutoff") + ), +) +REFORM_PATHS = ( + *(f"{SMG_NAME}.{relation}" for relation in ("SINGLE", "COUPLE")), + *(f"{SC_NAME}.threshold.{relation}" for relation in ("SINGLE", "COUPLE")), + f"{SC_NAME}.rate.phase_in", + *(f"{HB_NAME}.{kind}.aged" for kind in HB_RELATIONS), +) +MAXIMUM_PATHS = tuple( + f"{SC_NAME}.maximum.{relation}" for relation in ("SINGLE", "COUPLE") +) +# The maximum's uprating index (maximum.yaml). +BENEFIT_UPRATING_CPI = "gov.benefit_uprating_cpi" +# Cover zeros, subnormal values and large monetary/rate reforms, without +# overflowing intermediate products of independently generated float64 values. +nonnegative_amount = st.floats( + min_value=0, max_value=1e100, allow_nan=False, allow_infinity=False +) +nonnegative_rate = st.floats( + min_value=0, max_value=100, allow_nan=False, allow_infinity=False +) + + +@st.composite +def maximum_path(draw): + """One to three explicit anchors, from the year before the base onwards.""" + years = draw( + st.lists( + st.integers(FIRST_PROJECTION_YEAR - 2, LAST_INDEX_YEAR), + min_size=1, + max_size=3, + unique=True, + ) + ) + return [(year, draw(nonnegative_amount)) for year in sorted(years)] + + +@st.composite +def maximum_reforms(draw): + """A path of maximum Savings Credit anchors for one or both relationships.""" + reformed = draw(st.sets(st.sampled_from(MAXIMUM_PATHS), min_size=1)) + return {path: draw(maximum_path()) for path in sorted(reformed)} + + +@st.composite +def combined_reforms(draw): + """Change every independent component, including the lone parent's own base + and a path of maximum Savings Credit anchors for each relationship.""" + reforms = {} + for path in REFORM_PATHS: + year = draw(st.integers(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR)) + value = draw( + nonnegative_rate if path.endswith("phase_in") else nonnegative_amount + ) + reforms[path] = (year, value) + for path in MAXIMUM_PATHS: + reforms[path] = draw(maximum_path()) + return reforms + + +def _apply_reforms(parameters, reforms): + """Apply each change from April of its year; a list is a path of anchors.""" + for path, change in reforms.items(): + # In year order, so each open-ended update keeps the earlier anchors. + for year, value in change if isinstance(change, list) else [change]: + parameters.get_child(path).update(start=f"{year}-04-01", value=value) + + +@settings(max_examples=60, deadline=None) +@given(path=paths, reforms=combined_reforms(), advance_base=st.booleans()) +@example( + path=[(0.039, 0.035)], + reforms={path: (2026, 0.0) for path in REFORM_PATHS} + | {path: [(2026, 0.0)] for path in MAXIMUM_PATHS}, + advance_base=False, +) +@example( + path=[(0.0, 0.0)], + reforms={path: (2026, 0.0) for path in REFORM_PATHS} + | {path: [(2025, 0.0)] for path in MAXIMUM_PATHS}, + advance_base=True, +) +@example( + path=[(0.039, -0.06), (-0.06, 0.15)], + reforms={ + f"{SMG_NAME}.SINGLE": (2026, 300), + f"{SMG_NAME}.COUPLE": (2026, 459), + f"{SC_NAME}.threshold.SINGLE": (2026, 0), + f"{SC_NAME}.threshold.COUPLE": (2026, 1), + f"{SC_NAME}.rate.phase_in": (2026, 0), + f"{HB_NAME}.single.aged": (2026, 220), + f"{HB_NAME}.lone_parent.aged": (2026, 0), + f"{HB_NAME}.couple.aged": (2026, 300), + f"{SC_NAME}.maximum.SINGLE": [(2025, 1e100), (2028, 0), (2031, 40)], + f"{SC_NAME}.maximum.COUPLE": [(2030, 5e-324)], + }, + advance_base=False, +) +@example( + path=[(0.039, 0.035)], + reforms={f"{SC_NAME}.maximum.SINGLE": [(2025, 0.0)]}, + advance_base=True, +) +@example( + path=[(0.039, 0.035)], + reforms={ + f"{SC_NAME}.maximum.SINGLE": [(2026, RAW_SC.maximum.SINGLE("2026-04-30"))] + }, + advance_base=True, +) +def test_combined_reforms_build_and_never_project_negative_amounts( + path, reforms, advance_base +): + """Building succeeds even for zero bases and allowances below guarantees. + + The enacted tree supplies the base year; future reform anchors must not + turn into a mismatched set of supposedly enacted years. Maximum Savings + Credit paths, including anchors before the base year, combine with every + other reform, including after a complete rates update advances the base. + Independent component reforms must preserve the maximum's own uprating. + """ + parameters = synthetic_parameter_tree(path) + if advance_base: + for name, amount in NEXT_YEAR_RATES.items(): + parameters.get_child(name).update(start="2027-04-01", value=amount) + unreformed = parameters.clone() + _apply_reforms(parameters, reforms) + changed_maxima = { + name + for name in MAXIMUM_PATHS + if parameters.get_child(name).values_list + != unreformed.get_child(name).values_list + } + projected = project_synthetic_parameters(parameters, unreformed) + main = _main_parameters( + path, reforms={name: reforms[name] for name in MAXIMUM_PATHS if name in reforms} + ) + + for node in ( + projected.gov.dwp.pension_credit.savings_credit.threshold_uprating, + projected.gov.dwp.housing_benefit.allowances.protected_pension_age_uprating, + ): + for index in node.children.values(): + assert all( + isfinite(entry.value) and entry.value > 0 for entry in index.values_list + ) + + for name in PROJECTED_PATHS: + parameter = projected.get_child(name) + for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + assert parameter(f"{year}-04-30") >= 0, (name, year, reforms) + # A no-op maximum update still permits the ordinary rates-update fill. + if name in changed_maxima: + assert parameter(f"{year}-04-30") == pytest.approx( + main.get_child(name)(f"{year}-04-30"), rel=1e-12 + ), (name, year, reforms, advance_base) + + +@pytest.mark.parametrize( + "own_path,relationship,index_path", + [ + *( + ( + f"{SC_NAME}.threshold.{relationship}", + relationship, + f"{SC_NAME}.threshold_uprating.{relationship}", + ) + for relationship in ("SINGLE", "COUPLE") + ), + *( + ( + f"{HB_NAME}.{kind}.aged", + relationship, + f"{HB_NAME}.protected_pension_age_uprating.{kind}", + ) + for kind, relationship in HB_RELATIONS.items() + ), + ], +) +def test_zero_forecast_anchor_survives_an_extreme_component_rebound( + own_path, relationship, index_path +): + """A positive index floor must not turn an explicit zero into 0 * inf.""" + parameters = synthetic_parameter_tree([(0.039, 0.035)]) + unreformed = parameters.clone() + guarantee = f"{SMG_NAME}.{relationship}" + maximum = f"{SC_NAME}.maximum.{relationship}" + if ".threshold." in own_path: + reforms = { + guarantee: (2028, 1e100), + maximum: [(2026, 1e100), (2028, 0)], + } + rebound_path = guarantee + else: + reforms = { + guarantee: (2026, 0), + maximum: [(2027, 0), (2028, 1e100)], + } + rebound_path = maximum + _apply_reforms(parameters, reforms | {own_path: (2027, 0)}) + + projected = project_synthetic_parameters(parameters, unreformed) + + assert projected.get_child(own_path)("2026-04-30") == unreformed.get_child( + own_path + )("2026-04-30") + assert projected.get_child(rebound_path)("2028-04-30") == 1e100 + index = projected.get_child(index_path) + for year in range(FIRST_PROJECTION_YEAR, 2041): + at = f"{year}-04-30" + assert projected.get_child(own_path)(at) == 0 + assert isfinite(index(at)) and index(at) > 0 + + +OWN_ANCHOR_PATHS = ( + *( + ( + f"{SC_NAME}.threshold.{relationship}", + f"{SC_NAME}.threshold_uprating.{relationship}", + ) + for relationship in ("SINGLE", "COUPLE") + ), + *( + (f"{HB_NAME}.{kind}.aged", f"{HB_NAME}.protected_pension_age_uprating.{kind}") + for kind in HB_RELATIONS + ), +) + + +@st.composite +def own_anchor_path(draw): + """One to three explicit own amounts, in year order, from the base year.""" + years = draw( + st.lists( + st.integers(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR), + min_size=1, + max_size=3, + unique=True, + ) + ) + return [(year, draw(nonnegative_amount)) for year in sorted(years)] + + +@settings(max_examples=40, deadline=None) +@given(paths=st.sampled_from(OWN_ANCHOR_PATHS), anchors=own_anchor_path()) +@example(paths=OWN_ANCHOR_PATHS[0], anchors=[(2027, 0.0), (2029, 180.0)]) +@example(paths=OWN_ANCHOR_PATHS[-1], anchors=[(2027, 0.0), (2029, 180.0)]) +@example(paths=OWN_ANCHOR_PATHS[1], anchors=[(2026, 300.0), (2030, 0.0)]) +def test_explicit_own_anchors_survive_projection(paths, anchors): + """Each explicit threshold or protected-HB amount survives exactly. + + Years whose latest explicit amount is zero stay zero, including a zero + followed by a positive anchor, and the index stays positive and finite. + """ + own_path, index_path = paths + parameters = synthetic_parameter_tree([(0.039, 0.035)]) + unreformed = parameters.clone() + _apply_reforms(parameters, {own_path: anchors}) + + projected = project_synthetic_parameters(parameters, unreformed) + + own = projected.get_child(own_path) + for year, value in anchors: + assert own(f"{year}-04-30") == value, (year, anchors) + index = projected.get_child(index_path) + for year in range(anchors[0][0], LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + in_force = [value for anchor_year, value in anchors if anchor_year <= year] + if in_force[-1] == 0: + assert own(at) == 0, (year, anchors) + assert isfinite(index(at)) and index(at) > 0, (year, anchors) + + +@pytest.mark.parametrize("first_year", [2025, 2026]) +def test_advanced_base_preserves_a_pre_base_maximum_reform(first_year): + """A complete rates update must not replace an earlier maximum reform.""" + parameters = synthetic_parameter_tree([(0.039, 0.035)]) + for name, amount in NEXT_YEAR_RATES.items(): + parameters.get_child(name).update(start="2027-04-01", value=amount) + unreformed = parameters.clone() + parameters.get_child(f"{SC_NAME}.maximum.SINGLE").update( + start=f"{first_year}-04-01", value=0 + ) + + projected = project_synthetic_parameters(parameters, unreformed) + + for name, amount in NEXT_YEAR_RATES.items(): + assert projected.get_child(name)("2027-04-30") == amount + assert projected.get_child(f"{SC_NAME}.maximum.SINGLE")("2027-04-30") == 0 + assert projected.get_child(f"{SC_NAME}.maximum.SINGLE")("2028-04-30") == 0 + for name in ( + f"{SC_NAME}.threshold.SINGLE", + f"{HB_NAME}.single.aged", + f"{HB_NAME}.lone_parent.aged", + ): + assert projected.get_child(name)("2028-04-30") == pytest.approx(259.75) + for year in range(2028, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + assert projected.get_child(name)(at) == pytest.approx( + projected.get_child(f"{SMG_NAME}.SINGLE")(at) + ) + + +@pytest.mark.parametrize("first_year", [2026, 2028]) +def test_maximum_reforms_reach_both_dependent_forecasts(first_year): + parameters = synthetic_parameter_tree([(0.039, 0.035)]) + unreformed = parameters.clone() + parameters.get_child(f"{SC_NAME}.maximum.SINGLE").update( + start=f"{first_year}-04-01", value=0 + ) + projected = project_synthetic_parameters(parameters, unreformed) + for year in range(max(first_year, FIRST_PROJECTION_YEAR), LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + guarantee = projected.get_child(f"{SMG_NAME}.SINGLE")(at) + assert projected.get_child(f"{SC_NAME}.maximum.SINGLE")(at) == 0 + assert projected.get_child(f"{SC_NAME}.threshold.SINGLE")(at) == pytest.approx( + guarantee + ) + for kind in ("single", "lone_parent"): + assert projected.get_child(f"{HB_NAME}.{kind}.aged")(at) == pytest.approx( + guarantee + ) + + +FOLLOWS_SINGLE_MAXIMUM = ( + f"{SC_NAME}.threshold.SINGLE", + f"{HB_NAME}.single.aged", + f"{HB_NAME}.lone_parent.aged", +) + + +@pytest.mark.parametrize("first_year", [2026, 2028]) +@pytest.mark.parametrize( + "own_base,value", + [ + (f"{SC_NAME}.threshold.SINGLE", 180), + (f"{HB_NAME}.single.aged", 250), + (f"{HB_NAME}.lone_parent.aged", 230), + ], +) +def test_a_reformed_own_base_takes_precedence_over_a_maximum_reform( + first_year, own_base, value +): + """A threshold or allowance whose own base-year value is reformed keeps + projecting its own reformed gap, with or without a maximum reform. Every + other amount built from that maximum follows the maximum reform.""" + + def project(maximum_reform): + parameters = synthetic_parameter_tree([(0.039, 0.035)]) + unreformed = parameters.clone() + parameters.get_child(own_base).update(start="2026-04-01", value=value) + if maximum_reform: + parameters.get_child(f"{SC_NAME}.maximum.SINGLE").update( + start=f"{first_year}-04-01", value=0 + ) + return project_synthetic_parameters(parameters, unreformed) + + combined, own_base_only = project(True), project(False) + for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + assert combined.get_child(own_base)(at) == pytest.approx( + own_base_only.get_child(own_base)(at), rel=1e-12 + ), year + guarantee = combined.get_child(f"{SMG_NAME}.SINGLE")(at) + for name in FOLLOWS_SINGLE_MAXIMUM: + if name != own_base and year >= first_year: + assert combined.get_child(name)(at) == pytest.approx( + guarantee, rel=1e-12 + ), (name, year) + + +@settings(max_examples=40, deadline=None) +@given(path=paths) +def test_unreformed_higher_allowances_equal_guarantee_plus_maximum_every_year(path): + parameters = synthetic_parameters(path) + for allowance, guarantee, maximum in higher_allowances(parameters): + for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + assert allowance(at) == pytest.approx( + guarantee(at) + maximum(at), abs=1e-9 + ), (allowance.name, year) + + +ENACTED_VALUES = ( + *( + (f"{name}.{relation}", entry.instant_str, entry.value) + for name, node in ( + (SMG_NAME, RAW_SMG), + (f"{SC_NAME}.threshold", RAW_SC.threshold), + (f"{SC_NAME}.maximum", RAW_SC.maximum), + ) + for relation in ("SINGLE", "COUPLE") + for entry in getattr(node, relation).values_list + ), + *( + (f"{HB_NAME}.{kind}.{cohort}", entry.instant_str, entry.value) + for kind in HB_RELATIONS + for cohort in ("aged", "aged_from_cutoff") + for entry in getattr(getattr(RAW_HB, kind), cohort).values_list + ), +) + + +@settings(max_examples=60, deadline=None) +@given(path=paths, enacted=st.sampled_from(ENACTED_VALUES)) +def test_forecasts_reproduce_each_enacted_value_exactly(path, enacted): + name, instant, expected = enacted + parameters = synthetic_parameters(path) + assert parameters.get_child(name)(instant) == expected + + +@settings(max_examples=40, deadline=None) +@given( + path=paths, + increase=st.floats(min_value=1, max_value=1e100), + first_year=st.integers(2026, 2039), +) +@example(path=[(0.039, 0.035)], increase=300 / 238, first_year=2026) +def test_guarantee_increases_match_main_and_keep_protected_allowances_above_guarantee( + path, increase, first_year +): + parameters = synthetic_parameter_tree(path) + unreformed = parameters.clone() + reforms = {f"{SMG_NAME}.SINGLE": (first_year, 238 * increase)} + _apply_reforms(parameters, reforms) + projected = project_synthetic_parameters(parameters, unreformed) + main = _main_parameters(path, reforms=reforms) + for year in range(FIRST_PROJECTION_YEAR, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + guarantee = projected.get_child(f"{SMG_NAME}.SINGLE")(at) + for name in FOLLOWS_SINGLE_MAXIMUM: + assert projected.get_child(name)(at) == pytest.approx( + main.get_child(name)(at), rel=1e-12 + ) + for kind in ("single", "lone_parent"): + allowance = projected.get_child(f"{HB_NAME}.{kind}.aged")(at) + assert allowance >= guarantee - abs(guarantee) * 1e-12 + + +def _main_last_projected_year(parameters: ParameterNode) -> int: + """Frozen #2576 helper, eb0b8087: the earnings index's final year.""" + index = parameters.gov.economic_assumptions.indices.earnings_uprating + return max(int(entry.instant_str[:4]) for entry in index.values_list) + + +def _main_extend_after_last_stated( + parameter: Parameter, + last_year: int, + derive: Callable[[str, Any], Any], +) -> None: + """Frozen #2576 value-generation algorithm; names are prefixed locally.""" + last = max(parameter.values_list, key=lambda entry: entry.instant_str) + previous = last.value + for year in range(int(last.instant_str[:4]) + 1, last_year + 1): + previous = derive(f"{year}-04-30", previous) + parameter.values_list.append( + ParameterAtInstant(parameter.name, f"{year}-01-01", data=previous) + ) + parameter.values_list.sort(key=lambda entry: entry.instant_str, reverse=True) + parameter.mark_as_modified() + + +def _main_project_savings_credit_threshold(parameters: ParameterNode) -> ParameterNode: + """Frozen create_savings_credit_threshold.py from main eb0b8087.""" + pc = parameters.gov.dwp.pension_credit + sc = pc.savings_credit + last_year = _main_last_projected_year(parameters) + for relation, threshold in sc.threshold.children.items(): + guarantee = pc.guarantee_credit.minimum_guarantee.children[relation] + maximum = sc.maximum.children[relation] + + def derive(sample, previous): + phase_in = sc.rate.phase_in(sample) + if phase_in == 0: + return previous + return guarantee(sample) - maximum(sample) / phase_in + + _main_extend_after_last_stated(threshold, last_year, derive) + sc.threshold.clear_parent_cache() + return parameters + + +def _main_project_pension_age_allowances(parameters: ParameterNode) -> ParameterNode: + """Frozen create_pension_age_allowances.py from main eb0b8087.""" + pc = parameters.gov.dwp.pension_credit + allowances = parameters.gov.dwp.housing_benefit.allowances + last_year = _main_last_projected_year(parameters) + for kind, relation in HB_RELATIONS.items(): + guarantee = pc.guarantee_credit.minimum_guarantee.children[relation] + maximum = pc.savings_credit.maximum.children[relation] + + def derive(sample, previous): + return guarantee(sample) + maximum(sample) + + _main_extend_after_last_stated( + allowances.children[kind].aged, last_year, derive + ) + allowances.clear_parent_cache() + return parameters + + +def _main_parameters(path, phase_ins=(), reforms=None): + parameters = synthetic_parameter_tree(path, phase_ins) + # Main processed reforms written onto the statutory timeline. + _apply_reforms(parameters, reforms or {}) + pc = parameters.gov.dwp.pension_credit + # Main's thresholds and higher allowances were generated after uprating, + # without metadata pointing at the reconciliation's new forecast indices. + for node in ( + pc.savings_credit.threshold, + *pc.savings_credit.threshold.children.values(), + ): + node.metadata.pop("uprating", None) + for kind in HB_RELATIONS: + getattr(parameters.gov.dwp.housing_benefit.allowances, kind).aged.metadata.pop( + "uprating", None + ) + pc.savings_credit.maximum.metadata["uprating"] = "gov.benefit_uprating_cpi" + for maximum in pc.savings_credit.maximum.children.values(): + maximum.metadata["uprating"] = "gov.benefit_uprating_cpi" + propagate_parameter_metadata(parameters) + uprate_parameters(parameters) + _main_project_savings_credit_threshold(parameters) + _main_project_pension_age_allowances(parameters) + return parameters + + +@settings(max_examples=40, deadline=None) +@given(path=paths) +@example(path=[(0.039, 0.035)]) +def test_reconciled_builders_agree_with_main_where_its_behavior_is_retained(path): + """Retain main’s unreformed amounts under positive and negative macro inputs.""" + main = _main_parameters(path) + reconciled = synthetic_parameters(path) + for name in PROJECTED_PATHS: + for year in range(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + assert reconciled.get_child(name)(at) == pytest.approx( + main.get_child(name)(at), rel=0, abs=1e-9 + ), (name, year, path) + + +@settings(max_examples=40, deadline=None) +@given( + path=paths, + phase_ins=phase_in_paths, + reforms=maximum_reforms(), + index_reform=st.none() + | st.tuples( + st.integers(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR), + st.floats(min_value=1, max_value=1e6), + ), +) +@example( + path=[(0.039, 0.035)], + phase_ins=[], + reforms={f"{SC_NAME}.maximum.SINGLE": [(2026, 0.0)]}, + index_reform=None, +) +@example( + path=[(0.039, 0.035)], + phase_ins=[], + reforms={f"{SC_NAME}.maximum.COUPLE": [(2028, 0.0)]}, + index_reform=None, +) +@example( + path=[(0.039, -0.06), (-0.06, 0.15)], + phase_ins=[0.6, 0.0, 0.01], + reforms={ + f"{SC_NAME}.maximum.SINGLE": [(2025, 1e100), (2027, 25), (2030, 0)], + f"{SC_NAME}.maximum.COUPLE": [(2029, 5e-324)], + }, + index_reform=(2028, 150.0), +) +def test_maximum_reforms_reproduce_main_with_a_floored_threshold( + path, phase_ins, reforms, index_reform +): + """Any path of maximum Savings Credit reforms, before, at or after the base + year, reaches every forecast as main's post-uprating builders took it, + and so does a reform to the index the maximum is uprated by. + + Main's threshold could go negative; the reconciliation floors it at zero. + """ + if index_reform is not None: + reforms = reforms | {BENEFIT_UPRATING_CPI: index_reform} + main = _main_parameters(path, phase_ins, reforms) + parameters = synthetic_parameter_tree(path, phase_ins) + unreformed = parameters.clone() + _apply_reforms(parameters, reforms) + reconciled = project_synthetic_parameters(parameters, unreformed) + for name in PROJECTED_PATHS: + for year in range(FIRST_PROJECTION_YEAR - 1, LAST_INDEX_YEAR + 1): + at = f"{year}-04-30" + expected = main.get_child(name)(at) + if ".threshold." in name: + expected = max(expected, 0) + assert reconciled.get_child(name)(at) == pytest.approx( + expected, rel=1e-12, abs=1e-9 + ), (name, year, reforms) diff --git a/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py new file mode 100644 index 0000000000..dbc2a04920 --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_guarantee_reform_projections.py @@ -0,0 +1,884 @@ +"""Pension-age forecasts follow main's reformed guarantee and maximum. + +Invariants, for the Savings Credit threshold and protected pension-age Housing +Benefit allowance forecast indices: + +1. Guarantee reforms move forecast thresholds and protected allowances along + the guarantee's uprated path. The live phase-in rate determines the + threshold; zero preserves the previous threshold, as on main. +2. No reform to a base-year allowance or threshold stops the parameter tree + being built; only an enacted allowance below its guarantee raises. +3. A reformed allowance below its guarantee is projected whole by the + guarantee's earnings path, continuously with an allowance equal to it. +4. Each allowance has its own index: a reform to one leaves the others. +5. A reform to the maximum Savings Credit, from the base year or a later + year, reaches the projected threshold and higher allowances through every + reform route: each forecast year's threshold is the guarantee less the + reformed maximum / phase-in, and each allowance the guarantee plus it. + A threshold or allowance whose own base is reformed keeps projecting its + own reformed gap. +""" + +from math import isfinite + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st +from policyengine_core.periods import instant +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation, system +from policyengine_uk.model_api import Scenario +from policyengine_uk.parameters.gov.dwp.housing_benefit.allowances.create_pension_age_allowances import ( + ALLOWANCE_GUARANTEES, + protected_allowance_index_values, +) +from policyengine_uk.tax_benefit_system import ( + CountryTaxBenefitSystem, + process_parameter_tree, +) + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +GUARANTEE = "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee" +HB_ALLOWANCES = "gov.dwp.housing_benefit.allowances" +SAVINGS_CREDIT = "gov.dwp.pension_credit.savings_credit" +YEARS = range(2026, 2041) +PROJECTION_PATHS = ( + *(f"{HB_ALLOWANCES}.{name}.aged" for name in ("single", "lone_parent", "couple")), + *( + f"{HB_ALLOWANCES}.protected_pension_age_uprating.{name}" + for name in ALLOWANCE_GUARANTEES + ), + *( + f"{SAVINGS_CREDIT}.{name}.{relationship}" + for name in ("threshold", "threshold_uprating") + for relationship in ("SINGLE", "COUPLE") + ), +) + + +def _situation(couple, years): + names = ["claimant", "partner"] if couple else ["claimant"] + return { + "people": { + name: { + "age": {year: 70 for year in years}, + "state_pension": {year: 0 for year in years}, + "is_claimant_or_partner": {year: True for year in years}, + } + for name in names + }, + "benunits": { + "benunit": { + "members": names, + "claims_all_entitled_benefits": {year: True for year in years}, + "would_claim_pc": {year: True for year in years}, + "would_claim_uc": {year: False for year in years}, + } + }, + "households": { + "household": { + "members": names, + "country": {year: "ENGLAND" for year in years}, + "local_authority": {year: "MAIDSTONE" for year in years}, + "savings": {year: 0 for year in years}, + } + }, + } + + +def _simulation(couple, changes, years, route="reform"): + options = ( + {"reform": changes} + if route == "reform" + else { + "scenario": Scenario( + parameter_changes=changes, + applied_before_data_load=True, + ) + } + ) + return Simulation(situation=_situation(couple, years), **options) + + +def _modifier_simulation( + couple, changes, start="2026-04-01", stop=None, dated_changes=None +): + """Reload YAML, apply open or bounded updates, then reprocess it.""" + + def modify(simulation): + tax_benefit_system = simulation.tax_benefit_system + tax_benefit_system.reset_parameters() + for path, value in changes.items(): + tax_benefit_system.parameters.get_child(path).update( + start=instant(start), stop=instant(stop) if stop else None, value=value + ) + tax_benefit_system.process_parameters() + + return Simulation( + situation=_situation(couple, range(2026, 2031)), + scenario=Scenario( + parameter_changes=dated_changes, + simulation_modifier=modify, + applied_before_data_load=True, + ), + ) + + +@pytest.mark.parametrize( + "path,value", + [ + (f"{GUARANTEE}.SINGLE", 250), + (f"{SAVINGS_CREDIT}.threshold.SINGLE", 200), + (f"{HB_ALLOWANCES}.single.aged", 260), + ], +) +@pytest.mark.parametrize( + "start,stop", + [ + ("2026-04-01", "2100-12-31"), + ("2027-04-01", "2100-12-31"), + ("2027-04-01", None), + ], +) +def test_reprocessing_modifier_keeps_the_yaml_base_and_its_explicit_update( + path, value, start, stop +): + parameters = _modifier_simulation( + False, {path: value}, start, stop + ).tax_benefit_system.parameters + assert parameters.get_child(path)(f"{start[:4]}-04-30") == value + for index in ( + f"{SAVINGS_CREDIT}.threshold_uprating.SINGLE", + f"{HB_ALLOWANCES}.protected_pension_age_uprating.single", + ): + assert parameters.get_child(index)("2026-04-30") == 1 + assert parameters.get_child(index)("2028-04-30") > 0 + + +@pytest.mark.parametrize( + "path,value", + [ + (f"{GUARANTEE}.SINGLE", 250), + (f"{SAVINGS_CREDIT}.threshold.SINGLE", 200), + (f"{HB_ALLOWANCES}.single.aged", 260), + ], +) +def test_reprocessing_modifier_composes_with_dated_projection_reforms(path, value): + parameters = _modifier_simulation( + False, + {path: value}, + "2027-04-01", + "2100-12-31", + dated_changes={f"{HB_ALLOWANCES}.lone_parent.aged": {"2028": 280}}, + ).tax_benefit_system.parameters + assert parameters.get_child(path)("2027-04-30") == value + assert ( + parameters.gov.dwp.housing_benefit.allowances.lone_parent.aged("2028-04-30") + == 280 + ) + + +def _last_forecast_year(simulation): + """The earnings index's last generated year, before annualisation. + + Annualisation holds that year's amounts and adds later annual labels (and + a 2041 boundary), so the annualised parameter's own dates overstate it. + """ + timeline = simulation.tax_benefit_system.projected_timelines[ + "gov.economic_assumptions.indices.earnings_uprating" + ] + return max(int(entry.instant_str[:4]) for entry in timeline) + + +def _assert_projections_follow_main(simulation): + """Main's forecast algebra, preserving independently rounded enacted rates.""" + parameters = simulation.tax_benefit_system.parameters + pc = parameters.gov.dwp.pension_credit + allowances = parameters.gov.dwp.housing_benefit.allowances + for path in PROJECTION_PATHS: + assert parameters.get_child(path)("2026-04-30") == system.parameters.get_child( + path + )("2026-04-30") + for year in range(2027, _last_forecast_year(simulation) + 1): + at = f"{year}-04-30" + for relationship, kinds in { + "SINGLE": ("single", "lone_parent"), + "COUPLE": ("couple",), + }.items(): + guarantee = getattr(pc.guarantee_credit.minimum_guarantee, relationship)(at) + maximum = getattr(pc.savings_credit.maximum, relationship)(at) + threshold = getattr(pc.savings_credit.threshold, relationship) + phase_in = pc.savings_credit.rate.phase_in(at) + expected_threshold = ( + max(0, guarantee - maximum / phase_in) + if phase_in > 0 + else threshold(f"{year - 1}-04-30") + ) + assert threshold(at) == pytest.approx(expected_threshold), ( + relationship, + year, + ) + base_threshold = threshold("2026-04-30") + assert getattr(pc.savings_credit.threshold_uprating, relationship)( + at + ) == pytest.approx(expected_threshold / base_threshold) + for kind in kinds: + allowance = getattr(allowances, kind).aged + expected = guarantee + maximum + assert allowance(at) == pytest.approx(expected), (kind, year) + assert allowance(at) >= guarantee - abs(guarantee) * 1e-12 + assert getattr(allowances.protected_pension_age_uprating, kind)( + at + ) == pytest.approx(expected / allowance("2026-04-30")) + for path in PROJECTION_PATHS: + if "_uprating." in path: + assert parameters.get_child(path)(at) > 0, (path, year) + + +@pytest.mark.parametrize("route", ["reform", "scenario"]) +@pytest.mark.parametrize("couple,expected", [(False, 15_600), (True, 23_868)]) +def test_2026_guarantee_reform_pays_the_reformed_credit_and_moves_forecasts( + couple, expected, route +): + changes = { + f"{GUARANTEE}.SINGLE": 300, + f"{GUARANTEE}.COUPLE": 459, + } + simulation = _simulation(couple, changes, [2026], route) + + assert simulation.calculate("pension_credit", 2026)[0] == pytest.approx(expected) + _assert_projections_follow_main(simulation) + + +@pytest.mark.parametrize("route", ["reform", "scenario"]) +@pytest.mark.parametrize("couple,expected", [(False, 15_600), (True, 23_868)]) +def test_simultaneous_calendar_guarantee_reforms_follow_main(couple, expected, route): + """Rebuilding prior law for either dated change must tolerate the other.""" + changes = { + f"{GUARANTEE}.SINGLE": {"year:2026-01-01:1": 300}, + f"{GUARANTEE}.COUPLE": {"year:2026-01-01:1": 459}, + } + simulation = _simulation(couple, changes, [2026], route) + + assert simulation.calculate("pension_credit", 2026)[0] == pytest.approx(expected) + _assert_projections_follow_main(simulation) + + +@pytest.mark.parametrize("route", ["reform", "scenario"]) +@pytest.mark.parametrize("calendar_year", [False, True]) +@pytest.mark.parametrize("couple", [False, True]) +def test_joint_guarantee_and_hb_cuts_use_consistent_reformed_components( + couple, calendar_year, route +): + guarantees = {"SINGLE": 200, "COUPLE": 300} + allowances = {"single": 220, "lone_parent": 220, "couple": 330} + values = { + **{f"{GUARANTEE}.{name}": value for name, value in guarantees.items()}, + **{f"{HB_ALLOWANCES}.{name}.aged": value for name, value in allowances.items()}, + } + changes = { + path: {"year:2026-01-01:1": value} if calendar_year else value + for path, value in values.items() + } + simulation = _simulation(couple, changes, [2026], route) + parameters = simulation.tax_benefit_system.parameters + expected_credit = guarantees["COUPLE" if couple else "SINGLE"] * 52 + + assert simulation.calculate("pension_credit", 2026)[0] == pytest.approx( + expected_credit + ) + for name, amount in allowances.items(): + allowance = parameters.get_child(f"{HB_ALLOWANCES}.{name}.aged") + assert allowance("2026-04-30") == pytest.approx(amount) + + for name, relationship in ALLOWANCE_GUARANTEES.items(): + path = f"{HB_ALLOWANCES}.protected_pension_age_uprating.{name}" + index = parameters.get_child(path) + assumptions = parameters.gov.economic_assumptions + earnings = assumptions.indices.earnings_uprating + earnings_growth = earnings("2027-04-30") / earnings("2026-04-30") + cpi_growth = ( + assumptions.indices.september_cpi_uprating("2027-04-30") + / assumptions.indices.september_cpi_uprating("2026-04-30") + - 1 + ) + guarantee = guarantees[relationship] + base = allowances[name] + expected = ( + guarantee * earnings_growth + (base - guarantee) * (1 + cpi_growth) + ) / base + assert index("2027-04-30") == pytest.approx(expected) + + +@pytest.mark.parametrize("calendar_year", [False, True]) +def test_joint_guarantee_and_savings_threshold_cuts_use_reformed_components( + calendar_year, +): + guarantees = {"SINGLE": 200, "COUPLE": 300} + thresholds = {"SINGLE": 180, "COUPLE": 280} + values = { + **{f"{GUARANTEE}.{name}": value for name, value in guarantees.items()}, + **{ + f"{SAVINGS_CREDIT}.threshold.{name}": value + for name, value in thresholds.items() + }, + } + changes = { + path: {"year:2026-01-01:1": value} if calendar_year else value + for path, value in values.items() + } + simulation = _simulation(False, changes, [2026]) + parameters = simulation.tax_benefit_system.parameters + for relationship, base_threshold in thresholds.items(): + threshold = parameters.get_child(f"{SAVINGS_CREDIT}.threshold.{relationship}") + assert threshold("2026-04-30") == pytest.approx(base_threshold) + path = f"{SAVINGS_CREDIT}.threshold_uprating.{relationship}" + index = parameters.get_child(path) + assumptions = parameters.gov.economic_assumptions + earnings = assumptions.indices.earnings_uprating + earnings_growth = earnings("2027-04-30") / earnings("2026-04-30") + cpi_growth = ( + assumptions.indices.september_cpi_uprating("2027-04-30") + / assumptions.indices.september_cpi_uprating("2026-04-30") + - 1 + ) + # These reformed gaps imply exact £12 maxima at the 60% phase-in rate. + expected_threshold = guarantees[relationship] * earnings_growth - 20 * ( + 1 + cpi_growth + ) + assert index("2027-04-30") == pytest.approx(expected_threshold / base_threshold) + + +@settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + year=st.integers(2026, 2040), + weekly_guarantee=st.floats(min_value=0, allow_nan=False, allow_infinity=False), + couple=st.booleans(), + route=st.sampled_from(["reform", "scenario"]), + calendar_year=st.booleans(), +) +@example( + year=2026, weekly_guarantee=0, couple=False, route="reform", calendar_year=False +) +@example( + year=2026, + weekly_guarantee=100_000_000, + couple=True, + route="scenario", + calendar_year=True, +) +@example( + year=2040, weekly_guarantee=0, couple=True, route="scenario", calendar_year=False +) +@example( + year=2040, weekly_guarantee=1e300, couple=False, route="reform", calendar_year=True +) +def test_nonnegative_guarantee_reforms_never_break_pension_age_projections( + year, weekly_guarantee, couple, route, calendar_year +): + # Generate across the finite non-negative domain. Reading the weekly + # rate avoids overflowing the model's float32 annual-award variables. + relationship = "COUPLE" if couple else "SINGLE" + path = f"{GUARANTEE}.{relationship}" + period = f"year:{year}-01-01:1" if calendar_year else str(year) + simulation = _simulation( + couple, + {path: {period: weekly_guarantee}}, + [year], + route, + ) + + guarantee = simulation.tax_benefit_system.parameters.get_child(path) + assert guarantee(f"{year}-04-30") == weekly_guarantee + parameters = simulation.tax_benefit_system.parameters + # Keep the full finite input domain for buildability. At its upper end, + # earnings uprating can overflow amounts on main too; exact algebra is + # covered separately by the bounded differential property. + for path in PROJECTION_PATHS: + parameter = parameters.get_child(path) + for forecast_year in YEARS: + value = parameter(f"{forecast_year}-04-30") + assert value >= 0, (path, forecast_year) + if "_uprating." in path: + assert isfinite(value) and value > 0, (path, forecast_year) + for name, relationship in ALLOWANCE_GUARANTEES.items(): + allowance = parameters.get_child(f"{HB_ALLOWANCES}.{name}.aged") + guarantee = parameters.get_child(f"{GUARANTEE}.{relationship}") + for forecast_year in range(2027, _last_forecast_year(simulation) + 1): + at = f"{forecast_year}-04-30" + if isfinite(guarantee(at)): + assert allowance(at) >= guarantee(at) - abs(guarantee(at)) * 1e-12 + + +@pytest.mark.parametrize("couple", [False, True]) +def test_earnings_path_guarantee_reform_moves_dependent_forecasts(couple): + """Match uk-triple-lock's annual overrides from the 2026-27 guarantee.""" + changes = {} + for relationship in ("SINGLE", "COUPLE"): + base = float( + system.parameters.get_child(f"{GUARANTEE}.{relationship}")("2026-04-30") + ) + changes[f"{GUARANTEE}.{relationship}"] = { + str(year): base * 1.05 ** (year - 2026) for year in range(2027, 2041) + } + simulation = _simulation(couple, changes, YEARS) + relationship = "COUPLE" if couple else "SINGLE" + path = f"{GUARANTEE}.{relationship}" + parameter = simulation.tax_benefit_system.parameters.get_child(path) + + for year in range(2027, 2041): + expected = changes[path][str(year)] + assert parameter(f"{year}-04-30") == pytest.approx(expected) + assert simulation.calculate("pension_credit", year)[0] == pytest.approx( + expected * 52, rel=1e-6 + ) + _assert_projections_follow_main(simulation) + + +@pytest.mark.parametrize("relationship", ["SINGLE", "COUPLE"]) +def test_invalid_enacted_protected_allowance_still_raises(relationship): + parameters = system.parameters + guarantee = float(parameters.get_child(f"{GUARANTEE}.{relationship}")("2026-04-01")) + assumptions = parameters.gov.economic_assumptions + + with pytest.raises(ValueError, match="cannot be lower than"): + protected_allowance_index_values( + guarantee - 0.01, + guarantee, + assumptions.indices.earnings_uprating, + assumptions.statutory_uprating_inputs.cpi_september, + 2026, + base_year=2026, + ) + + +@pytest.mark.parametrize("allowance_name,amount", [("single", 200), ("couple", 300)]) +def test_invalid_statutory_allowance_still_raises_during_projection( + allowance_name, amount +): + tax_benefit_system = CountryTaxBenefitSystem() + tax_benefit_system.reset_parameters() + parameters = tax_benefit_system.parameters + parameters.get_child(f"{HB_ALLOWANCES}.{allowance_name}.aged").update( + start=instant("2026-04-01"), stop=None, value=amount + ) + + with pytest.raises(ValueError, match="cannot be lower than"): + process_parameter_tree(parameters, annualise=False) + + +def _index_growth(parameters, name, year=2027): + index = parameters.get_child( + f"{HB_ALLOWANCES}.protected_pension_age_uprating.{name}" + ) + return index(f"{year}-04-30") / index(f"{year - 1}-04-30") + + +def _earnings_growth(parameters, year=2027): + earnings = parameters.gov.economic_assumptions.indices.earnings_uprating + return earnings(f"{year}-04-30") / earnings(f"{year - 1}-04-30") + + +@pytest.mark.parametrize( + "relationship,allowance_name", [("SINGLE", "single"), ("COUPLE", "couple")] +) +def test_completed_joint_reform_below_the_guarantee_projects_with_the_guarantee( + relationship, allowance_name +): + """An allowance cut below a cut guarantee no longer stops the build: with + no retained Savings Credit, it follows the guarantee's earnings path.""" + changes = { + f"{GUARANTEE}.{relationship}": 230, + f"{HB_ALLOWANCES}.{allowance_name}.aged": 220, + } + simulation = _modifier_simulation(relationship == "COUPLE", changes) + parameters = simulation.tax_benefit_system.parameters + + allowance = parameters.get_child(f"{HB_ALLOWANCES}.{allowance_name}.aged") + assert allowance("2026-04-30") == pytest.approx(220) + assert _index_growth(parameters, allowance_name) == pytest.approx( + _earnings_growth(parameters) + ) + earnings = parameters.gov.economic_assumptions.indices.earnings_uprating + for year in range(2027, 2031): + at = f"{year}-04-30" + assert allowance(at) == pytest.approx( + 220 * earnings(at) / earnings("2026-04-30") + ) + + +@pytest.mark.parametrize("offset", [-100, -0.01, 0, 0.01]) +def test_allowance_reform_at_the_guarantee_boundary_is_continuous(offset): + """Below, at and just above the enacted £238 guarantee, a 2026 cut to the + single allowance projects within a millionth of the guarantee's earnings + growth, exactly so at or below it, and leaves the other indices. Just + above, the penny of retained Savings Credit grows by CPI, so the growth + lies between the two.""" + guarantee = float(system.parameters.get_child(f"{GUARANTEE}.SINGLE")("2026-04-30")) + changes = {f"{HB_ALLOWANCES}.single.aged": guarantee + offset} + parameters = _modifier_simulation(False, changes).tax_benefit_system.parameters + + growth = _index_growth(parameters, "single") + earnings_growth = _earnings_growth(parameters) + if offset <= 0: + assert growth == pytest.approx(earnings_growth, rel=1e-12) + else: + assert growth == pytest.approx(earnings_growth, rel=1e-6) + cpi = parameters.gov.economic_assumptions.statutory_uprating_inputs + cpi_growth = 1 + max(float(cpi.cpi_september("2026-09-01")), 0) + assert min(earnings_growth, cpi_growth) <= growth + assert growth <= max(earnings_growth, cpi_growth) + for name in ("lone_parent", "couple"): + assert _index_growth(parameters, name) == pytest.approx( + _index_growth(system.parameters, name) + ) + earnings = parameters.gov.economic_assumptions.indices.earnings_uprating + prices = parameters.gov.economic_assumptions.indices.september_cpi_uprating + for year in range(2027, 2031): + at = f"{year}-04-30" + expected = ( + min(guarantee, guarantee + offset) * earnings(at) / earnings("2026-04-30") + ) + expected += max(0, offset) * prices(at) / prices("2026-04-30") + assert parameters.gov.dwp.housing_benefit.allowances.single.aged( + at + ) == pytest.approx(expected) + + +@pytest.mark.parametrize( + "path", + [ + f"{HB_ALLOWANCES}.single.aged", + f"{HB_ALLOWANCES}.couple.aged", + f"{SAVINGS_CREDIT}.threshold.SINGLE", + f"{SAVINGS_CREDIT}.threshold.COUPLE", + ], +) +def test_zero_base_reform_builds_and_keeps_the_zero(path): + """Zeroing a base-year allowance or threshold used to raise or divide by + zero while the tree was built; the zero now holds.""" + simulation = _simulation(False, {path: {"year:2026-01-01:1": 0}}, [2026]) + parameter = simulation.tax_benefit_system.parameters.get_child(path) + assert parameter("2026-04-30") == 0 + + +@pytest.mark.parametrize("route", ["reform", "scenario"]) +def test_zero_phase_in_reform_keeps_the_previous_threshold_and_pays_no_credit(route): + """Follow main: a zero phase-in rate leaves no residual threshold to solve.""" + situation = _situation(False, [2027]) + # Savings Credit needs State Pension age before 6 April 2016. About £231 a + # week is above the projected threshold and below the guarantee. + situation["people"]["claimant"]["age"] = {2027: 85} + situation["people"]["claimant"]["state_pension"] = {2027: 12_000} + baseline = Simulation(situation=situation) + changes = {f"{SAVINGS_CREDIT}.rate.phase_in": 0} + options = ( + {"reform": changes} + if route == "reform" + else { + "scenario": Scenario( + parameter_changes=changes, applied_before_data_load=True + ) + } + ) + simulation = Simulation(situation=situation, **options) + + assert baseline.calculate("savings_credit", 2027)[0] > 0 + assert simulation.calculate("savings_credit", 2027)[0] == 0 + _assert_projections_follow_main(simulation) + + +MAXIMUM = f"{SAVINGS_CREDIT}.maximum" +FAR = "2100-12-31" +FORECAST_YEARS = range(2027, 2041) +MAXIMUM_ROUTES = ["reform", "scenario", "structural", "modifier"] +RELATIONSHIP_ALLOWANCES = {"SINGLE": ("single", "lone_parent"), "COUPLE": ("couple",)} + + +@pytest.mark.parametrize("route", MAXIMUM_ROUTES) +def test_guarantee_increase_reaches_forecasts_through_every_route(route): + changes = {f"{GUARANTEE}.SINGLE": {"2026-04-01.2100-12-31": 300}} + if route == "modifier": + simulation = _modifier_simulation( + False, {f"{GUARANTEE}.SINGLE": 300}, stop="2100-12-31" + ) + elif route == "structural": + simulation = Simulation( + situation=_situation(False, [2028]), + reform=Reform.from_dict(changes, country_id="uk"), + ) + else: + simulation = _simulation(False, changes, [2028], route) + _assert_projections_follow_main(simulation) + + +def test_zero_phase_in_modifier_preserves_a_reformed_own_threshold(): + parameters = _modifier_simulation( + False, + { + f"{SAVINGS_CREDIT}.threshold.SINGLE": 180, + f"{SAVINGS_CREDIT}.rate.phase_in": 0, + }, + ).tax_benefit_system.parameters + for year in range(2026, 2031): + assert ( + parameters.gov.dwp.pension_credit.savings_credit.threshold.SINGLE( + f"{year}-04-30" + ) + == 180 + ) + + +@pytest.mark.parametrize( + "weekly_guarantee,expected_threshold,expected_allowance,expected_step", + [(None, 221.585186, 272.195717, 1.01), (300, 268.368418, 318.978949, 1.38)], +) +def test_guarantee_credit_boundary_matches_main( + weekly_guarantee, expected_threshold, expected_allowance, expected_step +): + """Frozen origin/main (6030cbc0b) cliff-probe values, including current law.""" + year = 2028 + situation = { + "people": {"claimant": {"age": {year: 85}}}, + "benunits": {"benunit": {"members": ["claimant"]}}, + "households": { + "household": { + "members": ["claimant"], + "country": {year: "ENGLAND"}, + "local_authority": {year: "MAIDSTONE"}, + "tenure_type": {year: "RENT_FROM_COUNCIL"}, + "rent": {year: 10_000}, + "council_tax": {year: 2_000}, + "savings": {year: 0}, + } + }, + "axes": [ + [ + { + "name": "private_pension_income", + "min": 8_000, + "max": 22_000, + "count": 2801, + "period": year, + } + ] + ], + } + changes = {f"{GUARANTEE}.SINGLE": {"2026-01-01.2100-12-31": weekly_guarantee}} + simulation = Simulation( + situation=situation, reform=changes if weekly_guarantee is not None else None + ) + parameters = simulation.tax_benefit_system.parameters + assert parameters.gov.dwp.pension_credit.savings_credit.threshold.SINGLE( + "2028-04-30" + ) == pytest.approx(expected_threshold, rel=0, abs=5e-7) + assert parameters.gov.dwp.housing_benefit.allowances.single.aged( + "2028-04-30" + ) == pytest.approx(expected_allowance, rel=0, abs=5e-7) + guarantee_credit = simulation.calculate("guarantee_credit", year) + last = int(np.flatnonzero(guarantee_credit > 0)[-1]) + net = simulation.calculate("household_net_income", year) + simulation.calculate( + "tv_licence", year + ) + assert round(float(net[last + 1] - net[last]), 2) == expected_step + assert net[last + 1] >= net[last] + + +def _zero_single_maximum(start): + """A modifier that reloads the parameters, zeroes the single maximum + Savings Credit from ``start`` and processes them again.""" + + def modify(simulation): + tax_benefit_system = simulation.tax_benefit_system + tax_benefit_system.reset_parameters() + tax_benefit_system.parameters.get_child(f"{MAXIMUM}.SINGLE").update( + start=instant(start), stop=instant(FAR), value=0 + ) + tax_benefit_system.process_parameters() + + return modify + + +def _maximum_reform_simulation(route, start, situation, extra=None): + changes = {f"{MAXIMUM}.SINGLE": {f"{start}.{FAR}": 0}, **(extra or {})} + if route == "reform": + options = {"reform": changes} + elif route == "scenario": + options = { + "scenario": Scenario( + parameter_changes=changes, applied_before_data_load=True + ) + } + elif route == "structural": + options = {"reform": Reform.from_dict(changes, country_id="uk")} + else: + options = { + "scenario": Scenario( + simulation_modifier=_zero_single_maximum(start), + applied_before_data_load=True, + ) + } + return Simulation(situation=situation, **options) + + +SAVINGS_CREDIT_YEARS = [2027, 2028] + + +def _savings_credit_situation(): + """A single pensioner whose State Pension lies midway between the + projected threshold and guarantee in each year, so Savings Credit is paid. + Savings Credit needs State Pension age before 6 April 2016.""" + situation = _situation(False, SAVINGS_CREDIT_YEARS) + pc = system.parameters.gov.dwp.pension_credit + claimant = situation["people"]["claimant"] + claimant["age"] = {year: 85 + year - 2027 for year in SAVINGS_CREDIT_YEARS} + claimant["state_pension"] = { + year: 52 + * ( + pc.savings_credit.threshold.SINGLE(f"{year}-04-30") + + pc.guarantee_credit.minimum_guarantee.SINGLE(f"{year}-04-30") + ) + / 2 + for year in SAVINGS_CREDIT_YEARS + } + return situation + + +@pytest.fixture(scope="module") +def baseline_savings_credit(): + simulation = Simulation(situation=_savings_credit_situation()) + return { + year: simulation.calculate("savings_credit", year)[0] + for year in SAVINGS_CREDIT_YEARS + } + + +@pytest.mark.parametrize("route", MAXIMUM_ROUTES) +@pytest.mark.parametrize("start", ["2026-04-01", "2028-04-01"]) +def test_maximum_reform_reaches_both_forecasts_through_every_route( + route, start, baseline_savings_credit +): + """A zero single maximum from the base year, or from a later one, makes + the single threshold and the single and lone-parent higher allowances + equal the guarantee from then on, so no Savings Credit is paid. Every + forecast year reads the maximum the reform leaves; couples are unchanged. + """ + first_year = int(start[:4]) + simulation = _maximum_reform_simulation(route, start, _savings_credit_situation()) + parameters = simulation.tax_benefit_system.parameters + pc = parameters.gov.dwp.pension_credit + allowances = parameters.gov.dwp.housing_benefit.allowances + for year in FORECAST_YEARS: + at = f"{year}-04-30" + phase_in = pc.savings_credit.rate.phase_in(at) + for relationship, kinds in RELATIONSHIP_ALLOWANCES.items(): + guarantee = getattr(pc.guarantee_credit.minimum_guarantee, relationship)(at) + maximum = getattr(pc.savings_credit.maximum, relationship)(at) + threshold = getattr(pc.savings_credit.threshold, relationship)(at) + assert threshold == pytest.approx(max(0, guarantee - maximum / phase_in)), ( + relationship, + year, + ) + for kind in kinds: + assert getattr(allowances, kind).aged(at) == pytest.approx( + guarantee + maximum + ), (kind, year) + if relationship == "SINGLE" and year >= first_year: + assert maximum == 0 + assert threshold == pytest.approx(guarantee) + for path in PROJECTION_PATHS: + if path.endswith(("COUPLE", "couple", "couple.aged")): + for year in YEARS: + at = f"{year}-04-30" + assert parameters.get_child(path)(at) == pytest.approx( + system.parameters.get_child(path)(at) + ), (path, year) + for year in SAVINGS_CREDIT_YEARS: + assert baseline_savings_credit[year] > 0 + if year >= first_year: + assert simulation.calculate("savings_credit", year)[0] == 0, year + if route != "modifier": + # The dated routes keep prior law's projected maximum before the + # change, so everything before it is as baseline. (Uprating extends a + # parameter only from its last explicit value, so the modifier's + # direct update holds the 2026 maximum until it starts, and the + # forecasts follow that maximum instead, as checked above.) + for path in PROJECTION_PATHS: + for year in range(2026, first_year): + at = f"{year}-04-30" + assert parameters.get_child(path)(at) == pytest.approx( + system.parameters.get_child(path)(at) + ), (path, year) + for year in SAVINGS_CREDIT_YEARS: + if year < first_year: + assert simulation.calculate("savings_credit", year)[0] == pytest.approx( + baseline_savings_credit[year] + ), year + + +@pytest.mark.parametrize("route", ["reform", "structural"]) +@pytest.mark.parametrize( + "own_base,value,index", + [ + ( + f"{SAVINGS_CREDIT}.threshold.SINGLE", + 180, + f"{SAVINGS_CREDIT}.threshold_uprating.SINGLE", + ), + ( + f"{HB_ALLOWANCES}.single.aged", + 250, + f"{HB_ALLOWANCES}.protected_pension_age_uprating.single", + ), + ], +) +def test_a_reformed_own_base_keeps_its_index_beside_a_maximum_reform( + route, own_base, value, index +): + """A 2026 change to a threshold or allowance keeps its own index, which + projects its reformed gap whether or not the maximum is reformed. Once the + change ends, the dated routes restore prior law under the reform's other + changes (``prior_law_projection``), here the zero maximum, so it then + equals the guarantee, as the other amounts built from that maximum do.""" + simulation = _maximum_reform_simulation( + route, + "2026-04-01", + _situation(False, [2026]), + extra={own_base: {"year:2026-01-01:1": value}}, + ) + parameters = simulation.tax_benefit_system.parameters + assumptions = parameters.gov.economic_assumptions + earnings = assumptions.indices.earnings_uprating + cpi = assumptions.indices.september_cpi_uprating + earnings_growth = earnings("2027-04-30") / earnings("2026-04-30") + cpi_growth = cpi("2027-04-30") / cpi("2026-04-30") - 1 + guarantee = float(system.parameters.get_child(f"{GUARANTEE}.SINGLE")("2026-04-30")) + if "threshold" in own_base: + # The reformed gap implies a £34.80 maximum at the 60% phase-in rate. + implied_maximum = round(0.6 * (guarantee - value), 2) + expected = ( + guarantee * earnings_growth - implied_maximum * (1 + cpi_growth) / 0.6 + ) + else: + expected = guarantee * earnings_growth + (value - guarantee) * (1 + cpi_growth) + assert parameters.get_child(own_base)("2026-04-30") == pytest.approx(value) + assert parameters.get_child(index)("2027-04-30") == pytest.approx(expected / value) + projected_guarantee = parameters.get_child(f"{GUARANTEE}.SINGLE")("2027-04-30") + for path in ( + f"{SAVINGS_CREDIT}.threshold.SINGLE", + f"{HB_ALLOWANCES}.single.aged", + f"{HB_ALLOWANCES}.lone_parent.aged", + ): + assert parameters.get_child(path)("2027-04-30") == pytest.approx( + projected_guarantee + ), path diff --git a/policyengine_uk/tests/test_pension_credit_uprating.py b/policyengine_uk/tests/test_pension_credit_uprating.py index bea73fd5bd..380b132e85 100644 --- a/policyengine_uk/tests/test_pension_credit_uprating.py +++ b/policyengine_uk/tests/test_pension_credit_uprating.py @@ -14,8 +14,8 @@ earnings figure, and never fall. 2. Each higher HB allowance equals the projected guarantee plus the projected Savings Credit maximum, after its published amounts. -3. A positive phase-in rate times the guarantee less the threshold is the - maximum; a zero rate keeps the threshold in force before it. +3. The threshold is the guarantee less maximum / phase-in, floored at zero; + a zero enacted phase-in rate keeps the threshold in force before it. 4. Published amounts are unchanged by the projections. """ @@ -105,8 +105,8 @@ def add_node(parent, key): return node -def synthetic_parameters(path, phase_ins=()): - """Uprate and project the production Pension Credit and HB amounts. +def synthetic_parameter_tree(path, phase_ins=()): + """Build a small enacted tree with generated earnings and CPI paths. No simulation is built. Earlier observations merely supply the indices' 2010 base; every generated path begins with the May-July and September @@ -170,13 +170,22 @@ def synthetic_parameters(path, phase_ins=()): node.add_child(cohort, getattr(getattr(RAW_HB, kind), cohort).clone()) parameters = add_september_cpi_uprating(add_earnings_uprating(parameters)) parameters = create_economic_assumption_indices(parameters) + return parameters + + +def project_synthetic_parameters(parameters, unreformed=None): + """Use the production generation-before-uprating order, without a model.""" + project_savings_credit_threshold(parameters, unreformed) + project_pension_age_allowances(parameters, unreformed) propagate_parameter_metadata(parameters) uprate_parameters(parameters) - project_savings_credit_threshold(parameters) - project_pension_age_allowances(parameters) return parameters +def synthetic_parameters(path, phase_ins=()): + return project_synthetic_parameters(synthetic_parameter_tree(path, phase_ins)) + + def earnings_linked_amounts(parameters): minimum = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee yield minimum.SINGLE @@ -250,7 +259,10 @@ def test_higher_hb_allowances_keep_each_part_on_its_own_index(path): parameters = synthetic_parameters(path) assert_published_amounts_kept(parameters) for allowance, guarantee, maximum in higher_allowances(parameters): - assert "uprating" not in allowance.metadata + kind = allowance.name.split(".")[-2] + assert allowance.metadata["uprating"] == ( + f"{HB_NAME}.protected_pension_age_uprating.{kind}" + ) for i, (earnings, cpi) in enumerate(path): year = FIRST_PROJECTION_YEAR + i at, before = f"{year}-04-30", f"{year - 1}-04-30" @@ -270,8 +282,10 @@ def test_higher_hb_allowances_keep_each_part_on_its_own_index(path): @given(path=paths, phase_ins=phase_in_paths) @example(path=[(0.039, 0.035)], phase_ins=[0.0]) @example(path=[(0.039, 0.035), (0.02, 0.03), (0.05, 0.01)], phase_ins=[0.0, 0.6, 0.0]) -def test_projected_threshold_keeps_the_maximum_at_every_phase_in_rate(path, phase_ins): - """Invariant 3: a zero rate never divides, and keeps the threshold.""" +def test_projected_threshold_respects_phase_in_and_its_nonnegative_floor( + path, phase_ins +): + """Invariant 3: zero never divides and small positive rates cannot cut below zero.""" parameters = synthetic_parameters(path, phase_ins) assert_published_amounts_kept(parameters) pc = parameters.gov.dwp.pension_credit @@ -286,8 +300,8 @@ def test_projected_threshold_keeps_the_maximum_at_every_phase_in_rate(path, phas if rate == 0: assert threshold(at) == threshold(before), (relation, year) else: - assert rate * (guarantee(at) - threshold(at)) == pytest.approx( - maximum(at), abs=1e-9 + assert threshold(at) == pytest.approx( + max(0, guarantee(at) - maximum(at) / rate), abs=1e-9 ), (relation, year, rate) @@ -400,8 +414,8 @@ def zero_phase_in_situation(): def test_zero_phase_in_reform_builds_and_pays_no_savings_credit(): """A zero Savings Credit phase-in rate for 2027-28 once divided by zero. - It now keeps the 2026-27 threshold for that year, pays no Savings Credit, - and leaves 2028-29 as current law has it. + It now keeps the preceding threshold, as on main, pays no Savings + Credit, and leaves 2028-29 as current law has it. """ reform = {"gov.dwp.pension_credit.savings_credit.rate.phase_in": {"2027": 0}} baseline = Simulation(situation=zero_phase_in_situation()) @@ -426,9 +440,8 @@ def test_zero_phase_in_reform_builds_and_pays_no_savings_credit(): def test_reforms_keep_their_own_allowance_and_move_the_derived_one(): """A dated change to a higher allowance is kept, and prior law resumes. - A change to the guarantee moves the allowance derived from it in that - year, as it moves the Savings Credit threshold; the lower allowance has - its own earnings projection and does not follow. + A change to the guarantee moves the protected HB allowance and Savings + Credit threshold derived from it, as on main. """ reform = { f"{HB_NAME}.single.aged": {"2028": 300}, @@ -442,17 +455,22 @@ def test_reforms_keep_their_own_allowance_and_move_the_derived_one(): reformed = Simulation(situation=situation, reform=reform).tax_benefit_system allowances = reformed.parameters.gov.dwp.housing_benefit.allowances now = current.gov.dwp.housing_benefit.allowances - sc_max = reformed.parameters.gov.dwp.pension_credit.savings_credit.maximum for year in (2027, 2029, 2030): at = f"{year}-04-30" assert allowances.single.aged(at) == pytest.approx(now.single.aged(at)) assert allowances.single.aged("2028-04-30") == 300 - assert allowances.couple.aged("2029-04-30") == pytest.approx( - 400 + sc_max.COUPLE("2029-04-30") - ) for year in (2028, 2030): at = f"{year}-04-30" assert allowances.couple.aged(at) == pytest.approx(now.couple.aged(at)) + threshold = reformed.parameters.gov.dwp.pension_credit.savings_credit.threshold + baseline_threshold = current.gov.dwp.pension_credit.savings_credit.threshold + assert threshold.COUPLE(at) == pytest.approx(baseline_threshold.COUPLE(at)) + pc = reformed.parameters.gov.dwp.pension_credit + maximum = pc.savings_credit.maximum.COUPLE("2029-04-30") + assert allowances.couple.aged("2029-04-30") == pytest.approx(400 + maximum) + assert pc.savings_credit.threshold.COUPLE("2029-04-30") == pytest.approx( + 400 - maximum / pc.savings_credit.rate.phase_in("2029-04-30") + ) assert allowances.couple.aged_from_cutoff("2029-04-30") == pytest.approx( now.couple.aged_from_cutoff("2029-04-30") ) diff --git a/policyengine_uk/tests/test_process_time_reforms.py b/policyengine_uk/tests/test_process_time_reforms.py index 732e58f2ab..ec42bbc70f 100644 --- a/policyengine_uk/tests/test_process_time_reforms.py +++ b/policyengine_uk/tests/test_process_time_reforms.py @@ -387,14 +387,33 @@ def test_generated_parameters_are_the_series_processing_builds(): generates = tbs_module.generated_parameter_defines() statutory = tbs_module.statutory_parameter_defines() processed = tbs_module.processed_parameters() + protected_allowance_index = ( + "gov.dwp.housing_benefit.allowances.protected_pension_age_uprating" + ) + savings_credit_index = "gov.dwp.pension_credit.savings_credit.threshold_uprating" + # Pension-age forecast indices are generated in their program trees. + generated_prefixes = ( + f"{INDICES}.", + f"{GROWTH}.", + f"{protected_allowance_index}.", + f"{savings_credit_index}.", + ) for path in tbs_module._leaf_paths(processed): if path.startswith("baseline.") or path.startswith( "gov.simulation.labor_supply_responses." ): assert not generates(path), path continue - built = path.startswith((f"{INDICES}.", f"{GROWTH}.")) and not statutory(path) + built = path.startswith(generated_prefixes) and not statutory(path) assert generates(path) is built, path + for relationship in ("single", "lone_parent", "couple"): + assert generates(f"{protected_allowance_index}.{relationship}") + assert not generates(f"gov.dwp.housing_benefit.allowances.{relationship}.aged") + for relationship in ("SINGLE", "COUPLE"): + assert generates(f"{savings_credit_index}.{relationship}") + assert not generates( + f"gov.dwp.pension_credit.savings_credit.threshold.{relationship}" + ) assert generates(TRIPLE_LOCK) assert generates(f"{INDICES}.uc_standard_allowance") assert not generates(MINIMUM_RATE) diff --git a/policyengine_uk/tests/test_savings_credit_threshold_projection.py b/policyengine_uk/tests/test_savings_credit_threshold_projection.py new file mode 100644 index 0000000000..274b70fd8f --- /dev/null +++ b/policyengine_uk/tests/test_savings_credit_threshold_projection.py @@ -0,0 +1,227 @@ +import pytest +from policyengine_core.periods import instant + +from policyengine_uk import Simulation, system +from policyengine_uk.model_api import Scenario +from policyengine_uk.parameters.gov.dwp.pension_credit.savings_credit.create_savings_credit_threshold import ( + project_maximum_savings_credit, + round_currency, + savings_credit_threshold, +) +from policyengine_uk.parameters.gov.dwp.pension_credit.pension_age_projection_base import ( + projection_base_year, +) +from policyengine_uk.tax_benefit_system import ( + CountryTaxBenefitSystem, + process_parameter_tree, +) + + +PENSIONER = { + "people": {"person": {"age": {2027: 70}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, +} + + +def _parameters(): + return system.parameters + + +def _raw_parameters(): + tax_benefit_system = CountryTaxBenefitSystem() + tax_benefit_system.reset_parameters() + return tax_benefit_system.parameters + + +@pytest.mark.parametrize( + ("relationship", "base_maximum", "expected_2027_maximum"), + [ + ("SINGLE", 17.96, 18.5886), + ("COUPLE", 20.10, 20.8035), + ], +) +def test_2027_threshold_preserves_the_cpi_projected_maximum( + relationship, + base_maximum, + expected_2027_maximum, +): + """DWP A14/2025 sets £17.96/£20.10 and a 3.8% enacted CPI rise. + + The first forecast uses September 2026 CPI, 3.5% (ONS, 21 October 2026). + """ + parameters = _parameters() + savings_credit = parameters.gov.dwp.pension_credit.savings_credit + guarantee = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + phase_in_rate = float(savings_credit.rate.phase_in("2027-04-01")) + projected_guarantee = float(getattr(guarantee, relationship)("2027-04-01")) + projected_threshold = float( + getattr(savings_credit.threshold, relationship)("2027-04-01") + ) + projected_maximum = phase_in_rate * (projected_guarantee - projected_threshold) + + assert base_maximum * 1.035 == pytest.approx(expected_2027_maximum) + assert projected_maximum == pytest.approx(expected_2027_maximum, abs=0.001) + + +def test_projected_thresholds_preserve_the_maximum_in_every_forecast_year(): + parameters = _parameters() + savings_credit = parameters.gov.dwp.pension_credit.savings_credit + guarantee = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + cpi = parameters.gov.economic_assumptions.indices.september_cpi_uprating + + for relationship in ("SINGLE", "COUPLE"): + guarantee_parameter = getattr(guarantee, relationship) + threshold_parameter = getattr(savings_credit.threshold, relationship) + base_phase_in_rate = float(savings_credit.rate.phase_in("2026-04-01")) + maximum = round_currency( + base_phase_in_rate + * ( + float(guarantee_parameter("2026-04-01")) + - float(threshold_parameter("2026-04-01")) + ) + ) + for year in range(2027, 2040): + maximum = project_maximum_savings_credit( + maximum, + float(cpi(f"{year}-04-01")) / float(cpi(f"{year - 1}-04-01")) - 1, + ) + phase_in_rate = float(savings_credit.rate.phase_in(f"{year}-04-01")) + actual = phase_in_rate * ( + float(guarantee_parameter(f"{year}-04-01")) + - float(threshold_parameter(f"{year}-04-01")) + ) + assert actual == pytest.approx(maximum), (relationship, year) + + +def test_projection_floors_cpi_and_validates_the_phase_in_rate(): + assert project_maximum_savings_credit(17.96, -0.02) == 17.96 + assert savings_credit_threshold(238, 17.96, 0.6) == pytest.approx(208.0666666667) + with pytest.raises(ValueError, match="must be positive"): + savings_credit_threshold(238, 17.96, 0) + + +@pytest.mark.parametrize( + ("relationship", "reformed_value"), + [("SINGLE", 100), ("COUPLE", 200)], +) +def test_preprocessing_threshold_reform_is_not_replaced_by_projection( + relationship, + reformed_value, +): + """Generated indices must not overwrite scenario parameter changes.""" + path = f"gov.dwp.pension_credit.savings_credit.threshold.{relationship}" + simulation = Simulation( + situation=PENSIONER, + scenario=Scenario( + parameter_changes={path: {"2027": reformed_value}}, + applied_before_data_load=True, + ), + ) + threshold = simulation.tax_benefit_system.parameters.get_child(path) + + assert threshold("2027-06-01") == reformed_value + + +def test_postprocessing_threshold_reform_is_not_replaced_by_projection(): + """The direct reform interface must preserve its explicit value too.""" + path = "gov.dwp.pension_credit.savings_credit.threshold.SINGLE" + simulation = Simulation( + situation=PENSIONER, + reform={path: {"2027": 100}}, + ) + threshold = simulation.tax_benefit_system.parameters.get_child(path) + + assert threshold("2027-06-01") == 100 + + +# A next year's rates update, entered for every parameter the pension-age +# projections start from (illustrative amounts, not enacted rates). +NEXT_YEAR_RATES = { + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE": 250, + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.COUPLE": 380, + "gov.dwp.pension_credit.savings_credit.threshold.SINGLE": 220, + "gov.dwp.pension_credit.savings_credit.threshold.COUPLE": 350, + "gov.dwp.housing_benefit.allowances.single.aged": 270, + "gov.dwp.housing_benefit.allowances.lone_parent.aged": 270, + "gov.dwp.housing_benefit.allowances.couple.aged": 405, +} + + +def _system_with_rates(rates, year=2027): + """Process an edited raw YAML fixture, rather than a runtime modifier.""" + tax_benefit_system = CountryTaxBenefitSystem() + tax_benefit_system.reset_parameters() + for path, value in rates.items(): + tax_benefit_system.parameters.get_child(path).update( + start=instant(f"{year}-04-01"), stop=None, value=value + ) + raw = tax_benefit_system.parameters + # Later reforms of this fixture must retain its complete enacted base. + tax_benefit_system._enacted_parameters = raw.clone() + tax_benefit_system._raw_parameters = raw.clone() + ( + tax_benefit_system.parameters, + tax_benefit_system._projected_timelines, + ) = process_parameter_tree(raw) + return tax_benefit_system + + +def test_projection_base_year_is_the_last_enacted_year(): + assert projection_base_year(_raw_parameters()) == 2026 + + +def test_next_year_rates_move_the_projection_base(): + """Once 2027-28 rates are entered, the projections start from them: the + maximum Savings Credit follows CPI from the enacted 2027 maximum, and each + entered value is exact and anchors later years.""" + parameters = _system_with_rates(NEXT_YEAR_RATES).parameters + savings_credit = parameters.gov.dwp.pension_credit.savings_credit + guarantee = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + cpi = parameters.gov.economic_assumptions.indices.september_cpi_uprating + for path, value in NEXT_YEAR_RATES.items(): + assert parameters.get_child(path)("2027-06-01") == value, path + + for relationship, maximum in (("SINGLE", 18.0), ("COUPLE", 18.0)): + # 0.6 x (250 - 220) and 0.6 x (380 - 350). + threshold = getattr(savings_credit.threshold, relationship) + index = getattr(savings_credit.threshold_uprating, relationship) + assert index("2027-04-30") == 1 + projected_maximum = maximum * ( + 1 + max(float(cpi("2028-04-01")) / float(cpi("2027-04-01")) - 1, 0) + ) + phase_in = float(savings_credit.rate.phase_in("2028-04-30")) + assert phase_in * ( + float(getattr(guarantee, relationship)("2028-04-30")) + - float(threshold("2028-04-30")) + ) == pytest.approx(projected_maximum) + protected = ( + parameters.gov.dwp.housing_benefit.allowances.protected_pension_age_uprating + ) + for name in ("single", "lone_parent", "couple"): + assert getattr(protected, name)("2027-04-30") == 1 + + +@pytest.mark.parametrize( + "path", + [ + "gov.dwp.pension_credit.guarantee_credit.minimum_guarantee.SINGLE", + "gov.dwp.pension_credit.savings_credit.threshold.SINGLE", + "gov.dwp.housing_benefit.allowances.lone_parent.aged", + ], +) +def test_rates_update_that_misses_a_parameter_raises(path): + """A 2027 rate for some of the base parameters and not others would build + the indices from a projected amount, so the update must be completed.""" + with pytest.raises(ValueError, match="enacted to the same year"): + _system_with_rates({path: NEXT_YEAR_RATES[path]}) + + +@pytest.mark.parametrize("relationship", ["SINGLE", "COUPLE"]) +def test_threshold_uses_generated_index_through_standard_uprating(relationship): + savings_credit = _parameters().gov.dwp.pension_credit.savings_credit + threshold = getattr(savings_credit.threshold, relationship) + + assert threshold.metadata["uprating"] == ( + f"gov.dwp.pension_credit.savings_credit.threshold_uprating.{relationship}" + ) diff --git a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py index 69a84d1a1f..656a9a3ba0 100644 --- a/policyengine_uk/tests/test_triple_lock_statutory_inputs.py +++ b/policyengine_uk/tests/test_triple_lock_statutory_inputs.py @@ -4,9 +4,10 @@ earnings growth from the previous year. These tests check that forecasts of those inputs follow the OBR where it publishes them, fall back to calendar-year growth after, respond to macro scenarios applied before the -data load, and can be set directly; and that the earnings-path guarantee -models one reading of the plan announced in September 2026 as a -parameter reform. +data load, and can be set directly. They also check that Pension Credit uses +the same May-July earnings input without inheriting the CPI and 2.5% elements +of the State Pension triple lock, and that the earnings-path guarantee models +one reading of the plan announced in September 2026 as a parameter reform. """ import numpy as np @@ -43,10 +44,21 @@ def uprating(parameters, year): return parameters.gov.economic_assumptions.yoy_growth.triple_lock(f"{year}-01-01") +def earnings_uprating(parameters, year): + return parameters.gov.economic_assumptions.yoy_growth.earnings_uprating( + f"{year}-01-01" + ) + + def new_state_pension_weekly(parameters, year): return parameters.gov.dwp.state_pension.new_state_pension.amount(f"{year}-06-01") +def minimum_guarantee_weekly(parameters, relation_type, year): + guarantee = parameters.gov.dwp.pension_credit.guarantee_credit.minimum_guarantee + return getattr(guarantee, relation_type)(f"{year}-06-01") + + def statutory_inputs(parameters): return parameters.gov.economic_assumptions.statutory_uprating_inputs @@ -69,6 +81,42 @@ def test_april_2027_uses_published_may_july_2026_earnings(): ) +def test_pension_credit_uses_the_state_pension_statutory_earnings_input(): + parameters = system.parameters + + assert statutory_inputs(parameters).awe_total_pay_may_july( + "2026-07-01" + ) == pytest.approx(0.039) + assert earnings_uprating(parameters, 2027) == pytest.approx(0.039) + assert minimum_guarantee_weekly(parameters, "SINGLE", 2027) == pytest.approx( + 238 * 1.039, rel=1e-5 + ) + assert minimum_guarantee_weekly(parameters, "COUPLE", 2027) == pytest.approx( + 363.25 * 1.039, rel=1e-5 + ) + + +def test_pension_credit_earnings_floor_rounds_and_never_reduces_the_guarantee(): + parameters = parameters_under( + { + f"{INPUTS}.awe_total_pay_may_july": { + "2027": 0.0124, + "2028": -0.004, + } + } + ) + + assert [ + earnings_uprating(parameters, year) for year in (2028, 2029) + ] == pytest.approx([0.012, 0.0]) + for relation_type in ("SINGLE", "COUPLE"): + guarantee_2027 = minimum_guarantee_weekly(parameters, relation_type, 2027) + guarantee_2028 = minimum_guarantee_weekly(parameters, relation_type, 2028) + guarantee_2029 = minimum_guarantee_weekly(parameters, relation_type, 2029) + assert guarantee_2028 == pytest.approx(guarantee_2027 * 1.012, rel=1e-5) + assert guarantee_2029 == pytest.approx(guarantee_2028, rel=1e-5) + + def test_every_published_year_has_its_own_value(): """A missing year would silently carry the previous year's figure.""" inputs = statutory_inputs(system.parameters)