Skip to content

OBR costings divergence decomposition: axes registry + identity-checked pipeline (#59) - #67

Open
vahid-ahmadi wants to merge 2 commits into
mainfrom
uk/obr-divergence-decomposition
Open

OBR costings divergence decomposition: axes registry + identity-checked pipeline (#59)#67
vahid-ahmadi wants to merge 2 commits into
mainfrom
uk/obr-divergence-decomposition

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

The #30 pattern applied to the mode-2 UK lane: every #56 comparison row decomposes into signed GBP components summing exactly to gap = PE − OBR, with a residual, under two honesty rules enforced by the pipeline and tests — a sized component must carry per-FY values with verbatim fetched-primary-source provenance, and any unsized component forces decomposition_status: partial with the remainder labelled residual_plus_unsized, never explained.

What's sized with real published numbers (fetched, quoted in the registry)

  • Employer NICs package — missing Employment Allowance construction: +£4,581m (2026-27) / +£4,631m (2027-28) and static-vs-post-behavioural: +£723m / +£703m, from the OBR's 8 May 2025 supplementary release Static costing of changes to Employer NICs (Tables 1.3, 1.4, 1.5; https://obr.uk/docs/dlm_uploads/Cosings-supp-NICs-AB24-1-1.pdf). The sized components expose the real finding: the residual −£12.67bn is the like-for-like static gap between PE's employer-NICs wage-incidence machinery and HMRC's PTM static of the rate+threshold elements — a policyengine-uk development item, per the registry's residual recipe.
  • HICBC — missing welfare head: +£391m / +£426m (the comparison's own head rows; PE emits zero on that head).
  • The SB2024 Policy Costings document's final Exchequer rows and verbatim behavioural-response descriptions (employee NICs cut, HICBC) are quoted as provenance for the unsized behavioural axes, with sizing recipes (the OBR supplementary-release route has precedent, and the reckoner lane HMRC ready reckoner (mode 2): PE-UK counterparts to the direct effects of illustrative tax changes #60 is the other instrument).

Files

  • data/uk/obr_divergence_axes.json — 6-measure registry (JSON, not YAML: CI installs pytest only). Axes: behavioural_adjustment, baseline_vintage, cy_proxies_fy, head_scope, construction_scope, residual.
  • pipeline/decompose_uk_obr_divergence.py — engine-free csv/json arithmetic; emits results/uk/obr_divergence/DECOMPOSITION.{json,md}; a measure whose registered row_kind is absent errors rather than decomposing a different scope.
  • tests/fixtures/obr_costings_comparison_20260817.csv — verbatim copy of origin/obr-costings-mode2:results/uk/obr_costings/COMPARISON.csv (provenance in the test module docstring) so the pipeline is executable before OBR policy costings (mode 2): registry, offline certified compute, staging, descriptive comparison #56 merges.
  • tests/test_obr_divergence_decomposition.py — 10 tests: registry contract (sized⇒provenance, unsized⇒recipe), the exact identity, partial-never-explained, the pinned employer-NICs and HICBC sizings, missing-row refusal.

Suite: 173 passed / 4 skipped; ruff clean.

Dependency: consumes #56's COMPARISON.csv by path only — no shared commits; when #56 merges, running the pipeline against the real artifact replaces the fixture path. 12/12 decompositions are partial by design until the unsized axes get their runs.

Builds #59.

🤖 Generated with Claude Code

…ts from primary sources, identity-checked pipeline

Builds #59. Consumes PR #56's COMPARISON.csv by path (no shared
commits); a verbatim fixture copy makes the arithmetic executable now.

Sized from fetched primary sources: the employer NICs package's
missing Employment Allowance construction (+4,581m/+4,631m) and
static-vs-post-behavioural difference (+723m/+703m) from the OBR's
8 May 2025 supplementary static-costing release (Tables 1.3-1.5), and
HICBC's missing welfare head (+391m/+426m) from the comparison's own
head rows. Everything else is registered unsized with an explicit
sizing recipe; the pipeline forces decomposition_status=partial and
labels the remainder residual_plus_unsized so a partial decomposition
never presents as explained. gap = sized + residual holds exactly and
is test-pinned.

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

DTrim99 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Review — OBR costings divergence decomposition

The mechanical scaffolding (schema, "never explained while unsized" gate, JSON hygiene) is sound, but I think the decomposition has a substantive correctness problem that the current test locks in rather than catches. Requesting changes.

The identity "check" is a tautology

decompose() (decompose_uk_obr_divergence.py:293) defines residual = gap − sum(sized), so sized + residual == gap can never fail — and test_arithmetic_identity only re-checks that. The honest test (that independently-sourced sized components don't exceed or oppose the gap) is absent.

Working the employer-NICs 2026-27 row by hand: gap = PE − OBR = 16,246,687,341 − 23,610,420,262 = −£7.364bn. Σsized = +£4,581m + £723m = +£5.30bn, so residual = −£12.67bn — larger in magnitude than the gap and opposite in contribution. The "sized" terms move away from OBR, which is the opposite of a decomposition that explains divergence.

Sign / attribution of the sized components

Both employer-NICs sized components (data/uk/obr_divergence_axes.json:28-36) are attached to mapped_head_total (IT + NICs) with the convention "+ = PE above OBR" — but on that row PE is £7.36bn below OBR. The +£4,581m "missing Employment Allowance construction" claim (PE lacks EA ⇒ PE higher) is contradicted by the data: the NICs head alone has PE only +£0.83bn above OBR, and the total is dominated by the −£8.19bn IT head. So the component appears sized against the wrong row, and its direction argument doesn't hold where it's applied. test_employer_nics_sized_components hard-codes these as correct.

Should

  • The behavioural component (+£723m/+£703m, axes.json:35-36) is derived (static Table 1.5 minus summed PMD rows) and the provenance itself admits it "bundles the behavioural response with any static-vs-PMD head-allocation mismatch." That breaks the PR's own honesty rule ("sized ⇒ verbatim fetched primary-source value") — it should be unsized with a recipe, or a new derived status. load_registry only checks presence of values_gbp + provenance, never that the number is quoted.

To be clear on what's fine: the "never mark explained while an unsized component exists" rule is enforced in code and tested — that part is good. The gap is that presence is enforced but truth (right row, right sign, quoted-not-derived) isn't.

Reviewed with Claude Code assistance.

… diagnostics, quoted-vs-derived contract

The identity residual = gap - sum(valued) stays as bookkeeping but no
longer masquerades as a check. Every valued component is classified
explains_gap / masks_gap against the gap's sign; any masking component
sets divergence_understated with the widened like_for_like_gap_gbp,
|residual| > |gap| is flagged, and explained_share is emitted only for
complete decompositions with no masking components. The behavioural
component is re-statused 'derived' (formula mandatory, travels into the
artifact); sized now means verbatim-quoted only and load_registry
rejects a sized component carrying a derivation. The employer-NICs
measure gains its dominant missing component (employee_incidence_and_base,
unsized with a paired-run recipe: PE's employee_incidence=1 drives the
-8.5bn IT head vs OBR's -0.3bn) and the EA component's provenance now
carries the head-attribution caveat from the review. The review's
hand-worked row is a pinned regression: gap -7.364bn, both valued
components masking, residual -12.668bn exceeding the gap, no explained
claim.

Tests 10 -> 17 (synthetic masking/overshoot cases that fail on the old
semantics); full suite 180 passed / 4 skipped.

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

Copy link
Copy Markdown
Contributor Author

All three points addressed in ff38e0e, including the substantive one. Point by point:

The tautology. Agreed — residual = gap − Σ(sized) can never fail and the old test_arithmetic_identity only re-checked its own definition. It's now bookkeeping, labeled as such, and the record carries diagnostics that can actually fail: every valued component is classified explains_gap / masks_gap against the gap's sign; any masking component sets divergence_understated: true with like_for_like_gap_gbp (the gap with the masking axes stripped); residual_exceeds_gap flags |residual| > |gap|; and explained_share is emitted only for complete decompositions with no masking components. Synthetic tests pin each: a component opposing the gap must classify as masking with no explained claim (this test fails on the old semantics), and your hand-worked employer-NICs row is a committed regression — gap −7.364bn, Σvalued +5.304bn both masking, residual −12.668bn, residual_exceeds_gap and divergence_understated both true, no explained_share.

Sign / attribution. I re-checked the orientation chain and the two components' signs are individually defensible (EA-absence contributes +4.58bn to PE−OBR because the missing elements are revenue-reducing in OBR's leg; static−post-behavioural is +723m by Table 1.5 arithmetic) — but you're right that presenting them as decomposition steps on this row was wrong, and right about the head attribution: the EA effect lands on the NICs head where PE is only +0.83bn above OBR, so ~−3.75bn of offsetting NICs-base factors must coexist, and the total gap is dominated by the IT head (PE −8.51bn vs OBR −0.31bn) — which is PE's employee_incidence = 1 construction, previously not a component at all. The registry now: (a) carries the head-attribution caveat verbatim on the EA component, (b) adds employee_incidence_and_base as the named dominant component (unsized, paired-run recipe: re-run with employee_incidence = 0, difference per head; the same runs isolate the NICs-base offset), and (c) the artifact's honest statement for this measure is that the observed −7.36bn understates a ~−12.67bn like-for-like disagreement — a masking record, not an explanation.

Derived vs quoted. The behavioural component is re-statused derived: load_registry now requires a mandatory derivation formula (which travels into the artifact) and rejects any sized component carrying one — sized means verbatim-quoted only, enforced and tested both ways.

Tests 10 → 17; full suite 180 passed / 4 skipped; ruff clean.

@DTrim99

DTrim99 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Re-review — request-changes resolved ✅

This fully addresses my earlier review — nicely done, and thank you for the rework. Verified against the live pipeline (17 tests pass; 12 records, all partial, 0 with explained_share, 4 masking).

  • Identity tautology — the residual = gap − Σ(valued) identity is now explicitly labelled bookkeeping ("NOT evidence"), and real, failable checks replace it: direction, residual_exceeds_gap, divergence_understated, and explained_share emitted only when complete + unmasked + gap≠0 (never on these rows).
  • Sign/attribution — the EA (+£4.581bn) and behavioural (+£0.723bn) components now classify as masks_gap against the −£7.364bn gap (both opposite-signed), with divergence_understated: true and like_for_like_gap = −£12.668bn. I reproduced the arithmetic. The wrong-row concern is now surfaced via a head-attribution caveat + an unsized employee_incidence_and_base component naming the −£8.5bn IT-head driver with a paired-run recipe — pinned by test_employer_nics_row_matches_the_hand_computation.
  • Derived-not-quoted — new status: derived with a mandatory derivation, and load_registry enforces derived⇒derivation and sized⇒no-derivation, so a computed number can't masquerade as quoted.

Remaining bits are honestly disclosed and non-blocking (the EA head-attribution still awaits the paired run to confirm; a render-only unicode console quirk). Happy to see this move forward — clearing my change request.

@DTrim99 DTrim99 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed — my earlier change request is fully resolved: the identity is de-tautologized with failable checks, the employer-NICs components are reclassified masks_gap (I reproduced the arithmetic), and the behavioural term is status: derived under an enforced sized-vs-derived contract. Approving — thanks for the thorough rework.

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.

2 participants