From abee986c5db63a2711e8fd66d31bb6ad979b3932 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Jan 2026 15:39:57 -0500 Subject: [PATCH 1/9] Refactor reforms to use two-layer pattern with bypass parameter Adopt the PolicyEngine US reform pattern where: 1. A `create_*()` function returns the Reform class directly 2. A `create_*_reform()` wrapper checks parameters and calls the creator 3. A `bypass` parameter allows skipping parameter checks for programmatic use This enables: - Cleaner separation between core law (gov/) and policy proposals (contrib/) - Programmatic reform application without parameter checks - Pre-instantiated reform objects for direct import All reform files updated: - conservatives/household_based_hitc.py - cps/marriage_tax_reforms.py - policyengine/disable_simulated_benefits.py - policyengine/adjust_budgets.py - scotland/scottish_child_payment_reform.py (already had this pattern) Co-Authored-By: Claude Opus 4.5 --- changelog_entry.yaml | 4 + policyengine_uk/reforms/__init__.py | 52 ++++++++ .../reforms/conservatives/__init__.py | 12 +- .../conservatives/household_based_hitc.py | 67 ++++++---- policyengine_uk/reforms/cps/__init__.py | 16 ++- .../reforms/cps/marriage_tax_reforms.py | 33 ++++- .../reforms/policyengine/__init__.py | 20 ++- .../reforms/policyengine/adjust_budgets.py | 99 +++++++++----- .../disable_simulated_benefits.py | 121 ++++++++++-------- policyengine_uk/reforms/scotland/__init__.py | 12 +- 10 files changed, 319 insertions(+), 117 deletions(-) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index e69de29bb..f67f274eb 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -0,0 +1,4 @@ +- bump: minor + changes: + changed: + - Refactor reforms to use two-layer pattern with bypass parameter, matching PolicyEngine US architecture. This enables programmatic reform application without parameter checks and cleaner separation between core law (gov/) and policy proposals (contrib/). diff --git a/policyengine_uk/reforms/__init__.py b/policyengine_uk/reforms/__init__.py index 9b356f9bc..8494547d9 100644 --- a/policyengine_uk/reforms/__init__.py +++ b/policyengine_uk/reforms/__init__.py @@ -1 +1,53 @@ from .reforms import create_structural_reforms_from_parameters + +# Import reform creators for direct use (bypass pattern) +from .conservatives import ( + create_cb_hitc_household_based, + create_household_based_hitc_reform, + cb_hitc_household_based_reform, +) +from .cps import ( + create_expanded_ma_reform, + create_marriage_neutral_income_tax_reform, + create_marriage_tax_reform, + expanded_ma_reform, + marriage_neutral_income_tax_reform, +) +from .policyengine import ( + create_disable_simulated_benefits, + disable_simulated_benefits, + disable_simulated_benefits_reform, + create_budget_adjustment, + adjust_budget, + adjust_budgets, +) +from .scotland import ( + create_scottish_child_payment_baby_bonus_reform, + create_scottish_child_payment_reform, + scottish_child_payment_reform, +) + +__all__ = [ + "create_structural_reforms_from_parameters", + # Conservatives + "create_cb_hitc_household_based", + "create_household_based_hitc_reform", + "cb_hitc_household_based_reform", + # CPS + "create_expanded_ma_reform", + "create_marriage_neutral_income_tax_reform", + "create_marriage_tax_reform", + "expanded_ma_reform", + "marriage_neutral_income_tax_reform", + # PolicyEngine + "create_disable_simulated_benefits", + "disable_simulated_benefits", + "disable_simulated_benefits_reform", + "create_budget_adjustment", + "adjust_budget", + "adjust_budgets", + # Scotland + "create_scottish_child_payment_baby_bonus_reform", + "create_scottish_child_payment_reform", + "scottish_child_payment_reform", +] diff --git a/policyengine_uk/reforms/conservatives/__init__.py b/policyengine_uk/reforms/conservatives/__init__.py index 9ba72c886..6f37c3410 100644 --- a/policyengine_uk/reforms/conservatives/__init__.py +++ b/policyengine_uk/reforms/conservatives/__init__.py @@ -1 +1,11 @@ -from .household_based_hitc import * +from .household_based_hitc import ( + create_cb_hitc_household_based, + create_household_based_hitc_reform, + cb_hitc_household_based_reform, +) + +__all__ = [ + "create_cb_hitc_household_based", + "create_household_based_hitc_reform", + "cb_hitc_household_based_reform", +] diff --git a/policyengine_uk/reforms/conservatives/household_based_hitc.py b/policyengine_uk/reforms/conservatives/household_based_hitc.py index 861c04d3d..79c5a4b55 100644 --- a/policyengine_uk/reforms/conservatives/household_based_hitc.py +++ b/policyengine_uk/reforms/conservatives/household_based_hitc.py @@ -1,33 +1,56 @@ from policyengine_uk.model_api import * -class CB_HITC(Variable): - value_type = float - entity = Person - label = "Child Benefit High-Income Tax Charge" - definition_period = YEAR - reference = "https://www.legislation.gov.uk/ukpga/2003/1/part/10/chapter/8" - unit = GBP - defined_for = "is_higher_earner" - - def formula(person, period, parameters): - CB_received = person.benunit("child_benefit", period) - hitc = parameters(period).gov.hmrc.income_tax.charges.CB_HITC - personal_income = person("adjusted_net_income", period) - income = person.benunit.sum(personal_income) - percentage = max_(income - hitc.phase_out_start, 0) / ( - hitc.phase_out_end - hitc.phase_out_start +def create_cb_hitc_household_based() -> Reform: + """ + Reform that makes Child Benefit HITC assessed on household income + rather than individual income. + + Policy: Assess CB-HITC based on combined household income rather than + the highest individual earner's income. + + Source: Conservative Party proposal + """ + + class CB_HITC(Variable): + value_type = float + entity = Person + label = "Child Benefit High-Income Tax Charge" + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ukpga/2003/1/part/10/chapter/8" ) - return min_(percentage, 1) * CB_received + unit = GBP + defined_for = "is_higher_earner" + + def formula(person, period, parameters): + CB_received = person.benunit("child_benefit", period) + hitc = parameters(period).gov.hmrc.income_tax.charges.CB_HITC + personal_income = person("adjusted_net_income", period) + income = person.benunit.sum(personal_income) + percentage = max_(income - hitc.phase_out_start, 0) / ( + hitc.phase_out_end - hitc.phase_out_start + ) + return min_(percentage, 1) * CB_received + class reform(Reform): + def apply(self): + self.update_variable(CB_HITC) -class make_cb_hitc_household_based(Variable): - def apply(self): - self.update_variable(CB_HITC) + return reform -def create_household_based_hitc_reform(parameters, period): +def create_household_based_hitc_reform( + parameters, period, bypass: bool = False +): + if bypass: + return create_cb_hitc_household_based() + if parameters(period).gov.contrib.conservatives.cb_hitc_household: - return make_cb_hitc_household_based + return create_cb_hitc_household_based() else: return None + + +# For direct import +cb_hitc_household_based_reform = create_cb_hitc_household_based() diff --git a/policyengine_uk/reforms/cps/__init__.py b/policyengine_uk/reforms/cps/__init__.py index 5fa2f9d14..f14a9d9bc 100644 --- a/policyengine_uk/reforms/cps/__init__.py +++ b/policyengine_uk/reforms/cps/__init__.py @@ -1 +1,15 @@ -from .marriage_tax_reforms import create_marriage_tax_reform +from .marriage_tax_reforms import ( + create_expanded_ma_reform, + create_marriage_neutral_income_tax_reform, + create_marriage_tax_reform, + expanded_ma_reform, + marriage_neutral_income_tax_reform, +) + +__all__ = [ + "create_expanded_ma_reform", + "create_marriage_neutral_income_tax_reform", + "create_marriage_tax_reform", + "expanded_ma_reform", + "marriage_neutral_income_tax_reform", +] diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 1fbe3b5b3..b01634e20 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -1,10 +1,10 @@ from policyengine_uk.model_api import * -from typing import Union, Optional +from typing import Optional def create_expanded_ma_reform( max_child_age: Optional[int] = None, - child_education_levels: Optional[List[str]] = None, + child_education_levels: Optional[list[str]] = None, ) -> Reform: class meets_expanded_ma_conditions(Variable): label = "Qualifies for an expanded Marriage Allowance" @@ -209,7 +209,27 @@ def apply(self): return reform -def create_marriage_tax_reform(parameters, period): +def create_marriage_tax_reform(parameters, period, bypass: bool = False): + """ + Create marriage tax reforms based on CPS proposals. + + Policy: Expand Marriage Allowance eligibility and/or implement + marriage-neutral income tax. + + Args: + parameters: The parameter tree + period: The time period + bypass: If True, return default reform without parameter checks + + Source: Centre for Policy Studies + """ + if bypass: + # Return both reforms with default settings when bypassed + return ( + create_expanded_ma_reform(), + create_marriage_neutral_income_tax_reform(), + ) + cps = parameters(period).gov.contrib.cps.marriage_tax_reforms remove_income_condition = cps.expanded_ma.remove_income_condition rate = cps.expanded_ma.ma_rate @@ -243,3 +263,10 @@ def create_marriage_tax_reform(parameters, period): return it_reform else: return None + + +# For direct import +expanded_ma_reform = create_expanded_ma_reform() +marriage_neutral_income_tax_reform = ( + create_marriage_neutral_income_tax_reform() +) diff --git a/policyengine_uk/reforms/policyengine/__init__.py b/policyengine_uk/reforms/policyengine/__init__.py index 14d420a81..e13face64 100644 --- a/policyengine_uk/reforms/policyengine/__init__.py +++ b/policyengine_uk/reforms/policyengine/__init__.py @@ -1 +1,19 @@ -from .disable_simulated_benefits import * +from .disable_simulated_benefits import ( + create_disable_simulated_benefits, + disable_simulated_benefits, + disable_simulated_benefits_reform, +) +from .adjust_budgets import ( + create_budget_adjustment, + adjust_budget, + adjust_budgets, +) + +__all__ = [ + "create_disable_simulated_benefits", + "disable_simulated_benefits", + "disable_simulated_benefits_reform", + "create_budget_adjustment", + "adjust_budget", + "adjust_budgets", +] diff --git a/policyengine_uk/reforms/policyengine/adjust_budgets.py b/policyengine_uk/reforms/policyengine/adjust_budgets.py index 56354ce15..d98cb4698 100644 --- a/policyengine_uk/reforms/policyengine/adjust_budgets.py +++ b/policyengine_uk/reforms/policyengine/adjust_budgets.py @@ -1,7 +1,70 @@ from policyengine_core.model_api import * -def adjust_budgets(parameters, period): +def create_budget_adjustment(change_by_year: dict, budget_variable: str): + """ + Create a reform that adjusts a specific budget variable. + + Args: + change_by_year: Dict mapping year to budget change amount + budget_variable: Name of the budget variable to adjust + """ + + class adjust_budget_reform(Reform): + def apply(self): + simulation = self.simulation + + for time_period, budget_change in change_by_year.items(): + spending = simulation.calculate(budget_variable, time_period) + relative_increase = budget_change / (spending.sum() / 1e9) + + simulation.set_input( + budget_variable, + time_period, + spending * (1 + relative_increase), + ) + + return adjust_budget_reform + + +def adjust_budget( + baseline_parameter, parameter, budget_variable, bypass: bool = False +): + """ + Create a budget adjustment reform by comparing baseline to reformed values. + + Args: + baseline_parameter: The baseline parameter tree + parameter: The reformed parameter tree + budget_variable: Name of the budget variable to adjust + bypass: If True, skip the parameter comparison check + """ + change_by_year = {} + + for time_period in range(2023, 2030): + baseline_budget = baseline_parameter(time_period) + reformed_budget = parameter(time_period) + + if baseline_budget == reformed_budget: + continue + + budget_change = reformed_budget - baseline_budget + + change_by_year[time_period] = budget_change + + if len(change_by_year) == 0: + return None + + return create_budget_adjustment(change_by_year, budget_variable) + + +def adjust_budgets(parameters, period, bypass: bool = False): + """ + Reform that adjusts government department budgets. + + Policy: Adjust education, NHS, and transport spending based on + parameter differences from baseline. + """ budgets = [ ( parameters.baseline.gov.dfe.education_spending, @@ -38,37 +101,3 @@ def apply(self): reform.apply(self) return adjust_budgets_reform - - -def adjust_budget(baseline_parameter, parameter, budget_variable): - change_by_year = {} - - for time_period in range(2023, 2030): - baseline_budget = baseline_parameter(time_period) - reformed_budget = parameter(time_period) - - if baseline_budget == reformed_budget: - continue - - budget_change = reformed_budget - baseline_budget - - change_by_year[time_period] = budget_change - - if len(change_by_year) == 0: - return None - - class adjust_budget_reform(Reform): - def apply(self): - simulation = self.simulation - - for time_period, budget_change in change_by_year.items(): - spending = simulation.calculate(budget_variable, time_period) - relative_increase = budget_change / (spending.sum() / 1e9) - - simulation.set_input( - budget_variable, - time_period, - spending * (1 + relative_increase), - ) - - return adjust_budget_reform diff --git a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py index dc9db733e..c54b09896 100644 --- a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py +++ b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py @@ -1,64 +1,79 @@ from policyengine_core.model_api import * -def disable_simulated_benefits(parameters, period): - if parameters(period).gov.contrib.policyengine.disable_simulated_benefits: +def create_disable_simulated_benefits() -> Reform: + """ + Reform that disables simulated benefits and uses reported values instead. + + Policy: Replace calculated benefit amounts with reported values from + survey data. Useful for validation or when simulating partial reforms. + """ - class DisableSimulatedBenefits(Reform): - def apply(self): - simulation = self.simulation + class DisableSimulatedBenefits(Reform): + def apply(self): + simulation = self.simulation - BENEFITS = [ - "afcs", - "attendance_allowance", - "bsp", - "carers_allowance", - "child_benefit", - "child_tax_credit", - "council_tax_benefit", - "dla_m", - "dla_sc", - "esa_contrib", - "esa_income", - "housing_benefit", - "iidb", - "incapacity_benefit", - "income_support", - "jsa_contrib", - "jsa_income", - "pension_credit", - "pip_dl", - "pip_m", - "sda", - "ssmg", - "state_pension", - "universal_credit", - "winter_fuel_allowance", - "working_tax_credit", - ] - time_period = simulation.dataset.time_period - YEARS_IN_FUTURE = 10 - for variable in BENEFITS: - entity = simulation.tax_benefit_system.variables[ - variable - ].entity.key - reported_value = simulation.calculate( - variable + "_reported", time_period, map_to=entity - ) + BENEFITS = [ + "afcs", + "attendance_allowance", + "bsp", + "carers_allowance", + "child_benefit", + "child_tax_credit", + "council_tax_benefit", + "dla_m", + "dla_sc", + "esa_contrib", + "esa_income", + "housing_benefit", + "iidb", + "incapacity_benefit", + "income_support", + "jsa_contrib", + "jsa_income", + "pension_credit", + "pip_dl", + "pip_m", + "sda", + "ssmg", + "state_pension", + "universal_credit", + "winter_fuel_allowance", + "working_tax_credit", + ] + time_period = simulation.dataset.time_period + YEARS_IN_FUTURE = 10 + for variable in BENEFITS: + entity = simulation.tax_benefit_system.variables[ + variable + ].entity.key + reported_value = simulation.calculate( + variable + "_reported", time_period, map_to=entity + ) + for year in range(time_period, time_period + YEARS_IN_FUTURE): + simulation.set_input(variable, year, reported_value) + + if variable in ["child_tax_credit", "working_tax_credit"]: + # CTC and WTC have their own pre_minimum variables + # because tax credits aren't paid if below a threshold. + variable = variable + "_pre_minimum" for year in range( time_period, time_period + YEARS_IN_FUTURE ): simulation.set_input(variable, year, reported_value) - if variable in ["child_tax_credit", "working_tax_credit"]: - # CTC and WTC have their own pre_minimum variables because tax credits aren't paid if - # below a threshold. - variable = variable + "_pre_minimum" - for year in range( - time_period, time_period + YEARS_IN_FUTURE - ): - simulation.set_input( - variable, year, reported_value - ) + return DisableSimulatedBenefits + + +def disable_simulated_benefits(parameters, period, bypass: bool = False): + if bypass: + return create_disable_simulated_benefits() + + if parameters(period).gov.contrib.policyengine.disable_simulated_benefits: + return create_disable_simulated_benefits() + else: + return None + - return DisableSimulatedBenefits +# For direct import +disable_simulated_benefits_reform = create_disable_simulated_benefits() diff --git a/policyengine_uk/reforms/scotland/__init__.py b/policyengine_uk/reforms/scotland/__init__.py index d33418501..e3e9abc15 100644 --- a/policyengine_uk/reforms/scotland/__init__.py +++ b/policyengine_uk/reforms/scotland/__init__.py @@ -1 +1,11 @@ -from .scottish_child_payment_reform import create_scottish_child_payment_reform +from .scottish_child_payment_reform import ( + create_scottish_child_payment_baby_bonus_reform, + create_scottish_child_payment_reform, + scottish_child_payment_reform, +) + +__all__ = [ + "create_scottish_child_payment_baby_bonus_reform", + "create_scottish_child_payment_reform", + "scottish_child_payment_reform", +] From 49a42c53fd43e4e193f01a4ec6a46e807c810415 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Jan 2026 15:59:13 -0500 Subject: [PATCH 2/9] Remove all gov.contrib references from gov/ variables All contrib parameter behavior now handled via reforms: - abolish_council_tax: removes council_tax from gov_tax/household_tax - basic_income_interactions: handles UBI means-testing and taxation - two_child_limit_age_exemption: handles CTC/UC age exemptions - freeze_pension_credit: returns baseline pension credit values - employer_ni_pension_exemption: exempts employer pension contributions - salary_sacrifice_haircut: applies broad-base employment income haircut New reform files created: - policyengine/abolish_council_tax.py - policyengine/freeze_pension_credit.py - policyengine/employer_ni_pension_exemption.py - policyengine/salary_sacrifice_haircut.py - policyengine/two_child_limit_age_exemption.py - ubi_center/basic_income_interactions.py Base variables updated to remove conditional contrib logic: - gov_tax.py, household_tax.py - child_benefit_respective_amount.py - adjusted_net_income.py - tax_credits_applicable_income.py - pension_credit_income.py, pension_credit.py - income_support_applicable_income.py - housing_benefit_applicable_income.py - uc_mif_capped_earned_income.py - uc_individual_child_element.py - is_CTC_child_limit_exempt.py - ni_class_1_employer.py - salary_sacrifice_broad_base_haircut.py Co-Authored-By: Claude Opus 4.5 --- .beads/issues.jsonl | 6 + changelog_entry.yaml | 2 +- policyengine_uk/reforms/__init__.py | 9 + .../reforms/policyengine/__init__.py | 40 +++ .../policyengine/abolish_council_tax.py | 99 ++++++ .../employer_ni_pension_exemption.py | 59 ++++ .../policyengine/freeze_pension_credit.py | 53 +++ .../policyengine/salary_sacrifice_haircut.py | 91 +++++ .../two_child_limit_age_exemption.py | 138 ++++++++ policyengine_uk/reforms/reforms.py | 22 ++ .../reforms/ubi_center/__init__.py | 11 + .../ubi_center/basic_income_interactions.py | 312 ++++++++++++++++++ .../two_child_limit/age_exemption.yaml | 21 +- .../two_child_limit/ctc_age_exemption.yaml | 19 +- .../housing_benefit_applicable_income.py | 9 +- .../dwp/income_support_applicable_income.py | 5 +- .../gov/dwp/is_CTC_child_limit_exempt.py | 14 +- .../gov/dwp/pension_credit/pension_credit.py | 9 +- .../pension_credit/pension_credit_income.py | 5 +- .../gov/dwp/tax_credits_applicable_income.py | 5 +- .../uc_individual_child_element.py | 10 - .../uc_mif_capped_earned_income.py | 5 +- policyengine_uk/variables/gov/gov_tax.py | 9 - .../hmrc/child_benefit_respective_amount.py | 12 +- .../variables/gov/hmrc/household_tax.py | 14 - .../hmrc/income_tax/adjusted_net_income.py | 9 +- .../class_1/ni_class_1_employer.py | 15 +- .../salary_sacrifice_broad_base_haircut.py | 27 +- 28 files changed, 901 insertions(+), 129 deletions(-) create mode 100644 policyengine_uk/reforms/policyengine/abolish_council_tax.py create mode 100644 policyengine_uk/reforms/policyengine/employer_ni_pension_exemption.py create mode 100644 policyengine_uk/reforms/policyengine/freeze_pension_credit.py create mode 100644 policyengine_uk/reforms/policyengine/salary_sacrifice_haircut.py create mode 100644 policyengine_uk/reforms/policyengine/two_child_limit_age_exemption.py create mode 100644 policyengine_uk/reforms/ubi_center/__init__.py create mode 100644 policyengine_uk/reforms/ubi_center/basic_income_interactions.py diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index b9765668f..2d1845d51 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,9 @@ +{"id":"policyengine-uk-5oc","title":"Remove contrib refs: salary_sacrifice haircut rate","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:48.200273-05:00","updated_at":"2026-01-17T15:55:01.621992-05:00","closed_at":"2026-01-17T15:55:01.621992-05:00"} {"id":"policyengine-uk-5qy","title":"Update student loan validation notebook with deeper analysis","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-29T21:01:35.49966-05:00","updated_at":"2025-11-29T21:02:51.469593-05:00","closed_at":"2025-11-29T21:02:51.469593-05:00"} {"id":"policyengine-uk-75j","title":"Add student loan calibration targets to policyengine-uk-data","description":"Add student loan repayment and balance calibration targets to policyengine-uk-data loss function.\n\n## Proposed Calibration Targets (from SLC 2024-25 statistics)\n\n### Total Repayments by Country\n| Country | Repayments | Source |\n|---------|------------|--------|\n| England (HE) | £5.0bn | SLC 2024-25 |\n| Scotland | £203m | SLC 2024-25 |\n| Wales | £229m | SLC 2024-25 |\n| Northern Ireland | £182m | SLC 2024-25 |\n| **UK Total** | **~£5.6bn** | |\n\n### Repayments by Plan Type (England)\n| Plan | Amount | Share |\n|------|--------|-------|\n| Plan 1 | £1.9bn | 37% |\n| Plan 2 | £2.8bn | 55% |\n| Postgraduate | £0.3bn | 7% |\n| Plan 5 | £41m | 0.8% |\n\n### Number of Borrowers Repaying (England)\n- 3.0m via HMRC\n- 187k scheduled direct\n- 147k voluntary direct\n\n### Outstanding Balances\n- UK Total: £294bn (March 2025)\n\n## Implementation Notes\n1. Add targets to `loss.py` in policyengine-uk-data\n2. May need to adjust for timing (FRS year vs SLC reporting year)\n3. Consider whether to calibrate on modelled (`student_loan_repayment`) or reported (`student_loan_repayments`)\n\n## Sources\n- https://www.gov.uk/government/statistics/student-loans-in-england-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-scotland-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-northern-ireland-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-wales-2024-to-2025","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-29T21:01:37.038332-05:00","updated_at":"2025-11-30T12:42:25.851958-05:00","closed_at":"2025-11-30T12:42:25.851958-05:00"} +{"id":"policyengine-uk-agn","title":"Remove contrib refs: UBI basic income interactions","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:42.881372-05:00","updated_at":"2026-01-17T15:55:01.485723-05:00","closed_at":"2026-01-17T15:55:01.485723-05:00"} +{"id":"policyengine-uk-bza","title":"Remove contrib refs: abolish_council_tax","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:42.240661-05:00","updated_at":"2026-01-17T15:55:01.450161-05:00","closed_at":"2026-01-17T15:55:01.450161-05:00"} +{"id":"policyengine-uk-cur","title":"Remove contrib refs: two_child_limit age exemption","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:44.021055-05:00","updated_at":"2026-01-17T15:55:01.520798-05:00","closed_at":"2026-01-17T15:55:01.520798-05:00"} +{"id":"policyengine-uk-eao","title":"Remove contrib refs: freeze_pension_credit","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:44.669378-05:00","updated_at":"2026-01-17T15:55:01.55467-05:00","closed_at":"2026-01-17T15:55:01.55467-05:00"} +{"id":"policyengine-uk-iy2","title":"Remove contrib refs: employer_ni exempt pension contribs","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:47.592521-05:00","updated_at":"2026-01-17T15:55:01.588242-05:00","closed_at":"2026-01-17T15:55:01.588242-05:00"} {"id":"policyengine-uk-occ","title":"Research official student loan repayment aggregates for calibration","description":"## Research Findings\n\n### UK Student Loan Repayments 2024-25 (SLC Official Statistics)\n\n**England (HE):** £5.0bn total repayments\n- Plan 1: £1.9bn (37%)\n- Plan 2: £2.8bn (55%)\n- Plan 3/Postgraduate: £0.3bn (7%)\n- Plan 5: £41m (0.8%, voluntary only)\n\n**Scotland:** £203m total repayments (primarily Plan 4)\n\n**Wales:** ~£229m total repayments (6.9% increase from prior year)\n\n**Northern Ireland:** £182m total repayments\n\n**UK Total (estimated):** ~£5.6bn HE repayments\n\n### Borrowers Making Repayments (England)\n- 3.0m via HMRC (39.5% of those liable)\n- 187k scheduled direct to SLC\n- 147k voluntary direct to SLC\n\n### Outstanding Balances\n- England: £236.4bn (end March 2025)\n- Scotland: £9.4bn\n- Northern Ireland: £5.6bn\n- Wales: ~£9-10bn (estimated)\n- **UK Total: ~£260-295bn**\n\n### Sources\n- https://www.gov.uk/government/statistics/student-loans-in-england-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-scotland-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-northern-ireland-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-wales-2024-to-2025","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-29T21:01:36.199753-05:00","updated_at":"2025-11-30T12:41:52.88068-05:00","closed_at":"2025-11-30T12:41:52.88068-05:00","dependencies":[{"issue_id":"policyengine-uk-occ","depends_on_id":"policyengine-uk-75j","type":"blocks","created_at":"2025-11-29T21:01:47.791464-05:00","created_by":"daemon"}]} diff --git a/changelog_entry.yaml b/changelog_entry.yaml index f67f274eb..fd9a40c7e 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,4 +1,4 @@ - bump: minor changes: changed: - - Refactor reforms to use two-layer pattern with bypass parameter, matching PolicyEngine US architecture. This enables programmatic reform application without parameter checks and cleaner separation between core law (gov/) and policy proposals (contrib/). + - Refactor reforms to use two-layer pattern with bypass parameter, matching PolicyEngine US architecture. All gov.contrib parameter references have been removed from gov/ variables - contrib parameters now only affect behavior via reforms. This enables programmatic reform application and cleaner separation between core law (gov/) and policy proposals (contrib/). diff --git a/policyengine_uk/reforms/__init__.py b/policyengine_uk/reforms/__init__.py index 8494547d9..6ac5c4ff0 100644 --- a/policyengine_uk/reforms/__init__.py +++ b/policyengine_uk/reforms/__init__.py @@ -26,6 +26,11 @@ create_scottish_child_payment_reform, scottish_child_payment_reform, ) +from .ubi_center import ( + create_basic_income_interactions, + create_basic_income_interactions_reform, + basic_income_interactions_reform, +) __all__ = [ "create_structural_reforms_from_parameters", @@ -50,4 +55,8 @@ "create_scottish_child_payment_baby_bonus_reform", "create_scottish_child_payment_reform", "scottish_child_payment_reform", + # UBI Center + "create_basic_income_interactions", + "create_basic_income_interactions_reform", + "basic_income_interactions_reform", ] diff --git a/policyengine_uk/reforms/policyengine/__init__.py b/policyengine_uk/reforms/policyengine/__init__.py index e13face64..cef746810 100644 --- a/policyengine_uk/reforms/policyengine/__init__.py +++ b/policyengine_uk/reforms/policyengine/__init__.py @@ -8,6 +8,31 @@ adjust_budget, adjust_budgets, ) +from .abolish_council_tax import ( + create_abolish_council_tax, + create_abolish_council_tax_reform, + abolish_council_tax_reform, +) +from .freeze_pension_credit import ( + create_freeze_pension_credit, + create_freeze_pension_credit_reform, + freeze_pension_credit_reform, +) +from .employer_ni_pension_exemption import ( + create_employer_ni_pension_exemption, + create_employer_ni_pension_exemption_reform, + employer_ni_pension_exemption_reform, +) +from .salary_sacrifice_haircut import ( + create_salary_sacrifice_haircut, + create_salary_sacrifice_haircut_reform, + salary_sacrifice_haircut_reform, +) +from .two_child_limit_age_exemption import ( + create_two_child_limit_age_exemption, + create_two_child_limit_age_exemption_reform, + two_child_limit_age_exemption_reform, +) __all__ = [ "create_disable_simulated_benefits", @@ -16,4 +41,19 @@ "create_budget_adjustment", "adjust_budget", "adjust_budgets", + "create_abolish_council_tax", + "create_abolish_council_tax_reform", + "abolish_council_tax_reform", + "create_freeze_pension_credit", + "create_freeze_pension_credit_reform", + "freeze_pension_credit_reform", + "create_employer_ni_pension_exemption", + "create_employer_ni_pension_exemption_reform", + "employer_ni_pension_exemption_reform", + "create_salary_sacrifice_haircut", + "create_salary_sacrifice_haircut_reform", + "salary_sacrifice_haircut_reform", + "create_two_child_limit_age_exemption", + "create_two_child_limit_age_exemption_reform", + "two_child_limit_age_exemption_reform", ] diff --git a/policyengine_uk/reforms/policyengine/abolish_council_tax.py b/policyengine_uk/reforms/policyengine/abolish_council_tax.py new file mode 100644 index 000000000..61a972769 --- /dev/null +++ b/policyengine_uk/reforms/policyengine/abolish_council_tax.py @@ -0,0 +1,99 @@ +from policyengine_uk.model_api import * + + +def create_abolish_council_tax() -> Reform: + """ + Reform that abolishes council tax by removing it from tax calculations. + + Policy: Remove council tax from gov_tax and household_tax calculations. + """ + + class gov_tax(Variable): + label = "government tax revenue" + documentation = ( + "Government tax revenue impact in respect of this household." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + adds = [ + "expected_sdlt", + "expected_ltt", + "expected_lbtt", + "corporate_sdlt", + "business_rates", + # "council_tax", # Removed in abolish_council_tax reform + "domestic_rates", + "fuel_duty", + "tv_licence", + "wealth_tax", + "non_primary_residence_wealth_tax", + "income_tax", + "national_insurance", + "LVT", + "carbon_tax", + "capital_gains_tax", + "private_school_vat", + "corporate_incident_tax_revenue_change", + "consumer_incident_tax_revenue_change", + "ni_employer", + "student_loan_repayments", + "vat", + ] + + class household_tax(Variable): + value_type = float + entity = Household + label = "household taxes" + documentation = "Total taxes owed by the household" + definition_period = YEAR + unit = GBP + adds = [ + "expected_sdlt", + "expected_ltt", + "expected_lbtt", + "corporate_sdlt", + "business_rates", + # "council_tax", # Removed in abolish_council_tax reform + "domestic_rates", + "fuel_duty", + "tv_licence", + "wealth_tax", + "non_primary_residence_wealth_tax", + "income_tax", + "national_insurance", + "LVT", + "carbon_tax", + "vat_change", + "capital_gains_tax", + "private_school_vat", + "corporate_incident_tax_revenue_change", + "consumer_incident_tax_revenue_change", + "employer_ni_response_capital_incidence", + "employer_ni_response_consumer_incidence", + "student_loan_repayments", + ] + + class reform(Reform): + def apply(self): + self.update_variable(gov_tax) + self.update_variable(household_tax) + + return reform + + +def create_abolish_council_tax_reform( + parameters, period, bypass: bool = False +): + if bypass: + return create_abolish_council_tax() + + if parameters(period).gov.contrib.abolish_council_tax: + return create_abolish_council_tax() + else: + return None + + +# For direct import +abolish_council_tax_reform = create_abolish_council_tax() diff --git a/policyengine_uk/reforms/policyengine/employer_ni_pension_exemption.py b/policyengine_uk/reforms/policyengine/employer_ni_pension_exemption.py new file mode 100644 index 000000000..8fba92706 --- /dev/null +++ b/policyengine_uk/reforms/policyengine/employer_ni_pension_exemption.py @@ -0,0 +1,59 @@ +from policyengine_core.model_api import * +from policyengine_uk.model_api import Person, YEAR, GBP, WEEKS_IN_YEAR + + +def create_employer_ni_pension_exemption() -> Reform: + """ + Reform that exempts employer pension contributions from employer NI. + + Policy: By default, employer pension contributions are included in + taxed earnings for NI Class 1 employer calculations. This reform + removes them from the calculation when exemption is active. + """ + + class ni_class_1_employer(Variable): + value_type = float + entity = Person + label = "NI Class 1 employer-side contributions" + definition_period = YEAR + unit = GBP + defined_for = "ni_liable" + reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/9" + + def formula(person, period, parameters): + class_1 = parameters(period).gov.hmrc.national_insurance.class_1 + earnings = person("ni_class_1_income", period) + # Exempt employer pension contributions (don't add them) + taxed_earnings = earnings + secondary_threshold = ( + class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + ) + main_earnings = max_( + taxed_earnings - secondary_threshold, + 0, + ) + return class_1.rates.employer * main_earnings + + class reform(Reform): + def apply(self): + self.update_variable(ni_class_1_employer) + + return reform + + +def create_employer_ni_pension_exemption_reform( + parameters, period, bypass: bool = False +): + if bypass: + return create_employer_ni_pension_exemption() + + if parameters( + period + ).gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions: + return create_employer_ni_pension_exemption() + else: + return None + + +# For direct import +employer_ni_pension_exemption_reform = create_employer_ni_pension_exemption() diff --git a/policyengine_uk/reforms/policyengine/freeze_pension_credit.py b/policyengine_uk/reforms/policyengine/freeze_pension_credit.py new file mode 100644 index 000000000..279d27eaf --- /dev/null +++ b/policyengine_uk/reforms/policyengine/freeze_pension_credit.py @@ -0,0 +1,53 @@ +from policyengine_core.model_api import * +from policyengine_uk.model_api import BenUnit, YEAR, GBP + + +def create_freeze_pension_credit() -> Reform: + """ + Reform that freezes Pension Credit payments to their baseline values. + + Policy: When active, Pension Credit payments are set to whatever they + would be under baseline policy, effectively freezing the benefit. + """ + + class pension_credit(Variable): + label = "Pension Credit" + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2002/16/contents" + + def formula(benunit, period, parameters): + # When freeze is active, return baseline value + baseline = benunit.simulation.baseline + if baseline is not None: + return baseline.populations["benunit"]( + "pension_credit", period + ) + # Fallback to standard calculation if no baseline + entitlement = benunit("pension_credit_entitlement", period) + would_claim = benunit("would_claim_pc", period) + return entitlement * would_claim + + class reform(Reform): + def apply(self): + self.update_variable(pension_credit) + + return reform + + +def create_freeze_pension_credit_reform( + parameters, period, bypass: bool = False +): + if bypass: + return create_freeze_pension_credit() + + if parameters(period).gov.contrib.freeze_pension_credit: + return create_freeze_pension_credit() + else: + return None + + +# For direct import +freeze_pension_credit_reform = create_freeze_pension_credit() diff --git a/policyengine_uk/reforms/policyengine/salary_sacrifice_haircut.py b/policyengine_uk/reforms/policyengine/salary_sacrifice_haircut.py new file mode 100644 index 000000000..09c6b401a --- /dev/null +++ b/policyengine_uk/reforms/policyengine/salary_sacrifice_haircut.py @@ -0,0 +1,91 @@ +from policyengine_uk.model_api import * + + +def create_salary_sacrifice_haircut(haircut_rate: float) -> Reform: + """ + Reform that applies a broad-base haircut to all workers' employment income + due to employers spreading salary sacrifice cap costs. + + Policy: When the salary sacrifice cap is active, employers face increased NI + costs on excess contributions. They spread these costs across ALL employees + (not just salary sacrificers), as they cannot target only affected workers + without those workers negotiating to recoup the loss. + + Args: + haircut_rate: The rate at which employment income is reduced (e.g., 0.0016) + """ + + class salary_sacrifice_broad_base_haircut(Variable): + label = "Salary sacrifice broad-base employment income haircut" + documentation = ( + "Reduction in employment income for ALL workers due to employers spreading " + "the increased NI costs from the salary sacrifice cap across all employees. " + "This is a negative value that reduces employment_income. " + "\n\n" + "When the salary sacrifice cap is active, employers face increased NI costs " + "on excess contributions. They spread these costs across ALL employees (not " + "just salary sacrificers), as they cannot target only affected workers without " + "those workers negotiating to recoup the loss. " + "\n\n" + "See https://policyengine.org/uk/research/uk-salary-sacrifice-cap for methodology." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = ( + "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" + ) + + def formula(person, period, parameters): + cap = parameters( + period + ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap + + # If cap is infinite, no haircut applies + if np.isinf(cap): + return 0 + + # Apply haircut to employment income before any salary sacrifice adjustments + # Use employment_income_before_lsr to avoid circular dependency + employment_income = person("employment_income_before_lsr", period) + + # Return negative value (this reduces employment income for everyone) + return -employment_income * haircut_rate + + class reform(Reform): + def apply(self): + self.update_variable(salary_sacrifice_broad_base_haircut) + + return reform + + +def create_salary_sacrifice_haircut_reform( + parameters, period, bypass: bool = False +): + """ + Factory function to create the salary sacrifice haircut reform. + + Args: + parameters: The parameter tree + period: The time period + bypass: If True, always create the reform using the contrib parameter value + + Returns: + A Reform class if haircut_rate > 0, otherwise None + """ + haircut_rate = parameters( + period + ).gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate + + if bypass: + return create_salary_sacrifice_haircut(haircut_rate) + + if haircut_rate > 0: + return create_salary_sacrifice_haircut(haircut_rate) + else: + return None + + +# For direct import with default haircut rate +salary_sacrifice_haircut_reform = create_salary_sacrifice_haircut(0.0016) diff --git a/policyengine_uk/reforms/policyengine/two_child_limit_age_exemption.py b/policyengine_uk/reforms/policyengine/two_child_limit_age_exemption.py new file mode 100644 index 000000000..92d3c5e68 --- /dev/null +++ b/policyengine_uk/reforms/policyengine/two_child_limit_age_exemption.py @@ -0,0 +1,138 @@ +from policyengine_core.model_api import * +from policyengine_uk.model_api import Person, YEAR, MONTHS_IN_YEAR, GBP +from numpy import inf, select, where + + +def create_two_child_limit_age_exemption( + ctc_age_exemption: int = 0, uc_age_exemption: int = 0 +) -> Reform: + """ + Reform that applies age-based exemptions to the two-child limit for + Child Tax Credit and Universal Credit. + + Policy: Exempt families from the two-child limit when they have a child + under a specified age threshold. + + Args: + ctc_age_exemption: Age threshold for CTC exemption (0 = no exemption) + uc_age_exemption: Age threshold for UC exemption (0 = no exemption) + """ + + class is_CTC_child_limit_exempt(Variable): + value_type = bool + entity = Person + label = "Exemption from Child Tax Credit child limit" + documentation = "Exemption from Child Tax Credit limit on number of children based on birth year" + definition_period = YEAR + + def formula(person, period, parameters): + limit_year = parameters( + period + ).gov.dwp.tax_credits.child_tax_credit.limit.start_year + # Children must be born before April 2017. + # We use < 2017 as the closer approximation than <= 2017. + born_before_limit = person("birth_year", period) < limit_year + + # Age exemption from reform + if ctc_age_exemption > 0: + is_exempt = person.benunit.any( + person("age", period) < ctc_age_exemption + ) + return born_before_limit | is_exempt + + return born_before_limit + + class uc_individual_child_element(Variable): + value_type = float + entity = Person + label = "Universal Credit child element" + documentation = "For this child, given Universal Credit eligibility" + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.universal_credit.elements.child + child_index = person("child_index", period) + born_before_limit = person( + "uc_is_child_born_before_child_limit", period + ) + child_limit_applying = where( + ~born_before_limit, p.limit.child_count, inf + ) + is_eligible = (child_index != -1) & ( + child_index <= child_limit_applying + ) + + # Age exemption from reform + effective_born_before_limit = born_before_limit + if uc_age_exemption > 0: + is_exempt = person.benunit.any( + person("age", period) < uc_age_exemption + ) + effective_born_before_limit = is_exempt + + return ( + select( + [ + (child_index == 1) + & effective_born_before_limit + & is_eligible, + is_eligible, + ], + [ + p.first.higher_amount, + p.amount, + ], + default=0, + ) + * MONTHS_IN_YEAR + ) + + class reform(Reform): + def apply(self): + if ctc_age_exemption > 0: + self.update_variable(is_CTC_child_limit_exempt) + if uc_age_exemption > 0: + self.update_variable(uc_individual_child_element) + + return reform + + +def create_two_child_limit_age_exemption_reform( + parameters, period, bypass: bool = False +): + """ + Create a two-child limit age exemption reform based on parameters. + + Args: + parameters: The parameter tree + period: The time period + bypass: If True, always create the reform (used for direct application) + + Returns: + A reform class or None if no exemptions are enabled + """ + ctc_age = parameters( + period + ).gov.contrib.two_child_limit.age_exemption.child_tax_credit + uc_age = parameters( + period + ).gov.contrib.two_child_limit.age_exemption.universal_credit + + if bypass or ctc_age > 0 or uc_age > 0: + return create_two_child_limit_age_exemption( + ctc_age_exemption=ctc_age, uc_age_exemption=uc_age + ) + return None + + +# For direct import with default (no exemption) +two_child_limit_age_exemption_reform = create_two_child_limit_age_exemption() + +# Preset reforms for testing with age exemption of 3 +uc_age_exemption_3_reform = create_two_child_limit_age_exemption( + uc_age_exemption=3 +) +ctc_age_exemption_3_reform = create_two_child_limit_age_exemption( + ctc_age_exemption=3 +) diff --git a/policyengine_uk/reforms/reforms.py b/policyengine_uk/reforms/reforms.py index be00af9a3..bfbe34c41 100644 --- a/policyengine_uk/reforms/reforms.py +++ b/policyengine_uk/reforms/reforms.py @@ -4,7 +4,23 @@ disable_simulated_benefits, ) from .policyengine.adjust_budgets import adjust_budgets +from .policyengine.abolish_council_tax import ( + create_abolish_council_tax_reform, +) +from .policyengine.two_child_limit_age_exemption import ( + create_two_child_limit_age_exemption_reform, +) +from .policyengine.freeze_pension_credit import ( + create_freeze_pension_credit_reform, +) +from .policyengine.employer_ni_pension_exemption import ( + create_employer_ni_pension_exemption_reform, +) +from .policyengine.salary_sacrifice_haircut import ( + create_salary_sacrifice_haircut_reform, +) from .scotland import create_scottish_child_payment_reform +from .ubi_center import create_basic_income_interactions_reform from policyengine_core.model_api import * from policyengine_core import periods @@ -16,7 +32,13 @@ def create_structural_reforms_from_parameters(parameters, period): create_household_based_hitc_reform(parameters, period), disable_simulated_benefits(parameters, period), adjust_budgets(parameters, period), + create_abolish_council_tax_reform(parameters, period), create_scottish_child_payment_reform(parameters, period), + create_two_child_limit_age_exemption_reform(parameters, period), + create_freeze_pension_credit_reform(parameters, period), + create_employer_ni_pension_exemption_reform(parameters, period), + create_salary_sacrifice_haircut_reform(parameters, period), + create_basic_income_interactions_reform(parameters, period), ] reforms = tuple(filter(lambda x: x is not None, reforms)) diff --git a/policyengine_uk/reforms/ubi_center/__init__.py b/policyengine_uk/reforms/ubi_center/__init__.py new file mode 100644 index 000000000..b9bd566f5 --- /dev/null +++ b/policyengine_uk/reforms/ubi_center/__init__.py @@ -0,0 +1,11 @@ +from .basic_income_interactions import ( + create_basic_income_interactions, + create_basic_income_interactions_reform, + basic_income_interactions_reform, +) + +__all__ = [ + "create_basic_income_interactions", + "create_basic_income_interactions_reform", + "basic_income_interactions_reform", +] diff --git a/policyengine_uk/reforms/ubi_center/basic_income_interactions.py b/policyengine_uk/reforms/ubi_center/basic_income_interactions.py new file mode 100644 index 000000000..25db426e5 --- /dev/null +++ b/policyengine_uk/reforms/ubi_center/basic_income_interactions.py @@ -0,0 +1,312 @@ +from policyengine_uk.model_api import * +from policyengine_core.periods import period as period_ + + +def create_basic_income_interactions() -> Reform: + """ + Reform that modifies how basic income interacts with existing benefits. + + When active, this reform: + 1. Withdraws Child Benefit from basic income recipients + 2. Includes basic income in taxable income + 3. Includes basic income in means tests for: + - Tax Credits + - Pension Credit + - Income Support + - Housing Benefit + - Universal Credit + """ + + class child_benefit_respective_amount(Variable): + label = "Child Benefit (respective amount)" + documentation = "The amount of this benefit unit's Child Benefit which is in respect of this person" + entity = Person + definition_period = MONTH + value_type = float + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/part/IX", + "https://www.legislation.gov.uk/uksi/2006/965/regulation/2", + ) + defined_for = "is_child_or_QYP" + + def formula(person, period, parameters): + # When reform is active, CB is withdrawn for BI recipients + eligible = ( + person.benunit.sum(person("basic_income", period.this_year)) + == 0 + ) + is_eldest = person("is_eldest_child", period.this_year) + child_benefit = parameters(period).gov.hmrc.child_benefit.amount + amount = where( + is_eldest, child_benefit.eldest, child_benefit.additional + ) + return eligible * amount * WEEKS_IN_YEAR / MONTHS_IN_YEAR + + class adjusted_net_income(Variable): + value_type = float + entity = Person + label = "Taxable income after tax reliefs and before allowances" + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007, s. 23", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", + ) + unit = GBP + + def formula(person, period, parameters): + adjusted_net_income_components = parameters( + period + ).gov.hmrc.income_tax.adjusted_net_income_components + + # Find adjusted net income + ani = add(person, period, adjusted_net_income_components) + + # When reform is active, basic income is taxable + ani += person("basic_income", period) + + return max_(0, ani) + + class tax_credits_applicable_income(Variable): + value_type = float + entity = BenUnit + label = "Applicable income for Tax Credits" + definition_period = YEAR + unit = GBP + reference = "The Tax Credits (Definition and Calculation of Income) Regulations 2002 s. 3" + + def formula(benunit, period, parameters): + TC = parameters(period).gov.dwp.tax_credits + STEP_1_COMPONENTS = [ + "private_pension_income", + "savings_interest_income", + "dividend_income", + "property_income", + ] + income = add(benunit, period, STEP_1_COMPONENTS) + income = max_(income - TC.means_test.non_earned_disregard, 0) + STEP_2_COMPONENTS = [ + "employment_income", + "self_employment_income", + "social_security_income", + "miscellaneous_income", + "basic_income", # Include when reform is active + ] + income += add(benunit, period, STEP_2_COMPONENTS) + EXEMPT_BENEFITS = ["income_support", "esa_income", "jsa_income"] + on_exempt_benefits = add(benunit, period, EXEMPT_BENEFITS) > 0 + return income * ~on_exempt_benefits + + class pension_credit_income(Variable): + label = "Income for Pension Credit" + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/15" + + def formula(benunit, period, parameters): + pc = parameters(period).gov.dwp.pension_credit + sources = pc.guarantee_credit.income + total = add(benunit, period, sources) + # Include basic income in means test when reform is active + total += add(benunit, period, ["basic_income"]) + pension_contributions = add( + benunit, period, ["pension_contributions"] + ) + tax = add(benunit, period, ["income_tax", "national_insurance"]) + pen_con_deduction_rate = pc.income.pension_contributions_deduction + deductions = tax + pension_contributions * pen_con_deduction_rate + return max_(0, total - deductions) + + class income_support_applicable_income(Variable): + value_type = float + entity = BenUnit + label = "Relevant income for Income Support means test" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + IS = parameters(period).gov.dwp.income_support + INCOME_COMPONENTS = [ + "employment_income", + "self_employment_income", + "property_income", + "private_pension_income", + "basic_income", # Include when reform is active + ] + income = add(benunit, period, INCOME_COMPONENTS) + tax = add( + benunit, + period, + ["income_tax", "national_insurance"], + ) + income += add(benunit, period, ["social_security_income"]) + income -= tax + income -= add(benunit, period, ["pension_contributions"]) * 0.5 + family_type = benunit("family_type", period) + families = family_type.possible_values + # Calculate income disregards for each family type. + mt = IS.means_test + single = family_type == families.SINGLE + income_disregard_single = single * mt.income_disregard_single + single = family_type == families.SINGLE + income_disregard_couple = ( + benunit("is_couple", period) * mt.income_disregard_couple + ) + lone_parent = family_type == families.LONE_PARENT + income_disregard_lone_parent = ( + lone_parent * mt.income_disregard_lone_parent + ) + income_disregard = ( + income_disregard_single + + income_disregard_couple + + income_disregard_lone_parent + ) * WEEKS_IN_YEAR + return max_(0, income - income_disregard) + + class housing_benefit_applicable_income(Variable): + value_type = float + entity = BenUnit + label = "relevant income for Housing Benefit means test" + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + BENUNIT_MEANS_TESTED_BENEFITS = [ + "child_benefit", + "income_support", + "jsa_income", + "esa_income", + ] + PERSONAL_BENEFITS = [ + "carers_allowance", + "esa_contrib", + "jsa_contrib", + "state_pension", + "maternity_allowance", + "statutory_sick_pay", + "statutory_maternity_pay", + "ssmg", + ] + INCOME_COMPONENTS = [ + "employment_income", + "self_employment_income", + "property_income", + "private_pension_income", + ] + # Add personal benefits, credits and total benefits to income + benefits = add(benunit, period, BENUNIT_MEANS_TESTED_BENEFITS) + income = add(benunit, period, INCOME_COMPONENTS) + personal_benefits = add(benunit, period, PERSONAL_BENEFITS) + credits = add(benunit, period, ["tax_credits"]) + increased_income = income + personal_benefits + credits + benefits + + # When reform is active, basic income IS included in means tests + # (basic income is already in personal benefits via social_security_income, + # so no additional deduction needed) + + # Reduce increased income by pension contributions and tax + pension_contributions = ( + add(benunit, period, ["pension_contributions"]) * 0.5 + ) + TAX_COMPONENTS = ["income_tax", "national_insurance"] + tax = add(benunit, period, TAX_COMPONENTS) + increased_income_reduced_by_tax_and_pensions = ( + increased_income - tax - pension_contributions + ) + disregard = benunit( + "housing_benefit_applicable_income_disregard", period + ) + childcare_element = benunit( + "housing_benefit_applicable_income_childcare_element", period + ) + return max_( + 0, + increased_income_reduced_by_tax_and_pensions + - disregard + - childcare_element, + ) + + class uc_mif_capped_earned_income(Variable): + value_type = float + entity = Person + label = "Universal Credit gross earned income (incl. MIF)" + documentation = ( + "Gross earned income for UC, with MIF applied where applicable" + ) + definition_period = YEAR + unit = GBP + + def formula(person, period, parameters): + INCOME_COMPONENTS = [ + "employment_income", + "self_employment_income", + "miscellaneous_income", + "basic_income", # Include when reform is active + ] + personal_gross_earned_income = add( + person, period, INCOME_COMPONENTS + ) + floor = where( + person("uc_mif_applies", period), + person("uc_minimum_income_floor", period), + -inf, + ) + return max_(personal_gross_earned_income, floor) + + class reform(Reform): + def apply(self): + self.update_variable(child_benefit_respective_amount) + self.update_variable(adjusted_net_income) + self.update_variable(tax_credits_applicable_income) + self.update_variable(pension_credit_income) + self.update_variable(income_support_applicable_income) + self.update_variable(housing_benefit_applicable_income) + self.update_variable(uc_mif_capped_earned_income) + + return reform + + +def create_basic_income_interactions_reform( + parameters, period, bypass: bool = False +): + """ + Creates the basic income interactions reform based on parameter values. + + Args: + parameters: The parameter tree + period: The period to check + bypass: If True, return the reform unconditionally + + Returns: + Reform class or None + """ + if bypass: + return create_basic_income_interactions() + + p = parameters.gov.contrib.ubi_center.basic_income.interactions + + # Check if any interaction is enabled in current period or next 5 years + reform_active = False + current_period = period_(period) + + for i in range(5): + p_period = p(current_period) + if ( + p_period.include_in_means_tests + or p_period.include_in_taxable_income + or p_period.withdraw_cb + ): + reform_active = True + break + current_period = current_period.offset(1, "year") + + if reform_active: + return create_basic_income_interactions() + else: + return None + + +# For direct import +basic_income_interactions_reform = create_basic_income_interactions() diff --git a/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/age_exemption.yaml b/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/age_exemption.yaml index 9967d0b49..dcf3ee195 100644 --- a/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/age_exemption.yaml +++ b/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/age_exemption.yaml @@ -1,4 +1,8 @@ -- name: With no age exemption, only first 2 children get element amounts +# Universal Credit two-child limit age exemption tests +# Tests that the age exemption reform correctly exempts families with children +# under the threshold age from the two-child limit. + +- name: With no age exemption, only first 2 children get element amounts period: 2023 absolute_error_margin: 1 input: @@ -24,11 +28,12 @@ output: universal_credit: 14129.76 # Standard allowance + child elements with 2-child limit -- name: With age exemption, all children get element when any child is below threshold +- name: With age exemption reform, all children get element when any child is below threshold period: 2023 absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.policyengine.two_child_limit_age_exemption.uc_age_exemption_3_reform input: - gov.contrib.two_child_limit.age_exemption.universal_credit: 3 people: parent: age: 30 @@ -54,8 +59,9 @@ - name: No exemption when all children above age threshold period: 2023 absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.policyengine.two_child_limit_age_exemption.uc_age_exemption_3_reform input: - gov.contrib.two_child_limit.age_exemption.universal_credit: 3 people: parent: age: 30 @@ -67,7 +73,7 @@ child_index: 2 child_3: age: 3 # No child below threshold - child_index: 3 + child_index: 3 child_4: age: 3 child_index: 4 @@ -107,8 +113,9 @@ - name: Shows increased UC after applying age exemption reform period: 2023 absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.policyengine.two_child_limit_age_exemption.uc_age_exemption_3_reform input: - gov.contrib.two_child_limit.age_exemption.universal_credit: 3 # Exempt families with under-3s people: parent: age: 30 @@ -129,4 +136,4 @@ members: [parent, child_1, child_2, child_3, child_4] would_claim_uc: true output: - universal_credit: 14674.80 # Reform increases UC because 3rd and 4th children are now counted \ No newline at end of file + universal_credit: 14674.80 # Reform increases UC because 3rd and 4th children are now counted diff --git a/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/ctc_age_exemption.yaml b/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/ctc_age_exemption.yaml index a2c470489..ea9041179 100644 --- a/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/ctc_age_exemption.yaml +++ b/policyengine_uk/tests/policy/reforms/parametric/two_child_limit/ctc_age_exemption.yaml @@ -1,3 +1,7 @@ +# Child Tax Credit two-child limit age exemption tests +# Tests that the age exemption reform correctly exempts families with children +# under the threshold age from the two-child limit. + - name: With no age exemption, only first 2 children get CTC period: 2023 absolute_error_margin: 1 @@ -25,11 +29,12 @@ output: CTC_child_element: 6470 # CTC for only the first 2 children -- name: With age exemption, all children get CTC when any child is below threshold +- name: With age exemption reform, all children get CTC when any child is below threshold period: 2023 absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.policyengine.two_child_limit_age_exemption.ctc_age_exemption_3_reform input: - gov.contrib.two_child_limit.age_exemption.child_tax_credit: 3 people: parent: age: 30 @@ -56,8 +61,9 @@ - name: No exemption when all children above age threshold period: 2023 absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.policyengine.two_child_limit_age_exemption.ctc_age_exemption_3_reform input: - gov.contrib.two_child_limit.age_exemption.child_tax_credit: 3 people: parent: age: 30 @@ -70,7 +76,7 @@ birth_year: 2019 child_3: age: 3 # No child below threshold - birth_year: 2020 + birth_year: 2020 child_4: age: 3 birth_year: 2020 @@ -111,8 +117,9 @@ - name: Shows increased CTC after applying age exemption reform period: 2023 absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.policyengine.two_child_limit_age_exemption.ctc_age_exemption_3_reform input: - gov.contrib.two_child_limit.age_exemption.child_tax_credit: 3 # Exempt families with under-3s people: parent: age: 30 @@ -134,4 +141,4 @@ members: [parent, child_1, child_2, child_3, child_4] claims_all_entitled_benefits: true output: - CTC_child_element: 12940 # CTC for all children due to exemption \ No newline at end of file + CTC_child_element: 12940 # CTC for all children due to exemption diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index fad9620ff..64f327964 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -31,7 +31,6 @@ def formula(benunit, period, parameters): "property_income", "private_pension_income", ] - bi = parameters(period).gov.contrib.ubi_center.basic_income # Add personal benefits, credits and total benefits to income benefits = add(benunit, period, BENUNIT_MEANS_TESTED_BENEFITS) income = add(benunit, period, INCOME_COMPONENTS) @@ -39,9 +38,11 @@ def formula(benunit, period, parameters): credits = add(benunit, period, ["tax_credits"]) increased_income = income + personal_benefits + credits + benefits - if not bi.interactions.include_in_means_tests: - # Basic income is already in personal benefits, deduct if needed - increased_income -= add(benunit, period, ["basic_income"]) + # Default behavior: Basic income not included in means tests. + # Use basic_income_interactions reform to change this. + # Basic income is already in personal benefits via social_security_income, + # so deduct it to exclude from means test. + increased_income -= add(benunit, period, ["basic_income"]) # Reduce increased income by pension contributions and tax pension_contributions = ( add(benunit, period, ["pension_contributions"]) * 0.5 diff --git a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py index a3652c601..4f0291a54 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py @@ -10,15 +10,14 @@ class income_support_applicable_income(Variable): def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support + # Default behavior: Basic income not included in means tests. + # Use basic_income_interactions reform to change this. INCOME_COMPONENTS = [ "employment_income", "self_employment_income", "property_income", "private_pension_income", ] - bi = parameters(period).gov.contrib.ubi_center.basic_income - if bi.interactions.include_in_means_tests: - INCOME_COMPONENTS.append("basic_income") income = add(benunit, period, INCOME_COMPONENTS) tax = add( benunit, diff --git a/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py b/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py index d6b9e3dc8..08aacc872 100644 --- a/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py +++ b/policyengine_uk/variables/gov/dwp/is_CTC_child_limit_exempt.py @@ -14,16 +14,4 @@ def formula(person, period, parameters): ).gov.dwp.tax_credits.child_tax_credit.limit.start_year # Children must be born before April 2017. # We use < 2017 as the closer approximation than <= 2017. - born_before_limit = person("birth_year", period) < limit_year - - # Reform proposal - age_exemption = parameters.gov.contrib.two_child_limit.age_exemption.child_tax_credit( - period - ) - if age_exemption > 0: - is_exempt = person.benunit.any( - person("age", period) < age_exemption - ) - return born_before_limit | is_exempt - - return born_before_limit + return person("birth_year", period) < limit_year diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py index d94995540..60358856c 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py @@ -12,11 +12,4 @@ class pension_credit(Variable): def formula(benunit, period, parameters): entitlement = benunit("pension_credit_entitlement", period) would_claim = benunit("would_claim_pc", period) - amount = entitlement * would_claim - - freeze = parameters(period).gov.contrib.freeze_pension_credit - baseline = benunit.simulation.baseline - if freeze and baseline is not None: - return baseline.populations["benunit"]("pension_credit", period) - else: - return amount + return entitlement * would_claim diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py index df44d712f..0f79237d3 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_income.py @@ -12,10 +12,9 @@ class pension_credit_income(Variable): def formula(benunit, period, parameters): pc = parameters(period).gov.dwp.pension_credit sources = pc.guarantee_credit.income + # Default behavior: Basic income not included in means tests. + # Use basic_income_interactions reform to change this. total = add(benunit, period, sources) - bi = parameters(period).gov.contrib.ubi_center.basic_income - if bi.interactions.include_in_means_tests: - total += add(benunit, period, ["basic_income"]) pension_contributions = add(benunit, period, ["pension_contributions"]) tax = add(benunit, period, ["income_tax", "national_insurance"]) pen_con_deduction_rate = pc.income.pension_contributions_deduction diff --git a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py index 50b88a619..588bad358 100644 --- a/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/tax_credits_applicable_income.py @@ -19,15 +19,14 @@ def formula(benunit, period, parameters): ] income = add(benunit, period, STEP_1_COMPONENTS) income = max_(income - TC.means_test.non_earned_disregard, 0) + # Default behavior: Basic income not included in means tests. + # Use basic_income_interactions reform to change this. STEP_2_COMPONENTS = [ "employment_income", "self_employment_income", "social_security_income", "miscellaneous_income", ] - bi = parameters(period).gov.contrib.ubi_center.basic_income - if bi.interactions.include_in_means_tests: - STEP_2_COMPONENTS.append("basic_income") income += add(benunit, period, STEP_2_COMPONENTS) EXEMPT_BENEFITS = ["income_support", "esa_income", "jsa_income"] on_exempt_benefits = add(benunit, period, EXEMPT_BENEFITS) > 0 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_individual_child_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_individual_child_element.py index 67378fb9b..38a345ffc 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_individual_child_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/uc_individual_child_element.py @@ -22,16 +22,6 @@ def formula(person, period, parameters): child_index <= child_limit_applying ) - # Reform proposal - age_exemption = parameters.gov.contrib.two_child_limit.age_exemption.universal_credit( - period - ) - if age_exemption > 0: - is_exempt = person.benunit.any( - person("age", period) < age_exemption - ) - born_before_limit = is_exempt - return ( select( [ diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py index a266908d4..2f7913dd8 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/income_floor/uc_mif_capped_earned_income.py @@ -12,14 +12,13 @@ class uc_mif_capped_earned_income(Variable): unit = GBP def formula(person, period, parameters): + # Default behavior: Basic income not included in means tests. + # Use basic_income_interactions reform to change this. INCOME_COMPONENTS = [ "employment_income", "self_employment_income", "miscellaneous_income", ] - bi = parameters(period).gov.contrib.ubi_center.basic_income - if bi.interactions.include_in_means_tests: - INCOME_COMPONENTS.append("basic_income") personal_gross_earned_income = add(person, period, INCOME_COMPONENTS) floor = where( person("uc_mif_applies", period), diff --git a/policyengine_uk/variables/gov/gov_tax.py b/policyengine_uk/variables/gov/gov_tax.py index 131cf8ef7..63d6431b1 100644 --- a/policyengine_uk/variables/gov/gov_tax.py +++ b/policyengine_uk/variables/gov/gov_tax.py @@ -34,12 +34,3 @@ class gov_tax(Variable): "student_loan_repayments", "vat", ] - - def formula(household, period, parameters): - variables = list(gov_tax.adds) - if parameters(period).gov.contrib.abolish_council_tax: - variables = [ - variable for variable in variables if variable != "council_tax" - ] - - return add(household, period, variables) diff --git a/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py b/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py index 810cd3b0e..56bbbf4bb 100644 --- a/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py +++ b/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py @@ -15,17 +15,11 @@ class child_benefit_respective_amount(Variable): defined_for = "is_child_or_QYP" def formula(person, period, parameters): - eligible = True - if parameters( - period - ).gov.contrib.ubi_center.basic_income.interactions.withdraw_cb: - eligible &= ( - person.benunit.sum(person("basic_income", period.this_year)) - == 0 - ) + # Default behavior: Child benefit not withdrawn for basic income + # recipients. Use basic_income_interactions reform to change this. is_eldest = person("is_eldest_child", period.this_year) child_benefit = parameters(period).gov.hmrc.child_benefit.amount amount = where( is_eldest, child_benefit.eldest, child_benefit.additional ) - return eligible * amount * WEEKS_IN_YEAR / MONTHS_IN_YEAR + return amount * WEEKS_IN_YEAR / MONTHS_IN_YEAR diff --git a/policyengine_uk/variables/gov/hmrc/household_tax.py b/policyengine_uk/variables/gov/hmrc/household_tax.py index 5655b2477..eaec5c858 100644 --- a/policyengine_uk/variables/gov/hmrc/household_tax.py +++ b/policyengine_uk/variables/gov/hmrc/household_tax.py @@ -33,17 +33,3 @@ class household_tax(Variable): "employer_ni_response_consumer_incidence", "student_loan_repayments", ] - - def formula(household, period, parameters): - if parameters(period).gov.contrib.abolish_council_tax: - return add( - household, - period, - [ - tax - for tax in household_tax.adds - if tax not in ["council_tax"] - ], - ) - else: - return add(household, period, household_tax.adds) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py index 3723b6a60..9fcfb0909 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py @@ -18,13 +18,8 @@ def formula(person, period, parameters): ).gov.hmrc.income_tax.adjusted_net_income_components # Find adjusted net income + # Default behavior: Basic income not included in taxable income. + # Use basic_income_interactions reform to change this. ani = add(person, period, adjusted_net_income_components) - # For basic income contributions, add basic income - # Modifying param list directly is mutative, hence two-step process - if parameters( - period - ).gov.contrib.ubi_center.basic_income.interactions.include_in_taxable_income: - ani += person("basic_income", period) - return max_(0, ani) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py index 0e74bc5dd..e8b3ca877 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py @@ -13,15 +13,12 @@ class ni_class_1_employer(Variable): def formula(person, period, parameters): class_1 = parameters(period).gov.hmrc.national_insurance.class_1 earnings = person("ni_class_1_income", period) - if not parameters( - period - ).gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions: - added_pension_contributions = person( - "employer_pension_contributions", period - ) - taxed_earnings = earnings + added_pension_contributions - else: - taxed_earnings = earnings + # Default behavior: employer pension contributions ARE taxed + # Use employer_ni_pension_exemption reform to exempt them + added_pension_contributions = person( + "employer_pension_contributions", period + ) + taxed_earnings = earnings + added_pension_contributions secondary_threshold = ( class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR ) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_broad_base_haircut.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_broad_base_haircut.py index 54ab7ea41..c5d629488 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_broad_base_haircut.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_broad_base_haircut.py @@ -13,7 +13,10 @@ class salary_sacrifice_broad_base_haircut(Variable): "just salary sacrificers), as they cannot target only affected workers without " "those workers negotiating to recoup the loss. " "\n\n" - "See https://policyengine.org/uk/research/uk-salary-sacrifice-cap for methodology." + "See https://policyengine.org/uk/research/uk-salary-sacrifice-cap for methodology. " + "\n\n" + "By default, this returns 0 (no haircut). To apply a haircut, use the " + "salary_sacrifice_haircut reform from policyengine_uk.reforms.policyengine." ) entity = Person definition_period = YEAR @@ -22,22 +25,6 @@ class salary_sacrifice_broad_base_haircut(Variable): reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" def formula(person, period, parameters): - cap = parameters( - period - ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap - - # If cap is infinite, no haircut applies - if np.isinf(cap): - return 0 - - # Get the broad-base haircut rate (applies to ALL workers) - haircut_rate = parameters( - period - ).gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate - - # Apply haircut to employment income before any salary sacrifice adjustments - # Use employment_income_before_lsr to avoid circular dependency - employment_income = person("employment_income_before_lsr", period) - - # Return negative value (this reduces employment income for everyone) - return -employment_income * haircut_rate + # By default, no haircut applies. + # Use salary_sacrifice_haircut reform to apply a haircut rate. + return 0 From 133ed4ab245290c2a7df54158214addf726107be Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Jan 2026 15:59:29 -0500 Subject: [PATCH 3/9] Update beads --- .beads/deletions.jsonl | 6 ++++++ .beads/issues.jsonl | 6 ------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.beads/deletions.jsonl b/.beads/deletions.jsonl index 579036981..5809b0751 100644 --- a/.beads/deletions.jsonl +++ b/.beads/deletions.jsonl @@ -1,3 +1,9 @@ {"id":"policyengine-uk-lo8","ts":"2025-12-01T05:12:40.281291Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} {"id":"policyengine-uk-exv","ts":"2025-12-01T05:12:40.289337Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} {"id":"policyengine-uk-e55","ts":"2025-12-01T05:12:40.293639Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} +{"id":"policyengine-uk-5oc","ts":"2026-01-17T20:59:14.576055Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} +{"id":"policyengine-uk-iy2","ts":"2026-01-17T20:59:14.594357Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} +{"id":"policyengine-uk-eao","ts":"2026-01-17T20:59:14.601098Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} +{"id":"policyengine-uk-cur","ts":"2026-01-17T20:59:14.606669Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} +{"id":"policyengine-uk-agn","ts":"2026-01-17T20:59:14.618579Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} +{"id":"policyengine-uk-bza","ts":"2026-01-17T20:59:14.623346Z","by":"git-history-backfill","reason":"recovered from git history (pruned from manifest)"} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 2d1845d51..b9765668f 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,9 +1,3 @@ -{"id":"policyengine-uk-5oc","title":"Remove contrib refs: salary_sacrifice haircut rate","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:48.200273-05:00","updated_at":"2026-01-17T15:55:01.621992-05:00","closed_at":"2026-01-17T15:55:01.621992-05:00"} {"id":"policyengine-uk-5qy","title":"Update student loan validation notebook with deeper analysis","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-29T21:01:35.49966-05:00","updated_at":"2025-11-29T21:02:51.469593-05:00","closed_at":"2025-11-29T21:02:51.469593-05:00"} {"id":"policyengine-uk-75j","title":"Add student loan calibration targets to policyengine-uk-data","description":"Add student loan repayment and balance calibration targets to policyengine-uk-data loss function.\n\n## Proposed Calibration Targets (from SLC 2024-25 statistics)\n\n### Total Repayments by Country\n| Country | Repayments | Source |\n|---------|------------|--------|\n| England (HE) | £5.0bn | SLC 2024-25 |\n| Scotland | £203m | SLC 2024-25 |\n| Wales | £229m | SLC 2024-25 |\n| Northern Ireland | £182m | SLC 2024-25 |\n| **UK Total** | **~£5.6bn** | |\n\n### Repayments by Plan Type (England)\n| Plan | Amount | Share |\n|------|--------|-------|\n| Plan 1 | £1.9bn | 37% |\n| Plan 2 | £2.8bn | 55% |\n| Postgraduate | £0.3bn | 7% |\n| Plan 5 | £41m | 0.8% |\n\n### Number of Borrowers Repaying (England)\n- 3.0m via HMRC\n- 187k scheduled direct\n- 147k voluntary direct\n\n### Outstanding Balances\n- UK Total: £294bn (March 2025)\n\n## Implementation Notes\n1. Add targets to `loss.py` in policyengine-uk-data\n2. May need to adjust for timing (FRS year vs SLC reporting year)\n3. Consider whether to calibrate on modelled (`student_loan_repayment`) or reported (`student_loan_repayments`)\n\n## Sources\n- https://www.gov.uk/government/statistics/student-loans-in-england-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-scotland-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-northern-ireland-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-wales-2024-to-2025","status":"closed","priority":2,"issue_type":"feature","created_at":"2025-11-29T21:01:37.038332-05:00","updated_at":"2025-11-30T12:42:25.851958-05:00","closed_at":"2025-11-30T12:42:25.851958-05:00"} -{"id":"policyengine-uk-agn","title":"Remove contrib refs: UBI basic income interactions","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:42.881372-05:00","updated_at":"2026-01-17T15:55:01.485723-05:00","closed_at":"2026-01-17T15:55:01.485723-05:00"} -{"id":"policyengine-uk-bza","title":"Remove contrib refs: abolish_council_tax","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:42.240661-05:00","updated_at":"2026-01-17T15:55:01.450161-05:00","closed_at":"2026-01-17T15:55:01.450161-05:00"} -{"id":"policyengine-uk-cur","title":"Remove contrib refs: two_child_limit age exemption","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:44.021055-05:00","updated_at":"2026-01-17T15:55:01.520798-05:00","closed_at":"2026-01-17T15:55:01.520798-05:00"} -{"id":"policyengine-uk-eao","title":"Remove contrib refs: freeze_pension_credit","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:44.669378-05:00","updated_at":"2026-01-17T15:55:01.55467-05:00","closed_at":"2026-01-17T15:55:01.55467-05:00"} -{"id":"policyengine-uk-iy2","title":"Remove contrib refs: employer_ni exempt pension contribs","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-17T15:45:47.592521-05:00","updated_at":"2026-01-17T15:55:01.588242-05:00","closed_at":"2026-01-17T15:55:01.588242-05:00"} {"id":"policyengine-uk-occ","title":"Research official student loan repayment aggregates for calibration","description":"## Research Findings\n\n### UK Student Loan Repayments 2024-25 (SLC Official Statistics)\n\n**England (HE):** £5.0bn total repayments\n- Plan 1: £1.9bn (37%)\n- Plan 2: £2.8bn (55%)\n- Plan 3/Postgraduate: £0.3bn (7%)\n- Plan 5: £41m (0.8%, voluntary only)\n\n**Scotland:** £203m total repayments (primarily Plan 4)\n\n**Wales:** ~£229m total repayments (6.9% increase from prior year)\n\n**Northern Ireland:** £182m total repayments\n\n**UK Total (estimated):** ~£5.6bn HE repayments\n\n### Borrowers Making Repayments (England)\n- 3.0m via HMRC (39.5% of those liable)\n- 187k scheduled direct to SLC\n- 147k voluntary direct to SLC\n\n### Outstanding Balances\n- England: £236.4bn (end March 2025)\n- Scotland: £9.4bn\n- Northern Ireland: £5.6bn\n- Wales: ~£9-10bn (estimated)\n- **UK Total: ~£260-295bn**\n\n### Sources\n- https://www.gov.uk/government/statistics/student-loans-in-england-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-scotland-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-northern-ireland-2024-to-2025\n- https://www.gov.uk/government/statistics/student-loans-in-wales-2024-to-2025","status":"closed","priority":2,"issue_type":"task","created_at":"2025-11-29T21:01:36.199753-05:00","updated_at":"2025-11-30T12:41:52.88068-05:00","closed_at":"2025-11-30T12:41:52.88068-05:00","dependencies":[{"issue_id":"policyengine-uk-occ","depends_on_id":"policyengine-uk-75j","type":"blocks","created_at":"2025-11-29T21:01:47.791464-05:00","created_by":"daemon"}]} From 6111ba1104ec69f1aa3ac4e467e1872a272cb443 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Jan 2026 16:23:43 -0500 Subject: [PATCH 4/9] Fix basic income interactions to check params in reform formulas Following the US pattern, contrib parameters are now checked INSIDE the reform variable formulas rather than applying all changes unconditionally. This ensures each interaction (CB withdrawal, taxable income, means tests) only activates when its specific parameter is enabled. Co-Authored-By: Claude Opus 4.5 --- .../ubi_center/basic_income_interactions.py | 56 +++++++++++-------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/policyengine_uk/reforms/ubi_center/basic_income_interactions.py b/policyengine_uk/reforms/ubi_center/basic_income_interactions.py index 25db426e5..6eb1e202c 100644 --- a/policyengine_uk/reforms/ubi_center/basic_income_interactions.py +++ b/policyengine_uk/reforms/ubi_center/basic_income_interactions.py @@ -6,15 +6,11 @@ def create_basic_income_interactions() -> Reform: """ Reform that modifies how basic income interacts with existing benefits. - When active, this reform: - 1. Withdraws Child Benefit from basic income recipients - 2. Includes basic income in taxable income - 3. Includes basic income in means tests for: - - Tax Credits - - Pension Credit - - Income Support - - Housing Benefit - - Universal Credit + This reform checks the contrib parameters within each formula to allow + individual control over each interaction: + 1. withdraw_cb - Withdraws Child Benefit from basic income recipients + 2. include_in_taxable_income - Includes basic income in taxable income + 3. include_in_means_tests - Includes basic income in means tests """ class child_benefit_respective_amount(Variable): @@ -31,11 +27,13 @@ class child_benefit_respective_amount(Variable): defined_for = "is_child_or_QYP" def formula(person, period, parameters): - # When reform is active, CB is withdrawn for BI recipients - eligible = ( - person.benunit.sum(person("basic_income", period.this_year)) - == 0 - ) + eligible = True + bi = parameters(period).gov.contrib.ubi_center.basic_income + if bi.interactions.withdraw_cb: + eligible = ( + person.benunit.sum(person("basic_income", period.this_year)) + == 0 + ) is_eldest = person("is_eldest_child", period.this_year) child_benefit = parameters(period).gov.hmrc.child_benefit.amount amount = where( @@ -62,8 +60,10 @@ def formula(person, period, parameters): # Find adjusted net income ani = add(person, period, adjusted_net_income_components) - # When reform is active, basic income is taxable - ani += person("basic_income", period) + # Add basic income if parameter is set + bi = parameters(period).gov.contrib.ubi_center.basic_income + if bi.interactions.include_in_taxable_income: + ani += person("basic_income", period) return max_(0, ani) @@ -90,8 +90,10 @@ def formula(benunit, period, parameters): "self_employment_income", "social_security_income", "miscellaneous_income", - "basic_income", # Include when reform is active ] + bi = parameters(period).gov.contrib.ubi_center.basic_income + if bi.interactions.include_in_means_tests: + STEP_2_COMPONENTS.append("basic_income") income += add(benunit, period, STEP_2_COMPONENTS) EXEMPT_BENEFITS = ["income_support", "esa_income", "jsa_income"] on_exempt_benefits = add(benunit, period, EXEMPT_BENEFITS) > 0 @@ -109,8 +111,9 @@ def formula(benunit, period, parameters): pc = parameters(period).gov.dwp.pension_credit sources = pc.guarantee_credit.income total = add(benunit, period, sources) - # Include basic income in means test when reform is active - total += add(benunit, period, ["basic_income"]) + bi = parameters(period).gov.contrib.ubi_center.basic_income + if bi.interactions.include_in_means_tests: + total += add(benunit, period, ["basic_income"]) pension_contributions = add( benunit, period, ["pension_contributions"] ) @@ -133,8 +136,10 @@ def formula(benunit, period, parameters): "self_employment_income", "property_income", "private_pension_income", - "basic_income", # Include when reform is active ] + bi = parameters(period).gov.contrib.ubi_center.basic_income + if bi.interactions.include_in_means_tests: + INCOME_COMPONENTS.append("basic_income") income = add(benunit, period, INCOME_COMPONENTS) tax = add( benunit, @@ -195,6 +200,7 @@ def formula(benunit, period, parameters): "property_income", "private_pension_income", ] + bi = parameters(period).gov.contrib.ubi_center.basic_income # Add personal benefits, credits and total benefits to income benefits = add(benunit, period, BENUNIT_MEANS_TESTED_BENEFITS) income = add(benunit, period, INCOME_COMPONENTS) @@ -202,9 +208,9 @@ def formula(benunit, period, parameters): credits = add(benunit, period, ["tax_credits"]) increased_income = income + personal_benefits + credits + benefits - # When reform is active, basic income IS included in means tests - # (basic income is already in personal benefits via social_security_income, - # so no additional deduction needed) + if not bi.interactions.include_in_means_tests: + # Exclude basic income from means tests + increased_income -= add(benunit, period, ["basic_income"]) # Reduce increased income by pension contributions and tax pension_contributions = ( @@ -243,8 +249,10 @@ def formula(person, period, parameters): "employment_income", "self_employment_income", "miscellaneous_income", - "basic_income", # Include when reform is active ] + bi = parameters(period).gov.contrib.ubi_center.basic_income + if bi.interactions.include_in_means_tests: + INCOME_COMPONENTS.append("basic_income") personal_gross_earned_income = add( person, period, INCOME_COMPONENTS ) From 0ebe9bfd4712655c1aac8700be4dfa2c27aea83f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Jan 2026 16:35:33 -0500 Subject: [PATCH 5/9] Apply structural reforms at TaxBenefitSystem init, matching US pattern Key changes: 1. Apply structural reforms in CountryTaxBenefitSystem.__init__ so YAML tests (which use policyengine_core's test runner) also get reforms applied 2. Reform factories now always return reforms - formulas check params at calculation time, enabling dynamic parameter setting in tests 3. Removed unused period_ import from basic_income_interactions.py This matches the policyengine-us architecture where structural reforms are applied in both TaxBenefitSystem and Simulation initialization. Co-Authored-By: Claude Opus 4.5 --- .../policyengine/salary_sacrifice_haircut.py | 46 +++++++++++-------- .../ubi_center/basic_income_interactions.py | 43 ++++++----------- policyengine_uk/tax_benefit_system.py | 12 +++++ 3 files changed, 51 insertions(+), 50 deletions(-) diff --git a/policyengine_uk/reforms/policyengine/salary_sacrifice_haircut.py b/policyengine_uk/reforms/policyengine/salary_sacrifice_haircut.py index 09c6b401a..c57ee7567 100644 --- a/policyengine_uk/reforms/policyengine/salary_sacrifice_haircut.py +++ b/policyengine_uk/reforms/policyengine/salary_sacrifice_haircut.py @@ -1,7 +1,7 @@ from policyengine_uk.model_api import * -def create_salary_sacrifice_haircut(haircut_rate: float) -> Reform: +def create_salary_sacrifice_haircut() -> Reform: """ Reform that applies a broad-base haircut to all workers' employment income due to employers spreading salary sacrifice cap costs. @@ -11,8 +11,8 @@ def create_salary_sacrifice_haircut(haircut_rate: float) -> Reform: (not just salary sacrificers), as they cannot target only affected workers without those workers negotiating to recoup the loss. - Args: - haircut_rate: The rate at which employment income is reduced (e.g., 0.0016) + The haircut rate is read from parameters at calculation time, allowing + dynamic configuration through YAML tests or API calls. """ class salary_sacrifice_broad_base_haircut(Variable): @@ -46,6 +46,15 @@ def formula(person, period, parameters): if np.isinf(cap): return 0 + # Get haircut rate from parameters at calculation time + haircut_rate = parameters( + period + ).gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate + + # If no haircut rate set, return 0 + if haircut_rate == 0: + return 0 + # Apply haircut to employment income before any salary sacrifice adjustments # Use employment_income_before_lsr to avoid circular dependency employment_income = person("employment_income_before_lsr", period) @@ -66,26 +75,23 @@ def create_salary_sacrifice_haircut_reform( """ Factory function to create the salary sacrifice haircut reform. + This reform is ALWAYS applied because the formula checks parameters at + calculation time. This allows dynamic parameter setting (e.g., in YAML tests + or API calls) to work correctly. + Args: - parameters: The parameter tree - period: The time period - bypass: If True, always create the reform using the contrib parameter value + parameters: The parameter tree (unused, kept for API consistency) + period: The time period (unused, kept for API consistency) + bypass: If True, always create the reform (always True here) Returns: - A Reform class if haircut_rate > 0, otherwise None + A Reform class """ - haircut_rate = parameters( - period - ).gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate - - if bypass: - return create_salary_sacrifice_haircut(haircut_rate) - - if haircut_rate > 0: - return create_salary_sacrifice_haircut(haircut_rate) - else: - return None + # Always apply this reform - the formula checks parameters internally + # This matches the US pattern where reforms are always active but + # their effect depends on parameter values at calculation time + return create_salary_sacrifice_haircut() -# For direct import with default haircut rate -salary_sacrifice_haircut_reform = create_salary_sacrifice_haircut(0.0016) +# For direct import +salary_sacrifice_haircut_reform = create_salary_sacrifice_haircut() diff --git a/policyengine_uk/reforms/ubi_center/basic_income_interactions.py b/policyengine_uk/reforms/ubi_center/basic_income_interactions.py index 6eb1e202c..dce7aab8b 100644 --- a/policyengine_uk/reforms/ubi_center/basic_income_interactions.py +++ b/policyengine_uk/reforms/ubi_center/basic_income_interactions.py @@ -1,5 +1,4 @@ from policyengine_uk.model_api import * -from policyengine_core.periods import period as period_ def create_basic_income_interactions() -> Reform: @@ -280,40 +279,24 @@ def create_basic_income_interactions_reform( parameters, period, bypass: bool = False ): """ - Creates the basic income interactions reform based on parameter values. + Creates the basic income interactions reform. + + This reform is ALWAYS applied because the formulas check parameters at + calculation time. This allows dynamic parameter setting (e.g., in YAML tests + or API calls) to work correctly. Args: - parameters: The parameter tree - period: The period to check - bypass: If True, return the reform unconditionally + parameters: The parameter tree (unused, kept for API consistency) + period: The period (unused, kept for API consistency) + bypass: If True, return the reform unconditionally (always True here) Returns: - Reform class or None + Reform class """ - if bypass: - return create_basic_income_interactions() - - p = parameters.gov.contrib.ubi_center.basic_income.interactions - - # Check if any interaction is enabled in current period or next 5 years - reform_active = False - current_period = period_(period) - - for i in range(5): - p_period = p(current_period) - if ( - p_period.include_in_means_tests - or p_period.include_in_taxable_income - or p_period.withdraw_cb - ): - reform_active = True - break - current_period = current_period.offset(1, "year") - - if reform_active: - return create_basic_income_interactions() - else: - return None + # Always apply this reform - the formulas check parameters internally + # This matches the US pattern where reforms are always active but + # their effect depends on parameter values at calculation time + return create_basic_income_interactions() # For direct import diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index e76cddbd9..6c3974ef3 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -34,6 +34,8 @@ backdate_parameters, convert_to_fiscal_year_parameters, ) +from policyengine_uk.reforms import create_structural_reforms_from_parameters +from policyengine_core.periods import period as period_ # Module constants COUNTRY_DIR = Path(__file__).parent @@ -134,6 +136,16 @@ def __init__(self): self.reset_parameters() self.process_parameters() + # Apply structural reforms based on parameters + # This ensures reforms are applied even when using policyengine_core's + # test runner (YAML tests), which bypasses policyengine_uk's Simulation class + start_instant = period_(2025) + structural_reform = create_structural_reforms_from_parameters( + self.parameters, start_instant + ) + if structural_reform is not None: + self.apply_reform_set(structural_reform) + # Create system instance for module-level access system = CountryTaxBenefitSystem() From 8fd9703d2914f9ce4aa28e423845ec64771b5272 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Jan 2026 16:36:03 -0500 Subject: [PATCH 6/9] Update changelog with structural reform application change Co-Authored-By: Claude Opus 4.5 --- changelog_entry.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog_entry.yaml b/changelog_entry.yaml index fd9a40c7e..488c4318d 100644 --- a/changelog_entry.yaml +++ b/changelog_entry.yaml @@ -1,4 +1,4 @@ - bump: minor changes: changed: - - Refactor reforms to use two-layer pattern with bypass parameter, matching PolicyEngine US architecture. All gov.contrib parameter references have been removed from gov/ variables - contrib parameters now only affect behavior via reforms. This enables programmatic reform application and cleaner separation between core law (gov/) and policy proposals (contrib/). + - Refactor reforms to use two-layer pattern with bypass parameter, matching PolicyEngine US architecture. All gov.contrib parameter references have been removed from gov/ variables - contrib parameters now only affect behavior via reforms. Structural reforms are now applied at CountryTaxBenefitSystem initialization (in addition to Simulation), ensuring YAML tests work correctly with dynamic parameter setting. This enables programmatic reform application and cleaner separation between core law (gov/) and policy proposals (contrib/). From 095499e2e285f11fffde28282be244cf6aa6527b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Jan 2026 16:38:00 -0500 Subject: [PATCH 7/9] Format code with black Co-Authored-By: Claude Opus 4.5 --- .../reforms/ubi_center/basic_income_interactions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/reforms/ubi_center/basic_income_interactions.py b/policyengine_uk/reforms/ubi_center/basic_income_interactions.py index dce7aab8b..c54247c47 100644 --- a/policyengine_uk/reforms/ubi_center/basic_income_interactions.py +++ b/policyengine_uk/reforms/ubi_center/basic_income_interactions.py @@ -30,7 +30,9 @@ def formula(person, period, parameters): bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.withdraw_cb: eligible = ( - person.benunit.sum(person("basic_income", period.this_year)) + person.benunit.sum( + person("basic_income", period.this_year) + ) == 0 ) is_eldest = person("is_eldest_child", period.this_year) From bd79946622dcc93b482e3e1328dcce74d5cf7b4f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Jan 2026 17:38:03 -0500 Subject: [PATCH 8/9] Remove basic_income refs from gov/ variables - Remove basic_income from gov_spending.py adds list - Remove basic_income deduction from housing_benefit_applicable_income.py - Add gov_spending override to basic_income_interactions reform This completes the separation of gov/ (actual law) from contrib/ concepts. basic_income is now only added to gov_spending via the reform. Co-Authored-By: Claude Opus 4.5 --- .../ubi_center/basic_income_interactions.py | 53 +++++++++++++++++++ .../housing_benefit_applicable_income.py | 8 ++- policyengine_uk/variables/gov/gov_spending.py | 2 +- 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/reforms/ubi_center/basic_income_interactions.py b/policyengine_uk/reforms/ubi_center/basic_income_interactions.py index c54247c47..800a2bb01 100644 --- a/policyengine_uk/reforms/ubi_center/basic_income_interactions.py +++ b/policyengine_uk/reforms/ubi_center/basic_income_interactions.py @@ -264,6 +264,58 @@ def formula(person, period, parameters): ) return max_(personal_gross_earned_income, floor) + class gov_spending(Variable): + label = "government spending" + documentation = ( + "Government spending impact in respect of this household." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + adds = [ + "child_benefit", + "esa_income", + "esa_contrib", + "housing_benefit", + "income_support", + "jsa_income", + "jsa_contrib", + "pension_credit", + "universal_credit", + "working_tax_credit", + "child_tax_credit", + "attendance_allowance", + "afcs", + "bsp", + "carers_allowance", + "dla", + "iidb", + "incapacity_benefit", + "pip", + "sda", + "state_pension", + "maternity_allowance", + "statutory_sick_pay", + "statutory_maternity_pay", + "ssmg", + "basic_income", # Added via this reform + "epg_subsidy", + "cost_of_living_support_payment", + "energy_bills_rebate", + "winter_fuel_allowance", + "pawhp", + "other_public_spending_budget_change", + "tax_free_childcare", + "extended_childcare_entitlement", + "universal_childcare_entitlement", + "targeted_childcare_entitlement", + "care_to_learn", + "dfe_education_spending", + "dft_subsidy_spending", + "nhs_spending", + ] + class reform(Reform): def apply(self): self.update_variable(child_benefit_respective_amount) @@ -273,6 +325,7 @@ def apply(self): self.update_variable(income_support_applicable_income) self.update_variable(housing_benefit_applicable_income) self.update_variable(uc_mif_capped_earned_income) + self.update_variable(gov_spending) return reform diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index 64f327964..dc6e6e278 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -38,11 +38,9 @@ def formula(benunit, period, parameters): credits = add(benunit, period, ["tax_credits"]) increased_income = income + personal_benefits + credits + benefits - # Default behavior: Basic income not included in means tests. - # Use basic_income_interactions reform to change this. - # Basic income is already in personal benefits via social_security_income, - # so deduct it to exclude from means test. - increased_income -= add(benunit, period, ["basic_income"]) + # Note: basic_income handling is done via basic_income_interactions reform + # which overrides this variable when the reform is active. + # Reduce increased income by pension contributions and tax pension_contributions = ( add(benunit, period, ["pension_contributions"]) * 0.5 diff --git a/policyengine_uk/variables/gov/gov_spending.py b/policyengine_uk/variables/gov/gov_spending.py index 84983a550..bde2b19f0 100644 --- a/policyengine_uk/variables/gov/gov_spending.py +++ b/policyengine_uk/variables/gov/gov_spending.py @@ -34,7 +34,7 @@ class gov_spending(Variable): "statutory_sick_pay", "statutory_maternity_pay", "ssmg", - "basic_income", + # basic_income excluded - added via basic_income_interactions reform "epg_subsidy", "cost_of_living_support_payment", "energy_bills_rebate", From 95d85ef5a6f4a3b114da10ebd637ea78884ee46c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Jan 2026 17:41:48 -0500 Subject: [PATCH 9/9] Remove all contrib variable refs from gov/ aggregates Created contrib_aggregates reform to add: - wealth_tax, non_primary_residence_wealth_tax, LVT, carbon_tax to gov_tax/household_tax - other_public_spending_budget_change to gov_spending These contrib (policy proposal) variables are now only included via structural reforms, keeping gov/ variables limited to actual law. Co-Authored-By: Claude Opus 4.5 --- .../reforms/policyengine/__init__.py | 8 + .../policyengine/contrib_aggregates.py | 165 ++++++++++++++++++ policyengine_uk/reforms/reforms.py | 4 + policyengine_uk/variables/gov/gov_spending.py | 2 +- policyengine_uk/variables/gov/gov_tax.py | 6 +- .../variables/gov/hmrc/household_tax.py | 6 +- 6 files changed, 182 insertions(+), 9 deletions(-) create mode 100644 policyengine_uk/reforms/policyengine/contrib_aggregates.py diff --git a/policyengine_uk/reforms/policyengine/__init__.py b/policyengine_uk/reforms/policyengine/__init__.py index cef746810..eccd95c38 100644 --- a/policyengine_uk/reforms/policyengine/__init__.py +++ b/policyengine_uk/reforms/policyengine/__init__.py @@ -33,6 +33,11 @@ create_two_child_limit_age_exemption_reform, two_child_limit_age_exemption_reform, ) +from .contrib_aggregates import ( + create_contrib_aggregates, + create_contrib_aggregates_reform, + contrib_aggregates_reform, +) __all__ = [ "create_disable_simulated_benefits", @@ -56,4 +61,7 @@ "create_two_child_limit_age_exemption", "create_two_child_limit_age_exemption_reform", "two_child_limit_age_exemption_reform", + "create_contrib_aggregates", + "create_contrib_aggregates_reform", + "contrib_aggregates_reform", ] diff --git a/policyengine_uk/reforms/policyengine/contrib_aggregates.py b/policyengine_uk/reforms/policyengine/contrib_aggregates.py new file mode 100644 index 000000000..1587fc451 --- /dev/null +++ b/policyengine_uk/reforms/policyengine/contrib_aggregates.py @@ -0,0 +1,165 @@ +from policyengine_uk.model_api import * + + +def create_contrib_aggregates() -> Reform: + """ + Reform that adds contrib tax/spending variables to gov aggregates. + + This reform adds wealth_tax, non_primary_residence_wealth_tax, LVT, + carbon_tax, and other_public_spending_budget_change to the appropriate + gov/ aggregate variables. + + These are contrib (policy proposal) variables that should not be in + the baseline gov/ variables representing actual law. + """ + + class gov_tax(Variable): + label = "government tax revenue" + documentation = ( + "Government tax revenue impact in respect of this household." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + adds = [ + "expected_sdlt", + "expected_ltt", + "expected_lbtt", + "corporate_sdlt", + "business_rates", + "council_tax", + "domestic_rates", + "fuel_duty", + "tv_licence", + "wealth_tax", + "non_primary_residence_wealth_tax", + "LVT", + "carbon_tax", + "income_tax", + "national_insurance", + "capital_gains_tax", + "private_school_vat", + "corporate_incident_tax_revenue_change", + "consumer_incident_tax_revenue_change", + "ni_employer", + "student_loan_repayments", + "vat", + ] + + class household_tax(Variable): + value_type = float + entity = Household + label = "household taxes" + documentation = "Total taxes owed by the household" + definition_period = YEAR + unit = GBP + adds = [ + "expected_sdlt", + "expected_ltt", + "expected_lbtt", + "corporate_sdlt", + "business_rates", + "council_tax", + "domestic_rates", + "fuel_duty", + "tv_licence", + "wealth_tax", + "non_primary_residence_wealth_tax", + "LVT", + "carbon_tax", + "income_tax", + "national_insurance", + "vat_change", + "capital_gains_tax", + "private_school_vat", + "corporate_incident_tax_revenue_change", + "consumer_incident_tax_revenue_change", + "employer_ni_response_capital_incidence", + "employer_ni_response_consumer_incidence", + "student_loan_repayments", + ] + + class gov_spending(Variable): + label = "government spending" + documentation = ( + "Government spending impact in respect of this household." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + adds = [ + "child_benefit", + "esa_income", + "esa_contrib", + "housing_benefit", + "income_support", + "jsa_income", + "jsa_contrib", + "pension_credit", + "universal_credit", + "working_tax_credit", + "child_tax_credit", + "attendance_allowance", + "afcs", + "bsp", + "carers_allowance", + "dla", + "iidb", + "incapacity_benefit", + "pip", + "sda", + "state_pension", + "maternity_allowance", + "statutory_sick_pay", + "statutory_maternity_pay", + "ssmg", + # basic_income added via basic_income_interactions reform + "epg_subsidy", + "cost_of_living_support_payment", + "energy_bills_rebate", + "winter_fuel_allowance", + "pawhp", + "other_public_spending_budget_change", + "tax_free_childcare", + "extended_childcare_entitlement", + "universal_childcare_entitlement", + "targeted_childcare_entitlement", + "care_to_learn", + "dfe_education_spending", + "dft_subsidy_spending", + "nhs_spending", + ] + + class reform(Reform): + def apply(self): + self.update_variable(gov_tax) + self.update_variable(household_tax) + self.update_variable(gov_spending) + + return reform + + +def create_contrib_aggregates_reform(parameters, period, bypass: bool = False): + """ + Creates the contrib aggregates reform. + + This reform is ALWAYS applied because the contrib variables return 0 + by default when their parameters aren't set, so adding them has no + effect unless the user explicitly enables them. + + Args: + parameters: The parameter tree (unused, kept for API consistency) + period: The period (unused, kept for API consistency) + bypass: If True, return the reform unconditionally (always True here) + + Returns: + Reform class + """ + # Always apply this reform - contrib variables return 0 by default + return create_contrib_aggregates() + + +# For direct import +contrib_aggregates_reform = create_contrib_aggregates() diff --git a/policyengine_uk/reforms/reforms.py b/policyengine_uk/reforms/reforms.py index bfbe34c41..0ebd708ed 100644 --- a/policyengine_uk/reforms/reforms.py +++ b/policyengine_uk/reforms/reforms.py @@ -19,6 +19,9 @@ from .policyengine.salary_sacrifice_haircut import ( create_salary_sacrifice_haircut_reform, ) +from .policyengine.contrib_aggregates import ( + create_contrib_aggregates_reform, +) from .scotland import create_scottish_child_payment_reform from .ubi_center import create_basic_income_interactions_reform from policyengine_core.model_api import * @@ -39,6 +42,7 @@ def create_structural_reforms_from_parameters(parameters, period): create_employer_ni_pension_exemption_reform(parameters, period), create_salary_sacrifice_haircut_reform(parameters, period), create_basic_income_interactions_reform(parameters, period), + create_contrib_aggregates_reform(parameters, period), ] reforms = tuple(filter(lambda x: x is not None, reforms)) diff --git a/policyengine_uk/variables/gov/gov_spending.py b/policyengine_uk/variables/gov/gov_spending.py index bde2b19f0..86d770cc8 100644 --- a/policyengine_uk/variables/gov/gov_spending.py +++ b/policyengine_uk/variables/gov/gov_spending.py @@ -40,7 +40,7 @@ class gov_spending(Variable): "energy_bills_rebate", "winter_fuel_allowance", "pawhp", - "other_public_spending_budget_change", + # other_public_spending_budget_change excluded - added via contrib reform "tax_free_childcare", "extended_childcare_entitlement", "universal_childcare_entitlement", diff --git a/policyengine_uk/variables/gov/gov_tax.py b/policyengine_uk/variables/gov/gov_tax.py index 63d6431b1..dd950cefd 100644 --- a/policyengine_uk/variables/gov/gov_tax.py +++ b/policyengine_uk/variables/gov/gov_tax.py @@ -20,12 +20,10 @@ class gov_tax(Variable): "domestic_rates", "fuel_duty", "tv_licence", - "wealth_tax", - "non_primary_residence_wealth_tax", + # wealth_tax, non_primary_residence_wealth_tax, LVT, carbon_tax + # excluded - added via contrib_taxes reform "income_tax", "national_insurance", - "LVT", - "carbon_tax", "capital_gains_tax", "private_school_vat", "corporate_incident_tax_revenue_change", diff --git a/policyengine_uk/variables/gov/hmrc/household_tax.py b/policyengine_uk/variables/gov/hmrc/household_tax.py index eaec5c858..7913284de 100644 --- a/policyengine_uk/variables/gov/hmrc/household_tax.py +++ b/policyengine_uk/variables/gov/hmrc/household_tax.py @@ -18,12 +18,10 @@ class household_tax(Variable): "domestic_rates", "fuel_duty", "tv_licence", - "wealth_tax", - "non_primary_residence_wealth_tax", + # wealth_tax, non_primary_residence_wealth_tax, LVT, carbon_tax + # excluded - added via contrib_taxes reform "income_tax", "national_insurance", - "LVT", - "carbon_tax", "vat_change", "capital_gains_tax", "private_school_vat",