Skip to content

Limit salary sacrifice to the pay it comes out of - #2063

Merged
MaxGhenis merged 4 commits into
mainfrom
salary-sacrifice-needs-pay
Oct 10, 2026
Merged

MaxGhenis merged 4 commits into
mainfrom
salary-sacrifice-needs-pay

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

From 6 April 2029 the salary sacrifice above the £2,000 cap returns to employment income. The formula read the salary sacrifice input alone, so a sacrifice with no pay behind it gave a person without pay employment income. This PR counts a salary sacrifice only for people with pay.

The current enhanced FRS has such records: PolicyEngine/policyengine-uk-data#518 fixes the imputation that created them. On data built with that fix, this PR changes nothing.

Change

  • New pension_contributions_via_salary_sacrifice_from_pay: max(sacrifice, 0) where employment_income_before_lsr > 0, otherwise 0.
  • salary_sacrifice_returned_to_income and pension_contributions_via_salary_sacrifice_adjusted read it instead of the raw input. No other production formula reads the raw input (the microsimulation tests still do).
  • The sacrifice is not limited to the pay that remains. HMRC allows sacrificing, for example, a whole bonus, as long as cash pay stays above the National Minimum Wage. The first version of this PR capped it at remaining pay; review r1 caught that.

Invariants

  • The sacrifice from pay is max(sacrifice, 0) for a person with pay and 0 otherwise.
  • The capped sacrifice plus the amount returned to income equals the sacrifice from pay.
  • A person without pay has no employment income, nothing returned and no National Insurance charge on a returned amount, whatever their sacrifice input.
  • Nothing is returned before the cap starts.
  • In the tested baseline households (no labour supply or employer NI incidence response), employment income equals pay plus the amount returned plus the broad-base haircut.
  • For a person with pay, the amount returned is the sacrifice above the cap, as before.
  • The amount returned never falls when the sacrifice or the pay rises.

Tests

  • YAML (13 new cases in gov/hmrc/pensions): no pay with a sacrifice input gives no employment income in 2029 and 2030; a £40,000 salary with a £50,000 bonus sacrifice keeps the full sacrifice and returns £48,000 in 2030; low pay with a large sacrifice; negative inputs; nothing before 2029. The four no-pay cases fail on the old formulas.
  • Hypothesis properties for the invariants above, run through policyengine_uk.Simulation, plus an example that setting employment_income after construction keeps the sacrifice.
  • Existing salary sacrifice YAML cases now set employment_income_before_lsr beside employment_income, because the YAML runner does not copy one to the other as policyengine_uk.Simulation does. Their expected values are unchanged.
  • test_full_excess_redirected measures the excess on the sacrifice of people with pay.
  • Targeted runs on the merged branch: 109 YAML cases (the pensions, National Insurance and employer NI directories) and 3 property tests pass.

Impact

Each figure is from one real Microsimulation per package state and dataset: main at 8b2e6e1 against this branch's formulas at 7ca3bcb. The CI-dataset "after" figure is from the branch after merging current main (148e490), so it also carries main's other changes; it stays inside the cap test's £0.8bn to £2.8bn range.

On data with the uk-data#518 fix (local production-settings build): every saved numeric output array is identical with and without this PR in 2025, 2028, 2029 and 2030.

On the published enhanced FRS (1.56.16), where 1,528 records (1.08m people, weighted, in 2029) have a salary sacrifice and no pay:

2025 2028 2029 2030
People without pay who have employment income, before → after 0 → 0 0 → 0 874k → 0 878k → 0
Employment income 0 0 −£9.81bn −£9.85bn
Income tax 0 −£57m −£204m −£206m
Employee National Insurance 0 0 −£276m −£271m
Employer National Insurance 0 0 −£903m −£907m
Universal Credit 0 0 −£82m −£80m
Household net income 0 +£57m +£409m +£409m
Government balance 0 −£57m −£1.31bn −£1.32bn
  • In 2029, 288k households gain and 43k lose. No household without an affected person changes by more than £0.01.
  • Universal Credit falls because phantom pay can exempt a family from the benefit cap. In a single-household check, a lone parent with no pay and a £27,000 sacrifice input was exempt in 2029, with UC £31,273 instead of £21,845. The returned amount added nothing to UC earned income, since it is also an employee pension contribution. I did not count exemption transitions across the dataset.
  • The 2028 change is annual allowance charges on phantom sacrifices.
  • Among income-related ESA reporters, 106 records (27k weighted) had phantom pay in 2029. Apply the income-related ESA and income-based JSA remunerative work conditions #2031's permitted-work earnings test reads that pay, which is why its 2029-30 impact on this file includes phantom-pay cases.

The salary sacrifice cap's own estimate (government balance, a current-law simulation against one with the cap set to infinity, same dataset and package, 2029):

Dataset Before this PR After
Published 1.56.16 +£1.20bn −£0.02bn
Local build of uk-data main +£1.11bn not run
Local build with uk-data#518 +£1.52bn +£1.52bn
CI test dataset (2023-24, 1.40.3), 2030 +£1.51bn +£1.51bn

On the published 1.56.16 file most of the excess over the cap sits with people without pay (£9.8bn of £14.7bn), so with this PR and without the data fix the local estimate on that file is about zero. On the local build with uk-data#518, which recalibrates with the salary sacrifice on people with pay, the estimate is +£1.52bn. Until a data release includes uk-data#518, model estimates of the cap on the 1.56.16 file are low.

Known gap

employment_income_before_lsr is filled from employment_income when a Simulation is built. A simulation built with no pay whose pay is set afterwards, and the YAML runner given only employment_income, see no pay and so no sacrifice. The marginal tax rate branches and the participation response's changes for existing workers keep the original pay, so a worker's sacrifice is unaffected. Participation entrants, who had no pay at construction, get no sacrifice: that is the input this PR guards against.

