Skip to content

HMRC personal tax adapter (mode 1): liabilities tables + ready reckoner - #45

Merged
MaxGhenis merged 2 commits into
mainfrom
uk/hmrc-personal-tax-adapter
Aug 16, 2026
Merged

HMRC personal tax adapter (mode 1): liabilities tables + ready reckoner#45
MaxGhenis merged 2 commits into
mainfrom
uk/hmrc-personal-tax-adapter

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

Closes #36. Third UK mode-1 adapter (parent #3), and the external side the campaign's hmrc_reckoner_t2 family (14 staged PE results) compares against.

What's here

  • sources/hmrc-personal-tax/raw/ — the collated Income Tax liabilities tables 2.1–2.6 (July 2026 edition, 2023-24 SPI outturn + projections to 2026-27) and the June 2025 tax ready reckoner (still the current edition — HMRC deferred the scheduled July 2026 update pending an assumptions review)
  • sources/hmrc-personal-tax/adapter.py — stdlib-only parser → 1,213 tidy rows at data/externals/hmrc-personal-tax.json
  • source.json with an explicit baseline_key block (per Baseline as a first-class attribute of every score #13) + 7 annotations
  • data/lanes.jsonhmrc-personal-tax: registered → ingested

Coverage

  • Table 2.1 — taxpayer counts by marginal rate, sex, and age, 1990-91 → 2026-27. [not applicable] cells (bands that didn't exist that year) are skipped as structure, not suppressed as data.
  • Table 2.5 (four year-sheets) — payer counts and liabilities by income range × marginal-rate band, plus totals, average rate, average amount.
  • Ready reckoner, complete — 75 illustrative changes × 3 forecast years across 12 tax heads (income tax, NICs, child benefit, CT, CGT, IHT, duties, VED, APD, VAT, IPT, SDLT). Emitted as reform deltas: the subgroup is the verbatim change description, preserving the (cost)/(yield) asymmetric pairs as distinct claims.

Validation

10 inline checks pass: HMRC summary-statistics headlines (36.7M taxpayers 2023-24 → 40.8M 2026-27; 31.4M/7.70M/1.29M/415k by band; £274bn total liability 2023-24), a cross-table equality (Table 2.5 all-ranges band counts ≡ Table 2.1 marginal-rate counts), and two reckoner cells (basic rate 1p = £6.9bn, employer NICs 1pp = £11.15bn in 2026-27).

Honesty boundary

Annotations pin the comparison contract: reckoner rows are deltas against HMRC's indexed baseline (OBR Spring Statement 2025, April 2026 start) and include HMRC's behavioural adjustments — a static PE counterpart is expected to overshoot where elasticities bite, and that wedge is construction, not model error. Projected years (2024-25 onward) are forecast-vs-forecast. Liabilities are accrued, never comparable to receipts. Tables 2.2 (region/country) / 2.4 / 2.6 are vendored for a follow-up emission.

🤖 Generated with Claude Code

Third UK mode-1 adapter (#36, parent #3). Two artifacts, one source
lane: the July-2026 Income Tax liabilities statistics (SPI-based,
2023-24 outturn + projections to 2026-27) and the June-2025 tax ready
reckoner (still current; HMRC deferred the scheduled July 2026 update).
1,213 tidy rows at data/externals/hmrc-personal-tax.json:

- Table 2.1: taxpayer counts by marginal rate / sex / age, 1990-91 to
  2026-27 ([not applicable] band-structure cells skipped, not
  suppressed)
- Table 2.5 x 4 year-sheets: counts and liabilities by income range x
  band, plus totals/averages
- the full ready reckoner: 75 illustrative changes x 3 forecast years
  across 12 tax heads, emitted as reform deltas whose subgroup is the
  verbatim change description ((cost)/(yield) asymmetry preserved),
  with the indexed-baseline identity recorded as baseline_key in
  source.json per #13

Validation pins 10 figures: summary-statistics headlines (36.7M/40.8M
taxpayers, band counts, GBP274bn 2023-24 liability), a 2.1-vs-2.5
cross-table equality, and two reckoner cells. Annotations record the
delta-not-level contract, HMRC behavioural adjustments (static PE
counterparts expected to overshoot where elasticities bite),
SPI-projection vintage, liability-vs-receipts, and devolved-rates
scope. Lane hmrc-personal-tax: registered -> ingested.

Closes #36

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
HMRC publishes ready-reckoner effects as magnitudes with the direction
carried only in the English label, so a cost and a yield are both
positive: 'Increase additional rate by 1p (yield)' is +£145m and
'Decrease additional rate by 1p (cost)' is +£175m in 2026-27. A PE
counterpart computed as (reform - baseline) returns -£175m for the cost
row and reads as a 100% divergence -- or matches under any abs()
comparison, which is worse. Genuine negatives (28 of 225 rows) sit in
the same column, so the mixed convention is invisible by inspection.

Every revenue_effect row now carries:
  sign_convention  'magnitude_with_direction_in_label'
  direction        cost | yield | stated_change
  change_direction increase | decrease | unsigned

Both are read off the verbatim label only. Explicit (cost)/(yield)
markers give cost/yield; everything else is stated_change -- no
direction is invented for the plain 'Change X by 1p' form, and an
unmarked 'Increase ...' is NOT assumed to be a yield, because HMRC
publishes several of them negative ('Increase lower Capital Gains Tax
rate by 10 percentage points' is -£130m in 2026-27). Labels the
classifier cannot place raise rather than defaulting. No value is
changed or flipped; levels rows are untouched.

tests/test_hmrc_adapter.py covers this in CI, which never runs sources/
and so never saw the adapter's inline checks. It pins the row count,
asserts every reckoner row is classified, and asserts the additional
rate (cost)/(yield) pair is both positive AND separable by direction --
the exact ambiguity that motivated the change.

annotations.json gains reckoner-sign-convention stating the convention
as a comparison constraint, so the honesty record matches the data.

1213 rows, all 10 inline checks pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Fixed: ready-reckoner rows had a sign convention nobody declared

The reckoner emitted HMRC's own convention verbatim — positive magnitudes with the direction living only in the English label:

"Increase additional rate by 1p (yield)"  2026-27  +145,000,000
"Decrease additional rate by 1p (cost)"   2026-27  +175,000,000

Both positive. A PE counterpart computed as (reform − baseline) returns −£175m for the second and scores as a 100% divergence; an abs() comparison hides it by matching everything. And 28 of the 225 reckoner rows are genuinely negative, sitting in the same column, so nothing about the data flags the mixed convention.

Every revenue_effect row now carries three fields:

field values
sign_convention magnitude_with_direction_in_label
direction cost | yield | stated_change
change_direction increase | decrease | unsigned

Census over the 225 rows: cost 42, yield 36, stated_change 147.

What I deliberately did not do

Both fields are derived from the verbatim label and nothing else. Explicit (cost)/(yield) markers map to cost/yield. Everything else is stated_change — including unmarked Increase … rows, and this is the part worth arguing about, because the brief listed Increase …/Decrease … as direction-stating.

They state the direction of the change, not of the fiscal effect, and those come apart in this data. Increase lower Capital Gains Tax rate by 10 percentage points is −£130m in 2026-27 — an increase that loses revenue. Same for Increase Annual Exempt Amount by £500 … (−£35m in 2027-28). Mapping Increase→yield would have written a wrong direction onto 39 rows. Worse, two rows HMRC explicitly marked (Yield) are also negative (Increase NRSDLT by 1 percentage point (Yield), Increase Higher Rates of Duty on Additional Dwellings by 1 percentage point (Yield)), which is a useful reminder that the label tells you what claim is being made, not what sign to expect.

So change_direction captures the Increase/Decrease information without pretending it settles cost-vs-yield, and direction stays honest about what HMRC actually asserted. Unclassifiable labels raise instead of defaulting — verified against Abolish the personal allowance, a stray (net) marker, and a bare duty name outside the duties section.

No value changed, none flipped, levels rows untouched (byte-compared against HEAD: 0 rows with any modified pre-existing field).

Test

tests/test_hmrc_adapter.py. CI runs pytest tests/ and never touches sources/, so the adapter's 10 inline checks were invisible to it — this defect could not have been caught by CI as it stood. The new test reads the committed data/externals/hmrc-personal-tax.json (skips if absent) and asserts every reckoner row is classified, direction agrees with the verbatim label, the additional-rate pair is both positive and separable by direction, the −£130m CGT row keeps its sign and is not called a yield, levels rows carry none of the new keys, and the row count is pinned at 1,213 (988 levels + 225 reckoner).

annotations.json gains reckoner-sign-convention, stating the convention as a comparison constraint with the worked −£175m example, so the honesty record matches the data. A test asserts the annotation and the emitted field cannot drift apart.

Verification

  • python3 sources/hmrc-personal-tax/adapter.py — 10/10 checks OK, 1,213 rows, unchanged
  • PYTHONPATH=. uv run --with pytest pytest tests/ -q — 135 passed, 4 skipped
  • uvx ruff format --check scorecard_db tests pipeline — clean

Follow-up (not this PR)

#48 maps these rows to Metric.REVENUE_CHANGE in scorecard_db/ingest_uk_externals.py. The convention needs to travel into conditions there, otherwise the ingest re-loses it at the boundary and the scoring layer is back to comparing a cost against a signed delta. Untouched here — different branch.

@MaxGhenis
MaxGhenis merged commit 8abceb7 into main Aug 16, 2026
2 checks passed
@MaxGhenis
MaxGhenis deleted the uk/hmrc-personal-tax-adapter branch August 16, 2026 16:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HMRC personal tax statistics adapter (mode 1)

2 participants