Skip to content

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

Open
MaxGhenis wants to merge 7 commits into
mainfrom
salary-sacrifice-needs-pay
Open

MaxGhenis wants to merge 7 commits into
mainfrom
salary-sacrifice-needs-pay

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The salary sacrifice imputation gave people without pay a salary sacrifice, and converted pension contributors to salary sacrifice without taking the sacrifice out of their pay. This PR limits every amount to the person's pay and takes stage 2's sacrifice out of the donor's pay.

In a build of current main (2b9c1c2), 1,891 records (0.96m people, weighted, 2025) hold £7.7bn of the £28.1bn salary sacrifice total while having no pay. With this PR there are none.

The defects

Stage 1 drew a sacrifice for people without pay.

  • It runs the QRF (age, employment income) for everyone not asked SALSAC and kept positive draws even where employment income is zero.
  • microimpute predicts each row at a random quantile of the forest's conditional distribution. The forest learns the zero-pay case from the few SALSAC respondents with a job but no recorded pay, a minority of whom report a sacrifice, so a share of all unpaid adults drew one of those reported amounts.
  • Before Limit salary sacrifice to the pay it comes out of policyengine-uk#2063, the model returned such a sacrifice above the £2,000 cap to employment income from April 2029. That can generate employment income and National Insurance liabilities for a person without pay, and can trigger an earnings-based benefit cap exemption. #2063 (merged) makes the model ignore a sacrifice with no pay behind it; the amounts still sit in the dataset and in its calibration.

Stage 2 left the donor's pay unchanged.

  • It converts a pension contributor's employee contribution into salary sacrifice. FRS pay is before that deduction, and policyengine-uk treats employment income as pay after any sacrifice.
  • A converted donor therefore kept the same pay and lost the relief on the contribution.

The first version of the limit read uprated pay. A Microsimulation of the 2024 dataset calculates 2025 by default and uprates pay by 5.3% but not salary sacrifice. Review r1 caught it.

The fix

  • limit_salary_sacrifice_to_pay: every amount, reported or imputed, is between zero and the person's stored pay. The upper bound is an imputation bound, not a legal one: of the 300 FRS 2024-25 SALSAC respondents who report a sacrifice, none with pay reports more than that pay (out/ss_facts4_base_frs.txt).
  • The QRF trains only on SALSAC respondents with pay. Its cache file is renamed so an old forest is not reused.
  • Stage 2 takes the amount it moves out of the donor's pay, and moves at most half the pay so the sacrifice never exceeds the pay that remains. Any remainder stays as employee pension.

Invariants

  • No salary sacrifice without pay, and none above pay.
  • The limit is idempotent, leaves amounts within pay unchanged, and is non-decreasing in the sacrifice and in the pay.
  • For every stage-2 donor, employee pension plus salary sacrifice is unchanged and pay plus salary sacrifice is unchanged. Nobody else's pay changes.
  • Where policyengine-uk has its own guard, it never binds on the dataset.

Tests

  • Hypothesis properties of the limit, and of the whole imputation with a stub model.
  • Wiring tests at the 2024 survey year and at 2025. The 2024 cases fail on the commit that limited against uprated pay.
  • Built-dataset checks: nobody has a sacrifice without pay or above it; nobody without pay has employment income in 2029; the model's guard equals the stored sacrifice (skipped until the installed policyengine-uk has the variable).

Evidence