Review

  • r1 (GPT-6.1 Sol): request changes. The cap at remaining pay was blocking and is fixed. The later-pay-input path was blocking; r2 judged it partly resolved and no longer blocking (the known gap above).
  • r2 at 7ca3bcb: approve, with the stale NI YAML headers as a should-fix; fixed in 823477c.
  • r3 (GPT-6.1 Sol) at 148e490: approve; its description corrections are applied here. It reran 99 pensions and NI YAML cases and the 3 property tests.

axiom: n/a: microsimulation input guard (a salary sacrifice input with no pay behind it); the £2,000 cap rule is unchanged

🤖 Generated with Claude Code

MaxGhenis and others added 4 commits October 2, 2026 08:20
From 6 April 2029 salary_sacrifice_returned_to_income adds the sacrifice
above the £2,000 cap to employment_income. The formula read the input
pension_contributions_via_salary_sacrifice alone, so a sacrifice with no
pay behind it gave a person without a job employment income, National
Insurance on it, and lower means-tested benefits. The enhanced FRS has
such records (policyengine-uk-data imputed a sacrifice to people without
pay), which inflated 2029-30 results.

- pension_contributions_via_salary_sacrifice_from_pay: the input limited
  to employment_income_before_lsr (zero without pay).
- salary_sacrifice_returned_to_income and
  pension_contributions_via_salary_sacrifice_adjusted read it.
- YAML: no pay and a sacrifice input gives no employment income in 2029
  and 2030; partial pay; unchanged within pay; nothing before 2029. Six
  of the new cases fail on the old formulas.
- Existing salary sacrifice YAML cases set employment_income_before_lsr
  beside employment_income: the YAML runner does not copy one to the
  other as policyengine_uk.Simulation does. Expected values unchanged.
- Hypothesis properties through policyengine_uk.Simulation: bounds,
  conservation, no pay no gain, accounting, unchanged within pay,
  monotonicity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review (subfleet 20261002-093645-peuk-2063-ss-review) found that limiting
the sacrifice to the remaining pay is not the law: HMRC allows a whole
bonus to be sacrificed as long as cash pay stays above the National
Minimum Wage, so a £40,000 salary with a £50,000 bonus sacrifice is
valid. The guard now only zeroes a sacrifice with no pay behind it.

- pension_contributions_via_salary_sacrifice_from_pay: max(sacrifice, 0)
  where employment_income_before_lsr > 0, else 0.
- YAML: whole-bonus sacrifice above the remaining salary kept in full and
  its full excess returned in 2030; negative sacrifice input; low pay
  with a large sacrifice.
- Properties: definition, conservation, no pay no gain, accounting,
  unchanged for people with pay, monotonicity; conditions use the pay the
  model stores (float32). New example: setting employment_income after
  construction, as the marginal tax rate branches and the participation
  response do, keeps the sacrifice (the original pay stays in
  employment_income_before_lsr).
- test_full_excess_redirected measures the excess on the sacrifice of
  people with pay.
- Changelog fragment in the past tense.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r2 (subfleet 20261003-090152-peuk-2063-ss-review-r2, APPROVE):
- The two NI YAML headers still described the removed limit to remaining
  pay; they now say salary sacrifice counts only for people with pay.
- The property test checks that the pay the model stores is the pay put
  in, so it cannot pass if Simulation stopped moving employment_income
  to employment_income_before_lsr.
- Changelog: "people without pay", not "without a job".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 10, 2026 21:24
@MaxGhenis
MaxGhenis merged commit 88d4ff4 into main Oct 10, 2026
6 checks passed
@MaxGhenis
MaxGhenis deleted the salary-sacrifice-needs-pay branch October 10, 2026 21:24
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Merged at reviewed head 148e490 (merge commit 88d4ff4). Gates: gh pr checks exit 0 on the latest run, MERGEABLE, no CHANGES_REQUESTED. Independent reviews by GPT-6.1 Sol: r1 request changes (addressed), r2 approve at 7ca3bcb, r3 approve at 148e490 (its description corrections applied). Main had 551 commits since the branch's last merge; none touch the guard's files and the branch merged cleanly. Impact is in the description: inert on data with PolicyEngine/policyengine-uk-data#518; on the published 1.56.16 file it removes 2029-30 employment income from people without pay. Bug fix under Max's 2026-10-02 rule (aggregates move, no approval needed).

MaxGhenis added a commit that referenced this pull request Oct 11, 2026
Brings in #2258 (120-minute Test job), #2180 (NICs thresholds indexed by
September CPI after 2030-31), #2063 (salary sacrifice needs pay), #2038
(winter payments to the member a benefit is paid to), #2259, #2248,
#2251, #2262, #2266, #2270, #2285, #2288, #2294 and #2021.

Conflict: test_legacy_award_readers_properties.py imports. Kept both
hypothesis.event (this branch's screened-out events) and
hypothesis.example (#2294's found families). The rest of the file merged
cleanly: #2294's without/silent/with-other comparison, the severe
disability premium bound and the held benefit cap sit alongside this
branch's work generators, screened reference and outside-member tests.
Two comments now say the ESA and JSA readers also read the members' work.

_legacy_award_payee.py merged cleanly: #2038's can_claim (Pension Credit)
and this branch's own_report_is_paid are independent. The shared NI
parameters already match main (#2058); #2180 changes only 2031-32 onward.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request Oct 11, 2026
…2259, #2262, #2285) into benefit-cap-75f-claimant-partner

The payee helper takes both main's can_claim (Pension Credit) and this
branch's couple (the benefit cap's couple).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant