Skip to content

HMRC ready reckoner (mode 2): registry, offline compute, full-population triage - #66

Open
vahid-ahmadi wants to merge 5 commits into
mainfrom
uk/hmrc-reckoner-mode2
Open

HMRC ready reckoner (mode 2): registry, offline compute, full-population triage#66
vahid-ahmadi wants to merge 5 commits into
mainfrom
uk/hmrc-reckoner-mode2

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Contributor

What's here

  • data/uk/hmrc_reckoner_reforms.json — measure → reform registry covering the entire external reckoner population (75 (program, verbatim label) families from the committed data/externals/hmrc-personal-tax.json; a test pins registry == external vocabulary, so coverage gaps are labelled, never silent). Triage: 24 expressible / 22 partial / 29 not_expressible, each with a note. Registry stores deltas (absolute or relative), never absolute schedules — the reckoner's own construction is baseline-plus-illustrative-change, and the pipeline reads the live baseline value per year.
  • Measure keys are program-prefixed (reckoner_202506__vat__change_standard_rate_by_1_percentage_point): HMRC prints the identical label under both VAT and IPT — the same defect class the UK externals → DB ingest path (16,924 claims, five sources) #48 review caught — and the label-only key collided exactly there; a validator defect list plus a duplicate-key test pin it.
  • pipeline/compute_uk_reckoner.py — offline compute under the UK counterpart compute pipeline (managed sims, pe_gap-degrading registry) #51/OBR policy costings (mode 2): registry, offline certified compute, staging, descriptive comparison #56 conventions: managed sims on the certified bundle, one baseline per year reused across measures, per-run artifacts, staged JSONL under the campaign ingest contract (external_claim_match with the verbatim label as subgroup), signed PE delta plus the adapter's magnitude_with_direction_in_label orientation annotations (the pipeline never flips the external value). --dry-run validates the registry and, when policyengine-uk is importable, resolves every parameter path — a delta is never applied to a guessed parameter; any missing path demotes the whole measure to pe_gap.
  • tests/test_reckoner_registry.py — 12 tests: whole-population join, slug rule, expressible⇒delta, rate deltas are fractions not percentage points, directioned labels carry matching delta signs, delta arithmetic, staged-row contract, engine-free import and dry run. Suite: 175 passed / 4 skipped; ruff clean.

Honest caveats

  • Never executed against a managed environment — no certified populace-uk bundle on the authoring machine; the engine boundary is unverified and the docstring says so. The dry-run path gate exists so the first real run cannot silently compute against a wrong parameter.
  • paths_verified: true marks only paths resolved by the OBR registry (obr-costings-mode2) against pe-uk 2.89.2 (PA amount, uk/dividends band thresholds); NICs, child benefit and rate-field paths are candidates flagged false and must resolve in --dry-run first. policyengine-uk was not installed at authoring.
  • Registry is JSON, not YAML, deliberately: CI runs uv run --with pytest with no PyYAML, which is why OBR policy costings (mode 2): registry, offline certified compute, staging, descriptive comparison #56's pytest job currently fails on import yaml; this lane stays stdlib-parseable.

Builds #60.

🤖 Generated with Claude Code

… compute skeleton, full-population triage

- data/uk/hmrc_reckoner_reforms.json: all 75 (program, label) reckoner
  families triaged (24 expressible / 22 partial / 29 not_expressible),
  deltas not absolute schedules, program-prefixed measure keys (VAT/IPT
  share a verbatim label), paths_verified flag distinguishing
  OBR-registry-verified paths from candidates.
- pipeline/compute_uk_reckoner.py: engine-free-importable compute with
  --dry-run registry validation and a path-resolution gate; a delta is
  never applied to a guessed parameter (whole-measure pe_gap demotion).
- tests/test_reckoner_registry.py: 12 tests (whole-population join,
  slug rule, signed-direction consistency, fraction-not-pp rate deltas,
  delta arithmetic, staged-row contract, engine-free dry run).

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

DTrim99 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Review — HMRC ready reckoner (mode 2) registry

