Skip to content

Commit be4b470

Browse files
test: add parity boundary contracts
1 parent dfc4590 commit be4b470

3 files changed

Lines changed: 532 additions & 0 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Contracts for the original-data parity harness.
2+
3+
These tests do not require R or Stata. They make the committed
4+
``tests/orig_parity/results`` artifacts executable enough that a source
5+
change cannot leave the Python-side JSONs silently stale.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import math
12+
import shutil
13+
import subprocess
14+
import sys
15+
from pathlib import Path
16+
from typing import Any
17+
18+
19+
ROOT = Path(__file__).resolve().parents[1]
20+
ORIG = ROOT / "tests" / "orig_parity"
21+
RESULTS = ORIG / "results"
22+
23+
24+
def _read_json(path: Path) -> dict[str, Any]:
25+
return json.loads(path.read_text(encoding="utf-8"))
26+
27+
28+
def _copy_orig_harness(tmp_path: Path) -> Path:
29+
dest = tmp_path / "orig_parity"
30+
shutil.copytree(
31+
ORIG,
32+
dest,
33+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
34+
)
35+
return dest
36+
37+
38+
def _rows_by_stat(payload: dict[str, Any]) -> dict[str, dict[str, Any]]:
39+
return {row["statistic"]: row for row in payload["rows"]}
40+
41+
42+
def _assert_close(a: float | None, b: float | None, *, atol: float = 1e-9) -> None:
43+
if a is None or b is None:
44+
assert a is b
45+
return
46+
assert math.isfinite(float(a)) and math.isfinite(float(b))
47+
assert abs(float(a) - float(b)) <= atol
48+
49+
50+
def test_orig_parity_json_pairs_keep_joinable_schema():
51+
py_files = sorted(RESULTS.glob("*_py.json"))
52+
r_files = sorted(RESULTS.glob("*_R.json"))
53+
assert len(py_files) >= 11
54+
assert len(r_files) >= 11
55+
assert {p.stem[:-3] for p in py_files} == {p.stem[:-2] for p in r_files}
56+
57+
for path in [*py_files, *r_files]:
58+
payload = _read_json(path)
59+
suffix = "_py" if path.name.endswith("_py.json") else "_R"
60+
side = "py" if suffix == "_py" else "R"
61+
module = path.stem[: -len(suffix)]
62+
63+
assert payload["module"] == module
64+
assert payload["side"] == side
65+
assert payload["rows"], f"{path.name} has no rows"
66+
67+
seen: set[str] = set()
68+
for row in payload["rows"]:
69+
assert row["module"] == module
70+
assert row["side"] == side
71+
assert isinstance(row["statistic"], str) and row["statistic"]
72+
assert row["statistic"] not in seen
73+
seen.add(row["statistic"])
74+
for key in ("estimate", "se", "published"):
75+
value = row.get(key)
76+
assert value is None or isinstance(value, (int, float))
77+
assert not isinstance(value, float) or math.isfinite(value)
78+
79+
80+
def test_orig_parity_rollup_is_reproducible_from_committed_json(tmp_path):
81+
work = _copy_orig_harness(tmp_path)
82+
subprocess.run(
83+
[sys.executable, "compare_orig.py"],
84+
cwd=work,
85+
check=True,
86+
text=True,
87+
capture_output=True,
88+
)
89+
got = (work / "results" / "parity_table_orig.md").read_text(encoding="utf-8")
90+
expected = (RESULTS / "parity_table_orig.md").read_text(encoding="utf-8")
91+
assert got == expected
92+
93+
94+
def test_nhefs_ch12_python_artifact_recomputes_from_live_code(tmp_path):
95+
"""Regression guard for the IPW solver drift caught in June 2026."""
96+
work = _copy_orig_harness(tmp_path)
97+
subprocess.run(
98+
[sys.executable, "06_nhefs_ch12_ipw.py"],
99+
cwd=work,
100+
check=True,
101+
text=True,
102+
capture_output=True,
103+
)
104+
105+
fresh = _read_json(work / "results" / "06_nhefs_ch12_ipw_py.json")
106+
committed = _read_json(RESULTS / "06_nhefs_ch12_ipw_py.json")
107+
r_gold = _read_json(RESULTS / "06_nhefs_ch12_ipw_R.json")
108+
109+
fresh_rows = _rows_by_stat(fresh)
110+
committed_rows = _rows_by_stat(committed)
111+
r_rows = _rows_by_stat(r_gold)
112+
assert set(fresh_rows) == set(committed_rows)
113+
114+
for stat, row in fresh_rows.items():
115+
expected = committed_rows[stat]
116+
_assert_close(row.get("estimate"), expected.get("estimate"))
117+
_assert_close(row.get("se"), expected.get("se"))
118+
assert row.get("n") == expected.get("n")
119+
120+
_assert_close(
121+
fresh_rows["ipw_att"]["estimate"],
122+
r_rows["ipw_att"]["estimate"],
123+
atol=1e-6,
124+
)
125+
_assert_close(fresh["extra"]["ci_ipw"][0], committed["extra"]["ci_ipw"][0])
126+
_assert_close(fresh["extra"]["ci_ipw"][1], committed["extra"]["ci_ipw"][1])
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Executable bounds for documented parity/convention gaps.
2+
3+
These tests intentionally read committed parity artifacts only. They make the
4+
"known gap" language auditable without requiring R or Stata at test time.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import math
11+
from pathlib import Path
12+
from typing import Any
13+
14+
import statspai as sp
15+
16+
17+
ROOT = Path(__file__).resolve().parents[1]
18+
R_RESULTS = ROOT / "tests" / "r_parity" / "results"
19+
STATA_RESULTS = ROOT / "tests" / "stata_parity" / "results"
20+
21+
22+
def _payload(module: str, side: str) -> dict[str, Any]:
23+
if side == "Stata":
24+
path = STATA_RESULTS / f"{module}_Stata.json"
25+
else:
26+
path = R_RESULTS / f"{module}_{side}.json"
27+
return json.loads(path.read_text(encoding="utf-8"))
28+
29+
30+
def _row(module: str, side: str, statistic: str) -> dict[str, Any]:
31+
for row in _payload(module, side)["rows"]:
32+
if row["statistic"] == statistic:
33+
return row
34+
raise AssertionError(f"{module}_{side}.json has no statistic {statistic!r}")
35+
36+
37+
def _estimate(module: str, side: str, statistic: str) -> float:
38+
value = _row(module, side, statistic)["estimate"]
39+
assert isinstance(value, (int, float)) and math.isfinite(value)
40+
return float(value)
41+
42+
43+
def _rel_gap(a: float, b: float) -> float:
44+
denom = max(abs(a), abs(b), 1e-12)
45+
return abs(a - b) / denom
46+
47+
48+
def _extra(module: str, side: str = "py") -> dict[str, Any]:
49+
extra = _payload(module, side).get("extra", {})
50+
assert isinstance(extra, dict)
51+
return extra
52+
53+
54+
def _gap_description(module: str, gap: str) -> str:
55+
for row in sp.parity_gap_report(repo_root=ROOT, fmt="records"):
56+
if row["module_id"] == module and row["gap"] == gap:
57+
return row["description"]
58+
raise AssertionError(f"parity_gap_report has no {module}/{gap} row")
59+
60+
61+
def test_parity_gap_report_has_actionable_stable_rows():
62+
rows = sp.parity_gap_report(repo_root=ROOT, fmt="records")
63+
assert len(rows) >= 30
64+
65+
expected = {
66+
("06_rd", "bandwidth_selector_gap"),
67+
("08_dml", "fold_split_note"),
68+
("16_bjs", "stata_gap_note"),
69+
("17_etwfe", "aggregation_note"),
70+
("52_scm_unique", "note"),
71+
}
72+
observed = {(row["module_id"], row["gap"]) for row in rows}
73+
assert expected <= observed
74+
75+
for row in rows:
76+
assert row["module_id"]
77+
assert row["description"].strip()
78+
assert row["priority"] in {"high", "medium", "low"}
79+
assert row["next_action"].strip()
80+
81+
82+
def test_rd_bandwidth_gap_is_documented_and_common_bandwidth_passes():
83+
extra = _extra("06_rd")
84+
assert "bandwidth_selector_gap" in extra
85+
assert "forced to the same bandwidth" in extra["bandwidth_selector_gap"]
86+
87+
py_h = _estimate("06_rd", "py", "default_bandwidth_h")
88+
r_h = _estimate("06_rd", "R", "default_bandwidth_h")
89+
assert 0.70 <= _rel_gap(py_h, r_h) <= 0.80
90+
91+
for stat in (
92+
"forced_h0.042287_conventional_est",
93+
"forced_h0.042287_robust_est",
94+
):
95+
assert _rel_gap(_estimate("06_rd", "py", stat), _estimate("06_rd", "R", stat)) < 1e-10
96+
97+
se_rel = _rel_gap(
98+
_row("06_rd", "py", "forced_h0.042287_conventional_est")["se"],
99+
_row("06_rd", "R", "forced_h0.042287_conventional_est")["se"],
100+
)
101+
assert se_rel <= 0.07
102+
103+
104+
def test_dml_fold_split_gap_stays_small_and_explained():
105+
note = _extra("08_dml")["fold_split_note"]
106+
assert "fold-split" in note
107+
assert "machine precision" in note
108+
109+
py_theta = _estimate("08_dml", "py", "theta_DML_PLR")
110+
r_theta = _estimate("08_dml", "R", "theta_DML_PLR")
111+
assert abs(py_theta - r_theta) <= 3e-4
112+
assert _rel_gap(py_theta, r_theta) <= 0.003
113+
114+
115+
def test_scm_nonunique_row_is_bounded_by_unique_solution_counterpart():
116+
nonunique = _extra("07_scm")
117+
assert nonunique["validation_tier"] == "identification_dependent_native"
118+
assert nonunique["tier"] == "T4"
119+
assert "not unique" in nonunique["native_note"]
120+
121+
py_gap = _estimate("07_scm", "py", "avg_post_gap")
122+
r_gap = _estimate("07_scm", "R", "avg_post_gap")
123+
stata_gap = _estimate("07_scm", "Stata", "avg_post_gap")
124+
assert _rel_gap(py_gap, r_gap) <= 0.025
125+
assert _rel_gap(py_gap, stata_gap) <= 0.001
126+
127+
unique = _extra("52_scm_unique")
128+
assert unique["true_gap"] == 2.0
129+
assert "Unique-solution counterpart" in unique["note"]
130+
assert abs(_estimate("52_scm_unique", "py", "avg_post_gap") - 2.0) <= 1e-5
131+
assert _estimate("52_scm_unique", "py", "pre_treatment_rmse") == 0.0
132+
assert _rel_gap(
133+
_estimate("52_scm_unique", "py", "avg_post_gap"),
134+
_estimate("52_scm_unique", "R", "avg_post_gap"),
135+
) <= 0.01
136+
137+
138+
def test_did_aggregation_convention_rows_keep_point_parity():
139+
bjs_note = _extra("16_bjs")
140+
assert "parity_note" in bjs_note
141+
assert (
142+
"different autosample/aggregation convention"
143+
in _gap_description("16_bjs", "stata_gap_note")
144+
)
145+
assert _rel_gap(
146+
_estimate("16_bjs", "py", "att_bjs"),
147+
_estimate("16_bjs", "R", "att_bjs"),
148+
) < 1e-7
149+
150+
etwfe_note = _extra("17_etwfe")["aggregation_note"]
151+
assert "weighting='treated'" in etwfe_note
152+
assert "Point estimates match to machine precision" in etwfe_note
153+
assert _rel_gap(
154+
_estimate("17_etwfe", "py", "att_etwfe"),
155+
_estimate("17_etwfe", "R", "att_etwfe"),
156+
) < 1e-12
157+
158+
159+
def test_causal_forest_gap_is_within_combined_monte_carlo_error():
160+
extra = _extra("13_causal_forest")
161+
assert "clean overlap" in extra["dgp"]
162+
assert "AIPW doubly-robust" in extra["note"]
163+
164+
for stat in ("ate_causal_forest", "att_causal_forest"):
165+
py = _row("13_causal_forest", "py", stat)
166+
r = _row("13_causal_forest", "R", stat)
167+
combined_se = math.sqrt(float(py["se"]) ** 2 + float(r["se"]) ** 2)
168+
assert abs(float(py["estimate"]) - float(r["estimate"])) / combined_se <= 1.25
169+
assert _rel_gap(float(py["estimate"]), float(r["estimate"])) <= 0.07

0 commit comments

Comments
 (0)