Skip to content

Deduct pension relief from adjusted net income - #2440

Merged
MaxGhenis merged 20 commits into
mainfrom
fix/2243-ani-pension-relief
Oct 16, 2026
Merged

MaxGhenis merged 20 commits into
mainfrom
fix/2243-ani-pension-relief

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #2243

Pension contributions now reduce adjusted net income (ANI), so the Personal Allowance taper and High Income Child Benefit Charge recognise pension relief. The London 2024 reproducer gives £90,000 ANI, the full £12,570 Personal Allowance and £23,432 income tax: £37,700 × 20% + £39,730 × 40%. Previously it gave £150,000 ANI, no Personal Allowance and £28,460 tax.

ITA 2007 s.58 starts from s.23 Step-2 net income, then deducts gross Gift Aid and gross relieved RAS contributions under FA 2004 s.192(1). Statutory net income already reflects net-pay contributions under s.193 and relief on claim under s.194(1). HMRC ANI guidance confirms the deductions.

The model represents employment and net_income before individual pension relief, applying that aggregate relief through allowances. This PR therefore subtracts pension_contributions_relief in ANI while retaining the existing ordinary-tax representation. Personal pension inputs are gross contributions; they are not grossed up again. Direct ANI inputs restore both pension relief and gross Gift Aid through one shared helper in the baseline and marriage-neutral reform. Both routes preserve explicitly supplied annual allowances and the pre-allowance income used for student loan repayments. Following #2240, relief follows the earnings/basic-amount limit and the annual allowance applies through a separate charge.

Invariants covered by MOCK tests:

  • Raising relieved contributions cannot lower the Personal Allowance or raise HICBC with actual Child Benefit held fixed at £1,200. Resuming a Child Benefit claim after opting out is outside this property. Generated cases also assert statutory ANI/PA/charge values and strict threshold crossings, so a pension-insensitive formula fails.
  • Pension relief remains a single deduction from ordinary rate-band bases. received_allowances_*, property tax and CGT use net_income; CGT retains the relieved-RAS basic-rate band extension without also deducting it from that base.
  • ANI does not replace the annual-allowance taper's separate income definition. The merged Limit pension relief by earnings and charge the annual allowance #2240 earnings-limited relief, AA charge and rate-eligibility gates are retained; the simplified AA taper repair remains Annual allowance taper ignores the s. 228ZA threshold-income test and employer contributions in adjusted income #2244.

#2240 merged; resolution as in the merge commit

Merge commit 9a9a9175f9933e30952afb6671e94add9b197066 incorporates exact reviewed head 2154a3158b7ab03937a00186444a8836256428aa (upstream was still open when checked). Its resolved tree is exactly the review's 0c60e514675f7050dde6f804c4cd863da352c680, with no difference from the supplied resolution patch/tree. #2240's relief and charge formulas, latest rate-eligibility gates and xfail removals are retained, alongside this PR's ANI deduction/inverse and numeric/supplied-ANI updates.

The marriage-neutral inverse now uses the same pension/Gift Aid add-back as baseline. The YAML regression checks supplied £90,000 ANI produces £23,432 tax; the reused review script checks derived and supplied routes in separate simulations. The explicit £20,000-AA round trip checks £150,000 net income and £11,043.45 Plan 2 repayments on both routes. After #2240, relief is £60,000 on both routes, not the pre-merge £20,000: AA limits the separate charge rather than section 190 relief. The old cap solver and its override bypass are gone.

The AA property now bounds contributions using the full year-specific Personal Allowance plus other fixed allowances, and pins the review's four-person RAS/net-pay witness with @example. The two #2240 comment corrections change no expected values.

Impact

Four sequential foreground top-level microsimulations (baseline and fixed, one at a time for each year), using the certified cached enhanced FRS 2024-25 uk-data 1.58.1 artifact, snapshot 2526edbbb687a3b54dbb11272e5389e4b62d2ac2, verified SHA256 056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784. Baseline is main with exact reviewed #2240 head 2154a3158b7ab03937a00186444a8836256428aa; fixed head is e4e6323812ef92846ea43d09b8505cf23c2a3765. Every changed baseline variable class is restored from that baseline Git object, with source hashes saved; #2240 relief/charge formulas are retained in both worlds. The CPS reform is inactive for this score. Offline mode prevented downloads; only aggregate JSON/text was saved. All native thread caps were four and all four pre-run compressor checks were 14.84–15.24 GB.

