From 29aac9c53aa2ca37ccff2136ee919010a840aca9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:33:17 +0200 Subject: [PATCH 1/3] Preserve FRS benefit-unit capital for UC and re-anchor take-up after SPI (#828) - frs_spine carries TOTCAPB4 as frs_benunit_capital behind the named -1 unavailable sentinel (fully populated in FRS 2024-25; mapped-row count reported in stage evidence) - new uc_capital_coherence stage after the last universal_credit_reported writer: flipped-only weighted-empirical SPI reporter capital redraw (children band x couple cells, identity-keyed seed) + monotone would_claim_uc OR-refresh; materializes uc_reported_capital - shared column_implication gate + uk_uc_capital_coherence terminal binding (implication, sentinel floor, sentinel parity, same-source equality) - derived surfaces: sources.yaml authority + regenerated projection, schema branch, coverage manifest, export-surface allowlist, contract digests, roster pins; three signed differences for the new columns and the would_claim_uc lift - I1 receipts (blocker aggregates, TOTCAPB4 domain audit, A3 sizing 0.454m) live in data/ukds/acceptance/828-uc-capital Implemented by Codex from the reviewed #828 plan (zero unadjudicated deviations); licensed rebuild and recalibration follow as I7/I8. Co-Authored-By: Claude Fable 5 --- changelog.d/828-uc-capital-carrier.added.md | 1 + changelog.d/828-uc-claim-coherence.changed.md | 1 + .../src/microcosm/build/__init__.py | 2 + .../src/microcosm/build/country_spec.py | 1 + .../src/microcosm/build/gate_battery.py | 10 + .../src/microcosm/build/gates.py | 84 ++++- .../src/microcosm/build/source_manifest.py | 1 + .../spec_engine/schema/sources.schema.json | 57 ++++ .../src/microcosm/build/uk/gates.json | 21 ++ .../uk/release_input_coverage_manifest.json | 26 +- .../src/microcosm/build/uk/source_stages.json | 53 ++- .../src/microcosm/build/uk/spec/sources.yaml | 39 ++- .../uk/spine_swap_signed_differences.json | 80 ++++- .../build/uk_runtime/battery_bindings.py | 121 +++++++ .../build/uk_runtime/calibration_run.py | 1 + .../microcosm/build/uk_runtime/frs_spine.py | 41 ++- .../build/uk_runtime/terminal_gates.py | 2 + .../build/uk_runtime/uc_capital_coherence.py | 302 ++++++++++++++++++ .../tests/test_country_spec.py | 9 +- packages/microcosm-build/tests/test_gates.py | 42 +++ .../tests/test_spec_engine_country_bundles.py | 2 +- .../tests/test_uk_battery_bindings.py | 41 ++- .../tests/test_uk_frs_spine.py | 61 +++- .../tests/test_uk_signed_differences.py | 39 +++ .../tests/test_uk_source_stages.py | 16 +- .../tests/test_uk_spine_acceptance_receipt.py | 19 +- .../tests/test_uk_uc_capital_coherence.py | 266 +++++++++++++++ .../src/microcosm/data/contract.py | 27 +- tools/build_uk_frs_spine.py | 21 +- 29 files changed, 1321 insertions(+), 65 deletions(-) create mode 100644 changelog.d/828-uc-capital-carrier.added.md create mode 100644 changelog.d/828-uc-claim-coherence.changed.md create mode 100644 packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py create mode 100644 packages/microcosm-build/tests/test_uk_uc_capital_coherence.py diff --git a/changelog.d/828-uc-capital-carrier.added.md b/changelog.d/828-uc-capital-carrier.added.md new file mode 100644 index 000000000..54beb44f9 --- /dev/null +++ b/changelog.d/828-uc-capital-carrier.added.md @@ -0,0 +1 @@ +Add FRS benunit capital and the PolicyEngine-UK Universal Credit reported-capital carrier to the UK spine, with a named unavailable sentinel, deterministic SPI-reporter redraw, and same-source engine regression coverage (#828). diff --git a/changelog.d/828-uc-claim-coherence.changed.md b/changelog.d/828-uc-claim-coherence.changed.md new file mode 100644 index 000000000..f82511a34 --- /dev/null +++ b/changelog.d/828-uc-claim-coherence.changed.md @@ -0,0 +1 @@ +Refresh `would_claim_uc` monotonically for benunits reporting Universal Credit and enforce the reporter-to-claim and capital-carrier invariants in the terminal UK gate battery (#828). diff --git a/packages/microcosm-build/src/microcosm/build/__init__.py b/packages/microcosm-build/src/microcosm/build/__init__.py index dbb3278b2..624cdd3ed 100644 --- a/packages/microcosm-build/src/microcosm/build/__init__.py +++ b/packages/microcosm-build/src/microcosm/build/__init__.py @@ -81,6 +81,7 @@ def _assert_frame_compatible(version: str, required: tuple[int, int]) -> None: TargetCoverageRequirement, TargetFitRequirement, aggregate_admin_gate, + column_implication_gate, default_valued_columns_gate, enum_domain_gate, export_surface_gate, @@ -203,6 +204,7 @@ def _assert_frame_compatible(version: str, required: tuple[int, int]) -> None: "PreparedMonetaryMeasure", "add_ledger_artifact_args", "aggregate_admin_gate", + "column_implication_gate", "apply_ledger_target_profile", "bind_monetary_target", "default_valued_columns_gate", diff --git a/packages/microcosm-build/src/microcosm/build/country_spec.py b/packages/microcosm-build/src/microcosm/build/country_spec.py index 1eb45e39c..44448fa9f 100644 --- a/packages/microcosm-build/src/microcosm/build/country_spec.py +++ b/packages/microcosm-build/src/microcosm/build/country_spec.py @@ -112,6 +112,7 @@ { "aggregate_admin", "calibration_reference_coverage", + "column_implication", "degenerate_release_surface", "enum_domain", "export_surface", diff --git a/packages/microcosm-build/src/microcosm/build/gate_battery.py b/packages/microcosm-build/src/microcosm/build/gate_battery.py index 58de3281c..401879e9a 100644 --- a/packages/microcosm-build/src/microcosm/build/gate_battery.py +++ b/packages/microcosm-build/src/microcosm/build/gate_battery.py @@ -56,6 +56,7 @@ from microcosm.build.country_spec import CountrySpec, GateSelectionSpec, GatesManifest from microcosm.build.gates import ( GateResult, + column_implication_gate, input_mass_parity_gate, tail_concentration_gate, weights_audit_gate, @@ -348,6 +349,15 @@ def _input_mass_evidence( #: report, never a crash, so an incomplete registry cannot manufacture a #: pass. DEFAULT_REGISTRY: Mapping[str, GateBinding] = { + "column_implication": FunctionBinding( + name="column_implication", + gate=column_implication_gate, + parameter_keys=frozenset({"numeric_column", "boolean_column", "threshold"}), + artifact_arguments={ + "numeric_values": "column_implication_numeric_values", + "boolean_values": "column_implication_boolean_values", + }, + ), "weights_audit": FunctionBinding( name="weights_audit", gate=weights_audit_gate, diff --git a/packages/microcosm-build/src/microcosm/build/gates.py b/packages/microcosm-build/src/microcosm/build/gates.py index ac22c7e38..eeafe83e2 100644 --- a/packages/microcosm-build/src/microcosm/build/gates.py +++ b/packages/microcosm-build/src/microcosm/build/gates.py @@ -72,6 +72,7 @@ "parity_gate", "support_gate", "aggregate_admin_gate", + "column_implication_gate", "per_family_fit_gate", "source_coverage_gate", "source_stage_input_coverage_gate", @@ -215,9 +216,7 @@ def ledger_compile_parity_gate( actual = _drop_registry_keys(actual, signed_keys) expected = _drop_registry_keys(expected, signed_keys) report = ledger_target_registry_parity_report(expected, actual) - failures = ( - signed_failures + _calibration_effective_parity_failures(report.failures) - ) + failures = signed_failures + _calibration_effective_parity_failures(report.failures) return GateResult( name=name, passed=not failures, @@ -478,9 +477,7 @@ def _signed_difference_check_failures( signed_kind = signed.get("kind") live_kind = live.get("kind") if not signed_kind: - failures.append( - f"signed ledger parity difference {label} is missing kind." - ) + failures.append(f"signed ledger parity difference {label} is missing kind.") continue if signed_kind != live_kind: failures.append( @@ -491,8 +488,7 @@ def _signed_difference_check_failures( for value_field in _signed_difference_required_value_fields(str(signed_kind)): if value_field not in signed: failures.append( - "signed ledger parity difference " - f"{label} is missing {value_field}." + f"signed ledger parity difference {label} is missing {value_field}." ) continue if not _signed_difference_value_matches( @@ -1415,6 +1411,78 @@ def nonnegative_columns_gate( ) +def column_implication_gate( + numeric_values: Iterable[float], + boolean_values: Iterable[bool], + *, + numeric_column: str, + boolean_column: str, + threshold: float = 0.0, +) -> GateResult: + """Require ``numeric_column > threshold`` to imply ``boolean_column``. + + This row-wise primitive is country agnostic. Country bindings may derive + the aligned evidence at another entity grain before calling it (for + example, aggregating person-level benefit reports to benunits). + """ + + if not numeric_column: + raise ValueError("numeric_column must be non-empty.") + if not boolean_column: + raise ValueError("boolean_column must be non-empty.") + if not math.isfinite(float(threshold)): + raise ValueError(f"threshold must be finite, got {threshold!r}.") + + try: + numeric = np.asarray(numeric_values, dtype=np.float64).reshape(-1) + except (TypeError, ValueError) as exc: + raise ValueError(f"{numeric_column} must be numeric.") from exc + raw_boolean = np.asarray(boolean_values).reshape(-1) + if numeric.shape != raw_boolean.shape: + raise ValueError( + f"{numeric_column} and {boolean_column} must have the same shape; " + f"got {numeric.shape} and {raw_boolean.shape}." + ) + if raw_boolean.dtype.kind == "b": + boolean = raw_boolean.astype(bool, copy=False) + elif raw_boolean.dtype.kind in "iu" and np.isin(raw_boolean, (0, 1)).all(): + boolean = raw_boolean.astype(bool) + else: + raise ValueError( + f"{boolean_column} must contain only boolean or integer 0/1 values." + ) + + nonfinite = ~np.isfinite(numeric) + implicated = numeric > float(threshold) + violations = implicated & ~boolean + failures: list[str] = [] + if nonfinite.any(): + failures.append( + f"{numeric_column}: {int(nonfinite.sum())} non-finite value(s); " + "the implication evidence must be finite." + ) + if violations.any(): + failures.append( + f"{numeric_column} > {float(threshold):g} must imply " + f"{boolean_column} is true; {int(violations.sum())} violation(s)." + ) + + return GateResult( + name="column_implication", + passed=not failures, + failures=tuple(failures), + details={ + "numeric_column": numeric_column, + "boolean_column": boolean_column, + "threshold": float(threshold), + "rows_checked": int(numeric.size), + "implicated_rows": int(implicated.sum()), + "violation_count": int(violations.sum()), + "nonfinite_count": int(nonfinite.sum()), + }, + ) + + def formula_owned_export_gate( exported_columns: Iterable[str], formula_owned_columns: Iterable[str], diff --git a/packages/microcosm-build/src/microcosm/build/source_manifest.py b/packages/microcosm-build/src/microcosm/build/source_manifest.py index ebad0f611..8ee1d2b8e 100644 --- a/packages/microcosm-build/src/microcosm/build/source_manifest.py +++ b/packages/microcosm-build/src/microcosm/build/source_manifest.py @@ -130,6 +130,7 @@ "read_tables", "read_acs_rent_donor", "redraw_columns_from_fitted_qrf", + "redraw_spi_reporter_capital", "record_mass_conservation_receipt", "replace_zero_weight_spi_support", "retain_adjudicated_frs_hmrc_leaves", diff --git a/packages/microcosm-build/src/microcosm/build/spec_engine/schema/sources.schema.json b/packages/microcosm-build/src/microcosm/build/spec_engine/schema/sources.schema.json index c6f16826f..99dd94e35 100644 --- a/packages/microcosm-build/src/microcosm/build/spec_engine/schema/sources.schema.json +++ b/packages/microcosm-build/src/microcosm/build/spec_engine/schema/sources.schema.json @@ -6693,6 +6693,63 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "output", + "rows", + "donor_rows", + "dependent_children_bands", + "couple_status", + "weight_mapping", + "draw", + "identity", + "seed", + "salt" + ], + "properties": { + "kind": { + "const": "redraw_spi_reporter_capital" + }, + "output": { + "type": "string" + }, + "rows": { + "type": "string" + }, + "donor_rows": { + "type": "string" + }, + "dependent_children_bands": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "type": "string" + } + }, + "couple_status": { + "type": "string" + }, + "weight_mapping": { + "type": "string" + }, + "draw": { + "type": "string" + }, + "identity": { + "type": "string" + }, + "seed": { + "type": "integer" + }, + "salt": { + "type": "string" + } + } + }, { "type": "object", "additionalProperties": false, diff --git a/packages/microcosm-build/src/microcosm/build/uk/gates.json b/packages/microcosm-build/src/microcosm/build/uk/gates.json index c665d6042..42c9e42b2 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/gates.json +++ b/packages/microcosm-build/src/microcosm/build/uk/gates.json @@ -470,6 +470,25 @@ "parameters": {}, "notes": "Every UK source-stage column declared in source_stages.json nonnegative_outputs must be finite and non-negative on the terminal frame; this makes the shared nonnegative column gate live for UK." }, + { + "id": "uk_uc_capital_coherence", + "gate": "column_implication", + "phase": "terminal", + "criticality": "release_blocking", + "parameters": { + "numeric_entity": "person", + "numeric_column": "universal_credit_reported", + "numeric_group_column": "person_benunit_id", + "boolean_entity": "benunit", + "boolean_id_column": "benunit_id", + "boolean_column": "would_claim_uc", + "threshold": 0.0, + "capital_column": "uc_reported_capital", + "carrier_column": "frs_benunit_capital", + "sentinel": -1.0 + }, + "notes": "Benunit-aggregated reported Universal Credit must imply would_claim_uc. The reported-capital engine carrier is bounded below by its named -1 unavailable sentinel, may use that sentinel only where the FRS carrier is unavailable, and otherwise remains the exact same source value." + }, { "id": "uk_support", "gate": "support", @@ -535,6 +554,8 @@ "parameters": { "allowed_extra_columns": [ "benunit.child_benefit_opts_out", + "benunit.frs_benunit_capital", + "benunit.uc_reported_capital", "household.bus_fare_spending", "household.bus_subsidy_spending", "household.cash_isa", diff --git a/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json b/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json index a84f49346..927665543 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json +++ b/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json @@ -473,7 +473,7 @@ "capital_gains" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "source_vintages": { "source": "HMRC Capital Gains Tax statistics, July 2025, Table 2.1a", "survey": "HMRC Capital Gains Tax statistics Table 2.1a and Advani-Summers capital-gains incidence" @@ -496,7 +496,7 @@ "capital_gains" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "source_vintages": { "source": "Advani and Summers (2020), Capital Gains and UK Inequality, CAGE Working Paper 465", "survey": "Family Resources Survey 2024-25, SPI synthetic support, and Advani-Summers capital-gains incidence" @@ -525,7 +525,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "source_vintages": { "source": "UK Data Service SN 8856 Effects of Taxes and Benefits household tab, DfT rail fare index, and public NHS activity/cost table.", "survey": "Effects of Taxes and Benefits 1977-2024 and NHS age-gender public table" @@ -545,7 +545,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "source_vintages": { "source": "UK Data Service SN 8856 Effects of Taxes and Benefits household tab and cited VAT anchor resource.", "survey": "Effects of Taxes and Benefits 1977-2024" @@ -576,7 +576,7 @@ "superseded_by": { "reason": "The FRS spine build executes hmrc_cgt_gains_spine, which applies the same HMRC Table 3 amounts redraw directly in source_stages.json before calibration.", "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "stage": "hmrc_cgt_gains_spine" } }, @@ -596,7 +596,7 @@ "capital_gains" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "source_vintages": { "hmrc_surface": "2023-24", "mapped_build_period": "2024" @@ -610,7 +610,7 @@ "base_candidate_tier": "frs", "calibration_permitted": false, "canonical_source_manifest": "source_stages.json", - "canonical_source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "canonical_source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "effective_mass_requirements": { "charitable_investment_gifts": { "mass_share_denominator": "all_person_effective_mass", @@ -693,7 +693,7 @@ "superseded_by": { "reason": "The FRS spine build executes hmrc_spi_income_spine, which supersedes the June retained-leaves/hmrc_spi_income pair inside source_stages.json.", "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "stage": "hmrc_spi_income_spine" } }, @@ -727,7 +727,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "source_vintages": { "source": "UK Data Service SN 9468 Living Costs and Food Survey 2023-24 household/person tabs, NEED 2023 headline energy tables, Ofgem Q2 2026 unit rates, and WAS round-8 bridge donor.", "survey": "Living Costs and Food Survey 2023-24" @@ -748,7 +748,7 @@ "property_wealth" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "source_vintages": { "source": "MHCLG dwellings and ONS UK House Price Index December 2025 regional average prices.", "survey": "Public regional property reference" @@ -772,7 +772,7 @@ "employee_pension_contributions" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "source_vintages": { "source": "HMRC, Salary sacrifice reform for pension contributions effective from 6 April 2029", "survey": "Family Resources Survey 2024-25 salary-sacrifice respondents and HMRC salary-sacrifice reform analysis" @@ -794,7 +794,7 @@ "student_loan_plan" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "source_vintages": { "source": "Explore Education Statistics Table 6a, Higher education total", "survey": "Family Resources Survey 2024-25 and Student Loans Company borrower forecasts for England" @@ -827,7 +827,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "b04f5c9af88d5c6e11e687daa1c7eb382058e3ed4f637e1075887f4f7cc043e3", + "source_manifest_sha256": "8228dc54d7b1de19e3b2baed52f59fdeee738974d80d494a74df42daf814b273", "source_vintages": { "source": "Office for National Statistics Wealth and Assets Survey, UK Data Service SN 7215, DOI 10.5255/UKDA-SN-7215-20; local licensed 2006-22 household tab.", "survey": "Wealth and Assets Survey round 8" diff --git a/packages/microcosm-build/src/microcosm/build/uk/source_stages.json b/packages/microcosm-build/src/microcosm/build/uk/source_stages.json index 7d206dca1..25eba8185 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/source_stages.json +++ b/packages/microcosm-build/src/microcosm/build/uk/source_stages.json @@ -229,7 +229,7 @@ }, { "kind": "map_columns", - "scope": "direct raw-to-column E2 mappings only" + "scope": "direct raw-to-column E2 mappings only; benunit.TOTCAPB4 -> frs_benunit_capital with blank, nonnumeric, or negative raw values -> named -1 unavailable sentinel (mapped-row count reported in stage evidence)" }, { "kind": "map_coded_amounts", @@ -306,6 +306,7 @@ "esa_income_reported", "bsp_reported", "benunit_id", + "frs_benunit_capital", "is_married", "dependent_children", "household_id", @@ -2406,6 +2407,56 @@ ], "notes": "Runs the SPI-trained income QRFs on the raw-spine support channel, initializes FRS charity columns to zero, trains FRS-only stage 2 before redrawing base-channel dividends, and emits a sidecar-only 208-fact replay report for the spine path." }, + { + "stage": "uc_capital_coherence", + "survey": "Family Resources Survey 2024-25", + "source": "Family Resources Survey 2024-25 benefit-unit TOTCAPB4 observations carried through the SPI support channel.", + "grain": "benunit", + "artifacts": [], + "operations": [ + { + "kind": "aggregate_person_to_benunit", + "method": "any_positive", + "consumed_only": true, + "aggregates": { + "universal_credit_reported_anchor": "universal_credit_reported" + } + }, + { + "kind": "redraw_spi_reporter_capital", + "output": "frs_benunit_capital", + "rows": "spi_channel_post_fill_reporters", + "donor_rows": "base_frs_reporters_with_available_capital", + "dependent_children_bands": [ + "0", + "1", + "2", + "3+" + ], + "couple_status": "is_married", + "weight_mapping": "household_to_benunit", + "draw": "weighted_empirical", + "identity": "benunit_id", + "seed": 0, + "salt": "frs_benunit_capital" + }, + { + "kind": "derive", + "derived": { + "uc_reported_capital": "frs_benunit_capital", + "would_claim_uc": "would_claim_uc OR universal_credit_reported_anchor" + } + } + ], + "outputs": [ + "uc_reported_capital" + ], + "rewrites": [ + "frs_benunit_capital", + "would_claim_uc" + ], + "notes": "Runs after the last universal_credit_reported writer and before CGT cloning. Redraws only SPI post-fill reporters from weighted base-FRS reporter capital within dependent-children band by couple-status cells, then applies the monotone reported-UC OR refresh." + }, { "stage": "cgt_incidence_clone", "survey": "Family Resources Survey 2024-25, SPI synthetic support, and Advani-Summers capital-gains incidence", diff --git a/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml b/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml index c7054e4f5..a915a9578 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml +++ b/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml @@ -204,7 +204,7 @@ stages: person_table: adult union child household_sort: set_index('household_id').sort_index() before positional household reads - kind: map_columns - scope: direct raw-to-column E2 mappings only + scope: 'direct raw-to-column E2 mappings only; benunit.TOTCAPB4 -> frs_benunit_capital with blank, nonnumeric, or negative raw values -> named -1 unavailable sentinel (mapped-row count reported in stage evidence)' - kind: map_coded_amounts scope: region, tenure, accommodation, council tax band, benefit-code amount mappings - kind: annualize_periodic_amounts @@ -275,6 +275,7 @@ stages: - esa_income_reported - bsp_reported - benunit_id + - frs_benunit_capital - is_married - dependent_children - household_id @@ -1924,6 +1925,42 @@ stages: - hmrc_spi_unemployment_benefit_income - hmrc_spi_incapacity_benefit_income notes: Runs the SPI-trained income QRFs on the raw-spine support channel, initializes FRS charity columns to zero, trains FRS-only stage 2 before redrawing base-channel dividends, and emits a sidecar-only 208-fact replay report for the spine path. +- stage: uc_capital_coherence + survey: Family Resources Survey 2024-25 + source: Family Resources Survey 2024-25 benefit-unit TOTCAPB4 observations carried through the SPI support channel. + grain: benunit + artifacts: [] + operations: + - kind: aggregate_person_to_benunit + method: any_positive + consumed_only: true + aggregates: + universal_credit_reported_anchor: universal_credit_reported + - kind: redraw_spi_reporter_capital + output: frs_benunit_capital + rows: spi_channel_post_fill_reporters + donor_rows: base_frs_reporters_with_available_capital + dependent_children_bands: + - '0' + - '1' + - '2' + - 3+ + couple_status: is_married + weight_mapping: household_to_benunit + draw: weighted_empirical + identity: benunit_id + seed: 0 + salt: frs_benunit_capital + - kind: derive + derived: + uc_reported_capital: frs_benunit_capital + would_claim_uc: would_claim_uc OR universal_credit_reported_anchor + outputs: + - uc_reported_capital + rewrites: + - frs_benunit_capital + - would_claim_uc + notes: Runs after the last universal_credit_reported writer and before CGT cloning. Redraws only SPI post-fill reporters from weighted base-FRS reporter capital within dependent-children band by couple-status cells, then applies the monotone reported-UC OR refresh. - stage: cgt_incidence_clone survey: Family Resources Survey 2024-25, SPI synthetic support, and Advani-Summers capital-gains incidence source: Advani and Summers (2020), Capital Gains and UK Inequality, CAGE Working Paper 465 diff --git a/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json b/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json index 3f1bf1922..4380d96ef 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json +++ b/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "scope_note": "Adjudicated intentional differences between the microcosm-built UK spine and the frozen enhanced-FRS incumbent (#686). The whole-spine comparison treats any difference beyond the #723 acceptance band that is not signed here as a defect, so every entry is scoped to the exact surface and columns where the difference is expected to appear: a too-broad entry would sign a real defect. Entries are permanent adjudications and carry no expiry; time-limited per-gate suppressions belong in input_mass_reviewed_exclusions.json, qrf_tail_reviewed_exclusions.json or degenerate_reviewed_exclusions.json instead, and an entry here points at one through its evidence field when both descend from the same adjudication. The twenty-six beyond-band divergences measured against the re-pinned 1.56.16 reference are signed below, each scoped to the divergence actually observed rather than carried across on its prose classification, and each deliberately split so that no entry covers both columns where the spine is closer to its donor and columns where the incumbent is: the direction of the evidence is part of what is being signed. Donor figures quoted as magnitude evidence are survey-weighted shares and population means computed on each stage's own committed cleaning function over its own pinned donor tab, which is the convention that reproduces the E6 acceptance receipt's education figure exactly. The incumbent side of every figure is measured on the pinned 1.56.16 artifact itself (sha256 e433e532), not on a local rebuild: an earlier pass read the 1.56.14 published artifact, whose shares differ by up to 0.0045 and which flipped one verdict.", + "scope_note": "Adjudicated intentional differences between the microcosm-built UK spine and the frozen enhanced-FRS incumbent (#686). The whole-spine comparison treats any difference beyond the #723 acceptance band that is not signed here as a defect, so every entry is scoped to the exact surface and columns where the difference is expected to appear: a too-broad entry would sign a real defect. Entries are permanent adjudications and carry no expiry; time-limited per-gate suppressions belong in input_mass_reviewed_exclusions.json, qrf_tail_reviewed_exclusions.json or degenerate_reviewed_exclusions.json instead, and an entry here points at one through its evidence field when both descend from the same adjudication. The twenty-nine beyond-band divergences measured or structurally expected against the re-pinned 1.56.16 reference are signed below, each scoped to the divergence actually observed rather than carried across on its prose classification, and each deliberately split so that no entry covers both columns where the spine is closer to its donor and columns where the incumbent is: the direction of the evidence is part of what is being signed. Donor figures quoted as magnitude evidence are survey-weighted shares and population means computed on each stage's own committed cleaning function over its own pinned donor tab, which is the convention that reproduces the E6 acceptance receipt's education figure exactly. The incumbent side of every figure is measured on the pinned 1.56.16 artifact itself (sha256 e433e532), not on a local rebuild: an earlier pass read the 1.56.14 published artifact, whose shares differ by up to 0.0045 and which flipped one verdict.", "differences": [ { "id": "scottish-water-incumbent-nan-zeroing", @@ -458,6 +458,84 @@ ] } } + }, + { + "id": "frs-benunit-capital-net-new-column", + "class": "net_new_column", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "frs_benunit_capital" + ], + "entities": [ + "benunit" + ] + }, + "expectation": "column_missing_in_reference", + "magnitude_evidence": "The spine now persists the FRS TOTCAPB4 benunit carrier as frs_benunit_capital. The pinned enhanced-FRS reference has no column with that name, so this is a structural candidate-only export rather than a value divergence on a shared surface. The I1 domain receipt found all 18,850 current-vintage donor rows populated: 16,038 positive and 2,812 zero, with no blank or negative rows.", + "evidence": ".codex-work/828_before_ab.json", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-31", + "quantitative": { + "structural": { + "expected_columns": [ + "frs_benunit_capital" + ] + } + } + }, + { + "id": "uc-reported-capital-net-new-column", + "class": "net_new_column", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "uc_reported_capital" + ], + "entities": [ + "benunit" + ] + }, + "expectation": "column_missing_in_reference", + "magnitude_evidence": "The coherence stage now persists uc_reported_capital from the same frs_benunit_capital carrier for PolicyEngine-UK's UC means-test seam. The pinned enhanced-FRS reference has no column with that name, so its presence is a structural candidate-only export. The I1 receipt establishes the carrier domain and the separate before-engine receipt pins the complete 61,211-benunit join used by this seam.", + "evidence": ".codex-work/828_before_c.json", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-31", + "quantitative": { + "structural": { + "expected_columns": [ + "uc_reported_capital" + ] + } + } + }, + { + "id": "uc-reporter-claim-refresh-lift", + "class": "mechanism_change", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "would_claim_uc" + ], + "entities": [ + "benunit" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "The refresh is monotone and limited to post-fill reported-UC benunits: it ORs the reporter anchor into would_claim_uc and never turns an existing true value false. Against the incumbent's 0.550692 unweighted nonzero share, the I1 sizing receipt identifies 2,245 SPI-channel reporter records that are false before refresh out of 61,211 spine benunits, an expected whole-spine lift of 0.03668 (bounded at 0.0367 below); those rows carry 1.640 million weighted benunits. The I1 before-engine receipt independently measures 0.893 million weighted reported-UC benunits blocked by a false would_claim_uc flag before this repair.", + "evidence": ".codex-work/828_before_ab.json", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-31", + "quantitative": { + "shares": { + "would_claim_uc": { + "incumbent_share": 0.550692, + "direction": "candidate_above", + "max_abs_delta": 0.0367 + } + }, + "magnitude_provenance": "I1 pre-change receipts .codex-work/828_before_ab.json and .codex-work/828_before_c.json; 2,245 SPI-channel false reporters divided by 61,211 benunits, rounded up at 1e-4 grain." + } } ] } diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py index f2fd7f77f..f038ecddd 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py @@ -38,6 +38,7 @@ from typing import Any import numpy as np +import pandas as pd from microcosm.build.gate_battery import ( DEFAULT_REGISTRY, @@ -47,6 +48,7 @@ from microcosm.build.gates import ( GateResult, aggregate_admin_gate, + column_implication_gate, enum_domain_gate, ledger_compile_parity_gate, nonnegative_columns_gate, @@ -310,6 +312,107 @@ def _evaluate_nonnegative_columns( ) +def _evaluate_column_implication( + context: EvidenceContext, parameters: Mapping[str, Any] +) -> GateResult: + """Bind a person signal to a benunit flag and its same-source carrier.""" + + frame = context.frame + assert frame is not None # GateBinding enforces frame evidence first. + source_entity = str(parameters["numeric_entity"]) + target_entity = str(parameters["boolean_entity"]) + source = frame.table(source_entity) + target = frame.table(target_entity) + numeric_column = str(parameters["numeric_column"]) + source_group_column = str(parameters["numeric_group_column"]) + target_id_column = str(parameters["boolean_id_column"]) + boolean_column = str(parameters["boolean_column"]) + threshold = float(parameters.get("threshold", 0.0)) + + required_source = {numeric_column, source_group_column} + missing_source = sorted(required_source - set(source.columns)) + required_target = {target_id_column, boolean_column} + missing_target = sorted(required_target - set(target.columns)) + if missing_source or missing_target: + raise ValueError( + "column_implication evidence is missing columns: " + f"{source_entity}={missing_source}, {target_entity}={missing_target}." + ) + if target[target_id_column].duplicated().any(): + raise ValueError(f"{target_entity}.{target_id_column} must be unique.") + + numeric = pd.to_numeric(source[numeric_column], errors="coerce") + positive_ids = set(source.loc[numeric > threshold, source_group_column].tolist()) + aggregated = target[target_id_column].isin(positive_ids).to_numpy(dtype=np.int8) + result = column_implication_gate( + aggregated, + target[boolean_column], + numeric_column=f"{source_entity}.{numeric_column} aggregated to {target_entity}", + boolean_column=f"{target_entity}.{boolean_column}", + threshold=threshold, + ) + + capital_column = str(parameters["capital_column"]) + carrier_column = str(parameters["carrier_column"]) + sentinel = float(parameters.get("sentinel", -1.0)) + missing_capital = sorted({capital_column, carrier_column} - set(target.columns)) + if missing_capital: + raise ValueError( + f"column_implication {target_entity} capital evidence is missing " + f"columns {missing_capital}." + ) + capital = pd.to_numeric(target[capital_column], errors="coerce").to_numpy( + dtype=float + ) + carrier = pd.to_numeric(target[carrier_column], errors="coerce").to_numpy( + dtype=float + ) + nonfinite = ~np.isfinite(capital) | ~np.isfinite(carrier) + below_floor = np.isfinite(capital) & (capital < sentinel) + sentinel_mismatch = np.isclose(capital, sentinel) != np.isclose(carrier, sentinel) + same_source_mismatch = ( + np.isfinite(capital) & np.isfinite(carrier) & (capital != carrier) + ) + + failures = list(result.failures) + if nonfinite.any(): + failures.append( + f"{target_entity}.{capital_column}/{carrier_column}: " + f"{int(nonfinite.sum())} row(s) have non-finite carrier evidence." + ) + if below_floor.any(): + failures.append( + f"{target_entity}.{capital_column}: {int(below_floor.sum())} value(s) " + f"below the declared sentinel floor {sentinel:g}." + ) + if sentinel_mismatch.any(): + failures.append( + f"{target_entity}.{capital_column}: sentinel {sentinel:g} is allowed " + f"only where {carrier_column} has the same sentinel; " + f"{int(sentinel_mismatch.sum())} mismatch(es)." + ) + if same_source_mismatch.any(): + failures.append( + f"{target_entity}.{capital_column} must equal {carrier_column}; " + f"{int(same_source_mismatch.sum())} mismatch(es)." + ) + return GateResult( + name="column_implication", + passed=not failures, + failures=tuple(failures), + details={ + **dict(result.details), + "capital_column": f"{target_entity}.{capital_column}", + "carrier_column": f"{target_entity}.{carrier_column}", + "sentinel": sentinel, + "below_floor_count": int(below_floor.sum()), + "sentinel_mismatch_count": int(sentinel_mismatch.sum()), + "same_source_mismatch_count": int(same_source_mismatch.sum()), + "nonfinite_capital_count": int(nonfinite.sum()), + }, + ) + + def _evaluate_take_up_signal( context: EvidenceContext, parameters: Mapping[str, Any] ) -> GateResult: @@ -1143,6 +1246,24 @@ def _ledger_compile_parity_required_artifacts( evaluator=_evaluate_nonnegative_columns, artifact_keys=frozenset({"build_stage_names"}), ), + "column_implication": UKGateBinding( + name="column_implication", + evaluator=_evaluate_column_implication, + parameter_keys=frozenset( + { + "numeric_entity", + "numeric_column", + "numeric_group_column", + "boolean_entity", + "boolean_id_column", + "boolean_column", + "threshold", + "capital_column", + "carrier_column", + "sentinel", + } + ), + ), "take_up_signal": UKGateBinding( name="take_up_signal", evaluator=_evaluate_take_up_signal, diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/calibration_run.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/calibration_run.py index 7680727f1..654006311 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/calibration_run.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/calibration_run.py @@ -129,6 +129,7 @@ class UKCalibrationRunResult: "uk_release_input_coverage", "uk_degenerate_release_surface", "uk_nonnegative_columns", + "uk_uc_capital_coherence", "uk_support", "uk_aggregate_admin", "uk_export_surface", diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_spine.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_spine.py index 2da68ccf4..5ce501840 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_spine.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_spine.py @@ -22,6 +22,7 @@ "OUTPUT_COLUMNS", "REGION_MAP", "TIME_PERIOD", + "UC_CAPITAL_UNAVAILABLE", "WEEKS_IN_YEAR", "UKFRSSpineStageTransform", "artifact_by_table", @@ -57,6 +58,11 @@ WEEKS_IN_YEAR = 365.25 / 7 TIME_PERIOD = "2024" +# Raw TOTCAPB4 blanks, nonnumeric values, and negative codes mean that the +# benefit-unit capital observation is unavailable. They map to this engine +# sentinel; observed zero remains a valid capital value. +UC_CAPITAL_UNAVAILABLE = -1.0 + # FRS GVTREGNO uses skip-3 coding: code 3 (the retired Merseyside code) is # absent from the domain, so the real codes are [1, 2, 4..13] with # 12 = Scotland and 13 = Northern Ireland — matching the incumbent's @@ -210,6 +216,7 @@ "esa_income_reported", "bsp_reported", "benunit_id", + "frs_benunit_capital", "is_married", "dependent_children", "household_id", @@ -239,14 +246,33 @@ class UKFRSSpineStageTransform: def __init__(self, raw_dir: str | Path, *, stage: SourceStageSpec) -> None: self.raw_dir = Path(raw_dir) self.stage = stage + self._sentinel_mapped_rows: int | None = None def __call__(self, frame: Frame) -> Frame: - return build_uk_frs_spine_frame(self.raw_dir, stage=self.stage) + result = build_uk_frs_spine_frame(self.raw_dir, stage=self.stage) + capital = result.table("benunit")["frs_benunit_capital"] + self._sentinel_mapped_rows = int((capital == UC_CAPITAL_UNAVAILABLE).sum()) + return result @staticmethod def output_columns() -> tuple[str, ...]: return OUTPUT_COLUMNS + def checkpoint_metadata(self) -> dict[str, object]: + """Report how loudly the FRS capital availability rule fired.""" + + if self._sentinel_mapped_rows is None: + raise RuntimeError("checkpoint metadata requires a completed stage run.") + return { + "evidence": { + "stage": "frs_spine", + "frs_benunit_capital": { + "unavailable_sentinel": UC_CAPITAL_UNAVAILABLE, + "mapped_rows": self._sentinel_mapped_rows, + }, + } + } + def uk_frs_spine_seed_frame() -> Frame: """A minimal valid Frame for the root stage; the stage ignores its rows.""" @@ -366,6 +392,7 @@ def _assemble_frame(frs: Mapping[str, pd.DataFrame]) -> Frame: } ) pe_benunit = pd.DataFrame({"benunit_id": benunit_raw["benunit_id"].astype("int64")}) + pe_benunit["frs_benunit_capital"] = _frs_benunit_capital(benunit_raw) pe_household = pd.DataFrame( {"household_id": household_ids.astype("int64")}, index=household.index, @@ -813,6 +840,18 @@ def _raw_number(frame: pd.DataFrame, column: str) -> pd.Series: return pd.to_numeric(frame[column], errors="coerce") +def _frs_benunit_capital(benunit: pd.DataFrame) -> pd.Series: + """Map TOTCAPB4 to capital, using the sentinel only for unavailable values. + + The I1 FRS 2024-25 audit found all 18,850 rows populated and nonnegative. + Future raw blanks, nonnumeric values, or negative codes map to + ``UC_CAPITAL_UNAVAILABLE``; observed zero is preserved. + """ + + raw = _raw_number(benunit, "totcapb4") + return raw.where(raw.notna() & raw.ge(0), UC_CAPITAL_UNAVAILABLE).astype(float) + + def _positive(frame: pd.DataFrame, column: str) -> pd.Series: return np.maximum(_number(frame, column), 0) 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 1e4660caf..d835d5cf5 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 @@ -160,6 +160,8 @@ def __post_init__(self) -> None: # provenance or genuine additional model inputs, not incumbent-surface losses. UK_ALLOWED_EXTRA_EXPORT_COLUMNS: tuple[str, ...] = ( "benunit.child_benefit_opts_out", + "benunit.frs_benunit_capital", + "benunit.uc_reported_capital", "household.bus_fare_spending", "household.bus_subsidy_spending", "household.cash_isa", diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py new file mode 100644 index 000000000..abef0e3d4 --- /dev/null +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py @@ -0,0 +1,302 @@ +"""Late UK Universal Credit capital and take-up coherence stage.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +import numpy as np +import pandas as pd + +from microcosm.build.source_manifest import SourceStageSpec +from microcosm.build.stochastic_assignment import stable_identity_uniforms +from microcosm.build.uk_runtime.frs_spine import UC_CAPITAL_UNAVAILABLE +from microcosm.build.uk_runtime.national_frame import ( + uk_household_weight_kind, + uk_national_frame, + uk_time_period, + validate_uk_national_frame, +) +from microcosm.build.uk_runtime.spi_support import ( + BASE_FRS_SUPPORT_CHANNEL, + SPI_SYNTHETIC_SUPPORT_CHANNEL, + support_channel_column, +) +from microcosm.frame import Frame + +UC_CAPITAL_REDRAW_OUTPUT = "frs_benunit_capital" +UC_CAPITAL_REDRAW_SEED = 0 +UC_CAPITAL_REDRAW_SALT = UC_CAPITAL_REDRAW_OUTPUT +UC_CAPITAL_COHERENCE_OUTPUT_COLUMNS = ("uc_reported_capital",) + + +@dataclass(frozen=True) +class UKUCCapitalCoherenceResult: + """Output frame and receipt counts for the late coherence transform.""" + + frame: Frame + post_fill_reporter_count: int + redrawn_spi_reporter_count: int + refreshed_would_claim_count: int + + def evidence(self) -> dict[str, object]: + """Return JSON-safe stage evidence.""" + + return { + "stage": "uc_capital_coherence", + "post_fill_reporter_count": self.post_fill_reporter_count, + "redrawn_spi_reporter_count": self.redrawn_spi_reporter_count, + "refreshed_would_claim_count": self.refreshed_would_claim_count, + "redraw_seed": UC_CAPITAL_REDRAW_SEED, + "redraw_salt": UC_CAPITAL_REDRAW_SALT, + } + + +@dataclass(frozen=True) +class UKUCCapitalCoherenceStageTransform: + """Redraw SPI reporter capital and refresh UC take-up after SPI income.""" + + stage: SourceStageSpec + last_result: UKUCCapitalCoherenceResult | None = field(default=None, init=False) + + def __call__(self, frame: Frame) -> Frame: + _assert_stage_parameters(self.stage) + result = cohere_uc_capital(frame) + object.__setattr__(self, "last_result", result) + return result.frame + + @staticmethod + def output_columns() -> tuple[str, ...]: + return UC_CAPITAL_COHERENCE_OUTPUT_COLUMNS + + def checkpoint_metadata(self) -> dict[str, object]: + """Return the completed stage's redraw and refresh receipt.""" + + if self.last_result is None: + raise RuntimeError("checkpoint metadata requires a completed stage run.") + return {"evidence": self.last_result.evidence()} + + +def cohere_uc_capital(frame: Frame) -> UKUCCapitalCoherenceResult: + """Make late SPI UC receipt, FRS capital, and take-up flags coherent.""" + + validate_uk_national_frame(frame) + person = frame.table("person").copy() + benunit = frame.table("benunit").copy() + household = frame.table("household").copy() + _require_columns( + person, + ("person_benunit_id", "person_household_id", "universal_credit_reported"), + label="person", + ) + _require_columns( + benunit, + ( + "benunit_id", + support_channel_column("benunit"), + "frs_benunit_capital", + "dependent_children", + "is_married", + "would_claim_uc", + ), + label="benunit", + ) + _require_columns(household, ("household_id",), label="household") + + reporter = _post_fill_reporter_anchor(person, benunit) + capital = pd.to_numeric( + benunit[UC_CAPITAL_REDRAW_OUTPUT], errors="coerce" + ).to_numpy(dtype=float, na_value=np.nan, copy=True) + if not np.isfinite(capital).all() or (capital < UC_CAPITAL_UNAVAILABLE).any(): + raise ValueError( + "frs_benunit_capital must be finite and no lower than the named " + "unavailable sentinel." + ) + + channel = benunit[support_channel_column("benunit")].astype(str) + base = channel.eq(BASE_FRS_SUPPORT_CHANNEL).to_numpy(dtype=bool) + spi = channel.eq(SPI_SYNTHETIC_SUPPORT_CHANNEL).to_numpy(dtype=bool) + if np.any(~(base | spi)): + raise ValueError("UC capital coherence requires only FRS and SPI channels.") + redraw = spi & reporter + if redraw.any(): + _redraw_spi_reporter_capital( + benunit, + person=person, + household=household, + household_weights=frame.weights_for("household").values, + reporter=reporter, + base=base, + redraw=redraw, + capital=capital, + ) + + previous_would_claim = _boolean_values(benunit["would_claim_uc"]) + refreshed_would_claim = previous_would_claim | reporter + benunit[UC_CAPITAL_REDRAW_OUTPUT] = capital + benunit["uc_reported_capital"] = capital.copy() + benunit["would_claim_uc"] = refreshed_would_claim + + result_frame = uk_national_frame( + person=person, + benunit=benunit, + household=household, + time_period=uk_time_period(frame), + weight_kind=uk_household_weight_kind(frame), + household_weights=frame.weights_for("household").values, + mass_log=frame.mass_log, + ) + validate_uk_national_frame(result_frame) + return UKUCCapitalCoherenceResult( + frame=result_frame, + post_fill_reporter_count=int(reporter.sum()), + redrawn_spi_reporter_count=int(redraw.sum()), + refreshed_would_claim_count=int((~previous_would_claim & reporter).sum()), + ) + + +def _post_fill_reporter_anchor( + person: pd.DataFrame, benunit: pd.DataFrame +) -> np.ndarray: + amounts = pd.to_numeric( + person["universal_credit_reported"], errors="coerce" + ).fillna(0.0) + reporter_ids = person.loc[amounts > 0.0, "person_benunit_id"] + return benunit["benunit_id"].isin(reporter_ids).to_numpy(dtype=bool) + + +def _redraw_spi_reporter_capital( + benunit: pd.DataFrame, + *, + person: pd.DataFrame, + household: pd.DataFrame, + household_weights: np.ndarray, + reporter: np.ndarray, + base: np.ndarray, + redraw: np.ndarray, + capital: np.ndarray, +) -> None: + weights = _household_to_benunit_weights( + benunit, + person=person, + household=household, + household_weights=household_weights, + ) + child_band = _dependent_children_band(benunit["dependent_children"]) + couple = _boolean_values(benunit["is_married"]) + available = capital > UC_CAPITAL_UNAVAILABLE + donor = base & reporter & available & (weights > 0.0) + target_ids = benunit["benunit_id"].to_numpy() + draws = stable_identity_uniforms( + target_ids, + seed=UC_CAPITAL_REDRAW_SEED, + salt=UC_CAPITAL_REDRAW_SALT, + ) + + for band, is_couple in sorted( + set(zip(child_band[redraw], couple[redraw], strict=True)) + ): + target_cell = redraw & (child_band == band) & (couple == is_couple) + donor_cell = donor & (child_band == band) & (couple == is_couple) + if not donor_cell.any(): + label = "3+" if band == 3 else str(band) + raise ValueError( + "UC capital redraw has no positive-weight base-FRS reporter " + f"donors for dependent_children={label}, couple={is_couple}." + ) + donor_rows = pd.DataFrame( + { + "benunit_id": target_ids[donor_cell], + "capital": capital[donor_cell], + "weight": weights[donor_cell], + } + ).sort_values(["capital", "benunit_id"], kind="mergesort") + donor_values = donor_rows["capital"].to_numpy(dtype=float) + donor_weights = donor_rows["weight"].to_numpy(dtype=float) + cdf = np.cumsum(donor_weights) / float(donor_weights.sum()) + selected = np.searchsorted(cdf, draws[target_cell], side="right") + capital[target_cell] = donor_values[np.minimum(selected, len(cdf) - 1)] + + +def _household_to_benunit_weights( + benunit: pd.DataFrame, + *, + person: pd.DataFrame, + household: pd.DataFrame, + household_weights: np.ndarray, +) -> np.ndarray: + placements = person[["person_benunit_id", "person_household_id"]].drop_duplicates() + counts = placements.groupby("person_benunit_id", sort=False)[ + "person_household_id" + ].nunique() + if (counts != 1).any(): + raise ValueError("Every benefit unit must map to exactly one household.") + household_by_benunit = placements.set_index("person_benunit_id")[ + "person_household_id" + ] + weight_by_household = pd.Series( + np.asarray(household_weights, dtype=float), + index=household["household_id"], + ) + mapped_households = benunit["benunit_id"].map(household_by_benunit) + weights = mapped_households.map(weight_by_household) + if weights.isna().any(): + raise ValueError("Household weights do not cover every benefit unit.") + values = weights.to_numpy(dtype=float) + if not np.isfinite(values).all() or (values < 0.0).any(): + raise ValueError("Mapped benefit-unit weights must be finite and nonnegative.") + return values + + +def _dependent_children_band(values: pd.Series) -> np.ndarray: + numeric = pd.to_numeric(values, errors="coerce").to_numpy( + dtype=float, na_value=np.nan + ) + if ( + not np.isfinite(numeric).all() + or (numeric < 0.0).any() + or not np.equal(numeric, np.floor(numeric)).all() + ): + raise ValueError("dependent_children must contain nonnegative integers.") + return np.minimum(numeric, 3.0).astype(np.int8) + + +def _boolean_values(values: pd.Series) -> np.ndarray: + if pd.api.types.is_bool_dtype(values.dtype): + return values.to_numpy(dtype=bool) + numeric = pd.to_numeric(values, errors="coerce") + if numeric.isna().any() or not numeric.isin((0, 1)).all(): + raise ValueError(f"{values.name} must contain only boolean/0/1 values.") + return numeric.to_numpy(dtype=bool) + + +def _assert_stage_parameters(stage: SourceStageSpec) -> None: + redraw = [ + operation + for operation in stage.operations + if operation.kind == "redraw_spi_reporter_capital" + ] + if len(redraw) != 1: + raise ValueError( + "uc_capital_coherence must declare one redraw_spi_reporter_capital " + "operation." + ) + parameters = redraw[0].parameters + expected = { + "output": UC_CAPITAL_REDRAW_OUTPUT, + "seed": UC_CAPITAL_REDRAW_SEED, + "salt": UC_CAPITAL_REDRAW_SALT, + } + actual = {key: parameters.get(key) for key in expected} + if actual != expected: + raise ValueError( + "uc_capital_coherence redraw parameters drifted: " + f"expected {expected}, got {actual}." + ) + + +def _require_columns( + frame: pd.DataFrame, columns: tuple[str, ...], *, label: str +) -> None: + missing = sorted(set(columns) - set(frame.columns)) + if missing: + raise ValueError(f"UC capital coherence {label} columns missing: {missing}.") diff --git a/packages/microcosm-build/tests/test_country_spec.py b/packages/microcosm-build/tests/test_country_spec.py index df94c31f6..d12578359 100644 --- a/packages/microcosm-build/tests/test_country_spec.py +++ b/packages/microcosm-build/tests/test_country_spec.py @@ -954,13 +954,13 @@ def test_spi_spine_adds_no_country_package_resources(self) -> None: "local_target_reference_membership.json", ) - def test_uk_source_manifest_loads_twenty_seven_stages(self) -> None: + def test_uk_source_manifest_loads_twenty_eight_stages(self) -> None: spec = load_country_spec("uk") assert spec.sources is not None - # 24 spine stages (age_tail is the newest, #747) plus the two - # certified-pair stages the June path still uses. - assert len(spec.sources.stages) == 27 + # 26 spine stages (uc_capital_coherence is the newest, #828) plus the + # two certified-pair stages the June path still uses. + assert len(spec.sources.stages) == 28 class TestExistingPackagesGeneralize: @@ -1293,6 +1293,7 @@ def test_declares_the_full_june_battery(self, manifest) -> None: "uk_weight_ratio", "uk_weights_audit", "uk_nonnegative_columns", + "uk_uc_capital_coherence", "uk_support", "uk_aggregate_admin", "uk_export_surface", diff --git a/packages/microcosm-build/tests/test_gates.py b/packages/microcosm-build/tests/test_gates.py index 752493490..d7c781958 100644 --- a/packages/microcosm-build/tests/test_gates.py +++ b/packages/microcosm-build/tests/test_gates.py @@ -17,6 +17,7 @@ TargetCoverageRequirement, TargetFitRequirement, aggregate_admin_gate, + column_implication_gate, default_valued_columns_gate, enum_domain_gate, export_surface_gate, @@ -947,6 +948,47 @@ def __getitem__(self, key: slice) -> np.ndarray: assert result.details["negative_counts"] == {"auto_loan_interest": 1} +class TestColumnImplicationGate: + def test_positive_numeric_rows_require_true_boolean(self) -> None: + result = column_implication_gate( + [0.0, 10.0, -1.0, 5.0], + [False, True, False, False], + numeric_column="benefit_reported", + boolean_column="would_claim", + ) + + assert not result.passed + assert result.details["implicated_rows"] == 2 + assert result.details["violation_count"] == 1 + assert "benefit_reported > 0" in result.failures[0] + + def test_zero_and_negative_rows_do_not_imply_boolean(self) -> None: + result = column_implication_gate( + [0.0, -1.0, 1.0], + [False, False, True], + numeric_column="benefit_reported", + boolean_column="would_claim", + ) + + assert result.passed + + def test_evidence_must_be_aligned_and_boolean(self) -> None: + with pytest.raises(ValueError, match="same shape"): + column_implication_gate( + [1.0], + [True, False], + numeric_column="x", + boolean_column="y", + ) + with pytest.raises(ValueError, match="boolean or integer 0/1"): + column_implication_gate( + [1.0], + [2], + numeric_column="x", + boolean_column="y", + ) + + class TestFormulaOwnedExportGate: def test_formula_owned_column_fails_with_remedy_named(self) -> None: result = formula_owned_export_gate( diff --git a/packages/microcosm-build/tests/test_spec_engine_country_bundles.py b/packages/microcosm-build/tests/test_spec_engine_country_bundles.py index 736925be2..c4b076d9f 100644 --- a/packages/microcosm-build/tests/test_spec_engine_country_bundles.py +++ b/packages/microcosm-build/tests/test_spec_engine_country_bundles.py @@ -55,7 +55,7 @@ ), ( "uk", - "cce1c98ea40364a398ae361f4d15790c925d8379d8b9b427076d61059c7d6715", + "8d55b64e7c57ddda712ce2458b97a63f92d6350e4f75a9db1b6a54fc0b035ab2", { "benunit.benunit_id", "household.household_id", diff --git a/packages/microcosm-build/tests/test_uk_battery_bindings.py b/packages/microcosm-build/tests/test_uk_battery_bindings.py index 5d5adc3ae..c147b1267 100644 --- a/packages/microcosm-build/tests/test_uk_battery_bindings.py +++ b/packages/microcosm-build/tests/test_uk_battery_bindings.py @@ -84,9 +84,17 @@ def _tables(*, n: int = 4, weights=None): "person_household_id": household_ids, "person_benunit_id": np.arange(201, 201 + n, dtype=np.int64), "employment_income": np.arange(1, n + 1, dtype=float), + "universal_credit_reported": np.asarray([10.0, 0.0] * n)[:n], + } + ) + benunit = pd.DataFrame( + { + "benunit_id": np.arange(201, 201 + n, dtype=np.int64), + "would_claim_uc": np.asarray([True, False] * n)[:n], + "frs_benunit_capital": np.arange(n, dtype=float), + "uc_reported_capital": np.arange(n, dtype=float), } ) - benunit = pd.DataFrame({"benunit_id": np.arange(201, 201 + n, dtype=np.int64)}) household = pd.DataFrame( { "household_id": household_ids, @@ -228,6 +236,35 @@ def _nonnegative_frame(self, *, sic: list[float] | None): time_period="2023", ) + def test_uc_column_implication_binding_aggregates_and_checks_carrier(self) -> None: + person, benunit, household = _tables() + frame = uk_national_frame( + person=person, + benunit=benunit, + household=household, + time_period="2023", + ) + entry = next( + gate + for gate in load_country_spec("uk").gates.gates + if gate.id == "uk_uc_capital_coherence" + ) + + passing = UK_GATE_REGISTRY["column_implication"].evaluate( + EvidenceContext(frame=frame), entry.parameters + ) + assert passing.passed + + frame.table("benunit").loc[0, "would_claim_uc"] = False + frame.table("benunit").loc[1, "uc_reported_capital"] = -1.0 + failing = UK_GATE_REGISTRY["column_implication"].evaluate( + EvidenceContext(frame=frame), entry.parameters + ) + assert not failing.passed + assert any("must imply" in failure for failure in failing.failures) + assert any("same sentinel" in failure for failure in failing.failures) + assert any("must equal" in failure for failure in failing.failures) + def test_nonnegative_binding_requires_scheduled_stage_columns(self) -> None: # frs_employment declares sic_industry_division nonnegative; a build # that scheduled the stage but lost the column must fail — the @@ -457,7 +494,7 @@ def test_fully_armed_battery_evaluates_gate_for_gate(self) -> None: # student-loan enum gate; their evaluators have direct tests. The BRMA # enum gate is no longer among them: it moved to the spine battery's # assembled boundary, where its column is first written. - assert len(passed) == 15 + assert len(passed) == 16 qrf = by_id["uk_qrf_tail_concentration"] assert qrf.status is GateStatus.FAILED assert "declared QRF output is absent" in qrf.result.failures[0] diff --git a/packages/microcosm-build/tests/test_uk_frs_spine.py b/packages/microcosm-build/tests/test_uk_frs_spine.py index 4d684ab43..9794f83e7 100644 --- a/packages/microcosm-build/tests/test_uk_frs_spine.py +++ b/packages/microcosm-build/tests/test_uk_frs_spine.py @@ -23,6 +23,7 @@ from microcosm.build.uk_runtime.frs_spine import ( FRS_SPINE_TABLES, REGION_MAP, + UC_CAPITAL_UNAVAILABLE, WEEKS_IN_YEAR, UKFRSSpineStageTransform, build_uk_frs_spine_frame, @@ -184,8 +185,20 @@ def _fixture_tables() -> dict[str, list[dict[str, object]]]: "adult": [adult_2, adult_1], "child": [child_1], "benunit": [ - {"SERNUM": 2, "BENUNIT": 1, "FAMTYPB2": 5, "DEPCHLDB": 0}, - {"SERNUM": 1, "BENUNIT": 1, "FAMTYPB2": 7, "DEPCHLDB": 1}, + { + "SERNUM": 2, + "BENUNIT": 1, + "FAMTYPB2": 5, + "DEPCHLDB": 0, + "TOTCAPB4": 222.0, + }, + { + "SERNUM": 1, + "BENUNIT": 1, + "FAMTYPB2": 7, + "DEPCHLDB": 1, + "TOTCAPB4": 111.0, + }, ], "househol": [household_2, household_1], "pension": [ @@ -821,6 +834,41 @@ def test_root_stage_ignores_seed_frame_content(tmp_path: Path) -> None: assert records[0].stage == "frs_spine" +def test_root_stage_reports_capital_sentinel_mapping_count(tmp_path: Path) -> None: + stage = _write_fixture(tmp_path) + transform = UKFRSSpineStageTransform(tmp_path, stage=stage) + + transform(uk_frs_spine_seed_frame()) + + assert transform.checkpoint_metadata()["evidence"]["frs_benunit_capital"] == { + "unavailable_sentinel": UC_CAPITAL_UNAVAILABLE, + "mapped_rows": 0, + } + + +def test_benunit_capital_maps_unavailable_raw_values_to_named_sentinel( + tmp_path: Path, +) -> None: + tables = _fixture_tables() + tables["benunit"][0]["TOTCAPB4"] = "" + tables["benunit"][1]["TOTCAPB4"] = -7 + stage = _write_fixture(tmp_path, tables) + transform = UKFRSSpineStageTransform(tmp_path, stage=stage) + + frame = transform(uk_frs_spine_seed_frame()) + + assert frame.table("benunit")["frs_benunit_capital"].tolist() == [ + UC_CAPITAL_UNAVAILABLE, + UC_CAPITAL_UNAVAILABLE, + ] + assert ( + transform.checkpoint_metadata()["evidence"]["frs_benunit_capital"][ + "mapped_rows" + ] + == 2 + ) + + def test_direct_person_mapping_values_are_ported(tmp_path: Path) -> None: stage = _write_fixture(tmp_path) @@ -924,6 +972,8 @@ def test_household_and_benunit_mapping_values_are_ported(tmp_path: Path) -> None ) assert benunit.loc[101, "is_married"] assert benunit.loc[101, "dependent_children"] == 1 + assert benunit.loc[101, "frs_benunit_capital"] == 111.0 + assert benunit.loc[201, "frs_benunit_capital"] == 222.0 def test_region_code_map_covers_all_twelve_regions() -> None: @@ -1634,6 +1684,7 @@ def test_e8_manifest_seeds_all_reach_the_build_sidecar_harvester() -> None: declared = tool._declared_seeds([stages[name] for name in tool._STAGE_NAMES]) assert declared["cgt_incidence_clone"] == {"cgt_prior_amount": 0} + assert declared["uc_capital_coherence"] == {"frs_benunit_capital": 0} assert declared["cgt_band_donors"] == {"stack_band_donor_households": 1} assert declared["hmrc_cgt_gains_spine"] == {"within_band_draws": 552} assert declared["salary_sacrifice"] == { @@ -1694,9 +1745,7 @@ def checkpoint_metadata(self) -> dict[str, object]: "hmrc_spi_income_spine": _CheckpointStage( spi_payloads["hmrc_spi_income_spine"] ), - "cgt_incidence_clone": _CheckpointStage( - e8_payloads["cgt_incidence_clone"] - ), + "cgt_incidence_clone": _CheckpointStage(e8_payloads["cgt_incidence_clone"]), "cgt_band_donors": _CheckpointStage(e8_payloads["cgt_band_donors"]), "salary_sacrifice": _CheckpointStage(e8_payloads["salary_sacrifice"]), "student_loans": SimpleNamespace( @@ -1879,7 +1928,7 @@ def test_in_kind_benefits_map_from_the_raw_person_tapes(tmp_path: Path) -> None: def test_boundary_evidence_asks_only_the_stages_that_have_run() -> None: """The first licensed battery run failed at the assembled boundary because - the evidence provider consulted all 25 implementations, and an un-run + the evidence provider consulted all 26 implementations, and an un-run stage's checkpoint hook (correctly) refuses. Each boundary must offer only its executed prefix — an un-run stage being consulted is the regression. """ diff --git a/packages/microcosm-build/tests/test_uk_signed_differences.py b/packages/microcosm-build/tests/test_uk_signed_differences.py index 88aec675c..a51f0cef8 100644 --- a/packages/microcosm-build/tests/test_uk_signed_differences.py +++ b/packages/microcosm-build/tests/test_uk_signed_differences.py @@ -97,6 +97,45 @@ def test_committed_register_loads(self) -> None: assert register.differences assert register.scope_note + def test_uc_capital_entries_pin_new_exports_and_monotone_claim_lift(self) -> None: + register = load_uk_spine_swap_signed_differences() + + for column, identifier in { + "frs_benunit_capital": "frs-benunit-capital-net-new-column", + "uc_reported_capital": "uc-reported-capital-net-new-column", + }.items(): + entry = register.matching( + surface="nonzero_shares", + column=column, + expectation="column_missing_in_reference", + entity="benunit", + ) + assert entry is not None + assert entry.id == identifier + + claim = register.matching( + surface="nonzero_shares", + column="would_claim_uc", + expectation="column_differs", + entity="benunit", + ) + assert claim is not None + assert claim.id == "uc-reporter-claim-refresh-lift" + assert claim.quantitative == { + "shares": { + "would_claim_uc": { + "incumbent_share": 0.550692, + "direction": "candidate_above", + "max_abs_delta": 0.0367, + } + }, + "magnitude_provenance": ( + "I1 pre-change receipts .codex-work/828_before_ab.json and " + ".codex-work/828_before_c.json; 2,245 SPI-channel false " + "reporters divided by 61,211 benunits, rounded up at 1e-4 grain." + ), + } + def test_committed_entries_are_precisely_scoped(self) -> None: # A surface-wide entry (empty columns) signs every column on that # surface. That is a real capability for entity_counts, but on a diff --git a/packages/microcosm-build/tests/test_uk_source_stages.py b/packages/microcosm-build/tests/test_uk_source_stages.py index aaaceec6a..1520c9fd6 100644 --- a/packages/microcosm-build/tests/test_uk_source_stages.py +++ b/packages/microcosm-build/tests/test_uk_source_stages.py @@ -46,6 +46,9 @@ "spi_support_channel", "hmrc_spi_income_spine", ] +UC_COHERENCE_STAGE_NAMES = [ + "uc_capital_coherence", +] E8_STAGE_NAMES = [ "cgt_incidence_clone", "cgt_band_donors", @@ -67,6 +70,7 @@ *E5_STAGE_NAMES, *E6_STAGE_NAMES, *E7_STAGE_NAMES, + *UC_COHERENCE_STAGE_NAMES, *E8_STAGE_NAMES, *POST_E8_STAGE_NAMES, "frs_hmrc_retained_leaves", @@ -78,6 +82,7 @@ *E4_STAGE_NAMES, *E6_STAGE_NAMES, *E7_STAGE_NAMES, + *UC_COHERENCE_STAGE_NAMES, *E8_STAGE_NAMES, *POST_E8_STAGE_NAMES, ] @@ -147,7 +152,7 @@ def test_e7_block_sits_between_e6_and_e8(self) -> None: assert ( names[names.index("etb_services") + 1 : names.index("cgt_incidence_clone")] - == E7_STAGE_NAMES + == [*E7_STAGE_NAMES, *UC_COHERENCE_STAGE_NAMES] ) def test_e8_block_is_contiguous_and_the_certified_pair_stays_last(self) -> None: @@ -281,6 +286,7 @@ def test_country_stage_plan_assembles_spine_plan(self) -> None: "frs_hmrc_spine_leaves": _identity, "spi_support_channel": _identity, "hmrc_spi_income_spine": _identity, + "uc_capital_coherence": _identity, "cgt_incidence_clone": _identity, "cgt_band_donors": _identity, "hmrc_cgt_gains_spine": _identity, @@ -625,6 +631,11 @@ def test_e3_operation_kinds_are_declared_in_order(self) -> None: "classify_hmrc_income_facts_with_reviewed_fences", "gate_distributional_effective_mass", ] + assert [op.kind for op in stages["uc_capital_coherence"].operations] == [ + "aggregate_person_to_benunit", + "redraw_spi_reporter_capital", + "derive", + ] assert [op.kind for op in stages["cgt_incidence_clone"].operations] == [ "clone_records", "draw_capital_gains_prior_from_banded_quantiles", @@ -827,6 +838,9 @@ def test_e7_declared_seed_lockstep(self) -> None: assert stages["spi_support_channel"].operations[0].parameters["seed"] == 42 assert stages["hmrc_spi_income_spine"].operations[2].parameters["seed"] == 42 assert stages["hmrc_spi_income_spine"].operations[3].parameters["seed"] == 43 + assert ( + stages["uc_capital_coherence"].operations[1].parameters["seed"] == 0 + ) def test_e8_declared_seed_lockstep(self) -> None: stages = load_country_spec("uk").sources.stage_map() diff --git a/packages/microcosm-build/tests/test_uk_spine_acceptance_receipt.py b/packages/microcosm-build/tests/test_uk_spine_acceptance_receipt.py index 214e8f436..d937fca63 100644 --- a/packages/microcosm-build/tests/test_uk_spine_acceptance_receipt.py +++ b/packages/microcosm-build/tests/test_uk_spine_acceptance_receipt.py @@ -2,9 +2,9 @@ microcosm#771: the previous acceptance evidence quietly described a 24-stage build after the plan had grown to 25. This binder makes that class of drift a -CI failure: the receipt's stage roster must equal the roster the spine driver -actually executes, its verdicts must be the accepted ones, and its identity -bases must name the stage-time contract the instruments verify. +CI failure. The #828 stage is deliberately pending the licensed I7 rebuild, so +the historical receipt stays truthful while the test pins its one reviewed +roster difference from the current driver. """ from __future__ import annotations @@ -36,9 +36,16 @@ def _driver_stage_names() -> tuple[str, ...]: def test_receipt_roster_is_the_production_plan(): receipt = _receipt() - roster = tuple(receipt["candidate"]["stage_roster"]) - assert roster == _driver_stage_names() - assert receipt["candidate"]["stage_count"] == len(roster) + accepted_roster = tuple(receipt["candidate"]["stage_roster"]) + production_roster = _driver_stage_names() + coherence_index = production_roster.index("uc_capital_coherence") + + assert production_roster == ( + *accepted_roster[:coherence_index], + "uc_capital_coherence", + *accepted_roster[coherence_index:], + ) + assert receipt["candidate"]["stage_count"] == len(accepted_roster) def test_receipt_identity_and_verdicts_are_the_accepted_ones(): diff --git a/packages/microcosm-build/tests/test_uk_uc_capital_coherence.py b/packages/microcosm-build/tests/test_uk_uc_capital_coherence.py new file mode 100644 index 000000000..920fbedb8 --- /dev/null +++ b/packages/microcosm-build/tests/test_uk_uc_capital_coherence.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from microcosm.build.country_spec import load_country_spec +from microcosm.build.uk_runtime.national_frame import uk_national_frame +from microcosm.build.uk_runtime.spi_support import support_channel_column +from microcosm.build.uk_runtime.uc_capital_coherence import ( + UC_CAPITAL_REDRAW_OUTPUT, + UC_CAPITAL_REDRAW_SALT, + UC_CAPITAL_REDRAW_SEED, + UKUCCapitalCoherenceStageTransform, + _boolean_values, + _dependent_children_band, + _redraw_spi_reporter_capital, + cohere_uc_capital, +) +from microcosm.frame import WeightKind +from microcosm.frame.adapters.policyengine_uk import PolicyEngineUKEngine + + +def _stage(): + spec = load_country_spec("uk") + assert spec.sources is not None + return spec.sources.stage_map()["uc_capital_coherence"] + + +def _frame(): + rows = [ + # Base-FRS reporter donors in the 0-child, non-couple cell. Their + # household weights are 1:9, making the target draw a weighted test. + (1, 101, 1001, "frs", 100.0, 0, False, False, 10.0, 1.0), + (2, 201, 2001, "frs", 200.0, 0, False, True, 10.0, 9.0), + # The only base reporter donor in the 1-child, couple cell. + (3, 301, 3001, "frs", 3_000.0, 1, True, False, 10.0, 4.0), + # Base non-reporters exercise both remaining OR truth-table rows. + (4, 401, 4001, "frs", 999_999.0, 0, False, True, 0.0, 5.0), + (5, 501, 5001, "frs", 777_777.0, 0, False, False, 0.0, 5.0), + # SPI post-fill reporters are redrawn; the non-reporter is preserved. + (6, 1005, 6001, "spi", 999_999.0, 0, False, False, 10.0, 0.5), + (7, 1006, 7001, "spi", 888_888.0, 0, False, True, 0.0, 0.5), + (8, 1007, 8001, "spi", 999_999.0, 1, True, False, 10.0, 0.5), + ] + person = pd.DataFrame( + { + "person_id": [row[2] for row in rows], + "person_benunit_id": [row[1] for row in rows], + "person_household_id": [row[0] for row in rows], + "universal_credit_reported": [row[8] for row in rows], + } + ) + benunit = pd.DataFrame( + { + "benunit_id": [row[1] for row in rows], + support_channel_column("benunit"): [row[3] for row in rows], + "frs_benunit_capital": [row[4] for row in rows], + "dependent_children": [row[5] for row in rows], + "is_married": [row[6] for row in rows], + "would_claim_uc": [row[7] for row in rows], + } + ) + household = pd.DataFrame({"household_id": [row[0] for row in rows]}) + return uk_national_frame( + person=person, + benunit=benunit, + household=household, + household_weights=np.asarray([row[9] for row in rows]), + weight_kind=WeightKind.IMPORTANCE, + time_period="2024", + ) + + +def test_manifest_declares_exact_redraw_seed_and_output() -> None: + stage = _stage() + redraw = next( + operation + for operation in stage.operations + if operation.kind == "redraw_spi_reporter_capital" + ) + + assert redraw.parameters["output"] == UC_CAPITAL_REDRAW_OUTPUT + assert redraw.parameters["seed"] == UC_CAPITAL_REDRAW_SEED + assert redraw.parameters["salt"] == UC_CAPITAL_REDRAW_SALT + assert stage.outputs == ("uc_reported_capital",) + assert stage.rewrites == ("frs_benunit_capital", "would_claim_uc") + + +def test_stage_orders_after_every_universal_credit_report_writer() -> None: + spec = load_country_spec("uk") + assert spec.sources is not None + stages = spec.sources.stages + coherence_index = next( + index + for index, stage in enumerate(stages) + if stage.stage == "uc_capital_coherence" + ) + reporter_writers = [ + (index, stage.stage) + for index, stage in enumerate(stages) + if "universal_credit_reported" in (*stage.outputs, *stage.rewrites) + ] + + assert reporter_writers + assert all(index < coherence_index for index, _ in reporter_writers) + assert stages[coherence_index + 1].stage == "cgt_incidence_clone" + + +def test_or_refresh_truth_table_and_same_capital_source() -> None: + result = cohere_uc_capital(_frame()) + benunit = result.frame.table("benunit").set_index("benunit_id") + + assert benunit.loc[101, "would_claim_uc"] + assert benunit.loc[201, "would_claim_uc"] + assert benunit.loc[401, "would_claim_uc"] + assert not benunit.loc[501, "would_claim_uc"] + assert benunit.loc[1005, "would_claim_uc"] + assert benunit.loc[1007, "would_claim_uc"] + np.testing.assert_array_equal( + benunit["uc_reported_capital"], benunit["frs_benunit_capital"] + ) + assert result.refreshed_would_claim_count == 4 + + +def test_redraw_is_reporter_conditioned_cell_exact_and_household_weighted() -> None: + result = cohere_uc_capital(_frame()) + benunit = result.frame.table("benunit").set_index("benunit_id") + + # benunit 1005's identity draw is 0.364. The weighted donor CDF is + # [0.1, 1.0], so it selects 200; an unweighted draw would select 100. + assert benunit.loc[1005, "frs_benunit_capital"] == 200.0 + assert benunit.loc[1007, "frs_benunit_capital"] == 3_000.0 + assert benunit.loc[1006, "frs_benunit_capital"] == 888_888.0 + assert benunit.loc[401, "frs_benunit_capital"] == 999_999.0 + assert result.redrawn_spi_reporter_count == 2 + + +def test_transform_is_deterministic_and_idempotent() -> None: + transform = UKUCCapitalCoherenceStageTransform(stage=_stage()) + + first = transform(_frame()) + twin = UKUCCapitalCoherenceStageTransform(stage=_stage())(_frame()) + repeated = UKUCCapitalCoherenceStageTransform(stage=_stage())(first) + + for candidate in (twin, repeated): + for entity in ("person", "benunit", "household"): + pd.testing.assert_frame_equal( + first.table(entity), candidate.table(entity), check_exact=True + ) + assert transform.checkpoint_metadata()["evidence"] == { + "stage": "uc_capital_coherence", + "post_fill_reporter_count": 5, + "redrawn_spi_reporter_count": 2, + "refreshed_would_claim_count": 4, + "redraw_seed": UC_CAPITAL_REDRAW_SEED, + "redraw_salt": UC_CAPITAL_REDRAW_SALT, + } + + +def test_redraw_is_stable_under_input_row_permutation() -> None: + frame = _frame() + person = frame.table("person").copy() + benunit = frame.table("benunit").copy() + household = frame.table("household").copy() + weights = frame.weights_for("household").values.copy() + + def redraw_tables( + person_table: pd.DataFrame, + benunit_table: pd.DataFrame, + household_table: pd.DataFrame, + household_weight_values: np.ndarray, + ) -> pd.Series: + reporter_ids = person_table.loc[ + person_table["universal_credit_reported"] > 0, + "person_benunit_id", + ] + reporter = benunit_table["benunit_id"].isin(reporter_ids).to_numpy() + base = benunit_table[support_channel_column("benunit")].eq("frs").to_numpy() + redraw = ( + benunit_table[support_channel_column("benunit")].eq("spi").to_numpy() + & reporter + ) + capital = benunit_table["frs_benunit_capital"].to_numpy(dtype=float).copy() + _redraw_spi_reporter_capital( + benunit_table, + person=person_table, + household=household_table, + household_weights=household_weight_values, + reporter=reporter, + base=base, + redraw=redraw, + capital=capital, + ) + return pd.Series(capital, index=benunit_table["benunit_id"]).sort_index() + + expected = redraw_tables(person, benunit, household, weights) + order = np.asarray([7, 2, 5, 0, 6, 1, 4, 3]) + actual = redraw_tables( + person.iloc[order].reset_index(drop=True), + benunit.iloc[order].reset_index(drop=True), + household.iloc[order].reset_index(drop=True), + weights[order], + ) + + pd.testing.assert_series_equal(expected, actual) + + +def test_children_band_caps_at_three_plus_and_boolean_helper_is_strict() -> None: + np.testing.assert_array_equal( + _dependent_children_band(pd.Series([0, 1, 2, 3, 8])), + np.asarray([0, 1, 2, 3, 3], dtype=np.int8), + ) + np.testing.assert_array_equal( + _boolean_values(pd.Series([False, True], name="flag")), + np.asarray([False, True]), + ) + + +@pytest.mark.requires_uk +def test_engine_uses_reported_capital_and_sentinel_routes_to_residual_proxy() -> None: + person = pd.DataFrame( + { + "person_id": [1001, 1002, 1003], + "person_benunit_id": [101, 102, 103], + "person_household_id": [1, 2, 3], + "age": [40, 40, 40], + "is_benunit_head": [True, True, True], + "universal_credit_reported": [10.0, 10.0, 10.0], + } + ) + benunit = pd.DataFrame( + { + "benunit_id": [101, 102, 103], + "uc_reported_capital": [0.0, 16_000.0, -1.0], + "frs_benunit_capital": [0.0, 16_000.0, -1.0], + "would_claim_uc": [True, True, True], + } + ) + household = pd.DataFrame( + { + "household_id": [1, 2, 3], + "region": ["LONDON", "LONDON", "LONDON"], + "council_tax": [0.0, 0.0, 0.0], + "tenure_type": ["OWNED_OUTRIGHT", "OWNED_OUTRIGHT", "OWNED_OUTRIGHT"], + "rent": [0.0, 0.0, 0.0], + "savings": [100_000.0, 100_000.0, 7_000.0], + "other_residential_property_value": [0.0, 0.0, 0.0], + "non_residential_property_value": [0.0, 0.0, 0.0], + "corporate_wealth": [0.0, 0.0, 0.0], + } + ) + frame = uk_national_frame( + person=person, + benunit=benunit, + household=household, + household_weights=np.ones(3), + weight_kind=WeightKind.DESIGN, + time_period="2024", + ) + + result = PolicyEngineUKEngine().materialize(frame, ["uc_assessable_capital"], 2024)[ + "uc_assessable_capital" + ] + + np.testing.assert_array_equal(result, np.asarray([0.0, 16_000.0, 7_000.0])) diff --git a/packages/microcosm-data/src/microcosm/data/contract.py b/packages/microcosm-data/src/microcosm/data/contract.py index 9b93a6d9a..f3306e803 100644 --- a/packages/microcosm-data/src/microcosm/data/contract.py +++ b/packages/microcosm-data/src/microcosm/data/contract.py @@ -375,13 +375,13 @@ # fingerprint derives from the manifest digest. Editing the spec moves all # three here in the same reviewed change. _UK_GATE_BATTERY_POLICY_SHA256 = ( - "12c8a7fd526932decf19954881f43a123451f0454ac2603ff5ab08b0d246e37a" + "12aab28f1e8e49347887c53fe1fabd228a5eda045964d65224390e0ce8b118d5" ) _UK_GATE_BATTERY_GATES_MANIFEST_SHA256 = ( - "2a7cb1441d9c9bab3afde33ad1a2957484c7bde46f93c65386b98dd7a665b812" + "efdb12a1f97421197871aefbb7de4be90e5d9a4f0461e6c6e72e5dcc8cf65089" ) _UK_GATE_BATTERY_SPEC_FINGERPRINT = ( - "65a2c85db2abd8edd935fda79e5c5ef8e15f89ba59ec4e2763d485c5170fd550" + "96186a467471393be608dc638f8288db9ebfdcf2f54a1afbaf8f070db6716746" ) #: Spec entry id -> the legacy gate name whose observable detail checks #: apply unchanged (the battery re-keys the report by entry id; the gate @@ -394,6 +394,7 @@ "uk_weight_ratio": "weight_ratio", "uk_weights_audit": "weights_audit", "uk_nonnegative_columns": "nonnegative_columns", + "uk_uc_capital_coherence": "column_implication", "uk_support": "support", "uk_aggregate_admin": "aggregate_vs_admin", "uk_export_surface": "export_surface", @@ -460,6 +461,7 @@ "uk_weight_ratio": ("weight_ratio", "terminal"), "uk_weights_audit": ("weights_audit", "terminal"), "uk_nonnegative_columns": ("nonnegative_columns", "terminal"), + "uk_uc_capital_coherence": ("column_implication", "terminal"), "uk_support": ("support", "terminal"), "uk_aggregate_admin": ("aggregate_admin", "terminal"), "uk_export_surface": ("export_surface", "terminal"), @@ -600,6 +602,7 @@ "uk_target_surface_local_default_2025", "uk_ledger_compile_parity_production_2023", "uk_nonnegative_columns", + "uk_uc_capital_coherence", "uk_qrf_tail_concentration", "uk_release_family_build_stages", "uk_release_input_coverage", @@ -631,10 +634,10 @@ }, "release_cut": { "gates_manifest_sha256": ( - "cb480ea8735648bd089322bb434ce87f48fb71b7ad62f55f091d7e7356049c55" + "97f25fa3cf48b8828450831be7c986704740eddb35ed08a27da950e8dc412b64" ), "policy_sha256": ( - "775d7a91fa1fc4aebb312bd45f18e5d9a077bb38d936fc408a2f812c341ecc95" + "b72d6c6289e71e0e59556aea707676847b602a230e83f2d96bfd1fd4a9e86883" ), }, } @@ -3064,9 +3067,7 @@ def _check_uk_certification_evidence_binding( ( "score_receipt", _UK_CERTIFICATION_SCORE_RECEIPT_FILE, - score_receipt.get("sha256") - if isinstance(score_receipt, Mapping) - else None, + score_receipt.get("sha256") if isinstance(score_receipt, Mapping) else None, ) ) for owner, filename, signed_sha in bindings: @@ -3088,8 +3089,7 @@ def _check_uk_certification_evidence_binding( continue if _sha256(path) != signed_sha: failures.append( - f"{filename} does not match the certification's signed " - f"{owner}.sha256." + f"{filename} does not match the certification's signed {owner}.sha256." ) @@ -4432,9 +4432,10 @@ def validate_release_dir(release_dir: Path | str) -> None: failures, grandfathered_uk_june=release_id == _UK_JUNE_RELEASE_ID, ) - if _is_uk_exact_k_release_id( - release_id - ) or release_id == _UK_NATIONAL_RELEASE_ID: + if ( + _is_uk_exact_k_release_id(release_id) + or release_id == _UK_NATIONAL_RELEASE_ID + ): _check_uk_calibration_diagnostics(diagnostics, failures) if _is_uk_exact_k_release_id(release_id): _check_uk_exact_k_diagnostics_identity( diff --git a/tools/build_uk_frs_spine.py b/tools/build_uk_frs_spine.py index bb3fccbf4..bd86c6263 100644 --- a/tools/build_uk_frs_spine.py +++ b/tools/build_uk_frs_spine.py @@ -96,6 +96,9 @@ ) from microcosm.build.uk_runtime.student_loans import UKStudentLoansStageTransform from microcosm.build.uk_runtime.take_up_contract import load_uk_take_up_contract +from microcosm.build.uk_runtime.uc_capital_coherence import ( + UKUCCapitalCoherenceStageTransform, +) from microcosm.build.uk_runtime.was_wealth import UKWASWealthStageTransform from microcosm.frame.adapters.policyengine_uk import PolicyEngineUKEngine @@ -128,6 +131,7 @@ "frs_hmrc_spine_leaves", "spi_support_channel", "hmrc_spi_income_spine", + "uc_capital_coherence", "cgt_incidence_clone", "cgt_band_donors", "hmrc_cgt_gains_spine", @@ -499,9 +503,7 @@ def _collect_stage_evidence( metadata = dict(metadata_hook()) evidence = metadata.get("evidence", metadata) else: - evidence = _result_evidence( - getattr(implementation, "last_result", None) - ) + evidence = _result_evidence(getattr(implementation, "last_result", None)) if evidence is not None: evidence_by_stage[stage_name] = evidence return evidence_by_stage @@ -532,10 +534,9 @@ def _collect_fit_weight_records( continue # Detect the hook without evaluating it: a raising property must # count as a fitting stage with unreadable records, not vanish. - exposes_records = ( - getattr(type(implementation), "fit_weight_records", None) is not None - or "fit_weight_records" in getattr(implementation, "__dict__", {}) - ) + exposes_records = getattr( + type(implementation), "fit_weight_records", None + ) is not None or "fit_weight_records" in getattr(implementation, "__dict__", {}) if not exposes_records: continue try: @@ -1021,6 +1022,12 @@ def main(argv: list[str] | None = None) -> int: sample_fraction=args.sample_fraction, ) implementations["hmrc_spi_income_spine"] = hmrc_spine_transform + if "uc_capital_coherence" in stage_names: + implementations["uc_capital_coherence"] = ( + UKUCCapitalCoherenceStageTransform( + stage=stages_by_name["uc_capital_coherence"] + ) + ) if "cgt_incidence_clone" in stage_names: implementations["cgt_incidence_clone"] = UKCGTIncidenceCloneStageTransform( stage=stages_by_name["cgt_incidence_clone"] From 90030870ef4f39c8c693e464fd69775fa5247245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:40:42 +0200 Subject: [PATCH 2/3] Fix CI derived surfaces and disposition adversarial-review round 1 (#829) - test_contract.py: gate-battery digest mirrors moved in lockstep with contract.py; exact-k terminal fixture gains the uk_uc_capital_coherence entry and its column_implication detail block - signed-difference evidence anchors re-pointed at the committed experiments/828-uc-capital-receipts.md (the .codex-work copies are git-excluded, so CI could not resolve them) - review finding 2: binding de-conflates the person-amount threshold from the aggregated-indicator comparison (pinned 0.0), refuses negative thresholds, records amount_threshold in details; regression test proves a violation fires at a nonzero amount threshold - review findings 3/4: guarded-surface intent documented (the terminal capital checks guard stages between the producer and the boundary), and the ordering/determinism contract stated on the coherence module Co-Authored-By: Claude Fable 5 --- experiments/828-uc-capital-receipts.md | 45 + .../uk/spine_swap_signed_differences.json | 1058 ++++++++--------- .../build/uk_runtime/battery_bindings.py | 23 +- .../build/uk_runtime/uc_capital_coherence.py | 14 +- .../tests/test_uk_battery_bindings.py | 33 + .../microcosm-data/tests/test_contract.py | 31 +- 6 files changed, 669 insertions(+), 535 deletions(-) create mode 100644 experiments/828-uc-capital-receipts.md diff --git a/experiments/828-uc-capital-receipts.md b/experiments/828-uc-capital-receipts.md new file mode 100644 index 000000000..757ea802f --- /dev/null +++ b/experiments/828-uc-capital-receipts.md @@ -0,0 +1,45 @@ +# 828 — UC capital coherence: I1 before-receipts + +Committed, disclosure-safe record of the #828 I1 measurements (2026-08-31), the evidence +anchors for the three `spine_swap_signed_differences.json` entries this PR adds. The raw +measurement scripts and JSON receipts live licensed-side in +`data/ukds/acceptance/828-uc-capital/` (not in this repository); every aggregate below is +weighted or count-based with minimum cell count 3, and each receipt pins its inputs by +digest: `benunit.tab` `66b89462…` (matches the `frs_spine` manifest pin), spine-k H5 +`b4403ea4…`, policyengine-uk 2.92.1 at year 2024. + +## Part A — TOTCAPB4 domain audit + +The FRS 2024-25 `benunit.tab` `TOTCAPB4` column is **fully populated**: 18,850/18,850 +benefit units carry a value ≥ 0 (0 NaN rows, 0 negative codes, 2,812 exact zeros, 16,038 +positive). **Zero rows map to the −1 unavailable sentinel in this build** — stated loudly +per the round-2 adjudication; the `frs_spine` stage reports its mapped-row count in +checkpoint evidence so a future vintage with absences announces itself. General-population +share above the £16,000 UC capital limit: 26.5% weighted (30.9% unweighted) — against +0.36% among weighted UC reporters, the capital screen that makes donor-preserve unsafe for +synthetic reporters. + +## Part B — A3 sizing receipt + +Weighted SPI-channel post-fill UC reporters whose donor `TOTCAPB4` exceeds £16,000: +**0.454m weighted (606 records)** — 9× the ruled 0.05m negligibility threshold, so the +conditional redraw stays (adjudication A3). By dependent-children band: 0 → 0.276m, +1 → 0.068m, 2 → 0.096m, 3+ → 0.013m. SPI post-fill reporters total 2.001m weighted, of +which 1.640m are receipt-flips against their donor. Join coverage: 23,301/23,301 SPI +benunit source ids matched to the raw tab. + +## Part C — engine blocker aggregates on spine-k + +Measured with policyengine-uk 2.92.1 on the spine-k artifact — the "before" side of +acceptance criterion 7: + +| Blocker (weighted benunits) | This receipt | Issue #828 evidence | +|---|---|---| +| reported UC and `would_claim_uc = false` | 0.893m | 0.893m | +| reported UC and `uc_assessable_capital` > £16k | 0.936m | 0.939m | +| union | 1.492m | 1.495m | +| false-high: proxy > £16k while own FRS capital ≤ £16k | 0.622m | — (issue's 0.475m was raw-reporter-scoped) | + +Reported-UC benunits on spine-k support: 4.375m weighted. The full-spine source-ID join +resolves 61,211/61,211 benunits to raw `TOTCAPB4` — the criterion-1 provenance proof that +benefit-unit capital is reachable with no household-grain reassignment. diff --git a/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json b/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json index 4380d96ef..bc9ce2e33 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json +++ b/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json @@ -1,541 +1,541 @@ { - "schema_version": 1, - "scope_note": "Adjudicated intentional differences between the microcosm-built UK spine and the frozen enhanced-FRS incumbent (#686). The whole-spine comparison treats any difference beyond the #723 acceptance band that is not signed here as a defect, so every entry is scoped to the exact surface and columns where the difference is expected to appear: a too-broad entry would sign a real defect. Entries are permanent adjudications and carry no expiry; time-limited per-gate suppressions belong in input_mass_reviewed_exclusions.json, qrf_tail_reviewed_exclusions.json or degenerate_reviewed_exclusions.json instead, and an entry here points at one through its evidence field when both descend from the same adjudication. The twenty-nine beyond-band divergences measured or structurally expected against the re-pinned 1.56.16 reference are signed below, each scoped to the divergence actually observed rather than carried across on its prose classification, and each deliberately split so that no entry covers both columns where the spine is closer to its donor and columns where the incumbent is: the direction of the evidence is part of what is being signed. Donor figures quoted as magnitude evidence are survey-weighted shares and population means computed on each stage's own committed cleaning function over its own pinned donor tab, which is the convention that reproduces the E6 acceptance receipt's education figure exactly. The incumbent side of every figure is measured on the pinned 1.56.16 artifact itself (sha256 e433e532), not on a local rebuild: an earlier pass read the 1.56.14 published artifact, whose shares differ by up to 0.0045 and which flipped one verdict.", - "differences": [ - { - "id": "scottish-water-incumbent-nan-zeroing", - "class": "defect_fix", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "water_and_sewerage_charges" - ], - "entities": [ - "household" - ] - }, - "expectation": "column_differs", - "magnitude_evidence": "The spine's unweighted nonzero share exceeds the incumbent's by about +0.10 on the whole file. FRS 2024-25 retired CWATAMT/CSEWAMT: the headers survive but carry no data in any of the 16,288 households. The incumbent adds the retired CSEWAMT before filling, so NaN propagates and the charge is zeroed for every Scottish household that has one; the spine fills per column and they stand. Reproduced on the raw tab as 12,644 nonzero households for the incumbent formula against 14,307 for ours, a +0.1021 share gap whose 1,663 differing households are all Scottish, with England, Wales and Northern Ireland identical under both. Latent since at least 2023-24, where 378 Scottish households were already affected. The defect is on the incumbent side and survives at 1.56.16.", - "evidence": "experiments/686-uk-spine-swap-receipts.md#r1--scottish-water-and-sewerage-charges-736-item-13", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-22", - "quantitative": { - "shares": { - "water_and_sewerage_charges": { - "incumbent_share": 0.776937, - "direction": "candidate_above", - "max_abs_delta": 0.1009 - } - }, - "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." - } + "schema_version": 1, + "scope_note": "Adjudicated intentional differences between the microcosm-built UK spine and the frozen enhanced-FRS incumbent (#686). The whole-spine comparison treats any difference beyond the #723 acceptance band that is not signed here as a defect, so every entry is scoped to the exact surface and columns where the difference is expected to appear: a too-broad entry would sign a real defect. Entries are permanent adjudications and carry no expiry; time-limited per-gate suppressions belong in input_mass_reviewed_exclusions.json, qrf_tail_reviewed_exclusions.json or degenerate_reviewed_exclusions.json instead, and an entry here points at one through its evidence field when both descend from the same adjudication. The twenty-nine beyond-band divergences measured or structurally expected against the re-pinned 1.56.16 reference are signed below, each scoped to the divergence actually observed rather than carried across on its prose classification, and each deliberately split so that no entry covers both columns where the spine is closer to its donor and columns where the incumbent is: the direction of the evidence is part of what is being signed. Donor figures quoted as magnitude evidence are survey-weighted shares and population means computed on each stage's own committed cleaning function over its own pinned donor tab, which is the convention that reproduces the E6 acceptance receipt's education figure exactly. The incumbent side of every figure is measured on the pinned 1.56.16 artifact itself (sha256 e433e532), not on a local rebuild: an earlier pass read the 1.56.14 published artifact, whose shares differ by up to 0.0045 and which flipped one verdict.", + "differences": [ + { + "id": "scottish-water-incumbent-nan-zeroing", + "class": "defect_fix", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "water_and_sewerage_charges" + ], + "entities": [ + "household" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "The spine's unweighted nonzero share exceeds the incumbent's by about +0.10 on the whole file. FRS 2024-25 retired CWATAMT/CSEWAMT: the headers survive but carry no data in any of the 16,288 households. The incumbent adds the retired CSEWAMT before filling, so NaN propagates and the charge is zeroed for every Scottish household that has one; the spine fills per column and they stand. Reproduced on the raw tab as 12,644 nonzero households for the incumbent formula against 14,307 for ours, a +0.1021 share gap whose 1,663 differing households are all Scottish, with England, Wales and Northern Ireland identical under both. Latent since at least 2023-24, where 378 Scottish households were already affected. The defect is on the incumbent side and survives at 1.56.16.", + "evidence": "experiments/686-uk-spine-swap-receipts.md#r1--scottish-water-and-sewerage-charges-736-item-13", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-22", + "quantitative": { + "shares": { + "water_and_sewerage_charges": { + "incumbent_share": 0.776937, + "direction": "candidate_above", + "max_abs_delta": 0.1009 + } }, - { - "id": "scottish-water-sewerage-successor-level", - "class": "mechanism_change", - "scope": { - "surface": "weighted_totals", - "columns": [ - "water_and_sewerage_charges", - "council_tax" - ], - "entities": [ - "household" - ] - }, - "expectation": "column_differs", - "magnitude_evidence": "The Scottish charge is assembled from the successors FRS 2024-25 published for the cells it retired, so its level rises against both the incumbent and our own earlier build. CWATAMTD is the water charge alone; CSEWAMT1 supplies the sewerage side and is discounted at the household's own observed CWATAMTD/CWATAMT1 factor, which keeps the retired cells' after-discount meaning rather than switching to a gross basis. Weighted annual per Scottish household moves from about GBP 185 on water alone to about GBP 395, against roughly GBP 490 for England and Wales on WATSEWRT; the incumbent sits at zero because of the separate NaN defect. The same amount is netted from council_tax, so that column moves by the same construction. The nonzero share is unaffected, so this entry deliberately does not sign the share surface.", - "evidence": "experiments/686-uk-spine-swap-receipts.md#r1--scottish-water-and-sewerage-charges-736-item-13", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-22", - "quantitative": { - "weighted_totals": { - "expected_columns": [ - "water_and_sewerage_charges", - "council_tax" - ] - } - } - }, - { - "id": "lcfs-consumption-regime-gated-incidence", - "class": "mechanism_change", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "communication_consumption", - "domestic_energy_consumption", - "education_consumption", - "electricity_consumption", - "gas_consumption", - "health_consumption", - "household_furnishings_consumption", - "miscellaneous_consumption", - "restaurants_and_hotels_consumption" - ], - "entities": [ - "household" - ] - }, - "expectation": "column_differs", - "magnitude_evidence": "The spine draws LCFS consumption through a regime-gated QRF that carries the donor's zero mass as a modelled incidence, where the incumbent's plain QRF regresses a zero-inflated target toward its conditional mean. On these nine columns the spine's unweighted share is closer to the LCFS 2023-24 survey-weighted donor share than the incumbent's is, measured on the stage's own cleaned donor frame of 4,202 households against the pinned 1.56.16 artifact (sha256 e433e532): education_consumption donor 0.0476 against incumbent 0.1256 and ours 0.0170; restaurants_and_hotels donor 0.7651 against 0.6324 and 0.7903; miscellaneous donor 0.9728 against 0.9003 and 0.9918; domestic_energy donor 0.9835 against 0.9549 and 0.9953, with its electricity and gas components moving the same way. Population mean per household is closer for the spine on eight of the nine, the exception being communication_consumption at 1.20x the donor against the incumbent's 1.15x; the incumbent runs 1.7x to 4.8x the donor mean on the rest. Every cell is above the disclosure floor, the thinnest being 193 donor carriers on education_consumption.", - "evidence": "experiments/686-uk-spine-comparison-ledger.md#e6--consumption--signed", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-24", - "quantitative": { - "shares": { - "communication_consumption": { - "incumbent_share": 0.79743, - "direction": "candidate_above", - "max_abs_delta": 0.084 - }, - "domestic_energy_consumption": { - "incumbent_share": 0.954869, - "direction": "candidate_above", - "max_abs_delta": 0.0404 - }, - "education_consumption": { - "incumbent_share": 0.125591, - "direction": "candidate_below", - "max_abs_delta": 0.1087 - }, - "electricity_consumption": { - "incumbent_share": 0.86209, - "direction": "candidate_above", - "max_abs_delta": 0.1024 - }, - "gas_consumption": { - "incumbent_share": 0.953241, - "direction": "candidate_above", - "max_abs_delta": 0.042 - }, - "health_consumption": { - "incumbent_share": 0.512849, - "direction": "candidate_above", - "max_abs_delta": 0.0258 - }, - "household_furnishings_consumption": { - "incumbent_share": 0.813553, - "direction": "candidate_above", - "max_abs_delta": 0.1081 - }, - "miscellaneous_consumption": { - "incumbent_share": 0.900333, - "direction": "candidate_above", - "max_abs_delta": 0.0915 - }, - "restaurants_and_hotels_consumption": { - "incumbent_share": 0.632441, - "direction": "candidate_above", - "max_abs_delta": 0.1579 - } - }, - "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." - } - }, - { - "id": "lcfs-fuel-consumption-incidence-gate", - "class": "mechanism_change", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "diesel_spending", - "petrol_spending" - ], - "entities": [ - "household" - ] - }, - "expectation": "column_differs", - "magnitude_evidence": "These two are signed with the evidence pointing the other way on incidence, and that is the point of scoping them apart from the rest of the LCFS class. The spine gates fuel spending on a has_fuel draw, so it places incidence on fewer households than either the donor or the incumbent: petrol donor 0.3911 against incumbent 0.4446 and ours 0.3002, diesel donor 0.2040 against 0.1910 and 0.1580. The incumbent is closer on both shares. On level the ordering reverses decisively - population mean per household is 0.85x the donor for petrol and 0.81x for diesel against the incumbent's 2.05x and 2.31x - so the gate is under-placing incidence while the incumbent is over-stating amounts by roughly a factor of two. Signed as the accepted cost of the fuel gate, not as a claim that the spine is closer here; the incidence rate of the gate is the pre-registered lever if this surface needs to move.", - "evidence": "experiments/686-uk-spine-comparison-ledger.md#e6--consumption--signed", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-24", - "quantitative": { - "shares": { - "diesel_spending": { - "incumbent_share": 0.191027, - "direction": "candidate_below", - "max_abs_delta": 0.0331 - }, - "petrol_spending": { - "incumbent_share": 0.444632, - "direction": "candidate_below", - "max_abs_delta": 0.1445 - } - }, - "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." - } - }, - { - "id": "lcfs-aggregate-incidence-incumbent-closer", - "class": "mechanism_change", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "alcohol_and_tobacco_consumption", - "transport_consumption" - ], - "entities": [ - "household" - ] - }, - "expectation": "column_differs", - "magnitude_evidence": "Scoped apart from the rest of the LCFS class because on these two the incumbent is closer on the share and the spine is closer on the level, so a class verdict would misstate both. transport_consumption: donor 0.8702 against incumbent 0.8668 and ours 0.8934, so the spine overshoots by 0.0232 where the incumbent undershoots by 0.0034; on level the spine is at 1.37x the donor population mean per household against the incumbent's 1.62x. alcohol_and_tobacco_consumption: donor 0.5383 against incumbent 0.5603 and ours 0.5150, so the incumbent is off by 0.0220 and the spine by 0.0233 - close enough that it read as a tie against the previous 1.56.14 pin and resolves to the incumbent against the pinned 1.56.16 artifact; on level the two are within a point of each other, 1.24x for the spine against 1.26x. Signed as accepted incidence costs of the regime-gated draw, with the direction recorded so each can be re-examined on its own rather than under a class verdict it does not share.", - "evidence": "experiments/686-uk-spine-comparison-ledger.md#e6--consumption--signed", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-24", - "quantitative": { - "shares": { - "alcohol_and_tobacco_consumption": { - "incumbent_share": 0.560288, - "direction": "candidate_below", - "max_abs_delta": 0.0453 - }, - "transport_consumption": { - "incumbent_share": 0.866802, - "direction": "candidate_above", - "max_abs_delta": 0.0266 - } - }, - "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." - } - }, - { - "id": "etb-services-regime-gated-incidence", - "class": "defect_fix", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "bus_subsidy_spending", - "dfe_education_spending" - ], - "entities": [ - "household" - ] - }, - "expectation": "column_differs", - "magnitude_evidence": "The incumbent's state-education column is degenerate and the spine's is not, which makes this a defect fix on the incumbent side rather than a method preference. Measured on the ETB services stage's own cleaned donor frame \u2014 SN 8856, year 2023, complete cases on the thirteen-column services subset, 4,199 rows, weighted by hhold_adj_weight \u2014 dfe_education_spending has a donor share of 0.2794 weighted (0.2546 unweighted, which reproduces the E6 acceptance receipt's figure exactly) and a donor population mean of GBP 3,461 per household. The incumbent carries 14 nonzero households out of 52,846, a share of 0.000265 and a population mean of GBP 2 per household; the spine carries 11,934, a share of 0.2258 and GBP 3,111, or 0.90x the donor. bus_subsidy_spending moves the same way: donor share 0.5255 weighted and GBP 87 per household, against incumbent 0.3167 and GBP 113 (1.30x) and ours 0.5554 and GBP 89 (1.02x). The spine is closer on both the share and the level of both columns. This resolves the ETB weight-basis question that was previously recorded as blocking these rows: the stage's convention is the household grossing weight, and the verdict holds on either basis.", - "evidence": "experiments/686-uk-spine-comparison-ledger.md#etb--the-weight-basis-question-is-closed", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-24", - "quantitative": { - "shares": { - "bus_subsidy_spending": { - "incumbent_share": 0.316675, - "direction": "candidate_above", - "max_abs_delta": 0.2388 - }, - "dfe_education_spending": { - "incumbent_share": 0.000265, - "direction": "candidate_above", - "max_abs_delta": 0.2256 - } - }, - "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." - } - }, - { - "id": "was-wealth-qrf-incidence", - "class": "qrf_implementation", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "corporate_wealth", - "main_residence_value", - "other_residential_property_value", - "property_wealth", - "savings" - ], - "entities": [ - "household" - ] - }, - "expectation": "column_differs", - "magnitude_evidence": "Carries forward the E5 adjudication of 2026-08-19 \u2014 that the wealth stage is not required to reproduce the incumbent's inflated totals \u2014 now scoped to the five household columns that actually diverge beyond the band, and re-measured against WAS Round 8 on the stage's own cleaning of the pinned tab (15,128 rows, weighted by R8xshhwgt). The spine is closer than the incumbent on all five survey-weighted donor shares: savings donor 0.6072 against incumbent 0.6620 and ours 0.6107; other_residential_property_value donor 0.0363 against 0.0763 and 0.0367; property_wealth donor 0.6433 against 0.7081 and 0.6607; main_residence_value donor 0.6236 against 0.6747 and 0.6356; corporate_wealth donor 0.7629 against 0.8222 and 0.7792. The level evidence behind the original adjudication reproduces and is the more dramatic surface: population mean per household runs 5.66x the donor for the incumbent's savings against 1.97x for ours, and 6.12x against 1.26x for other residential property. main_residence_value is the one column where the incumbent's level is closer, at 1.02x against our 0.88x. Note that the unweighted donor shares tell the opposite story on incidence, because WAS oversamples wealth-holders by design; the weighted basis is the population one and is the basis quoted here throughout.", - "evidence": "experiments/686-uk-spine-comparison-ledger.md#e5--wealth--signed-carried-forward", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-24", - "quantitative": { - "shares": { - "corporate_wealth": { - "incumbent_share": 0.822181, - "direction": "candidate_below", - "max_abs_delta": 0.0431 - }, - "main_residence_value": { - "incumbent_share": 0.674658, - "direction": "candidate_below", - "max_abs_delta": 0.0391 - }, - "other_residential_property_value": { - "incumbent_share": 0.076316, - "direction": "candidate_below", - "max_abs_delta": 0.0397 - }, - "property_wealth": { - "incumbent_share": 0.708057, - "direction": "candidate_below", - "max_abs_delta": 0.0474 - }, - "savings": { - "incumbent_share": 0.661999, - "direction": "candidate_below", - "max_abs_delta": 0.0513 - } - }, - "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." - } - }, - { - "id": "was-student-loan-balance-fold", - "class": "qrf_implementation", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "student_loan_balance" - ], - "entities": [ - "person" - ] - }, - "expectation": "column_differs", - "magnitude_evidence": "Scoped apart from the benchmarked wealth columns because it has no donor benchmark to quote: the column is a fold of two WAS aggregates (total loans less total loans excluding Student Loans Company debt) and is a person-entity column where the wealth columns beside it are household-entity, so no like-for-like donor share exists on the stage's cleaned frame. The observed divergence is +0.0296 on the unweighted share, incumbent 0.0197 against ours 0.0493 \u2014 the spine places student debt on about two and a half times as many carriers. Signed under the standing E5 adjudication as part of the same correlated-rank draw, with the absence of a benchmark stated rather than papered over; if the wealth stage is revisited, this is the column whose direction is unevidenced.", - "evidence": "experiments/686-uk-spine-comparison-ledger.md#e5--wealth--signed-carried-forward", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-24", - "quantitative": { - "shares": { - "student_loan_balance": { - "incumbent_share": 0.019707, - "direction": "candidate_above", - "max_abs_delta": 0.0297 - } - }, - "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." - } + "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." + } + }, + { + "id": "scottish-water-sewerage-successor-level", + "class": "mechanism_change", + "scope": { + "surface": "weighted_totals", + "columns": [ + "water_and_sewerage_charges", + "council_tax" + ], + "entities": [ + "household" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "The Scottish charge is assembled from the successors FRS 2024-25 published for the cells it retired, so its level rises against both the incumbent and our own earlier build. CWATAMTD is the water charge alone; CSEWAMT1 supplies the sewerage side and is discounted at the household's own observed CWATAMTD/CWATAMT1 factor, which keeps the retired cells' after-discount meaning rather than switching to a gross basis. Weighted annual per Scottish household moves from about GBP 185 on water alone to about GBP 395, against roughly GBP 490 for England and Wales on WATSEWRT; the incumbent sits at zero because of the separate NaN defect. The same amount is netted from council_tax, so that column moves by the same construction. The nonzero share is unaffected, so this entry deliberately does not sign the share surface.", + "evidence": "experiments/686-uk-spine-swap-receipts.md#r1--scottish-water-and-sewerage-charges-736-item-13", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-22", + "quantitative": { + "weighted_totals": { + "expected_columns": [ + "water_and_sewerage_charges", + "council_tax" + ] + } + } + }, + { + "id": "lcfs-consumption-regime-gated-incidence", + "class": "mechanism_change", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "communication_consumption", + "domestic_energy_consumption", + "education_consumption", + "electricity_consumption", + "gas_consumption", + "health_consumption", + "household_furnishings_consumption", + "miscellaneous_consumption", + "restaurants_and_hotels_consumption" + ], + "entities": [ + "household" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "The spine draws LCFS consumption through a regime-gated QRF that carries the donor's zero mass as a modelled incidence, where the incumbent's plain QRF regresses a zero-inflated target toward its conditional mean. On these nine columns the spine's unweighted share is closer to the LCFS 2023-24 survey-weighted donor share than the incumbent's is, measured on the stage's own cleaned donor frame of 4,202 households against the pinned 1.56.16 artifact (sha256 e433e532): education_consumption donor 0.0476 against incumbent 0.1256 and ours 0.0170; restaurants_and_hotels donor 0.7651 against 0.6324 and 0.7903; miscellaneous donor 0.9728 against 0.9003 and 0.9918; domestic_energy donor 0.9835 against 0.9549 and 0.9953, with its electricity and gas components moving the same way. Population mean per household is closer for the spine on eight of the nine, the exception being communication_consumption at 1.20x the donor against the incumbent's 1.15x; the incumbent runs 1.7x to 4.8x the donor mean on the rest. Every cell is above the disclosure floor, the thinnest being 193 donor carriers on education_consumption.", + "evidence": "experiments/686-uk-spine-comparison-ledger.md#e6--consumption--signed", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-24", + "quantitative": { + "shares": { + "communication_consumption": { + "incumbent_share": 0.79743, + "direction": "candidate_above", + "max_abs_delta": 0.084 + }, + "domestic_energy_consumption": { + "incumbent_share": 0.954869, + "direction": "candidate_above", + "max_abs_delta": 0.0404 + }, + "education_consumption": { + "incumbent_share": 0.125591, + "direction": "candidate_below", + "max_abs_delta": 0.1087 + }, + "electricity_consumption": { + "incumbent_share": 0.86209, + "direction": "candidate_above", + "max_abs_delta": 0.1024 + }, + "gas_consumption": { + "incumbent_share": 0.953241, + "direction": "candidate_above", + "max_abs_delta": 0.042 + }, + "health_consumption": { + "incumbent_share": 0.512849, + "direction": "candidate_above", + "max_abs_delta": 0.0258 + }, + "household_furnishings_consumption": { + "incumbent_share": 0.813553, + "direction": "candidate_above", + "max_abs_delta": 0.1081 + }, + "miscellaneous_consumption": { + "incumbent_share": 0.900333, + "direction": "candidate_above", + "max_abs_delta": 0.0915 + }, + "restaurants_and_hotels_consumption": { + "incumbent_share": 0.632441, + "direction": "candidate_above", + "max_abs_delta": 0.1579 + } }, - { - "id": "spi-channel-qrf-incidence", - "class": "qrf_implementation", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "employer_pension_contributions", - "savings_interest_income", - "tax_free_savings_income" - ], - "entities": [ - "person" - ] - }, - "expectation": "column_differs", - "magnitude_evidence": "The three columns rewritten on the SPI channel, each measured against its own source of truth rather than against one another, which is what closes the open item #717 left. savings_interest_income against the SPI donor INCBBS, FACT-weighted: truth 0.3960, incumbent 0.4250, ours 0.3946. tax_free_savings_income against the raw FRS at the frs_spine stage: truth 0.1540, incumbent 0.1897, ours 0.1352. employer_pension_contributions against the 3x derive at frs_hmrc_spine_leaves: truth 0.2587, incumbent 0.3149, ours 0.2682. The spine is closer on all three, so the divergence #717 recorded as uniformly one-way and unexplained is uniformly toward the source. Attribution is to the last stage that rewrites, not the stage that produces: the first two originate in frs_spine and are rewritten by hmrc_spi_income_spine, and attributing them to their producer would report them as raw-mapping defects, which is the one signature that indicates a genuine port defect.", - "evidence": "experiments/686-uk-spine-comparison-ledger.md#e7--spi-channel--evidence-gap-closed-favours-the-spine", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-24", - "quantitative": { - "shares": { - "employer_pension_contributions": { - "incumbent_share": 0.314865, - "direction": "candidate_below", - "max_abs_delta": 0.0467 - }, - "savings_interest_income": { - "incumbent_share": 0.424963, - "direction": "candidate_below", - "max_abs_delta": 0.0305 - }, - "tax_free_savings_income": { - "incumbent_share": 0.189664, - "direction": "candidate_below", - "max_abs_delta": 0.0545 - } - }, - "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." - } + "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." + } + }, + { + "id": "lcfs-fuel-consumption-incidence-gate", + "class": "mechanism_change", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "diesel_spending", + "petrol_spending" + ], + "entities": [ + "household" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "These two are signed with the evidence pointing the other way on incidence, and that is the point of scoping them apart from the rest of the LCFS class. The spine gates fuel spending on a has_fuel draw, so it places incidence on fewer households than either the donor or the incumbent: petrol donor 0.3911 against incumbent 0.4446 and ours 0.3002, diesel donor 0.2040 against 0.1910 and 0.1580. The incumbent is closer on both shares. On level the ordering reverses decisively - population mean per household is 0.85x the donor for petrol and 0.81x for diesel against the incumbent's 2.05x and 2.31x - so the gate is under-placing incidence while the incumbent is over-stating amounts by roughly a factor of two. Signed as the accepted cost of the fuel gate, not as a claim that the spine is closer here; the incidence rate of the gate is the pre-registered lever if this surface needs to move.", + "evidence": "experiments/686-uk-spine-comparison-ledger.md#e6--consumption--signed", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-24", + "quantitative": { + "shares": { + "diesel_spending": { + "incumbent_share": 0.191027, + "direction": "candidate_below", + "max_abs_delta": 0.0331 + }, + "petrol_spending": { + "incumbent_share": 0.444632, + "direction": "candidate_below", + "max_abs_delta": 0.1445 + } }, - { - "id": "salary-sacrifice-conversion-depth", - "class": "mechanism_change", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "employee_pension_contributions" - ], - "entities": [ - "person" - ] - }, - "expectation": "column_differs", - "magnitude_evidence": "Signed at #684 and transcribed here against the re-pinned reference. The spine converts salary-sacrificed pension contributions at the depth the mechanism specifies, where the incumbent's conversion step was inert, so contributions that should have moved out of the employee column stayed in it. Unweighted share 0.2735 for the incumbent against 0.2246 for the spine, a difference of -0.0488 on the person entity. The counterpart column pension_contributions_via_salary_sacrifice sits inside the acceptance band at -0.0035 and is therefore not signed.", - "evidence": "experiments/686-uk-spine-comparison-ledger.md#e8-and-entity-counts--signed-at-684", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-24", - "quantitative": { - "shares": { - "employee_pension_contributions": { - "incumbent_share": 0.273489, - "direction": "candidate_below", - "max_abs_delta": 0.0489 - } - }, - "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." - } + "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." + } + }, + { + "id": "lcfs-aggregate-incidence-incumbent-closer", + "class": "mechanism_change", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "alcohol_and_tobacco_consumption", + "transport_consumption" + ], + "entities": [ + "household" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "Scoped apart from the rest of the LCFS class because on these two the incumbent is closer on the share and the spine is closer on the level, so a class verdict would misstate both. transport_consumption: donor 0.8702 against incumbent 0.8668 and ours 0.8934, so the spine overshoots by 0.0232 where the incumbent undershoots by 0.0034; on level the spine is at 1.37x the donor population mean per household against the incumbent's 1.62x. alcohol_and_tobacco_consumption: donor 0.5383 against incumbent 0.5603 and ours 0.5150, so the incumbent is off by 0.0220 and the spine by 0.0233 - close enough that it read as a tie against the previous 1.56.14 pin and resolves to the incumbent against the pinned 1.56.16 artifact; on level the two are within a point of each other, 1.24x for the spine against 1.26x. Signed as accepted incidence costs of the regime-gated draw, with the direction recorded so each can be re-examined on its own rather than under a class verdict it does not share.", + "evidence": "experiments/686-uk-spine-comparison-ledger.md#e6--consumption--signed", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-24", + "quantitative": { + "shares": { + "alcohol_and_tobacco_consumption": { + "incumbent_share": 0.560288, + "direction": "candidate_below", + "max_abs_delta": 0.0453 + }, + "transport_consumption": { + "incumbent_share": 0.866802, + "direction": "candidate_above", + "max_abs_delta": 0.0266 + } }, - { - "id": "donor-selection-rng-entity-counts", - "class": "rng_stream", - "scope": { - "surface": "entity_counts", - "columns": [ - "benunit", - "person" - ], - "entities": [ - "benunit", - "person" - ] - }, - "expectation": "count_differs", - "magnitude_evidence": "The CGT band-donor selection draws over id-sorted candidate households, so the 270 donors it picks are not the 270 the incumbent picked, and the two sets carry different numbers of people and benefit units. Persons 113,617 in the reference against 113,649 in the spine, a difference of +32; benefit units 61,223 against 61,211, a difference of -12. Households are 52,846 on both sides and match exactly, which is what proves this is a selection difference rather than a miscount: the record-count identity (16,288 raw FRS plus 10,000 SPI) times two for the capital-gains clone, plus 270 band donors, closes on the nose. This entry is deliberately scoped to the two entities that differ rather than written surface-wide, so that any future divergence in the household count is still a defect.", - "evidence": "experiments/686-uk-spine-swap-receipts.md#r3--the-spine-rebuilt-and-the-l2-adjudication-queue", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-24", - "quantitative": { - "expected_deltas": { - "benunit": -12, - "person": 32 - } - } + "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." + } + }, + { + "id": "etb-services-regime-gated-incidence", + "class": "defect_fix", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "bus_subsidy_spending", + "dfe_education_spending" + ], + "entities": [ + "household" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "The incumbent's state-education column is degenerate and the spine's is not, which makes this a defect fix on the incumbent side rather than a method preference. Measured on the ETB services stage's own cleaned donor frame \u2014 SN 8856, year 2023, complete cases on the thirteen-column services subset, 4,199 rows, weighted by hhold_adj_weight \u2014 dfe_education_spending has a donor share of 0.2794 weighted (0.2546 unweighted, which reproduces the E6 acceptance receipt's figure exactly) and a donor population mean of GBP 3,461 per household. The incumbent carries 14 nonzero households out of 52,846, a share of 0.000265 and a population mean of GBP 2 per household; the spine carries 11,934, a share of 0.2258 and GBP 3,111, or 0.90x the donor. bus_subsidy_spending moves the same way: donor share 0.5255 weighted and GBP 87 per household, against incumbent 0.3167 and GBP 113 (1.30x) and ours 0.5554 and GBP 89 (1.02x). The spine is closer on both the share and the level of both columns. This resolves the ETB weight-basis question that was previously recorded as blocking these rows: the stage's convention is the household grossing weight, and the verdict holds on either basis.", + "evidence": "experiments/686-uk-spine-comparison-ledger.md#etb--the-weight-basis-question-is-closed", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-24", + "quantitative": { + "shares": { + "bus_subsidy_spending": { + "incumbent_share": 0.316675, + "direction": "candidate_above", + "max_abs_delta": 0.2388 + }, + "dfe_education_spending": { + "incumbent_share": 0.000265, + "direction": "candidate_above", + "max_abs_delta": 0.2256 + } }, - { - "id": "num-bedrooms-net-new-column", - "class": "net_new_column", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "num_bedrooms" - ], - "entities": [ - "household" - ] - }, - "expectation": "column_missing_in_reference", - "magnitude_evidence": "The spine populates num_bedrooms at the frs_spine stage from the raw household tape; the pinned incumbent does not populate it at all, so the column is present in the candidate and absent from the reference. This is coverage the spine adds rather than a divergence in a shared column, and it cannot be measured as a share difference. Its predictor quality is a separate question tracked on #145, not a parity matter.", - "evidence": "experiments/686-uk-spine-comparison-ledger.md#column-coverage", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-24", - "quantitative": { - "structural": { - "expected_columns": [ - "num_bedrooms" - ] - } - } + "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." + } + }, + { + "id": "was-wealth-qrf-incidence", + "class": "qrf_implementation", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "corporate_wealth", + "main_residence_value", + "other_residential_property_value", + "property_wealth", + "savings" + ], + "entities": [ + "household" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "Carries forward the E5 adjudication of 2026-08-19 \u2014 that the wealth stage is not required to reproduce the incumbent's inflated totals \u2014 now scoped to the five household columns that actually diverge beyond the band, and re-measured against WAS Round 8 on the stage's own cleaning of the pinned tab (15,128 rows, weighted by R8xshhwgt). The spine is closer than the incumbent on all five survey-weighted donor shares: savings donor 0.6072 against incumbent 0.6620 and ours 0.6107; other_residential_property_value donor 0.0363 against 0.0763 and 0.0367; property_wealth donor 0.6433 against 0.7081 and 0.6607; main_residence_value donor 0.6236 against 0.6747 and 0.6356; corporate_wealth donor 0.7629 against 0.8222 and 0.7792. The level evidence behind the original adjudication reproduces and is the more dramatic surface: population mean per household runs 5.66x the donor for the incumbent's savings against 1.97x for ours, and 6.12x against 1.26x for other residential property. main_residence_value is the one column where the incumbent's level is closer, at 1.02x against our 0.88x. Note that the unweighted donor shares tell the opposite story on incidence, because WAS oversamples wealth-holders by design; the weighted basis is the population one and is the basis quoted here throughout.", + "evidence": "experiments/686-uk-spine-comparison-ledger.md#e5--wealth--signed-carried-forward", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-24", + "quantitative": { + "shares": { + "corporate_wealth": { + "incumbent_share": 0.822181, + "direction": "candidate_below", + "max_abs_delta": 0.0431 + }, + "main_residence_value": { + "incumbent_share": 0.674658, + "direction": "candidate_below", + "max_abs_delta": 0.0391 + }, + "other_residential_property_value": { + "incumbent_share": 0.076316, + "direction": "candidate_below", + "max_abs_delta": 0.0397 + }, + "property_wealth": { + "incumbent_share": 0.708057, + "direction": "candidate_below", + "max_abs_delta": 0.0474 + }, + "savings": { + "incumbent_share": 0.661999, + "direction": "candidate_below", + "max_abs_delta": 0.0513 + } }, - { - "id": "other-investment-income-net-new-column", - "class": "net_new_column", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "other_investment_income" - ], - "entities": [ - "person" - ] - }, - "expectation": "column_missing_in_reference", - "magnitude_evidence": "The spine populates other_investment_income at the hmrc_spi_income_spine stage. The column is declared by the incumbent's own national restoration but is not populated in the pinned artifact, so the spine is ahead of the reference here rather than diverging from it. Present in the candidate, absent from the reference, and not measurable as a share difference.", - "evidence": "experiments/686-uk-spine-comparison-ledger.md#column-coverage", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-24", - "quantitative": { - "structural": { - "expected_columns": [ - "other_investment_income" - ] - } - } + "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." + } + }, + { + "id": "was-student-loan-balance-fold", + "class": "qrf_implementation", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "student_loan_balance" + ], + "entities": [ + "person" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "Scoped apart from the benchmarked wealth columns because it has no donor benchmark to quote: the column is a fold of two WAS aggregates (total loans less total loans excluding Student Loans Company debt) and is a person-entity column where the wealth columns beside it are household-entity, so no like-for-like donor share exists on the stage's cleaned frame. The observed divergence is +0.0296 on the unweighted share, incumbent 0.0197 against ours 0.0493 \u2014 the spine places student debt on about two and a half times as many carriers. Signed under the standing E5 adjudication as part of the same correlated-rank draw, with the absence of a benchmark stated rather than papered over; if the wealth stage is revisited, this is the column whose direction is unevidenced.", + "evidence": "experiments/686-uk-spine-comparison-ledger.md#e5--wealth--signed-carried-forward", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-24", + "quantitative": { + "shares": { + "student_loan_balance": { + "incumbent_share": 0.019707, + "direction": "candidate_above", + "max_abs_delta": 0.0297 + } }, - { - "id": "frs-benunit-capital-net-new-column", - "class": "net_new_column", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "frs_benunit_capital" - ], - "entities": [ - "benunit" - ] - }, - "expectation": "column_missing_in_reference", - "magnitude_evidence": "The spine now persists the FRS TOTCAPB4 benunit carrier as frs_benunit_capital. The pinned enhanced-FRS reference has no column with that name, so this is a structural candidate-only export rather than a value divergence on a shared surface. The I1 domain receipt found all 18,850 current-vintage donor rows populated: 16,038 positive and 2,812 zero, with no blank or negative rows.", - "evidence": ".codex-work/828_before_ab.json", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-31", - "quantitative": { - "structural": { - "expected_columns": [ - "frs_benunit_capital" - ] - } - } + "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." + } + }, + { + "id": "spi-channel-qrf-incidence", + "class": "qrf_implementation", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "employer_pension_contributions", + "savings_interest_income", + "tax_free_savings_income" + ], + "entities": [ + "person" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "The three columns rewritten on the SPI channel, each measured against its own source of truth rather than against one another, which is what closes the open item #717 left. savings_interest_income against the SPI donor INCBBS, FACT-weighted: truth 0.3960, incumbent 0.4250, ours 0.3946. tax_free_savings_income against the raw FRS at the frs_spine stage: truth 0.1540, incumbent 0.1897, ours 0.1352. employer_pension_contributions against the 3x derive at frs_hmrc_spine_leaves: truth 0.2587, incumbent 0.3149, ours 0.2682. The spine is closer on all three, so the divergence #717 recorded as uniformly one-way and unexplained is uniformly toward the source. Attribution is to the last stage that rewrites, not the stage that produces: the first two originate in frs_spine and are rewritten by hmrc_spi_income_spine, and attributing them to their producer would report them as raw-mapping defects, which is the one signature that indicates a genuine port defect.", + "evidence": "experiments/686-uk-spine-comparison-ledger.md#e7--spi-channel--evidence-gap-closed-favours-the-spine", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-24", + "quantitative": { + "shares": { + "employer_pension_contributions": { + "incumbent_share": 0.314865, + "direction": "candidate_below", + "max_abs_delta": 0.0467 + }, + "savings_interest_income": { + "incumbent_share": 0.424963, + "direction": "candidate_below", + "max_abs_delta": 0.0305 + }, + "tax_free_savings_income": { + "incumbent_share": 0.189664, + "direction": "candidate_below", + "max_abs_delta": 0.0545 + } }, - { - "id": "uc-reported-capital-net-new-column", - "class": "net_new_column", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "uc_reported_capital" - ], - "entities": [ - "benunit" - ] - }, - "expectation": "column_missing_in_reference", - "magnitude_evidence": "The coherence stage now persists uc_reported_capital from the same frs_benunit_capital carrier for PolicyEngine-UK's UC means-test seam. The pinned enhanced-FRS reference has no column with that name, so its presence is a structural candidate-only export. The I1 receipt establishes the carrier domain and the separate before-engine receipt pins the complete 61,211-benunit join used by this seam.", - "evidence": ".codex-work/828_before_c.json", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-31", - "quantitative": { - "structural": { - "expected_columns": [ - "uc_reported_capital" - ] - } - } + "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." + } + }, + { + "id": "salary-sacrifice-conversion-depth", + "class": "mechanism_change", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "employee_pension_contributions" + ], + "entities": [ + "person" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "Signed at #684 and transcribed here against the re-pinned reference. The spine converts salary-sacrificed pension contributions at the depth the mechanism specifies, where the incumbent's conversion step was inert, so contributions that should have moved out of the employee column stayed in it. Unweighted share 0.2735 for the incumbent against 0.2246 for the spine, a difference of -0.0488 on the person entity. The counterpart column pension_contributions_via_salary_sacrifice sits inside the acceptance band at -0.0035 and is therefore not signed.", + "evidence": "experiments/686-uk-spine-comparison-ledger.md#e8-and-entity-counts--signed-at-684", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-24", + "quantitative": { + "shares": { + "employee_pension_contributions": { + "incumbent_share": 0.273489, + "direction": "candidate_below", + "max_abs_delta": 0.0489 + } }, - { - "id": "uc-reporter-claim-refresh-lift", - "class": "mechanism_change", - "scope": { - "surface": "nonzero_shares", - "columns": [ - "would_claim_uc" - ], - "entities": [ - "benunit" - ] - }, - "expectation": "column_differs", - "magnitude_evidence": "The refresh is monotone and limited to post-fill reported-UC benunits: it ORs the reporter anchor into would_claim_uc and never turns an existing true value false. Against the incumbent's 0.550692 unweighted nonzero share, the I1 sizing receipt identifies 2,245 SPI-channel reporter records that are false before refresh out of 61,211 spine benunits, an expected whole-spine lift of 0.03668 (bounded at 0.0367 below); those rows carry 1.640 million weighted benunits. The I1 before-engine receipt independently measures 0.893 million weighted reported-UC benunits blocked by a false would_claim_uc flag before this repair.", - "evidence": ".codex-work/828_before_ab.json", - "adjudicator": "juaristi22", - "adjudicated_on": "2026-08-31", - "quantitative": { - "shares": { - "would_claim_uc": { - "incumbent_share": 0.550692, - "direction": "candidate_above", - "max_abs_delta": 0.0367 - } - }, - "magnitude_provenance": "I1 pre-change receipts .codex-work/828_before_ab.json and .codex-work/828_before_c.json; 2,245 SPI-channel false reporters divided by 61,211 benunits, rounded up at 1e-4 grain." - } + "magnitude_provenance": "max_abs_delta re-measured 2026-08-26 on the 25-stage candidate spine-e (sha256 3c8799970851c409e4cb8578d33a180acb30ae600f4bd99ca3a190f9c5eb870a) against the packaged 1.56.16 reference, rounded up at 1e-4 grain; the prior bounds were the comparison ledger's rounded quotes measured on the 24-stage pre-SPI-zero-fix build." + } + }, + { + "id": "donor-selection-rng-entity-counts", + "class": "rng_stream", + "scope": { + "surface": "entity_counts", + "columns": [ + "benunit", + "person" + ], + "entities": [ + "benunit", + "person" + ] + }, + "expectation": "count_differs", + "magnitude_evidence": "The CGT band-donor selection draws over id-sorted candidate households, so the 270 donors it picks are not the 270 the incumbent picked, and the two sets carry different numbers of people and benefit units. Persons 113,617 in the reference against 113,649 in the spine, a difference of +32; benefit units 61,223 against 61,211, a difference of -12. Households are 52,846 on both sides and match exactly, which is what proves this is a selection difference rather than a miscount: the record-count identity (16,288 raw FRS plus 10,000 SPI) times two for the capital-gains clone, plus 270 band donors, closes on the nose. This entry is deliberately scoped to the two entities that differ rather than written surface-wide, so that any future divergence in the household count is still a defect.", + "evidence": "experiments/686-uk-spine-swap-receipts.md#r3--the-spine-rebuilt-and-the-l2-adjudication-queue", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-24", + "quantitative": { + "expected_deltas": { + "benunit": -12, + "person": 32 + } + } + }, + { + "id": "num-bedrooms-net-new-column", + "class": "net_new_column", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "num_bedrooms" + ], + "entities": [ + "household" + ] + }, + "expectation": "column_missing_in_reference", + "magnitude_evidence": "The spine populates num_bedrooms at the frs_spine stage from the raw household tape; the pinned incumbent does not populate it at all, so the column is present in the candidate and absent from the reference. This is coverage the spine adds rather than a divergence in a shared column, and it cannot be measured as a share difference. Its predictor quality is a separate question tracked on #145, not a parity matter.", + "evidence": "experiments/686-uk-spine-comparison-ledger.md#column-coverage", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-24", + "quantitative": { + "structural": { + "expected_columns": [ + "num_bedrooms" + ] + } + } + }, + { + "id": "other-investment-income-net-new-column", + "class": "net_new_column", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "other_investment_income" + ], + "entities": [ + "person" + ] + }, + "expectation": "column_missing_in_reference", + "magnitude_evidence": "The spine populates other_investment_income at the hmrc_spi_income_spine stage. The column is declared by the incumbent's own national restoration but is not populated in the pinned artifact, so the spine is ahead of the reference here rather than diverging from it. Present in the candidate, absent from the reference, and not measurable as a share difference.", + "evidence": "experiments/686-uk-spine-comparison-ledger.md#column-coverage", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-24", + "quantitative": { + "structural": { + "expected_columns": [ + "other_investment_income" + ] } - ] + } + }, + { + "id": "frs-benunit-capital-net-new-column", + "class": "net_new_column", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "frs_benunit_capital" + ], + "entities": [ + "benunit" + ] + }, + "expectation": "column_missing_in_reference", + "magnitude_evidence": "The spine now persists the FRS TOTCAPB4 benunit carrier as frs_benunit_capital. The pinned enhanced-FRS reference has no column with that name, so this is a structural candidate-only export rather than a value divergence on a shared surface. The I1 domain receipt found all 18,850 current-vintage donor rows populated: 16,038 positive and 2,812 zero, with no blank or negative rows.", + "evidence": "experiments/828-uc-capital-receipts.md#part-a--totcapb4-domain-audit", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-31", + "quantitative": { + "structural": { + "expected_columns": [ + "frs_benunit_capital" + ] + } + } + }, + { + "id": "uc-reported-capital-net-new-column", + "class": "net_new_column", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "uc_reported_capital" + ], + "entities": [ + "benunit" + ] + }, + "expectation": "column_missing_in_reference", + "magnitude_evidence": "The coherence stage now persists uc_reported_capital from the same frs_benunit_capital carrier for PolicyEngine-UK's UC means-test seam. The pinned enhanced-FRS reference has no column with that name, so its presence is a structural candidate-only export. The I1 receipt establishes the carrier domain and the separate before-engine receipt pins the complete 61,211-benunit join used by this seam.", + "evidence": "experiments/828-uc-capital-receipts.md#part-c--engine-blocker-aggregates-on-spine-k", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-31", + "quantitative": { + "structural": { + "expected_columns": [ + "uc_reported_capital" + ] + } + } + }, + { + "id": "uc-reporter-claim-refresh-lift", + "class": "mechanism_change", + "scope": { + "surface": "nonzero_shares", + "columns": [ + "would_claim_uc" + ], + "entities": [ + "benunit" + ] + }, + "expectation": "column_differs", + "magnitude_evidence": "The refresh is monotone and limited to post-fill reported-UC benunits: it ORs the reporter anchor into would_claim_uc and never turns an existing true value false. Against the incumbent's 0.550692 unweighted nonzero share, the I1 sizing receipt identifies 2,245 SPI-channel reporter records that are false before refresh out of 61,211 spine benunits, an expected whole-spine lift of 0.03668 (bounded at 0.0367 below); those rows carry 1.640 million weighted benunits. The I1 before-engine receipt independently measures 0.893 million weighted reported-UC benunits blocked by a false would_claim_uc flag before this repair.", + "evidence": "experiments/828-uc-capital-receipts.md#part-b--a3-sizing-receipt", + "adjudicator": "juaristi22", + "adjudicated_on": "2026-08-31", + "quantitative": { + "shares": { + "would_claim_uc": { + "incumbent_share": 0.550692, + "direction": "candidate_above", + "max_abs_delta": 0.0367 + } + }, + "magnitude_provenance": "I1 pre-change receipts .codex-work/828_before_ab.json and .codex-work/828_before_c.json; 2,245 SPI-channel false reporters divided by 61,211 benunits, rounded up at 1e-4 grain." + } + } + ] } diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py index f038ecddd..e3c6ad007 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py @@ -315,7 +315,17 @@ def _evaluate_nonnegative_columns( def _evaluate_column_implication( context: EvidenceContext, parameters: Mapping[str, Any] ) -> GateResult: - """Bind a person signal to a benunit flag and its same-source carrier.""" + """Bind a person signal to a benunit flag and its same-source carrier. + + The capital checks (sentinel floor, sentinel parity, same-source + equality, nonfinite) are deliberately re-derived at the terminal frame + even though ``cohere_uc_capital`` establishes them at stage time: they + guard the pipeline BETWEEN that stage and the terminal boundary — CGT + cloning and donor stacking, salary sacrifice, student loans, age tail, + and assembly — any of which could rebuild an entity table and corrupt + one column without the other. Against the producer itself they cannot + fail; that is not their job (adversarial-review finding 3). + """ frame = context.frame assert frame is not None # GateBinding enforces frame evidence first. @@ -341,15 +351,23 @@ def _evaluate_column_implication( if target[target_id_column].duplicated().any(): raise ValueError(f"{target_entity}.{target_id_column} must be unique.") + if threshold < 0: + raise ValueError( + f"column_implication amount threshold must be nonnegative, got {threshold!r}." + ) numeric = pd.to_numeric(source[numeric_column], errors="coerce") positive_ids = set(source.loc[numeric > threshold, source_group_column].tolist()) aggregated = target[target_id_column].isin(positive_ids).to_numpy(dtype=np.int8) + # The configured threshold applies to the person-level amounts above; the + # aggregated evidence is a 0/1 indicator, so the primitive's threshold is + # pinned at 0.0 regardless — reusing the amount threshold there would make + # any configuration >= 1 vacuously green (adversarial-review finding 2). result = column_implication_gate( aggregated, target[boolean_column], numeric_column=f"{source_entity}.{numeric_column} aggregated to {target_entity}", boolean_column=f"{target_entity}.{boolean_column}", - threshold=threshold, + threshold=0.0, ) capital_column = str(parameters["capital_column"]) @@ -402,6 +420,7 @@ def _evaluate_column_implication( failures=tuple(failures), details={ **dict(result.details), + "amount_threshold": threshold, "capital_column": f"{target_entity}.{capital_column}", "carrier_column": f"{target_entity}.{carrier_column}", "sentinel": sentinel, diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py index abef0e3d4..53bc5b0bb 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py @@ -1,4 +1,16 @@ -"""Late UK Universal Credit capital and take-up coherence stage.""" +"""Late UK Universal Credit capital and take-up coherence stage. + +Ordering and determinism contract (adversarial-review finding 4): this stage +runs after the last ``universal_credit_reported`` writer and BEFORE +``cgt_incidence_clone``, so the redraw sees only pre-clone benunit ids and +un-split design/prior-mass weights; clone twins then copy the already-drawn +values byte-for-byte, which is why clone re-keying cannot desynchronize them. +The redraw is deterministic in the twin-build sense used across the spine: +identical frame plus the declared seed reproduces identical draws (uniforms +are identity-keyed by ``benunit_id``; the donor CDF sorts by (capital, +benunit_id) with a stable sort). A different vintage or upstream frame +legitimately produces different draws, as with every seeded stage. +""" from __future__ import annotations diff --git a/packages/microcosm-build/tests/test_uk_battery_bindings.py b/packages/microcosm-build/tests/test_uk_battery_bindings.py index c147b1267..4899756fe 100644 --- a/packages/microcosm-build/tests/test_uk_battery_bindings.py +++ b/packages/microcosm-build/tests/test_uk_battery_bindings.py @@ -265,6 +265,39 @@ def test_uc_column_implication_binding_aggregates_and_checks_carrier(self) -> No assert any("same sentinel" in failure for failure in failing.failures) assert any("must equal" in failure for failure in failing.failures) + def test_uc_column_implication_binding_is_not_vacuous_at_amount_thresholds( + self, + ) -> None: + # Adversarial-review finding 2: the configured threshold filters the + # person-level amounts; the aggregated 0/1 indicator must always be + # compared at 0. With the two conflated, any threshold >= 1 could + # never flag a violation. This pins the de-conflation: a reporter + # above a nonzero amount threshold with would_claim_uc=False must + # still fail the gate. + person, benunit, household = _tables() + frame = uk_national_frame( + person=person, + benunit=benunit, + household=household, + time_period="2023", + ) + entry = next( + gate + for gate in load_country_spec("uk").gates.gates + if gate.id == "uk_uc_capital_coherence" + ) + parameters = {**dict(entry.parameters), "threshold": 100.0} + frame.table("person")["universal_credit_reported"] = 500.0 + frame.table("benunit")["would_claim_uc"] = False + + failing = UK_GATE_REGISTRY["column_implication"].evaluate( + EvidenceContext(frame=frame), parameters + ) + assert not failing.passed + assert any("must imply" in failure for failure in failing.failures) + assert failing.details["amount_threshold"] == 100.0 + assert failing.details["threshold"] == 0.0 + def test_nonnegative_binding_requires_scheduled_stage_columns(self) -> None: # frs_employment declares sic_industry_division nonnegative; a build # that scheduled the stage but lost the column must fail — the diff --git a/packages/microcosm-data/tests/test_contract.py b/packages/microcosm-data/tests/test_contract.py index d6e75396e..20f5de14e 100644 --- a/packages/microcosm-data/tests/test_contract.py +++ b/packages/microcosm-data/tests/test_contract.py @@ -137,13 +137,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 = ( - "12c8a7fd526932decf19954881f43a123451f0454ac2603ff5ab08b0d246e37a" + "12aab28f1e8e49347887c53fe1fabd228a5eda045964d65224390e0ce8b118d5" ) UK_GATE_BATTERY_GATES_MANIFEST_SHA256 = ( - "2a7cb1441d9c9bab3afde33ad1a2957484c7bde46f93c65386b98dd7a665b812" + "efdb12a1f97421197871aefbb7de4be90e5d9a4f0461e6c6e72e5dcc8cf65089" ) UK_GATE_BATTERY_SPEC_FINGERPRINT = ( - "65a2c85db2abd8edd935fda79e5c5ef8e15f89ba59ec4e2763d485c5170fd550" + "96186a467471393be608dc638f8288db9ebfdcf2f54a1afbaf8f070db6716746" ) UK_GATE_BATTERY_DEGENERATE_EVIDENCE_SHA256 = ( "d0d024043132fa07c378c393dbe2b24fe99bf19e876bcc39997d2c80cc9bd4f6" @@ -243,6 +243,11 @@ def _trusted_terminal_gate_signing_key(monkeypatch) -> None: "terminal", "nonnegative_columns", ), + "uk_uc_capital_coherence": ( + "column_implication", + "terminal", + "column_implication", + ), "uk_support": ("support", "terminal", "support"), "uk_aggregate_admin": ("aggregate_admin", "terminal", "aggregate_vs_admin"), "uk_export_surface": ("export_surface", "terminal", "export_surface"), @@ -926,6 +931,26 @@ def _terminal_gate_details(name: str) -> dict: "invalid_examples": {}, "allowed_values": {"brma": ["CENTRAL_LONDON"]}, } + if name == "column_implication": + # Mirrors _evaluate_column_implication's composite detail block. + return { + "numeric_column": ( + "person.universal_credit_reported aggregated to benunit" + ), + "boolean_column": "benunit.would_claim_uc", + "threshold": 0.0, + "rows_checked": 1, + "implicated_rows": 1, + "violation_count": 0, + "nonfinite_count": 0, + "capital_column": "benunit.uc_reported_capital", + "carrier_column": "benunit.frs_benunit_capital", + "sentinel": -1.0, + "below_floor_count": 0, + "sentinel_mismatch_count": 0, + "same_source_mismatch_count": 0, + "nonfinite_capital_count": 0, + } raise AssertionError(f"No terminal fixture details for {name!r}") From 880b0d173cd976e6ef91ca79383184ff6dafd7d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:29:12 +0200 Subject: [PATCH 3/3] Close the undefined negative interval and the last staging citation (#829 round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - residual (a): the -1 contract defines exactly two regions (the sentinel, nonnegative amounts); enforce that domain at both layers — stage-time refusal in cohere_uc_capital (and the donor filter tightened to >= 0) and the terminal binding's capital AND carrier checks, since a corrupted pair like -0.5/-0.5 legitimately passes the sentinel-parity and same-source checks. Regression tests pin the -0.5 case at both layers; gate details rename below_floor_count to capital/carrier_domain_violation_count with the fixture mirror moved in lockstep. - residual (b): the uc-reporter-claim-refresh-lift magnitude_provenance now cites the committed experiments/828-uc-capital-receipts.md; zero .codex-work references remain in tracked files. Co-Authored-By: Claude Fable 5 --- .../uk/spine_swap_signed_differences.json | 2 +- .../build/uk_runtime/battery_bindings.py | 27 +++++++++++--- .../build/uk_runtime/uc_capital_coherence.py | 13 ++++--- .../tests/test_uk_battery_bindings.py | 36 +++++++++++++++++++ .../tests/test_uk_signed_differences.py | 6 ++-- .../tests/test_uk_uc_capital_coherence.py | 10 ++++++ .../microcosm-data/tests/test_contract.py | 3 +- 7 files changed, 84 insertions(+), 13 deletions(-) diff --git a/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json b/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json index bc9ce2e33..7a98e63be 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json +++ b/packages/microcosm-build/src/microcosm/build/uk/spine_swap_signed_differences.json @@ -534,7 +534,7 @@ "max_abs_delta": 0.0367 } }, - "magnitude_provenance": "I1 pre-change receipts .codex-work/828_before_ab.json and .codex-work/828_before_c.json; 2,245 SPI-channel false reporters divided by 61,211 benunits, rounded up at 1e-4 grain." + "magnitude_provenance": "I1 pre-change receipts, committed as experiments/828-uc-capital-receipts.md (Part B; raw JSON licensed-side in data/ukds/acceptance/828-uc-capital/); 2,245 SPI-channel false reporters divided by 61,211 benunits, rounded up at 1e-4 grain." } } ] diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py index e3c6ad007..a11f01ab7 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/battery_bindings.py @@ -386,7 +386,16 @@ def _evaluate_column_implication( dtype=float ) nonfinite = ~np.isfinite(capital) | ~np.isfinite(carrier) - below_floor = np.isfinite(capital) & (capital < sentinel) + # The -1 contract reserves exactly one negative: a value is either the + # sentinel or nonnegative. A bare floor (`< sentinel`) would admit the + # open interval between them — the one region the contract does not + # define (adversarial-review verification residual 1). + out_of_domain = np.isfinite(capital) & ~( + np.isclose(capital, sentinel) | (capital >= 0.0) + ) + carrier_out_of_domain = np.isfinite(carrier) & ~( + np.isclose(carrier, sentinel) | (carrier >= 0.0) + ) sentinel_mismatch = np.isclose(capital, sentinel) != np.isclose(carrier, sentinel) same_source_mismatch = ( np.isfinite(capital) & np.isfinite(carrier) & (capital != carrier) @@ -398,10 +407,17 @@ def _evaluate_column_implication( f"{target_entity}.{capital_column}/{carrier_column}: " f"{int(nonfinite.sum())} row(s) have non-finite carrier evidence." ) - if below_floor.any(): + if out_of_domain.any(): + failures.append( + f"{target_entity}.{capital_column}: {int(out_of_domain.sum())} " + f"value(s) outside the declared domain (exactly the {sentinel:g} " + "sentinel or >= 0)." + ) + if carrier_out_of_domain.any(): failures.append( - f"{target_entity}.{capital_column}: {int(below_floor.sum())} value(s) " - f"below the declared sentinel floor {sentinel:g}." + f"{target_entity}.{carrier_column}: " + f"{int(carrier_out_of_domain.sum())} value(s) outside the declared " + f"domain (exactly the {sentinel:g} sentinel or >= 0)." ) if sentinel_mismatch.any(): failures.append( @@ -424,7 +440,8 @@ def _evaluate_column_implication( "capital_column": f"{target_entity}.{capital_column}", "carrier_column": f"{target_entity}.{carrier_column}", "sentinel": sentinel, - "below_floor_count": int(below_floor.sum()), + "capital_domain_violation_count": int(out_of_domain.sum()), + "carrier_domain_violation_count": int(carrier_out_of_domain.sum()), "sentinel_mismatch_count": int(sentinel_mismatch.sum()), "same_source_mismatch_count": int(same_source_mismatch.sum()), "nonfinite_capital_count": int(nonfinite.sum()), diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py index 53bc5b0bb..b8b7ea44e 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/uc_capital_coherence.py @@ -118,10 +118,14 @@ def cohere_uc_capital(frame: Frame) -> UKUCCapitalCoherenceResult: capital = pd.to_numeric( benunit[UC_CAPITAL_REDRAW_OUTPUT], errors="coerce" ).to_numpy(dtype=float, na_value=np.nan, copy=True) - if not np.isfinite(capital).all() or (capital < UC_CAPITAL_UNAVAILABLE).any(): + valid_domain = np.isfinite(capital) & ( + np.isclose(capital, UC_CAPITAL_UNAVAILABLE) | (capital >= 0.0) + ) + if not valid_domain.all(): raise ValueError( - "frs_benunit_capital must be finite and no lower than the named " - "unavailable sentinel." + "frs_benunit_capital values must be exactly the named unavailable " + "sentinel or nonnegative; the open interval between them has no " + "meaning under the -1 contract." ) channel = benunit[support_channel_column("benunit")].astype(str) @@ -195,7 +199,8 @@ def _redraw_spi_reporter_capital( ) child_band = _dependent_children_band(benunit["dependent_children"]) couple = _boolean_values(benunit["is_married"]) - available = capital > UC_CAPITAL_UNAVAILABLE + # Domain-validated upstream: every non-sentinel value is >= 0. + available = capital >= 0.0 donor = base & reporter & available & (weights > 0.0) target_ids = benunit["benunit_id"].to_numpy() draws = stable_identity_uniforms( diff --git a/packages/microcosm-build/tests/test_uk_battery_bindings.py b/packages/microcosm-build/tests/test_uk_battery_bindings.py index 4899756fe..875051e83 100644 --- a/packages/microcosm-build/tests/test_uk_battery_bindings.py +++ b/packages/microcosm-build/tests/test_uk_battery_bindings.py @@ -298,6 +298,42 @@ def test_uc_column_implication_binding_is_not_vacuous_at_amount_thresholds( assert failing.details["amount_threshold"] == 100.0 assert failing.details["threshold"] == 0.0 + def test_uc_column_implication_binding_refuses_the_undefined_interval( + self, + ) -> None: + # Adversarial-review round-2 residual (a): the -1 contract defines + # exactly two regions — the sentinel and nonnegative amounts. A + # corrupted -0.5 previously cleared every capital check: above the + # bare floor, not isclose to the sentinel on either side, finite, + # and equal to a carrier carrying the same corruption. The domain + # predicate must refuse it, on both columns. + person, benunit, household = _tables() + frame = uk_national_frame( + person=person, + benunit=benunit, + household=household, + time_period="2023", + ) + entry = next( + gate + for gate in load_country_spec("uk").gates.gates + if gate.id == "uk_uc_capital_coherence" + ) + frame.table("benunit")["uc_reported_capital"] = -0.5 + frame.table("benunit")["frs_benunit_capital"] = -0.5 + + failing = UK_GATE_REGISTRY["column_implication"].evaluate( + EvidenceContext(frame=frame), entry.parameters + ) + assert not failing.passed + assert any("outside the declared domain" in f for f in failing.failures) + assert failing.details["capital_domain_violation_count"] > 0 + assert failing.details["carrier_domain_violation_count"] > 0 + # The corruption must NOT be reported as a sentinel or equality + # mismatch — those checks legitimately pass on it, which is exactly + # why the domain check exists. + assert failing.details["same_source_mismatch_count"] == 0 + def test_nonnegative_binding_requires_scheduled_stage_columns(self) -> None: # frs_employment declares sic_industry_division nonnegative; a build # that scheduled the stage but lost the column must fail — the diff --git a/packages/microcosm-build/tests/test_uk_signed_differences.py b/packages/microcosm-build/tests/test_uk_signed_differences.py index a51f0cef8..a658e6a3a 100644 --- a/packages/microcosm-build/tests/test_uk_signed_differences.py +++ b/packages/microcosm-build/tests/test_uk_signed_differences.py @@ -130,8 +130,10 @@ def test_uc_capital_entries_pin_new_exports_and_monotone_claim_lift(self) -> Non } }, "magnitude_provenance": ( - "I1 pre-change receipts .codex-work/828_before_ab.json and " - ".codex-work/828_before_c.json; 2,245 SPI-channel false " + "I1 pre-change receipts, committed as " + "experiments/828-uc-capital-receipts.md (Part B; raw JSON " + "licensed-side in data/ukds/acceptance/828-uc-capital/); " + "2,245 SPI-channel false " "reporters divided by 61,211 benunits, rounded up at 1e-4 grain." ), } diff --git a/packages/microcosm-build/tests/test_uk_uc_capital_coherence.py b/packages/microcosm-build/tests/test_uk_uc_capital_coherence.py index 920fbedb8..d09e535e8 100644 --- a/packages/microcosm-build/tests/test_uk_uc_capital_coherence.py +++ b/packages/microcosm-build/tests/test_uk_uc_capital_coherence.py @@ -206,6 +206,16 @@ def redraw_tables( pd.testing.assert_series_equal(expected, actual) +def test_stage_refuses_the_undefined_negative_interval() -> None: + # Round-2 residual (a), stage-time arm: the -1 contract has exactly two + # regions. A carrier value of -0.5 (finite, above the old bare floor, + # not the sentinel) must refuse at the stage boundary, not flow on. + frame = _frame() + frame.table("benunit")["frs_benunit_capital"] = -0.5 + with pytest.raises(ValueError, match="sentinel or nonnegative"): + cohere_uc_capital(frame) + + def test_children_band_caps_at_three_plus_and_boolean_helper_is_strict() -> None: np.testing.assert_array_equal( _dependent_children_band(pd.Series([0, 1, 2, 3, 8])), diff --git a/packages/microcosm-data/tests/test_contract.py b/packages/microcosm-data/tests/test_contract.py index 20f5de14e..4b1a88596 100644 --- a/packages/microcosm-data/tests/test_contract.py +++ b/packages/microcosm-data/tests/test_contract.py @@ -946,7 +946,8 @@ def _terminal_gate_details(name: str) -> dict: "capital_column": "benunit.uc_reported_capital", "carrier_column": "benunit.frs_benunit_capital", "sentinel": -1.0, - "below_floor_count": 0, + "capital_domain_violation_count": 0, + "carrier_domain_violation_count": 0, "sentinel_mismatch_count": 0, "same_source_mismatch_count": 0, "nonfinite_capital_count": 0,