Skip to content

Commit 2f74a7a

Browse files
Yoojin-namclaude
andauthored
feat(meta-analysis): add Phase 4 entry gate — pool consistency assert (#31)
Adds scripts/check_pool_consistency.py: blocks Phase 4 (data extraction) when round-3 adjudication TSV's UID set disagrees with FINAL_POOL_LOCK.yaml. Default include labels: INCLUDE, INCLUDE_MIXED. JSON output records in_lock_not_tsv and in_tsv_not_lock set differences so users can trace the exact UIDs that disagree. Companion to PR T1-5 (lock template). Without this gate, a stale extraction TSV silently produces a synthesis matrix that does not correspond to the locked pool. Synthetic fixture tests cover agree / TSV-extra / lock-extra / missing-column. All 4 pass. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 0819efe commit 2f74a7a

3 files changed

Lines changed: 362 additions & 0 deletions

File tree

skills/meta-analysis/SKILL.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,44 @@ file (`FINAL_POOL_LOCK_v2.yaml`), and propagate to every artifact.
265265

266266
**Goal**: Create standardized extraction forms and extract 2x2 or effect size data.
267267

268+
#### 4.0 Entry gate (MANDATORY): pool composition lock ↔ adjudication TSV
269+
270+
Before any extraction work begins, run the deterministic UID-set check
271+
to confirm that the round-3 adjudication TSV and `FINAL_POOL_LOCK.yaml`
272+
(produced in Phase 3f.5) agree on which UIDs are included.
273+
274+
```bash
275+
python "${CLAUDE_SKILL_DIR}/scripts/check_pool_consistency.py" \
276+
--lock 2_Data/FINAL_POOL_LOCK.yaml \
277+
--adjudication-tsv 2_Screening/round3_adjudication.tsv \
278+
--decision-col round3_decision \
279+
--uid-col uid \
280+
--include-labels "INCLUDE,INCLUDE_MIXED" \
281+
--out qc/pool_consistency.json
282+
```
283+
284+
Output `qc/pool_consistency.json`:
285+
286+
```json
287+
{
288+
"submission_safe": false,
289+
"match": false,
290+
"lock_include_n": 42,
291+
"tsv_include_n": 43,
292+
"in_lock_not_tsv": ["UID_007"],
293+
"in_tsv_not_lock": ["UID_055"]
294+
}
295+
```
296+
297+
The gate fails closed: any UID disagreement blocks extraction. To
298+
resolve, either (a) re-freeze the lock with the corrected set of UIDs
299+
and propagate to downstream artifacts, or (b) correct the adjudication
300+
TSV if a row was mis-labeled. Do NOT proceed to Phase 4 with a
301+
mismatch — the resulting extraction matrix will not align with the
302+
locked pool, and the drift surfaces as a fabrication-grade red flag at
303+
peer review.
304+
305+
268306
> **Failure-mode cross-ref**`references/data_integrity_checklist.md` DI-1~DI-5 are mandatory during extraction (2x2 arm-swap, KM audit trail, methodology mismatch, PRISMA 5-way drift, single-source k).
269307
270308
**Recommended extraction form**: For SR-MA targeting high-impact radiology / medical AI journals, use `${CLAUDE_SKILL_DIR}/templates/extraction_form_v2.md`. Dual-extractor + source-page-reference + verbatim-quote columns prevent the 2x2 cell-swap and cohort-overlap blind spots surfaced in recent SR-MA peer-review cycles. New required columns: `cohort_source`, `source_page_ref`, `source_verbatim_quote`, `extraction_consensus_status`, `overlap_flag_reviewer1/2`, `sample_n_dta_pool` vs `sample_n_prognostic_pool`.
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
#!/usr/bin/env python3
2+
"""
3+
check_pool_consistency.py — Phase 4 entry gate.
4+
5+
Asserts UID-set equality between (a) the frozen `FINAL_POOL_LOCK.yaml`
6+
and (b) the actual round-3 adjudication TSV that feeds extraction. Blocks
7+
Phase 4 (data extraction) until the two agree.
8+
9+
Why this gate exists
10+
====================
11+
Cross-project precedent (anonymized): an LLM reporting-quality SR carried
12+
five documents that disagreed on INCLUDE/EXCLUDE counts. Three EXCLUDE
13+
rows existed in the downstream extraction sheet without matching INCLUDE
14+
decisions. The drift traced to a post-freeze adjudication change that
15+
propagated to the extraction TSV but not the lock — or the other way
16+
around. Either direction is fatal at peer review.
17+
18+
The gate fails CLOSED: if the lock and the extraction sheet disagree on
19+
even one UID, extraction is blocked.
20+
21+
Inputs
22+
======
23+
24+
--lock PATH FINAL_POOL_LOCK.yaml (Phase 3f.5 artifact)
25+
--adjudication-tsv PATH round3_adjudication.tsv (Phase 3c artifact)
26+
--decision-col NAME column holding the decision label
27+
(default: "round3_decision")
28+
--uid-col NAME column holding the UID (default: "uid")
29+
--include-labels LIST decisions counted as INCLUDE
30+
(default: "INCLUDE,INCLUDE_MIXED")
31+
--out PATH JSON report (default: qc/pool_consistency.json)
32+
33+
Output JSON
34+
===========
35+
36+
{
37+
"submission_safe": false,
38+
"lock_include_n": 42,
39+
"tsv_include_n": 43,
40+
"in_lock_not_tsv": ["UID_007"],
41+
"in_tsv_not_lock": ["UID_055"],
42+
"match": false
43+
}
44+
45+
Exit codes
46+
==========
47+
0 lock and TSV agree on the UID set
48+
1 disagreement (PR T1-5 blocks extraction)
49+
2 invocation error (missing files, missing columns)
50+
51+
Read-only script. No file modification.
52+
"""
53+
54+
from __future__ import annotations
55+
56+
import argparse
57+
import csv
58+
import json
59+
import sys
60+
from pathlib import Path
61+
62+
63+
def load_lock_uids(lock_path: Path, label_set: list[str]) -> set[str]:
64+
try:
65+
import yaml # type: ignore
66+
except ImportError:
67+
print(
68+
"ERROR: PyYAML required for --lock parsing. pip install PyYAML",
69+
file=sys.stderr,
70+
)
71+
sys.exit(2)
72+
data = yaml.safe_load(lock_path.read_text(encoding="utf-8"))
73+
if not isinstance(data, dict):
74+
print(f"ERROR: lock file not a mapping: {lock_path}", file=sys.stderr)
75+
sys.exit(2)
76+
# INCLUDE_MIXED maps to mixed_uids in the lock template.
77+
uids: set[str] = set()
78+
if "INCLUDE" in label_set:
79+
uids.update(str(u) for u in (data.get("include_uids") or []))
80+
if "INCLUDE_MIXED" in label_set:
81+
uids.update(str(u) for u in (data.get("mixed_uids") or []))
82+
if "MIXED" in label_set:
83+
uids.update(str(u) for u in (data.get("mixed_uids") or []))
84+
if "EXCLUDE" in label_set:
85+
uids.update(str(u) for u in (data.get("exclude_uids") or []))
86+
return uids
87+
88+
89+
def load_tsv_uids(
90+
tsv_path: Path,
91+
decision_col: str,
92+
uid_col: str,
93+
label_set: set[str],
94+
) -> set[str]:
95+
# Allow .tsv or .csv (sniff by extension).
96+
delim = "," if tsv_path.suffix.lower() == ".csv" else "\t"
97+
with tsv_path.open("r", encoding="utf-8", newline="") as fh:
98+
reader = csv.DictReader(fh, delimiter=delim)
99+
if reader.fieldnames is None:
100+
print(f"ERROR: empty TSV: {tsv_path}", file=sys.stderr)
101+
sys.exit(2)
102+
if uid_col not in reader.fieldnames:
103+
print(
104+
f"ERROR: uid column {uid_col!r} not in TSV columns "
105+
f"{reader.fieldnames!r}",
106+
file=sys.stderr,
107+
)
108+
sys.exit(2)
109+
if decision_col not in reader.fieldnames:
110+
print(
111+
f"ERROR: decision column {decision_col!r} not in TSV columns "
112+
f"{reader.fieldnames!r}",
113+
file=sys.stderr,
114+
)
115+
sys.exit(2)
116+
uids: set[str] = set()
117+
for row in reader:
118+
decision = (row.get(decision_col) or "").strip()
119+
if decision in label_set:
120+
uid = (row.get(uid_col) or "").strip()
121+
if uid:
122+
uids.add(uid)
123+
return uids
124+
125+
126+
def main(argv: list[str] | None = None) -> int:
127+
parser = argparse.ArgumentParser(
128+
description=(
129+
"Phase 4 entry gate: asserts UID-set equality between the frozen "
130+
"FINAL_POOL_LOCK.yaml and the round-3 adjudication TSV."
131+
)
132+
)
133+
parser.add_argument("--lock", type=Path, required=True)
134+
parser.add_argument("--adjudication-tsv", type=Path, required=True)
135+
parser.add_argument("--decision-col", default="round3_decision")
136+
parser.add_argument("--uid-col", default="uid")
137+
parser.add_argument(
138+
"--include-labels",
139+
default="INCLUDE,INCLUDE_MIXED",
140+
help="Comma-separated decision labels counted as included.",
141+
)
142+
parser.add_argument("--out", type=Path, default=Path("qc/pool_consistency.json"))
143+
parser.add_argument("--quiet", action="store_true")
144+
args = parser.parse_args(argv)
145+
146+
if not args.lock.is_file():
147+
print(f"ERROR: lock not found: {args.lock}", file=sys.stderr)
148+
return 2
149+
if not args.adjudication_tsv.is_file():
150+
print(f"ERROR: TSV not found: {args.adjudication_tsv}", file=sys.stderr)
151+
return 2
152+
153+
labels = [s.strip() for s in args.include_labels.split(",") if s.strip()]
154+
label_set = set(labels)
155+
lock_uids = load_lock_uids(args.lock, labels)
156+
tsv_uids = load_tsv_uids(
157+
args.adjudication_tsv, args.decision_col, args.uid_col, label_set
158+
)
159+
160+
in_lock_only = sorted(lock_uids - tsv_uids)
161+
in_tsv_only = sorted(tsv_uids - lock_uids)
162+
match = not in_lock_only and not in_tsv_only
163+
164+
report = {
165+
"submission_safe": match,
166+
"match": match,
167+
"lock_include_n": len(lock_uids),
168+
"tsv_include_n": len(tsv_uids),
169+
"in_lock_not_tsv": in_lock_only,
170+
"in_tsv_not_lock": in_tsv_only,
171+
"include_labels": labels,
172+
}
173+
args.out.parent.mkdir(parents=True, exist_ok=True)
174+
args.out.write_text(json.dumps(report, indent=2), encoding="utf-8")
175+
176+
if not args.quiet:
177+
if match:
178+
print(f"PASS: lock and TSV agree ({len(lock_uids)} UIDs).")
179+
else:
180+
print(
181+
f"FAIL: lock includes {len(lock_uids)} UIDs, TSV includes "
182+
f"{len(tsv_uids)} UIDs."
183+
)
184+
if in_lock_only:
185+
print(f" In lock but not TSV ({len(in_lock_only)}):")
186+
for u in in_lock_only[:10]:
187+
print(f" - {u}")
188+
if len(in_lock_only) > 10:
189+
print(f" ... and {len(in_lock_only) - 10} more")
190+
if in_tsv_only:
191+
print(f" In TSV but not lock ({len(in_tsv_only)}):")
192+
for u in in_tsv_only[:10]:
193+
print(f" - {u}")
194+
if len(in_tsv_only) > 10:
195+
print(f" ... and {len(in_tsv_only) - 10} more")
196+
197+
return 0 if match else 1
198+
199+
200+
if __name__ == "__main__":
201+
sys.exit(main())
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#!/usr/bin/env bash
2+
# Regression tests for meta-analysis check_pool_consistency.py.
3+
4+
set -uo pipefail
5+
6+
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
7+
SCRIPT="$REPO_ROOT/skills/meta-analysis/scripts/check_pool_consistency.py"
8+
TMP="$(mktemp -d -t pool_consist.XXXXXX)"
9+
trap 'rm -rf "$TMP"' EXIT
10+
11+
[[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
12+
python3 -c "import yaml" 2>/dev/null || { echo "SKIP: pyyaml unavailable"; exit 0; }
13+
14+
fail=0
15+
ran=0
16+
assert_exit() {
17+
local label="$1" expected="$2" actual="$3"
18+
ran=$((ran + 1))
19+
if [[ "$expected" == "$actual" ]]; then
20+
printf ' PASS %-50s exit=%s\n' "$label" "$actual"
21+
else
22+
printf ' FAIL %-50s expected=%s actual=%s\n' "$label" "$expected" "$actual"
23+
fail=$((fail + 1))
24+
fi
25+
}
26+
27+
# --------------------------------------------------------------------------
28+
# Case 1: lock and TSV agree on 3 UIDs => PASS
29+
# --------------------------------------------------------------------------
30+
mkdir -p "$TMP/c1"
31+
cat > "$TMP/c1/lock.yaml" <<'EOF'
32+
freeze_date: "2026-01-01"
33+
final_pool_n: 3
34+
include_count: 3
35+
exclude_count: 0
36+
mixed_count: 0
37+
include_uids: [UID_001, UID_002, UID_003]
38+
exclude_uids: []
39+
mixed_uids: []
40+
EOF
41+
cat > "$TMP/c1/r3.tsv" <<'EOF'
42+
uid round3_decision notes
43+
UID_001 INCLUDE ok
44+
UID_002 INCLUDE ok
45+
UID_003 INCLUDE ok
46+
UID_004 EXCLUDE out of scope
47+
EOF
48+
python3 "$SCRIPT" --lock "$TMP/c1/lock.yaml" \
49+
--adjudication-tsv "$TMP/c1/r3.tsv" \
50+
--out "$TMP/c1/report.json" --quiet
51+
assert_exit "case 1: lock and TSV agree" 0 $?
52+
53+
# --------------------------------------------------------------------------
54+
# Case 2: TSV has extra UID => FAIL
55+
# --------------------------------------------------------------------------
56+
mkdir -p "$TMP/c2"
57+
cat > "$TMP/c2/lock.yaml" <<'EOF'
58+
include_uids: [UID_001, UID_002]
59+
exclude_uids: []
60+
mixed_uids: []
61+
EOF
62+
cat > "$TMP/c2/r3.tsv" <<'EOF'
63+
uid round3_decision
64+
UID_001 INCLUDE
65+
UID_002 INCLUDE
66+
UID_009 INCLUDE
67+
EOF
68+
python3 "$SCRIPT" --lock "$TMP/c2/lock.yaml" \
69+
--adjudication-tsv "$TMP/c2/r3.tsv" \
70+
--out "$TMP/c2/report.json" --quiet
71+
assert_exit "case 2: TSV extra UID (FAIL)" 1 $?
72+
python3 - "$TMP/c2/report.json" <<'PY' || fail=$((fail + 1))
73+
import json, sys
74+
with open(sys.argv[1]) as fh: r = json.load(fh)
75+
assert "UID_009" in r["in_tsv_not_lock"], r
76+
assert not r["in_lock_not_tsv"], r
77+
PY
78+
79+
# --------------------------------------------------------------------------
80+
# Case 3: lock has extra UID => FAIL
81+
# --------------------------------------------------------------------------
82+
mkdir -p "$TMP/c3"
83+
cat > "$TMP/c3/lock.yaml" <<'EOF'
84+
include_uids: [UID_001, UID_002, UID_999]
85+
mixed_uids: []
86+
exclude_uids: []
87+
EOF
88+
cat > "$TMP/c3/r3.tsv" <<'EOF'
89+
uid round3_decision
90+
UID_001 INCLUDE
91+
UID_002 INCLUDE
92+
EOF
93+
python3 "$SCRIPT" --lock "$TMP/c3/lock.yaml" \
94+
--adjudication-tsv "$TMP/c3/r3.tsv" \
95+
--out "$TMP/c3/report.json" --quiet
96+
assert_exit "case 3: lock extra UID (FAIL)" 1 $?
97+
python3 - "$TMP/c3/report.json" <<'PY' || fail=$((fail + 1))
98+
import json, sys
99+
with open(sys.argv[1]) as fh: r = json.load(fh)
100+
assert "UID_999" in r["in_lock_not_tsv"], r
101+
PY
102+
103+
# --------------------------------------------------------------------------
104+
# Case 4: missing decision column => exit 2
105+
# --------------------------------------------------------------------------
106+
mkdir -p "$TMP/c4"
107+
cat > "$TMP/c4/lock.yaml" <<'EOF'
108+
include_uids: [UID_001]
109+
mixed_uids: []
110+
exclude_uids: []
111+
EOF
112+
cat > "$TMP/c4/r3.tsv" <<'EOF'
113+
uid notes
114+
UID_001 whatever
115+
EOF
116+
python3 "$SCRIPT" --lock "$TMP/c4/lock.yaml" \
117+
--adjudication-tsv "$TMP/c4/r3.tsv" \
118+
--out "$TMP/c4/report.json" --quiet 2>/dev/null
119+
assert_exit "case 4: missing decision col (exit 2)" 2 $?
120+
121+
echo ""
122+
echo "ran=$ran fail=$fail"
123+
[[ $fail -eq 0 ]]

0 commit comments

Comments
 (0)