Two production-settings builds (512 epochs) on one machine: main at 2b9c1c2 and this branch at 99916cb (this PR merged with that main). Both retrained every imputation model, and used the same frozen HTTP target downloads and a seeded torch generator. I did not build the same commit twice, so differences between the two builds include whatever the calibration path does when its inputs change. Model figures use policyengine-uk main at 4857dc75f (which includes #2063), one real Microsimulation per dataset.

Before calibration the two datasets differ in three person columns only: salary sacrifice, employee pension contributions and employment income.

Before calibration, 2025, prior weights main This PR
Salary sacrifice, total £35.6bn £17.4bn
of which held by people without pay £21.0bn £0
Stage-2 donors (weighted) 2.65m 4.00m
Pay moved into their sacrifice £0 £9.0bn

Holding those weights fixed, modelled 2025 income tax by stage-2 cohort (aggregates in stage2_cohorts4.json):

Cohort People (weighted) Pay Income tax
Donors main already converts 2.65m −£6.0bn −£1.96bn
Donors this PR adds 1.34m −£3.0bn −£0.03bn

The second row shows the conversion is close to income-tax-neutral once pay falls with it (those donors' National Insurance falls, as it does under a sacrifice). The first row is the same correction applied to donors whose pay main left unchanged.

After calibration (saved file, 2025):

main This PR
Records with a sacrifice and no pay 1,891 (0.96m weighted) 0
Records with a sacrifice above their pay 8 0
Records where #2063's guard binds 1,891 (£7.7bn) 0
Salary sacrifice £28.1bn £25.4bn
Salary sacrifice users 7.99m 8.08m

Calibration fit, 645 national targets at the last logged epoch of the constituency solve (whose weights the saved file carries):

Target (2025) main This PR
Targets within 5% / 10% 64.2% / 86.8% 65.0% / 86.7%
Salary sacrifice contributions £24.7bn +18.4% +7.0%
Income tax relief, basic rate £1.65bn +2.3% +4.3%
Income tax relief, higher rate £5.67bn +0.1% +2.5%
Income tax relief, additional rate £1.85bn +2.6% +3.4%
Employee NICs relief £1.03bn −2.0% +2.3%
Employer NICs relief £3.81bn +2.5% +4.2%
Users, total 7.88m +1.8% +2.9%
Users at or below £2,000 4.40m +3.2% +4.0%
Users above £2,000 3.38m +3.0% +4.6%
OBR income tax £331bn −11.2% −11.1%
OBR employee / employer NICs £49.5bn / £145.3bn +0.7% / +4.6% +0.7% / +4.6%
Employment income by band (26 targets), mean absolute error 12.8% 12.7%
  • With this PR the relief and headcount targets move by 0.8 to 4.2 percentage points (employee NICs relief from −2.0% to +2.3%); all stay within 5%.
  • The logged contributions figure is higher than what a user of the saved file gets. Calibration evaluates the dataset uprated to 2025; policyengine-uk loads the 2024 file and does not uprate salary sacrifice (at 2025, calculated over stored is 1.000 for salary sacrifice and 1.053 for pay: out/uprating_ratio_fix4.txt). On the saved file at 2025, contributions are £28.1bn on main (+13.6% against the target) and £25.4bn with this PR (+2.7%).
  • Other saved-file residuals also differ from the logged ones, in both builds alike (for example OBR income tax −10.0% / −9.9% against −11.2% / −11.1% logged, employer NICs +5.9% / +5.9% against +4.6% / +4.6%). The impact runs use policyengine-uk 2.126.1 (main 4857dc75f), while the build locks 2.122.2, so tax and NI rules differ between the two; I have not reconciled the residuals line by line.

Weights:

main This PR
Effective sample size, households (of 52,846) 1,305 1,315
Share of weight in the top 1% of households 44.2% 43.3%
Effective sample size, salary sacrifice users 257 272

Household weights in the two builds correlate at 0.965; households holding 44% of main's weight move by more than 10%.

Headline aggregates (2025):

main This PR
Employment income £1,172.0bn £1,171.3bn
Income tax £298.4bn £298.7bn
Universal Credit £79.4bn £79.4bn
Household net income £1,660.4bn £1,659.6bn
Poverty rate after housing costs 20.7% 20.5%
Child poverty rate after housing costs 30.7% 30.4%

Salary sacrifice cap (government balance, a current-law simulation against one with the cap set to infinity, same dataset, 2029): +£1.22bn on the main build, +£1.93bn with this PR. The sacrifice above the cap held by people with pay rises from £10.5bn to £13.7bn.

Earlier builds against the 2 October main (b45c373), before the HMRC relief targets returned in #529, gave the same direction: contributions +9.4% → −2.8%, overall fit broadly similar (within 5% / 10%: 63.7% / 84.0% → 64.2% / 84.6%).

For the release

  • The residuals above are for the data owner to accept or remediate: contributions +7.0% (logged) and the relief and headcount targets up to +4.6%.
  • Still outstanding before release, for the batch build rather than this PR: fit of the final saved file (employment income bands, employee NICs) under the policyengine-uk version the release locks, and a reconciliation of saved-file against logged residuals.
  • The differential check against the model's guard runs only once the locked policyengine-uk includes #2063.

Review

  • r1 (GPT-6.1 Sol) at f95ef2c: request changes (uprated pay, stage-2 pay accounting, stale model cache). All addressed.
  • r2 at 9910ad4: approve. Its comment nits are fixed in 321e613, which changes no behaviour.
  • r3 (GPT-6.1 Sol) at 14e2c22: code accepted; request changes to this description (qualify the donor tax and benefit cap claims, add release evidence, fix a rounding). Applied.
  • r4 (GPT-6.1 Sol) at 99916cb: code accepted (the four files are unchanged since 14e2c22; no adverse interaction with the merged main); request changes to this description. Applied here: an incorrect explanation of the contributions overshoot removed (the relief counterfactual adds every row's sacrifice to pay, including people without pay), ranges and roundings corrected, outstanding release evidence listed, supporting files cited.

Merging this PR is a data release, so it waits for the batched release.

axiom: n/a: data

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits October 2, 2026 07:53
Stage 1 of the salary sacrifice imputation ran the QRF for everyone not
asked SALSAC and kept positive draws even without pay. The forest learnt
the no-pay case from the 30 SALSAC respondents with a job but no recorded
pay, three of whom report a sacrifice, so about 1.5k records (about 1.06m
weighted, £11.3bn of the £25.8bn total in the 1.56.16 enhanced FRS) had a
sacrifice and no pay. From 2029 policyengine-uk returns the excess over
the £2,000 cap to employment income, so those people gained pay.

- limit_salary_sacrifice_to_pay: every amount, reported or imputed, is
  between zero and the person's pay. No FRS 2024-25 respondent who
  reports a sacrifice and has pay reports more than that pay.
- The QRF trains only on SALSAC respondents with pay.
- Stage 2 moves employee pension to salary sacrifice up to the person's
  pay; any remainder stays as employee pension.
- Tests: Hypothesis properties of the limit and of the whole imputation
  (stub model), and built-dataset checks that nobody has a sacrifice
  without pay or above it, and nobody without pay has employment income
  in 2029. Both built-dataset checks fail on the 1.56.16 file.
- hypothesis added to the dev extras.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
policyengine-uk is gaining the same limit
(pension_contributions_via_salary_sacrifice_from_pay). Where the installed
model has it, the built dataset's salary sacrifice must already equal it.
Skipped on a policyengine-uk without the variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaxGhenis and others added 4 commits October 2, 2026 11:00
Stage 2 converts a pension contributor's employee contribution into
salary sacrifice but left their pay unchanged. FRS pay is before that
deduction, and policyengine-uk treats employment income as pay after any
sacrifice, so a converted donor kept the same pay, lost the relief on the
contribution and paid more income tax. Removing the phantom no-pay users
enlarges stage 2's shortfall: at pre-calibration weights the extra donors
(1.32m weighted) moved £2.95bn and paid £0.90bn more income tax for that
reason alone.

Stage 2 now takes the amount moved out of pay, so taxable pay is
unchanged, and moves at most half the pay, so the sacrifice never
exceeds the pay that remains. Tests: pay falls by exactly the amount
moved, pay plus salary sacrifice and the pension total are unchanged for
every donor, nobody else's pay changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review (subfleet 20261002-093645-ukdata-518-ss-review) found the limits
used employment_income from a Microsimulation of the 2024 survey-year
dataset, which calculates 2025 by default and uprates pay by 5.3% but
not salary sacrifice. Sacrifices could therefore reach 105.3% of stored
pay, and stage 2 could move up to 52.65% of stored pay and leave less
than that. CI on a99a1ef caught it: 4 people in the TESTING build
sacrificed more than their pay.

- The limits and stage-2 donor pay use the stored employment_income
  column. QRF predictors still come from the simulation, as in training.
- The model cache is now salary_sacrifice_paid_respondents.pkl, so a
  forest cached from the old training set is not reused.
- The no-training-data error names the actual condition; docstrings say
  the upper bound is an imputation bound, not a legal one, and that
  only predictions for people with pay are kept.
- Wiring tests run at 2024 and 2025; the 2024 cases fail on a99a1ef.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review r2 (subfleet 20261003-090151-ukdata-518-ss-review-r2, APPROVE)
nits: the stage 2 comments still said donors keep their full employee
pension and become below-cap users, and "keeps taxable pay unchanged"
claimed more than the code guarantees (pay plus sacrifice is unchanged).
The stored pay array is copied before use. No change in behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s-pay

# Conflicts:
#	pyproject.toml
#	uv.lock
@MaxGhenis
MaxGhenis marked this pull request as ready for review October 11, 2026 15:30
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Hand-off to the UK hub (session local_bfbe2fd0 is closing).

  • Head: 99916cb (merge of main 2b9c1c2; the PR's four files are unchanged since 14e2c22).
  • Review: GPT-6.1 Sol r5 APPROVE at 99916cb with the current description (~/reviews/uk-data-salary-sacrifice-no-pay-2026-10-02/subfleet/review_uk_data_r5.md). Earlier rounds: r1 RC, r2 APPROVE @9910ad4, r3 and r4 code accepted, description RC (all applied).
  • Passed: gh pr checks exit 0 (changelog, lint, test, bundle manifest), MERGEABLE, out of draft.
  • Waiting on: the batched uk-data release (d833). Merging this PR is a data release, so it does not land on its own.
  • Depends on: nothing. Companion Limit salary sacrifice to the pay it comes out of policyengine-uk#2063 merged 2026-10-10 (88d4ff48d); its guard is inert on data built from this branch.
  • Owed before the release, for the batch build rather than this PR: fit of the final saved file (employment income bands, employee NICs) under the policyengine-uk version the release locks, and a reconciliation of saved-file against logged residuals. Residuals for the data owner to accept: contributions +7.0% (logged), relief and headcount targets up to +4.6%.
  • Evidence files: ~/reviews/uk-data-salary-sacrifice-no-pay-2026-10-02/ (STATUS.md, out/, impact/*.json; builds/ holds licensed files and is the base for ud#557).

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.

1 participant