Skip to content

DWP workplace pension participation: the first pensions claims in the UK scorecard (#98) - #103

Open
vahid-ahmadi wants to merge 4 commits into
mainfrom
uk/pensions-participation
Open

DWP workplace pension participation: the first pensions claims in the UK scorecard (#98)#103
vahid-ahmadi wants to merge 4 commits into
mainfrom
uk/pensions-participation

Conversation

@vahid-ahmadi

@vahid-ahmadi vahid-ahmadi commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Addresses #98 (external half). Branches from main.

PolicyEngine-UK models pensions — contributions, their relief, the age limit, the salary-sacrifice interaction — and not one of the 15,858 UK external claims said anything about pensions at all. This is the external side: 1,377 participation rates by earnings band, age band and region, split public/private/overall, 2009–2025.

Two facts ride on every claim

Leaving either in a methodology note would make the rows uninterpretable:

denominator — eligibility is part of the identity. The rate is a share of employees eligible for automatic enrolment, and that is an earnings trigger and an age range that have both moved over the series. A participation rate whose denominator is unstated says nothing at all.

survey_axis — ASHE is not the FRS. DWP derives these from an employer survey of jobs; the certified world is FRS-based. A PE-vs-DWP gap is a survey-population difference before it is an engine question — the same axis the LPC lane (#88) carries, and the two now share it.

What I refused to read

Every sheet holds six tables side by side and only the first carries a descriptor (row 6). The other five are unlabelled. A number whose meaning is inferred from its column position is not a claim, so 15 blocks are recorded as unread with the reason, alongside 21 other data sheets that are each their own population decision. A re-shuffled workbook fails the block-1 anchor rather than quietly reading the wrong table.

Identity

The half this PR does not close

#98 has two halves and this is one of them. The other is upstream: the pension commencement lump sum has no representation in the engine at all (verified at the pin — the lookup raises AttributeError), which is why the Budget's most-reported pensions measure cannot be scored. That is recorded in the relationship evidence so the limit travels with the claims, and it belongs in a policyengine-uk issue rather than here.

So this lane makes pensions validatable; it does not make the reported measure scoreable.

Verification

Suite 283 passed, two builds agree on content_hash, no-drift clean, ruff format --check clean.

Reviewers

@MaxGhenis @DTrim99 — the judgement worth testing is reading only block 1. It costs five-sixths of each sheet, and DWP's own contents page tells you six tables are there. My reasoning: the descriptor is what makes a number a claim, and I would rather ship 1,377 interpretable rows than 8,000 where five in six are positional guesses. If you know what the other blocks are, they are cheap to add.


Updated 2026-08-25. Two changes since this body was written, both disclosed in comments:

  1. The LPC age-band DISTINCT pair is deferred rather than asserted (the bullet above is the corrected version) — it named a source registered only on another branch, which is the defect @DTrim99 caught on ONS effects of taxes and benefits (mode 1, distributional): the decile-incidence population HMT publishes only as charts (#90) #92 recurring here. Details in this comment.
  2. That PR now also carries tests/test_uk_aliases_ledger.py, a general guard refusing any DISTINCT pair that names an unregistered source or value — so the class cannot recur a third time.

PolicyEngine-UK models pensions — contributions, their relief, the age
limit, the salary-sacrifice interaction — and not one of the 15,858 UK
external claims said anything about pensions at all. 1,377 participation
rates by earnings band, age band and region, split public/private/
overall, 2009-2025, from the current DWP edition.

Two facts ride on every claim because leaving them in a methodology note
would make the rows uninterpretable:

  denominator   ELIGIBILITY IS PART OF THE IDENTITY. The rate is a share
                of employees eligible for automatic enrolment — an
                earnings trigger and an age range that have BOTH moved
                over the series. A participation rate whose denominator
                is unstated says nothing.
  survey_axis   DWP derives these from ONS ASHE, an employer survey of
                jobs, where the certified world is FRS-based. A PE-vs-DWP
                gap is a survey-population difference before it is an
                engine question — the same axis the LPC lane (#88)
                carries, and the two lanes now share it.

Geography is GB, not UK: ASHE excludes Northern Ireland, kept apart in
the registry for the same reason #91 keeps IFS's coverage-restricted
analyses apart. Rates are validated as shares, and a percent slipping in
is refused — it would be a hundredfold error nobody could see
downstream.

What is deliberately not read is tallied, not implied. Every sheet holds
SIX tables side by side and only the first carries a descriptor (row 6);
the other five are unlabelled. A number whose meaning is inferred from
its column position is not a claim, so 15 blocks are recorded as unread
with the reason, alongside 21 other data sheets that are each their own
population decision. A re-shuffled workbook fails the block-1 anchor
rather than reading the wrong table.

Identity: DWP's auto-enrolment earnings bands are defined by the
enrolment trigger, not by an income distribution, so they are recorded
DISTINCT from UKMOD's quantiles; and DWP's age bands are DISTINCT from
LPC's minimum-wage age bands, which are a wage-rate category rather than
an enrolment range.

The relationship evidence also records the limit of what this lane can
ask: the engine has no pension commencement lump sum at all (verified at
the pin, #98), so the pensions question the scorecard can put is
narrower than the pensions system.

Suite 280 passed, two builds agree on content_hash, no-drift clean,
ruff format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Review request — @MaxGhenis @DTrim99.

This one is part of a batch; the whole queue, with a suggested merge order and what is blocked on whom, is in #104 so you can triage in one place rather than PR by PR.

Found by re-checking every branch for the defect review caught on #92:
DISTINCT is a plain frozenset of "source:value" strings, so nothing stops
it naming a source with no registered vocabulary. A pair like that reads
as if a distinction had been checked when there is nothing on the other
side to check against — and because the pair is just a string, every
test asserting it exists still passes. Mine did.

This branch claimed DWP's age bands DISTINCT from LPC's minimum-wage age
bands. The distinction is real, but the `lpc` vocabulary is registered on
#93's branch and not here, so on this branch the assertion pointed at
nothing. Deferred with a comment naming the prerequisite, and stated in
prose in source.json where it is a sentence rather than a ledger entry.

Two occurrences is a class, so it gets a guard rather than a third
correction. tests/test_uk_aliases_ledger.py reads the registered sources
out of uk_aliases itself — no hardcoded list — and refuses:

  - a DISTINCT pair naming a source with no registered vocabulary
  - the stricter form: a pair naming a VALUE that is registered nowhere,
    since "ukmod:q9" would be equally unfalsifiable

plus a check that the guard is not passing trivially on a near-empty
ledger. Verified by reintroducing the dangling pair and watching it fail.

Suite 283 passed, two builds agree on content_hash, ruff format clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Self-review found the #92 defect recurring here, fixed in the latest push — plus a guard so it cannot happen a third time.

I re-checked every open branch for the class @DTrim99 caught on #92 (ONS quintiles claimed DISTINCT from HBAI, which registers no quantile vocabulary at all). It recurred on this branch, in a way that was invisible to my own tests:

This PR claimed DWP's age bands DISTINCT from LPC's minimum-wage age bands. The distinction is real — one is an auto-enrolment range, the other a wage-rate category — but the lpc vocabulary is registered on #93's branch, not this one. So on this branch the assertion pointed at nothing.

And it passed. DISTINCT is a plain frozenset of "source:value" strings, so my test asserting the pair exists was satisfied by the string existing. The ledger read as if a distinction had been checked when there was nothing on the other side to check against.

The fix

The pair is deferred with a comment naming #93 as the prerequisite, and the distinction is stated in prose in source.json — where it is a sentence rather than a ledger entry. It goes into the ledger when both sides exist.

The guard, because two occurrences is a class

tests/test_uk_aliases_ledger.py reads the registered sources out of uk_aliases itself — no hardcoded list, so it keeps working as sources are added — and refuses:

  • a DISTINCT pair naming a source with no registered vocabulary;
  • the stricter form: a pair naming a value that is registered nowhere, since ukmod:q9 would be equally unfalsifiable;
  • and it checks it is not passing trivially on a near-empty ledger.

Verified by reintroducing the dangling pair and watching it fail:

DISTINCT names sources with no registered vocabulary: ['lpc'].
Either register them, or defer the pair with a comment saying which branch
or issue brings the other side — do not assert against nothing.

Once this lands it protects every lane, including the ones already approved.

Suite 283 passed, two builds agree on content_hash, ruff format clean.

source.json cited the LPC lane as "(#93/PR #93)", conflating the issue
with the pull request. The issue is #88; #93 is its PR. The uk_aliases
comment and the test both say "#93's branch", which is correct — it is
the branch that carries the vocabulary — so only the source.json note
needed correcting.

Small, but this note exists to tell a future reader where the other half
of a deferred assertion lives, and a wrong pointer defeats the point of
writing it down.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me

@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.

Verdict: request changes — rebase required before review can complete. mergeable=CONFLICTING; the PR is 1 ahead / 3 behind main.

Scope note: I read the 11 small code/schema/methodology files; the ~24,800-line data blob was sampled and machine-summarised, not line-audited.

Merge conflict (blocks merge)

  • Four append-heavy wiring files were edited on both this branch and main since the merge-base: data/lanes.json, app/public/data/lanes.json, scorecard_db/build_db.py, scorecard_db/relationships.py. Rebase onto main; these should resolve as keep-both appends.

Schema — sound

  • Sampled rows carry both body facts (denominator, survey_axis) plus geography=GB, program, axis, subgroup, sector, period, value, and a traceable source_column. Rows are self-describing; no meaning inferred from position.

Ingest — good at a high level

  • adapter.py anchors on the row-6 block-1 descriptor (fails loudly on reshuffle), closes every label through a registry, pins the xlsx by sha256, and tallies unread blocks. ingest_dwp_pensions.py rejects value ∉ [0,1] and hard-asserts per-axis counts {earnings:357, age:459, region:561}. The LPC age-band DISTINCT pair is correctly deferred (unregistered source). No bug spotted; dwp_pensions relationship = HELD_OUT.

Counts / span — verified cheaply

  • 1,377 rows = 357 + 459 + 561 (7/9/11 bands × 3 sectors × 17 years), 2009–2025, all values in [0.17, 0.97]. Consistent with _EXPECTED and the lane note.

A full review still needs (after rebase): a build + suite re-run and content-hash on the merged tree; a live sha256 re-fetch check against the GOV.UK xlsx; and a human spot-check that block-1 values track the published DWP tables (I sampled 3).

🤖 review via Claude Code

Conflicts were the two lanes.json copies, both append-vs-rename:
main renamed the BE lane's "Ledger facts" to "Chronicle facts" while
this branch appended the dwp-pension-participation lane. Took main's
wording and re-appended the lane — an 11-line addition against
origin/main, repo escaping preserved.

app/public/data/lanes.json is a literal copy of data/lanes.json, so it
is cp'd rather than written independently; writing both from their own
sides is what test_app_data_copies_match_committed_data exists to catch.

build_db.py and relationships.py auto-merged. Verified the step list
survived intact — a merge that splits a step tuple yields invalid
Python, and one that drops a step yields a silently missing lane; the
import-vs-runs guard covers the second.

284 tests, ruff clean, two builds agree on content_hash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016HuXJFVme8HRbnke2Ey3Me
@vahid-ahmadi

Copy link
Copy Markdown
Contributor Author

Rebased — mergeable is green.

Both conflicts were in the lanes.json pair, and both were the shape you predicted: main renamed the BE lane's "Ledger facts" to "Chronicle facts" while this branch appended the dwp-pension-participation lane. Took main's wording, re-appended the lane. It's an 11-line addition against origin/main with the repo's escaping preserved.

One thing worth noting for the next rebase of this kind: app/public/data/lanes.json is a literal copy of data/lanes.json, not an independently-maintained file. Resolving each from its own side produced two files that differed only in unicode escaping, which test_app_data_copies_match_committed_data caught immediately. It's a cp.

build_db.py and relationships.py auto-merged, and I checked the step list rather than trusting that — a merge that splits a step tuple gives invalid Python, and one that drops a step gives a silently missing lane. Both survived; the import-vs-runs guard from #74 covers the second case anyway.

284 tests, ruff format --check clean, two build_db runs agree on content_hash. Ready for review.

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