UK spine: run age_tail first so age-conditioned imputations derive from the disaggregated surface; retire the NHS 85+ fold-in (#785) - #842
Open
juaristi22 wants to merge 3 commits into
Open
Conversation
juaristi22
force-pushed
the
uk-uc-reporter-redraw-832
branch
from
September 2, 2026 11:58
f2271ee to
cf16f45
Compare
juaristi22
force-pushed
the
uk-age-tail-reorder-785
branch
from
September 2, 2026 12:58
4615113 to
4c4c417
Compare
juaristi22
force-pushed
the
uk-uc-reporter-redraw-832
branch
from
September 2, 2026 15:07
cf16f45 to
7f3a9cd
Compare
…ns see the disaggregated surface (#785) Move the UK age-tail disaggregation from the spine's tail to index 1 and key its deterministic draw on the base-row person_id (person_source_id does not exist until the SPI support channel stacks clones at stage 17; base rows copy person_id into it verbatim and every clone is a whole-table copy, so the final age column is expected byte-identical). A runtime guard refuses a frame that already carries clone provenance. The E6/E8 identity receipts invert to the stage_time_disaggregated basis, the acceptance binder becomes a pure function of the accepted roster applying the pending reviewed transformations in order, the age-tail gate moves to the assembled phase with its digests and mirrors, and the receipts scaffolding (experiments checklist, committed payload expectation on the donor-excluded slice, fail-closed classifier) lands for the licensed L2 measurement. On the graph-executed spine the reorder is a declaration change: age_tail consumes only age and gender (no clone provenance), and because the root version owns age its boundary opens by the same-version owner rule, so every later age reader binds to the disaggregated cell. That early boundary means root cells are inherited rather than owned downstream, so frs_education_grant_split's rewrite of education_grants now needs the reader-isolation entry the UC coherence stage already used. Two consequences of age arriving as float64 early are closed at their source: frs_education casts its age_started_or_accepted_current_education_or_training output to int64 explicitly, and the H2 parity fixture tool snapshots the captured root instead of aliasing the person table age_tail rewrites in place; the H2 fixture is re-pinned on the reordered graph. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…p bands (#785) With age_tail ahead of etb_services, persons aged 85-97 exist at NHS allocation time, so the donor table's 85-89, 90-94 and 95+ cells are all occupied and the fold into one 85-120 cell has no reason left. Per-service realized totals are invariant to the fold when every cell is occupied; only the within-85+ per-person rate structure changes, which the licensed fold-delta receipt measures on the same frame. The stage's manifest declaration retires the fold with it, moving the UK spec digest and the coverage manifest. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
juaristi22
force-pushed
the
uk-age-tail-reorder-785
branch
from
September 2, 2026 17:19
4c4c417 to
75503c6
Compare
…h age_tail (#785, #845) The graph's CREATE node declared person.age as float64 while the legacy frs_spine root produces int64, so every graph stage saw a float age from node 0 and the legacy plan an integer until age_tail rewrote it. The interface forbids a rewrite from changing its base's dtype, so the honest declaration is int64 end to end: the root cell is declared int64, age_tail's rewrite is declared int64, and the transform preserves an integer input dtype (its bands and within-band offsets are integral). frs_education's explicit int64 cast stays as the stage's own contract. The exported age column changes dtype (float64 to int64) and nothing else: the payload expectation declares that as a dtype-only change through a new per-group surfaces allowlist in the classifier, so a value difference on age stays unexpected. The H2 fixture is re-pinned; the receipts checklist names the graph-executed post-#835 build as the before artifact, since the legacy-executed spine-m carries executor normalizations that are not this change's. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
juaristi22
force-pushed
the
uk-age-tail-reorder-785
branch
from
September 2, 2026 17:21
75503c6 to
9a81d92
Compare
juaristi22
marked this pull request as ready for review
September 2, 2026 17:23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the PR half (P1–P6 code) of the approved #785 plan (
repos/uk-785-age-reorder-plan.md, rev 2 of 2026-09-02, María's rulings folded in), and folds in #845. Originally stacked on #835; now based onmainafter #835 merged. Codex-implemented under the approved-plan protocol, reviewed section-by-section and re-verified independently. Three commits: the reorder with its instruments and derived surfaces; the NHS un-fold; the int64agedeclaration (#845).What this does
age_tailmoves from the spine's tail (index 26) to index 1, immediately afterfrs_spine, so every age-conditioned stage — ETB services' NHS allocation, both SPI QRF stages, salary sacrifice, the CGT carrier selection, and UK spine: benefit-unit-grain SPI UC reporter draw with pre-take-up screen (#832); exact capital sentinel (#833) #835's UC reporter redraw — sees the disaggregated 80–97 surface instead of the FRS top-code at 80. The draw key changes fromperson_source_id(which only exists oncespi_support_channelstacks clones at stage 17) to the base-rowperson_id; because base rows copyperson_idverbatim intoperson_source_idand every clone is a whole-table copy of a base row, the final age column is expected byte-identical to today's — so the ONS 85+ age-band targets cannot move and every downstream difference is a pure conditioning effect. A runtime guard refuses a frame that already carries clone provenance, so a future reorder back fails loudly.stage_time_disaggregatedbasis (themin(age, 80)clamps go; the two regression tests that proved the clamps necessary now prove the opposite direction). The committed spine-i acceptance receipt keeps its historicalstage_time_top_codedfields until the licensed re-mint.test_uk_spine_acceptance_receipt.py) becomes a pure function of the accepted roster applying the pending reviewed transformations in order — the two UK spine: condition SPI UC receipt on benefit-unit composition and add a UC-specific child count #832 insertions, then the Derive NHS allocation (and audit age-conditioned imputations) from the disaggregated age surface #785 move — each with an inner assert pinning the receipt state it expects, so each licensed re-mint must delete its own step and strict equality returns when the list is empty.uk_stage_age_tail_targetsmovestransferred→assembled(the stage now precedesfrs_brma); itsachieved_weightedreceipt now measures raw FRS-grossed 80+ mass before any support channel exists (stated in the gate notes and changelog).etb_servicesno longer folds the donor table's 85–89 / 90–94 / 95+ bands into one cell — the fold existed only because top-coded recipients could never populate them. Thetop_band_fold_inmanifest declaration is retired with it. Per-service realized totals are invariant to the fold when all cells are occupied; only the within-85+ rate structure changes (the L2 fold-delta receipt proves both on the same frame, no extra build).experiments/785-age-tail-reorder-receipts.md(every number "to be measured at L2"), the committed blast-radius contracttools/uk_age_tail_reorder_payload_expectation.json, and the fail-closedtools/classify_uk_payload_diff.pyovercompare_uk_h5_payload.py's report. The contract is defined on the donor-excluded, row-aligned slice: the comparator compares column values only when row counts agree, and the reselected CGT band-donor set changes person/benunit counts, so structural surfaces can never be declared expected and any unequal table fails closed.Why the premise changed (recorded so it gets signed, not discovered)
The issue's framing was "a few variables, a minor change". Exploration corrected it: ~3.59m weighted persons sit at exactly 80 and ~90% of them change value; the empty 85+ NHS cell today allocates ~£21.4bn of donor cost to nobody (realized total ≈ £180.6bn vs the £202bn anchor — inside the ±15%
uk_aggregate_adminband, and the move lands it essentially on-anchor); SPI stage 2 uses age as a training feature on FRS base rows, so the whole SPI-channel fill surface churns (and stage 19/20 follow through their inputs); the CGT 270-donor selection reshuffles (the 255/270 precedent). WAS, LCFS, VAT, take-up, draws, BRMA, council tax, education, legacy proxies, student loans and every base-channel row of stages 19–20 are expected byte-equal — that expectation is the fail-closed half of the committed contract (R1).Derived surfaces
sources.yamlauthority (block moved,draw_key: person_id, notes rewritten as a quoted scalar — fixing the pre-existing#623YAML-comment truncation of the parsed note) + lockstepsource_stages.json; UK spec sha re-pin; coverage manifest regenerated; the three gate-battery digests plus the twospinecertification-part digests inmicrocosm-data/contract.pyand theirtest_contract.pymirrors; driver_STAGE_NAMES; roster/order tests (age_tailisfrs_spine + 1, no later stage may output or rewriteage). Not touched, by design:spine_swap_signed_differences.json(re-cuts need licensed numbers),spine_candidate_acceptance.json,efrs_parity_known_gaps.json, WAS anduc_reporter_redrawmodels, US surfaces.On the graph-executed spine (rebased over #835's review passes, then onto
main)#835 rebased onto main and gained the
microcosm-graphpackage: the UK driver, binder, and roster tests now derive stage order fromcompile_graph(uk_spine_graph(spec)), a cell-ownership graph, and the spine executes through it. On that base the reorder is a declaration change rather than a list edit:uk_runtime/graph.py:age_tailconsumes onlyageandgender(no clone provenance); because the root version ownsage, its boundary opens by the same-version owner rule and every later age reader — every finite-consumes stage requestsageas a context carrier — binds to the disaggregated cell. The compiled order equals the manifest order withage_tailsecond, verified by the existing declaration-order-invariance test.frs_education_grant_split's rewrite of the root celleducation_grantsno longer opens a boundary and the open-surfacefrs_legacy_proxiesreader formed a cycle; it joins the reader-isolation set exactly asuc_capital_coherencealready did.agearriving as float64 early are closed at their source:frs_educationcastsage_started_or_accepted_current_education_or_trainingto int64 explicitly (the stage owns its output dtype; the export contract is unchanged), andtools/graph_uk_spine_fixture.pysnapshots the captured root instead of aliasing the person tableage_tailrewrites in place (at the tail, earlier stages had long since replaced that table). The charter-H2 parity fixture is re-pinned on the reordered graph; its captured root stays the top-coded surface.spinecertification part, UK bundle sha, coverage manifest) are cut onmain— plan A8 is moot.#845 folded in (third commit)
The CREATE node declared
person.agefloat64 while the FRS root produces int64, so graph stages saw a float age from node 0 and the legacy plan an integer untilage_tailrewrote it. The interface forbids a rewrite from changing its base's dtype (decl.py: "the base's declared dtype must equal dtype"), so #845's literal proposal — int64 root, float64 rewrite — is not expressible; the honest declaration is int64 end to end: the root cell andage_tail's rewrite are declared int64, and the transform preserves an integer input dtype (its bands and within-band offsets are integral; a float input, as in synthetic test frames, stays float).frs_education's explicit int64 cast stays as the stage's own contract. The only payload consequence is the exportedagedtype (float64 → int64), declared as a dtype-only expected change through a new per-groupsurfacesallowlist in the classifier — a value difference onageremains unexpected, so Receipt 1's byte-identity claim keeps its fail-closed instrument. H2 fixture re-pinned (schema.json+uk_spine.json); policyengine-uk's loader casts to its float32ageas before.Deviations from the plan
Codex's, all accepted on review: #830 absent from the supplied base (see A8); P4's funnel needed P5's binder to pass (order preserved); the two
spinecertification-part digests moved alongside the three named pins; coverage-manifest occurrence count is the generator's; sandbox needed a local uv cache anduv run pythonfor the CI-group verifier. Reviewer's post-Codex tightenings: the donor-excluded-slice contract in the classifier/expectation (above); thetop_band_fold_indeclaration retired with the fold; the E8 proof test asserts the lifted person wins outright rather than comparing a function to itself.Verification
Scoped UK + shared-machinery funnel (
test_uk_*, country_spec, spec_engine, source_runtime, stochastic_assignment, gate_battery, gates, release_input_coverage, datatest_contract) plus the wholemicrocosm-graphsuite (charter-H2 UK spine parity included),ruff check ., andtools/ci_test_groups.py --verify— run independently after review, and again on the rebased tree. Expected pre-existing failures only: the two licensed-cache regeneration tests (test_uk_parity_referencereference arm,test_uk_release_input_coverage_manifestcandidate arm — committed 2.89.0 evidence vs the 2.92.1 floor, PR #749's lane).Out of scope / next (licensed lane, after #835's I5/I6 and the 2026-09-30 deferral disposition)
L1 before-side on the graph-executed post-#835 build of
main(the legacy-executed spine-m would show the executor's own normalizations — StringDtype text columns, CREATE-time casts — as differences that are not this change's); L2 spine-n build onuk-publication-stack-785with Receipt 1 (byte-identical age values; dtype now int64), Receipt 2 (payload classification on the donor-excluded slices), the fold-delta receipt, the identity ladder e4–e8 on the new basis and the gate battery; L3 parity re-cut (donor-selection-rng-entity-counts,spi-channel-qrf-incidence,salary-sacrifice-conversion-depth,uc-reporter-benefit-unit-redraw-incidencere-derived on this candidate — its refresh-lift sibling was retired by #835's third review pass — and a new NHS weighted-totals entry) and the acceptance re-mint; L4 delta-eval vs v18. This PR is held until those numbers exist.Part of #785 (stays open for L1–L4). Closes #845. Parent lanes: #145 (WS-E), #796, epic #665.
🤖 Generated with Claude Code