DWP workplace pension participation: the first pensions claims in the UK scorecard (#98) - #103
DWP workplace pension participation: the first pensions claims in the UK scorecard (#98)#103vahid-ahmadi wants to merge 4 commits into
Conversation
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
|
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
|
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 This PR claimed DWP's age bands And it passed. The fixThe pair is deferred with a comment naming #93 as the prerequisite, and the distinction is stated in prose in The guard, because two occurrences is a class
Verified by reintroducing the dangling pair and watching it fail: Once this lands it protects every lane, including the ones already approved. Suite 283 passed, two builds agree on |
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
left a comment
There was a problem hiding this comment.
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) plusgeography=GB,program,axis,subgroup,sector,period,value, and a traceablesource_column. Rows are self-describing; no meaning inferred from position.
Ingest — good at a high level
adapter.pyanchors 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.pyrejects 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_pensionsrelationship = 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
_EXPECTEDand 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
|
Rebased — 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 One thing worth noting for the next rebase of this kind:
284 tests, |
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
DISTINCTfrom UKMOD's quantiles.lpcvocabulary is registered on Minimum wage coverage and bite (mode 1): the first external check on PE-UK's minimum-wage machinery (#88) #93's branch, and asserting distinctness against a source nobody has registered would read as if it had been checked when there is nothing on the other side. It is stated in prose insource.jsonand goes intoDISTINCTwhen Minimum wage coverage and bite (mode 1): the first external check on PE-UK's minimum-wage machinery (#88) #93 lands.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 --checkclean.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:
DISTINCTpair 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.tests/test_uk_aliases_ledger.py, a general guard refusing anyDISTINCTpair that names an unregistered source or value — so the class cannot recur a third time.