Model year Income tax before (£bn) Income tax after (£bn) Δ income tax (£m) HICBC before (£m) HICBC after (£m) Δ HICBC (£m) People with changed PA
2026 317.736587 317.275227 -461.360 2,059.864 1,967.537 -92.327 360,199
2030 384.402877 382.859412 -1,543.465 2,814.301 2,583.281 -231.020 959,040

Compared with round 1 against old main (2026 −£444.554m, 2030 −£1,488.281m), income-tax reductions are now £16.806m and £55.184m larger. These estimates measure the incremental ANI correction with #2240 present in both worlds and supersede the old-main score.

Changes are fixed minus baseline. HICBC is included in income tax; the changes must not be added. Counts use matching person IDs and model-year person weights once, with a £0.01 PA-change tolerance. These are static paired-model results under inherited pension inputs and relief/AA approximations, not a #2244 taper score. Reproduction script, source hashes and aggregate provenance are retained in .job/impact-r2.py and .job/impact-r2-results.json for the hub. No individual records were written.

Validation

  • uv run --frozen --no-sync pytest -n 3 policyengine_uk/tests/test_adjusted_net_income_pension_relief.py policyengine_uk/tests/test_gift_aid.py policyengine_uk/tests/test_child_benefit_charge_rounding_properties.py policyengine_uk/tests/test_capital_gains_tax_components.py -q: 78 passed, 1 existing dataset-dependent CGT skip, 362.31s. All 17 ANI regression cases pass.
  • uv run --frozen --no-sync policyengine-core test over HMRC income_tax, pensions, capital_gains_tax, child_benefit, tax_free_childcare and DfE extended_childcare_entitlement, with -c policyengine_uk: 643 passed, 192.55s.
  • uv run --frozen --no-sync policyengine-core test policyengine_uk/tests/policy/reforms/cps -c policyengine_uk: 10 passed, 29.69s.
  • uv run --frozen --no-sync pytest -n 3 policyengine_uk/tests/test_pension_annual_allowance_properties.py -q: 10 passed, 749.56s, including the four-person @example. Reused combined-cps-check.py: exit 0; derived and supplied routes agree on all checked outputs.
  • New explicit-AA Python regression and corrected supplied-ANI CPS YAML regression each fail behaviorally at exact original PR head 1dbc88172: 1 failed each. Reused original ani-mechanism-check.py confirms both original P2 failures. Unchanged combined AA property fails the exact four-person witness; corrected domain accepts it. Original 16 Python and eight YAML ANI cases already have independently verified behavioral failures against old main, as recorded in round 1/review.
  • make format, uvx ruff format --check . (1,727 files) and whitespace checks pass. Committed-head fix-evidence check passed (seven changed executable test files).
  • Locked workspace environment: Python 3.13.9 / policyengine-core 3.33.2 via UV_LINK_MODE=clone uv sync --frozen --extra dev. All tests run in the foreground with at most three workers; native threads are one. Old-head tests overlapped at at most one additional worker. No dataset downloads.
  • Full repository suite and documentation build were not run locally; required focused suites cover the affected paths, and documentation changes are an engineering Markdown guide. No self-review or in-job reviewer was run; the hub must dispatch independent review. No PR merge performed.

Round 3: trading-loss reference

