Repository navigation
Stop the statutory Marriage Allowance rate from switching on the CPS expanded Marriage Allowance - #2553
Conversation
… only create_marriage_tax_reform built the expanded Marriage Allowance whenever the CPS ma_rate differed from the statutory Marriage Allowance rate. A change to the statutory rate therefore switched the reform on: setting it to 0 replaced it with the CPS 10% rate and, with no child-age condition, opened the allowance to higher- and additional-rate taxpayers. Compare ma_rate with its own unreformed value (parameters.baseline, as adjust_budgets does) instead. remove_income_condition still switches the reform on by itself. Fixes #2551 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… off The switch runs when policyengine_uk.Simulation is built, which YAML policy tests skip, so the reproducers are Python tests on every reform route: - a statutory rate of 0 removes the allowance (the £30,000 earner pays 252 more; the £60,000 earner the same), and 20% raises only the statutory allowance; - remove_income_condition or an ma_rate change still switch the reform on, with the values main gave; - couples outside the reform's child condition keep the statutory rate. Hypothesis properties cover the switch itself over random parameter values, a zero statutory rate giving no allowance and the same income tax as no claims, the band and £10-rounded cap bounds at any statutory rate, monotonicity in the statutory rate, and the switched-on reform matching the explicit expanded_ma_reform. YAML cases pin the issue's hand-computed 2025-26 values. On main's create_marriage_tax_reform, 12 of the 45 new Python tests fail (the reproducers); the rest pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Aggregate impact on certified data: passBase Checks: 0 broken by this change, 1 failing on both sides, 0 fixed. Moves flagged for review: 0 (thresholds in Notes
Moves flagged for reviewNone. Spending by benefit and service
Caseloads
Household net income by decileNo household's net income changes in any year. No change in any year: receipts by tax head; government balance, each component counted once; model totals; components the model counts twice; household income totals; population; poverty rates; people in poverty; inequality; mean household net income in each side's own deciles; standard reform costings (change in gov_balance); standard reforms: change in each reform's target. Checks failing on both base and head (1)
Counted-once checkEach of 84 components was raised by £1 per unit in 2026-27, and every head of its totals was read household by household: its own head must rise by exactly £1, no other tax head may move, no other benefit may rise, and the model's own total must move by what its heads moved. A head that breaks a rule is measured again twice: with every other head held at its baseline value, and with every other head held at its value after the first £1 while the component rises again. It fails where either still shows the response. 0 failed; 0 were skipped.
Provenance
Dataset: Receipts by tax head: head levels
Spending by benefit and service: head levels
Caseloads: head levels
Government balance, each component counted once: head levels
Model totals: head levels
Components the model counts twice: head levels
Household income totals: head levels
Population: head levels
Poverty rates: head levels
People in poverty: head levels
Inequality: head levels
Mean household net income in each side's own deciles: head levels
Standard reform costings (change in gov_balance): head levels
Standard reforms: change in each reform's target: head levels
|
|
Merged by the UK hub (Claude session 8e19f9d5) at head
|
|
Confirmed on the released fix. I reran the #2551 reproduction (
That is the expected statutory behaviour. The national rerun of the scorecard's 🤖 Generated with Claude Code |
Fixes #2551
What was wrong
create_marriage_tax_reform(policyengine_uk/reforms/cps/marriage_tax_reforms.py) adds the CPS expanded Marriage Allowance to every simulation whose parameters switch it on. The switch wasremove_income_condition or rate != original_rate. Hererateisgov.contrib.cps.marriage_tax_reforms.expanded_ma.ma_rate(default 0.1) andoriginal_rateis the statutorygov.hmrc.income_tax.allowances.marriage_allowance.max, so any change to the statutory rate switched the reform on. Withmax_child_ageat its default 0, the reform'smeets_expanded_ma_conditionsis true for everyone. It then admits the higher and additional bands and caps every transfer atma_rate, ignoring the statutory rate. A statutory rate of 0 therefore kept a 10% allowance for basic-rate couples and gave one to higher-rate couples. Raising the statutory rate to 20% also left basic-rate couples at 10%.The fix
The reform now switches on from its own parameters only:
remove_income_condition, or anma_rateother than its unreformed value. The unreformed value is read fromparameters.baseline, the unreformed copy thatadjust_budgetsandbaseline_vatalready compare against. On every reform route below I checked thatparameters.baselineholds 0.1 when the switch runs, whatever the reform sets. Nothing inside the reform changes. People outside its conditions (with amax_child_age) keep the statutory rate, as before, and a test now covers that.Why not a new
in_effectflagOther UK structural reforms switch on from a boolean (
gov.contrib.conservatives.cb_hitc_household,gov.contrib.policyengine.disable_simulated_benefits,gov.contrib.scotland.scottish_child_payment.in_effect), or compare a parameter withparameters.baseline(adjust_budgets). The expanded Marriage Allowance already has a boolean switch,remove_income_condition. Withma_rateat its default, anin_effectflag would build exactly the reform thatremove_income_conditionbuilds.Searching for
expanded_maandmarriage_tax_reforms:git grepat origin HEAD): no references.budget_reform_contract.pyusesremove_income_conditionas its structural-switch case, which still works. policyengine-scorecard has only replay result packets.gov.contrib.*behind itsincludeContribfilter, so saved policies may setma_rate. Those policies still switch the reform on, because the rate differs from its default.The only saved policies that change are ones that moved the statutory rate:
max_child_age5) now needsremove_income_condition: true.Tests
The switch runs when
policyengine_uk.Simulationis built. YAML policy tests build simulations with core'sSimulationBuilder, which never runs the parameter-switched structural reforms. So the reproducers are Python tests, and they run on each route a parameter change takes: a reform dict,Scenario(parameter_changes=...),Reform.from_dict, andScenariowithapplied_before_data_load.Reproducer:
policyengine_uk/tests/test_marriage_allowance_cps_switch.py::test_a_zero_statutory_rate_removes_the_allowance,policyengine_uk/tests/test_marriage_allowance_cps_switch.py::test_a_higher_statutory_rate_raises_only_the_statutory_allowance,policyengine_uk/tests/test_marriage_allowance_cps_switch.py::test_a_zero_statutory_rate_is_no_allowance_for_anyone,policyengine_uk/tests/test_marriage_allowance_cps_switch.py::test_the_reform_switches_on_from_its_own_parameters_onlyThe issue's couples, computed by hand from the 2025-26 parameters (£12,570 personal allowance; 20% on the first £37,700, then 40%). The YAML cases are in
policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_statutory_rate.yaml, and the Python tests repeat them throughSimulation:marriage_allowance(earner, partner)income_taxRegression cases (invariant 5):
remove_income_conditionandma_rate = 0.2still switch the reform on, on every route, with the values main gives. Withma_rate = 0.2andmax_child_age = 5, couples without a child under 5 keep the statutory allowance (1,260 at current law, 0 at a statutory 0), and couples with one get 2,520.Invariants
Each is stated for every input and tested with Hypothesis.
remove_income_conditionis true orma_ratediffers from its unreformed value. The statutory rate has no effect. This is tested over random statutory rates, CPS rates, switches and child ages. At the statutory rate in force it is identical to the old switch, and at current law the reform is off in every year from 2016 to 2035.marriage_allowanceis 0 for every person. Income tax then equals current-law income tax when no one claims the allowance.eligible_bands(which includes everyone in the higher and additional bands) meetsmeets_marriage_allowance_income_conditionsor has an allowance. No one's allowance exceeds12,570 × raterounded up to the next £10. Rates are drawn in whole thousandths, so the bound is exact; the test file comment explains why.marriage_allowancenever falls when the statutory rate rises.remove_income_condition, the reform gives the samemarriage_allowance, income conditions andincome_taxas the explicitexpanded_ma_reformfor every generated couple.The generated couples: 20 per simulation, drawn incomes including values near the UK and Scottish band edges, married or not, each partner claiming or not. A quarter of them live in Scotland.
Run locally
A host reservation was in force, so everything ran single-process and without microsimulation tests. CI runs the full suite.
marriage_tax_reforms.py: the 12 reproducer tests fail (the zero and 20% statutory-rate cases on all four routes, the CPS-rate case, invariants 1, 2 and 3) and the other 33 pass.policyengine-core testonpolicyengine_uk/tests/policy/baseline/gov/hmrc/income_taxandpolicyengine_uk/tests/policy/reforms/cps: 334 passed.test_reform_representations.py,test_dated_reform_routes.py::test_a_dict_reform_switches_on_a_parameter_gated_structural_reformandtest_labour_supply_couples.py: 103 passed.ruff format --check .andruff check .: clean.Impact
Current law should be exactly unchanged. At baseline the statutory rate equals
ma_rate, so the old switch was off too, and the reform's variables are untouched. Aggregate impact report: pending (CI).Reforms that change the statutory rate do change. Abolishing the allowance now raises income tax: on the issue's couples, +£252 for the basic-rate earner and nothing for the higher-rate one. Before, it gave the higher-rate one £504 off. The policyengine-scorecard
as2013_marriage_allowancererun on this branch is pending.Overlapping pull requests
rate != original_rateswitch and passesremove_income_conditionintocreate_expanded_ma_reform. The conflict is one hunk increate_marriage_tax_reform. Whichever lands second keeps this switch and Model Marriage Allowance as the statutory s.55B transfer and Step 6 reduction #1963's argument. Model Marriage Allowance as the statutory s.55B transfer and Step 6 reduction #1963 also stops a rate-only CPS change from opening the higher bands. If it lands second, it should change thecps_rateexpectations inOWN_SWITCHES(the £60,000 earner's 2,520 and 10,424) by design. Its owner session has been told.tax_bandformula, the one that applies before 2017-04-06. No file overlaps this PR. These tests use 2025-26, whichformula_2018_06_01serves.axiom: n/a: this changes when a contributed (CPS) reform switches on, not a statutory provision; the statutory Marriage Allowance rules (ITA 2007 ss. 55B–55C) are unchanged
🤖 Generated with Claude Code