Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/828-uc-capital-carrier.added.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions changelog.d/828-uc-claim-coherence.changed.md
Original file line number Diff line number Diff line change
@@ -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).
45 changes: 45 additions & 0 deletions experiments/828-uc-capital-receipts.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions packages/microcosm-build/src/microcosm/build/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
{
"aggregate_admin",
"calibration_reference_coverage",
"column_implication",
"degenerate_release_surface",
"enum_domain",
"export_surface",
Expand Down
10 changes: 10 additions & 0 deletions packages/microcosm-build/src/microcosm/build/gate_battery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
84 changes: 76 additions & 8 deletions packages/microcosm-build/src/microcosm/build/gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions packages/microcosm-build/src/microcosm/build/uk/gates.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading