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/changelog_entry.yaml b/changelog_entry.yaml index e69de29bb..488c4318d 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. 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/). diff --git a/policyengine_uk/reforms/__init__.py b/policyengine_uk/reforms/__init__.py index 9b356f9bc..6ac5c4ff0 100644 --- a/policyengine_uk/reforms/__init__.py +++ b/policyengine_uk/reforms/__init__.py @@ -1 +1,62 @@ 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, +) +from .ubi_center import ( + create_basic_income_interactions, + create_basic_income_interactions_reform, + basic_income_interactions_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", + # UBI Center + "create_basic_income_interactions", + "create_basic_income_interactions_reform", + "basic_income_interactions_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..eccd95c38 100644 --- a/policyengine_uk/reforms/policyengine/__init__.py +++ b/policyengine_uk/reforms/policyengine/__init__.py @@ -1 +1,67 @@ -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, +) +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, +) +from .contrib_aggregates import ( + create_contrib_aggregates, + create_contrib_aggregates_reform, + contrib_aggregates_reform, +) + +__all__ = [ + "create_disable_simulated_benefits", + "disable_simulated_benefits", + "disable_simulated_benefits_reform", + "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", + "create_contrib_aggregates", + "create_contrib_aggregates_reform", + "contrib_aggregates_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/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/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/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/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..c57ee7567 --- /dev/null +++ b/policyengine_uk/reforms/policyengine/salary_sacrifice_haircut.py @@ -0,0 +1,97 @@ +from policyengine_uk.model_api import * + + +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. + + 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. + + 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): + 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 + + # 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) + + # 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. + + 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 (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 + """ + # 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 +salary_sacrifice_haircut_reform = create_salary_sacrifice_haircut() 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..0ebd708ed 100644 --- a/policyengine_uk/reforms/reforms.py +++ b/policyengine_uk/reforms/reforms.py @@ -4,7 +4,26 @@ 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 .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 * from policyengine_core import periods @@ -16,7 +35,14 @@ 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), + create_contrib_aggregates_reform(parameters, period), ] reforms = tuple(filter(lambda x: x is not None, reforms)) 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", +] 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..800a2bb01 --- /dev/null +++ b/policyengine_uk/reforms/ubi_center/basic_income_interactions.py @@ -0,0 +1,358 @@ +from policyengine_uk.model_api import * + + +def create_basic_income_interactions() -> Reform: + """ + Reform that modifies how basic income interacts with existing benefits. + + 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): + 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): + 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( + 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) + + # 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) + + 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", + ] + 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 + 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) + 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 + 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", + ] + 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, + 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", + ] + 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) + personal_benefits = add(benunit, period, PERSONAL_BENEFITS) + credits = add(benunit, period, ["tax_credits"]) + increased_income = income + personal_benefits + credits + benefits + + 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 = ( + 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", + ] + 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), + person("uc_minimum_income_floor", period), + -inf, + ) + 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) + 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) + self.update_variable(gov_spending) + + return reform + + +def create_basic_income_interactions_reform( + parameters, period, bypass: bool = False +): + """ + 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 (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 - 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 +basic_income_interactions_reform = create_basic_income_interactions() 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() 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..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 @@ -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,9 @@ 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"]) + # 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/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_spending.py b/policyengine_uk/variables/gov/gov_spending.py index 84983a550..86d770cc8 100644 --- a/policyengine_uk/variables/gov/gov_spending.py +++ b/policyengine_uk/variables/gov/gov_spending.py @@ -34,13 +34,13 @@ 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", "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 131cf8ef7..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", @@ -34,12 +32,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..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", @@ -33,17 +31,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