Verified the core claims directly against the registry and data/externals/hmrc-personal-tax.json, and they all hold:

  • Coverage: registry (program, label) set == external set exactly; test_every_external_reckoner_label_is_triaged pins reg == ext and fails loudly on drift. 24 + 22 + 29 = 75 ✓
  • VAT/IPT collision resolved: program-prefixed keys are distinct (…__vat__change_standard_rate… vs …__insurance_premium_tax__change_standard_rate…); 0 duplicate measure_keys across all 75 ✓
  • Delta-not-schedule: every delta is an increment (max abs 520 for UPL; rate legs 0.01); no absolute schedule or hardcoded baseline anywhere. Sign discipline holds; child-benefit spending sign flip handled at compute:1439
  • Triage honesty: all 22 partial + 29 not_expressible carry a note; 0 expressible rows with a null delta (no over-claim) ✓

Should address (robustness nits, none blocking)

  • test_rate_deltas_are_fractions (test_reckoner_registry.py:1533) relies on and binding tighter than or (path.endswith(".rate") or ".rates." in path and path.endswith((...))). It classifies today's paths correctly, but a future .rates.*.threshold leg ending in "main" would be miscaught — add parentheses.
  • head_var dict (compute_uk_reckoner.py:1426) covers only income_tax / NI / child_benefit. All 24 expressible measures fall in those 3 programs today, but a future expressible measure elsewhere crashes with a bare KeyError rather than a pe_gap demotion — .get() + gap fallback would be safer.
  • Baseline instant vs full-year window: baseline is read at {year}-06-01 (compute:1319/1433) while the reform is applied over {year}-01-01..{year}-12-31. Harmless for flat-year params, but relative deltas scale the mid-year read — worth a note.

Note

18 of 24 "expressible" measures have paths_verified: false — the --dry-run whole-measure resolution gate is the real backstop and is well-designed, but most expressible paths are still unverified guesses.

Reviewed with Claude Code assistance.

…ine-instant note

- test_rate_deltas_are_fractions: parenthesized so a future
  .rates.*.threshold path ending in "main" is not miscaught by
  and-binds-tighter-than-or.
- head_var lookup uses .get() with a pe_gap demotion instead of a bare
  KeyError for a future expressible measure outside the three programs.
- reform_values documents the mid-year baseline read vs full-year reform
  window: identical for flat-year params, revisit for step-mid-year
  paths.

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

Copy link
Copy Markdown
Contributor Author

All three robustness nits fixed in the latest commit:

  • Operator precedence — parenthesized: path.endswith(".rate") or (".rates." in path and path.endswith((...))); a future .rates.*.threshold leg ending in "main" is no longer miscaught.
  • head_var bare KeyError — now .get() with a pe_gap demotion and a comment: a future expressible measure outside income_tax/NI/child_benefit stages a gap row instead of crashing the run.
  • Baseline instant vs window — documented at the read site: identical for flat-year parameters (every current registry path), flagged for revisit if a step-mid-year path enters the registry.

On the note: agreed the --dry-run resolution gate is the real backstop for the 18 paths_verified: false measures — first action on a managed machine is the dry run, and any non-resolving path demotes its measure before a sim is attempted.

Suite: 175 passed / 4 skipped; ruff clean.

@DTrim99

DTrim99 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Re-review — all addressed ✅

  • Rate-delta test precedence — the and clause is now parenthesised (.rate or (".rates." in path and endswith(...))).
  • head_var KeyError — now .get(program) with an is Nonepe_gap staged-row + continue, so a future expressible measure outside the three programs demotes instead of crashing.
  • Baseline mid-year read — documented (identical for flat-year params; a relative delta on a mid-year-stepping param would scale only the 06-01 value — "revisit if such a path enters").

Coverage 75, VAT/IPT split, and deltas-not-schedules all still hold. Good to go.

@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 — all findings addressed (test precedence parens, head_var .get()+gap fallback, mid-year read documented). Approving.

@MaxGhenis

Copy link
Copy Markdown
Contributor

Gate round 1 — the declarative triage is right; six findings, five blocking

