Skip to content

Commit 80db846

Browse files
Yoojin-namclaude
andauthored
feat(check-reporting): automate PRISMA flow cascade arithmetic verify (#33)
Adds scripts/prisma_cascade_check.py: reads round1/round2/round3 screening TSV/CSV artifacts, computes the PRISMA flow cascade from raw decisions, and optionally cross-checks against manuscript prose. Motivation: PRISMA cascade off-by-one prose errors are a high-frequency reviewer red flag (e.g., 151+108+39+1+1+4=304 followed by prose summary "305"). Deterministic recompute from raw artifacts catches these before submission. Synthetic fixture tests cover TSV-only / matching-prose / off-by-one / bad-column. All 4 pass. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3a0f42f commit 80db846

3 files changed

Lines changed: 408 additions & 0 deletions

File tree

skills/check-reporting/SKILL.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,36 @@ These items are frequently missing in medical manuscripts:
373373

374374
---
375375

376+
## PRISMA Cascade Arithmetic Auto-Verify
377+
378+
PRISMA 2020 flow diagrams chain a cascade of subtractions (database
379+
records → after dedup → title/abstract screened → full-text reviewed →
380+
included in synthesis). Off-by-one errors in the prose cascade are a
381+
high-frequency reviewer red flag (e.g., `151 + 108 + 39 + 1 + 1 + 4 =
382+
304` followed by a prose summary "305" four lines later).
383+
384+
When PRISMA 2020 or PRISMA-DTA is selected and round-by-round
385+
screening TSV artifacts are available, run the cascade auto-verify:
386+
387+
```bash
388+
python "${CLAUDE_SKILL_DIR}/scripts/prisma_cascade_check.py" \
389+
--round1 2_Screening/round1.tsv \
390+
--round2 2_Screening/round2.tsv \
391+
--round3 2_Screening/round3_adjudication.tsv \
392+
--manuscript manuscript.md \
393+
--out qc/prisma_cascade.json
394+
```
395+
396+
The script:
397+
1. Reads the round TSVs and counts `INCLUDE` / `EXCLUDE` / `MAYBE`
398+
decisions per round.
399+
2. Computes the cascade arithmetic from raw decisions (no prose).
400+
3. Optionally grep the manuscript for matching stage-count claims and
401+
emits per-stage drift when the prose disagrees.
402+
403+
Treat any `manuscript_drift` entry as a P0 blocker — fix the prose to
404+
match the computed cascade and re-run.
405+
376406
## Submission Checklist Export
377407

378408
Many journals require a filled reporting checklist to be submitted alongside the manuscript.
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
#!/usr/bin/env python3
2+
"""
3+
prisma_cascade_check.py — PRISMA flow cascade arithmetic auto-verify.
4+
5+
PRISMA 2020 flow diagrams chain a cascade of subtractions:
6+
7+
[identified across databases] → [after dedup]
8+
→ [title/abstract screened]
9+
→ [full-text reviewed]
10+
→ [included in qualitative synthesis]
11+
→ [included in quantitative synthesis]
12+
13+
Each transition has a corresponding "excluded" count. The arithmetic is
14+
trivial in principle but reviewers and editors find off-by-one errors at
15+
high frequency: the prose cascade `151 + 108 + 39 + 1 + 1 + 4 = 304` is
16+
followed by a prose summary "305" four lines later. The desk-reject
17+
follows immediately because the prose is presented as fact.
18+
19+
This script:
20+
21+
1. Reads round-by-round screening TSV artifacts
22+
(`round1.tsv`, `round2.tsv`, `round3_adjudication.tsv`).
23+
2. Computes the canonical flow chain from raw decisions.
24+
3. Optionally cross-checks against a manuscript markdown body and emits
25+
a per-stage drift report.
26+
27+
Usage
28+
=====
29+
30+
python prisma_cascade_check.py \\
31+
--round1 2_Screening/round1.tsv \\
32+
--round2 2_Screening/round2.tsv \\
33+
--round3 2_Screening/round3_adjudication.tsv \\
34+
--out qc/prisma_cascade.json
35+
36+
python prisma_cascade_check.py \\
37+
--round1 ... --round2 ... --round3 ... \\
38+
--manuscript manuscript.md \\
39+
--out qc/prisma_cascade.json
40+
41+
Decision column defaults:
42+
round 1 / round 2: `decision` ∈ {INCLUDE, EXCLUDE, MAYBE}
43+
round 3: `round3_decision` ∈ {INCLUDE, EXCLUDE}
44+
45+
Output JSON:
46+
47+
{
48+
"submission_safe": false,
49+
"stage_counts": {
50+
"round1_total": 458,
51+
"round1_include": 220,
52+
"round2_include": 87,
53+
"round3_include": 42,
54+
"round3_exclude": 45
55+
},
56+
"cascade_arithmetic": {
57+
"round1_to_round2": {"excluded": 238, "checked": true},
58+
"round2_to_round3": {"excluded": 133, "checked": true}
59+
},
60+
"manuscript_drift": [
61+
{"stage": "round3_include", "computed": 42, "manuscript": 43,
62+
"manuscript_line": 67}
63+
]
64+
}
65+
66+
Exit codes:
67+
0 — no drift (and manuscript agrees if supplied)
68+
1 — drift between computed cascade and manuscript prose
69+
2 — invocation error
70+
"""
71+
72+
from __future__ import annotations
73+
74+
import argparse
75+
import csv
76+
import json
77+
import re
78+
import sys
79+
from dataclasses import dataclass, field
80+
from pathlib import Path
81+
82+
83+
def count_decisions(tsv_path: Path, decision_col: str) -> dict[str, int]:
84+
"""Return {decision: count} for the given TSV."""
85+
delim = "," if tsv_path.suffix.lower() == ".csv" else "\t"
86+
counts: dict[str, int] = {}
87+
total = 0
88+
with tsv_path.open("r", encoding="utf-8", newline="") as fh:
89+
reader = csv.DictReader(fh, delimiter=delim)
90+
if reader.fieldnames is None or decision_col not in reader.fieldnames:
91+
print(
92+
f"ERROR: decision column {decision_col!r} not in {tsv_path}",
93+
file=sys.stderr,
94+
)
95+
sys.exit(2)
96+
for row in reader:
97+
decision = (row.get(decision_col) or "").strip().upper()
98+
if decision:
99+
counts[decision] = counts.get(decision, 0) + 1
100+
total += 1
101+
counts["_total"] = total
102+
return counts
103+
104+
105+
def search_manuscript_stage(text: str, stage_name: str) -> tuple[int | None, int | None]:
106+
"""Best-effort grep for a stage count and its line number.
107+
108+
Stage names map to a small dictionary of cascade phrases. Returns
109+
(value, lineno) or (None, None) when no match found.
110+
"""
111+
patterns = {
112+
"round1_include": [
113+
r"(\d{1,3}(?:,\d{3})*)\s+records?\s+(?:were\s+)?(?:included\s+)?after\s+title.{0,40}abstract",
114+
r"(\d{1,3}(?:,\d{3})*)\s+records?\s+screened\s+by\s+title",
115+
],
116+
"round2_include": [
117+
r"(\d{1,3}(?:,\d{3})*)\s+records?\s+(?:moved\s+forward\s+)?to\s+full.?text",
118+
r"(\d{1,3}(?:,\d{3})*)\s+records?\s+(?:were\s+)?retrieved\s+for\s+full.?text",
119+
],
120+
"round3_include": [
121+
r"(\d{1,3}(?:,\d{3})*)\s+studies?\s+(?:were\s+)?included\s+in\s+(?:the\s+)?(?:final\s+)?(?:qualitative|quantitative)\s+synthesis",
122+
r"(\d{1,3}(?:,\d{3})*)\s+studies?\s+(?:were\s+)?included\s+in\s+(?:the\s+)?(?:meta-analysis|review)",
123+
r"(?:included|comprised|finally\s+included)\s+(\d{1,3}(?:,\d{3})*)\s+studies?",
124+
],
125+
}
126+
pats = patterns.get(stage_name)
127+
if not pats:
128+
return None, None
129+
for lineno, line in enumerate(text.splitlines(), start=1):
130+
for pat in pats:
131+
m = re.search(pat, line, flags=re.IGNORECASE)
132+
if m:
133+
try:
134+
val = int(m.group(1).replace(",", ""))
135+
except ValueError:
136+
continue
137+
return val, lineno
138+
return None, None
139+
140+
141+
@dataclass
142+
class CascadeReport:
143+
submission_safe: bool
144+
stage_counts: dict[str, int] = field(default_factory=dict)
145+
cascade_arithmetic: dict[str, dict] = field(default_factory=dict)
146+
manuscript_drift: list[dict] = field(default_factory=list)
147+
148+
149+
def build_report(
150+
round1: Path,
151+
round2: Path,
152+
round3: Path,
153+
manuscript: Path | None,
154+
r1_col: str,
155+
r2_col: str,
156+
r3_col: str,
157+
) -> CascadeReport:
158+
r1 = count_decisions(round1, r1_col)
159+
r2 = count_decisions(round2, r2_col)
160+
r3 = count_decisions(round3, r3_col)
161+
162+
stage_counts = {
163+
"round1_total": r1.get("_total", 0),
164+
"round1_include": r1.get("INCLUDE", 0) + r1.get("MAYBE", 0),
165+
"round2_include": r2.get("INCLUDE", 0) + r2.get("MAYBE", 0),
166+
"round3_include": r3.get("INCLUDE", 0),
167+
"round3_exclude": r3.get("EXCLUDE", 0),
168+
}
169+
170+
cascade: dict[str, dict] = {
171+
"round1_to_round2": {
172+
"excluded": stage_counts["round1_total"] - stage_counts["round2_include"],
173+
"checked": True,
174+
},
175+
"round2_to_round3": {
176+
"excluded": (
177+
stage_counts["round2_include"]
178+
- (stage_counts["round3_include"] + stage_counts["round3_exclude"])
179+
),
180+
"checked": True,
181+
},
182+
}
183+
184+
drifts: list[dict] = []
185+
if manuscript is not None and manuscript.is_file():
186+
text = manuscript.read_text(encoding="utf-8")
187+
for stage in ("round1_include", "round2_include", "round3_include"):
188+
val, lineno = search_manuscript_stage(text, stage)
189+
if val is None:
190+
continue
191+
if val != stage_counts[stage]:
192+
drifts.append(
193+
{
194+
"stage": stage,
195+
"computed": stage_counts[stage],
196+
"manuscript": val,
197+
"manuscript_line": lineno,
198+
}
199+
)
200+
201+
submission_safe = not drifts
202+
return CascadeReport(
203+
submission_safe=submission_safe,
204+
stage_counts=stage_counts,
205+
cascade_arithmetic=cascade,
206+
manuscript_drift=drifts,
207+
)
208+
209+
210+
def main(argv: list[str] | None = None) -> int:
211+
parser = argparse.ArgumentParser(description="PRISMA flow cascade arithmetic auto-verify.")
212+
parser.add_argument("--round1", type=Path, required=True)
213+
parser.add_argument("--round2", type=Path, required=True)
214+
parser.add_argument("--round3", type=Path, required=True)
215+
parser.add_argument("--manuscript", type=Path, default=None)
216+
parser.add_argument("--r1-col", default="decision")
217+
parser.add_argument("--r2-col", default="decision")
218+
parser.add_argument("--r3-col", default="round3_decision")
219+
parser.add_argument("--out", type=Path, default=Path("qc/prisma_cascade.json"))
220+
parser.add_argument("--quiet", action="store_true")
221+
args = parser.parse_args(argv)
222+
223+
for p, label in (
224+
(args.round1, "--round1"),
225+
(args.round2, "--round2"),
226+
(args.round3, "--round3"),
227+
):
228+
if not p.is_file():
229+
print(f"ERROR: {label} not a file: {p}", file=sys.stderr)
230+
return 2
231+
232+
report = build_report(
233+
args.round1, args.round2, args.round3,
234+
args.manuscript, args.r1_col, args.r2_col, args.r3_col,
235+
)
236+
237+
args.out.parent.mkdir(parents=True, exist_ok=True)
238+
args.out.write_text(
239+
json.dumps(
240+
{
241+
"submission_safe": report.submission_safe,
242+
"stage_counts": report.stage_counts,
243+
"cascade_arithmetic": report.cascade_arithmetic,
244+
"manuscript_drift": report.manuscript_drift,
245+
},
246+
indent=2,
247+
),
248+
encoding="utf-8",
249+
)
250+
251+
if not args.quiet:
252+
if report.submission_safe:
253+
print(
254+
"PASS: cascade computed. Stages: "
255+
+ " → ".join(
256+
f"{k}={v}" for k, v in report.stage_counts.items() if not k.startswith("_")
257+
)
258+
)
259+
else:
260+
print(
261+
f"FAIL: {len(report.manuscript_drift)} manuscript-prose "
262+
"drift(s) vs computed cascade."
263+
)
264+
for d in report.manuscript_drift:
265+
print(
266+
f" {d['stage']}: computed={d['computed']} "
267+
f"manuscript={d['manuscript']} (line {d['manuscript_line']})"
268+
)
269+
270+
return 0 if report.submission_safe else 1
271+
272+
273+
if __name__ == "__main__":
274+
sys.exit(main())

0 commit comments

Comments
 (0)