Skip to content

HMT distributional analysis (mode 2, distributional): Budget 2025 external side — chart-only scope, honestly - #68

Open
vahid-ahmadi wants to merge 5 commits into
mainfrom
uk/hmt-distributional
Open

HMT distributional analysis (mode 2, distributional): Budget 2025 external side — chart-only scope, honestly#68
vahid-ahmadi wants to merge 5 commits into
mainfrom
uk/hmt-distributional

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

First slice of the HMT DA lane. The headline finding shapes the whole PR: HMT publishes no machine-readable decile values. The Budget 2025 "Impact on households" document is a 20-page PDF whose Figures 1.A–1.C are unlabeled chart bars; the supporting-documents page carries XLSX only for the costings tables (4.1/4.2), and no chart-data file exists. So this PR deliberately ships the external side without value claims rather than digitizing charts by eye.

What's here

  • sources/hmt-distributional/raw/ — the real publication, fetched 2026-08-17 from the gov.uk assets URL, SHA-256 9f7e68f3…480ea pinned in README, source.json, the registry and the tests.
  • data/uk/hmt_da_packages.yaml — the Budget 2025 package entry: all 30 in-scope measures verbatim from pp. 7–9, each with channel (16 tax / 9 welfare / 5 benefits-in-kind) and computability triage (3 expressible / 9 partial / 18 not_expressible, each partial naming what's missing and each not_expressible why), the published exclusions (employer NICs incidence, NLW, devolved decisions, behavioural effects…), the counterfactual (no policy changes since AB2024), horizon (2028-29) and income concept (equivalised net BHC). No invented reform dicts: expressible components reference measure families for the OBR policy costings (mode 2): Policy Measures Database + EFO 3.17 → PE-UK static counterparts #54/OBR policy costings (mode 2): registry, offline certified compute, staging, descriptive comparison #56 registry, so one measure keeps one executable home.
  • sources/hmt-distributional/adapter.py — verifies rather than parses: SHA gate, page count, figure inventory, and every registry component title verbatim-anchored in the document text (normalized for PDF line breaks and typographic quotes), so the registry cannot drift from the publication. Emits a meta artifact recording value_claims_emitted: 0 and the value-availability rule. Runs clean against the vendored PDF.
  • tests/test_hmt_da_adapter.py — raw-file identity, provenance files, the no-value-emission contract, meta honesty; registry schema checks via importorskip("yaml") (CI's bare-pytest env skips those five assertions; the adapter enforces the same contract on every execution).

What this defers, and to what

Suite: 167 passed / 5 skipped; ruff format clean.

Builds #61.

🤖 Generated with Claude Code

…nest chart-only scope

- Vendors the real publication (Impact on households: distributional
  analysis to accompany Budget 2025, fetched 2026-08-17 from gov.uk,
  SHA-256 pinned in raw/README.md, source.json, registry and tests).
- data/uk/hmt_da_packages.yaml: the Budget 2025 package entry — verbatim
  in-scope measure list (30 components) with per-component channel and
  computability triage (3 expressible / 9 partial / 18 not_expressible),
  published exclusions, counterfactual, horizon and income concept.
  Executable reform specs are never invented here; expressible
  components reference measure families for the OBR measure registry.
- sources/hmt-distributional/adapter.py: verifies rather than parses —
  SHA gate, page count, figure inventory, and every registry component
  title verbatim-anchored in the document text. EMITS NO VALUE CLAIMS:
  HMT publishes the decile impacts as unlabeled chart bars with no data
  tables (confirmed against the Budget 2025 supporting documents, which
  carry XLSX only for costings tables 4.1/4.2), so per-decile external
  values wait for an HMT data release or a documented digitization.
- tests/test_hmt_da_adapter.py: raw-file identity, provenance files,
  no-value-emission contract, meta-artifact honesty; registry schema
  checks run when pyyaml is importable (CI's bare pytest env skips them;
  the adapter enforces the same contract when executed).

Suite: 167 passed / 5 skipped; ruff format clean.

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

DTrim99 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Review — HMT distributional analysis (mode 2, chart-only)

The central call here — shipping the external side without value claims because HMT publishes no machine-readable decile data — is the right one, and it holds across every file: no fabricated or eyeballed decile value appears anywhere. Every number in hmt_da_packages.yaml sits inside a verbatim HMT measure title (rates, dates, "450,000 children" — HMT's own text), never a per-decile £/% chart read, and value_claims_emitted: 0 is asserted in tests with no emit path for value rows by construction.

Verified:

  • SHA-256 pin is consistent — one 64-hex hash across meta.json:10, yaml:63, README:394, test:465 (and source.json deliberately omits it, which test_lane_provenance asserts). It's actually checked: adapter.py:319-321 recomputes-and-compares and test:470 recomputes the raw file hash.
  • Counts sum both ways — channel 16 tax / 9 welfare / 5 BIK = 30; triage 3 expressible / 9 partial / 18 not_expressible = 30 (both match meta.json).

Should address

  • The strongest honesty guarantees aren't in CI. The verbatim-title, figure-presence, and "20-page" checks live only in adapter.py (needs pypdf) and run outside the default pytest path — so tests/test_hmt_da_adapter.py doesn't enforce that titles are actually verbatim or figures actually present; only a manual uv run of the adapter does. A pypdf-gated test (importorskip) mirroring the registry-schema test at :499 would catch drift in CI rather than locally.

Minor

Reviewed with Claude Code assistance.

test_document_anchoring_in_ci_when_pypdf_available loads the adapter
and enforces the verbatim-title, figure-anchor and 20-page checks in
any test env with pypdf+pyyaml (importorskip, mirroring the yaml-gated
schema test); the adapter's own run() stays the local backstop. Also
adds meta.json's missing trailing newline.

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

Copy link
Copy Markdown
Contributor Author

Addressed in the latest commit:

  • Honesty guarantees now in pytesttest_document_anchoring_in_ci_when_pypdf_available loads the adapter and enforces the verbatim-title anchoring (all 30 components), figure anchors, and the 20-page identity against the real committed PDF, gated with importorskip("pypdf")/importorskip("yaml") exactly like the schema test. Verified passing locally under uv run --with pypdf --with pyyaml (6/6 in the file); it skips in CI's bare env — same limitation as the yaml-gated test, and the reason the adapter's run() remains the local backstop. If CI ever grows pypdf/pyyaml, the guarantee turns on with no further change.
  • meta.json trailing newline added.

Full suite: 167 passed / 6 skipped; ruff clean. The unverified measure_family pointers stay as-is per your note — they resolve when the #54 registry merges.

@DTrim99

DTrim99 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Re-review — addressed ✅

The pypdf-gated CI test I asked for is in: test_document_anchoring_in_ci_when_pypdf_available importorskips pypdf+yaml and enforces all three guarantees in CI — the 20-page identity, every FIGURE_TITLES anchor present, and verbatim titles (empty unanchored set). Skips gracefully in a bare env with the adapter run as local backstop.

SHA-256 pin, value_claims_emitted: 0, and the 16/9/5 = 30 / 3/9/18 = 30 counts all still hold. Nothing left from my side.

@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 — addressed: the pypdf-gated CI test now enforces the verbatim/figure/20-page guarantees, and the SHA pin + counts still hold. Approving.

@MaxGhenis

Copy link
Copy Markdown
Contributor

Gate round 1 — the chart-only honesty holds; six findings before rows can land

Verified clean: extraction is text-layer anchoring only — no OCR, no digitization, no numeric values at claimed precision — so there is not a single fabricated-precision row today; the vendored 20-page PDF's SHA-256 matches all four pins; and the current GOV.UK attachment list corroborates that the distributional analysis is PDF-only.

  1. Blocking — the lane vanishes from post-The database leaves git: derived artifact, built in CI, published to Supabase storage #74 builds. The adapter writes standalone metadata, not ExternalScore rows; nothing registers in build_db.py; no lane entry exists. A fresh CI-built DB contains no trace of HMT — not even a zero-claim lane.

  2. High — chart omissions aren't exactly tallied. The PDF plots 3 figures × 11 groups × 4 series = 132 values; the registry lists figures/series but not groups or expected cells. The accounting to enforce: 132 source marks = 0 emitted + 132 chart_not_digitized, by figure/group/series. (The 30 policy-component accounting does reconcile: 3+9+18 = 16+9+5 = 30.)

  3. High — decile identity is prose. "Equivalised net household income, before housing costs" needs to become closed data identity: enumerated deciles in the income_group/quantile vocabulary via a deliberate uk_aliases extension, with housing_costs=bhc and equivalisation pinned as conditions (the HBAI/UKMOD pattern) — and no aliasing of HMT deciles to UKMOD quintiles.

  4. High — baseline identity isn't machine-enforced. One prose counterfactual covers all charts and is omitted from emitted metadata — but 1.A/1.B are changes vs the no-policy package world (public services vs Spring Budget 2024 plans) while 1.C is a post-policy level. Without a registered world + ReformRef.baseline + mirrored baseline_policy, future HMT rows silently default to current_law.

  5. High — the advertised PDF-anchoring gate is optional in CI. The document checks importorskip pypdf/pyyaml, and CI installs only pytest — so the anchoring guarantee never actually runs there. Install the deps and remove the skip path. Also: the adapter omits the trailing newline the committed metadata has, so wiring into the no-drift build would immediately dirty the tree.

  6. Medium — add the deliberate held-out relationship entry (with evidence) before numeric rows land — the resolver currently fails closed on the unknown source, which is correct but temporary; and the "engine defect may be inferred after decomposition" guidance should point at the citable-known-issue gate instead.

🤖 Generated with Claude Code

r and others added 2 commits August 21, 2026 17:43
1. Blocking — the lane no longer vanishes from a post-#74 build. The
   adapter wrote standalone metadata and nothing registered in
   build_db.py, so a fresh CI-built DB held no trace of HMT at all —
   not even a zero. scorecard_db/ingest_hmt_distributional.py is the
   missing step: it emits no external_scores rows (deliberately, and it
   raises if the metadata ever claims otherwise), writes the lane row
   with the exact chart-cell accounting as its detail, and mirrors it
   into the committed feed under UK. Registered in build_db after
   uk_deductions.

2. The omission is tallied, not asserted. The registry listed figures
   and series but not the GROUPS, so "0 emitted" had no denominator. The
   eleven income groups are now registry data, and the adapter enumerates
   and checks every cell: 132 source marks = 0 emitted + 132
   chart_not_digitized, by figure / income group / series, with the
   arithmetic raising if it does not close. The 30 policy components
   still reconcile 3+9+18 = 16+9+5.

3. Decile identity is data. The ten equivalised-net-income deciles plus
   the all-households bar are a closed uk_aliases vocabulary, with
   housing_costs=bhc and equivalisation=modified_oecd pinned beside them
   (the HBAI/UKMOD pattern), and HMT deciles are recorded as DISTINCT
   from UKMOD quintiles so nothing can alias them later.

4. Baseline identity is machine-enforced and per figure. 1.A and 1.B are
   changes against the no-policy-change world, which is now registered in
   baselines.py with its paragraph citation; 1.C is a post-policy LEVEL
   and keys current law. The assignment lives in the registry, is emitted
   into the metadata artifact, and an unregistered chart baseline raises —
   so a future HMT row cannot silently default to current_law.

5. The anchoring gate actually runs in CI. It was importorskip-gated on
   pypdf and pyyaml while CI installed only pytest, so the lane's
   strongest honesty guarantee never executed there. CI now installs both
   and the skip path is gone. The adapter also writes the trailing
   newline the committed metadata has, so wiring it into the no-drift
   build no longer dirties the tree.

6. The held-out relationship is registered with its evidence, before any
   numeric row can land rather than in an emergency when the first one
   does; and the diagnosis guidance now names the citable-known-issue
   gate (class + rationale + action_link) instead of saying an engine
   defect "may be inferred".

Suite 264 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

All six addressed in d376a7f (rebased onto post-#74 main).

  1. Blocking — the lane. scorecard_db/ingest_hmt_distributional.py is the missing step: it emits no external_scores rows (deliberately, and it raises if the metadata ever claims otherwise), writes the lane row with the chart-cell accounting as its detail, and mirrors it into the feed under UK. Registered in build_db after uk_deductions.

  2. The omission is tallied. You were right that "0 emitted" had no denominator — the registry listed figures and series but not the groups. The eleven income groups are now registry data and the adapter enumerates and checks every cell: 132 source marks = 0 emitted + 132 chart_not_digitized, by figure / income group / series, with the arithmetic raising if it does not close. The 30 policy components still reconcile 3+9+18 = 16+9+5.

  3. Decile identity. The ten deciles plus the all-households bar are a closed uk_aliases vocabulary with housing_costs=bhc and equivalisation=modified_oecd pinned beside them, and HMT deciles are recorded as DISTINCT from UKMOD quintiles so nothing can alias them later.

  4. Baseline identity. Per figure now: 1.A/1.B are changes against the no-policy-change world, registered in baselines.py with its paragraph citation; 1.C is a post-policy LEVEL and keys current law. The assignment lives in the registry, is emitted into the metadata artifact, and an unregistered chart baseline raises.

  5. The CI gate. Correct — it never ran. CI now installs pypdf and pyyaml and the importorskip path is gone. The adapter also writes the trailing newline, so wiring it into the no-drift build no longer dirties the tree.

  6. Relationship + diagnosis prose. The held-out entry is registered with its evidence before any numeric row can land, and the guidance now names the citable-known-issue gate (class + rationale + action_link) instead of saying an engine defect "may be inferred".

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

data/lanes.json and its app/public mirror are DERIVED feeds and both
sides appended a lane, so the textual conflict is not a real one —
regenerated from a build rather than hand-merged. 33 lanes now,
including the BE country-report lane from #82 alongside this branch's
HMT distributional lane.

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

3 participants