Repository navigation
Description
What the law says
- ITA 2007 s. 11A charges the Scottish rates on "the non-savings income of a Scottish taxpayer" (s. 11A(1A)), subject to the dividend rates of s. 13 (s. 11A(5)).
- So a Scottish taxpayer's savings and dividend income is charged at the UK rates and bands, on top of their other income.
- ITA 2007 s. 55B(2)(b) lets a person receive the Marriage Allowance only if they are liable at no rate other than the basic rate, the Scottish basic rate, "a Scottish rate below the Scottish basic rate", the Scottish intermediate rate, the dividend ordinary rate and the other rates it lists.
What the model does
tax_band (from 2018-19) takes adjusted_net_income - allowances, which includes savings and dividends, and compares it with the Scottish thresholds for a Scottish taxpayer:
income = ani - allowances
scottish_band = select(
[income < threshold for threshold in rates.scotland.rates.thresholds[:5]],
[NONE, STARTER, BASIC, INTERMEDIATE, HIGHER],
default=ADDITIONAL,
)The liability itself is right: earned_income_tax applies the Scottish rates to earnings only, and dividend_income_tax applies the UK dividend bands. Only the label is wrong, and what reads it:
meets_marriage_allowance_income_conditionsrefuses a Scottish taxpayer whose earnings sit in the intermediate band and whose dividends are charged at the ordinary rate.- Counts of taxpayers by marginal rate put some Scottish taxpayers a band too high: those whose savings or dividends carry their total income over a Scottish threshold.
savings_allowancealso readstax_band. That is Personal Savings Allowance uses the Scottish bands for Scottish taxpayers (ITA 2007 s. 12B(8)) #2347, which needs the rest-of-UK bands and is a separate fix.
Minimal reproduction
policyengine-uk 2.122.2, period 2025, one adult aged 40. The formula on main is the same.
- Scotland, £40,000 of pay and £5,000 of dividends
- Model:
tax_bandisHIGHER;meets_marriage_allowance_income_conditionsis false. - Law: the pay is charged at the starter, basic and intermediate rates (£27,430 of taxable income, below the £31,092 higher-rate threshold). The dividends are charged at the ordinary rate (total taxable income £32,430, below £37,700). The person is liable at no higher rate, so the condition holds.
- The model's tax is right: £5,582.82 on the pay and £393.75 on the dividends.
- Model:
- England, the same incomes:
BASIC, and the condition holds. - Scotland, £60,000 of pay and £20,000 of dividends
- Model:
tax_bandisADDITIONAL, because £67,430 of total taxable income is above the £62,430 advanced-rate threshold. - Law: the pay reaches the Scottish higher rate only (£47,430 of taxable income). The dividends are charged at the upper rate (total below £125,140). No income is charged at the advanced, top or dividend additional rate.
- Model:
- England, the same incomes:
HIGHER.
from policyengine_uk import Simulation
def band(country, pay, dividends, year=2025):
situation = {
"people": {"p": {
"age": {str(year): 40},
"employment_income": {str(year): pay},
"dividend_income": {str(year): dividends},
}},
"benunits": {"b": {"members": ["p"]}},
"households": {"h": {"members": ["p"], "country": {str(year): country}}},
}
sim = Simulation(situation=situation)
return (
sim.calculate("tax_band", year),
sim.calculate("meets_marriage_allowance_income_conditions", year),
)
band("SCOTLAND", 40_000, 5_000) # HIGHER, False; law: intermediate, True
band("SCOTLAND", 60_000, 20_000) # ADDITIONAL; law: higherA possible fix
For a Scottish taxpayer, take the higher of two bands:
- the Scottish band of taxable income that is neither savings nor dividends, against the Scottish thresholds;
- the rest-of-UK band of total taxable income, which is the highest rate their savings and dividends are charged at.
On the cases above this gives INTERMEDIATE and HIGHER. A Scottish taxpayer with £30,000 of pay and £25,000 of dividends stays HIGHER, which is right: part of the dividends is charged at the upper rate.
Tests to add in scottish_rates.yaml: the two Scottish cases above, with dividends and with savings interest.
Where it showed up
PolicyEngine/microcosm#1198 binds HMRC's counts of Scottish higher and additional rate Income Tax payers (Income Tax liabilities statistics, Table 2.2) on tax_band. An engine test there pins the first case above, so a fix here shows up as a failure of that pin.
Activity
Merge #2350's Scottish tax band and Marriage Allowance fix (cd974a4)
Merge #2350 at 0d1e279
Merge #2350 at 3af8cb7
MaxGhenis commentedon Nov 2, 2026
Ready for hub review
Head
f0f576a4451adb83eee5f7bcccbf58f7ffdb81ea, branchfix-2350-scottish-tax-band(the branch tip when fetched was the same SHA). Environment set up withuv sync --frozen --extra dev;HUGGING_FACE_TOKENandPOLICYENGINE_UK_DEFAULT_DATASETunset. 4 vCPU, 16 GB; no memory trouble,-n 4throughout.uv run policyengine-core test policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax -c policyengine_ukuv run policyengine-core test <the 11 YAML paths in the brief, one command> -c policyengine_ukuv run pytest -q -o xfail_strict=true -n 4 policyengine_uk/tests/test_tax_band_scottish_properties.pyuv run pytest -q -o xfail_strict=true -n 4 policyengine_uk/tests/test_marriage_allowance_cps_switch.pyuv run python /tmp/probe_couples.pyat headuv run python /tmp/probe_couples.pyatmain(2da212abcf2086337fc088a4a1236f4ae7b6af63)ISSUE_2015_PARAMETERSat2da212abISSUE_SCOTTISH_2015_16at2da212abISSUE_SAVINGS_SLICESat2da212abSummary lines: (1)
497 passed, 1 warning in 80.71s; (2)145 passed, 1 warning in 24.45s; (3)5 passed in 51.49s; (4)45 passed in 243.24s (0:04:03). Wall times includeuv runstart-up. The one warning in 1 and 2 isPytestAssertRewriteWarning: Module already imported so cannot be rewritten; anyio.Failures: none. No test skipped for want of a dataset. Entry 4 finished well inside the 90-minute limit, so no
-ksplit was needed; I did not record per-test durations, so I can't say how longtest_the_allowance_never_falls_as_the_statutory_rate_risestook on its own.Mutants: the brief named none, so none were run. The working tree was clean (
git status --porcelainempty) after every run.Skipped: nothing.
mainwas at2da212abcf2086337fc088a4a1236f4ae7b6af63when fetched, so 5b and entries 6–8 all ran at that commit after oneuv sync --frozen --extra dev(it reported no package changes). Each script's stdout follows; I removed only uv'sUV_NATIVE_TLSdeprecation warning line.5a. Couples table at head
f0f576a45b. Couples table at
main2da212ab6. ISSUE_2015_PARAMETERS at
2da212ab7. ISSUE_SCOTTISH_2015_16 at
2da212ab8. ISSUE_SAVINGS_SLICES at
2da212abGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
Picking this up for the UK hub. Reply here if you're mid-change and I'll stop.
Generated by Claude Code
MaxGhenis commentedon Nov 2, 2026
PR_TITLE.txtGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
PR_BODY.mdGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
AXIOM_ISSUE.mdGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
FOLLOWUPS.mdGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
ISSUE_2015_PARAMETERS.mdGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
ISSUE_SAVINGS_SLICES.mdGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
ISSUE_SCOTTISH_2015_16.mdGenerated by Claude Code
MaxGhenis commentedon Nov 2, 2026
Ready for hub review
Head
c74ca6424849beb39525a83230785ce0fab37ed3on branchclaude/apply-2350-review-round-3-kxns4x. It sits onf0f576a4451adb83eee5f7bcccbf58f7ffdb81eaand adds two commits:0713b7c9changes the P3-0 case and nothing else.c74ca642changes text only.My harness only lets me push
claude/...branches, sofix-2350-scottish-tax-bandis still atf0f576a44. To move it, fast-forward it toc74ca642. No PR is open and this branch started no CI. The hub documents are in the seven comments above this one.Findings
P3-0. I changed
meets_marriage_allowance_income_conditions.yamllines 98-111 toemployment_income: 50_270andsavings_interest_income: 501. The outputs are unchanged (£0.40 of savings tax, conditions false), and the comment is the review's. I worked it by hand first, for 2026-27 in England:Model runs (England 2026-27; savings tax, conditions met, income tax):
ea880ff7674fdacf1How I built the merged trees: the head merged with each #2106 commit, taking #2106's
non_savings_income_after_allowances.py. I checked thatpolicyengine_uk.__file__resolved to each merged tree.scottish_rates.yamlea880ff7674fdacf1ea880ff76merge with the oldf0f576a44YAML (control)#2106 has moved on since the review (
ea880ff76→74fdacf1). P3-0 holds on both.P3-1. I used the review's replacement for both sentences, in the PR body's Overlap paragraph.
P3-2. I added
## Completion boundaryafter thereview_findingblock inAXIOM_ISSUE.md, with the review's text. I dropped the comma after "£16,956" in the S6M-20844 quotation.P3-3. I used the review's replacement for the S2 sentence, at the end of the PR body's mutation paragraph.
P3-4. I used the review's replacement for the "The two agree…" sentence in the PR body.
ISSUE_SAVINGS_SLICES.md"What the model does" now names the phase-out. I readsavings_starter_rate_incomefirst: it cuts the starting rate limit byphase_out×non_savings_taxable_income, and the parameter holds 1 from 2014.FOLLOWUPS.mditem 8.P3-5.
<PENDING RUN: …>now holds the review's item 3 paste.<2106 interaction: hub fills>now holds the item 7 bullet.FOLLOWUPS.mdline 3 holds a run record, and item 9 holds the review's text.ISSUE_2015_PARAMETERS.md(the law bullet and the proposed-fix bullet).<…issue>and<PE PR>placeholders for the hub.P3-6. I applied every row, across these files:
savings_allowance.py,non_savings_income_after_allowances.pyandtax_band.py;marriage_tax_reforms.py,test_marriage_allowance_cps_switch.pyandtest_tax_band_scottish_properties.py;FOLLOWUPS.mdand the three drafts.Statutory quotations, the
review_findingblock, theaxiom:line, theReproducer:line and the footer are unchanged. I rewrapped the comment blocks I touched to 79 columns, without breaking citations or sums across lines. The body quotes the new docstring and documentation.Judgment calls
scottish_rates.yamlheader: "The Scottish rates charge only…" became "Parliament charges the Scottish rates only…".FOLLOWUPS.mditems 2 and 6 now say "In resolution S5M-… the Scottish Parliament set". Item 5 says "Parliament introduced the savings nil rate … in FA 2016 s. 4".non_savings_income_after_allowances: I replaced the "should reconcile" sentence with a pointer to the Allowances and reliefs are set against savings before dividends, not in the order giving the greatest reduction (ITA 2007 s.25(2)) #2106 bullet.origin/main. It has moved past the held2da212abcto46a60678. This head merges with it cleanly (git merge-tree). The body's SHAs stay at2da212abc, the commit the runs used.Tests at
c74ca642No survey data,
-n 4.meets_marriage_allowance_income_conditions.yamlscottish_rates.yamlmarriage_tax_reforms.yaml+integration/income.yamltest_tax_band_scottish_properties.py(5),test_marriage_allowance_cps_switch.py(45),test_gift_aid.py(12),test_removed_marriage_allowance_eligible_bands_parameter.py(3),test_parameter_descriptions.py(24)policyengine_uk/tests/code_healthuvx ruff@0.17.2 format --check .,check .andcheck --select F811,F821 policyengine_uk;git diff --checkThe pytest and two-file YAML runs started before my final comment rewrap. The comparison below shows the rewrap left the code unchanged, and I reran the three YAML runs at the final head.
Issue scripts, run exactly as printed, alone, at
main2da212a and at this head:main.Code-change check (AST and parsed YAML). I compared Python ASTs with docstrings and
documentation/labelstrings blanked, and parsed YAML with casename/descriptiondropped.f0f576a44..c74ca642: every Python file has an equal AST.scottish_rates.yamlparses equal. In the Marriage Allowance YAML exactly one case differs, "One pound of savings at the savings higher rate fails the conditions", whereemployment_incomegoes from 50070 to 50270. That file has 31 cases at both ends.0713b7c9..c74ca642: every file is equal, so that commit is text only.Mutations and reverts
Skipped
f0f576a44, and the code is unchanged.fix-2350-scottish-tax-band.Generated by Claude Code
MaxGhenis commentedon Nov 2, 2026
Ready for hub review
Verdict: APPROVE WITH NITS at
c74ca6424849beb39525a83230785ce0fab37ed3(branchclaude/apply-2350-review-round-3-kxns4x, onf0f576a4451adb83eee5f7bcccbf58f7ffdb81ea).This review covers the delta
f0f576a44..c74ca6424(two commits:0713b7c9P3-0,c74ca642text) and the hub documents posted above. There's no P1 or P2. The code is unchanged apart from one YAML input. Every test I reran passes, both at this head and merged with #2106's current tip. The findings below change text only. Two of them come from branches that have moved since round 3: #2670 and #2106.Findings
P3-1. The body's #2670 sentence is stale: #2670's head now merges cleanly with
mainand with this branch#2670's head has moved from
a63f18e67to3f67112c("Merge main into structural reform period fixes"). The body's Overlap paragraph says "#2670 conflicts withorigin/maininpolicyengine_uk/simulation.py". That no longer holds.#2670 still edits
create_expanded_ma_reform: it adds_parameter_drivenand reads the child-age cap from parameters.Replacement for "#2670 also edits
create_expanded_ma_reform(its child condition), and git merges that file cleanly with this branch; #2670 conflicts withorigin/maininpolicyengine_uk/simulation.py, which this branch doesn't touch.":The #1963 sentences still hold at #1963's unchanged head
69c919f6. Against this head, git conflicts in the same seven files. Againstmain46a60678, it conflicts in the same three:income_tax_subtractions.yaml,uses_property_allowance.pyandproperty_finance_cost_relief.py.P3-2. The #2106 merge bullet names an old SHA, overstates "either allocation" and leaves the merge instruction loose
PR body, Merge notes, last bullet;
FOLLOWUPS.mditem 9.The bullet has three problems:
ea880ff76, butfix-2106-allowance-ordernow points to74fdacf1d4933f759f94d15999a28e64b4cc73e2(gh api repos/PolicyEngine/policyengine-uk/branches/fix-2106-allowance-order --jq .commit.sha).TOLERANCE(utils/income_tax_allocation.py:111-113and:594-598at74fdacf1). The conditions output (false) holds under any allocation.Runs (England 2026-27; non-savings after allowances, savings tax, income tax, conditions):
74fdacf1Replacement bullet:
FOLLOWUPS.mditem 9: change "atea880ff76" to "atea880ff76and at its current tip74fdacf1".P3-3. The packet's s. 16(5) quotation drops the lead-in and the paragraph letters without marking the cut
AXIOM_ISSUE.md, "The law", s. 16 bullet (judgment call 4). Checked against the hub's fetch of ITA 2007 s. 16:The packet quotes "the savings income and dividend income are together treated … and the dividend income is treated as the higher part…" as one run of text. It drops "If a person has both savings income and dividend income—", "(a)" and "(b)", with no ellipsis. The words of (a) and (b) match. The packet asks the encoder to "quote the pinned provisions verbatim", so the quotation should be verbatim too.
Replacement:
I couldn't compare it with the pinned corpus row. The hub's text is legislation.gov.uk's current text, and the packet says the pinned s. 16 row has no subsection numbers.
P3-4. Axiom companion row 9: say that the alternative share ties
AXIOM_ISSUE.md, the paragraph after the companion table. Judgment call 1 is right, and I worked it independently:The packet's sentence says "no other share of the allowance lowers the tax", which is true. But the row's component columns (non-savings false, savings true) hold only for the order the table uses. The tied share gives non-savings true and savings false.
Replacement for "In the ninth, pay sits at the basic rate limit, so no other share of the allowance lowers the tax (s. 25(2)): the savings allowance covers £500 of the £501 of interest and the last pound bears the savings higher rate.":
P3-5. Two citations the voice rewrite left thin
tax_band.py:46-47. The rewrite dropped the old "each resolution" without citing it. Write: "…and the Scottish Parliament charges each Scottish rate "on income above" one limit "and up to" the next (each Scottish rate resolution)."scottish_rates.yaml:132-133. "Parliament charges the Scottish rates only on income that is neither savings nor dividends (ITA 2007 s. 11A(1A); …)". Section 11A(1A) and (4), as the packet quotes them, exclude only savings income. Section 11A(5) makes the section "subject to— section 13" for dividends. Write: "(ITA 2007 s. 11A(1A), (4) and (5), and s. 13; S6M-16531 for 2025-26)."Previous round's findings
meets_marriage_allowance_income_conditions.yaml:98-111now uses pay £50,270 and £501 of interest, with the outputs unchanged and the review's comment.74fdacf1. The probe table in P3-2 matches the author's table value for value. Mutation: settingsavings_at_forbidden_ratesto always false fails 4 cases, the new case among them (killed; restored).git merge-treeagainst current headsreview_findingblock, and the S6M-20844 quotation reads "£16,956 (c)".ISSUE_SAVINGS_SLICES.mdandFOLLOWUPS.mditem 8.phase_out(grepfinds onlysavings_starter_rate.allowance, line 80).savings_starter_rate_incomesubtractsphase_out × non_savings_taxable_income, andphase_out.yamlholds 1 from 2014-04-06. The review's example (pay £49,570, interest £1,200, 2026) reruns: phase-out 1 gives starting-rate £0, higher-rate savings £500, savings tax £240, false; phase-out 0.1 gives £700, £0, £0, false.FOLLOWUPS.mdlines 3 and 39, the three drafts' run records, the #1822 sentence and the savings draft's corrected cell are all in.0713b7c9..c74ca642ISSUE_2015_PARAMETERS.md.Judgment calls
scottish_rates.yamlheader citation (P3-5).mainmoved. This head merges cleanly with46a60678(git merge-treeexit 0). The commits since2da212abctouch the State Pension, the programme registry and the Pension Tax Allowance, and none of the scripts' cases.Checked and sound
The delta is text only. I wrote my own comparison: Python ASTs with module, class and function docstrings and
documentation/labelstrings blanked, and parsed YAML with casename/descriptiondropped.f0f576a44..c74ca6424: all 7 Python files are equal andscottish_rates.yamlis equal. In the Marriage Allowance YAML (31 cases at both ends), exactly one case differs: case 8, whereemployment_incomegoes from 50070 to 50270.0713b7c9..c74ca6424: every file is equal.Tests at head (no survey data,
HUGGING_FACE_TOKENandPOLICYENGINE_UK_DEFAULT_DATASETunset):meets_marriage_allowance_income_conditions.yamlscottish_rates.yamlmarriage_tax_reforms.yaml+integration/income.yamltest_parameter_descriptions.py+test_tax_band_scottish_properties.pytest_gift_aid.py+test_removed_marriage_allowance_eligible_bands_parameter.py+tests/code_healthformat --check .(2,166 files),check .,check --select F811,F821 policyengine_uk;git diff --check f0f576a44..c74ca6424Head merged with Allowances and reliefs are set against savings before dividends, not in the order giving the greatest reduction (ITA 2007 s.25(2)) #2106's tip
74fdacf1. I built it the way the author did: the merge, with Allowances and reliefs are set against savings before dividends, not in the order giving the greatest reduction (ITA 2007 s.25(2)) #2106'snon_savings_income_after_allowances.pytaken whole, andpolicyengine_uk.__file__checked to resolve to the merged tree.scottish_rates.yamltest_tax_band_scottish_properties.pyMerges.
main46a60678: clean. Correct Scottish income tax limits and rates from 2018-19 #2783 (9aa5ee4c9, unchanged): clean. Model Marriage Allowance as the statutory s.55B transfer and Step 6 reduction #1963 (69c919f6, unchanged): seven files, three of them shared withmain. Fix structural reforms for their requested periods #2670 (3f67112c): clean (P3-1). Allowances and reliefs are set against savings before dividends, not in the order giving the greatest reduction (ITA 2007 s.25(2)) #2106 tip: one file.Open PRs touching this branch's 14 files. Only Model Marriage Allowance as the statutory s.55B transfer and Step 6 reduction #1963 and Fix structural reforms for their requested periods #2670 (REST
pulls/{n}/filesover every open PR).The body's quoted docstring and documentation. Both match the head's text word for word, apart from the reader sentence the body leaves out.
The issue drafts' code claims. The three savings band variables set
zero_rate_savings = min_(…, savings_starter_rate_income + savings_allowance)and start atnon_savings_taxable_income + zero_rate_savings, asISSUE_SAVINGS_SLICES.mdsays.What I couldn't check
test_marriage_allowance_cps_switch.py(45 cases). Its code is AST-equal to round 3, where all 45 passed. I didn't rerun it, given its runtime of about 85 minutes.main2da212abc. I ran the issue scripts at this head only.Generated by Claude Code