From 3e609d5159e7ab517f67da5583ce335d99d881b4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 20:00:46 -0400 Subject: [PATCH 1/3] Measure salary sacrifice NI as the change in the Class 1 liability salary_sacrifice_pension_ni_employee and _employer multiplied the whole amount returned to income by one rate. An amount crossing the upper earnings limit was charged entirely at the additional rate, an amount below the primary or secondary threshold was charged in full, and the age conditions on Class 1 were ignored. Each measure is now the complete Class 1 liability on earnings less the liability on the same earnings without the amount returned. They remain reporting measures: no tax total reads them. Refs #2361 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2361.fixed.md | 1 + .../salary_sacrifice_pension_ni_employee.yaml | 118 ++++++++++++++++++ .../salary_sacrifice_pension_ni_employer.yaml | 89 +++++++++++++ .../salary_sacrifice_pension_ni_employee.py | 74 +++++++---- .../salary_sacrifice_pension_ni_employer.py | 47 ++++--- 5 files changed, 292 insertions(+), 37 deletions(-) create mode 100644 changelog.d/2361.fixed.md diff --git a/changelog.d/2361.fixed.md b/changelog.d/2361.fixed.md new file mode 100644 index 0000000000..713eb286d9 --- /dev/null +++ b/changelog.d/2361.fixed.md @@ -0,0 +1 @@ +- The salary sacrifice National Insurance measures (`salary_sacrifice_pension_ni_employee` and `salary_sacrifice_pension_ni_employer`) now equal the change in the Class 1 liability from the amount returned to income: an amount crossing the upper earnings limit is split between the main and additional rates, and the thresholds and age conditions apply. No tax total changes. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml index 5b36e53d92..6a21aeb98d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml @@ -106,6 +106,124 @@ # Full excess is redirected (no targeted haircut) salary_sacrifice_returned_to_income: 1_000 +# The cases below set only the pay (employment_income_before_lsr), so +# employment_income is the pay plus the amount returned. The charge is the +# employee Class 1 liability on those earnings less the liability without the +# amount returned. 2025 thresholds: primary threshold 241.73 x 52 = 12,569.96, +# upper earnings limit 966.73 x 52 = 50,269.96. +- name: Amount returned straddles the upper earnings limit + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 48_000 + pension_contributions_via_salary_sacrifice: 6_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + salary_sacrifice_returned_to_income: 4_000 + employment_income: 52_000 + # 8% from 48,000 to the limit, 2% from the limit to 52,000 = 216.20. + # (A single rate on the whole amount would give 4,000 x 2% = 80.) + salary_sacrifice_pension_ni_employee: (50_269.96 - 48_000) * 0.08 + (52_000 - 50_269.96) * 0.02 + +- name: Amount returned stays below the primary threshold + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 6_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 9_000 + # Earnings of 9,000 are below the threshold, so no contributions are due. + ni_class_1_employee: 0 + salary_sacrifice_pension_ni_employee: 0 + +- name: Amount returned straddles the primary threshold + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 11_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 14_000 + # Only the part above the threshold is charged: 1,430.04 x 8% = 114.40. + ni_class_1_employee: (14_000 - 12_569.96) * 0.08 + salary_sacrifice_pension_ni_employee: (14_000 - 12_569.96) * 0.08 + +- name: No employee charge over State Pension age + period: 2025 + absolute_error_margin: 0.01 + input: + age: 70 + employment_income_before_lsr: 45_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + salary_sacrifice_returned_to_income: 3_000 + # Primary contributions end at pensionable age (SSCBA 1992 s.6(3)). + ni_class_1_employee: 0 + salary_sacrifice_pension_ni_employee: 0 + +- name: No employee charge under 16 + period: 2025 + absolute_error_margin: 0.01 + input: + age: 15 + employment_income_before_lsr: 20_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + salary_sacrifice_returned_to_income: 3_000 + ni_class_1_employee: 0 + salary_sacrifice_pension_ni_employee: 0 + +- name: Statutory pay counts towards the upper earnings limit + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 46_000 + statutory_sick_pay: 3_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 49_000 + ni_class_1_income: 52_000 + # Class 1 earnings are 52,000 with the 3,000 returned and 49,000 without. + salary_sacrifice_pension_ni_employee: (50_269.96 - 49_000) * 0.08 + (52_000 - 50_269.96) * 0.02 + +- name: Worked example in the salary sacrifice cap research post + period: 2025 + absolute_error_margin: 0.01 + input: + # A 50,000 salary with 5,000 sacrificed leaves pay of 45,000. + employment_income_before_lsr: 45_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 48_000 + # Earnings stay below the upper earnings limit: 3,000 x 8% = 240. + salary_sacrifice_pension_ni_employee: 240 + +- name: Current law in 2029, with the cap and the broad-base haircut + period: 2029 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 48_000 + pension_contributions_via_salary_sacrifice: 6_000 + output: + salary_sacrifice_returned_to_income: 4_000 + # 48,000 + 4,000 returned - 76.80 haircut (0.16% of 48,000). + employment_income: 51_923.20 + salary_sacrifice_pension_ni_employee: (50_269.96 - 47_923.20) * 0.08 + (51_923.20 - 50_269.96) * 0.02 + - name: Broad-base haircut reduces all workers employment income period: 2025 absolute_error_margin: 1 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml index 4eb0fdf97c..c2aa4cff99 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml @@ -105,3 +105,92 @@ salary_sacrifice_pension_ni_employer: (3_000 - 2_000) * 0.15 # Full excess is redirected (no targeted haircut) salary_sacrifice_returned_to_income: 1_000 + +# The cases below set only the pay (employment_income_before_lsr), so +# employment_income is the pay plus the amount returned. The charge is the +# employer Class 1 liability on those earnings less the liability without the +# amount returned. 2025 secondary threshold: 96.153846 x 52 = 5,000. +- name: Amount returned stays below the secondary threshold + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 1_500 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 4_500 + # Earnings of 4,500 are below the threshold, so no contributions are due. + # (The rate on the whole amount would give 3,000 x 15% = 450.) + ni_class_1_employer: 0 + salary_sacrifice_pension_ni_employer: 0 + +- name: Amount returned straddles the secondary threshold + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 3_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 6_000 + # Only the part above the threshold is charged: 1,000 x 15% = 150. + ni_class_1_employer: (6_000 - 5_000) * 0.15 + salary_sacrifice_pension_ni_employer: (6_000 - 5_000) * 0.15 + +- name: Employer charge continues over State Pension age + period: 2025 + absolute_error_margin: 0.01 + input: + age: 70 + employment_income_before_lsr: 45_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + # Secondary contributions have no upper age limit (SSCBA 1992 s.6(3)). + salary_sacrifice_pension_ni_employer: 3_000 * 0.15 + +- name: No employer charge under 16 + period: 2025 + absolute_error_margin: 0.01 + input: + age: 15 + employment_income_before_lsr: 20_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + salary_sacrifice_returned_to_income: 3_000 + ni_class_1_employer: 0 + salary_sacrifice_pension_ni_employer: 0 + +- name: Employer pension contributions count when they are not exempt + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 1_500 + employer_pension_contributions: 2_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions: false + output: + employment_income: 4_500 + # Charged earnings are 4,500 + 2,000 = 6,500 with the 3,000 returned and + # 3,500 without: (6,500 - 5,000) x 15% = 225. + ni_class_1_employer: (6_500 - 5_000) * 0.15 + salary_sacrifice_pension_ni_employer: (6_500 - 5_000) * 0.15 + +- name: Current law in 2029, with the cap and the broad-base haircut + period: 2029 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 48_000 + pension_contributions_via_salary_sacrifice: 6_000 + output: + salary_sacrifice_returned_to_income: 4_000 + employment_income: 51_923.20 + # Earnings stay above the secondary threshold: 4,000 x 15% = 600. + salary_sacrifice_pension_ni_employer: 4_000 * 0.15 diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py index ba71d85791..b4f1a35d68 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py @@ -1,36 +1,64 @@ from policyengine_uk.model_api import * +def _employee_class_1(earnings, class_1): + """Employee Class 1 contributions on a month's earnings. + + The same schedule as ni_class_1_employee_primary plus + ni_class_1_employee_additional. + """ + primary_threshold = ( + class_1.thresholds.primary_threshold * WEEKS_IN_YEAR / MONTHS_IN_YEAR + ) + upper_earnings_limit = ( + class_1.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR + ) + above_upper_earnings_limit = max_(earnings - upper_earnings_limit, 0) + main_rate_earnings = ( + max_(earnings - primary_threshold, 0) - above_upper_earnings_limit + ) + return ( + class_1.rates.employee.main * main_rate_earnings + + class_1.rates.employee.additional * above_upper_earnings_limit + ) + + class salary_sacrifice_pension_ni_employee(Variable): label = "Employee NI on salary sacrifice pension contributions above cap" documentation = ( - "Additional employee National Insurance contributions due to " - "salary sacrifice pension contributions exceeding the cap. " - "The full excess is redirected to regular pension contributions " - "and subject to NI (but gets income tax relief)." + "Additional employee Class 1 National Insurance due because salary " + "sacrifice pension contributions above the cap return to employment " + "income. It is the employee's complete Class 1 liability " + "(ni_class_1_employee) on their earnings, less the liability on the " + "same earnings without the amount returned " + "(salary_sacrifice_returned_to_income). The amount returned is the " + "top slice of earnings: it bears the main rate between the primary " + "threshold and the upper earnings limit, the additional rate above " + "the limit, and nothing below the threshold or for a person who is " + "not liable to primary contributions (ni_liable). " + "\n\n" + "This measures part of ni_class_1_employee, which already charges the " + "amount returned. Do not add it to a National Insurance total." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" + defined_for = "ni_liable" + reference = ( + "https://policyengine.org/uk/research/uk-salary-sacrifice-cap", + "https://www.legislation.gov.uk/ukpga/1992/4/section/8", + ) def formula(person, period, parameters): - # Get the excess that's redirected to regular pension - # This is the amount subject to NI - excess = person("salary_sacrifice_returned_to_income", period) - - # Use existing NI Class 1 parameters - ni_params = parameters(period).gov.hmrc.national_insurance.class_1 - employment_income = person("employment_income", period) - upper_earnings_limit = ni_params.thresholds.upper_earnings_limit * WEEKS_IN_YEAR - - # Apply appropriate NI rate based on income level - # Main rate (8%) for income <= UEL, additional rate (2%) for income > UEL - ni_rate = where( - employment_income <= upper_earnings_limit, - ni_params.rates.employee.main, - ni_params.rates.employee.additional, - ) - - return excess * ni_rate + # ni_class_1_employee is a monthly variable, so the difference is taken + # month by month, with each month's earnings and parameters. + charge = 0 + for month in period.get_subperiods(MONTH): + class_1 = parameters(month).gov.hmrc.national_insurance.class_1 + earnings = person("ni_class_1_income", month) + returned = person("salary_sacrifice_returned_to_income", month) + charge += _employee_class_1(earnings, class_1) - _employee_class_1( + earnings - returned, class_1 + ) + return charge diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py index ed9de113a4..351ea50555 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py @@ -4,24 +4,43 @@ class salary_sacrifice_pension_ni_employer(Variable): label = "Employer NI on salary sacrifice pension contributions above cap" documentation = ( - "Additional employer National Insurance contributions due to " - "salary sacrifice pension contributions exceeding the cap. " - "The full excess is redirected to regular pension contributions " - "and subject to employer NI." + "Additional employer Class 1 National Insurance due because salary " + "sacrifice pension contributions above the cap return to employment " + "income. It is the employer's complete Class 1 liability " + "(ni_class_1_employer) on the employee's earnings, less the liability " + "on the same earnings without the amount returned " + "(salary_sacrifice_returned_to_income). The amount returned is the " + "top slice of earnings: it bears the employer rate above the " + "secondary threshold, and nothing below it or for an earner on whom " + "no secondary contributions are due (ni_class_1_secondary_liable). " + "\n\n" + "This measures part of ni_class_1_employer, which already charges the " + "amount returned. Do not add it to a National Insurance total." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" + defined_for = "ni_class_1_secondary_liable" + reference = ( + "https://policyengine.org/uk/research/uk-salary-sacrifice-cap", + "https://www.legislation.gov.uk/ukpga/1992/4/section/9", + ) def formula(person, period, parameters): - # Get the excess that's redirected to regular pension - # This is the amount subject to employer NI - excess = person("salary_sacrifice_returned_to_income", period) - - # Use existing NI Class 1 employer rate parameter - ni_params = parameters(period).gov.hmrc.national_insurance.class_1 - employer_rate = ni_params.rates.employer - - return excess * employer_rate + gov = parameters(period).gov + class_1 = gov.hmrc.national_insurance.class_1 + returned = person("salary_sacrifice_returned_to_income", period) + # The earnings ni_class_1_employer charges. + earnings = person("ni_class_1_income", period) + employer_ni = gov.contrib.policyengine.employer_ni + if not employer_ni.exempt_employer_pension_contributions: + earnings = earnings + person("employer_pension_contributions", period) + secondary_threshold = class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + above_threshold = max_(earnings - secondary_threshold, 0) + above_threshold_without_returned = max_( + earnings - returned - secondary_threshold, 0 + ) + return class_1.rates.employer * ( + above_threshold - above_threshold_without_returned + ) From 00694cface26cffb1e1ed543490bcdc354bf664c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 20:09:23 -0400 Subject: [PATCH 2/3] Test that the salary sacrifice NI measures equal the liability difference Property test: each measure equals the complete Class 1 liability of a simulation with the amount returned less that of the same people with nothing returned, under current law and in a world where the employee rates and upper earnings limit change within a year. Also bounds, exact zeros, the single-rate case and monotonicity. Refs #2361 Co-Authored-By: Claude Opus 5.5 --- ..._salary_sacrifice_ni_measure_properties.py | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py diff --git a/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py b/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py new file mode 100644 index 0000000000..d5b032eb36 --- /dev/null +++ b/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py @@ -0,0 +1,344 @@ +"""Property-based tests: the salary sacrifice NI measures equal the change in +the complete Class 1 liability. + +From 6 April 2029 the salary sacrifice above the £2,000 cap returns to +employment income (salary_sacrifice_returned_to_income), where Class 1 +National Insurance charges it. salary_sacrifice_pension_ni_employee and +salary_sacrifice_pension_ni_employer report how much of the employee and +employer liability is due to the amount returned. + +Every test builds the same people twice: as they are, and with nothing +returned (salary_sacrifice_returned_to_income set to zero, all else equal). +The second simulation gives the liability without the amount returned from +the model's own liability variables, so it does not depend on the formulas +under test. + +Invariants, for any pay, salary sacrifice, age, statutory sick pay and +employer pension contribution: + +1. Difference of complete liabilities: the employee measure equals + ni_class_1_employee with the amount returned less ni_class_1_employee + without it, and the employer measure equals the same difference in + ni_class_1_employer. +2. Bounds: each measure lies between zero and the amount returned times the + highest rate that can apply to it. +3. Exact zeros: a measure is exactly zero when nothing is returned (so in + every year before the cap), for a person not liable to primary + contributions (employee) and for an earner on whom no secondary + contributions are due (employer). +4. One band, one rate: when the earnings with and without the amount + returned lie in the same band, the measure is that band's rate times the + amount returned. This is where the earlier single-rate formulas were + right. +5. Monotonicity: a measure never falls when the sacrifice rises. + +The invariants are checked under current law (2028, before the cap, and 2029 +and 2030 under it) and in a second world where the employee rates and the +upper earnings limit change within 2041 and employer pension contributions +are charged (invariant 4 under current law only: it needs one rate for the +year). Fiscal-year conversion gives every parameter one value for each year +from 2015 to 2040, so a year after 2040 is needed for rates that differ +between months; ni_class_1_employee is a monthly variable. + +Comparisons allow float32 rounding: the model stores values as float32. +""" + +from collections import namedtuple + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.model_api import MONTHS_IN_YEAR, WEEKS_IN_YEAR + +CLASS_1 = "gov.hmrc.national_insurance.class_1" +# name: (parameter changes, years) +WORLDS = { + "current law": (None, [2028, 2029, 2030]), + "rates change within the year": ( + { + f"{CLASS_1}.rates.employee.main": {"2041-07-01.2041-12-31": 0.12}, + f"{CLASS_1}.rates.employee.additional": {"2041-10-01.2041-12-31": 0.05}, + f"{CLASS_1}.thresholds.upper_earnings_limit": { + "2041-04-01.2041-12-31": 1_300 + }, + "gov.contrib.policyengine.employer_ni." + "exempt_employer_pension_contributions": False, + }, + [2030, 2041], + ), +} +PROPERTY_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Pounds. Liabilities are float32 sums of up to about £50,000. +TOLERANCE = 0.03 + +Row = namedtuple("Row", "pay sacrifice more_sacrifice age sick_pay employer_pension") + +# People the properties must always cover, whatever is drawn. With the £2,000 +# cap: an amount returned that crosses the upper earnings limit, one that +# crosses the primary threshold, one that crosses the secondary threshold, one +# below every threshold, a person over State Pension age, a person under 16, +# one whose statutory sick pay takes them over the upper earnings limit, and +# one with nothing returned. +KNOWN_CASES = [ + Row(48_000, 6_000, 10_000, 40, 0, 0), + Row(11_000, 5_000, 3_000, 40, 0, 0), + Row(3_000, 5_000, 4_000, 40, 0, 2_000), + Row(1_000, 3_000, 500, 40, 0, 0), + Row(45_000, 5_000, 20_000, 70, 0, 0), + Row(20_000, 5_000, 5_000, 15, 0, 0), + Row(46_000, 5_000, 0, 40, 3_000, 0), + Row(60_000, 1_500, 0, 40, 0, 0), +] + + +def _amount(maximum): + return st.floats(0, maximum, allow_nan=False, allow_infinity=False) + + +# Annual thresholds in the years tested lie near these amounts (secondary +# threshold, primary threshold, upper earnings limit), so pay near them makes +# the amount returned cross a threshold often. +near_a_threshold = st.builds( + lambda threshold, offset: max(threshold + offset, 0.0), + st.sampled_from([5_000.0, 6_000.0, 12_570.0, 15_100.0, 50_270.0, 60_600.0]), + st.floats(-12_000, 6_000, allow_nan=False, allow_infinity=False), +) +rows = st.lists( + st.builds( + Row, + pay=st.one_of(st.just(0.0), _amount(200_000), near_a_threshold), + sacrifice=st.one_of(st.just(0.0), _amount(60_000), _amount(6_000)), + more_sacrifice=_amount(30_000), + age=st.one_of( + st.sampled_from([15, 16, 17, 64, 65, 66, 67, 68, 69, 70]), + st.integers(10, 95), + ), + sick_pay=st.one_of(st.just(0.0), _amount(8_000)), + employer_pension=st.one_of(st.just(0.0), _amount(20_000)), + ), + min_size=1, + max_size=40, +) + + +def _simulate(people, world, nothing_returned=False): + """One single-person household for each of the people.""" + changes, years = WORLDS[world] + names = [f"person_{i}" for i in range(len(people))] + + def each_year(value): + return {str(year): value for year in years} + + situation = { + "people": { + name: { + "age": each_year(row.age), + "employment_income": each_year(row.pay), + "pension_contributions_via_salary_sacrifice": each_year(row.sacrifice), + "statutory_sick_pay": each_year(row.sick_pay), + "employer_pension_contributions": each_year(row.employer_pension), + } + for name, row in zip(names, people) + }, + "benunits": {f"benunit_{i}": {"members": [n]} for i, n in enumerate(names)}, + "households": {f"household_{i}": {"members": [n]} for i, n in enumerate(names)}, + } + simulation = Simulation(situation=situation, reform=changes) + if nothing_returned: + for year in years: + simulation.set_input( + "salary_sacrifice_returned_to_income", + year, + np.zeros(len(people), dtype=np.float32), + ) + return simulation + + +def _values(simulation, variable, year): + return np.asarray(simulation.calculate(variable, year), dtype=float) + + +def _class_1(simulation, instant): + parameters = simulation.tax_benefit_system.parameters(instant) + return parameters.gov.hmrc.national_insurance.class_1 + + +def _check_one_band_one_rate(simulation, year, returned): + """Invariant 4, for a year whose rates and thresholds do not change.""" + class_1 = _class_1(simulation, f"{year}-01-01") + thresholds = class_1.thresholds + primary_threshold = thresholds.primary_threshold * WEEKS_IN_YEAR + upper_earnings_limit = thresholds.upper_earnings_limit * WEEKS_IN_YEAR + secondary_threshold = thresholds.secondary_threshold * WEEKS_IN_YEAR + earnings = _values(simulation, "ni_class_1_income", year) + without = earnings - returned + # A pound clear of each threshold, so float32 rounding cannot move a + # person across it. + margin = 1 + bands = [ + ("employee", "ni_liable", earnings < primary_threshold - margin, 0), + ( + "employee", + "ni_liable", + (without > primary_threshold + margin) + & (earnings < upper_earnings_limit - margin), + class_1.rates.employee.main, + ), + ( + "employee", + "ni_liable", + without > upper_earnings_limit + margin, + class_1.rates.employee.additional, + ), + ( + "employer", + "ni_class_1_secondary_liable", + earnings < secondary_threshold - margin, + 0, + ), + ( + "employer", + "ni_class_1_secondary_liable", + without > secondary_threshold + margin, + class_1.rates.employer, + ), + ] + for side, liable, in_band, rate in bands: + applies = in_band & simulation.calculate(liable, year) + measure = _values(simulation, f"salary_sacrifice_pension_ni_{side}", year) + np.testing.assert_allclose( + measure[applies], rate * returned[applies], rtol=0, atol=TOLERANCE + ) + + +@pytest.mark.parametrize("world", list(WORLDS)) +@PROPERTY_SETTINGS +@example(KNOWN_CASES) +@given(rows) +def test_measures_equal_the_difference_of_complete_liabilities(world, drawn): + # The people as drawn, then the same people sacrificing more. + people = drawn + [ + row._replace(sacrifice=row.sacrifice + row.more_sacrifice) for row in drawn + ] + n = len(drawn) + with_returned = _simulate(people, world) + without_returned = _simulate(people, world, nothing_returned=True) + for year in WORLDS[world][1]: + returned = _values(with_returned, "salary_sacrifice_returned_to_income", year) + assert np.all( + _values(without_returned, "salary_sacrifice_returned_to_income", year) == 0 + ) + months = [ + _class_1(with_returned, f"{year}-{month:02d}-01") + for month in range(1, MONTHS_IN_YEAR + 1) + ] + highest_rate = { + "employee": max( + max(month.rates.employee.main, month.rates.employee.additional) + for month in months + ), + # ni_class_1_employer is a yearly variable: it reads the rate at + # the start of the year. + "employer": months[0].rates.employer, + } + for side, liable in ( + ("employee", "ni_liable"), + ("employer", "ni_class_1_secondary_liable"), + ): + measure = _values( + with_returned, f"salary_sacrifice_pension_ni_{side}", year + ) + liability = f"ni_class_1_{side}" + # 1. Difference of complete liabilities. + np.testing.assert_allclose( + measure, + _values(with_returned, liability, year) + - _values(without_returned, liability, year), + rtol=0, + atol=TOLERANCE, + ) + # 2. Bounds. + assert np.all(measure >= 0) + assert np.all(measure <= highest_rate[side] * returned + TOLERANCE) + # 3. Exact zeros. + assert np.all(measure[returned == 0] == 0) + assert np.all(measure[~with_returned.calculate(liable, year)] == 0) + # 5. Monotonicity. + assert np.all(measure[n:] >= measure[:n] - TOLERANCE) + # 4. One band, one rate. + if world == "current law": + _check_one_band_one_rate(with_returned, year, returned) + + +def test_known_cases_cross_each_threshold_and_age_condition(): + """The pinned people are the cases their comment says they are.""" + simulation = _simulate(KNOWN_CASES, "current law") + year = 2029 + thresholds = _class_1(simulation, f"{year}-01-01").thresholds + returned = _values(simulation, "salary_sacrifice_returned_to_income", year) + earnings = _values(simulation, "ni_class_1_income", year) + without = earnings - returned + + def crosses(threshold): + return (without < threshold * WEEKS_IN_YEAR) & ( + earnings > threshold * WEEKS_IN_YEAR + ) + + assert crosses(thresholds.upper_earnings_limit).tolist() == [ + True, + False, + False, + False, + False, + False, + True, + False, + ] + assert crosses(thresholds.primary_threshold)[1] + assert crosses(thresholds.secondary_threshold)[2] + assert returned[3] > 0 + assert earnings[3] < thresholds.secondary_threshold * WEEKS_IN_YEAR + liable = simulation.calculate("ni_liable", year) + secondary_liable = simulation.calculate("ni_class_1_secondary_liable", year) + assert not liable[4] and secondary_liable[4] + assert not liable[5] and not secondary_liable[5] + assert returned[7] == 0 + + +def test_rates_change_within_the_year_in_the_second_world(): + """The second world keeps the changes dated within 2041. + + If fiscal-year conversion came to cover 2041, the changes would be + replaced by one value for the year and the property above would no longer + test a liability whose rates differ between months. + """ + simulation = _simulate(KNOWN_CASES[:1], "rates change within the year") + january, december = ( + _class_1(simulation, f"2041-{month}-01") for month in ("01", "12") + ) + assert january.rates.employee.main != december.rates.employee.main + assert january.rates.employee.additional != december.rates.employee.additional + assert ( + january.thresholds.upper_earnings_limit + != december.thresholds.upper_earnings_limit + ) + + +def test_measures_are_deterministic(): + """Two builds of the same people give identical measures.""" + world = "current law" + first, second = _simulate(KNOWN_CASES, world), _simulate(KNOWN_CASES, world) + for year in WORLDS[world][1]: + for side in ("employee", "employer"): + variable = f"salary_sacrifice_pension_ni_{side}" + assert np.array_equal( + _values(first, variable, year), _values(second, variable, year) + ) From 385c547b5b975b8d75e331c0add5cf14352aae09 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 20:12:02 -0400 Subject: [PATCH 3/3] Correct the units of two shares, a pound amount and seven flags shareholding and consumption_shareholding return a share of a national total but declared pounds; free_tv_licence_value returns pounds but declared a ratio; seven true/false variables declared a unit. The unit also sets the default quantity type in policyengine-core: a float with unit "/1" is a stock, any other float a flow, and a yearly flow read for a month is divided by twelve. So a month's read of a share was a twelfth of the share, and a month's read of the free TV licence value was the whole year's. Every reader of these variables in the model reads them for a year, so no yearly value changes. Refs #2362 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2362.fixed.md | 1 + policyengine_uk/tests/test_variable_units.py | 73 +++++++++++++++++++ .../bbc/tv_licence/free_tv_licence_value.py | 2 +- .../is_guarantee_credit_eligible.py | 1 - .../is_pension_credit_eligible.py | 1 - .../is_savings_credit_eligible.py | 1 - .../variables/gov/hmrc/sdlt_liable.py | 1 - .../gov/revenue_scotland/lbtt_liable.py | 1 - .../consumption/consumption_share.py | 2 +- .../consumption/rent/benunit_pays_rent.py | 1 - .../household/wealth/shareholding.py | 2 +- .../input/vehicle/car_is_ved_exempt.py | 1 - 12 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 changelog.d/2362.fixed.md create mode 100644 policyengine_uk/tests/test_variable_units.py diff --git a/changelog.d/2362.fixed.md b/changelog.d/2362.fixed.md new file mode 100644 index 0000000000..af6bf90fa1 --- /dev/null +++ b/changelog.d/2362.fixed.md @@ -0,0 +1 @@ +- Corrected units: `shareholding` and `consumption_shareholding` are shares (`/1`, so a month's value is no longer a twelfth of the share), `free_tv_licence_value` is in pounds, and seven true/false variables (`sdlt_liable`, `lbtt_liable`, `benunit_is_rent_liable`, the three Pension Credit eligibility flags and `car_is_ved_exempt`) no longer declare a unit. No yearly value changes. diff --git a/policyengine_uk/tests/test_variable_units.py b/policyengine_uk/tests/test_variable_units.py new file mode 100644 index 0000000000..44589b0750 --- /dev/null +++ b/policyengine_uk/tests/test_variable_units.py @@ -0,0 +1,73 @@ +"""Units of variables whose values are shares, pound amounts or flags. + +A variable's unit is served in the model metadata and decides how a value is +displayed: a "/1" variable as a percentage, a currency variable in pounds. In +policyengine-core it also sets the default quantity type. A float with the +unit "/1" is a stock and any other float is a flow, and a yearly flow read +for a month is divided by twelve, where a yearly stock is not. + +So a share must declare "/1" (a month's share of the national total is the +share, not a twelfth of it), a pound amount must declare pounds, and a +true/false variable declares no unit. +""" + +import pytest + +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.model_api import FLOW, GBP, MONTHS_IN_YEAR, STOCK + +YEAR = 2025 +SHARES = ["shareholding", "consumption_shareholding"] + + +@pytest.fixture(scope="module") +def variables(): + return CountryTaxBenefitSystem().variables + + +def _household(variable, value): + """One household with the variable entered directly for the year.""" + return Simulation( + situation={ + "people": {"person": {"age": {YEAR: 40}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": {"members": ["person"], variable: {YEAR: value}} + }, + } + ) + + +@pytest.mark.parametrize("share", SHARES) +def test_a_share_declares_a_ratio_and_is_a_stock(variables, share): + assert variables[share].unit == "/1" + assert variables[share].quantity_type == STOCK + + +@pytest.mark.parametrize("share", SHARES) +def test_a_share_read_for_a_month_is_the_share(share): + simulation = _household(share, 0.25) + assert simulation.calculate(share, YEAR).tolist() == [0.25] + assert simulation.calculate(share, f"{YEAR}-01").tolist() == [0.25] + + +def test_free_tv_licence_value_is_in_pounds_and_is_a_flow(variables): + assert variables["free_tv_licence_value"].unit == GBP + assert variables["free_tv_licence_value"].quantity_type == FLOW + + +def test_free_tv_licence_value_read_for_a_month_is_a_twelfth(): + simulation = _household("free_tv_licence_value", 180) + assert simulation.calculate("free_tv_licence_value", YEAR).tolist() == [180] + assert simulation.calculate("free_tv_licence_value", f"{YEAR}-01").tolist() == [ + 180 / MONTHS_IN_YEAR + ] + + +def test_no_true_or_false_variable_declares_a_unit(variables): + with_a_unit = { + name: variable.unit + for name, variable in variables.items() + if variable.value_type is bool and variable.unit is not None + } + assert with_a_unit == {} diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py index 6626fc9842..c07c8118c3 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py @@ -6,7 +6,7 @@ class free_tv_licence_value(Variable): entity = Household definition_period = YEAR value_type = float - unit = "/1" + unit = GBP reference = ( "https://www.legislation.gov.uk/ukpga/2003/21/section/365A", "https://www.tvlicensing.co.uk/reducedfee", diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/is_guarantee_credit_eligible.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/is_guarantee_credit_eligible.py index 094f112c06..1c7d1aac45 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/is_guarantee_credit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/is_guarantee_credit_eligible.py @@ -6,7 +6,6 @@ class is_guarantee_credit_eligible(Variable): entity = BenUnit definition_period = YEAR value_type = bool - unit = GBP reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/section/2", "https://www.legislation.gov.uk/nia/2002/14/section/2", diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py b/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py index d970390993..7dc9128a1c 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py @@ -6,7 +6,6 @@ class is_pension_credit_eligible(Variable): entity = BenUnit definition_period = YEAR value_type = bool - unit = GBP reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/section/1", "https://www.legislation.gov.uk/ukpga/2002/16/section/4", diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py index a7263e8b74..526f3fa347 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py @@ -6,7 +6,6 @@ class is_savings_credit_eligible(Variable): entity = BenUnit definition_period = YEAR value_type = bool - unit = GBP reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/3" def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py index e609d02667..ed88a8d32d 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py @@ -7,7 +7,6 @@ class sdlt_liable(Variable): entity = Household definition_period = YEAR value_type = bool - unit = GBP def formula(household, period): country = household("country", period) diff --git a/policyengine_uk/variables/gov/revenue_scotland/lbtt_liable.py b/policyengine_uk/variables/gov/revenue_scotland/lbtt_liable.py index a9c1487c6b..e4cc9daacc 100644 --- a/policyengine_uk/variables/gov/revenue_scotland/lbtt_liable.py +++ b/policyengine_uk/variables/gov/revenue_scotland/lbtt_liable.py @@ -9,7 +9,6 @@ class lbtt_liable(Variable): entity = Household definition_period = YEAR value_type = bool - unit = GBP def formula(household, period): country = household("country", period) diff --git a/policyengine_uk/variables/household/consumption/consumption_share.py b/policyengine_uk/variables/household/consumption/consumption_share.py index 3646ee73fb..3df735698b 100644 --- a/policyengine_uk/variables/household/consumption/consumption_share.py +++ b/policyengine_uk/variables/household/consumption/consumption_share.py @@ -7,7 +7,7 @@ class consumption_shareholding(Variable): entity = Household definition_period = YEAR value_type = float - unit = GBP + unit = "/1" def formula(household, period, parameters): consumption = household("consumption", period) diff --git a/policyengine_uk/variables/household/consumption/rent/benunit_pays_rent.py b/policyengine_uk/variables/household/consumption/rent/benunit_pays_rent.py index 62dad6cbe1..93c636f573 100644 --- a/policyengine_uk/variables/household/consumption/rent/benunit_pays_rent.py +++ b/policyengine_uk/variables/household/consumption/rent/benunit_pays_rent.py @@ -6,7 +6,6 @@ class benunit_is_rent_liable(Variable): entity = BenUnit label = "Whether the benefit unit is liable to pay rent" definition_period = YEAR - unit = GBP def formula(benunit, period, parameters): rent = benunit("benunit_rent", period) diff --git a/policyengine_uk/variables/household/wealth/shareholding.py b/policyengine_uk/variables/household/wealth/shareholding.py index dcf773f8c3..96ac7fbc96 100644 --- a/policyengine_uk/variables/household/wealth/shareholding.py +++ b/policyengine_uk/variables/household/wealth/shareholding.py @@ -7,7 +7,7 @@ class shareholding(Variable): entity = Household definition_period = YEAR value_type = float - unit = GBP + unit = "/1" def formula(household, period, parameters): wealth = household("corporate_sector_wealth", period) diff --git a/policyengine_uk/variables/input/vehicle/car_is_ved_exempt.py b/policyengine_uk/variables/input/vehicle/car_is_ved_exempt.py index 7599208f45..38293a8baa 100644 --- a/policyengine_uk/variables/input/vehicle/car_is_ved_exempt.py +++ b/policyengine_uk/variables/input/vehicle/car_is_ved_exempt.py @@ -6,4 +6,3 @@ class car_is_ved_exempt(Variable): entity = Person definition_period = YEAR label = "Car has a vehicle excise duty exemption" - unit = "/1"