Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
6ec7179
Model Marriage Allowance as the statutory s.55B transfer
MaxGhenis Sep 30, 2026
ba72b98
Test Marriage Allowance from the statute and re-enable it in the UC p…
MaxGhenis Sep 30, 2026
144195c
Add Marriage Allowance property tests, including a brute-force differ…
MaxGhenis Sep 30, 2026
a1a4822
Take the couple's Marriage Allowance take-up from the gaining spouse
MaxGhenis Sep 30, 2026
20c0231
Test dividends against the basic rate limit; fix Marriage Allowance t…
MaxGhenis Sep 30, 2026
6084d7b
Correct Marriage Allowance tests and notes from the statutory review
MaxGhenis Sep 30, 2026
548d6f9
Stack dividends above all non-savings income in the Marriage Allowanc…
MaxGhenis Sep 30, 2026
292f7c8
Harden the Marriage Allowance election from the robustness review
MaxGhenis Oct 1, 2026
cf5f1a0
Merge #1949 (uc-earned-income-own-tax, 8c88c7cd) into marriage-allowa…
MaxGhenis Oct 3, 2026
cb0f0ab
Merge origin/main (2cbf20ce) into marriage-allowance-election
MaxGhenis Oct 3, 2026
f2d7f7d
Merge origin/main (6d89338ab) into marriage-allowance-election
MaxGhenis Oct 11, 2026
696362a
Deduct the finance-cost reduction before the Marriage Allowance reduc…
MaxGhenis Oct 11, 2026
45837af
Claim Marriage Allowance in the UC unearned-benefit property tests
MaxGhenis Oct 11, 2026
0ddb7d6
Name the changelog fragment after issue #1947
MaxGhenis Oct 11, 2026
9a429d0
Test the s. 55B(2)(ba) dividend boundary at the basic rate limit
MaxGhenis Oct 11, 2026
5fdc01e
Wrap a long comment in the UC earnings deductions YAML
MaxGhenis Oct 11, 2026
5a32ce9
Give finance costs to a quarter of generated populations
MaxGhenis Oct 11, 2026
71ba551
Test the election's branches for query order, baseline isolation and …
MaxGhenis Oct 11, 2026
13fbdbb
Test take-up when the electing spouse is the elder
MaxGhenis Oct 11, 2026
0c9c4d6
Weigh the election under the property allowance route the couple uses
MaxGhenis Oct 11, 2026
418a0b9
Make the branch tests reach the property route choice
MaxGhenis Oct 11, 2026
f9fdccd
Merge origin/main (3f174d07e) into marriage-allowance-election
MaxGhenis Oct 12, 2026
09e4135
Hold every input the election resolves first fixed in its branches
MaxGhenis Oct 12, 2026
4757b37
Merge remote-tracking branch 'origin/main' into marriage-allowance-el…
MaxGhenis Oct 12, 2026
dafe630
Unpin the Marriage Allowance band-base case now that #1947 is fixed
MaxGhenis Oct 12, 2026
99182e3
Weigh the property allowance route on the couple's tax; fix the 2015-…
MaxGhenis Oct 12, 2026
829fcca
Merge origin/main (76cfc9e79) into marriage-allowance-election
MaxGhenis Oct 20, 2026
10e6b33
Test the Marriage Allowance rates on the bands main now taxes with
MaxGhenis Oct 20, 2026
d75ab7a
Give the Marriage Allowance property fixture explicit claimant and pa…
MaxGhenis Oct 21, 2026
6260034
Merge main and fix Marriage Allowance counterfactual inputs
juaristi22 Oct 22, 2026
00bf62a
Fix pension precision and review regressions
juaristi22 Oct 22, 2026
c4cfc94
Improve CI guard diagnostics and shard coverage
juaristi22 Oct 23, 2026
1cd7977
Merge origin/main into marriage-allowance-election
MaxGhenis Oct 28, 2026
8117a44
Supply the 70/45 couple's roles in the Marriage Allowance YAML case
MaxGhenis Oct 28, 2026
21977b2
Align main's Marriage Allowance tests with the Step 6 election (#1947)
MaxGhenis Oct 28, 2026
fae3baa
Keep a patched setter from leaking into the election's branches
MaxGhenis Oct 28, 2026
5cedfaf
Judge the Marriage Allowance rate condition on taxable income itself
MaxGhenis Oct 28, 2026
69c919f
Free the election branches' arrays as soon as they are dropped
MaxGhenis Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .github/test_outcome_allowlist.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@
# Keep entries narrow. An entry that matches nothing in a run is reported as a
# warning; remove it once its bug is fixed. Failures can never be accepted.

[[allow]]
nodeid = "policyengine_uk/tests/test_uc_state_pension_properties.py::test_state_pension_does_not_change_partners_marriage_allowance"
outcome = "xfailed"
reason = "#1947"
why = "Known bug #1947 (Marriage Allowance booked on the recipient), pinned by a strict xfail until it is fixed."

