Repository navigation
Limit salary sacrifice to the pay it comes out of - #2063
Merged
Merged
Conversation
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>
Collaborator
Author
|
Merged at reviewed head 148e490 (merge commit 88d4ff4). Gates: |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
pension_contributions_via_salary_sacrifice_from_pay:max(sacrifice, 0)whereemployment_income_before_lsr > 0, otherwise 0.salary_sacrifice_returned_to_incomeandpension_contributions_via_salary_sacrifice_adjustedread it instead of the raw input. No other production formula reads the raw input (the microsimulation tests still do).Invariants
max(sacrifice, 0)for a person with pay and 0 otherwise.Tests
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.policyengine_uk.Simulation, plus an example that settingemployment_incomeafter construction keeps the sacrifice.employment_income_before_lsrbesideemployment_income, because the YAML runner does not copy one to the other aspolicyengine_uk.Simulationdoes. Their expected values are unchanged.test_full_excess_redirectedmeasures the excess on the sacrifice of people with pay.Impact
Each figure is from one real
Microsimulationper package state and dataset:mainat 8b2e6e1 against this branch's formulas at 7ca3bcb. The CI-dataset "after" figure is from the branch after merging currentmain(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:
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):
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_lsris filled fromemployment_incomewhen aSimulationis built. A simulation built with no pay whose pay is set afterwards, and the YAML runner given onlyemployment_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
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