From f6a21397371cb6d4fa0a3b81d71bb8d52332ff9c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 07:53:40 -0400 Subject: [PATCH 1/6] Limit salary sacrifice to the pay it comes out of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../salary-sacrifice-needs-pay.fixed.md | 1 + .../datasets/imputations/salary_sacrifice.py | 60 ++++- ...sion_contributions_via_salary_sacrifice.py | 30 +++ .../tests/test_salary_sacrifice_imputation.py | 209 ++++++++++++++++++ pyproject.toml | 1 + uv.lock | 73 +++++- 6 files changed, 361 insertions(+), 13 deletions(-) create mode 100644 changelog.d/salary-sacrifice-needs-pay.fixed.md create mode 100644 policyengine_uk_data/tests/test_salary_sacrifice_imputation.py diff --git a/changelog.d/salary-sacrifice-needs-pay.fixed.md b/changelog.d/salary-sacrifice-needs-pay.fixed.md new file mode 100644 index 000000000..58afe644b --- /dev/null +++ b/changelog.d/salary-sacrifice-needs-pay.fixed.md @@ -0,0 +1 @@ +- Limit every salary sacrifice amount, reported or imputed, to the person's pay, and train the salary sacrifice QRF only on SALSAC respondents with pay. Stage 1 had drawn amounts for people without pay from the three SALSAC respondents with a job but no recorded pay, so about a million people with no pay carried salary sacrifice, and from 2029 policyengine-uk returned the excess over the £2,000 cap to their employment income. diff --git a/policyengine_uk_data/datasets/imputations/salary_sacrifice.py b/policyengine_uk_data/datasets/imputations/salary_sacrifice.py index 35800b339..578620eef 100644 --- a/policyengine_uk_data/datasets/imputations/salary_sacrifice.py +++ b/policyengine_uk_data/datasets/imputations/salary_sacrifice.py @@ -12,6 +12,14 @@ employee pension contributions to salary sacrifice. Targets the OBR/ASHE estimate of ~4.3mn below-cap SS users. +A salary sacrifice is pay given up for an employer pension contribution, +so every amount, reported or imputed, is limited to the person's pay: +nobody without pay sacrifices anything. Without that limit, stage 1 drew +amounts for people with no pay from the few SALSAC respondents who have a +job but no recorded pay, and from 2029 policyengine-uk returns the excess +over the £2,000 cap to employment income, giving those people pay they do +not have. + Exact monetary totals (~£24bn SS contributions) and final headcount calibration happen via weight optimisation in a subsequent step. """ @@ -33,14 +41,35 @@ ] +def limit_salary_sacrifice_to_pay( + salary_sacrifice: np.ndarray, employment_income: np.ndarray +) -> np.ndarray: + """Limit salary sacrifice amounts to the pay they are sacrificed from. + + A person without pay sacrifices nothing, and nobody sacrifices more + than the pay they still receive. No FRS 2024-25 respondent who reports + a sacrifice and has pay reports more than that pay, so the upper bound + only binds on imputed amounts. + + Args: + salary_sacrifice: Annual salary sacrifice pension contributions. + employment_income: Annual employment income after the sacrifice. + + Returns: + Amounts between zero and the person's pay (zero without pay). + """ + return np.clip(salary_sacrifice, 0, np.maximum(employment_income, 0)) + + def save_salary_sacrifice_model(): """ Train and save salary sacrifice imputation model using FRS data. - Uses FRS respondents who were asked about salary sacrifice (SALSAC field) - as training data. The model learns to predict the SS pension amount - directly - non-participants have 0, participants have their reported - SPNAMT value. + Uses FRS respondents with pay who were asked about salary sacrifice + (SALSAC field) as training data. The model learns to predict the SS + pension amount directly - non-participants have 0, participants have + their reported SPNAMT value. Respondents without pay are left out: the + model only predicts for people with pay. Returns: Trained QRF model for salary sacrifice imputation. @@ -71,9 +100,9 @@ def save_salary_sacrifice_model(): ) ss_asked = dataset.person.salary_sacrifice_asked.values - # Build training DataFrame with only those who were asked + # Build training DataFrame with only those who were asked and have pay # This includes both participants (with amounts) and non-participants (0) - training_mask = ss_asked == 1 + training_mask = (ss_asked == 1) & (employment_income > 0) if training_mask.sum() == 0: raise ValueError("No training data found - no respondents were asked SALSAC.") @@ -119,9 +148,12 @@ def impute_salary_sacrifice( Impute salary sacrifice pension amounts for FRS non-respondents. Stage 1: QRF predicts SS amounts for respondents not asked SALSAC. + Every amount, reported or predicted, is then limited to the person's + pay (see ``limit_salary_sacrifice_to_pay``). Stage 2: Converts a fraction of pension contributors to below-cap SS users, targeting ~4.3mn (OBR/ASHE). Moves employee pension - contributions to salary sacrifice to keep total pension consistent. + contributions (up to the person's pay) to salary sacrifice to keep + total pension consistent. Args: dataset: PolicyEngine UK dataset with salary_sacrifice_asked @@ -166,9 +198,13 @@ def impute_salary_sacrifice( # For those not asked, use the imputed values final_ss = np.where( ss_asked == 1, - current_ss, # Keep reported values exactly + current_ss, # Keep reported values imputed_ss, # Use imputed for non-respondents ) + # A sacrifice comes out of pay. This also covers SPI-synthetic rows + # copied from a SALSAC respondent, whose kept amounts come from the + # FRS-only imputation rather than the survey. + final_ss = limit_salary_sacrifice_to_pay(final_ss, employment_income) # Stage 2: Headcount-targeted imputation for SS users. # ASHE data shows many more SS users than the FRS captures due to @@ -198,15 +234,15 @@ def impute_salary_sacrifice( rng = np.random.default_rng(seed=2024) newly_imputed = is_donor & (rng.random(len(final_ss)) < imputation_rate) - # Move full employee pension to SS so the above/below - # 2k split reflects the natural pension distribution - ss_new = employee_pension.copy() + # Move full employee pension (up to pay) to SS so the + # above/below 2k split reflects the natural pension distribution + ss_new = limit_salary_sacrifice_to_pay(employee_pension, employment_income) final_ss = np.where(newly_imputed, ss_new, final_ss) # Reduce employee pension correspondingly dataset.person["employee_pension_contributions"] = np.where( newly_imputed, - 0.0, + employee_pension - ss_new, employee_pension, ) diff --git a/policyengine_uk_data/tests/test_pension_contributions_via_salary_sacrifice.py b/policyengine_uk_data/tests/test_pension_contributions_via_salary_sacrifice.py index 9ed48abfe..5362beb69 100644 --- a/policyengine_uk_data/tests/test_pension_contributions_via_salary_sacrifice.py +++ b/policyengine_uk_data/tests/test_pension_contributions_via_salary_sacrifice.py @@ -20,3 +20,33 @@ def test_pension_contributions_via_salary_sacrifice(baseline): assert total < total_employment, ( f"Salary sacrifice contributions ({total / 1e9:.1f}B) cannot exceed total employment income ({total_employment / 1e9:.1f}B)" ) + + +def test_salary_sacrifice_needs_pay(baseline): + """A salary sacrifice comes out of pay: none without pay, none above it.""" + pay = baseline.calculate("employment_income_before_lsr", period=2025).values + ss = baseline.calculate( + "pension_contributions_via_salary_sacrifice", period=2025 + ).values + weight = baseline.calculate("person_weight", period=2025).values + + without_pay = (ss > 0) & (pay <= 0) + assert not without_pay.any(), ( + f"{without_pay.sum()} people ({weight[without_pay].sum() / 1e3:.0f}k " + "weighted) have salary sacrifice but no pay" + ) + # Both columns uprate by the same index, so only rounding can separate them. + above_pay = ss > pay * (1 + 1e-9) + 1e-6 + assert not above_pay.any(), ( + f"{above_pay.sum()} people sacrifice more than their pay" + ) + + +def test_salary_sacrifice_cap_gives_nobody_pay_they_do_not_have(baseline): + """From 2029 the excess over the cap returns to pay, so it needs pay.""" + pay = baseline.calculate("employment_income_before_lsr", period=2029).values + employment_income = baseline.calculate("employment_income", period=2029).values + phantom = (pay <= 0) & (employment_income > 0) + assert not phantom.any(), ( + f"{phantom.sum()} people without pay have employment income in 2029" + ) diff --git a/policyengine_uk_data/tests/test_salary_sacrifice_imputation.py b/policyengine_uk_data/tests/test_salary_sacrifice_imputation.py new file mode 100644 index 000000000..d91ab8e73 --- /dev/null +++ b/policyengine_uk_data/tests/test_salary_sacrifice_imputation.py @@ -0,0 +1,209 @@ +"""Salary sacrifice imputation keeps every sacrifice within the person's pay. + +Invariants of ``limit_salary_sacrifice_to_pay``, for every input: + +- the result is between zero and the person's pay, so zero without pay; +- an amount already within those bounds is unchanged; +- applying it twice changes nothing; +- it is non-decreasing in the sacrifice and in the pay. + +``impute_salary_sacrifice`` applies it to every record, reported or +imputed, and stage 2 moves employee pension contributions to salary +sacrifice without changing anyone's total of the two. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk_data.datasets.imputations import ( + salary_sacrifice as salary_sacrifice_module, +) +from policyengine_uk_data.datasets.imputations.salary_sacrifice import ( + limit_salary_sacrifice_to_pay, +) + +amounts = st.floats(min_value=-1e5, max_value=1e7, allow_nan=False) +pairs = st.lists(st.tuples(amounts, amounts), min_size=1, max_size=40) + + +def _split(rows): + ss, pay = np.array(rows, dtype=float).T + return ss, pay + + +@given(pairs) +def test_result_is_between_zero_and_pay(rows): + ss, pay = _split(rows) + limited = limit_salary_sacrifice_to_pay(ss, pay) + assert np.all(limited >= 0) + assert np.all(limited <= np.maximum(pay, 0)) + assert np.all(limited[pay <= 0] == 0) + + +@given(pairs) +def test_amounts_within_pay_are_unchanged(rows): + ss, pay = _split(rows) + within = (ss >= 0) & (ss <= pay) + limited = limit_salary_sacrifice_to_pay(ss, pay) + assert np.array_equal(limited[within], ss[within]) + + +@given(pairs) +def test_limit_is_idempotent(rows): + ss, pay = _split(rows) + once = limit_salary_sacrifice_to_pay(ss, pay) + assert np.array_equal(limit_salary_sacrifice_to_pay(once, pay), once) + + +@given(pairs, st.floats(min_value=0, max_value=1e6), st.floats(0, 1e6)) +def test_limit_is_monotone_in_sacrifice_and_pay(rows, more_ss, more_pay): + ss, pay = _split(rows) + limited = limit_salary_sacrifice_to_pay(ss, pay) + assert np.all(limit_salary_sacrifice_to_pay(ss + more_ss, pay) >= limited) + assert np.all(limit_salary_sacrifice_to_pay(ss, pay + more_pay) >= limited) + + +class _StubModel: + """Stands in for the trained QRF: predicts a fixed amount per row.""" + + def __init__(self, predictions): + self.predictions = np.asarray(predictions, dtype=float) + + def predict(self, X): + assert len(X) == len(self.predictions) + return pd.DataFrame( + {"pension_contributions_via_salary_sacrifice": self.predictions} + ) + + +def _dataset(pay, asked, reported, employee_pension): + from policyengine_uk.data import UKSingleYearDataset + + n = len(pay) + person = pd.DataFrame( + { + "person_id": np.arange(n), + "person_benunit_id": np.arange(n), + "person_household_id": np.arange(n), + "age": 40, + "employment_income": np.asarray(pay, dtype=float), + "salary_sacrifice_asked": np.asarray(asked, dtype=int), + "pension_contributions_via_salary_sacrifice": np.asarray( + reported, dtype=float + ), + "employee_pension_contributions": np.asarray(employee_pension, dtype=float), + } + ) + benunit = pd.DataFrame({"benunit_id": np.arange(n)}) + household = pd.DataFrame( + { + "household_id": np.arange(n), + "household_weight": 1.0, + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "council_tax": 0.0, + "rent": 0.0, + } + ) + return UKSingleYearDataset( + person=person, benunit=benunit, household=household, fiscal_year=2025 + ) + + +def _impute(monkeypatch, predictions, **columns): + monkeypatch.setattr( + salary_sacrifice_module, + "create_salary_sacrifice_model", + lambda: _StubModel(predictions), + ) + dataset = _dataset(**columns) + before = dataset.person.copy() + after = salary_sacrifice_module.impute_salary_sacrifice(dataset).person + return before, after + + +def _assert_invariants(before, after): + pay = before.employment_income.values + ss = after.pension_contributions_via_salary_sacrifice.values + assert np.all(ss >= 0) + assert np.all(ss <= np.maximum(pay, 0)) + assert np.all(ss[pay <= 0] == 0) + # Stage 2 moves employee pension to salary sacrifice: whoever lost + # employee pension gained exactly that much salary sacrifice. + pension_before = before.employee_pension_contributions.values + pension_after = after.employee_pension_contributions.values + moved = pension_after != pension_before + assert np.all(pension_after <= pension_before) + assert np.allclose(pension_after[moved] + ss[moved], pension_before[moved]) + + +def test_nobody_without_pay_keeps_a_sacrifice(monkeypatch): + pay = [0, 0, -500, 30_000, 30_000, 8_000, 0] + before, after = _impute( + monkeypatch, + # The model predicts a large sacrifice for everyone. + predictions=[25_000] * 7, + pay=pay, + asked=[0, 1, 0, 0, 1, 0, 1], + reported=[0, 6_000, 0, 0, 1_200, 0, 0], + employee_pension=[0] * 7, + ) + ss = after.pension_contributions_via_salary_sacrifice.values + # Unpaid: imputed (0, 2) and reported (1) amounts are all dropped. + assert ss[[0, 1, 2, 6]].tolist() == [0, 0, 0, 0] + # Paid and not asked: the prediction, limited to pay. + assert ss[3] == 25_000 + assert ss[5] == 8_000 + # Paid and asked: the reported amount is kept. + assert ss[4] == 1_200 + _assert_invariants(before, after) + + +def test_stage_two_moves_employee_pension_up_to_pay(monkeypatch): + n = 40 + # Every donor's employee pension exceeds their pay, so each one moved + # keeps the part above their pay as employee pension. + before, after = _impute( + monkeypatch, + predictions=[0] * n, + pay=[1_000] * n, + asked=[0] * n, + reported=[0] * n, + employee_pension=[1_500] * n, + ) + ss = after.pension_contributions_via_salary_sacrifice.values + moved = ss > 0 + assert moved.any() + assert np.all(ss[moved] == 1_000) + assert np.all(after.employee_pension_contributions.values[moved] == 500) + _assert_invariants(before, after) + + +row = st.tuples( + st.sampled_from([-1_000.0, 0.0, 500.0, 15_000.0, 60_000.0]), # pay + st.integers(0, 1), # asked SALSAC + st.sampled_from([0.0, 800.0, 6_000.0, 70_000.0]), # reported amount + st.sampled_from([0.0, 300.0, 3_000.0, 90_000.0]), # employee pension + st.sampled_from([-100.0, 0.0, 1_500.0, 27_000.0]), # model prediction +) + + +@settings(max_examples=10, deadline=None) +@given(st.lists(row, min_size=1, max_size=12)) +def test_imputation_keeps_every_sacrifice_within_pay(rows): + pay, asked, reported, employee_pension, predictions = map(list, zip(*rows)) + with pytest.MonkeyPatch.context() as monkeypatch: + before, after = _impute( + monkeypatch, + predictions=predictions, + pay=pay, + asked=asked, + reported=reported, + employee_pension=employee_pension, + ) + _assert_invariants(before, after) diff --git a/pyproject.toml b/pyproject.toml index beff7f1a4..6880a9a25 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dev = [ "itables", "quantile-forest", "build", "towncrier>=24.8.0", + "hypothesis", ] diff --git a/uv.lock b/uv.lock index a3e9f44d9..c301c6c9f 100644 --- a/uv.lock +++ b/uv.lock @@ -577,6 +577,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/f4/124858007ddf3c61e9b144107304c9152fa80b5b6c168da07d86fe583cc1/huggingface_hub-1.1.5-py3-none-any.whl", hash = "sha256:e88ecc129011f37b868586bbcfae6c56868cae80cd56a79d61575426a3aa0d7d", size = 516000, upload-time = "2025-11-20T15:49:30.926Z" }, ] +[[package]] +name = "hypothesis" +version = "6.168.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/b7/13118bbc45d6d8b9d04e2de779e2a4ff23145ea39efa692b994fb874ca72/hypothesis-6.168.3.tar.gz", hash = "sha256:a43388f9067678fef6e13bdff325b6cfa6961a590498bb37f7ff31589c83bc75", size = 511022, upload-time = "2026-09-28T05:20:58.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/cd/746a2fc1e5e5ba34f07ea6ee1ad07525a3dde5ff4836db3a7980af49a891/hypothesis-6.168.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d20972ca134e652e9928ecd200966a8a2857adc2812c1d74b12a872e5cd503eb", size = 791536, upload-time = "2026-09-28T05:18:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/c3/0f/7a158e377b69556c8e12c25c8fd811d0103e54c24a12dab1f3b9819f202e/hypothesis-6.168.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:eafbec09d3e87d13d8242411d1f5868b5e879f1bd6d95e233e9ff80c527bea1c", size = 787313, upload-time = "2026-09-28T05:20:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/45/c6/7df9104ee359e8fcd77791781c47e70794964ca69673665e0de2a6590f4c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:73c5627497968cc62d140e9fde1ea21e12cac7b64dc419fea8286cd34ff1ad4e", size = 1124036, upload-time = "2026-09-28T05:20:04.372Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/8a61715404a73b9a82cb1f976803428d603cdea976a9c30266c72ad6a7ce/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dc56e6dc6eeb0aeb08ac463f847279bde2336c4786faf7ee0af4b93cd031a7", size = 1147875, upload-time = "2026-09-28T05:19:15.495Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3f/2b16e95cc3b9a069afa23830a012aefe377ce4a457a227e5a5026eb827ff/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:753bb501f8560d2e321ed3b62596b4496c56f15668b0a0669231ccc3e6c4e80d", size = 1149489, upload-time = "2026-09-28T05:19:18.886Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e7/d7cf6dc068bb02732b2a6e38a35a0dcb7f2137608198fc79d740f69d197c/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71ab606c472449cb872ef2a7acaec679ee4f651b7f0a477a04ebc85d8933ef8c", size = 1191992, upload-time = "2026-09-28T05:19:21.83Z" }, + { url = "https://files.pythonhosted.org/packages/5f/26/f1e5b25dec15998e8375d722825ec3dae1075cbe7cb1f74221b2312a2e33/hypothesis-6.168.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26928956c54748e4dfa587333741ab750246123b93484955646ff316cca27eef", size = 1169911, upload-time = "2026-09-28T05:19:27.996Z" }, + { url = "https://files.pythonhosted.org/packages/03/36/901234a49147e6bf8a5b9e54b775706a223c2b52befea1106ea2aaf15d48/hypothesis-6.168.3-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:0bdfc53c041b61c854fa3761735736b991bc2bddafee45a361cfe4d43027c1ef", size = 1129406, upload-time = "2026-09-28T05:18:42.632Z" }, + { url = "https://files.pythonhosted.org/packages/55/c1/5fc9ff91ec9fba6bb527833386c66812524b5cc75fe7d4a4f9122ee1c420/hypothesis-6.168.3-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:650528e2b1e2a45e95c2df624d4b9364b8ac5a027ba84e1eea2bc5398dd01dcd", size = 1160399, upload-time = "2026-09-28T05:20:23.911Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/49ad1ef5c55547c8abfd0fc571b3493806dda530fd5cb3d8263dbad86a72/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:209dc54cdb1b4d6d7020ad8d09e44c49756361b7183adacea4d6f5705085b595", size = 1299906, upload-time = "2026-09-28T05:18:58.417Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/f98a26094c4f462c4215807bed0f9a6b5508b27a3c329bcba1bc8adc7ac7/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:af8ca98cd11dc7f9427bb90483abcd9688d4df2e8e63893a30a2423b027ebb11", size = 1425538, upload-time = "2026-09-28T05:20:49.074Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b8/c5b4406e3a6591e51a42f85469351d1d824e0a54b84167aec9b2353759fe/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:f8122cfdd0bc0ba843063effa22ac310bdebdb3a1319bf1e63f14baa119c72ab", size = 1377084, upload-time = "2026-09-28T05:20:06.618Z" }, + { url = "https://files.pythonhosted.org/packages/e0/01/0d84a8ea469d024c15f602799c0b8410fbe03d99fdb0370449b33c3c916f/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4bedbb379eab34f792af7ee9a05aae04e9c08bbb52e0f90f5a2110d4fe4b2fbd", size = 1281162, upload-time = "2026-09-28T05:19:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/b5/ea6435038de1a795f005cb603531e8ccc053febc50180f3ab55583c9c60e/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:5a953115b9f5c95133ab2d04efffeec96e5658c3207923dca7285f7db3e6bbef", size = 1300433, upload-time = "2026-09-28T05:18:55.634Z" }, + { url = "https://files.pythonhosted.org/packages/6c/78/fa6c77d9f64b6d592e69e582d83cbce7d7e56897faa4762885a2123b0166/hypothesis-6.168.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6173558e676ad25ed1e20507fa4024a0d816dd90f715f77006e5a28f19109026", size = 1336273, upload-time = "2026-09-28T05:19:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/f1/56/4acd0d778bab818c9ea336fcba768bf43bc70db7f84cc373579298df4641/hypothesis-6.168.3-cp310-abi3-win32.whl", hash = "sha256:dc66390fb12d80585aa9222bf538ce8b7aa22cf5d118250647355a1c9e8f62f4", size = 678211, upload-time = "2026-09-28T05:19:23.355Z" }, + { url = "https://files.pythonhosted.org/packages/b0/db/0a02b146ad1c30f9716362f2e1a379dfd6b0f7be03ac1e22c9da24c9e2a9/hypothesis-6.168.3-cp310-abi3-win_amd64.whl", hash = "sha256:92325b276360fe86c5bf71a568c0d53a6d140b0de36dcf029f9164a17803bb24", size = 684906, upload-time = "2026-09-28T05:18:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c0/958deaf726848f96f52250740bf39f13f476b068e22f73e2b358eaa07532/hypothesis-6.168.3-cp310-abi3-win_arm64.whl", hash = "sha256:3cf6f1eeaf41cd8d60cf1f88fde905ca1dd77c906929a507d6ac7f66f2ccba2a", size = 683292, upload-time = "2026-09-28T05:19:57.404Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a2/6787da846d929e52fc3344d803299c45782dbeac287528af08380e984bf8/hypothesis-6.168.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1b230a850de63334c16654a34a2d547e0179d36b9071d4439b3e7237f6d077e7", size = 793254, upload-time = "2026-09-28T05:19:36.294Z" }, + { url = "https://files.pythonhosted.org/packages/89/ba/2893f5ca42501f4d562cba3229c8994c3a3e5fac66ef60c1e0e0b5220a5d/hypothesis-6.168.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d63b0226cd3e0d8bdd97c3384b22a21934ed4d53246c1c93575dada616672499", size = 784699, upload-time = "2026-09-28T05:18:54.194Z" }, + { url = "https://files.pythonhosted.org/packages/b7/aa/7d7349daf75b71f6f35876f8de115e974c5c04d600e86df1b2779b0883ab/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0369f5df055f96e117ab12e5f249668ff144731ab5280bc7a205fdf81f990b89", size = 1123016, upload-time = "2026-09-28T05:20:36.372Z" }, + { url = "https://files.pythonhosted.org/packages/00/f0/7774e1ea072708ea46cb25c4aeee5f9978b6c271764809069e8a6789858e/hypothesis-6.168.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f076bcd0f77fdcdb7797826c879099573d03a02e228ebe649ea917081b962ac0", size = 1168989, upload-time = "2026-09-28T05:20:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7d/113992abed9efbd7944e3a496a382da58152dce126fddf6419ff31a0a57d/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b823ba1fcec8da730f29316d010b06d3f7e0c3828dcf91020e24c55e7d24652a", size = 1298626, upload-time = "2026-09-28T05:19:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/d5/65/3659fa5e733027e5b37a27486e57f4053535e40853d23501c422bb275043/hypothesis-6.168.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dd2849c269d674e4618590f3b48d443bd4c06b5aef3d8d869086c2e6d213d248", size = 1335184, upload-time = "2026-09-28T05:20:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/b4/6c/35aab2221b5ea65125340f236e1a765a9e9f3b28d89a389ea0033fed29f7/hypothesis-6.168.3-cp313-cp313-win_amd64.whl", hash = "sha256:3ef7d26f5789e691401d5f87eafed9bd2763f0dbe47af6d6b66012a509404766", size = 682173, upload-time = "2026-09-28T05:19:12.762Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/27b0cb56ff5d2bc92458bf6fcebdb1f0dc01f57f043a178e9f9d74498169/hypothesis-6.168.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e2d4c68729a13df9af4998d2652cfb5d541c5609b88c306880a5dfb284938ec2", size = 793287, upload-time = "2026-09-28T05:20:38.522Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bd/5342f95c3bc36586777ef8cb8eba87ac7acf0184210327fb0e6d8654e295/hypothesis-6.168.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b345f818083ec99966a43ca4f7b38feb62c6920ce28572bc2bde4948a8b7eaba", size = 784857, upload-time = "2026-09-28T05:20:42.609Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5f/fc774241518a0588d362680a3084275058aab86d6bc02a2c61e1073142d0/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0608c610fc002978fc5de0f471770d8817e9455cb8649a47983c9f8bccfc1897", size = 1123330, upload-time = "2026-09-28T05:19:29.735Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/131f16775a3dca5f4fe27f0d6ad9a9851600098a54a872d163f6ebf0b664/hypothesis-6.168.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dcf6448b1ecc37f2b23f2d1b3ddfc9ff6b6910614c15a642dc82f419b96037b", size = 1169178, upload-time = "2026-09-28T05:19:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/c9f5bff8b73321c9fd12f2d69666c6e4861b97b60f5f24fdd6ac293007fd/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f9f094041dcce5119ebd7bab71062743ab02b6e654d056b370beda78c19e2", size = 1299127, upload-time = "2026-09-28T05:20:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/d4618f8ab12dd76c4d58ea052252759aa2bf0c4e71f4b502ba5d577e0b9f/hypothesis-6.168.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d479985fe73af97badfb72dc6d20c6a353e486a36f6d065f600026e0cf954a86", size = 1335390, upload-time = "2026-09-28T05:20:10.326Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/727161e17cc297df1aeea945de05c532033b66751625bec192bc5b5ff707/hypothesis-6.168.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:785e2c45f8c08e274b4bf1ccae97f1a4e09407e9a68e80790cfab1d17a4a45fa", size = 624291, upload-time = "2026-09-28T05:18:36.376Z" }, + { url = "https://files.pythonhosted.org/packages/77/23/2f9b506392a0b8712440bcb781abc5713be9ffac1303b6d10f3669050882/hypothesis-6.168.3-cp314-cp314-win_amd64.whl", hash = "sha256:320920b1e3dae8611eee8a03d063cf2187446f2a17c38cfb8a7fc1466f71eee2", size = 682064, upload-time = "2026-09-28T05:20:16.336Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/efabfd95eb2b69c0c9fa1e1c83c8290aa2715d46baeb5a7764faf09c133a/hypothesis-6.168.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:35380baa981108a7f60c4eab71e46acd8d8f58440520346a1a6aba06dca7e074", size = 791875, upload-time = "2026-09-28T05:19:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/2a0ada1623a9a33048bbf9f00a7c92513398c6b8865cba5efb854262ce30/hypothesis-6.168.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f0aaaed00438fa6673d856aaee12a4a8afbde82a9f3c5ff487cacfb064239afe", size = 783412, upload-time = "2026-09-28T05:18:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/41/31/73c615d37eeb12da0209555612c32ced628d860267c23cfd2691c6207aea/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27e6df1576bf838e7f484d4ae5cba92114497ca30afb917056bf1ea33e95675", size = 1121662, upload-time = "2026-09-28T05:19:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/92/54/1893344a3b8bbf83fa7c9bb7e96f6836580213854f46671cb749f7b4706c/hypothesis-6.168.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bc85014577982ec6d2e266edc5cd6e7a7d3674c648791ca983c67a52f89a0c4", size = 1167761, upload-time = "2026-09-28T05:18:49.85Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/a61d82968febd25a0f08ffbbfabfaeead66f68fc5fa73f3c9ece1c36fc8f/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ccfc29505aa1cdcc254cb9cd701fd0811fef5480addd97d4127a00df023410f7", size = 1297309, upload-time = "2026-09-28T05:19:51.711Z" }, + { url = "https://files.pythonhosted.org/packages/bc/01/fe8e4cf6d39d02f4efadaa351427fa13722086cfc54a2da3579741c5177a/hypothesis-6.168.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32d0699566aaa93f9e97a44705d7164386f1e91de78d277f53bada35e78bcc96", size = 1334260, upload-time = "2026-09-28T05:19:00.121Z" }, + { url = "https://files.pythonhosted.org/packages/39/6a/09177ebc62f4778f94379ecade6a51299d3d8c4f4b75ba636bf3a0202364/hypothesis-6.168.3-cp314-cp314t-win_amd64.whl", hash = "sha256:28d88fa174ecbd4ecbd7bb290f06d0db084a3971c3f511ae2830b65b5e25500f", size = 681992, upload-time = "2026-09-28T05:19:48.043Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f6/890bf33d608cd63348d3146a3ca83362fbf09e589c5e5230a00c403070f4/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:61f5782d807b1e6aa5c9beef1054cb2037e7d1add48a64972cd6ee447778781f", size = 791231, upload-time = "2026-09-28T05:18:43.998Z" }, + { url = "https://files.pythonhosted.org/packages/03/dd/fc36b204f8aa7437c676576d9437f0422de46aa8a729e6cd5bbb0224e759/hypothesis-6.168.3-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:7aacf3cf40c7ce8f9e4924d5b57bc0b068beafdfed2347bcf160a28b4cfbba7b", size = 783171, upload-time = "2026-09-28T05:19:20.353Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d2/3cd087d577db67c404991d7723e64d7cd75570cdc82a0f7329aed6f2086c/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:01768a03a30dc54df7fe457c0b34016c84d598fa00d5965ededab93ba4eb3408", size = 1121152, upload-time = "2026-09-28T05:18:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/42/6f/a05a68cc66e7a22701c50bf94fb9ddeb36f4f5d0de3cc130066709eda621/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1a8a4ffc6c6e6e577f2bfbcfebf7cffbb310283ba2532c729570a0a752cebaaa", size = 1144069, upload-time = "2026-09-28T05:20:44.705Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f4/fdd7a093fb2fb3a0ce24256c92ad5bf67936f4669bd06d5ceae4298150ec/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b987d73eba95183a7e59cca6d1925c588aa4307922d9852cc2b4d282a2ae4128", size = 1146692, upload-time = "2026-09-28T05:20:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/6dbd4377e935505ae4fc8ee4c7b18c69994c4bbaca015447c470218bdbee/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4569c39bd97d9573e946429ed676f3b55a7c8ed80920addd67d384a47d59b38", size = 1189480, upload-time = "2026-09-28T05:20:46.734Z" }, + { url = "https://files.pythonhosted.org/packages/ba/bf/ff288b496b690000d2686dcfa7f67855e4c5a6dddb464ed8e4880be83bb2/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6042b8707a4b25bbbfe20b110258fb7549a68951e5525608a8ce06091039e9fc", size = 1167109, upload-time = "2026-09-28T05:19:24.758Z" }, + { url = "https://files.pythonhosted.org/packages/d9/d1/3fee2bc29fc747fabbb27e174515592e390809ca465d3bbbabbcfa3235a8/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:f413629de94d38a7a2ad259697a6752526143cb49c2e2d7bdba19381c80693f6", size = 1126823, upload-time = "2026-09-28T05:18:52.636Z" }, + { url = "https://files.pythonhosted.org/packages/58/6a/585294392fa6a6d9719446a042a777ba98ecca265d9e48cbedc10c88df0f/hypothesis-6.168.3-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f071737e4e775bebba07e319eb645a880d1e0186b4d24bad23255e849d86d483", size = 1155773, upload-time = "2026-09-28T05:20:51.268Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/fa283ff79996debf1ff08593e01f8b773eb8211b19b3d6d5d491317a65bf/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f59a3912858d0609c26054aa1c474847c797937f5e0e1c77f0d3f08032e50f09", size = 1296671, upload-time = "2026-09-28T05:18:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/42/05/98f9c2f628da5afabb6c3e8df5d2d299d2d50bf306b14a6e26d3954c4951/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:f09c05a23a8025dd5cad07c2ed299a46778e72d49ff0dd5bf353bcc0f7dc1580", size = 1422001, upload-time = "2026-09-28T05:19:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c9/f2109ade29a7ec284d55bca328d784743e8b13e321576fd34ee7e65f99af/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:44ace770bda3a0301739fc5a413c764de790c739df1f1a7218dd049f8594d9f5", size = 1374182, upload-time = "2026-09-28T05:20:27.764Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6d/e05d5f72441564a3bebc71fa155deafd0fd3b015d6014ec8a00edf6e42bb/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:03b131043608f94a2578a2a896a1a72092acb7079815eef5b8513b08447e0e62", size = 1278402, upload-time = "2026-09-28T05:20:56.332Z" }, + { url = "https://files.pythonhosted.org/packages/96/d7/6988a7f1f69c5c530687dce03a32c157e2d878e3a2e32af9269ce016b78a/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:e9784aca26eddfe99b03a0292320db742cc8a74200ef864fdce949f526f973cd", size = 1297770, upload-time = "2026-09-28T05:19:11.387Z" }, + { url = "https://files.pythonhosted.org/packages/a2/28/5bb82b60b836bd2329e1fe01ad94efc2b14dfa806f8e50ed14b795d78770/hypothesis-6.168.3-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:2b52ac363096232bebc2add117e9178d91f4248f4cdb919fd1026b5f86a4bb16", size = 1333977, upload-time = "2026-09-28T05:20:02.483Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7d/d419841b8f65481ea1a50c4ba36f2670d48b8c7a51e4e8586089564cf7f6/hypothesis-6.168.3-cp315-abi3.abi3t-win32.whl", hash = "sha256:d28e3a6b511a74ce37df5274b51f36c2b274fea7365e00b16f0c81e22acd5957", size = 675394, upload-time = "2026-09-28T05:19:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/1de6a2ad100e44817f2e9a8e8ba3eeaf4769621f4f87f2d4966c4971fcc4/hypothesis-6.168.3-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:7b9638789548361a57d984f56619ac694a914c328181d911271618409261ff4a", size = 681699, upload-time = "2026-09-28T05:19:09.957Z" }, + { url = "https://files.pythonhosted.org/packages/c0/79/be3370fd02734d6b1d950183ae9224580d19fa8d356b95348d60f1e3eeb7/hypothesis-6.168.3-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:65d78e4357ec48ed2c67825f06740ee3599be4cfe770a6092bed07108d679ac5", size = 679849, upload-time = "2026-09-28T05:19:06.884Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -1364,7 +1433,7 @@ wheels = [ [[package]] name = "policyengine-uk-data" -version = "1.56.16" +version = "1.57.4" source = { editable = "." } dependencies = [ { name = "google-auth" }, @@ -1392,6 +1461,7 @@ dependencies = [ dev = [ { name = "build" }, { name = "furo" }, + { name = "hypothesis" }, { name = "itables" }, { name = "l0-python" }, { name = "pytest" }, @@ -1410,6 +1480,7 @@ requires-dist = [ { name = "google-auth" }, { name = "google-cloud-storage" }, { name = "huggingface-hub" }, + { name = "hypothesis", marker = "extra == 'dev'" }, { name = "itables", marker = "extra == 'dev'" }, { name = "l0-python", marker = "extra == 'dev'", specifier = ">=0.4.0" }, { name = "microcalibrate", specifier = ">=0.18.0" }, From f95ef2c521c62a459705ba4366cabb8490dbaa0a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:27:04 -0400 Subject: [PATCH 2/6] Check the model's salary sacrifice pay limit never binds on the dataset 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 --- ...sion_contributions_via_salary_sacrifice.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/policyengine_uk_data/tests/test_pension_contributions_via_salary_sacrifice.py b/policyengine_uk_data/tests/test_pension_contributions_via_salary_sacrifice.py index 5362beb69..41d5c784a 100644 --- a/policyengine_uk_data/tests/test_pension_contributions_via_salary_sacrifice.py +++ b/policyengine_uk_data/tests/test_pension_contributions_via_salary_sacrifice.py @@ -1,3 +1,7 @@ +import numpy as np +import pytest + + def test_pension_contributions_via_salary_sacrifice(baseline): """Test that pension_contributions_via_salary_sacrifice loads and has reasonable values.""" values = baseline.calculate( @@ -50,3 +54,20 @@ def test_salary_sacrifice_cap_gives_nobody_pay_they_do_not_have(baseline): assert not phantom.any(), ( f"{phantom.sum()} people without pay have employment income in 2029" ) + + +def test_model_pay_limit_never_binds_on_the_dataset(baseline): + """policyengine-uk limits salary sacrifice to pay as the imputation does.""" + limited = "pension_contributions_via_salary_sacrifice_from_pay" + if limited not in baseline.tax_benefit_system.variables: + pytest.skip(f"policyengine-uk has no {limited} variable") + for period in (2025, 2029): + ss = baseline.calculate( + "pension_contributions_via_salary_sacrifice", period=period + ).values + from_pay = baseline.calculate(limited, period=period).values + differing = ~np.isclose(from_pay, ss, rtol=1e-6, atol=1e-2) + assert not differing.any(), ( + f"policyengine-uk limits the salary sacrifice of {differing.sum()} " + f"people in {period}" + ) From a99a1ef2b9c69fd72e368f5a3b613907455715fc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 11:00:00 -0400 Subject: [PATCH 3/6] Take stage 2's salary sacrifice out of the donor's pay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../salary-sacrifice-needs-pay.fixed.md | 1 + .../datasets/imputations/salary_sacrifice.py | 29 ++++++++---- .../tests/test_salary_sacrifice_imputation.py | 47 +++++++++++++++---- 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/changelog.d/salary-sacrifice-needs-pay.fixed.md b/changelog.d/salary-sacrifice-needs-pay.fixed.md index 58afe644b..f60a7a5ad 100644 --- a/changelog.d/salary-sacrifice-needs-pay.fixed.md +++ b/changelog.d/salary-sacrifice-needs-pay.fixed.md @@ -1 +1,2 @@ - Limit every salary sacrifice amount, reported or imputed, to the person's pay, and train the salary sacrifice QRF only on SALSAC respondents with pay. Stage 1 had drawn amounts for people without pay from the three SALSAC respondents with a job but no recorded pay, so about a million people with no pay carried salary sacrifice, and from 2029 policyengine-uk returned the excess over the £2,000 cap to their employment income. +- Stage 2 of the salary sacrifice imputation now takes the employee pension contribution it converts to salary sacrifice out of the donor's pay (at most half the pay). It had left pay unchanged, so converted donors lost the income tax relief on that contribution. diff --git a/policyengine_uk_data/datasets/imputations/salary_sacrifice.py b/policyengine_uk_data/datasets/imputations/salary_sacrifice.py index 578620eef..0e10a1097 100644 --- a/policyengine_uk_data/datasets/imputations/salary_sacrifice.py +++ b/policyengine_uk_data/datasets/imputations/salary_sacrifice.py @@ -9,8 +9,9 @@ 2. Headcount-targeted imputation: converts a fraction of pension contributors without SS into below-cap (≤£2,000) SS users, moving - employee pension contributions to salary sacrifice. Targets the - OBR/ASHE estimate of ~4.3mn below-cap SS users. + employee pension contributions to salary sacrifice and taking the + amount moved out of their pay. Targets the OBR/ASHE estimate of + ~4.3mn below-cap SS users. A salary sacrifice is pay given up for an employer pension contribution, so every amount, reported or imputed, is limited to the person's pay: @@ -152,8 +153,8 @@ def impute_salary_sacrifice( pay (see ``limit_salary_sacrifice_to_pay``). Stage 2: Converts a fraction of pension contributors to below-cap SS users, targeting ~4.3mn (OBR/ASHE). Moves employee pension - contributions (up to the person's pay) to salary sacrifice to keep - total pension consistent. + contributions to salary sacrifice to keep total pension consistent, + and takes the amount moved out of pay (at most half the pay). Args: dataset: PolicyEngine UK dataset with salary_sacrifice_asked @@ -234,17 +235,29 @@ def impute_salary_sacrifice( rng = np.random.default_rng(seed=2024) newly_imputed = is_donor & (rng.random(len(final_ss)) < imputation_rate) - # Move full employee pension (up to pay) to SS so the - # above/below 2k split reflects the natural pension distribution - ss_new = limit_salary_sacrifice_to_pay(employee_pension, employment_income) + # Move full employee pension to SS so the above/below 2k + # split reflects the natural pension distribution. The FRS + # pay these contributions were deducted from is before the + # sacrifice, so the sacrifice comes out of it: pay falls by + # the amount moved, which keeps taxable pay unchanged. At most + # half the pay moves, so the sacrifice never exceeds the pay + # that remains. + ss_new = limit_salary_sacrifice_to_pay( + employee_pension, employment_income / 2 + ) final_ss = np.where(newly_imputed, ss_new, final_ss) - # Reduce employee pension correspondingly + # Reduce employee pension and pay correspondingly dataset.person["employee_pension_contributions"] = np.where( newly_imputed, employee_pension - ss_new, employee_pension, ) + dataset.person["employment_income"] = np.where( + newly_imputed, + dataset.person["employment_income"].values - ss_new, + dataset.person["employment_income"].values, + ) dataset.person["pension_contributions_via_salary_sacrifice"] = final_ss diff --git a/policyengine_uk_data/tests/test_salary_sacrifice_imputation.py b/policyengine_uk_data/tests/test_salary_sacrifice_imputation.py index d91ab8e73..f71a5bdb5 100644 --- a/policyengine_uk_data/tests/test_salary_sacrifice_imputation.py +++ b/policyengine_uk_data/tests/test_salary_sacrifice_imputation.py @@ -8,8 +8,10 @@ - it is non-decreasing in the sacrifice and in the pay. ``impute_salary_sacrifice`` applies it to every record, reported or -imputed, and stage 2 moves employee pension contributions to salary -sacrifice without changing anyone's total of the two. +imputed. Stage 2 moves employee pension contributions to salary sacrifice +and takes the amount moved out of pay, so for every donor the pension total +and pay plus salary sacrifice are both unchanged, and the sacrifice never +exceeds the pay that remains. """ from __future__ import annotations @@ -128,18 +130,22 @@ def _impute(monkeypatch, predictions, **columns): def _assert_invariants(before, after): - pay = before.employment_income.values + pay_before = before.employment_income.values + pay = after.employment_income.values ss = after.pension_contributions_via_salary_sacrifice.values assert np.all(ss >= 0) assert np.all(ss <= np.maximum(pay, 0)) assert np.all(ss[pay <= 0] == 0) - # Stage 2 moves employee pension to salary sacrifice: whoever lost - # employee pension gained exactly that much salary sacrifice. + # Stage 2 moves employee pension to salary sacrifice out of pay: whoever + # lost employee pension gained exactly that much salary sacrifice and + # lost exactly that much pay. Nobody else's pay changes. pension_before = before.employee_pension_contributions.values pension_after = after.employee_pension_contributions.values moved = pension_after != pension_before assert np.all(pension_after <= pension_before) assert np.allclose(pension_after[moved] + ss[moved], pension_before[moved]) + assert np.allclose(pay[moved] + ss[moved], pay_before[moved]) + assert np.array_equal(pay[~moved], pay_before[~moved]) def test_nobody_without_pay_keeps_a_sacrifice(monkeypatch): @@ -164,10 +170,30 @@ def test_nobody_without_pay_keeps_a_sacrifice(monkeypatch): _assert_invariants(before, after) -def test_stage_two_moves_employee_pension_up_to_pay(monkeypatch): +def test_stage_two_takes_the_sacrifice_out_of_pay(monkeypatch): n = 40 - # Every donor's employee pension exceeds their pay, so each one moved - # keeps the part above their pay as employee pension. + before, after = _impute( + monkeypatch, + predictions=[0] * n, + pay=[30_000] * n, + asked=[0] * n, + reported=[0] * n, + employee_pension=[1_500] * n, + ) + ss = after.pension_contributions_via_salary_sacrifice.values + moved = ss > 0 + assert moved.any() and not moved.all() + assert np.all(ss[moved] == 1_500) + assert np.all(after.employee_pension_contributions.values[moved] == 0) + assert np.all(after.employment_income.values[moved] == 28_500) + assert np.all(after.employment_income.values[~moved] == 30_000) + _assert_invariants(before, after) + + +def test_stage_two_moves_at_most_half_the_pay(monkeypatch): + n = 40 + # Every donor's employee pension exceeds half their pay, so each one + # moved sacrifices half and keeps the rest as employee pension. before, after = _impute( monkeypatch, predictions=[0] * n, @@ -179,8 +205,9 @@ def test_stage_two_moves_employee_pension_up_to_pay(monkeypatch): ss = after.pension_contributions_via_salary_sacrifice.values moved = ss > 0 assert moved.any() - assert np.all(ss[moved] == 1_000) - assert np.all(after.employee_pension_contributions.values[moved] == 500) + assert np.all(ss[moved] == 500) + assert np.all(after.employee_pension_contributions.values[moved] == 1_000) + assert np.all(after.employment_income.values[moved] == 500) _assert_invariants(before, after) From 9910ad42b414591a3c89ade0c17e9fa5d4431faa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 09:00:55 -0400 Subject: [PATCH 4/6] Limit salary sacrifice against stored pay, not the simulation's pay 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 --- .../datasets/imputations/salary_sacrifice.py | 45 +++++++++++-------- .../tests/test_salary_sacrifice_imputation.py | 26 ++++++++--- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/policyengine_uk_data/datasets/imputations/salary_sacrifice.py b/policyengine_uk_data/datasets/imputations/salary_sacrifice.py index 0e10a1097..bc74d6719 100644 --- a/policyengine_uk_data/datasets/imputations/salary_sacrifice.py +++ b/policyengine_uk_data/datasets/imputations/salary_sacrifice.py @@ -32,6 +32,10 @@ from policyengine_uk.data import UKSingleYearDataset from policyengine_uk import Microsimulation +# Trained only on SALSAC respondents with pay; the name changed with that +# training set so a forest cached by an older build is not reused. +MODEL_FILE = "salary_sacrifice_paid_respondents.pkl" + PREDICTORS = [ "age", "employment_income", @@ -47,10 +51,12 @@ def limit_salary_sacrifice_to_pay( ) -> np.ndarray: """Limit salary sacrifice amounts to the pay they are sacrificed from. - A person without pay sacrifices nothing, and nobody sacrifices more - than the pay they still receive. No FRS 2024-25 respondent who reports - a sacrifice and has pay reports more than that pay, so the upper bound - only binds on imputed amounts. + A person without pay sacrifices nothing. The upper bound is an + imputation bound, not a legal one (an employee may sacrifice, for + example, a whole bonus, as long as cash pay stays above the National + Minimum Wage): no FRS 2024-25 respondent who reports a sacrifice and + has pay reports more than the pay they still receive, so imputed + amounts are held to the same bound. Args: salary_sacrifice: Annual salary sacrifice pension contributions. @@ -69,8 +75,8 @@ def save_salary_sacrifice_model(): Uses FRS respondents with pay who were asked about salary sacrifice (SALSAC field) as training data. The model learns to predict the SS pension amount directly - non-participants have 0, participants have - their reported SPNAMT value. Respondents without pay are left out: the - model only predicts for people with pay. + their reported SPNAMT value. Respondents without pay are left out: only + predictions for people with pay are kept. Returns: Trained QRF model for salary sacrifice imputation. @@ -106,7 +112,10 @@ def save_salary_sacrifice_model(): training_mask = (ss_asked == 1) & (employment_income > 0) if training_mask.sum() == 0: - raise ValueError("No training data found - no respondents were asked SALSAC.") + raise ValueError( + "No training data found - no SALSAC respondents with positive " + "employment income." + ) train_df = pd.DataFrame( { @@ -119,7 +128,7 @@ def save_salary_sacrifice_model(): # Train QRF model model = QRF() model.fit(train_df[PREDICTORS], train_df[IMPUTATIONS]) - model.save(STORAGE_FOLDER / "salary_sacrifice.pkl") + model.save(STORAGE_FOLDER / MODEL_FILE) return model @@ -136,7 +145,7 @@ def create_salary_sacrifice_model(overwrite_existing: bool = False): """ from policyengine_uk_data.utils.qrf import QRF - model_path = STORAGE_FOLDER / "salary_sacrifice.pkl" + model_path = STORAGE_FOLDER / MODEL_FILE if model_path.exists() and not overwrite_existing: return QRF(file_path=model_path) return save_salary_sacrifice_model() @@ -166,9 +175,13 @@ def impute_salary_sacrifice( dataset = dataset.copy() sim = Microsimulation(dataset=dataset) - # Get variables needed for imputation + # Get variables needed for imputation. The predictors come from the + # simulation, as in training; the limits use the stored pay, which is in + # the same year as the stored salary sacrifice (the simulation may + # calculate a later year and uprate pay but not salary sacrifice). age = sim.calculate("age").values employment_income = sim.calculate("employment_income").values + pay = dataset.person["employment_income"].values current_ss = dataset.person.pension_contributions_via_salary_sacrifice.values # Get indicator for who was asked @@ -205,7 +218,7 @@ def impute_salary_sacrifice( # A sacrifice comes out of pay. This also covers SPI-synthetic rows # copied from a SALSAC respondent, whose kept amounts come from the # FRS-only imputation rather than the survey. - final_ss = limit_salary_sacrifice_to_pay(final_ss, employment_income) + final_ss = limit_salary_sacrifice_to_pay(final_ss, pay) # Stage 2: Headcount-targeted imputation for SS users. # ASHE data shows many more SS users than the FRS captures due to @@ -219,7 +232,7 @@ def impute_salary_sacrifice( has_ss = final_ss > 0 # Donor pool: employed pension contributors not already SS users - is_donor = (employee_pension > 0) & ~has_ss & (employment_income > 0) + is_donor = (employee_pension > 0) & ~has_ss & (pay > 0) # Create enough SS records for the calibrator to work with. # Target ~70% of the 7.7mn total so the calibrator can gently @@ -242,9 +255,7 @@ def impute_salary_sacrifice( # the amount moved, which keeps taxable pay unchanged. At most # half the pay moves, so the sacrifice never exceeds the pay # that remains. - ss_new = limit_salary_sacrifice_to_pay( - employee_pension, employment_income / 2 - ) + ss_new = limit_salary_sacrifice_to_pay(employee_pension, pay / 2) final_ss = np.where(newly_imputed, ss_new, final_ss) # Reduce employee pension and pay correspondingly @@ -254,9 +265,7 @@ def impute_salary_sacrifice( employee_pension, ) dataset.person["employment_income"] = np.where( - newly_imputed, - dataset.person["employment_income"].values - ss_new, - dataset.person["employment_income"].values, + newly_imputed, pay - ss_new, pay ) dataset.person["pension_contributions_via_salary_sacrifice"] = final_ss diff --git a/policyengine_uk_data/tests/test_salary_sacrifice_imputation.py b/policyengine_uk_data/tests/test_salary_sacrifice_imputation.py index f71a5bdb5..574b1fb8e 100644 --- a/policyengine_uk_data/tests/test_salary_sacrifice_imputation.py +++ b/policyengine_uk_data/tests/test_salary_sacrifice_imputation.py @@ -70,6 +70,12 @@ def test_limit_is_monotone_in_sacrifice_and_pay(rows, more_ss, more_pay): assert np.all(limit_salary_sacrifice_to_pay(ss, pay + more_pay) >= limited) +# The build imputes on the survey-year dataset (2024) while the simulation +# calculates a later default year, uprating pay but not salary sacrifice, so +# the wiring is checked in both years. +YEARS = pytest.mark.parametrize("year", [2024, 2025]) + + class _StubModel: """Stands in for the trained QRF: predicts a fixed amount per row.""" @@ -83,7 +89,7 @@ def predict(self, X): ) -def _dataset(pay, asked, reported, employee_pension): +def _dataset(pay, asked, reported, employee_pension, year): from policyengine_uk.data import UKSingleYearDataset n = len(pay) @@ -113,17 +119,17 @@ def _dataset(pay, asked, reported, employee_pension): } ) return UKSingleYearDataset( - person=person, benunit=benunit, household=household, fiscal_year=2025 + person=person, benunit=benunit, household=household, fiscal_year=year ) -def _impute(monkeypatch, predictions, **columns): +def _impute(monkeypatch, predictions, year=2024, **columns): monkeypatch.setattr( salary_sacrifice_module, "create_salary_sacrifice_model", lambda: _StubModel(predictions), ) - dataset = _dataset(**columns) + dataset = _dataset(year=year, **columns) before = dataset.person.copy() after = salary_sacrifice_module.impute_salary_sacrifice(dataset).person return before, after @@ -148,10 +154,12 @@ def _assert_invariants(before, after): assert np.array_equal(pay[~moved], pay_before[~moved]) -def test_nobody_without_pay_keeps_a_sacrifice(monkeypatch): +@YEARS +def test_nobody_without_pay_keeps_a_sacrifice(monkeypatch, year): pay = [0, 0, -500, 30_000, 30_000, 8_000, 0] before, after = _impute( monkeypatch, + year=year, # The model predicts a large sacrifice for everyone. predictions=[25_000] * 7, pay=pay, @@ -170,10 +178,12 @@ def test_nobody_without_pay_keeps_a_sacrifice(monkeypatch): _assert_invariants(before, after) -def test_stage_two_takes_the_sacrifice_out_of_pay(monkeypatch): +@YEARS +def test_stage_two_takes_the_sacrifice_out_of_pay(monkeypatch, year): n = 40 before, after = _impute( monkeypatch, + year=year, predictions=[0] * n, pay=[30_000] * n, asked=[0] * n, @@ -190,12 +200,14 @@ def test_stage_two_takes_the_sacrifice_out_of_pay(monkeypatch): _assert_invariants(before, after) -def test_stage_two_moves_at_most_half_the_pay(monkeypatch): +@YEARS +def test_stage_two_moves_at_most_half_the_pay(monkeypatch, year): n = 40 # Every donor's employee pension exceeds half their pay, so each one # moved sacrifices half and keeps the rest as employee pension. before, after = _impute( monkeypatch, + year=year, predictions=[0] * n, pay=[1_000] * n, asked=[0] * n, From 321e613f2ef3b3175db997d90199800f39eb5d84 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:33:08 -0400 Subject: [PATCH 5/6] Correct the stage 2 comments after the pay change 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 --- .../datasets/imputations/salary_sacrifice.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/policyengine_uk_data/datasets/imputations/salary_sacrifice.py b/policyengine_uk_data/datasets/imputations/salary_sacrifice.py index bc74d6719..83a57b925 100644 --- a/policyengine_uk_data/datasets/imputations/salary_sacrifice.py +++ b/policyengine_uk_data/datasets/imputations/salary_sacrifice.py @@ -8,10 +8,10 @@ not asked. 2. Headcount-targeted imputation: converts a fraction of pension - contributors without SS into below-cap (≤£2,000) SS users, moving - employee pension contributions to salary sacrifice and taking the - amount moved out of their pay. Targets the OBR/ASHE estimate of - ~4.3mn below-cap SS users. + contributors without SS into SS users, moving employee pension + contributions to salary sacrifice and taking the amount moved out of + their pay, so the calibrator has enough records to reach the OBR + headcounts (7.7mn users: 4.3mn at or below £2,000, 3.3mn above). A salary sacrifice is pay given up for an employer pension contribution, so every amount, reported or imputed, is limited to the person's pay: @@ -160,8 +160,8 @@ def impute_salary_sacrifice( Stage 1: QRF predicts SS amounts for respondents not asked SALSAC. Every amount, reported or predicted, is then limited to the person's pay (see ``limit_salary_sacrifice_to_pay``). - Stage 2: Converts a fraction of pension contributors to below-cap - SS users, targeting ~4.3mn (OBR/ASHE). Moves employee pension + Stage 2: Converts a fraction of pension contributors to SS users, + for the OBR headcount targets. Moves employee pension contributions to salary sacrifice to keep total pension consistent, and takes the amount moved out of pay (at most half the pay). @@ -181,7 +181,7 @@ def impute_salary_sacrifice( # calculate a later year and uprate pay but not salary sacrifice). age = sim.calculate("age").values employment_income = sim.calculate("employment_income").values - pay = dataset.person["employment_income"].values + pay = dataset.person["employment_income"].values.copy() current_ss = dataset.person.pension_contributions_via_salary_sacrifice.values # Get indicator for who was asked @@ -225,8 +225,9 @@ def impute_salary_sacrifice( # self-reporting bias in auto-enrolment. Impute additional SS users # from pension contributors to create enough records for calibration # to hit OBR headcount targets (7.7mn total, 3.3mn above 2k, - # 4.3mn below 2k). Donors keep their full employee pension amount - # so those above 2k become above-cap records and the rest below-cap. + # 4.3mn below 2k). Donors move their employee pension amount (at + # most half their pay), so those above 2k become above-cap records + # and the rest below-cap. person_weight = sim.calculate("person_weight").values employee_pension = dataset.person["employee_pension_contributions"].values.copy() has_ss = final_ss > 0 @@ -252,7 +253,7 @@ def impute_salary_sacrifice( # split reflects the natural pension distribution. The FRS # pay these contributions were deducted from is before the # sacrifice, so the sacrifice comes out of it: pay falls by - # the amount moved, which keeps taxable pay unchanged. At most + # the amount moved, which keeps pay plus sacrifice unchanged. At most # half the pay moves, so the sacrifice never exceeds the pay # that remains. ss_new = limit_salary_sacrifice_to_pay(employee_pension, pay / 2) From 14e2c22b2795e28f5d4bc2cc4b827cfdaf511bf3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:41:04 -0400 Subject: [PATCH 6/6] Describe the source of the no-pay draws without a respondent count Co-Authored-By: Claude Opus 5.5 --- changelog.d/salary-sacrifice-needs-pay.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/salary-sacrifice-needs-pay.fixed.md b/changelog.d/salary-sacrifice-needs-pay.fixed.md index f60a7a5ad..7f9682293 100644 --- a/changelog.d/salary-sacrifice-needs-pay.fixed.md +++ b/changelog.d/salary-sacrifice-needs-pay.fixed.md @@ -1,2 +1,2 @@ -- Limit every salary sacrifice amount, reported or imputed, to the person's pay, and train the salary sacrifice QRF only on SALSAC respondents with pay. Stage 1 had drawn amounts for people without pay from the three SALSAC respondents with a job but no recorded pay, so about a million people with no pay carried salary sacrifice, and from 2029 policyengine-uk returned the excess over the £2,000 cap to their employment income. +- Limit every salary sacrifice amount, reported or imputed, to the person's pay, and train the salary sacrifice QRF only on SALSAC respondents with pay. Stage 1 had drawn amounts for people without pay from the few SALSAC respondents who report a sacrifice but have no recorded pay, so about a million people with no pay carried salary sacrifice, and from 2029 policyengine-uk returned the excess over the £2,000 cap to their employment income. - Stage 2 of the salary sacrifice imputation now takes the employee pension contribution it converts to salary sacrifice out of the donor's pay (at most half the pay). It had left pay unchanged, so converted donors lost the income tax relief on that contribution.