At 7f1b608f20c9ddd9d4de77721e7e90ba212d73a7, the trading-loss property's reference uses the model's pre-pension net_income for its ordinary-income comparisons and subtracts independently calculated pension relief once for adjusted total income. Its module explanation is updated. This round changes only test_trading_loss_properties.py; production and the round-two impact estimates are unchanged.

  • Full file: uv run --frozen --no-sync pytest -n 3 -q --hypothesis-show-statistics policyengine_uk/tests/test_trading_loss_properties.py: 12 passed, including the unchanged 16-person @example (833.10s).
  • Five-file round-two suite: uv run --frozen --no-sync pytest -n 4 --dist worksteal -q policyengine_uk/tests/test_{adjusted_net_income_pension_relief,gift_aid,child_benefit_charge_rounding_properties,capital_gains_tax_components,pension_annual_allowance_properties}.py: 88 passed, 1 existing dataset-dependent CGT skip (2118.88s; exit 0). A non-failing Hypothesis GC-timing shutdown warning is retained in the log; all 89 collected tests reported outcomes.
  • POLICYENGINE_UK_DEFAULT_DATASET=MOCK uv run --frozen --no-sync policyengine-core test policyengine_uk/tests/policy/baseline/gov/hmrc/{income_tax,pensions} -c policyengine_uk: 359 passed, 207.45s.
  • Before editing, the exact property node at e4e6323812ef92846ea43d09b8505cf23c2a3765 failed as expected: 1 failed, £73,200 expected versus £74,100 actual, 102.66s. Saved source matches that Git blob exactly. The reused evidence-r2/ci_loss_replay.py retains its three assertions: old reference/current production fails; exact Limit pension relief by earnings and charge the annual allowance #2240 ANI baseline passes all 16; corrected reference/current production passes all 16. Replay exits 0 and records £3,600 relief and £296,400 ATI and explicitly asserts £73,200 versus £74,100.
  • uv run --frozen --no-sync make format and required uvx ruff format --check . pass (1,727 files); source-scope and whitespace checks pass. All suites ran in foreground sessions, at most four workers, with native threads capped at one and no dataset downloads. No microsimulation, full repository suite or documentation build was run for this test-only change. No self-review, in-job reviewer or merge was performed.

Pushed once successfully on the first attempt. PR remains a draft. Logs, replay source and exact commands are retained in .job/ for the hub. Independent review, CI observation and the existing Axiom/#2441 coordination remain with the hub.

axiom: needed

Rulespec-uk main uk/statutes/ukpga/2007/3/58.yaml is deferred with no rules and its companion 58.test.yaml is empty. Signed encoding and pension-deduction companion cases remain for the hub; no billed encoder was invoked.

Reproducer: policyengine_uk/tests/test_adjusted_net_income_pension_relief.py::test_150000_pay_and_60000_ras_restore_full_personal_allowance

MaxGhenis and others added 15 commits October 10, 2026 06:59
…ess once

pension_contributions_relief capped relief at the annual allowance, and
personal_pension_contributions_tax also charged the excess over it, so
contributions above the allowance were taxed twice. Relief now runs to the
s. 190 limit (the greater of relevant UK earnings and £3,600), and the annual
allowance charge recovers it once.

The charge now taxes the chargeable amount as the top slice of reduced net
income at the basic, higher and additional rates (FA 2004 s. 227(4A)), or the
Scottish rates with the starter band at the Scottish basic rate (s. 227(4AA)),
instead of one marginal rate. Unused allowance carried forward (s. 228A) is a
new input, nil by default.

Fixes #2237

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 1 on #2240:
- The annual allowance charge now stacks the chargeable amount on statutory
  reduced net income and raises every rate limit by relieved relief-at-source
  contributions and grossed-up Gift Aid (FA 2004 s. 227(4C)). Translating the
  model's deductions and flooring at zero lost the extension once
  relief-at-source contributions passed income after allowances (£8,460
  charged where the statute gives £8,000).
- Only relief at source can give the increase to the £3,600 basic amount
  (s. 191(7)); personal contributions are taken as relief at source, employee
  contributions as net pay, as capital_gains_tax does.
- Recognise the Scottish starter band only when the raw bracket has both a
  rate and a threshold.
- Property tests cover both relief methods and Gift Aid, use float32-aware
  tolerances, and exclude Scotland from the no-double-tax property; a YAML
  example pins the Scottish deviation from relieving relief at source by
  deduction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 2 on #2240: the upper and lower charge bounds now allow the same
