Repository navigation
Set reliefs and allowances against income for the least tax - #2830
Conversation
…gives the least tax ITA 2007 s. 25(2) deducts Step 2 reliefs and Step 3 allowances in the way that gives the greatest reduction in liability, and from 2027-28 s. 25(3A) deducts them from income other than property, savings and dividend income first. The model set them against non-savings, savings and dividend income in that fixed order. utils/income_tax_allocation.py finds the least-tax share per person by evaluating the Step 5 tax at every crossing of the lines where its slope changes. Fixes #2106. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the least-tax share moves Base non-savings income on net_income plus trade loss relief, as the default order does, so a supplied adjusted net income or a reform that sets net income is followed. Add a 2027-28 case where s. 25(3A) keeps the allowance against pay. Update the gift aid and integration cases, and the gift aid snapshot, whose tax falls under s. 25(2), with the arithmetic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The search now runs only on people that three tests leave: one share exists (nothing to share, or no income left after the deductions); the old order sets every shared deduction against the first component and leaves no savings or dividends charged above the nil rates; or the rate on the first component just above where the old order leaves it covers the highest savings and dividend rates the moved deductions can reach. Each test gives the result the search would, and a property test checks that on synthetic people, with rising and with falling rates. The search evaluates its candidates only on the people left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fast path looked up each rate by the last threshold passed, which goes wrong once a Gift Aid gift lifts the UK basic and higher rate limits above the £10 million extra bracket: a person whose income sits below the lifted basic rate limit read as paying the additional rate, so the fast path could keep the old order where moving deductions saves tax. The fast path now sums the rate steps the income has passed, the slope of the tax the search evaluates, and requires every step to be non-negative. The property test gains that person. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ve it Case 12 sets £270 of the allowance against dividends above the basic rate limit, with £300 of savings between the pay and the dividends inside the savings allowance. The search finds that share only through the line where the pay reaches the basic rate limit. The changelog fragment now names the model as the actor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Property income of £40,000 and savings of £20,000 in 2026-27: under ITA 2007 s. 25(2) the model sets £10,270 of the Personal Allowance against savings, which keeps the property income at the basic rate limit and puts the £500 savings allowance against savings taxed at 40%. Savings tax falls from £5,846 to £3,692 and income tax from £11,332 to £11,232. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Under ITA 2007 s. 25(2) the model can set allowances against savings or dividends while property income is left, so: - the accounting invariant subtracts the savings and dividends left after the allowances set against them; - the monotonic invariant tests income tax, since more property income can lower savings tax: property income of £30,000 to £40,000 with savings of £20,000 takes savings tax from £3,800 to £3,692 while income tax rises from £7,286 to £11,232, which a new test pins; - the higher-rate check pins savings tax of £3,692, income tax of £11,232 and non-savings taxable income of £37,700 for property income of £40,000 and savings of £20,000. The file now loads the model once through cloned_uk_tax_benefit_system, as other test files do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The notes now say the model sets the allowances under ITA 2007 s. 25(2), rather than the section or the allowance doing it. Comments and docstrings only; the code and the YAML data don't change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ITA 2007 s. 55B(1) gives the Marriage Allowance as a tax reduction, which Step 6 of s. 23 deducts (s. 26 lists Chapter 3A), not as a deduction at Step 3. The search now leaves marriage_allowance out of the earned income exclusions it reads, so the s. 55B(2)(b) rate test can read the share without a cycle. Case 13 of least_tax_allocation.yaml fails when the search reads the allowance again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comments only: the module docstring, the IGNORED_EXCLUSIONS note and case 13's working. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace passive constructions in the branch's comments, two YAML case
names, a test module docstring and the documentation of
non_savings_income_after_allowances ("is taxed at 20%" becomes "bears
20%", "is left" becomes "remains"). No test input, expected value or
formula changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Scottish taxpayer's property income stays on the Scottish rates while the Budget 2026 savings rates rise, so the least-tax order can move an allowance onto savings. Property tax and savings tax then both move, so the identities in test_budget_2026_savings_property_rates_properties.py hold only for people whose allowance order the rates leave unchanged. Pin that person with a worked case, and add them to the fast-path test's fixed people. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…order Pension relief under net pay (FA 2004 s. 193(2)) and relief at source (s. 192(4)) is no Step 2 deduction the taxpayer can set against savings or dividends, so the least-tax search now holds min(pension relief, non-savings income) on non-savings income and shares only the rest. The income tax that Universal Credit deducts from earnings reads the old order's deductions, so more savings or dividends never raise it. Before 2027-28 the search adds the lines that an exclusion from earned income creates beside property income. non_savings_income_after_allowances comes from the allocation's own arithmetic, and earned_taxable_income never exceeds it, so float32 rounding in the relief added back leaves no residue. New tests: YAML cases for pension relief, the earned income add-back, a pension-exempting reform and the float32 residue; property tests for the UC slice, the search with exclusions and the Budget 2026 rate bound; tests that separate the fast path's supplied-net-income and Scottish starter rate checks. Docstrings name the actor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6JoK8gQvt3JZzVN18g
The branch renamed test_more_property_income_never_lowers_savings_or_dividend_tax to test_more_property_income_never_lowers_income_tax, and the checked-in durations still named the old test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6JoK8gQvt3JZzVN18g
Quote what FA 2004 s. 192(1) and (4) give for relief at source in the allocation's docstring and comment, YAML case 15 and the pension test. Give the first-threshold sample's counts, bound the float32 residue at under a penny, name the model's choice of PAYE coding for the Universal Credit slice, and note that non_savings_taxable_income reads non_savings_income_after_allowances through its two parts. No formula changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N2tyHPRRwcYXiPudqKR4bf
Aggregate impact on certified data: passBase Checks: 0 broken by this change, 1 failing on both sides, 0 fixed. Moves flagged for review: 0 (thresholds in Notes
Moves flagged for reviewNone. Receipts by tax head
Spending by benefit and service
Caseloads
Government balance, each component counted once
Model totals
Household income totals
Inequality
Mean household net income in each side's own deciles
Standard reform costings (change in gov_balance)
Standard reforms: change in each reform's target
Household net income by decileMean change in household net income per household, and as a share of the decile's base income; households stay in the base run's deciles.
No change in any year: population; poverty rates; people in poverty. Checks failing on both base and head (1)
Counted-once checkThe check raised each of 92 components by £1 per unit in 2026-27 and read every head of its totals household by household: its own head must rise by exactly £1, no other tax head may move, no other benefit may rise, and the model's own total must move by what its heads moved. A response that is not finite breaks every rule. The check measures a head that breaks a rule twice more: once holding every other head at its baseline value, and once holding every other head at its value after the first £1 while the component rises again. The head fails where either measurement still shows the response. The check cannot hold a head at a value that is not finite. In each unit where another head is not finite after the first £1, it holds that head at its baseline value, and the second measurement judges no household the unit belongs to. There a head fails wherever it first broke a rule, or wherever the check could not confirm whether its response contains the component. 0 failed and the check skipped 0.
Provenance
Dataset: Receipts by tax head: head levels
Spending by benefit and service: head levels
Caseloads: head levels
Government balance, each component counted once: head levels
Model totals: head levels
Household income totals: head levels
Population: head levels
Poverty rates: head levels
People in poverty: head levels
Inequality: head levels
Mean household net income in each side's own deciles: head levels
Standard reform costings (change in gov_balance): head levels
Standard reforms: change in each reform's target: head levels
|
|
Ready for hub review Verdict: REQUEST_CHANGES at The text-only delta closes all nine P3 findings from the previous round. Every quotation I checked against the law in the brief matches word for word, and both of the author's judgment calls are right. The new 2027-28 case is right by hand and in a run. No formula changed. But CI on this head is red. Two existing tests, which pass on How I worked: detached worktrees of the head and of P1P1-1. CI is red: two tests still pin the old order. The CI run Neither file has changed since the merge base (a)
With the one-character assertion change, the file passes (on a scratch copy, then restored). (b)
Model runs give the same figures: on head, HIGH is
With that change the file passes (on a scratch copy, then restored). One caveat: this rests on the model's Body: add both tests to "Existing tests whose expected values move, and why", with the reasons above. Replace "Not run: the whole test suite, which CI runs on this PR, and any test that needs a survey dataset." with the CI result once it is green. P3P3-1. The pension paragraph's last sentence reads against its own claim (body, "Pension relief stays on non-savings income"). The paragraph says neither arrangement gives a Step 2 deduction. It then ends "since the taxpayer must otherwise deduct it from that income first (s. 25(3A))", but s. 25(3A) reaches only "the reliefs and allowances in Steps 2 and 3". Replace with: "…since the model otherwise deducts it from that income first, as s. 25(3A) requires of the reliefs and allowances in Steps 2 and 3." P3-2. Optional: Scottish limits ( P3-3. Axiom packet, the pending note on the 2027-28 case. The packet's own blocker section lists s. 8 among the sections already in the corpus at Previous round, finding by finding
The author's two judgment calls
The new 2027-28 caseBy hand, at the model's 2027-28 parameters (I read them: PA 12,570, UK thresholds [0, 37,700, 125,140], dividend rates [10.75%, 35.75%, 39.35%]). Pay £50,000 and dividends £20,000:
Run: QuotationsChecked word for word against the hub's fetched text in the brief:
Each cited subsection says what the text claims:
Checked and sound
What I couldn't check
Generated by Claude Code |
|
Picking this up for the UK hub. Reply here if you're mid-change and I'll stop. Generated by Claude Code |
The trading loss differential now bounds the tax with a loss by the tax on income cut in the old order, and keeps the equality where the person has no savings or dividend income. The paternity pay test expects the £100.20 rise that setting £1,500 of the Personal Allowance against savings gives. The allocation docs cite FA 2004 s. 192(4A) for the Scottish rate limits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LwQq6KkfvQBfSHTvh7rcen
|
Generated by Claude Code |
|
Generated by Claude Code |
|
Generated by Claude Code |
|
Ready for hub review Head: FindingsP1-1(a),
P1-1(b),
Body bullets (item 3): I added both bullets at the end of "Existing tests whose expected values move, and why", with figures from runs at head:
Test plan (item 4): I added a "Ran at 056a674…" list and labelled the older lists by commit. The checkbox now reads "Lint and format checks pass at 056a674…". The "Not run" line is unchanged. Merge notes: I reran them from head.
P3-1: I replaced the pension paragraph's last clause with the review's text. P3-2: P3-3 and case 11,
Where I departed from the review's or the brief's text
Tests at 056a674
Mutation checksEach ran on a scratch copy, on the renamed test without xdist, and was then restored.
Skipped
Generated by Claude Code |
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Ready for hub review Verdict: APPROVE WITH NITS at The delta closes P1-1(a), P1-1(b) and P3-1 to P3-3 from the review of How I worked: a detached worktree of P1None. P2None. P3P3-1.
Replace docstring lines 25 to 33, from "the payer keeps" to "shares only the rest.", with: In the comment at P3-2. Body, test plan and merge notes: production history, and SHAs that lag the head.
P3-3. Rules cited as actors in the new s. 12B text. The writing rules have Parliament act and cite the rule in brackets.
P3-4. Optional: widen the no-choice branch ( Earlier findings and this round's rulings
Checked and soundP1-1(a): the one-sided bound can fail. It fails wherever the model charges more than the old-order cut, for example when the relief comes off net income short of its full amount:
So the bound catches under-relief, and the kept equality catches over-relief. The author didn't rerun "survives with the branch disabled", and I didn't either. P1-1(a), the docstrings against the code.
Model runs, 2026-27, London, age 40.
The paternity rise is Hand checks.
The s. 12B reading, against the quoted law: right.
Merge notes,
Tests at
CI at the head. Every check run is Python shards from the job logs:
Live PR. The title and body match the hub's documents: the first lines are kept, both new "expected values move" bullets are there, and the s. 12B judgment call and the P3-1 text are in place. What I couldn't check
Generated by Claude Code |
FA 2004 s. 192(4A) raises the Scottish basic rate limit and the Scottish rate limits above it, where a Scottish rate above the Scottish basic rate is chargeable. The docstring and one comment said "the Scottish rate limits". Text only; the AST with docstrings blanked matches 3b54d53. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
For C1, we've quoted the law behind case 4 in rulespec-uk#524. Parliament set the 2027-28 property basic rate at 22% by FA 2026 s. 7. By s. 6(3) and (4) it inserted ITA 2007 ss. 11CA and 16A, with effect from 2027-28 (s. 6(8)). We quote s. 7, s. 11CA(1) and (4), and the s. 6(4) insertion beside the s. 6(8) commencement text. We now cite the law for each step of case 4, where we take a taxpayer who is neither a Scottish nor a Welsh taxpayer (s. 11CA(4)). The £19,000 of property income sits immediately below the dividends (s. 16A(4)), within the basic rate band, so Parliament charges it at 22% (s. 11CA(1) and s. 7(a)). We added the same steps to the At |
juaristi22
left a comment
There was a problem hiding this comment.
Re-reviewed 6602ccce488f1302972331d61ba94f02528d08e7 and the new filed evidence. COMPLETE — C1 RESOLVED; no open findings or material evidence gaps.
The actual Axiom #524 issue now supplies FA 2026 s.7's 22% rate, the operative s.11CA and s.16A insertions, commencement, the non-Scottish/non-Welsh case scope and the module/corpus ingestion boundary. These match the independently checked official legislation. Companion case 4's statutory 22% calculation remains separate from the PR's announced 24% scenario.
Code and description are unchanged. The prior 143 passing checks and native result were verified and retained, with current runtime/dependency pins; no tests or model calculations were rerun. Applicable checks pass at this exact head. Approved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main added this case after this branch's last CI run. With the allowances set for the least tax, the parent's income tax falls by 100, so the case now expects 15,935, with 7,540 on the pay and a deduction of 13,649. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22
left a comment
There was a problem hiding this comment.
Incremental review at aed86e9a3154db7cdfbc015603dcccd825130c7f: COMPLETE, no remaining findings or material evidence gaps. Prior finding C1 remains resolved. Production relief-allocation logic is unchanged from the previously approved head; this round checked the changed test oracles and affected dependencies, retaining the relevant earlier statutory evidence.
Validation: 11 repository tests passed (9 YAML and 2 Python), plus a fresh two-household control through final income tax and household net income. The earlier 143 checks remain historical evidence and were not repeated. All 33 retained official source originals and their text extracts passed integrity checks; the enacted 2027 property rates remain distinguished from the announced scenario.
This is a completed review comment. Fresh approval is deferred while exact-current-head CI is pending; CI can be checked later without repeating the completed code/source review.
|
All 20 checks pass at |
juaristi22
left a comment
There was a problem hiding this comment.
Approved at aed86e9a3154db7cdfbc015603dcccd825130c7f. The completed independent review has no remaining findings or material evidence gaps; C1 remains resolved. All 20 latest checks now pass at this exact head, including Aggregate impact and the required Lint and Test / Result checks.
This closes the pending-CI approval hold. The reviewed code and description are unchanged, the retained source evidence was integrity-checked, and the author's approval request adds no new scope. No code review, tests or model calculations were repeated.
…he allowance order branch Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
juaristi22
left a comment
There was a problem hiding this comment.
Review status: COMPLETE. Request changes at 321fb198acd86ec9507a7f3abaa7e586d22c38fc.
C2 — Account for Marriage Allowance in the final-liability objective. The allocator's Step 5 objective/fast path at income_tax_allocation.py:652 misses a lower final bill after the newly merged Step 6 credit.
Executed case: 2026, England, married 40-year-olds; recipient earnings £40,000, savings £9,000 and dividends £1,570, with a zero-income donor and a valid transfer election/UK residence assumed, without Married Couple's Allowance. The current calculation puts the £12,570 Personal Allowance against earnings, gives no Marriage Allowance credit, and charges £7,376.02. A feasible split of £2,500 against earnings, £8,500 against savings and £1,570 against dividends leaves £37,500 non-savings income, £500 savings covered by the savings nil rate, and no dividends. The model then calculates the £252 credit and £7,248 final tax. Eligibility, the credit and final tax were calculated; only the allocation was controlled. This proves at least £128.02 excess tax, rather than claiming £7,248 is the global minimum.
ITA 2007 s25(2) requires the greatest reduction in liability, which s23 defines through the later steps. S55B(2)(b)–(ba) distinguishes savings nil-rate income from the dividend-rate test without the dividend allowance. The objective and pruning need to account for the resulting Step 6 eligibility/credit, with a regression covering this household. A Step 5-only tie-break does not fix this case.
A1 — Refresh the Marriage Allowance description. The present-tense paragraphs still describe the old earned-income subtraction and a future Step 6 fix, although the merged dependency now implements the credit. Update those statements or date them as historical; keep the old grid results attributed to their original heads.
C1 remains resolved. The statutory rate evidence and queued companion-case source packet remain valid.
Validation: 143 current repository tests passed (99 YAML, 44 Python). A separate household assertion in the same bounded campaign exposed C2; a focused actual-versus-two-legal-allocation comparison confirmed it without forcing eligibility, credit or tax. The overall campaign exit 1 is retained as finding evidence. Both independent roles completed; 36 cached official source/extract pairs validated, with no new source acquisition. Exact-head Python/aggregate CI was pending at final capture and is refreshed again before this review is submitted. No approval is issued.
Section 25(2) asks for the greatest reduction in liability, which s. 23 fixes after the Step 6 tax reductions. The share decides whether a recipient meets the Marriage Allowance rate test (s. 55B(2)(b) and (ba)), so the search now compares the Step 5 tax less the reduction each share wins. marriage_allowance_reduction_at_stake gives it that reduction whatever the share, and the CPS expansion sets it to nil where the reform gives the allowance at any share. The search adds the rate test's limit on non-savings income to its lines; the test's other edges, no dividends left and savings left at the savings allowance, already lie among them. It counts a share as meeting the test only with room below each limit, and sets savings or dividends exactly where the test reads them, so float32 rounding can't deny the reduction. It judges the old order by the test's own arithmetic, so a recipient the old order qualifies moves only for a lower Step 5 tax. The rate test moves to utils/marriage_allowance.py, which the allocation and meets_marriage_allowance_income_conditions share. YAML cases 19 to 23 and new property tests cover recipients, and case 13's comment, the module docstring and the changelog describe the Step 6 reduction. Since #2828 the model no longer subtracts the allowance from earned income, so the synthetic people's £1,260 exclusion from earned income drops the allowance's name. The Universal Credit earnings slice takes off the Step 6 reductions, so more savings or dividends can raise it by failing a recipient on the rate test; its comment and property-test docstring now say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
For C2, the search at 9f33c78 minimises the Step 5 tax less the Marriage Allowance reduction each share wins. For your household, it sets £2,500 of the Personal Allowance against pay, £8,500 against savings and £1,570 against dividends. Income tax comes to £7,248, as YAML case 19 now checks. For the pruning, the fast path sends to the search each recipient whom the old order denies the reduction, wherever some share could bring their non-savings income within the rate test's limit. Cases 20 and 22 add recipients whose share wins the allowance by clearing dividends and stopping savings at the nil rate, one with pay in 2026-27 and one with property income in 2027-28. In case 21 pay alone exceeds the limit, so no share wins it, and in case 23 a recipient whom the old order qualifies at the limit keeps that order. For A1, we rewrote the Marriage Allowance paragraphs around the Step 6 reduction that #2828 merged. We also tied the earned-income subtraction, the older grid results and the aggregate estimate to the heads they come from. |
juaristi22
left a comment
There was a problem hiding this comment.
Follow-up review at 9f33c7801d3bf7a40005f9561404b5fc7631d3de: C1, C2 and A1 are resolved. No new code finding; overall status is PARTIAL solely for hosted CI execution.
C2 resolved. With the original 2026 England household (married 40-year-olds; recipient earnings £40,000, savings £9,000 and dividends £1,570; zero-income donor), the model now automatically allocates £2,500/£8,500/£1,570 of deductions to those income components, calculates the £252 Marriage Allowance credit, and returns £7,248 income tax and £41,227.60 household net income. Allocation, eligibility, credit and final tax were not forced. The final-liability objective and pruning now account for the credit. New cases 19–23 and the focused boundary/helper/CPS/UC checks passed.
A1 resolved. The description now states the current Step 6 treatment and attributes the old subtraction/grid/aggregate results to their historical heads. C1 remains resolved with the retained statutory source packet.
Validation: 84 current tests passed (74 YAML, 10 Python), plus the automatic original-household calculation in the same bounded invocation. Six runtime pins matched the lock and UK imports resolved to the captured snapshot. Both independent roles completed; 36 cached official source/extract pairs validated, with no new policy-source acquisition. The earlier 143 passes and the author's grid are not counted as new executions.
CI_EXECUTION remains open. Python 4/7, job 129498543581 is reported against this PR head, with synthetic checkout 6f1da93e on base 93b1711e. Its test step was cancelled after the runner received a shutdown signal. No failed assertion or model defect was identified in the captured job log/annotation. A successful current-head hosted run is still needed before approval. Please rerun the interrupted CI; I will recheck the CI gate without repeating this completed implementation/source review.
juaristi22
left a comment
There was a problem hiding this comment.
Approve at 9f33c7801d3bf7a40005f9561404b5fc7631d3de. The CI-only gap from the completed fix review is closed: all 20 latest exact-head checks passed, including Lint and Test / Result. The replacement Python 4/7 check passed; the earlier interrupted runner attempt is superseded.
C1, C2 and A1 remain resolved, with no open findings or material evidence gaps. This retains the prior exact-head 84 test passes (74 YAML, 10 Python) and automatic Marriage Allowance witness (£7,248 final income tax, £252 credit); no tests or model runs were repeated. The 36 cached source pairs and 107 technical receipt members were integrity-checked.
Head, description, human activity, source dependencies, readiness and applicable required checks are refreshed before publication.
Fixes #2106
Reproducer:
policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml::Case 2, savings in the starting rate band leave the Personal Allowance to dividendsaxiom: TheAxiomFoundation/rulespec-uk#524 queued
What and why
Parliament set the rule in ITA 2007 s. 25(2): "At Steps 2 and 3, deduct the reliefs and allowances in the way which will result in the greatest reduction in the taxpayer's liability to income tax." The model always deducted them from non-savings income first, then savings, then dividends. Under that order it overcharged wherever an allowance set against savings or dividends would save more tax:
origin/mainBoth columns come from runs of the model at 6429896 (whose model code 70633f0 keeps) and
origin/main2da212a; the YAML cases pin the right-hand column.The model overcharged people with savings as well as dividends (second row), and people with only pay and savings (fourth row).
Parliament changed the rule from 2027-28:
gov.hmrc.income_tax.reliefs.other_income_first: false until 2027-04-06, true from then.The model now shares the deductions this way in every year it covers. Before 2027-28, who gains depends on the starting rate limit, the personal savings allowance, the dividend allowance and the rate limits. The model sets the dividend allowance at £5,000 in 2016-17 and 2017-18 and £2,000 from 2018-19 to 2022-23 (
dividend_allowance.yaml).In 2027-28, take property income of £20,000, dividends of £200,000 and a £1,000 trading loss. Setting the relief against property income saves 24% on property income in the basic rate band (22% under FA 2026 s. 7). It also moves £1,000 of dividends down into the 10.75% ordinary rate band, because the dividends start where the property income ends and still end at £219,000. Setting it against dividends leaves that boundary where it is. So the model sets the relief against property income and charges £74,710.21, as
origin/maindoes, against £74,842.71, worked by hand, with the relief against dividends. YAML case 4 works the arithmetic.Deductions the model shares out
At Steps 2 and 3 the model deducts:
trade_loss_relief_against_general_income(s. 64, a Step 2 relief). Parliament made s. 25(2) subject to the priority rule in s. 65(2) to (4) (s. 25(3)). Under that rule the taxpayer's claims follow a set order, but nothing ties the relief to a component, so the model sets it wherever it saves the most tax, after other income from 2027-28.allowances, the sum of:personal_allowanceandblind_persons_allowance, the Step 3 allowances (Part 3 Chapter 2);charitable_investment_gifts(Part 8 Chapter 3),covenanted_paymentsandother_deductions;pension_contributions_relief, which the model holds on non-savings income (below).Each part except pension relief can come off any component, and the tax depends only on how much comes off each. So the model shares out their sum, up to total income (s. 25(4) and (5)).
Pension relief stays on non-savings income. The model takes employee contributions as a net pay arrangement and personal contributions as relief at source (
pension_contributions_relief,net_pay_pension_relief). Under a net pay arrangement the employer deducts the contributions from employment income (FA 2004 s. 193(2)). Under relief at source the payer keeps basic rate tax on the contribution (s. 192(1)) and, on a claim where higher or additional rate tax is chargeable, Parliament raises the basic and higher rate limits by the contribution (s. 192(4)). Neither gives a Step 2 deduction that the taxpayer can set against savings or dividends; only a net pay excess relieved on a claim (s. 193(4) and (6)) or relief on a claim under s. 194(1) does. The model gives both kinds as a deduction from income, so the search holds min(pension relief, non-savings income) on non-savings income and shares only the rest. YAML cases 14 and 15 pin it: pay £40,000, £10,000 of pension contributions and savings £30,000 in 2026-27 give £11,332, the tax on pay of £30,000 and savings of £30,000. Worked by hand: 17,430 × 20% = 3,486 on the pay; savings from £17,430 to £47,430, £500 at the nil rate, 19,770 × 20% = 3,954 and 9,730 × 40% = 3,892. Every share keeps the savings allowance below the £37,700 limit, so no share lowers that tax. Sharing the pension relief too would charge £11,232. From 2027-28 the hold matters only where the relief exceeds income other than property, savings and dividend income, since the model otherwise deducts it from that income first, as s. 25(3A) requires of the reliefs and allowances in Steps 2 and 3.How the model finds the least tax
The model shares the deductions between three components: the first (non-savings income before 2027-28, property income after), savings and dividends. Write x for the first component left, w for x plus the savings left, and T for the Step 3 income of the three, which no share changes. Savings sit above non-savings income and dividends above savings (ss. 16 and 16A), so:
So the Step 5 tax runs continuous and linear on each piece of the feasible polygon that vertical (x constant), horizontal (w constant) and diagonal (w − x constant) lines cut. A linear function takes its least value on a polygon at a corner, so the least tax lies where two of the lines cross.
policyengine_uk/utils/income_tax_allocation.pylists every crossing, 173 a person in 2026-27 and 2027-28 from 11 vertical, 10 horizontal and 3 diagonal lines. It projects each onto the feasible polygon and evaluates the Step 5 tax at all of them, for all the people searched at once. For a Marriage Allowance recipient, it takes off the reduction each share wins (below).Before 2027-28 a reform can add an exclusion E from earned income, such as one that exempts pensions (
uc_earnings_deductions.yamlruns one). With property income P, the savings then sit on q = x up to x = P, on q = P up to x = P + E, and on q = x − E beyond. The search adds the lines of each piece: the two boundaries, the rate limits moved by P and E, and the diagonal lines that a fixed q = P gives. That raises the count to 1,034 a person, and only where someone has an exclusion. YAML case 17 pins the result: under that reform, pay £5,000, private pension £10,000 and savings £30,000 give £3,286, against £4,800 under the old order. With the pension and £5,000 of the allowance setting earned income at nil, £22,430 of savings start at nil: £5,000 at the starting rate, £1,000 at the savings nil rate and 16,430 × 20% = 3,286.The search relies on three things that hold in every modelled year: a starting rate limit at or below the basic rate limit, a starting rate band that shrinks by a pound for each pound of non-savings income (s. 12), and a first UK rate threshold at nil. A reform that sets that threshold above nil leaves savings below it untaxed and adds lines on which x + w stays constant, which the search doesn't list. On 6,520 synthetic people with that threshold at £2,000, a 101 × 101 grid found less tax than the search for 32 in 2026-27 (by up to £181.70) and 38 in 2027-28 (by up to £214.75). The fast path sends everyone to the search under such a reform, and the search still keeps the old order unless a share it lists charges less.
Parliament gives the Marriage Allowance as a tax reduction at Step 6 (ss. 23, 26 and 55B(1)), and since #2828 the model does too (
marriage_allowance_tax_reduction). Parliament allows it only to a recipient who pays tax at no rate outside the list in s. 55B(2)(b), which includes the savings nil rate. It also bars a recipient who would pay the dividend upper or additional rate without the dividend nil rate (s. 55B(2)(ba)). The model applies that rate test tonon_savings_income_after_allowancesand both allocation variables, so the share decides who qualifies. Under s. 25(2) the taxpayer deducts the reliefs and allowances in the way that most reduces their liability, and Parliament fixes liability after the Step 6 reductions (s. 23). So the search minimises the Step 5 tax less the reduction each share wins.For example, take a married recipient in 2026-27 with pay of £40,000, savings of £9,000 and dividends of £1,570, and a partner with no income. Setting the whole Personal Allowance against pay gives the least Step 5 tax, £7,376.025, but leaves £300 of dividends above the basic rate limit, so the recipient fails the rate test. Setting £2,500 against pay, £8,500 against savings and £1,570 against dividends leaves £37,500 of pay, £500 of savings at the savings nil rate and no dividends. The Step 5 tax comes to £7,500, and income tax, after the £252 reduction, to £7,248 (YAML case 19).
The search finds that share as it finds the least Step 5 tax. Where all the income left after the deductions exceeds the basic rate limit, a recipient with dividends left fails the rate test, so a qualifying share lies on the line w = T. There the nil rates must also cover the savings above the limit. Under current rates that holds only where savings left come to at most A (w − x ≤ A). Under a reform that sets the basic rate limit below L + A, it can hold along the whole of w = T. The search already lists both lines. For the test's limit on non-savings income, it adds one vertical line where non-savings income reaches that limit. On each side of those edges the Step 5 tax less the reduction differs from the Step 5 tax by a constant, so its least value again lies where two lines cross. The new line raises the count to 186 a person in 2026-27 and 2027-28, and to 1,061 with an exclusion from earned income, wherever anyone searched has a reduction at stake.
The model stores its variables in float32, so the search counts a share as meeting the rate test only with room below each limit. The room comes to a tenth of a penny plus a millionth of the largest amount the test compares, or 3.9p at £38,000. Where a share leaves savings at the allowance or no dividends, the search writes in those amounts rather than computing them, so rounding can't push them across the test's limits. It judges the old order by the test's own arithmetic, without that room. So a recipient who already qualifies under the old order moves only for a lower Step 5 tax (YAML case 23).
marriage_allowance_reduction_at_stakegives the search the reduction whatever the share: the appropriate percentage ofmarriage_allowance_if_income_conditions_met, which reads neither the income conditions nor the share.reforms/cps/marriage_tax_reforms.pygives the allowance at any share to people who meet its expanded conditions, and sets their reduction at stake to nil. For them, the search follows the Step 5 tax alone.We built 2,640 recipients married to partners with no income, on a grid of pay, savings, dividends and property income in London and Scotland in 2026-27 and 2027-28. For each, we forced the old order and an 11 × 11 grid of shares through the model's own variables at 6c91a3c. No recipient pays more income tax than under the old order or under any feasible grid share. Against the old order, 73 pay less, by up to £272, and 8 gain the allowance it denies, while none lose it. Six of the 8 pay more Step 5 tax than some grid share does, to win the allowance, as in the example above. All 73 live in London and gain in 2026-27. From 2027-28 the taxpayer deducts first from pay (s. 25(3A)), and no grid recipient gains that year, though YAML case 22 shows a gain from property income. In these years no Scottish taxpayer can win the allowance through the share. The Scottish limit on non-savings income trails the basic rate limit by more than the savings allowance, so no share meets the test where the income left exceeds the basic rate limit. Below that limit, the old order already leaves the least non-savings income. At 321fb19, whose search left the reduction out, 69 paid less, 2 gained the allowance, and 6 paid up to £212 more than a grid share.
At 5d9ce13, before #2828, the model subtracted the allowance from earned income (#1947), and the search left the allowance out. At that head's model code we built 20,364 married couples a year by hand, 12,277 of them with a recipient. No recipient paid more income tax than under the old order in 2026-27 or 2027-28, and 1,069 and 1,155 recipients paid less, by up to £645. On a merge of 5d9ce13 with f0f576a, from #2828's branch, no couple paid more than under the old order, and 1,119 and 1,200 recipients paid less, again by up to £645. In 2026-27 the merged model gave the allowance to 12 more couples than the old order did, and to none fewer.
Where several shares give the same tax, the model takes the one setting most against the first component, then most against savings: the nearest the least tax allows to the old order. It keeps the old order, to the float32 bit, unless another share saves more than £0.001.
Who skips the search
The search runs only on people three tests leave and on Marriage Allowance recipients whom another share might qualify (below). Each test gives the result the search would give.
Tests 2 and 3 apply only when every rate scale rises with income from nil; under a reform that makes a scale fall, or sets the first UK threshold above nil, the model sends everyone with something to share to the search. The model also sends to the search each Marriage Allowance recipient whom the old order denies the reduction, where the least non-savings income any share leaves falls within the rate test's limit (
_marriage_allowance_in_reach).For people the tests clear, the search would keep the old order too, since it moves the share only for a saving above £0.001. A property test checks the two paths give the same deductions, bit for bit, on synthetic people with rising and with falling rates, with pension relief among them. Another does the same with Marriage Allowance recipients among them. Two new tests pin the checks that no earlier test separated: a supplied adjusted net income with trade loss relief, where the full search saves £200 and £60 over the old order, and a reform that lifts the Scottish starter band to £9,000, where it saves £30.
Cost
The search adds work only for people it can't skip. On 20,000 synthetic people of the "realistic" mix in
test_income_tax_allocation.py, most with pay alone and some with savings, dividends, property income or pension relief, the three tests send 0.925% of them (185 of 20,000) to the search in 2026-27 and 0.635% (127 of 20,000) in 2027-28, at 70633f0. Timings from earlier commits, on a hand-built mix of 20,000 people without a simulation: at 60de330 the deductions took 5.5 ms with the tests against 4.6 s with the search for everyone in 2026-27, and 3.4 ms against 2.5 s in 2027-28. At 5d9ce13, a coldincome_taxfor one household (pay £50,000 and savings £10,000, 2026-27) took 0.907 to 1.041 s of CPU time in three runs against 0.898 to 1.026 s onorigin/main, and the allocation variables took 2.6 to 4.6 ms against 0.35 to 0.45 ms. The pension hold adds one column to the search, and the exclusion lines run only for people with an exclusion. For the Marriage Allowance, the search adds a line, and the model sends it the recipients a share might qualify. Nobody has timed 6429896 or later.Aggregate impact
At 9f33c78, CI's Aggregate impact report (run 42716260318) compares main at
93b1711e57with6f1da93e51, CI's merge of 9f33c78 into that main. CI runs a full microsimulation for each side and year onenhanced_frs_2024_25.h5(policyengine-uk-data 1.58.1). The change breaks none of the report's checks, one check fails on both sides, and the report flags no move. We estimate that income tax receipts fall, and household net income rises, by £18.6m in 2025-26, £19.6m in 2026-27, £9.5m in 2027-28, £10.7m in 2028-29, £20.4m in 2029-30, £20.6m in 2030-31 and £11.3m in 2031-32. Between 0.9% (2025-26) and 0.3% (2027-28, 2028-29 and 2031-32) of people gain over £1, and 0.0% lose over £1 in every year. Councils spend £1,126 to £1,301 a year less on Council Tax Reduction from 2027-28, and poverty rates don't change in any year.At 6602ccc, before this branch took in #2828, we estimated that income tax receipts would fall, and household net income would rise, by £18.7m in 2025-26, £19.5m in 2026-27, £9.5m in 2027-28, £10.7m in 2028-29, £20.4m in 2029-30, £20.6m in 2030-31 and £11.3m in 2031-32. Between 0.9% (2025-26) and 0.3% (2027-28, 2028-29 and 2031-32) of people gained over £1, and 0.0% lost over £1 in every year.
CI's Aggregate impact report (run 41535543658) compares main at
0080274d78with3b1d34afc6, CI's merge of 6602ccc into main, onenhanced_frs_2024_25.h5(policyengine-uk-data 1.58.1), a full microsimulation for each side and year. It passes, with 0 checks broken and 1 failing on both sides. It flags two moves, both in 2030-31 and on mean income in each side's own deciles: decile 5 rises £140 and decile 6 falls £114. By base decile, neither decile moves that year.Readers of the changed variables
Every output variable keeps its name.
git grepfinds these readers:received_allowances_savings_incomeandreceived_allowances_dividend_incomenow come from the search. Their readers (basic_rate_savings_income,higher_rate_savings_income,add_rate_savings_income,basic_rate_savings_income_pre_starter,savings_starter_rate_income,dividend_income_tax,taxed_dividend_income,received_allowances) don't change.received_allowances_earned_income(allowances against non-savings income beyond trade loss relief) andtrade_loss_relief_against_savings_and_dividendsnow follow from those two. They equal the old order's values wherever the share equals the old order.non_savings_income_after_allowancesnow comes from the allocation's own arithmetic: where the share keeps the old order, net income less savings, dividends and allowances, asorigin/maincomputes it; elsewhere, in float64, the non-savings income less the deductions the share sets against it.earned_taxable_incomeadds back the relief set against savings and dividends, becausenet_incomededucts all of it, and never exceedsnon_savings_income_after_allowanceswhile savings, dividends andreceived_allowances_earned_incomesit on its exclusions list. Without the add-back, the model would undercount non-savings income wherever the share set relief against savings or dividends while some non-savings income remained (YAML case 16). Without the float64 arithmetic, float32 rounding in net income plus the relief added back left a residue above nil (YAML case 18).taxable_property_income_after_allowances,property_income_tax,non_savings_taxable_income(throughearned_taxable_incomeandtaxable_property_income_after_allowances),earned_income_taxand the basic, higher and additional rate earned income variables read them as before.adjusted_net_incomeandnet_incomesum totals that no share changes, so neither moves. Nor do the Personal Allowance taper and the savings allowance band (savings_allowancereads net income less allowances).meets_marriage_allowance_income_conditionsreadsnon_savings_income_after_allowancesand both allocation variables, so it follows the share, and the search weighs the reduction it decides (above). For Scottish taxpayers,tax_bandreadsnon_savings_income_after_allowancesand so follows the share too; no variable readstax_band.utils/uc_income_tax.py(uc_income_tax_on_earnings,uc_income_tax_on_trade) takes earnings as the lowest slice of non-savings income, with trade loss relief off the other non-savings income first and allowances off earnings first. It reads the old order's deductions (default_order_deductions), not the share, and takes off the Step 6 reductions thatearnings_tax_reductionscounts, the Marriage Allowance among them. So the slice equalsorigin/main's wherever the share leaves those reductions unchanged, and it rises with more savings or dividends only where they cost the person a Step 6 reduction. For example, a recipient with pay of £37,000 and a partner with no income has a slice of £4,634 in 2026-27. With £20,000 of dividends, the recipient fails the rate test, and the slice rises to £4,886. For self-employment income of £37,406 and savings of £14,407 in 2026-27, the share moves the whole Personal Allowance onto the savings and income tax falls from £8,057.20 to £8,016, whileuc_income_tax_on_tradestays at £4,967.20 (74fdacf gave £7,481.20). For pay £50,000 and savings £10,000 it stays at £7,486. See the Universal Credit judgment call for why the slice keeps the old order.legacy_means_test_deducted_income_tax. It takes the income tax before the winter fuel payment charge and subtracts what the tax on rent, interest and dividends and the other Step 7 charges would come to if nobody had earnings. The model takes the least-tax share in both computations, so the deduction moves by the difference between what the share saves in each; it falls £100 in the child benefit charge case below. The earnings disregards and the Housing Benefit childcare limit read net earnings fromhousing_benefit/applicable_income/_net_earnings.py. It takes off a pro-rata share of the income tax before the winter fuel payment charge, so net earnings there rise where the share lowers the tax.property_finance_costs_relievedcomputes adjusted total income (its reference: ITTOIA 2005 s. 274AA(6)) as net income less all the savings and dividend income,charitable_investment_gifts,other_deductionsand the personal and blind person's allowances. It doesn't read the share, so it doesn't change (see judgment calls).Invariants
Invariants 1 to 7 hold for every person with no Marriage Allowance reduction at stake, on the Step 5 tax (
income_tax_pre_charges), the only tax the share then changes:Least tax: no share of the deductions that the taxpayer may make under s. 25 gives less Step 5 tax than the model's.
Never more than the old order: the tax never exceeds what the order non-savings, savings, dividends charges, apart from float32 rounding.
Accounting: the deductions against non-savings income (
received_allowances_earned_incomeplus the relief not set against savings and dividends), savings and dividends add up to min(relief + allowances, income). None falls below zero or exceeds its component, and the relief set against savings and dividends never exceeds what they take.One kind of income: with one kind of income nothing can move, and the tax and the deductions equal the old order's.
Differential: the search's own Step 5 function (
step_5_tax) equalsincome_tax_pre_chargeswhenever a test forces a share through the model.Search exactness: the vertex search's value never exceeds a dense grid's minimum of that function, and comes within the grid's step of it, now also with exclusions from earned income beside property income before 2027-28.
Fast path: for everyone the tests clear, the deductions equal the full search's, bit for bit.
Invariants 8 to 12 hold for every Marriage Allowance recipient with a reduction at stake, whose share also decides the reduction at Step 6:
Never more than the old order: income tax never exceeds what the order non-savings, savings, dividends charges, apart from float32 rounding.
Least liability: forcing any share through the model's own variables never gives less income tax than the model's share, apart from float32 rounding.
Float32: where the search moves a share and counts it as meeting the rate test, the model's own variables meet the test at that share.
Search exactness: the vertex search returns a feasible share, and no share on a 101 × 101 grid gives a lower Step 5 tax less the reduction, beyond £0.00001. Nor does any share on the line with no dividends left or the line with savings left at the savings allowance.
Fast path: invariant 7 holds with recipients among the people.
Invariants 13 to 15 hold for everyone:
uc_income_tax_on_earningsoruc_income_tax_on_tradefor a person whose allowances and Step 6 reductions it leaves unchanged.On invariant 13: pay £2,000, personal pension contributions £3,600 and dividends £30,000 in 2026-27 give
received_allowances_earned_incomeof £2,000 andreceived_allowances_dividend_incomeof £14,170, which includes the £1,600 of pension relief beyond the pay, and income tax of £1,647.975, at 70633f0 and onorigin/main6c6e229 alike.Test plan
Ran at 6c91a3c (Python 3.13.9; no survey dataset; a 2 GiB memory cap on each process). 9f33c78 merges in
origin/main93b1711. Since the branch last merged main, main has changed 20 files, on carer's benefits, student loans and Universal Credit conditions, and this PR changes none of them.allowancesdirectory pass, all 23 ofleast_tax_allocation.yamlamong them, and so do all 8 instep_6_step_7.yamland all 9 inlegacy_means_tests/income_tax_caused_by_earnings.yaml. All 336 cases pass in the 16 other YAML files that involve the Marriage Allowance, among themproperty_income_band_base.yaml,gift_aid.yaml,scottish_rates.yamland the CPS reform'smarriage_tax_reforms.yaml.test_income_tax_allocation.py, both intest_uc_income_tax_properties.py, all 3 intest_marriage_allowance_tax_reduction_properties.pyand all 21 intest_income_tax_step_6_step_7_properties.pypass. Intest_marriage_allowance_cps_switch.py45 pass; its two Hypothesis tests over the statutory rate run past the 2 GiB cap, so CI runs them.ruff0.17.2format --check,checkandcheck --select F811,F821pass on the 11 changed Python files,git diff --checkreports nothing for either commit, and no line the PR adds to a Python file runs past 88 characters..github/check_fix_pr.pyreports "Fix evidence: PASS" on this title and body, with the changes fromorigin/main93b1711 to 9f33c78 and 18 changed executable tests.Each mutation below makes one change to 6c91a3c's model code:
_marriage_allowance_in_reach)test_recipient_share_gives_the_least_income_taxfails in 2026least_tax_allocation.yamlfailstest_vertex_search_weighs_the_marriage_allowancefails in 2027test_search_keeps_a_recipient_the_test_passes_exactly_at_the_limitfails: the search sets £10,700 of the allowance against savings, not niltest_the_share_weighs_the_allowance_only_where_the_share_decides_itfails, with £252 at stake rather than nilleast_tax_allocation.yamlfailstest_search_keeps_a_recipient_the_test_passes_exactly_at_the_limitfailsleast_tax_allocation.yamlfailsmeets_marriage_allowance_income_conditions.yamlfailsRan at 056a674 (
uv sync --frozen --extra dev; no survey dataset;HUGGING_FACE_TOKENandPOLICYENGINE_UK_DEFAULT_DATASETunset):test_trading_loss_properties.pyandtest_statutory_paternity_pay_income_tax_properties.py: all 20 pass with-n 4, and all 20 without xdist.test_income_tax_allocation.pyandtest_uc_income_tax_properties.py: all 24 pass.least_tax_allocation.yaml: all 18 pass.trade_loss_relief_against_general_income.yaml: all 22 pass.policyengine_uk/tests/code_healthwith-n 4: all 3,089 tests pass..github/check_fix_pr.pyon this title and body, with the branch's changes from its merge base withorigin/main6c6e229 (2da212a): "Fix evidence: PASS", with 15 changed executable tests.uvx ruff@0.17.2 format --check .("2167 files already formatted"),uvx ruff@0.17.2 check .anduvx ruff@0.17.2 check --select F811,F821 policyengine_ukpass, andgit diff --check 70633f089..HEADreports nothing.income_tax_allocation.pychanges only in a docstring and a comment: its AST, with docstrings blanked, matches 70633f0's.test_relief_never_taxes_more_than_income_cut_in_the_old_orderwithout xdist and then restored:net_incomesubtractingtrade_loss_relief_against_general_incometwice, with the relief itself unchanged: the one-sided bound passes for everyone, and the check for people with no savings or dividend income fails on pinned person 3 (pay £300,000, loss £200,000), with £53,703 against £87,453. With that check removed, the test passes under the mutant.At 3b54d53:
income_tax_allocation.pydiffers from 056a674 only in one comment, and its AST, with docstrings blanked, matches 056a674's, so the runs above hold there. At that head CI passes all seven Python shards, YAML, Lint, Fix evidence and Aggregate impact,uvx ruff@0.17.2 format --check .,check .andcheck --select F811,F821 policyengine_ukpass, and.github/check_fix_pr.pygives "Fix evidence: PASS" with 15 changed executable tests.At 6602ccc:
income_tax_allocation.pydiffers from 3b54d53 only in its module docstring and that comment, and its AST, with docstrings blanked, matches 3b54d53's, so the runs above hold for this head. At this head CI passes all seven Python shards, YAML, Lint, Fix evidence and Aggregate impact.Ran at 70633f0 (
uv sync --frozen --extra dev; no survey dataset):uvx ruff@0.17.2 format --check .,uvx ruff@0.17.2 check .anduvx ruff@0.17.2 check --select F811,F821 policyengine_ukpass, and so douvx ruff@0.17.0 formatandcheckon the changed Python files andgit diff --check. No line the branch adds to a Python file runs past 88 characters.policyengine_uk/tests/code_health: all 3,089 tests pass.least_tax_allocation.yamlamong them.test_income_tax_allocation.py: all 22 pass.test_uc_income_tax_properties.py: both pass..github/check_fix_pr.pyon this title and body, with the branch's changes againstorigin/main: "Fix evidence: PASS", with 13 changed executable tests.origin/main6c6e229:origin/main.synthetic_people, seeds 100 to 119, 326 each) withuk[0].thresholdat £2,000,vertex_search_and_gridfinds less tax on the grid than from the search for 32 people in 2026-27 (by up to £181.70) and 38 in 2027-28 (by up to £214.75), and for no one below the grid minimum less two steps. Seeds 0 to 3 give 6 of 1,304 by up to £91.86.Ran at def18d3, whose model code 70633f0 keeps (70633f0 changes only docstrings, comments and one variable's documentation):
policyengine_uk/tests/code_health: all 3,089 tests pass.test_income_tax_allocation.py(22) andtest_uc_income_tax_properties.py(2): all 24 pass.uc_earnings_deductions.yaml(15) andintegration/income.yaml(8): all 23 pass.Ran on the working tree as it stood at 6429896 or one step before it (
uv sync --extra dev, Python 3.13.18, 4 vCPU; no survey dataset). The earlier tree lacked only YAML case 18 and the cap that holds pension relief withinallowances, which can't bind in the model becauseallowancesincludes the relief; def18d3 keeps 6429896's code and renames one line ofshard_durations.toml. The runs at def18d3 above repeat the ones that touch case 18 or the allocation tests:policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax) passes 489 cases,integration/income.yaml8, the Universal Credit income directory (withuc_earnings_deductions.yaml) 174, anduc_trading_loss.yaml,uc_company_analogous_business.yamland the benefit cap directory 150.test_income_tax_allocation.py: 22 tests pass, including 7 new: the dense-grid search with exclusions in 2018-19, 2026-27 and 2027-28, two supplied adjusted net income cases, the Scottish starter band reform and the pension hold.test_uc_income_tax_properties.py(new): both tests pass, including the Hypothesis property with the self-employment witness as an explicit example in 2025-26 and 2026-27.test_budget_2026_savings_property_rates_properties.py: all 8 tests pass with-n 4and without, including the new bound (invariant 15).test_scottish_top_rate.py10,test_savings_starting_rate_indexation.py26 andtest_property_income_rate_reforms.py55 pass.test_gift_aid.py,test_property_income_band_base_properties.py,test_income_tax_step_6_step_7_properties.py,test_adjusted_net_income_pension_relief.py,test_income_concepts_properties.py,test_uc_company_owner_properties.pyandtest_pension_annual_allowance_properties.py: all 63 tests pass.origin/main2da212a on 3,000 random single adults a year in London and Scotland, 2025-26 to 2028-29 (12,000 rows), with pay, self-employment, property income, savings, dividends, employee and personal pension contributions, trading losses and Gift Aid:income_tax_pre_chargesfalls by more than 1p plus a millionth of the tax in 725 rows, by up to £645, and rises beyond that in none (float32 rounding explains the largest rise, £0.0078).uc_income_tax_on_earningsanduc_income_tax_on_trademove by more than 1p in no row. At 74fdacf the two slices rose 319 times over the 12,000 rows, counting each slice separately, by up to £4,930.98, and fell in none.adjusted_net_incomemoves in no row.origin/main's.origin/mainleaves with no non-savings income, 74fdacf left 607 above nil (up to £4.00) and 6429896 leaves 10 (up to £0.0078, rounding in the moved share);earned_taxable_incomewent from 63 above nil to none.tax_bandcompares a Scottish taxpayer's total income with the Scottish thresholds; the Scottish rates apply to non-savings, non-dividend income only #2350, checked for a dependency cycle (see Merge notes).Revert and mutant checks, each on a copy of the package as it stood at 6429896 or one step before it (the earlier copies lacked only the cap that holds pension relief within
allowances):received_allowances_earned_income2,300, not 22,570), and so doestest_pension_relief_stays_on_non_savings_incomeearned_taxable_incomeadd-back removedearned_taxable_income30,000, not 37,700)received_allowances_earned_income7,300, not 5,000), andtest_vertex_search_equals_dense_grid_with_exclusionsfails in 2018 and 2026test_uc_income_tax_properties.pyfail (the slice rises by £2,514)non_savings_income_after_allowancesfrom the add-back sum, with no cap onearned_taxable_incomeF6_old_order_check_droppedtest_fast_path_follows_a_supplied_net_incomecases failF11_all_used_threshold_widetest_fast_path_follows_a_supplied_net_income[all-savings-used]failsF8_uk_rates_for_scotstest_fast_path_reads_the_scottish_starter_ratefailsF10_all_used_without_old_order_checkF1_shared_within_first_droppedin_firstalready impliesshared <= first, so the check matters only for float32 residues and negative componentsF1andF6togethertest_fast_path_equals_full_searchfailThe 74fdacf model code gives £11,232 for cases 14 and 15 and fails both. Under the pension-exempting reform, the search at 74fdacf missed the least tax that a 101 × 101 grid finds for 40 of 5,216 synthetic people with exclusions, in eight years from 2016-17 to 2028-29, by up to £415.14; at 6429896 it misses it for none of 15,648 in the same years.
Ran at 74fdacf, before these changes:
test_budget_2026_savings_property_rates_properties.py: all 7 tests pass with-n 4and without, and the two failing tests pass with the seeds108342745077855236797573561117658719055and245902028163439803096709191647468822378.-n 4and without the rUK property test: 3 pass and 2 fail,test_savings_tax_rises_by_two_points_in_every_countryandtest_scottish_property_tax_ignores_the_property_rates, as at 5d9ce13. Both failing people pay Scottish income tax and have property income of about £54,000 and savings; see below.Earlier runs, at the commits named (each later commit to 74fdacf changed only tests, comments or the Marriage Allowance exclusion, as the commit messages say):
test_property_income_rate_reforms.py(55) andtest_savings_starting_rate_indexation.py(26) passed; 18 other YAML files passed, 271 cases, every other file in the income tax directory with a case of two or more people.A_rate_limit_lines_dropped,B_s25_3A_ignored,C_tie_break_reversedandG1_marriage_allowance_readeach failed one of the 13 cases then inleast_tax_allocation.yaml;F2,F3,F4,F5,F8b,F9andF12failedtest_fast_path_equals_full_search, andF7failedtest_fast_path_equals_full_search_when_rates_fall[2026].test_gift_aid.py(12),test_income_tax_step_6_step_7_properties.py(10),test_property_income_band_base_properties.py(5),test_adjusted_net_income_pension_relief.py(17) andtest_income_concepts_properties.py(2) passed.origin/main2da212a: cases 2, 3, 5, 7 and 8 of the first 11 cases ofleast_tax_allocation.yamlfailed and the other 6 passed.CI at 9f33c78 (
pull_requestrun 42716260469): Python shards 1 to 7 pass 13,159 tests (61 skipped, 6 xfailed), andTest / YAMLpasses 6,459. The Python shards run the tests that need survey data on the pinnedenhanced_frs_2024_25.h5(policyengine-uk-data 1.58.1).policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml: both rows of the issue's table; savings £4,000, dividends £200,000 and a £1,000 trading loss; property £20,000, dividends £200,000 and a £1,000 trading loss in 2027-28; two Scottish taxpayers; pay and savings, and pay and dividends, across the basic rate limit; pay, nil-rate savings and dividends across it; one income type (savings only; property only in 2027-28); two Marriage Allowance recipients who already qualify under the old order, one whose taxable income ends at the basic rate limit, three whose share wins the allowance (at a higher Step 5 tax, by clearing dividends and stopping savings at the nil rate, and from property income in 2027-28) and one whose pay alone exceeds the limit (cases 13 and 19 to 23); net pay and relief at source pension relief kept on non-savings income; trading loss relief set against savings with the pay left in earned income; a reform that exempts pensions; trading loss relief past the non-savings income.changelog.d/2106.fixed.md.The Budget 2026 property tests and the share
Four tests in
test_budget_2026_savings_property_rates_properties.pycompare everyone under the Budget 2025 and Budget 2026 rates on the assumption that each person keeps the same allowance order. The least-tax search can move the allowance when the rates change, and at 5d9ce13 two of those tests failed on people it moved. Minimised to one person: a Scottish taxpayer with property income of £54,000 and savings of £13,000 in 2027-28. Under the Budget 2025 rates the allowance saves 42% against either income (Scottish higher rate on the property income, the UK higher savings rate on the savings), so the model keeps it on the property income. Under the Budget 2026 rates savings pay 44% while the property income still pays the Scottish 42%, so the model sets £12,500 of the allowance against savings, leaving £500 within the savings allowance, and £70 against the property income. Income tax stays at £15,904.89, against £16,154.89 had the allowance stayed on property income: the move saves 2% of £12,500. Property tax rises by 42% of £12,500, so the identity "Scottish property tax doesn't move" fails for this person, and so does "savings tax rises by 2% of banded savings". The search gives the least tax under s. 25(2); the tests' assumption was wrong.same_choiceslimits the identities to people whose election and allowance order stay the same, a pinned test (property £58,000, savings £24,000) checks the move, and invariant 15 checks every person, moved or not.Existing tests whose expected values move, and why
Each of these expected values moves because the model charges less tax under the least-tax share. Each case's comment works the arithmetic.
reliefs/trade_loss_relief_against_general_income.yaml, "Relief takes non-savings income first, then savings, then dividends", becomes "Relief and allowances leave savings that the starting rate and savings allowance cover". Savings take 4,000 (from 10,000) and dividends 23,570 (from 17,570), since savings up to £6,000 bear 0% (ss. 12 and 12A). Income tax falls from 1,282.48 to 637.475.integration/income.yaml, "Savings slice of basic rate band" (2020-21): £2,500 of the allowance against pay and £10,000 against savings keep the pay at the £37,500 limit.basic_rate_savings_income_pre_startergoes from 10,000 to 0,basic_rate_savings_incomefrom 9,500 to 0,higher_rate_savings_incomefrom 5,000 to 4,500 andtaxed_savings_incomefrom 14,500 to 4,500. The tax falls by £100, to £9,300. Setting £500 more against the savings leaves it at £9,300; setting £500 more against the pay charges £9,400.received_allowances_savings_income: 10_000joins the outputs.integration/income.yaml, "Personal allowance caps savings starter rate before dividends.", becomes "Personal allowance goes to dividends rather than savings at the starting rate." Savings take 0 (from 2,570) and dividends 2,570.basic_rate_savings_income_pre_starterandsavings_starter_rate_incomego from 2,430 to 5,000 andtaxed_dividend_incomefrom 4,500 to 1,930. Income tax falls from 483.75 to 207.475.gift_aid.yaml, "Gift Aid extends UK limits for dividends above the basic-rate limit":earned_taxable_incomegoes from [37,430, 37,430] to [37,700, 38,700],dividend_income_taxfrom [3,396.25, 3,203.75] to [3,299.725, 2,942.225] andincome_taxfrom [10,882.25, 10,689.75] to [10,839.725, 10,682.225].gift_aid.yaml, "Gift Aid extends UK limits across savings and dividend slices":savings_income_taxgoes from [460, 300] to [120, 0],dividend_income_taxfrom [1,608.75, 1,433.75] to [1,608.75, 1,001] andincome_taxfrom [9,368.75, 9,033.75] to [9,268.75, 8,941].gift_aid.yaml, "Gift Aid raises the basic rate limit for savings above property income":savings_income_taxgoes from [5,646, 5,846] to [3,292, 3,692]; the income tax of each falls by £100.test_gift_aid.py::test_zero_gift_preserves_baseline_outputs_bit_for_bit: the fourth rest-of-UK row (pay £30,000, savings £12,000, dividends £18,000, property £9,000) sets £1,300 against pay and £11,270 against savings.earned_taxable_incomeandbasic_rate_earned_incomego from 17,430 to 28,700,received_allowances_earned_incomefrom 12,570 to 1,300,received_allowances_savings_incomefrom 0 to 11,270,basic_rate_savings_incomefrom 10,770 to 0,higher_rate_savings_incomefrom 730 to 230,earned_income_taxfrom 3,486 to 5,740,savings_income_taxfrom 2,446 to 92 andincome_taxfrom 13,988.25 to 13,888.25. Its Scottish twin doesn't change, because pay above £31,092 would bear 42%.received_allowances_savings_income.yamlandreceived_allowances_dividend_income.yamlunit cases fedreceived_allowances_earned_incomeandreceived_allowances_savings_incomein as inputs, which the new formulas don't read. They now take the incomes as inputs. Every expected value stays the same except two dividend cases, which move from 0 to 500: the old order would leave £500 of dividends taxed at 8.75%, while £500 more allowance against dividends keeps the dividends left within the £500 nil rate (s. 13A). Onereceived_allowances_earned_income.yamlcase replaces an inconsistentnet_incomeinput withtaxable_employment_income; its expected value doesn't change.property_income_band_base.yaml, "Case 2, savings sit above property income" (2026-27, property income £40,000 and savings £20,000):savings_income_taxgoes from 5,846 to 3,692, andincome_tax(7,540 + 3,692) joins the outputs. The model sets £2,300 of the Personal Allowance against the property income, which leaves it at the £37,700 basic rate limit, and the other £10,270 against savings, so the £500 savings allowance (s. 12B(3)) covers savings that would bear 40%. Income tax falls by £100.test_property_income_band_base_properties.py:cloned_uk_tax_benefit_systemfixture, as 76 other test files do.test_budget_2026_savings_property_rates_properties.py:same_choicesreplacessame_election(above), and two tests join: the pinned Scottish move (property £58,000 and savings £24,000 in 2027-28: savings tax falls from £9,870 to £4,809.20, property tax rises by £5,279.40 and income tax rises by £218.60, against £470 had the allowance stayed on property income) and the bound for everyone (invariant 15).test_trading_loss_properties.py:test_relief_taxes_like_income_cut_in_section_25_orderbecomestest_relief_never_taxes_more_than_income_cut_in_the_old_order, and its key inshard_durations.tomlfollows. It asserted that a person's income tax before the winter fuel payment charge with a loss equals, within 50p and float32 rounding, the tax of a twin with no loss whose income the relief cuts in the order non-savings, savings, dividends. That cut, with the allowances set against it in the same order, gives the old order's share, which the model always weighs and keeps unless another share saves more than £0.001, and the Personal Allowance taper reads the same adjusted net income for both. So the tax with the loss can now only match the twin's or fall below it. Pinned person 1 (pay £5,000, savings £10,000, dividends £30,000, loss £20,000) pays £637.47 against the twin's £1,282.47, the figures of thetrade_loss_relief_against_general_income.yamlbullet above. The test now bounds the tax with the loss by the twin's for everyone, and keeps the equality for people with no savings or dividend income, for whom every share equals the cut; pinned person 3 (pay £300,000, loss £200,000) runs that check.test_statutory_paternity_pay_income_tax_properties.py::test_a_pound_of_statutory_paternity_pay_can_halve_the_savings_allowance: the rise in income tax before the winter fuel payment charge from a pound of statutory paternity pay on wages of £48,270 and savings of £2,000 (2026-27) goes from £100.40 to £100.20. With the pound, the model sets £1,500 of the Personal Allowance against savings and £11,070 against pay, so pay of £37,201 bears 20% and the £500 of savings left falls within the £500 savings allowance: income tax goes from £7,340 to £7,440.20, against £7,440.40 under the old order. The last pound of Step 3 income would bear 40% but for the savings nil rate, so it still counts as higher-rate income (ITA 2007 s. 12B(8)(b)(ii)), and the allowance stays at £500 (s. 12B(3)).legacy_means_tests/income_tax_caused_by_earnings.yaml, "The High Income Child Benefit Charge the pay brings in is deducted" (2026-27, pay £40,000 and interest £30,000): the model sets £2,300 of the Personal Allowance against the pay, which leaves it at the £37,700 limit, and £10,270 against the interest. The £500 savings allowance then covers interest that would bear 40%. Income tax before the winter fuel payment charge falls from £16,035 to £15,935,legacy_means_test_income_taxrises from £5,486 to £7,540, andlegacy_means_test_deducted_income_taxfalls from £13,749 to £13,649. Without the pay, the tax stays at £2,286.Merge notes
main: 0f38c3c merges 9f33c78 intomainat 5800015. Since 93b1711, which 9f33c78 merges in,mainhad taken in Price real incomes to a fixed base year #2876, Run the tests that need the dataset in their own CI shard #2893, Require policyengine-core 3.34.9 #2897, Bar income-related ESA at pensionable age and on Pension Credit #2807 and Count enhanced PIP daily living and AFIP for the enhanced premium #2808. The merge queue's CI passed at 0f38c3c (run 42826612936).git merge-tree --write-tree --name-onlywith explicit SHAs finds that each one merges withmainat ddcabe5, after this PR, as it merges with 5800015: 34 cleanly, and 18 with conflicts in the same files. So this PR added no conflict.mainin 21 files, and this PR changed five of them:marriage_allowance.py,meets_marriage_allowance_income_conditions.py,reforms/cps/marriage_tax_reforms.py,property_income_band_base.yamlandtest_marriage_allowance_cps_switch.py.Judgment calls
origin/main, against £1,286 under the law (£2,286 less the £1,000 the payer keeps). Where the relief exceeds non-savings income, the model deducts the rest from savings and dividends, asorigin/maindoes. A net pay excess relieved on a claim (s. 193(4) and (6)) or relief on a claim under s. 194(1) could go against savings or dividends (s. 24(1)(a)); the model doesn't separate those, so it holds them too.property_finance_costs_relievedstill deducts all the savings and dividend income, whatever share the search takes. At Step 2 of ITTOIA 2005 s. 274AA(6), Parliament excludes only "so much of [net income] as is" savings or dividend income, so relief that the share sets against savings or dividends should raise adjusted total income under the law. The model's gap predates this PR, and this PR leaves it.capped_mcad,property_finance_cost_relief,pension_tax_allowance,marriage_allowance_tax_reductionandincome_tax_reductionsreadincome_tax_pre_chargesonly to cap the Step 6 reductions at the tax left, and no reduction except the Marriage Allowance reads a share variable.income_tax_before_winter_fuel_payment_chargecharges max(0, Step 5 tax − reductions) + the Step 7 charges, which never rises when the objective falls, so the least objective gives the least liability. Where the other reductions already take the tax to nil, the search can still move the share, with no change in liability.savings_allowancecomputes from net income less allowances against the basic rate limit, which no share changes.🤖 Generated with Claude Code