Skip to content

Make the salary sacrifice NI measures equal the change in Class 1 liability, and correct mis-declared units - #2363

Draft
MaxGhenis wants to merge 3 commits into
mainfrom
fix-salary-sacrifice-ni-measures-and-units
Draft

MaxGhenis wants to merge 3 commits into
mainfrom
fix-salary-sacrifice-ni-measures-and-units

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2361
Fixes #2362

Two defects found by the independent specification of the tax-head partition written for #2235 (its defects 7 and 8). I confirmed both in the code on main before changing anything.

1. Salary sacrifice NI measures (#2361)

What was wrong

salary_sacrifice_pension_ni_employee multiplied the whole amount returned to income (salary_sacrifice_returned_to_income) by one rate, chosen by whether employment_income was at or below the upper earnings limit. salary_sacrifice_pension_ni_employer multiplied it by the employer rate. Neither applied the Class 1 thresholds or age conditions, so neither matched what the model charges on that amount.

What changes

Each measure is now the complete Class 1 liability on earnings less the liability on the same earnings without the amount returned.

  • Employee: against ni_class_1_employee, month by month because that variable is monthly, and only for people liable to primary contributions (defined_for = "ni_liable").
  • Employer: against ni_class_1_employer, yearly as that variable is, only where secondary contributions are due (defined_for = "ni_class_1_secondary_liable"), with employer pension contributions in the base when gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions is false.

Both stay reporting measures. No variable in the model reads either one (git grep), so no liability and no tax total changes.

Ten single-person cases, run on main and on this branch (2025 thresholds, a £2,000 cap, no broad-base haircut; examples.py in the evidence folder). The "liability difference" column comes from the model's own liability variables in two simulations, with and without the amount returned:

Case Side Main This PR Liability difference
Pay £48,000, £6,000 sacrificed (crosses the upper earnings limit) employee £80.00 £216.20 £216.20
Pay £46,000 and £3,000 statutory sick pay, £5,000 sacrificed employee £240.00 £136.20 £136.20
Pay £11,000, £5,000 sacrificed (crosses the primary threshold) employee £240.00 £114.40 £114.40
Pay £6,000, £5,000 sacrificed (below the primary threshold) employee £240.00 £0.00 £0.00
Age 70, pay £45,000, £5,000 sacrificed employee £240.00 £0.00 £0.00
Age 15, pay £20,000, £5,000 sacrificed employee £240.00 £0.00 £0.00
Pay £3,000, £5,000 sacrificed (crosses the secondary threshold) employer £450.00 £150.00 £150.00
Pay £1,500, £5,000 sacrificed (below the secondary threshold) employer £450.00 £0.00 £0.00
Age 15, pay £20,000, £5,000 sacrificed employer £450.00 £0.00 £0.00
Research post example: pay £45,000 after £5,000 sacrificed employee £240.00 £240.00 £240.00
Pay £80,000, £5,000 sacrificed (all above the upper earnings limit) employee £60.00 £60.00 £60.00

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. 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.
  3. Exact zeros. A measure is exactly zero when nothing is returned (so in every year before the cap), and for a person on whom that side's contributions are not due.
  4. One band, one rate. When earnings with and without the amount returned lie in one band, the measure is that band's rate times the amount returned. This is where the old formulas were right.
  5. Monotonicity. A measure never falls when the sacrifice rises.
  6. Determinism. Two builds of the same people give identical values.

Tests

  • policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py (Hypothesis). Invariant 1 is a differential test: the "without" side is a second simulation of the same people with salary_sacrifice_returned_to_income set to zero, computed by the model's liability variables, not by the formulas under test. It runs under current law (2028, 2029, 2030) and in a second world where the employee rates and the upper earnings limit change within 2041 and employer pension contributions are charged. A year after 2040 is needed for that: fiscal-year conversion gives every parameter one value per year from 2015 to 2040.
  • 14 new YAML cases (8 employee, 6 employer) with hand-computed expectations.
  • On main's formulas the property test fails in both worlds (on the pinned people: 14 of 16 differ, the largest gap £640) and 11 of the 14 new YAML cases fail. The three that pass on both are cases where the old single rate was right.

National totals (real run)

Pending. A base-and-head microsimulation on the Enhanced FRS (2029 and 2030, current law, plus a head run with nothing returned) is queued behind the shared heavy-run lock. It will report the national totals of both measures on main and here, their agreement with the liability difference, and that no tax total, household_net_income or hbai_household_net_income moves.

Where the measures are used, and whether a published figure moves

No published figure moves.

  • The research post (uk-salary-sacrifice-cap, the reference on both variables) does not use either variable. Its revenue and distributional figures come from uk-salary-sacrifice-analysis, which takes the change in gov_balance and household_net_income between two simulations (src/salary_sacrifice/analysis.py, no_behavioral.py) and never calculates the measures. Its worked example (£240 of employee NI on a £3,000 excess for a £50,000 earner) has earnings below the upper earnings limit, so the corrected measure gives the same £240; that case is now a YAML test. The post's "£2.1 billion in additional employer NI (at 15%)" is arithmetic in the post (£13.8 billion × 15%), not a read of the variable.
  • salary_sacrifice_broad_base_haircut_rate (0.0016) is a fixed parameter derived from that arithmetic. It does not read the employer measure, so it does not move.
  • policyengine-scorecard records both measures as head_variables of its Autumn Budget 2025 measure (pipeline/build_ab2025_registry.py), so its result files hold their totals (results/uk/ab2025/ab2025__salary_sacrifice_pension_nics_cap_2000_*.json, totals.*.heads; for 2029 at policyengine-uk 2.89.2: £216.1m employee, £1,406.7m employer). Those files are pinned to that release and do not change. If that lane is recomputed on a release with this fix, those two recorded totals will change. No comparison value is computed from them: the staged exchequer effect is the change in gov_tax less gov_spending (pipeline/stage_uk_ab2025.py), and the lane that compares with the OBR's NICs costing reads the complete liabilities ni_class_1_employee, ni_class_1_employer and ni_class_4 (pipeline/compute_uk_obr_costings.py, NI_HEAD_VARIABLES).
  • Model, API, app: nothing in policyengine-uk reads the measures, and policyengine-api, policyengine-api-v2, policyengine.py, policyengine-uk-data and policyengine-app-v2 do not name them (git grep on each default branch).

2. Units (#2362)

Variable Returns Unit on main Unit here Quantity type
shareholding share of corporate-sector wealth GBP /1 flow → stock
consumption_shareholding share of UK consumption GBP /1 flow → stock
free_tv_licence_value pounds (licence fee × discount) /1 GBP stock → flow
sdlt_liable, lbtt_liable, benunit_is_rent_liable, is_pension_credit_eligible, is_guarantee_credit_eligible, is_savings_credit_eligible true/false GBP none stock (unchanged)
car_is_ved_exempt true/false /1 none stock (unchanged)

The brief named the first three and sdlt_liable. The other six true/false variables have the same defect, and test_variable_units.py now asserts that no true/false variable declares a unit.

The unit is not only metadata

In policyengine-core the unit sets the default quantity_type: a float with unit /1 is a stock and any other float is a flow (variables/variable.py), and a yearly flow read for a month is divided by twelve while a yearly stock is not (simulations/simulation.py). So on main a month's read of shareholding returned a twelfth of the share, and a month's read of free_tv_licence_value returned the whole year's pounds. With 0.25 and £180 entered for a year (unit_semantics.py in the evidence folder):

Variable Month's read on main Month's read here
shareholding 0.0208 0.25
consumption_shareholding 0.0208 0.25
free_tv_licence_value 180 15

No yearly value changes. Every reader of these three variables is a yearly variable that reads them for the year: corporate_sdlt, baseline_business_rates, carbon_tax and corporate_incident_tax_revenue_change read shareholding; hbai_household_net_income adds free_tv_licence_value; nothing reads consumption_shareholding. None is a dataset column or set with set_input in the package. A true/false variable is a stock whatever its unit.

Downstream

  • policyengine-api serves variable.unit unchanged in the metadata (policyengine_api/country.py, build_variables).
  • policyengine-app-v2 formats by it: formatVariableValue (app/src/utils/householdValues.ts) and formatChartValueForVariable (app/src/utils/chartValueFormatting.ts) show a /1 variable as a percentage and a currency-* variable as money, and VariableInput.tsx multiplies a /1 input by 100. So free_tv_licence_value will display in pounds rather than as a percentage, the two shares as percentages rather than pounds, and the flags without a pound sign.
  • policyengine.py copies unit and quantity_type into its variable records (tax_benefit_models/common/model_version.py) and does not branch on them.
  • No code in those repos, policyengine-api-v2, policyengine-household-api or policyengine-uk-data names any of the ten variables, so nothing depends on the old units.

Overlap with open PRs

Out of scope

Other float variables declare a unit that does not match what they return (for example income_elasticity_lsr and substitution_elasticity_lsr in pounds). Because the unit sets the quantity type, each needs its readers checked; that audit is a separate task, not this PR.

axiom: n/a: reporting measures and unit metadata only; no liability, parameter or eligibility rule changes.

Commands run

uv run --extra dev ruff format . && uv run --extra dev ruff check .
uv run policyengine-core test <national_insurance, pensions, stamp_duty, dcms, dwp/pension_credit, consumption/rent and dft/vehicle_excise_duty YAML folders> -c policyengine_uk   # 440 passed
uv run --extra dev pytest policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py   # 5 passed, 83 s
uv run --extra dev pytest policyengine_uk/tests/test_variable_units.py   # 7 passed
uv run --extra dev pytest policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py policyengine_uk/tests/test_corporate_land_value.py   # 7 passed

The full suite (including test_build_metadata.py) and the microsimulation tests were left to CI.

Evidence (scripts, run outputs, review): ~/reviews/uk-salary-sacrifice-ni-measures-2361/.

🤖 Generated with Claude Code

Reproducer: policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml, policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml, policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py::test_measures_equal_the_difference_of_complete_liabilities, policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py::test_known_cases_cross_each_threshold_and_age_condition, policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py::test_rates_change_within_the_year_in_the_second_world, policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py::test_measures_are_deterministic

MaxGhenis and others added 3 commits October 11, 2026 20:00
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 <noreply@anthropic.com>
…ence

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shares, a pound amount and true/false variables declare the wrong unit Salary sacrifice NI measures charge the whole amount returned at one rate

1 participant