From 6ec7179158d9ce235a2e214646cc65fc04614d38 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 16:25:17 -0400 Subject: [PATCH 01/30] Model Marriage Allowance as the statutory s.55B transfer The transferor's election cuts their own personal allowance by the fixed transferable amount (s.55B(4)-(6)); the gaining party gets a Step 6 tax reduction at the basic or Scottish basic rate, capped at their Step 5 tax (s.55A(2), s.55B(1), (3), s.29(2)). The couple elects only when the conditions in s.55B(2) and s.55C(1)(b)-(c) hold, no married couple's allowance is claimed, it lowers their combined income tax, and the elder spouse's take-up draw is true. Co-Authored-By: Claude Opus 5.5 --- .../marriage_allowance/eligible_bands.yaml | 14 -- .../allowances/marriage_allowance/max.yaml | 10 +- .../rounding_increment.yaml | 4 +- .../earned_taxable_income_exclusions.yaml | 1 - .../income_tax/income_tax_subtractions.yaml | 3 + .../reforms/cps/marriage_tax_reforms.py | 68 ++++------ ..._marriage_allowance_income_conditions.yaml | 123 ++++++++++++++--- .../income/uc_income_tax_on_earnings.py | 13 +- .../is_marriage_allowance_spouse.py | 27 ++++ .../makes_marriage_allowance_election.py | 127 ++++++++++++++++++ .../allowances/marriage_allowance.py | 53 ++++---- ...rriage_allowance_appropriate_percentage.py | 31 +++++ .../marriage_allowance_relinquished.py | 22 +++ .../marriage_allowance_tax_reduction.py | 34 +++++ .../marriage_allowance_tax_reduction_limit.py | 30 +++++ .../marriage_allowance_transferable_amount.py | 26 ++++ ...ts_marriage_allowance_income_conditions.py | 79 +++++++++-- .../partners_unused_personal_allowance.py | 21 --- .../allowances/personal_allowance.py | 20 ++- 19 files changed, 564 insertions(+), 142 deletions(-) delete mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py delete mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml deleted file mode 100644 index 526252c4ed..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: Tax bands whose members may transfer personal allowance under the Marriage Allowance election (ITA 2007 s.55B). A transferor is eligible if they are not liable at the higher rate or above; the Scottish intermediate rate is treated as within the basic-rate envelope for this purpose. -values: - 0000-01-01: - - NONE - - STARTER - - BASIC - - INTERMEDIATE -metadata: - unit: list - label: Marriage Allowance eligible tax bands - period: year - reference: - - title: "Income Tax Act 2007, s. 55B" - href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml index bbde24b1b1..21e32ebf9f 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml @@ -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 diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml index af43fa56bd..c396ce4e47 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/rounding_increment.yaml @@ -1,4 +1,4 @@ -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: @@ -6,4 +6,4 @@ metadata: 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 diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml index d40254f49b..f4e83a3d2c 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml @@ -5,7 +5,6 @@ values: - taxable_dividend_income - taxable_property_income - received_allowances_earned_income - - marriage_allowance metadata: unit: list diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml index b516d33e30..2fae722e09 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml @@ -5,7 +5,10 @@ metadata: reference: - title: Income Tax Act 2007 s. 23 href: https://www.legislation.gov.uk/ukpga/2007/3/section/23 + - title: Income Tax Act 2007 s. 26(1)(a) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/26 values: 0000-01-01: - capped_mcad - other_tax_credits + - marriage_allowance_tax_reduction diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index c10ff49f3b..8722ac013b 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +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 @@ -28,66 +31,51 @@ 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) - ) - return base_eligible | expansion + return liable_only_at_marriage_allowance_rates( + person, period, parameters + ) | 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( - "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 = min_(transferable_amount, max_amount) - # 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 + return np.ceil(amount / increment) * increment 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 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 064d9679fc..76faab8662 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -1,41 +1,128 @@ -- name: Basic rate band is eligible for Marriage Allowance - period: 2021 +# ITA 2007 s. 55B(2)(b), (ba) and s. 55C(1)(c), (ca): only the basic rates, +# the Scottish starter and intermediate rates, the dividend ordinary rate and +# the nil and starting rates for savings. 2026-27: personal allowance 12,570, +# basic rate limit 37,700, Scottish higher rate from 31,092 of taxable income. + +- name: Basic-rate earner meets the conditions + period: 2026 + input: + employment_income: 30_000 + output: + meets_marriage_allowance_income_conditions: true + +- name: No income meets the conditions + period: 2026 + input: + employment_income: 0 + output: + meets_marriage_allowance_income_conditions: true + +- name: Taxable income exactly at the basic rate limit meets the conditions + period: 2026 input: - tax_band: BASIC + # 50,270 - 12,570 = 37,700, all at the basic rate. + employment_income: 50_270 output: meets_marriage_allowance_income_conditions: true -- name: Higher rate band is ineligible for Marriage Allowance - period: 2021 +- name: One pound into the higher rate fails the conditions + period: 2026 input: - tax_band: HIGHER + employment_income: 50_271 output: meets_marriage_allowance_income_conditions: false -- name: Additional rate band is ineligible for Marriage Allowance - period: 2021 +- name: Additional-rate earner fails the conditions + period: 2026 input: - tax_band: ADDITIONAL + employment_income: 150_000 output: meets_marriage_allowance_income_conditions: false -- name: Below-PA earner (band NONE) is eligible for Marriage Allowance (regression for #395) - period: 2021 +- name: Transferor test uses the cut allowance (s. 55C(1)(c)) + period: 2026 input: - tax_band: NONE + # Allowance 12,570 - 1,260 = 11,310, so 50,000 leaves 38,690 taxable, + # 990 above the basic rate limit. + employment_income: 50_000 + marriage_allowance_relinquished: 1_260 + output: + personal_allowance: 11_310 + meets_marriage_allowance_income_conditions: false + +- name: The same income meets the conditions with the full allowance + period: 2026 + input: + # 50,000 - 12,570 = 37,430, within the basic rate band. + employment_income: 50_000 output: meets_marriage_allowance_income_conditions: true -- name: Scottish starter rate band is eligible for Marriage Allowance - period: 2021 +- name: Scottish intermediate-rate earner meets the conditions + period: 2026 input: - tax_band: STARTER + # 40,000 - 12,570 = 27,430, below the Scottish higher rate at 31,092. + employment_income: 40_000 + region: SCOTLAND output: meets_marriage_allowance_income_conditions: true -- name: Scottish intermediate rate band is eligible for Marriage Allowance - period: 2021 +- name: Scottish starter-rate earner meets the conditions + period: 2026 input: - tax_band: INTERMEDIATE + employment_income: 14_000 + region: SCOTLAND output: meets_marriage_allowance_income_conditions: true + +- name: Scottish higher-rate earner fails the conditions + period: 2026 + input: + # 44,000 - 12,570 = 31,430, above 31,092. + employment_income: 44_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: false + +- name: Scottish savings are banded on UK rates, so an intermediate-rate earner with savings meets the conditions + period: 2026 + input: + # Non-savings 27,430 is below the Scottish higher rate; savings stack on + # it to 32,430, below the UK basic rate limit of 37,700. + employment_income: 40_000 + savings_interest_income: 5_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: Savings within the savings nil rate above the basic rate limit meet the conditions + period: 2026 + input: + # Non-savings 50,070 - 12,570 = 37,500. The 500 of interest is covered by + # the savings nil rate (s. 12A), which s. 55B(2)(b) allows, so none of it + # is charged at the savings higher rate. + employment_income: 50_070 + savings_interest_income: 500 + output: + meets_marriage_allowance_income_conditions: true + +- name: Dividends within the dividend nil rate above the basic rate limit fail the conditions + period: 2026 + input: + # The same 500 as dividends: s. 55B(2)(ba) ignores the dividend nil rate + # (s. 13A), and 37,500 + 500 = 38,000 would put 300 at the dividend upper + # rate. + employment_income: 50_070 + dividend_income: 500 + output: + meets_marriage_allowance_income_conditions: false + +- name: Savings charged at the savings higher rate fail the conditions + period: 2026 + input: + # Taxable income 37,500 + 2,000 = 39,500. A higher-rate taxpayer's + # savings nil rate covers 500, leaving savings at the higher rate. + employment_income: 50_070 + savings_interest_income: 2_000 + output: + meets_marriage_allowance_income_conditions: false diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py index 630b5320c9..bade3e5c59 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/income/uc_income_tax_on_earnings.py @@ -75,7 +75,14 @@ def formula(person, period, parameters): rates.scotland.rates.calc(taxable_earnings), rates.uk.calc(taxable_earnings), ) - # Tax reductions (for example the married couple's allowance) can - # leave total income tax below the tax on the earnings slice; never - # deduct more than the person pays. + # HMRC gives a Marriage Allowance recipient the transfer through + # their tax code (code letter M), so it comes off the tax on their + # earnings first. + tax = max_(0, tax - person("marriage_allowance_tax_reduction", period)) + # Other tax reductions (for example the married couple's allowance) + # can leave total income tax below the tax on the earnings slice; + # never deduct more than the person pays. That sets them against tax + # on other income first, the opposite order to the Marriage + # Allowance; the married couple's allowance needs a birth before + # 6 April 1935, so it hardly ever reaches a UC claimant. return min_(tax, person("income_tax", period)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py new file mode 100644 index 0000000000..a6203ffc90 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py @@ -0,0 +1,27 @@ +from policyengine_uk.model_api import * + + +class is_marriage_allowance_spouse(Variable): + value_type = bool + entity = Person + label = "Spouse or civil partner for Marriage Allowance" + documentation = ( + "Whether this person is one of the married couple or civil partners " + "in their benefit unit, who can make or gain from a Marriage " + "Allowance election. These are the two eldest members whose marital " + "status is married, and only when the benefit unit has two." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55C(1)(a)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ) + + def formula(person, period, parameters): + status = person("marital_status", period) + married = status == status.possible_values.MARRIED + # Without a marital status input, every member of a married benefit + # unit, children included, is married; the two eldest are the couple. + rank = person.get_rank(person.benunit, -person("age", period), condition=married) + spouse = married & (rank < 2) + return spouse & (person.benunit.sum(spouse) == 2) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py new file mode 100644 index 0000000000..bb710f8c63 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py @@ -0,0 +1,127 @@ +from policyengine_uk.model_api import * + +BRANCH_OUTPUTS = [ + "income_tax", + "personal_allowance", + "marriage_allowance_tax_reduction_limit", + "meets_marriage_allowance_income_conditions", +] + + +def tax_position(simulation, name, period, relinquished): + """Each person's tax with no election in force and the given allowance cut. + + The branch holds the election off, so no one gains a reduction, and cuts + each person's personal allowance by ``relinquished`` (ITA 2007 s. 55B(6)). + The branch is dropped afterwards so a later period starts from a fresh + copy of the simulation. + """ + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) + try: + branch.set_input( + "makes_marriage_allowance_election", + period, + np.zeros(len(relinquished), dtype=bool), + ) + branch.set_input("marriage_allowance_relinquished", period, relinquished) + population = branch.populations["person"] + return {variable: population(variable, period) for variable in BRANCH_OUTPUTS} + finally: + del simulation.branches[name] + + +class makes_marriage_allowance_election(Variable): + value_type = bool + entity = Person + label = "Makes a Marriage Allowance election" + documentation = ( + "Whether this person elects to give up part of their personal " + "allowance so that their spouse or civil partner gets the Marriage " + "Allowance tax reduction. The couple elects only when the election " + "is allowed and lowers their combined income tax, in whichever " + "direction lowers it more, and only if the elder spouse's " + "would_claim_marriage_allowance is true." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 55B", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 55C", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ), + dict( + title="Income Tax Act 2007 s. 55E", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55E", + ), + ] + + def formula(person, period, parameters): + spouse = person("is_marriage_allowance_spouse", period) + transferable = person("marriage_allowance_transferable_amount", period) + simulation = person.simulation + without = tax_position( + simulation, + "marriage_allowance_without_election", + period, + np.zeros_like(transferable), + ) + relinquishing = tax_position( + simulation, + "marriage_allowance_relinquishing", + period, + spouse * transferable, + ) + + def partner(values): + # The other spouse's value, for each spouse. + return person.benunit.sum(spouse * values) - spouse * values + + # If this person elects, the other spouse's tax falls by the + # appropriate percentage of this person's transferable amount, capped + # at the tax they have left (s. 55B(1), (3); s. 29(2)), and this + # person pays tax on the allowance they give up. + rate = person("marriage_allowance_appropriate_percentage", period) + partner_reduction = min_( + partner(rate) * transferable, + partner(without["marriage_allowance_tax_reduction_limit"]), + ) + extra_tax = relinquishing["income_tax"] - without["income_tax"] + # Tax is computed to the penny; smaller differences are rounding. + saving = np.round(partner_reduction - extra_tax, 2) + married_couples_allowance_claimed = person.benunit.any( + spouse & (person("married_couples_allowance", period) > 0) + ) + allowed = ( + spouse + # s. 55B(2)(d): neither spouse claims married couple's allowance. + & ~married_couples_allowance_claimed + # s. 55C(1)(b): the electing spouse is entitled to an allowance. + & (without["personal_allowance"] > 0) + # s. 55C(1)(c), (ca): only basic rates once the allowance is cut. + & relinquishing["meets_marriage_allowance_income_conditions"] + # s. 55B(2)(b), (ba): the gaining party pays only basic rates. + & (partner(without["meets_marriage_allowance_income_conditions"]) > 0) + ) + # Elect only to lower the couple's tax, and at most one spouse elects + # (s. 55E); on a tie the elder spouse elects. + saving_if_allowed = where(allowed & (saving > 0), saving, 0) + other_saving = partner(saving_if_allowed) + elder = ( + person.get_rank(person.benunit, -person("age", period), condition=spouse) + == 0 + ) + chooses = (saving_if_allowed > 0) & ( + (saving_if_allowed > other_saving) + | ((saving_if_allowed == other_saving) & elder) + ) + # The couple's take-up draw is the elder spouse's, so each couple has + # one draw whichever of them elects. + would_claim = person.benunit.any( + spouse & elder & person("would_claim_marriage_allowance", period) + ) + return chooses & would_claim diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py index a848ad9931..b91b8365d2 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py @@ -4,8 +4,11 @@ class would_claim_marriage_allowance(Variable): label = "Would claim Marriage Allowance" documentation = ( - "Whether this person would claim Marriage Allowance if eligible. " - "Generated stochastically in the dataset using take-up rates." + "Whether this person's couple would make a Marriage Allowance " + "election when it lowers their income tax. Generated stochastically " + "in the dataset using take-up rates. The couple's election follows " + "the value on its elder spouse or civil partner " + "(makes_marriage_allowance_election)." ) entity = Person definition_period = YEAR @@ -19,31 +22,31 @@ class would_claim_marriage_allowance(Variable): class marriage_allowance(Variable): value_type = float entity = Person - label = "Marriage Allowance" + label = "Marriage Allowance received" + documentation = ( + "The transferable amount this person's spouse or civil partner gives " + "up under a Marriage Allowance election. This person's tax falls by " + "the appropriate percentage of it (marriage_allowance_tax_reduction); " + "the allowance itself does not come off their taxable income." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A" + reference = [ + dict( + title="Income Tax Act 2007 s. 55B", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 55C", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ), + ] unit = GBP def formula(person, period, parameters): - marital = person("marital_status", period) - married = marital == marital.possible_values.MARRIED - is_adult = person("is_adult", period) - eligible = ( - married - & is_adult - & 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 - 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 + # The gaining party is the other spouse or civil partner of the person + # who elects (s. 55C(1)(a)); at most one of the couple elects. + spouse = person("is_marriage_allowance_spouse", period) + given_up = person("makes_marriage_allowance_election", period) * person( + "marriage_allowance_transferable_amount", period ) - # Use pre-generated take-up decision from dataset - would_claim = person("would_claim_marriage_allowance", period) - return eligible * amount_if_eligible * would_claim + return spouse * (person.benunit.sum(spouse * given_up) - given_up) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py new file mode 100644 index 0000000000..1554e2652d --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_appropriate_percentage(Variable): + value_type = float + entity = Person + label = "Marriage Allowance appropriate percentage" + documentation = ( + "The rate at which the Marriage Allowance reduces the gaining party's " + "tax: the basic rate, or the Scottish basic rate for a Scottish " + "taxpayer, whatever rate their income is actually taxed at. The Welsh " + "basic rate equals the basic rate in this model." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55B(3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ) + unit = "/1" + + def formula(person, period, parameters): + rates = parameters(period).gov.hmrc.income_tax.rates + # The Scottish basic rate follows the starter rate; before 2018-19 the + # Scottish scale had no starter rate. + scottish = rates.scotland.rates.rates + scottish_basic = scottish[1 if len(scottish) > 3 else 0] + return where( + person("pays_scottish_income_tax", period), + scottish_basic, + rates.uk.rates[0], + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py new file mode 100644 index 0000000000..f71e331e49 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_relinquished(Variable): + value_type = float + entity = Person + label = "Personal allowance given up under a Marriage Allowance election" + documentation = ( + "The transferable amount by which a Marriage Allowance election cuts " + "the electing person's own personal allowance." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55B(6)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ) + unit = GBP + + def formula(person, period, parameters): + return person("makes_marriage_allowance_election", period) * person( + "marriage_allowance_transferable_amount", period + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py new file mode 100644 index 0000000000..826fcfc7a4 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_tax_reduction(Variable): + value_type = float + entity = Person + label = "Marriage Allowance tax reduction" + documentation = ( + "The gaining party's tax reduction at Step 6 of the income tax " + "calculation: the appropriate percentage of the transferable amount, " + "capped at the tax left to reduce." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 55A(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55A", + ), + dict( + title="Income Tax Act 2007 s. 55B(1) and (3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 29(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + ] + unit = GBP + + def formula(person, period, parameters): + reduction = person("marriage_allowance_appropriate_percentage", period) * ( + person("marriage_allowance", period) + ) + return min_(reduction, person("marriage_allowance_tax_reduction_limit", period)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py new file mode 100644 index 0000000000..01f541268e --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_tax_reduction_limit(Variable): + value_type = float + entity = Person + label = "Tax available for the Marriage Allowance reduction" + documentation = ( + "Tax at Step 5 of the income tax calculation left after the person's " + "other tax reductions. The Marriage Allowance tax reduction cannot " + "exceed it. Deducting the other reductions first gives the same total " + "as the order that most reduces the person's liability." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 29(2) and (3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + dict( + title="Income Tax Act 2007 s. 27(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/27", + ), + ] + unit = GBP + + def formula(person, period, parameters): + step_5_tax = person("income_tax_pre_charges", period) + other_reductions = add(person, period, ["capped_mcad", "other_tax_credits"]) + return max_(0, step_5_tax - other_reductions) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py new file mode 100644 index 0000000000..a26dbec283 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_transferable_amount(Variable): + value_type = float + entity = Person + label = "Marriage Allowance transferable amount" + documentation = ( + "The fixed amount of personal allowance a Marriage Allowance election " + "transfers: 10% of the personal allowance in ITA 2007 s. 35(1), " + "rounded up to a multiple of £10. It does not depend on how much of " + "the transferor's allowance is unused." + ) + definition_period = YEAR + reference = dict( + title="Income Tax Act 2007 s. 55B(4) and (5)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ) + unit = GBP + + def formula(person, period, parameters): + allowances = parameters(period).gov.hmrc.income_tax.allowances + ma = allowances.marriage_allowance + amount = allowances.personal_allowance.amount * ma.max + increment = ma.rounding_increment + return np.ceil(amount / increment) * increment diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index e91f14cbd3..dd32ee569c 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -1,18 +1,81 @@ from policyengine_uk.model_api import * -import numpy as np + + +def liable_only_at_marriage_allowance_rates(person, period, parameters): + """Whether the person pays no income tax above the basic rates. + + ITA 2007 s. 55B(2)(b) and s. 55C(1)(c) allow only the basic rate, the + property, default, savings, Scottish and Welsh basic rates, the Scottish + starter and intermediate rates, the dividend ordinary rate and the nil and + starting rates for savings. So no non-savings income may reach the higher + rate (the Scottish higher rate for a Scottish taxpayer), and no savings the + savings higher rate. Dividends covered by the dividend nil rate still + count: s. 55B(2)(ba) and s. 55C(1)(ca) test the dividend upper rate as if + s. 13A were omitted. + """ + rates = parameters(period).gov.hmrc.income_tax.rates + savings = person("taxable_savings_interest_income", period) + dividends = person("taxable_dividend_income", period) + # Earnings, pensions, property and other non-savings income after + # allowances, as in property_income_tax. + non_savings = max_( + 0, + person("adjusted_net_income", period) + - savings + - dividends + - person("allowances", period), + ) + # The Scottish higher rate follows the starter, basic and intermediate + # rates; before 2018-19 the Scottish scale had only the basic rate below it. + scottish = rates.scotland.rates + scottish_higher = scottish.thresholds[3 if len(scottish.rates) > 3 else 1] + non_savings_limit = where( + person("pays_scottish_income_tax", period), + scottish_higher, + rates.uk.thresholds[1], + ) + savings_above_basic = add( + person, period, ["higher_rate_savings_income", "add_rate_savings_income"] + ) + # Stacked as in dividend_income_tax, before the dividend allowance. + below_dividends = person("earned_taxable_income", period) + max_( + 0, savings - person("received_allowances_savings_income", period) + ) + dividends_after_allowances = max_( + 0, dividends - person("received_allowances_dividend_income", period) + ) + dividends_above_basic = (dividends_after_allowances > 0) & ( + below_dividends + dividends_after_allowances > rates.dividends.thresholds[1] + ) + return ( + (non_savings <= non_savings_limit) + & (savings_above_basic <= 0) + & ~dividends_above_basic + ) 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, given their actual allowances. The " + "gaining party must meet this (ITA 2007 s. 55B(2)(b), (ba)); the " + "electing spouse must meet it with their allowance already cut by " + "the transferable amount (s. 55C(1)(c), (ca))." + ) entity = Person definition_period = YEAR value_type = bool - reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" + reference = [ + dict( + title="Income Tax Act 2007 s. 55B(2)(b) and (ba)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 55C(1)(c) and (ca)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55C", + ), + ] def formula(person, period, parameters): - band = person("tax_band", period) - eligible_bands = parameters( - period - ).gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands - return np.isin(band.decode_to_str(), eligible_bands) + return liable_only_at_marriage_allowance_rates(person, period, parameters) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py deleted file mode 100644 index 8d26ee5cb1..0000000000 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/partners_unused_personal_allowance.py +++ /dev/null @@ -1,21 +0,0 @@ -from policyengine_uk.model_api import * -from numpy import ceil - - -class partners_unused_personal_allowance(Variable): - label = "Partner's unused personal allowance" - documentation = ( - "The personal tax allowance not used by this person's partner, if they exist" - ) - entity = Person - definition_period = YEAR - value_type = float - unit = GBP - - def formula(person, period, parameters): - is_adult = person("is_adult", period) - pa = person("unused_personal_allowance", period) - # Subtract this person's own unused PA only if they are an adult, so - # non-adults (whose PA isn't part of the adult-summed pool) cannot - # produce a negative transferable amount. - return person.benunit.sum(is_adult * pa) - is_adult * pa diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py index 52efd2fab7..19b60ca948 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py @@ -7,10 +7,16 @@ class personal_allowance(Variable): label = "Personal Allowance for the year" unit = GBP definition_period = YEAR - reference = dict( - title="Income Tax Act 2007 s. 35, s. 58", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/35", - ) + reference = [ + dict( + title="Income Tax Act 2007 s. 35, s. 58", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/35", + ), + dict( + title="Income Tax Act 2007 s. 55B(6)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + ] def formula(person, period, parameters): params = parameters(period) @@ -23,4 +29,8 @@ def formula(person, period, parameters): ANI_for_taper = ANI - gift_aid_grossed_up excess = max_(0, ANI_for_taper - PA.maximum_ANI) reduction = excess * PA.reduction_rate - return max_(0, np.ceil(personal_allowance - reduction)) + allowance = max_(0, np.ceil(personal_allowance - reduction)) + # A Marriage Allowance election cuts the electing spouse's allowance + # by the transferable amount (s. 55B(6)). + relinquished = person("marriage_allowance_relinquished", period) + return max_(0, allowance - relinquished) From ba72b98c58dc7732a59edf603fe4a804e3f7dd4a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 16:45:16 -0400 Subject: [PATCH 02/30] Test Marriage Allowance from the statute and re-enable it in the UC properties YAML cases from ITA 2007 ss.55A-55E, hand-computed, including the issue's worked example and each departure it lists. The State Pension and earnings-deduction UC properties now run with Marriage Allowance claimed, holding each couple's election at its first-run choice; the strict xfail on the partner's Marriage Allowance now passes, and a new case pins the point where the couple stops electing. Co-Authored-By: Claude Opus 5.5 --- .../allowances/marriage_allowance.yaml | 541 ++++++++++++++++++ .../test_uc_earnings_deductions_properties.py | 33 +- .../tests/test_uc_state_pension_properties.py | 86 ++- 3 files changed, 620 insertions(+), 40 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index 521f62d955..1e29b0c459 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -62,3 +62,544 @@ output: marriage_allowance: [0, 0, 0, 0] income_tax: [9_432, 4_486, 0, 0] + +# ITA 2007 ss. 55A-55E. 2026-27: personal allowance 12,570 (s. 35(1)), so the +# transferable amount is 10% x 12,570 = 1,257, rounded up to 1,260 +# (s. 55B(4)(b), (5)). The transferor's allowance falls to 11,310 (s. 55B(6)); +# the recipient's tax falls by 20% x 1,260 = 252 at Step 6 (s. 55A(2), +# s. 55B(1), (3)), capped at their Step 5 tax (s. 29(2)). The couple elects +# only when that lowers their combined tax. + +- name: Issue example - transferor between the cut and the full allowance + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Transferor: (12,000 - 11,310) x 20% = 138. Recipient: + # (30,000 - 12,570) x 20% - 252 = 3,486 - 252 = 3,234. The couple pays + # 3,372 against 3,486 without the election. + makes_marriage_allowance_election: [false, true] + marriage_allowance_relinquished: [0, 1_260] + marriage_allowance: [1_260, 0] + personal_allowance: [12_570, 11_310] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [3_234, 138] + +- name: Transferor with no income + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + marriage_allowance: [1_260, 0] + income_tax: [3_234, 0] + +- name: Transferor just under the allowance still saves the couple tax + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_500 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Transferor: (12,500 - 11,310) x 20% = 238, less than the 252 saved. + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 238] + +- name: Transferor using the whole allowance gains nothing, so the couple does not elect + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_570 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Electing would cost the transferor 1,260 x 20% = 252, exactly the + # recipient's reduction. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + personal_allowance: [12_570, 12_570] + income_tax: [3_486, 0] + +- name: Issue departure 1 - transferor with 15,000 of interest stays at the starting rate for savings + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + savings_interest_income: 15_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Transferor: 15,000 - 11,310 = 3,690 of interest, all inside the 5,000 + # starting rate for savings at 0% (s. 12), so they can elect + # (s. 55C(1)(c)) and pay nothing more. Recipient: 3,486 - 252 = 3,234. + makes_marriage_allowance_election: [false, true] + personal_allowance: [12_570, 11_310] + income_tax: [3_234, 0] + +- name: Issue departure 1 - transferor with 12,000 of interest + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + savings_interest_income: 12_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 0] + +- name: Issue departure 2 - recipient taxed mostly on dividends keeps the reduction + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 12_570 + dividend_income: 5_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Earnings use the whole allowance. Dividends: 500 at the dividend nil + # rate, 4,500 x 10.75% = 483.75 at Step 5, less 252 = 231.75. + marriage_allowance: [1_260, 0] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [231.75, 0] + +- name: Issue departure 3 - Scottish intermediate-rate recipient saves the Scottish basic rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 5_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: SCOTLAND + output: + # Recipient: 17,430 taxable = 3,967 x 19% + 12,989 x 20% + 474 x 21% + # = 753.73 + 2,597.80 + 99.54 = 3,451.07, less 20% x 1,260 = 252, not + # the 21% marginal rate. + marriage_allowance_appropriate_percentage: [0.2, 0.2] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [3_199.07, 0] + +- name: Scottish starter-rate transferor + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: SCOTLAND + output: + # Transferor: 690 x 19% = 131.10, less than the 252 saved. + makes_marriage_allowance_election: [false, true] + income_tax: [3_199.07, 131.10] + +- name: Issue departure 5 - both partners below the allowance, no election and no double counting + period: 2026 + input: + people: + p1: + age: 40 + employment_income: 5_000 + p2: + age: 38 + employment_income: 6_000 + benunits: + benunit: + members: [p1, p2] + is_married: true + output: + # Neither has tax to reduce. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [0, 0] + +- name: Both partners basic-rate taxpayers, no election + period: 2026 + input: + people: + p1: + age: 40 + employment_income: 30_000 + p2: + age: 38 + employment_income: 20_000 + benunits: + benunit: + members: [p1, p2] + is_married: true + output: + makes_marriage_allowance_election: [false, false] + income_tax: [3_486, 1_486] + +- name: Higher-rate recipient cannot gain (s. 55B(2)(b)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 60_000 + transferor: + age: 38 + employment_income: 5_000 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # 37,700 x 20% + 9,730 x 40% = 11,432. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [11_432, 0] + +- name: Issue departure 6 - no election when married couple's allowance is claimed (s. 55B(2)(d)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + married_couples_allowance: 11_270 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # 3,486 less 10% x 11,270 = 1,127. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [2_359, 0] + +- name: The couple's take-up follows the elder spouse + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + would_claim_marriage_allowance: false + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, false] + income_tax: [3_486, 0] + +- name: The younger spouse's take-up value does not stop the election + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 0] + +- name: The lower earner elects whichever spouse is older + period: 2026 + input: + people: + transferor: + age: 40 + employment_income: 8_000 + recipient: + age: 38 + employment_income: 30_000 + benunits: + benunit: + members: [transferor, recipient] + is_married: true + output: + makes_marriage_allowance_election: [true, false] + marriage_allowance: [0, 1_260] + personal_allowance: [11_310, 12_570] + income_tax: [0, 3_234] + +- name: Reduction capped at the recipient's Step 5 tax (s. 29(2)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 13_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Step 5 tax (13,000 - 12,570) x 20% = 86, so the reduction is 86. + makes_marriage_allowance_election: [false, true] + marriage_allowance: [1_260, 0] + marriage_allowance_tax_reduction: [86, 0] + income_tax: [0, 0] + +- name: Other tax reductions come first (s. 29(3)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 15_000 + other_tax_credits: 300 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Step 5 tax 2,430 x 20% = 486, less 300 leaves 186 for the reduction. + marriage_allowance_tax_reduction_limit: [186, 0] + marriage_allowance_tax_reduction: [186, 0] + income_tax: [0, 0] + +- name: When either spouse's election saves the same, the elder elects + period: 2026 + input: + people: + p1: + age: 40 + savings_interest_income: 15_000 + dividend_income: 10_000 + p2: + age: 38 + savings_interest_income: 15_000 + dividend_income: 10_000 + benunits: + benunit: + members: [p1, p2] + is_married: true + output: + # Each: the allowance covers interest, the rest sits in the 0% starting + # rate for savings either way, and dividends pay 9,500 x 10.75% = + # 1,021.25. Either election saves 252. + makes_marriage_allowance_election: [true, false] + personal_allowance: [11_310, 12_570] + income_tax: [1_021.25, 769.25] + +- name: Children in the benefit unit are not spouses + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + child: + age: 5 + benunits: + benunit: + members: [recipient, transferor, child] + is_married: true + output: + is_marriage_allowance_spouse: [true, true, false] + makes_marriage_allowance_election: [false, true, false] + marriage_allowance: [1_260, 0, 0] + income_tax: [3_234, 0, 0] + +- name: s. 55B(2)(ba) - dividends in the nil rate above the basic rate limit bar the recipient + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 50_070 + dividend_income: 500 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # Taxable 37,500 + 500: without the dividend nil rate 300 of dividends + # would be at the dividend upper rate. + makes_marriage_allowance_election: [false, false] + income_tax: [7_500, 0] + +- name: Savings in the savings nil rate above the basic rate limit do not bar the recipient + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 50_070 + savings_interest_income: 500 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # The interest is at the savings nil rate, which s. 55B(2)(b) allows: + # 37,500 x 20% - 252 = 7,248. + makes_marriage_allowance_election: [false, true] + income_tax: [7_248, 0] + +- name: Scottish intermediate-rate recipient with savings banded on UK rates can gain + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 40_000 + savings_interest_income: 5_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: SCOTLAND + output: + makes_marriage_allowance_election: [false, true] + marriage_allowance_tax_reduction: [252, 0] + +- name: Pensioner transferor with State Pension, as in the UC State Pension test + period: 2026 + input: + people: + pensioner: + age: 70 + state_pension: 12_000 + partner: + age: 45 + employment_income: 20_000 + benunits: + benunit: + members: [pensioner, partner] + is_married: true + output: + # Pensioner: 690 x 20% = 138. Partner: 7,430 x 20% - 252 = 1,234. + makes_marriage_allowance_election: [true, false] + personal_allowance: [11_310, 12_570] + income_tax: [138, 1_234] + +- name: 2015-16 transferable amount is 1,060 (s. 55B(4)(a)) + period: 2015 + input: + people: + recipient: + age: 40 + employment_income: 20_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # (20,000 - 10,600) x 20% = 1,880, less 20% x 1,060 = 212. + marriage_allowance_transferable_amount: [1_060, 1_060] + personal_allowance: [10_600, 9_540] + income_tax: [1_668, 0] + +- name: 2019-20 transferable amount is 10% of 12,500 + period: 2019 + input: + people: + recipient: + age: 40 + employment_income: 20_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + # 7,500 x 20% = 1,500, less 20% x 1,250 = 250. + marriage_allowance_transferable_amount: [1_250, 1_250] + income_tax: [1_250, 0] diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 1d68ea2b29..14a4275937 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -25,10 +25,13 @@ Invariants 1-3 hold only while unearned income leaves the person's allowances alone, so the generated incomes keep adjusted net income below -the personal allowance taper (100,000), no one is old enough for the -married couple's allowance, and no one claims Marriage Allowance (see -test_uc_state_pension_properties.py for the Marriage Allowance deviation). -Invariant 4 runs with Marriage Allowance claimed. +the personal allowance taper (100,000) and no one is old enough for the +married couple's allowance. Marriage Allowance is claimed. Each couple's +election is held at its choice in the first run: the gaining partner's +reduction then comes off their tax on earnings whatever anyone's unearned +income, but the couple's choice to elect responds to it (see +test_uc_state_pension_properties.py). Invariant 4 compares runs that elect +alike, since the tax is the same in both. """ import numpy as np @@ -81,7 +84,9 @@ "universal_credit_pre_benefit_cap", "uc_earned_income", ] +ELECTION = "makes_marriage_allowance_election" PERSON_VARIABLES = [ + ELECTION, "uc_individual_earned_income", "uc_income_tax_on_earnings", "uc_national_insurance_on_earnings", @@ -117,11 +122,12 @@ def families(draw): ) -def situation(units, year, bump=None, earnings_only=False, marriage_allowance=False): +def situation(units, year, bump=None, earnings_only=False, election=None): """One simulation holding every family. ``bump`` is (family index, adult index, variable, amount) to add. - ``earnings_only`` drops every kind of unearned income. + ``earnings_only`` drops every kind of unearned income. ``election``, one + value per person in order, fixes who makes a Marriage Allowance election. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -136,8 +142,6 @@ def situation(units, year, bump=None, earnings_only=False, marriage_allowance=Fa if bump is not None and bump[:2] == (i, j) and not earnings_only: variable, amount = bump[2], bump[3] person[variable] = {year: adult.get(variable, 0.0) + amount} - if not marriage_allowance: - person["would_claim_marriage_allowance"] = {year: False} people[name] = person names.append(name) for k, age in enumerate(unit["children"]): @@ -151,6 +155,9 @@ def situation(units, year, bump=None, earnings_only=False, marriage_allowance=Fa "tenure_type": {year: unit["tenure"]}, "region": {year: unit["region"]}, } + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} return {"people": people, "benunits": benunits, "households": households} @@ -177,7 +184,7 @@ def bumped(draw): def test_unearned_income_never_changes_earned_income(case, year): units, bump = case low = calculate(units, year) - high = calculate(units, year, bump=bump) + high = calculate(units, year, bump=bump, election=low[ELECTION]) for variable in ["uc_individual_earned_income", "uc_earned_income"]: np.testing.assert_allclose( high[variable], low[variable], atol=0.01, err_msg=f"{bump} {units}" @@ -189,7 +196,7 @@ def test_unearned_income_never_changes_earned_income(case, year): def test_uc_is_non_increasing_in_unearned_income(case, year): units, bump = case low = calculate(units, year) - high = calculate(units, year, bump=bump) + high = calculate(units, year, bump=bump, election=low[ELECTION]) for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: assert np.all(high[variable] <= low[variable] + 0.01), (variable, bump, units) @@ -201,7 +208,7 @@ def test_uc_is_non_increasing_in_unearned_income(case, year): ) def test_deductions_equal_tax_and_ni_on_earnings_alone(units, year): full = calculate(units, year) - alone = calculate(units, year, earnings_only=True) + alone = calculate(units, year, earnings_only=True, election=full[ELECTION]) adult = full["is_adult_person"] np.testing.assert_allclose( full["uc_income_tax_on_earnings"][adult], @@ -251,8 +258,8 @@ def formula(benunit, period, parameters): year=st.sampled_from(YEARS), ) def test_fix_only_removes_deductions(units, year): - after = calculate(units, year, marriage_allowance=True) - before = calculate(units, year, scenario=BEFORE_1942, marriage_allowance=True) + after = calculate(units, year) + before = calculate(units, year, scenario=BEFORE_1942) assert np.all(after["uc_earned_income"] >= before["uc_earned_income"] - 0.01), units for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: assert np.all(after[variable] <= before[variable] + 0.01), (variable, units) diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index d3b3963724..46975f124b 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -22,12 +22,15 @@ income (held without property capital, so it is not treated as capital yield under reg. 72), although property income is taxed on less. -Marriage Allowance is switched off for invariants 2 and 3. The model gives -the recipient min(partner's unused personal allowance, 10% of the personal -allowance) instead of the fixed transferable amount in ITA 2007 s. 55B(4)-(6), -so State Pension that uses up the pensioner's unused allowance raises the -earning partner's tax on earnings. The strict xfail at the end pins that -deviation (PolicyEngine/policyengine-uk#MA_ISSUE). +Marriage Allowance is claimed throughout. Once a couple has elected, the +gaining partner's reduction is fixed (ITA 2007 s. 55B(1), (4)-(6)), so the +pensioner's State Pension cannot move the earning partner's tax on earnings. +The couple's election itself responds to the pensioner's income: the couple +stops electing once it no longer lowers their tax (from 12,570 of State +Pension in 2026-27), which raises the earner's tax and so the award. So +invariants 1-3 hold each couple's election at its choice in the first run; +test_couple_stops_electing_when_it_no_longer_saves_tax pins the change in +election (PolicyEngine/policyengine-uk#1947). """ import numpy as np @@ -70,6 +73,7 @@ "uc_income_reduction", "uc_maximum_amount", ] +ELECTION = "makes_marriage_allowance_election" @st.composite @@ -95,7 +99,7 @@ def situation( year, income_variable="state_pension", pension_bump=0.0, - marriage_allowance=True, + election=None, ): """Build one simulation holding every family. @@ -103,8 +107,8 @@ def situation( ``pension_bump``) under ``income_variable``; when that is not ``state_pension``, their State Pension is set to zero so the same amount arrives as the other income instead. A working-age partner receives the - family's earnings. With ``marriage_allowance=False`` no one claims - Marriage Allowance. + family's earnings. ``election``, one value per person in order, fixes + who makes a Marriage Allowance election. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -112,8 +116,6 @@ def situation( for j, age in enumerate(unit["ages"]): name = f"p{i}_{j}" person = {"age": {year: age}, "state_pension": {year: 0.0}} - if not marriage_allowance: - person["would_claim_marriage_allowance"] = {year: False} if j == 0: amount = unit["state_pension"] + pension_bump person[income_variable] = {year: amount} @@ -132,12 +134,15 @@ def situation( "tenure_type": {year: unit["tenure"]}, "savings": {year: unit["savings"]}, } + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} return {"people": people, "benunits": benunits, "households": households} def calculate(units, year, **kwargs): sim = Simulation(situation=situation(units, year, **kwargs)) - return {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + return {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES + [ELECTION]} def assert_same(a, b, message): @@ -155,7 +160,7 @@ def assert_same(a, b, message): ) def test_uc_is_non_increasing_in_state_pension(units, bump, year): low = calculate(units, year) - high = calculate(units, year, pension_bump=bump) + high = calculate(units, year, pension_bump=bump, election=low[ELECTION]) for variable in ["universal_credit", "universal_credit_pre_benefit_cap"]: assert np.all(high[variable] <= low[variable] + 0.01), (variable, units) # State Pension changes nothing in the maximum amount, and all of it is @@ -182,9 +187,10 @@ def test_uc_is_non_increasing_in_state_pension(units, bump, year): ) def test_uc_falls_pound_for_pound_in_state_pension(units, bump, year): # Earnings in the family change nothing: tax on State Pension is never - # deducted from anyone's earnings (reg. 55(5)(b), reg. 57(2) step 3). - low = calculate(units, year, marriage_allowance=False) - high = calculate(units, year, pension_bump=bump, marriage_allowance=False) + # deducted from anyone's earnings (reg. 55(5)(b), reg. 57(2) step 3), and + # an election already made fixes the earner's Marriage Allowance. + low = calculate(units, year) + high = calculate(units, year, pension_bump=bump, election=low[ELECTION]) award = low["universal_credit_pre_benefit_cap"] np.testing.assert_allclose( high["universal_credit_pre_benefit_cap"], @@ -214,14 +220,17 @@ def test_state_pension_counts_like_private_pension(units, year): ) def test_state_pension_counts_like_property_income(units, year): # Property income is taxed on less (the 1,000 property allowance), but - # neither tax comes off the partner's earnings. + # neither tax comes off the partner's earnings. The lower tax can change + # whether the couple elects, so hold the election at the State Pension + # run's choice. + pension = calculate(units, year) assert_same( - calculate(units, year, marriage_allowance=False), + pension, calculate( units, year, income_variable="property_income", - marriage_allowance=False, + election=pension[ELECTION], ), str(units), ) @@ -249,14 +258,6 @@ def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): assert values["universal_credit"][0] == pytest.approx(4_919.86, abs=0.01) -@pytest.mark.xfail( - strict=True, - reason=( - "PolicyEngine/policyengine-uk#MA_ISSUE: Marriage Allowance is booked on " - "the recipient as the transferor's unused personal allowance, so the " - "transferor's State Pension raises the recipient's tax on earnings" - ), -) def test_state_pension_does_not_change_partners_marriage_allowance(): # 2026: pensioner aged 70, partner aged 45 earning 20,000, council rent # 20,000. Once the pensioner elects, ITA 2007 s. 55B gives the partner a @@ -279,3 +280,34 @@ def test_state_pension_does_not_change_partners_marriage_allowance(): values = calculate([unit], 2026) assert values["uc_earned_income"][0] == pytest.approx(18_171.60, abs=0.01) assert values["universal_credit"][0] == pytest.approx(6_009.26, abs=0.01) + + +def test_couple_stops_electing_when_it_no_longer_saves_tax(): + # 2026, the couple above with State Pension of 12,500 and 12,600. At + # 12,500 electing costs the pensioner (12,500 - 11,310) x 20% = 238 and + # saves the partner 252, so the couple elects and the partner's earned + # income is 18,171.60 as above: + # UC = 28,003.64 - (0.55 x 18,171.60 + 12,500) = 5,509.26. + # At 12,600 it would cost 252, the same as it saves, so the couple does not + # elect. The partner's tax on earnings is 1,486, earned income + # 20,000 - 1,486 - 594.40 = 17,919.60, and + # UC = 28,003.64 - (0.55 x 17,919.60 + 12,600) = 5,547.86. + # The election weighs only the couple's income tax, not their UC, so 100 + # more State Pension raises UC by 38.60 here. + unit = dict( + ages=[70, 45], + children=[], + tenure="RENT_FROM_COUNCIL", + rent=20_000.0, + savings=0.0, + earnings=20_000.0, + state_pension=12_500.0, + ) + low = calculate([unit], 2026) + high = calculate([unit], 2026, pension_bump=100.0) + assert low[ELECTION].tolist() == [True, False] + assert high[ELECTION].tolist() == [False, False] + assert low["uc_earned_income"][0] == pytest.approx(18_171.60, abs=0.01) + assert high["uc_earned_income"][0] == pytest.approx(17_919.60, abs=0.01) + assert low["universal_credit"][0] == pytest.approx(5_509.26, abs=0.01) + assert high["universal_credit"][0] == pytest.approx(5_547.86, abs=0.01) From 144195c84c24980bb3c0d178290f5b66a3e300f6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 17:13:32 -0400 Subject: [PATCH 03/30] Add Marriage Allowance property tests, including a brute-force differential The couple's tax never rises with the model's election, which matches the cheapest allowed option found by running each election fixed; the gaining partner's reduction does not depend on the elector's income; transfers balance and reductions stay within s.55B(1)/(3) and s.29(2); couple tax never falls with more income. Co-Authored-By: Claude Opus 5.5 --- .../test_marriage_allowance_properties.py | 351 ++++++++++++++++++ .../is_marriage_allowance_spouse.py | 4 +- 2 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 policyengine_uk/tests/test_marriage_allowance_properties.py diff --git a/policyengine_uk/tests/test_marriage_allowance_properties.py b/policyengine_uk/tests/test_marriage_allowance_properties.py new file mode 100644 index 0000000000..d0927e90de --- /dev/null +++ b/policyengine_uk/tests/test_marriage_allowance_properties.py @@ -0,0 +1,351 @@ +"""Property-based tests for the Marriage Allowance (ITA 2007 ss. 55A-55E). + +An election by one spouse or civil partner cuts their own personal allowance +by the transferable amount (s. 55B(6)) and gives the other a Step 6 tax +reduction of the appropriate percentage of it, capped at their Step 5 tax +(s. 55A(2), s. 55B(1), (3), s. 29(2)). The couple elects only when the +conditions allow it and it lowers their combined income tax. + +Invariants, for any generated population of couples with every kind of +taxable income, in England, Wales or Scotland: + +1. The couple's income tax is never higher than with no election. +2. Differential against brute force: the model's election gives the lowest + couple income tax of no election, the first spouse electing and the second + spouse electing, among those the conditions allow, each run with the + election fixed. +3. Once a spouse has elected, the gaining partner's reduction, allowance and + income tax do not depend on the electing spouse's income, and the electing + spouse's allowance falls by exactly the transferable amount. +4. Accounting: in each couple what one spouse gives up the other receives, at + most one spouse elects, the gaining partner's tax falls by exactly the + reduction, and the reduction is at most the appropriate percentage of the + transferable amount and the tax left to reduce. +5. The couple's income tax, with the election the model makes, never falls + when either spouse has more income. +6. No one outside a married couple gives up or receives anything. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# 2019 has a 1,250 transferable amount, 2026 current rates and the 10.75% +# dividend ordinary rate, 2027 the property and savings rates (Finance Act +# 2026). +YEARS = [2019, 2026, 2027] +REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND"] +INCOMES = { + "employment_income": 60_000, + "self_employment_income": 20_000, + "private_pension_income": 20_000, + "state_pension": 15_000, + "savings_interest_income": 20_000, + "dividend_income": 15_000, + "property_income": 10_000, +} +ELECTION = "makes_marriage_allowance_election" +VARIABLES = [ + ELECTION, + "is_marriage_allowance_spouse", + "marriage_allowance_transferable_amount", + "marriage_allowance_relinquished", + "marriage_allowance", + "marriage_allowance_appropriate_percentage", + "marriage_allowance_tax_reduction", + "marriage_allowance_tax_reduction_limit", + "meets_marriage_allowance_income_conditions", + "personal_allowance", + "income_tax", +] +TOLERANCE = 0.01 + + +def amounts(cap): + # Many incomes are zero, so the allowances and bands are often unused. + return st.one_of(st.just(0.0), st.floats(0, cap)) + + +@st.composite +def adults(draw): + age = draw(st.integers(18, 100)) + adult = dict(age=age) + for variable, cap in INCOMES.items(): + if variable == "state_pension" and age < 67: + continue + adult[variable] = draw(amounts(cap)) + return adult + + +@st.composite +def families(draw): + return dict( + adults=[draw(adults()) for _ in range(draw(st.sampled_from([1, 2, 2, 2])))], + married=draw(st.sampled_from([True, True, True, False])), + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], + region=draw(st.sampled_from(REGIONS)), + # s. 55B(2)(d): now and then the first adult claims married couple's + # allowance. + married_couples_allowance=draw(st.sampled_from([0.0] * 9 + [11_270.0])), + ) + + +def situation(units, year, election=None, bump=None): + """One simulation holding every family. + + ``election``, one value per person in order, fixes who elects. + ``bump`` is (family index, adult index, variable, amount) to add. + """ + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + person = {"state_pension": {year: 0.0}} + for variable, value in adult.items(): + person[variable] = {year: value} + if j == 0: + person["married_couples_allowance"] = { + year: unit["married_couples_allowance"] + } + if bump is not None and bump[:2] == (i, j): + person[bump[2]] = {year: adult.get(bump[2], 0.0) + bump[3]} + people[f"p{i}_{j}"] = person + names.append(f"p{i}_{j}") + for k, age in enumerate(unit["children"]): + people[f"c{i}_{k}"] = {"age": {year: age}} + names.append(f"c{i}_{k}") + benunits[f"b{i}"] = { + "members": names, + "is_married": {year: unit["married"] and len(unit["adults"]) == 2}, + } + households[f"h{i}"] = {"members": names, "region": {year: unit["region"]}} + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(units, year, **kwargs): + sim = Simulation(situation=situation(units, year, **kwargs)) + values = {v: np.asarray(sim.calculate(v, year)) for v in VARIABLES} + values["benunit"] = np.asarray(sim.populations["benunit"].members_entity_id) + return values + + +def per_person(units, adult_value, child_value=0.0): + """One value per person in input order: adults, then children.""" + return np.array( + [ + value + for unit in units + for value in [adult_value(unit, j) for j in range(len(unit["adults"]))] + + [child_value] * len(unit["children"]) + ] + ) + + +def couple_total(values, variable): + """Each person's couple total of ``variable``.""" + total = np.bincount(values["benunit"], weights=values[variable]) + return total[values["benunit"]] + + +def spouses_by_order(values): + """Masks for the first and second spouse of each couple, in input order.""" + spouse = values["is_marriage_allowance_spouse"] + first = np.zeros_like(spouse) + second = np.zeros_like(spouse) + seen = set() + for i, (is_spouse, unit) in enumerate(zip(spouse, values["benunit"])): + if not is_spouse: + continue + if unit in seen: + second[i] = True + else: + first[i] = True + seen.add(unit) + return first, second + + +couple_populations = st.lists(families(), min_size=1, max_size=8) + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +def test_election_never_raises_the_couples_tax(units, year): + model = calculate(units, year) + none = calculate(units, year, election=np.zeros_like(model[ELECTION])) + model_tax = couple_total(model, "income_tax") + none_tax = couple_total(none, "income_tax") + assert np.all(model_tax <= none_tax + TOLERANCE), units + # A couple that elects saves at least a penny. + elects = couple_total(model, ELECTION) > 0 + assert np.all(model_tax[elects] <= none_tax[elects] - TOLERANCE + 1e-6), units + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +def test_election_is_the_best_allowed_choice(units, year): + model = calculate(units, year) + none = calculate(units, year, election=np.zeros_like(model[ELECTION])) + first, second = spouses_by_order(model) + options = [none] + allowed = [np.ones_like(first)] + claims_mca = per_person( + units, lambda unit, j: float(j == 0 and unit["married_couples_allowance"] > 0) + ) + no_mca = couple_total(dict(benunit=model["benunit"], mca=claims_mca), "mca") == 0 + for elector in [first, second]: + run = calculate(units, year, election=elector) + gainer = model["is_marriage_allowance_spouse"] & ~elector + # s. 55C(1)(b), (c): the elector has an allowance and, with it cut, + # pays only basic rates; s. 55B(2)(b): so does the gaining partner; + # s. 55B(2)(d): no married couple's allowance. + conditions = ( + elector + & (none["personal_allowance"] > 0) + & run["meets_marriage_allowance_income_conditions"] + ) | (gainer & none["meets_marriage_allowance_income_conditions"]) + couple_ok = ( + couple_total(dict(benunit=model["benunit"], ok=conditions), "ok") == 2 + ) & no_mca + options.append(run) + allowed.append(couple_ok) + best = couple_total(none, "income_tax") + for run, ok in zip(options[1:], allowed[1:]): + best = np.where(ok, np.minimum(best, couple_total(run, "income_tax")), best) + np.testing.assert_allclose( + couple_total(model, "income_tax"), best, atol=TOLERANCE, err_msg=str(units) + ) + + +@st.composite +def bumped(draw): + units = draw(st.lists(families(), min_size=1, max_size=8)) + i = draw(st.integers(0, len(units) - 1)) + j = draw(st.integers(0, len(units[i]["adults"]) - 1)) + variables = [ + v for v in INCOMES if v != "state_pension" or units[i]["adults"][j]["age"] >= 67 + ] + return units, (i, j, draw(st.sampled_from(variables)), draw(st.floats(1, 30_000))) + + +@PROPERTY_SETTINGS +@given(case=bumped(), year=st.sampled_from(YEARS)) +def test_gaining_partner_is_independent_of_the_electors_income(case, year): + units, (i, j, variable, amount) = case + base = calculate(units, year) + # The bumped adult elects wherever they are a spouse. + person_index = ( + sum(len(unit["adults"]) + len(unit["children"]) for unit in units[:i]) + j + ) + election = np.zeros_like(base[ELECTION]) + election[person_index] = base["is_marriage_allowance_spouse"][person_index] + low = calculate(units, year, election=election) + high = calculate(units, year, election=election, bump=(i, j, variable, amount)) + others = np.arange(len(election)) != person_index + for variable_name in [ + "marriage_allowance", + "marriage_allowance_tax_reduction", + "personal_allowance", + "income_tax", + ]: + np.testing.assert_allclose( + high[variable_name][others], + low[variable_name][others], + atol=TOLERANCE, + err_msg=f"{variable_name} {case}", + ) + # s. 55B(6): the elector's allowance falls by the transferable amount. + none = calculate(units, year, election=np.zeros_like(election)) + for run in [low]: + np.testing.assert_allclose( + run["personal_allowance"], + np.maximum( + 0, + none["personal_allowance"] + - election * run["marriage_allowance_transferable_amount"], + ), + atol=TOLERANCE, + err_msg=str(case), + ) + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +def test_marriage_allowance_accounting(units, year): + model = calculate(units, year) + none = calculate(units, year, election=np.zeros_like(model[ELECTION])) + spouse = model["is_marriage_allowance_spouse"] + elects = model[ELECTION] + # Only spouses elect, at most one per couple. + assert not np.any(elects & ~spouse), units + assert np.all(couple_total(model, ELECTION) <= 1), units + # What one spouse gives up the other receives. + np.testing.assert_allclose( + couple_total(model, "marriage_allowance"), + couple_total(model, "marriage_allowance_relinquished"), + atol=TOLERANCE, + ) + transferable = model["marriage_allowance_transferable_amount"] + received = model["marriage_allowance"] + assert np.all(np.isclose(received, 0) | np.isclose(received, transferable)), units + # The reduction is bounded by s. 55B(1), (3) and s. 29(2). + reduction = model["marriage_allowance_tax_reduction"] + rate = model["marriage_allowance_appropriate_percentage"] + assert np.all(reduction <= rate * received + TOLERANCE), units + assert np.all( + reduction <= model["marriage_allowance_tax_reduction_limit"] + TOLERANCE + ), units + # The gaining partner's tax falls by exactly the reduction. + gainer = received > 0 + np.testing.assert_allclose( + model["income_tax"][gainer], + none["income_tax"][gainer] - reduction[gainer], + atol=TOLERANCE, + err_msg=str(units), + ) + # The elector pays at least as much as without the election. + assert np.all( + model["income_tax"][elects] >= none["income_tax"][elects] - TOLERANCE + ), units + + +@PROPERTY_SETTINGS +@given(case=bumped(), year=st.sampled_from(YEARS)) +def test_couples_tax_never_falls_with_more_income(case, year): + units, bump = case + low = calculate(units, year) + high = calculate(units, year, bump=bump) + assert np.all( + couple_total(high, "income_tax") >= couple_total(low, "income_tax") - TOLERANCE + ), (bump, units) + + +@PROPERTY_SETTINGS +@given(units=couple_populations, year=st.sampled_from(YEARS)) +def test_only_married_couples_transfer(units, year): + model = calculate(units, year) + outside = ~model["is_marriage_allowance_spouse"] + for variable in [ + ELECTION, + "marriage_allowance", + "marriage_allowance_relinquished", + "marriage_allowance_tax_reduction", + ]: + assert np.all(model[variable][outside] == 0), (variable, units) + married_pairs = { + i + for i, unit in enumerate(units) + if unit["married"] and len(unit["adults"]) == 2 + } + spouse_units = set(model["benunit"][model["is_marriage_allowance_spouse"]]) + assert spouse_units == married_pairs, units diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py index a6203ffc90..88b3d85828 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py @@ -22,6 +22,8 @@ def formula(person, period, parameters): married = status == status.possible_values.MARRIED # Without a marital status input, every member of a married benefit # unit, children included, is married; the two eldest are the couple. - rank = person.get_rank(person.benunit, -person("age", period), condition=married) + rank = person.get_rank( + person.benunit, -person("age", period), condition=married + ) spouse = married & (rank < 2) return spouse & (person.benunit.sum(spouse) == 2) From a1a4822cb9dff122db67f6ac9e65848369fd6902 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 17:22:46 -0400 Subject: [PATCH 04/30] Take the couple's Marriage Allowance take-up from the gaining spouse The dataset draws would_claim_marriage_allowance independently per person at 50%. Reading the elder spouse's draw reshuffled take-up across about 0.65m couples on the Enhanced FRS for no legal reason. The gaining spouse's draw is the one that decided take-up before, so the same couples claim. Also add the changelog fragment. Co-Authored-By: Claude Opus 5.5 --- changelog.d/marriage-allowance-election.fixed.md | 1 + .../income_tax/allowances/marriage_allowance.yaml | 4 ++-- .../makes_marriage_allowance_election.py | 15 ++++++++------- .../income_tax/allowances/marriage_allowance.py | 8 ++++---- 4 files changed, 15 insertions(+), 13 deletions(-) create mode 100644 changelog.d/marriage-allowance-election.fixed.md diff --git a/changelog.d/marriage-allowance-election.fixed.md b/changelog.d/marriage-allowance-election.fixed.md new file mode 100644 index 0000000000..d5f17e0058 --- /dev/null +++ b/changelog.d/marriage-allowance-election.fixed.md @@ -0,0 +1 @@ +Model Marriage Allowance as ITA 2007 ss. 55A-55E provide. The electing spouse gives up the fixed transferable amount (10% of the personal allowance, rounded up to £10) from their own allowance, and the other spouse's tax falls at Step 6 by the basic rate (or Scottish basic rate) times that amount, capped at their tax. Previously the recipient's taxable income fell by the transferor's unused allowance. The couple elects only when the conditions in s. 55B(2) and s. 55C(1) are met and it lowers their combined income tax. Universal Credit deducts the reduction from the recipient's tax on their earnings, so a pensioner's State Pension no longer changes the earning partner's earned income. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index 1e29b0c459..da33ce2d26 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -344,7 +344,7 @@ marriage_allowance: [0, 0] income_tax: [2_359, 0] -- name: The couple's take-up follows the elder spouse +- name: The couple's take-up follows the spouse who would gain period: 2026 input: people: @@ -362,7 +362,7 @@ makes_marriage_allowance_election: [false, false] income_tax: [3_486, 0] -- name: The younger spouse's take-up value does not stop the election +- name: The electing spouse's take-up value does not stop the election period: 2026 input: people: diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py index bb710f8c63..049d439d52 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py @@ -41,8 +41,8 @@ class makes_marriage_allowance_election(Variable): "allowance so that their spouse or civil partner gets the Marriage " "Allowance tax reduction. The couple elects only when the election " "is allowed and lowers their combined income tax, in whichever " - "direction lowers it more, and only if the elder spouse's " - "would_claim_marriage_allowance is true." + "direction lowers it more, and only if the spouse who would gain has " + "would_claim_marriage_allowance true." ) definition_period = YEAR reference = [ @@ -119,9 +119,10 @@ def partner(values): (saving_if_allowed > other_saving) | ((saving_if_allowed == other_saving) & elder) ) - # The couple's take-up draw is the elder spouse's, so each couple has - # one draw whichever of them elects. - would_claim = person.benunit.any( - spouse & elder & person("would_claim_marriage_allowance", period) + # The couple's take-up draw is the gaining spouse's, the same draw + # that decided take-up when the transfer was modelled on the + # recipient alone. + partner_would_claim = ( + partner(person("would_claim_marriage_allowance", period)) > 0 ) - return chooses & would_claim + return chooses & partner_would_claim diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py index b91b8365d2..ec3ee2b6df 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py @@ -4,10 +4,10 @@ class would_claim_marriage_allowance(Variable): label = "Would claim Marriage Allowance" documentation = ( - "Whether this person's couple would make a Marriage Allowance " - "election when it lowers their income tax. Generated stochastically " - "in the dataset using take-up rates. The couple's election follows " - "the value on its elder spouse or civil partner " + "Whether this person would claim Marriage Allowance from their spouse " + "or civil partner's election when it lowers the couple's income tax. " + "Generated stochastically in the dataset using take-up rates. The " + "couple's election follows the value on the spouse who would gain " "(makes_marriage_allowance_election)." ) entity = Person From 20c023130b1956ec7e1388ff6bce390da77055f2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 17:27:18 -0400 Subject: [PATCH 05/30] Test dividends against the basic rate limit; fix Marriage Allowance test comments s.55B(2)(ba) asks whether dividends would reach the dividend upper rate, which starts at the basic rate limit (s.13); the dividends scale's own threshold lags it before 2026-27. State the married premise in the UC property tests, and correct the comments on why the couples do not elect. Co-Authored-By: Claude Opus 5.5 --- .../income/uc_earnings_deductions.yaml | 4 ++-- ...eets_marriage_allowance_income_conditions.yaml | 11 +++++++++++ .../test_uc_earnings_deductions_properties.py | 7 ++++++- .../tests/test_uc_state_pension_properties.py | 15 +++++++++++---- .../meets_marriage_allowance_income_conditions.py | 6 ++++-- 5 files changed, 34 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 2e9505a83e..19d14738c4 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -77,8 +77,8 @@ output: # Pensioner: (16,000 - 12,570) x 20% = 686, on State Pension. # Partner: (13,000 - 12,570) x 20% = 86 and (13,000 - 12,569.96) x 8% - # = 34.40. Both are above the personal allowance, so there is no - # Marriage Allowance. + # = 34.40. Either spouse electing for Marriage Allowance would cost them + # 252 and save the other 252, so the couple does not elect. income_tax: [686, 86] uc_income_tax_on_earnings: [0, 86] uc_national_insurance_on_earnings: [0, 34.40] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 76faab8662..41637af051 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -126,3 +126,14 @@ savings_interest_income: 2_000 output: meets_marriage_allowance_income_conditions: false + +- name: The dividend test uses the basic rate limit (2025-26, 37,700) + period: 2025 + input: + # Taxable income 49,970 - 12,570 = 37,400, plus 250 of dividends within + # the dividend allowance: 37,650 is inside the basic rate limit, so no + # dividends would be at the dividend upper rate even without s. 13A. + employment_income: 49_970 + dividend_income: 250 + output: + meets_marriage_allowance_income_conditions: true diff --git a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py index 14a4275937..e6a2954b58 100644 --- a/policyengine_uk/tests/test_uc_earnings_deductions_properties.py +++ b/policyengine_uk/tests/test_uc_earnings_deductions_properties.py @@ -148,7 +148,12 @@ def situation(units, year, bump=None, earnings_only=False, election=None): name = f"c{i}_{k}" people[name] = {"age": {year: age}} names.append(name) - benunits[f"b{i}"] = {"members": names} + benunits[f"b{i}"] = { + "members": names, + # Marriage Allowance needs a marriage or civil partnership + # (ITA 2007 s. 55C(1)(a)). + "is_married": {year: len(unit["adults"]) == 2}, + } households[f"h{i}"] = { "members": names, "rent": {year: unit["rent"]}, diff --git a/policyengine_uk/tests/test_uc_state_pension_properties.py b/policyengine_uk/tests/test_uc_state_pension_properties.py index 46975f124b..f7bedc0746 100644 --- a/policyengine_uk/tests/test_uc_state_pension_properties.py +++ b/policyengine_uk/tests/test_uc_state_pension_properties.py @@ -127,7 +127,12 @@ def situation( name = f"c{i}_{k}" people[name] = {"age": {year: age}} names.append(name) - benunits[f"b{i}"] = {"members": names} + benunits[f"b{i}"] = { + "members": names, + # Marriage Allowance needs a marriage or civil partnership + # (ITA 2007 s. 55C(1)(a)). + "is_married": {year: len(unit["ages"]) == 2}, + } households[f"h{i}"] = { "members": names, "rent": {year: unit["rent"]}, @@ -239,8 +244,9 @@ def test_state_pension_counts_like_property_income(units, year): def test_tax_on_state_pension_does_not_reduce_partners_earned_income(): # 2026: pensioner aged 70 with State Pension 16,000 pays income tax of # (16,000 - 12,570) x 20% = 686. The partner aged 45 earns 13,000 and pays - # income tax of 86 and NI of 34.40 (above the personal allowance, so no - # marriage allowance). Council rent 20,000. + # income tax of 86 and NI of 34.40. Either spouse electing for Marriage + # Allowance would cost them 252 and save the other 252, so the couple + # does not elect. Council rent 20,000. # Earned income = 13,000 - 86 - 34.40 = 12,879.60 (reg. 55(5)(b)). # Maximum amount = 12 x 666.97 + 20,000 = 28,003.64. # UC = 28,003.64 - (0.55 x 12,879.60 + 16,000) = 4,919.86. @@ -266,7 +272,8 @@ def test_state_pension_does_not_change_partners_marriage_allowance(): # keeps them within the basic rate, so they can elect (s. 55C(1)(c)). # Partner's tax on earnings = (20,000 - 12,570) x 20% - 252 = 1,234 and # NI (20,000 - 12,569.96) x 8% = 594.40, so earned income = 18,171.60 - # at any State Pension up to the pensioner's election limit. + # at any State Pension while the election saves the couple tax (below + # 12,570). # UC = 8,003.64 + 20,000 - (0.55 x 18,171.60 + 12,000) = 6,009.26. unit = dict( ages=[70, 45], diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index dd32ee569c..f7476d4766 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -37,7 +37,9 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): savings_above_basic = add( person, period, ["higher_rate_savings_income", "add_rate_savings_income"] ) - # Stacked as in dividend_income_tax, before the dividend allowance. + # Dividends stack on the other income as in dividend_income_tax, before + # the dividend allowance; the dividend upper rate starts at the basic + # rate limit (s. 13). below_dividends = person("earned_taxable_income", period) + max_( 0, savings - person("received_allowances_savings_income", period) ) @@ -45,7 +47,7 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): 0, dividends - person("received_allowances_dividend_income", period) ) dividends_above_basic = (dividends_after_allowances > 0) & ( - below_dividends + dividends_after_allowances > rates.dividends.thresholds[1] + below_dividends + dividends_after_allowances > rates.uk.thresholds[1] ) return ( (non_savings <= non_savings_limit) From 6084d7b8eba38bc544626d356e9752aff1496347 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 17:29:28 -0400 Subject: [PATCH 06/30] Correct Marriage Allowance tests and notes from the statutory review The tie case no longer asserts income tax, which depended on the model's fixed allowance order rather than s.25(2). The married couple's allowance case uses spouses old enough to claim it. The ordering case cites s.27(2) and s.29(2)-(3); the limit notes s.27(6) for double taxation relief; the single election per couple is described as a modelling choice, since s.55E does not bar elections both ways. Co-Authored-By: Claude Opus 5.5 --- .../income_tax/allowances/marriage_allowance.yaml | 15 ++++++++------- .../makes_marriage_allowance_election.py | 7 +++++-- .../marriage_allowance_tax_reduction_limit.py | 4 +++- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index da33ce2d26..cd7102739f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -328,12 +328,13 @@ period: 2026 input: people: + # Married couple's allowance needs a spouse born before 6 April 1935. recipient: - age: 40 + age: 92 employment_income: 30_000 married_couples_allowance: 11_270 transferor: - age: 38 + age: 90 benunits: benunit: members: [recipient, transferor] @@ -420,7 +421,7 @@ marriage_allowance_tax_reduction: [86, 0] income_tax: [0, 0] -- name: Other tax reductions come first (s. 29(3)) +- name: Other tax reductions leave less tax for the reduction (s. 27(2), s. 29(2)-(3)) period: 2026 input: people: @@ -457,12 +458,12 @@ members: [p1, p2] is_married: true output: - # Each: the allowance covers interest, the rest sits in the 0% starting - # rate for savings either way, and dividends pay 9,500 x 10.75% = - # 1,021.25. Either election saves 252. + # The spouses are identical, so either election saves the same and the + # elder elects. Income tax is not asserted: the model sets allowances + # against savings before dividends, where s. 25(2) requires the order + # that most reduces tax. makes_marriage_allowance_election: [true, false] personal_allowance: [11_310, 12_570] - income_tax: [1_021.25, 769.25] - name: Children in the benefit unit are not spouses period: 2026 diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py index 049d439d52..acb52a7da2 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py @@ -107,8 +107,11 @@ def partner(values): # s. 55B(2)(b), (ba): the gaining party pays only basic rates. & (partner(without["meets_marriage_allowance_income_conditions"]) > 0) ) - # Elect only to lower the couple's tax, and at most one spouse elects - # (s. 55E); on a tie the elder spouse elects. + # Elect only to lower the couple's tax. Only one spouse elects: s. 55E + # allows each of them one election and one reduction and does not bar + # elections both ways, but those help only when both spouses would + # pay nothing on the allowance they give up and both have tax to + # reduce, which is not modelled. On a tie the elder spouse elects. saving_if_allowed = where(allowed & (saving > 0), saving, 0) other_saving = partner(saving_if_allowed) elder = ( diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py index 01f541268e..db38ac65d4 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py @@ -9,7 +9,9 @@ class marriage_allowance_tax_reduction_limit(Variable): "Tax at Step 5 of the income tax calculation left after the person's " "other tax reductions. The Marriage Allowance tax reduction cannot " "exceed it. Deducting the other reductions first gives the same total " - "as the order that most reduces the person's liability." + "as the order that most reduces the person's liability. Double " + "taxation relief should come after the Marriage Allowance (s. 27(6)), " + "but other_tax_credits does not separate it out." ) definition_period = YEAR reference = [ From 548d6f95f3806c02e6ddb6ba71e664e298f0e02a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 18:18:26 -0400 Subject: [PATCH 07/30] Stack dividends above all non-savings income in the Marriage Allowance rate test s.16 puts dividends above property income as well as earnings, so a landlord whose dividends cross the basic rate limit is liable at the dividend upper rate (s.55B(2)(ba)). Note that a spouse outside the benefit unit is not modelled. Co-Authored-By: Claude Opus 5.5 --- .../meets_marriage_allowance_income_conditions.yaml | 12 ++++++++++++ .../allowances/is_marriage_allowance_spouse.py | 4 +++- .../meets_marriage_allowance_income_conditions.py | 9 +++++---- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 41637af051..3549367fd0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -137,3 +137,15 @@ dividend_income: 250 output: meets_marriage_allowance_income_conditions: true + +- name: Dividends above property income cross the basic rate limit and fail the conditions + period: 2026 + input: + # Property 47,000 less the 1,000 property allowance and the allowance of + # 12,570 leaves 33,430 of non-savings income. Dividends sit on top + # (s. 16), from 33,430 to 38,430, so 730 would be at the dividend upper + # rate (s. 55B(2)(ba)). + property_income: 47_000 + dividend_income: 5_000 + output: + meets_marriage_allowance_income_conditions: false diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py index 88b3d85828..b8c8b0492d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py @@ -9,7 +9,9 @@ class is_marriage_allowance_spouse(Variable): "Whether this person is one of the married couple or civil partners " "in their benefit unit, who can make or gain from a Marriage " "Allowance election. These are the two eldest members whose marital " - "status is married, and only when the benefit unit has two." + "status is married, and only when the benefit unit has two. The Act " + "needs only a marriage or civil partnership, not living together, but " + "a spouse outside the benefit unit is not modelled." ) definition_period = YEAR reference = dict( diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index f7476d4766..00a89c6c59 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -37,10 +37,11 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): savings_above_basic = add( person, period, ["higher_rate_savings_income", "add_rate_savings_income"] ) - # Dividends stack on the other income as in dividend_income_tax, before - # the dividend allowance; the dividend upper rate starts at the basic - # rate limit (s. 13). - below_dividends = person("earned_taxable_income", period) + max_( + # Dividends are the top slice, above all non-savings income (property + # included) and savings (s. 16), counted before the dividend allowance; + # the dividend upper rate starts at the basic rate limit (s. 13). The + # savings test above uses the model's own savings bands. + below_dividends = non_savings + max_( 0, savings - person("received_allowances_savings_income", period) ) dividends_after_allowances = max_( From 292f7c8b2d572e45b3fd5022d9bcbc5c2fbe07b5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 22:10:04 -0400 Subject: [PATCH 08/30] Harden the Marriage Allowance election from the robustness review Resolve the election-independent income tax inputs before branching, so a reform that branches inside adjusted_net_income gives the same result whichever variable is asked for first, and free the branch arrays afterwards. Only a spouse can give allowance up, and spouses must be old enough to marry (16, the Scottish minimum). On a tie the lower-income spouse elects. The CPS expanded reform lifts the income condition only when remove_income_condition is set. Refresh stale comments. Co-Authored-By: Claude Opus 5.5 --- .../minimum_spouse_age.yaml | 11 ++++ .../reforms/cps/marriage_tax_reforms.py | 10 +++- .../income/uc_earnings_deductions.yaml | 7 ++- .../reforms/cps/marriage_tax_reforms.yaml | 34 ++++++++++-- .../is_marriage_allowance_spouse.py | 10 +++- .../makes_marriage_allowance_election.py | 53 +++++++++++++++---- .../marriage_allowance_relinquished.py | 6 ++- 7 files changed, 109 insertions(+), 22 deletions(-) create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/minimum_spouse_age.yaml diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/minimum_spouse_age.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/minimum_spouse_age.yaml new file mode 100644 index 0000000000..7726336275 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/minimum_spouse_age.yaml @@ -0,0 +1,11 @@ +description: Youngest age at which anyone in the UK can be a party to a marriage, and so a spouse who can make or gain from a Marriage Allowance election. It is 16 in Scotland; England and Wales raised it to 18 from 27 February 2023. +values: + 0000-01-01: 16 +metadata: + unit: year + label: Minimum age of a Marriage Allowance spouse + reference: + - title: Marriage (Scotland) Act 1977 s. 1(1) + href: https://www.legislation.gov.uk/ukpga/1977/15/section/1 + - title: Marriage and Civil Partnership (Minimum Age) Act 2022 s. 1 + href: https://www.legislation.gov.uk/ukpga/2022/28/section/1 diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 8722ac013b..a3d49a40f5 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -8,6 +8,7 @@ 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" @@ -42,9 +43,12 @@ class meets_marriage_allowance_income_conditions(Variable): reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" def formula(person, period, parameters): - return liable_only_at_marriage_allowance_rates( + conditions = liable_only_at_marriage_allowance_rates( person, period, parameters - ) | person("meets_expanded_ma_conditions", period) + ) + if not remove_income_condition: + return conditions + return conditions | person("meets_expanded_ma_conditions", period) class marriage_allowance_transferable_amount(Variable): value_type = float @@ -194,6 +198,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 @@ -220,4 +225,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() diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 19d14738c4..4b24733ac2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -158,10 +158,9 @@ household: members: [recipient, transferor] output: - # The transferor's unused allowance of 2,570 exceeds the 1,257 cap, which - # rounds up to 1,260. Tax on the recipient's earnings: - # (15,000 - 12,570 - 1,260) x 20% = 234, the same as 486 less the - # 20% x 1,260 = 252 reduction. Dividends: (5,000 - 500) x 10.75% = 483.75, + # The transferor (10,000, below the cut allowance of 11,310) elects, and + # the recipient's tax falls by 20% x 1,260 = 252. Tax on the recipient's + # earnings: (15,000 - 12,570) x 20% = 486, less the 252 reduction = 234. Dividends: (5,000 - 500) x 10.75% = 483.75, # not deducted. marriage_allowance: [1_260, 0] income_tax: [717.75, 0] diff --git a/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml b/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml index 0b70c7eeb7..0b5b40ab73 100644 --- a/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml +++ b/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml @@ -51,9 +51,10 @@ household: members: [high_earner, low_earner] output: - # Low earner's unused PA (12,570 − 10,000 = 2,570) is capped at PA × ma_rate - # = 12,570 × 0.1 = 1,257, then rounded up to the nearest £10 → 1,260. - # Low earner's partner has no unused PA to transfer, so low_earner's MA is 0. + # The low earner elects: the transferable amount is PA x ma_rate = + # 12,570 x 0.1 = 1,257, rounded up to the nearest £10 → 1,260, and costs + # them nothing (10,000 is below 11,310). With the income condition lifted, + # the higher-rate earner gains it; the low earner receives nothing. marriage_allowance: [1260, 0] - name: Expanded MA — explicit Marriage Allowance take-up input drives the reform @@ -152,3 +153,30 @@ members: [high_earner, low_earner] output: marriage_allowance: [0, 0] + +- name: Expanded MA — rate-only expansion keeps the higher-rate bar + period: 2026 + absolute_error_margin: 1 + reforms: + - policyengine_uk.reforms.cps.marriage_tax_reforms.expanded_ma_reform_rate_only + input: + people: + high_earner: + age: 35 + employment_income: 60_000 + low_earner: + age: 33 + employment_income: 8_000 + benunits: + benunit: + members: [high_earner, low_earner] + is_married: true + households: + household: + members: [high_earner, low_earner] + output: + # Without remove_income_condition the higher-rate earner still cannot + # gain (ITA 2007 s. 55B(2)(b)): 37,700 x 20% + 9,730 x 40% = 11,432. + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [11_432, 0] diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py index b8c8b0492d..9a1c567a33 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/is_marriage_allowance_spouse.py @@ -21,9 +21,15 @@ class is_marriage_allowance_spouse(Variable): def formula(person, period, parameters): status = person("marital_status", period) - married = status == status.possible_values.MARRIED + minimum_age = parameters( + period + ).gov.hmrc.income_tax.allowances.marriage_allowance.minimum_spouse_age + married = (status == status.possible_values.MARRIED) & ( + person("age", period) >= minimum_age + ) # Without a marital status input, every member of a married benefit - # unit, children included, is married; the two eldest are the couple. + # unit, children included, is married; the two eldest old enough to + # marry are the couple. rank = person.get_rank( person.benunit, -person("age", period), condition=married ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py index acb52a7da2..a13da510aa 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py @@ -6,6 +6,28 @@ "marriage_allowance_tax_reduction_limit", "meets_marriage_allowance_income_conditions", ] +# Inputs to income tax that do not depend on the election. Resolving them +# before branching lets both branches reuse them, and keeps the result +# independent of which variable a caller asks for first (a reform that +# branches inside adjusted_net_income would otherwise see a cached election). +INDEPENDENT_OF_ELECTION = [ + "age", + "adjusted_net_income", + "gift_aid_grossed_up", + "gift_aid", + "blind_persons_allowance", + "covenanted_payments", + "charitable_investment_gifts", + "other_deductions", + "pension_contributions_relief", + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + "pays_scottish_income_tax", + "CB_HITC", + "other_tax_credits", + "married_couples_allowance", +] def tax_position(simulation, name, period, relinquished): @@ -13,8 +35,8 @@ def tax_position(simulation, name, period, relinquished): The branch holds the election off, so no one gains a reduction, and cuts each person's personal allowance by ``relinquished`` (ITA 2007 s. 55B(6)). - The branch is dropped afterwards so a later period starts from a fresh - copy of the simulation. + The branch is dropped afterwards, and its copied arrays freed, so a later + period starts from a fresh copy of the simulation. """ while name in simulation.branches or name == simulation.branch_name: name += "_" @@ -30,6 +52,9 @@ def tax_position(simulation, name, period, relinquished): return {variable: population(variable, period) for variable in BRANCH_OUTPUTS} finally: del simulation.branches[name] + for population in branch.populations.values(): + for holder in population._holders.values(): + holder._memory_storage._arrays.clear() class makes_marriage_allowance_election(Variable): @@ -42,7 +67,9 @@ class makes_marriage_allowance_election(Variable): "Allowance tax reduction. The couple elects only when the election " "is allowed and lowers their combined income tax, in whichever " "direction lowers it more, and only if the spouse who would gain has " - "would_claim_marriage_allowance true." + "would_claim_marriage_allowance true. Set as an input, an election " + "is taken to be one the other spouse is entitled to gain from: the " + "conditions are checked only when the model makes the choice." ) definition_period = YEAR reference = [ @@ -61,6 +88,8 @@ class makes_marriage_allowance_election(Variable): ] def formula(person, period, parameters): + for variable in INDEPENDENT_OF_ELECTION: + person(variable, period) spouse = person("is_marriage_allowance_spouse", period) transferable = person("marriage_allowance_transferable_amount", period) simulation = person.simulation @@ -107,20 +136,26 @@ def partner(values): # s. 55B(2)(b), (ba): the gaining party pays only basic rates. & (partner(without["meets_marriage_allowance_income_conditions"]) > 0) ) - # Elect only to lower the couple's tax. Only one spouse elects: s. 55E - # allows each of them one election and one reduction and does not bar - # elections both ways, but those help only when both spouses would - # pay nothing on the allowance they give up and both have tax to - # reduce, which is not modelled. On a tie the elder spouse elects. + # Elect only to lower the couple's tax. Only one spouse elects. s. 55E + # allows each of them one election and one reduction and does not + # bar elections both ways, which would lower some couples' tax + # further (where giving up allowance costs less than the basic rate, + # as on dividends or at the Scottish starter rate), but HMRC's + # guidance describes a single transfer from the lower earner. saving_if_allowed = where(allowed & (saving > 0), saving, 0) other_saving = partner(saving_if_allowed) + # On a tie the spouse with the lower adjusted net income elects, then + # the elder, then the first listed. + income = person("adjusted_net_income", period) + other_income = partner(income) elder = ( person.get_rank(person.benunit, -person("age", period), condition=spouse) == 0 ) + first_on_tie = (income < other_income) | ((income == other_income) & elder) chooses = (saving_if_allowed > 0) & ( (saving_if_allowed > other_saving) - | ((saving_if_allowed == other_saving) & elder) + | ((saving_if_allowed == other_saving) & first_on_tie) ) # The couple's take-up draw is the gaining spouse's, the same draw # that decided take-up when the transfer was modelled on the diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py index f71e331e49..48fb598dfe 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_relinquished.py @@ -17,6 +17,8 @@ class marriage_allowance_relinquished(Variable): unit = GBP def formula(person, period, parameters): - return person("makes_marriage_allowance_election", period) * person( - "marriage_allowance_transferable_amount", period + # Only a spouse or civil partner can give allowance up. + elects = person("makes_marriage_allowance_election", period) & person( + "is_marriage_allowance_spouse", period ) + return elects * person("marriage_allowance_transferable_amount", period) From 696362a51526eb7cadba48eaa9435ead674e94e0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:37:24 -0400 Subject: [PATCH 09/30] Deduct the finance-cost reduction before the Marriage Allowance reduction Main added property_finance_cost_relief as a Step 6 reduction limited to the tax left after every other reduction, so it came after the Marriage Allowance reduction. The election then valued the gaining spouse's reduction against tax the finance-cost reduction would have cleared anyway: a landlord whose finance costs wipe out their tax still "saved" 252, so the couple elected and paid the elector's extra tax for nothing. Each Step 6 reduction is limited to the tax left after those before it (ITA 2007 s. 29(2)-(3)); the order changes how the total is split but not the total (s. 27(2)). Take the finance-cost reduction first and the Marriage Allowance reduction last, so the reduction is exactly the tax the election saves the gaining spouse. YAML cases pin both the no-election and the partial cases; the property tests now generate finance costs. Co-Authored-By: Claude Opus 5.5 --- .../allowances/marriage_allowance.yaml | 57 +++++++++++++++++++ .../test_marriage_allowance_properties.py | 6 +- .../marriage_allowance_tax_reduction_limit.py | 20 +++++-- .../reliefs/property_finance_cost_relief.py | 10 +++- 4 files changed, 83 insertions(+), 10 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index bb3d9ad579..991a652fac 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -676,3 +676,60 @@ members: [parent_1, parent_2, student] output: marriage_allowance: [0, 0, 0] + +- name: A landlord whose finance-cost reduction clears their tax gains nothing, so the couple does not elect + # ITA 2007 s. 29(2)-(3): a reduction is deducted only from Step 5 tax left + # after the reductions before it. The finance-cost reduction (ITTOIA 2005 + # s. 274AA) is deducted first; s. 27(2) gives the same total either way. + period: 2026 + input: + people: + landlord: + age: 40 + property_rental_income: 20_000 + property_income: 20_000 + property_finance_costs: 15_000 + earner: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [landlord, earner] + is_married: true + output: + # Landlord: Step 5 tax (20,000 - 12,570) x 20% = 1,486. Costs relieved + # min(15,000, 20,000, 7,430) = 7,430, so the reduction is 1,486 and no tax + # is left for a Marriage Allowance reduction. The earner electing would + # pay (12,000 - 11,310) x 20% = 138 and save the landlord nothing. + uses_property_allowance: [false, false] + property_finance_cost_relief: [1_486, 0] + marriage_allowance_tax_reduction_limit: [0, 0] + makes_marriage_allowance_election: [false, false] + marriage_allowance_tax_reduction: [0, 0] + income_tax: [0, 0] + +- name: A landlord's Marriage Allowance reduction is limited to the tax the finance-cost reduction leaves + period: 2026 + input: + people: + landlord: + age: 40 + property_rental_income: 20_000 + property_income: 20_000 + property_finance_costs: 6_700 + earner: + age: 38 + employment_income: 12_000 + benunits: + benunit: + members: [landlord, earner] + is_married: true + output: + # Landlord: Step 5 tax 1,486, finance-cost reduction 20% x 6,700 = 1,340, + # leaving 146. The earner electing pays (12,000 - 11,310) x 20% = 138 and + # saves the landlord 146, so the couple elects and pays 138 against 146. + property_finance_cost_relief: [1_340, 0] + marriage_allowance_tax_reduction_limit: [146, 138] + makes_marriage_allowance_election: [false, true] + marriage_allowance_tax_reduction: [146, 0] + income_tax: [0, 138] diff --git a/policyengine_uk/tests/test_marriage_allowance_properties.py b/policyengine_uk/tests/test_marriage_allowance_properties.py index d0927e90de..e4f5ab500a 100644 --- a/policyengine_uk/tests/test_marriage_allowance_properties.py +++ b/policyengine_uk/tests/test_marriage_allowance_properties.py @@ -7,7 +7,7 @@ conditions allow it and it lowers their combined income tax. Invariants, for any generated population of couples with every kind of -taxable income, in England, Wales or Scotland: +taxable income and residential finance costs, in England, Wales or Scotland: 1. The couple's income tax is never higher than with no election. 2. Differential against brute force: the model's election gives the lowest @@ -52,6 +52,9 @@ "dividend_income": 15_000, "property_income": 10_000, } +# Residential finance costs give the landlord a Step 6 reduction of their own +# (ITTOIA 2005 s. 274AA), deducted before the Marriage Allowance reduction. +FINANCE_COSTS = 10_000 ELECTION = "makes_marriage_allowance_election" VARIABLES = [ ELECTION, @@ -82,6 +85,7 @@ def adults(draw): if variable == "state_pension" and age < 67: continue adult[variable] = draw(amounts(cap)) + adult["property_finance_costs"] = draw(amounts(FINANCE_COSTS)) return adult diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py index db38ac65d4..82caa9078f 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction_limit.py @@ -8,10 +8,13 @@ class marriage_allowance_tax_reduction_limit(Variable): documentation = ( "Tax at Step 5 of the income tax calculation left after the person's " "other tax reductions. The Marriage Allowance tax reduction cannot " - "exceed it. Deducting the other reductions first gives the same total " - "as the order that most reduces the person's liability. Double " - "taxation relief should come after the Marriage Allowance (s. 27(6)), " - "but other_tax_credits does not separate it out." + "exceed it. Each reduction is limited to the tax left after those " + "before it, so the order changes how the total is split between " + "them but not the total, which is the greatest reduction in " + "liability. Deducting this one last makes it exactly the tax an " + "election saves the gaining spouse. Double taxation relief should " + "come after the Marriage Allowance (s. 27(6)), but other_tax_credits " + "does not separate it out." ) definition_period = YEAR reference = [ @@ -27,6 +30,11 @@ class marriage_allowance_tax_reduction_limit(Variable): unit = GBP def formula(person, period, parameters): + income_tax = parameters(period).gov.hmrc.income_tax + other_reductions = [ + variable + for variable in income_tax.income_tax_subtractions + if variable != "marriage_allowance_tax_reduction" + ] step_5_tax = person("income_tax_pre_charges", period) - other_reductions = add(person, period, ["capped_mcad", "other_tax_credits"]) - return max_(0, step_5_tax - other_reductions) + return max_(0, step_5_tax - add(person, period, other_reductions)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py index b23956b772..7c0f2113fa 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py @@ -1,5 +1,7 @@ from policyengine_uk.model_api import * +DEDUCTED_AFTER = ["property_finance_cost_relief", "marriage_allowance_tax_reduction"] + class property_finance_cost_relief(Variable): value_type = float @@ -42,12 +44,14 @@ def formula(person, period, parameters): ) # The order of Step 6 reductions does not change their total, so the # person's other reductions come first; this one is limited to the - # Step 5 tax they leave. Step 7 charges (ITA 2007 s. 30), such as the - # High Income Child Benefit Charge, are not reduced. + # Step 5 tax they leave. The Marriage Allowance reduction comes after + # this one, so it is exactly the tax an election saves the gaining + # spouse. Step 7 charges (ITA 2007 s. 30), such as the High Income + # Child Benefit Charge, are not reduced. other_reductions = [ variable for variable in income_tax.income_tax_subtractions - if variable != "property_finance_cost_relief" + if variable not in DEDUCTED_AFTER ] tax_left = max_( 0, From 45837af8bfd2c2596ce499231b18b47b72a9ce26 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:37:34 -0400 Subject: [PATCH 10/30] Claim Marriage Allowance in the UC unearned-benefit property tests The tests switched Marriage Allowance off because the old model booked it as the transferor's unused allowance (#1947). With the statutory transfer, an election fixes the gaining partner's reduction, so hold each couple's election at the first run's choice instead, as the other UC property tests now do. Co-Authored-By: Claude Opus 5.5 --- .../test_uc_unearned_benefits_properties.py | 83 ++++++++++++------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py index 78a59e33a0..ba67888d58 100644 --- a/policyengine_uk/tests/test_uc_unearned_benefits_properties.py +++ b/policyengine_uk/tests/test_uc_unearned_benefits_properties.py @@ -26,13 +26,17 @@ For carer support payment both families keep the carer's caring hours, so both get the carer element. -Marriage Allowance is switched off for invariants 2 and 3, and for invariant -1 for carer support payment. The model books it on the recipient as the -transferor's unused personal allowance (PolicyEngine/policyengine-uk#1947), -so a taxable benefit that uses up the first adult's allowance raises the -earning partner's tax on earnings. Invariants 2 and 3 compare the award before the benefit cap because -contributory ESA and industrial injuries benefit trigger benefit cap -exemptions and ESA counts towards the cap, which private pension does not. +Marriage Allowance is claimed throughout. Once a couple has elected, the +gaining partner's reduction is fixed (ITA 2007 s. 55B(1), (4)-(6)), so the +first adult's benefit cannot move the earning partner's tax on earnings. The +couple's election itself can respond to the benefit, since a taxable benefit +changes whether electing lowers the couple's tax (see +test_uc_state_pension_properties.py). So every invariant that compares runs +holds each couple's election at its choice in the first run +(PolicyEngine/policyengine-uk#1947). Invariants 2 and 3 compare the award +before the benefit cap because contributory ESA and industrial injuries +benefit trigger benefit cap exemptions and ESA counts towards the cap, which +private pension does not. """ import numpy as np @@ -84,6 +88,7 @@ "uc_income_reduction", "uc_maximum_amount", ] +ELECTION = "makes_marriage_allowance_election" @st.composite @@ -100,17 +105,16 @@ def families(draw): ) -def situation( - units, year, income_variable, bump=0.0, carer=False, marriage_allowance=True -): +def situation(units, year, income_variable, bump=0.0, carer=False, election=None): """Build one simulation holding every family. The first adult receives the family's ``amount`` (plus ``bump``) under ``income_variable``; a partner receives the family's earnings. With ``carer``, the first adult cares for 40 hours a week in Scotland and has carer support payment set explicitly (zero unless it is the income - variable), so the carer element applies whatever the income. With - ``marriage_allowance=False`` no one claims Marriage Allowance. + variable), so the carer element applies whatever the income. + ``election``, one value per person in order, fixes who makes a Marriage + Allowance election. """ people, benunits, households = {}, {}, {} for i, unit in enumerate(units): @@ -122,8 +126,6 @@ def situation( # head's child) does not apply. Setting it for anyone makes it an # input for everyone, so children get False below. person = {"age": {year: age}, "is_claimant_or_partner": {year: True}} - if not marriage_allowance: - person["would_claim_marriage_allowance"] = {year: False} if j == 0: if carer: person["care_hours"] = {year: 40} @@ -145,12 +147,16 @@ def situation( "savings": {year: unit["savings"]}, "country": {year: "SCOTLAND" if carer else "ENGLAND"}, } + if election is not None: + for person, elects in zip(people.values(), election): + person[ELECTION] = {year: bool(elects)} return {"people": people, "benunits": benunits, "households": households} def calculate(units, year, income_variable, **kwargs): sim = Simulation(situation=situation(units, year, income_variable, **kwargs)) values = {v: np.asarray(sim.calculate(v, year)) for v in UC_VARIABLES} + values[ELECTION] = np.asarray(sim.calculate(ELECTION, year)) counted = np.asarray(sim.calculate("uc_unearned_carer_support_payment", year)) values["counted_csp"] = np.asarray( sim.map_result(counted, "person", "benunit", how="sum") @@ -203,7 +209,7 @@ def assert_monotone(low, high, increase, units): ) def test_uc_is_non_increasing_in_each_benefit(units, benefit, bump, year): low = calculate(units, year, benefit) - high = calculate(units, year, benefit, bump=bump) + high = calculate(units, year, benefit, bump=bump, election=low[ELECTION]) assert_monotone(low, high, bump, units) @@ -214,9 +220,15 @@ def test_uc_is_non_increasing_in_each_benefit(units, benefit, bump, year): year=st.sampled_from(CSP_YEARS), ) def test_uc_is_non_increasing_in_carer_support_payment(units, bump, year): - kwargs = dict(carer=True, marriage_allowance=False) - low = calculate(units, year, "carer_support_payment", **kwargs) - high = calculate(units, year, "carer_support_payment", bump=bump, **kwargs) + low = calculate(units, year, "carer_support_payment", carer=True) + high = calculate( + units, + year, + "carer_support_payment", + bump=bump, + carer=True, + election=low[ELECTION], + ) increase = high["counted_csp"] - low["counted_csp"] assert np.all(increase >= -0.01), units assert_monotone(low, high, increase, units) @@ -230,8 +242,8 @@ def test_uc_is_non_increasing_in_carer_support_payment(units, bump, year): year=st.sampled_from(YEARS), ) def test_uc_falls_pound_for_pound_in_each_benefit(units, benefit, bump, year): - low = calculate(units, year, benefit, marriage_allowance=False) - high = calculate(units, year, benefit, bump=bump, marriage_allowance=False) + low = calculate(units, year, benefit) + high = calculate(units, year, benefit, bump=bump, election=low[ELECTION]) award = low["universal_credit_pre_benefit_cap"] np.testing.assert_allclose( high["universal_credit_pre_benefit_cap"], @@ -248,9 +260,15 @@ def test_uc_falls_pound_for_pound_in_each_benefit(units, benefit, bump, year): year=st.sampled_from(CSP_YEARS), ) def test_uc_falls_pound_for_pound_in_counted_carer_support_payment(units, bump, year): - kwargs = dict(carer=True, marriage_allowance=False) - low = calculate(units, year, "carer_support_payment", **kwargs) - high = calculate(units, year, "carer_support_payment", bump=bump, **kwargs) + low = calculate(units, year, "carer_support_payment", carer=True) + high = calculate( + units, + year, + "carer_support_payment", + bump=bump, + carer=True, + election=low[ELECTION], + ) award = low["universal_credit_pre_benefit_cap"] increase = high["counted_csp"] - low["counted_csp"] np.testing.assert_allclose( @@ -268,9 +286,13 @@ def test_uc_falls_pound_for_pound_in_counted_carer_support_payment(units, bump, year=st.sampled_from(YEARS), ) def test_each_benefit_counts_like_private_pension(units, benefit, year): + with_benefit = calculate(units, year, benefit) + with_pension = calculate( + units, year, "private_pension_income", election=with_benefit[ELECTION] + ) assert_same( - calculate(units, year, benefit, marriage_allowance=False), - calculate(units, year, "private_pension_income", marriage_allowance=False), + with_benefit, + with_pension, [v for v in UC_VARIABLES if v != "universal_credit"], str(units), ) @@ -282,12 +304,17 @@ def test_each_benefit_counts_like_private_pension(units, benefit, year): year=st.sampled_from(CSP_YEARS), ) def test_carer_support_payment_counts_like_capped_private_pension(units, year): - kwargs = dict(carer=True, marriage_allowance=False) - with_csp = calculate(units, year, "carer_support_payment", **kwargs) + with_csp = calculate(units, year, "carer_support_payment", carer=True) # The same family with the counted amount of Carer Support Payment # received as private pension instead. counted = [dict(unit, amount=c) for unit, c in zip(units, with_csp["counted_csp"])] - with_pension = calculate(counted, year, "private_pension_income", **kwargs) + with_pension = calculate( + counted, + year, + "private_pension_income", + carer=True, + election=with_csp[ELECTION], + ) assert_same( with_csp, with_pension, From 0ddb7d6ba8c52cd4b31d11bab809e81eb4188071 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:18:17 -0400 Subject: [PATCH 11/30] Name the changelog fragment after issue #1947 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1947.fixed.md | 1 + changelog.d/marriage-allowance-election.fixed.md | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 changelog.d/1947.fixed.md delete mode 100644 changelog.d/marriage-allowance-election.fixed.md diff --git a/changelog.d/1947.fixed.md b/changelog.d/1947.fixed.md new file mode 100644 index 0000000000..59d59411f5 --- /dev/null +++ b/changelog.d/1947.fixed.md @@ -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. diff --git a/changelog.d/marriage-allowance-election.fixed.md b/changelog.d/marriage-allowance-election.fixed.md deleted file mode 100644 index d5f17e0058..0000000000 --- a/changelog.d/marriage-allowance-election.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Model Marriage Allowance as ITA 2007 ss. 55A-55E provide. The electing spouse gives up the fixed transferable amount (10% of the personal allowance, rounded up to £10) from their own allowance, and the other spouse's tax falls at Step 6 by the basic rate (or Scottish basic rate) times that amount, capped at their tax. Previously the recipient's taxable income fell by the transferor's unused allowance. The couple elects only when the conditions in s. 55B(2) and s. 55C(1) are met and it lowers their combined income tax. Universal Credit deducts the reduction from the recipient's tax on their earnings, so a pensioner's State Pension no longer changes the earning partner's earned income. From 9a429d0e2c5137284a462449423e73fdd0803d92 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:56:06 -0400 Subject: [PATCH 12/30] Test the s. 55B(2)(ba) dividend boundary at the basic rate limit Companion cases B and C of TheAxiomFoundation/rulespec-uk#437: on 50,070 of earnings, 200 of dividends reaches the basic rate limit exactly and meets the conditions; 201 would put 1 at the dividend upper rate if s. 13A were omitted, so it does not. Co-Authored-By: Claude Opus 5.5 --- ..._marriage_allowance_income_conditions.yaml | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 3549367fd0..bc8c4b2cf2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -149,3 +149,24 @@ dividend_income: 5_000 output: meets_marriage_allowance_income_conditions: false + +- name: Dividends that reach the basic rate limit exactly meet the conditions (s. 55B(2)(ba)) + period: 2026 + input: + # 50,070 - 12,570 = 37,500 of earnings, then 200 of dividends to 37,700: + # even without the dividend nil rate (s. 13A) none would be at the + # dividend upper rate. Companion case B of TheAxiomFoundation/rulespec-uk#437. + employment_income: 50_070 + dividend_income: 200 + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound of dividends above the basic rate limit fails the conditions (s. 55B(2)(ba)) + period: 2026 + input: + # 37,500 + 201 = 37,701: without s. 13A, 1 would be at the dividend upper + # rate. Companion case C of TheAxiomFoundation/rulespec-uk#437. + employment_income: 50_070 + dividend_income: 201 + output: + meets_marriage_allowance_income_conditions: false From 5fdc01e48a5689e6794f23675c06c9a894006d4f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:56:43 -0400 Subject: [PATCH 13/30] Wrap a long comment in the UC earnings deductions YAML Co-Authored-By: Claude Opus 5.5 --- .../universal_credit/income/uc_earnings_deductions.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 2d59c465c8..b216be085b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -203,8 +203,8 @@ output: # The transferor (10,000, below the cut allowance of 11,310) elects, and # the recipient's tax falls by 20% x 1,260 = 252. Tax on the recipient's - # earnings: (15,000 - 12,570) x 20% = 486, less the 252 reduction = 234. Dividends: (5,000 - 500) x 10.75% = 483.75, - # not deducted. + # earnings: (15,000 - 12,570) x 20% = 486, less the 252 reduction = 234. + # Dividends: (5,000 - 500) x 10.75% = 483.75, not deducted. marriage_allowance: [1_260, 0] income_tax: [717.75, 0] uc_income_tax_on_earnings: [234, 0] From 5a32ce9b75ae0eefa9b27df65ebc933c36cda07a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:57:04 -0400 Subject: [PATCH 14/30] Give finance costs to a quarter of generated populations Finance costs make the property allowance a choice that income tax weighs in its own branches, which nest with the election's, so giving them to every adult made the property tests about ten times slower. Draw them for about one population in four, and always try a landlord couple whose finance-cost reduction clears the landlord's tax. Co-Authored-By: Claude Opus 5.5 --- .../test_marriage_allowance_properties.py | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/test_marriage_allowance_properties.py b/policyengine_uk/tests/test_marriage_allowance_properties.py index e4f5ab500a..a70f683211 100644 --- a/policyengine_uk/tests/test_marriage_allowance_properties.py +++ b/policyengine_uk/tests/test_marriage_allowance_properties.py @@ -7,7 +7,8 @@ conditions allow it and it lowers their combined income tax. Invariants, for any generated population of couples with every kind of -taxable income and residential finance costs, in England, Wales or Scotland: +taxable income, some with residential finance costs, in England, Wales or +Scotland: 1. The couple's income tax is never higher than with no election. 2. Differential against brute force: the model's election gives the lowest @@ -27,7 +28,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -54,7 +55,24 @@ } # Residential finance costs give the landlord a Step 6 reduction of their own # (ITTOIA 2005 s. 274AA), deducted before the Marriage Allowance reduction. +# They also make the property allowance a choice that income tax weighs in +# branches of its own, which is slow, so about a quarter of the generated +# populations have them, and LANDLORD_COUPLE is always tried. FINANCE_COSTS = 10_000 +# A landlord whose finance-cost reduction clears their tax, married to a +# low earner who would pay 138 to elect: electing saves the landlord nothing. +LANDLORD_COUPLE = [ + dict( + adults=[ + dict(age=40, property_income=20_000.0, property_finance_costs=15_000.0), + dict(age=38, employment_income=12_000.0), + ], + married=True, + children=[], + region="LONDON", + married_couples_allowance=0.0, + ) +] ELECTION = "makes_marriage_allowance_election" VARIABLES = [ ELECTION, @@ -78,21 +96,25 @@ def amounts(cap): @st.composite -def adults(draw): +def adults(draw, finance_costs=False): age = draw(st.integers(18, 100)) adult = dict(age=age) for variable, cap in INCOMES.items(): if variable == "state_pension" and age < 67: continue adult[variable] = draw(amounts(cap)) - adult["property_finance_costs"] = draw(amounts(FINANCE_COSTS)) + if finance_costs: + adult["property_finance_costs"] = draw(amounts(FINANCE_COSTS)) return adult @st.composite -def families(draw): +def families(draw, finance_costs=False): return dict( - adults=[draw(adults()) for _ in range(draw(st.sampled_from([1, 2, 2, 2])))], + adults=[ + draw(adults(finance_costs)) + for _ in range(draw(st.sampled_from([1, 2, 2, 2]))) + ], married=draw(st.sampled_from([True, True, True, False])), children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], region=draw(st.sampled_from(REGIONS)), @@ -179,11 +201,18 @@ def spouses_by_order(values): return first, second -couple_populations = st.lists(families(), min_size=1, max_size=8) +@st.composite +def populations(draw): + finance_costs = draw(st.sampled_from([False, False, False, True])) + return draw(st.lists(families(finance_costs), min_size=1, max_size=8)) + + +couple_populations = populations() @PROPERTY_SETTINGS @given(units=couple_populations, year=st.sampled_from(YEARS)) +@example(units=LANDLORD_COUPLE, year=2026) def test_election_never_raises_the_couples_tax(units, year): model = calculate(units, year) none = calculate(units, year, election=np.zeros_like(model[ELECTION])) @@ -197,6 +226,7 @@ def test_election_never_raises_the_couples_tax(units, year): @PROPERTY_SETTINGS @given(units=couple_populations, year=st.sampled_from(YEARS)) +@example(units=LANDLORD_COUPLE, year=2026) def test_election_is_the_best_allowed_choice(units, year): model = calculate(units, year) none = calculate(units, year, election=np.zeros_like(model[ELECTION])) @@ -233,7 +263,7 @@ def test_election_is_the_best_allowed_choice(units, year): @st.composite def bumped(draw): - units = draw(st.lists(families(), min_size=1, max_size=8)) + units = draw(populations()) i = draw(st.integers(0, len(units) - 1)) j = draw(st.integers(0, len(units[i]["adults"]) - 1)) variables = [ From 71ba551dc074bc776d8d6df3a0fc9c115e3c5679 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 14:57:14 -0400 Subject: [PATCH 15/30] Test the election's branches for query order, baseline isolation and cleanup The election and the property allowance route both weigh income tax in policyengine-core branches, and they nest. Check that results are bit-for-bit the same whichever output is asked for first, with and without a dated personal allowance reform; that a reform's baseline matches an unreformed simulation whichever runs first; and that no branch is left after computing six years. Co-Authored-By: Claude Opus 5.5 --- ...st_marriage_allowance_election_branches.py | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 policyengine_uk/tests/test_marriage_allowance_election_branches.py diff --git a/policyengine_uk/tests/test_marriage_allowance_election_branches.py b/policyengine_uk/tests/test_marriage_allowance_election_branches.py new file mode 100644 index 0000000000..196b8ede83 --- /dev/null +++ b/policyengine_uk/tests/test_marriage_allowance_election_branches.py @@ -0,0 +1,238 @@ +"""Engineering checks for the branches behind the Marriage Allowance election. + +makes_marriage_allowance_election weighs each couple's income tax in two +policyengine-core branches (ITA 2007 ss. 55B, 55C), and uses_property_allowance +weighs income tax in branches of its own (ITTOIA 2005 s. 783BL), so the two +nest whenever a spouse has property income with finance costs. For any +generated population of couples and singles: + +1. Determinism under query order: the election, the Marriage Allowance + reduction, personal allowances, income tax, the property allowance route, + the finance-cost reduction, Universal Credit and household net income are + bit-for-bit the same whichever of them is asked for first, with and without + a reform to the personal allowance dated to the year. +2. Baseline isolation: a reformed simulation's baseline gives bit-for-bit the + same election, reduction, allowances and income tax as a simulation with no + reform, whether the reform or the baseline is computed first, and the + reform's own results do not depend on that order either. +3. Branches are dropped: computing six years leaves no branch behind. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.utils.scenario import Scenario + +PROPERTY_SETTINGS = settings( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +YEARS = [2026, 2027] +ELECTION = "makes_marriage_allowance_election" +OUTPUTS = [ + ELECTION, + "marriage_allowance_tax_reduction", + "personal_allowance", + "income_tax", + "uses_property_allowance", + "property_finance_cost_relief", + "universal_credit", + "household_net_income", +] +# Each order asks for one output first; the rest follow in list order. +FIRST = [ + ELECTION, + "universal_credit", + "income_tax", + "uses_property_allowance", + "personal_allowance", +] +BASELINE_OUTPUTS = [ + ELECTION, + "marriage_allowance_transferable_amount", + "marriage_allowance_tax_reduction", + "personal_allowance", + "income_tax", +] +TENURES = ["RENT_FROM_COUNCIL", "RENT_PRIVATELY", "OWNED_OUTRIGHT"] +REGIONS = ["LONDON", "WALES", "SCOTLAND"] + + +def personal_allowance_reform(year): + # A 15,000 allowance for this tax year only (a bare year is the UK fiscal + # year), so the transferable amount is 1,500 (s. 55B(4)(b), (5)). + return Scenario( + parameter_changes={ + "gov.hmrc.income_tax.allowances.personal_allowance.amount": { + str(year): 15_000 + } + } + ) + + +def amounts(cap): + return st.one_of(st.just(0.0), st.floats(0, cap)) + + +@st.composite +def adults(draw): + adult = dict( + age=draw(st.integers(18, 80)), + employment_income=draw(amounts(50_000)), + savings_interest_income=draw(amounts(10_000)), + dividend_income=draw(amounts(5_000)), + property_income=draw(amounts(15_000)), + property_finance_costs=draw(amounts(10_000)), + ) + if adult["age"] >= 67: + adult["state_pension"] = draw(amounts(15_000)) + return adult + + +@st.composite +def families(draw): + return dict( + adults=[draw(adults()) for _ in range(draw(st.sampled_from([1, 2, 2])))], + married=draw(st.sampled_from([True, True, False])), + children=[draw(st.integers(0, 15)) for _ in range(draw(st.integers(0, 2)))], + tenure=draw(st.sampled_from(TENURES)), + rent=draw(amounts(15_000)), + region=draw(st.sampled_from(REGIONS)), + ) + + +# Small populations: every adult may have finance costs, so each simulation +# runs both kinds of branch. +populations = st.lists(families(), min_size=1, max_size=3) + + +def situation(units, years): + """One simulation holding every family, with the same inputs each year.""" + years = [years] if isinstance(years, int) else years + + def each_year(value): + return {year: value for year in years} + + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, adult in enumerate(unit["adults"]): + person = {variable: each_year(value) for variable, value in adult.items()} + person["is_claimant_or_partner"] = each_year(True) + people[f"p{i}_{j}"] = person + names.append(f"p{i}_{j}") + for k, age in enumerate(unit["children"]): + people[f"c{i}_{k}"] = { + "age": each_year(age), + "is_claimant_or_partner": each_year(False), + } + names.append(f"c{i}_{k}") + benunits[f"b{i}"] = { + "members": names, + "is_married": each_year(unit["married"] and len(unit["adults"]) == 2), + } + households[f"h{i}"] = { + "members": names, + "rent": each_year(unit["rent"]), + "tenure_type": each_year(unit["tenure"]), + "region": each_year(unit["region"]), + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(simulation, year, variables, first=None): + order = [first] + [v for v in variables if v != first] if first else variables + for variable in order: + simulation.calculate(variable, year) + return {v: np.asarray(simulation.calculate(v, year)) for v in variables} + + +def assert_identical(a, b, message): + for variable in a: + assert a[variable].dtype == b[variable].dtype, (variable, message) + assert np.array_equal(a[variable], b[variable]), (variable, message) + + +@PROPERTY_SETTINGS +@given( + units=populations, + year=st.sampled_from(YEARS), + reformed=st.booleans(), +) +def test_results_do_not_depend_on_query_order(units, year, reformed): + scenario = personal_allowance_reform(year) if reformed else None + results = [ + calculate( + Simulation(situation=situation(units, year), scenario=scenario), + year, + OUTPUTS, + first=first, + ) + for first in FIRST + ] + for first, result in zip(FIRST[1:], results[1:]): + assert_identical(results[0], result, f"{first} first: {units}") + + +@PROPERTY_SETTINGS +@given(units=populations, year=st.sampled_from(YEARS)) +def test_reform_leaves_the_baseline_unchanged(units, year): + plain = calculate( + Simulation(situation=situation(units, year)), year, BASELINE_OUTPUTS + ) + # Reform first, then its baseline. + reformed = Simulation( + situation=situation(units, year), scenario=personal_allowance_reform(year) + ) + reform_first = calculate(reformed, year, BASELINE_OUTPUTS) + baseline_second = calculate(reformed.baseline, year, BASELINE_OUTPUTS) + # Baseline first, then the reform. + reformed = Simulation( + situation=situation(units, year), scenario=personal_allowance_reform(year) + ) + baseline_first = calculate(reformed.baseline, year, BASELINE_OUTPUTS) + reform_second = calculate(reformed, year, BASELINE_OUTPUTS) + assert_identical(plain, baseline_second, f"baseline after reform: {units}") + assert_identical(plain, baseline_first, f"baseline before reform: {units}") + assert_identical(reform_first, reform_second, f"reform: {units}") + # The reform reached the simulation: the transferable amount is 10% of + # 15,000 (s. 55B(4)(b)), and 1,260 in the baseline. + assert np.all(reform_first["marriage_allowance_transferable_amount"] == 1_500) + assert np.all(plain["marriage_allowance_transferable_amount"] == 1_260) + + +def test_no_branch_is_left_after_any_year(): + # A couple who elect (12,000 and 30,000 of earnings), one of them a + # landlord whose finance costs make the property allowance a choice, so + # both the election's and the property route's branches run each year. + units = [ + dict( + adults=[ + dict(age=40, employment_income=30_000.0), + dict( + age=38, + employment_income=12_000.0, + property_income=3_000.0, + property_finance_costs=2_000.0, + ), + ], + married=True, + children=[4], + tenure="RENT_PRIVATELY", + rent=9_000.0, + region="LONDON", + ) + ] + years = list(range(2025, 2031)) + simulation = Simulation(situation=situation(units, years)) + before = set(simulation.branches) + elected = [] + for year in years: + simulation.calculate("household_net_income", year) + elected.append(bool(simulation.calculate(ELECTION, year).any())) + assert set(simulation.branches) == before, (year, list(simulation.branches)) + assert all(elected), elected From 13fbdbb2e051a302fcf76221a3cf8d3e8a46ffc9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:18:54 -0400 Subject: [PATCH 16/30] Test take-up when the electing spouse is the elder The two take-up cases both had the gaining spouse as the elder, so they could not tell the gaining spouse's draw from the elder spouse's. Add the same cases with the electing spouse older. A mutant that reads the elder spouse's draw survived the YAML before and fails both now. Co-Authored-By: Claude Opus 5.5 --- .../allowances/marriage_allowance.yaml | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index 991a652fac..3f8722bf63 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -733,3 +733,41 @@ makes_marriage_allowance_election: [false, true] marriage_allowance_tax_reduction: [146, 0] income_tax: [0, 138] + +- name: Take-up follows the gaining spouse when the electing spouse is older + # The draw that decides the couple's take-up is the gaining spouse's, not + # the elder spouse's. + period: 2026 + input: + people: + recipient: + age: 38 + employment_income: 30_000 + would_claim_marriage_allowance: false + transferor: + age: 40 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, false] + income_tax: [3_486, 0] + +- name: An older electing spouse's take-up value does not stop the election + period: 2026 + input: + people: + recipient: + age: 38 + employment_income: 30_000 + transferor: + age: 40 + would_claim_marriage_allowance: false + benunits: + benunit: + members: [recipient, transferor] + is_married: true + output: + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 0] From 0c9c4d66b0444ed8b56652872564d2fd2cccc47a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:38:01 -0400 Subject: [PATCH 17/30] Weigh the election under the property allowance route the couple uses policyengine-core 3.33.0 makes an input set on a branch invalidate the values derived from it. The election's branches set the election and the allowance cut, so under 3.33 they dropped the property allowance route they had inherited and chose one of their own for their counterfactual. CI (core 3.33.2) caught it in test_no_one_lowers_their_tax_by_switching_ route_alone: a partner with receipts within the allowance was taxed as electing on actual expenses (269.79), where the couple would not elect. Pass the settled route to both branches as an input. Under 3.32.27 the branches already inherited it, so nothing changes there; under 3.33.2 the failing example now gives the same taxes as under 3.32.27. A 2027 YAML case pins it. Co-Authored-By: Claude Opus 5.5 --- .../allowances/marriage_allowance.yaml | 40 +++++++++++++++++++ .../makes_marriage_allowance_election.py | 14 ++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index 3f8722bf63..f496be2837 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -771,3 +771,43 @@ output: makes_marriage_allowance_election: [false, true] income_tax: [3_234, 0] + +- name: The election is weighed under the property allowance route the couple uses + # Each property allowance route is weighed with the election it would + # bring (ITTOIA 2005 s. 783BL); the election is then weighed under the route + # chosen. Under policyengine-core 3.33 the election's branches dropped the + # chosen route and picked their own, so the earner elected as if on full + # relief while taxed on actual expenses (CI, 11 Oct 2026). + period: 2027 + input: + people: + landlord: + age: 40 + employment_income: 20_000 + property_rental_income: 10_000 + property_income: 10_000 + property_finance_costs: 2_000 + earner: + age: 38 + employment_income: 12_000 + property_rental_income: 800 + property_income: 800 + property_finance_costs: 200 + benunits: + benunit: + members: [landlord, earner] + is_married: true + output: + # Earner, full relief: no tax, or 690 x 20% = 138 if electing, so the + # couple would elect. On actual expenses: employment uses 12,000 of the + # allowance, 230 of property at the 22% property basic rate = 50.60, less + # 22% x 200 of finance costs = 6.60; electing would raise that to + # 138 + 176 - 44 = 270, by 263.40, more than the 252 it saves, so no + # election. Actual expenses cost the earner less (6.60 against 138), so + # the earner uses them. + # Landlord: 7,430 x 20% + 10,000 x 22% - 22% x 2,000 = 3,246, against + # 3,466 with the property allowance. + uses_property_allowance: [false, false] + makes_marriage_allowance_election: [false, false] + property_finance_cost_relief: [440, 44] + income_tax: [3_246, 6.60] diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py index 12f7f4ef0b..92075432cf 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py @@ -30,11 +30,16 @@ ] -def tax_position(simulation, name, period, relinquished): +def tax_position(simulation, name, period, relinquished, property_route): """Each person's tax with no election in force and the given allowance cut. The branch holds the election off, so no one gains a reduction, and cuts each person's personal allowance by ``relinquished`` (ITA 2007 s. 55B(6)). + It also holds each person's property allowance route at + ``property_route``, the route the simulation uses. Inputs set on a branch + invalidate the values derived from them (policyengine-core 3.33), so + without it the branch would choose a route of its own for the + counterfactual it represents. The branch shares the simulation's cached arrays until it writes, and is dropped afterwards so a later period starts from a fresh branch. """ @@ -48,6 +53,7 @@ def tax_position(simulation, name, period, relinquished): np.zeros(len(relinquished), dtype=bool), ) branch.set_input("marriage_allowance_relinquished", period, relinquished) + branch.set_input("uses_property_allowance", period, property_route) population = branch.populations["person"] return {variable: population(variable, period) for variable in BRANCH_OUTPUTS} finally: @@ -89,18 +95,24 @@ def formula(person, period, parameters): person(variable, period) spouse = person("is_marriage_allowance_spouse", period) transferable = person("marriage_allowance_transferable_amount", period) + # The property allowance route (ITTOIA 2005 s. 783BL) is settled + # first, each route weighed with the election it would bring. The + # election is then weighed under that route. + property_route = person("uses_property_allowance", period) simulation = person.simulation without = tax_position( simulation, "marriage_allowance_without_election", period, np.zeros_like(transferable), + property_route, ) relinquishing = tax_position( simulation, "marriage_allowance_relinquishing", period, spouse * transferable, + property_route, ) def partner(values): From 418a0b9b3b430c760fa8cd3260378cb249654565 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:38:17 -0400 Subject: [PATCH 18/30] Make the branch tests reach the property route choice The cleanup test's couple never elected (electing cost exactly what it saved) and its landlord had no receipts, so neither branch ran. Use a couple who elect every year, with receipts within the property allowance and finance costs. Give generated adults gross receipts too, so the property route is a choice weighed in branches in most examples. Co-Authored-By: Claude Opus 5.5 --- ...st_marriage_allowance_election_branches.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/policyengine_uk/tests/test_marriage_allowance_election_branches.py b/policyengine_uk/tests/test_marriage_allowance_election_branches.py index 196b8ede83..26475cb2ac 100644 --- a/policyengine_uk/tests/test_marriage_allowance_election_branches.py +++ b/policyengine_uk/tests/test_marriage_allowance_election_branches.py @@ -88,6 +88,10 @@ def adults(draw): property_income=draw(amounts(15_000)), property_finance_costs=draw(amounts(10_000)), ) + # Gross receipts are the profit plus expenses; with expenses under the + # 1,000 property allowance, and finance costs, the route is a choice + # weighed in branches (ITTOIA 2005 s. 783BH, s. 783BL). + adult["property_rental_income"] = adult["property_income"] + draw(amounts(1_500)) if adult["age"] >= 67: adult["state_pension"] = draw(amounts(15_000)) return adult @@ -105,8 +109,8 @@ def families(draw): ) -# Small populations: every adult may have finance costs, so each simulation -# runs both kinds of branch. +# Small populations: every adult may have property receipts and finance +# costs, so most simulations run both kinds of branch. populations = st.lists(families(), min_size=1, max_size=3) @@ -206,18 +210,20 @@ def test_reform_leaves_the_baseline_unchanged(units, year): def test_no_branch_is_left_after_any_year(): - # A couple who elect (12,000 and 30,000 of earnings), one of them a - # landlord whose finance costs make the property allowance a choice, so - # both the election's and the property route's branches run each year. + # A couple who elect every year: 30,000 and 9,000 of earnings, so electing + # costs the lower earner nothing and saves the other 252 (s. 55B). The + # lower earner's property receipts are within the property allowance and + # carry finance costs, so the property route is weighed in branches too. units = [ dict( adults=[ dict(age=40, employment_income=30_000.0), dict( age=38, - employment_income=12_000.0, - property_income=3_000.0, - property_finance_costs=2_000.0, + employment_income=9_000.0, + property_rental_income=800.0, + property_income=800.0, + property_finance_costs=200.0, ), ], married=True, From 09e41354eca63823536ed394d96078e9d0febf04 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 23:39:35 -0400 Subject: [PATCH 19/30] Hold every input the election resolves first fixed in its branches policyengine-core 3.33 (#560) makes an input set on a branch drop what the branch holds that may have been calculated from the value it replaces. The election's branches set the election itself, which the simulation may first have calculated inside branches of its own: the property allowance route's, a CPS marriage-neutral reform's split of adjusted net income, or a labour supply response's measurement. Everything stored after that is dropped in the election's branches and calculated again there, with the election held off, so each of those choices could be made afresh for the counterfactual. 0c9c4d66b held the route; this holds the rest of what the formula resolves before branching (now including employment income) at the simulation's values. Under 3.32.27, which the lockfile pins, branches already inherit these values, so nothing changes there. Co-Authored-By: Claude Opus 5.5 --- .../makes_marriage_allowance_election.py | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py index 92075432cf..de1543e4ef 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py @@ -6,12 +6,15 @@ "marriage_allowance_tax_reduction_limit", "meets_marriage_allowance_income_conditions", ] -# Inputs to income tax that do not depend on the election. Resolving them -# before branching lets both branches reuse them, and keeps the result -# independent of which variable a caller asks for first (a reform that -# branches inside adjusted_net_income would otherwise see a cached election). +# Inputs to income tax that do not depend on the election. They are resolved +# before branching and held at the simulation's values in both branches, so +# the result does not depend on which variable a caller asks for first, and a +# choice made in branches upstream of them (a reform's split of adjusted net +# income, or a labour supply response in employment income) is not made again +# for the counterfactual. INDEPENDENT_OF_ELECTION = [ "age", + "employment_income", "adjusted_net_income", "gift_aid_grossed_up", "gift_aid", @@ -30,16 +33,19 @@ ] -def tax_position(simulation, name, period, relinquished, property_route): +def tax_position(simulation, name, period, relinquished, held): """Each person's tax with no election in force and the given allowance cut. The branch holds the election off, so no one gains a reduction, and cuts each person's personal allowance by ``relinquished`` (ITA 2007 s. 55B(6)). - It also holds each person's property allowance route at - ``property_route``, the route the simulation uses. Inputs set on a branch - invalidate the values derived from them (policyengine-core 3.33), so - without it the branch would choose a route of its own for the - counterfactual it represents. + It also holds each variable in ``held`` at the given values, the + simulation's own: the inputs to income tax the election does not change, + and the property allowance route. From policyengine-core 3.33, an input + set on a branch drops what the branch holds that may have been calculated + from the value it replaces. A choice the simulation made in branches of + its own before this election (the property allowance route, say) was + weighed with an election in force there, so without these inputs the + branch would make that choice again for the counterfactual it represents. The branch shares the simulation's cached arrays until it writes, and is dropped afterwards so a later period starts from a fresh branch. """ @@ -53,7 +59,8 @@ def tax_position(simulation, name, period, relinquished, property_route): np.zeros(len(relinquished), dtype=bool), ) branch.set_input("marriage_allowance_relinquished", period, relinquished) - branch.set_input("uses_property_allowance", period, property_route) + for variable, values in held.items(): + branch.set_input(variable, period, values) population = branch.populations["person"] return {variable: population(variable, period) for variable in BRANCH_OUTPUTS} finally: @@ -91,28 +98,29 @@ class makes_marriage_allowance_election(Variable): ] def formula(person, period, parameters): - for variable in INDEPENDENT_OF_ELECTION: - person(variable, period) + held = { + variable: person(variable, period) for variable in INDEPENDENT_OF_ELECTION + } spouse = person("is_marriage_allowance_spouse", period) transferable = person("marriage_allowance_transferable_amount", period) # The property allowance route (ITTOIA 2005 s. 783BL) is settled # first, each route weighed with the election it would bring. The # election is then weighed under that route. - property_route = person("uses_property_allowance", period) + held["uses_property_allowance"] = person("uses_property_allowance", period) simulation = person.simulation without = tax_position( simulation, "marriage_allowance_without_election", period, np.zeros_like(transferable), - property_route, + held, ) relinquishing = tax_position( simulation, "marriage_allowance_relinquishing", period, spouse * transferable, - property_route, + held, ) def partner(values): From dafe6301c3d95897e5e8c5a1c5540cd5f6b09141 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 06:23:54 -0400 Subject: [PATCH 20/30] Unpin the Marriage Allowance band-base case now that #1947 is fixed Marriage Allowance received is a Step 6 tax reduction (ITA 2007 s.55A(2), s.55B(1)), so it leaves the non-savings band base at 30,000 - 12,570 = 17,430; the case pinned the old 16,170 until this fix. Co-Authored-By: Claude Opus 5.5 --- .../hmrc/income_tax/property_income_band_base.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml index 7312c6d5bd..58c603f093 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml @@ -119,7 +119,7 @@ taxed_income: 47_430 personal_pension_contributions_tax: 4_000 -- name: Case 7, Marriage Allowance still comes off the band base (pinned; #1947). +- name: Case 7, Marriage Allowance leaves the band base alone (#1947). period: 2026 absolute_error_margin: 0.01 input: @@ -134,8 +134,9 @@ members: [person] country: ENGLAND output: - # earned_taxable_income subtracts Marriage Allowance, which the law gives - # as a tax reduction (ITA 2007 s. 55B), so the base is 30,000 - 12,570 - - # 1,260 = 16,170 rather than 17,430. Pinned here until #1947 is fixed. - non_savings_taxable_income: 16_170 + # Marriage Allowance received is a tax reduction at Step 6 (ITA 2007 + # s. 55A(2), s. 55B(1)), 20% x 1,260 = 252, not a cut to taxable income, + # so the base is 30,000 - 12,570 = 17,430. + non_savings_taxable_income: 17_430 non_savings_income_after_allowances: 17_430 + marriage_allowance_tax_reduction: 252 From 99182e396bc92de1996bf2e36c83173741ac89cd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 12 Oct 2026 08:14:44 -0400 Subject: [PATCH 21/30] Weigh the property allowance route on the couple's tax; fix the 2015-16 amount The independent review found a couple whose automatic choices raised their tax: the landlord spouse's property allowance route was chosen on their own tax, which forfeited the other spouse's Marriage Allowance reduction (2026: 3,492 against 3,372 with full relief and the election). The route now compares the benefit unit's income tax; the case is a YAML test and an explicit example in both couple-tax properties, which fail on the old rule. 2015-16's transferable amount is fixed at 1,060 (ITA 2007 s.55B(4)(a)), not 10% of whatever the allowance is, via a fixed_amount parameter; the CPS reform respects it outside its expansion. Co-Authored-By: Claude Opus 5.5 --- .../marriage_allowance/fixed_amount.yaml | 10 +++ .../reforms/cps/marriage_tax_reforms.py | 8 +- .../allowances/marriage_allowance.yaml | 74 ++++++++++++++----- .../test_marriage_allowance_properties.py | 61 +++++++++++++++ .../marriage_allowance_transferable_amount.py | 4 +- .../allowances/uses_property_allowance.py | 15 +++- 6 files changed, 148 insertions(+), 24 deletions(-) create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/fixed_amount.yaml diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/fixed_amount.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/fixed_amount.yaml new file mode 100644 index 0000000000..a443717068 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/fixed_amount.yaml @@ -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 diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index c0eaca3ab1..b36085179b 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -77,7 +77,13 @@ def formula(person, period, parameters): ) amount = allowances.personal_allowance.amount * share increment = allowances.marriage_allowance.rounding_increment - return np.ceil(amount / increment) * 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): diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index f496be2837..0f27e9389c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -773,11 +773,12 @@ income_tax: [3_234, 0] - name: The election is weighed under the property allowance route the couple uses - # Each property allowance route is weighed with the election it would - # bring (ITTOIA 2005 s. 783BL); the election is then weighed under the route - # chosen. Under policyengine-core 3.33 the election's branches dropped the - # chosen route and picked their own, so the earner elected as if on full - # relief while taxed on actual expenses (CI, 11 Oct 2026). + # Each property allowance route is weighed on the couple's tax, with the + # election it would bring (ITTOIA 2005 s. 783BL); the election is then + # weighed under the route chosen. Under policyengine-core 3.33 the + # election's branches dropped the chosen route and picked their own, so + # the earner elected as if on full relief while taxed on actual expenses + # (CI, 11 Oct 2026). period: 2027 input: people: @@ -798,16 +799,53 @@ members: [landlord, earner] is_married: true output: - # Earner, full relief: no tax, or 690 x 20% = 138 if electing, so the - # couple would elect. On actual expenses: employment uses 12,000 of the - # allowance, 230 of property at the 22% property basic rate = 50.60, less - # 22% x 200 of finance costs = 6.60; electing would raise that to - # 138 + 176 - 44 = 270, by 263.40, more than the 252 it saves, so no - # election. Actual expenses cost the earner less (6.60 against 138), so - # the earner uses them. - # Landlord: 7,430 x 20% + 10,000 x 22% - 22% x 2,000 = 3,246, against - # 3,466 with the property allowance. - uses_property_allowance: [false, false] - makes_marriage_allowance_election: [false, false] - property_finance_cost_relief: [440, 44] - income_tax: [3_246, 6.60] + # Landlord: 7,430 x 20% + 10,000 x 22% (property basic rate) - 22% x + # 2,000 = 3,246 on actual expenses, against 3,466 with the property + # allowance, so actual expenses. Basic rates only, so they can gain. + # Earner, full relief: taxable 12,000; electing costs + # (12,000 - 11,310) x 20% = 138 and saves the landlord 20% x 1,260 = + # 252, so the couple pays 2,994 + 138 = 3,132. On actual expenses: 230 + # of property at 22% = 50.60 less 22% x 200 = 6.60; electing would raise + # that by 263.40, more than the 252 it saves, so no election and the + # couple pays 3,246 + 6.60 = 3,252.60. The couple keeps full relief and + # elects. + uses_property_allowance: [false, true] + makes_marriage_allowance_election: [false, true] + property_finance_cost_relief: [440, 0] + marriage_allowance_tax_reduction: [252, 0] + income_tax: [2_994, 138] + +- name: The property allowance route is chosen on the couple's tax, with the election it brings + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 30_000 + transferor: + age: 38 + employment_income: 12_000 + property_income: 800 + property_rental_income: 800 + property_finance_costs: 200 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: LONDON + output: + # Receipts of 800 are within the 1,000 property allowance, so full relief + # applies (ITTOIA 2005 ss. 783BE-783BF) unless the transferor elects out + # to deduct expenses and take the finance-cost reduction (s. 783BL). + # Full relief: taxable income 12,000; electing for Marriage Allowance + # costs (12,000 - 11,310) x 20% = 138 and saves the recipient 252, so the + # couple pays 3,234 + 138 = 3,372. Electing out: profit 800 taxable, + # (12,800 - 12,570) x 20% - 20% x 200 = 6, but now electing would cost + # 252, so no election and the couple pays 3,486 + 6 = 3,492. The route + # that is cheaper for the transferor alone costs the couple 120 more. + uses_property_allowance: [false, true] + makes_marriage_allowance_election: [false, true] + income_tax: [3_234, 138] diff --git a/policyengine_uk/tests/test_marriage_allowance_properties.py b/policyengine_uk/tests/test_marriage_allowance_properties.py index a70f683211..142cf86e82 100644 --- a/policyengine_uk/tests/test_marriage_allowance_properties.py +++ b/policyengine_uk/tests/test_marriage_allowance_properties.py @@ -73,6 +73,29 @@ married_couples_allowance=0.0, ) ] +# A low earner whose small lettings are within the property allowance. Full +# relief and an election save the couple 114; electing out of the allowance +# for the finance-cost reduction saves the low earner 132 but stops the +# election, costing the couple 120 more. The route must be weighed on the +# couple's tax (independent review of #1963, 12 Oct 2026). +SMALL_LETTINGS_COUPLE = [ + dict( + adults=[ + dict(age=40, employment_income=30_000.0), + dict( + age=38, + employment_income=12_000.0, + property_income=800.0, + property_rental_income=800.0, + property_finance_costs=200.0, + ), + ], + married=True, + children=[], + region="LONDON", + married_couples_allowance=0.0, + ) +] ELECTION = "makes_marriage_allowance_election" VARIABLES = [ ELECTION, @@ -213,6 +236,7 @@ def populations(draw): @PROPERTY_SETTINGS @given(units=couple_populations, year=st.sampled_from(YEARS)) @example(units=LANDLORD_COUPLE, year=2026) +@example(units=SMALL_LETTINGS_COUPLE, year=2026) def test_election_never_raises_the_couples_tax(units, year): model = calculate(units, year) none = calculate(units, year, election=np.zeros_like(model[ELECTION])) @@ -227,6 +251,7 @@ def test_election_never_raises_the_couples_tax(units, year): @PROPERTY_SETTINGS @given(units=couple_populations, year=st.sampled_from(YEARS)) @example(units=LANDLORD_COUPLE, year=2026) +@example(units=SMALL_LETTINGS_COUPLE, year=2026) def test_election_is_the_best_allowed_choice(units, year): model = calculate(units, year) none = calculate(units, year, election=np.zeros_like(model[ELECTION])) @@ -383,3 +408,39 @@ def test_only_married_couples_transfer(units, year): } spouse_units = set(model["benunit"][model["is_marriage_allowance_spouse"]]) assert spouse_units == married_pairs, units + + +def test_2015_transferable_amount_is_fixed_whatever_the_allowance(): + # ITA 2007 s. 55B(4)(a) fixes 2015-16 at 1,060; only later years take 10% + # of the s. 35(1) allowance. With the 2015 allowance raised to 15,000 the + # transfer is still 1,060: the transferor's allowance falls to 13,940 and + # the recipient on 30,000 pays (30,000 - 15,000) x 20% - 212 = 2,788. + year = 2015 + situation = { + "people": { + "recipient": {"age": {year: 40}, "employment_income": {year: 30_000}}, + "transferor": {"age": {year: 38}}, + }, + "benunits": { + "couple": { + "members": ["recipient", "transferor"], + "is_married": {year: True}, + } + }, + "households": {"home": {"members": ["recipient", "transferor"]}}, + } + sim = Simulation( + situation=situation, + reform={ + "gov.hmrc.income_tax.allowances.personal_allowance.amount": { + "2015-01-01.2015-12-31": 15_000 + } + }, + ) + np.testing.assert_allclose( + sim.calculate("marriage_allowance_transferable_amount", year), [1_060, 1_060] + ) + np.testing.assert_allclose( + sim.calculate("personal_allowance", year), [15_000, 13_940] + ) + np.testing.assert_allclose(sim.calculate("income_tax", year), [2_788, 0]) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py index a26dbec283..c5ee28febc 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_transferable_amount.py @@ -23,4 +23,6 @@ def formula(person, period, parameters): ma = allowances.marriage_allowance amount = allowances.personal_allowance.amount * ma.max increment = ma.rounding_increment - return np.ceil(amount / increment) * increment + share = np.ceil(amount / increment) * increment + # 2015-16 is a fixed amount (s. 55B(4)(a)), whatever the allowance. + return share if ma.fixed_amount == 0 else ma.fixed_amount diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py index 6e77ce8650..79563187d2 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/uses_property_allowance.py @@ -46,7 +46,9 @@ class uses_property_allowance(Variable): "two cannot be combined. Full relief applies to receipts within the " "allowance unless the person elects out of it, and partial relief " "only if they elect for it; each election is made only if it lowers " - "the person's own income tax, with everyone else's route held fixed. " + "the income tax of the person's benefit unit, with everyone else's " + "route held fixed. That is the person's own tax plus their spouse's, " + "which the route can move through the Marriage Allowance election. " "Ties keep the default, so a person with receipts within the " "allowance keeps full relief even where electing out would carry " "finance costs forward. Without finance costs to relieve, the " @@ -119,9 +121,14 @@ def formula(person, period, parameters): period, route & ~deciding, ) - # Tax is computed to the penny; smaller differences are - # rounding. - saving = np.round(tax_not_using - tax_using, 2) + # Weigh the benefit unit's tax: a route can change whether a + # spouse elects for the Marriage Allowance, which moves the + # other spouse's tax (#1947). Tax is computed to the penny; + # smaller differences are rounding. + saving = np.round( + person.benunit.sum(tax_not_using) - person.benunit.sum(tax_using), + 2, + ) prefers_allowance = where(full_relief, saving >= 0, saving > 0) decided = where(deciding, prefers_allowance, route) changed = changed or bool((decided != route).any()) From 10e6b338cd68bdab25f42410bdbfdf302f78b971 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 18:44:32 -0400 Subject: [PATCH 22/30] Test the Marriage Allowance rates on the bands main now taxes with Main's Gift Aid and adjusted net income changes (#2253 and the s. 58 rework) moved the pension relief into adjusted net income while allowances still deduct it, and made Gift Aid extend the rate limits instead of coming off income. The rate test took allowances off adjusted net income, so after the merge it deducted pension relief twice (a higher-rate pension saver passed s. 55B(2)(b)) and took the gross gift off income below dividends instead of extending the basic rate limit (a donor with dividends failed s. 55B(2)(ba)). It now reads non_savings_taxable_income, the base savings and dividend tax use, and extends the non-savings and dividend limits by the grossed-up gift (ITA 2007 s. 414(2)(b)), as earned_income_tax does. The election also holds net_income in its branches, so they do not rebuild it from the held adjusted net income through net_income_from_adjusted_net_income. Also drops the stale #1947 xfail allowlist entry (the xfail went with the fix) and the stale Marriage Allowance sentence in non_savings_taxable_income. Co-Authored-By: Claude Opus 5.5 --- .github/test_outcome_allowlist.toml | 6 -- .../allowances/marriage_allowance.yaml | 57 +++++++++++++++++ ..._marriage_allowance_income_conditions.yaml | 57 +++++++++++++++++ .../test_marriage_allowance_properties.py | 61 +++++++++++++++++++ .../makes_marriage_allowance_election.py | 9 ++- ...ts_marriage_allowance_income_conditions.py | 33 +++++----- .../non_savings_taxable_income.py | 8 +-- 7 files changed, 203 insertions(+), 28 deletions(-) diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index 9206674edc..6eeabeb2f1 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -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" diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index 0f27e9389c..4f7791b907 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -849,3 +849,60 @@ uses_property_allowance: [false, true] makes_marriage_allowance_election: [false, true] income_tax: [3_234, 138] + +- name: A recipient whose earnings after pension relief reach the higher rate cannot gain (s. 55B(2)(b)) + period: 2026 + input: + people: + recipient: + age: 40 + employment_income: 60_000 + employee_pension_contributions: 5_000 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: LONDON + output: + # 60,000 less 5,000 of relieved contributions (FA 2004 s. 188) and the + # 12,570 allowance leaves 42,430, 4,730 of it at the higher rate: + # 37,700 x 20% + 4,730 x 40% = 9,432. So no election. + meets_marriage_allowance_income_conditions: [false, true] + makes_marriage_allowance_election: [false, false] + marriage_allowance: [0, 0] + income_tax: [9_432, 0] + +- name: A Gift Aid donor's dividends within the extended basic rate limit let them gain (s. 55B(2)(ba), ITA 2007 s. 414(2)(b)) + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 12_570 + dividend_income: 40_000 + gift_aid: 3_200 + transferor: + age: 38 + benunits: + benunit: + members: [recipient, transferor] + is_married: true + households: + household: + members: [recipient, transferor] + region: LONDON + output: + # The 3,200 gift grosses up to 4,000 and extends the basic rate limit to + # 41,700, so all 40,000 of dividends (above earnings that use the whole + # allowance) are within it: (40,000 - 500 dividend allowance) x 10.75% = + # 4,246.25, less 20% x 1,260 = 252 from the transferor's election. + meets_marriage_allowance_income_conditions: [true, true] + makes_marriage_allowance_election: [false, true] + marriage_allowance: [1_260, 0] + income_tax: [3_994.25, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index bc8c4b2cf2..b39981a534 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -170,3 +170,60 @@ dividend_income: 201 output: meets_marriage_allowance_income_conditions: false + +- name: Pension relief comes off income once, so income at the basic rate limit after it meets the conditions (s. 55B(2)(b)) + period: 2026 + input: + # Net pay arrangement: 55,270 of earnings less 5,000 of relieved + # contributions (FA 2004 s. 188) and the 12,570 allowance leaves 37,700, + # all at the basic rate. + employment_income: 55_270 + employee_pension_contributions: 5_000 + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound above the basic rate limit after pension relief fails the conditions (s. 55B(2)(b)) + period: 2026 + input: + # 55,271 - 5,000 - 12,570 = 37,701, so 1 is at the higher rate. Adjusted + # net income (50,271) already has the relief deducted (ITA 2007 s. 58), + # so taking the allowances, which include the relief, off it would + # deduct the relief twice. + employment_income: 55_271 + employee_pension_contributions: 5_000 + output: + meets_marriage_allowance_income_conditions: false + +- name: Gift Aid extends the basic rate limit, so income up to the extended limit meets the conditions (ITA 2007 s. 414(2)(b)) + period: 2026 + input: + # A 1,600 gift grosses up to 2,000 (s. 415), extending the basic rate + # limit to 39,700. 52,270 - 12,570 = 39,700, all at the basic rate. + employment_income: 52_270 + gift_aid: 1_600 + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound above the Gift Aid-extended basic rate limit fails the conditions (ITA 2007 s. 414(2)(b)) + period: 2026 + input: + # 52,271 - 12,570 = 39,701, 1 above the extended limit of 39,700. + employment_income: 52_271 + gift_aid: 1_600 + output: + meets_marriage_allowance_income_conditions: false + +- name: Gift Aid extends the basic rate limit for the dividend test too (s. 55B(2)(ba), ITA 2007 s. 414(2)(b)) + period: 2026 + input: + # Earnings of 12,570 use the whole allowance, so 40,000 of dividends run + # from 0 to 40,000 of taxable income. A 3,200 gift grosses up to 4,000, + # extending the basic rate limit to 41,700, so even without the dividend + # nil rate (s. 13A) none would be at the dividend upper rate. Taking the + # gross gift off adjusted net income instead leaves nothing below the + # dividends to absorb it. + employment_income: 12_570 + dividend_income: 40_000 + gift_aid: 3_200 + output: + meets_marriage_allowance_income_conditions: true diff --git a/policyengine_uk/tests/test_marriage_allowance_properties.py b/policyengine_uk/tests/test_marriage_allowance_properties.py index 142cf86e82..6418e09e71 100644 --- a/policyengine_uk/tests/test_marriage_allowance_properties.py +++ b/policyengine_uk/tests/test_marriage_allowance_properties.py @@ -25,6 +25,11 @@ 5. The couple's income tax, with the election the model makes, never falls when either spouse has more income. 6. No one outside a married couple gives up or receives anything. +7. Differential against the tax bands: outside Scotland, with no dividends or + property income, a person meets the income conditions exactly when the + model's own band variables put none of their income above the basic rate + limit, with pension relief and Gift Aid (which extends the limits, ITA + 2007 s. 414(2)(b)) in play. """ import numpy as np @@ -410,6 +415,62 @@ def test_only_married_couples_transfer(units, year): assert spouse_units == married_pairs, units +# Incomes the rate test and the band variables both see. Dividends (tested as +# if the dividend nil rate were omitted, s. 55B(2)(ba)) and property income +# (banded inside property_income_tax) have no band variables to compare with. +BANDED_INCOMES = { + "employment_income": 160_000, + "private_pension_income": 30_000, + "savings_interest_income": 20_000, + # Net pay arrangement and relief at source (FA 2004 ss. 188-192). + "employee_pension_contributions": 20_000, + "personal_pension_contributions": 10_000, + "gift_aid": 10_000, +} +ABOVE_BASIC_RATE = [ + "higher_rate_earned_income", + "add_rate_earned_income", + "higher_rate_savings_income", + "add_rate_savings_income", +] +# At the basic rate limit after pension relief, and 1 above it. +PENSION_SAVERS = [ + dict(employment_income=55_270.0, employee_pension_contributions=5_000.0), + dict(employment_income=55_271.0, employee_pension_contributions=5_000.0), +] + + +@st.composite +def taxpayers(draw): + return {variable: draw(amounts(cap)) for variable, cap in BANDED_INCOMES.items()} + + +@PROPERTY_SETTINGS +@given( + people=st.lists(taxpayers(), min_size=1, max_size=20), year=st.sampled_from(YEARS) +) +@example(people=PENSION_SAVERS, year=2026) +def test_income_conditions_match_the_tax_bands(people, year): + # The rate test (s. 55B(2)(b), s. 55C(1)(c)) must use the income the tax + # bands use (net income less allowances, so pension relief once) and the + # same Gift Aid-extended limits. + situation = {"people": {}, "benunits": {}, "households": {}} + for i, incomes in enumerate(people): + person = {variable: {year: value} for variable, value in incomes.items()} + situation["people"][f"p{i}"] = {"age": {year: 40}, **person} + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = { + "members": [f"p{i}"], + "region": {year: "LONDON"}, + } + sim = Simulation(situation=situation) + above_basic = sum(np.asarray(sim.calculate(v, year)) for v in ABOVE_BASIC_RATE) + meets = np.asarray( + sim.calculate("meets_marriage_allowance_income_conditions", year) + ) + np.testing.assert_array_equal(meets, above_basic <= 0, err_msg=str(people)) + + def test_2015_transferable_amount_is_fixed_whatever_the_allowance(): # ITA 2007 s. 55B(4)(a) fixes 2015-16 at 1,060; only later years take 10% # of the s. 35(1) allowance. With the 2015 allowance raised to 15,000 the diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py index de1543e4ef..3fac6555d7 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py @@ -9,12 +9,15 @@ # Inputs to income tax that do not depend on the election. They are resolved # before branching and held at the simulation's values in both branches, so # the result does not depend on which variable a caller asks for first, and a -# choice made in branches upstream of them (a reform's split of adjusted net -# income, or a labour supply response in employment income) is not made again -# for the counterfactual. +# choice made in branches upstream of them (a reform's split of net income, or +# a labour supply response in employment income) is not made again for the +# counterfactual. Net income is held as well as adjusted net income: a branch +# holding only adjusted net income would read it as a supplied input and +# rebuild net income from it (net_income_from_adjusted_net_income). INDEPENDENT_OF_ELECTION = [ "age", "employment_income", + "net_income", "adjusted_net_income", "gift_aid_grossed_up", "gift_aid", diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index 00a89c6c59..2063b8b51d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -12,35 +12,37 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): savings higher rate. Dividends covered by the dividend nil rate still count: s. 55B(2)(ba) and s. 55C(1)(ca) test the dividend upper rate as if s. 13A were omitted. + + The bands are the ones the model taxes with: Gift Aid extends the rate + limits by the grossed-up gift (s. 414(2)(b)), as in earned_income_tax, + and does not come off the income. """ rates = parameters(period).gov.hmrc.income_tax.rates - savings = person("taxable_savings_interest_income", period) - dividends = person("taxable_dividend_income", period) + gross_gift = person("gift_aid_grossed_up", period) # Earnings, pensions, property and other non-savings income after - # allowances, as in property_income_tax. - non_savings = max_( - 0, - person("adjusted_net_income", period) - - savings - - dividends - - person("allowances", period), - ) + # allowances: the income below savings and dividends (s. 16), from net + # income, not adjusted net income, which deducts the gross gift and the + # pension relief already in allowances (s. 58). + non_savings = person("non_savings_taxable_income", period) # The Scottish higher rate follows the starter, basic and intermediate # rates; before 2018-19 the Scottish scale had only the basic rate below it. scottish = rates.scotland.rates scottish_higher = scottish.thresholds[3 if len(scottish.rates) > 3 else 1] non_savings_limit = where( person("pays_scottish_income_tax", period), - scottish_higher, - rates.uk.thresholds[1], + scottish_higher + gross_gift, + rates.uk.thresholds[1] + gross_gift, ) savings_above_basic = add( person, period, ["higher_rate_savings_income", "add_rate_savings_income"] ) # Dividends are the top slice, above all non-savings income (property # included) and savings (s. 16), counted before the dividend allowance; - # the dividend upper rate starts at the basic rate limit (s. 13). The - # savings test above uses the model's own savings bands. + # the dividend upper rate starts at the basic rate limit (s. 13), extended + # by the gross gift. The savings test above uses the model's own savings + # bands, which Gift Aid extends too. + savings = person("taxable_savings_interest_income", period) + dividends = person("taxable_dividend_income", period) below_dividends = non_savings + max_( 0, savings - person("received_allowances_savings_income", period) ) @@ -48,7 +50,8 @@ def liable_only_at_marriage_allowance_rates(person, period, parameters): 0, dividends - person("received_allowances_dividend_income", period) ) dividends_above_basic = (dividends_after_allowances > 0) & ( - below_dividends + dividends_after_allowances > rates.uk.thresholds[1] + below_dividends + dividends_after_allowances + > rates.uk.thresholds[1] + gross_gift ) return ( (non_savings <= non_savings_limit) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py index 8e1d2253a4..c95668545c 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py @@ -12,10 +12,10 @@ class non_savings_taxable_income(Variable): "earned_taxable_income leaves property income out because " "property_income_tax taxes it separately, but property income still " "fills the bands before savings and dividends do (from 2027-28, " - "s. 16A places it immediately below savings income). With no Marriage " - "Allowance it equals non_savings_income_after_allowances; " - "earned_taxable_income also subtracts Marriage Allowance, which the " - "law gives as a tax reduction (#1947)." + "s. 16A places it immediately below savings income). It equals " + "non_savings_income_after_allowances. The Marriage Allowance does not " + "come off it: the law gives it as a tax reduction " + "(marriage_allowance_tax_reduction)." ) definition_period = YEAR reference = [ From d75ab7ac32c884a38f435ac57e04963564104b3f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 05:01:20 -0400 Subject: [PATCH 23/30] Give the Marriage Allowance property fixture explicit claimant and partner roles From the independent review of 10e6b338c: the fixture left is_claimant_or_partner to inference from ages, which can read a married couple aged 18 and 100 as parent and child, so neither is a spouse and test_only_married_couples_transfer failed on a valid draw. Adults are now claimant or partner and children are not, as in the other fixtures, and that couple is pinned as an example. Co-Authored-By: Claude Opus 5.5 --- .../test_marriage_allowance_properties.py | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_marriage_allowance_properties.py b/policyengine_uk/tests/test_marriage_allowance_properties.py index 6418e09e71..ce2ab65f2a 100644 --- a/policyengine_uk/tests/test_marriage_allowance_properties.py +++ b/policyengine_uk/tests/test_marriage_allowance_properties.py @@ -162,7 +162,12 @@ def situation(units, year, election=None, bump=None): for i, unit in enumerate(units): names = [] for j, adult in enumerate(unit["adults"]): - person = {"state_pension": {year: 0.0}} + # The roles are given, not inferred: inference from ages alone + # can read a couple aged 18 and 100 as parent and child. + person = { + "state_pension": {year: 0.0}, + "is_claimant_or_partner": {year: True}, + } for variable, value in adult.items(): person[variable] = {year: value} if j == 0: @@ -174,7 +179,10 @@ def situation(units, year, election=None, bump=None): people[f"p{i}_{j}"] = person names.append(f"p{i}_{j}") for k, age in enumerate(unit["children"]): - people[f"c{i}_{k}"] = {"age": {year: age}} + people[f"c{i}_{k}"] = { + "age": {year: age}, + "is_claimant_or_partner": {year: False}, + } names.append(f"c{i}_{k}") benunits[f"b{i}"] = { "members": names, @@ -394,8 +402,30 @@ def test_couples_tax_never_falls_with_more_income(case, year): ), (bump, units) +# A married couple aged 18 and 100 with no income: the review's +# counterexample when roles were inferred from ages. +COUPLE_18_AND_100 = [ + dict( + adults=[ + dict( + age=18, + **{ + variable: 0.0 for variable in INCOMES if variable != "state_pension" + }, + ), + dict(age=100, **{variable: 0.0 for variable in INCOMES}), + ], + married=True, + children=[], + region=REGIONS[0], + married_couples_allowance=0.0, + ) +] + + @PROPERTY_SETTINGS @given(units=couple_populations, year=st.sampled_from(YEARS)) +@example(units=COUPLE_18_AND_100, year=2026) def test_only_married_couples_transfer(units, year): model = calculate(units, year) outside = ~model["is_marriage_allowance_spouse"] From 00bf62a81008d3439ae8a43e1c4cf32412ab4ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:18:16 +0100 Subject: [PATCH 24/30] Fix pension precision and review regressions --- policyengine_uk/data/fiscal_partition.yaml | 6 +- .../test_pension_credit_notional_class2.py | 18 ++-- .../tests/test_state_pension_components.py | 96 +++++++++++++++++++ .../variables/gov/dwp/basic_state_pension.py | 7 +- .../variables/gov/dwp/new_state_pension.py | 7 +- 5 files changed, 123 insertions(+), 11 deletions(-) diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 3ae9f9fef0..f97877f424 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -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 @@ -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 diff --git a/policyengine_uk/tests/test_pension_credit_notional_class2.py b/policyengine_uk/tests/test_pension_credit_notional_class2.py index 9e177477da..0a6641d658 100644 --- a/policyengine_uk/tests/test_pension_credit_notional_class2.py +++ b/policyengine_uk/tests/test_pension_credit_notional_class2.py @@ -49,9 +49,13 @@ def test_other_trade_loss_keeps_protected_couple_notional_class2(): "claimant": { "age": {2023: 70}, "attendance_allowance": {2023: 3_000}, + "marital_status": {2023: "MARRIED"}, + "would_claim_marriage_allowance": {2023: False}, }, "partner": { "age": {2023: 60}, + "marital_status": {2023: "MARRIED"}, + "would_claim_marriage_allowance": {2023: False}, "dla_sc_category": {2023: "MIDDLE"}, "self_employment_income": {2023: 20_000}, "trading_loss": {2023: loss}, @@ -60,6 +64,7 @@ def test_other_trade_loss_keeps_protected_couple_notional_class2(): "benunits": { "couple": { "members": ["claimant", "partner"], + "is_married": {2023: True}, "has_mixed_age_couple_pension_credit_saving": {2023: True}, "would_claim_pc": {2023: True}, } @@ -72,12 +77,13 @@ def test_other_trade_loss_keeps_protected_couple_notional_class2(): assert actual_class2 == pytest.approx(0 if loss else 179.40, abs=0.01) # CER 13(12) prohibits another employment's loss reducing the # £20,000 positive profit. Notional deductions are therefore - # (£20,000 - £12,570 PA - £1,260 marriage allowance) * 20% = - # £1,234 tax; plus £668.70 Class 4 and £179.40 Class 2 = £2,082.10. + # (£20,000 - £12,570 PA) * 20% = £1,486 tax, with Marriage Allowance + # takeup explicitly off to isolate the notional-Class-2 property; + # plus £668.70 Class 4 and £179.40 Class 2 = £2,334.10. deductions = simulation.calculate( "pension_credit_self_employment_deductions", 2023 )[1] - assert deductions == pytest.approx(2_082.10, abs=0.01) + assert deductions == pytest.approx(2_334.10, abs=0.01) results.append( ( deductions, @@ -88,9 +94,9 @@ def test_other_trade_loss_keeps_protected_couple_notional_class2(): # SI 2023/316 art.30(2)(a), (d): the couple guarantee plus the higher # severe-disability addition is (£306.85 + £152.80) * 52 = £23,901.80. # https://www.legislation.gov.uk/uksi/2023/316/article/30/made - # PC income = £20,000 - £2,082.10 deductions - £1,040 disregard = - # £16,877.90; the award is £23,901.80 - £16,877.90 = £7,023.90. - assert results[0] == pytest.approx((2_082.10, 16_877.90, 7_023.90), abs=0.01) + # PC income = £20,000 - £2,334.10 deductions - £1,040 disregard = + # £16,625.90; the award is £23,901.80 - £16,625.90 = £7,275.90. + assert results[0] == pytest.approx((2_334.10, 16_625.90, 7_275.90), abs=0.01) assert results[1] == pytest.approx(results[0], abs=0.01) diff --git a/policyengine_uk/tests/test_state_pension_components.py b/policyengine_uk/tests/test_state_pension_components.py index 7586378f28..40bba3e3c5 100644 --- a/policyengine_uk/tests/test_state_pension_components.py +++ b/policyengine_uk/tests/test_state_pension_components.py @@ -839,3 +839,99 @@ def test_dated_reform_after_the_data_year_is_unchanged_by_the_baseline_split(): assert calculate(sim, "state_pension", 2027)[0] == approx( expected["flat"] + expected["additional"] ) + + +@pytest.mark.parametrize("composition", ["singletons", "married_pairs"]) +@pytest.mark.parametrize("cap", [2.261527994036541e-210, 0.0], ids=["tiny", "zero"]) +def test_tiny_new_cap_constructor_preserves_public_components(cap, composition): + """A finite cap must stay finite through public counterfactual construction. + + These are the original finite records from the rate-reform property failure, + also paired into lawful married benefit units. All are below State Pension + age; changing the flat rate must leave the seven-year add-on oracle intact. + """ + people = [ + {"age": 55, "weekly": 200.0, "male": True, "months": 1.5848689724912486e-117}, + {"age": 58, "weekly": 0.0, "male": True, "months": 0.0}, + {"age": 55, "weekly": 0.0, "male": False, "months": 0.0}, + {"age": 55, "weekly": 0.0, "male": False, "months": 0.0}, + ] + + def dataset(): + person = person_table(people) + ids = np.arange(len(people)) + if composition == "married_pairs": + person["person_benunit_id"] = [0, 0, 1, 1] + person["person_household_id"] = [0, 0, 1, 1] + person["marital_status"] = "MARRIED" + ids = np.arange(2) + benunit = pd.DataFrame({"benunit_id": ids}) + if composition == "married_pairs": + benunit["is_married"] = True + household = pd.DataFrame( + { + "household_id": ids, + "household_weight": np.ones(len(ids)), + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ) + return UKSingleYearDataset( + person=person, benunit=benunit, household=household, fiscal_year=DATA_YEAR + ) + + baseline = Microsimulation(dataset=dataset()) + reform = { + BSP: {"2000-01-01.2100-12-31": 0.0}, + NSP: {"2000-01-01.2100-12-31": cap}, + } + reformed = Microsimulation(dataset=dataset(), reform=reform) + for year in YEARS: + assert np.allclose( + calculate(reformed, "additional_state_pension", year), + calculate(baseline, "additional_state_pension", year), + rtol=1e-12, + atol=1e-9, + ), year + for variable in COMPONENTS + ["state_pension", "net_income"]: + assert np.all(np.isfinite(calculate(reformed, variable, year))), variable + assert np.all(calculate(reformed, "state_pension", year) == 0) + spouses = calculate(reformed, "is_marriage_allowance_spouse", year) + assert np.all(spouses == (composition == "married_pairs")) + + +@pytest.mark.parametrize("cap", [2.261527994036541e-210, 0.0], ids=["tiny", "zero"]) +def test_tiny_basic_cap_constructor_preserves_positive_add_on(cap): + """A BASIC pensioner's positive add-on survives a tiny or zero flat rate. + + The £80.50 a week above the legislated 2024 basic rate follows September + CPI, including when a finite reform cap is below float32's range. + """ + person = {"age": 90, "male": True, "months": 3, "weekly": 250} + baseline = projected_simulation([person]) + reformed = Microsimulation( + dataset=year_dataset([person], DATA_YEAR), + reform={BSP: {"2000-01-01.2100-12-31": cap}}, + ) + for year in YEARS: + additional = calculate(reformed, "additional_state_pension", year) + assert np.all(additional > 0) + assert np.allclose( + additional, + calculate(baseline, "additional_state_pension", year), + rtol=1e-12, + atol=1e-9, + ), year + assert additional[0] == approx( + (250 - LEGISLATED[(DATA_YEAR, "BASIC")]) + * WEEKS_IN_YEAR + * add_on_uprating(reformed, year) + ) + for variable in COMPONENTS + ["state_pension", "net_income"]: + assert np.all(np.isfinite(calculate(reformed, variable, year))), variable + assert calculate(reformed, "new_state_pension", year)[0] == 0 + assert calculate(reformed, "basic_state_pension", year)[0] == pytest.approx( + cap * WEEKS_IN_YEAR, abs=0.01 + ) diff --git a/policyengine_uk/variables/gov/dwp/basic_state_pension.py b/policyengine_uk/variables/gov/dwp/basic_state_pension.py index 48ac56fb13..0f3a5a7143 100644 --- a/policyengine_uk/variables/gov/dwp/basic_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/basic_state_pension.py @@ -21,7 +21,10 @@ def formula(person, period, parameters): else: data_year = period.start.year - reported = person("state_pension_reported", data_year) / WEEKS_IN_YEAR + reported = ( + person("state_pension_reported", data_year).astype(np.float64) + / WEEKS_IN_YEAR + ) pension_type = person("state_pension_type", period) # state_pension_reported was observed under the legislated rates, so # split it at the baseline's data-year rate, not the reformed one. @@ -36,7 +39,7 @@ def formula(person, period, parameters): if baseline is not None else parameters ).gov.dwp.state_pension - max_sp_data_year = legislated.basic_state_pension.amount(data_year) + max_sp_data_year = np.float64(legislated.basic_state_pension.amount(data_year)) max_sp_period = parameters.gov.dwp.state_pension.basic_state_pension.amount( period ) diff --git a/policyengine_uk/variables/gov/dwp/new_state_pension.py b/policyengine_uk/variables/gov/dwp/new_state_pension.py index 69c9a3bfce..a68da8a353 100644 --- a/policyengine_uk/variables/gov/dwp/new_state_pension.py +++ b/policyengine_uk/variables/gov/dwp/new_state_pension.py @@ -23,7 +23,10 @@ def formula(person, period, parameters): pension_type = person("state_pension_type", period) eligible = pension_type == pension_type.possible_values.NEW - reported_weekly = person("state_pension_reported", data_year) / WEEKS_IN_YEAR + reported_weekly = ( + person("state_pension_reported", data_year).astype(np.float64) + / WEEKS_IN_YEAR + ) # state_pension_reported was observed under the legislated rates, so # split it at the baseline's data-year rate, not the reformed one. # Otherwise a reform that also sets the data year's rate (an undated @@ -37,7 +40,7 @@ def formula(person, period, parameters): if baseline is not None else parameters ).gov.dwp.state_pension - max_new_data_year = legislated.new_state_pension.amount(data_year) + max_new_data_year = np.float64(legislated.new_state_pension.amount(data_year)) max_new_period = parameters.gov.dwp.state_pension.new_state_pension.amount( period ) From c4cfc9489becb74c616e1f988fef4377793a8b46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:27:23 +0100 Subject: [PATCH 25/30] Improve CI guard diagnostics and shard coverage --- .github/workflows/test-suite.yaml | 8 +- changelog.d/1963.fixed.md | 1 + docs/engineering/skills/testing.md | 4 +- .../test_aggregate_impact_certified.py | 7 +- .../test_aggregate_impact_measure.py | 155 ++++++++++++++++++ .../tests/code_health/test_ci_test_shards.py | 36 ++++ .../utils/aggregate_impact/measure.py | 41 +++++ 7 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 changelog.d/1963.fixed.md diff --git a/.github/workflows/test-suite.yaml b/.github/workflows/test-suite.yaml index dddd56c9d2..648605d5d5 100644 --- a/.github/workflows/test-suite.yaml +++ b/.github/workflows/test-suite.yaml @@ -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 @@ -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 diff --git a/changelog.d/1963.fixed.md b/changelog.d/1963.fixed.md new file mode 100644 index 0000000000..446c627935 --- /dev/null +++ b/changelog.d/1963.fixed.md @@ -0,0 +1 @@ +- Made counted-once guard failures show only validated public diagnostics and increased source and wheel test shards after one-hour timeouts. diff --git a/docs/engineering/skills/testing.md b/docs/engineering/skills/testing.md index 9e4df81565..285f9e692c 100644 --- a/docs/engineering/skills/testing.md +++ b/docs/engineering/skills/testing.md @@ -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 diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py index 06f9baedcd..2a31dec884 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py @@ -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, @@ -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) diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py index 20636f0dad..a711676fee 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_measure.py @@ -1096,3 +1096,158 @@ def guard(units): assert shown in text and "NO LONGER contained" in text if units == 3: assert "0.43" not in text + + +def test_certified_guard_failure_message_excludes_private_fields(): + """A failed invariant stays failed, with no raw survey detail in its message.""" + from policyengine_uk.tests.aggregate_impact import test_aggregate_impact_certified + from policyengine_uk.utils.aggregate_impact.partition import ( + default_partition_path, + load_partition, + ) + + partition = load_partition(default_partition_path()) + row = { + "variable": partition.tax_heads[0].variable, + "status": "fail", + "problems": [ + { + "total": "gov_tax", + "head": "income_tax", + "kind": "own_head", + "households": 3, + "where": np.array([True, False]), + "share": 0.123456, + } + ], + "detail": "SECRET_C4_household_amount", + "household_id": "SECRET_C4_household_id", + "responses": {"SECRET_C4_response": 1_234.5}, + } + measured = ( + { + "guard": { + "status": "ran", + "reset_reproduces_baseline": True, + "components": [row], + } + }, + None, + partition, + None, + ) + with pytest.raises(AssertionError) as error: + test_aggregate_impact_certified.test_every_tax_component_is_counted_exactly_once( + measured + ) + message = str(error.value) + assert "SECRET_C4" not in message + assert "income_tax" in message and "own_head" in message + assert "where" not in message and "share" not in message + + +@pytest.mark.parametrize( + "slot", ["variable", "status", "total", "head", "kind", "households"] +) +def test_guard_component_diagnostic_rejects_untrusted_slot_values(slot): + """An allowed key cannot smuggle an object or an arbitrary private string.""" + import json + + from policyengine_uk.utils.aggregate_impact.measure import ( + component_guard_diagnostic, + ) + from policyengine_uk.utils.aggregate_impact.partition import ( + default_partition_path, + load_partition, + ) + + class Unprintable: + def __str__(self): + raise AssertionError("SECRET_C4_str_was_called") + + __repr__ = __str__ + __hash__ = __str__ + + def __eq__(self, other): + raise AssertionError("SECRET_C4_eq_was_called") + + def __int__(self): + raise AssertionError("SECRET_C4_int_was_called") + + class UntrustedString(str): + def __str__(self): + raise AssertionError("SECRET_C4_subclass_was_printed") + + partition = load_partition(default_partition_path()) + for value in ("SECRET_C4", Unprintable(), UntrustedString("income_tax")): + problem = { + "total": "gov_tax", + "head": "income_tax", + "kind": "own_head", + "households": 10, + } + row = {"variable": "income_tax", "status": "fail", "problems": [problem]} + if slot in ("variable", "status"): + row[slot] = value + else: + problem[slot] = value + diagnostic = component_guard_diagnostic(row, partition) + rendered = json.dumps(diagnostic) + assert "SECRET_C4" not in rendered + field = ( + diagnostic[slot] + if slot in ("variable", "status") + else diagnostic["problems"][0][slot] + ) + assert field == (None if slot == "households" else "unknown") + + +@pytest.mark.parametrize( + "count,shown", + [ + (0, 0), + (3, None), + (9, None), + (10, 10), + (100, 100), + (None, None), + (True, None), + (3.5, None), + ], +) +def test_guard_component_diagnostic_preserves_suppressed_counts(count, shown): + from policyengine_uk.utils.aggregate_impact.measure import ( + component_guard_diagnostic, + ) + from policyengine_uk.utils.aggregate_impact.partition import ( + default_partition_path, + load_partition, + ) + + partition = load_partition(default_partition_path()) + row = { + "variable": "income_tax", + "status": "fail", + "units": 100, + "problems": [ + { + "total": "gov_tax", + "head": "income_tax", + "kind": "own_head", + "households": count, + } + ], + } + diagnostic = component_guard_diagnostic(row, partition) + assert diagnostic == { + "variable": "income_tax", + "status": "fail", + "problems": [ + { + "total": "gov_tax", + "head": "income_tax", + "kind": "own_head", + "households": shown, + } + ], + } diff --git a/policyengine_uk/tests/code_health/test_ci_test_shards.py b/policyengine_uk/tests/code_health/test_ci_test_shards.py index 9a91babf72..15b17e2312 100644 --- a/policyengine_uk/tests/code_health/test_ci_test_shards.py +++ b/policyengine_uk/tests/code_health/test_ci_test_shards.py @@ -888,3 +888,39 @@ def test_the_release_tells_policyengine_py_only_after_pypi_has_it(): assert "repos/PolicyEngine/policyengine.py/dispatches" in run assert "event_type=policyengine-uk-release" in run assert "client_payload[version]=$VERSION" in run + + +@pytest.mark.parametrize("dist_artifact", ["", "release-dist"]) +def test_configured_source_and_wheel_shards_account_for_every_node( + conftest, dist_artifact +): + """The two configured runner budgets retain the same strict coverage audit.""" + import yaml + + workflow = yaml.safe_load( + (GITHUB_DIR / "workflows" / "test-suite.yaml").read_text() + ) + matrix = workflow["jobs"]["Python"]["strategy"]["matrix"]["shard"] + + # The shared matrix is the runner set for both source and wheel; N is + # derived from the actual configured jobs, never a second fixed count. + selected = matrix + assert isinstance(selected, list) and selected + assert selected == list(range(1, len(selected) + 1)) + collected = [f"tests/public_synthetic.py::test_case[{i}]" for i in range(200)] + shards = [ + [ + checker.Record(nodeid, "passed") + for nodeid in collected + if conftest.shard_of(nodeid, len(selected)) == number + ] + for number in selected + ] + mode = "wheel" if dist_artifact else "source" + assert checker.check(collected, shards, mode=mode, data="present").problems == [] + populated = next(cases for cases in shards if cases) + missing = [cases for cases in shards if cases is not populated] + assert checker.check(collected, missing, mode=mode, data="present").problems + assert checker.check( + collected, [*shards, populated], mode=mode, data="present" + ).problems diff --git a/policyengine_uk/utils/aggregate_impact/measure.py b/policyengine_uk/utils/aggregate_impact/measure.py index 192a512422..fd59ac060a 100644 --- a/policyengine_uk/utils/aggregate_impact/measure.py +++ b/policyengine_uk/utils/aggregate_impact/measure.py @@ -1205,6 +1205,47 @@ def publishable_guard(guard: dict) -> dict: return out +def component_guard_diagnostic(row: Mapping, partition: Partition) -> dict: + """A failed component's public identifiers and suppressed aggregate counts. + + This is an assertion message, not a change to the guard's verdict. Only + plain strings from the trusted partition and fixed enums may be printed; + values from unrecognised types are never coerced or represented. + """ + components = set(partition.component_variables()) + totals = set(partition.totals) + heads = {head.variable for head in partition.heads} | totals + + def public_identifier(value, allowed): + return value if type(value) is str and value in allowed else "unknown" + + def public_count(value): + # counted_once creates built-in integer counts; reject booleans, + # subclasses and other objects without invoking their conversions. + if type(value) is not int or value < 0 or small(value): + return None + return value + + row = row if type(row) is dict else {} + problems = row.get("problems") + problems = problems if type(problems) in (list, tuple) else () + kinds = {"own_head", "contains", "moves", "rises", "model_total"} + return { + "variable": public_identifier(row.get("variable"), components), + "status": public_identifier(row.get("status"), {"pass", "fail", "skipped"}), + "problems": [ + { + "total": public_identifier(problem.get("total"), totals), + "head": public_identifier(problem.get("head"), heads), + "kind": public_identifier(problem.get("kind"), kinds), + "households": public_count(problem.get("households")), + } + for problem in problems + if type(problem) is dict + ], + } + + def _finite_metrics_check(rec: Recorder): bad = [ f"{m['id']} {fiscal_year_label(int(year))}" From 8117a44624253b92fc7545d04e87eba7b4993c87 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 15:27:32 -0400 Subject: [PATCH 26/30] Supply the 70/45 couple's roles in the Marriage Allowance YAML case Main now presumes a member 20 or more years younger than the claimant is their child (613326be1), so the pensioner transferor case read the couple as parent and child and no one elected. Set is_claimant_or_partner for both, as the variable's documentation advises. Also tighten the comment on why the election compares tax before the winter fuel payment charge. Co-Authored-By: Claude Opus 5.5 --- .../hmrc/income_tax/allowances/marriage_allowance.yaml | 4 ++++ .../allowances/makes_marriage_allowance_election.py | 10 +++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml index 4f7791b907..5ce3a79dc1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance.yaml @@ -551,13 +551,17 @@ - name: Pensioner transferor with State Pension, as in the UC State Pension test period: 2026 input: + # A couple 25 years apart is read as a parent and adult child unless the + # roles are supplied (is_claimant_or_partner). people: pensioner: age: 70 state_pension: 12_000 + is_claimant_or_partner: true partner: age: 45 employment_income: 20_000 + is_claimant_or_partner: true benunits: benunit: members: [pensioner, partner] diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py index 9f631a6f67..d1e49f49d5 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py @@ -2,11 +2,11 @@ # The election is weighed on income tax before the winter fuel payment charge. # The charge is Step 7 tax on a person whose own total income (ITA 2007 s.23 -# Step 1) is over the threshold, which the election does not change; it can -# differ between the two positions only where the election's tax change ends -# or starts a relevant benefit (ITEPA 2003 s.681I(4)), and the election does -# not weigh effects on benefits. Leaving it out keeps the benefits that decide -# the charge out of these branches. +# Step 1) is over the threshold, which the election does not change. So it can +# differ between the two positions only through the benefits that the charge +# and the payment read (ITEPA 2003 s.681I(4)), and the election does not weigh +# effects on benefits. Leaving the charge out keeps benefits out of these +# branches. COMPARED_TAX = "income_tax_before_winter_fuel_payment_charge" BRANCH_OUTPUTS = [ COMPARED_TAX, From 21977b274c8739d40b5ade9bbedb79069ca44791 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 17:15:25 -0400 Subject: [PATCH 27/30] Align main's Marriage Allowance tests with the Step 6 election (#1947) Round-3 review of 8117a4462 found tests that still asserted the old mechanics: - CPS switch suite (#2553): the expanded allowance is a Step 6 reduction at the basic rate, and a CPS rate alone keeps the income condition. The removed eligible_bands parameter is no longer read. Monotonicity is on the transferable amount; a couple that stops electing as the rate rises is pinned as intended. Bands are judged a penny lower, since tax_band calls taxable income at the basic rate limit higher. - Pension Credit earnings oracle: the transferor's notional tax uses the allowance cut by s. 55B(6). - Statutory paternity pay: the recipient's tax is monotone only while the election is unchanged. - Marriage Allowance properties: tax before the winter fuel payment charge, which the election weighs; a couple whose election starts the charge is pinned. Co-Authored-By: Claude Opus 5.5 --- .../test_marriage_allowance_cps_switch.py | 110 ++++++++++++++---- .../test_marriage_allowance_properties.py | 74 +++++++++--- ...on_credit_earnings_disregard_properties.py | 63 ++++++++-- ...ory_paternity_pay_income_tax_properties.py | 72 +++++++++++- ...nsion_credit_self_employment_deductions.py | 10 +- 5 files changed, 270 insertions(+), 59 deletions(-) diff --git a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py index caeacd7ce2..057416e90d 100644 --- a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py +++ b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py @@ -24,12 +24,15 @@ one has a Marriage Allowance, and income tax is what it is at current law when no one claims the allowance. 3. With any statutory rate and the CPS parameters at their defaults, no one - in the higher or additional band meets the income conditions or has the + liable at the higher rate or above meets the income conditions or has the allowance, and no one has more than the statutory share of the personal allowance, rounded up to the next £10. -4. The allowance never falls when the statutory rate rises. -5. Switched on by its own parameters, the reform behaves as it did before - this change (regression values computed before the fix). +4. The transferable amount never falls when the statutory rate rises, and the + allowance is all of it or none. The allowance itself can fall: a couple + stops electing once giving up the larger allowance costs the elector more + than the partner gains (intended; ITA 2007 s. 55B(6)). +5. Switched on by its own parameters, the reform gives the expanded + allowance as a Step 6 tax reduction (hand-computed values below). """ import functools @@ -161,19 +164,20 @@ def test_a_higher_statutory_rate_raises_only_the_statutory_allowance(route): assert outcome(simulation) == ([2_520, 0, 0, 0], [2_982, 11_432]) -# Invariant 5. Each expectation was computed on main before this change -# (ecd531776); the reform's own parameters switched it on there too. The -# expanded allowance reaches the higher-rate earner. The model takes the -# allowance off taxable income, so it is worth 40% to them: 11,432 - 1,260 x -# 40% = 10,928 at 10%, and 11,432 - 2,520 x 40% = 10,424 at 20%. +# Invariant 5, by hand. The allowance is a Step 6 tax reduction at the basic +# rate (ITA 2007 s. 55B(1), (3)), whatever the recipient's own marginal rate. +# Lifting the income condition lets the higher-rate earner gain: 11,432 - +# 1,260 x 20% = 11,180. A CPS rate alone keeps the income condition, so it +# raises the basic-rate couple's transfer to 2,520 (3,486 - 504 = 2,982) and +# leaves the higher-rate earner at 11,432. OWN_SWITCHES = { "remove_income_condition": ( change((REMOVE_INCOME_CONDITION, True)), - ([1_260, 0, 1_260, 0], [3_234, 10_928]), + ([1_260, 0, 1_260, 0], [3_234, 11_180]), ), "cps_rate": ( change((CPS_RATE, 0.2)), - ([2_520, 0, 2_520, 0], [2_982, 10_424]), + ([2_520, 0, 0, 0], [2_982, 11_432]), ), } @@ -188,11 +192,12 @@ def test_the_reform_still_switches_on_from_its_own_parameters(route, switch): def test_a_cps_rate_switches_the_reform_on_whatever_the_statutory_rate(): - # The old switch missed this: the two rates were equal. + # The old switch missed this: the two rates were equal. The income + # condition stays, so only the basic-rate earner gains. reform = change((STATUTORY_RATE, 0.2), (CPS_RATE, 0.2)) simulation = Simulation(situation=ISSUE_COUPLES, reform=reform) assert REFORM_VARIABLE in simulation.tax_benefit_system.variables - assert outcome(simulation) == ([2_520, 0, 2_520, 0], [2_982, 10_424]) + assert outcome(simulation) == ([2_520, 0, 0, 0], [2_982, 11_432]) CHILD_CONDITION_COUPLES = couples( @@ -208,9 +213,9 @@ def test_couples_outside_the_child_condition_keep_the_statutory_allowance( statutory_rate, statutory_allowance ): # With a child condition, only couples with a child under 5 get the CPS - # rate and the higher bands. The rest keep the statutory allowance: the - # basic-rate earner without a child gets the statutory rate, and the - # higher-rate earner without a child gets nothing. + # rate. The rest keep the statutory allowance: the basic-rate earner + # without a child gets the statutory rate. remove_income_condition is off, + # so neither higher-rate earner gains, with a child or without. pairs = [(CPS_RATE, 0.2), (MAX_CHILD_AGE, 5)] if statutory_rate is not None: pairs.append((STATUTORY_RATE, statutory_rate)) @@ -220,7 +225,7 @@ def test_couples_outside_the_child_condition_keep_the_statutory_allowance( statutory_allowance, 2_520, 0, - 2_520, + 0, ] others = np.delete(np.arange(len(allowance)), CHILD_CONDITION_EARNERS) assert (allowance[others] == 0).all() @@ -281,8 +286,8 @@ def test_the_reform_switches_on_from_its_own_parameters_only( # parameters, reused: each example writes its own inputs. COUPLES = 20 -# Every fourth couple lives in Scotland, whose starter and intermediate bands -# are eligible bands. +# Every fourth couple lives in Scotland, whose starter and intermediate rates +# allow an election (ITA 2007 s. 55B(2)(b)). REGIONS = ["SCOTLAND", "LONDON", "NORTH_EAST", "WALES"] @@ -337,6 +342,10 @@ def simulation_for(*pairs, reform=None): st.booleans(), # the second would claim ) drawn_couples = st.lists(couple, min_size=COUPLES, max_size=COUPLES) +# The bands in which an earner with only employment income is liable at no +# rate above the basic, Scottish starter, basic or intermediate rates (ITA +# 2007 s. 55B(2)(b), s. 55C(1)(c)). +ELECTING_BANDS = ["NONE", "STARTER", "BASIC", "INTERMEDIATE"] # Statutory rates in whole thousandths. 12,570 x k / 1,000 = 12.57k is a # multiple of £10 only at k = 0 and 1,000, and otherwise at least a penny from # one, so float rounding cannot move the allowance across a £10 step and the @@ -368,7 +377,6 @@ def run(simulation, drawn, anyone_claims=True): simulation.set_input("is_married", YEAR, married) simulation.set_input("would_claim_marriage_allowance", YEAR, claims) simulation.reset_calculations() - p = simulation.tax_benefit_system.parameters(YEAR) return dict( allowance=simulation.calculate("marriage_allowance", YEAR), meets_income_conditions=simulation.calculate( @@ -376,7 +384,9 @@ def run(simulation, drawn, anyone_claims=True): ), # Simulation.calculate returns an enum's names. band=np.asarray(simulation.calculate("tax_band", YEAR), dtype=str), - eligible_bands=p.gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands, + transferable=simulation.calculate( + "marriage_allowance_transferable_amount", YEAR + ), income_tax=simulation.calculate("income_tax", YEAR), ) @@ -400,6 +410,24 @@ def test_a_zero_statutory_rate_is_no_allowance_for_anyone(simulation_for, drawn) np.testing.assert_array_equal(abolished["income_tax"], nobody_claims["income_tax"]) +def test_taxable_income_at_the_basic_rate_limit_still_meets_the_conditions(): + # £50,270 of earnings is £37,700 of taxable income, all at the basic rate, + # so the earner can gain; tax_band calls it higher. £50,271 is liable at + # the higher rate on £1. The same holds at the Scottish higher rate's + # start, £43,662 of earnings in 2025-26. + situation = couples((50_270, None), (50_271, None)) + simulation = Simulation(situation=situation) + assert simulation.calculate("marriage_allowance", YEAR).tolist() == [ + 1_260, + 0, + 0, + 0, + ] + assert np.asarray(simulation.calculate("tax_band", YEAR), dtype=str)[ + EARNERS + ].tolist() == ["HIGHER", "HIGHER"] + + @SETTINGS @given(drawn_couples, permille) def test_any_statutory_rate_keeps_the_statutory_conditions_and_cap( @@ -407,9 +435,18 @@ def test_any_statutory_rate_keeps_the_statutory_conditions_and_cap( ): simulation = simulation_for((STATUTORY_RATE, rate_permille / 1_000)) assert REFORM_VARIABLE not in simulation.tax_benefit_system.variables + # tax_band places taxable income exactly at a band's lower limit in that + # band, though none of it is charged at the band's rate (ITA 2007 s. 10). + # Someone still outside the electing bands a penny lower is liable above + # them. + penny_lower = [ + (max(first - 0.01, 0.0), max(second - 0.01, 0.0), *rest) + for first, second, *rest in drawn + ] + below = run(simulation, penny_lower) r = run(simulation, drawn) - outside = ~np.isin(r["band"], r["eligible_bands"]) - higher = np.isin(r["band"], ["HIGHER", "ADDITIONAL"]) + outside = ~np.isin(below["band"], ELECTING_BANDS) + higher = np.isin(below["band"], ["HIGHER", "ADDITIONAL"]) assert outside[higher].all() assert not r["meets_income_conditions"][outside].any() assert (r["allowance"][outside] == 0).all() @@ -419,13 +456,36 @@ def test_any_statutory_rate_keeps_the_statutory_conditions_and_cap( @settings(SETTINGS, max_examples=10) @given(drawn_couples, permille, st.sampled_from(GRID_PERMILLE)) -def test_the_allowance_never_falls_as_the_statutory_rate_rises( +def test_the_transferable_amount_never_falls_as_the_statutory_rate_rises( simulation_for, drawn, first_permille, second_permille ): lower, higher = sorted([first_permille, second_permille]) at_lower = run(simulation_for((STATUTORY_RATE, lower / 1_000)), drawn) at_higher = run(simulation_for((STATUTORY_RATE, higher / 1_000)), drawn) - assert (at_lower["allowance"] <= at_higher["allowance"]).all() + assert (at_lower["transferable"] <= at_higher["transferable"]).all() + for r in (at_lower, at_higher): + received = r["allowance"] > 0 + np.testing.assert_array_equal( + r["allowance"][received], r["transferable"][received] + ) + + +def test_a_couple_can_stop_electing_as_the_statutory_rate_rises(): + # Intended: the allowance is not monotone in the rate. Earnings of £20,000 + # and £12,000 (2025-26). At 10% the £12,000 earner gives up 1,260 of + # allowance, paying 20% x (12,000 - 11,310) = 138 for the partner's 252. + # At 100% they give up all 12,570 and would pay 2,400 for a reduction + # capped at the partner's tax, 20% x (20,000 - 12,570) = 1,486, so no one + # elects. + situation = couples((20_000, None)) + situation["people"]["partner_0"]["employment_income"] = {YEAR: 12_000} + at_ten = Simulation(situation=situation) + at_full = Simulation(situation=situation, reform=change((STATUTORY_RATE, 1.0))) + assert at_ten.calculate("marriage_allowance", YEAR).tolist() == [1_260, 0] + assert at_full.calculate("marriage_allowance", YEAR).tolist() == [0, 0] + assert at_full.calculate( + "marriage_allowance_transferable_amount", YEAR + ).tolist() == [12_570, 12_570] @SETTINGS diff --git a/policyengine_uk/tests/test_marriage_allowance_properties.py b/policyengine_uk/tests/test_marriage_allowance_properties.py index ce2ab65f2a..b59edc9282 100644 --- a/policyengine_uk/tests/test_marriage_allowance_properties.py +++ b/policyengine_uk/tests/test_marriage_allowance_properties.py @@ -4,7 +4,11 @@ by the transferable amount (s. 55B(6)) and gives the other a Step 6 tax reduction of the appropriate percentage of it, capped at their Step 5 tax (s. 55A(2), s. 55B(1), (3), s. 29(2)). The couple elects only when the -conditions allow it and it lowers their combined income tax. +conditions allow it and it lowers their combined income tax before the winter +fuel payment charge, which is what the election weighs: the charge can differ +between the two positions only through benefits, which the election does not +weigh (an intended modelling choice; WINTER_FUEL_COUPLE pins a case where +electing starts the charge). "Income tax" below means that tax. Invariants, for any generated population of couples with every kind of taxable income, some with residential finance costs, in England, Wales or @@ -102,6 +106,28 @@ ) ] ELECTION = "makes_marriage_allowance_election" +# The tax the election weighs (makes_marriage_allowance_election.COMPARED_TAX). +TAX = "income_tax_before_winter_fuel_payment_charge" +# A mixed-age couple with children (2026). Electing saves 114 before the +# charge, which ends their £1 of Universal Credit, a relevant benefit (ITEPA +# 2003 s. 681I(4)), so the elder's £200 winter fuel payment is charged back: +# income tax including the charge rises from 7,002.26 to 7,088.26. +WINTER_FUEL_COUPLE = [ + dict( + adults=[ + dict( + age=70, + employment_income=28_581.31818, + savings_interest_income=20_000.0, + ), + dict(age=45, employment_income=12_000.0), + ], + married=True, + children=[12, 8], + region="LONDON", + married_couples_allowance=0.0, + ) +] VARIABLES = [ ELECTION, "is_marriage_allowance_spouse", @@ -113,6 +139,7 @@ "marriage_allowance_tax_reduction_limit", "meets_marriage_allowance_income_conditions", "personal_allowance", + TAX, "income_tax", ] TOLERANCE = 0.01 @@ -250,11 +277,12 @@ def populations(draw): @given(units=couple_populations, year=st.sampled_from(YEARS)) @example(units=LANDLORD_COUPLE, year=2026) @example(units=SMALL_LETTINGS_COUPLE, year=2026) +@example(units=WINTER_FUEL_COUPLE, year=2026) def test_election_never_raises_the_couples_tax(units, year): model = calculate(units, year) none = calculate(units, year, election=np.zeros_like(model[ELECTION])) - model_tax = couple_total(model, "income_tax") - none_tax = couple_total(none, "income_tax") + model_tax = couple_total(model, TAX) + none_tax = couple_total(none, TAX) assert np.all(model_tax <= none_tax + TOLERANCE), units # A couple that elects saves at least a penny. elects = couple_total(model, ELECTION) > 0 @@ -265,6 +293,7 @@ def test_election_never_raises_the_couples_tax(units, year): @given(units=couple_populations, year=st.sampled_from(YEARS)) @example(units=LANDLORD_COUPLE, year=2026) @example(units=SMALL_LETTINGS_COUPLE, year=2026) +@example(units=WINTER_FUEL_COUPLE, year=2026) def test_election_is_the_best_allowed_choice(units, year): model = calculate(units, year) none = calculate(units, year, election=np.zeros_like(model[ELECTION])) @@ -291,11 +320,11 @@ def test_election_is_the_best_allowed_choice(units, year): ) & no_mca options.append(run) allowed.append(couple_ok) - best = couple_total(none, "income_tax") + best = couple_total(none, TAX) for run, ok in zip(options[1:], allowed[1:]): - best = np.where(ok, np.minimum(best, couple_total(run, "income_tax")), best) + best = np.where(ok, np.minimum(best, couple_total(run, TAX)), best) np.testing.assert_allclose( - couple_total(model, "income_tax"), best, atol=TOLERANCE, err_msg=str(units) + couple_total(model, TAX), best, atol=TOLERANCE, err_msg=str(units) ) @@ -328,7 +357,7 @@ def test_gaining_partner_is_independent_of_the_electors_income(case, year): "marriage_allowance", "marriage_allowance_tax_reduction", "personal_allowance", - "income_tax", + TAX, ]: np.testing.assert_allclose( high[variable_name][others], @@ -380,15 +409,13 @@ def test_marriage_allowance_accounting(units, year): # The gaining partner's tax falls by exactly the reduction. gainer = received > 0 np.testing.assert_allclose( - model["income_tax"][gainer], - none["income_tax"][gainer] - reduction[gainer], + model[TAX][gainer], + none[TAX][gainer] - reduction[gainer], atol=TOLERANCE, err_msg=str(units), ) # The elector pays at least as much as without the election. - assert np.all( - model["income_tax"][elects] >= none["income_tax"][elects] - TOLERANCE - ), units + assert np.all(model[TAX][elects] >= none[TAX][elects] - TOLERANCE), units @PROPERTY_SETTINGS @@ -397,9 +424,26 @@ def test_couples_tax_never_falls_with_more_income(case, year): units, bump = case low = calculate(units, year) high = calculate(units, year, bump=bump) - assert np.all( - couple_total(high, "income_tax") >= couple_total(low, "income_tax") - TOLERANCE - ), (bump, units) + assert np.all(couple_total(high, TAX) >= couple_total(low, TAX) - TOLERANCE), ( + bump, + units, + ) + + +def test_electing_can_start_the_winter_fuel_payment_charge(): + # Intended: the election weighs tax before the charge, and does not weigh + # what it does to benefits (see the module docstring). + model = calculate(WINTER_FUEL_COUPLE, 2026) + none = calculate(WINTER_FUEL_COUPLE, 2026, election=np.zeros_like(model[ELECTION])) + assert model[ELECTION].tolist() == [False, True, False, False] + np.testing.assert_allclose(couple_total(model, TAX)[0], 6_888.26, atol=TOLERANCE) + np.testing.assert_allclose(couple_total(none, TAX)[0], 7_002.26, atol=TOLERANCE) + np.testing.assert_allclose( + couple_total(model, "income_tax")[0], 7_088.26, atol=TOLERANCE + ) + np.testing.assert_allclose( + couple_total(none, "income_tax")[0], 7_002.26, atol=TOLERANCE + ) # A married couple aged 18 and 100 with no income: the review's diff --git a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py index fa856dc008..3b19091a60 100644 --- a/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py +++ b/policyengine_uk/tests/test_pension_credit_earnings_disregard_properties.py @@ -8,9 +8,10 @@ - each person's own earnings, each source floored at nil, less their primary Class 1 contributions, half of their pension contributions and, on self-employed profit, the notional tax and contributions of CER 1996 - reg. 14 (basic rate on the profit less the personal allowance and any - marriage allowance; main-rate Class 4 under State Pension age), floored - at nil; + reg. 14 (basic rate on the profit less the personal allowance, itself + cut by any Marriage Allowance the person gives up (ITA 2007 s. 55B(6)), + and less any Marriage Allowance they receive; main-rate Class 4 under + State Pension age), floored at nil; - less the income tax payable on the claimant's and partner's employed earnings, measured as the fall in their income tax when the household is simulated again with nobody's employment income. @@ -35,7 +36,7 @@ """ import numpy as np -from hypothesis import HealthCheck, assume, given, settings +from hypothesis import HealthCheck, assume, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -155,14 +156,19 @@ def weekly_amount(people): return 20 if (disabled or carer or lone_parent) else (10 if couple else 5) -def notional_deductions(person, marriage_allowance): - """CER 1996 reg. 14 on the person's profit, from the inputs: no - generated adult is blind-person's-allowance relieved, born before 1935 - or liable for Class 2 (abolished in 2024-25).""" +def notional_deductions(person, relinquished, marriage_allowance): + """CER 1996 reg. 14 on the person's profit, from the inputs and the + couple's Marriage Allowance election: no generated adult is + blind-person's-allowance relieved, born before 1935 or liable for Class 2 + (abolished in 2024-25). The relief appropriate to the person's + circumstances is their personal allowance less any allowance they give up + (ITA 2007 s. 55B(6)). The allowance they receive comes off the profit + here, which at the basic rate is the same as the s. 55B(1) reduction.""" profit = max(0, person.get("self_employment_income", 0)) if profit == 0: return 0.0 - tax = BASIC_RATE * max(0, profit - PERSONAL_ALLOWANCE - marriage_allowance) + allowance = PERSONAL_ALLOWANCE - relinquished + tax = BASIC_RATE * max(0, profit - allowance - marriage_allowance) class_4 = 0.0 if person["age"] < STATE_PENSION_AGE: class_4 = CLASS_4_MAIN_RATE * min( @@ -172,7 +178,43 @@ def notional_deductions(person, marriage_allowance): return tax + class_4 +# A couple over State Pension age with £12,000 and £15,000 of profit (2024-25), +# who would claim. If the first gives up £1,260 of allowance they pay 20% x +# (12,000 - 11,310) = 138 for the partner's 252 reduction, so they elect. The +# transferor's notional tax is 138, not 0; the recipient's is 20% x (15,000 - +# 12,570 - 1,260) = 234. +ELECTING_COUPLE = { + "people": { + "claimant": { + "age": 67, + "self_employment_income": 12_000, + "would_claim_marriage_allowance": True, + }, + "partner": { + "age": 67, + "self_employment_income": 15_000, + "would_claim_marriage_allowance": True, + }, + }, + "country": "ENGLAND", +} + + +def test_the_transferor_s_notional_tax_uses_the_cut_allowance(): + sim = simulate(ELECTING_COUPLE) + assert person_values(sim, "marriage_allowance_relinquished").tolist() == [ + 1_260, + 0, + ] + assert person_values(sim, "marriage_allowance").tolist() == [0, 1_260] + assert person_values(sim, "pension_credit_self_employment_deductions").tolist() == [ + 138, + 234, + ] + + @SETTINGS +@example(ELECTING_COUPLE) @given(units()) def test_statute_bounds_differential_and_monotonicity(case): people = case["people"] @@ -186,10 +228,11 @@ def test_statute_bounds_differential_and_monotonicity(case): [max(0, people[n].get("self_employment_income", 0)) for n in names], dtype=float, ) + relinquished = person_values(sim, "marriage_allowance_relinquished") marriage_allowance = person_values(sim, "marriage_allowance") notional = np.array( [ - notional_deductions(people[n], marriage_allowance[i]) + notional_deductions(people[n], relinquished[i], marriage_allowance[i]) for i, n in enumerate(names) ] ) diff --git a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py index ad32b4fee5..8322087645 100644 --- a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py +++ b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py @@ -13,10 +13,13 @@ statutory paternity pay: 1. Monotonic: raising the recipient's statutory paternity pay, with - everything else fixed, never lowers the recipient's income tax or the - family's total income tax. A Child Benefit opt-out can lower the family's - tax as income rises (the High Income Child Benefit Charge stops when the - benefit stops), for wages too, so the generated families do not opt out. + everything else fixed, never lowers the family's total income tax, nor the + recipient's while the couple's Marriage Allowance election stays the same. + A Child Benefit opt-out can lower the family's tax as income rises (the + High Income Child Benefit Charge stops when the benefit stops), for wages + too, so the generated families do not opt out. A couple can stop electing + as the recipient's income rises, and the recipient's own tax then falls + while the couple's rises (intended; ITA 2007 s. 55B(6)). 2. Bounds: a pound more of statutory paternity pay raises the family's income tax, before the winter fuel payment charge and less the High Income Child Benefit Charge, by at most a pound, for families that do not @@ -59,7 +62,7 @@ """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -256,6 +259,51 @@ def family_total(self, variable, i, version): return float(self.members(variable, i, version).sum()) +ELECTION = "makes_marriage_allowance_election" + +# Intended exception to invariant 1 for the recipient alone (2026-27). The +# recipient has no wages; their spouse earns £24,000 and pays 2,286. On +# £12,000 of statutory paternity pay the recipient gives up £1,260 of +# allowance, paying 20% x (12,000 - 11,310) = 138 for the spouse's 252 +# reduction. On £12,570 giving it up would cost 252 for 252, so no one +# elects: the recipient pays nothing and the couple 114 more. +STOPS_ELECTING = dict( + adults=[ + dict( + age=33, + wages=0.0, + profit=0.0, + pension_income=0.0, + savings=0.0, + dividends=0.0, + pension_contributions=0.0, + student_loan_plan="NONE", + claims_marriage_allowance=True, + ), + dict( + age=33, + wages=24_000.0, + profit=0.0, + pension_income=0.0, + savings=0.0, + dividends=0.0, + pension_contributions=0.0, + student_loan_plan="NONE", + claims_marriage_allowance=True, + ), + ], + married=True, + child_ages=[], + region="NORTH_EAST", + tenure="RENT_PRIVATELY", + rent=0.0, + would_claim_uc=False, + recipient=0, + low=12_000.0, + high=12_570.0, +) + + def claims_marriage_allowance(family): return family["married"] and any( adult["claims_marriage_allowance"] for adult in family["adults"] @@ -263,15 +311,19 @@ def claims_marriage_allowance(family): @PROPERTY_SETTINGS +@example(population=[STOPS_ELECTING]) @given(population=populations()) def test_income_tax_never_falls_as_statutory_paternity_pay_rises(population): results = Results(population) for i, family in enumerate(population): # Invariant 1. + same_election = np.array_equal( + results.members(ELECTION, i, HIGH), results.members(ELECTION, i, LOW) + ) recipient_gain = results.recipient("income_tax", i, HIGH) - results.recipient( "income_tax", i, LOW ) - assert recipient_gain >= -TOLERANCE, family + assert recipient_gain >= -TOLERANCE or not same_election, family family_gain = results.family_total( "income_tax", i, HIGH ) - results.family_total("income_tax", i, LOW) @@ -297,6 +349,14 @@ def test_income_tax_never_falls_as_statutory_paternity_pay_rises(population): assert gain <= family["high"] - family["low"] + TOLERANCE, family +def test_a_couple_can_stop_electing_as_statutory_paternity_pay_rises(): + results = Results([STOPS_ELECTING]) + assert results.members(ELECTION, 0, LOW).tolist() == [1.0, 0.0] + assert results.members(ELECTION, 0, HIGH).tolist() == [0.0, 0.0] + assert results.members("income_tax", 0, LOW).tolist() == [138, 2_034] + assert results.members("income_tax", 0, HIGH).tolist() == [0, 2_286] + + @PROPERTY_SETTINGS @given(population=populations()) def test_statutory_paternity_pay_is_treated_as_statutory_sick_pay(population): diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py index 8ac7b852c4..02b5d0ec2e 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py @@ -42,9 +42,13 @@ def formula(person, period, parameters): scottish_basic_rate, rates.uk.rates[0], ) - # Chapter 2 (personal and blind person's allowances) and Chapter 3A - # (marriage allowance, which the model deducts from income) relieve - # income; Chapter 3 (married couple's allowance) reduces the tax. + # Chapter 2 (personal and blind person's allowances) relieves income; + # the personal allowance is already cut by any Marriage Allowance the + # person gives up (Chapter 3A, s. 55B(6)). The Marriage Allowance the + # person receives is a Chapter 3A tax reduction at the basic rate on + # that amount (s. 55B(1), (3)), the same as relieving the amount here + # at the basic rate. Chapter 3 (married couple's allowance) reduces + # the tax. reliefs = add( person, period, From fae3baaea2fea0be8b45749a6a5eea642005a60a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 17:23:49 -0400 Subject: [PATCH 28/30] Keep a patched setter from leaking into the election's branches test_partial_setter_failure_restores_only_touched_input patched child.set_input with a plain closure. Simulation.clone copies instance attributes and rebinds only bound methods, so every branch the Marriage Allowance election makes from the child kept the closure: the branch's set_input wrote to the child, the branch never held its election, and the election's formula branched again until the recursion limit (CI shard 2/6 on 8117a4462). Binding the setter to the child lets clone rebind it to each branch; only the child's own write fails. Co-Authored-By: Claude Opus 5.5 --- .../test_aggregate_impact_childcare_provenance.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_childcare_provenance.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_childcare_provenance.py index eca0398709..bc644800f1 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_childcare_provenance.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_childcare_provenance.py @@ -5,6 +5,7 @@ """ from datetime import date +import types import numpy as np import pytest from policyengine_core.periods import period @@ -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 From 5cedfaf9f9022d34c922f8c1a8678072fbcf3f5a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 18:35:19 -0400 Subject: [PATCH 29/30] Judge the Marriage Allowance rate condition on taxable income itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conditions property judged tax_band a penny lower to step round tax_band calling income exactly at the basic rate limit higher. That missed an earner liable at the higher rate on less than a penny (£50,270.005, stored as £50,270.00390625), so a £0.01 tolerance in the condition could pass. The property now compares each earner's taxable income with the 2025-26 limits, strictly, and checks both ways; the sub-penny earner is an @example and a pinned case. A £0.01 tolerance mutant now fails both tests. Co-Authored-By: Claude Opus 5.5 --- .../test_marriage_allowance_cps_switch.py | 67 ++++++++++++------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py index 057416e90d..57fe502b9b 100644 --- a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py +++ b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py @@ -25,8 +25,8 @@ when no one claims the allowance. 3. With any statutory rate and the CPS parameters at their defaults, no one liable at the higher rate or above meets the income conditions or has the - allowance, and no one has more than the statutory share of the personal - allowance, rounded up to the next £10. + allowance, everyone else meets the conditions, and no one has more than the + statutory share of the personal allowance, rounded up to the next £10. 4. The transferable amount never falls when the statutory rate rises, and the allowance is all of it or none. The allowance itself can fall: a couple stops electing once giving up the larger allowance costs the elector more @@ -40,7 +40,7 @@ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_core import periods from policyengine_core.reforms import Reform @@ -72,6 +72,12 @@ # (marriage_allowance/rounding_increment.yaml). PERSONAL_ALLOWANCE = 12_570 ROUNDING_INCREMENT = 10 +# 2025-26 taxable income above which non-savings income is charged above the +# electing rates: the basic rate limit (ITA 2007 s. 10), and for a Scottish +# taxpayer the start of the Scottish higher rate, £43,662 of income less the +# personal allowance (Scottish Rate Resolution 2025). +BASIC_RATE_LIMIT = 37_700 +SCOTTISH_HIGHER_RATE_START = 31_092 REFORM_VARIABLE = "meets_expanded_ma_conditions" # Only the reform adds it. @@ -342,10 +348,10 @@ def simulation_for(*pairs, reform=None): st.booleans(), # the second would claim ) drawn_couples = st.lists(couple, min_size=COUPLES, max_size=COUPLES) -# The bands in which an earner with only employment income is liable at no -# rate above the basic, Scottish starter, basic or intermediate rates (ITA -# 2007 s. 55B(2)(b), s. 55C(1)(c)). -ELECTING_BANDS = ["NONE", "STARTER", "BASIC", "INTERMEDIATE"] +# A London earner liable at the higher rate on less than a penny: £50,270.005 +# is stored as £50,270.00390625. +SUB_PENNY_ABOVE_THE_LIMIT = [(0.0, 0.0, True, True, True)] * COUPLES +SUB_PENNY_ABOVE_THE_LIMIT[1] = (50_270.005, 0.0, True, True, True) # Statutory rates in whole thousandths. 12,570 x k / 1,000 = 12.57k is a # multiple of £10 only at k = 0 and 1,000, and otherwise at least a penny from # one, so float rounding cannot move the allowance across a £10 step and the @@ -388,6 +394,15 @@ def run(simulation, drawn, anyone_claims=True): "marriage_allowance_transferable_amount", YEAR ), income_tax=simulation.calculate("income_tax", YEAR), + # The earners have only employment income, so all their taxable + # income is non-savings income. The personal allowance is the one in + # force, cut for a spouse who elects (s. 55B(6)). + taxable=np.maximum( + simulation.calculate("employment_income", YEAR) + - simulation.calculate("personal_allowance", YEAR), + 0, + ), + scottish=simulation.calculate("pays_scottish_income_tax", YEAR), ) @@ -413,43 +428,43 @@ def test_a_zero_statutory_rate_is_no_allowance_for_anyone(simulation_for, drawn) def test_taxable_income_at_the_basic_rate_limit_still_meets_the_conditions(): # £50,270 of earnings is £37,700 of taxable income, all at the basic rate, # so the earner can gain; tax_band calls it higher. £50,271 is liable at - # the higher rate on £1. The same holds at the Scottish higher rate's - # start, £43,662 of earnings in 2025-26. - situation = couples((50_270, None), (50_271, None)) + # the higher rate on £1, and £50,270.005 (stored as £50,270.00390625) on + # less than a penny. The same holds at the Scottish higher rate's start, + # £43,662 of earnings in 2025-26. + situation = couples((50_270, None), (50_271, None), (50_270.005, None)) simulation = Simulation(situation=situation) assert simulation.calculate("marriage_allowance", YEAR).tolist() == [ 1_260, 0, 0, 0, + 0, + 0, ] assert np.asarray(simulation.calculate("tax_band", YEAR), dtype=str)[ - EARNERS - ].tolist() == ["HIGHER", "HIGHER"] + 0::2 + ].tolist() == ["HIGHER", "HIGHER", "HIGHER"] @SETTINGS @given(drawn_couples, permille) +@example(SUB_PENNY_ABOVE_THE_LIMIT, 100) def test_any_statutory_rate_keeps_the_statutory_conditions_and_cap( simulation_for, drawn, rate_permille ): simulation = simulation_for((STATUTORY_RATE, rate_permille / 1_000)) assert REFORM_VARIABLE not in simulation.tax_benefit_system.variables - # tax_band places taxable income exactly at a band's lower limit in that - # band, though none of it is charged at the band's rate (ITA 2007 s. 10). - # Someone still outside the electing bands a penny lower is liable above - # them. - penny_lower = [ - (max(first - 0.01, 0.0), max(second - 0.01, 0.0), *rest) - for first, second, *rest in drawn - ] - below = run(simulation, penny_lower) r = run(simulation, drawn) - outside = ~np.isin(below["band"], ELECTING_BANDS) - higher = np.isin(below["band"], ["HIGHER", "ADDITIONAL"]) - assert outside[higher].all() - assert not r["meets_income_conditions"][outside].any() - assert (r["allowance"][outside] == 0).all() + # Liable above the electing rates on any of their income, however little + # (ITA 2007 s. 55B(2)(b), s. 55C(1)(c)). tax_band also calls taxable + # income exactly at the limit higher, though none of it is charged at + # the higher rate (s. 10), so it is checked one way only. + limit = np.where(r["scottish"], SCOTTISH_HIGHER_RATE_START, BASIC_RATE_LIMIT) + above = r["taxable"] > limit + assert np.isin(r["band"], ["HIGHER", "ADDITIONAL"])[above].all() + assert not r["meets_income_conditions"][above].any() + assert r["meets_income_conditions"][~above].all() + assert (r["allowance"][above] == 0).all() assert (r["allowance"] >= 0).all() assert (r["allowance"] <= statutory_cap(rate_permille)).all() From 69c919f6054023fa5a51b42d1ffbbcb0078e8082 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 19:05:51 -0400 Subject: [PATCH 30/30] Free the election branches' arrays as soon as they are dropped policyengine-core records each new branch in every calculation under way (get_branch adds it to each open frame's created_branches), and those frames keep it until they finish. So the two branches behind each Marriage Allowance election, with their copies of the simulation's arrays and their own results, lasted until the calculation that asked for the election ended, though they were already out of simulation.branches. tax_position now clears their storage when it drops them. It clears it directly: delete_arrays and drop_computed_arrays would record a change in each of those frames, though nothing reads the branch again. On a 2,033-household sample of the certified Enhanced FRS, the traced peak while calculating one year's household_net_income is 48.4 MB on main 41fa5ddb5, 77.1 MB on 5cedfaf9f and 52.0 MB here. Seven outputs over three years are bit-for-bit the same as at 5cedfaf9f. CI's test shard 3, which reaches 15.2 GB of a 16 GB runner on main, lost its runner at every head of this PR since the 28 Oct merge. test_the_election_releases_its_branches fails at 5cedfaf9f and passes here. Co-Authored-By: Claude Opus 5.5 --- ...st_marriage_allowance_election_branches.py | 76 +++++++++++++------ .../makes_marriage_allowance_election.py | 17 +++++ 2 files changed, 69 insertions(+), 24 deletions(-) diff --git a/policyengine_uk/tests/test_marriage_allowance_election_branches.py b/policyengine_uk/tests/test_marriage_allowance_election_branches.py index 22ef153ff1..4aba6316f0 100644 --- a/policyengine_uk/tests/test_marriage_allowance_election_branches.py +++ b/policyengine_uk/tests/test_marriage_allowance_election_branches.py @@ -16,6 +16,9 @@ reform, whether the reform or the baseline is computed first, and the reform's own results do not depend on that order either. 3. Branches are dropped: computing six years leaves no branch behind. +4. Branches are released: once the election is made, its two branches hold + no values, though policyengine-core keeps them until the calculations + under way end, and the simulation keeps its own. """ import numpy as np @@ -62,6 +65,29 @@ ] TENURES = ["RENT_FROM_COUNCIL", "RENT_PRIVATELY", "OWNED_OUTRIGHT"] REGIONS = ["LONDON", "WALES", "SCOTLAND"] +# A couple who elect every year: 30,000 and 9,000 of earnings, so electing +# costs the lower earner nothing and saves the other 252 (s. 55B). The lower +# earner's property receipts are within the property allowance and carry +# finance costs, so the property route is weighed in branches too. +ELECTING_COUPLE = [ + dict( + adults=[ + dict(age=40, employment_income=30_000.0), + dict( + age=38, + employment_income=9_000.0, + property_rental_income=800.0, + property_income=800.0, + property_finance_costs=200.0, + ), + ], + married=True, + children=[4], + tenure="RENT_PRIVATELY", + rent=9_000.0, + region="LONDON", + ) +] def personal_allowance_reform(year): @@ -212,31 +238,8 @@ def test_reform_leaves_the_baseline_unchanged(units, year): def test_no_branch_is_left_after_any_year(): - # A couple who elect every year: 30,000 and 9,000 of earnings, so electing - # costs the lower earner nothing and saves the other 252 (s. 55B). The - # lower earner's property receipts are within the property allowance and - # carry finance costs, so the property route is weighed in branches too. - units = [ - dict( - adults=[ - dict(age=40, employment_income=30_000.0), - dict( - age=38, - employment_income=9_000.0, - property_rental_income=800.0, - property_income=800.0, - property_finance_costs=200.0, - ), - ], - married=True, - children=[4], - tenure="RENT_PRIVATELY", - rent=9_000.0, - region="LONDON", - ) - ] years = list(range(2025, 2031)) - simulation = Simulation(situation=situation(units, years)) + simulation = Simulation(situation=situation(ELECTING_COUPLE, years)) before = set(simulation.branches) elected = [] for year in years: @@ -246,6 +249,31 @@ def test_no_branch_is_left_after_any_year(): assert all(elected), elected +def test_the_election_releases_its_branches(monkeypatch): + # Each branch would otherwise hold its copies of the simulation's arrays, + # and its own results, until household net income is calculated. + year = 2026 + simulation = Simulation(situation=situation(ELECTING_COUPLE, year)) + get_branch = type(simulation).get_branch + made = [] + + def recording_get_branch(self, name="branch", clone_system=False): + branch = get_branch(self, name, clone_system) + if self is simulation and name.startswith("marriage_allowance_"): + made.append(branch) + return branch + + monkeypatch.setattr(type(simulation), "get_branch", recording_get_branch) + simulation.calculate("household_net_income", year) + assert simulation.calculate(ELECTION, year).any() + assert len(made) == 2 + for branch in made: + assert not any(p._holders for p in branch.populations.values()) + assert not branch._fast_cache + # The simulation keeps the values it calculated before branching. + assert simulation.get_array("adjusted_net_income", year) is not None + + @pytest.mark.parametrize( "tax", ["income_tax", "income_tax_before_winter_fuel_payment_charge"] ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py index d1e49f49d5..43ea41417e 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/makes_marriage_allowance_election.py @@ -80,6 +80,23 @@ def tax_position(simulation, name, period, relinquished, held): return {variable: population(variable, period) for variable in BRANCH_OUTPUTS} finally: del simulation.branches[name] + release(branch) + + +def release(branch): + """Free the arrays a deleted branch holds. + + policyengine-core records each new branch in every calculation then + under way, which keeps it until they finish. Without this, both branches + and their copies of the simulation's arrays would last until the + calculation that asked for the election (household net income, say) + ended. The storage is cleared directly: deleting values through the + branch would record a change in each of those calculations, though + nothing reads the branch again. + """ + for population in branch.populations.values(): + population._holders.clear() + branch._fast_cache = {} class makes_marriage_allowance_election(Variable):