relative float32 error as the other assertions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Hand-off at 1dbc881 (tests 93a21a304, fix 1dbc88172), pushed once successfully. This draft PR fixes #2243; the London 2024 reproducer now gives £90,000 ANI, £12,570 PA and £23,432 income tax, with one ordinary tax-base pension deduction.

  • Tests: all 16 new regressions fail on main and pass here. Focused Python 77 passed / 1 existing dataset-dependent CGT test skipped; tax and childcare YAML 610 passed. make format, required uvx ruff format --check . (1,723 files), committed-head fix evidence and whitespace checks pass. Full repository suite and documentation build were not run locally.
  • Certified cached 1.58.1 impact: 2026 income tax −£444.554m, HICBC −£92.327m, PA changed for 332,434 weighted people. 2030 income tax −£1,488.281m, HICBC −£231.020m, PA changed for 942,583 weighted people. HICBC is included in income tax. Four sequential foreground microsimulations, four threads, compressor checks below 12GB, no downloads, aggregates only. Exact hashes/provenance and the table are in the body and .job/impact-results.json.
  • Limit pension relief by earnings and charge the annual allowance #2240: compared both merge orders with 2154a3158b7ab03937a00186444a8836256428aa; one conflict in pension_contributions_relief.py. Keep Limit pension relief by earnings and charge the annual allowance #2240's earnings-limited relief and newer charge gate; keep this PR's ANI deduction/inverse. Four Limit pension relief by earnings and charge the annual allowance #2240 YAML values and six supplied-ANI parameterizations need the exact updates listed in the body and .job/compatibility.md.
  • Open items: observe CI; dispatch independent review at this exact head; complete Axiom s.58 signed encoding and companion cases (existing module deferred, tests empty). No self-review or in-job reviewer was run, and this PR has not been merged.

.job/HANDOFF.md, .job/handoffs.jsonl, progress notes, aggregate evidence and verified .job/fix-2243.bundle are ready for the hub. Commits use writable .job/repository.git because shared Git metadata is outside the sandbox; the caller's checkout and shared refs were untouched.

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Round 2 addresses the review at 1dbc88172; final head e4e6323.

  1. CPS inverse: baseline and marriage-neutral reform now share the Gift Aid/pension add-back helper. For the unmarried London £150k/£60k RAS case, derived and supplied £90k ANI both give £150k net income and £23,432 tax. uv run --frozen --no-sync policyengine-core test policyengine_uk/tests/policy/reforms/cps -c policyengine_uk: 10 passed. The corrected new YAML case fails at the old head (£90k instead of £150k net income); the reused review script independently confirms old supplied tax £3,486. The reused combined CPS script now exits 0 with identical outputs.

  2. Explicit AA / loan round trip: merging Limit pension relief by earnings and charge the annual allowance #2240 removes the cap solver and its bypass. The new regression supplies the derived ANI with explicit £20k AA; both routes preserve £150k net income and £11,043.45 Plan 2 repayment, rather than the old supplied-route £14,643.45. After Limit pension relief by earnings and charge the annual allowance #2240, section 190 relief is £60k on both routes, not the pre-merge £20k: AA governs the separate charge. The new Python case fails behaviorally at 1dbc88172 (£60k supplied relief versus £20k derived). Executed:

    uv run --frozen --no-sync pytest -n 3 policyengine_uk/tests/test_adjusted_net_income_pension_relief.py policyengine_uk/tests/test_gift_aid.py policyengine_uk/tests/test_child_benefit_charge_rounding_properties.py policyengine_uk/tests/test_capital_gains_tax_components.py -q

    78 passed, 1 existing dataset-dependent CGT skip (362.31s).

  3. Restored-PA property domain: the bound now reserves the year's full PA while preserving other fixed allowances. Added the exact four-person RAS/net-pay witness as @example. The unchanged combined property fails that witness with AssertionError; the corrected property passes it. uv run --frozen --no-sync pytest -n 3 policyengine_uk/tests/test_pension_annual_allowance_properties.py -q: 10 passed (749.56s).

  4. Limit pension relief by earnings and charge the annual allowance #2240 and comments: local merge 9a9a9175f incorporates exact reviewed head 2154a3158; its resolved tree exactly matches review tree 0c60e514675f7050dde6f804c4cd863da352c680, with no difference from the prescribed resolution. Relief/charge formulas and rate gates remain byte-identical to Limit pension relief by earnings and charge the annual allowance #2240; xfail removals and this PR's numeric/supplied-ANI updates are preserved. Both P3 comment nits are fixed without changing expectations. This equivalent shell expansion reproduces the executed six-directory command:

    uv run --frozen --no-sync policyengine-core test policyengine_uk/tests/policy/baseline/gov/{hmrc/{income_tax,pensions,capital_gains_tax,child_benefit,tax_free_childcare},dfe/extended_childcare_entitlement} -c policyengine_uk

    643 passed (192.55s).

The body now qualifies HICBC's fixed £1,200 actual Child Benefit property, excludes opt-out claim resumption, replaces the conflict section with the merge resolution, and retains Reproducer:. make format, required uvx ruff format --check . (1,727 files), whitespace and committed-head fix evidence all pass.