[[allow]]
nodeid = "policyengine_uk/tests/test_build_metadata.py::test_runtime_metadata_uses_bundle_contract_when_available"
outcome = "skipped"
Expand Down
1 change: 1 addition & 0 deletions changelog.d/1947.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Model Marriage Allowance as ITA 2007 ss. 55A-55E provide: the electing spouse gives up the fixed transferable amount from their own personal allowance and the other spouse gets a Step 6 tax reduction of the basic (or Scottish basic) rate times that amount, instead of the recipient's taxable income falling by the transferor's unused allowance.
1 change: 1 addition & 0 deletions changelog.d/1963.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Made counted-once guard failures show only validated public diagnostics.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
description: Transferable amount where the Act fixes it in pounds rather than as a share of the personal allowance. For 2015-16 it is £1,060 (ITA 2007 s. 55B(4)(a)); from 2016-17 the amount is the share of the personal allowance in `max`, so this is zero.
values:
2015-04-06: 1_060
2016-04-06: 0
metadata:
unit: currency-GBP
label: Marriage Allowance fixed transferable amount
reference:
- title: Income Tax Act 2007 s. 55B(4)(a)
href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
description: Maximum Marriage Allowance taxable income reduction, as a percentage of the full Personal Allowance.
description: Marriage Allowance transferable amount, as a share of the personal allowance in ITA 2007 s. 35(1). For 2015-16 the Act fixes the amount at £1,060, which is 10% of that year's £10,600 allowance.
values:
2016-04-01: 0.1
2015-04-06: 0.1
metadata:
unit: /1
label: Marriage Allowance maximum percentage
label: Marriage Allowance transferable share of the personal allowance
reference:
- title: Income Tax Act 2007 s. 55B(4)(b)
href: https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A
- title: Income Tax Act 2007 s. 55B(4)
href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
description: The Marriage Allowance is rounded up by this increment.
description: The Marriage Allowance transferable amount is rounded up to a multiple of this amount.
values:
2016-04-01: 10
metadata:
unit: currency-GBP
label: Marriage Allowance rounding increment
reference:
- title: Income Tax Act 2007 s. 55B(5)
href: https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A
href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ values:
- taxable_dividend_income
- taxable_property_income
- received_allowances_earned_income
- marriage_allowance

metadata:
unit: list
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ values:
- capped_mcad
- other_tax_credits
- property_finance_cost_relief
- marriage_allowance_tax_reduction
82 changes: 39 additions & 43 deletions policyengine_uk/reforms/cps/marriage_tax_reforms.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
from policyengine_uk.model_api import *
from policyengine_uk.utils.income_tax import net_income_from_adjusted_net_income
from policyengine_uk.utils.supplied_inputs import supplied_input
from policyengine_uk.variables.gov.hmrc.income_tax.allowances.meets_marriage_allowance_income_conditions import (
liable_only_at_marriage_allowance_rates,
)
from typing import Union, Optional


def create_expanded_ma_reform(
max_child_age: Optional[int] = None,
child_education_levels: Optional[List[str]] = None,
remove_income_condition: bool = True,
) -> Reform:
class meets_expanded_ma_conditions(Variable):
label = "Qualifies for an expanded Marriage Allowance"
Expand Down Expand Up @@ -34,70 +38,60 @@ def formula(person, period):

class meets_marriage_allowance_income_conditions(Variable):
label = "Meets Marriage Allowance income conditions"
documentation = "Whether this person (and their partner) meets the conditions for this person to be eligible for the Marriage Allowance, as set out in the Income Tax Act 2007 sections 55B and 55C"
documentation = (
"Whether this person pays income tax only at the rates that allow "
"a Marriage Allowance election, or meets the reform's expansion "
"conditions, which lift that restriction."
)
entity = Person
definition_period = YEAR
value_type = bool
reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B"

