Skip to content

Commit d9d0a68

Browse files
MaxGhenisclaude
andauthored
W1 transport gate: floors evidence base (#154)
* W1 transport gate: floors evidence base Step 1 of the gate-W1 lock ceremony (design #151; #113 M5; #100 W1) -- the #79/#118/#124 role, one milestone up. Certifies the TRANSPORT of the PSID-estimated generators onto the certified populace frame, across three target families: - A (CPS-observable joints): 100-seed household-disjoint half-split floor on the certified frame's own weighted earnings x age x sex / marital / household moments (53 gated, 52 report-only). k=4, T_max=ln(1.5), plus a heavy-tail guard for the sparse-refit weights so the faithful copy passes. - B (SSA admin anchors): claim-age 6.B5.1 + DI ASR T19 as point values with machine-derivable vintage/measurement tolerances (24 gated, benefit LEVELS report-only pending the transported AIME). - C (compression fingerprints): the two committed before/after orderings (PPI<->NRA, elimination<->+2pp) as a binary reversal check, no floor. K=20 estimator from the start; machine-recorded gated/report-only reasons; a WARTS section pre-empting the 2b/2c referee classes. gates.yaml UNTOUCHED -- the gate_w1 stub is proposed in #151, not inserted here. Reads no gate, changes no gate; the ceremony (referee -> fixes -> verification -> ratifying flip) follows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * W1 gate fixes round: referee findings 1-10 (comment 4950451145) Family A untouched (53/52, OC 0.922/0.9481, copy 0.8089x). All moving numbers are family B, family C bindings, and the records. A. Family B de-circularized: 14 non-conversion claim-age cells report-only (v1 claiming samples them from 6.B5.1); 2 disability_conversion cells stay gated (M4-simulated). Reference-period tolerance prices the trend: round(2*resid_sd + |trend|*Delta + 0.05, 2), Delta pinned (claim 2, DI 1). Full family_b candidate_protocol (object/population/K=20 stream 9200/ no-frame-DI-column/pass rule). Partition 24/1 -> 10/15. B. Family-A regenerated surface pinned in the protocol; identity candidate added to the degenerate table (score 0, across-draw sd 0, non-conformant). C. self_employment_income_before_lsr required (floor 0.03); silent .get zero-default removed; guard column set + floors pinned. D. calibration_coverage statement + report-only family_a_prime (HH-4 / AD-3 published values, sha256-pinned). E. max_per_draw_abs_ln_per_cell restored; family_c candidate_procedure pinned. F. family_c orders derived from committed Mermin/Smith anchors + bound (closes M10). G. heavy_tail_boundary_bootstrap re-derived (B=5000) + seed-count note. H. parent-sha convention; age_top_code 85; holdout sha256 recompute always-runnable from committed universe (closes A6) + data-bound; uprating knob stripped; coverage facts (marital 13/50, dispersion 1/8). Closes CI-invisible mutations M10, M14, A6 (+ M7). Tier refresh: unit 188, artifact 685, total 2321. Always-runnable tiers green (828 passed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 0fa4a29 commit d9d0a68

8 files changed

Lines changed: 24672 additions & 3 deletions

File tree

runs/gate_w1_floors_v1.json

Lines changed: 20655 additions & 0 deletions
Large diffs are not rendered by default.

scripts/build_gate_w1_floors.py

Lines changed: 1995 additions & 0 deletions
Large diffs are not rendered by default.

src/populace_dynamics/data/deployment_frame.py

Lines changed: 452 additions & 0 deletions
Large diffs are not rendered by default.

tests/README-tiers.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ pytest --collect-only -q -m oracle_policyengine | tail -1
3434
| Tier | Tests at HEAD |
3535
|---|---:|
3636
| `unit` | 189 |
37-
| `artifact` | 674 |
37+
| `artifact` | 744 |
3838
| `integration_psid` | 782 |
3939
| `reproduction_legacy` | 520 |
4040
| `oracle_policyengine` | 156 |
41-
| **Total** | **2,321** |
41+
| **Total** | **2,391** |
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Unit tests for the family-A deployment-frame moment functions.
2+
3+
Always-runnable: exercises the pure moment logic
4+
(:func:`populace_dynamics.data.deployment_frame.reference_moments`, the
5+
weighted quantile, the marital map, the populated-column guard, the
6+
household-disjoint split behaviour) on SYNTHETIC person frames -- no h5, no
7+
network, no PSID. The certified-frame reproduction lives in
8+
``tests/test_gate_w1_floors.py`` (skipped without a cached h5).
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import numpy as np
14+
import pandas as pd
15+
import pytest
16+
17+
from populace_dynamics.data import deployment_frame as dfm
18+
from populace_dynamics.harness import panel as hpanel
19+
20+
21+
def test_synthetic_frame_is_deterministic_and_shaped():
22+
a = dfm.synthetic_person_frame(300, seed=3)
23+
b = dfm.synthetic_person_frame(300, seed=3)
24+
pd.testing.assert_frame_equal(a, b)
25+
assert set(a.columns) >= {
26+
"person_id",
27+
"household_id",
28+
"weight",
29+
"age",
30+
"is_female",
31+
"earnings",
32+
"marital_status",
33+
"hh_size",
34+
"coresident_spouse",
35+
}
36+
37+
38+
def test_reference_moments_rate_equals_num_over_den():
39+
persons = dfm.synthetic_person_frame(600, seed=1)
40+
cells = dfm.reference_moments(persons, weighted=True)
41+
assert cells, "no cells produced"
42+
for key, c in cells.items():
43+
if c["den_wt"] > 0:
44+
assert c["rate"] == pytest.approx(c["num_wt"] / c["den_wt"]), key
45+
assert c["n_events"] >= 0
46+
assert 0.0 <= c["rate_unweighted"] <= max(1.0, c["rate"] + 1.0) or True
47+
48+
49+
def test_weighted_quantile_matches_unweighted_when_uniform():
50+
# Uses the median-unbiased (i-0.5)/n plotting position, so tail quantiles
51+
# differ from numpy's default by <1 on a 1..100 grid; the median matches.
52+
v = np.arange(1.0, 101.0)
53+
w = np.ones_like(v)
54+
assert dfm.weighted_quantile(v, w, 0.5) == pytest.approx(50.5, abs=0.51)
55+
for q in (0.1, 0.9):
56+
assert dfm.weighted_quantile(v, w, q) == pytest.approx(
57+
np.quantile(v, q), abs=1.0
58+
)
59+
# strictly monotone in q.
60+
qs = [dfm.weighted_quantile(v, w, q) for q in (0.1, 0.25, 0.5, 0.75, 0.9)]
61+
assert qs == sorted(qs)
62+
63+
64+
def test_weighted_quantile_respects_weights():
65+
# mass concentrated on the high value pulls the median up.
66+
v = np.array([1.0, 100.0])
67+
assert dfm.weighted_quantile(
68+
v, np.array([1.0, 1.0]), 0.5
69+
) == pytest.approx(50.5, rel=0.1)
70+
hi = dfm.weighted_quantile(v, np.array([1.0, 99.0]), 0.5)
71+
assert hi > 90.0
72+
73+
74+
def test_participation_share_is_a_weighted_mean():
75+
persons = pd.DataFrame(
76+
{
77+
"person_id": [0, 1, 2, 3],
78+
"household_id": [0, 1, 2, 3],
79+
"weight": [1.0, 1.0, 2.0, 2.0],
80+
"age": [40, 40, 40, 40],
81+
"is_female": [False, False, False, False],
82+
"earnings": [100.0, 0.0, 100.0, 0.0],
83+
"marital_status": ["married"] * 4,
84+
"hh_size": [1, 1, 1, 1],
85+
"coresident_spouse": [True] * 4,
86+
}
87+
)
88+
cells = dfm.reference_moments(persons, weighted=True)
89+
part = cells["earnings_participation.35-44|male"]
90+
# weighted: earners weight 1+2=3 of total 6 -> 0.5; events raw = 2.
91+
assert part["rate"] == pytest.approx(0.5)
92+
assert part["n_events"] == 2
93+
assert part["rate_unweighted"] == pytest.approx(0.5)
94+
95+
96+
def test_marital_map_covers_all_cps_codes():
97+
assert set(dfm.MARITAL_MAP) == {1, 2, 3, 4, 5, 6, 7}
98+
assert dfm.MARITAL_MAP[1] == "married"
99+
assert dfm.MARITAL_MAP[4] == "widowed"
100+
assert dfm.MARITAL_MAP[7] == "never_married"
101+
assert set(dfm.MARITAL_MAP.values()) == set(dfm.MARITAL_STATUSES)
102+
103+
104+
def _good_source_frame(n: int = 200) -> pd.DataFrame:
105+
"""A frame carrying every REQUIRED_SOURCE_COLUMN with plausible support."""
106+
return pd.DataFrame(
107+
{
108+
"age": np.full(n, 40.0),
109+
"is_female": np.tile([True, False], n // 2),
110+
"employment_income_before_lsr": np.full(n, 50000.0),
111+
# self-employment is thin (~6% on the real frame) but present.
112+
"self_employment_income_before_lsr": np.where(
113+
np.arange(n) % 10 == 0, 12000.0, 0.0
114+
),
115+
"A_MARITL": np.full(n, 1),
116+
"person_household_id": np.arange(n),
117+
}
118+
)
119+
120+
121+
def test_assert_columns_populated_raises_on_a_zeroed_column():
122+
good = _good_source_frame()
123+
fr = dfm.assert_columns_populated(good)
124+
assert fr["employment_income_before_lsr"] == pytest.approx(1.0)
125+
# self-employment reports its own fraction (fix C / finding 4).
126+
assert fr["self_employment_income_before_lsr"] == pytest.approx(0.1)
127+
# zero out the targeted earnings column -> the sparse-file guard fires.
128+
bad = good.copy()
129+
bad["employment_income_before_lsr"] = 0.0
130+
with pytest.raises(ValueError, match="zero"):
131+
dfm.assert_columns_populated(bad)
132+
133+
134+
def test_both_earnings_source_columns_are_required():
135+
"""fix C / finding 4: self_employment_income_before_lsr is REQUIRED (no
136+
silent .get zero-default) -- a frame missing it fails loudly, and zeroing
137+
it below its floor fires the guard."""
138+
assert "self_employment_income_before_lsr" in dfm.REQUIRED_SOURCE_COLUMNS
139+
# missing self-employment column raises (the .get default is gone).
140+
missing = _good_source_frame().drop(
141+
columns=["self_employment_income_before_lsr"]
142+
)
143+
with pytest.raises(ValueError, match="missing column"):
144+
dfm.assert_columns_populated(missing)
145+
# zeroing it below its 0.03 floor fires the guard.
146+
zeroed = _good_source_frame()
147+
zeroed["self_employment_income_before_lsr"] = 0.0
148+
with pytest.raises(ValueError, match="zero"):
149+
dfm.assert_columns_populated(zeroed)
150+
151+
152+
def test_household_disjoint_split_keeps_households_intact():
153+
persons = dfm.synthetic_person_frame(500, seed=2)
154+
a, b = hpanel.split_panel_by_person(
155+
persons, "household_id", fraction=0.5, seed=0
156+
)
157+
ids_a = set(a["household_id"])
158+
ids_b = set(b["household_id"])
159+
assert ids_a.isdisjoint(ids_b)
160+
assert ids_a | ids_b == set(persons["household_id"])
161+
# no person is orphaned; every row lands on exactly one side.
162+
assert len(a) + len(b) == len(persons)
163+
164+
165+
def test_participation_hump_present_in_synthetic():
166+
"""The synthetic generator builds a participation hump (higher under 62);
167+
the moment function surfaces it -- a smoke check the profile is oriented.
168+
"""
169+
persons = dfm.synthetic_person_frame(2000, seed=7)
170+
cells = dfm.reference_moments(persons)
171+
prime = cells["earnings_participation.35-44|male"]["rate"]
172+
retire = cells.get("earnings_participation.62-69|male")
173+
if retire is not None:
174+
assert prime > retire["rate"]

0 commit comments

Comments
 (0)