Fix the two production-path breakages the node graph left in the UK spine driver (#836) - #844
Conversation
…able on real data The graph executor's mass ledger is weighted person mass per stratum (Frame.stratum_mass: household weights broadcast through membership), so a node declared mass='conserve' can only pass when the expansion keeps household composition fixed. spi_support_channel does not: it stacks synthetic households whose person counts differ from the FRS households whose mass they take over, conserving household mass exactly (the stage's allocate_zero_weight_prior_mass declares conservation: exact_total) while person mass moves with the composition change. On the FRS 2024-25 spine the executor measured 68,251,110 -> 65,436,869.6 persons and rejected the node, so no full licensed UK spine build could complete after the node graph landed (#836); the hermetic H2 fixture never exercised the class. The node now declares its mass change: the UK expand kernel states the person-mass ledger the executor verifies and asserts the invariant that is actually the stage's contract, household-mass conservation at the ledger's own tolerance. The CGT clone nodes keep conserve (a clone is its source at half weight) and the band-donor stack keeps free. The pinned uk_spine.json is regenerated; a regression test reproduces the class on a two-household fixture (a mass shift across household sizes at conserved household mass is rejected under conserve and accepted under declared with the ledger), and the node policies are pinned. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… stage records With the SPI node declared, the first full licensed run through the graph completed every stage and then died in the driver's record projection: "graph stage 'frs_spine' exposes 0 artifacts for declared output 'person_id'". Entity ids and memberships are executor-carried context, not owned cells, so the root node exposes no artifact for them although frs_spine declares them among its 86 outputs. The projection now reads those columns' share from the final population (the legacy plan recorded 1.0 for them on every vintage) and still refuses any other output without exactly one artifact. A requires_uk regression test runs the projection over the hermetic H2 fixture and asserts a record with a share for every declared output of every stage, so the class fails in CI's engine lane rather than at the end of a licensed build. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
MaxGhenis
left a comment
There was a problem hiding this comment.
Reviewed as the node-graph owner, with a sol pass over the PR head (fix-spi-support-channel-mass-836). No must-fix defect. The diagnosis matches the executor: population.py measures weighted person mass per stratum through Frame.stratum_mass(), conserve asserts per-stratum equality, and declared requires receipt["mass"] and validates it against the executor's own measurement, so the receipt cannot misstate the change. declared is the right policy here; free would have dropped the check. The new regression tests fail on main without the graph.py/graph_kernels.py hunks and pass with them.
Verified locally on the PR head: test_uk_graph.py plus the graph shard's H parity file (H2 with the engine), ruff, tools/ci_test_groups.py --verify. The full suite is CI's to certify.
Two follow-ups, neither blocking:
_graph_stage_records: reading the structural shares from the terminal frame is numerically equivalent today (every root id and membership has share 1.0 and no later stage declares them), but the legacy records were measured immediately after each stage, so the root population is the faithful source. Worth switching when the driver is next touched.- The H2 fixture still cannot show this failure class: its SPI draw selects all 135 households and household size is aligned with region, so person mass is 282.24 before and after. The smallest deterministic change the sol pass found: draw 131 households and assign household 52 to region 11; at seed 42 person mass moves 282.24 → 281.48 while household mass stays exactly 226.8. A lane is making that change on top of this branch (it lands as its own PR after this one).
The charter's D2 row now says this in so many words (weighted person mass per stratum; composition-changing expansions declare with a receipt stating the invariant they do hold), in the amendments branch that follows.
🤖 Generated with Claude Code
|
Review pass at The diagnosis is right and I could confirm each link of it from the code: 1. The invariant that replaces
|
…h the SPI support channel The hermetic fixture's SPI draw used to select every household with household size aligned to region, so the support-channel expansion left weighted person mass unchanged (282.24 -> 282.24) and CI could not see the failure class #844 fixed on licensed data. Child presence now follows household_id % 5, independent of the % 4 region cadence, so the region-stratified prior allocation moves mass across household sizes: person mass 272.7 -> 272.43 while household mass stays exactly 226.8. A regression test runs the real root and support-channel transforms on the committed fixture and asserts both facts, so a regeneration cannot silently lose the property. With the node declared conserve, the driver path now fails on the fixture (captured in the lane report). Built by a sol lane (20260902-091401-h2-fixture-composition) and reproduced here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Problem
Every full licensed UK spine build on main has failed since #836 landed the node graph as the UK driver's only path. Reproduced today at
90293e0awith the production invocation (FRS 2024-25 tabs, WAS R8, LCFS, ETB, SPI donor, HMRC ODS):Diagnosis
mass='conserve'(uk_runtime/graph.py,_STRUCTURAL_MASS).Frame.stratum_mass()(microcosm-frame/.../bundle.py), which is weighted person mass per stratum: household weights broadcast through membership, compared per stratum at_MASS_RTOL = 1e-9(microcosm/graph/population.py::_assert_mass_mapping).allocate_zero_weight_prior_massdeclaresconservation: exact_totaland moves the declared prior-mass share (0.5) onto the stacked synthetic households. Those households have fewer persons per household than the FRS households whose mass they take over, so person mass necessarily falls. The 4.1% drop the executor measured (68.25m → 65.44m) matches the composition difference visible on the last good spine (spine-l: 29.2m households carry 66.1m persons; survey rows alone 34.6m at half mass).conserveis therefore unsatisfiable for this node on any real vintage. CGT cloning passes it because a clone is its source household at half weight (composition fixed); the band-donor stack is alreadyfree.packages/microcosm-graph/tests/fixtures/parity/uk_spine/) is synthetic and small and happens to preserve person mass through the stage; Node graph: acceptance charter and frozen interfaces for microcosm-graph #836's history shows no full licensed run.Consequence until fixed: no spine re-mint, no #835 I5, no #685 twin builds, no recertification.
Fix
_STRUCTURAL_MASS["spi_support_channel"]→declared, with the rationale written next to the table.UKExpandStageKernelemitsreceipt["mass"]for adeclarednode: the person-mass ledger (before/after, per stratum) the executor verifies against its own measurement, plus an assertion of the invariant that is actually the stage's contract — the expand weight entity's total mass is unchanged at the ledger's own tolerance (_declared_mass_receipt). A composition-changing expansion that also moved household mass would still fail, loudly and by name.uk_spine.jsonis regenerated (one node'smassfield).test_uk_graph.py: a two-household fixture (one person in household 10, two in household 20) whose expansion clones the one-person household and shifts mass onto it at conserved household mass — rejected underconservewithchanged stratum(the exact class), accepted underdeclaredwith the kernel ledger; and the three structural nodes' policies pinned.No stage transform, manifest, gate, digest or spine byte changes: the declaration and the kernel receipt move, the population does not.
Second breakage found by the same reproduction
With the node declared, the licensed run completed all 26 stages and then died in the driver's record projection:
_graph_stage_records(tools/build_uk_frs_spine.py) demands exactly one artifact per declared output. Entity ids and memberships are executor-carried context rather than owned cells (_STRUCTURAL_COLUMNSinuk_runtime/graph.py), so the CREATE node exposes none for the five id/membership columnsfrs_spinedeclares. The projection now reads those from the final population (the legacy plan recorded 1.0 for them) and still refuses any other output without exactly one artifact. Second commit; arequires_uktest runs the projection over the H2 fixture for every stage.Verification
packages/microcosm-build/tests/test_uk_graph.py,packages/microcosm-graph/tests(incl.test_h2_uk_spine_parity,requires_uk, run locally with the engine present),test_uk_source_stages.py,test_uk_spine_acceptance_receipt.py,test_us_spine_blindness.py: 755 passed, 0 failed, 0 skipped (JUnit-counted; run from the branch's own venv with policyengine-uk 2.92.1 present, so therequires_ukgraph tests ran rather than skipped).ruff check/ruff format --checkclean on the touched files;tools/ci_test_groups.py --verifyok.90293e0anow runs end to end on this branch (launched 14:48, H5 written 14:55; all 26 stages, 19 QRF fits). The artifact matches the last legacy-path spine (spine-l, built throughStagePlanbefore Node graph: acceptance charter and frozen interfaces for microcosm-graph #836) on every surface the driver records: entity rows 113,649 / 61,211 / 52,846, household weight total 29,247,433.0, 26 stage records, and all 210 final nonzero shares identical to 1e-6; thefrs_spinerecord carries its 86 shares with the id columns at 1.0. Between the two commits the same run reproduced the second failure exactly once, at the end of the build.Follow-ups (not in this PR)
🤖 Generated with Claude Code
cc @MaxGhenis