def formula(person, period, parameters):
band = person("tax_band", period)
eligible_bands = parameters(
period
).gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands
base_eligible = np.isin(band.decode_to_str(), eligible_bands)
# Expand to higher bands if the reform's expansion conditions are met.
bands = band.possible_values
expansion = person("meets_expanded_ma_conditions", period) & (
(band == bands.HIGHER) | (band == bands.ADDITIONAL)
conditions = liable_only_at_marriage_allowance_rates(
person, period, parameters
)
return base_eligible | expansion
if not remove_income_condition:
return conditions
return conditions | person("meets_expanded_ma_conditions", period)

class marriage_allowance(Variable):
class marriage_allowance_transferable_amount(Variable):
value_type = float
entity = Person
label = "Marriage Allowance"
label = "Marriage Allowance transferable amount"
documentation = (
"The share of the personal allowance a Marriage Allowance election "
"transfers, at the reform's rate for couples meeting its expansion "
"conditions, rounded up to the rounding increment."
)
definition_period = YEAR
reference = "https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A"
reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B"
unit = GBP

def formula(person, period, parameters):
marital = person("marital_status", period)
married = marital == marital.possible_values.MARRIED
eligible = (
married
& person("is_claimant_or_partner", period)
& person("meets_marriage_allowance_income_conditions", period)
)
transferable_amount = person("partners_unused_personal_allowance", period)
allowances = parameters(period).gov.hmrc.income_tax.allowances
capped_percentage = allowances.marriage_allowance.max
expanded_ma_cap = parameters(
period
).gov.contrib.cps.marriage_tax_reforms.expanded_ma.ma_rate
capped_percentage = where(
p = parameters(period)
allowances = p.gov.hmrc.income_tax.allowances
share = where(
person("meets_expanded_ma_conditions", period),
expanded_ma_cap,
capped_percentage,
)
max_amount = allowances.personal_allowance.amount * capped_percentage
amount_if_eligible_pre_rounding = max_(
min_(transferable_amount, max_amount), 0
)
# Round up.
rounding_increment = allowances.marriage_allowance.rounding_increment
amount_if_eligible = (
np.ceil(amount_if_eligible_pre_rounding / rounding_increment)
* rounding_increment
p.gov.contrib.cps.marriage_tax_reforms.expanded_ma.ma_rate,
allowances.marriage_allowance.max,
)
takes_up = person("would_claim_marriage_allowance", period)
return eligible * amount_if_eligible * takes_up
amount = allowances.personal_allowance.amount * share
increment = allowances.marriage_allowance.rounding_increment
rounded = np.ceil(amount / increment) * increment
# 2015-16 is a fixed amount (ITA 2007 s. 55B(4)(a)) unless the
# reform's expansion applies.
fixed = allowances.marriage_allowance.fixed_amount
if fixed == 0:
return rounded
return where(person("meets_expanded_ma_conditions", period), rounded, fixed)

class reform(Reform):
def apply(self):
self.add_variable(meets_expanded_ma_conditions)
self.update_variable(meets_marriage_allowance_income_conditions)
self.update_variable(marriage_allowance)
self.update_variable(marriage_allowance_transferable_amount)

return reform

Expand Down Expand Up @@ -232,6 +226,7 @@ def create_marriage_tax_reform(parameters, period):
if remove_income_condition or rate != default_rate:
ma_reform = create_expanded_ma_reform(
max_child_age=ma_max_child_age if ma_max_child_age > 0 else None,
remove_income_condition=bool(remove_income_condition),
)
else:
ma_reform = None
Expand All @@ -258,4 +253,5 @@ def create_marriage_tax_reform(parameters, period):
# These build the reform classes with no child-age / education conditions, so the reform
# applies to every married couple regardless of child presence.
expanded_ma_reform = create_expanded_ma_reform()
expanded_ma_reform_rate_only = create_expanded_ma_reform(remove_income_condition=False)
marriage_neutral_it_reform = create_marriage_neutral_income_tax_reform()
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@
import numpy as np
import pytest

from policyengine_uk.utils.aggregate_impact.measure import measure
from policyengine_uk.utils.aggregate_impact.measure import (
component_guard_diagnostic,
measure,
)
from policyengine_uk.utils.aggregate_impact.partition import (
default_partition_path,
load_partition,
Expand Down Expand Up @@ -86,7 +89,7 @@ def test_every_tax_component_is_counted_exactly_once(measured):
rows = {r["variable"]: r for r in guard["components"]}
for head in partition.tax_heads:
row = rows[head.variable]
assert row["status"] == "pass", row
assert row["status"] == "pass", component_guard_diagnostic(row, partition)
for total, k in row["coefficients"].items():
assert k == pytest.approx(1, abs=1e-3), (head.id, total)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"""

from datetime import date
import types
import numpy as np
import pytest
from policyengine_core.periods import period
Expand Down Expand Up @@ -442,20 +443,23 @@ def test_partial_setter_failure_restores_only_touched_input(monkeypatch):
parent, child, sibling, part, saved, keys, model = _subset_origin_fixture()
before = values(child)
snapshot = guard.snapshot_inputs(child, part.component_variables())
original = child.set_input
original = type(child).set_input
failed = False

class DeliberatePartialSetterFailure(Exception):
pass

def partial(name, when, value):
def partial(simulation, name, when, value):
nonlocal failed
original(name, when, value)
if name == U and not failed:
original(simulation, name, when, value)
if simulation is child and name == U and not failed:
failed = True
raise DeliberatePartialSetterFailure()

monkeypatch.setattr(child, "set_input", partial)
# Bound to the child, so a branch made from it while the guard calculates
# (the Marriage Allowance election makes two) gets the setter bound to the
# branch, and its inputs stay in the branch.
monkeypatch.setattr(child, "set_input", types.MethodType(partial, child))
with pytest.raises(DeliberatePartialSetterFailure):
guard.counted_once(child, part, YEAR, 1e-6, snapshot, variables_to_check=[U])
assert failed and genuine(child, U) is None
Expand Down
Loading
Loading