The closed 24/50 declaration over the 74 distinct labels (VAT/IPT duplicate deliberate) is correct and matches the merged registry. The findings are in what happens at runtime:

  1. Blocking — no safe disposition of the existing 14. As shipped the PR is inert (nothing in build_db), but wired in, all 14 FY2026-27 claim keys overlap the campaign attaches on main: 11 would shadow them via latest-result selection and 3 — savings allowance, dividend allowance, savings starting-rate limit — would regress to pe_gap despite exact executed paths already on main. Superseding staged campaign results is the exact Two-country catalog: UK seven-source harvest, campaign PE results, baselines registry (#13), descriptive register (#9) #32-class doctrine violation; this needs an explicit retained-vs-new reconciliation manifest with tests, preserving the 14.

  2. Blocking — the compute is neither committed nor reproducible. The module notes it has never run in the managed environment; there are no artifacts, staged JSONL, manifest, hashes, or builder step, and the dry-run passed under ambient PolicyEngine-UK 2.75.3 while the registry prose references 2.89.2. Unpinned defaults on both simulations; only nullable baseline-side provenance is stamped.

  3. Blocking — baseline honesty not encoded. The run executes current law while claims key hmrc_indexed_baseline_spring_2025, yet neither rows nor artifacts stamp baseline_key=current_law, and successes emit invalid status ok instead of constructed — the guard can't verify the executed world. (The merged campaign rows model this correctly: result current_law vs claim indexed, effective status constructed.)

  4. Blocking — runtime contradicts the declared triage. Three acknowledged partial, missing-leg reforms carry deltas and pass the compute gate → 27 ok / 48 gap per year, not the declared 24/51. And both employer-NIC measures sum through national_insurance where the committed campaign artifacts establish ni_employer as the relevant head.

  5. High — cited-gap and magnitude semantics missing. All non-expressible rows lack action links (the App design: public scoreboard + mission control, four views #9 gate), staging drops even the free-text note, and annotations omit the established abs(PE)/abs(HMRC) ratio and use a mapping where the ingest contract is string[].

  6. Blocking — the staging shape can't ingest. Records use hmrc-personal-tax/revenue_effect/string-FY where DB claims are uk_hmrc/revenue_change/FY-end integers with exact conditions; computed_at and data_bundle are absent; and the campaign ingester rejects family reckoner. The registry validator is also year-blind, so missing or duplicated FY claims pass.

The path of least rework: treat the existing 14 as the anchor set, extend with the producer/claim_id-direct pattern (#72) for the new measures and years, and stage through the campaign contract rather than a parallel shape.

🤖 Generated with Claude Code

r and others added 2 commits August 21, 2026 17:54
…tract

1. The merged campaign's 14 resolved reckoner results are the ANCHOR
   SET. Every (measure, year) now resolves to exactly one ingested claim
   and is reconciled against them before anything is emitted: an anchored
   claim is retained and this lane emits nothing for it, and the
   reconciliation REFUSES to run if any of the 14 is unreachable. Without
   that, wiring the lane in would have shadowed 11 via latest-result
   selection and regressed 3 — savings allowance, dividend allowance,
   savings starting-rate limit — to pe_gap despite exact executed paths
   on main. The manifest records retained vs new and is testable.

2. Reproducibility. The registry pins policyengine-uk 2.89.2 and the run
   refuses any other version, on the import AND on the managed sim's own
   reported version — the earlier dry-run passed under whatever ambient
   engine happened to be installed (2.75.3) while the prose said 2.89.2,
   so resolving every path proved nothing about the run's engine. The run
   writes per-measure artifacts plus a manifest carrying a sha256 over
   every artifact and the staged file. The run itself is still #77; what
   is fixed here is that it can no longer be reproducible-in-prose.

3. Baseline honesty is encoded. The run executes CURRENT LAW while the
   claim keys hmrc_indexed_baseline_spring_2025; both keys are stamped on
   the staged row and on the artifact so the guard can verify the two
   worlds differ, and successes emit `constructed` rather than `ok` —
   which is not a valid ComparisonStatus at all. Modelled on the merged
   campaign rows.

4. The runtime no longer contradicts the triage. The three acknowledged
   partial, missing-leg reforms carried deltas and passed the compute
   gate, giving 27 ok / 48 gap against a declared 24 / 51; their deltas
   are now null with the reason recorded, the validator rejects a partial
   that carries one, and the declared triage is checked against the
   number of measures that actually carry a delta. Both employer-NIC
   measures read `ni_employer` — the head the committed campaign
   artifacts establish — via a registry field rather than a dict inside
   the runtime, and every delta-carrying measure names its head.

5. Gap and magnitude semantics. All 29 not_expressible measures carry an
   action_link (gate #9), the registry's free-text note travels onto the
   staged row instead of being dropped, and annotations are a LIST OF
   STRINGS — the ingest contract — carrying the established
   abs(PE)/abs(HMRC) ratio, the sign convention, the CY/FY proxy and the
   basis mismatch.

6. The staging shape can ingest. Rows stage claim_id-direct through the
   campaign contract, which needs no family normalizer, instead of naming
   hmrc-personal-tax / revenue_effect / a string FY where DB claims are
   uk_hmrc / revenue_change / FY-END integers; computed_at and
   data_bundle are present. The validator is year-aware: a missing or
   duplicated FY claim now fails instead of passing.

Suite 272 passed, build deterministic, 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 63184b6, taking the path of least rework you suggested — the existing 14 as the anchor set, claim_id-direct through the campaign contract.

  1. The existing 14. They are now the anchor set. Every (measure, year) resolves to exactly one ingested claim and is reconciled against them before anything is emitted: an anchored claim is retained and this lane emits nothing for it, and the reconciliation REFUSES to run if any of the 14 is unreachable. You were right about the three — savings allowance, dividend allowance, savings starting-rate limit — that would have regressed to pe_gap rather than merely being shadowed. The manifest records retained vs new and is testable.

  2. Reproducibility. The registry pins policyengine-uk 2.89.2 and the run refuses any other version, on the import AND on the managed sim's own reported version — the dry-run passing under the ambient 2.75.3 proved nothing about the run's engine, agreed. The run writes per-measure artifacts plus a manifest carrying a sha256 over every artifact and the staged file. The run itself is still UK compute campaign, run 2: reckoner full-population compute (24 expressible measures × 3 forecast years) #77; what is fixed is that it can no longer be reproducible-in-prose.

  3. Baseline honesty. Both keys are stamped, on the staged row and the artifact, so the guard can verify the two worlds differ, and successes emit constructedok is not a valid ComparisonStatus at all. Modelled on the merged campaign rows as you pointed out.

  4. Triage. The three partial, missing-leg reforms no longer carry deltas — a partial multi-leg reform is a different reform from the one the label states — so the runtime is 24/51 as declared, the validator rejects a partial that carries one, and the declared triage is checked against the number of measures that actually carry a delta. Both employer-NIC measures read ni_employer via a registry field rather than a dict inside the runtime.

  5. Gap and magnitude semantics. All 29 not_expressible measures carry an action_link, the registry's free-text note travels onto the staged row instead of being dropped, and annotations are a list of strings carrying the established abs(PE)/abs(HMRC) ratio, the sign convention, the CY/FY proxy and the basis mismatch.

  6. Staging shape. claim_id-direct through the campaign contract, which needs no family normalizer; computed_at and data_bundle present. The validator is year-aware now: a missing or duplicated FY claim fails instead of passing.

Suite 272 passed, build deterministic, ruff format clean.

Verified against the 2.89.2 wheel: policyengine_uk exposes no
__version__ attribute at all, so the pin check I added read None and
would have refused to run on the CORRECT engine — the gate was
unusable rather than strict. importlib.metadata.version() is the
version of record; __version__ stays as a fallback in case a future
release adds one.

With that fixed the dry-run now RUNS against a real pinned 2.89.2:
registry valid, and every one of the 24 delta-carrying measures'
parameter paths resolves against the live tree. The pin gate is also
confirmed live — bumping the registry pin refuses with the installed
version named.

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

Follow-up on the engine pin, because I found a defect in my own fix and it is worth being explicit about.

I installed a real policyengine-uk 2.89.2 rather than reasoning about it, and the pin check I added was broken in the direction that matters least visibly: policyengine_uk exposes no __version__ attribute at all, so getattr(policyengine_uk, "__version__", None) read None, compared it to "2.89.2", and would have refused to run on the correct engine. A gate that always fires is not strict, it is unusable — and it would have looked like the pin working. Fixed in a2cde64: the version comes from importlib.metadata.version("policyengine-uk"), with __version__ kept as a fallback if a future release adds one.

With that fixed the dry-run actually runs against the pinned engine, which is the thing this PR could previously only claim:

[    0.0s] registry valid: 75 measures
[    1.0s] dry run complete: no sims, no output
  • all 24 delta-carrying measures' parameter paths resolve against the live tree — no unresolved paths;
  • the pin gate is confirmed live: bumping the registry pin to a fake version refuses with the installed version named;
  • reform values are plausible against real parameters (employer NI rate 0.15, personal allowance £12,570 at 2026) — no sign flips, no runaway relative deltas, nothing going negative;
  • both employer measures read ni_employer.

So finding 2's "reproducible" claim is now evidenced up to the engine boundary. The managed run itself is still #77.

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