Re-measured incremental ANI impact with #2240 in both worlds, certified uk-data 1.58.1 and verified SHA256 056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784:

Year Δ income tax Δ HICBC (included in income tax) Weighted people with changed PA
2026 −£461.360m −£92.327m 360,199
2030 −£1,543.465m −£231.020m 959,040

Versus round 1's −£444.554m / −£1,488.281m, the income-tax reductions are £16.806m / £55.184m larger. Four sequential foreground top-level runs, four native threads, compressor 14.84–15.24GB, matching IDs/model-year weights once, no downloads or record outputs. Aggregate evidence/script and verified recovery bundle are in .job/ for the hub.

The branch was pushed in one successful push cycle. PR remains a draft. Full repository suite and documentation build were not run locally. No self-review, in-job reviewer or PR merge was performed. Hub follow-up: independent review at this head, CI observation, and signed Axiom s.58 encoding (module still deferred; axiom: needed).

@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Fixed the remaining round-two finding in 7f1b608f20c9ddd9d4de77721e7e90ba212d73a7: the trading-loss reference uses pre-pension net_income throughout and retains one pension deduction; its explanation is updated. Only that test file changed.

  • uv run --frozen --no-sync pytest -n 3 -q --hypothesis-show-statistics policyengine_uk/tests/test_trading_loss_properties.py: 12 passed, including the 16-person @example.
  • Round-two five-file Python suite, pytest -n 4 --dist worksteal -q: 88 passed, 1 existing dataset-dependent CGT skip.
  • uv run --frozen --no-sync policyengine-core test policyengine_uk/tests/policy/baseline/gov/hmrc/{income_tax,pensions} -c policyengine_uk: 359 passed.
  • Old node at exact e4e632381: 1 expected assertion failure, £73,200 versus £74,100. The reused review replay passes all three outcome assertions.
  • make format and uvx ruff format --check .: passed, 1,727 files.

Pushed once successfully on the first attempt. PR remains a draft. Production and its round-two impact estimates are unchanged; independent review remains with the hub.

@MaxGhenis
MaxGhenis marked this pull request as ready for review October 16, 2026 22:28
@MaxGhenis
MaxGhenis added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit df61251 Oct 16, 2026
17 checks passed
@MaxGhenis
MaxGhenis deleted the fix/2243-ani-pension-relief branch October 16, 2026 23:06
@MaxGhenis

Copy link
Copy Markdown
Collaborator Author

Landed by the UK hub (merge queue, pinned to head 7f1b608f20c9ddd9d4de77721e7e90ba212d73a7).

  • Gates at this head: gh pr checks exits 0 (15/15), mergeable and CLEAN, marked ready by the hub, no CHANGES_REQUESTED.
  • Review chain (the hub's independent GPT-6.1 Sol reviews):
    • REQUEST_CHANGES at 1dbc88172 (two supplied-ANI regressions);
    • REQUEST_CHANGES at e4e632381 (one stale test reference);
    • APPROVE, no findings, at this head (Subfleet 20261016-172711-uk2440-review-r3c).
    • The last delta changes only test_trading_loss_properties.py. Its reference is independent of production, and it rejects all three planted mutants: the pension deduction doubled, omitted, or the cap on pre-pension income.
    • 12 head tests and 427 cases on the tree merged with main pass.
  • Law: ITA 2007 s. 58. Adjusted net income deducts the grossed-up Gift Aid and the gross relief-at-source pension contributions. The baseline and marriage-neutral reform inverses share one add-back. This builds on Limit pension relief by earnings and charge the annual allowance #2240's earnings-limited relief.
  • Impact, certified uk-data 1.58.1, against main with Limit pension relief by earnings and charge the annual allowance #2240:
    • 2026: income tax −£461.360m, including HICBC −£92.327m; 360,199 weighted people with a changed personal allowance.
    • 2030: income tax −£1,543.465m, including HICBC −£231.020m; 959,040 people.
  • Follow-ups:

MaxGhenis added a commit that referenced this pull request Oct 17, 2026
Conflict: test_trading_loss_properties.py docstring. Kept main's invariant 1 wording (pre-pension net_income, #2440) and this branch's invariants 2 and 3 (tax before the charge).

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.

Pension contributions do not reduce adjusted net income (ITA 2007 s. 58), so the personal allowance taper and HICBC ignore them

1 participant