From d8b913d738426d8643e42ee9ed3664594276fb4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:24:22 +0100 Subject: [PATCH 1/3] Let a leg of deferred cells stand without a control (#1301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signing out the North East and Wales £2m region rows (485008810) left their local-authority cells, all deferred by the 400-home floor, on legs with no control. The small-cell rule adds such cells to the reconciliation frame to take their share of a region control, so the unparented-leg check then refused the surface: compile_uk_full_target_surface stopped on the abf0921 feed, and build 1 would have failed at uk.full.target_compilation. Nothing in CI compiles the full surface on the feed. The local surface now passes the reconciliation the area rows that bind nothing (its frame-only rows). A leg on which every open row is one of them and every target is licensed empty needs no control: it is receipted under deferred_legs_without_control instead of refused. A bound row on such a leg, a frame-only row without the licence, and a row of a target the leg is not licensed for are still orphans, as the Scottish authority of an England-only target is. Co-Authored-By: Claude Opus 5.5 --- .../1301-uk-local-solve-registers.changed.md | 2 +- .../src/microcosm/build/cross_grain.py | 93 +++++++++++++++++-- .../build/uk_runtime/ledger_targets.py | 14 +++ .../engine_free/shared/test_cross_grain.py | 67 +++++++++++++ 4 files changed, 167 insertions(+), 9 deletions(-) diff --git a/changelog.d/1301-uk-local-solve-registers.changed.md b/changelog.d/1301-uk-local-solve-registers.changed.md index 78cb73a71..228a26755 100644 --- a/changelog.d/1301-uk-local-solve-registers.changed.md +++ b/changelog.d/1301-uk-local-solve-registers.changed.md @@ -4,7 +4,7 @@ The registers that change the UK solve are in place before the first local relea **High-value homes.** The family's region rows control it on the local line, so: - `tools/generate_uk_local_target_references.py` defers its constituency level whole (`high_value_homes_constituency_grain_deferred`, `defer_if_compiles`), after the English band-H precedent, with a roster guard of 575 England and Wales constituencies; -- a new signed small-cell rule, `hvcts_la_household_floor` in `uk/local_small_cell_rule.json` (prefix `microcosm.high_value_homes.`, floor 400 homes, about two household weights at 150,000 households, expires 2027-04-01), defers the local-authority cells under the floor and keeps 192, 101 and 57 cells over £1m, £1.5m and £2m, whose deferred homes still count in their region rows. The two region rows below the floor, `microcosm.high_value_homes.over_2m_by_region` for the North East (`E12000001`) and Wales (`W92000004`), are signed out of the region fan-out by two row exclusions in `uk/target_reference_signed_exclusions.json` (María's ruling of 17 October 2026): their published stock is about one average household weight each at the ruled 150,000 households (an expectation from the stock, not a count of frame records), so their fit would be noise, and a public gate-only sign-off would narrow an estimate expected to rest on fewer than 10 records. A row-level signed exclusion can now name a geography cell (`row: {"dimension": "geography_id", ...}`). The same areas' £1m and £1.5m rows, which count those homes, stay bound; +- a new signed small-cell rule, `hvcts_la_household_floor` in `uk/local_small_cell_rule.json` (prefix `microcosm.high_value_homes.`, floor 400 homes, about two household weights at 150,000 households, expires 2027-04-01), defers the local-authority cells under the floor and keeps 192, 101 and 57 cells over £1m, £1.5m and £2m, whose deferred homes still count in their region rows. The two region rows below the floor, `microcosm.high_value_homes.over_2m_by_region` for the North East (`E12000001`) and Wales (`W92000004`), are signed out of the region fan-out by two row exclusions in `uk/target_reference_signed_exclusions.json` (María's ruling of 17 October 2026): their published stock is about one average household weight each at the ruled 150,000 households (an expectation from the stock, not a count of frame records), so their fit would be noise, and a public gate-only sign-off would narrow an estimate expected to rest on fewer than 10 records. A row-level signed exclusion can now name a geography cell (`row: {"dimension": "geography_id", ...}`). The same areas' £1m and £1.5m rows, which count those homes, stay bound. Their £2m authority cells are all deferred, so the cross-grain reconciliation now lets a leg that holds only the small-cell rule's deferred cells, of targets licensed empty there, stand without a control, receipted under `deferred_legs_without_control`; a bound row on such a leg is still refused as an orphan; - the `high_value_home_stock_derived_estimate` adjudication records where the family binds, and that the seat rank correlations, now out of sample, decide its renewal. **Islands.** Orkney (`local_authority/S12000023`) and Shetland (`local_authority/S12000027`) are pre-signed gate-only exclusions of the area-support floor in `uk/local_area_support_exclusions.json`, scoped to the local line and expiring 2027-01-16. Their cells stay bound. diff --git a/packages/microcosm-build/src/microcosm/build/cross_grain.py b/packages/microcosm-build/src/microcosm/build/cross_grain.py index 3a2dfd218..301f1e955 100644 --- a/packages/microcosm-build/src/microcosm/build/cross_grain.py +++ b/packages/microcosm-build/src/microcosm/build/cross_grain.py @@ -284,13 +284,19 @@ def _reconcile_cross_grain_surface_with_control_receipts( *, licensed_empty_legs: Mapping[str, frozenset[str]] | None = None, check_factor_bounds: bool = True, + frame_only_positions: frozenset[int] = frozenset(), ) -> tuple[ pd.DataFrame, list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], ]: - """Return a copied surface with every detected lower grain rescaled.""" + """Return a copied surface with every detected lower grain rescaled. + + ``frame_only_positions`` are rows that join the frame to take a share of + a control and bind nothing (a small-cell rule's deferred cells): a leg + holding only those, for targets licensed empty on it, needs no control. + """ columns = _surface_columns(local_frame) _validate_rule(rule) @@ -313,6 +319,7 @@ def _reconcile_cross_grain_surface_with_control_receipts( unparented_rows: dict[tuple[str, str], dict[int, str]] = {} first_pair_id: dict[tuple[str, str], str] = {} absent_middle_tier_legs: list[dict[str, Any]] = [] + deferred_legs_without_control: list[dict[str, Any]] = [] pairs_per_key: dict[tuple[str, str], int] = {} for group in materialized_groups: key = (group.inconsistency_id.rsplit(":", 1)[0], group.lower_grain) @@ -386,12 +393,24 @@ def _reconcile_cross_grain_surface_with_control_receipts( if last_pair_for_key and group.lower_grain not in control_grains: # No farther control can still claim these leaf rows: refuse now, # ahead of the empty-leg licence check, as the standing rule does. + # A leg only licensed-empty targets hold carries deferred cells + # alone and needs no control (_wholly_deferred_legs). + open_rows = { + position: leg + for position, leg in pending.items() + if position not in already and leg not in assigned_controls + } unparented = sorted( - { - leg - for position, leg in pending.items() - if position not in already and leg not in assigned_controls - } + set(open_rows.values()) + - set( + _wholly_deferred_legs( + open_rows, + reconciled, + columns, + licensed_empty_legs, + frame_only_positions, + ) + ) ) if unparented: raise ValueError( @@ -649,17 +668,30 @@ def _reconcile_cross_grain_surface_with_control_receipts( # A control-tier row with no control above it is the top of its # own leg, not an orphan. continue - still = { - leg + open_rows = { + position: leg for position, leg in pending.items() if position not in claimed[(identity, lower_grain)] } + deferred = _wholly_deferred_legs( + open_rows, reconciled, columns, licensed_empty_legs, frame_only_positions + ) + still = set(open_rows.values()) - set(deferred) if still: raise ValueError( f"cross-grain inconsistency " f"{first_pair_id[(identity, lower_grain)]!r} has " f"unparented lower-grain leg(s) {sorted(still)}." ) + deferred_legs_without_control.extend( + { + "inconsistency_id": first_pair_id[(identity, lower_grain)], + "lower_grain": lower_grain, + "leg": leg, + "lower_target_ids": list(target_ids), + } + for leg, target_ids in sorted(deferred.items()) + ) return ( reconciled, receipts, @@ -667,9 +699,47 @@ def _reconcile_cross_grain_surface_with_control_receipts( controls_without_lower_rows, delegated_legs, absent_middle_tier_legs, + deferred_legs_without_control, ) +def _wholly_deferred_legs( + rows: Mapping[int, str], + frame: pd.DataFrame, + columns: Mapping[str, str], + licensed_empty_legs: Mapping[str, frozenset[str]] | None, + frame_only_positions: frozenset[int], +) -> dict[str, tuple[str, ...]]: + """The legs of ``rows`` that hold deferred cells alone. + + A leg qualifies when every row on it is frame-only (it binds nothing and + joins the frame only to take its share of a control) and every target on + it is licensed empty there, so no cell of it binds on the leg. With no + control above them such rows have nothing to reconcile, and the leg is + no orphan (microcosm#1301: a region row signed out below the small-cell + floor leaves its wholly deferred authorities without one). A bound row, + or a row of a target the leg is not licensed for, keeps it an orphan. + """ + + targets_by_leg: dict[str, set[str]] = {} + bound_legs: set[str] = set() + for position, leg in rows.items(): + targets_by_leg.setdefault(leg, set()).add( + str(frame.iloc[position][columns["target_id"]]) + ) + if position not in frame_only_positions: + bound_legs.add(leg) + return { + leg: tuple(sorted(target_ids)) + for leg, target_ids in targets_by_leg.items() + if leg not in bound_legs + and all( + leg in _licensed_legs_for_target(target_id, licensed_empty_legs) + for target_id in target_ids + ) + } + + def reconcile_cross_grain_surface( local_frame: pd.DataFrame, groups: Iterable[CrossGrainInconsistency], @@ -699,12 +769,16 @@ def apply_cross_grain_reconciliation( reviewed_unbound_higher_targets: Mapping[str, Mapping[str, object]] | None = None, licensed_empty_legs: Mapping[str, frozenset[str]] | None = None, check_factor_bounds: bool = True, + frame_only_positions: Iterable[int] = (), ) -> tuple[pd.DataFrame, dict[str, Any]]: """Detect, reconcile, and return the always-present pass receipt. ``check_factor_bounds=False`` records each bounded bridge's factor without refusing it, for a pass that is not the caller's last (a surface whose off-surface cells join a second pass is bounded on that one). + ``frame_only_positions`` names the rows that bind nothing (deferred + cells); a leg holding only those needs no control + (``deferred_legs_without_control``). """ bound = tuple(str(target_id) for target_id in bound_higher_targets) @@ -728,12 +802,14 @@ def apply_cross_grain_reconciliation( controls_without_lower_rows, delegated_legs, absent_middle_tier_legs, + deferred_legs_without_control, ) = _reconcile_cross_grain_surface_with_control_receipts( local_frame, groups, rule, licensed_empty_legs=licensed_empty_legs, check_factor_bounds=check_factor_bounds, + frame_only_positions=frozenset(int(p) for p in frame_only_positions), ) receipt = { "bound_higher_targets": list(bound), @@ -745,6 +821,7 @@ def apply_cross_grain_reconciliation( "controls_without_lower_rows": controls_without_lower_rows, "delegated_legs": delegated_legs, "absent_middle_tier_legs": absent_middle_tier_legs, + "deferred_legs_without_control": deferred_legs_without_control, "absence": ( None if groups diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/ledger_targets.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/ledger_targets.py index 36f96a328..b3f16fe42 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/ledger_targets.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/ledger_targets.py @@ -1997,6 +1997,7 @@ def apply_uk_cross_grain_reconciliation( extra_bridges: Sequence[CrossGrainBridge] = (), extra_signatures: Mapping[str, Mapping[str, Any]] | None = None, check_factor_bounds: bool = True, + frame_only_positions: Iterable[int] = (), ) -> tuple[pd.DataFrame, dict[str, Any]]: """Apply the standing UK rule to a bound mixed-grain target surface. @@ -2004,6 +2005,9 @@ def apply_uk_cross_grain_reconciliation( controls the joint surface adds for its declared fan-out sum bridges. ``check_factor_bounds=False`` records the bridges' factor bounds without refusing, for a pass the caller reruns with more rows. + ``frame_only_positions`` names the rows that bind nothing (the small-cell + rule's deferred cells), so a wholly deferred leg with no control above it + is receipted rather than refused (microcosm#1301). Increment #762 may extend the grains in the UK rowwise solve only through this front door, so detection, reconciliation, and the manifest receipt @@ -2040,6 +2044,7 @@ def apply_uk_cross_grain_reconciliation( reviewed_unbound_higher_targets=reviewed_unbound_higher_targets, licensed_empty_legs=licences, check_factor_bounds=check_factor_bounds, + frame_only_positions=frame_only_positions, ) @@ -2741,6 +2746,14 @@ def reconcile( columns=["grain", "geography_id", "target_id", "value", "_output_position"], ) raw = frame[["grain", "geography_id", "target_id", "value"]] + # The area rows that bind nothing (deferred and off-surface cells): a + # leg holding only deferred ones needs no control (microcosm#1301). + frame_only = [ + position + for position, row in enumerate(reconciliation_rows) + if row["_output_position"] is None + and row["grain"] in level_to_area_type.values() + ] result, pass_receipt = apply_uk_cross_grain_reconciliation( raw, bound_control_ids, @@ -2750,6 +2763,7 @@ def reconcile( extra_bridges=fanout_bridges, extra_signatures=fanout_signatures, check_factor_bounds=check_factor_bounds, + frame_only_positions=frame_only, ) return frame, raw, result, pass_receipt diff --git a/packages/microcosm-build/tests/engine_free/shared/test_cross_grain.py b/packages/microcosm-build/tests/engine_free/shared/test_cross_grain.py index 3723e65bd..6b50bc315 100644 --- a/packages/microcosm-build/tests/engine_free/shared/test_cross_grain.py +++ b/packages/microcosm-build/tests/engine_free/shared/test_cross_grain.py @@ -400,6 +400,7 @@ def test_absence_receipt_exists_when_no_higher_target_is_bound(): "controls_without_lower_rows": [], "delegated_legs": [], "absent_middle_tier_legs": [], + "deferred_legs_without_control": [], "absence": "No cross-grain inconsistencies are in force on this surface.", } @@ -597,6 +598,72 @@ def test_unparented_and_empty_legs_are_refused(): apply_cross_grain_reconciliation(empty, ("national",), signatures, _rule()) +def test_a_leg_holding_only_deferred_cells_needs_no_control(): + """Frame-only rows of a target licensed empty on their leg are deferred + cells: with no control above them they are receipted, not refused. A + bound row there, a frame-only row without the licence, or a licensed + row that binds is still an orphan (microcosm#1301).""" + + surface = pd.DataFrame( + [ + ("country", "E", "national", 10.0), + ("constituency", "E1", "local", 10.0), + ("constituency", "N1", "local", 3.0), + ], + columns=["grain", "geography_id", "target_id", "value"], + ) + signatures = { + target_id: _signature() for target_id in ("national", "local", "bound") + } + licences = {"local": frozenset({"N"})} + + reconciled, receipt = apply_cross_grain_reconciliation( + surface, + ("national",), + signatures, + _rule(), + licensed_empty_legs=licences, + frame_only_positions=(2,), + ) + + assert reconciled["value"].tolist() == [10.0, 10.0, 3.0] + assert receipt["deferred_legs_without_control"] == [ + { + "inconsistency_id": receipt["groups"][0]["inconsistency_id"], + "lower_grain": "constituency", + "leg": "N", + "lower_target_ids": ["local"], + } + ] + + with_bound = pd.concat( + [ + surface, + pd.DataFrame( + [("constituency", "N2", "bound", 2.0)], columns=surface.columns + ), + ], + ignore_index=True, + ) + with pytest.raises(ValueError, match=r"unparented lower-grain leg\(s\) \['N'\]"): + apply_cross_grain_reconciliation( + with_bound, + ("national",), + signatures, + _rule(), + licensed_empty_legs=licences, + frame_only_positions=(2,), + ) + for kwargs in ( + {"frame_only_positions": (2,)}, + {"licensed_empty_legs": licences}, + ): + with pytest.raises(ValueError, match="unparented"): + apply_cross_grain_reconciliation( + surface, ("national",), signatures, _rule(), **kwargs + ) + + def test_empty_leg_licensed_for_every_lower_target_is_receipted_and_skipped(): surface = pd.DataFrame( [ From d0f0fafec74080bc9a12b8b22ba57762872be8fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:04:45 +0100 Subject: [PATCH 2/3] Renew the ONS D.41 savings-interest diagnostic exclusion to 2027-01-08 (#866) The ons.savings_interest_income measure exclusion lapses on 2026-10-22. After that date no national calibration could run, since the window is checked when the exclusions are applied, and the dense two-build flow's gate clock and assembly deadline stop there too. The row stays a measured diagnostic and is never fitted, as ruling R3 of 2026-09-22 set: the ONS D.41 row is a national-accounts concept, while the model carries cash interest, and the SPI interest rows are the fitting anchor. Renewed by juaristi22 on 2026-10-16 in the sixth #1095 ports PR (#1308), and shipped here ahead of it, unchanged, so that the first local release build stands on it; #1308's re-review recommended shipping it alone. The packaged-exclusions test pins the new window and the renewal note, and the 2026-10-16 approvals are now the employer NICs diagnostic and this. Co-Authored-By: Claude Opus 5.5 --- ...866-savings-interest-diagnostic-renewal.changed.md | 1 + .../build/uk/calibration_measure_exclusions.json | 6 +++--- .../engine_free/uk/test_uk_measure_simulation.py | 11 +++++++---- 3 files changed, 11 insertions(+), 7 deletions(-) create mode 100644 changelog.d/866-savings-interest-diagnostic-renewal.changed.md diff --git a/changelog.d/866-savings-interest-diagnostic-renewal.changed.md b/changelog.d/866-savings-interest-diagnostic-renewal.changed.md new file mode 100644 index 000000000..c9e76ffb2 --- /dev/null +++ b/changelog.d/866-savings-interest-diagnostic-renewal.changed.md @@ -0,0 +1 @@ +The `ons.savings_interest_income` measure exclusion is renewed to 2027-01-08 (microcosm#866; renewed by juaristi22 on 2026-10-16): the ONS D.41 interest row stays a measured diagnostic and is never fitted, as ruling R3 of 2026-09-22 set, because its concept differs from the model's cash interest. Without the renewal it lapsed on 2026-10-22, and no national calibration could run after that date. diff --git a/packages/microcosm-build/src/microcosm/build/uk/calibration_measure_exclusions.json b/packages/microcosm-build/src/microcosm/build/uk/calibration_measure_exclusions.json index b74314e4d..9b7341dff 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/calibration_measure_exclusions.json +++ b/packages/microcosm-build/src/microcosm/build/uk/calibration_measure_exclusions.json @@ -285,9 +285,9 @@ "reason": "Concept, not a weight question: the ONS UKEA HAXV row is households' (S.14) D.41 interest resources in the national accounts (FISIM-adjusted, including the unincorporated-business part of the sector; GBP 95.5bn at 2025) while the model's savings_interest_income is cash interest, so the row is unreachable by design (-82 percent on the v20 national candidate). From 2026-09-22 the fitting anchor for interest is the SPI 2023-24 Table 3.7 interest rows by total-income band, uprated to the calibration year (hmrc.spi.savings_interest_income.*, PolicyEngine/chronicle#280 lane); this row is measured on every evaluation as the D.41 diagnostic and never fitted. Retire only with a declared D.41-to-cash bridge.", "tracking": "microcosm#866", "approved_by": "juaristi22", - "adjudication": "PolicyEngine/microcosm#1006 (issue comment 5812206367, the register rulings María gave in session: ruling R3 of 2026-09-22, the SPI interest rows bound and the ONS row a measured diagnostic; income-anchor lane, PolicyEngine/chronicle#280); previously microcosm#762 A16 (2026-09-03).", - "approved_on": "2026-09-22", - "expires_on": "2026-10-22" + "adjudication": "PolicyEngine/microcosm#1006 (issue comment 5812206367, the register rulings María gave in session: ruling R3 of 2026-09-22, the SPI interest rows bound and the ONS row a measured diagnostic; income-anchor lane, PolicyEngine/chronicle#280); previously microcosm#762 A16 (2026-09-03); renewed on 2026-10-16 to 2027-01-08 by juaristi22 in session (folded into the sixth microcosm#1095 ports PR): the gap is one of concept, not of weights, so the row stays a measured diagnostic until a D.41-to-cash bridge is declared.", + "approved_on": "2026-10-16", + "expires_on": "2027-01-08" }, { "name": "obr.housing_benefit", diff --git a/packages/microcosm-build/tests/engine_free/uk/test_uk_measure_simulation.py b/packages/microcosm-build/tests/engine_free/uk/test_uk_measure_simulation.py index f11d51a9f..c1a502846 100644 --- a/packages/microcosm-build/tests/engine_free/uk/test_uk_measure_simulation.py +++ b/packages/microcosm-build/tests/engine_free/uk/test_uk_measure_simulation.py @@ -785,10 +785,11 @@ def test_packaged_exclusions_load(): # microcosm#1095: the five-or-more children row held out on 2026-10-14 # (María's ruling on microcosm#1271) is no target since 2026-10-16, but a # diagnostic beside the two-child-limit rows, so no exclusion names it and - # only the housing-element renewal carries that date. + # only the housing-element renewal carries that date. The 2026-10-16 + # entries are the employer NICs diagnostic and the savings-interest renewal. assert sorted( e["name"] for e in exclusions if e["approved_on"] == "2026-10-16" - ) == ["obr.ni_employer"] + ) == ["obr.ni_employer", "ons.savings_interest_income"] assert sorted( e["name"] for e in exclusions if e["approved_on"] == "2026-10-14" ) == sorted(_UC_ELEMENT_REGISTER_ROWS) @@ -806,10 +807,12 @@ def test_packaged_exclusions_load(): assert entry["tracking"] == a16_issues[name], name for name in _A16_CONCEPT_ROWS: entry = next(e for e in exclusions if e["name"] == name) - assert entry["approved_on"] == "2026-09-22", name - assert entry["expires_on"] == "2026-10-22", name + # Ruling R3 of 2026-09-22, renewed on 2026-10-16 to 2027-01-08. + assert entry["approved_on"] == "2026-10-16", name + assert entry["expires_on"] == "2027-01-08", name assert entry["tracking"] == a16_issues[name], name assert "microcosm#1006" in entry["adjudication"], name + assert "renewed on 2026-10-16" in entry["adjudication"], name assert "D.41" in entry["reason"] and "diagnostic" in entry["reason"], name # The fitting anchor the row hands over to: the SPI Table 3.7 interest # rows, uprated (PolicyEngine/chronicle#280 lane). From 07d6b43e7a4be708d338ebe76cd22fcb96259a9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:06:28 +0100 Subject: [PATCH 3/3] Allow the high-value homes support columns on the UK export surface (#1301) The first dense release build (microcosm#1301, attempt 1f0939c4) failed uk_export_surface on three household columns outside the incumbent surface with no allow-list entry: high_value_support_copies, high_value_support_copy_index and household_is_high_value_support_copy. They are the microcosm#1240 high-value homes support split's declared outputs (the release input coverage manifest lists them), the exact analog of the CGT support split's three columns, which the list already carries. microcosm#1270 added the stage but not the allow-list entries, so every UK build since then fails the gate at the calibrated battery. Add them to gates.json and UK_ALLOWED_EXTRA_EXPORT_COLUMNS in the same place as the CGT split's columns. The gate fails only on missing or unexpected columns, so a line that does not carry them is unaffected. Re-pin the full gate battery's policy, gates-manifest and spec digests and the release-cut part's digests; the spine, calibration-seam and dense digests do not move, so existing spines stay valid. gates.json is in the solve fingerprint, so the next dense build re-solves. Co-Authored-By: Claude Opus 5.5 --- .../1301-uk-export-surface-high-value-support.fixed.md | 1 + .../microcosm-build/src/microcosm/build/uk/gates.json | 3 +++ .../src/microcosm/build/uk_runtime/terminal_gates.py | 7 +++++++ packages/microcosm-data/src/microcosm/data/contract.py | 10 +++++----- .../tests/engine_free/shared/test_contract.py | 6 +++--- 5 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 changelog.d/1301-uk-export-surface-high-value-support.fixed.md diff --git a/changelog.d/1301-uk-export-surface-high-value-support.fixed.md b/changelog.d/1301-uk-export-surface-high-value-support.fixed.md new file mode 100644 index 000000000..31f4978f2 --- /dev/null +++ b/changelog.d/1301-uk-export-surface-high-value-support.fixed.md @@ -0,0 +1 @@ +The UK export-surface gate's allow-list carries the high-value homes support split's three household columns (`high_value_support_copies`, `high_value_support_copy_index` and `household_is_high_value_support_copy`, microcosm#1240), beside the CGT support split's precedent, in `gates.json` and `UK_ALLOWED_EXTRA_EXPORT_COLUMNS` alike. They are the stage's declared coverage-manifest outputs. Without them every UK build since microcosm#1270 fails `uk_export_surface` at the calibrated gates, and the first dense release build (microcosm#1301) did; that gate has no gate-only register, so the fix changes the solve's key and the next build re-solves. The full gate battery's policy, gates-manifest and spec digests and the release-cut part's digests are re-pinned; the spine, calibration-seam and dense digests do not move. diff --git a/packages/microcosm-build/src/microcosm/build/uk/gates.json b/packages/microcosm-build/src/microcosm/build/uk/gates.json index cea2637b2..aae6d2be7 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/gates.json +++ b/packages/microcosm-build/src/microcosm/build/uk/gates.json @@ -953,6 +953,9 @@ "household.cash_isa", "household.cgt_support_copies", "household.cgt_support_copy_index", + "household.high_value_support_copies", + "household.high_value_support_copy_index", + "household.household_is_high_value_support_copy", "household.household_is_cgt_residential_clone", "household.cgt_residential_clone_index", "household.household_clone_index", diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/terminal_gates.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/terminal_gates.py index 7f48ee938..09c09006d 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/terminal_gates.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/terminal_gates.py @@ -182,6 +182,13 @@ def __post_init__(self) -> None: # #1045: the CGT support split's family copy count. "household.cgt_support_copies", "household.cgt_support_copy_index", + # microcosm#1240: the high-value homes support split's family copy count, + # index and flag, the CGT split's precedent. The stage's coverage-manifest + # outputs; the first dense release build failed this gate on them + # (microcosm#1301). + "household.high_value_support_copies", + "household.high_value_support_copy_index", + "household.household_is_high_value_support_copy", # #1063: the residential split's arm flag and index. "household.household_is_cgt_residential_clone", "household.cgt_residential_clone_index", diff --git a/packages/microcosm-data/src/microcosm/data/contract.py b/packages/microcosm-data/src/microcosm/data/contract.py index 971f65158..38f8118a4 100644 --- a/packages/microcosm-data/src/microcosm/data/contract.py +++ b/packages/microcosm-data/src/microcosm/data/contract.py @@ -447,13 +447,13 @@ def compatibility_claim_declarer_error(declared_by: object) -> str | None: # fingerprint derives from the manifest digest. Editing the spec moves all # three here in the same reviewed change. _UK_GATE_BATTERY_POLICY_SHA256 = ( - "575039e9d1876fbf3732fc30ae05b19c13160550a507aa8cad4f6301d050b896" + "1e6c3da7cba654da5b0244affa6f2696369ced8464cc47d831e37f5c9fd76377" ) _UK_GATE_BATTERY_GATES_MANIFEST_SHA256 = ( - "445a09e59c2cea85bb6b3e7dc160465c9ca4a3b7da7d525201c0b4730ff7a663" + "3efc24780ea8e3961f36ee526de69048ed2198285e738a65d82f0fda13080cd1" ) _UK_GATE_BATTERY_SPEC_FINGERPRINT = ( - "f84643ec1f55f85ddff61fe0b65beba1b29478378212aa161d055341b86dad43" + "1a6e57d5e889f4c62297c283a041a917dcaa025dce129eee38ae126ffcb37233" ) #: Spec entry id -> the legacy gate name whose observable detail checks #: apply unchanged (the battery re-keys the report by entry id; the gate @@ -939,10 +939,10 @@ def _uk_line_cut_tag_re(release_id: str) -> re.Pattern[str] | None: }, "release_cut": { "gates_manifest_sha256": ( - "507b7f7482b128dba12fe67b8451143d2a9a3774339c0c016b7c7cc2ff5262ab" + "778e1a26f58b2f1347c0438321117da905e6ff1f5902468c1fe24a186cd5b891" ), "policy_sha256": ( - "c3c8622ec081579ad280fadb3c6e58515457cb8a3f114c8fd6a4ca19490d2b20" + "3a1dd5dfae4cd52de9d71f3f3542e67758c5e73862fde88b0b97e040d5d46396" ), }, } diff --git a/packages/microcosm-data/tests/engine_free/shared/test_contract.py b/packages/microcosm-data/tests/engine_free/shared/test_contract.py index d8e7e1851..98510cca0 100644 --- a/packages/microcosm-data/tests/engine_free/shared/test_contract.py +++ b/packages/microcosm-data/tests/engine_free/shared/test_contract.py @@ -213,13 +213,13 @@ def _trusted_terminal_gate_signing_key(monkeypatch) -> None: UK_GATE_BATTERY_PRODUCER = "microcosm.build.gate_battery" UK_GATE_BATTERY_SIGNING_KEY_ENV = "MICROCOSM_UK_TERMINAL_GATE_SIGNING_KEY" UK_GATE_BATTERY_POLICY_SHA256 = ( - "575039e9d1876fbf3732fc30ae05b19c13160550a507aa8cad4f6301d050b896" + "1e6c3da7cba654da5b0244affa6f2696369ced8464cc47d831e37f5c9fd76377" ) UK_GATE_BATTERY_GATES_MANIFEST_SHA256 = ( - "445a09e59c2cea85bb6b3e7dc160465c9ca4a3b7da7d525201c0b4730ff7a663" + "3efc24780ea8e3961f36ee526de69048ed2198285e738a65d82f0fda13080cd1" ) UK_GATE_BATTERY_SPEC_FINGERPRINT = ( - "f84643ec1f55f85ddff61fe0b65beba1b29478378212aa161d055341b86dad43" + "1a6e57d5e889f4c62297c283a041a917dcaa025dce129eee38ae126ffcb37233" ) UK_GATE_BATTERY_DEGENERATE_EVIDENCE_SHA256 = ( "6a179222c6b6058ad9caacfd347f7bdb8cb98a62237b50d1fc1136df09337251"