Skip to content

Commit 3aca401

Browse files
juaristi22claude
andcommitted
Make the identity-stability instrument actually verify the claim
The tool's main() wrote a stub receipt ("requires caller-supplied transform") without checking anything. It now recomputes every E4 column from the pure derivations in original and permuted row order, un-permutes by entity id, compares both against each other and against the stored artifact columns, reports missing stored columns explicitly, and exits nonzero on any mismatch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 67fd0f6 commit 3aca401

2 files changed

Lines changed: 210 additions & 13 deletions

File tree

packages/microcosm-build/tests/test_uk_stochastic_tools.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,66 @@ def transform(candidate):
9595
assert receipt["mismatches"] == {}
9696

9797

98+
def test_e4_identity_receipt_survives_permutation_on_synthetic_frame() -> None:
99+
tool = _load_tool("verify_uk_identity_stability")
100+
101+
class Contract:
102+
def rate(self, key: str) -> float:
103+
return 0.5 if not key.startswith("scp") else 0.9
104+
105+
def continuous_entry(self, key: str):
106+
return {"mean": 15.019, "sd": 4.972, "lower": 0, "upper": 30}
107+
108+
person = pd.DataFrame(
109+
{
110+
"person_id": [101, 102, 201, 301],
111+
"person_benunit_id": [10, 10, 20, 30],
112+
"person_household_id": [1, 1, 1, 2],
113+
"age": [5, 6, 40, 70],
114+
"child_benefit_reported": [0.0, 10.0, 0.0, 0.0],
115+
"pension_credit_reported": [0.0, 0.0, 0.0, 5.0],
116+
"universal_credit_reported": [0.0, 0.0, 20.0, 0.0],
117+
}
118+
)
119+
frame = uk_national_frame(
120+
person=person,
121+
benunit=pd.DataFrame({"benunit_id": [10, 20, 30]}),
122+
household=pd.DataFrame(
123+
{
124+
"household_id": [1, 2],
125+
"region": ["LONDON", "SCOTLAND"],
126+
"household_weight": [2.0, 3.0],
127+
}
128+
),
129+
time_period="2023",
130+
)
131+
count_resource = {
132+
"cells": {
133+
"LONDON": {"A": {"CENTRAL_LONDON": 3, "OUTER_LONDON": 1}},
134+
"SCOTLAND": {"A": {"LOTHIAN": 2}},
135+
}
136+
}
137+
138+
receipt = tool.e4_identity_receipt(
139+
frame,
140+
contract=Contract(),
141+
count_resource=count_resource,
142+
lha_category=["A", "A", "A"],
143+
permutation_seed=7,
144+
)
145+
146+
assert receipt["identical_under_permutation"] is True
147+
assert receipt["permutation_mismatches"] == {}
148+
# The synthetic frame carries no stored E4 columns; the receipt says so
149+
# explicitly instead of silently passing the stored comparison.
150+
assert receipt["matches_stored_columns"] is False
151+
assert set(receipt["stored_columns_missing"]) == {
152+
"person",
153+
"benunit",
154+
"household",
155+
}
156+
157+
98158
def test_brma_distribution_masks_small_counts() -> None:
99159
tool = _load_tool("emit_uk_brma_distribution")
100160
household = pd.DataFrame(

tools/verify_uk_identity_stability.py

Lines changed: 150 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,38 @@
1-
"""Verify E4 stochastic stage identity stability on an existing UK frame."""
1+
"""Verify E4 stochastic stage identity stability on an existing UK frame.
2+
3+
Recomputes every E4 column twice from the pure derivations — once in the
4+
frame's row order, once on row-permuted tables — un-permutes by entity id,
5+
and also compares the original-order recomputation against the columns
6+
stored in the artifact. Exit status is nonzero on any mismatch.
7+
"""
28

39
from __future__ import annotations
410

511
import argparse
612
import json
7-
from collections.abc import Callable, Sequence
13+
import sys
14+
from collections.abc import Callable, Mapping, Sequence
815
from pathlib import Path
916

17+
import numpy as np
18+
import pandas as pd
19+
20+
from microcosm.build.uk_runtime.frs_brma import (
21+
UK_BRMA_DECLARED_SEEDS,
22+
_benunit_regions,
23+
_enum_name,
24+
assign_brma_by_cell,
25+
collapse_benunit_brma_to_household,
26+
load_brma_count_resource,
27+
)
28+
from microcosm.build.uk_runtime.frs_household_draws import derive_frs_household_draws
29+
from microcosm.build.uk_runtime.frs_person_draws import derive_frs_person_draws
30+
from microcosm.build.uk_runtime.frs_take_up import (
31+
aggregate_person_reported_to_benunit,
32+
derive_frs_take_up,
33+
)
1034
from microcosm.build.uk_runtime.national_build import load_uk_national_frame
35+
from microcosm.build.uk_runtime.national_frame import uk_time_period
1136

1237

1338
def identity_stability_receipt(
@@ -45,28 +70,140 @@ def identity_stability_receipt(
4570
}
4671

4772

48-
def main() -> None:
49-
parser = argparse.ArgumentParser(description=__doc__)
50-
parser.add_argument("--input-h5", type=Path, required=True)
51-
parser.add_argument("--output", type=Path, required=True)
52-
args = parser.parse_args()
53-
frame, _provenance = load_uk_national_frame(args.input_h5)
54-
receipt = {
73+
def e4_identity_receipt(
74+
frame,
75+
*,
76+
contract,
77+
count_resource: Mapping[str, object],
78+
lha_category: Sequence[object],
79+
permutation_seed: int,
80+
) -> dict[str, object]:
81+
"""Recompute every E4 column in original and permuted row order.
82+
83+
Two claims are receipted: a row permutation of the input tables changes
84+
no assignment per entity id, and the original-order recomputation equals
85+
the columns stored in the artifact (re-derivation identity).
86+
"""
87+
88+
person = frame.table("person")
89+
benunit = frame.table("benunit").copy()
90+
household = frame.table("household")
91+
if len(lha_category) != len(benunit):
92+
raise ValueError("LHA_category materialization must align to benunit rows.")
93+
benunit["LHA_category"] = [_enum_name(value) for value in lha_category]
94+
benunit["region"] = _benunit_regions(person, household, benunit)
95+
96+
def recompute(person_t, benunit_t, household_t) -> dict[str, pd.DataFrame]:
97+
anchors = aggregate_person_reported_to_benunit(person_t, benunit_t)
98+
take_up = derive_frs_take_up(benunit_t, anchors=anchors, contract=contract)
99+
take_up.index = benunit_t["benunit_id"].to_numpy()
100+
person_draws = derive_frs_person_draws(person_t, contract=contract)
101+
person_draws.index = person_t["person_id"].to_numpy()
102+
household_draws = derive_frs_household_draws(household_t, contract=contract)
103+
household_draws.index = household_t["household_id"].to_numpy()
104+
seed = UK_BRMA_DECLARED_SEEDS["brma"]
105+
benunit_brma = pd.DataFrame(
106+
{
107+
"benunit_id": benunit_t["benunit_id"].to_numpy(),
108+
"brma": assign_brma_by_cell(
109+
benunit_t, count_resource=count_resource, seed=seed
110+
),
111+
}
112+
)
113+
household_draws["brma"] = collapse_benunit_brma_to_household(
114+
person_t, benunit_brma, household_t, seed=seed
115+
)
116+
return {
117+
"benunit": take_up,
118+
"person": person_draws,
119+
"household": household_draws,
120+
}
121+
122+
original = recompute(person, benunit, household)
123+
rng = np.random.default_rng(permutation_seed)
124+
permuted = recompute(
125+
person.iloc[rng.permutation(len(person))].reset_index(drop=True),
126+
benunit.iloc[rng.permutation(len(benunit))].reset_index(drop=True),
127+
household.iloc[rng.permutation(len(household))].reset_index(drop=True),
128+
)
129+
130+
stored = {
131+
"person": frame.table("person").set_index("person_id"),
132+
"benunit": frame.table("benunit").set_index("benunit_id"),
133+
"household": frame.table("household").set_index("household_id"),
134+
}
135+
permutation_mismatches: dict[str, list[str]] = {}
136+
stored_mismatches: dict[str, list[str]] = {}
137+
stored_columns_missing: dict[str, list[str]] = {}
138+
for entity, values in original.items():
139+
for column in values.columns:
140+
left = values[column]
141+
right = permuted[entity][column].reindex(left.index)
142+
if not np.array_equal(left.to_numpy(), right.to_numpy()):
143+
permutation_mismatches.setdefault(entity, []).append(column)
144+
if column not in stored[entity].columns:
145+
stored_columns_missing.setdefault(entity, []).append(column)
146+
continue
147+
kept = stored[entity][column].reindex(left.index)
148+
if not np.array_equal(
149+
left.to_numpy(), kept.to_numpy().astype(left.to_numpy().dtype)
150+
):
151+
stored_mismatches.setdefault(entity, []).append(column)
152+
return {
55153
"check": "uk_e4_identity_stability",
56-
"input_h5": str(args.input_h5),
57-
"status": "requires caller-supplied E4 transform in acceptance harness",
154+
"permutation_seed": permutation_seed,
155+
"identical_under_permutation": not permutation_mismatches,
156+
"matches_stored_columns": not stored_mismatches and not stored_columns_missing,
157+
"permutation_mismatches": permutation_mismatches,
158+
"stored_mismatches": stored_mismatches,
159+
"stored_columns_missing": stored_columns_missing,
160+
"columns_by_entity": {
161+
entity: list(values.columns) for entity, values in original.items()
162+
},
58163
"entity_row_counts": {
59164
entity: int(len(frame.table(entity))) for entity in frame.entities
60165
},
61166
}
167+
168+
169+
def main() -> int:
170+
parser = argparse.ArgumentParser(description=__doc__)
171+
parser.add_argument("--input-h5", type=Path, required=True)
172+
parser.add_argument("--output", type=Path, required=True)
173+
parser.add_argument("--permutation-seed", type=int, default=123)
174+
args = parser.parse_args()
175+
176+
from microcosm.build.uk_runtime.take_up_contract import load_uk_take_up_contract
177+
from microcosm.frame.adapters.policyengine_uk import PolicyEngineUKEngine
178+
179+
frame, _provenance = load_uk_national_frame(args.input_h5)
180+
engine = PolicyEngineUKEngine()
181+
lha_category = engine.materialize(frame, ("LHA_category",), uk_time_period(frame))[
182+
"LHA_category"
183+
]
184+
receipt = e4_identity_receipt(
185+
frame,
186+
contract=load_uk_take_up_contract(),
187+
count_resource=load_brma_count_resource(),
188+
lha_category=lha_category,
189+
permutation_seed=args.permutation_seed,
190+
)
191+
receipt["input_h5"] = str(args.input_h5)
62192
args.output.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n")
193+
ok = bool(
194+
receipt["identical_under_permutation"] and receipt["matches_stored_columns"]
195+
)
196+
print(
197+
"identity stability:",
198+
"PASS" if ok else f"FAIL ({args.output})",
199+
)
200+
return 0 if ok else 1
63201

64202

65203
def _reverse_rows(frame):
66204
from microcosm.build.uk_runtime.national_frame import (
67205
uk_household_weight_kind,
68206
uk_national_frame,
69-
uk_time_period,
70207
)
71208

72209
return uk_national_frame(
@@ -81,4 +218,4 @@ def _reverse_rows(frame):
81218

82219

83220
if __name__ == "__main__":
84-
main()
221+
sys.exit(main())

0 commit comments

Comments
 (0)