Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 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
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
8 changes: 5 additions & 3 deletions .github/workflows/test-suite.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
# publish. All three run the same jobs in the same locked environment.
#
# Jobs: the YAML policy tests (which also list every Python test), the Python
# tests split over five runners, and Result, which fails unless every listed
# tests split over eight runners, and Result, which fails unless every listed
# Python test ran in exactly one shard and passed, apart from the outcomes
# accepted in .github/test_outcome_allowlist.toml. A caller that names its job
# Test gets the checks "Test / YAML", "Test / Python K/5" and "Test / Result";
# Test gets the checks "Test / YAML", "Test / Python K/8" and "Test / Result";
# "Test / Result" is the one to require.

name: Test suite
Expand Down Expand Up @@ -133,12 +133,14 @@ jobs:
runs-on: ubuntu-24.04-arm
# The shards took 15, 20 and 15 minutes on 10 October (run
# 38085920460), and up to 52 minutes with four shards on 21 October.
# Eight, not five: both source and wheel jobs on #1963 exceeded an hour
# with five shards. Keep the same limit and every-ID coverage audit.
# 60 minutes leaves room for a slow runner and still stops a hung shard.
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5]
shard: [1, 2, 3, 4, 5, 6, 7, 8]
steps:
- name: Checkout repo
uses: actions/checkout@v6
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 and increased source and wheel test shards after one-hour timeouts.
4 changes: 2 additions & 2 deletions docs/engineering/skills/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ the same suite,
tree; a release tests the wheel it is about to publish, installed on its own.

The suite runs the YAML policy tests in one job and splits the Python tests
over five runners. Each runner runs one shard:
over eight runners. Each runner runs one shard:

```bash
pytest policyengine_uk/tests -n 4 --dist worksteal --shard K/5 -o xfail_strict=true
pytest policyengine_uk/tests -n 4 --dist worksteal --shard K/8 -o xfail_strict=true
```

`--shard K/N` is defined in `policyengine_uk/tests/conftest.py`. It keeps the
Expand Down
6 changes: 5 additions & 1 deletion policyengine_uk/data/fiscal_partition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ tax_heads:
payer: household
totals: [gov_tax, household_tax, pre_budget_change_household_tax]
sign: nonnegative
reads:
- head: income_tax
route: capital_gains_tax -> allowances -> personal_allowance -> marriage_allowance_relinquished -> makes_marriage_allowance_election -> (private counterfactual) income_tax
why: the election determines personal allowance and the income occupying CGT rate bands; income tax is compared to choose the election, not included in capital gains tax
- id: student_loan_repayments
label: Student loan repayments
variable: student_loan_repayments
Expand Down Expand Up @@ -675,7 +679,7 @@ overlap_guards:
here as well.
- id: eligibility_reads
kind: read_not_inclusion
heads: [tv_licence, private_school_vat]
heads: [tv_licence, private_school_vat, capital_gains_tax]
property: >-
These heads reach other heads of the same total only through
eligibility or imputation (see each head's reads). Raising the other
Expand Down

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 @@ -228,6 +222,7 @@ def create_marriage_tax_reform(parameters, period):
if remove_income_condition or rate != original_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 @@ -254,4 +249,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
Loading
Loading