self-review
Pre-submission self-review for the user's own manuscripts, applying a reviewer perspective. Systematic check across 10 categories with research-type branching. Outputs Anticipated Major/Minor Comments with severity framing and optional R0 numbering for /revise pipeline integratio
Install
npx skills add https://github.com/Aperivue/medsci-skills/tree/main/skills/self-review
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install aperivue-medsci-skills@llmmart
git clone https://github.com/Aperivue/medsci-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole aperivue/medsci-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Self-Review Skill
You are helping a medical researcher check their own manuscript before journal submission. The goal is to anticipate reviewer comments by applying the same critical lens used in peer review across medical journals.
This is NOT about writing a review. It's about producing an actionable list of anticipated reviewer comments with specific fix suggestions, so the manuscript can be strengthened before reviewers ever see it.
Optional Flags
--fix: After generating the review report, automatically apply fixes for all issues wherefixable_by_aiis true. Edits the manuscript in place, then reports a diff summary. Does NOT fix issues markedfixable_by_ai: false(e.g., missing data, design flaws). Maximum 2 fix-and-re-review iterations.--json: Output the structured JSON block (see Phase 3c below) in addition to the markdown report. Default when called from/write-paperPhase 7.--panel: Run the multi-agent panel review (Phase 2.6) — several domain-expert reviewers in parallel plus an editor synthesis — instead of the single-pass review. Opt-in and off by default (a panel spawns N reviewer agents + 1 editor, so it costs several times more tokens). Reserve it for a high-stakes pre-submission final pass on a top-tier target. Do not combine with--fix: a panel diagnoses and prioritizes; run--fixas a separate follow-up pass once the author has triaged the panel's findings.
Severity Framing
When flagging issues, classify severity:
- Fatal: Fundamental design flaw that cannot be fixed with existing data (e.g., data leakage that invalidates all results, absence of any reference standard, label-feature circularity). The manuscript likely needs redesign. Submission would likely result in Reject.
- Fixable: Significant but addressable with existing data (e.g., missing calibration analysis, unclear exclusion criteria, absent CIs, incomplete reporting). These are the most actionable findings.
Most issues are Fixable. Reserve Fatal for true design-level problems.
Two Objectives: the Floor and the Ceiling
A submission-ready manuscript optimizes two things at once, and most of this skill (and the gate stack behind it) only optimizes the first:
- Floor — minimize rejection-for-cause. Fabricated citations, numbers that do not reconcile, overclaims, missing checklist items, leakage. Categories A–K and the deterministic gates (Phases 2.5–2.5f) do this, and they are right to. Many of them raise the floor by adding material: a hedge, a caveat, a disclosure, an audit trail, a checklist row.
- Ceiling — maximize editorial-championing. Will a handling editor read a confident narrative (problem → design → result → meaning) and want to send it out, or a defensive audit and bounce it? Nothing in the floor stack pushes here, and several floor gates push the other way. Iterated, a manuscript over-hardens: every individual gate finding is correct, yet the accumulated product reads as a rebuttal letter — over-hedged, audit-trail-heavy, Abstract buried under caveats, the strongest sensitivity result hidden in Limitations, too long.
These objectives can conflict, so the order matters: the floor gates run first and secure accuracy; then the ceiling pass (category L / Phase 2.5g) reads the accurate manuscript as a whole and recommends SUBTRACTION — REMOVE, MOVE, or TIGHTEN — so the same content is read confidently. The ceiling pass is advisory and never blocks; it cannot relax a floor gate. Without it, repeated self-review monotonically over-defends. Surface the ceiling findings as their own first-class output (Phase 3), not folded silently into the "add this" comments. Phase 2.5i (the loop controller) then reads the floor + ceiling state to declare when the loop is done — including a zero-edit PASS — so an accurate draft is not over-hardened by a pass it does not need.
Workflow
Phase 1: Intake
Get the manuscript -- PDF, Word doc, or pasted text.
Ask the user:
- Target journal? (affects reporting standards and scope expectations)
- Manuscript type? (original research / review / perspective / technical note / letter / meta-analysis / case report)
- Anything they're already worried about?
- Review depth? The default is a single-pass review. For a high-stakes pre-submission final pass, a multi-agent panel (
--panel, Phase 2.6) is available — several domain-expert reviewers run independently, then an editor consolidates them (more thorough, but it spawns several agents so it costs several times more tokens). On an interactive run, surface this option once in one line and offer it; then proceed with the single-pass review unless the user opts in. Do not surface or auto-apply the panel when invoked with--jsonor from/write-paper— those stay single-pass.
Read the full manuscript.
SSOT gate — confirm there is one manuscript, not several. Self-review reads a single input file, so a divergence between a legacy working copy and the live submission copy is structurally invisible to it. Before a
--panelrun (or any pre-submission pass), check for multiple copies and reconcile first:find . \( -path '*manuscript*' -o -path '*main_document*' \) -name '*.md' | grep -v node_modulesIf more than one manuscript-like file exists, confirm which is the SSOT and run
/sync-submission's divergence gate before reviewing — aSTALE_COPY(an SSOT numeric claim or heading that did not propagate to the other copy) is a P0 that must clear first:python3 "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/sync-submission/scripts/detect_copy_divergence.py" \ --ssot <ssot>.md --copy <other-copy>.mdReview the SSOT copy; do not review a stale copy and pass it.
In
--panelmode this is a blocking precondition, not advice. A panel spawns N reviewer agents + an editor, so reviewing a stale copy wastes the whole pass (a prior panel's top finding was literally "you reviewed the wrong file"). If thefindabove returns more than one manuscript-like.mdand the SSOT is not pinned — noSSOT.yamlwithtruth.manuscript_mdand no explicit--ssot <path>argument — STOP before spawning any reviewer and have the user name the SSOT (and clear anySTALE_COPY). Do not auto-pick the longest/newest file. The single-pass review may proceed on the one file it was given, but the panel must not.
Phase 2: Systematic Check
Run the manuscript through each applicable category below. For each item, assess whether a reviewer would raise it as a Major or Minor comment. Use the Research-Type Adaptation table (below) to determine which categories apply fully, partially, or not at all.
The categories (A–L). The per-item check tables — what to look for under each — live
in references/phases/phase2_systematic_check.md; read it once you have the manuscript
and know its type, and work the categories the adaptation table marks as applicable.
| Category | What it asks | |
|---|---|---|
| A | Study Design & Data Integrity | patient-level splits, leakage, input-text contamination, analysis unit |
| B | Reference Standard & Ground Truth | definition specificity, timing, annotator independence |
| C | Validation & Statistical Reporting | CIs, calibration, comparator, effect size, power-aware nulls, equivalence margins, interaction anchoring |
| D | Clinical Framing & Importance | intended use, overclaiming, novelty, endpoint↔conclusion scope |
| E | Reproducibility | preprocessing, model detail, hardware/software, data & code availability |
| F | Reporting Completeness | abstract↔body consistency, flow diagram, ethics, missing data, word cap |
| G | Reporting Guideline Compliance | match the type to its checklist; /check-reporting does the item-level audit |
| H | Circularity | label–feature overlap, tautological prediction, circular validation |
| I | Protocol Heterogeneity | multi-site acquisition, harmonization, temporal protocol drift |
| J | Method Transparency | model provenance, fine-tuning, classical-style body conventions |
| K | Reviewer-team consistency | SR/MA only — dual-vs-single conjunction, LLM-as-reviewer (both fabrication-grade) |
| L | Editorial impression & defensiveness | advisory, never blocking — the ceiling category: REMOVE / MOVE / TIGHTEN |
Run the deterministic gates. These are greps and counts, so they belong in a gate rather than in eyeballing. Run them at Phase 2 entry, on every path:
# D. endpoint↔conclusion scope
python3 "${CLAUDE_SKILL_DIR}/scripts/check_scope_coherence.py" \
--manuscript manuscript.md --out qc/scope_coherence.json --strict
# J. classical-style body conventions
python3 "${CLAUDE_SKILL_DIR}/scripts/check_classical_style.py" \
--manuscript manuscript.md --out qc/classical_style.json --strict
# K. reviewer-team consistency (SR/MA only; pass the extraction JSON file or directory)
python "${CLAUDE_SKILL_DIR}/scripts/check_reviewer_team_consistency.py" \
--manuscript manuscript.md --prospero prospero/record.md \
--extraction-json extraction/ --out _audit_self/reviewer_team_consistency.md
# L. editorial impression (advisory; exits 0 even under --strict)
python3 "${CLAUDE_SKILL_DIR}/scripts/check_editorial_impression.py" \
--manuscript manuscript.md --out qc/editorial_impression.json
# J/D. Perspective structure (genre-gated: silent unless article_type is a Perspective).
# Pass the known type via --type; it also self-detects from the front-matter article_type.
python3 "${CLAUDE_SKILL_DIR}/scripts/check_perspective_structure.py" \
--manuscript manuscript.md --type "${TYPE:-}" --out qc/perspective_structure.json
Verdict mapping: CROSS_SECTIONAL_PROGNOSTIC, SURROGATE_CARE_DIRECTIVE, SECTION_SYMBOL,
INBODY_AI_DISCLOSURE, and any reviewer-team hit (exit 1) are Anticipated Major Comments.
CROSS_SECTIONAL_YIELD_LANGUAGE, ELIGIBILITY_PROSE, DECIMAL_INCONSISTENCY,
EM_DASH_OVERUSE, PERSPECTIVE_HEADING_NOT_ASSERTION, PERSPECTIVE_ABSTRACT_NO_AUTHORIAL_MOVE,
and every check_editorial_impression verdict are Minor. The
per-verdict rationale and the resolution paths are in the reference file.
Read on demand:
Then check that every analysis you report was ever defined. The detectors in this skill ask whether a number is correct. None asks whether the analysis that produced it was defined — and that is the gap a reviewer walks straight into:
"The outcome (dependent variable) for the multivariable Cox model is not specified." … "The ground truth (reference standard) against which discrimination and calibration were assessed is not defined." … "This section is largely incomprehensible in its current form."
python3 "${CLAUDE_SKILL_DIR}/scripts/check_analysis_definitions.py" \
--manuscript manuscript.md --out qc/analysis_definitions.json --strict
MODEL_OUTCOME_UNDEFINED (a Cox / Fine–Gray / logistic model with no outcome named), MODEL_NOT_IN_METHODS, and REFERENCE_STANDARD_UNDEFINED (discrimination or calibration with nothing to score against) are Anticipated Major Comments. TIER_LABEL_UNDEFINED is Minor.
ANALYSIS_LOAD is informational and never a verdict. The reviewer who wrote "too many analyses have been performed and reported" also named the mechanism — "this appears to have contributed to omissions of critical information in the Materials and Methods section" — while a second reviewer of the same manuscript listed its sensitivity analyses as a strength. Load is the cause, not the crime. Do not cut analyses to satisfy this gate; restore the definitions the analyses crowded out. If load is genuinely high, move the defensive analyses to the supplement — same defence, far less reader burden and far less attack surface.
| File | Read it when | Cost if read blindly |
|---|---|---|
references/phases/phase2_systematic_check.md |
you are working the A–L manual pass and know the manuscript type | ~5,600 tokens — and a run that halts at Phase 1, or a panel-mode review, never reaches it |
Research-Type Adaptation
Not all categories apply equally to every study type. Use this routing table:
| Category | AI/ML | Observational | Educational | Meta-Analysis | Case Report | Surgical |
|---|---|---|---|---|---|---|
| A. Study Design | Full | Full | Partial | N/A | N/A | Full |
| B. Reference Standard | Full | Full | N/A | Per-study | Partial | Full |
| C. Validation & Stats | Full | Full | Full | Special* | Partial | Full |
| D. Clinical Framing | Full | Full | Full | Full | Full | Full |
| E. Reproducibility | Full | Partial | Partial | Partial | N/A | Full |
| F. Reporting | Full | Full | Full | Full | Full | Full |
| G. Guideline Compliance | Full | Full | Full | Full | Full | Full |
| H. Circularity | Full | Partial | N/A | N/A | N/A | Partial |
| I. Protocol Heterogeneity | Full | Full | N/A | Per-study | N/A | Full |
| J. Method Transparency | Full | Partial | Partial | N/A | N/A | Partial |
| K. Reviewer-team consistency | N/A | N/A | N/A | Full | N/A | N/A |
| L. Editorial impression | Full | Full | Full | Full | Full | Full |
*Meta-analysis: Replace C with heterogeneity assessment (I-squared, prediction intervals), publication bias (funnel plot, Egger), and sensitivity/subgroup analyses.
Type-Specific Additional Checks:
- Observational studies: Confounding assessment (DAG or adjustment strategy), selection bias, exposure measurement validity. Run Phase 2.5e (Confounding Completeness), then apply the O-probes in
references/domain-probes/observational_confounding.md— the two deterministic ones are O1 (a covariate imbalanced by exposure in Table 1 yet absent from the adjustment set) and O8 (records > subjects with the analysis unit undisclosed;check_cohort_arithmetic.py --id-col), and O7 is their opposite-direction twin (adjusting for a consequence/mediator of the outcome). If the manuscript develops or compares a clinical prediction model (TRIPOD / TRIPOD+AI, nested predictor-set comparison), also apply the CP-probes inreferences/domain-probes/clinical_prediction_model.md. The module is the single source for the probe list and its numbering; do not re-enumerate it here. - Educational studies: Learning outcome measurement validity, Kirkpatrick level, control group adequacy, curriculum fidelity
- Meta-analyses: Search comprehensiveness (2+ databases), screening reproducibility (2 reviewers), RoB assessment per study, GRADE certainty
- Case reports: Diagnostic reasoning transparency, timeline completeness, informed consent, generalizability disclaimer
- Surgical studies: Learning curve consideration, surgeon volume/experience, complication grading (Clavien-Dindo), operative detail completeness
Domain probe modules (load when the manuscript type matches):
These modules carry the same domain-specific critique probes used by /peer-review, vendored here so self-review reaches the same depth (in particular, survival/time-to-event manuscripts now get a dedicated probe set that the routing table above does not otherwise cover).
| Manuscript type / signal | Probe module |
|---|---|
| Systematic Review / Meta-Analysis | references/domain-probes/sr_ma.md (P0–P19) |
| Time-to-event / survival / prognostic model (Cox, Fine-Gray, DeepSurv, nomogram, risk-stratification cutoff) | references/domain-probes/survival_prognostic.md (S1–S9) |
| Radiomic feature reproducibility / acquisition-parameter sweep / reliability-based feature filtering | references/domain-probes/radiomics.md (R1–R4) |
| Cross-modality image synthesis (MRI→PET / MRI→CT / non-contrast→contrast / low-dose→full-dose) claiming functional/molecular information or target-modality substitution | references/domain-probes/image_synthesis.md (IS1–IS4) |
| Narrative / review article / primer / state-of-the-art | references/domain-probes/narrative_review.md (RV1–RV9) |
| Perspective / opinion / viewpoint (argumentative essay — npj DM long-essay, Lancet Comment, NEJM AI / RYAI short-structured) | references/domain-probes/narrative_review.md (RV1–RV9) + the deterministic check_perspective_structure.py gate above (IMRAD-heading + abstract authorial-move tells) |
| AI/ML primary study with a clinical claim (generalizable / outperforms clinicians / deployment-ready / can replace a reader) | references/domain-probes/ai_overclaiming.md (AO0–AO7) |
| Engineer-built medical-imaging model (segmentation / classification / detection; CNN / U-Net / nnU-Net / transformer) being validated — partition/leakage, seed & run variance, metric selection, reproducibility, reference-standard quality; plus saliency-map faithfulness, uncertainty/OOD/abstention, and deployment feasibility when a clinical-use claim is made | references/domain-probes/model_development.md (MD0–MD11) |
| LLM / MLLM evaluated on a clinical task (radiology report generation, visual question answering, clinical text extraction/classification; closed API or open weights) | references/domain-probes/mllm_evaluation.md (ME0–ME8) |
| Randomised controlled trial (parallel / crossover / cluster / stepped-wedge) | references/domain-probes/rct_trial.md (RC0–RC7) |
| Diagnostic test accuracy (DTA) primary study / multi-reader multi-case (MRMC) reader study (index test vs reference standard, AI-vs-reader, AI-assisted reading, modality comparison) | references/domain-probes/diagnostic_accuracy.md (D1–D12) |
| Case report / case series / single-patient clinical narrative (incl. adverse-event/pharmacovigilance and imaging-led radiology/nuclear-medicine/IR reports) | references/domain-probes/case_report.md (CR1–CR9) |
| AI/ML, prediction, or diagnostic study claiming cross-population performance (generalizable / deployment-ready / "works for patients"), or presenting subgroup analyses as a fairness/equity argument | references/domain-probes/equity_fairness.md (EQ0–EQ6) |
| Mendelian randomization (genetic variants as instrumental variables: two-sample summary-data, one-sample, multivariable MR, drug-target / cis-MR, non-linear MR) | references/domain-probes/mendelian_randomization.md (MR1–MR8) |
| Polygenic risk score / polygenic score (PRS / PGS) developed, validated, or applied as a predictor or risk-stratifier | references/domain-probes/polygenic_risk_score.md (PG1–PG8) |
| Network meta-analysis (≥3 interventions via direct + indirect evidence, treatment ranking, incl. component NMA) | references/domain-probes/network_meta_analysis.md (NM1–NM8) |
| Health economic evaluation (cost-effectiveness / cost-utility / cost-benefit / budget-impact; trial-based or decision-model-based — decision tree, Markov, DES) | references/domain-probes/health_economic_evaluation.md (HE1–HE8) |
| Observational study using routinely-collected health data (administrative claims / EHR / disease or population registry / health-checkup DB, linked or not) | references/domain-probes/record_routinely_collected_data.md (RD1–RD8) |
| Self-report survey / questionnaire study (KAP, physician/patient survey, cross-sectional questionnaire, web/e-survey) | references/domain-probes/survey_research.md (SV1–SV8) |
| Scoping review (maps the breadth/nature of evidence, clarifies concepts, identifies gaps; PCC framing, charting, optional appraisal — not a focused effectiveness/accuracy question) | references/domain-probes/scoping_review.md (SC1–SC8) |
| Qualitative study (interviews, focus groups, ethnography, grounded theory, phenomenology, document analysis; reflexivity, trustworthiness, thematic analysis — not quantitative validity) | references/domain-probes/qualitative_research.md (QL1–QL8) |
| Self-improving / self-evaluating system (an agent that critiques and rewrites its own output; training on model-generated data; an LLM used as the judge that scores the training signal; "self-evolving" clinical agents) | references/domain-probes/self_improving_system.md (SI1–SI7) + skills/peer-review/scripts/check_self_improvement_claims.py |
For a classifier / NLP / tabular ML manuscript, also run the deterministic feature-selection-leakage gate — a data-driven selection (feature selection, log-odds / univariate filtering, vocabulary construction, a threshold) fit on the FULL dataset before cross-validation inflates the CV metric:
python3 "${CLAUDE_SKILL_DIR}/scripts/check_cv_leakage.py" \
--manuscript manuscript.md --out qc/cv_leakage.json
CV_SELECTION_LEAKAGE (Major) fires when a selection token co-occurs with cross-validation and no fold-nesting is disclosed ("within each fold" / "nested CV" suppresses it). This is distinct from patient-vs-image split leakage (model-validation/check_split_leakage.py).
When the manuscript matches a row, read ${CLAUDE_SKILL_DIR}/references/domain-probes/<module>.md and apply each probe as an additional source of Anticipated Major / Minor Comments. The module severity words (MAJOR / MINOR) map to this skill's framing as follows: a conclusion-threatening or design-level finding becomes a Fatal Anticipated Major Comment, a reporting-level finding becomes a Fixable Anticipated Minor Comment, and each is tagged with the closest category letter (A–K). These probes complement categories A–K above; they do not replace them. (The modules are vendored byte-identical from /peer-review; do not edit one copy only — run python3 scripts/check_domain_probe_sync.py --sync.)
Phase 2.5: Numerical Cross-Verification (Internal)
Before generating the report, verify internal consistency:
- Abstract vs Body: Do all numbers in the Abstract match the Results section and Tables?
- Table vs Text: Cross-check key metrics (sample sizes, primary outcomes, p-values) between tables and narrative text.
- Figure vs Text: Do figure legends match the data described in Results?
- Percentage arithmetic: Verify that n/N percentages are calculated correctly (e.g., 23/150 = 15.3%, not 15.0%).
- CI plausibility: Do confidence intervals seem reasonable given sample sizes?
- Rate back-calculation: every reported rate must invert to its own numerator/denominator — an incidence rate ≈ events / person-years × scale (±rounding). A rate that does not recompute from the stated events and person-time (or that implies more events than the cohort can supply) is a Major, not a Minor.
- Exclusion-cascade and complete-case arithmetic (cohort/observational): the STROBE flow must balance — start N − Σ(exclusions) == final analytic N — and any complete-case statement must balance — total − missing == complete. A footnote N that does not equal the subtraction is a Major.
For cohort/observational manuscripts, run the deterministic gate instead of eyeballing it (it parses prose equations + GFM tables, and recomputes from a committed CSV when given one):
python3 "${CLAUDE_SKILL_DIR}/scripts/check_cohort_arithmetic.py" \
--manuscript manuscript.md --data analysis/cohort.csv --id-col mockid \
--out qc/cohort_arithmetic.json --strict
RATE_BACKCALC / CASCADE_SUM / PARTITION_OVERLAP rows are Anticipated Major Comments (category: A. Study Design & Data Integrity); the partition check is the Phase 2.5b cohort branch below. Pass --id-col (or let it auto-detect a subject-ID column) on health-screening / EMR / registry data so the gate also runs the analysis-unit check: when records > unique subjects and the manuscript states neither the analysis unit nor a one-record-per-subject sensitivity, it emits ANALYSIS_UNIT_UNDISCLOSED (Major — non-independent observations give anti-conservative CIs; probe O8). Flag any remaining internal-consistency discrepancies as Anticipated Minor Comments (category: F. Reporting Completeness).
Then recompute the three things a reviewer recomputes by hand. These are the arithmetic checks a careful reviewer does with a calculator on the train home, and the ones that end a submission when they fail:
# Every "n (%)" in a table, recomputed against its own denominator.
python3 "${CLAUDE_SKILL_DIR}/scripts/check_table_percentages.py" \
--manuscript manuscript.md --out qc/table_percentages.json --strict
# Every reported P beside a 2×2 (or r×c) count, recomputed from the counts themselves.
python3 "${CLAUDE_SKILL_DIR}/scripts/check_reported_p_from_counts.py" \
--manuscript manuscript.md --out qc/reported_p.json --strict
# Diagnostic-accuracy only: sensitivity/specificity against the reference-standard denominators.
python3 "${CLAUDE_SKILL_DIR}/scripts/check_dta_denominators.py" \
--manuscript manuscript.md --out qc/dta_denominators.json --strict
PCT_MISMATCH, P_MISMATCH / P_IMPOSSIBLE, and DENOM_MISMATCH are P0 Major — a percentage
that does not follow from its own denominator, or a P value that does not follow from its own counts,
is not a rounding disagreement. It means one of the two numbers is wrong, and the reviewer who checks
will find it. Run the first two on every manuscript with a table; the third only on
diagnostic-accuracy work.
Phase 2.5a: Numerical Source-Fidelity Audit (External)
Internal consistency (Phase 2.5) is necessary but not sufficient. Numbers can be fully self-consistent across Abstract / Table / Text and still be wrong at the source — a single transcription error propagates cleanly through every downstream stage, and every internal check then confirms it. Only a traversal back to the primary source catches it.
Run the displayed-arithmetic gate first — a stated difference must equal the subtraction of its two displayed component values at the same precision:
python3 "${CLAUDE_SKILL_DIR}/scripts/check_rounded_delta.py" \
--manuscript manuscript.md --out qc/rounded_delta.json
ROUNDED_DELTA_MISMATCH (Minor) fires when AUCs shown as 0.70 and 0.73 (a displayed gap of
0.03) are reported with a between-arm difference of 0.02 — self-consistent only on the
unrounded values. A higher-precision component pair (0.703 vs 0.726) with a 2-dp delta is
the legitimate unrounded case and is not flagged.
When to run the external audit: MA revisions, submissions, or any review where the user says "check against the source", "verify extraction", or "random sample". Skip otherwise.
The audit, in one line: draw a stratified sample of 5 numerical claims — always including one
comparative-arm value and one revision-introduced number, the two highest-yield strata — and
trace each through three layers (manuscript → extraction CSV → primary-source page; plus analysis
script → CSV where a script produced it). Any mismatch is a Major Comment, and one that
reverses a direction or crosses a significance boundary is a P0 blocker. Every [VERIFY-CSV] tag
is a mandatory audit item regardless of sample size.
The traversal procedure, the recording table, the sampling strata, and the four prose-judgement
rules it also applies — hand-entered analysis-script inputs, prose↔table statistic-type
mismatches (a median in the text against a mean in Table 1), stale derived CSVs after a
model/adjustment-set change (the analytic n is the fastest tell, and the conflict can flip
significance), and the precedent direction-reversal that internal consistency could not see —
are in the reference file.
Read on demand:
| File | Read it when | Cost if read blindly |
|---|---|---|
references/phases/phase2_5a_source_fidelity.md |
you are running the external audit — tracing sampled claims back to primary sources | ~2,500 tokens; a first-draft review with no extraction CSV and no primary sources cannot use any of it |
Phase 2.5a-2: Design & Power Statistic Provenance
A design or power statistic is computed, not copied from a source, so the source-fidelity audit of Phase 2.5a cannot check it — it has to be re-derived from the manuscript's own inputs. This applies only when the manuscript states a sample-size calculation, a power figure, or a detectable-effect claim.
Read on demand:
| File | Read it when | Cost if read blindly |
|---|---|---|
references/phases/phase2_5a2_design_power.md |
the manuscript reports a sample-size / power / MDE calculation | ~1,050 tokens; a manuscript with no power statement needs none of it |
Load-on-demand: read ${CLAUDE_SKILL_DIR}/references/phases/phase2_5a2_design_power.md when the manuscript reports a sample-size / power / MDE calculation.
Phase 2.5b: Screening-Count Reconciliation from ID Sets (SR/MA + observational tier/stratum)
Internal consistency across Abstract/Methods/Results (Phase 2.5) and source fidelity of 2×2 and effect-size numbers (Phase 2.5a) do not cover study-count arithmetic. That is a separate failure mode: a prior-draft prose total ("30 → 32 after FLAG consensus") survives every downstream pass because Abstract, Methods, Results, Discussion, the Figure 1 caption, and even the supplementary consensus file all cite the same wrong number back to each other. The only thing that catches it is a recount from the ID sets.
When to run: any SR/MA manuscript revision, regardless of stage (run before Phase 3); or any observational manuscript presenting an ordinal tier / mutually-exclusive stratum split. Skip otherwise.
A. SR/MA — recount from the ID sets. Derive every study count from the screening TSV and the
consensus sheet rather than from prose, and list the narrative-only IDs explicitly — the
highest-yield cross-check, the one that turns "10 narrative-only studies" into "2 (IDs 120, 474)".
Any derived total that disagrees with Abstract, Methods, Results, the Figure 1 caption or
Limitations is a P0 Major, blocking submission, and any N → M transition claim not backed by
an enumerable ID addition/subtraction set is itself a Major — it is unverifiable by downstream
audit. The set definitions, the derivation formulas and the reconciliation-block template are in
the reference file.
B. Observational tier/stratum — the same set logic, as arithmetic. A partition claimed to be
disjoint must satisfy Σ(stratum N) == unique total and Σ(stratum events) == total events.
Denominators summing above the unique cohort double-count subjects; a table where every
stratum n equals the grand total is a mis-entry, not a partition. Confirm the reference
(baseline) row of any stratified hazard/odds table is present and labelled — without it the
other strata are uninterpretable.
python3 "${CLAUDE_SKILL_DIR}/scripts/check_cohort_arithmetic.py" \
--manuscript manuscript.md --data analysis/strata.csv --strict
C. Cross-script cut-point consistency — the root cause of stratum-N drift. When the same
cohort is re-stratified in more than one analysis script, the derived categorical must use one
identical cut definition (same breaks, same right= closure, same labels). Two scripts binning
one variable differently drift the per-stratum Ns while the grand total still reconciles — so a
manuscript-only check cannot localize it. The same gate covers the composite-indicator sibling
(a derived 0/1 criterion rebuilt in a second script with a clause dropped).
python3 "${CLAUDE_SKILL_DIR}/scripts/check_binning_consistency.py" \
--root analysis --root scripts --strict
PARTITION_OVERLAP, BINNING_DRIFT, and DERIVED_DEF_DRIFT are all P0 Major.
Read on demand:
| File | Read it when | Cost if read blindly |
|---|---|---|
references/phases/phase2_5b_screening_counts.md |
this is an SR/MA (ID-set recount) or a stratified cohort, and you are doing the recount | ~3,300 tokens — nothing in it applies to a single-cohort manuscript with no strata |
Phase 2.5c: Reference Scans (hallucination + adequacy)
Two scans run on the bibliography: 2.5c catches a citation that does not exist or whose first author is invented, and 2.5c-2 catches a claim that carries no citation at all. Both need a bibliography — a draft with no refs.bib and no reference list skips them entirely. Run /verify-refs --strict first; these scans read its audit rather than re-deriving it, then run the adequacy checker:
python3 "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/self-review/scripts/check_reference_adequacy.py" \
--manuscript manuscript/manuscript.md --bib "$BIB" \
--article-type "$TYPE" ${CAP:+--journal-cap "$CAP"} \
--out qc/reference_adequacy.json --strict
Read on demand:
| File | Read it when | Cost if read blindly |
|---|---|---|
references/phases/phase2_5c_reference_scans.md |
the manuscript has a bibliography and you are auditing citations | ~1,950 tokens; a draft with no reference list cannot use any of it |
Load-on-demand: read ${CLAUDE_SKILL_DIR}/references/phases/phase2_5c_reference_scans.md when the manuscript has a bibliography and you are auditing citations.
Phase 2.5d: Cross-Reference QC (Manuscript ↔ rendered DOCX)
Reference-list integrity (Phase 2.5c) does not cover Table/Figure cross-references. That is a separate failure mode: an in-text citation ("Supplementary Table S4 reports a sensitivity analysis") resolves to a different caption in the rendered DOCX ("Supp Table S4 = a diagnostics table") because the build script carries its own legacy SSOT. Internal consistency (Phase 2.5) cannot see it — the prose and the build artifact each echo their own divergent truth cleanly.
Markdown stage (always). Every captioned Figure N. / Table N. must be cited at least once
elsewhere in the body:
python3 "${CLAUDE_SKILL_DIR}/scripts/check_figure_citation.py" \
--manuscript manuscript.md --out qc/figure_citation.json
FIGURE_ORPHAN / TABLE_ORPHAN (Minor) catch a newly-added float that has a legend but no
in-text citation — the early, no-build counterpart to check_xref's UNCITED.
DOCX stage (when a rendered DOCX exists — circulation drafts, post-build pre-submission checks. Skip on early drafts with no build):
python3 "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/manage-refs/scripts/check_xref.py" \
--md manuscript/manuscript.md --docx manuscript/manuscript_final.docx \
--out qc/xref_audit.json [--allow-separate-attachments]
Severity depends on the journal's figure/table submission policy. Many radiology and medical
journals (European Radiology, Radiology, AJR) accept figures and tables as separate
attachments rather than inline — pass --allow-separate-attachments there so a legitimate
attachment style is not read as a blocker.
| Status | Default policy | With --allow-separate-attachments |
|---|---|---|
MISSING_DOCX |
Major (P0) — cited Table/Figure absent from rendered output | Minor — separately attached per journal policy |
MISSING_BODY |
Major (P0) — no body caption definition | Major (P0) when the float IS in the rendered DOCX (SSOT drift). Minor when no --docx was supplied — excused without evidence, and reported as such |
MISMATCH |
Major (P0) — caption text disagrees between body and rendered DOCX | Major (P0) (no change) |
UNCITED |
Minor — orphan caption; cite it or remove it | Minor (no change) |
MISMATCH stays P0 under every policy. So does MISSING_BODY when the float is present in the
rendered DOCX — the build pipeline is then the only place that knows the caption text, which is SSOT
drift and not a style choice. MISSING_BODY with no --docx supplied is different: nothing was
checked, so under --allow-separate-attachments it is excused on the author's declaration and the run
says so in those words. Treat those rows as unverified, not as verified — re-run with --docx before
submission and read summary.downgraded_unchecked in the audit JSON.
Do NOT auto-fix cross-reference defects in --fix mode. Rewriting a caption in the body
without re-running the DOCX build merely moves the mismatch. Emit each P0 row as its own
M-numbered Major Comment with category: "F" and fixable_by_ai: false, and route the user to
/write-paper Step 7.6a for the pipeline-side fix.
Read on demand:
| File | Read it when | Cost if read blindly |
|---|---|---|
references/phases/phase2_5d_xref_qc.md |
the xref gate fired and you are writing up the reconciliation | ~2,400 tokens; an early draft with no DOCX build never reaches this stage |
Phase 2.5e: Confounding Completeness (observational only)
When to run: the manuscript is observational (cohort, case-control, cross-sectional, health-screening registry) and the central claim is an adjusted exposure–outcome association. Skip for RCTs, diagnostic-accuracy, SR/MA, and descriptive studies — which is why the full procedure is loaded on demand rather than carried inline.
The highest-yield, most mechanical observational finding — a covariate that is measured,
imbalanced across exposure groups in Table 1, and absent from the adjustment set
(residual confounding by a measured variable) — is invisible to a prose pass and only
exposed by joining the exposure-stratified Table 1 against the Methods adjustment set
(probe O1). Run the deterministic gate and treat each UNADJUSTED_IMBALANCED covariate as
an Anticipated Major Comment (category A. Study Design & Data Integrity):
python3 "${CLAUDE_SKILL_DIR}/scripts/check_confounding_completeness.py" \
--table1 table1_by_<exposure>.csv \
--adjusted-list "age, sex, BMI, hypertension, diabetes" \
--exposure-defining-list "body mass index, waist, fasting glucose, triglycerides, HDL cholesterol" \
--out qc/confounding_completeness.json --strict
When the manuscript is observational, load references/phases/confounding_completeness.md
for the full procedure: the precedent failure pattern; the --exposure-defining-list
over-adjustment exemption for guideline-defined exposures (MASLD / metabolic syndrome / CKM
/ sarcopenia / frailty); the SMD-from-mean ± SD fallback; the extended-adjustment
sensitivity model and its frame discipline (refit the unadjusted estimate on the reduced
complete-case frame, not the full frame); and the rest of the observational probe set
(O2–O10) from references/domain-probes/observational_confounding.md.
Phase 2.5f: Claim-vs-Artifact Cross-Check
Phases 2.5–2.5e check numbers and adjustment sets. This phase checks claims against the external artifacts they should trace to — the pre-registration, the protocol, the analysis outputs. These are the errors that survive a single-pass review because the manuscript prose is internally consistent yet disagrees with the registration or the analysis it reports: a primary re-designated after the results were known, an E-value that does not recompute from the estimate it is quoted against, an analysis promised in Methods that never reaches Results.
Run the gates (all deterministic; pass the supplement so the corpus is complete):
# 1. claims ↔ pre-registration/protocol: estimand provenance + E-value arithmetic
python3 "${CLAUDE_SKILL_DIR}/scripts/check_claim_artifact.py" \
--manuscript manuscript.md --prereg prereg.md \
--out qc/claim_artifact.json --strict
# 2. Methods ↔ Results ↔ disk coverage (both directions: promised-absent AND run-but-unreported)
python3 "${CLAUDE_SKILL_DIR}/scripts/check_artifact_coverage.py" \
--manuscript manuscript.md --supplement supplement.md --analysis-dir output/analysis \
--out qc/artifact_coverage.json --strict
# 3. reader-facing residue in EVERY rendered artifact, not just the body
python3 "${CLAUDE_SKILL_DIR}/scripts/check_supplement_hygiene.py" \
--supplement supplement.md --supplement tables.md --supplement captions.md \
--manuscript manuscript.md --out qc/supplement_hygiene.json --strict
# 4. float AND in-text reference-number ([N]) citation order — a desk-reject item the hygiene gate does not cover
python3 "${CLAUDE_SKILL_DIR}/scripts/check_citation_order.py" \
--manuscript manuscript.md --out qc/citation_order.json --strict
# 5. a headline null is uninterpretable without a precision statement
python3 "${CLAUDE_SKILL_DIR}/scripts/check_null_calibration.py" \
--manuscript manuscript.md --out qc/null_calibration.json --strict
# 5b. a headline OR/HR/RR whose 95% CI spans an order of magnitude (a direction, not a magnitude), or events/covariates < 10 (EPV)
python3 "${CLAUDE_SKILL_DIR}/scripts/check_effect_stability.py" \
--manuscript manuscript.md --out qc/effect_stability.json --strict
# 5c. incorporation bias — a trajectory-defined reference standard with a trajectory predictor (growth) reported as associated with the outcome
python3 "${CLAUDE_SKILL_DIR}/scripts/check_incorporation_bias.py" \
--manuscript manuscript.md --out qc/incorporation_bias.json --strict
# 6. reader/observer study only — prove the (call × confidence) → score encoding is strictly
# monotonic; a folded score silently mis-estimates the AUC and no prose review can see it
python3 "${MEDSCI_SKILLS_ROOT}/skills/analyze-stats/scripts/rating_monotonicity.py" \
--encoding score_def.json
Verdict → severity. The rationale and the resolution path for each are in the reference file.
| Verdict | Severity |
|---|---|
PRIMARY_REASSIGNED |
Major — the primary was re-designated after results were known |
EVALUE_ARITHMETIC, EVALUE_NON_PRIMARY |
Major — recompute for the declared primary estimate |
PROMISED_ABSENT, DISK_UNREPORTED, PROMISED_STAT_NO_VALUE |
Major |
SUPP_INTERNAL_LABEL, SUPP_PLACEHOLDER, SUPP_BUILD_MARKER, SUPP_RESPONSE_FRAMING, SUPP_PLANNING_RESIDUE, SUPP_XREF_UNRESOLVED |
Major — a slip in a supplement is as fatal at a technical check as one in the body |
CITATION_ORDER |
Major; CITATION_GAP Minor |
CONFIRM_NULL_NO_MDE |
Major |
ESTIMAND_DRIFT, PRIMARY_DISCLOSURE_NOTE |
Advisory Minor — never a blocker. The provenance match is fuzzy (token overlap); confirm against the actual registration first. PRIMARY_DISCLOSURE_NOTE flags the honest disclosure the guidance recommends writing — do not penalise it. |
Four checks no script makes (prose judgement — the reference file has the full text):
- Primary-change guard — two models for one contrast, one significant and one null, the significant one foregrounded: confirm which was pre-specified.
- Headline vs own-sensitivity direction — if the headline claim points the opposite way from the authors' own sensitivity estimate, the paper contradicts its own robustness check.
- Rating → AUC monotonicity — a folded (call × confidence) score silently mis-estimates the AUC, and prose review cannot see an estimator bug.
- Figure-embedded numbers are grep-blind — every numeric audit above is blind to numbers inside a rasterised figure. Read each figure page visually before submission.
Also re-run /sync-submission's check_cross_artifact_stale.py after any reframe, not just
once at the start. For time-to-event manuscripts, apply probe S8 (estimand provenance) of
references/domain-probes/survival_prognostic.md.
Read on demand:
| File | Read it when | Cost if read blindly |
|---|---|---|
references/phases/phase2_5f_claim_artifact.md |
a gate above fired and you need the rationale + resolution path, or there is a pre-registration to reconcile | ~4,800 tokens; a manuscript with no registration and no firing gate needs none of it |
Phase 2.5g: Editorial-Impression / Defensiveness Scan (the ceiling pass)
Run this after the floor gates (Phases 2.5–2.5f), because it reads the accurate manuscript and recommends what to take back out. It is the operational form of category L and the counterweight to the additive bias of the rest of the stack: every other phase can only make the manuscript longer and more defended; this one is the only phase that can make it shorter and more confident. It is advisory and non-blocking — it never produces a Major and never gates submission.
python3 "${CLAUDE_SKILL_DIR}/scripts/check_editorial_impression.py" \
--manuscript manuscript.md --out qc/editorial_impression.json
The gate reads the manuscript as a whole, segments it by IMRAD heading, and emits up to six
verdicts, each tagged with a SUBTRACTION action:
| Verdict | Reads as | Action |
|---|---|---|
HEDGE_DENSITY |
defensive-caveat tokens per 1,000 narrative words over threshold | TIGHTEN |
HEDGE_REPEAT |
one caveat motif repeated across body + Abstract | TIGHTEN |
AUDIT_IN_BODY |
SHA / commit / unit-test / post-lock / manifest / seed in the narrative | MOVE (→ Methods/supplement) |
LIMITATIONS_VOLUME |
a long enumerated Limitations list | TIGHTEN (consolidate) |
ABSTRACT_CAVEAT_LOAD |
several caveat clauses in the Abstract | TIGHTEN |
BURIED_DEFENSE |
strong numeric robustness result only in Limitations/supplement | MOVE (→ Results) |
Fold the findings into the report as the SUBTRACTION axis, not the additive one. Each
becomes a Minor issues[] entry under category: "L" / category_name: "Editorial impression",
additively carrying issue_type: "editorial_impression", subtype: <verdict>, and
action: "REMOVE" | "MOVE" | "TIGHTEN". They are summarized in their own Phase 3 block
("Editorial-Impression Risks — REMOVE / MOVE / TIGHTEN"), kept visually separate from the
"Anticipated Major / Minor Comments (ADD / FIX)" so the author sees both forces. Mark them
fixable_by_ai: false by default — TIGHTEN-ing a hedge or MOVE-ing a robustness result is a
voice-and-judgment edit the author should own — except a clearly-redundant repeated caveat
(HEDGE_REPEAT), which --fix may collapse to a single statement.
Net-impact note. When an earlier phase recommends adding a caveat or disclosure, weigh it against L: an integrity-critical disclosure is a must (state it once, crisply), but a defensive over-disclosure is a cut / move. The two are not symmetric — keep the disclosure, but place it once and point to the supplement rather than repeating it at every claim site (placement discipline: main text narrates, auditability lives in the supplement).
Phase 2.5h: Baseline Drift (anchor to the last human-approved version)
Run this after the ceiling pass and before the loop controller (Phase 2.5i), so its
findings are counted when the terminal state is judged. The refine loop's hazard is the
anchor: each pass silently takes the previous AI output as its baseline, so a small
framing bias compounds across passes while every pass looks locally fine. This gate
compares the current manuscript against the last human-approved version — the frozen
v_N of manuscript-versioning (a senior/co-author-circulated draft), not the last AI
output — and reports lexical framing drift. Supply the baseline explicitly; with none
available (a first draft) skip it — the gate is a no-op without one.
python3 "${CLAUDE_SKILL_DIR}/scripts/check_baseline_drift.py" \
--manuscript manuscript.md --baseline "$BASELINE_MD" \
--out qc/baseline_drift.json
| Verdict | Signal (baseline → current) | Fold into report as |
|---|---|---|
STRENGTH_INFLATION |
certainty markers up while hedges fall | Minor — tone back to the approved strength |
SIGNIFICANCE_INFLATION_DRIFT |
novel/pivotal/unprecedented tokens added | Minor — remove the inflation |
SCOPE_INFLATION_DRIFT |
new generalization phrases ("in clinical practice") | Minor — the estimand did not widen; re-scope |
HEDGE_ACCRETION |
hedge/caveat density up | Minor — cumulative over-hardening; TIGHTEN |
Every finding is Minor and advisory — framing is the author's judgment and the gate
never blocks. Treat drift as review against the approved anchor, not an instruction to
revert: legitimate new analysis can justify a stronger claim, but the author should confirm
it rather than let it accrete unexamined across AI passes. Its qc/baseline_drift.json
feeds the loop controller, so a draft that has drifted does not read as a zero-edit PASS.
Phase 2.5i: Refinement Terminal-State (the loop controller)
Run this last, after the floor gates (Phases 2.5–2.5f) and the ceiling pass (Phase
2.5g), because it reads their qc/*.json artifacts and classifies whether the
refine-and-review loop is done. Self-review is run iteratively (review → revise →
review); the floor gates converge to a fixed point of zero Major findings, but the
additive bias of the whole stack means a naive loop never stops — there is always one
more caveat to add. This step is the counterweight's controller: it turns the floor +
ceiling state into a reproducible STOP verdict and makes a zero-edit result a valid
PASS, so an accurate manuscript is not over-hardened by another pass it does not need.
It is not a detector (it finds no defect, carries no check_ prefix, is uncounted in
the catalog) and it is advisory — it never blocks; it must not double-gate the floor
detectors, which already fail under --strict on their own Majors.
python3 "${CLAUDE_SKILL_DIR}/scripts/refinement_stop.py" \
--qc-dir qc --out qc/refinement_stop.json
| Verdict | Meaning | What the harness must do |
|---|---|---|
CONTINUE |
a floor gate still reports a Major | genuine work remains — keep going |
STOP_OVERHARDENING |
floor clean, ceiling flags accumulation | STOP adding; only optional SUBTRACTION (REMOVE/MOVE/TIGHTEN) remains — do not run another additive pass |
STOP_MINOR_OPTIONAL |
floor clean, only optional Minor polish left | stop the required-work loop; present the Minor items as an optional menu, do not loop for them |
STOP_ZERO_EDIT |
floor at fixed point, ceiling clean | the manuscript is submission-ready as-is — NO EDITS REQUIRED. Do not manufacture changes. Report the zero-edit PASS as a first-class outcome |
INDETERMINATE |
no gate artifacts yet | run the floor + ceiling gates first |
Stopping principle. Deterministic floor gates iterate to their fixed point (0 Major);
subjective refinement does not get an open loop. Once the verdict is any STOP_*,
stop the additive cycle — surface the terminal state in the Phase 3 report and do not
re-run self-review to find "one more thing". A STOP_ZERO_EDIT or STOP_MINOR_OPTIONAL
verdict is a legitimate terminal state; treating "found nothing required" as a failure to
try harder is exactly the over-hardening this phase exists to stop.
Phase 2.5j: Refinement Regression (fixed vs broke, across runs)
Run this each round, after the loop controller, and record the run. Self-review is
stateless: a revision that resolves finding X can introduce finding Y, and the pass-rate
(how many old findings are gone) hides it. This step reads a small run-history ledger — one
line per run, the verdict@where fingerprints of that run's findings — and reports the
regression axis next to the pass-rate axis: what the revision fixed vs what it broke.
python3 "${CLAUDE_SKILL_DIR}/scripts/refinement_regression.py" \
--qc-dir qc --ledger qc/refinement_ledger.jsonl --append \
--out qc/refinement_regression.json
Use --append on a real run so the current findings become the next entry; omit it to
classify without recording.
| Verdict | Meaning | What the harness must do |
|---|---|---|
PROGRESSING |
findings resolved, none new | continue |
REGRESSION |
the revision introduced new finding(s) | review the new findings before accepting the fix — the pass-rate went up but something broke |
CHURNING |
a resolved finding reappeared (Mirror Loop) | stop revising and re-anchor — more passes re-derive, they do not converge |
CONVERGED |
nothing new, nothing carried | the loop is done |
INDETERMINATE |
first run, no prior entry | re-run after a revision |
It is not a detector (no check_ prefix, uncounted) and advisory — it never blocks.
Report both axes in Phase 3: a revision is an improvement only if it resolved findings and
the new/churn columns are empty. A CHURNING verdict is the deterministic form of the
same stop signal the loop controller raises — the loop is no longer making progress.
Phase 2.6: Multi-Agent Panel Review (--panel, opt-in)
Run this phase only when --panel is passed. The default single-pass review (Phases 2–2.5d) stays the fast path; the panel is the high-cost, high-precision option for a pre-submission final pass on a top-tier target. Run it after the numerical audits (Phases 2.5–2.5d) so the reviewers see source-verified numbers, and before the Phase 3 report, which it feeds.
Two things bind before you spawn anything: the SSOT must be singular (the Phase 1 step 4 gate — halt and ask if more than one manuscript-like .md is unpinned), and the roster must not be a substrate monoculture (a panel that shares the drafter's model inherits its blind spots; route at least one lens to Codex or a human co-author). Both are enforced by check_panel_diversity.py --strict, which also fires PANEL_UNDERRETURN when fewer reviewers returned than were spawned — a panel with <2 returned reviews is a failed run, not a thin one.
Read on demand:
| File | Read it when | Cost if read blindly |
|---|---|---|
references/phases/phase2_6_panel.md |
--panel was passed and you are composing the reviewer set |
~2,600 tokens — the reviewer-set table, roster manifest, editor synthesis and lens-diversity gate; a default single-pass review reaches none of it |
Load-on-demand: read ${CLAUDE_SKILL_DIR}/references/phases/phase2_6_panel.md when --panel is passed.
Phase 3: Report
Before writing the Anticipated Comments, skim references/exemplar_findings/ for the
finding at hand (cohort-arithmetic mismatch, unadjusted confounder, cross-sectional scope
overreach, post-hoc primary / estimand drift). Each models the full shape — which gate
fired, the comment in the reviewer's own words, Fatal/Fixable severity, the closest
category letter, the concrete fix, fixable_by_ai, and an R0-ready line for Phase 3b.
They are synthetic teaching models — match the structure, not the wording.
Generate a concise report with this structure:
# Self-Review Report: {manuscript title}
**Target journal**: {journal}
**Manuscript type**: {type}
**Date**: {date}
**Overall assessment**: {1-2 sentences: key vulnerability and overall readiness}
## Anticipated Major Comments (fix before submission)
M1. **{Issue title}** [{Category letter}]
{1-2 sentences: what a reviewer would likely say, with specific manuscript location}
**Severity**: {Fatal | Fixable}
**Suggested fix**: {specific, actionable fix using existing data}
M2. ...
## Anticipated Minor Comments (address proactively)
m1. **{Issue}** [{Category}]: {1 sentence with location + fix}
m2. ...
## Editorial-Impression Risks (REMOVE / MOVE / TIGHTEN)
*The subtraction axis — what to take out, move, or tighten so the accurate manuscript reads
confidently. Advisory and non-blocking; from Phase 2.5g / category L. Omit this block only if the
scan returned nothing.*
L1. **{Issue}** [{REMOVE | MOVE | TIGHTEN}]: {1 sentence — what reads as over-defensive and where, with the subtraction to make}
L2. ...
## Strengths (emphasize in cover letter)
- {Specific strength 1}
- {Specific strength 2}
- ...
The report carries two axes, kept visually separate: the ADD / FIX axis (Anticipated Major / Minor Comments — what is missing or wrong) and the SUBTRACTION axis (Editorial-Impression Risks — what to remove, move, or tighten). Do not fold the L items into the Minor Comments; an author who sees only "add this" will monotonically over-defend.
Conciseness targets:
- Anticipated Major Comments: 3-7 items, each 3-5 lines
- Anticipated Minor Comments: 3-6 items, each 1-2 sentences
- Editorial-Impression Risks: 0-6 items, each 1 sentence (only what the Phase 2.5g gate flagged)
- Strengths: 3-5 items, each 1 sentence
- Total report: 400-800 words (excluding optional R0 section)
Phase 3b: R0 Numbering (Optional)
If the user plans to use /revise after receiving actual reviews, offer to append
R0-numbered output for pipeline compatibility:
## R0 Pre-Submission Findings (for /revise cross-reference)
R0-1 [MAJ] {mapped from M1}: {issue title}
R0-2 [MAJ] {mapped from M2}: {issue title}
R0-3 [MIN] {mapped from m1}: {issue title}
...
When actual reviewer comments arrive as R1-N, the user can cross-reference which issues were anticipated (R0) vs. novel (R1-only).
Phase 3c: Structured JSON Output (--json)
Emit the review as machine-readable JSON only when --json is passed (or when another skill consumes this run). The schema, field semantics and worked example live in the reference; a human-facing review never serializes anything.
Read on demand:
| File | Read it when | Cost if read blindly |
|---|---|---|
references/phases/phase3c_json_output.md |
--json was passed, or a downstream skill consumes this run | ~790 tokens of schema a human-facing review never emits |
Load-on-demand: read ${CLAUDE_SKILL_DIR}/references/phases/phase3c_json_output.md when --json was passed, or a downstream skill consumes this run.
Phase 4: Fix Support (on request)
The review ends at Phase 3. Enter this phase only when the user asks for help applying the findings — a review that is read and acted on by the author never reaches it.
Read on demand:
| File | Read it when | Cost if read blindly |
|---|---|---|
references/phases/phase4_fix_support.md |
the user asks you to apply or draft fixes for the findings | ~850 tokens; a review that is only read never reaches this phase |
Load-on-demand: read ${CLAUDE_SKILL_DIR}/references/phases/phase4_fix_support.md when the user asks you to apply or draft fixes for the findings.
What This Skill Does NOT Do
- Does not write the paper or rewrite entire sections
- Does not generate fake data or fabricate results
- Does not guarantee acceptance -- it reduces preventable reviewer criticism
- Does not replace formal peer review by an external reviewer
Tone
Be direct and practical. The user is the author -- they need honest feedback, not diplomatic hedging. Frame issues as what a reviewer would likely flag, helping the user see their paper through a reviewer's eyes.
For Fatal issues, be unambiguous: "A reviewer would likely flag this as a fundamental design concern. Submitting without addressing this risks Reject."
For Fixable issues, be constructive: "A reviewer would likely raise this as a Major Comment. Here is how to address it with your existing data."
Anti-Hallucination
- Never fabricate references. All citations must be verified via
/search-litwith confirmed DOI or PMID. Mark unverified references as[UNVERIFIED - NEEDS MANUAL CHECK]. Self-review enforces this through **Phase 2.5c: Reference Ha
Files (medsci-skills)
-
references
-
domain-probes
-
ai_overclaiming.md 15 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a task- or design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # AI / ML overclaiming probes (AO0–AO7) A 7-probe checklist (AO1–AO7, with AO0 as a gate) for medical-AI/ML primary studies (diagnostic, prognostic, triage, detection) where the **conclusion's reach exceeds the evidence**. These probes complement (do not replace) the generic Phase 2 issue checklist and the signature "Overclaiming vs evidence level" check. The aim is to keep a framing-level over-reach from passing as a wording nitpick: a paper can report sound metrics yet draw a clinical claim — generalizable, outperforms clinicians, deployment-ready — that the design does not support, and that claim is what a reader carries away. AO1–AO4 target over-reach in the *claim sentences*; AO5 targets over-reach baked into the *reported metric itself* (an optimistically- or unreproducibly-reported number that makes the result look stronger than a faithful estimate). Run AO0 first. **AO0 — Locate the strongest claim, then its support (run before AO1; gates any over-reach finding)**: - Identify the load-bearing claims in the Title, Abstract, and Conclusion (the sentences a reader quotes). For each, find the specific evidence cited (which dataset, which comparison, which metric + uncertainty). - An over-reach finding is a **lead until the claim and its support are read together against the manuscript** — do not strawman a stray adjective. Escalate only when a headline claim genuinely outruns the cited evidence. - If the claim is already appropriately hedged to the evidence, record "claim matched to evidence" and move on. **AO1 — Generalizability claimed from limited external validation**: - Does the Abstract/Conclusion assert the model "generalizes," is "transferable/robust across settings," or is suitable for broad populations, while external validation is a single site / single scanner-vendor / single source (or absent)? - Sub-check: is the external set demographically narrow (single ethnicity, single sex-dominant, narrow age) relative to the population the claim names? - If the generalizability claim outruns the external evidence → recommend softening to the evidence ("validated at one external site") and moving multi-setting generalizability to a stated limitation + next step. MAJOR candidate when it is a headline claim; MINOR when it is a single qualifier in the Discussion. **AO2 — Superiority language against overlapping or under-powered comparison**: - Flag "outperforms", "superior to", "beats", "can replace [clinician/radiologist]" when (a) the model vs comparator 95% CIs overlap, (b) no test of the *difference* is reported (two separate AUCs are not a comparison), or (c) the comparison rests on a small test set / few readers. - Ask for the difference in the metric with its CI and a paired test of that difference, not two standalone estimates. - If the difference is not statistically supported → recommend reframing from "outperforms" to "comparable to" (still a meaningful result). MAJOR when a superiority/replacement claim is the headline; otherwise MINOR. **AO3 — Comparison-frame mismatch (model task ≠ human task)**: - When a model-vs-clinician comparison drives a claim, verify the two performed the **same task on the same inputs under the same constraints**: same images/inputs available, same time budget, same question asked, same decision point. - Common mismatches: the model sees a curated single view while readers see the full study; readers are timed or work from a different modality; the "reader" benchmark is a literature value on a different cohort. - A mismatch makes "outperforms clinicians" non-interpretable as a clinical claim → ask the authors to state exactly which task the comparison establishes, or to align the conditions. MAJOR candidate when it underpins a headline. **AO4 — Deployment / clinical-readiness claim from retrospective internal evidence**: - Flag "ready for clinical deployment", "can be used to triage/guide treatment", "will reduce workload/cost", or a recommended decision threshold, when the evidence is a retrospective, internally-split (or even external but observational) accuracy study with no prospective, silent-trial, or decision-impact data and (often) no calibration or decision-curve analysis. - Discrimination on retrospective data does not establish that acting on the model helps patients; a probability that drives a decision must also be calibrated, and net benefit must be shown. - Recommend reframing deployment/utility language to "supports further prospective evaluation", and (where a threshold is proposed) adding calibration + decision-curve evidence. MAJOR when a deployment/care-directive claim is made; MINOR when only a hedged "potential utility" sentence. **AO5 — Optimistic / non-reproducible performance reporting (the metric overstates the evidence)**: - Triggered when a primary AI/ML study reports discrimination or accuracy as a headline result. Check how the number was produced before taking it at face value. - (a) **Best-fold vs cross-fold**: is the headline metric the single best cross-validation fold (or one favorable train/test split) rather than the mean across folds with its SD or a 95% CI? Best-fold figures are upward-biased; ask for the cross-fold mean ± SD (or CI). - (b) **Operating point**: are sensitivity/specificity/F1 reported without stating the decision threshold and how it was chosen? A threshold tuned on the test set inflates the metric; ask for the operating point and that it was selected on training folds only (e.g., Youden's J on the training data). This probe is about whether the reported number is reproducible/optimistic, not whether the threshold is clinically deployable (that is AO4). - (c) **Prevalence-representative metrics**: was training/evaluation done on an artificially balanced set while the real prevalence is much lower, with accuracy quoted as the headline? Accuracy — and PPV/NPV — on a re-balanced set do not transfer to the deployment base rate (PPV/NPV are prevalence-dependent). Ask for the evaluation class distribution, threshold-independent discrimination (AUROC, and AUPRC under imbalance, with CIs), sensitivity / false-negative rate at the stated threshold, and PPV / NPV estimated on a prevalence-representative holdout (or modelled for the target clinical prevalence, with uncertainty). - (d) **Code-vs-claims fidelity**: where code is released, does the described tuning/metric match it? Common mismatches: a claimed hyperparameter search the code does not run; a metric (e.g., specificity) attributed to a library function that does not compute it. A confirmed mismatch is an integrity/reproducibility flag — verify against the released code before asserting it. - Severity: MAJOR when the load-bearing performance claim rests on a best-fold number, an unstated/test-tuned threshold, a rebalanced-accuracy headline, or a code-vs-claims mismatch (the reported result is optimistic or not reproducible); MINOR when cross-validation was sound and only the cross-fold summary, the operating point, or a class-aware metric is missing from the write-up. **AO6 — Arm-defining task vs deployment workflow (construct validity of the evaluation)**: - Distinct from AO3 (model-task ≠ human-task *framing*) and from scope-coherence (claim ≠ result): AO6 asks whether the **task that defines the study arms mirrors the deployment workflow the claim targets**, or an artificial handicap/selection. Two recurrent failure modes: - (a) **Handicapped arm** — the AI (or comparator) arm is operationalized in a way the real workflow never imposes: e.g., AI read in pure blind interpretation while the actual deployment provides clinical context / priors / the report, so the evaluation measures a task no one performs. - (b) **Success-conditioned selection** — the arm or the analyzed subset is gated on an AI-success condition (cases where the model produced an output, segmentations that "passed", studies the pipeline did not fail on), so the comparison is conditioned on the very thing under test. - This is a **design/paradigm-level** defect: the operationalized task, not the prose, is mis-specified, so it cannot be fixed by rewording the claim — escalate **past an ordinary Major** (editors read it as a Reject-grade construct-validity failure; a panel that files it as a fixable Major under-rates it). The fix is a re-designed arm whose task matches the intended deployment workflow and an unconditioned (consecutive / intention-to-diagnose) analysis set. **AO7 — Comparative "within/comparable-to X variability" claim whose benchmark X was never quantified**: - When the Abstract / hypothesis / Conclusion asserts that a result falls **"within inter-expert variability"**, **"comparable to the reference range"**, or **"on par with human readers"**, the named comparator quantity (the inter-expert spread, the human-reader range) must actually be **computed and reported** in Results / a Table. A benchmark claim whose benchmark does not exist in the paper is an unbenchmarked overclaim — the reader cannot check the comparison because the "within X" bound was never measured (e.g. an agreement-envelope was computed but the *volume* inter-expert spread it is compared against was not). - Lead: for each "within / comparable-to / on par with `<comparator>` variability|range|performance" claim, verify the `<comparator>` quantity appears in Results or a Table with a number; if absent → flag as unbenchmarked. - Severity: MAJOR when the comparative claim is a headline; the fix is to compute and report the benchmark, or to drop the comparative framing. Distinct from AO5 (metric overstates evidence) — here the comparison *target* is simply missing. ## Decision-impact / early-deployment probes (DECIDE-AI axis, DI1–DI5) Co-apply when a study claims **clinical utility, deployment, or decision impact** of an AI system, or *is* an early-stage live clinical evaluation. The reporting axis is then DECIDE-AI (early-stage clinical evaluation of AI decision-support); these probes check that a utility/deployment claim rests on real-use evidence, not retrospective accuracy. They sharpen AO4 for the deployment-evaluation case. **DI1 — Live/prospective evidence vs retrospective accuracy**: - Is there any **prospective, in-workflow (silent-trial / shadow-deployment / live)** evidence of how the system performs and is used, or is the deployment/utility claim built only on a retrospective, internally-split accuracy study? A deployment claim from retrospective discrimination alone → MAJOR (reframe to "supports prospective evaluation"). **DI2 — Intended use + deployment pathway within early-stage limits**: - Is the **intended use** (condition, decision supported, users, setting) stated, and is the claim kept within what an early-stage evaluation can show? Over-reach to "ready for routine clinical use" from a developmental/exploratory study → MAJOR. **DI3 — Decision threshold + calibration/utility**: - If a **decision threshold** or care directive is proposed, is it justified, with the predicted probabilities **calibrated** and **net benefit** (decision-curve) shown — not discrimination alone? An unjustified threshold or missing calibration/utility for a probability that drives a decision → MAJOR. **DI4 — Workflow integration + human–computer interaction**: - Does the study report **how the system was used in the real workflow** — user adherence/exposure, **human–computer agreement/override rates**, **usability and learning curve** (human factors)? A utility claim with no real-use interaction data is the central DECIDE-AI gap → MAJOR/PARTIAL. **DI5 — Safety, error capture, and subgroup safety**: - Were **significant errors/malfunctions pre-defined and captured**, the **safety profile** reported and discussed, and performance/safety examined across **relevant subgroups** (fairness)? Absent pre-defined error capture or subgroup-safety assessment where the claim is clinical deployment → MAJOR. **Output template (AO1 example)**: > "The Conclusion states the model 'generalizes across institutions,' but external validation appears limited to a single site ([Methods, External validation]). I'd suggest softening this to the evidence — e.g., 'validated at one external site' — and framing multi-institution generalizability as a stated limitation and a next step. If a broader claim is intended, an external set spanning multiple sites/vendors would be needed to support it." **Output template (AO2 / AO3 example)**: > "The 'outperforms radiologists' claim rests on a comparison whose 95% CIs for model and reader [metric] overlap ([Figure/Table]), and no test of the difference is reported; the reader task also differs from the model's in [inputs/time] ([Methods/Table]). I'd suggest (a) reporting the difference in [metric] with its CI and a paired test rather than two separate estimates, and (b) stating explicitly which clinical task the comparison establishes. If the difference is not statistically supported, reframing from 'outperforms' to 'comparable to' would be both defensible and still a meaningful result." **Output template (AO5 example)**: > "Table 2 appears to report the single best cross-validation fold; because best-fold figures are optimistically biased, I'd suggest reporting the mean across folds with its SD (or a 95% CI). Relatedly, the sensitivity/specificity depend on a classification threshold I could not find stated — reporting the operating point and how it was chosen (e.g., Youden's J on the training folds only, to avoid tuning on the test data) would make these numbers reproducible. Finally, since the training set was balanced to 1:1 while the clinical prevalence is much lower, accuracy (and PPV/NPV) on a re-balanced set may not reflect performance at the true base rate; stating the evaluation class distribution and adding AUROC/AUPRC with CIs alongside sensitivity/FNR at the stated threshold, with PPV/NPV on a prevalence-representative holdout, would make the clinical cost of a miss visible." **Discipline — leads vs findings (applies to AO0–AO5)**: - A claim-vs-evidence mismatch surfaced by a quick scan is a **lead, not a finding, until the claim sentence and its cited support are read together** against the manuscript. Do not escalate a hedged Discussion qualifier as if it were a headline. - Anchor every over-reach comment to the exact claim location and the exact evidence (dataset, comparison, metric + CI). A comment that names the location and the gap is actionable; "the authors overclaim" is not. - Keep severity tied to *where* the claim sits and *what it drives*: a headline/clinical-action claim that outruns the design is design-/framing-level (MAJOR, often Major #1); a stray adjective is MINOR. -
case_report.md 8.8 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a design-level flaw (missing consent, identifiable patient data, or causal overclaim) is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Case-report probes (CR1–CR9) A checklist for **case reports and small single-patient clinical narratives**. These probes complement (do not replace) the generic Phase 2 issue checklist and the CARE items. They target the places reviewers most often challenge case reports: why the case is publishable, whether the patient is protected, whether the story is chronologically complete, and whether the Discussion stays inside the evidence level of n=1. **CR1 — Novelty / teaching-value justification**: - Does the manuscript state why this case is worth publishing: rare presentation, diagnostic trap, management complication, unusual imaging/pathology correlation, unexpected response, or a practical bedside lesson? - Is the claim anchored to a transparent literature boundary or epidemiologic context? "Rare" without a search boundary, comparator cases, or a clinically actionable lesson is a MAJOR priority concern, because the paper's contribution is the case's teaching value. **CR2 — Consent, patient protection, and image de-identification**: - Is written informed consent documented, or is a waiver/IRB basis stated when appropriate? Are patient images, dates, locations, institution names, initials, faces, scan metadata, and unusual demographic details removed or justified? - Missing consent language or identifiable images/data is a MAJOR ethical/reporting issue. If the manuscript includes potentially identifying details, treat it as a finding even when the prose says "de-identified." **CR3 — Causal attribution discipline**: - Does the Discussion distinguish temporal association from causation? A single case can suggest a hypothesis or illustrate a plausible adverse event; it cannot establish incidence, efficacy, safety, risk, or mechanism. - Escalate when Title/Abstract/Conclusion claims that an intervention "caused", "prevented", "proved", "was effective", or "should change practice" without stronger evidence. The fix is to reframe as "was temporally associated with", "is consistent with", "highlights", or "should prompt consideration." **CR4 — Similar-case comparison and literature absence handling**: - Does the manuscript compare the case with the nearest prior cases, including similarities and differences in presentation, diagnosis, intervention, and outcome? If five or more similar cases are found, a compact comparison table is usually clearer than prose. - "First case", "only case", or "no prior reports" requires a documented search boundary. Without it, ask the authors to soften the claim or state the search strategy; do not let an unverified priority claim carry the Abstract. **CR5 — CARE narrative completeness**: - Can the reader reconstruct Patient Information -> Clinical Findings -> Timeline -> Diagnostic Assessment -> Therapeutic Intervention -> Follow-up and Outcomes? Is the timeline figure/table present when the course is multi-step, and does it include the final follow-up interval? - Missing timeline, follow-up duration, diagnostic reasoning, or outcome assessment is usually a Fixable Major for a case report because the narrative is the evidence. **CR6 — Generalizability and teaching-point framing**: - Does the conclusion state a narrow learning point rather than a population-level recommendation? Strong case reports teach clinicians what to consider, monitor, or report; they do not infer prevalence, comparative effectiveness, or standard-of-care changes. - Check the Abstract and final paragraph together. A cautious Discussion cannot rescue an overgeneralized Abstract conclusion. **CR7 — Adverse drug/device/contrast reaction: causality discipline** (apply when the case *is* the adverse event): - Is attribution supported by a **named instrument** (Naranjo or WHO-UMC for drugs) with the score and tier reported, not just the word "caused"? Is **dechallenge** documented (withdrawal → resolution) with the exposure-to-onset latency, and is **rechallenge** either reported or explicitly withheld on safety grounds? Mechanism-only narrative with no instrument and no dechallenge → MAJOR for the attribution claim. - Are **alternative causes excluded**, and is the event located against a **denominator** (institutional rate or pharmacovigilance database count) rather than implying incidence from n=1? Severity/ preventability instruments (e.g., Hartwig–Siegel, Schumock–Thornton) strengthen a safety report. Blaming the agent for a course actually driven by a downstream confounder (e.g., patient self-management) without separating the steps → overreach. **CR8 — Case-series-specific design** (apply only when the manuscript reports a series, n≥2): - Does the series have **cohort-style methods** (design, setting, case-identification source, eligibility, protocol) and an **all-cases summary table**, rather than being N stacked single reports? Absent methods/summary table for a series → MAJOR (it cannot be read as a series). - Is **selection/ascertainment** stated, with the **screened pool size** so consecutiveness can be judged? Does the manuscript avoid inferring **prevalence/proportion or effectiveness** from a selected/referral series (report counts, not rates)? A rate claimed from a referral series, or a series presented with no selection description, → MAJOR interpretive limit. Cohort-level limitations (small n, retrospective, non-uniform protocol, no comparator) should be explicit. **CR9 — Imaging-led (radiology / nuclear medicine / interventional) case report** (apply when the contribution is the image or an image-guided procedure): - Is each modality described as **technique → findings → impression**, in clinical order, with **reproducible technique** (sequence/phase, field strength, contrast agent/dose/rate, CT kV/keV/CTDIvol, PET tracer dose/uptake time, transducer frequency; for IR, devices with sizes and the step sequence)? Modalities merged into one undifferentiated paragraph, or findings stated without the acquisition needed to reproduce them, → reporting gap. - When a **structured-reporting system** applies (BI-RADS/LI-RADS/PI-RADS/TI-RADS/Lung-RADS/O-RADS), is the **category given with its meaning/risk** rather than a bare number? Are **quantitative values** reported with method (ROI placement) and **threshold honesty** — a value with no validated cutoff labeled exploratory, not diagnostic? - For **multimodality discordance**, is the disagreement stated and resolved (decisive modality or histopathology), and is a missing standard modality named as a limitation? For an **IR** case, is complication **latency** and the diagnostic→therapeutic pathway documented with pre/post outcome? - **Patient-protection at the image level**: are images de-identified at the DICOM level (no burned-in annotations, accession numbers, dates, faces)? Is figure **alt text real** (not a placeholder)? Is a **device/vendor relationship disclosed** for an advanced-technique or device case? Identifiable images or undisclosed device-vendor COI → MAJOR. **Output template (CR2 / CR3 example)**: > "The case is clinically interesting, but the submission does not yet provide enough information for > publication as a case report. First, the consent/anonymization statement should be made explicit, > including whether the accompanying images have been stripped of identifiers. Second, the Discussion > currently treats the temporal improvement after treatment as evidence that the intervention was > effective. Because this is a single case, I would suggest reframing the conclusion as a hypothesis- > generating observation and narrowing the teaching point to when clinicians should consider this > diagnosis or management option." **Discipline — leads vs findings (applies to CR1–CR9)**: - A missing element is a **lead until the whole manuscript, figures, legends, and ethics statements are checked**. Do not allege absent consent or absent follow-up if it appears in a title page, figure legend, supplement, or patient-perspective section. - Anchor every comment to what the flaw changes: patient protection (CR2), evidentiary overreach (CR3/CR6), contribution/priority (CR1/CR4), or narrative completeness (CR5). Avoid asking for broad extra literature unless it directly supports the case's teaching value. -
clinical_prediction_model.md 7.7 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a task- or design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Clinical Prediction-Model probes (CP1–CP6) A 6-probe checklist for **cross-sectional / observational clinical prediction models** — a binary or multinomial outcome predicted from a covariate set, reported under TRIPOD / TRIPOD+AI, usually as a **nested predictor-set comparison** ("does adding marker X to a base model improve prediction of Y?"). This is the diagnostic/prognostic *prediction* counterpart to `survival_prognostic.md` (time-to-event) and complements the `observational_confounding.md` probes (a prediction model still has an analysis unit (O8) and can be over-adjusted (O7)). Route here when the manuscript develops or compares prediction models and reports discrimination (AUC/C-statistic), calibration, or decision-curve / net-benefit analysis. **CP1 — Apparent vs optimism-corrected performance**: - Discrimination, **calibration** (slope / intercept / calibration plot), and **decision-curve / net-benefit** all suffer optimism when computed in the same sample used to fit the model. A manuscript that optimism-corrects *only* the C-statistic (bootstrap / cross-validation) but reports calibration and net benefit **in-sample** has corrected one axis and left two apparent. - An apparent calibration slope near unity (≈ 1.0) does **not** establish out-of-sample calibration — it is the expected in-sample result and says little about transportability. - Calibration and DCA computed in-sample must be **labelled "apparent"**, or the optimism correction must be extended to them (e.g. bootstrap the calibration slope/intercept and the net-benefit curves). In-sample calibration/DCA described as "well calibrated" / "clinically useful" without the apparent caveat → MAJOR. **CP2 — The two-null distinction (incremental value vs marginal effect)**: - A "negative" prediction study conflates two different nulls that have different evidentiary status: - **Incremental value negligible** — a *well-powered* statement: ΔAUC ≈ 0, optimism correction does not favour the larger model (and may reverse sign toward the parsimonious one), net benefit does not exceed the base model. This is a genuine "adding X does not improve prediction." - **Marginal predictor effect null** — an *underpowered* statement: the predictor's adjusted OR/coefficient has a confidence interval that still admits a clinically relevant effect (e.g. OR up to ~1.7). This is "we could not exclude an effect," not "there is no effect." - A manuscript that collapses both into a blanket "X did not predict Y" overstates the marginal-effect arm. Flag when a negative conclusion rests on a non-significant marginal OR whose CI admits a relevant effect, without separating it from the (better-powered) incremental-value finding → MAJOR. The honest reframe reports both explicitly. **CP3 — Events-per-variable (EPV) per nested model**: - Report EPV for **each** nested model, not just the full cohort. A model with 6 covariates and 60 events (EPV 10) is adequately supported; the same 6 covariates in an age≥60 subgroup with 13 events (EPV ≈ 2) is separation-prone and unstable. - Penalization (Firth / ridge / LASSO) is justified and should be stated when EPV < 10; subgroup or interaction models with EPV in the low single digits are **separation-affected and must be descriptive-only**, not presented as estimated effects. - Subgroup/interaction effect estimates from a model with EPV ≲ 5, presented as if reliably estimated, or no EPV disclosure for the nested models → MAJOR (subgroup) / MINOR (EPV simply not reported but adequate). **CP4 — Net benefit is a model comparison, not a policy endorsement**: - A decision curve showing the model exceeds treat-all / treat-none across a threshold range is a **model-vs-default** statement about ranking, not evidence that the underlying **imaging / test / screening strategy** should be adopted in practice. Net benefit is computed on the same outcomes and says nothing about cost, downstream harm, or whether the test should be ordered. - A conclusion that reads decision-curve superiority as an endorsement of the clinical strategy ("supports using X for screening / triage") → MAJOR (scope; cross-link `scope-coherence-gate.md`). Reframe to the model-comparison claim the DCA actually supports. **CP5 — Intended-use horizon leakage (claim-timepoint vs feature availability)**: - Read the intended-use horizon off the title / abstract / aim — the adjectives **preoperative, pretreatment, baseline, screening, triage, pre-procedure** assert a decision made *before* some event X. Then check each predictor's **availability timepoint**: any feature that exists only *after* X (post-procedure pathology, treatment-response variables, post-contrast-only measures in a "pre-contrast screening" claim, follow-up-derived labels) is leakage relative to the claimed use, even if it is not train/test leakage. - This is a **claim × feature-availability mismatch**: the model may be statistically clean yet unusable at the moment its claim targets. Distinct from ordinary data leakage (which is about train/test contamination) — this keys on the decision horizon. - Flag the specific offending predictor(s) and either narrow the intended-use claim to the horizon at which all inputs exist, or drop the post-horizon features and re-fit. → MAJOR when a post-horizon variable supports a pre-horizon decision claim. **CP6 — Validation-nomenclature conflation (development step vs test step)**: - Distinguish the **development** step (including any internal cross-validation or bootstrap optimism correction) from a **held-out / external test** step. Flag phrasing that merges them: "developed with external validation" where a single external set is used *as* development data; "trained and validated on the external cohort"; an "external validation set" that was used for tuning/feature selection. - Cross-validation and bootstrapping are development-time optimism corrections, **not** external validation; calling them "validation" overclaims out-of-sample evidence. Require an explicit statement of which data trained, which tuned, and which was touched only once for the final estimate. - → MAJOR when the overclaim props up a generalisability/deployment-readiness conclusion; a clarify-request when the data flow is actually clean but the wording is loose. **Output template (CP1 / CP2 example)**: > "Discrimination is bootstrap optimism-corrected, but the calibration slope (0.99) and the net-benefit curves are computed in the development sample and described as 'well calibrated' and 'clinically useful' without the apparent caveat — an in-sample slope near 1.0 is expected and does not establish out-of-sample calibration. I'd suggest labelling the calibration and decision-curve results 'apparent' or extending the optimism correction to them. Relatedly, the Conclusion states that the marker 'did not predict' the outcome, but two distinct results are merged: the incremental-value analysis is well powered (ΔAUC ≈ 0, optimism correction favours the parsimonious model), whereas the marginal adjusted OR has a confidence interval that still admits an effect up to ~1.7 (underpowered). I'd report these separately — 'adding the marker did not improve prediction' is supported; 'the marker has no association' is not." -
diagnostic_accuracy.md 14.5 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a design-level flaw (verification bias, two-gate sampling, unblinded reference) is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Diagnostic-accuracy / reader-study probes (D1–D12) A checklist for **diagnostic test accuracy (DTA) primary studies** — an index test against a reference standard, including **multi-reader multi-case (MRMC)** reader studies (e.g., AI-vs-reader or modality-comparison). These probes complement (do not replace) the generic Phase 2 issue checklist and the STARD / QUADAS-2 items; they target the biases QUADAS-2 names and the MRMC design/variance issues a reader study adds. Pairs the `analyze-stats` `table-standards/table-types/reader_study.md` table and the `make-figures` `exemplar_plots/mrmc_roc.md` figure. (For a DTA **meta-analysis**, use `sr_ma.md`.) **D1 — Reference standard validity + verification bias**: - Is the **reference standard** appropriate and applied to (essentially) all participants? Partial verification (only test-positives get the gold standard), differential verification (different reference standards by index result), and **incorporation bias** (the index test is part of the reference) each inflate accuracy. QUADAS-2 Domain 3/4. - Was the reference standard interpreted **without** knowledge of the index result, and was the **time interval** between index and reference short enough that the target condition did not change? A long or undefined interval, or a reference that incorporates the index, → MAJOR. **D2 — Spectrum + sampling design**: - How were participants sampled — a **single-gate** consecutive/random series of those with the target condition suspected (preserves spectrum and prevalence), or a **two-gate** case-control design (known cases vs healthy controls)? Two-gate sampling **overestimates** accuracy and distorts spectrum; it is a MAJOR interpretive limit when the headline is clinical accuracy, not just proof-of-concept. - Is the study **prospective** with a pre-specified index threshold, or retrospective with a threshold chosen on the same data (D-linked to optimism)? Spectrum (disease severity, comorbid mimics) should match the intended-use population. - **Representativeness-of-the-analysed-subset table**: when the analysed subset (e.g. the pathology-correlated cases) is compared with the parent cohort that *contains* it, a reported P value is **invalid** — nested groups, no valid null (`check_nested_group_comparison.py`). Judge representativeness by magnitude; the valid contrast is subset vs remainder. Separately, the sensitivity a/(a+c) and specificity d/(b+d) **denominators must equal the reference-standard positive / negative counts** in the characteristics table — grand-total agreement does **not** license a per-category split mismatch, which is exactly what lets it survive review under an Abstract headline (`check_dta_denominators.py`). **D3 — Blinding of index and reference interpretation**: - Was the **index test** interpreted blind to the reference standard and to clinical information that would not be available at the point of use, and vice versa? Unblinded interpretation (review bias) inflates agreement. - For an AI index test, was the **operating threshold pre-specified** (not tuned on the test set)? A test-set-tuned threshold reported as the result is an optimistic-validation finding (pair with `exemplar_reviews/optimistic_validation_reporting.md`). **D4 — Indeterminate / uninterpretable results**: - How were **indeterminate, uninterpretable, or intermediate** index results handled — excluded, or counted (intention-to-diagnose)? Silently dropping them inflates accuracy. State the rule and report an **intention-to-diagnose** sensitivity analysis (non-diagnostic counted as wrong). Undisclosed exclusion of indeterminates → MAJOR. **D5 — MRMC reader-study design**: - Is the design **fully crossed** (every reader reads every case in every modality) or a nested/split-plot variant — and is that stated? Was **reading order randomized** and a **washout** interval used between modalities so a case is not recognised from a prior read? Absent washout/order control in a within-reader modality comparison → MAJOR (memory/recognition bias). - Are the **readers sampled to generalise** to the intended reader population (number, expertise mix), and were **training/instructions and the information available** (priors, clinical data) standardised? A 2–3 expert-reader convenience panel cannot support a population-level "AI matches radiologists" claim — flag the generalisation gap. **D6 — MRMC analysis + estimand**: - Does the analysis account for **both reader and case variability** (Obuchowski–Rockette / Dorfman–Berbaum–Metz or an equivalent multi-reader model), or does it treat readers as fixed / pool reads as if independent? Ignoring reader variance understates uncertainty and is a MAJOR statistical flaw for a generalising claim. - Is the **estimand** clear: reader-averaged vs a fixed specific reader; per-**patient** vs per-**lesion** (clustered) unit; superiority vs **non-inferiority** with a pre-specified margin? Are **per-reader** results shown alongside the reader-averaged estimate (a single averaged AUC can hide one weak reader)? A non-inferiority claim with no pre-specified margin, or a clustered design analysed as independent, → MAJOR. **D7 — Index-test-as-enrollment-criterion circularity**: - Cross-check the **inclusion criteria** against the **index / proposed test**. When a study proposes or validates a diagnostic or classification instrument *and* uses a threshold of that **same** instrument (or a component of it) as an enrollment criterion, the validation is circular: the spectrum is built into the design, and sensitivity/specificity (or the instrument's apparent discrimination) are inflated by construction. - Typical signatures: "patients were included if [index score] ≥ T" in a paper whose aim is to evaluate that index; enrolling on a positive screening test to then "validate" the screening test; defining the diseased group by the same reader/algorithm output under study. - This is **design-level, not a reporting fix** — escalate past an ordinary Major (a co-reviewer / editor reads it as a fatal selection/spectrum artifact). The fix is a reference standard and an enrollment criterion that are independent of the index test (a consecutive suspected-disease series), not a re-analysis. **D8 — Exclusion flow-diagram ↔ Methods-prose consistency + modality-safety enumeration**: - Cross-check the **exclusion criteria drawn in the participant flow diagram** (STROBE/STARD flow) against the **exclusion list in the Methods prose**. A criterion that appears in one but not the other (a count in the flow with no prose rationale, or a prose exclusion not reflected in the flow boxes) is a reporting inconsistency a co-reviewer catches by reading the two side by side. - For an **imaging** study, check that **modality-specific safety contraindications** and **device/artifact exclusions** are enumerated where applicable: MR safety (pacemaker/implant, claustrophobia), iodinated/gadolinium **contrast** contraindications (renal function, allergy, pregnancy), and **image-quality/artifact** exclusions (motion, metal artifact, incomplete coverage). Silent omission of these categories in a prospective imaging cohort understates the selected spectrum. - Severity: a flow-vs-prose exclusion mismatch is MAJOR when it changes the analytic-N or the eligible spectrum; missing modality-safety/artifact exclusion categories is MINOR–MAJOR depending on how much of the source population they remove. The fix is a reconciled exclusion list (flow == prose) plus an explicit modality-safety/artifact exclusion enumeration. **D9 — Confidence-weighted / rating reader study: unweighted baseline AUC must accompany the weighted primary**: - When a reader study's novelty is a **confidence-weighted** (or rating-collapsed) score used as the ROC/AUC predictor, the **unweighted binary-call AUC** must be reported side-by-side (as a sensitivity analysis). Without it, you cannot tell whether the weighting *created* the result or hid an estimator fragility (e.g. a folded/non-monotonic encoding that collapses `real/5` with `ai/1`). - Lead: if the primary predictor is a confidence/rating→single-score collapse and no unweighted binary-call AUC appears, ask for it; also confirm the (call × confidence) encoding is strictly monotonic (no boundary collision) — the folded-score bug understates one hypothesis and can flip another. - Severity: MAJOR when the weighted score is the headline and no unweighted baseline is shown; the weighting must "earn its place" against the simpler estimator. - Produce the fix: `analyze-stats` `references/analysis_guides/diagnostic_accuracy.md` has the monotonic-encoding check + the unweighted-baseline AUC beside the weighted primary (and the per-stratum admissibility table for D10 and the one-scale-per-comparison rule for D11). **D10 — "No stratum met threshold X" vs a per-stratum table that does meet X**: - When the manuscript states a numeric admissibility/deployability rule (e.g. "AUC ≥ 0.75 **and** lower 95% bound ≥ 0.70") and concludes "**no stratum met** the rule" / "all strata were below", cross-check that claim against the **per-stratum AUC + CI table**. A blanket negative-stratum claim contradicted by a tabled stratum that literally satisfies the rule (e.g. ultrasound 0.789, CI 0.742–0.834) is a self-contradiction a reviewer verifies with arithmetic. - Lead: parse the stated threshold rule, then test each stratum row's estimate+CI against it; flag any "no stratum met" prose contradicted by a qualifying row. Note multiplicity (with k strata, some crossings are expected) — the fix is to report the qualifying strata and frame them as hypothesis-generating, not to deny them. - Severity: MAJOR (a headline negative claim contradicted by the paper's own table). **D11 — Mixed normalisation in one comparison column (apples-to-oranges head-to-head)**: - When a table column (or a row-wise "A vs B" comparison) places two metrics computed under **different normalisations / definitions** side by side — e.g. one method's volume error as a *standard relative error* and the comparator's as a *range-normalised* error — the implied head-to-head is invalid; the numbers are not on the same scale. - Lead: check whether the two values sharing a comparison column are defined identically in the Methods/footnotes; if not, require either a common-scale recomputation or an explicit "not on the same scale" footnote. Severity: MEDIUM–MAJOR when a superiority/comparability claim rests on the mixed-scale comparison. **D12 — Standalone vs AI-assisted arm, and automation bias in the assisted arm**: - Ask **which arm carries the clinical claim**. A standalone AI-vs-reader comparison answers "is the model as accurate as a reader"; the deployment question is "does the reader do better *with* the model", and only an assisted arm answers it. A workflow/adoption claim resting on standalone evidence alone is capped at standalone accuracy → MAJOR when the assisted claim is the headline (cross-link `ai_overclaiming.md` AO4). - When an assisted arm exists, require the reader change to be **decomposed by whether the AI was right**. A single pooled aided-minus-unaided delta hides the harm direction: readers also follow *incorrect* AI output, and the net delta can look positive while a subgroup of cases is made worse. Ask for the aided result stratified into **AI-correct** and **AI-incorrect** cases, the rate at which readers switched to the AI's answer, and whether the effect differs by reader experience. Evidence in radiology: incorrect AI BI-RADS suggestions degraded reader performance, with the largest effect in inexperienced readers (Dratsch et al., *Radiology* 2023;307(4):e222176); incorrect AI results likewise reduced radiologists' accuracy in a multi-reader study (Bernstein et al., *Eur Radiol* 2023;33(11):8263–8269). - Confirm the **assistance paradigm** is stated and matches the claim — AI shown **concurrently**, as a **second reader** after an independent read, or as **triage / pre-selection**. These measure different quantities and are not interchangeable; a concurrent-display study cannot support a second-reader deployment claim. Where the same readers read both arms, the washout / ordering controls of D5 apply to the arm comparison too. - Severity: an adoption/workflow claim with no assisted arm, or a pooled assisted delta with no AI-correct / AI-incorrect decomposition, → MAJOR (the pooled number cannot exclude automation-bias harm); an unstated assistance paradigm or a missing experience-stratified breakdown → MINOR pending clarification. Boundary: D5/D6 own the MRMC design and variance mechanics; D12 owns what the arms *mean* and the assisted-arm harm decomposition. **Output template (D2 / D6 example)**: > "The study uses a case-control (two-gate) design — confirmed cases versus healthy controls — rather than a consecutive series of patients in whom the diagnosis was suspected. This typically overestimates accuracy and does not reflect the intended-use spectrum, so I'd read the reported sensitivity/specificity as proof-of-concept rather than clinical accuracy, and suggest tempering the Abstract accordingly. Separately, the reader study reports a single reader-averaged AUC; because readers are a sample, I'd suggest an MRMC analysis (e.g., Obuchowski–Rockette) that accounts for both reader and case variance, with per-reader estimates shown and the unit of analysis (per-patient vs per-lesion) stated." **Discipline — leads vs findings (applies to D1–D12)**: - A verification/blinding/spectrum concern from a quick scan is a **lead until Methods and the participant flow are read together** — distinguish under-reporting (ask to clarify) from a true design bias (MAJOR). - Anchor each comment to the exact bias (partial vs differential verification; single- vs two-gate; reader-averaged vs fixed-reader; per-patient vs per-lesion) and the location. Keep severity tied to what the flaw does: two-gate sampling, incorporation bias, or ignoring reader variance is design/analysis-level (MAJOR, often Major #1); an unreported reading-order detail is a clarify-request. -
equity_fairness.md 7.5 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a task- or design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Fairness / Equity / Subgroup-performance probes (EQ0–EQ6) A 7-probe checklist for **AI/ML, prediction, or diagnostic studies that make (or imply) a claim about performance across a heterogeneous population** — generalizable / deployment-ready / "works for patients" claims, or studies that present **subgroup analyses as evidence of fairness or equity**. These probes complement (do not replace) the generic Phase 2 issue checklist, the AI-overclaiming probes (`ai_overclaiming.md`, with which EQ co-applies), the prediction-model probes (`clinical_prediction_model.md`, whose EPV logic EQ5 reuses), and the reporting items in TRIPOD+AI, DECIDE-AI, and CONSORT-AI. They target one recurring failure: an aggregate performance number, or an eyeballed "similar across groups" statement, presented as evidence that a model is *equitable* or *broadly deployable*. **EQ0 — Applicability gate (apply only when a cross-population claim is made)**: - Apply EQ1–EQ6 when the manuscript claims (or its framing implies) that the model/score/test performs adequately across a heterogeneous population, **or** reports subgroup analyses as a fairness/equity argument. - Do **not** fire these probes on a study that explicitly **scopes its claim to a single, well-defined population** and does not generalise beyond it — there, the right probe is scope coherence (the conclusion must stay within the evaluated population), not a fairness audit. **EQ1 — Disaggregated subgroup performance, not aggregate-only**: - An overall AUC / sensitivity / specificity / calibration is **not** evidence of per-subgroup performance — it can hide large, offsetting subgroup gaps. A broad-applicability or deployment claim requires **disaggregated** metrics, with confidence intervals, for **pre-specified** subgroups defined by clinically and (where collected and appropriate) socially relevant attributes (e.g. age band, sex, acquisition site / scanner / vendor, and protected attributes when available). - Reporting only the pooled metric while claiming the model works "across patients" / "in practice" → MAJOR. Reporting per-subgroup point estimates **without CIs**, so the reader cannot tell a real gap from noise → MINOR. **EQ2 — Error-rate parity ≠ discrimination parity; base-rate dependence**: - Equal **AUC** across groups does not imply equal **sensitivity/specificity** at the operating threshold, nor equal **PPV/NPV** — and a *single* threshold applied across subgroups with different outcome prevalence yields different error trade-offs by construction. "Discrimination is similar, therefore the model is fair" conflates two different fairness criteria. - The probe asks for the metric that drives the **deployment harm** (often the subgroup false-negative rate, or PPV at the decision threshold), not just AUC parity. A fairness claim resting on AUC parity alone, with the threshold-dependent error rates unreported or unequal → MAJOR. **EQ3 — A fairness claim needs a named estimand + a gap test, not eyeballed point estimates**: - "Performance was similar across groups" is an estimand-free assertion. If fairness is claimed, the manuscript must (a) **name the fairness criterion** — error-rate parity / calibration-within-groups / equalized odds — recognising that these can **mathematically conflict** and cannot all hold at once when base rates differ; (b) report the **between-group gap with a confidence interval** and a test, not overlapping point estimates; and (c) ideally pre-specify it. - A parity conclusion read off non-overlapping-looking point estimates with no gap CI / test → MAJOR (eyeballed/data-mined parity). A reported gap that was **not pre-specified** and is selected post hoc among several metrics → MINOR (disclose as exploratory). **EQ4 — Development-cohort representativeness vs the deployment population**: - Report the demographic / site composition of the **derivation (training) data**. A subgroup that is **small or absent** in development cannot be claimed to generalise to it, regardless of the pooled test metric; and **including an attribute as a covariate is not the same as validating performance within that stratum**. - A deployment / "generalisable to [population]" claim for a subgroup that is unrepresented or trivially small in the development data → MAJOR. Composition simply not reported → MINOR (it is a required disclosure under TRIPOD+AI). **EQ5 — Subgroup power / events-per-variable for the fairness comparison**: - A "**no disparity**" conclusion drawn from subgroups with **few events** is underpowered — the same separation/instability problem the prediction-model EPV probe (CP3) raises. A fairness **null** needs the between-group gap CI to **exclude a clinically relevant difference**; a wide CI that still admits a meaningful gap is "we could not detect a disparity," not "the model is equitable." - Subgroup comparisons with EPV in the low single digits, presented as evidence of fairness → MAJOR (overclaim); the comparison should be **descriptive-only**. EPV / event counts per subgroup not reported → MINOR. **EQ6 — Equity-aware framing + guideline alignment; a limitation is not an endorsement**: - Scope the conclusion to the **populations actually evaluated**, and align reporting to **TRIPOD+AI** (fairness / subgroup-performance and training-data composition items), **DECIDE-AI**, and **CONSORT-AI** (pre-specified subgroup analyses). A known fairness **limitation** (untested subgroup, unequal error rate, unrepresentative training data) must **not** be converted into a deployment endorsement or a recommendation-grade equity claim. - Recommendation-grade equity language ("equitable", "fair across groups", "ready for diverse populations") that the evidence does not support, or a conclusion that silently generalises past the evaluated subgroups → MAJOR. Cross-link `~/.claude/rules/scope-coherence-gate.md`: an aggregate or single-population claim cannot stand on subgroup-silent data. --- **Output mapping.** In **peer-review**, map each finding to a Major / Minor comment; EQ1 (aggregate-only performance behind a deployment claim), EQ2 (AUC-parity-only fairness claim), and EQ4 (deployment claim for an unrepresented subgroup) are **design-level** — surface them in the Confidential Comments to the Editor and place the strongest as the Major #1 candidate. EQ4 and an EQ5 underpowered-null overclaim are frequently **unfixable in the current data** (the missing subgroup or events cannot be added in revision) and govern the recommendation per Phase 2F. In **self-review**, a design-level EQ finding becomes a **Fatal** Anticipated Major Comment and a reporting-level one a **Fixable** Anticipated Minor Comment, tagged with the closest category letter. EQ co-applies with `ai_overclaiming.md` (a fairness gap is one route to an over-broad clinical claim) and reuses the EPV logic of `clinical_prediction_model.md` (CP3) at the subgroup level. -
health_economic_evaluation.md 8.2 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a perspective/time-horizon/uncertainty/ICER-interpretation flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Health economic evaluation probes (HE1–HE8) An 8-probe checklist for **health economic evaluations** — comparative analyses of the costs and consequences of two or more options: cost-effectiveness (CEA), cost-utility (CUA, QALY-based), cost-benefit (CBA), cost-minimisation, and budget-impact/HTA analyses, whether **trial-based** or **decision-model-based** (decision tree, Markov/state-transition, discrete-event simulation). These probes complement (do not replace) the generic Phase 2 checklist and the **CHEERS 2022** reporting items; they target the gap between a clean-looking incremental cost-effectiveness ratio (ICER) and whether the structural choices that produced it — perspective, time horizon, discounting, the effectiveness source, the cost basis, the model, and the handling of uncertainty — are defensible. Because the headline number is a single ratio that a reader cannot reproduce from the manuscript, the burden is on the study to *evidence* each structural choice, not assert it. HE2 (time horizon & discounting), HE5 (model structure), and HE6 (uncertainty / probabilistic sensitivity analysis) are the highest-yield; run them first. **HE1 — Decision problem, comparators, and perspective**: - Is the decision/policy question explicit, and are **all relevant comparators** included — the current standard of care and, where appropriate, a do-nothing/usual-care arm — rather than a convenient or obsolete comparator that flatters the new intervention? Omitting the true standard of care is the most common way to manufacture a favourable ICER. - Is the **analytic perspective** stated (healthcare-system / payer vs **societal**) and justified, and is it applied consistently to what is counted (HE4)? A "societal" claim that omits productivity and informal-care costs — or a payer analysis that smuggles in societal costs — is an internal inconsistency. - A missing/obsolete comparator, an unstated perspective, or a perspective inconsistent with the costs counted → MAJOR. **HE2 — Time horizon and discounting**: - Is the **time horizon** long enough to capture all material differential costs and health effects? For a chronic disease or a one-off intervention with lifelong consequences, a **lifetime horizon** is usually required; a horizon truncated to trial follow-up flatters whichever arm has earlier benefit and hides downstream costs. - Are **both costs and outcomes discounted** at a stated, justified rate (with the jurisdiction's reference-case rate used and a sensitivity at alternative rates)? Discounting only costs, or not discounting a multi-year horizon at all, biases the ICER. - A truncated horizon with no extrapolation justification, undiscounted long-horizon results, or asymmetric discounting → MAJOR. **HE3 — Effectiveness source and outcome valuation**: - Where do the **effectiveness/relative-effect estimates** come from — a head-to-head RCT or meta-analysis, or a weaker source (single-arm, naive indirect comparison, observational) — and is that source appropriate and its uncertainty carried into the model (HE6)? A model driven by an optimistic point estimate from a weak source is not rescued by elegant modelling. - For a **cost-utility analysis**, are **QALYs** built from a **named preference-based instrument** (e.g. EQ-5D-5L) and a stated **value set / tariff** for the relevant population, rather than ad-hoc or mapped utilities with the mapping undisclosed? - A weak/again-mismatched effectiveness source, or QALYs with no stated utility instrument/value set → MAJOR (or a prominent limitation if the source is the best available and is treated as uncertain). **HE4 — Costs, resource use, currency, and price year**: - Are **resource quantities and unit costs reported separately** (so a reader can re-value for another setting), and are the cost categories **consistent with the stated perspective** (HE1)? - Are the **currency, price (cost) year, and any inflation/currency-conversion method** stated? Costs pooled across years or countries without a stated price year and conversion are not interpretable or transferable. - Missing price year, perspective-inconsistent cost categories, or undisclosed resource-vs-unit-cost bundling → MAJOR. **HE5 — Model structure, assumptions, and validation**: - If **model-based**, is the model **type and structure** (states/events, cycle length, half-cycle correction for a Markov model) described and **justified** against the disease's natural history, rather than chosen for convenience? Is **structural uncertainty** acknowledged (alternative plausible structures), and was the model **validated** (internal consistency, and external/predictive validation where data exist)? - Are key **structural assumptions and extrapolation** beyond the data (e.g. survival curve extrapolation, treatment-effect waning) declared and tested? - An unjustified structure, undeclared extrapolation, or no validation/structural-sensitivity → MAJOR (the structure can drive the ICER more than any single parameter). **HE6 — Uncertainty: deterministic AND probabilistic**: - Is **parameter uncertainty** characterised by a **probabilistic sensitivity analysis (PSA)** — every uncertain input assigned a distribution and propagated by Monte Carlo — and reported as a **cost-effectiveness plane** and **cost-effectiveness acceptability curve (CEAC)**, not just a deterministic point ICER? Are **one-way / tornado** analyses used to identify the drivers, and **scenario analyses** for non-parametric (structural/methodological) uncertainty? - Are the parameter **distributions justified** (beta for probabilities/utilities, gamma/log-normal for costs) rather than arbitrary ±20% ranges presented as if probabilistic? - A single deterministic ICER with **no PSA**, or "sensitivity analysis" that is one-way only with no probabilistic component → MAJOR. **HE7 — Results presentation and ICER interpretation**: - Are **incremental costs and incremental effects reported separately** (and ideally a disaggregated cost/outcome table), not only the ratio? Are **dominance and extended dominance** correctly identified and dominated strategies excluded before computing ICERs? - Is the ICER interpreted against a **stated, justified cost-effectiveness threshold (willingness-to-pay)** appropriate to the jurisdiction — not an arbitrary or post-hoc threshold — and is the conclusion ("cost-effective") conditional on that threshold and the uncertainty (CEAC probability), rather than asserted from the point estimate alone? Net monetary/health benefit (INMB/INHB) is a clearer alternative at a given threshold. - An ICER reported without incrementals, mishandled dominance, or a "cost-effective" claim with no threshold context or ignoring the CEAC → MAJOR. **HE8 — Equity, generalisability, funding/COI, and CHEERS reporting**: - Are **distributional/equity effects** considered where relevant (who bears the costs and gains the health), and is **generalisability/transferability** to other settings or jurisdictions discussed (cost and epidemiology transfer, not just clinical effect)? - Are **funding source and the funder's role** and **conflicts of interest** disclosed? An industry-funded evaluation whose ICER sits just below the threshold and is insensitive to plausible assumptions warrants heightened scrutiny of the structural choices (HE1–HE5). - Is reporting mapped to **CHEERS 2022**, and is the model / health-economic analysis plan available for scrutiny? - No generalisability discussion for a single-jurisdiction model presented as broadly applicable, or undisclosed funder role on a threshold-hugging result → MAJOR (funding/COI) / MINOR (generalisability), per centrality. -
image_synthesis.md 6.1 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a task- or design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Image-Synthesis / Cross-Modality Generation probes (IS1–IS4) A 4-probe checklist for studies that synthesize one imaging modality from another (MRI→PET, MRI→CT, CT→MRI, non-contrast→contrast, low-dose→full-dose) and then claim the synthetic image carries clinically usable **functional/molecular** information. These probes complement (do not replace) the generic Phase 2 issue checklist. Their purpose is to keep three structurally distinct failure modes — which a single review tends to split or miss — under one reviewer's coverage, because each looks individually "addressable by reframing" while together they govern whether the central claim survives. Trigger: the manuscript trains a generative model (GAN/PatchGAN, diffusion, U-Net/Swin-UNet, CycleGAN) to produce a target modality from a source modality **and** frames the output as providing metabolic / receptor / perfusion / metabolic-uptake information, or as a substitute for the real target modality when it is unavailable. **IS1 — Determinism / information ceiling (synthetic-vs-source value claim)**: - The synthetic image is a deterministic function of the input source image(s). For the **same reader on the same case**, "source + synthetic" cannot carry patient-specific information beyond "source alone" — any reader AUC gain is most consistent with a representation/presentation/interpretability effect, not new diagnostic information. - Does a direct source→label baseline (e.g., MRI-only model on the same split) exist to separate *information* from *presentation*? Absent → the incremental-value claim is unsupported as written. - An interpretability/accessibility gain can still be clinically useful — recommend reframing the Abstract/Discussion to that, not to "added diagnostic value." MAJOR candidate when the Abstract/Conclusion reads the gain as new information. **IS2 — Target-derived preprocessing / label leakage (circularity)**: - Was any mask, ROI, lesion segmentation, slice-selection rule, intensity normalization, or registration target derived from the **target modality** or the **outcome label** rather than from the input source alone? - The decisive case: if a tumour/lesion mask (drawn on the target modality or from the diagnosis) guided slice selection or training, then the central claim — "functional information is *inferred from source structure*" — is circular and not supported by the design. - Is the slice-selection / sampling strategy described at all? Silence here is itself a MAJOR candidate: the reviewer cannot exclude leakage, so the claim cannot be granted. Request the exact provenance of every preprocessing input and a leakage-free re-analysis. **IS3 — Lesion/target-level vs global validation**: - Quantitative agreement (SUVR/SUV correlation, Bland–Altman, PSNR/SSIM) reported on a **global or whole-organ** metric (e.g., whole-brain-to-reference-region SUVR) does not establish that the synthetic image reproduces the **lesion-level** quantity that actually drives grading/differentiation. Tumour uptake is heterogeneous; global agreement can be high while lesion uptake is wrong. - Is target-level agreement reported (tumour ROI / target-to-background ratio)? Absent → the quantitative-fidelity claim must be tempered, or lesion-level agreement added. MAJOR candidate when the manuscript states lesion-level usability from a global metric. **IS4 — Mechanistic / proxy-signal plausibility**: - Name the physical quantity the source measures versus the target measures (e.g., MRI structure / blood–brain-barrier disruption vs PET tracer amino-acid transport & protein synthesis). When the manuscript labels the synthetic signal with the target's biology ("metabolic information"), is the source→target signal link **validated**, or merely assumed because the outputs look like the target? - High image similarity is not evidence that an unmeasured biological signal was recovered. An unvalidated proxy claim is over-interpretation regardless of PSNR/SSIM. Watch for tracer-mechanism mislabeling carried through the whole manuscript (e.g., calling amino-acid-transport PET a "metabolism" readout). - Absent validation of the structure→function link → temper the biological-information claim to image-resemblance. MAJOR candidate when the title/Abstract/Conclusion assert recovered functional/molecular information. **Output template (IS1 example)**: > "Because the synthetic PET is a deterministic function of the input MRI, for the same reader on the same case it cannot add patient-specific information beyond the MRI; the MRI-alone vs MRI+synthetic-PET gain is therefore more consistent with a representation/interpretability effect than with new diagnostic information, and no direct MRI→label baseline is provided to separate the two. I'd suggest reframing the Abstract and Discussion as an interpretability/accessibility gain rather than added diagnostic value, and (optionally) reporting a same-split MRI→label baseline to quantify how much of the gain is information versus presentation." **Output template (IS2 example)**: > "The slice-selection strategy is not described. If a tumour mask (drawn on the reference PET or from the diagnosis) guided which slices were preprocessed or trained on, then the central claim — that functional information is inferred from MRI structure — would be circular, and I could not exclude this from the manuscript as submitted. Please state the exact provenance of every preprocessing input (masks, ROIs, slice selection, normalization, registration target) and, if any was derived from the target modality or the label, provide a leakage-free re-analysis." -
mendelian_randomization.md 11 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; an instrument-validity / pleiotropy / sample-overlap design flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Mendelian randomization probes (MR1–MR8) An 8-probe checklist for **Mendelian randomization (MR)** studies — causal-inference designs that use germline genetic variants as **instrumental variables (IVs)** for an exposure: two-sample summary-data MR, one-sample MR, multivariable MR (MVMR), drug-target / cis-MR, and non-linear MR (NLMR). These probes complement (do not replace) the generic Phase 2 checklist and the **STROBE-MR** reporting items; they target the gap between MR's "genetic randomization mimics a trial" framing and whether the three IV assumptions and their sensitivity analyses actually hold. The two-sample summary-data approach is cheap and public, which has produced an explosion of low-quality MR — so the burden is on the manuscript to *evidence* validity, not assert it. MR2 (instrument validity) and MR4 (pleiotropy-robust sensitivity) are the highest-yield; run them first. **MR1 — Research question, estimand, and engagement with prior evidence**: - Is the exposure a **well-defined, modifiable, heritable** trait with a credible genetic instrument, or an ill-defined / non-modifiable / composite construct for which a genetic proxy is not interpretable? A genetic instrument for a vague exposure estimates the effect of *whatever the variants actually act on*, not the named exposure. - MR estimates a **lifelong average effect of a genetically-proxied shift** in the exposure, not the effect of a time-limited clinical intervention; interpreting a per-SD genetic estimate as the expected effect of a drug/behaviour change started in adulthood is an estimand mismatch (see MR8). - Is the finding **triangulated** with prior observational/RCT evidence and discrepancies discussed, or presented in isolation? A hypothesis-free "everything-on-everything" phenome-wide MR scan additionally inherits the many-comparison multiplicity problem (cross-link O17 in `observational_confounding.md`). - An MR on an ill-defined/non-modifiable exposure, an estimand over-translated to a clinical-intervention magnitude, or a result with no engagement with the existing literature → MAJOR. **MR2 — The three IV assumptions, evidenced not asserted**: - **(IV1) Relevance** — the variant(s) are strongly associated with the exposure. Is instrument strength quantified (**F-statistic**, variance explained R²; a mean/conditional F well above ~10)? **Weak instruments** bias two-sample MR toward the null and one-sample MR toward the confounded observational estimate, and inflate type-1 error under sample overlap (MR6). - **(IV2) Independence / exchangeability** — the instrument is independent of confounders of the exposure–outcome relationship. Is this supported (e.g. a PhenoScanner-style scan showing the variants are not associated with obvious confounders; population-stratification control via ancestry-matched GWAS + principal components)? - **(IV3) Exclusion restriction** — the instrument affects the outcome **only through the exposure**. This is the un-testable assumption; **horizontal pleiotropy** is the central threat and must be interrogated indirectly (MR4). - **Winner's curse** — were instruments selected in a GWAS **independent** of the one used to estimate the variant–exposure association? Selecting and estimating in the same sample inflates the instrument–exposure effect and biases the MR estimate. - A genome-wide-significant instrument list with **no F-statistic / strength reporting**, no confounder/pleiotropy screen, or instruments discovered and weighted in the same sample → MAJOR. **MR3 — Instrument construction, LD, and harmonization**: - Are instruments selected at a justified threshold (genome-wide significance P < 5 × 10⁻⁸, or a pre-stated alternative) and **LD-clumped** (r² and window stated; a sensitivity at a stricter r² is reassuring)? Were **palindromic / ambiguous** SNPs handled and effect alleles **harmonized** across the exposure and outcome GWAS (a harmonization error silently flips the sign)? - For **cis / drug-target MR** with multiple correlated variants in a single locus, naive IVW (which assumes independent instruments) is wrong — a correlation-aware estimator (e.g. **GLS-corrected IVW** with the LD matrix) is required, and the LD reference must match the GWAS ancestry. - Were variants directly associated with the **outcome or known confounders** removed (or a reason given to keep them)? - Undisclosed clumping/harmonization, palindromic mishandling, or naive IVW on correlated cis variants → MAJOR (sign/precision can be driven by the artefact). **MR4 — Pleiotropy-robust sensitivity suite (not IVW alone)**: - IVW is efficient but assumes **no horizontal pleiotropy**; a credible MR reports a **pre-specified suite of estimators with different bias assumptions** and shows the estimate is **concordant** across them: **MR-Egger** (its intercept tests directional pleiotropy), **weighted median** (valid if ≤50% of weight is invalid), **weighted mode**, and **MR-PRESSO** (detects/corrects outliers). Concordance across methods — not the IVW point estimate — is the actual robustness claim. - Is **heterogeneity** across instruments reported (**Cochran's Q**, I²_GX), and are outliers investigated rather than silently dropped? - A headline causal claim resting on **IVW only**, with no pleiotropy-robust estimators and no heterogeneity assessment → MAJOR (downgrade to exploratory or add the sensitivity suite). MR-Egger alone, with low precision, is not a substitute for the full suite. **MR5 — Direction of causation / reverse causation**: - Could the genetic association run **outcome → exposure** (or to a shared upstream trait)? Is the **direction** addressed — **Steiger filtering** (the instrument should explain more variance in the exposure than in the outcome) and/or **bidirectional MR**? - When exposure and outcome are biologically intertwined (e.g. a biomarker and a disease that alters it), absence of a direction check is a real threat, not a formality. - A plausibly-reversible exposure–outcome pair analysed in one direction only, with no Steiger/bidirectional check → MAJOR (or a prominent limitation when direction is otherwise constrained). **MR6 — Sample overlap, ancestry, and two-sample assumptions**: - Two-sample MR assumes the exposure-GWAS and outcome-GWAS samples are **independent**. Is the **degree of overlap** reported (e.g. UK Biobank contributing to both)? Overlap biases the estimate toward the **confounded observational** association and, combined with weak instruments (MR2), inflates type-1 error; it must be disclosed and, where material, addressed (independent samples, an overlap-corrected method, or a stated bias direction). - Are the GWASs **ancestry-matched**, with population stratification controlled? Cross-ancestry instruments carry different LD and allele frequencies and can bias the estimate. - Undisclosed substantial sample overlap, or cross-ancestry instrument transport with no LD/stratification handling → MAJOR. **MR7 — Non-linear MR caution**: - A reported **non-linear / threshold / J- or U-shaped** MR effect from the **residual** or **doubly-ranked** stratification methods must be treated with caution: both can produce **artefactual shapes** (documented failures for Vitamin D, BMI, and a failure to recover the trial-established LDL-C → myocardial-infarction shape). A quoted inflection point/threshold from NLMR is not a validated cutoff. - Are the NLMR-specific safety checks present — **negative-control outcomes**, **positive controls** (a relationship of known shape), **sensitivity excluding extreme exposure strata**, **biological plausibility**, and **triangulation**? Is the stratification on the **non-genetic (residual) exposure** acknowledged as the assumption it is? - An NLMR "threshold/saturation" claim with no negative/positive controls and no extreme-stratum sensitivity → MAJOR (this is the MR analogue of the data-driven threshold mining in O12; cross-link it). **MR8 — Interpretation, drug-target specifics, and STROBE-MR reporting**: - Is the conclusion scaled to what MR supports — a **lifelong genetic-proxy effect direction**, not a precise **clinical-intervention magnitude**? "A drug lowering X will reduce risk by the per-SD MR estimate" over-reads the design (MR1 estimand). - **Drug-target / cis-MR** specifics: is **colocalization** reported to distinguish a shared causal variant from LD confounding between the exposure-pQTL/eQTL and the outcome signal; is a **positive control** (a known on-target effect) shown; and is an **adverse-effect / phenome-wide** scan reported for safety claims? A drug-target MR with no colocalization is open to LD-driven confounding. - Is the study reported against **STROBE-MR** (data sources, instrument selection, assumptions, sensitivity, overlap), and — for a registered analysis — was the primary analysis pre-specified? - A causal claim translated into a clinical-effect size, a drug-target MR without colocalization, or reporting that omits STROBE-MR-level instrument/assumption detail → MAJOR for the interpretation/headline, MINOR for a reporting-only gap. **Output template (MR4 example)**: > "The causal claim rests on the inverse-variance-weighted estimate alone. Because IVW assumes no horizontal pleiotropy — the un-testable exclusion-restriction assumption — a single IVW estimate cannot support a causal conclusion on its own. I'd suggest reporting a pre-specified pleiotropy-robust suite (MR-Egger with its intercept test for directional pleiotropy, weighted median, weighted mode, and MR-PRESSO for outliers) and showing the estimate is concordant across them, together with a heterogeneity assessment (Cochran's Q) and an F-statistic for instrument strength. If the estimate is not stable across methods, or the MR-Egger intercept indicates directional pleiotropy, the causal interpretation should be downgraded accordingly and the Abstract/Conclusions revised to match." **Output template (MR6 example)**: > "The exposure and outcome GWAS both include UK Biobank, so the two-sample independence assumption is violated by partial sample overlap. Overlap biases the estimate toward the confounded observational association and, with any weak instruments, inflates the type-1 error rate. I'd suggest quantifying the overlap, reporting the mean F-statistic, and either using non-overlapping samples, applying an overlap-aware correction, or at minimum stating the expected direction and magnitude of the resulting bias so the causal claim can be read against it." -
mllm_evaluation.md 7.6 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a task- or design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # LLM / MLLM clinical-evaluation probes (ME0–ME8) For the evaluation of a **large language model or multimodal LLM on a clinical task** — radiology report generation, visual question answering (VQA), or clinical text extraction / classification — whether the model is a **closed API or open weights**. This probe owns the *evaluation* mechanics (reference standard, faithfulness, contamination, prompt sensitivity, reader study); it extends, and cross-links, `ai_overclaiming.md` (the claim) and the self-review input-contamination / fine-tuning checks. Author-side harness design is `/mllm-eval`; this module is the reviewer-side audit. (For image-to-image generative models use `image_synthesis.md`; this is for text / VQA output.) **ME0 — Pin the task, the model + version, the comparator, and the decoding settings (gate; run first)**: - State the task (report generation / VQA / extraction-classification), the **exact model and version/date** (closed API or open-weights id), the **decoding settings** (temperature, max tokens), and **what the outputs are scored against**. Model-agnostic by construction — every probe applies to a closed API and to open weights. - A finding is a **lead** until the eval protocol is read in full; do not escalate before the reference standard and metrics are located. **ME1 — Reference standard for generated text (not a noisy proxy)**: - For report generation or open-ended answers, the comparator must be an **adjudicated expert reference**, not a single unverified clinical report or a model-generated label treated as truth. State who set the reference, the adjudication rule, and the agreement among reference-setters (cross-link MI-CLEAR-LLM; the diagnostic-accuracy D-probes). - Benchmark labels mined from raw reports without adjudication carry the report's own errors into the "ground truth." An undefined or single-rater unadjudicated reference for a generation claim → MAJOR. **ME2 — Clinical-efficacy metrics beyond n-gram overlap**: - BLEU / ROUGE / METEOR measure surface word overlap and are **weakly correlated with clinical correctness** — a report can score high while inverting laterality or missing a pneumothorax (Yu et al., *Patterns* 2023, RadCliQ). Require a **factual / clinical-efficacy metric**: **RadGraph-F1** (entity-relation overlap; Jain et al., NeurIPS Datasets & Benchmarks 2021), **CheXbert-F1** (finding-label agreement; Smit et al., 2020 — or the rule-based CheXpert labeler, Irvin et al., 2019), or a composite (RadCliQ), reported **alongside** any n-gram score. - A headline resting on BLEU / ROUGE as if it were correctness → MAJOR; a clinical metric present but without CIs or a per-finding breakdown → MINOR. **ME3 — Faithfulness / hallucination, measured not assumed**: - A fluent answer is not a faithful one. For generated text require an **atomic-fact faithfulness** measure — decompose the output into atomic claims and check each against the image / source — and a **false-premise / abstention** robustness probe (does the model fabricate a finding when asked a leading or unanswerable question? — MedVH, Med-HALT). Report a **hallucination / fabrication rate**, not just an accuracy. - A generation or VQA claim with no faithfulness or false-premise evaluation → MAJOR (the central MLLM gap); present but only spot-checked → MINOR. **ME4 — Test-set contamination of the model's pretraining**: - Public clinical benchmarks (VQA-RAD, SLAKE, MIMIC-CXR-derived, MedQA) may sit inside the model's pretraining corpus, so a high score can be **memorisation, not capability** — and for closed APIs the corpus is unknowable. Require an explicit contamination statement: the model's training cutoff vs the benchmark release date, a **held-out / private or post-cutoff** evaluation set, or a contamination probe (canary strings, perturbed-duplicate gap, membership test). - "We evaluated model X on benchmark Y" with no contamination handling, where Y predates the cutoff → MAJOR; acknowledged as a limitation but not mitigated → MINOR (escalate if it carries the headline). **ME5 — Prompt sensitivity, determinism, and run variance**: - LLM outputs move with **prompt phrasing / format, temperature, and run-to-run sampling** (closed APIs are non-deterministic even at temperature 0). A single-prompt single-run number overstates stability. Require: the **exact prompt(s)** (MI-CLEAR-LLM transparency), temperature / seed, **≥ 3 runs** with variance, and a **prompt-robustness** check (≥ 2 phrasings / formats) for the headline. - Single prompt, single run, temperature / seed undisclosed → MAJOR; prompt disclosed but variance unreported → MINOR. **ME6 — Task-metric discipline for VQA / classification**: - Apply the imbalance and operating-point discipline (cross-link `ai_overclaiming.md` and the model-development MD6 metric checks): report accuracy **at the real clinical prevalence**, not on a balanced QA set; specify **how free-text answers were matched** to the key (exact / normalised / LLM-judge — and if an LLM judges, validate it on controls, cross-link `/design-ai-benchmarking`); and state how **refusals / abstentions** are scored. - Balanced-set accuracy generalised to deployment, or an unspecified answer-matching rule → MAJOR; refusal handling unstated → MINOR. **ME7 — Reader study for generated reports**: - Automated metrics do not establish clinical acceptability. A report-generation deployment claim needs a **blinded clinical reader study** with a pre-defined **error taxonomy** (clinically significant vs insignificant, omission vs fabrication), a severity scale, and **inter-reader agreement** (route the rubric / IRR to `/design-ai-benchmarking`, the ICC/κ to `/analyze-stats`, and reader / case sizing to `/calc-sample-size`). - A deployment / utility claim from automated metrics alone, with no reader evaluation → MAJOR. **ME8 — Calibration, abstention, and safety**: - A clinical LLM that is confidently wrong is dangerous. Check whether the study reports **confidence calibration / appropriate abstention** (does the model express uncertainty or refuse when it should?), a **fabricated-citation / evidence** rate, **harmful-output** screening, and **subgroup robustness** (defer fairness depth to `equity_fairness.md`). - For a deployment-flavoured claim, the absence of any safety / abstention evaluation → MAJOR; for an exploratory benchmark, → MINOR with a stated limitation. **Output template (ME2 / ME3 example)**: > "Report quality is reported as BLEU-4 and ROUGE-L, which track word overlap rather than clinical > correctness — a report can score well while reversing laterality. I would add a factual metric > (RadGraph-F1 or CheXbert-F1) reported with confidence intervals alongside the n-gram scores, and — > because fluency is not faithfulness — an atomic-fact faithfulness rate plus a false-premise probe (does > the model assert a finding when asked about an absent one?). Without these, the evaluation cannot > distinguish a fluent confabulation from an accurate report." -
model_development.md 15.5 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a task- or design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Model-Development / Validation-Integrity probes (MD0–MD11) A checklist for **engineer-built medical-imaging models** — a segmentation, classification, or detection model (CNN / U-Net / nnU-Net / transformer) that a clinical team received from an engineering collaborator and is now validating and writing up. This probe owns the **partition, leakage, reproducibility, and metric-selection mechanics** of the model's evaluation (MD1–MD8) and — when the manuscript frames the model for clinical use — the **interpretability-faithfulness, uncertainty/OOD, and deployment-feasibility mechanics** behind that framing (MD9–MD11). It is the imaging-model counterpart to `clinical_prediction_model.md` (tabular TRIPOD prediction models) and `radiomics.md` (hand-crafted feature pipelines), and it complements `ai_overclaiming.md` (which owns the *claim*, MD owns the *mechanism behind the number*). Route here when the manuscript develops, validates, or reports the performance of a deep-learning imaging model. Author-side study design for such a model is `/model-validation`; this module is the reviewer-side audit. **MD0 — Locate the deployment task and the evaluation it rests on (gate; run first)**: - State the model's task (segmentation / classification / detection), its **intended-use horizon** (screening, triage, pre-procedure, post-hoc), and the **single number the conclusion leans on** (Dice, AUROC, sensitivity at an operating point, FROC). Then find the partition that produced it: which data trained the model, which tuned it, and which was touched once for the final estimate. - An integrity finding is a **lead until the metric and the partition that produced it are read together** — do not escalate a clean number whose provenance is simply not yet described; ask for it first. - Boundary with neighbours: `ai_overclaiming.md` AO5 = the *claim/metric is optimistic*; `clinical_prediction_model.md` CP = a *tabular TRIPOD model*; MD = the *partition, leakage, reproducibility, and metric mechanics of an engineer-built imaging model*. **MD1 — Partition disjointness and the leakage taxonomy**: - Confirm the split is at the **patient level**, not the image / series / slice level — the same patient contributing slices to both train and test inflates every metric, because the model can memorise patient-specific anatomy rather than pathology (CLAIM 2024 data-partition items; Varoquaux & Cheplygina, *npj Digit Med* 2022). - Walk the leakage taxonomy (Kapoor & Narayanan, *Patterns* 2023): (a) **same-patient / near-duplicate** records across splits; (b) **preprocessing-before-split** — any normalisation, intensity scaling, resampling, feature selection, foundation-model embedding, or ComBat-style harmonisation **fit on the whole cohort** before partitioning leaks test statistics into training; (c) **site / scanner / label shortcut** — a model that separates classes by acquisition site or a burned-in annotation rather than by pathology; (d) **temporal leakage** — a random split in which future and past coexist. - The decisive test: *could any value used in training have been computed only with knowledge of a test case?* → MAJOR (often Fatal) when a leakage path is present and the headline metric depends on it; a clarify-request when the split is clean but its level or ordering is simply undescribed. **MD2 — Tuning, threshold, and model selection on the test set**: - The test set must be touched **once**, for the final estimate. Flag any architecture search, hyperparameter sweep, early-stopping criterion, **operating-point / threshold** choice (Youden, F1-max), or "best checkpoint" selection that reads the test set — each is a form of tuning-on-test that makes the reported number optimistic and non-reproducible. - Require an explicit statement that the threshold and the final model were fixed on training / tuning folds only. A test set reused across multiple model variants (model-selection leakage) → MAJOR; a tuning step whose data source is unstated → PARTIAL pending clarification. (Cross-link `ai_overclaiming.md` on threshold provenance.) **MD3 — Internal split vs genuine external validation (and the nomenclature conflation)**: - Classify the evidence honestly: apparent → internal random split → cross-validation → **temporal** (later time window) → **geographic / external** (different site, scanner, vendor) → **multi-site external**. Cross-validation and bootstrap are development-time optimism corrections, **not** external validation (cross-link `clinical_prediction_model.md` CP6). - Flag "developed **with external validation**" when the single external set was used for tuning or feature selection, and flag a single external site propping up a broad generalisability claim (cross-link `ai_overclaiming.md`). A geographically or temporally external test of adequate size is the strongest single lever for a deployment-readiness claim — its absence caps the claim to internal performance. → MAJOR when an external / generalisability claim outruns an internal-only design. **MD4 — Seed, run-to-run variance, and single-run reporting**: - A deep model's metric varies with random seed, initialisation, augmentation order, and (for non-deterministic GPU operations) hardware. A single-run headline number with no spread overstates precision and is unreproducible. - Require the metric as **mean ± SD (or 95% CI) across ≥3 seeds / runs**, or — minimally — a fixed, reported seed with the determinism caveat, and confirm the reported figure is not a cherry-picked best run or epoch. Single-run headline with no variance and no fixed seed → MAJOR; variance unreported but a fixed seed is stated → MINOR. **MD5 — Test-set size and event count for the headline metric (and for calibration)**: - A metric is only as stable as its denominator. Check the **test-set events per class**, not the cohort total: an AUROC of 0.92 on 18 positive cases has a confidence interval spanning much of the usable range, and a calibration claim needs roughly ≥100 events to be meaningful (Riley et al., prediction-model sample-size work; CLAIM 2024 asks that the test size be justified). - Segmentation / detection: confirm the number of **lesions / structures**, not just patients, supports the per-structure metric. Sparse-test headline metric with no precision statement → MAJOR (route the sizing to `/calc-sample-size`); adequate n but the CI omitted → MINOR. **MD6 — Metric selection and the imbalanced-data pitfalls**: - Match the metric to the task and the prevalence (Metrics Reloaded — Maier-Hein & Reinke et al., *Nat Methods* 2024; pitfalls companion Reinke et al., *Nat Methods* 2024). (a) **Segmentation**: Dice / IoU alone is shape-insensitive — pair an **overlap metric with a boundary metric** (HD95 / Normalised Surface Distance), report **per-structure** rather than only a global mean, and beware Dice instability on small structures. (b) **Classification under imbalance**: **accuracy is misleading** — report threshold-independent discrimination with CIs (**AUROC and AUPRC**, since AUPRC tracks the minority class), plus sensitivity / specificity and prevalence-dependent **PPV / NPV at the deployment base rate**, not on an artificially balanced set. (c) **Detection**: report **FROC / sensitivity-per-false-positive or mAP with the IoU match criterion stated**, not patient-level accuracy. - A headline that is accuracy-on-imbalanced, Dice-only, or a metric whose match / operating criterion is unstated → MAJOR; a sound metric missing only its CI or a secondary boundary metric → MINOR. **MD7 — Reproducibility and provenance of the engineer-built artifact**: - The clinician inherits a model and must be able to say where it came from and rebuild the evaluation. Require: model **provenance** (in-house / vendor / open-weights + version), architecture, all hyperparameters, framework + **library versions**, the **preprocessing pipeline in order**, hardware, the **random seed**, and **code + weights + data availability** (or a stated reason) — the standard ML-reproducibility set (Pineau et al., ML Reproducibility Checklist; CLAIM 2024; TRIPOD+AI). - For a **vendor / closed** model, state the access limits explicitly (training-data composition unknown, results not independently reproducible) rather than implying full transparency. Missing the items needed to reproduce the headline result → MAJOR; a few secondary items absent → MINOR. **MD8 — Label quality and the reference standard behind the engineer's metrics**: - A model is bounded by its labels. Check who produced the reference standard, the **number of annotators, the consensus / adjudication rule, blinding, and reported inter-reader agreement** (κ / ICC), and whether any labels are **automated / "silver" or model-derived** (NLP-mined report labels, prior model outputs) — label noise caps achievable performance and silent self-labelling is circular (CLAIM 2024 reference-standard items; cross-link self-review circularity checks). - For subgroup / fairness, defer to `equity_fairness.md` rather than re-deriving it here. An undefined reference standard, or model-derived labels feeding the same model's evaluation → MAJOR (Fatal when circular); agreement simply unreported → MINOR. **MD9 — Interpretability faithfulness (a saliency figure is not evidence)**: - Triggered when Grad-CAM / Grad-CAM++ / attention / integrated-gradients maps are offered as support for "the model attends to the right structure". Check three things before accepting the figure as evidence: (a) **sanity checks** — does the map change when the model's parameters are randomised, and when the labels are randomised? Several widely used methods produce visually plausible, anatomically convincing maps that are largely independent of the trained weights, so a plausible-looking heatmap does not by itself indicate the model learned the intended signal (Adebayo et al., *Sanity Checks for Saliency Maps*, NeurIPS 2018). (b) **Quantitative localisation across the cohort** against the annotated ground truth (IoU / pointing game / Dice), reported for the test set as a whole rather than a hand-picked panel of exemplars; a deletion/insertion (perturbation) curve serves the same purpose where no lesion mask exists. (c) **Framing** — an attribution map indicates *where the evidence lies*, not *that the prediction is correct*, and cannot stand in for clinical validation. - CLAIM 2024 item 31 asks for explainability methods **and their validation**; a figure with neither sanity check nor cohort metric is at best PARTIAL against that item. Author-side workflow and its deterministic gate: `/explainability` (`check_explainability_report.py`). - Severity: MAJOR when an interpretive or trust claim leans on saliency maps with no sanity check and no cohort-level localisation metric (the fix is to demote the figure to illustrative, or to run the validation); MINOR when a cohort metric is reported but the sanity checks are not. **MD10 — Uncertainty, OOD input handling, and abstention under a deployment claim**: - Triggered **only when the manuscript frames the model for clinical use** (triage, autonomous or pre-read interpretation, worklist prioritisation) — a retrospective accuracy study that claims nothing beyond accuracy does not owe this. A deployment claim built on **point predictions** describes a system with no mechanism to fail safely, and that is the gap a reviewer names. - Ask for: (a) **per-case uncertainty** (MC-dropout, deep ensemble, conformal, Bayesian) whose **coverage was measured**, not asserted — a stated 90% prediction interval with no empirical coverage is an untested claim; (b) an **OOD / input-validity guard** validated on a **held-out OOD set** (different scanner or vendor, wrong protocol or sequence, unsupported anatomy, non-diagnostic quality), since an OOD claim with no OOD data is unfalsifiable; (c) an **abstention / referral rule at a pre-specified operating point**, with **both** the retained-case performance **and** the abstention rate reported; (d) whether calibration survives **distribution shift**, not only the internal test set. - Interaction with MD5: selective prediction reports performance on the retained subset, so an apparent gain achieved by abstaining on hard cases is not a like-for-like comparison unless the abstention rate is given alongside — and the retained subset shrinks the denominator the CI rests on. Author-side workflow and gate: `/uncertainty-imaging` (`check_uncertainty_reporting.py`); DECIDE-AI covers the live-clinical-use reporting. - Severity: MAJOR for a deployment or autonomy claim resting on point predictions only, and for an abstention result whose abstention rate is unreported (the number cannot be interpreted); MINOR when uncertainty is quantified but never tested under shift or against OOD input. **MD11 — Deployment feasibility and the post-deployment lifecycle**: - A manuscript that calls the model clinic-ready usually says nothing about what running it costs or how it stays correct. Ask for: **inference time per case on stated hardware**, checked against the workflow's latency budget (a triage claim needs minutes, not hours; an intra-procedural claim needs seconds); the **integration path** (PACS / EMR / worklist, DICOM in and out, on-prem vs cloud and the data-governance consequence of that choice); a **monitoring plan for dataset shift** — scanner replacement, protocol revision, case-mix drift — naming what triggers re-validation or retraining; and, for a clinical journal, any **cost or resource analysis** (route a full economic evaluation to `health_economic_evaluation.md`). - These are completeness items, not design flaws: their absence does not invalidate the reported accuracy, it **caps the claim** the paper may draw from it. Report them as such rather than as a defect in the result. Boundary: AO4 owns the *claim* ("deployment-ready" from retrospective internal evidence); MD11 owns the *operational facts* that claim would need. Inference-time reporting is covered author-side by `/model-evaluation`; the monitoring/retraining plan by `/model-scaffold` (`references/mlops_guide.md`). - Severity: MAJOR when a clinical-deployment claim is the headline and none of latency, integration path, or monitoring is addressed (route the claim-side fix to AO4); MINOR when the paper is otherwise appropriately hedged and only the cost or resource analysis is absent. **Output template (MD1 / MD6 example)**: > "The split appears to be at the image level (Methods, Data partitions) while the claim is patient-level, and intensity normalisation is described before the split — either path lets test statistics into training, so I would ask for a patient-level partition with all preprocessing fit on the training fold only and the metrics re-reported. Separately, segmentation quality is reported as mean Dice alone; because Dice is insensitive to boundary error and unstable on small structures, I would add a boundary metric (HD95 or NSD) and per-structure values with 95% confidence intervals. As written, the headline understates uncertainty and cannot be reproduced." -
narrative_review.md 12.7 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a task- or design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Narrative / Review-Article probes (RV1–RV9) A 9-probe checklist for a Review / narrative review / primer / state-of-the-art / educational review — i.e., a non-systematic synthesis rather than original research. Supporting appraisal material (the SANRA appraisal items, a consolidated evaluation checklist, and a candidate-additions catalog for AI/LLM-in-radiology reviews) is maintained separately by the peer-review skill and is not required to apply RV1–RV9 below. The original-research probes (the generic Phase 2 issue checklist, and the SR-MA / Survival / Radiomics probes) do not transfer to review articles. The key inversion: for original research, reviewers are discouraged from scope-expanding requests, but **for narrative reviews, identifying thematic gaps and proportionately suggesting missing content is an expected part of the reviewer's role** — error-spotting alone is necessary but not sufficient. Keep SANRA in its lane: it is a 6-item *critical appraisal tool, not a reporting guideline*, so do not over-enforce it (only RV3 is SANRA-aligned, and as a suggestion; do not demand PRISMA — narrative ≠ systematic). **RV1 — Novelty & value-add** *(editorial value-add axis)*: Against ≥2–3 recent reviews/primers on the same topic, does the manuscript state explicitly what it adds? For saturated topics, if the authors do not position their contribution against the current review literature, the incremental value is hard to judge — MAJOR candidate. Judge contribution magnitude only; scope-fit is the editor's call. **RV2 — Scope & aims clarity** (SANRA items 1–2): Is the topic's importance established, and are the review's aims and scope boundaries (what is included/excluded) explicit? **RV3 — Evidence-gathering transparency** *(SANRA item 3, suggestion-level)*: Even a narrative review benefits from one paragraph on how the literature was identified (databases, time window, selection logic). This is **not a reject criterion** — phrase it as a SANRA-aligned transparency suggestion. Do not require PRISMA. **RV4 — Technical & medical accuracy** *(reviewer niche strength)*: Engineering correctness (autoregressive decoding, RAG, RLHF, instruction tuning, hallucination mechanisms, evaluation/mitigation methods) and medical correctness (radiology claims, clinical examples, anatomy/imaging detail). Itemize errors with location. This axis is where a domain-literate reviewer adds unique value. > **Model-class conflation sub-probe** *(LLM/VLM-in-radiology reviews)*: check whether the manuscript treats text-only LLMs, multimodal/vision-language models (VLMs), conventional narrow CAD, and image-reconstruction artifacts as one phenomenon with one risk profile. They are distinct: a text-only LLM doing report structuring/summarization/grammar correction (low-risk language support) carries very different failure modes from a VLM asked to interpret images (high-risk image interpretation). When a "primer on LLMs" silently spans these classes, flag it — and note that the most actionable radiology-specific contribution is usually a **task-risk stratification** (which tasks are acceptable, which need safeguards, which to avoid) rather than a generic "LLMs hallucinate" statement. > **Verify-your-own-criticism gate**: before raising a technical inaccuracy or a citation–claim mismatch as a major finding, cross-check the assertion against a current authoritative source (the full cited paper, CrossRef, arXiv). Fast-moving fields make critiques go stale: a method dismissed as "not applicable" may have been adapted, and a "preprint" may since have been peer-reviewed. If unverified, downgrade to a hedged "Please verify…"; if confirmed, state it firmly. This applies with extra force to claims about what a cited reference *argues* (a review about hallucination must not itself mis-attribute a source). **RV5 — Taxonomy / synthesis coherence**: Is the manuscript's classification mutually exclusive and collectively exhaustive, and does it map to established taxonomies (intrinsic vs extrinsic; faithfulness vs factuality; published hallucination surveys)? Ad-hoc categories should be reconciled with an established taxonomy. Is the synthesis integrative rather than a list? > **Source/cause vs masking/amplifying-factor sub-probe**: when the manuscript presents a list of "sources" or "causes" of the error, check that each item is genuinely *generative* of the error rather than a factor that lets it go *undetected* or *amplifies its impact*. A recurring miscategorization: black-box opacity and automation bias are framed as sources of hallucination, but they do not generate hallucinations — opacity hides them and automation bias amplifies their downstream effect. Mis-labeling a masking/amplifying factor as a source is a **sharper conceptual defect than "scattered/ad-hoc taxonomy"** (the framework's own pillars are wrong, not just disorganized) — raise it explicitly rather than folding it into a generic synthesis comment. **RV6 — Balance, currency, citation accuracy** (SANRA items 4–5): Is conflicting evidence handled fairly (no cherry-picking)? Are citations current and primary-source-weighted? Spot-check citation accuracy (author/year/claim match) — for a review *about* hallucination, citation errors are thematically critical. **Single-anchor overload**: when a load-bearing clinical claim rests on essentially one study (n=1, often abstract-only, paywalled, or unreplicated), check the Abstract↔body register for that anchor — a MAJOR red flag is the Abstract calling it "landmark/definitive" while the body concedes the evidence base "is thin." Soften the anchor to "an early signal" and align both claim sites; if a verifiable independent second study exists, add it rather than leaning the whole stake on one source. **RV7 — Load-bearing figures/tables** *(editorial value-add axis; SANRA item 6 secondary)*: Are there standardized comparison tables, a landscape figure, or a concrete clinical worked example? Assess whether figures/tables carry synthesis weight or are decorative — strong radiology-AI reviews tend to use standardized comparison matrices and a worked example. **RV8 — Constructive gap-filling & additions** *(the expected-role probe)*: Identify missing topics/frameworks/key references and propose them as **"consider adding X because it directly supports Y"** — never "must cite." Tier candidates by publication status: - *Peer-reviewed guidelines*: TRIPOD-LLM, MI-CLAIM-GEN, and STARD-AI (all Nature Medicine), and the CLAIM 2024 update (Radiology: AI) - *Preprint (label as such)*: any not-yet-peer-reviewed arXiv/medRxiv item — name it as a preprint and do not place it at the same level as peer-reviewed guidelines. Verify status before citing, since preprints are frequently published later (a checklist first posted to arXiv may since have appeared in a journal) - *Concepts/tools*: RAG specifics (retrieval failure vs fabrication), uncertainty/confidence calibration, radiology-specific evaluation (RadGraph, CheXbert/CheXpert-F1, ReXTrust), regulatory context (FDA 510(k)/CE, RADAR) Keep additions **proportionate** (≈ ≤1 new reference per page, each motivated; no wholesale rewrite). Suggesting missing *literature/topics* is expected; demanding new *studies* is not. **Self-citation architecture** *(intellectual-COI, narrative form)*: Check whether the manuscript's weakest / most-deferred axes coincide with the authors' own forthcoming or companion work — a structural signal that the review may be agenda-setting for the authors' pipeline. This is legitimate for an invited review, but it must be made transparent: require a body-level motivation/COI line, and ensure every load-bearing axis carries at least one *independent* (other-group) source so the argument does not collapse to "trust us / see our upcoming paper." Companion-paper citations must stay strictly non-load-bearing ("not relied upon here") and survive into the revision. **RV9 — Bibliometric circularity of a curated base** *(narrative-review FATAL pattern)*: A non-systematic review that asserts a **field-level / bibliometric property** — "the field has invested heavily in X but neglected Y," a density/asymmetry/maturity gradient across topics — is making a *measured* claim from an *unmeasured* base. Because the reference set was curated, not searched, the asymmetry is a property of the authors' selection, not of the field; a hostile reviewer manufactures the opposite thesis by re-curating. This is a **Fatal** candidate when the gradient is the manuscript's central contribution. Two acceptable resolutions (a strategy fork, not a wording tweak): - **Down-scope (narrative/invited route)**: restate the claim as "within the literature surveyed here" and **delete every field-level phrasing at every claim site** — Abstract, body, each figure/table caption, and Conclusion. The grep discipline matters: a single residual "the field has…" sentence re-arms the rebuttal, so the down-scope must leave a field-level residue of zero. - **Measure (scoping route)**: add a documented search string + time window + per-axis counts so the asymmetry becomes a reproducible, reviewer-survivable measurement (this converts the piece toward a scoping review and is journal-agnostic). Also separate "volume of methods papers" from "clinical evidence": an axis can be *engineering-dense yet clinically empty*, and calling such an axis "mature/well-invested" while the same section concedes it measures the wrong thing is an internal contradiction. Reframing the gradient as engineering-density vs clinical/reader-validation is usually the stronger, defensible thesis. **Output template (RV1 example)**: > "The topic of LLM hallucinations is now addressed by several recent reviews, so it would strengthen the manuscript to state explicitly what this primer adds beyond them — for example, a radiology-specific failure taxonomy, a worked clinical example, or an actionable verification workflow that existing general-purpose reviews do not provide. As written, the Introduction does not position the contribution against the current review literature, which makes the incremental value difficult to judge." **Output template (RV8 example)**: > "The mitigation section would benefit from engaging with emerging reporting standards for generative models, as these directly support the manuscript's call for controlled deployment. Consider adding a brief discussion of TRIPOD-LLM and MI-CLAIM-GEN (both peer-reviewed reporting guidelines for LLM/generative studies), and clarifying how retrieval-augmented generation shifts the dominant failure mode from fabrication toward retrieval error rather than eliminating hallucination, a distinction the current text conflates." **Output template (RV9 example)**: > "The central thesis — that the field has invested heavily in image generation while neglecting detection and education — is presented as a property of the field, but the evidence is a curated, non-systematic reference set, so the asymmetry could equally reflect the authors' selection. As written, an opposing reviewer could re-curate the citations and reach the reverse conclusion. We suggest either (a) restating the claim throughout as 'within the literature surveyed here' and removing the field-level phrasings in the Abstract, figure captions, and Conclusion, or (b) adding a brief documented search (sources, window, per-theme counts) so the asymmetry is reproducible. Relatedly, the 'mature' provenance axis is dense in engineering/IP methods but, by the manuscript's own admission, carries no clinical or reader validation; distinguishing 'volume of methods papers' from 'clinical evidence' would make the contrast sharper and harder to rebut." This module gives review/narrative manuscripts a dedicated audit gate, on the principle that constructive gap-filling is an expected part of appraising a review article. ## When this module does not apply These probes are out of scope for: - Original research / development / validation / trial (→ Phase 2 + 2A/2B/2C) - Systematic review **with pooling** (meta-analysis) → Phase 2A - Case report / editorial / commentary (opinion form; no recommendation gating) Moved here from the consuming skill so the scope travels with the probes. -
network_meta_analysis.md 9.2 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a transitivity / incoherence / ranking-over-interpretation flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Network meta-analysis probes (NM1–NM8) An 8-probe checklist for **network meta-analysis (NMA)** — a synthesis that compares three or more interventions simultaneously by combining **direct** (head-to-head) and **indirect** evidence across a network, and usually produces a **ranking** of interventions. These probes complement (do not replace) the pairwise SR/MA probes in `sr_ma.md` (which cover the search/screening/pooling that an NMA also needs), the PRISMA-NMA reporting items, and the **RoB-NMA** risk-of-bias tool; they target what NMA *adds* — the transitivity assumption, consistency between direct and indirect evidence, network geometry, ranking interpretation, and network-level certainty. NM1 (transitivity) and NM4 (ranking over-interpretation) are the highest-yield; run them first. For the pairwise-pooling machinery (heterogeneity model, publication bias mechanics, study-count thresholds) also apply `sr_ma.md`. **NM1 — Transitivity (the foundational assumption)**: - An NMA combines A-vs-C and B-vs-C trials to learn about A-vs-B indirectly; this is valid only if the **effect modifiers are distributed similarly across the comparisons** (transitivity / exchangeability). Did the authors **assess** transitivity — comparing the distribution of plausible effect modifiers (e.g., baseline severity, age, sex, dose, follow-up, year) across comparisons (box plots / distribution tables / meta-regression) — or merely assert it? - Intransitivity (an effect modifier concentrated in one comparison) **invalidates the indirect comparisons**, and no statistical test fully substitutes for the clinical/epidemiological transitivity judgment. - No transitivity assessment, or evidence of an imbalanced effect modifier left undiscussed, where indirect evidence drives a conclusion → MAJOR (request the effect-modifier distribution and a discussion of its impact). **NM2 — Consistency / incoherence (direct vs indirect agreement)**: - Transitivity's statistical footprint is **consistency**: direct and indirect estimates for the same comparison should agree. Was incoherence assessed both **globally** (design-by-treatment interaction model) and **locally** (node-splitting / loop-specific / back-calculation)? - **A star network (no closed loops / no head-to-head comparisons) cannot be checked for incoherence at all** — the entire result then rests on the untestable transitivity assumption and must be flagged as such, not presented as if consistency were confirmed. - Local inconsistency is often **driven by a single trial or loop** (excluding it can remove or reveal incoherence) — was the source of any inconsistency investigated rather than only reported as a global p value? - No consistency assessment in a network with closed loops, or an unacknowledged star network presented as a validated comparison, or unexplained significant incoherence underlying a headline → MAJOR. **NM3 — Network geometry & connectivity**: - Is a **network plot** presented (nodes = treatments with size ∝ sample size/number of studies; edges = direct comparisons with thickness ∝ number of trials), so the reader can see where the evidence is thin? - **Sparse edges (single-trial comparisons), a dominant node, or a poorly connected / disconnected network** make the indirect estimates fragile and the ranking unstable; comparisons supported by one small trial should not carry a strong claim. Is the connectivity discussed? - A headline comparison resting on a single-study edge or a poorly connected network, presented with the same confidence as well-connected ones → MAJOR (or a prominent limitation). **NM4 — Ranking over-interpretation (SUCRA / P-score / rankograms)**: - Ranking metrics (**SUCRA**, **P-score**, rank probabilities, "probability best") are **easy to over-read**: a treatment can top the ranking on sparse or low-certainty evidence, the top rank is **not** a test of statistically significant superiority, and rankings are **unstable when the effect estimates are imprecise**. A small absolute difference can reorder ranks. - Is the ranking interpreted **together with the effect estimates, their uncertainty, and the certainty of evidence** (e.g., a league table + CINeMA grades), rather than "Treatment X was best" headlined from SUCRA alone? Is it noted that ranking does not imply a clinically meaningful or significant difference from the runner-up? - A "best treatment" conclusion driven by a ranking statistic, without the paired effect size / CI / certainty, → MAJOR (reframe to the magnitude and certainty of the actual comparisons). **NM5 — Heterogeneity (common-τ² assumption)**: - Is **between-study heterogeneity** reported (global τ² / heterogeneity rating), and is the usual **common (shared) heterogeneity assumption** across the network examined rather than assumed? Substantial heterogeneity undermines both the pooled estimates and the transitivity premise. - High global heterogeneity with no exploration (subgroup / meta-regression / sensitivity), where it could explain the findings → MAJOR; pairwise heterogeneity mechanics carry over from `sr_ma.md`. **NM6 — Certainty of network estimates (CINeMA / GRADE-NMA)**: - Network certainty is assessed **per estimate**, not as one overall grade: **CINeMA / GRADE for NMA** rates each comparison across within-study bias, reporting bias, indirectness, imprecision, heterogeneity, and **incoherence**. Were estimate-level certainty grades reported, and were **indirect-only** comparisons downgraded appropriately? - Conclusions must be **scaled to certainty** — a low/very-low-certainty network estimate cannot support a definitive "X is superior" claim. A uniform or absent certainty assessment, or conclusions that ignore low certainty, → MAJOR. **NM7 — Publication / small-study bias**: - For small-study/publication bias in a network, the appropriate tool is a **comparison-adjusted funnel plot** (with a pre-specified comparison ordering); standard funnel/Egger tests are uninformative with **< 10 studies** per comparison and should be reported as "suspected/not assessable" rather than "absent." Was small-study bias addressed at the network level? - A claim of "no publication bias" from an underpowered or comparison-naive funnel analysis → MINOR–MAJOR depending on how load-bearing the affected comparisons are. **NM8 — Component NMA additivity & interpretation/estimand**: - For a **component NMA (cNMA)** that decomposes multicomponent interventions into component effects, the analysis assumes **additivity** of component effects (and often no interaction between components) — is this assumption stated and, where possible, checked (an interaction/full-interaction model)? An unstated additivity assumption can manufacture component "effects." - Is the **reference/comparator and the estimand** clearly defined (relative effects via a league table; the chosen reference), and are conclusions kept within what the network supports (indirect, ranking-based, certainty-bounded) rather than translated into a definitive head-to-head clinical recommendation the trials never made? - An unstated cNMA additivity assumption, an undefined reference/estimand, or a network-level result over-stated as direct comparative proof → MAJOR for the interpretation, MINOR for a reporting-only gap. **Output template (NM1 example)**: > "The conclusion that A is superior to B rests on indirect evidence (there are few or no head-to-head A-vs-B trials), so it depends on the transitivity assumption — that effect modifiers are distributed similarly across the A-vs-C and B-vs-C comparisons. I could not find an assessment of this: the distributions of likely effect modifiers (baseline severity, age, dose, follow-up) across comparisons are not shown. I'd suggest presenting those distributions (box plots or a table, and/or network meta-regression), assessing local and global incoherence where closed loops exist, and tempering the comparative claim if an effect modifier is imbalanced — because intransitivity, not sampling error, would then drive the indirect estimate." **Output template (NM4 example)**: > "The abstract leads with the SUCRA ranking ('Treatment X ranked best'). A SUCRA/P-score value is a summary of rank probabilities, not a test of superiority, and it is unstable when the underlying estimates are imprecise or based on sparse, low-certainty evidence; a treatment can rank first while its credible interval overlaps several others. I'd suggest reporting the ranking alongside the relative effect estimates and their intervals (a league table) and the CINeMA/GRADE certainty for the key comparisons, and reframing the conclusion around the magnitude and certainty of the X-vs-comparator difference rather than the rank position." -
observational_confounding.md 35.8 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a confounding / design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Observational / Confounding probes (O1–O18) An 18-probe checklist for observational studies (cohort, case-control, cross-sectional, health-screening registry) where the central claim is an exposure–outcome association estimated by adjustment rather than randomization. These probes complement (do not replace) the generic Phase 2 issue checklist and the STROBE reporting items; they target the gap between what a manuscript *says* it adjusted for and what the exposure-stratified data show. O1, O7, and O8 are data-checkable and the highest-yield probes — the self-review skill automates O1 as a deterministic gate (Phase 2.5e, `scripts/check_confounding_completeness.py`, which resolves DB column codes against a prose adjustment set via an alias map) and O8 via `scripts/check_cohort_arithmetic.py --id-col` (records-vs-subjects). O1 (measured-but-unadjusted) and O7 (over-adjustment) are opposite-direction failures — a model can be confounded and over-adjusted at once — so run both. **O1 — Confounding completeness (measured-but-unadjusted)**: - Does the exposure-stratified baseline table (Table 1 by exposure) show covariates that are **significantly imbalanced** across exposure groups (p < 0.05, or a standardized mean difference > 0.1) yet are **absent from the adjustment set**? - A covariate that was measured, is imbalanced by exposure, and is a plausible cause of the outcome is residual confounding by a *measured* variable — the most preventable kind. Common offenders in metabolic / screening cohorts: smoking pack-years, uric acid, HDL, total cholesterol, HbA1c, eGFR. - Is the adjustment set justified (DAG, prior literature, or a pre-specified plan), or is it a short default (age/sex/BMI + a few comorbidities) that silently omits imbalanced labs? - **Exposure-defining-covariate exemption (do not flag, and do not "fix" by adjusting):** when the exposure is a *guideline-defined construct* (MASLD / metabolic syndrome / CKM / sarcopenia / frailty), the components of its own diagnostic criteria (BMI, waist, glycaemia, lipids, blood pressure) are imbalanced *by construction* and correctly absent from the adjustment set. Adjusting for them is **over-adjustment / conditioning on the exposure definition** (probe O7), which attenuates the effect under study, not residual confounding. Do not raise these as O1 Majors; the legitimate remedy for residual confounding is an extended-adjustment model adding only **non-defining prognostic** covariates (e.g. continuous HbA1c beyond the diabetes criterion, frank diabetes, FIB-4), not the defining criteria. The deterministic gate takes `--exposure-defining-vars` and marks these `EXPOSURE_DEFINING_EXEMPT` rather than Major; `check_confounding_completeness.py` also computes the SMD from per-stratum mean ± SD when the Table 1 carries no p / SMD column. - Measured-but-unadjusted imbalanced covariate(s) that are **non-defining plausible causes of the outcome** → MAJOR. Recommend an extended-adjustment sensitivity model that adds the omitted covariates and reports whether the primary estimate is robust; the original model stays primary only if the extended model agrees. **O2 — Adjustment-set provenance (DAG vs Table-1-stepwise)**: - Was the adjustment set chosen by a causal structure (DAG / explicit confounder reasoning) or by a data-driven "include if Table 1 p < 0.05" / stepwise rule? - Data-driven selection risks both directions: **over-adjustment** for mediators or colliders (a variable on the causal path, or a common effect of exposure and outcome, biases the estimate) and **under-adjustment** for a confounder that happens to be balanced in this sample. - No stated rationale for inclusion/exclusion of each adjustment variable → MAJOR (the same model can be confounded and over-adjusted at once). **O3 — Selection / collider bias at enrollment**: - Is the cohort a self-selected or conditioned sample (health-screening attendees, survivors, a registry conditioned on having had the index test) such that enrollment is a collider opening a backdoor path? - Index-event bias (conditioning on a first event), immortal-time bias (exposure defined over a window during which subjects must survive), and prevalent-user bias addressed? - Unaddressed selection/collider structure that could generate the reported association → MAJOR; at minimum require an explicit selection-bias paragraph and, where possible, a sensitivity analysis. - **Name the mechanism correctly — reference-arm contamination ≠ selection/collider bias.** When the *no-exposure reference arm* admixes competing-cause cases (e.g. a "no steatotic liver disease" reference that still contains viral hepatitis, prevalent cirrhosis, or undernutrition), the threat is **comparator-validity / reference-arm contamination** — the reference is not a clean unexposed group — not Hernán-style collider/selection bias from conditioning on enrollment. Mislabeling a contamination argument as selection bias invites a reviewer correction. The fix is to define a clean reference (exclude competing causes) or report a sensitivity analysis restricting the reference, and to frame the limitation as contamination toward the null, not collider opening of a backdoor path. **O4 — Exposure measurement validity**: - Is the exposure a validated/quantitative measure or an unvalidated binary flag (e.g., a single reader's visual call, an ICD code, a self-report) with no in-cohort reliability (κ / ICC) and no severity gradient? - Structural-zero dose covariates: a dose/duration variable anchored to a categorical exposure (never-smoker → pack-years = 0, never-drinker → grams = 0) must be treated as a structural zero, not missing — misclassification here both mismeasures the exposure and (O5) collapses the analytic sample. - Non-differential misclassification biases toward the null (an underpowered null is not reassurance); differential misclassification can bias either way. Binary/unvalidated exposure with no reliability estimate → MAJOR (or a prominent limitation with a quantitative bias argument). **O5 — Missing-data mechanism & complete-case collapse**: - Is the missing-data mechanism (MCAR / MAR / MNAR) stated and justified, with the missingness fraction per key variable reported (ideally by exposure stratum)? - Does a dose/duration covariate (pack-years, cessation duration, alcohol grams) entering a complete-case multivariable model collapse n in the unexposed stratum (structural zeros dropped as missing), distorting subgroup estimates? Report n before and after model fitting. - If multiple imputation is used, are the mechanism assumption, the number of imputations, the imputation model, and a seed reported, and are structural zeros kept out of the imputation? Unjustified MAR for a large missing fraction, or an undisclosed complete-case collapse → MAJOR. **O6 — Residual confounding quantification (E-value)**: - Is an E-value (or a comparable quantitative-bias / negative-control analysis) reported for the **primary** estimate and its confidence limit, so a reader can judge how strong an unmeasured confounder would need to be to explain the association? - An E-value computed for a non-primary, supporting estimate but quoted as if it bounds the primary claim is a provenance error (the E-value must trace to the declared primary contrast). - For a non-null primary association presented as actionable with no residual-confounding quantification → MAJOR (request an E-value at the point estimate and the bound nearest the null); for a null primary, residual confounding is less load-bearing but power (see the power-aware null check) should be addressed instead. **O7 — Over-adjustment (conditioning on a mediator or consequence of the outcome)**: - The mirror image of O1. Adjusting for a variable that is a **mediator** of the exposure–outcome effect, or a **downstream consequence of the outcome itself**, biases the estimate (over-adjustment / collider bias) — and a cross-sectional model is especially exposed because temporal order is not observed, so a consequence of the outcome looks like a baseline covariate. - The signature offender is a lab that the **outcome physiologically drives**: in an eGFR / renal-function outcome model, **serum uric acid** (renally excreted, so low eGFR raises urate), and often blood pressure, HbA1c, or other organ-damage markers, sit on or after the causal path. Adjusting for them removes part of the effect under study. - "Adjust for everything that differs in Table 1" is **not** a confounder-selection rule. Imbalance by exposure justifies considering a variable as a *confounder* only if it plausibly *causes* the outcome and is *not* a consequence/mediator of it; mediators and outcome-consequences must be excluded from the primary adjustment set regardless of how imbalanced they are. - A covariate that is a mediator/consequence and is adjusted in the primary model, where removing it could move the headline estimate → MAJOR. Recommend: (1) a DAG or explicit causal rationale for each adjustment variable, (2) a sensitivity model that **drops** the suspect covariate and reports whether the estimate changes, and (3) a parsimonious, history-/design-based primary model reported alongside the kitchen-sink one, with collinearity (VIF) disclosed. **O8 — Analysis unit & clustering (records vs subjects)**: - Is the **analysis unit** stated — one record per subject, or one row per encounter/visit? Health-screening, EMR, and registry cohorts routinely have repeat attendees, so a record count is not a subject count. - When records > unique subjects, observations are **non-independent**; treating them as independent gives anti-conservative confidence intervals and p-values. A reviewer who has the row count and a subject ID recomputes this immediately. - Is there a one-record-per-subject (e.g. first-visit) **sensitivity analysis**, or a cluster-robust / mixed model that accounts for within-subject correlation? The deterministic gate `scripts/check_cohort_arithmetic.py --id-col <id>` flags `records > subjects` when the manuscript discloses neither the analysis unit nor a per-subject sensitivity. - Undisclosed repeat-subject clustering (no analysis-unit statement, no per-subject sensitivity, no cluster-robust SEs) → MAJOR. The fix is usually cheap (a first-visit sensitivity that reproduces the primary), but the gap must be closed. **O9 — Outcome construct validity (report-/registry-derived outcomes)**: - For outcomes assembled from radiology/pathology reports, ICD codes, or registry flags rather than a prospective adjudicated endpoint, four sub-checks: - **(a) Composite homogeneity** — does a composite union findings that share a causal pathway with the exposure, or is it an "any report-flagged X" pragmatic union of heterogeneous entities (e.g. acute infarction + encephalomalacia of any cause + nonspecific change)? A heterogeneous composite needs per-component reporting, not just the union. - **(b) Ascertainment** — single unblinded read vs adjudicated/duplicate read; is interobserver agreement (κ / ICC) reported? A single unblinded read with no reliability estimate is an ascertainment threat. - **(c) Label provenance** — dictionary-first: a code or label re-interpreted to mean the outcome must trace to the written data dictionary at file/sheet/row granularity, not to a verbal-only confirmation of what someone remembers it meaning. A verbal-only adjudication is an outcome-validity threat. Where the outcome is a multi-rater classification, a low agreement coefficient is not self-explanatory: below roughly κ = 0.40 the manuscript owes a provenance paragraph that quotes the analytic definition and, beside it, the wording the raters were actually shown. The usual cause is a re-classification form that paraphrased or quietly extended the protocol definition, which produces disagreement that looks like rater unreliability and is not. - **(d) Misclassification direction** — is it stated? Non-differential misclassification biases toward the null, which itself **bounds the strength of a negative conclusion** (the null may be partly measurement, not absence of effect); differential misclassification can bias either way. The direction must be argued, not assumed. - A load-bearing outcome with a heterogeneous composite, single unblinded reads with no κ, verbal-only label provenance, or an unstated misclassification direction → MAJOR (or a prominent limitation with a quantitative bias argument when the conclusion is negative). **O10 — Overlapping-subset gradient (descriptive vs inferential)**: - When a manuscript reports an effect-size **gradient or trend across nested / overlapping cohorts or subsets** — a "comparator-refinement gradient" where the exposed arm is held fixed and only the reference group is progressively refined (Frame A ⊃ B ⊃ C; e.g. 144 → 119 → 84 events as competing-cause cases are removed from the reference) — the across-frame shift is **attributable by construction**: the reference changed, not the exposure. The estimates are not independent and there is no contrast being tested. - Inferential language for that gradient — "the association **attenuated significantly** across frames", "the [confounder] **largely accounted for** the effect", "a **stepwise reduction**" — is unsupported without a formal **difference / interaction test**. Two acceptable resolutions: (a) keep the gradient **descriptive** (report each frame's estimate and state that the shift follows by construction from refining the reference, not from a tested difference), or (b) fit **one model** with a frame-membership × exposure interaction (or a formally nested-model comparison) and report that interaction term. - This is the cohort comparator-refinement analogue of the sensitivity-envelope rule in `~/.claude/rules/scope-coherence-gate.md` (a numeric analysis introduced as if estimable/tested without the supporting test). An inferential "attenuated/accounted-for/reduced across overlapping subsets" claim with no difference/interaction test → MAJOR; downgrade to descriptive or add the interaction model. - **A P value comparing an analysed subset with the parent cohort that contains it is invalid, not merely uninformative** — the groups are nested (the subset's patients are *inside* the "full cohort"), so the two-group test has no valid null. This is the RC5 baseline-testing failure's observational sibling, and here it is worse than uninformative. The shape appears when authors answer a selection-bias comment with a "representativeness of the analysed subset" table (full cohort n vs subset n, with P values). Judge representativeness by the **magnitude** of the difference; if a test is genuinely wanted, the valid contrast is **subset vs remainder** (n_full − n_subset), which is disjoint. Flag any subset-vs-parent P as MAJOR (the reported P is uninterpretable). The self-review sibling automates this as a deterministic gate (`scripts/check_nested_group_comparison.py`); on the reviewer side, do not *request* such a table either — ask for the subset's characteristics (disclosure) and read representativeness off the magnitudes. **O11 — Complex-survey design & weighting (NHANES / KNHANES / CHNS and other multistage probability samples)**: - When the data are a **complex probability survey** (NHANES, KNHANES, CHNS, BRFSS, national health-examination surveys), unweighted or naively-weighted analysis is wrong. Check that the analysis is **design-based**: the **sampling weight** is applied AND the **stratification** (e.g. NHANES `SDMVSTRA`) and **primary sampling unit / cluster** (`SDMVPSU`) are declared so that variances use Taylor linearization or replicate weights — a weighted point estimate with **model-based (unclustered) standard errors** still understates uncertainty. - **Correct weight, correctly scaled.** Is the right weight used for the variables analysed (NHANES MEC-examination `WTMEC*` for examination/lab variables vs interview `WTINT*` for interview-only), and when **multiple cycles are pooled, is the weight divided by the number of cycles** (a frequent error that distorts the effective population)? Name the cycles and the weight variable. - **Subpopulation, not subset.** A restricted analysis (e.g. adults only, one sex, a disease subgroup) must be a **domain/subpopulation analysis that keeps the full design** (`svy: , subpop()` / `subset=` design object), **not a row-deleted dataset** refit — deleting rows discards strata/PSU information and gives wrong SEs and design df. Check the Methods/code for `subpop`/domain handling rather than a filtered data frame. - **Report the design honestly.** Weighted vs unweighted N (the unweighted n is the sample; the weighted figure is a population estimate — do not call the weighted total a sample size), design-effect or effective sample size where it matters, and whether unweighted and weighted estimates diverge (large divergence signals weight-dependent selection worth discussing). - An analysis that ignores the weights, applies the weight without strata/PSU (model-based SEs), uses the wrong/unscaled weight, or does a subgroup by row-deletion → MAJOR (the headline CI/p-value is not valid for the target population). Thin "weighted logistic regression was used" with no strata/PSU/weight-variable statement → at least a reporting MAJOR until the design is specified. **O12 — Data-driven threshold / non-linearity mining ("inflection point" / "saturation effect")**: - A common move in cross-sectional index-exposure papers: fit a restricted cubic spline, declare "a non-linear / L-shaped / saturation relationship," then run a **two-piecewise (segmented) / "threshold-effect" model whose breakpoint is found by a recursive search over the data**, and report a single **inflection point** (e.g. "2400 MET-min/week", "AIP 0.x"). Two problems: (a) the breakpoint was **estimated from the same data**, so it needs a **confidence interval** (and is often unstable across cycles/subgroups), not a point quoted as if known; (b) searching the knot is a **multiplicity / overfitting** operation — the "saturation" can be an artifact of the search. - Is the non-linearity claim supported by a **pre-specified** test (RCS with a likelihood-ratio / Wald test of the non-linear terms against the linear model, knots at pre-stated percentiles), rather than only the visual spline shape? Is the threshold model's improvement over the linear model formally tested (the LRT the "threshold effect analysis" should report), and is the breakpoint given **with uncertainty** and a **sensitivity** check (does it move under reasonable alternative knots / in a holdout cycle)? - A reported single inflection point with no breakpoint CI, no pre-specification, and no multiplicity acknowledgement, used to anchor a clinical "target/cutoff" claim → MAJOR (the cutoff is data-mined). Recommend: pre-specify the non-linearity test, report the breakpoint with a CI, frame it as exploratory, and verify stability. Cross-link `~/.claude/rules/scope-coherence-gate.md` (a searched cutoff is not a validated threshold) and the survival cutoff-optimism probe (S4 in `survival_prognostic.md`). **O13 — Cross-sectional mediation (temporal order & sequential ignorability)**: - A **mediation / indirect-effect** claim (Baron–Kenny, Sobel, a PROCESS or bootstrapped a×b indirect effect, KHB) estimated on **single-timepoint cross-sectional** data cannot establish the X → M → Y *sequence*: exposure, mediator, and outcome are measured at the same moment, so the assumed temporal ordering is by assertion. The same data are typically just as consistent with M → X, a reversed Y → M path, or a shared common cause. Reporting a "mediating role" / "X affects Y through M" as if the chain were demonstrated is a design–claim mismatch. - A **significant bootstrapped indirect-effect CI does not fix this.** The bootstrap quantifies sampling variability of the a×b product; it says nothing about whether the estimand is *identified*. Mediation identification additionally requires **no unmeasured mediator–outcome confounding** (sequential ignorability) — which holds essentially never in observational cross-sectional data, even when the exposure is clean. Is a **sensitivity analysis** for unmeasured M–Y confounding reported (e.g. an E-value for the indirect effect, or a rho-/correlation-based mediation sensitivity), or is the indirect effect presented as established? - **Proportion mediated** is unstable when the total effect is small or near-null (it can exceed 100% or flip sign); a headline "X% of the effect was mediated" needs the total effect to be well-estimated and the proportion reported with uncertainty. - A cross-sectional mediation presented as a causal chain with **no temporal-order caveat** and **no sequential-ignorability / unmeasured-confounding sensitivity analysis** → MAJOR. Reframe to association-level ("the pattern is *consistent with* mediation"), state plainly that a single-timepoint design cannot order X/M/Y, add the M–Y confounding sensitivity analysis, and reserve the causal-mediation claim for a design that measures M before Y (longitudinal / two-wave). Report against **AGReMA** (the mediation-analysis reporting guideline). Cross-link the cross-sectional → prognostic/temporal scope rule (`scope-coherence-gate.md`). **O14 — Interaction scale (additive vs multiplicative) for joint-effect / synergy claims**: - A **joint-effect / synergy / "combined exposure" / effect-modification** claim is scale-dependent, and the scale that matters for **public-health / biological synergy** is the **additive** one. A synergy claim therefore needs **RERI** (relative excess risk due to interaction), **AP** (attributable proportion), or **S** (synergy index) — each **with a confidence interval** — not only a multiplicative OR/HR product term. A non-significant *multiplicative* interaction is fully compatible with a large, significant *additive* interaction (and vice versa); reporting one scale and concluding "(no) synergy / interaction" is scale-conflation. - **"Joint association" by a combined multi-level exposure variable** (e.g. high-PA+high-diet vs low+low, four categories) describes the joint *categories* but does **not test interaction** — the joint-vs-referent OR is not an interaction estimate. An interaction claim needs the product term (multiplicative) and/or RERI/AP/S (additive), not just the four joint-category ORs. - **Stratified-only** "the association was stronger in stratum A than B / exercise mitigated the exposure effect" is the difference-in-significance fallacy applied to effect modification (see the §C interaction-anchor / difference-in-significance checks): it needs the formal interaction parameter on a stated scale, plus the additive measures for a synergy/mitigation claim. - A synergy / joint-effect / effect-modification claim resting on a **multiplicative-only** interaction, on **joint-category ORs**, or on **stratified estimates** with **no additive-scale measure (RERI/AP/S with CI)** → MAJOR for a synergy/public-health claim (downgrade to the scale actually tested, or add RERI/AP/S). Follow the Knol & VanderWeele interaction-reporting recommendations and report the chosen scale explicitly. Cross-link `analyze-stats` (interaction-scale reporting) and the §C interaction-anchor discipline. **O15 — Selection on modality / procedure availability (spectrum bias distinct from generalizability)**: - When the **analytic cohort is defined by the availability of an optional input or procedure** — an exotic/optional acquisition (raw signal, a special MR sequence, contrast obtained in only a subset), or a procedure that only some patients underwent (endarterectomy-only, biopsy-only, surgery-confirmed-only) — the sample is selected on a non-random availability mechanism. This is a **selection / spectrum bias** that biases the estimates, not merely a "generalizability" caveat: patients who get the optional modality/procedure differ systematically from the source population. - A panel that files this under "limited generalizability" under-names it. Require: (a) **consecutive / all-eligible enrollment** rather than availability-gated inclusion; (b) a **comparison of the analyzed subset vs the source/eligible population** (Table-1-style) to show how selection operated; (c) framing of the estimand as conditional on that selected spectrum if it cannot be removed. - Severity: MAJOR when availability gating plausibly drives the headline estimate (verification/spectrum bias); MINOR when the subset is shown comparable to the source population. Cross-link D2 (spectrum/sampling) for the diagnostic-accuracy case and O3 (collider/selection at enrollment). **O16 — Serial-imaging size/growth endpoint: which-lesion-tracked + multiplicity**: - For a longitudinal imaging endpoint defined by a size change or threshold crossing (e.g., "max diameter crossing 10 mm", "≥2 mm growth", RECIST-style progression) in a cohort where subjects can have **multiple lesions** (polyps, nodules, cysts, masses), the Methods must state the **lesion-tracking rule**: is the *same* index lesion followed over time, or is the endpoint the *largest lesion at each visit*? Under multiplicity, a "crossing" can reflect a **different lesion**, misclassifying it as progression of the original. - Require: (a) an explicit per-subject lesion-tracking / index-lesion rule; (b) the **prevalence of multiplicity** in the cohort (how many subjects had >1 lesion); (c) a **solitary-lesion sensitivity analysis** restricting to subjects with a single lesion, where tracking is unambiguous. - Absent a stated tracking rule in a multi-lesion cohort → MAJOR (the progression endpoint is ill-defined). When multiplicity is low and a solitary-lesion sensitivity reproduces the result, MINOR. **O17 — Many-exposure agnostic scan: multiplicity, replication, and selective reporting (ExWAS / EWAS / MWAS / proteome-/metabolome-/nutrient-wide)**: - When the design tests **many exposures at once against one (or a few) outcome(s)** — an exposome-/environment-/metabolome-/proteome-/nutrient-wide association scan (ExWAS / EWAS / MWAS), or any "we screened N candidate predictors" pass — the inferential threat is **multiplicity across the exposure axis**, distinct from the single-exposure threshold/non-linearity mining in O12. A hit quoted at nominal p < 0.05 out of dozens-to-thousands of tested exposures is uninterpretable; the questions are what multiplicity control was applied, against what denominator, and whether the hit survived independent replication. - **(a) Correction declared, matched to the claim, against the true denominator.** Is a multiple-testing method stated AND is the **number of tested exposures/models reported as the denominator** so the reader can judge it? Family-wise control (Bonferroni, or a permutation-based exposome-/metabolome-wide significance level) fits a *confirmatory single-hit* claim; FDR (Benjamini–Hochberg q-value) fits *discovery/screening* and must then be framed as hypothesis-generating, not confirmatory. The denominator must be honest — count **every** exposure tested (including ones too sparse to model well), not only the survivors. A scan reporting raw p < 0.05 hits with no correction, or quietly shrinking the denominator to the favoured hits → MAJOR. - **(b) Independent replication is the real safeguard, not the correction alone.** Agnostic scans carry a high false-discovery proportion even after FDR; the load-bearing control is an **independent replication** — a held-out split, a different cohort, or a different survey cycle — requiring **directional concordance** and a reported **replication rate** (how many discovery hits replicated, e.g. "110/164 replicated", "59% Bonferroni-surviving and concordant"). A single-cohort FDR-significant scan with no replication is hypothesis-generating only; presenting its top hits as established → MAJOR (downgrade to exploratory or add replication). A held-out **validation** set must be used once, for the final model, not reused as a second discovery pass. - **(c) Correlated exposures — Bonferroni conservatism and co-exposure confounding.** Exposome/omics exposures are heavily intercorrelated, with two consequences: (i) Bonferroni on the raw count is **over-conservative** (the effective number of independent tests is smaller — a permutation threshold or an effective-number adjustment is more honest), and (ii) a **univariate** hit can be a *marker for a correlated true cause* rather than itself causal, because the single-exposure model ignores co-exposure confounding / mixtures. Is the correlation structure addressed (effective-number-of-tests, clustering / dimension reduction, or a multi-exposure / mixture model), and is a univariate hit read as a *screen* rather than an isolated causal effect? A causal/actionable claim from one univariate hit inside a correlated exposure block, with no co-exposure adjustment → MAJOR. - **(d) Selective top-hit reporting / hidden multiple testing (HARKing).** The **full tested-exposure set and the full results** (every effect size + p/q, not just the significant rows) must be available — ideally pre-registered, at minimum in a supplement. Reporting only the significant hits, or re-narrating the scan as a "focused a-priori hypothesis" about the winners after seeing the results, is selective reporting / hidden multiplicity. Nominal (uncorrected) hits may be shown to inform future work only if **explicitly labelled as not surviving correction**. A Results section that presents only winners, with no full results table and no pre-registration → MAJOR (reporting). **O18 — Pseudoreplication in multi-rater agreement / reader studies (pooled pairwise vs per-subject)**: - When an agreement or reader study computes a test (Mann–Whitney, t-test, correlation) on **pooled pairwise distances / reader-pairs** rather than on **independent units** (subjects, lesions, aneurysms), the effective n is inflated (each subject contributes several pairwise rows) and the p-value is anti-conservative. This is the reader/agreement sibling of the analysis-unit / clustering issue in O8: the number of *observations* is not the number of *independent units*. - Lead: when a reported test **n exceeds the number of independent subjects/lesions** (e.g. n=448 or 672 pairwise from 112 aneurysms) and no clustering / mixed-effects / per-subject aggregation is stated → flag. Ask for the analysis re-run at the **per-subject** level (or a mixed model with a subject random effect); a pooled-pairwise p can flip (e.g. p=0.02 pooled → p=0.59 per-aneurysm). - Severity: MAJOR when a headline agreement/superiority claim rests on the pooled-pairwise test; MINOR when a per-subject sensitivity reproduces it. Applies to all multi-rater agreement / MRMC reader studies. - Produce the fix: `analyze-stats` `references/analysis_guides/agreement_reliability.md` has the pseudoreplication-safe per-subject aggregation + subject-random-effect ICC code. - **(e) Effect size and resolution honesty at scale.** With the very large N these scans run on, trivially small effects clear any threshold; report **effect magnitudes** and clinical relevance alongside significance, and respect the **resolution floor** (a permutation procedure with k permutations cannot resolve p below ≈ 1/k; FDR has a minimum detectable q at a given hit count). A "number of significant exposures" headline with no effect sizes overstates the finding. - Severity: MAJOR when a headline causal/actionable claim rests on uncorrected, single-cohort-only, or univariate-in-a-correlated-block top hits; MINOR when correction + replication + full results are present and the claim is framed as screening. Cross-link O11 (complex-survey design — an NHANES/KNHANES ExWAS must combine design-based standard errors with the multiplicity correction, not one or the other), O12 (the single-exposure threshold/non-linearity analogue), and O2/O7 (confounding / over-adjustment for whichever hit survives). Report the tested-set size, the correction method, and the replication design explicitly. **Output template (O1 example)**: > "Table 1 shows that uric acid (p < 0.001), smoking pack-years (p = 0.001), HDL (p < 0.001), total cholesterol (p = 0.010), and HbA1c (p < 0.001) differ significantly across exposure groups, but the multivariable model adjusts only for age, sex, BMI, hypertension, and diabetes. Because these imbalanced laboratory covariates are plausible causes of the outcome, the reported association may carry residual confounding by measured variables. I'd suggest reporting an extended-adjustment sensitivity model that adds the imbalanced covariates and stating whether the primary estimate is materially unchanged; if the extended model attenuates the association, that should be reflected in the Abstract and Conclusions." **Output template (O5 example)**: > "The multivariable model appears to be complete-case, and pack-years is included as a continuous covariate. Because never-smokers carry a structural zero rather than a measured value, complete-case deletion can drop a large share of the unexposed stratum (here the analytic n falls from 5,203 to 1,993, with the female subgroup reduced to n ≈ 58), which distorts the subgroup estimates. I'd suggest adjusting for smoking status (never/former/current) rather than pack-years, reserving pack-years for an ever-smoker-restricted secondary analysis, and reporting the missingness fraction by exposure stratum with the MCAR/MAR/MNAR rationale." **Output template (O7 example)**: > "The outcome is eGFR, and the multivariable model adjusts for serum uric acid. Because urate is renally excreted, a lower eGFR mechanically raises uric acid, so uric acid is a consequence of the outcome rather than a confounder of the exposure–eGFR association; adjusting for it is over-adjustment and can attenuate the very effect under study. Imbalance in Table 1 is not sufficient justification — the selection criterion should be whether a variable plausibly *causes* the outcome and is not on or after the causal path. I'd suggest a DAG-justified primary model that excludes uric acid (and any other outcome-consequence such as blood pressure or HbA1c if similarly downstream), with the current model reported as a sensitivity analysis and collinearity (VIF) disclosed." **Output template (O17 example)**: > "The Methods describe an exposome-wide scan of 164 exposures against mortality, but the Results lead with the strongest associations and the full tested set is not tabulated. With 164 tests this is uninterpretable without a stated correction and its denominator, and a single-cohort scan — even after FDR — carries a high false-discovery proportion. I'd suggest (1) reporting the multiple-testing method (an FDR q-value for a discovery framing, or a permutation-based exposome-wide threshold for a confirmatory one) together with the number of exposures tested; (2) an independent replication (a held-out split or a second cohort/cycle) requiring directional concordance, with the replication rate reported; and (3) a supplementary table of all tested exposures with effect sizes and q-values, framing the surviving associations as screening rather than established effects. Because the exposures are correlated, a top univariate hit should be read as a marker pending a co-exposure/mixture model, not as an isolated causal effect." ## When this module does not apply These probes are out of scope for: - Randomized trials (confounding controlled by design → Phase 2 + CONSORT) - Purely descriptive / prevalence reports with no adjusted association claim - Diagnostic-accuracy studies with no exposure–outcome estimand (→ Phase 2A DTA cells + categories A–C) Moved here from the consuming skill so the scope travels with the probes. -
polygenic_risk_score.md 9.5 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; an ancestry-portability / incremental-value / overfitting design flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Polygenic risk score probes (PG1–PG8) An 8-probe checklist for **polygenic risk score / polygenic score (PRS / PGS)** studies — a genome-wide weighted sum of trait-associated alleles used as a predictor or risk-stratifier. These probes complement (do not replace) the generic Phase 2 checklist, the **TRIPOD+AI / TRIPOD** prediction-model items, the **PGS Reporting Standards (PGS-RS)**, and the clinical-prediction-model probes (CP1–CP4 in `clinical_prediction_model.md`); they target the failure modes a PRS adds on top of a generic prediction model — **ancestry transferability, base/target leakage, incremental value over established clinical risk, and the gap between discrimination and clinical/screening utility**. PG1 (ancestry portability) and PG4 (incremental value) are the highest-yield; run them first. This is distinct from the instrumental-variable use of genetics in `mendelian_randomization.md` (PRS is *prediction*, MR is *causal inference*). **PG1 — Ancestry transferability / portability**: - Is the **ancestry of the discovery (base) GWAS** stated, and is PRS performance **reported separately for each target ancestry** rather than pooled or assumed? A PRS trained in European-ancestry GWAS systematically **underperforms in non-European ancestries** (lower R²/accuracy) because of **LD differences, allele-frequency differences (drift/selection), and gene–environment interactions on causal effect sizes** — and the loss can be large. - Differences **within** broad ancestry groups (e.g. across African regional ancestries) can be **as large as across continental groups**, so a single "African" performance figure can itself mislead. - Was multi-ancestry / ancestry-matched discovery used, or is a single-ancestry score generalized? A deployment / clinical-utility claim extended to ancestries in which the score was not validated → MAJOR (portability failure **and** an equity harm; cross-link the fairness/equity probes EQ in `equity_fairness.md`). The fix is per-ancestry validation (and ancestry-matched or multi-ancestry training), not a generalizability caveat alone. **PG2 — Base/target independence, tuning, and overfitting**: - Are the **discovery (base) GWAS** and the **target/validation** samples **independent**? Overlap (e.g. the same biobank in both) inflates the apparent performance. Was any **tuning** (P-value threshold / clumping, LDpred or PRS-CS shrinkage / proportion-of-causal-variants, the chosen quantile cut) done on a **separate tuning set** and the final score evaluated **out-of-sample**? - Reporting the **best-tuned** configuration's performance **in the same data** used to pick it is overfitting; **winner's curse** similarly inflates if instruments/weights and evaluation share a sample. - Undisclosed base/target overlap, tuning-and-evaluating in the same data, or no independent/out-of-cohort validation → MAJOR. **PG3 — Score construction & effect-size reporting**: - Is the PRS **construction reproducible** — variant set / GWAS source (a **PGS Catalog ID** where available), weights, build, allele alignment/strand handling, and how missing genotypes/imputation were treated? - Is the score **standardized** and the effect reported as **OR/HR per SD** with a CI, plus **quantile stratification** (decile/percentile, with the **reference group** stated) and, for a clinical claim, **absolute risk** by stratum — not relative risk alone? - A black-box score with no construction detail, or relative-risk-only reporting with no per-SD CI / absolute risk → MAJOR (reporting), MINOR if only the PGS Catalog ID is missing. **PG4 — Incremental value over established clinical risk (the clinical crux)**: - A PRS clinical claim is **not** supported by **PRS-alone AUC**. Does the study report the PRS **on top of the guideline-recommended clinical model** (SCORE2 / QRISK3 / Pooled Cohort Equations / Tyrer-Cuzick / FRAX as appropriate) — the **change in discrimination (ΔC-statistic with a CI)**, **NRI/IDI**, and ideally net benefit — versus the clinical model alone? - A large **HR/OR per SD** or a high **PRS-alone** AUC presented as "improves prediction" without the incremental-over-clinical comparison overstates utility; the right question is what the PRS adds to what a clinician already has. - An "improves risk prediction / clinically useful" claim resting on PRS-alone discrimination, or on a ΔC with no CI, → MAJOR (downgrade to "associated with" or add the incremental-value analysis against the clinical model). **PG5 — Prevalent vs incident, and study design**: - Is the validation **prospective/incident** (predicting future events in a cohort) or a **cross-sectional case–control prevalent-disease** association? A PRS associates strongly with **prevalent** disease but typically predicts **incident** disease less well — and incident prediction is what a clinical-utility claim requires. - Case–control sampling, survival/prevalence–incidence bias, and immortal-time issues (cf. the observational probes) apply. A prediction/utility claim from a prevalent case–control design with no incident-cohort validation → MAJOR. **PG6 — Screening / stratification utility ≠ discrimination**: - A **population-screening or risk-stratification** claim needs the **operating-characteristic** evidence, not AUC: the **detection rate at a fixed false-positive rate** (or the likelihood ratio for a given PRS quantile), and the **number needed to screen / absolute risk difference** at the proposed cut. Discrimination (AUC) and HR-per-SD routinely look favourable while the **detection rate at an acceptable FPR is poor** — the two are different questions. - A "useful for population screening / would justify earlier screening in the top decile" claim with only AUC/HR-per-SD and no detection-rate-at-FPR / likelihood-ratio evidence → MAJOR. **PG7 — Calibration & absolute risk in the target population**: - Discrimination is **not** calibration. Is the PRS-based (or PRS-plus-clinical) **absolute risk calibrated in the target population** (calibration plot / slope-intercept, observed-vs-expected by stratum), especially when transported **across ancestries or cohorts** where baseline incidence and allele frequencies differ? - A well-discriminating but **miscalibrated** score gives wrong absolute risks at the individual level. An absolute-risk / actionable-threshold claim with no calibration in the target population → MAJOR (cross-link CP1 apparent-vs-corrected calibration in `clinical_prediction_model.md`). **PG8 — Reporting standards & clinical-actionability overclaim**: - Is the study reported against **PGS-RS** (PGS Reporting Standards) / TRIPOD+AI — development and validation samples with ancestry composition, score-construction provenance, and the full performance set (discrimination, calibration, incremental value)? - Is the conclusion scaled to the evidence — a **relative-risk gradient across quantiles is not, by itself, demonstrated clinical actionability**? A "once-in-a-lifetime test to guide management" / "should be added to guidelines" claim requires incremental value (PG4), calibration (PG7), screening operating characteristics (PG6), and ideally decision-analytic or trial evidence, not association alone. - A clinical-deployment / guideline-adoption recommendation outrunning the validation, or reporting that omits PGS-RS-level ancestry/provenance/performance detail → MAJOR for the interpretation/headline, MINOR for a reporting-only gap. **Output template (PG1 example)**: > "The polygenic score was developed and validated in participants of European ancestry, but the Abstract and Conclusions frame it as a tool for risk stratification in the general population. Because polygenic scores transfer poorly across ancestries — owing to differences in linkage disequilibrium, allele frequencies, and gene–environment interactions — performance in non-European ancestries cannot be assumed and is often substantially lower, which also raises an equity concern. I'd suggest reporting performance (R²/discrimination and calibration) separately for each ancestry available, training on ancestry-matched or multi-ancestry GWAS where possible, and scoping the deployment claim to the validated population unless per-ancestry performance is shown." **Output template (PG4 example)**: > "The clinical claim rests on the polygenic score's standalone discrimination (AUC and HR per SD). Because clinicians already risk-stratify with an established model, the decision-relevant question is what the score adds *on top of* that model. I'd suggest reporting the change in the C-statistic (with a confidence interval) and the NRI/IDI when the polygenic score is added to the guideline-recommended clinical risk score (e.g. SCORE2 / Pooled Cohort Equations / Tyrer-Cuzick), and ideally a decision-curve/net-benefit analysis, rather than the score-alone AUC; if the incremental gain is small or its CI includes no improvement, the Abstract and Conclusions should be revised accordingly." -
qualitative_research.md 6.8 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a reflexivity / analysis-transparency / over-claim flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Qualitative research probes (QL1–QL8) An 8-probe checklist for **qualitative studies** — in-depth interviews, focus groups, observation/ethnography, document analysis, grounded theory, phenomenology, narrative research. These probes complement (do not replace) the generic Phase 2 checklist and the qualitative reporting standards — **COREQ** (interviews/focus groups; Tong et al. 2007) and **SRQR** (all qualitative approaches; O'Brien et al. 2014). They target what makes qualitative rigour distinct from quantitative validity: researcher **reflexivity**, a transparent **analysis** process, **trustworthiness** (credibility/dependability/confirmability/transferability) rather than statistical validity, and findings **grounded in quoted data** — and they guard the most common mis-calibration on both sides: applying quantitative yardsticks (sample-size power, statistical generalizability, p-values) to qualitative work. QL2 (reflexivity), QL5 (analysis transparency), and QL6 (trustworthiness, not statistical validity) are the highest-yield; run them first. **QL1 — Approach/paradigm fit and research question**: - Is a **qualitative** approach justified for the question — exploring meaning, experience, process, or context, rather than measuring frequency/effect (which would be a quantitative design)? - Is a **named approach** (grounded theory, phenomenology, ethnography, case study, narrative) and guiding paradigm stated **with a rationale**, rather than an unspecified "qualitative study" / "thematic analysis" with no methodological orientation? - A question that is really quantitative (prevalence/effect) answered with a few interviews, or no named methodological orientation behind the analysis → MAJOR (method–question mismatch) / MINOR (unnamed approach). **QL2 — Reflexivity and researcher positioning**: - Are the **researcher's characteristics** reported — who collected the data, their credentials/role, experience/training, and **prior relationship to participants** — and is there reflexive consideration of how their assumptions/position may have shaped data collection and interpretation? - Reflexivity is a core qualitative-rigour requirement (COREQ Domain 1; SRQR item 6) and the **single most-omitted** element. - An interview/focus-group study with no reflexivity / no statement of who interviewed and their relationship to participants → MAJOR (or MINOR if partially addressed). **QL3 — Sampling logic and adequacy (purposive; information power / saturation)**: - Is the **sampling strategy** stated and justified — **purposive / theoretical / maximum-variation** logic appropriate to the question, not an unexplained convenience sample — and is there an argument for **when sampling stopped** (data **saturation** or information power)? - A **small sample is not a flaw** in qualitative work; the flaw is an *unjustified* sample with no purposive rationale and no saturation/information-power discussion. - Convenience sample presented with no rationale, or no account of sampling adequacy/saturation behind broad thematic claims → MINOR / MAJOR per centrality. **QL4 — Data-collection rigour**: - Are the **data-collection methods** described in enough detail to judge them — the **interview/topic guide** (and whether it was piloted/iterated), the **setting**, **recording and transcription**, **field notes**, and interview/focus-group **duration**? - Thinly reported data collection (no guide, unclear recording/transcription, no setting) that undercuts interpretability → MINOR. **QL5 — Analysis transparency and audit trail**: - Is the **analysis process** transparent — how many **coders**, the **coding framework/tree**, whether themes were **derived inductively or applied a priori**, any **software**, and an **audit trail**? - "Themes **emerged** from the data" with no described coding/analytic process is a black box. - An analysis with no described coding process / no audit trail behind the reported themes → MAJOR (analysis not reproducible/auditable). **QL6 — Trustworthiness, NOT statistical validity (the calibration trap)**: - Are **trustworthiness** techniques reported and matched to the four criteria — **credibility** (member checking, triangulation, prolonged engagement), **dependability** (audit trail), **confirmability** (reflexivity), **transferability** (thick description) — rather than quantitative "reliability/validity"? - The trap is bidirectional: (a) a **reviewer** must **not** demand a power calculation, a "representative" sample, statistical generalizability, or treat inter-coder κ as the sole truth — these are quantitative yardsticks inappropriate to qualitative work; (b) **authors** must **not** claim statistical generalizability or dress qualitative findings in quantitative certainty. - Missing trustworthiness strategies entirely, or quantitative-validity language misapplied (by either side) → MAJOR / MINOR per how load-bearing it is. **QL7 — Findings grounded in data (quotations, thick description, deviant cases)**: - Are the themes **substantiated by participant quotations / excerpts** (with participant identifiers), with enough **thick description** to let the reader judge the interpretation, and is there **consistency between the data and the findings**? - Are **negative / deviant cases** and minor themes considered, not just confirmatory exemplars? - Asserted themes with no quoted evidence, or interpretation not traceable to the data → MAJOR; cherry-picked confirmatory quotes with no deviant-case consideration → MINOR. **QL8 — Ethics, interpretive scope, and reporting standard**: - Are **ethics** reported (IRB approval/consent; confidentiality and de-identification of identifiable narrative quotes), and does interpretation **stay within what qualitative data support** — no **causal, effectiveness, prevalence, or population-level** claims, and no over-generalisation beyond the studied context (**transferability**, not generalizability)? - Is the study mapped to the appropriate reporting standard — **COREQ** (interviews/focus groups) or **SRQR** (broader qualitative)? - Causal/quantitative/population over-claiming from qualitative data, missing consent/de-identification for identifiable quotes, or no reporting-standard mapping → MAJOR (over-claim / ethics) / MINOR (reporting). -
radiomics.md 5.7 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a task- or design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Radiomics / Feature-Reproducibility probes (R1–R4) A 4-probe checklist for radiomic feature reliability/reproducibility, acquisition–reconstruction parameter sweeps, and reliability/harmonization-based feature filtering claims. These probes complement (do not replace) the generic Phase 2 issue checklist. Their purpose is to keep design-level structural validity from being under-weighted: a review can correctly flag the reporting-layer issues (an over-claiming Abstract, a small external cohort) yet still miss whether the central contribution holds, which softens the assessment by one notch. **R1 — Design-grid circularity (in-domain "prediction" tautology)**: - Is an outcome (e.g., feature reliability) predicted from the very grid parameters that were systematically/exhaustively varied to construct the dataset? - If so, a high in-domain R² / accuracy is structurally guaranteed by the design ("predicting the construction recipe"), not a discovered relationship — do the predictors simply index the axes of the design grid? - Does the manuscript frame in-domain performance as a finding/success and lead the Abstract/Key Points with it? - If yes → do **not** endorse the in-domain success. Recommend reframing so the substantive finding is the cross-domain transportability (and, where present, its failure). MAJOR candidate. **R2 — Construct validity / proxy-target gap**: - The clinical rationale typically assumes that features which are reliable/stable/robust in the phantom are also better predictors of a biological/clinical target. A feature can be perfectly stable and biologically uninformative — this link is not logically guaranteed. - Is any post-filter performance gain shown to be signal recovery, rather than a by-product of removing a degraded/misaligned baseline feature space? - Does the manuscript acknowledge and test the orthogonality of the proxy (reliability) and the target (outcome)? Absent → MAJOR candidate. **R3 — Transportability framing vs reporting issue**: - When cross-phantom / cross-scanner / cross-center failure (negative R² on the target domain, low Jaccard overlap of selected features, calibration slope < 1) is the substantive result, is it nonetheless framed as a generalization success in the Abstract/Key Points/Conclusion? - Does the Results text state explicitly that a negative R² on the target domain means the model performs worse than predicting the mean (i.e., the mapping does not transport), rather than reading it as a weak continuous performance metric? - **Calibration link**: if in-domain "success" is partly a design artifact and the cross-domain result is a failure, reframing the Abstract will not rescue the central contribution. This is a design-level finding, not a reporting fix — keep its severity at the design level and do not soften it to a reporting issue. **R4 — Multiplicity (model × threshold / model × cohort grid)**: - Are multiple classifiers × multiple reliability thresholds (or cohorts) compared with one-sided tests, with a few reaching p < 0.05? - Is multiple-testing correction applied, and is the expected number of false positives by chance named explicitly (e.g., "5 models × 3 thresholds = 15 tests, ≈1 expected false positive")? Do not defer this to a generic "statistical review needed." - For a small external cohort (n ≤ ~30), do bootstrap ΔAUC intervals cross zero? If so, restrict any headline-gain claim accordingly (e.g., to a single classifier family in a small cohort). **Output template (R1 example)**: > "Because the acquisition parameters were varied as a systematic factorial grid, a model that predicts feature reliability from those same parameters is largely recovering the grid by construction; the in-domain R² ≈ 1.0 therefore reflects design structure rather than a discovered relationship. I'd suggest reframing the Abstract and Key Points so the substantive finding is the cross-phantom/cross-scanner transportability (and its failure), and stating explicitly in the Results that a negative R² on the target domain means the model performs worse than predicting the mean — i.e., the reliability mapping does not transport." **Output template (R4 example)**: > "The reported gains come from a grid of [N models] × [M thresholds] one-sided comparisons; with [N×M] tests, roughly one positive is expected by chance alone, and the external cohort (n = [k]) yields bootstrap ΔAUC intervals that cross zero for several thresholds. I'd suggest reporting a multiplicity-adjusted analysis (or stating the expected false-positive count), restricting the headline claim to the classifier family that survives, and marking the ΔAUC intervals that cross zero in the figure." ## When this module does not apply These probes are out of scope for: - Single fixed-protocol radiomic model with no parameter sweep and no reliability-filtering claim - Pure deep-learning end-to-end imaging model (handcrafted feature reproducibility not at issue) - Replication of a documented prior radiomic pipeline with no new reliability/transportability claim Moved here from the consuming skill so the scope travels with the probes. -
rct_trial.md 9.1 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a design-level flaw (concealment, unblinding, non-ITT primary) is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # RCT / Intervention-Trial probes (RC0–RC7) A checklist for **randomised controlled trials** (parallel-group, crossover, cluster, stepped-wedge) where the claim is that an intervention *causes* an outcome difference. These probes complement (do not replace) the generic Phase 2 issue checklist and the CONSORT reporting items; they target the threats randomisation is supposed to remove but reporting can hide. Run RC0 first. **RC0 — Registration & pre-specified-primary gate (run before RC1–RC7)**: - Find the trial registration (ClinicalTrials.gov / ISRCTN / registry ID) and the **pre-specified primary outcome + timepoint**. If absent, that is itself a MAJOR (prospective registration is a baseline expectation for a trial). - Compare the registered primary to the **reported** primary. A switch (a registered secondary reported as primary, the registered primary demoted/dropped, the timepoint/metric changed) without a dated, justified amendment is a MAJOR — see `exemplar_reviews/selective_outcome_reporting.md`. **RC1 — Randomisation sequence + allocation concealment**: - Is the **sequence generation** described (computer random, blocked, stratified) and, separately, is **allocation concealment** described (central/pharmacy randomisation, sequentially-numbered opaque sealed envelopes)? These are different items; concealment prevents foreknowledge of the next assignment. - Inadequate or unstated concealment → selection bias the analysis cannot fix → MAJOR (or a request to clarify if merely under-reported). **RC2 — Blinding & functional unblinding**: - State **who** was blinded (participants, providers, outcome assessors, analysts). For a **subjective primary outcome**, outcome-assessor (or self-report) blinding is critical. - **Functional unblinding**: even a nominally blinded trial can be effectively unblinded when the active arm produces unmistakable effects (a drug with obvious side-effects, an arm with a different experience/intensity) — flag this when the design implies it, and ask for any blinding-success check (guess-the-arm). An open-label trial with a subjective outcome is a MAJOR interpretive limit, not a footnote. **RC3 — ITT primary + missing-data handling**: - Is the **primary analysis intention-to-treat** (all randomised, as randomised)? A per-protocol or completers-only primary breaks randomisation and is a MAJOR; per-protocol belongs as a **sensitivity** analysis. - How were dropouts/missing data handled? Prefer a principled approach (mixed models under MAR, multiple imputation) over **LOCF/single-imputation**; the missing-data assumption should be stated and probed. High or differential attrition without this → MAJOR. **RC4 — Multiplicity & outcome hierarchy**: - One pre-specified primary; secondaries and subgroups handled for **multiplicity** (the analysis plan states the correction) — and a "significant" secondary that does not survive correction is reported as such, not spun as a headline. - Unplanned subgroup claims, or a forest of secondary p-values mined for significance, → MAJOR when they drive the conclusion; otherwise a request to label them exploratory. **RC5 — Baseline comparability (no baseline significance testing)**: - Table 1 by arm should show the groups are comparable. **Do not run baseline significance tests** — randomisation guarantees exchangeability *in expectation*, so a baseline p-value tests a null the design already makes true and any imbalance is chance; the test is uninformative. Flag a manuscript that reports baseline p-values (MINOR) and, more importantly, one that uses "baseline p > 0.05" to *justify* not adjusting. - A **notable imbalance** on a prognostic covariate (chance, in a small trial) warrants a **pre-specified adjusted** analysis as sensitivity — request it rather than reading the unadjusted estimate as definitive. **RC6 — Early stopping & interim analyses**: - If the trial **stopped early for benefit**, were there pre-specified stopping rules with an alpha-spending function? Trials stopped early for benefit **overestimate** the effect — the estimate needs that caveat. Unplanned interim looks that informed stopping → MAJOR. **RC7 — Power, pilot framing & estimand**: - Is there an a-priori **sample-size / power** calculation tied to the primary, or is the trial an explicit **pilot/feasibility** study? A small pilot must be framed as hypothesis-generating, not confirmatory — a definitive efficacy claim from an underpowered trial is a MAJOR over-reach. - Is the **estimand** clear (the contrast, population, and handling of intercurrent events — treatment discontinuation, rescue medication)? ## AI-trial reporting-flow probes (CONSORT-AI / SPIRIT-AI, A1–A5) Co-apply when the trial's intervention **includes an AI/ML component** — the reporting axis is then CONSORT-AI (completed-trial report) or SPIRIT-AI (protocol), on top of base CONSORT/SPIRIT. These probe what randomisation cannot protect: whether the AI was specified, used, and monitored as it would be in practice. Name the base instrument and the AI extension, and cite each. **A1 — Algorithm version + lock**: - Is the exact **AI algorithm version** stated, and was it **locked** for the trial (no mid-trial retraining/updating)? A trial result tied to an unspecified or silently-updated model is not interpretable or reproducible. Undefined/changed version → MAJOR. **A2 — Input-data eligibility + poor-input handling**: - Are the **input-data** inclusion/exclusion criteria stated **separately** from participant eligibility (a trial can enrol eligible patients yet feed the AI out-of-distribution inputs)? Is the handling of **poor-quality or unavailable inputs** specified? Missing input-data eligibility or undefined poor-input handling → MAJOR (the evaluated population is undefined, and silent failures bias the result). **A3 — Human–AI interaction + decision pathway**: - Is the **human–AI interaction** described (autonomous vs assistive), with the **expertise required of the user**, and is it explained **how the AI output fed the clinical decision** (5(vi)/11a(vi))? This determines whether the trial evaluated the AI *as used in practice*. A bare "the model assisted clinicians" with no interaction/decision detail → MAJOR interpretive gap. **A4 — Performance-error analysis + safety monitoring**: - Are **performance errors** analysed (CONSORT-AI item 19) — or, for a protocol, is there a **plan to identify and analyse them** (SPIRIT-AI item 22)? AI-specific harms (systematic failure on a subgroup, distribution shift during the trial) live here. Absent error analysis/plan where the design permits → MAJOR. **A5 — Setting integration + code/intervention accessibility**: - Are the **onsite/offsite integration** requirements described (how the AI was embedded in the trial setting), and is the **intervention/code accessibility** stated (with restrictions)? Opaque integration or undisclosed accessibility limits reproducibility and external validity → minor–MAJOR depending on the claim. **Output template (RC2 / RC3 example)**: > "The trial is described as blinded, but the active intervention produces [obvious effects] and the comparator arm undergoes [a different experience], so participants were likely **functionally unblinded** ([Methods]); with a self-reported primary outcome this is a real interpretive limit. I'd suggest reporting any blinding-success assessment and tempering the causal language accordingly. Relatedly, the primary analysis appears to be [per-protocol / completers]; because that breaks the randomisation, I'd suggest making the **intention-to-treat** analysis (all randomised) the primary, with per-protocol as a sensitivity analysis, and stating the missing-data assumption and method (e.g., a mixed model under MAR rather than LOCF)." **Discipline — leads vs findings (applies to RC0–RC7)**: - A concealment/blinding/ITT concern surfaced by a quick scan is a **lead until the Methods and the CONSORT flow are read together** — distinguish under-reporting (ask to clarify) from a true design flaw (MAJOR). - Anchor every comment to the exact item (sequence vs concealment; ITT vs per-protocol; registered vs reported primary) and the location. "The trial is biased" is not actionable; "the primary analysis is per-protocol, which breaks randomisation (Methods, Statistical analysis)" is. - Keep severity tied to what the flaw *does*: a broken-randomisation primary, unconcealed allocation, or an open-label subjective outcome is design-level (MAJOR, often Major #1); a reported baseline p-value is MINOR. -
record_routinely_collected_data.md 7.4 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a code-list / linkage-quality / selection-flow / RWD-bias design flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Routinely-collected-data study probes (RD1–RD8) An 8-probe checklist for observational studies conducted using **routinely-collected health data** — administrative claims, electronic health records (EHR), disease/population registries, health-administrative and health-checkup databases, and linked versions of these (data **not collected for the study's purpose**). These probes complement (do not replace) the generic Phase 2 checklist, the **STROBE** + **RECORD** reporting items (and **RECORD-PE** for drug studies), and the observational-confounding probes (`observational_confounding.md`, which cover adjustment/collider/analysis-unit issues). They target what secondary-use data add: whether the database can even observe the question, whether the phenotype code-lists and linkage are evidenced rather than asserted, and whether the limitations endemic to data collected for another purpose are confronted. RD2 (phenotype code-lists & validation), RD3 (linkage quality), and RD4 (participant-selection flow) are the highest-yield; run them first. **RD1 — Database identity, provenance, and fitness-for-purpose**: - Is the **database named** and its **type** (claims / EHR / registry / health-checkup), provenance, coverage population, and capture window described — and can it **structurally observe** the exposure and outcome? A claims database cannot see out-of-network or cash-pay care; an EHR cannot see care at other systems; neither reliably sees OTC drugs, over-the-counter outcomes, or death out of hospital. - Is the **timeframe and geographic setting** stated (title/abstract per RECORD 1.1–1.2)? - An unnamed/undescribed database, or one whose structure cannot capture the named exposure/outcome (so the measure is systematically incomplete), → MAJOR. **RD2 — Phenotype definitions: code-lists and algorithms, evidenced not asserted**: - Are the **codes / algorithms** (ICD-9/10, CPT/HCPCS, ATC/NDC drug codes, Read/SNOMED, lab thresholds) used to define the **population, exposure, outcome, confounders, and effect modifiers** provided in full (text or supplement), per RECORD 6.1 / 7.1? "Diabetes/MI/the cohort was identified from the database" with **no code list** is the single most common RECORD failure. - Are those definitions **validated** — a referenced validation study, or a PPV/sensitivity estimate — or at least is the lack of validation acknowledged (RECORD 6.2)? An unvalidated outcome algorithm presented as if it were a gold-standard diagnosis is a misclassification risk (RD5). - Missing code-lists, or validated-sounding phenotypes with no validation reference/acknowledgement → MAJOR. **RD3 — Data linkage and linkage-quality evaluation**: - If two or more databases were **linked**, is the **linkage method** (deterministic on a unique identifier vs **probabilistic**, and on which fields) and the **linkage-quality evaluation** (match/linkage rate, handling of non-matches and false matches, any bias in who links) reported (RECORD 12.3)? Is a **person-flow at each linkage stage** shown (RECORD 6.3)? - Are individuals who **failed to link** characterised (linkage is often differential by age/region/insurance), and is the impact on selection considered? - Undisclosed linkage method or quality, no linkage-stage flow, or treating the linked subset as representative without examining non-linkage → MAJOR. **RD4 — Participant-selection flow including data-quality filtering**: - Is there a **selection/flow** from the source database to the analytic cohort that includes filtering on **data quality, data availability, and linkage** — not only clinical eligibility — with the **N at each step** (RECORD 13.1)? A jump from "the database contains N million records" straight to an analytic N, with the exclusions opaque, hides selection bias. - Is the **analysis unit** (persons vs records/encounters/claims) explicit and consistent (cross-link `observational_confounding.md` O8)? - No data-driven selection flow, or an unexplained gap between source and analytic N → MAJOR. **RD5 — Misclassification of exposure and outcome**: - Are **exposure and outcome misclassification** (from coding/recording, not clinical adjudication) acknowledged and, where possible, **quantified** (validation PPV/sensitivity, quantitative bias analysis, or a sensitivity analysis under alternative definitions)? Are **proxy/surrogate** measures (a prescription ≠ ingestion; a code ≠ the disease) flagged as such? - Coded variables treated as gold-standard with no misclassification discussion, or a single rigid definition with no sensitivity to a broader/narrower one → MAJOR (or MINOR if non-differential and acknowledged). **RD6 — Missing data and informative missingness**: - Secondary data are frequently **missing-not-at-random** — a lab not ordered is not a normal lab, an unrecorded covariate is not absence of the condition. Is missingness **characterised** (extent, pattern) and handled appropriately (not a naive complete-case that assumes MCAR when missingness is informative; multiple imputation or a sensitivity analysis where justified)? - Naive complete-case analysis on informatively-missing EHR fields, or treating "no record of X" as "X absent" without justification → MAJOR. **RD7 — Unmeasured confounding and RWD-specific design bias**: - Is **unmeasured/residual confounding** confronted — secondary data often lack lifestyle, disease severity, frailty, or over-the-counter exposures — with a negative-control, E-value, or sensitivity analysis, rather than asserting "adjusted for available confounders" (cross-link `observational_confounding.md`)? - For an exposure/drug study, are the biases endemic to RWD addressed by **design**: **immortal-time bias** (time-fixed exposure misclassified person-time), **prevalent-user bias** (new-user / active-comparator design), **protopathic/reverse-causation bias** (a lag/induction window), and confounding by indication? (These are the core of **RECORD-PE**.) - An effect estimate with no engagement with unmeasured confounding, or a drug-effect design exposed to immortal-time / prevalent-user bias with no mitigation → MAJOR. **RD8 — Eligibility drift, data access, and reproducibility**: - Over the study window, did **coding systems or eligibility/enrolment rules change** (ICD-9→10 transition, formulary or coverage changes), and is that acknowledged (RECORD 19.1)? - Are the **extent of data access**, the **data-cleaning methods**, and the **availability of the protocol, derived-variable definitions / code-lists, and analysis code** stated (RECORD 12.1 / 12.2 / 22.1)? Reproducibility in RWD studies rests on the published phenotype definitions and code. - Unacknowledged coding/eligibility drift over a multi-year window, or no availability of protocol/code-lists/code for a non-public database, → MAJOR (drift) / MINOR (availability), per centrality. -
scoping_review.md 7.1 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a scoping-fit / mapping-not-pooling / over-claim flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Scoping review probes (SC1–SC8) An 8-probe checklist for **scoping reviews** — reviews that *map* the breadth of evidence on a topic, clarify concepts/definitions, and identify gaps. These probes complement (do not replace) the generic Phase 2 checklist and the **PRISMA-ScR** reporting items (Tricco et al. *Ann Intern Med* 2018), and assume the JBI / Arksey & O'Malley / Levac conduct frameworks. They target what is specific to a scoping design: whether the question genuinely suits a *mapping* (not an effectiveness/accuracy) review, whether the conduct uses scoping methods (PCC framing, charting, optional appraisal), and — the single most common over-reach — whether the synthesis stays a **map** rather than drifting into pooled effect estimates and definitive effectiveness claims. SC1 (scoping fit), SC6 (critical-appraisal calibration — the asymmetric trap), and SC7 (mapping not pooling) are the highest-yield; run them first. **SC1 — Scoping fit and objectives (mapping intent, PCC framing)**: - Is the review genuinely a **scoping** question — mapping the extent/range/nature of evidence, clarifying concepts, or identifying gaps — rather than a focused **effectiveness/accuracy** question that should be a systematic review (PRISMA 2020 / PRISMA-DTA)? - Are objectives framed with **Population, Concept, Context (PCC)** or an equivalent mapping structure, not a PICO effectiveness/comparison frame with a defined outcome estimate? - A focused "is X effective / is test Y accurate?" question run as a scoping review (to sidestep risk-of-bias and synthesis), or a scoping review whose objectives are really an SR's → MAJOR (reframe as a systematic review, or restate the objective as mapping). **SC2 — Protocol and registration (a-priori; OSF, not PROSPERO)**: - Is an **a-priori protocol** available (OSF / Figshare / a published protocol), with the question, eligibility, and charting plan pre-specified? - Note that **PROSPERO does not register scoping reviews** — a manuscript claiming PROSPERO registration for a scoping review is in error and should cite the correct registry/repository. - No protocol for a scoping review of any size, or a wrong-registry claim → MINOR (protocol absent → MAJOR if the charting plan appears post-hoc / outcome-driven). **SC3 — Eligibility by concept; sources of evidence (not only studies)**: - Are eligibility criteria specified by **PCC plus source characteristics** (years, language, publication status, source/evidence types) **with a rationale**? - Does the review admit the **heterogeneous "sources of evidence"** a scoping review is meant to map (quantitative and qualitative research, grey literature, policy/guidance, expert opinion) where relevant, rather than silently restricting to a narrow study type and presenting it as a comprehensive map? - Over-restrictive, study-only eligibility presented as a comprehensive map of the field, or eligibility with no rationale → MINOR. **SC4 — Search comprehensiveness and reproducibility**: - Is the **full search strategy for at least one database reproducible** (terms, limits), the date of the most recent search stated, and the information sources appropriate to a *mapping* aim — typically **multiple databases plus grey literature** rather than a single narrow source? - Given that scoping reviews aim for breadth, is the search broad enough to support a "map the field" claim? - A comprehensiveness/mapping claim resting on one narrow database with no grey-literature or no reproducible strategy → MAJOR / MINOR per the strength of the claim. **SC5 — Selection and data charting**: - Is the **two-stage selection** process (title/abstract then full-text) described, and the **data-charting** process specified — a calibrated/team-tested charting form, whether charting was **independent / in duplicate**, and any **iterative refinement** of the form (charting is iterative in scoping reviews)? - Is the correct terminology used (**charting**, not extraction; **sources of evidence**, not only studies)? - No charting process described, or single-reviewer charting with no calibration behind a large map → MINOR (or MAJOR if selection itself is undocumented). **SC6 — Critical appraisal is OPTIONAL (the asymmetric trap)**: - Critical appraisal / risk-of-bias is **not required** in a scoping review (PRISMA-ScR items 12/16 are optional). A reviewer must **not** flag "no risk-of-bias assessment" as a deficiency for a scoping review, and authors need not appraise. - Conversely, if appraisal **was** done, is the rationale, method, and use reported (items 12/16)? And does the review avoid claiming **certainty grading (GRADE) or quality-weighted conclusions** it never derived? - The trap is bidirectional: (a) demanding RoB of a scoping review is a mis-calibrated review comment; (b) a scoping review asserting GRADE-style certainty or "high-quality evidence shows…" without having appraised is over-claiming → MINOR/MAJOR per how load-bearing the certainty claim is. **SC7 — Synthesis is mapping, NOT pooling (no effect estimates)**: - Are results presented as a **map/characterisation** — counts, categories, concept/theme groupings, evidence-gap maps, charting tables — rather than a **meta-analytic pooled estimate** (pooled OR/RR/HR/AUC, summary sensitivity/specificity)? - A scoping review should **not** compute or headline a pooled effect/accuracy estimate; doing so is an estimand/scope mismatch (the design carries no risk-of-bias or synthesis machinery to support an effect claim). - A pooled effect/accuracy estimate, forest plot of effects, or quantitative effectiveness synthesis presented from a scoping review → MAJOR (move to a systematic review/meta-analysis, or relabel the figure as a descriptive frequency map). **SC8 — Interpretation, gaps, terminology, and reporting**: - Are conclusions matched to a **descriptive map** — what evidence exists, where the gaps are, whether a future systematic review/primary study is warranted — and free of **clinical-practice recommendations** or definitive effectiveness/accuracy claims a map cannot support (cf. the scope-coherence discipline)? - Is a **PRISMA-ScR flow diagram** present, the study correctly labelled a scoping review (not conflated with a systematic review), and the report mapped to **PRISMA-ScR**? - A practice recommendation or definitive effectiveness/accuracy conclusion drawn from a scoping map, mislabelling the study as a "systematic review," or missing PRISMA-ScR reporting → MAJOR (over-claim) / MINOR (reporting), per centrality. -
self_improving_system.md 8.2 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a task- or design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Self-Improving / Self-Evaluating System probes (SI1–SI7) For any manuscript whose claimed mechanism of improvement is the system judging or revising **itself**: an agent that iteratively critiques and rewrites its own output, a pipeline trained on data it generated, an LLM used as the judge that selects or scores the training signal, a "self-evolving" clinical agent. This class is growing fast in medical AI and is reviewed badly, because the loop *looks* like a method while the thing that decides whether it worked is often the system itself. Co-apply with `mllm_evaluation.md` (when an LLM is the evaluator), `ai_overclaiming.md` (when the claim outruns the evidence), and `model_development.md` (when the loop produces training data). **The organizing question is not "did it improve?" but "what said so?"** Every improvement loop is a claim that some signal can substitute for human judgment, and signals are not interchangeable. Order them (Chen, Wang & Qu 2026, arXiv:2607.07663, §5.2): | Rung | Signal | What it buys | |------|--------|--------------| | 1 | **Formal verifier** — proof checker, type system, schema validator | Sound by construction; cannot accept a false improvement | | 2 | **Execution feedback** — a test suite, a recomputation, an independent measurement, a held-out labelled set | Reliable but incomplete | | 3 | **Learned judge** — reward model, LLM-as-judge | Bounded by the judge's own competence; is itself an optimization target | | 4 | **Intrinsic signal** — the model's own confidence, self-consistency, likelihood | Cheapest, most gameable | Demonstrated self-improvement strength tracks that order. A paper claiming a rung-1 result on a rung-3 signal is the single most common failure in this literature, and it is a MAJOR. **SI1 — Name the rung.** Which signal decided that an iteration was an improvement? If the answer is "the model judged its own output better," the paper has a rung-3 or rung-4 signal and must not report a rung-1/2 conclusion (e.g., "the agent autonomously improves diagnostic accuracy"). If the signal is never named at all — the loop simply ran N times and the final output is presented as better — that is a MAJOR: no evidence of improvement has been offered, only evidence of change. **SI2 — The self-confirming loop (generator == evaluator).** When the generator and the evaluator share weights, their biases correlate: the loop preferentially reinforces the errors the model is **most confident about**, and a self-critique "inherits the blind spots that produce confident fabrication." - Is the judge the same model (or the same family / the same provider's checkpoint) as the system being judged? Disclosed? - Is the judge validated **against an external standard** — human expert ratings, a held-out labelled set, a task with checkable ground truth — and is the agreement reported? - Unvalidated same-model judge → **MAJOR**. The reported gain may be the judge and the generator agreeing with each other, which is not evidence about the world. - Reward hacking is the special case where the gamed judge is explicit; the self-confirming loop is the general case, and **needs no adversary** — do not accept "we had no incentive to game it" as an answer. **SI3 — Reformulation vs progress.** Ungrounded self-critique converges to rewording. Ten rounds of self-critique across three model providers and four task families produced a **55% decline in informational change** across iterations — the loop circles — while a *single* verification step restored forward movement (DeVilling 2025, arXiv:2510.21861). - Does the paper report the **trajectory** across iterations, or only the final output vs the first? - Is there a plateau/decline analysis, or a stopping rule that is anything other than a fixed N? - Is any of the measured gain attributable to the output getting *longer* or *more hedged* rather than more correct? - Improvement claimed from an ungrounded loop with no per-iteration external measurement → **MAJOR**. **SI4 — Training on self-generated data (collapse).** If the system trains on its own outputs: - What fraction of the training signal is **exogenous** (real, externally grounded) data, and is it held constant across rounds? If the exogenous fraction goes to zero, degenerative dynamics follow — loss of distribution tails, entropy decay, variance amplification (Shumailov et al., *Nature* 2024). - Is filtering/gating of the generated data described, and is the filter itself rung-3 (an LLM) or rung-2 (a checkable criterion)? - Are rare classes / tail findings — the ones that matter clinically — checked for erosion across rounds, or is only aggregate accuracy reported? - Self-training with no real-data mixing and no tail analysis → **MAJOR**. **SI5 — Diversity collapse.** Distinct from data collapse: in co-evolutionary or curriculum loops the *task* distribution narrows, because the proposer drifts toward the band of problems that satisfy the reward. - Is the diversity of generated cases/questions measured over rounds, or only their quantity? - For synthetic clinical cases: are they still covering the presentations that were rare at round 0? - "Novelty is a consumable resource that closed loops deplete" — a paper reporting rising performance on a distribution its own loop is narrowing has measured the wrong thing → **MAJOR** if the curriculum is the contribution, MINOR if incidental. **SI6 — Result-level vs process-level evidence.** Does the evaluator judge the **answer** or the **procedure**? Outcome-only filtering admits lucky guesses with wrong reasoning — a serious matter in a clinical pipeline, where the reasoning is what a clinician must audit. - Is any process-level check reported (step correctness, the retrieved evidence actually supporting the claim, the derivation), or only final-answer agreement? - If the paper's claim is that the system's *method* improved, but its evidence is that its *answers* scored higher on N cases, the claim outruns the evidence → MAJOR (see `ai_overclaiming.md`). **SI7 — Human-in-the-loop honesty and clinical deployment.** Where in the loop does a human remain, and does the paper say so plainly? - Direction-setting — deciding *what to improve* — is the paradigm case of a non-verifiable task, and it is where humans remain. A paper describing a "fully autonomous" clinical improvement loop that in fact had a human choosing the objective, the prompts, the stopping point, and the failure cases should say so; "autonomous" as a headline over a human-steered loop is an overclaim → MAJOR. - Deployment framing: an improvement loop that runs **after** deployment changes the device. Is there any account of how the improved system is re-validated, monitored, or rolled back? A self-modifying system in a clinical setting with no re-validation path is a regulatory as well as scientific gap → MAJOR when deployment is claimed. ## Deterministic support `skills/peer-review/scripts/check_self_improvement_claims.py` (also runnable from `/self-review`) reads the manuscript text and reports: - `SELF_CONFIRMING_EVALUATOR` (major) — the same model is named as both the system and the judge, and no external validation of the judge (human expert / ground truth / held-out labels) appears anywhere. - `UNGROUNDED_SELF_LOOP` (major) — an explicit self-refinement / self-critique / self-training claim with no external verification vocabulary anywhere in the text. - `SELF_TRAINING_NO_REAL_DATA` (minor) — training on model-generated data with no mention of real-data mixing or an exogenous fraction. The script narrows SI2 / SI3 / SI4 to what can be decided by reading; the remaining probes are judgment and stay judgment. -
sr_ma.md 19.7 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a task- or design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Systematic Review / Meta-Analysis probes (P0–P19) Internal-consistency-first gate (P0) plus a 19-probe checklist (P1–P19). These probes complement (do not replace) the generic Phase 2 issue checklist. **P0 — Internal-consistency-first gate (run before P1; gates any fabrication claim)**: - Before alleging fabrication on a manuscript that "feels AI-generated", reproduce the headline pooled statistics, paired study counts (k), and subgroup counts directly from the extracted data table (or supplement included-studies table). - If paired k, pooled medians, and subgroup counts reproduce, fabrication is unlikely — **pivot the review to table-vs-source fidelity (P1), comparator definition (P1), and eligibility**, not to a fabrication framing. - Only if the table cannot be reproduced, or is internally inconsistent, escalate to a transparency/integrity MAJOR. - Rationale: an "AI-smelling" surface is not evidence of fabrication. Real references can be present and the arithmetic coherent while the substantive flaws are extraction, comparator, eligibility, and overclaiming. **P1 — Performance-MA value + comparator-existence probe**: - For method-comparison MAs reporting accuracy / DSC / AUC / F1 (model-vs-model, AI-vs-reader, two training paradigms) and for DTA MAs reporting sensitivity / specificity, select ≥2 outlier or headline-driving studies. - (a) Verify each sampled arm value against the source paper (PubMed abstract or full text). For DTA cells, check for **sens/spec swap** (source sens=A% / spec=B% appearing in the forest as sens=B% / spec=A%). - (b) **Comparator-existence check**: verify the comparator arm is consistently defined and actually exists in each source. A baseline mislabeled as the comparator inflates the headline (e.g., a limited single-source baseline reported as a "centralised" comparator when the source paper has no centralised arm). - (c) Per-study schema: `Exists | Correct citation | Eligible (domain-specific) | Same comparator (same task/dataset) | Value matches source | Author-derived/averaged | Verdict`. - (d) Severity ladder: `<1pp rounding or author-derived average = minor`; `wrong dataset/task/comparator or not domain-specific = major`; `unfindable or wrong-citation = integrity concern (verify against source); potentially major`. - If a confirmed error drives a reported subgroup p-value or a headline claim, register as a primary major finding. **P2 — Cohort / benchmark non-independence probe**: - Identify clusters in included studies sharing: (a) institution name, (b) author surname + year proximity, (c) public ICU/EHR database (MIMIC-IV, eICU, MIMIC-III, KNHIS, UK Biobank, Optum, MarketScan, IBM), (d) **public imaging-challenge benchmark** (BraTS, FeTS, TCIA, Kaggle) reused across multiple included studies. - For each cluster, fetch PubMed efetch affiliation + abstract Methods database/benchmark source. - Flag pairs sharing the same data source + overlapping enrollment period (or the same public benchmark) as "high-confidence non-independence". - Manuscript should acknowledge in Limitations + perform a leave-one-dataset-out sensitivity analysis and add a data-provenance column to Table 1. If absent → MAJOR. - **Nuance**: map provenance and *request* the provenance column + sensitivity analysis; do NOT assert that a specific study used a given benchmark from coarse supplement labels alone (e.g., a supplement labeling a study only as "Hospital" or "Public" does not confirm BraTS/FeTS use). Confirm against the source before stating it. **P3 — Diagnostic subset N transparency (mixed DTA + prognostic MA)**: - Compute bivariate pool denominator (TP+FP+TN+FN) from Table 2 or forest plot. - Compare to total N reported in Abstract. - If diagnostic subset is <50% of total without explicit "diagnostic subset N = X / Y" in Results → MAJOR transparency gap. **P4 — k=1 subgroup flag**: - Inspect subgroup analyses for strata with k=1 (single included study). - If a reported subgroup p-value is driven by k=1 stratum → flag MAJOR. - Recommend reframing as exploratory or removing from formal subgroup test. **P5 — Supplementary completeness check**: - SR-MA supplementary must contain at minimum: - PRISMA / PRISMA-DTA checklist with page refs - Full-text exclusion list with reasons (per PRISMA 2020 item 16b) - Per-study data extraction table - Per-study × per-domain risk-of-bias table (QUADAS-2 / QUADAS-AI / PROBAST / PROBAST-AI) - Full search strategy verbatim per database - If supplementary contains only figure captions or is missing 3+ of these → MAJOR. **P6 — PROSPERO ID format + live URL request**: - Standard PROSPERO format: `CRD42` + 4-digit YYYY + 6-digit sequential = 13 chars total. Some pre-2020 IDs are 12 chars (5-digit sequential). - IDs with >13 chars or non-numeric tail → FORMAT_ANOMALY (MAJOR). - Always request authors provide live registration URL in cover letter for protocol cross-check. **P7 — Reference duplicate detection** (extends `/verify-refs`): - Run `/verify-refs` (PubMed + CrossRef). In addition to standard checks, detect duplicate PMID or DOI within reference list. - Verbatim duplicates indicate LLM-assisted reference compilation error → MAJOR (cite renumbering required). **P8 — AI Disclosure presence**: - `grep -iE "chatgpt|gpt-|llm|generative ai|ai was used|ai-assisted|copilot|claude|gemini|chatbot|large language model"` on manuscript body. - If 0 matches AND journal requires AI Disclosure (RYAI / Radiology / RSNA family / Lancet family / JAMA family / most BMJ family / Nature family) → flag MINOR-to-MAJOR. **P9 — Non-significant finding promoted to Abstract (overclaim probe)**: - Flag any exploratory or non-significant result (a crossover, a trend, a post-hoc subgroup) that appears in the Abstract or Key Points framed as a finding. - Sub-check: does the promoted finding depend on a study flagged or mis-extracted under P1? (A headline crossover can collapse once a mis-extracted comparator is corrected.) - Flag "non-inferiority" / "equivalence" asserted without a pre-specified margin. A margin cannot be pre-specified retrospectively — ask the authors to document any pre-existing protocol margin, otherwise drop the non-inferiority language or present it explicitly as a post hoc equivalence / sensitivity analysis. **P10 — Citation-metadata confusion class (over-escalation guard)**: - DOI-suffix digits that surface as an apparent article number (e.g., a DOI tail "77196" against article number 26068, or "60466-1" against 6274) are cosmetic metadata confusion, **not** fabrication — do not escalate them as fabricated references. - Reference-list duplicates are handled by `/verify-refs` (`duplicate_findings[]`); AI-disclosure presence is the cross-cutting P8 check. Neither is unique to SR/MA. **P11 — Poolability / construct-validity gate (does this synthesis hold at all?)**: - Before refining cell-level extraction (P0/P1), ask the higher-altitude question the forensic layer skips: are the pooled studies answering the *same clinical question* on *comparable populations / index tests / reference standards*, such that combining them into one estimate is meaningful? - Check that the quality / risk-of-bias instrument is valid for the included *study/model class* (e.g., a radiomics-specific QA tool applied to deep-learning studies; METRICS vs CLAIM / PROBAST mismatch). An instrument applied outside its intended class invalidates the RoB synthesis. - When k is small and P2 non-independence reduces the independent-cohort count further (e.g., k=6 → k≈4 after overlap), quantitative pooling itself may be inappropriate → a systematic review with **descriptive synthesis** is the correct form, not a forced meta-analytic pool (and not merely a sensitivity analysis layered on top). - This is an **unfixable-in-current-form** defect when present: cell-level re-extraction cannot rescue a synthesis that should not have been pooled. Let it dominate the severity tier over fixable extraction/reporting defects — strong cell-level forensics must not set a lenient tier when the synthesis itself is invalid. **P12 — Risk-of-bias table row-sum ↔ figure-matrix reconciliation**: - For every per-study risk-of-bias row (NOS ★/☆, JBI Y/N, QUADAS-2/PROBAST domain grid), the count of awarded items must equal the row's stated total (NOS: ★ count == "N/9"; JBI: Y count == the "Yes" column). A row whose cells do not sum to its printed total is a visible internal contradiction — especially in a traffic-light **figure**, where a reader sees, e.g., seven filled cells next to a "6/9" label. - Cross-check the **figure-generating data matrix** (the R/Python script that draws the traffic-light) against the **supplementary RoB table** cell-by-cell. A transcription drift between the two surfaces as a figure-vs-table mismatch even when each row's printed total looks fine. - The single source of truth is the **primary reviewer assessment form** (the filled extraction/appraisal sheet), not a value hardcoded in a plotting script. When they disagree, regenerate the figure from the assessment form and re-derive the table; then re-sync the table, caption, and any prose sentence that names per-item losses. - Severity: a row-sum vs total mismatch (or figure vs table mismatch) in a submitted artifact is MAJOR (it reads as a data-handling error). Motivating case: an NOS figure drew one study with seven stars but labeled it 6/9 because the plotting script's comparability cell contradicted the primary appraisal form. **P13 — Included-study ↔ reference-list completeness**: - In an SR/MA, **every study in the characteristics table (Table 1) must be cited as a numbered reference.** A manuscript that lists included studies only as "First-author Year" labels in Table 1, while the reference list holds only methodology/guideline citations, will draw an "included studies not cited" reviewer flag. - Deterministic check: the set of Table-1 included-study labels (First-author + Year) must be a subset of the reference-list first-authors. Flag any included study with no matching reference. - Place the citations as a cluster at the point the pool is first introduced ("N studies were included [c1; c2; … cN]") so a numeric CSL renumbers them in order of appearance; citations placed only inside a separately-built table file are not processed by the manuscript's citeproc pass. - Do not source included-study citations from hand-kept extraction notes / appraisal sheets — those carry journal and page errors. Resolve each via PubMed `efetch` (authoritative), disambiguating same-author/year papers by **technique + sample size** (e.g., a study reporting "249 tumors, 110 adjacent to the diaphragm" is identified by the n that matches the synthesis, not by a remembered journal name). Take the article DOI only from `PubmedData/ArticleIdList`, not from an ArticleId inside the article's own reference list. **P14 — Small-k DTA/proportion MA enrollment-overlap → effective independent k**: - Extends P2 (non-independence) for the small-k case where double-counting changes the verdict, not just the precision. Group included studies by first-author / center / source database, then pull each study's **enrollment / recruitment window** from the source and compare overlapping groups. - When same-group studies share patients, recompute the **effective independent k** (e.g., k=8 reported → k≈5 once two same-center windows overlap). Ask explicitly whether quantitative pooling is still defensible at the reduced k, or whether a descriptive synthesis is the honest form (cross-link P11). - A panel that flags overlap as "possible / fixable in revision" without recomputing effective k under-rates a desk-fatal flaw. Severity: MAJOR when the reduced k undermines the pooled estimate; escalate toward unfixable-in-current-form when pooling itself becomes inappropriate. **P15 — Mixed analysis-unit denominator pooled into one proportion**: - For a single-arm proportion MA (technical success, complication rate, pneumothorax, etc.), the contributing studies must share a denominator **unit**. Pooling per-patient, per-session, and per-lesion denominators into one proportion makes the estimand undefined. - Deterministic lead: read the extraction denominator-unit column (or compare each study's reported N vs N_patients / N_sessions / N_lesions). Flag any outcome whose contributing studies mix units. - Recommend denominator-coherent subset pools as the primary analysis (e.g., per-patient pool primary; per-session as a separate, labeled pool), not one blended proportion. Severity: MAJOR (estimand undefined). **P16 — "Prospectively registered" vs registration-after-search chronology**: - When the manuscript or cover letter calls the review "prospectively registered" (PROSPERO/OSF), cross-check the **registration date** against the **search execution / cutoff date**. A search cutoff that predates registration reads as retrospective registration. - Lead: if `prospectiv*` co-occurs with the registry name AND (search date < registration date) → flag. Recommend either correcting the chronology claim or reframing as "screening and extraction followed registration" (only the steps that genuinely post-date registration may be called prospective). - Severity: MINOR–MAJOR depending on how load-bearing the "prospective" framing is. Every PROSPERO SR/MA cover letter is a candidate. **P17 — Boundary-degenerate proportion pooled with spurious precision**: - When a pooled proportion (technical success, sensitivity, specificity) has **a majority of studies at the 0% or 100% boundary** (e.g., 5 of 6 studies at 100%), a GLMM / random-effects pool is near-degenerate: I²=0 is a boundary artifact, and a pooled point estimate with a tight CI (e.g., 99.5%, CI 69–100) reads as authoritative precision the data do not support. - Lead: count studies at the 0%/100% boundary per outcome; if ≥ (k−1) are at the boundary, flag the pooled point-estimate/CI as spurious precision. - Recommend a descriptive tally ("5 of 6 studies reported 100%; the sixth reported X%") rather than a pooled estimate. Severity: MAJOR when the boundary pool is presented as a headline accuracy/success figure. **P18 — Train-vs-validation pool integrity: apparent estimate smuggled into the "validation" pool**: - A DTA / prognostic-model MA that separates a "training" pool from a "validation" pool must build the validation pool from **held-out / external / cross-validated** estimates only. The failure mode: for studies without cross-validation, the authors drop each study's **apparent (in-sample / resubstitution)** sensitivity–specificity (or "overall estimate") INTO the validation pool — so the headline "validation" performance is partly the same in-sample numbers, and the "train ≈ validation stability" the Discussion leans on is mechanical reuse. - Lead: grep Methods for a train/validation split co-occurring with {`overall estimate`, `non–cross-validated` / `non-cross-validated`, `apparent`, `resubstitution`, `where validation was unavailable … used the overall`}. Then check whether a **sensitivity analysis excluding non-CV studies materially moves the validation pool** (a large drop = the primary "validation" estimate is optimism-contaminated). - Severity: MAJOR #1 when the headline rests on it (estimand incoherence — the "validation" pool is not a validation estimate); often unfixable in the current form (recompute restricted to genuinely held-out estimates, or relabel as apparent). See `~/.claude/rules/dta-meta-analysis.md`. **P19 — Reviewer-side included-study cell audit: metric-type, self-eligibility contradiction, CI provenance**: - As a reviewer, a **random/convenience sample of the open-access included studies** cell-checked against source is sufficient to demonstrate a systematic extraction-error class — full re-extraction is NOT the reviewer's job. A sampled error rate above ~30% ⇒ the pooled estimates are untrustworthy without author-side full cell reconciliation (P1 / `dta-meta-analysis.md` §1). - Beyond value-matches-source (P1), check three extraction pathologies: **(a) metric-type identity** — an `accuracy` or a *different task's* AUC tabulated AS the target AUC (accuracy ↔ AUC ↔ sens/spec are not interchangeable); **(b) self-eligibility contradiction** — an extracted metric/design that violates the review's OWN stated inclusion rule (e.g. an accuracy-only study the eligibility text excludes, entered anyway); **(c) CI provenance** — a CI present in the MA table that **does not exist in the source** ("derived from the point estimate and sample size" is not a valid route to an AUC CI). - Also flag validation-type / analysis-unit mislabels (5-fold CV recorded as train/validation; N scans counted as N patients). Absence of a per-study 2×2 / sens–spec table is itself a PRISMA-DTA reporting MAJOR. - Lead: sample K OA included studies; per study confirm metric TYPE + value + that the extracted design satisfies the paper's own eligibility + that any tabled CI appears in the source. Severity scales with the sampled error rate. **Output template (P1 cell-swap example)**: > "I spot-checked [Author Year] (PMID [...]) against the source paper and found that the values in Figure X are swapped. The source paper reports external-test sensitivity A% / specificity B% (n=N); the manuscript forest entries place [num1/denom1] in the sensitivity slot (which is the source's specificity numerator/denominator) and [num2/denom2] in the specificity slot (which is the source's sensitivity)." **Output template (P1 comparator-existence example)**: > "I spot-checked [Author Year] (PMID [...]) against the source. The manuscript lists this study's comparator ('[label]', [value]) in [comparison], but the source paper does not report that arm; the [value] appears to be the study's [limited single-source baseline]. Because this entry contributes to [the pooled comparison / a headline claim], I'd suggest re-extracting the comparator definition per study and adding a comparator-definition column to Table 1 so readers can confirm each arm is the same task on the same data." **Output template (P2 example)**: > "[Author1 Year1] uses [Database] (N=...). [Author2 Year2] uses [Database] (N=...). These are nearly certainly overlapping patient pools, and the statistical independence assumption for MA pooling is violated. I'd suggest a sensitivity analysis excluding one of the two studies, plus an explicit cohort-source column in Table 1." **Discipline — leads vs findings (applies to every P0–P10 probe)**: - Output from a forensic sub-agent or automated scan is a **lead, never a finding, until confirmed against the source.** Concrete failure modes to discard on inspection: treating recent (in-press / current-year) publication dates as "impossible", inventing journal article-number rules, and inflated all-or-nothing fabrication-risk scores. - Before finalizing, run an **overclaim sweep of your own draft** (mandatory external-QC pass — independent model or colleague). Two worked examples: a strong claim that "the references are real, not fabricated" should be narrowed to "the sampled references / DOIs resolved"; a benchmark example list should be trimmed to studies whose benchmark use was source-confirmed. - **Do not compute chance-probabilities** for suspicious or identical values. Record the observation neutrally: "exact match to ≥2 decimals; source verification pending." -
survey_research.md 7.1 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a representativeness / response-rate / instrument-validity flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Survey / questionnaire study probes (SV1–SV8) An 8-probe checklist for **self-report survey / questionnaire studies** — knowledge-attitudes-practices (KAP), physician and patient surveys, cross-sectional questionnaires, and web/e-surveys. These probes complement (do not replace) the generic Phase 2 checklist, the **CROSS** reporting items (and **CHERRIES** for internet surveys), and the scale-reliability guidance (`analyze-stats` Survey/Likert). They target the gap between a clean results table and whether the sample can support a population claim at all: representativeness, the response-rate denominator and non-response bias, and whether the instrument measures what it claims. The most common failure is generalising from a self-selected convenience sample. SV1 (representativeness), SV3 (response rate & non-response), and SV4 (instrument validity) are the highest-yield; run them first. **SV1 — Target population, sampling frame, and representativeness**: - Is the **target population** defined and the **sampling frame** (the actual list/channel from which respondents were drawn) stated, with comment on how well the frame **covers** the target population (coverage error)? A survey distributed via a society listserv, social media, or a conference app reaches a self-selected slice, not "clinicians" or "the public." - Is there any evidence the respondents **resemble the target population** (compare respondent demographics to a known population, or to the frame)? - A convenience / self-selected / undisclosed-frame sample whose results are generalised to a population, with no representativeness assessment → MAJOR (downgrade claims to "among respondents"). **SV2 — Sampling method and sample-size justification**: - Is the **sampling method** stated and correctly characterised — **probability** (random/systematic/stratified/cluster) vs **non-probability** (convenience/snowball/quota)? A non-probability sample cannot yield design-unbiased population estimates and should not be presented as if it could. - Is there an **a-priori sample-size or precision justification** (for a prevalence/estimate: target margin of error; for a comparison: power), rather than a post-hoc rationalisation of however many happened to respond? - A non-probability sample presented as representative, or no sample-size rationale for a precision / comparison claim → MAJOR / MINOR per the claim. **SV3 — Response rate (defined denominator) and non-response bias**: - Is a **response rate reported with an explicit, defensible denominator** (an AAPOR/CASRO-style definition: completed responses ÷ eligible invitees), not just "N people responded"? For an **open** web survey where the denominator is unknowable, is that limitation stated (a view/participation/completion rate per CHERRIES instead of a true response rate)? - Is **non-response bias** assessed — responders vs non-responders, early vs late responders, or respondents vs the population? A low response rate is not fatal, but an **unassessed** low/undefined response rate carrying a population estimate is. - No defined denominator, or a low response rate with no non-response analysis behind a population claim → MAJOR. **SV4 — Instrument development, validity, and reliability**: - Was the questionnaire **previously validated** (cited) or **newly developed**? For a new/adapted instrument, was it **pre-tested / piloted** (cognitive interviewing, a pilot sample)? - For **multi-item scales**, are **validity** (content/construct, factor structure) and **reliability** (Cronbach's α / McDonald's ω, test–retest) reported? (A negative or implausibly low α usually signals a reverse-coded item not re-scored — see the scale-reliability guidance, not a multidimensionality story.) - A novel, unvalidated, un-piloted instrument carrying the headline, or multi-item scales with no reliability evidence → MAJOR (or MINOR if the instrument is established and cited). **SV5 — Administration mode, coverage, and e-survey (CHERRIES) reporting**: - Is the **mode** (web, email, postal, telephone, in-person) and its **coverage/selection implications** stated (a web survey excludes the digitally excluded; a clinic survey excludes non-attenders)? - For an **internet survey**, are the CHERRIES specifics reported: **open vs closed** (invited) survey; how the **denominator and completion** were computed; **voluntariness and any incentive**; **duplicate-submission control** (IP/cookie/log-in); and use of mandatory/adaptive questions and completeness? - A web survey with no CHERRIES reporting (unknown denominator, no duplicate control, undisclosed incentive) → MAJOR / MINOR per centrality. **SV6 — Question design and measurement**: - Is the **instrument available** (appended or referenced) so wording can be judged? Are there **leading, double-barrelled, or ambiguous** questions, and is the handling of **neutral / "don't know" / not-applicable** options appropriate (forced-choice can manufacture opinion)? - Are **Likert / ordinal** items treated appropriately (ordinal vs assumed-interval), and composite scores justified? - An unavailable instrument, biased item wording, or inappropriate scale treatment driving a conclusion → MAJOR / MINOR. **SV7 — Analysis, weighting, denominators, and missing data**: - For a sample that under-represents parts of the target population, were **design weights / post-stratification** applied (and the weighting described), or are unweighted estimates presented as population figures? - Are **per-item denominators** explicit and consistent (completers vs all respondents; the denominator should not silently shift across items), and is **item-level missingness / partial completion** handled and reported? - Unweighted estimates from a skewed sample presented as population values, or shifting/opaque denominators → MAJOR. **SV8 — Interpretation, generalisability, ethics, and reporting**: - Are conclusions **matched to the sampled population** (no over-generalisation from a single-setting / low-response / convenience sample to "physicians" or "patients" broadly), and are self-report and **social-desirability** biases acknowledged? - Are **ethics** (consent, IRB approval/exemption, data protection/anonymity) reported, and is the study mapped to **CROSS** (and **CHERRIES** for e-surveys) with instrument/data availability where possible? - Over-generalisation beyond the sampled population, or missing ethics/consent reporting for an identifiable-respondent survey → MAJOR (generalisation) / MINOR (reporting), per centrality. -
survival_prognostic.md 14.3 KB
<!-- Domain probe module — shared, vendored BYTE-IDENTICAL by /peer-review and /self-review. Severity words below (MAJOR / MINOR / major / minor) denote finding severity, NOT a journal recommendation. Each consuming skill maps findings to its own output: - peer-review: Major / Minor comments + Confidential Comments to the Editor; a task- or design-level flaw is placed as Major #1. - self-review: Anticipated Major / Minor Comments (Fatal / Fixable) mapped to category letters. Do NOT edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`. --> # Survival / Prognostic Model probes (S1–S9) A 9-probe checklist for time-to-event outcomes and prognostic model development. These probes complement (do not replace) the generic Phase 2 issue checklist and may be co-applied with the SR-MA probes for a meta-analysis of prognostic models. **S1 — Conditioning / causal framing**: - Does the manuscript claim a "preoperative" / "screening" / "triage" / "X replaces Y" use case while outcomes are conditioned on the downstream treatment whose value the model is supposed to inform? - Inputs include post-decision variables (resection margin status, adjuvant chemo/radiotherapy, transplant status) that are unknown at the claimed decision point? - Non-treatment comparator or causal framework present? - Conditioning gap → MAJOR candidate. Recommend retrain without leaky variables / add non-treatment arm / reframe intended use. - **Time origin & survivorship** (incident / transition models): is the at-risk clock started at the correct origin for each incident model, with immortal time (a span in which the event cannot occur, misattributed to one group) and left-truncation / delayed entry handled? Is a "progressor" / transition label conditioned on *surviving to* a later ascertainment (a second scan, a follow-up visit) — a survivorship that needs a landmark time or an explicit intermediate-state model? If the primary analysis is **not** the full cohort (e.g., complete-case while a large fraction is missing) and the complete-case model is the significant one, that selection needs a stated justification and a MAR rationale — an outcome-dependent choice of the analysis set is the S8 concern. Any of these unhandled → MAJOR. - **Self-confession escalation**: a Methods or Limitations admission that a time-origin, immortal-time, return-conditioning, or selection issue was *"not formally assessed"* (or equivalent) is itself a MAJOR — it names a known bias that was left unaddressed, not a mitigated limitation. **S2 — Censoring handling in training loss**: - Cox partial-likelihood loss or DeepSurv-style loss specified? How is censoring handled (right-censoring, interval-censoring, informative censoring by death)? - If Methods describe a Cox or partial-likelihood loss but do not specify censoring treatment, register as MAJOR (reproducibility). - Covariate incompleteness in the model fit is part of the same disclosure: is a structural-zero covariate (a never-smoker's pack-years = 0 by definition, not missing) handled as a zero, or dropped under complete-case so the unexposed stratum and the events-per-variable silently collapse? An undisclosed complete-case collapse from a dose/duration covariate is a reproducibility + power issue — adjust on the categorical status and reserve the continuous dose for an exposed-only secondary analysis. **S3 — Competing risks**: - 2+ event types (local recurrence + distant metastasis + death, or cause-specific mortality) modeled? - Cause-specific hazards or Fine-Gray subdistribution hazards used? - Patient developing one event still at risk for the other (informative censoring by death)? - If competing-risks structure is ignored and outcomes are treated as independent right-censored events → MAJOR. - Produce the fix: `analyze-stats` `references/analysis_guides/survival.md` has the Aalen–Johansen/Fine–Gray cumulative-incidence code (naive 1−KM overestimates) + the cause-specific-vs-subdistribution estimand choice (S8) and the PH→RMST fallback. **S4 — Cutoff derivation optimism**: - Cutoffs derived via maximally selected log-rank statistics, AUC-based Youden's J, or similar data-driven methods? - Hothorn-Lausen correction or equivalent optimism correction applied? - Was the same cohort used for both model selection (hyperparameter tuning) AND cutoff selection? (Optimism bias) - Bootstrap optimism estimate or sensitivity analysis on cutoff choice (e.g., ±0.5 SD perturbation)? - Same-cohort dual use without correction → MAJOR. **S5 — Comparator horizon alignment**: - External baseline prognostic nomogram (commonly designed for 5- or 10-year endpoints) applied as the comparator? - Manuscript's available follow-up duration aligned with that horizon? - Mismatch → baseline C-index degradation may reflect design-horizon mismatch ≠ intrinsic inferiority. Recommend time-dependent C-index or time-stratified analyses. - Baseline implementation specified: applied as published, locally recalibrated, or refit as a new Cox model with similar variables? - Unclear implementation → MAJOR (a refit local model should be described as a clinicopathologic comparator, not a "guideline model"). **S6 — C-index variant + reverse Kaplan-Meier follow-up**: - Which C-index variant: Harrell's C, Uno's C, time-dependent AUC, IPCW-C? - Variant appropriate for the censoring distribution and sample size? - Time-dependent AUC at a clinically anchored horizon (e.g., 2-year, 3-year) reported alongside Harrell's C? - Reverse Kaplan-Meier median follow-up reported per cohort and per outcome (LR vs DM separately) with censoring date? **S7 — Calibration beyond discrimination**: - Calibration plot (intercept / slope) across all cohorts? - Brier score / Integrated Brier Score (IBS)? - Decision-curve analysis at clinically relevant probability thresholds? - For a prognostic model intended to guide surveillance intensity, treatment intensification, or eligibility for adjuvant therapy, discrimination alone is insufficient. If Methods mention calibration but Results/supplement contain no calibration plot or numeric metrics → MAJOR. - **Apparent-vs-optimism-corrected deterministic tell**: discrimination/calibration reported with no internal-validation token nearby (`optimism`, `bootstrap`, `cross-valid`, `held-out`, `external`) is presumptively **apparent (in-sample) performance**. A **calibration slope reported as exactly 1.00** (and/or Uno's C, Brier, net-benefit all on the development sample) is the in-sample-fit signature — the model was evaluated on the data it was fit to. Flag MINOR (a bootstrap optimism correction is cheap and usually reproduces the estimate); escalate to MAJOR when the model is assessed for clinical utility / net-benefit, where optimistic metrics directly inflate the deployment claim. - Produce the fix: `analyze-stats` `references/analysis_guides/calibration.md` has the bootstrap optimism-corrected calibration slope/intercept (the apparent slope is 1.00 by construction), the flexible calibration curve + scaled Brier, and why Hosmer–Lemeshow is dropped. **S8 — Estimand provenance**: - Is the survival estimand stated explicitly and held consistent across Abstract / Methods / Results — event-free survival, cause-specific cumulative incidence, all-cause mortality — and at the subject vs population level? A subdistribution hazard (Fine-Gray) answers a different question than a cause-specific hazard; quoting an sHR for an etiologic claim, or a cause-specific HR for an absolute-risk claim, is an estimand mismatch. - Is the evaluation horizon (2-/3-/5-year) and the primary model fixed in advance and consistent with the registered/pre-specified primary endpoint, or was the primary endpoint, model, or horizon re-designated after the results were known (outcome-dependent primary selection)? - Does every derived statistic (E-value, an sHR-vs-cause-specific-HR contrast) trace to the *declared primary* estimand, or is a supporting/non-primary estimate quoted as if it bounded the headline claim? - Estimand drift — a primary re-designated post-hoc, or a derived statistic computed on a non-primary estimate but presented as primary → MAJOR. Recommend reporting the pre-specified and revised models coequally, disclosing the change, and recomputing any E-value for the primary estimate. (The self-review skill automates the registration ↔ manuscript and E-value arithmetic checks as Phase 2.5f, `scripts/check_claim_artifact.py`.) **S9 — Panel-data / multistate variance (within-person non-independence)**: - For a **multistate / transition / recurrent-event / longitudinal** model — a continuous-time Markov occupancy model (e.g. `msm`), a multistate Cox, or a repeated-visit transition model — are the occupancy and transition-intensity confidence intervals **person-clustered**, or are they the naive model-based (likelihood / observed-information) CIs that treat every **visit-to-visit transition** as an independent observation? - A cohort with many visits per person contributes far fewer independent units than transition records (e.g. ~470,000 visit transitions from ~30,000 people). Model-based CIs computed on the transition count ignore within-person correlation and are **anti-conservative** (too narrow) — the same non-independence the analysis-unit probe (O8 in `observational_confounding.md`) raises for a single-outcome model, here at the transition level. - Is there a **person-level nonparametric bootstrap** (resample persons, refit the model) or a **robust/sandwich (cluster-by-person)** variance, and does the manuscript report the number of **persons** (and, separately, contributing transitions/visit-pairs) rather than only the transition count? - Naive model-based CIs on panel/multistate data with no person-clustered variance or person-bootstrap sensitivity → MAJOR (the point estimate may be fine, but its precision is overstated). The fix is usually cheap — a person-resampled bootstrap that reproduces the estimate with honest CIs. ## TRIPOD+AI reporting-flow probes (prediction-model studies, T1–T4) Co-apply these when the manuscript develops or validates a **multivariable prediction model** (often AI/ML), where the reporting axis is TRIPOD+AI (Collins et al. 2024) — and TRIPOD-LLM (Gallifant et al. 2025) when the model is a large language model. They check the *reporting flow* of a prediction model rather than the survival design probed in S1–S9; name the base instrument and the extension, and cite each. **T1 — Dataset / model flow (development vs validation)**: - Are the data partitions reported explicitly and kept separate: training, tuning/hyperparameter, internal test, and **external** validation? Is the unit (patient vs image vs record) consistent across splits? - Is there any leakage path — preprocessing/feature selection/threshold choice fit on pooled data, the same cohort used for tuning and evaluation, or (for an LLM) evaluation data plausibly in pretraining/prompt-development? - Development-only study presented as if externally validated, or an undisclosed shared-patient overlap between "development" and "validation" → MAJOR. **T2 — Performance with discrimination AND calibration**: - Is performance reported with uncertainty (95% CI), and does it include **both** discrimination (C-statistic/AUC) **and** calibration (plot + slope/intercept)? Discrimination alone for a model that outputs probabilities is incomplete. - Is a **decision-curve / net-benefit** analysis reported when the model is meant to guide an action (surveillance intensity, treatment, eligibility)? Pair calibration with the decision curve (`make-figures` `exemplar_plots/decision_curve.md`); added-value claims need the incremental-value table (`analyze-stats` `table-standards/table-types/incremental_value.md`). - AUC-only reporting for a probability model intended to drive a decision → MAJOR (calibration/utility missing). **T3 — Subgroup / fairness performance**: - Is performance reported across relevant subgroups (age, sex, site, scanner/source, and where available race/ethnicity), or is a single pooled metric the only evidence? - Are subgroup sample sizes adequate, and is differential performance discussed rather than buried? Absent fairness/subgroup assessment where data permit → minor–MAJOR depending on the deployment claim. **T4 — Model availability and intended use**: - Can a reader reproduce predictions — full coefficients (and intercept/baseline hazard) for a regression model, or model/code/weights availability and input–output spec for an AI/LLM model? - Is the **intended use** stated with the required human oversight, and are claims kept within the evidence (no "deployment-ready" from a single internal test set)? Missing model specification/availability, or use claims beyond the validation evidence → MAJOR. **Output template (S4 example)**: > "The Methods (p. X) state that optimal cutoffs for [outcome] were determined via maximally selected log-rank statistics on the internal validation cohort. Two concerns: (a) Hothorn-Lausen correction is cited but it is unclear whether the corrected p-value was used in the cutoff selection; (b) the internal validation cohort appears to have been used for both model selection and cutoff selection, which is a known source of optimism. I'd suggest reporting bootstrap-based optimism estimates or a sensitivity analysis showing how external performance shifts under ±0.5-SD perturbation of the chosen cutoff." **Output template (S5 example)**: > "The chosen baseline nomogram was originally designed and validated for prediction of long-horizon endpoints (5- and 10-year). In this study, median follow-up in [external cohort] is substantially shorter than that horizon, so the comparator's apparent underperformance may partly reflect a horizon mismatch rather than intrinsic inferiority. I'd suggest (a) stating explicitly the time horizon at which both models were evaluated, (b) reporting time-dependent C-indices at a clinically anchored horizon, and (c) clarifying whether the comparator was applied as published, recalibrated locally, or refit as a new Cox model with similar variables." ## When this module does not apply These probes are out of scope for: - Pure diagnostic accuracy (sensitivity / specificity / AUC, binary classification with no time component) - Cross-sectional risk model without time-to-event endpoint - Replication of a documented prior methodology Moved here from the consuming skill so the scope travels with the probes.
-
-
exemplar_findings
-
cohort_arithmetic_mismatch.md 1.8 KB
# Exemplar — cohort arithmetic does not reconcile **Fired:** `check_cohort_arithmetic.py` — `CASCADE_SUM` (the STROBE flow does not balance: start N − Σ(exclusions) ≠ final analytic N) and/or `RATE_BACKCALC` (a reported incidence rate does not reproduce from numerator/person-time). **Severity:** Fatal → Anticipated **Major**. **Category:** A. Study Design & Data Integrity. ## Anticipated Major Comment (how a reviewer will put it) > The participant flow does not add up. The Methods report 1,000 screened and exclusions of > 120 + 60 + 40, which leaves 780, but the analytic cohort is given as 800 (Figure 1 vs > Results, first paragraph). Please reconcile the flow diagram, the text, and Table 1 so the > numbers are internally consistent, and state which figure is correct. > > Relatedly, the incidence rate of 12.0 per 1,000 person-years with 9,800 person-years > implies ~118 events, but 96 events are reported (Table 2). Please confirm the numerator, > denominator, and rate. ## Severity / category rationale This is **Fatal** because the cohort size and the event/person-time are the denominators of every downstream estimate — if they are inconsistent, the reader cannot trust any rate or effect size. It is **category A** (data integrity), not a wording issue. ## Fix Recompute the cascade and the rate from the source data (never hand-retype), correct the diagram/text/table to a single set of numbers, and add a one-line reconciliation note if a late exclusion was applied. `fixable_by_ai: false` — the numbers must come from the data, not be guessed; the author re-derives from the CSV. ## R0-ready line > R0-A1 (Major, Fatal): STROBE flow and the incidence rate do not reconcile (Figure 1 / > Results / Table 2); re-derive from source and unify. [gate: check_cohort_arithmetic] -
cross_sectional_mediation.md 2.8 KB
# Exemplar — a causal mediation chain claimed from cross-sectional data **Fired:** `observational_confounding.md` probe **O13** (cross-sectional mediation: temporal order & sequential ignorability), with **O3** (the cross-sectional design signal) nearby. Surfaced by reading the mediation claim against the design, not an automated gate. **Severity:** Fatal → Anticipated **Major**. **Category:** D. Clinical Framing & Importance (with C. Validation for the missing sensitivity analysis). ## Anticipated Major Comment (how a reviewer will put it) > The analysis reports a bootstrapped indirect effect and concludes that the exposure influences > the outcome "through" the mediator. But exposure, mediator, and outcome are all measured at a > single visit, so the design cannot establish the X → M → Y temporal sequence — the same data are > equally consistent with the mediator preceding the exposure, with reverse causation, or with a > shared common cause. The 5,000-sample bootstrap quantifies the sampling variability of the a×b > product; it does not address whether the mediated effect is identified. Mediation additionally > assumes no unmeasured mediator–outcome confounding, which is not plausible here and is not probed. > Please reframe the indirect effect as association-level ("consistent with mediation"), state that > a single-timepoint design cannot order the three variables, add a sensitivity analysis for > unmeasured mediator–outcome confounding (e.g. an E-value for the indirect effect), and reserve the > causal-mediation claim for a longitudinal design that measures the mediator before the outcome. ## Severity / category rationale **Fatal** because the headline contribution *is* the causal chain, and the design cannot support it; a reader could act on a mediation pathway that the data do not establish. The bootstrap CI is a true result but answers the wrong question (precision, not identification). The fix is reframing + a sensitivity analysis, not a new dataset — but the claim cannot stand as written. ## Fix Down-scope every claim site (Abstract, Results, Discussion, Title) from "X affects Y through M" to "the cross-sectional pattern is consistent with mediation by M." Add an unmeasured M–Y confounding sensitivity analysis and report proportion-mediated with uncertainty (and only when the total effect is well-estimated). Report against AGReMA. `fixable_by_ai: true` for the wording; the sensitivity analysis is `requires_reanalysis` (route to `/analyze-stats`). ## R0-ready line > R0-D1 (Major, Fatal): cross-sectional design but a causal mediation chain (X→M→Y) is claimed from > a bootstrapped indirect effect — reframe to association-level, add an unmeasured M–Y-confounding > sensitivity analysis, defer the causal claim to a longitudinal design. [probe: O13] -
estimand_drift_posthoc_primary.md 2.1 KB
# Exemplar — the reported "primary" differs from the registered one **Fired:** `check_claim_artifact.py` — `PRIMARY_REASSIGNED` / `ESTIMAND_DRIFT` (the manuscript's stated primary analysis or estimand does not match the pre-registration / protocol), or an E-value attached to a non-primary or non-reproducing estimate. **Severity:** Fatal → Anticipated **Major**. **Category:** C. Validation & Statistical Reporting. ## Anticipated Major Comment (how a reviewer will put it) > The registered protocol names the complete-case model as the primary analysis, but the > manuscript presents the multiple-imputation model as primary (Methods, *Primary analysis* > vs the registration). Selecting the primary after seeing results is outcome-dependent and > can bias inference. Please either (a) restore the pre-registered primary and present the > other model as a pre-specified sensitivity analysis, or (b) if the change was unavoidable, > report both models coequally, disclose the change in the Abstract and a Limitations > paragraph, and lodge the corresponding registration amendment. > > The reported E-value of 3.10 should also be recomputed from, and attached to, the primary > estimate (it currently appears to derive from a different model). ## Severity / category rationale **Fatal** because *which* result is primary determines the paper's headline; choosing it post hoc is a credibility issue a methods reviewer catches deterministically. **Category C** (estimand provenance). This is the estimand-provenance-lock principle: primary contrast and derived statistics trace to the pre-registration, not to the data. ## Fix Restore the registered primary (or present both coequally with disclosure + amendment), recompute the E-value from the declared primary estimate, and propagate the framing to every claim site (Abstract, Highlights, any plain-language summary). `fixable_by_ai: false` — requires the registered protocol as the source of truth and a re-run. ## R0-ready line > R0-C1 (Major, Fatal): reported primary ≠ registered primary; restore or report coequally + > disclose + amend; recompute E-value from the primary. [gate: check_claim_artifact] -
over_adjustment_collider.md 2.6 KB
# Exemplar — over-adjustment: a consequence of the outcome in the adjustment set **Fired:** `observational_confounding.md` probe **O7** (over-adjustment / conditioning on a mediator or consequence of the outcome). This is the opposite-direction failure to O1 (measured-but-unadjusted, the `check_confounding_completeness.py` gate): here a covariate that the outcome physiologically *drives* is wrongly **inside** the primary model. Not an automated gate — surfaced by reading the adjustment set against the outcome's causal structure (a DAG). **Severity:** Fatal → Anticipated **Major**. **Category:** C. Validation & Statistical Reporting (with E. Reproducibility for the sensitivity model). `requires_reanalysis: true`. ## Anticipated Major Comment (how a reviewer will put it) > The outcome is eGFR, and the multivariable model adjusts for serum uric acid. Because urate is > renally excreted, a lower eGFR mechanically raises serum uric acid — uric acid is a *consequence* > of the outcome, not a confounder of the exposure–eGFR relationship. Adjusting for it (and, if > similarly downstream, blood pressure or HbA1c) is over-adjustment and can attenuate the very > association the study reports. The adjustment set appears to have been chosen because these > variables differ across exposure groups in Table 1, but baseline imbalance is not a > confounder-selection criterion. Please justify the adjustment set with a DAG, report a primary > model that excludes outcome-consequences, and show the current model as a sensitivity analysis > with collinearity (VIF) disclosed. ## Severity / category rationale **Fatal** because removing the over-adjusted covariate can move the headline estimate — the result as reported may be biased toward or away from the null. The fix is not wording: it needs a re-fit under a defensible adjustment set (`requires_reanalysis`), so `fixable_by_ai: false`. **Category C** (model specification), with the parsimonious-vs-kitchen-sink comparison reported under E. ## Fix Draw a DAG; classify each adjustment variable as confounder / mediator / outcome-consequence / collider. Keep only confounders in the primary model. Report a parsimonious history-based model as primary, the full model as a sensitivity analysis, and VIF for collinearity. Route the re-fit to `/analyze-stats`. If the estimate changes materially, propagate to Abstract and Conclusions. ## R0-ready line > R0-C1 (Major, Fatal, requires_reanalysis): eGFR model adjusts for serum uric acid (an outcome > consequence) — over-adjustment; re-fit a DAG-justified primary model excluding outcome- > consequences, report the current model as sensitivity + VIF. [probe: O7] -
prediction_two_null_conflation.md 2.6 KB
# Exemplar — a "negative" prediction study that conflates two different nulls **Fired:** `clinical_prediction_model.md` probe **CP2** (incremental-value-vs-marginal-effect two-null distinction), with **CP1** (apparent vs optimism-corrected calibration/DCA) alongside. Surfaced by reading the negative conclusion against the powered analyses, not an automated gate. **Severity:** Fatal → Anticipated **Major**. **Category:** C. Validation & Statistical Reporting (D. Clinical Framing for the conclusion wording). Calibration re-correction is `requires_reanalysis: true`. ## Anticipated Major Comment (how a reviewer will put it) > The Conclusion states the marker "did not predict" the outcome, but two distinct results are > merged. The incremental-value analysis is well powered — the change in AUC is ≈ 0 and bootstrap > optimism correction does not favour (and may reverse toward the parsimonious model away from) the > larger model — so "adding the marker did not improve prediction" is supported. The marginal > adjusted odds ratio, however, has a confidence interval that still admits an effect up to about > 1.7; that arm is underpowered, so "the marker is not associated with the outcome" is *not* > supported. Please report these separately. Relatedly, discrimination was optimism-corrected but the > calibration slope (0.99) and the decision curves are in-sample and described as "well calibrated" > and "clinically useful" without the apparent caveat — an in-sample slope near 1.0 is expected and > does not establish out-of-sample calibration. ## Severity / category rationale **Fatal** because the blanket "X did not predict Y" overreads the underpowered marginal arm and a reader could wrongly abandon the marker. The CP1 calibration/DCA labelling and the optimism extension are reporting-grade but mandatory. Splitting the two nulls is wording + correct framing; re-correcting calibration/DCA needs a re-run, so that part is `fixable_by_ai: false`. ## Fix Rewrite the conclusion to two sentences: (1) the well-powered incremental-value null ("adding the marker did not improve discrimination, calibration, or net benefit"), and (2) the underpowered marginal result ("the marginal association could not be excluded; the CI admits OR up to ~1.7"). Label in-sample calibration and decision-curve results "apparent," or extend optimism correction to them via `/analyze-stats`. Report EPV per nested model (CP3). ## R0-ready line > R0-C1 (Major, Fatal): negative prediction conclusion conflates a well-powered ΔAUC≈0 (incremental > value) with an underpowered marginal OR (CI admits ~1.7); split the two nulls and label in-sample > calibration/DCA "apparent." [probe: CP1/CP2] -
README.md 3.1 KB
# Exemplar Anticipated Comments — gate result → self-review finding `/self-review` runs deterministic Phase 2.5 gates (cohort arithmetic, confounding completeness, scope coherence, claim-vs-artifact) and a systematic A–K check, then writes **Anticipated Major / Minor Comments** the author can fix before a reviewer sees them. This directory models how a *gate hit* becomes a well-formed Anticipated Comment — the missing worked-example layer between "the gate fired" and "here is the comment + fix." Each file shows the same shape `/self-review` Phase 3 produces: 1. **What fired** — the deterministic gate (or category) and the specific signal. 2. **Anticipated Major/Minor Comment** — phrased the way the *reviewer* will phrase it, so the author reads it as the warning it is. 3. **Severity** — Fatal (conclusion-threatening / design-level → Anticipated **Major**) vs Fixable (reporting-level → Anticipated **Minor**). 4. **Category** — the closest letter (A–K). 5. **Fix** — the concrete change to make now, and whether it is `fixable_by_ai`. 6. **R0-ready** — a one-line form suitable for Phase 3b numbering into the `/revise` pipeline. These differ from `/peer-review`'s `exemplar_reviews/`: those model a *reviewer's* partner-voice comment to authors; these model the *author's* anticipation-and-fix entry. ## Contents - `cohort_arithmetic_mismatch.md` — STROBE cascade / rate back-calc fails (gate: `check_cohort_arithmetic.py`) → category A. - `unadjusted_confounder.md` — an imbalanced measured covariate left out of the model (gate: `check_confounding_completeness.py`) → category C/E. - `over_adjustment_collider.md` — the opposite failure: a consequence/mediator of the outcome wrongly inside the model (probe: O7) → category C, `requires_reanalysis`. - `prediction_two_null_conflation.md` — a "negative" prediction study merging a well-powered incremental-value null with an underpowered marginal-effect null, plus apparent (uncorrected) calibration/DCA (probes: CP1/CP2) → category C/D. - `scope_overreach_cross_sectional.md` — a prognostic/surveillance claim from a cross-sectional design (gate: `check_scope_coherence.py`) → category D. - `cross_sectional_mediation.md` — a causal X→M→Y mediation chain claimed from single-timepoint data via a bootstrapped indirect effect, no temporal order / no M–Y-confounding sensitivity (probe: O13) → category D/C, sensitivity is `requires_reanalysis`. - `estimand_drift_posthoc_primary.md` — the reported "primary" differs from the registered one (gate: `check_claim_artifact.py`) → category C. ## Curator guidelines - **Synthetic only.** Author the example with placeholder numbers; never paste a real manuscript's text or data. No PII, no real citations, English only. - **One gate/finding per file**, end to end (fired → comment → severity → category → fix → R0 line). - **Tie severity to the same Fatal/Fixable rule** the skill uses, and name the gate/script that surfaces it (do not re-document the gate — link by name). - Keep each file ~40–70 lines. -
scope_overreach_cross_sectional.md 1.9 KB
# Exemplar — a prognostic/surveillance claim from a cross-sectional design **Fired:** `check_scope_coherence.py` — `CROSS_SECTIONAL_PROGNOSTIC` (a single-timepoint / cross-sectional design signal co-occurs with a conclusion-region action verb such as "predict", "progression", "rescreen", "surveillance"). **Severity:** Fatal → Anticipated **Major**. **Category:** D. Clinical Framing & Importance. ## Anticipated Major Comment (how a reviewer will put it) > The study is cross-sectional — exposure and outcome are measured at a single visit > (Methods, *Study design*) — but the Conclusion recommends a "surveillance interval" and > describes "disease progression." A cross-sectional association cannot establish temporal > order or progression, so these claims outrun the design. Please reframe the conclusion to > the association actually estimated (e.g., "X was associated with Y at the index visit") and > move any prognostic or surveillance language to a clearly-labeled hypothesis for future > longitudinal work. ## Severity / category rationale This is **Fatal** because the gap is between the *design* and the *clinical action the paper recommends* — a reader could adopt a surveillance schedule that the data do not support. The fix is reframing, not new analysis, but the claim cannot stand as written. **Category D** (framing / endpoint↔conclusion scope). ## Fix Rewrite the Abstract Conclusion and the Discussion's closing claim to the cross-sectional estimand; demote prognostic/surveillance statements to "future directions." Check that the Title does not imply prediction. `fixable_by_ai: true` (wording), but verify the author agrees the longitudinal claim is genuinely unsupported before softening. ## R0-ready line > R0-D1 (Major, Fatal): cross-sectional design but the Conclusion claims surveillance/ > progression; reframe to the index-visit association. [gate: check_scope_coherence] -
unadjusted_confounder.md 1.9 KB
# Exemplar — an imbalanced measured covariate is left out of the model **Fired:** `check_confounding_completeness.py` — `UNADJUSTED_IMBALANCED` (a covariate that is measured and imbalanced across exposure groups in Table 1 is not in the Methods adjustment set). **Severity:** usually Fixable → Anticipated **Minor**; Fatal → Anticipated **Major** when the covariate plausibly explains the primary association. **Category:** C. Validation & Statistical Reporting (with E. Reproducibility for the model spec). ## Anticipated Major/Minor Comment (how a reviewer will put it) > Baseline smoking differs between the exposed and unexposed groups (Table 1: 41% vs 23%, > standardized mean difference 0.39), and smoking is a plausible confounder of the > exposure–outcome relationship, but it does not appear in the adjustment set (Methods, > *Statistical analysis*). Please either add it to the multivariable model or explain why it > was excluded, and report whether the primary estimate changes when it is included. ## Severity / category rationale It is **Minor/Fixable** when the covariate is one of several already-adjusted and the result is robust; it escalates to **Major/Fatal** when it is imbalanced *and* on the causal path to the outcome *and* the primary estimate is near the significance boundary — then an unadjusted confounder could flip the conclusion. Tag **category C** (the estimate's validity), noting the model spec under E. ## Fix Add the covariate to the model (or pre-specified sensitivity model), report the adjusted estimate with its CI, and state the change from the unadjusted estimate. If exclusion was deliberate (e.g., collider/mediator), say so and cite the DAG rationale. `fixable_by_ai: false` — requires re-running the model on the data. ## R0-ready line > R0-C2 (Major, Fatal-if-flips): smoking is imbalanced (SMD 0.39) but unadjusted; add to the > model and report the change in the primary estimate. [gate: check_confounding_completeness]
-
-
phases
-
confounding_completeness.md 5.2 KB
# Phase 2.5e — Confounding Completeness (observational only) Load-on-demand procedure for self-review Phase 2.5e. Loaded only when the manuscript is observational and the central claim is an adjusted exposure–outcome association — an RCT, diagnostic-accuracy, SR/MA, or descriptive review never pays the cost of reading it. --- For an observational study, the highest-yield reviewer finding is also the most mechanical, and a prose pass misses it because the manuscript text is internally consistent: a covariate that was **measured**, is **imbalanced across exposure groups** in the baseline table, and is **absent from the adjustment set** is residual confounding by a measured variable. Only a join of the exposure- stratified Table 1 against the Methods adjustment set exposes it. This is probe O1 of `references/domain-probes/observational_confounding.md`, run here as a deterministic gate so the finding lands without the `--panel` cost. **When to run:** manuscript type is observational (cohort, case-control, cross-sectional, health-screening registry) and the central claim is an adjusted exposure–outcome association. Skip for RCTs and descriptive studies. **The failure pattern:** > A cross-sectional screening-cohort manuscript reported an adjusted association > while Table 1 showed several laboratory and lifestyle covariates significantly > imbalanced across the exposure groups — none of which were in a > demographic-and-comorbidity adjustment set. The single-pass > review passed it; only an epidemiology panel reviewer who read the Table 1 CSV > against the Methods caught the gap. After refitting with extended adjustment the > primary estimate held, but the manuscript had claimed robustness it had not shown. **Procedure:** 1. **Locate the exposure-stratified baseline table** as a CSV (e.g. `table1_by_<exposure>.csv` from `/analyze-stats`) and the Methods adjustment set (the variables after "adjusted for ..."). 2. **Run the deterministic gate:** ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_confounding_completeness.py" \ --table1 table1_by_<exposure>.csv \ --adjusted-list "age, sex, BMI, hypertension, diabetes" \ --exposure-defining-list "body mass index, waist, fasting glucose, triglycerides, HDL cholesterol" \ --out qc/confounding_completeness.json --strict ``` It emits a reconciliation table (covariate | imbalance p | SMD | in adjustment set? | verdict) and flags each **measured-but-unadjusted imbalanced** covariate as an `UNADJUSTED_IMBALANCED` Major candidate. The gate resolves DB column codes against a prose adjustment set (alias map), and when the Table 1 has no p / SMD column it **computes the SMD from per-stratum "mean ± SD" group columns** (`--group-cols A,B`, or auto-detected). When the CSV is unavailable, apply probe O1 by hand from the published Table 1. **Guideline-defined exposures (MASLD / metabolic syndrome / CKM / sarcopenia / frailty):** pass `--exposure-defining-list` (the components of the exposure's own diagnostic criteria). Those rows are marked `EXPOSURE_DEFINING_EXEMPT`, **not** Major — adjusting for them is over-adjustment (probe O7), not a confounding fix. Without the exemption the gate false-positives a Major on every metabolic-criteria covariate. The residual-confounding remedy is an extended-adjustment model adding only **non-defining prognostic** covariates. 3. **Each `UNADJUSTED_IMBALANCED` covariate is an Anticipated Major Comment** (category: A. Study Design & Data Integrity), with the suggested fix: report an **extended-adjustment sensitivity model** that adds the omitted covariates and states whether the primary estimate is materially unchanged; the original model stays primary only if the extended model agrees. 4. **Then apply the rest of the observational probe set** (O2 adjustment-set provenance, O3 selection/collider bias, O4 exposure measurement validity, O5 missing-data mechanism & complete-case collapse, O6 residual-confounding E-value, O7 over-adjustment, O8 analysis unit, O9 outcome construct validity, O10 overlapping-subset gradient) from `references/domain-probes/observational_confounding.md` — these are prose probes (O1/O7/O8 are the data-checkable ones), and complement the generic Phase 2 categories rather than replacing them. 5. **Extended-adjustment frame discipline.** When the extended-adjustment model adds covariates that carry missingness, its analytic n shrinks. Comparing the adjusted estimate to the **full-frame** unadjusted estimate confounds adjustment with case-concentrated missingness ("adjustment inflated the estimate" when the drift is who-was-dropped). The fair anchor is the **unadjusted estimate refit on the reduced complete-case frame**; flag any "adjustment changed the estimate" claim that compares across different frames, and route the refit to `/analyze-stats` (`requires_reanalysis`). **Adjustment-set matching is fuzzy** (a table row "Smoking, pack-years" vs an adjustment token "smoking"): read the reconciliation table rather than trusting the count, and confirm each flagged covariate is a plausible cause of the outcome (not a mediator or collider, which O2 covers) before raising it. -
phase2_5a2_design_power.md 4.2 KB
# Phase 2.5a-2 — Design & power statistic provenance (computed, not extracted) Phase 2.5a traces data-derived numbers back to a CSV and a primary source. **Design and power statistics are a different class and a common blind spot**: the minimum detectable effect (MDE), a-priori or post-hoc power, the required sample size for a future trial, and the a-priori effect-size assumptions behind them are *computed*, not extracted, so they have no CSV row or source-paper Table to trace to. They routinely escape both the internal-consistency check and the source-fidelity audit above. **The failure pattern:** > A pilot study reported a minimum detectable effect that no standard two-sample method > reproduces at the stated n, alpha, and power. It survived > several review rounds because no committed script computed it — the value had been hand-entered — > and one reviewer even cited the figure approvingly. In the same manuscript, a set of future-trial > sample sizes was numerically correct but had been produced with an exact noncentral-t tool, while > the committed script used a normal approximation and printed different numbers: right value, no > reproducible provenance. **Procedure:** 1. **Inventory design/power claims.** Search for: "minimum detectable", "detectable effect", "MDE", "power" (80% / 90% / "1 − beta"), "sample size", "n = N per arm/group", "to detect", "powered to", "a priori", and any a-priori planning effect size (Cohen's d / f / OR used for sizing). 2. **Require a reproducible source for each.** Every such value must be produced by committed code (e.g. `statsmodels` `TTestIndPower`, a G*Power-equivalent, or an explicit noncentral-t computation), with the inputs stated in the manuscript: n per arm, alpha, power, allocation ratio, and one- vs two-sided. A value with no committed-code source is the highest-risk case. 3. **Recompute independently** with a standard tool, then classify: - **Not reproducible by any standard method** → likely a calculation error (Major; P0 if it is a headline claim). This is the minimum-detectable-effect case above. - **Reproducible only by a method the committed script does not implement** (e.g. the manuscript value is noncentral-t but the script is a normal approximation) → provenance / method drift. The number may be correct, but update the committed code so it reproduces the reported value (Major: reproducibility, not correctness). 4. **Method-consistency across the manuscript.** All power, sample-size, and MDE statistics in one paper should share a single method family (e.g. all noncentral-t). A mix of normal approximation and exact-t within one manuscript signals that some values were computed in an ad-hoc side tool. 5. **Any non-reproducible design/power value is a Major Comment;** a non-reproducible headline power or MDE claim is a P0 submission blocker. **Hand-entered design/power statistics are a code smell even when correct.** If no committed function emits the value, flag it: the next revision will re-introduce the risk, and a reviewer who recomputes will not match the manuscript. **`POWER_MODEL_MISSPEC` — the power/MDE simulation's adjustment set must match the primary model.** For cohort "negative findings," the whole conclusion leans on the MDE ("the literature effect of 1.2–1.5 cannot be excluded"), so the MDE must be computed under the **same covariate set as the primary analysis**. When a committed power/MDE script exists, read its model formula: if it fits `y ~ exposure + age` (2 covariates) while the primary model adjusts for 6, it **overstates power** (omitted covariates inflate the apparent precision) — the MDE is too small and the negative claim too strong. Re-running a parametric bootstrap under the full model is the fix (in one worked case MDE moved from a 2-covariate "OR 1.67" to a full-model "OR ≈ 1.70"). A power/MDE whose script omits primary-model covariates → Major (P0 when the MDE is a headline). This is `requires_reanalysis` (re-simulate, not a prose edit). **`POWER_VALUE_INTERPOLATED`** — any `interpolat`/`approx`/`interp` token in a power/MDE CSV's provenance column means the headline value was never simulated on the grid; treat a non-reproducible headline power/MDE as Major. -
phase2_5a_source_fidelity.md 5.8 KB
# Phase 2.5a — Numerical Source-Fidelity Audit (External) Load-on-demand companion to `/self-review` Phase 2.5a. SKILL.md keeps the entry condition, the displayed-arithmetic gate, and the escalation rule; this file carries the 3-layer traversal procedure, the sampling strata, the precedent failure, and the four prose-judgement rules (hand-entered script inputs, statistic-type fidelity, stale derived CSVs, `[VERIFY-CSV]` tags). Read it when you are actually tracing sampled claims back to their primary sources. Internal consistency (Phase 2.5) is necessary but not sufficient. Numbers can be fully self- consistent across Abstract / Table / Text and still be wrong at the source — a single transcription error propagates cleanly through every downstream stage. Also run the **displayed-arithmetic** gate — a stated difference must equal the subtraction of its two displayed component values at the SAME precision: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_rounded_delta.py" \ --manuscript manuscript.md --out qc/rounded_delta.json ``` `ROUNDED_DELTA_MISMATCH` (Minor) fires when e.g. AUCs are shown as `0.70` and `0.73` (a displayed gap of 0.03) while the between-arm difference is stated as `0.02` — self-consistent only on the unrounded values. Fix: report components and the delta at one precision, or footnote that the delta is computed on unrounded values. A higher-precision component pair (`0.703` vs `0.726`) with a 2-dp delta is the legitimate unrounded case and is not flagged. **The failure pattern:** > A revision-era comparative meta-analysis reported a safety-outcome 2x2 with the > arm-level events direction-reversed relative to the primary-source Table. Internal > consistency passed because Abstract, Discussion, Table, and the R script all echoed > the same wrong values. The reversal was caught only by an explicit second-pass audit > that randomly sampled claims and traced each back to the primary paper. **When to run:** MA revisions, submissions, or any review where the user mentions "check against the source," "verify extraction," or "random sample." **Inputs the reviewer should expect:** - `manuscript.md` (or .docx converted to .md) - `extraction_final.csv` (or equivalent data-extraction spreadsheet) - A directory of primary-source PDFs (or equivalent accessible text) **Procedure:** 1. **Inventory numerical claims** in Abstract, Results, and Discussion (patterns: `\\d+/\\d+`, `\\d+\\.\\d+%`, `(95% CI:`, `p\\s*=\\s*0\\.`, `I\\^2`, `n\\s*=`, etc.). 2. **Stratified random sample** — draw 5 claims across: (a) pooled estimates, (b) subgroup / sensitivity results, (c) comparative-arm specific values, (d) study-level numbers (first-cited in narrative), (e) a claim introduced during revision if the draft is post-v1. Comparative-arm specific values and revision-introduced numbers are the two highest- yield strata — always include one of each. 3. **For each sampled claim, traverse 3 layers:** - **Layer 1 (Manuscript → CSV):** Find the row / column in the extraction CSV. - **Layer 2 (CSV → Primary source):** Locate the exact Table, Figure, or paragraph in the original paper. Record page number. - **Layer 3 (Analysis script → CSV):** If the claim came from an analysis script, read the script and confirm its input value matches the CSV cell. 4. **Record results in a table** and append to the report: | Claim (manuscript location) | CSV row/col | Primary source (paper, Table/Fig, page) | Script input | Match? | |---|---|---|---|---| 5. **Any mismatch is a Major Comment (M-level), not Minor.** Mismatches that reverse a direction or change a significance boundary are P0 blockers for submission. **Revision-specific rule:** If the manuscript contains `[VERIFY-CSV]` tags, treat each as a mandatory audit item regardless of the sampling size. The tag exists precisely because that number was introduced after the initial extraction pass and has not yet been independently checked. **Hand-entered analysis-script inputs are a code smell.** When Layer 3 reveals a `matrix(...)`, `c(1, 2, 3)`, or `data.frame(...)` line with numerical data and no CSV-coordinate comment, escalate to a Major Comment even if the audited values happen to match — the next revision will re-introduce the same risk. **Statistic-type fidelity (not just the value).** A prose sentence must match the table/CSV not only on the **number** but on the **statistic type**. A body sentence that reports a *median* ("median eGFR 92.8") while Table 1 reports a *mean* ("mean 91.3") for the same variable cannot be reconciled by a reviewer comparing the two — and the mismatch usually means one of them was not regenerated after a Table 1 rule change (see the mean/median-by-skewness rule in `/analyze-stats` `table-types/table1_demographics.md`). Treat a prose↔table statistic-type mismatch (mean vs median, SD vs IQR, n vs %) as a Minor Comment, or Major if it sits on a primary characteristic the conclusion leans on. Also re-check that any descriptive figure the prose quotes (e.g. "78.4% male") matches the *current* table value, not a stale earlier one. **Stale derived CSVs after a model/adjustment-set change (n mismatch).** When the primary model or adjustment set changes mid-revision, **every** derived CSV (Table 2, sensitivity tables, supplements) must be regenerated, or a stale file silently contradicts the new primary. The fastest tell is the analytic **n**: if a derived CSV's `n` differs from the manuscript's current primary n, suspect it is stale — and the conflict can flip a result's significance (a proteinuria sensitivity CSV left at the old `n = 4,914` / OR 4.52 contradicted the new primary `n = 4,214` / OR 3.99, significant ↔ not). Grep each derived CSV's `n` against the primary n; any divergence that is not explained by a stated sub-analysis restriction is a Major Comment, `requires_reanalysis` (re-run, not a prose edit — see Phase 4). -
phase2_5b_screening_counts.md 7.5 KB
# Phase 2.5b — Screening-Count Reconciliation from ID Sets Load-on-demand companion to `/self-review` Phase 2.5b. SKILL.md keeps the entry condition and the two deterministic gates; this file carries the ID-set recount procedure, the reconciliation-block template, and the precedent failures. Read it when the manuscript is an SR/MA (the ID-set recount), or when a cohort manuscript presents an ordinal tier / mutually-exclusive stratum split whose Ns must partition (the binning and composite-indicator branch). Internal consistency across Abstract/Methods/Results (Phase 2.5) + source fidelity of 2×2 and effect-size numbers (Phase 2.5a) do **not** cover study-count arithmetic. The latter is a separate failure mode: a prior-draft prose total ("30 → 32 after FLAG consensus") can survive every downstream pass because Abstract, Methods, Results, Discussion, Figure 1 caption, and even the supplementary consensus file all cite the same wrong number back to each other. **The failure pattern:** > A late-revision manuscript reports a set of study counts — qualitative synthesis, > narrative-only, full-text-excluded — that no longer match the screening artifacts. An ID-level > recount against the screening TSV and consensus sheet (with FLAG additions reconciled) moves > every one of them. The reported figures came from an early-draft assumption that was never > reconciled against the ID-level artifacts; downstream files (consensus markdown, supplementary > tables, edit plans) then propagated the same wrong total. Nothing but an explicit ID-set > recount finds it, because every document agrees with every other document. **When to run:** any SR/MA manuscript revision, regardless of stage. Run before Phase 3. **Inputs:** - Screening TSV with one row per full-text-reviewed record and an include/exclude column - Consensus spreadsheet (Excel/CSV) with one row per record requiring adjudication and a `Consensus` column (typical values: `Exclude`, `Include-qualitative`, `Include-bivariate`) - Any FLAG-adjudicated inclusion log documenting records added to the qualitative pool outside the primary screening TSV - The manuscript's Table 1 (or equivalent): the definitive list of studies contributing to the primary quantitative synthesis **Procedure:** 1. **Enumerate the ID sets:** - A = set of IDs marked INCLUDE in the screening TSV - B = set of IDs marked Exclude in the consensus spreadsheet - C = set of IDs marked Include-qualitative in the consensus spreadsheet - T = set of IDs represented in Table 1 (via author/year cross-match) 2. **Derive canonical totals:** - k_qualitative = |A \ B| + |C| - k_bivariate = |T| - k_narrative-only = k_qualitative − k_bivariate = |(A ∪ C) \ B \ T| - k_FT-excluded = |screening TSV rows| − |A| + |B ∩ A| + |(B \ A) encountered at FT stage| 3. **List the narrative-only IDs explicitly** — this is the highest-yield cross-check. A manuscript claiming "10 narrative-only studies" while the (A ∪ C) \ B \ T set contains only 2 IDs is an immediate P0 finding. 4. **Compare each derived total against the manuscript's prose claim** in Abstract, Methods §Study Selection, Results §Study Selection, Figure 1 caption, Discussion §Limitations, and any References §Narrative-Only heading. Any mismatch between derived total and manuscript prose = P0 Major Comment, blocking submission. 5. **Record results in a short reconciliation block** and append to the report: ``` | Quantity | Manuscript claim | ID-derived value | Status | |---|---|---|---| | k_full-text | 78 | 78 | ✓ | | k_qualitative | 32 | 24 | ✗ P0 | | k_bivariate | 22 | 22 | ✓ | | k_narrative-only | 10 | 2 (IDs 120, 474) | ✗ P0 | | k_FT-excluded | 46 | 54 | ✗ P0 | ``` **Any "N → M" transition claim in a consensus summary (e.g., "30 → 32 after FLAG consensus") that is not backed by an enumerable ID addition/subtraction set is itself a Major Comment**, because the transition is unverifiable by downstream audit. Require conversion of every such claim to explicit ID lists before closing the report. **Observational tier/stratum branch.** The same set-recount logic applies when a cohort manuscript presents an ordinal tier or mutually-exclusive stratum split. A partition that is claimed to be disjoint must satisfy `Σ(stratum N) == unique total` and `Σ(stratum events) == total events`; denominators that sum *above* the unique cohort double-count subjects, and a table where every stratum n equals the grand total is a stratum-total mis-entry rather than a partition. Run `check_cohort_arithmetic.py` (Phase 2.5 above) with the stratum CSV — its `PARTITION_OVERLAP` verdict is the cohort analogue of an ID-set mismatch and is a P0 Major: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_cohort_arithmetic.py" \ --manuscript manuscript.md --data analysis/strata.csv --strict ``` Also confirm the reference (baseline) row of any stratified hazard/odds table is present and labelled; a missing reference category makes the other strata uninterpretable. **Cross-script cut-point consistency (root cause of stratum-N drift).** When the same cohort is re-stratified in more than one analysis script — a primary table in one file, a sensitivity or secondary analysis in another — the derived categorical (age band, BMI category, eGFR stage, risk tier) must use one identical cut definition: same breaks, same interval closure (`right=`), same labels. If two scripts bin the same variable differently, per-stratum Ns drift between tables while the grand total still reconciles, and a stratum can spuriously cross a threshold — a `PARTITION_OVERLAP`/stratum-N check on the manuscript alone will not localize the cause. `check_binning_consistency.py` parses the analysis source (R/Python) and emits `BINNING_DRIFT` (Major) when one variable is derived with ≥2 different `(breaks, right)` signatures across files: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_binning_consistency.py" \ --root analysis --root scripts --strict ``` Precedent: a screening cohort binned age with `breaks=c(-Inf,45,50,60,Inf), right=FALSE` in the primary script and `breaks=c(-Inf,44,49,59,Inf), right=TRUE` in a threshold sensitivity script; fractional ages fell into different bands, shifting hundreds of participants and producing a spurious "reached" stratum in the sensitivity table that vanished once the binning was harmonized. Fix at the source by defining each cut once in a shared helper that every script sources. The same gate also covers the **composite-indicator** sibling failure: a derived 0/1 component (e.g. a metabolic-syndrome criterion built from `as.integer(a >= x | b == 1 | c == 1)`) that is re-built in a second script with a clause dropped or added. It splits each definition into comparison atoms on the top-level `|`, compares them as a SET (clause order, whitespace, outer parentheses, dataframe `df$` qualifiers and commutative `&`-operands are normalized away), and emits `DERIVED_DEF_DRIFT` (Major) when one variable carries ≥2 distinct atom sets across scripts. Precedent: `mets_bp <- as.integer(bl_he_sbp>=130 | bl_he_dbp>=85 | bl_tx_hypertension_med==1 | bl_hypertension==1)` in the benchmark script vs the same name without the final `| bl_hypertension==1` in a re-analysis script. The two definitions classify different participants, so the metabolic-syndrome C-index computed from each disagreed in the fourth decimal — enough to put two different values for one quantity into a main table and its supplement, and small enough that nobody reading either artifact alone would notice. -
phase2_5c_reference_scans.md 7.8 KB
# Phase 2.5c / 2.5c-2 — Reference hallucination and adequacy scans Numerical audits (2.5/2.5a/2.5b) cover in-text numbers; they do **not** cover reference-list integrity. LLM-drafted or co-author-handed-in bibliographies frequently contain fabricated DOIs, wrong author/year combinations for a real DOI, or plausible-looking references that never existed. These slip past human proofreading because the surface form looks canonical. **When to run:** every manuscript at self-review, regardless of stage. Mandatory before submission and before any revision circulation to co-authors or the editor. **Procedure:** 1. **Locate the bibliography.** From `SSOT.yaml` → `truth.refs_bib` (fallback `manuscript/_src/refs.bib` for legacy projects). If `SSOT.yaml` is absent, scan `references/library.bib` as a last resort. 2. **Invoke `/verify-refs`** on the resolved bib. The skill writes `qc/reference_audit.json` with a per-entry verdict (`VERIFIED` / `FABRICATED` / `UNVERIFIED`) and a top-level `submission_safe` boolean. ```bash # equivalent CLI form (same result as invoking the skill). # verify_refs.py takes a positional input (the .bib path) and writes its audit # to <project-root>/qc/reference_audit.json (path derived from --project-root). BIB="$(python3 -c "import yaml; print(yaml.safe_load(open('SSOT.yaml'))['truth']['refs_bib'])")" python3 skills/verify-refs/scripts/verify_refs.py "$BIB" --project-root . --strict ``` When both reference QC and cross-reference QC are needed in one pass, prefer the master orchestration entry point in `/manage-refs` — it chains `check_citation_keys.py` → `verify_refs.py --strict` → `render_pandoc.sh` (optional) → `check_xref.py --strict` and writes `qc/pre_submission_gate.json` as the single submission-readiness artifact: ```bash bash "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/manage-refs/scripts/pre_submission_gate.sh" \ --md manuscript/manuscript.md \ --bib manuscript/_src/refs.bib \ --docx submission/<journal>/manuscript.docx \ --allow-separate-attachments # see Phase 2.5d for when this is appropriate ``` 3. **Read `qc/reference_audit.json`.** For each entry not marked `VERIFIED`, add a row to the reconciliation block below. `FABRICATED` entries are P0 Major Comments (block submission). `UNVERIFIED` entries are Minor Comments unless the manuscript is at a circulation/submission gate, in which case they escalate to Major. For each `duplicate_findings[]` entry (category `duplicate_pmid` / `duplicate_doi`), add a Major Comment row noting the duplicated `ref_ids` pair and recommend cite renumbering — duplicates block submission (P0 Major) regardless of per-record `VERIFIED` status. 4. **Cross-check placeholder + pagination drift.** Run, on every round: ```bash grep -nE '\[@NEW:|\[N\]|\[N–N\]|e0{3}.{0,5}e0{3}|in[ .]?press|\bTBD\b|forthcoming' manuscript/ ``` Two failure classes: - **Citation-queue placeholders** (`[@NEW:topic]`, `[N]`, `[N–N]`): a citation slot that was never resolved. Any remaining at self-review is a P0. - **Pagination placeholders** (`e000–e000`, `in press`, `TBD`, `forthcoming`): `/verify-refs` (Phase 2.5c step 2) marks these `UNVERIFIED` with `note = "pagination_placeholder"` but cannot judge centrality from the .bib alone. **Here, with the manuscript in hand, decide centrality:** if the unresolved reference supports a method choice or a headline claim (grep the citekey/marker against the Abstract, the Statistical Analysis subsection, and the first Results paragraph), escalate it to a **P0 Major** rather than a generic Minor. A method-load-bearing citation that is still "in press / e000" at submission is a blocker. Include each in the reconciliation block. 5. **Record results in a short reconciliation block** and append to the Phase 3 report: ``` | Citekey | Verdict | Source check | Status | |---|---|---|---| | Kim_2024_Validation | VERIFIED | DOI + PubMed match | ✓ | | Park_2023_Radiomics | FABRICATED | DOI resolves to unrelated paper | ✗ P0 | | Lee_2022_DeepLearning | UNVERIFIED | No DOI/PMID, title not found | △ Major before submission | | [@NEW:segmentation_review] | PLACEHOLDER | unresolved citation queue | ✗ P0 | ``` **Short-circuit rule:** if `qc/reference_audit.json` already exists with a bib-hash match within 60s (P9 cache TTL, pending), the scan MAY reuse it; otherwise re-run. Never consume a stale audit from a prior manuscript revision. **Do NOT fabricate replacement references** if any entry fails. Fix-forward belongs to `/search-lit` and `/lit-sync`, not to this skill. Self-review only reports the failure and blocks submission. ### Phase 2.5c-2: Reference Adequacy Scan Phase 2.5c covers reference **integrity** — are the cited references real (fabricated / unverified / duplicate / placeholder)? It does **not** ask whether there are *enough* references, in the right sections, grounding every named method. That is reference **adequacy**, and it is the failure mode behind a draft with thirteen references where the Statistical Analysis subsection names a competing-risk model, multiple imputation, the E-value, and an eGFR equation with zero citations. Keep the two strictly separate: an integrity failure blocks because a citation is *wrong*; an adequacy failure flags because a citation is *missing*. **When to run:** every manuscript at self-review, after the integrity scan. The two share the manuscript and the resolved bib path. **Procedure:** 1. **Run the deterministic checker.** Resolve the article type from `project.yaml` (passed verbatim; the script's alias map handles repo paper-type names) and the journal cap from the target journal profile when known: ```bash python3 "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/self-review/scripts/check_reference_adequacy.py" \ --manuscript manuscript/manuscript.md --bib "$BIB" \ --article-type "$TYPE" ${CAP:+--journal-cap "$CAP"} \ --out qc/reference_adequacy.json --strict ``` It reports the cited-reference count vs the article-type target, the section distribution (Introduction / Methods / Results / Discussion), every named method found in the Methods/Statistical-Analysis block, which of them lack a citation in their paragraph, and a `methods_zero_citations` flag. 2. **Fold `findings[]` into the review.** Each finding becomes a standard `issues[]` entry (so `/revise` and downstream consumers ingest adequacy and other comments uniformly), **additively** carrying the machine-readable `issue_type` + `subtype` alongside the usual fields, under `category: "F" / category_name: "Reporting Completeness"`: ```json {"id":"M2","severity":"major","category":"F","category_name":"Reporting Completeness", "issue_type":"reference_adequacy","subtype":"methods_named_method_uncited", "location":"Methods - Statistical Analysis", "description":"Fine-Gray competing-risk model is named without a canonical citation.", "fixable_by_ai":false, "suggested_fix":"Run /search-lit for the canonical Fine-Gray competing-risk source, sync via /lit-sync, then rerun /verify-refs --strict."} ``` **Severity:** `methods_zero_citations` (original / AI-validation / meta-analysis) and each uncited statistical method → **Major** (a P0 candidate before submission when the method is central to the primary or a sensitivity analysis); each uncited reporting/diagnostic standard → **Minor**; a total count below the article-type target → **Major** when far below (under half the floor), otherwise **Minor**, scaled also by stage (escalate at a submission/circulation gate). 3. **Fix-forward, not fabricate.** As in Phase 2.5c, this skill never writes replacement references. Every adequacy finding carries `fixable_by_ai: false`; the remedy is `/search-lit` (Manuscript Paper Reference Pool mode) → `/lit-sync` → `/verify-refs --strict`, which the author runs. -
phase2_5d_xref_qc.md 5.5 KB
# Phase 2.5d — Cross-Reference QC (Manuscript ↔ rendered DOCX) Load-on-demand companion to `/self-review` Phase 2.5d. SKILL.md keeps the two gates, the severity policy table, and the no-auto-fix rule; this file carries the precedent failure, the input-location procedure, the reconciliation-block template, and the comment-emission convention. Read it when a rendered DOCX exists and the xref gate has fired. Before the DOCX is built, run the **markdown-stage orphan gate** — every captioned `Figure N.` / `Table N.` must be cited at least once elsewhere in the body: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_figure_citation.py" \ --manuscript manuscript.md --out qc/figure_citation.json ``` `FIGURE_ORPHAN` / `TABLE_ORPHAN` (Minor) catch a newly-added float that has a legend but no in-text citation — the early, no-build counterpart to `check_xref`'s `UNCITED` verdict, which catches the same class on the rendered DOCX (below). Reference-list integrity (Phase 2.5c) does **not** cover Table/Figure cross-references. This is a separate failure mode where in-text citations ("Supplementary Table S4 reports a sensitivity analysis") resolve to a different caption in the rendered DOCX ("Supp Table S4 = a diagnostics table") because the build script carries its own legacy SSOT. Internal consistency (Phase 2.5) cannot detect it — both the prose and the build artifact echo their own divergent truths cleanly. **The failure pattern:** > Body prose cites a supplementary table as a sensitivity analysis; under that > number the rendered DOCX carries a diagnostics table instead. Several further > supplement numbers mismatch the same way, and two more are cited in the > manuscript but absent from the rendered DOCX entirely. > Caught only on co-author circulation review. **When to run:** every manuscript at self-review when a rendered DOCX exists (e.g., circulation drafts, post-build pre-submission checks). Skip only if no DOCX build has occurred yet (early drafts). **Procedure:** 1. **Locate inputs.** `manuscript/manuscript.md` (or the SSOT `truth.manuscript_md`) and the rendered DOCX (typically `manuscript/manuscript_final.docx` or the most recent circulation `.docx`). 2. **Invoke the shared script** (lives in `/manage-refs`): ```bash python3 "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/manage-refs/scripts/check_xref.py" \ --md manuscript/manuscript.md \ --docx manuscript/manuscript_final.docx \ --out qc/xref_audit.json \ [--allow-separate-attachments] ``` The script writes `qc/xref_audit.json` with per-label rows tagged `OK | MISSING_DOCX | MISSING_BODY | MISMATCH | UNCITED | NOT_CITED_NO_BODY`, a top-level `submission_safe` boolean, and a `policy.allow_separate_attachments` field that records which severity policy applied. 3. **Translate findings to anticipated comments.** Severity mapping depends on the journal's figure/table submission policy. Many radiology and medical journals (e.g., European Radiology, Radiology, AJR) accept figures and tables as separate attachment files rather than inline in the manuscript DOCX; for those workflows pass `--allow-separate-attachments`. That flag downgrades two things, and they are not equally well evidenced — the audit JSON and the console output keep them apart, and so should you: - `MISSING_DOCX` — a `--docx` was supplied and PROVED the float is not in the rendered main document. That is what a separate attachment looks like. - `MISSING_BODY` where **no `--docx` was supplied** — nothing was checked. The float is either separately attached, as declared, or a caption nobody wrote. Excused on the author's word; counted in `summary.downgraded_unchecked`. `MISMATCH` remains P0 regardless. So does `MISSING_BODY` when the float IS in the rendered DOCX: the build pipeline is then the only place that knows the caption text, which is SSOT drift and not an attachment style. | Status | Default policy | With `--allow-separate-attachments` | |---|---|---| | `MISSING_DOCX` | **Major (P0)** — cited Table/Figure absent from rendered output | **Minor** — figure/table is separately attached per journal policy | | `MISSING_BODY` | **Major (P0)** — no body caption definition | **Major (P0)** when the float IS in the rendered DOCX (SSOT drift). **Minor** when no `--docx` was supplied — excused without evidence, and reported as such | | `MISMATCH` | **Major (P0)** — caption text disagrees between body and rendered DOCX | **Major (P0)** (no change) | | `UNCITED` | Minor — orphan caption that should be cited or removed | Minor (no change) | 4. **Append a reconciliation block to the Phase 3 report:** ``` | Label | Status | Body caption | DOCX caption | Verdict | |---|---|---|---|---| | Supplementary Table S4 | MISMATCH | Sensitivity analysis | Diagnostics table | ✗ P0 | | Supplementary Table S8 | MISSING_DOCX | (defined in body) | — | ✗ P0 | | Figure 2 | UNCITED | Forest plot of subgroups | Forest plot of subgroups | △ Minor | ``` 5. **Emit each P0 row as a separate `M`-numbered Major Comment** with `category: "F"` (Reporting Completeness) and `fixable_by_ai: false` (build script changes are out of scope for the auto-fix loop — they require pipeline-side fixes per `/write-paper` Step 7.6a routing). **Do NOT auto-fix cross-reference defects in `--fix` mode.** Caption rewrites in the body without re-running the DOCX build will simply move the mismatch. Surface as Major Comments and let the user route to `/write-paper` Step 7.6a. -
phase2_5f_claim_artifact.md 12.1 KB
# Phase 2.5f — Claim-vs-Artifact Cross-Check: procedure and verdict rationale Load-on-demand companion to `/self-review` Phase 2.5f. SKILL.md keeps the gate invocations and the verdict-severity map; this file carries the precedent failure, the per-verdict rationale, the resolution paths, and the four checks no script makes. Read it when a Phase 2.5f gate fires and you need to know what the verdict means and how to resolve it — or when the manuscript has a pre-registration to reconcile against. Phases 2.5–2.5e check numbers and adjustment sets. This phase checks **claims against the external artifacts they should trace to** — the pre-registration, the protocol, the analysis outputs. These are the errors that survive a single-pass review because the manuscript prose is internally consistent yet disagrees with the registration or the analysis it reports. The first scope is the two highest- value, deterministic instances; figure/flow-count reconciliation, Methods-promised- analysis completeness, and imputation-input integrity are separate subchecks (run `/make-figures` legend reconciliation and `/write-paper`'s Methods-promised gate). **The failure pattern:** > A manuscript reported a null primary association from a multiple-imputation model > and described it as "pre-specified," while the registered primary had been the > complete-case model that was significant — the primary had been re-designated after > the results were known. In the same paper an E-value was attached to the primary > HR but does not recompute from it (it came from a different, > non-primary estimate), and a second E-value bounded an exploratory cause-specific > hazard, not the headline contrast. None of these tripped the internal-consistency > checks; all three are deterministic against the registration and the arithmetic. **Procedure:** 1. **Run the cross-check** with the manuscript and (if available) the pre-registration / protocol / `project.yaml`: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_claim_artifact.py" \ --manuscript manuscript.md --prereg prereg.md \ --out qc/claim_artifact.json --strict ``` 2. **Estimand provenance.** The script raises `PRIMARY_REASSIGNED` (Major, category: A. Study Design & Data Integrity) only on **explicit** language that the primary was re-designated / switched / chosen post-hoc after results were known — a genuine P0. The fix is to report the pre-specified and the revised models **coequally** and disclose the change in the Abstract and Limitations, not to silently lead with the more favourable estimate. Two related verdicts are **advisory, not Major** — surface them as Anticipated Minor Comments to confirm, never as a blocker: `ESTIMAND_DRIFT` (the fuzzy manuscript↔registration primary token overlap is below threshold — noisy; confirm against the actual registration before treating it as drift) and `PRIMARY_DISCLOSURE_NOTE` (the manuscript discloses a manuscript-stage analytical decision — the honest disclosure estimand-provenance guidance *recommends writing*; confirm it is reported coequally, do not penalise it). 3. **E-value.** `EVALUE_ARITHMETIC` means the reported E-value does not recompute from its adjacent effect estimate (the value was likely produced for a different estimate); `EVALUE_NON_PRIMARY` means the E-value is attached to a secondary/exploratory estimate but presented as if it bounded the headline claim. Both warrant a Major/Minor comment — recompute the E-value for the **declared primary** estimate and its near-null confidence limit, and quote it there. 4. **Primary-change guard.** Independently of the script, if the manuscript reports two models for the same contrast where one is significant and the other null and the significant one is foregrounded, confirm which was pre-specified; an outcome-dependent choice of primary model is a Major comment even when each model is individually correct. 5. **Headline vs own-sensitivity direction.** Read the sensitivity series (S1 etc.) the manuscript itself reports. If the headline causal/association claim points the *opposite* way from the authors' own adjusted or sensitivity estimate — a positive lead sentence over a sensitivity model that attenuates to the null, or vice versa — that is a Major: the paper is contradicting its own robustness check. This is a prose judgement, not a script verdict. 6. **Methods ↔ Results ↔ disk coverage.** Run the deterministic coverage gate: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_artifact_coverage.py" \ --manuscript manuscript.md --analysis-dir output/analysis \ --out qc/artifact_coverage.json --strict ``` `PROMISED_ABSENT` (an analysis named in Methods that never reaches Results) and `DISK_UNREPORTED` (an analysis output on disk — an added-value DeLong CSV, a calibration table — never mentioned in the manuscript) are Anticipated Major Comments. The reverse direction matters because a run-but-unreported result can be the one that undercuts the headline. When an `_analysis_outputs.md` manifest exists the gate uses it as the source of truth; otherwise it globs `--analysis-dir` and only escalates analysis-bearing file names. The same gate also flags `PROMISED_STAT_NO_VALUE`: a statistic framed as a **bound/ ceiling/de-confounded** value (e.g. "the de-confounded reader AUC is reported in Table S16", "the classifier ceiling AUC") promised with a reporting verb but never given a numeric value anywhere in the manuscript **or supplement** — the bound that makes the primary estimand interpretable, sometimes marked "Addressed" in a checklist yet absent from every table. Pass the rendered supplement so the corpus is complete: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_artifact_coverage.py" \ --manuscript manuscript.md --supplement supplement.md \ --out qc/artifact_coverage.json --strict ``` 7. **Supplement / tables / caption hygiene.** Phases 2.5–2.5e and the classical-style gate lint `manuscript.md` only; the rendered **supplement, a separately-built tables file, and figure-caption files** are never linted — yet that is where technical-check- fatal residue hides (internal §/§L SAP labels, unfilled `Table SX`/`[Authors]` placeholders, `[VERIFY]`/`TODO` build markers, response-to-reviewers framing, planning residue, and body↔supplement cross-reference numbers that do not resolve). Run the supplement-hygiene gate over **every** rendered reader-facing artifact: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_supplement_hygiene.py" \ --supplement supplement.md --supplement tables.md --supplement captions.md \ --manuscript manuscript.md --out qc/supplement_hygiene.json --strict ``` All verdicts (`SUPP_INTERNAL_LABEL`, `SUPP_PLACEHOLDER`, `SUPP_BUILD_MARKER`, `SUPP_RESPONSE_FRAMING`, `SUPP_PLANNING_RESIDUE`, `SUPP_XREF_UNRESOLVED`) are Anticipated Major Comments — a reader-facing slip in a supplement is as fatal at a technical check as one in the body. **Citation order — floats AND the in-text reference series (same technical-check pass).** Editorial offices "unsubmit" manuscripts *before* peer review when numbered floats are not cited in ascending order of first appearance — a fully deterministic desk-check item the hygiene gate above does not cover (it lints xref *resolution*, not *order*). Run the citation-order gate, which checks each series independently (main Tables, main Figures, Supplementary Tables, Supplementary Figures) **and the in-text reference-number series** (`[12]`, `[4–11]`) — the Vancouver numbering a hand-typed `[N]` manuscript (the Word/Zotero path) has no other gate for; a citeproc `[@key]` manuscript has no numbers to check and stays silent. Ranges are expanded (`[4–11]`→4..11) so a number inside a rendered range is never a false gap. It scans only the narrative body (auto-excluding the Figure Legends / back-matter so an in-order legends block cannot mask an out-of-order body): ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_citation_order.py" \ --manuscript manuscript.md --out qc/citation_order.json --strict ``` `CITATION_ORDER` (Major) — a series cited out of numerical order (e.g. Table 3 before Tables 1–2, or Supplementary Tables cited S3, S1, S2, …); fix by renumbering the series by first-citation order (and reordering the float/supplement document + remapping every cross-reference, expanding ranges like `S12–S15` by hand and leaving non-float sensitivity-spec labels such as `S1–S6` untouched) or by rephrasing away the early citation. `CITATION_GAP` (Minor) — cited numbers not contiguous from 1 (a possible missing/mis-numbered float). `REFERENCE_ORDER` (Major) — in-text reference numbers cited out of order (e.g. `[12]` before `[5]`); the Vancouver list is mis-numbered. `REFERENCE_GAP` (Minor) — a reference number never cited. `REFERENCE_COUNT_MISMATCH` — the highest cited `[N]` overruns the reference-list length (dangling, **Major**) or the list has trailing entries never cited (**Minor**). 8. **Re-run cross-artifact staleness after any audit or reframe.** When a headline number is corrected or an analysis is re-framed, the fix often lands only in the body while a supplement footnote or a figure-source data file keeps the stale (sometimes *reversed*) value. Re-run `/sync-submission`'s `check_cross_artifact_stale.py` across the body, the supplement, and any figure-source data immediately **after** the reframe — not just once at the start — so a corrected body never ships next to a stale supplement. 9. **Power-aware null interpretation.** A headline negative claim ("no synergy", "not associated", "showed no difference") is interpretable only next to a precision statement — a minimum-detectable-effect, a power calculation, an equivalence margin/TOST, or a CI-compatibility sentence. Run the null-calibration gate: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_null_calibration.py" \ --manuscript manuscript.md --out qc/null_calibration.json --strict ``` `CONFIRM_NULL_NO_MDE` (Major) fires when a negative/equivalence claim in the Title/ Abstract/Conclusion has no such token anywhere in those regions — a non-significant result is not evidence of no effect without one. (A single MDE/power/equivalence/CI- compatibility sentence suppresses it.) Pair with the interaction-scale checks (`O14`) when the null is a synergy/interaction claim. 10. **Confidence-weighted / rating → AUC monotonicity.** For an observer or reader study that collapses a (binary-call × confidence) rating into a single score used as the ROC/AUC predictor, verify the encoding is **strictly monotonic** across the full ladder — a *folded* score (`cws = confidence if positive-call else 6 − confidence`) collapses opposite (call × confidence) cells and silently mis-estimates the AUC; a prose review cannot see an estimator bug. Run the encoding through the reusable monotonicity probe and ship its 10-combination unit test: `python3 "${MEDSCI_SKILLS_ROOT}/skills/analyze-stats/scripts/rating_monotonicity.py" --encoding score_def.json`. 11. **Figure-embedded numbers are text-grep blind.** PRISMA/flow/forest/statistic figures are rasterised, so every numeric audit above is blind to the numbers *inside* them. Before submission, (a) **visually** read each such figure page in the rendered/blind PDF, and (b) reconcile the **hard-coded integers** in the figure-generation script (`create_figure*.R`, `make_*.py`) against the body/flow-source counts (`grep -nE '<-\s*[0-9]+|=\s*[0-9]+' figures/*.R`). See `submission-portal-verification` §9.5 (figure-image DATA drift) for the full procedure. The script is deterministic but its provenance match is fuzzy (token overlap): read the reconciliation in `qc/claim_artifact.json` and confirm against the actual registration before raising `ESTIMAND_DRIFT`. For time-to-event manuscripts, also apply probe **S8 (estimand provenance)** of `references/domain-probes/survival_prognostic.md`. -
phase2_6_panel.md 10.2 KB
# Phase 2.6 — Multi-Agent Panel Review (`--panel`) Read this only when `--panel` was passed. A default single-pass review never reaches any of it. The panel simulates independent peer reviewers who do not see each other's comments, then an editor who consolidates them — the same structure a journal uses. It reuses the vendored domain-probe modules so every reviewer applies the same criteria. **Precondition (blocking): the SSOT must be singular.** Before spawning any reviewer, enforce the Phase 1 step 4 SSOT gate: if more than one manuscript-like `.md` exists and none is pinned (no `SSOT.yaml` `truth.manuscript_md`, no explicit `--ssot`), **halt and ask the user which file is the SSOT** — a panel is too expensive to spend on a stale copy. Clear any `STALE_COPY` from `detect_copy_divergence.py` first. ## Step 1 — Compose the reviewer set by research type Auto-detect the manuscript type (Phase 1 input + the Research-Type Adaptation table). Each reviewer loads the matching domain-probe module so the panel's criteria are single-sourced. | Research type | Reviewer set (each is one reviewer) | Domain-probe module each loads | |---|---|---| | Survival / prognostic cohort | R1 Biostatistics & Study Design · R2 Clinical (domain) · R3 Imaging/Radiology (if an imaging exposure) | `references/domain-probes/survival_prognostic.md` | | Systematic review / meta-analysis | R1 Methodology (search/screening/PRISMA) · R2 Clinical · R3 Statistics (pooling/heterogeneity) | `references/domain-probes/sr_ma.md` | | Radiomics / feature reproducibility | R1 Imaging physics & acquisition · R2 ML / Statistics · R3 Clinical translation | `references/domain-probes/radiomics.md` | | Diagnostic-accuracy / AI model | R1 Study design & leakage · R2 Statistics (DeLong, calibration) · R3 Clinical / reference standard | `references/domain-probes/sr_ma.md` (P1 DTA cells) + `references/domain-probes/ai_overclaiming.md` (AO0–AO7, for AI clinical claims) + categories A–C | | Observational (STROBE) | R1 Epidemiology / confounding · R2 Clinical · R3 Statistics | `references/domain-probes/observational_confounding.md` (O1/O8 run as the Phase 2.5e / `check_cohort_arithmetic.py --id-col` deterministic gates; O7 over-adjustment) + `references/domain-probes/clinical_prediction_model.md` (CP1–CP4, when it is a prediction-model paper) + categories A–J + the effect-size / added-value axes | | Narrative / review article | R1 Domain-content expert · R2 Methodology / SANRA · R3 Technical accuracy · R4 Adversarial reject-hunter (structural: RV9 curated-base circularity, RV6 single-anchor overload, RV8 self-citation architecture) | `references/domain-probes/narrative_review.md` | | Perspective / opinion / viewpoint | R1 Domain-content expert · R2 Argument architecture (thesis clarity, section-as-argument-move, single spine device) · R3 Technical accuracy · R4 Adversarial reject-hunter | `references/domain-probes/narrative_review.md` | | Case report | R1 Clinical case-report reviewer · R2 Ethics / de-identification · R3 Literature-context reviewer | `references/domain-probes/case_report.md` + CARE items + categories D/F/G | If the type is ambiguous, ask the user before composing the set. Append the **handling-editor desk-impression** persona (the ceiling lens) to every reviewer set: it loads no domain probe, reads only for narrative confidence vs over-defensiveness, and returns Minor REMOVE / MOVE / TIGHTEN findings (category L) that the editor routes to the separate Editorial-Impression Risks block. Its focus checklist is in `references/panel_review_template.md`. It does not count toward the Step 3.5 lens-diversity axes. ## Step 2 — Run the reviewers (portable execution) When the host provides a parallel subagent / Task capability (Claude Code, or any harness exposing an Agent tool), spawn the reviewer set as independent parallel subagents, each blinded to the others, then run the editor as a final synthesis agent. **Fallback (no subagent capability — e.g. a minimal Codex/Cursor harness):** a single agent role-plays each reviewer sequentially and in isolation — it completes and writes out reviewer R1's full structured review before reading the manuscript "fresh" as R2, so a later reviewer never sees an earlier reviewer's comments. The panel is defined by these instructions; it does **not** depend on the `Workflow` tool or any Claude-Code-only orchestration. **Before spawning, write a roster manifest** — `panel_roster.json`. At minimum the list of `reviewer_id`s you are about to spawn (so a reviewer that returned nothing is distinguishable from one that was never expected — the Step 3.5 `--roster` completeness check). **When the manuscript was AI-drafted, the roster must also declare substrates** — a top-level `generator_substrate` and a `substrate` per reviewer, each a coarse lane label (`"claude"` | `"codex"` | `"gpt"` | `"human"`) — because a panel that shares the drafter's substrate inherits its blind spots and is not an independent check. Routing at least one lens to a **different substrate** (the Codex adversarial path) or a human co-author is the **default, not an option**: the Step 3.5 gate fires `SUBSTRATE_MONOCULTURE` (Major) when every declared reviewer shares `generator_substrate`. Example: `{"generator_substrate": "claude", "reviewers": [{"reviewer_id": "R1", "substrate": "claude"}, {"reviewer_id": "R2", "substrate": "codex"}, {"reviewer_id": "R3", "substrate": "human"}]}`. A reusable reviewer schema, a generic harsh-but-fair reviewer prompt skeleton with per-domain focus checklists, and the editor synthesis prompt skeleton live in `${CLAUDE_SKILL_DIR}/references/panel_review_template.md`. Each reviewer returns: `reviewer_id`, `expertise_area`, an `overall_assessment` (name the single biggest threat to the conclusions), `strengths` (2–3), `major[]` (each with `heading`, `comment`, `location`, `severity`, `suggested_fix`), and `minor[]`. Map `severity` onto this skill's own scale — a conclusion-threatening / design-level finding is **Fatal**, a reporting-level finding is **Fixable** — rather than introducing a separate vocabulary. ## Step 3 — Editor synthesis One editor pass (a final agent, or the main agent in the fallback) consolidates the reviews: 1. **Dedupe** findings by theme across reviewers. 2. **Flag CONSENSUS** for any theme raised by ≥2 reviewers, with R1/R2/R3 attribution (e.g., `[CONSENSUS: R1+R3]`); single-reviewer findings are attributed to the one reviewer. 3. **Decide** an internal readiness verdict (this sets the Phase 3c `verdict` / `overall_score`; it is not printed as a journal recommendation). 4. **Rank** the concrete pre-submission actions the author should complete first. 5. State a one-line **readiness verdict** (ready for the target tier now / fix specific items first / consider a different tier). ## Step 3.5 — Lens-diversity gate (deterministic) A panel only earns its cost if its reviewers span *distinct* axes rather than echo one theme louder. Before the editor finalizes, serialize the reviewers' structured outputs (the schema above) to a JSON file — either a top-level list or `{"reviewers": [...], "research_type": "..."}` — and run the gate: ```bash python3 ${CLAUDE_SKILL_DIR}/scripts/check_panel_diversity.py \ --panel panel_reviews.json --roster panel_roster.json \ --research-type {survival|sr_ma|radiomics|dta|observational|narrative} --strict ``` With `--roster` it first checks **completeness** — **`PANEL_UNDERRETURN`** (Major) fires when fewer reviewers returned a parseable review than were spawned, or when fewer than 2 returned at all. A panel with <2 returned reviews is a **failed run, not a thin one**: do not synthesize it or report it as a review — re-spawn (or route to a different substrate / human co-author). This is the case that otherwise passes silently, because a thin or empty `panel_reviews.json` errors nowhere. It then checks **independence** — **`SUBSTRATE_MONOCULTURE`** (Major) fires when the roster declares a `generator_substrate` and every declared reviewer shares it: a same-model panel inherits the drafter's blind spots and is not an independent check, so route at least one lens to a different substrate (Codex) or a human co-author (skipped when the roster declares no substrates — backward compatible). It then reports three diversity failures, each mapped onto a concern family aligned to the focus checklists: - **`UNCOVERED_AXIS`** (Major) — an axis the research type is expected to probe (e.g. heterogeneity/pooling for an SR/MA) drew **zero** major findings. The editor re-probes it with the owning reviewer before finalizing, or records in the synthesis why the gap is acceptable. - **`FAMILY_MONOCULTURE`** (Major) — the majority of majors fall in one concern family; the lenses converged rather than spanned the manuscript. - **`LENS_COLLAPSE`** (Flag) — a reviewer raised only families another reviewer already covered, adding no independent axis. Healthy CONSENSUS is preserved — agreement on *some* themes is a strength (Step 3 flags it), and the gate fires `LENS_COLLAPSE` only on a *fully* redundant reviewer and the Major checks on panel-level coverage, never on agreement per se. Do not silently ship a monoculture: resolve every Major before the synthesis verdict. ## Step 4 — Feed Phase 3 The consolidated panel output flows into the Phase 3 report, Phase 3b R0 numbering (**preserved**, so `/revise` still consumes it), and Phase 3c JSON. CONSENSUS flags and reviewer attribution are additive annotations on the existing `M`/`m` comments (and the optional `consensus` JSON field); they do not change the report or JSON structure. ## Re-run the panel after a large revision A panel is high-yield not only before the first submission but **again after any large edit** — a word-count compression, a primary-model or adjustment-set change, or resolving a batch of majors. Such edits introduce *new* drift (a compression drops a caveat; a re-fit leaves a derived CSV stale; a relocation orphans a cross-reference), and the second panel's findings shift character accordingly (method → compression-drift → residual). If the author has just compressed or re-modelled, recommend one more `--panel` pass rather than assuming the prior panel still holds; in practice each post-revision round surfaces real, distinct errors. -
phase2_systematic_check.md 18.7 KB
# Phase 2 — Systematic Check: the A–L category detail Load-on-demand companion to `/self-review` Phase 2. SKILL.md keeps the category index and the deterministic gates; this file carries the per-category check tables — the long enumeration you only need once you have the manuscript in hand and know its type. Run the manuscript through each applicable category. For each item, assess whether a reviewer would raise it as a Major or Minor comment. Use the Research-Type Adaptation table in SKILL.md to decide which categories apply fully, partially, or not at all for this manuscript type. ## A. Study Design & Data Integrity | Check | What to look for | |-------|-----------------| | Patient-level splitting | Are train/val/test splits at the patient level? Is this explicitly stated? | | Leakage risk | Any postoperative variable used in a preoperative model? Cohort-wide preprocessing before split? | | Input-text contamination | For NLP/LLM extraction tasks, does any supplied report text (clinical history, indication, impression, prior diagnosis, referral text) already contain the target label? If yes, mark as Major unless the input was masked or a no-leaky-field sensitivity analysis is reported. | | Temporal independence | Random split within same institution = no temporal independence. Acknowledged? | | Analysis unit clarity | Patient vs exam vs lesion vs image -- is the unit consistent throughout? | | Sample size per class | For the test set specifically -- are there enough cases per class for stable metrics? | ## B. Reference Standard & Ground Truth | Check | What to look for | |-------|-----------------| | Definition specificity | Is the reference standard precisely defined? (e.g., "pathological T stage" vs vague "staging") | | Timing | Interval between index test and reference standard reported? | | Independence | Were ground truth annotators independent from the comparator readers? | | Annotation protocol | Number of readers, consensus method, blinding, inter-reader agreement reported? | ## C. Validation & Statistical Reporting | Check | What to look for | |-------|-----------------| | Confidence intervals | All primary metrics have 95% CIs? | | Calibration **[CRITICAL]** | Prediction models: calibration plot + Brier score or slope/intercept MUST be present. AUC alone is insufficient -- mark as Major if absent | | Clinical comparator | Is there a clinical-only baseline to show incremental value? | | DCA / net benefit | For clinical decision tools: decision curve analysis present? | | Fine-tuning baseline | For LLM/NLP fine-tuning, LoRA, prompt-engineering, or multi-agent claims, is there a same-backbone zero-shot or few-shot comparator on the same input, schema, and test split? | | Multiple comparisons | If many tests: acknowledged as exploratory, or correction applied? | | Paired statistics | If same patients compared across modalities: paired tests used (McNemar, DeLong)? | | Effect-size meaningfulness | Scored separately from significance: is each primary effect (OR, HR, beta, Cohen's d, correlation) translated to a real-world unit shift and compared to a minimal clinically important difference? Is significance driven by magnitude rather than sample size? | | Power-aware null interpretation | Scored separately from significance, for any **non-significant primary result** (p > 0.05, 95% CI crossing the null): is the analysis powered to *exclude* a clinically meaningful effect? An underpowered null is "not yet established," not "no effect" -- if the upper CI bound still includes a meaningful effect size, a flat "X was not associated with Y" claim overreads the data. Look for reported observed power or a minimum detectable effect that justifies a negative conclusion, and watch for **bilateral over-correction** (a prior "independently associated" overclaim swinging to an equally unsupported "not associated" claim during revision). Undocumented null = Minor; a null that drives a clinical recommendation or a headline negative conclusion without power/CI-compatibility justification = Major. | | Equivalence-margin discipline | A claim that two groups/methods are "equivalent," "non-inferior," "indistinguishable," or show "no difference" requires a **pre-stated margin** — a TOST procedure, or the CI compared against a declared MCID. Grep `indistinguishable\|equivalent\|non-inferior\|no difference` and check for an adjacent `margin\|TOST\|MCID\|non-inferiority`; a margin-free equivalence claim is a Major (it converts a failure to reject into positive evidence of no effect). | | Interaction-anchor discipline | When synergy / interaction / effect-modification **is** the research question, the null must be anchored to the **interaction parameter** (a likelihood-ratio test of the interaction term, or the interaction OR/HR on one consistent scale), not to a main-effect OR whose upper CI is then read as "no synergy." Grep `synergy\|interaction\|joint effect\|effect modification`; if present, confirm Results carries an `OR_int\|β_int\|LRT\|p_interaction` term. A synergy conclusion resting on a main-effect estimate is a model mis-specification (Major), even when each main effect is individually correct. | | Difference-in-significance discipline | A between-group claim that an association is "more X / stronger / more pronounced in group A than group B" must rest on a **formal interaction test**, not on group A being significant (p < 0.05) while group B is not (p = NS). The difference between "significant" and "non-significant" is **not** itself significant. Grep `more (clearly\|strongly\|pronounced)\|stronger in\|(only\|chiefly) in (men\|women\|older\|younger\|the [A-Za-z]+ subgroup)` near two stratum-specific estimates with discordant p-values; if no interaction term (`p_interaction\|OR_int\|LRT`) is reported for that contrast, flag it (difference-in-significance fallacy). A subgroup-difference conclusion built this way is a Major; the fix is to report the interaction test or soften to "associations were observed in group A; the interaction was not formally tested." | ## D. Clinical Framing & Importance | Check | What to look for | |-------|-----------------| | Intended use | Is the clinical decision point clearly stated? (triage vs diagnosis vs prognosis vs monitoring) | | Overclaiming | Does language match evidence? ("will improve" -> "may potentially"; "superior" read off two arms' separate CIs rather than a tested difference — note that overlapping CIs neither establish nor refute a difference, so the fix is to report the paired delta and its CI, not to soften a claim the test supports) | | Terminology precision | Key terms defined? (e.g., "perioperative" = when exactly?) | | Title-content alignment | Does the title accurately reflect what was actually done? | | Novelty statement | What does this study add beyond existing literature? Is this explicitly stated? | | Substantive novelty differentiation | For AI/LLM extraction papers, does the Introduction name 2-3 close prior papers/systems and state the concrete delta (new task, dataset, workflow, method, validation, or clinical decision point), rather than merely saying the method is novel? | | Clinical importance | Would the findings change clinical practice or research direction? Is this articulated? | | Decision impact | Does the manuscript state what decision, workflow step, or downstream action would change if the model is correct? A text-only phenotype that does not alter triage, treatment, surveillance, enrichment, or research operations has weak clinical utility even if accuracy is high. | | Added value / actionability | Scored separately from novelty: does the finding add value over a measure already in routine use, or is it "real but redundant" (restates a standard test)? At the typical effect size, would a clinician act on it for an individual? | | Endpoint↔conclusion scope **[CRITICAL]** | Does the conclusion's *action* exceed what the design or endpoint supports? A cross-sectional / single-visit study cannot license a prognostic or surveillance claim (rescreen interval, disease progression); a binary surrogate endpoint (present/absent, >0) is risk stratification, not a care directive (defer/withhold/initiate therapy). Both are documented anti-patterns. | Run the deterministic scope gate: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_scope_coherence.py" \ --manuscript manuscript.md --out qc/scope_coherence.json --strict ``` `CROSS_SECTIONAL_PROGNOSTIC` and `SURROGATE_CARE_DIRECTIVE` are Anticipated Major Comments (category: D. Clinical Framing). `CROSS_SECTIONAL_YIELD_LANGUAGE` is an Anticipated **Minor** Comment — a cross-sectional / prevalence design using incidence-flavored screening vocabulary ("yield", "detection rate", "number-needed-to-screen/image", "rescreen interval") without defining "yield" once as cross-sectional report-positive prevalence. The gate is conservative — it fires only when a design/endpoint signal and a conclusion-region action verb (or the yield lexicon) co-occur. ## E. Reproducibility | Check | What to look for | |-------|-----------------| | Preprocessing details | All steps listed in order? Normalization, augmentation, resampling specified? | | Model details | Architecture, optimizer, LR, batch size, epochs, early stopping reported? | | Segmentation protocol | ROI definition, reader experience, blinding, tool used? | | Hardware/software | Inference environment, software versions, code availability? | | Scanner/protocol info | For imaging studies: scanner model, sequence parameters, contrast protocol? | | Data/code availability | Is a data availability statement included? Code shared or reason for not sharing stated? | ## F. Reporting Completeness | Check | What to look for | |-------|-----------------| | Abstract-body consistency | Numbers in Abstract match Tables/Results? | | Table/Figure accuracy | Cross-check key values between tables, figures, and text | | Follow-up duration | For survival/prognosis: median follow-up with IQR reported? | | Ethics | All participating institutions' IRB approval documented? Patient consent described? | | Missing data | Handling of incomplete cases described? | | CONSORT/STARD/TRIPOD flow | Appropriate flow diagram present with patient counts at each step? | | Body word count vs journal cap | Is the body within the target journal's word limit? A revise loop monotonically adds words and silently breaches the cap. Run `/sync-submission` `scripts/check_wordcount_cap.py` (`--journal-profile` or `--limit`; the binding number is the rendered DOCX count). Over cap → Major; within 0.95× → Minor (a further pass will likely breach). | | Funding & COI | Funding sources and competing interests disclosed? | ## G. Reporting Guideline Compliance Match the manuscript type to the appropriate checklist and verify key items: | Manuscript type | Checklist | Critical items to verify | |----------------|-----------|------------------------| | Diagnostic accuracy | STARD / STARD-AI | Flow diagram, reference standard, spectrum | | Prediction model (non-AI) | TRIPOD 2015 | Model development vs validation, calibration, missing data | | Prediction model (AI/ML) | TRIPOD+AI 2024 | Model development vs validation, calibration, leakage, fairness | | AI / Radiomics | CLAIM 2024 / CLEAR | Feature selection transparency, external validation | | RCT | CONSORT / CONSORT-AI | Randomization, blinding, ITT | | Systematic review (interventions) | PRISMA 2020 | Search strategy, screening, risk of bias | | Meta-analysis (observational) | MOOSE + PRISMA 2020 | Confounding assessment, heterogeneity, publication bias | | Observational | STROBE | Confounding, selection bias, missing data | | Reliability / agreement | GRRAS | ICC model/type, rater description, measurement protocol | | Educational | SQUIRE 2.0 | Intervention description, outcome measures, context | | Case report | CARE | Timeline, diagnostic reasoning, informed consent | | Surgical | STROBE-Surgery | Surgeon experience, technique details, complications | For a full item-by-item audit, run `/check-reporting` on this manuscript. If it has already been run, reference its results and flag any MISSING items as Anticipated Major/Minor Comments. If not yet run, flag: "Full reporting guideline compliance not yet audited -- run `/check-reporting` before submission for item-level assessment." ## H. Circularity | Check | What to look for | |-------|-----------------| | Label-feature overlap | Is the prediction label derived from the same data source as any input features? (e.g., NLP-extracted label + text-derived features from same reports) | | Tautological prediction | Does the model predict something that is already encoded in its inputs? | | Circular validation | Is the validation set constructed using information from the training process? | ## I. Protocol Heterogeneity | Check | What to look for | |-------|-----------------| | Multi-site acquisition | If multi-site: are scanner models, protocols, and acquisition parameters reported per site? | | Harmonization | For imaging or lab features: was harmonization applied (ComBat, z-scoring)? If not, acknowledged? | | Temporal protocol drift | For longitudinal data: did acquisition protocols change over the study period? | ## J. Method Transparency | Check | What to look for | |-------|-----------------| | Model provenance | Is it clear where the model came from? (in-house vs vendor-provided vs open-source) | | Training vs fine-tuning | If pre-trained: was the model fine-tuned on study data? If vendor-provided: any access to training data composition? | | Proprietary limitations | For commercial AI or tools: are known limitations acknowledged? Can results be independently reproduced? | | Classical-style body conventions | Does the body carry an AI tell or a policy violation a senior reviewer flags on sight — a `§` symbol, an in-body AI-disclosure paragraph, eligibility criteria as prose, mixed OR/HR decimal places, or em-dash overuse? | Run the deterministic classical-style lint (these are all greps, so they belong in a gate, not eyeballing): ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_classical_style.py" \ --manuscript manuscript.md --out qc/classical_style.json --strict ``` `SECTION_SYMBOL` and `INBODY_AI_DISCLOSURE` are Major (the `§` count must be 0; the AI-disclosure paragraph belongs on the title page for a classical / senior-MA target, not the body). `ELIGIBILITY_PROSE`, `DECIMAL_INCONSISTENCY`, and `EM_DASH_OVERUSE` are Minor. This is the self-review-side mirror of `/write-paper` Step 7.1's classical QC (manuscript-style-classical §5/§6/§7/§8). ## K. Reviewer-team consistency (SR/MA-only; fabrication-grade) | Check | What to look for | |-------|-----------------| | DUAL vs SINGLE conjunction **[CRITICAL]** | Methods or PROSPERO claims dual independent reviewers AND Discussion/Limitations admits single primary reviewer + 20% sample (or "deferred to before submission")? Mark as **MAJOR**, fabrication-grade. | | LLM-as-reviewer **[CRITICAL]** | A per-study extraction JSON whose `reviewer`/`screener`/`extractor` field is an LLM (Claude, GPT-4, Gemini, "LLM")? An LLM is a tool, not an independent reviewer — listing it as one misrepresents the team. **Fatal**, regardless of the prose. | | Deferred mitigation | A future-tense mitigation promise — "a 20% sample **will be completed before submission**" — unmet at circulation? The future tense is the tell that the work is not done. **MAJOR**. | Run the deterministic check at Phase 2 entry (pass the extraction JSON — a file or a directory of per-study JSONs — so the prose↔JSON↔confession 3-way is covered): ```bash python "${CLAUDE_SKILL_DIR}/scripts/check_reviewer_team_consistency.py" \ --manuscript manuscript.md \ --prospero prospero/record.md \ --extraction-json extraction/ \ --out _audit_self/reviewer_team_consistency.md ``` Exit 1 = MAJOR red flag. The JSON sidecar carries `dual_hits`, `single_hits`, `llm_reviewer_hits`, and `deferred_mitigation_hits`. Any of the DUAL+SINGLE conjunction, an LLM reviewer field, or a deferred mitigation trips it. Either of the dual/single claims alone is fine; the conjunction is read by reviewers as fabrication. Resolution path: 1. Honest Methods/PROSPERO update (single-reviewer execution disclosed), OR 2. Limitations confession rewritten if dual review was actually completed. ## L. Editorial impression & defensiveness (advisory; the counterweight) This is the **ceiling** category (see "Two Objectives" above) and the inverse of the floor gates: where A–K and the numerical gates ask "what is missing or wrong?" (and answer by **adding**), L asks "does the accurate manuscript read confidently, or has it over-defended?" (and answers by **subtracting**). Every L finding is **advisory (Minor / impression) and non-blocking** — it never converts to a Major and never blocks submission. The fixes are REMOVE / MOVE / TIGHTEN, not "add a caveat." | Check | What to look for | Action | |-------|-----------------|--------| | Hedge density | Defensive-caveat tokens stacking up per 1,000 narrative words — the prose hedges faster than it asserts. Keep the load-bearing caveats; cut the reflexive ones. | TIGHTEN | | Repeated caveat | The same caveat motif ("no deployable claim", "not generalizable", "hypothesis-generating") repeated across body + Abstract. Say it once, firmly. | TIGHTEN | | Audit minutiae in body | Provenance tokens (SHA / git commit / unit-test / post-lock timeline / manifest / seed=N / audit trail) in the Introduction / Results / Discussion narrative. Reproducibility detail belongs in a Methods statement or a supplement. | MOVE | | Limitations volume | A Limitations passage that enumerates a long list of discrete items reads as a rebuttal letter; consolidate related items. | TIGHTEN | | Abstract caveat load | The Abstract carries several caveat clauses, burying the headline result before a reader reaches it. Lead with the result; keep one or two essential qualifiers. | TIGHTEN | | Buried defense | A strong numeric robustness / sensitivity result sitting only in Limitations or the supplement, with no robustness mention in Results. Promote it into Results — it is *evidence for* the finding, not a caveat against it. (The inverse of the scope-coherence gate, which pushes a *weak* analysis out of Results.) | MOVE | Run the deterministic gate (Phase 2.5g) rather than eyeballing it — these are all counts and placements: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_editorial_impression.py" \ --manuscript manuscript.md --out qc/editorial_impression.json ``` `HEDGE_DENSITY`, `HEDGE_REPEAT`, `AUDIT_IN_BODY`, `LIMITATIONS_VOLUME`, `ABSTRACT_CAVEAT_LOAD`, and `BURIED_DEFENSE` are Anticipated **Minor** Comments (category: L. Editorial impression), each carrying a REMOVE / MOVE / TIGHTEN `action`. The gate never blocks (it has no Major and exits 0 even under `--strict`); thresholds are tunable (`--hedge-per-1k`, `--repeat-threshold`, `--limitations-max`, `--abstract-caveat-max`). It is conservative — each probe fires only on an explicit, locatable signal. -
phase3c_json_output.md 3.1 KB
# Phase 3c — Structured JSON output When `--json` is passed, or when invoked by `/write-paper` Phase 7, append a machine-readable JSON block after the markdown report. Fence it with triple backticks and the `json` language tag so downstream parsers can extract it. ```json { "self_review_version": "1.0", "manuscript_title": "...", "date": "YYYY-MM-DD", "overall_score": 72, "verdict": "REVISE", "fatal_count": 0, "major_count": 3, "minor_count": 4, "issues": [ { "id": "M1", "severity": "major", "category": "C", "category_name": "Validation & Stats", "location": "Methods, paragraph 5", "description": "Calibration plot and Brier score absent for prediction model", "fixable_by_ai": true, "suggested_fix": "Add calibration analysis paragraph after discrimination results. Generate calibration plot via /make-figures." }, { "id": "m1", "severity": "minor", "category": "F", "category_name": "Reporting Completeness", "location": "Abstract, line 3", "description": "Abstract reports AUC 0.91 but Table 2 shows 0.912 -- rounding inconsistency", "fixable_by_ai": true, "suggested_fix": "Change abstract to match table: AUC 0.91 (95% CI: 0.87-0.95)" } ] } ``` **Field definitions:** - `overall_score`: Integer 0-100 reflecting manuscript submission readiness - `verdict`: `"PASS"` (score >= 85, no fatal issues) or `"REVISE"` - `severity`: `"fatal"`, `"major"`, or `"minor"` - `category`: Letter code from the 10-category system (A-J) - `fixable_by_ai`: `true` if the issue can be resolved by editing manuscript text with existing data; `false` if it requires new data, analyses, or human judgment (e.g., design changes, IRB decisions, missing experiments) - `requires_reanalysis` *(optional, default `false`)*: `true` when closing the finding needs a **committed analysis re-run against the real data**, not a prose edit — power/MDE re-simulation under the full model, first-visit/one-record-per-subject dedup, an extended- or reduced-adjustment sensitivity model, optimism correction of calibration. Always implies `fixable_by_ai: false`. Additive and backwards-compatible; parsers that do not expect it must ignore it. Route these to `/analyze-stats` (see Phase 4). - `suggested_fix`: Specific, actionable instruction. If `fixable_by_ai` is true, this must be concrete enough for the fixer to execute without ambiguity. - `consensus` *(optional, panel mode only)*: array of reviewer ids that raised the issue, e.g. `["R1","R3"]`. Additive and backwards-compatible — present only when Phase 2.6 ran; parsers that do not expect it must ignore it. - `action` *(optional, editorial-impression findings only)*: `"REMOVE" | "MOVE" | "TIGHTEN"` — the SUBTRACTION direction for a category-L finding (Phase 2.5g). Present alongside `issue_type: "editorial_impression"` and `subtype: <verdict>` (e.g. `HEDGE_REPEAT`). Additive and backwards-compatible; these are always `severity: "minor"`, never block, and are `fixable_by_ai: false` by default (except a redundant `HEDGE_REPEAT`, which `--fix` may collapse). Parsers that do not expect it must ignore it. -
phase4_fix_support.md 3.4 KB
# Phase 4 — Fix support #### Standard mode (no --fix flag) After presenting the report, offer to help fix specific issues: - Rewrite overclaiming sentences - Draft missing limitation statements - Suggest statistical additions (e.g., calibration analysis code via `/analyze-stats`) - Draft intended use, decision-impact, or novelty-delta statements - Check specific tables/figures for consistency - Generate missing flow diagrams via `/make-figures` **`requires_reanalysis` findings route to `/analyze-stats`, not a prose edit (observational/cohort).** For cohort and observational manuscripts, the highest-value fixes are usually *data-level*: a power/MDE re-simulation under the full primary model, a first-visit / one-record-per-subject dedup sensitivity, an extended- or reduced-adjustment (over-adjustment) sensitivity model, or optimism correction of calibration. These are **not** `fixable_by_ai` text edits — `--fix` is text-only and will silently skip them. Tag each such finding `requires_reanalysis: true` and route it to `/analyze-stats` for a committed script + CSV, then feed the regenerated numbers back into the manuscript and re-run the relevant Phase 2.5 gate. Surface these explicitly to the author rather than letting an auto-fix pass appear to "resolve" them. #### Auto-fix mode (--fix flag) When `--fix` is passed: 1. **Filter fixable issues**: Select all issues where `fixable_by_ai` is true. 2. **Apply fixes sequentially**: For each fixable issue, edit the manuscript file directly: - Text rewrites (overclaiming, missing sentences, terminology) → Edit in place - Missing reporting items (ethics statement, data availability) → Insert at suggested location - Numerical inconsistencies (abstract-table mismatch) → Correct to match tables - Do NOT attempt: new statistical analyses, new figures, design changes, IRB-dependent items, or any issue tagged `requires_reanalysis` (route those to `/analyze-stats`) - Do NOT invoke other skills (`/make-figures`, `/analyze-stats`) during fix — text edits only 3. **Report changes**: After all fixes, output a summary: ``` ## Auto-Fix Summary - Fixed: {N} issues - Skipped (requires human): {M} issues - Changes: {list of id + one-line description of what was changed} ``` 4. **Post-edit paren-span safety scan**: if any fix reduced em-dashes (e.g. a `— X —` appositive → `(X)`), run the parenthesis-span gate before re-review — a bulk conversion can pair two unrelated dashes across a sentence boundary and wrap a whole sentence (or an ordinal "Sixth, …" limitation) inside one parenthesis (paren-balanced, so a balance check misses it): ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_paren_spans.py" \ --manuscript manuscript.md --out qc/paren_spans.json --strict ``` `PAREN_SPAN_ORDINAL` / `PAREN_SPAN_SENTENCE` is a Major — undo or repair that conversion before continuing. 5. **Re-review**: Run Phase 2 (systematic check) again on the modified manuscript. 6. **Iterate**: If new fixable issues emerge, apply one more round (maximum 2 total fix iterations). 7. **Final output**: Regenerate the Phase 3 report and Phase 3c JSON with updated scores. **Iteration limit**: Maximum 2 fix-and-re-review cycles. If the score has not reached "PASS" after 2 iterations, output the final report with remaining issues and flag: "Auto-fix limit reached. Remaining issues require human review."
-
-
panel_review_template.md 16.6 KB
# Panel Review Template (Phase 2.6) Reusable scaffolding for the multi-agent panel: a reviewer output schema, a generic reviewer prompt skeleton with per-domain focus checklists, and an editor synthesis prompt skeleton. This is a template, not a runtime program — it carries no manuscript-specific content. Fill the `{...}` placeholders from the manuscript under review and from the reviewer-set mapping in Phase 2.6. The per-domain focus checklists below name *categories of concern*; the precise probes (and their output templates) live in the vendored domain-probe modules (`references/domain-probes/*.md`), which each reviewer should load. --- ## Reviewer output schema Each reviewer returns one object with these fields: ```json { "reviewer_id": "R1", "expertise_area": "Biostatistics & Study Design", "overall_assessment": "3-5 sentences. State explicitly whether a fatal flaw exists and name the single biggest threat to the conclusions.", "strengths": ["...", "..."], "major": [ { "number": 1, "heading": "Short title of the issue", "comment": "Detailed critique. Quote the manuscript where relevant. Explain why it threatens validity.", "location": "section / table / figure / specific claim", "severity": "Fatal | Fixable", "suggested_fix": "What the authors should do to address it" } ], "minor": [ { "number": 1, "comment": "...", "location": "..." } ] } ``` `severity` uses this skill's own scale: **Fatal** for a conclusion-threatening / design-level finding, **Fixable** for a reporting-level finding. --- ## Reviewer prompt skeleton > You are an expert peer reviewer for a competitive journal in {field}, performing a > blinded pre-submission review of the author's own manuscript. Read the full > manuscript (and any supplement) first. > > TONE: rigorous and skeptical, but fair and constructive. Hunt for the issues that > threaten the manuscript's conclusions. Keep strengths to 2–3 genuine items. Every > major comment must threaten an actual conclusion or a reporting requirement; quote > the manuscript when you criticize a specific claim, and cite the location. > > Stay strictly within YOUR assigned area of expertise: {expertise_area}. Do not > stray into the other reviewers' domains. Load and apply the probes in > {domain_probe_module} where it applies. Report **only genuine threats** to the > conclusions — quality, not quantity. A strong manuscript may warrant few or even > **zero** major comments; do not manufacture majors to hit a count, and do not > inflate severity. List as many minor or optional refinements as are genuinely > useful. Return them in the reviewer output schema above. Set `reviewer_id` > to "{reviewer_id}" and `expertise_area` to "{expertise_area}". > > YOUR FOCUS: > {focus_checklist} ### Per-domain focus checklists (generic) **Biostatistics & Study Design** - Model specification: is the primary model pre-specified, or chosen post hoc in a results-favorable direction? Over-adjustment / conditioning on mediators on the causal pathway? - Assumption checks appropriate to the model (e.g., proportional hazards for Cox), and adequacy of any corrections. - Missing data: extent, plausibility of the missingness mechanism, whether the headline estimate survives a principled imputation, and whether imputation is primary or relegated to sensitivity. - Competing risks / informative censoring where multiple event types exist. - Sparse events: events-per-variable, penalization adequacy, CI stability. - Multiplicity across multiple outcomes, subgroups, and exploratory analyses. - Sensitivity / bias analyses (e.g., E-value) computed and interpreted correctly. - Time-zero / immortal-time / left-truncation; power for any null finding. **Clinical (domain)** - Clinical actionability: do the stated recommendations follow from the actual (possibly attenuated) results, or do they overreach? - Residual confounding by the dominant clinical driver of the outcome; can the exposure be disentangled from it? - Screening vs symptomatic population framing; external validity to the target readership. - Plausibility of the pattern of findings (e.g., a selective association without an expected concomitant one) — biology vs confounding vs chance. - Whether the findings would change management for a real patient. - Missing clinical variables and their interpretive cost. **Imaging / Radiology** - Exposure / measurement validity: how the imaging variable was defined and measured; visual/binary vs quantitative; threshold dependence. - Interobserver reliability measured in THIS cohort vs cited from the literature; defensibility of any non-differential-misclassification "bias toward the null" claim. - Protocol heterogeneity over time (scanner generation, slice thickness, reconstruction, dose) and its effect on the measurement. - Unavailable / non-retrievable images and selection implications. - Subtype/severity collapsed into a binary; loss of dose-response. - Reliability of routine clinical reports used as a research-grade variable. **Methodology (SR/MA)** - Search comprehensiveness and reproducibility; screening reviewer count. - Extraction fidelity vs source; comparator existence and consistent definition. - Non-independence (overlapping cohorts / shared public benchmarks). - Risk-of-bias instrument and per-study application; supplementary completeness. - Registration (PROSPERO) format and amendment discipline. **ML / Statistics (radiomics, AI)** - Design-grid circularity: is an outcome predicted from the very axes used to construct the dataset? - Construct validity: reliability ≠ predictiveness; orthogonality of proxy and target. - Transportability: cross-domain failure framed as success; negative R² read as a weak metric. - Multiplicity across model × threshold / cohort grids; small-cohort bootstrap intervals. - Leakage (patient-level vs image-level splits); calibration beyond discrimination. - Prior-art / dual-publication (salami): does the contribution duplicate a findable prior paper (same dataset size / architecture / metric values, delta-only)? An undisclosed near-duplicate is a contribution killer — check prior art, do not assume novelty. **Clinical translation / reference standard** - Reference-standard validity and verification bias. - Whether reported performance reflects the intended-use population and decision point. - Incremental value over the comparator already in routine use. **Methodology / SANRA (narrative review)** - Novelty / value-add against recent reviews; scope and aims clarity. - Evidence-gathering transparency (suggestion-level; not PRISMA). - Taxonomy / synthesis coherence; balance, currency, citation accuracy. - Load-bearing figures/tables; proportionate gap-filling ("consider adding", never "must cite"). **Technical accuracy (narrative review)** - Engineering and domain correctness of specific claims; itemize errors with location. - Verify-your-own-criticism: cross-check each asserted inaccuracy against a current authoritative source before raising it. **Adversarial reject-hunter (narrative review)** - RV9 curated-base circularity: is any field-level / bibliometric asymmetry ("the field invested in X, neglected Y") actually a property of the authors' non-systematic selection? Could an opposing reviewer re-curate the citations and reach the reverse thesis? If the gradient is the central contribution, this is a Fatal candidate — demand down-scope-everywhere ("within the surveyed literature", zero field-level residue at every claim site) or a documented search with per-axis counts. - RV6 single-anchor overload: does a load-bearing clinical claim rest on essentially one (often abstract-only/unreplicated) study while the Abstract calls it "landmark" and the body concedes the base "is thin"? Flag the Abstract↔body register mismatch. - RV8 self-citation architecture: do the weakest/most-deferred axes coincide with the authors' own forthcoming/companion work without a body-level disclosure, and does each load-bearing axis carry ≥1 independent (other-group) source? **Handling editor — desk-impression / champion-or-bounce (cross-type, the ceiling lens)** This persona is the counterweight to the rigor reviewers above. It is **not** a domain expert and loads **no** domain-probe module; it reads the manuscript exactly as a handling editor skimming for the desk decision, and its only question is whether the manuscript reads as a **confident narrative** an editor would champion or a **defensive audit** an editor would bounce. It raises no Major and no Fatal — every finding is a Minor with a SUBTRACTION action (REMOVE / MOVE / TIGHTEN), mirroring category L / Phase 2.5g. Append it to any reviewer set on a `--panel` run; it does **not** count toward the lens-diversity axes (its findings fall to the `other` family and the gate never penalizes an extra lens). Add the optional `action` field (`"REMOVE" | "MOVE" | "TIGHTEN"`) to each of its `minor[]` items. - Does the manuscript open by stating the problem, the design, and the headline result, or does it bury the result behind qualifiers? Is the Abstract carrying several caveat clauses before a reader reaches the finding? (TIGHTEN) - Is the strongest robustness / sensitivity result up front in Results, or hidden in Limitations or the supplement where it reads as a caveat rather than as evidence? (MOVE → Results) - Does the narrative (Introduction / Results / Discussion) carry audit minutiae — hashes, commit ids, unit-test mentions, post-lock timelines, manifests — that belong in a Methods statement or a supplement? (MOVE) - Is the same caveat repeated at multiple claim sites? Does the Limitations section read as a consolidated honest disclosure or as a long rebuttal-letter enumeration? Say each caveat once, firmly. (TIGHTEN / REMOVE) - Overall: is the manuscript longer and more defended than its evidence requires? Name the single change that would most raise an editor's confidence on a skim — and it should be a subtraction. Run `scripts/check_editorial_impression.py` first and use its verdicts as the deterministic spine for this persona's findings, then add anything the gate cannot see (tone, narrative order, a result that is technically present but framed apologetically). --- ## Editor synthesis prompt skeleton > You are the handling editor. {N} expert reviewers (areas: {areas}) have returned > independent blinded reviews of this pre-submission manuscript. Here are their > structured reviews as JSON: > > {reviews_json} > > Read enough of the manuscript to adjudicate conflicts and weigh severity yourself. > Then: > 1. Reach an internal readiness decision and state the rationale honestly. (This sets > the Phase 3c verdict / score; it is not a journal recommendation to print.) Run BOTH > lenses before settling the tier: the measurement/design lens AND a contribution/priority > lens (is the value and the novelty actually there?). A measurement-only read is the > classic cause of a too-lenient verdict. When an **unfixable-in-current-form** defect is > present — invalid poolability/construct validity, irrecoverable leakage/circularity, an > undisclosed near-identical prior publication, or (for a review/primer) weak novelty / > no distinct contribution since the contribution IS the product — let it dominate the > tier over fixable reporting defects rather than letting the fixable framing soften it. > Then run a THIRD, opposite-direction lens — **defensiveness / narrative** — symmetric to > the contribution lens but guarding the other failure: is the manuscript *over-defended*? > Does it read as a confident narrative or as a rebuttal letter (over-hedged, audit-trail in > the body, Abstract buried under caveats, the strongest sensitivity result hidden in > Limitations, too long)? Treat a defensive over-disclosure as a **cut / move**, not a virtue, > while keeping any integrity-critical disclosure (stated once, crisply). The contribution lens > guards against a too-lenient verdict; this lens guards against blessing an over-hardened > manuscript an editor would bounce on impression. > 2. De-duplicate and consolidate the major comments by theme. For each consolidated > point, flag CONSENSUS (raised by ≥2 reviewers) or single-reviewer, and attribute > (R1/R2/R3). > 3. List the top priority pre-submission actions, ranked and concrete. > 4. Give an honest readiness verdict: ready for the target tier now, fix specific > items first, or consider a different tier. > 5. Rate each reviewer's contribution as high-signal / mixed / low-signal with one > reason (which of their findings survived consolidation; their signal-to-noise; any > whole-axis they uniquely covered). This makes the per-lens contribution explicit > rather than implicit in the attribution. > > Map every finding onto the self-review framing (Fatal / Fixable, category letters > A–L) and emit it through the Phase 3 report, Phase 3b R0 numbering, and Phase 3c > JSON, adding the optional `consensus` field where ≥2 reviewers agreed. Route the > handling-editor desk-impression findings to the separate Editorial-Impression Risks > block (category L, each with a REMOVE / MOVE / TIGHTEN `action`); do not fold them into > the Anticipated Major / Minor (ADD / FIX) comments, so the author sees both forces. > Follow the manuscript-style rules: no "§" symbols, minimal em-dashes, full prose, cite > specific locations. --- ## Lens-diversity gate (Step 3.5) Before finalizing, the editor runs `scripts/check_panel_diversity.py` on the collected reviewer JSON. The gate classifies each major finding into a concern family and checks that the panel spans the axes its research type is expected to probe — the deterministic backstop against a panel converging on one easy theme while a high-risk axis goes unprobed. **Expected high-risk axes per research type** (each should yield ≥1 major; mirrors the Phase 2.6 reviewer-set table). Optional axes — for example imaging when the exposure is non-imaging — are not required: | Research type | Expected axes (families) | |---|---| | Survival / prognostic | statistics, clinical | | Systematic review / meta-analysis | search_screening, clinical, statistics | | Radiomics | imaging, statistics, clinical | | Diagnostic-accuracy / AI model | design_leakage, statistics, clinical | | Observational (STROBE) | confounding, clinical, statistics | | Narrative / review article | clinical, reporting | Concern families the classifier recognizes: `search_screening`, `design_leakage`, `confounding`, `imaging`, `reporting`, `reproducibility`, `statistics`, `clinical` (everything else falls to `other` and does not count toward coverage). When the research type is unknown, the axis-coverage check is skipped (the monoculture and lens-collapse checks still run). The gate never penalizes genuine consensus — only full reviewer redundancy (`LENS_COLLAPSE`) or panel-level concentration (`FAMILY_MONOCULTURE`). ### What a same-substrate panel cannot check: claims about other papers `SUBSTRATE_MONOCULTURE` says the panel is not independent. It does not say **where** the dependence bites, and that turns out to be specific rather than diffuse. Findings grounded in the manuscript's **own numbers** are checkable by any lens — the table is right there, and a reviewer sharing the drafter's substrate can still add the column up. Findings about **what another paper says** are not. There the reviewer inherits the drafter's reading of a source it never reopens, and every lens inherits the same one. A four-reviewer panel plus an editor lens, all sharing the drafter's substrate, cleared **two false characterisations of cited papers**. Its own roster had `SUBSTRATE_MONOCULTURE` recorded as an open finding, so the *risk* was known; what was not known is that the failures concentrate exactly there. A different-substrate pass caught one in a single run by opening the sources' enrolment criteria, and a senior co-author caught the other by reading the anchor paper's stated conclusion. **Roster rule.** Route these claim types by construction to a **non-drafter substrate or a human**: - Introduction premises about the state of the literature - Descriptions of comparator or prior studies — design, population, what they concluded - "No prior study has…" / "this is the first…" statements - Any sentence whose truth depends on a document the panel did not open **When the whole panel shares the drafter's substrate, the editor synthesis reports this class as `UNCHECKED`, not as reviewed.** That is the entire change, and it is deliberately cheap: it adds no reviewer and finds no new defect. It stops the panel claiming coverage it did not have — which is what let two false characterisations through with a clean panel report attached to them.
-
-
scripts
-
check_analysis_definitions_challenge
-
expected
-
defined.txt 351 B
== OK == fixture/defined.md load: models=2 auxiliary=2 tables=0 every reported analysis names its outcome and its reference standard. [INFO] ANALYSIS_LOAD L0 2 model families, 2 auxiliary analyses, 0 tables. Load is context, not a verdict — but it is what crowds the Methods until definitions fall out. If the checks above fired, look here first. -
undefined.txt 921 B
== UNDEFINED ANALYSES == fixture/undefined.md load: models=2 auxiliary=2 tables=0 [MAJOR] MODEL_OUTCOME_UNDEFINED L8 a Cox proportional-hazards model is specified with no outcome / dependent variable named near it — state the event and, for time-to-event, the time variable and the censoring rule [MAJOR] MODEL_OUTCOME_UNDEFINED L9 a Fine–Gray subdistribution model is specified with no outcome / dependent variable named near it — state the event and, for time-to-event, the time variable and the censoring rule [MAJOR] REFERENCE_STANDARD_UNDEFINED L15 discrimination and/or calibration are reported, but Methods names no reference standard or observed outcome to score the predictions against [INFO] ANALYSIS_LOAD L0 2 model families, 2 auxiliary analyses, 0 tables. Load is context, not a verdict — but it is what crowds the Methods until definitions fall out. If the checks above fired, look here first.
-
-
fixture
-
defined.md 1.3 KB
## Materials and Methods ### Study population Consecutive participants with a lesion detected at screening between 2010 and 2020 were included. Lesions were stratified into four size tiers: T4 (<6 mm), T3 (6–9 mm), T2 (10–14 mm), T1 (≥15 mm). ### Statistical analysis The outcome was a categorical size transition to a higher tier, confirmed on a subsequent scan. Time to event was measured from the baseline scan to the first qualifying transition; participants without a transition were censored at their last scan. Multivariable Cox proportional-hazards regression was used, adjusted for age, sex, and comorbidity. Because death and resection preclude a transition, a Fine–Gray subdistribution hazard model was fitted for the same outcome, treating those as competing events. Discrimination and calibration were assessed against the observed transition status at five years, with predicted versus observed risks compared across deciles. Two sensitivity analyses and a landmark analysis were performed. ## Results Median follow-up was 3.9 years. Compared with T4, the adjusted hazard ratios were 2.10 for T3 and 2.04 for T2. The Cox model showed a C-index of 0.71 and adequate calibration. The Fine–Gray model gave similar subdistribution hazard ratios. ## Discussion The tiers behaved as expected. -
undefined.md 857 B
## Materials and Methods ### Study population Consecutive participants with a lesion detected at screening between 2010 and 2020 were included. Lesions were stratified into four size tiers: T4 (<6 mm), T3 (6–9 mm), T2 (10–14 mm), T1 (≥15 mm). ### Statistical analysis Multivariable Cox proportional-hazards regression was used, adjusted for age, sex, and comorbidity. A Fine–Gray subdistribution hazard model was additionally fitted. Discrimination and calibration were assessed. Two sensitivity analyses and a landmark analysis were performed. ## Results Median follow-up was 3.9 years. Compared with T4, the adjusted hazard ratios were 2.10 for T3 and 2.04 for T2. The Cox model showed a C-index of 0.71 and adequate calibration. The Fine–Gray model gave similar subdistribution hazard ratios. ## Discussion The tiers behaved as expected.
-
-
problem.md 2.7 KB
# Challenge card — analysis-definitions gate (self-review) ## Problem A manuscript reports a multivariable Cox model. Nowhere does it say what the model predicts. It reports discrimination and calibration. Nowhere does it say what the predictions were scored against. That is not a hard paper. That is an **incomplete** one — and it is invisible to every other detector in this skill, because all twenty-four of them ask whether a number is *correct*, and none asks whether the analysis that produced it was ever *defined*. ## Why the count is not the crime The rejection that motivated this gate said, verbatim: > "**too many analyses** have been performed and reported, resulting in a manuscript > with multiple tables and a lengthy Results section. **This appears to have > contributed to omissions of critical information in the Materials and Methods > section** and further undermines readability." and, of the same manuscript: > "This section is **largely incomprehensible** in its current form." It is tempting to read that as *count the analyses and cap them*. **Do not.** A second reviewer, on the same manuscript, listed the sensitivity analyses as a **strength**. A detector that capped the count would have punished the strength and missed the defect. Load is the **cause**, not the crime: the analyses crowded the Methods until the definitions fell out. So the gate reports the load as **context** (`ANALYSIS_LOAD`, informational, never a verdict) and blocks only on what actually went missing. The two fixtures make this concrete. They contain **the same number of analyses** (two model families, two auxiliary analyses). One is flagged, one is clean. The only difference is whether each analysis names its outcome and its reference standard. ## What the gate does | verdict | fires when | |---|---| | `MODEL_NOT_IN_METHODS` | a model carries results but Methods never describes it | | `MODEL_OUTCOME_UNDEFINED` | a Cox / Fine–Gray / logistic / Poisson / mixed model is specified with no outcome or dependent variable named near it (and, for time-to-event, no time variable or censoring rule) | | `REFERENCE_STANDARD_UNDEFINED` | discrimination (C-index / AUC) or calibration is reported, but Methods names no reference standard or observed outcome to score against | | `TIER_LABEL_UNDEFINED` | a tier / group label carries results but Methods never states its defining criterion | | `ANALYSIS_LOAD` | informational — models, auxiliary analyses, tables. Context for the above; never a verdict on its own | ## Verify ```bash ./verify.sh ``` Exit 0 and `PASS` when the undefined manuscript is flagged (exit 1 under `--strict`) and the defined one — carrying the identical analyses — is clean. -
verify.sh 1.5 KB
#!/usr/bin/env bash # Deterministic verifier for the analysis-definitions challenge card. # Fixtures (synthetic only — no real manuscript, no PII): # undefined.md — a Cox model and a Fine–Gray model with no outcome named, and # discrimination/calibration with no reference standard. # defined.md — THE SAME ANALYSES (models=2, auxiliary=2), each carrying its # outcome, time variable, censoring rule, and reference standard. # The two differ only in whether the analyses are DEFINED. That is the point: the # count is identical, so a load-counting detector could not tell them apart. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)"; DET="$HERE/../check_analysis_definitions.py"; cd "$HERE" und="$(python3 "$DET" --manuscript fixture/undefined.md)" def="$(python3 "$DET" --manuscript fixture/defined.md)" pass=1 diff -u expected/undefined.txt <(printf '%s\n' "$und") || { echo "FAIL: undefined drift" >&2; pass=0; } diff -u expected/defined.txt <(printf '%s\n' "$def") || { echo "FAIL: defined drift" >&2; pass=0; } python3 "$DET" --manuscript fixture/undefined.md --strict --quiet >/dev/null 2>&1 && ru=0 || ru=$? python3 "$DET" --manuscript fixture/defined.md --strict --quiet >/dev/null 2>&1 && rd=0 || rd=$? [ "${ru:-0}" -eq 1 ] || { echo "FAIL: undefined should exit 1 (got ${ru:-0})" >&2; pass=0; } [ "$rd" -eq 0 ] || { echo "FAIL: defined should exit 0 (got $rd)" >&2; pass=0; } [ "$pass" -eq 1 ] && echo "PASS: same analysis count, opposite verdict — definition is what the gate reads." || exit 1
-
-
check_baseline_drift_challenge
-
expected
-
drifted.txt 576 B
Baseline-drift scan manuscript: fixture/drifted.md baseline: fixture/baseline.md DRIFT FOUND: 3 finding(s) (advisory / Minor) [STRENGTH_INFLATION] certainty markers 0.0->52.24/1k while hedges 30.49->0.0/1k vs baseline -- the result is stated more strongly than the approved version [SIGNIFICANCE_INFLATION_DRIFT] significance-inflation tokens 0->3 vs baseline (added: novel, pivotal, unprecedented) [SCOPE_INFLATION_DRIFT] generalization phrase(s) not in baseline: "can be used to", "in clinical practice", "ready for clinical use", "ready for deployment" -
nobaseline.txt 139 B
Baseline-drift scan manuscript: fixture/baseline.md baseline: (none -- no-op) OK: no baseline supplied; nothing to anchor against. -
overhedged.txt 237 B
Baseline-drift scan manuscript: fixture/overhedged.md baseline: fixture/baseline.md DRIFT FOUND: 1 finding(s) (advisory / Minor) [HEDGE_ACCRETION] hedge/caveat density 30.49->125.0/1k vs baseline -- cumulative over-hardening -
stable.txt 127 B
Baseline-drift scan manuscript: fixture/stable.md baseline: fixture/baseline.md OK: no framing drift beyond threshold.
-
-
fixture
-
baseline.md 1.1 KB
# Deep-learning classification of pulmonary nodules on CT ## Abstract In this single-center retrospective cohort, a convolutional neural network was evaluated for the classification of pulmonary nodules on chest CT. The model achieved an area under the curve of 0.84 (95% CI 0.79-0.88). Performance may depend on scanner type and reconstruction kernel, and the results could reflect the specific case mix of this institution. External validation on an independent cohort is needed before the model is used in another setting. ## Discussion The results suggest that the network discriminates malignant from benign nodules within this cohort. The model appears to perform comparably to a previously reported classifier, although direct comparison is limited by differences in the reference standard. These findings might inform a future prospective study. The sample was modest, the labels were assigned retrospectively, and calibration was not assessed, so the estimates should be interpreted with care. Whether the model generalizes beyond this population remains a question for prospective testing. -
drifted.md 913 B
# Deep-learning classification of pulmonary nodules on CT ## Abstract In this single-center retrospective cohort, a convolutional neural network was evaluated for the classification of pulmonary nodules on chest CT. The model achieved an area under the curve of 0.84 (95% CI 0.79-0.88). This novel network definitively demonstrates that malignant and benign nodules can be separated and establishes a new standard for automated triage. The approach is broadly applicable and can be used to guide management in clinical practice. ## Discussion The results prove that the network discriminates malignant from benign nodules. This pivotal, unprecedented model conclusively outperforms prior classifiers and demonstrates readiness across sites. These findings establish that automated classification is ready for clinical use, and the tool is ready for deployment as a standard component of the diagnostic pathway. -
overhedged.md 977 B
# Deep-learning classification of pulmonary nodules on CT ## Abstract In this single-center retrospective cohort, a convolutional neural network was evaluated for the classification of pulmonary nodules on chest CT. The model achieved an area under the curve of 0.84 (95% CI 0.79-0.88). Performance may depend on scanner type, might depend on reconstruction kernel, could depend on slice thickness, and may vary with vendor. The results could reflect the case mix of this institution and might not generalize. ## Discussion The results suggest, though we cannot exclude chance, that the network may discriminate nodules within this cohort. The model appears to perform comparably, but this possibly reflects overlap in the data, and it seems the estimate could be unstable. These findings might inform future work, although they could equally be artefactual, and we speculate that performance may degrade externally. The estimates could be fragile and might not be reliable. -
stable.md 1 KB
# Deep-learning classification of lung nodules on CT ## Abstract In this single-center retrospective study, a convolutional neural network was assessed for classifying pulmonary nodules on chest CT. The model reached an area under the curve of 0.84 (95% CI 0.79-0.88). Performance may depend on scanner type and reconstruction kernel, and the results could reflect the particular case mix of this institution. Validation on an independent cohort is needed before the model is used in another setting. ## Discussion The results suggest that the network discriminates malignant from benign nodules within this cohort. The model appears to perform comparably to a previously reported classifier, although direct comparison is limited by reference-standard differences. These findings might inform a future prospective study. The sample was modest, the labels were assigned retrospectively, and calibration was not assessed, so the estimates should be interpreted with care. Whether the model generalizes beyond this population remains a question for prospective testing.
-
-
problem.md 2.3 KB
# Challenge card — baseline drift (self-review) ## Problem Self-review is run in a loop (review → revise → review). The hazard is not any single pass but the *anchor*: each pass takes the previous **AI output** as its baseline, so a small framing bias introduced in one pass becomes the starting point for the next and compounds — claims strengthen, scope inflates, caveats accrete — while every individual pass looks locally reasonable. Nothing measured how far the manuscript had drifted from the last version a human actually approved. ## What the gate does `scripts/check_baseline_drift.py` compares the current manuscript against a **baseline** — the last human-approved / circulated version (the frozen v_N of manuscript-versioning), not the last AI output — and reports lexical framing drift via fixed, word-boundary lexicons: `STRENGTH_INFLATION` (certainty markers up while hedges fall), `SIGNIFICANCE_INFLATION_DRIFT` (novel / pivotal / unprecedented … tokens up), `SCOPE_INFLATION_DRIFT` (generalization phrases the baseline lacked), and `HEDGE_ACCRETION` (hedge density up — the cumulative form of over-hardening). It is advisory: framing is a judgment call, so every finding is Minor and the gate never blocks. With no `--baseline` there is nothing to anchor against and it emits zero findings. ## Fixture (synthetic only — no real manuscript, no PII) One approved `baseline.md` plus three revisions of it: - `fixture/drifted.md` — assertions replace hedges, three significance tokens and four scope phrases added → `STRENGTH_INFLATION` + `SIGNIFICANCE_INFLATION_DRIFT` + `SCOPE_INFLATION_DRIFT`. - `fixture/overhedged.md` — caveats piled on, claim strength unchanged → `HEDGE_ACCRETION` only. - `fixture/stable.md` — a legitimate reword at the same strength → **clean** (the false-positive control). - `baseline.md` alone (no `--baseline`) — **clean** (the crossfire path passes only `--manuscript`). ## Expected - `expected/<scenario>.txt` — the drift summary for each run. - Every scenario exits 0, even under `--strict` (advisory). The JSON artifact carries the same verdicts; the drifted draft carries all three inflation verdicts, the reword and the no-baseline path carry none. `verify.sh` diffs each stdout against `expected/`, asserts the always-exit-0 contract, and checks the JSON verdicts. Network-free, stdlib-only. -
verify.sh 2.1 KB
#!/usr/bin/env bash # Deterministic verifier for the baseline-drift challenge card. # Network-free, stdlib-only. The gate is advisory, so every run must exit 0 -- framing # drift is a judgment call the author owns; the gate flags it, it never blocks. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../check_baseline_drift.py" cd "$HERE" pass=1 check() { # name args... local name="$1"; shift local got; got="$(python3 "$DET" "$@")" if ! diff -u "expected/$name.txt" <(printf '%s\n' "$got"); then echo "FAIL: $name output drifted from expected/$name.txt" >&2; pass=0 fi python3 "$DET" "$@" --strict --quiet >/dev/null 2>&1 && rc=0 || rc=$? if [ "${rc:-0}" -ne 0 ]; then echo "FAIL: $name must exit 0 (advisory, even under --strict); got ${rc:-0}" >&2; pass=0 fi } check drifted --manuscript fixture/drifted.md --baseline fixture/baseline.md check overhedged --manuscript fixture/overhedged.md --baseline fixture/baseline.md check stable --manuscript fixture/stable.md --baseline fixture/baseline.md check nobaseline --manuscript fixture/baseline.md # The inflated draft must carry all three inflation verdicts in the JSON artifact; # the legitimate reword (stable) and the no-baseline path must carry none. tmp="$(mktemp)" python3 "$DET" --manuscript fixture/drifted.md --baseline fixture/baseline.md --out "$tmp" --quiet for v in STRENGTH_INFLATION SIGNIFICANCE_INFLATION_DRIFT SCOPE_INFLATION_DRIFT; do grep -q "\"verdict\": \"$v\"" "$tmp" || { echo "FAIL: drifted JSON missing $v" >&2; pass=0; } done python3 "$DET" --manuscript fixture/stable.md --baseline fixture/baseline.md --out "$tmp" --quiet grep -q '"verdict": "OK"' "$tmp" || { echo "FAIL: stable JSON should be OK (no drift)" >&2; pass=0; } python3 "$DET" --manuscript fixture/baseline.md --out "$tmp" --quiet grep -q '"n_claims": 0' "$tmp" || { echo "FAIL: no-baseline JSON should have 0 findings" >&2; pass=0; } rm -f "$tmp" if [ "$pass" -eq 1 ]; then echo "PASS: baseline-drift flags strength/significance/scope inflation and cumulative hedge accretion vs the human baseline, clears a legitimate reword, and is a no-op without a baseline." else exit 1 fi
-
-
check_dta_denominators_challenge
-
expected
-
bad.txt 506 B
== DENOMINATOR MISMATCH == fixture/dta_bad.md mismatches=2 [MAJOR] DTA_DENOMINATOR_MISMATCH sensitivity denominator 18 (disease-positive) ≠ reference-standard positive count 19 from the characteristics table [MAJOR] DTA_DENOMINATOR_MISMATCH specificity denominator 15 (disease-negative) ≠ reference-standard negative count 14 from the characteristics table [INFO ] GRAND_TOTAL_AGREES grand totals agree (14+19 = 18+15 = 33), so a totals-only check passes — the split-level mismatch is the defect -
ok.txt 119 B
== OK == fixture/dta_ok.md mismatches=0 sens/spec denominators reconcile with the reference-standard category counts.
-
-
fixture
-
dta_bad.md 281 B
## Table 1. Reference standard (pathological N stage) | Pathological N stage | Patients | | --- | --- | | pN0 | 14 | | pN1 | 13 | | pN2 | 6 | ## Results Diagnostic performance was assessed in 33 patients. The specificity was 93.3% (14/15) and the sensitivity was 72.2% (13/18). -
dta_ok.md 280 B
## Table 1. Reference standard (pathological N stage) | Pathological N stage | Patients | | --- | --- | | pN0 | 14 | | pN1 | 13 | | pN2 | 6 | ## Results Diagnostic performance was assessed in 33 patients. The specificity was 100% (14/14) and the sensitivity was 68.4% (13/19).
-
-
problem.md 1.5 KB
# Challenge card — DTA-denominator gate (self-review) ## Problem Sensitivity is a/(a+c) over disease-positive patients and specificity d/(b+d) over disease-negative patients, so those denominators must equal the reference-standard positive / negative counts in the characteristics table. When they disagree while the grand total still matches (table 14/19, Results 15/18, both = 33), a totals-only check passes and the split-level error — under an Abstract headline — survives multiple review rounds. It is present from the first submission, not introduced by a revision. ## What the gate does `check_dta_denominators.py` recovers the reference-standard negative / positive counts from the characteristics table (pN0 vs pN1+pN2; node-negative vs -positive; absent vs present) and the sensitivity / specificity denominators from the Results prose, and asserts each denominator equals its category count — explicitly noting when the grand totals agree, because that is what hides it. It also checks a staging breakdown sums (`STAGE_ROWSUM`: correct + over + under == n). ## Fixture (synthetic only — no real manuscript, no PII) - `fixture/dta_bad.md` — table pN0=14 / pN1+pN2=19; Results specificity 14/15 and sensitivity 13/18; grand totals both 33. - `fixture/dta_ok.md` — Results 14/14 and 13/19 match the table. ## Expected - `expected/bad.txt` — two `DTA_DENOMINATOR_MISMATCH` + `GRAND_TOTAL_AGREES`; exit 1 under `--strict`. - `expected/ok.txt` — `OK`; exit 0. `verify.sh` diffs both outputs and asserts the exit-code contract. Network-free, stdlib-only. -
verify.sh 1.4 KB
#!/usr/bin/env bash # Deterministic verifier for the DTA-denominator challenge card. cd HERE for a # stable relative source path. # Fixtures (synthetic only — no real manuscript, no PII): # dta_bad.md — reference-standard table gives pN0=14 (neg) / pN1+pN2=19 (pos), but # Results report specificity 14/15 and sensitivity 13/18; grand totals # still agree (33) -> 2x DTA_DENOMINATOR_MISMATCH + GRAND_TOTAL_AGREES. # dta_ok.md — Results 14/14 and 13/19 match the table -> OK. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)"; DET="$HERE/../check_dta_denominators.py"; cd "$HERE" bad="$(python3 "$DET" --manuscript fixture/dta_bad.md)"; ok="$(python3 "$DET" --manuscript fixture/dta_ok.md)" pass=1 diff -u expected/bad.txt <(printf '%s\n' "$bad") || { echo "FAIL: bad drift" >&2; pass=0; } diff -u expected/ok.txt <(printf '%s\n' "$ok") || { echo "FAIL: ok drift" >&2; pass=0; } python3 "$DET" --manuscript fixture/dta_bad.md --strict --quiet >/dev/null 2>&1 && rb=0 || rb=$? python3 "$DET" --manuscript fixture/dta_ok.md --strict --quiet >/dev/null 2>&1 && ro=0 || ro=$? [ "${rb:-0}" -eq 1 ] || { echo "FAIL: bad should exit 1 (got ${rb:-0})" >&2; pass=0; } [ "$ro" -eq 0 ] || { echo "FAIL: ok should exit 0 (got $ro)" >&2; pass=0; } [ "$pass" -eq 1 ] && echo "PASS: DTA-denominator gate flags the split mismatch behind a matching grand total." || exit 1
-
-
check_effect_stability_challenge
-
expected
-
bad.txt 726 B
========================================= Effect Stability (Phase 2.5) ========================================= | Check | Severity | Detail | |---|---|---| | UNSTABLE_EFFECT_ESTIMATE | Major | OR 24.0 has a 95% CI 3.0-240.0 spanning 80-fold (> 10x) in the Abstract/Conclusions with no co-located caveat; a 80-fold interval supports a direction, not the point estimate — report it as such or add an imprecision/exploratory caveat | | EPV_LOW | Major | 18 events for 3 covariates is 6.0 events per variable (< 10); the model is underpowered for stable coefficient estimation — reduce covariates, use penalisation, or report the estimates as exploratory | MAJOR candidate: 2 unstable effect estimate / low-EPV finding(s). -
ok.txt 305 B
========================================= Effect Stability (Phase 2.5) ========================================= | Check | Severity | Detail | |---|---|---| | (none) | — | no headline estimate has an order-of-magnitude interval | OK: no headline estimate spans an order of magnitude without a caveat.
-
-
fixture
-
effect_bad.md 233 B
# Growth as a predictor of malignancy ## Conclusions Interval growth was strongly associated with malignancy (OR 24.0; 95% CI 3.0–240.0). ## Methods The multivariable logistic model was estimated on 18 events for 3 covariates. -
effect_ok.md 309 B
# Growth as a predictor of malignancy ## Conclusions Interval growth was associated with malignancy (OR 2.4; 95% CI 1.3–4.4). A secondary subgroup estimate (OR 24.0; 95% CI 3.0–240.0) is exploratory and imprecise given the small number of events, and is reported as a direction rather than a magnitude.
-
-
problem.md 953 B
# Challenge: an effect estimate whose CI spans an order of magnitude A manuscript's Conclusions report **OR 24.0 (95% CI 3.0–240.0)** as a magnitude. The interval spans **80-fold** — the data support a direction, not the point estimate — and the model was fit on **18 events for 3 covariates** (EPV 6.0 < 10). This is a synthetic counterexample: the expected findings are a wide-interval warning and a low events-per-variable warning. `check_effect_stability.py` recomputes both from the printed cells: `UNSTABLE_EFFECT_ESTIMATE` when a headline OR/HR/RR has a CI upper/lower ratio > 10 with no co-located imprecision caveat, and `EPV_LOW` when events/covariates < 10. `verify.sh` runs the detector on `fixture/effect_bad.md` (fires both) and `fixture/effect_ok.md` (a tight CI plus the same wide CI labelled exploratory — must stay silent), diffing stdout against `expected/` and asserting exit codes. Synthetic fixtures only; network-free. -
verify.sh 2.1 KB
#!/usr/bin/env bash # Deterministic verifier for the effect-stability challenge card. # Runs check_effect_stability.py on two synthetic manuscripts and diffs stdout # against expected/. Stdlib-only, network-free. Exit 0 = both match and exit codes # are correct. cd into HERE so the reported source path is the stable relative # "fixture/..." (portable across CI checkout locations). # # Fixtures (synthetic only — no real manuscript, no PII): # effect_bad.md — Conclusions "OR 24.0; 95% CI 3.0-240.0" (80-fold, no caveat) + # Methods "18 events for 3 covariates" (EPV 6.0) # -> UNSTABLE_EFFECT_ESTIMATE + EPV_LOW. # effect_ok.md — a tight CI (OR 2.4; 1.3-4.4, ratio 3.4) plus the SAME wide CI # explicitly labelled "exploratory and imprecise ... a direction # rather than a magnitude" -> silent (caveat suppresses the flag, # tight CI is below threshold). The precision control. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../check_effect_stability.py" cd "$HERE" bad="$(python3 "$DET" --manuscript fixture/effect_bad.md)" ok="$(python3 "$DET" --manuscript fixture/effect_ok.md)" pass=1 if ! diff -u expected/bad.txt <(printf '%s\n' "$bad"); then echo "FAIL: bad-fixture output drifted from expected/bad.txt" >&2; pass=0 fi if ! diff -u expected/ok.txt <(printf '%s\n' "$ok"); then echo "FAIL: ok-fixture output drifted from expected/ok.txt" >&2; pass=0 fi python3 "$DET" --manuscript fixture/effect_bad.md --strict --quiet >/dev/null 2>&1 && rc_bad=0 || rc_bad=$? python3 "$DET" --manuscript fixture/effect_ok.md --strict --quiet >/dev/null 2>&1 && rc_ok=0 || rc_ok=$? [ "${rc_bad:-0}" -eq 1 ] || { echo "FAIL: bad fixture should exit 1 under --strict (got ${rc_bad:-0})" >&2; pass=0; } [ "$rc_ok" -eq 0 ] || { echo "FAIL: ok fixture should exit 0 under --strict (got $rc_ok)" >&2; pass=0; } if [ "$pass" -eq 1 ]; then echo "PASS: effect-stability gate flags the 80-fold interval + low EPV and clears the tight CI + caveat-labelled control." else exit 1 fi
-
-
check_figure_citation_challenge
-
fixture
-
panel_regression.md 453 B
# Multi-panel citation regression The lesion appears in (Figure 1a) and the control in (Figure 1b). Figure 2c shows the overlay, and Table 1 lists the cohort.   ## Figure legends **Figure 1.** Multi-panel figure: (a) lesion, (b) control. **Figure 2.** Overlay panels: (a) baseline, (b) follow-up, (c) difference. **Table 1.** Baseline characteristics of the cohort. -
real_orphan.md 329 B
# Orphan detection still works We reference (Figure 1a) in the text; Table 1 summarizes the results.  ## Figure legends **Figure 1.** Panels (a) and (b). **Figure 2.** This figure has a caption but is never cited anywhere in the body. **Table 1.** Baseline characteristics.
-
-
problem.md 1.4 KB
# Challenge — figure-citation gate must read panel-suffixed citations ## The defect this fixes Multi-panel figures are cited in the body only by panel — `(Figure 3a)`, `(Figure 3b)` — never as a bare "Figure 3". The orphan gate's mention regex ended in `(?P<num>\d+)\b`, and there is **no word boundary between "3" and "a"**, so "Figure 3a" matched nothing. Figure 3 therefore looked uncited and `FIGURE_ORPHAN` fired — a false positive on every manuscript that uses multi-panel figures. The fix allows an optional single-letter panel suffix in the *citation* regex only; the caption anchor (`Figure 3.` names the whole float) is unchanged, so the caption↔citation correspondence the gate depends on is preserved. ## Fixtures - **`panel_regression.md`** (regression / negative): Figure 1 is cited only as `(Figure 1a)` / `(Figure 1b)`, Figure 2 only as `Figure 2c`, the table is cited, and both images are embedded. Post-fix this is **clean (zero claims)**; pre-fix Figures 1 and 2 both fired `FIGURE_ORPHAN`. - **`real_orphan.md`** (positive): Figure 1 is cited (`Figure 1a`) but Figure 2 has a caption and is **never cited in any form** → `FIGURE_ORPHAN` for Figure 2 only. Proves the fix did not blind the gate to genuine orphans. ## Verify `bash verify.sh` — deterministic, network-free. Asserts the panel-cited manuscript is clean and the truly-uncited figure still fires. -
verify.sh 1.6 KB
#!/usr/bin/env bash # Deterministic verifier for the figure-citation panel-suffix regression. # Regression: figures cited ONLY by panel (Figure 1a / 2c) must NOT be orphans -> clean. # Positive: a figure with a caption and no citation in any form -> FIGURE_ORPHAN. # No network. Exit 0 = both match expectations. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../check_figure_citation.py" tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT # --- Regression: panel-cited figures are NOT orphans, and nothing else fires --- python3 "$DET" --manuscript "$HERE/fixture/panel_regression.md" --quiet --out "$tmp/reg.json" n=$(python3 -c "import json;print(len(json.load(open('$tmp/reg.json'))['claims']))") if [ "$n" -ne 0 ]; then echo "FAIL: panel-cited manuscript should be clean, got $n claim(s) (the false positive this fixes)" >&2 cat "$tmp/reg.json" >&2 exit 1 fi # --- Positive: a genuinely uncited figure still fires; a panel-cited one does not --- python3 "$DET" --manuscript "$HERE/fixture/real_orphan.md" --quiet --out "$tmp/orph.json" python3 - "$tmp/orph.json" <<'PY' import json, sys orph = [c for c in json.load(open(sys.argv[1]))["claims"] if c["verdict"] == "FIGURE_ORPHAN"] assert any("Figure 2 " in c["detail"] for c in orph), "Figure 2 (never cited) should be FIGURE_ORPHAN" assert not any("Figure 1 " in c["detail"] for c in orph), "Figure 1 (cited via 1a) must NOT be an orphan" print("orphan detection intact: Figure 2 flagged, Figure 1 (panel-cited) not") PY echo "PASS: panel-cited figures are not orphans (regression); a truly uncited figure still fires."
-
-
check_incorporation_bias_challenge
-
expected
-
bad.txt 624 B
========================================= Incorporation Bias (Phase 2.5) ========================================= | Check | Severity | Detail | |---|---|---| | INCORPORATION_BIAS | Major | the reference standard defines the outcome by size trajectory (resolution / decrease / stability / growth), and 'Growth' — a trajectory variable — is reported as associated with the outcome: the predictor is built into the reference standard, so the association is partly definitional. Use a predictor independent of the reference standard, or disclose the overlap explicitly | MAJOR candidate: 1 incorporation-bias finding(s). -
ok.txt 303 B
========================================= Incorporation Bias (Phase 2.5) ========================================= | Check | Severity | Detail | |---|---|---| | (none) | — | reference standard and reported predictors are independent | OK: reference standard and reported predictors are independent.
-
-
fixture
-
incorp_bad.md 347 B
# Growth as a predictor of malignancy in pulmonary nodules ## Reference standard Nodules were classified benign by (b) complete resolution, (c) decrease in diameter, or (d) size stability on follow-up CT; malignant nodules were confirmed histologically. ## Results Growth was strongly associated with malignancy (OR 24.0, 95% CI 3.0–240.0). -
incorp_ok.md 299 B
# Growth as a predictor of malignancy in pulmonary nodules ## Reference standard Malignancy was confirmed by surgical pathology, and benign nodules by two-year imaging follow-up plus multidisciplinary review. ## Results Growth was strongly associated with malignancy (OR 3.1, 95% CI 1.8–5.3).
-
-
problem.md 949 B
# Challenge: the reference standard and the predictor are the same construct A synthetic nodule-study example classifies nodules benign by **resolution / decrease / stability** (all forms of *not growing*) and then reports **growth** as associated with malignancy (OR 24.0). A resolved nodule cannot be malignant under that standard — the growth–malignancy association is partly definitional (incorporation bias). Two reviewers called it fatal; no gate fired. `check_incorporation_bias.py` reads trajectory tokens from the reference-standard DEFINING sentences and, if a trajectory-named predictor is reported as associated with the outcome, emits `INCORPORATION_BIAS` (Major) — unless the overlap is disclosed. `verify.sh` runs it on `fixture/incorp_bad.md` (fires) and `fixture/incorp_ok.md` (a pathology + follow-up standard → silent), diffing stdout against `expected/` and asserting exit codes. Synthetic fixtures only; network-free. -
verify.sh 1.8 KB
#!/usr/bin/env bash # Deterministic verifier for the incorporation-bias challenge card. # Runs check_incorporation_bias.py on two synthetic manuscripts and diffs stdout # against expected/. Stdlib-only, network-free. cd into HERE so the reported source # path is the stable relative "fixture/...". # # Fixtures (synthetic only — no real manuscript, no PII): # incorp_bad.md — reference standard defines benign by resolution/decrease/stability # (a size trajectory) AND Results reports "Growth ... associated with # malignancy (OR 24.0)" -> INCORPORATION_BIAS (the predictor is the # reference standard). # incorp_ok.md — reference standard is surgical pathology + imaging follow-up (no # trajectory tier); the growth-OR is then independent -> silent. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../check_incorporation_bias.py" cd "$HERE" bad="$(python3 "$DET" --manuscript fixture/incorp_bad.md)" ok="$(python3 "$DET" --manuscript fixture/incorp_ok.md)" pass=1 diff -u expected/bad.txt <(printf '%s\n' "$bad") || { echo "FAIL: bad drifted" >&2; pass=0; } diff -u expected/ok.txt <(printf '%s\n' "$ok") || { echo "FAIL: ok drifted" >&2; pass=0; } python3 "$DET" --manuscript fixture/incorp_bad.md --strict --quiet >/dev/null 2>&1 && rb=0 || rb=$? python3 "$DET" --manuscript fixture/incorp_ok.md --strict --quiet >/dev/null 2>&1 && ro=0 || ro=$? [ "${rb:-0}" -eq 1 ] || { echo "FAIL: bad should exit 1 (got ${rb:-0})" >&2; pass=0; } [ "$ro" -eq 0 ] || { echo "FAIL: ok should exit 0 (got $ro)" >&2; pass=0; } [ "$pass" -eq 1 ] && echo "PASS: incorporation-bias gate flags the trajectory-standard/trajectory-predictor overlap and clears the pathology-standard control." || exit 1
-
-
check_nested_group_comparison_challenge
-
expected
-
clean.txt 104 B
== OK == fixture/clean.md nested_comparisons=0 no subset-vs-parent-cohort P-value comparison detected. -
nested.txt 301 B
== NESTED COMPARISON FOUND == fixture/nested.md nested_comparisons=1 [MAJOR] NESTED_GROUP_TEST L3 P-value table compares subset 'Pathology-correlated subset (n = 33)' (n=33) against 'Full cohort (n = 132)' (n=132) that contains it — nested groups; the valid contrast is subset vs remainder (n=99)
-
-
fixture
-
clean.md 328 B
## Table 2a. Randomised arms | Characteristic | Arm A (n = 66) | Arm B (n = 66) | P | | --- | --- | --- | --- | | Male | 40 (61) | 39 (59) | 0.85 | ## Table 2b. Subset vs remainder (disjoint) | Characteristic | Analysed subset (n = 33) | Remainder (n = 99) | P | | --- | --- | --- | --- | | Male | 20 (61) | 59 (60) | 0.91 | -
nested.md 243 B
## Table 3. Representativeness of the analysed subset | Characteristic | Full cohort (n = 132) | Pathology-correlated subset (n = 33) | P | | --- | --- | --- | --- | | Male | 79 (60) | 20 (61) | 0.90 | | Age > 60 | 66 (50) | 18 (55) | 0.62 |
-
-
problem.md 1.5 KB
# Challenge card — nested-group-comparison gate (self-review) ## Problem Answering a selection-bias comment, authors add a "representativeness" table that compares the analysed subset (n=33) against the "full cohort" (n=132) *containing* those 33 patients, with P values. The groups are nested, so the two-group test is invalid — not merely uninformative. The correct contrast is subset vs remainder (n=99). The column headers announce both n's and their labels verbatim, so the defect is deterministically visible. ## What the gate does `check_nested_group_comparison.py` scans GFM tables for two `n = N` column headers, one labelled subset/sub-cohort/correlated/analysed/surgical/pathology and the other labelled full/total/overall/entire/whole cohort, together with a P-value column, and flags `NESTED_GROUP_TEST` when the subset n is smaller than the cohort that names it. A "remainder" label is excluded so a valid subset-vs-remainder table does not fire. ## Fixture (synthetic only — no real manuscript, no PII) - `fixture/nested.md` — full cohort (n=132) vs pathology-correlated subset (n=33) + P. - `fixture/clean.md` — disjoint randomised arms (n=66 / n=66) and a valid subset-vs-remainder table (n=33 / n=99), both with P columns; neither fires. ## Expected - `expected/nested.txt` — `NESTED_GROUP_TEST`; exit 1 under `--strict`. - `expected/clean.txt` — `OK`; exit 0. `verify.sh` diffs both outputs and asserts the exit-code contract. Network-free, stdlib-only. -
verify.sh 1.4 KB
#!/usr/bin/env bash # Deterministic verifier for the nested-group-comparison challenge card. # cd HERE so the reported source path is the stable relative "fixture/...". # Fixtures (synthetic only — no real manuscript, no PII): # nested.md — a P-value table comparing "subset (n=33)" against "full cohort # (n=132)" that contains it -> NESTED_GROUP_TEST. # clean.md — disjoint randomised arms (n=66 / n=66) + a valid subset-vs-remainder # table (n=33 / n=99), both with P columns -> OK (no false positive). set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)"; DET="$HERE/../check_nested_group_comparison.py"; cd "$HERE" nested="$(python3 "$DET" --manuscript fixture/nested.md)" clean="$(python3 "$DET" --manuscript fixture/clean.md)" pass=1 diff -u expected/nested.txt <(printf '%s\n' "$nested") || { echo "FAIL: nested drift" >&2; pass=0; } diff -u expected/clean.txt <(printf '%s\n' "$clean") || { echo "FAIL: clean drift" >&2; pass=0; } python3 "$DET" --manuscript fixture/nested.md --strict --quiet >/dev/null 2>&1 && rn=0 || rn=$? python3 "$DET" --manuscript fixture/clean.md --strict --quiet >/dev/null 2>&1 && rc=0 || rc=$? [ "${rn:-0}" -eq 1 ] || { echo "FAIL: nested should exit 1 (got ${rn:-0})" >&2; pass=0; } [ "$rc" -eq 0 ] || { echo "FAIL: clean should exit 0 (got $rc)" >&2; pass=0; } [ "$pass" -eq 1 ] && echo "PASS: nested-group gate flags subset-vs-parent and clears disjoint + subset-vs-remainder." || exit 1
-
-
check_paired_difference_estimator_challenge
-
expected
-
bad.txt 550 B
== ESTIMATOR PROBLEM == fixture/paired_bad.md problems=3 [MAJOR] DEGENERATE_CI reported 95% CI has equal bounds (0.5–0.5) — zero width [MAJOR] ESTIMATOR_UNNAMED a median-difference effect size with a CI is reported but no estimator / interval method (Hodges–Lehmann, pseudomedian, bootstrap, Wilcoxon, exact, sign test) is named [MAJOR] MEDIAN_PARITY reported median paired difference 0.5 is not achievable: the median of n=153 (odd) integer differences must be an integer — likely a Hodges–Lehmann pseudomedian mislabelled as a median -
ok.txt 111 B
== OK == fixture/paired_ok.md problems=0 median-difference estimator, interval, and value set are consistent.
-
-
fixture
-
paired_bad.md 257 B
## Results Across 153 paired integer scores on a 4-point Likert scale, the median paired difference was 0.5 points (95% CI: 0.5–0.5) in favour of the index test. ## Statistical analysis Paired scores were compared and summary statistics were reported. -
paired_ok.md 287 B
## Results Across 153 paired scores, the Hodges–Lehmann pseudomedian of the paired differences was 0.5 (95% CI 0.0 to 1.0) in favour of the index test. ## Statistical analysis Paired differences were summarised with the Hodges–Lehmann pseudomedian and exact confidence intervals.
-
-
problem.md 1.5 KB
# Challenge card — paired-difference-estimator gate (self-review) ## Problem A reader/Likert study answers an uncertainty comment by adding a "median paired difference … 0.5 points (95% CI 0.5–0.5)". The tells are deterministic: the median of an odd number of integer paired differences is one of those integers, so 0.5 is impossible (it is a Hodges–Lehmann pseudomedian mislabelled as a median); the CI has zero width; and no estimator or interval method is named, so nothing is reproducible. ## What the gate does `check_paired_difference_estimator.py` flags `MEDIAN_PARITY` (n odd ∧ integer scale ∧ non-integer reported median difference — allowing a 1/R step when scores are means of R raters), `DEGENERATE_CI` (equal interval bounds), and `ESTIMATOR_UNNAMED` (a median-difference effect size + CI with no Hodges–Lehmann / pseudomedian / bootstrap / Wilcoxon / exact / sign-test named). A value labelled a Hodges–Lehmann pseudomedian is exempt from the parity check. ## Fixture (synthetic only — no real manuscript, no PII) - `fixture/paired_bad.md` — 0.5 median over n=153 4-point scores, 95% CI 0.5–0.5, no estimator. - `fixture/paired_ok.md` — Hodges–Lehmann pseudomedian 0.5 (95% CI 0.0 to 1.0), estimator named. ## Expected - `expected/bad.txt` — `MEDIAN_PARITY` + `DEGENERATE_CI` + `ESTIMATOR_UNNAMED`; exit 1 under `--strict`. - `expected/ok.txt` — `OK`; exit 0. `verify.sh` diffs both outputs and asserts the exit-code contract. Network-free, stdlib-only. -
verify.sh 1.5 KB
#!/usr/bin/env bash # Deterministic verifier for the paired-difference-estimator challenge card. cd HERE # for a stable relative source path. # Fixtures (synthetic only — no real manuscript, no PII): # paired_bad.md — "median paired difference 0.5" over n=153 (odd) 4-point integer # scores, a 95% CI of 0.5–0.5, and no estimator named # -> MEDIAN_PARITY + DEGENERATE_CI + ESTIMATOR_UNNAMED. # paired_ok.md — a Hodges–Lehmann pseudomedian 0.5 (95% CI 0.0 to 1.0) with the # estimator named -> OK (a pseudomedian may be half-integer). set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)"; DET="$HERE/../check_paired_difference_estimator.py"; cd "$HERE" bad="$(python3 "$DET" --manuscript fixture/paired_bad.md)"; ok="$(python3 "$DET" --manuscript fixture/paired_ok.md)" pass=1 diff -u expected/bad.txt <(printf '%s\n' "$bad") || { echo "FAIL: bad drift" >&2; pass=0; } diff -u expected/ok.txt <(printf '%s\n' "$ok") || { echo "FAIL: ok drift" >&2; pass=0; } python3 "$DET" --manuscript fixture/paired_bad.md --strict --quiet >/dev/null 2>&1 && rb=0 || rb=$? python3 "$DET" --manuscript fixture/paired_ok.md --strict --quiet >/dev/null 2>&1 && ro=0 || ro=$? [ "${rb:-0}" -eq 1 ] || { echo "FAIL: bad should exit 1 (got ${rb:-0})" >&2; pass=0; } [ "$ro" -eq 0 ] || { echo "FAIL: ok should exit 0 (got $ro)" >&2; pass=0; } [ "$pass" -eq 1 ] && echo "PASS: paired-difference gate flags the impossible median + degenerate CI + unnamed estimator." || exit 1
-
-
check_perspective_structure_challenge
-
expected
-
edge_hardened.txt 300 B
========================================== Perspective structure (§J / §D) ========================================== | Verdict | Severity | Detail | |---|---|---| | (none) | - | Perspective structure within convention | OK: argument-move headings and an abstract that carries an authorial move. -
not_perspective.txt 283 B
========================================== Perspective structure (§J / §D) ========================================== | Verdict | Severity | Detail | |---|---|---| | (none) | - | not gated as a Perspective | OK: article_type='original article' is not a Perspective; no findings. -
perspective_bad.txt 1000 B
========================================== Perspective structure (§J / §D) ========================================== | Verdict | Severity | Detail | |---|---|---| | PERSPECTIVE_HEADING_NOT_ASSERTION | Minor | 2 IMRAD section heading(s) in a Perspective (1. Introduction, Methods) — published Perspectives name sections as argument-moves, not "Introduction / Methods / Results / Discussion". Rename each heading to the claim it makes; Conclusion / Summary are fine. | | PERSPECTIVE_ABSTRACT_NO_AUTHORIAL_MOVE | Minor | the abstract states its thesis declaratively, with no explicit authorial move ("we argue" / "we propose" / "here we ..."). Eight of nine sampled npj Digital Medicine Perspectives open the abstract with one; a report-style abstract reads as a study, not a position. Lead the thesis with the move. | PERSPECTIVE_HEADING_NOT_ASSERTION - 1. Introduction; Methods PERSPECTIVE_ABSTRACT_NO_AUTHORIAL_MOVE - Automated triage tools are being placed after the clinician forms a ju... -
perspective_ok.txt 300 B
========================================== Perspective structure (§J / §D) ========================================== | Verdict | Severity | Detail | |---|---|---| | (none) | - | Perspective structure within convention | OK: argument-move headings and an abstract that carries an authorial move.
-
-
fixture
-
edge_hardened.md 1.5 KB
--- title: "Automated triage advice and the second opinion" article_type: "Perspective" --- ## Abstract We argue that a tool placed after the clinician's judgment inherits the clinician's framing and cannot, on its own, serve as an independent check. Systems should declare and measure where their review is independent, so that a claim of a second opinion can be examined rather than assumed. <!-- ## Methods This commented block must be ignored: a commented heading must not be flagged, and a stray "we argue" inside a comment must not affect the abstract verdict. --> ## The tool reads your summary, not the case Automated triage advice is offered at the point of care, and the safeguard asks the clinician to commit before the tool speaks. The tool receives the written summary, reasons over it, and returns the clinician's frame with a machine's authority. That is the arrangement a genuine second opinion is meant to avoid, and naming it is the first step toward measuring it. ### Box 1 A level-three heading is not a section and must not be treated as one by the parser. ## A route the first reading did not take A review is independent only when it reaches the judgment by a path the first assessment did not travel. Where the record can be read directly, the tool should read it, and the two readings should meet only after both exist. ## Submission notes ## Abstract A duplicate abstract heading in the notes tail is declarative and states no position; the gate evaluates the first abstract only and warns about the duplicate rather than scoring this one. -
not_perspective.md 1.2 KB
--- title: "Automated triage advice: an agreement study" article_type: "Original Article" --- ## Abstract Background: Automated triage tools are increasingly deployed. Methods: Agreement between a tool and clinicians was evaluated. Results: Agreement was moderate. Conclusion: Independent review remains necessary. ## Introduction Automated triage advice is offered at the point of care. This study evaluates how often a tool and a clinician reach the same assessment, and whether disagreements identify cases that benefit from review. Prior descriptions of such tools have not measured the independence of the tool's review from the clinician's own summary. ## Methods Tool output was compared with clinician assessments across a set of synthetic cases. Agreement was summarized descriptively, and no new patient data were collected for this analysis. ## Results Agreement between the tool and clinicians was moderate across the synthetic cases considered, with most disagreements concentrated on ambiguous presentations. ## Discussion Independent review remains necessary, and a tool that reasons over the clinician's summary may not supply it. The genre gate should keep this study silent because it is not a Perspective. -
perspective_bad.md 1.4 KB
--- title: "Automated triage advice and the second opinion" article_type: "Perspective" --- ## Abstract Automated triage tools are being placed after the clinician forms a judgment. This preserves the clinician's reasoning, but the tool then reviews a summary the clinician wrote, so its agreement is not an independent check. Tools should state where their review is independent. ## 1. Introduction Automated triage advice is now offered at the point of care, and the common safeguard asks the clinician to commit to an assessment before the tool speaks. The intent is sound: the clinician's own reasoning is exercised rather than skipped, and the habit of thinking first is kept intact. Yet the tool receives the clinician's written summary, not the raw case, and it reasons over that summary. When the tool agrees, it is partly returning the clinician's own framing with the authority of a machine. ## Methods We describe the interaction pattern and the conditions under which the second review remains independent of the first. The account is conceptual and draws on published descriptions of triage deployments rather than on new data collected for this piece. ## A way forward A review is independent only when it reaches the judgment by a route the first assessment did not travel. Where the record can be read directly, the tool should read it, and the two readings should meet only after both exist. -
perspective_ok.md 1.3 KB
--- title: "Automated triage advice and the second opinion" article_type: Perspective --- ## Abstract Here we argue that placing an automated tool after the clinician's judgment preserves the clinician's reasoning but does not, by itself, provide an independent second opinion, because the tool reasons over the clinician's own summary. We propose that systems declare where their review is independent and measure it where they cannot. ## The tool reads your summary, not the case Automated triage advice is offered at the point of care, and the safeguard asks the clinician to commit before the tool speaks. The clinician's reasoning is exercised, which is what the safeguard was built to protect. The tool, however, receives the summary the clinician wrote: a selection of findings, an ordering, and often the working impression itself. Reasoning over that summary, the tool inherits the clinician's frame, and its agreement returns that frame with a machine's authority. ## A route the first reading did not take A review is independent only when it reaches the judgment by a path the first assessment did not travel. Where the record can be read directly, the tool should read it, and the two readings should meet only after both exist. This is the arrangement a genuine second opinion depends upon.
-
-
problem.md 2.4 KB
# Challenge: a Perspective drafted like an original article A **Perspective** earns its place through prose, not data. Two habits carried over from IMRAD writing make a draft read as a study rather than an argument, and reviewers notice both: 1. **IMRAD section headings.** Published Perspectives name their sections as *argument-moves* — "The model reads your account, not your patient" — never "Introduction / Methods / Results / Discussion". A generic IMRAD heading in a Perspective is a tell. 2. **A thesis abstract with no authorial move.** Eight of nine sampled npj Digital Medicine Perspectives open the abstract with an explicit "we argue" / "we propose" / "here we ...". A purely declarative abstract reads as a report. `check_perspective_structure.py` catches exactly these two surface forms — and only when the manuscript is a Perspective (front-matter `article_type:` or `--type`). Both verdicts are Minor (advisory); the gate never judges the argument. ## Why the parser has to be careful The trap is false positives on a *good* Perspective, so the parser (hardened against a Codex design review) must: - read the genre only from the leading `---` front matter, not a body `**Article type**` line; - blank HTML comment blocks first, so a commented-out `## Methods` is not flagged and a "we argue" inside a comment does not suppress the abstract verdict; - treat only level-2 `##` lines as sections (`### Box 1` is not one) and strip leading section numbers ("1. Introduction") before the IMRAD-token test; - skip structural / front / back-matter headings (Title page, Abstract, Display items, Submission notes, References, ...); - evaluate the first body Abstract and warn on a duplicate; - allow Conclusion / Summary headings and an absent abstract. ## What the fixtures assert | Fixture | Expectation | |---|---| | `perspective_bad.md` | fires HEADING (`## 1. Introduction`, `## Methods`) **and** ABSTRACT (flat) | | `perspective_ok.md` | silent — argument-move headings + "Here we argue ..." abstract | | `not_perspective.md` | silent — an Original Article with full IMRAD (genre gate) | | `edge_hardened.md` | silent findings + a duplicate-abstract warning — commented `## Methods` and "we argue" ignored, `### Box` is not a section, the first (authorial) abstract wins | The gate is Minor-only, so it always exits 0; the verdicts are in stdout, which is why `verify.sh` diffs the golden stdout rather than trusting an exit code. -
verify.sh 3.4 KB
#!/usr/bin/env bash # Deterministic verifier for the perspective-structure challenge card. # Runs check_perspective_structure.py on four synthetic manuscripts and diffs stdout against # expected/. This gate is Minor-only, so it always exits 0 — exit code proves nothing. The # verdicts live in stdout, so the golden-diff is the real assertion (learned from a Codex design # review: crossfire cannot observe a Minor manuscript detector). Stdlib-only, network-free. # # Fixtures (synthetic only — no real manuscript, no PII): # perspective_bad.md article_type Perspective, "## 1. Introduction" + "## Methods", a flat # declarative abstract -> fires HEADING_NOT_ASSERTION + ABSTRACT_NO_AUTHORIAL_MOVE. # perspective_ok.md argument-move headings + a "Here we argue ..." abstract -> silent. # not_perspective.md article_type Original Article + full IMRAD -> silent (genre gate). # edge_hardened.md Perspective; a commented "## Methods" and "we argue", a "### Box" level-3 # heading, and a duplicate "## Abstract" in the notes tail whose FIRST copy # carries the move -> silent findings + a duplicate-abstract warning on stderr. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../check_perspective_structure.py" cd "$HERE" pass=1 for f in perspective_bad perspective_ok not_perspective edge_hardened; do out="$(python3 "$DET" --manuscript "fixture/$f.md" 2>/dev/null)" if ! diff -u "expected/$f.txt" <(printf '%s\n' "$out"); then echo "FAIL: $f stdout drifted from expected/$f.txt" >&2; pass=0 fi done # The positive fixture must name both verdicts. bad="$(python3 "$DET" --manuscript fixture/perspective_bad.md 2>/dev/null)" printf '%s' "$bad" | grep -q PERSPECTIVE_HEADING_NOT_ASSERTION \ || { echo "FAIL: perspective_bad missing HEADING_NOT_ASSERTION" >&2; pass=0; } printf '%s' "$bad" | grep -q PERSPECTIVE_ABSTRACT_NO_AUTHORIAL_MOVE \ || { echo "FAIL: perspective_bad missing ABSTRACT_NO_AUTHORIAL_MOVE" >&2; pass=0; } # The genre gate must keep a non-Perspective silent despite IMRAD headings + a flat abstract. printf '%s' "$(python3 "$DET" --manuscript fixture/not_perspective.md 2>/dev/null)" \ | grep -q PERSPECTIVE_ && { echo "FAIL: not_perspective should be silent (genre gate)" >&2; pass=0; } # Findings[] inspection on a known-good Perspective (a Minor detector's exit code is meaningless, # so assert the JSON directly: active AND empty findings — not silent because misclassified). python3 "$DET" --manuscript fixture/perspective_ok.md --quiet --out "/tmp/ps_ok.$$" 2>/dev/null python3 -c "import json,sys; d=json.load(open('/tmp/ps_ok.$$')); sys.exit(0 if d['findings']==[] and d['metrics']['active'] else 1)" \ || { echo "FAIL: perspective_ok should be an active Perspective with empty findings[]" >&2; pass=0; } rm -f "/tmp/ps_ok.$$" # The duplicate-abstract path must warn on stderr (and not crash). python3 "$DET" --manuscript fixture/edge_hardened.md --quiet >/dev/null 2>/tmp/edge_err.$$ || true grep -q "2 '## Abstract' sections" /tmp/edge_err.$$ \ || { echo "FAIL: edge_hardened should warn about a duplicate abstract" >&2; pass=0; } rm -f /tmp/edge_err.$$ if [ "$pass" -eq 1 ]; then echo "PASS: perspective-structure gate flags IMRAD headings + a flat abstract, stays silent on a clean Perspective and on a non-Perspective, and warns on a duplicate abstract." else exit 1 fi
-
-
check_reported_p_from_counts_challenge
-
expected
-
bad.txt 308 B
== NON-REPRODUCIBLE P == fixture/p_bad.md non_reproducible=1 [MAJOR] P_NOT_REPRODUCIBLE L6 row 'Adenocarcinoma' (5/132 vs 4/33) reports P<0.001, but recomputes to Fisher 0.0799 / Yates 0.145 / uncorrected 0.0594 (closest Pearson chi-square (uncorrected); table family ≈ Pearson chi-square (uncorrected)) -
ok.txt 112 B
== OK == fixture/p_ok.md non_reproducible=0 every reported P reproduces from its counts under a standard test.
-
-
fixture
-
p_bad.md 206 B
## Table 3. Baseline comparison | Characteristic | Full cohort (n = 132) | Subset (n = 33) | P | | --- | --- | --- | --- | | Male | 79 (60) | 16 (48) | 0.237 | | Adenocarcinoma | 5 (4) | 4 (12) | <0.001 | -
p_ok.md 205 B
## Table 3. Baseline comparison | Characteristic | Full cohort (n = 132) | Subset (n = 33) | P | | --- | --- | --- | --- | | Male | 79 (60) | 16 (48) | 0.237 | | Adenocarcinoma | 5 (4) | 4 (12) | 0.060 |
-
-
problem.md 1.5 KB
# Challenge card — reported-P-from-counts gate (self-review) ## Problem A baseline table comparing two groups prints a count per group and a P value per row. That P is fully determined by the four cell counts, yet a wrong one — a reported `p<0.001` whose true value is ~0.06 — survives review because no one recomputes it. The test family is identifiable from the rows that *do* reproduce (here a sex row reproduces exactly at 0.237 under uncorrected Pearson), which calibrates the check for the rest. ## What the gate does `check_reported_p_from_counts.py` rebuilds the 2x2 table for every integer-count row (from the two `n = N` group headers), recomputes Fisher's exact test and Pearson's chi-square with and without Yates' correction in pure stdlib (`math.comb` / `math.erfc`), calibrates the family on the rows that reproduce to ≤ 1e-3, and flags any row whose reported P differs by more than one order of magnitude under **every** family (`P_NOT_REPRODUCIBLE`). Continuous rows (mean ± SD, median [IQR]) are skipped and a single-row table never fires. ## Fixture (synthetic only — no real manuscript, no PII) - `fixture/p_bad.md` — Male 79/132 vs 16/33 (reproduces at 0.237) and Adenocarcinoma 5/132 vs 4/33 reporting `P<0.001` (true ≈ 0.06). - `fixture/p_ok.md` — same table with the Adenocarcinoma P corrected to 0.060. ## Expected - `expected/bad.txt` — one `P_NOT_REPRODUCIBLE`; exit 1 under `--strict`. - `expected/ok.txt` — `OK`; exit 0. `verify.sh` diffs both outputs and asserts the exit-code contract. Network-free, stdlib-only. -
verify.sh 1.5 KB
#!/usr/bin/env bash # Deterministic verifier for the reported-P-from-counts challenge card. cd HERE for # a stable relative source path. Pure stdlib (math.comb / math.erfc) — no scipy. # Fixtures (synthetic only — no real manuscript, no PII): # p_bad.md — a 2-group baseline table; the Male row reproduces at 0.237 # (uncorrected Pearson, so the family calibrates) while the # Adenocarcinoma row claims P<0.001 whose true value is ~0.06 under # every family -> 1x P_NOT_REPRODUCIBLE. # p_ok.md — same table with the Adenocarcinoma P corrected to 0.060 -> OK. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)"; DET="$HERE/../check_reported_p_from_counts.py"; cd "$HERE" bad="$(python3 "$DET" --manuscript fixture/p_bad.md)"; ok="$(python3 "$DET" --manuscript fixture/p_ok.md)" pass=1 diff -u expected/bad.txt <(printf '%s\n' "$bad") || { echo "FAIL: bad drift" >&2; pass=0; } diff -u expected/ok.txt <(printf '%s\n' "$ok") || { echo "FAIL: ok drift" >&2; pass=0; } python3 "$DET" --manuscript fixture/p_bad.md --strict --quiet >/dev/null 2>&1 && rb=0 || rb=$? python3 "$DET" --manuscript fixture/p_ok.md --strict --quiet >/dev/null 2>&1 && ro=0 || ro=$? [ "${rb:-0}" -eq 1 ] || { echo "FAIL: bad should exit 1 (got ${rb:-0})" >&2; pass=0; } [ "$ro" -eq 0 ] || { echo "FAIL: ok should exit 0 (got $ro)" >&2; pass=0; } [ "$pass" -eq 1 ] && echo "PASS: reported-P gate flags the non-reproducible p<0.001 and clears the corrected table." || exit 1
-
-
check_table_percentages_challenge
-
expected
-
bad.txt 301 B
== MISMATCH FOUND == fixture/table_bad.md mismatches=2 findings=2 [MAJOR] PERCENT_MISMATCH L3 printed 37% but 53/132 (header n=) = 40.2% (Δ3.2pp) cell> Female: 53 (37) [MAJOR] PERCENT_MISMATCH L3 printed 63% but 79/132 (header n=) = 59.8% (Δ3.2pp) cell> Male: 79 (63) -
ok.txt 112 B
== OK == fixture/table_ok.md mismatches=0 findings=0 all n (%) cells reconcile with their column denominators.
-
-
fixture
-
table_bad.md 121 B
## Table 1. Characteristics | Characteristic | Cohort (n = 132) | | --- | --- | | Male | 79 (63) | | Female | 53 (37) | -
table_ok.md 235 B
## Table 1. Reference standard and demographics | Nodal stage | Cohort (n = 33) | | --- | --- | | N0 | 16 (48%) | | N1 | 17 (52%) | | Variable | Value (n = 33) | | --- | --- | | Age, mean (SD) | 45 (12) | | BMI, mean (SD) | 24 (3) |
-
-
problem.md 2 KB
# Challenge card — table-percentage gate (self-review) ## Problem A characteristics table prints `79 (63%)` and `53 (37%)` under a stated denominator of 132. The true percentages are 59.8% and 40.2% — both cells are wrong. This is pure arithmetic with no judgement, yet it routinely survives multiple review rounds because it is present verbatim from the first submission and no prior gate recomputed a printed percentage against its own column count. `check_cohort_arithmetic.py` covers rate back-calculation, exclusion cascades, and tier-partition disjointness, but not a single `n (%)` cell versus its denominator. ## What the gate does `scripts/check_table_percentages.py` parses GFM pipe tables from the manuscript, recovers each column's denominator (a `n = N` header, a Total row, or the column's counts summing), and recomputes `100·n/denominator` for every count/percentage cell, flagging any that differs from the printed value by more than 0.5 pp (configurable). A column is treated as percentages only when a cell carries an explicit `%` OR its parenthetical values sum to ~100 (a partition) — so `mean (SD)` cells, whose SDs carry no `%` and do not sum to 100, are never misread as percentages. ## Fixture (synthetic only — no real manuscript, no PII) - `fixture/table_bad.md` — `79 (63)` / `53 (37)` under `n = 132`; both wrong (59.8% / 40.2%). Detected via the partition path (63 + 37 = 100), so the gate fires even though *both* cells are wrong and neither reproduces. - `fixture/table_ok.md` — a correct percentage column (`16 (48%)` / `17 (52%)` under `n = 33`) plus a `mean (SD)` table (`45 (12)` / `24 (3)`); the gate must clear the correct column and must not false-positive on the standard deviations. ## Expected - `expected/bad.txt` — `MISMATCH FOUND`, two `PERCENT_MISMATCH` rows; exit 1 under `--strict`. - `expected/ok.txt` — `OK`, zero findings; exit 0 under `--strict`. `verify.sh` diffs both stdout outputs against `expected/` and asserts the exit-code contract (bad → 1, ok → 0). Network-free, stdlib-only. -
verify.sh 2 KB
#!/usr/bin/env bash # Deterministic verifier for the table-percentage challenge card. # Runs check_table_percentages.py on two synthetic manuscript tables and diffs # stdout against expected/. Stdlib-only, network-free. Exit 0 = both match and # exit codes are correct. cd into HERE so the reported source path is the stable # relative "fixture/..." (portable across CI checkout locations). # # Fixtures (synthetic only — no real manuscript, no PII): # table_bad.md — a characteristics column under n=132 printing 79 (63) / 53 (37); # true values are 59.8% / 40.2%, so BOTH cells are wrong (the real # incident shape) -> 2x PERCENT_MISMATCH. # table_ok.md — a correct percentage column (16 (48%) / 17 (52%) under n=33) plus # a mean (SD) table (45 (12) / 24 (3)); the SD cells must NOT be # read as percentages -> OK, zero findings (no false positive). set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../check_table_percentages.py" cd "$HERE" bad="$(python3 "$DET" --manuscript fixture/table_bad.md)" ok="$(python3 "$DET" --manuscript fixture/table_ok.md)" pass=1 if ! diff -u expected/bad.txt <(printf '%s\n' "$bad"); then echo "FAIL: bad-fixture output drifted from expected/bad.txt" >&2; pass=0 fi if ! diff -u expected/ok.txt <(printf '%s\n' "$ok"); then echo "FAIL: ok-fixture output drifted from expected/ok.txt" >&2; pass=0 fi python3 "$DET" --manuscript fixture/table_bad.md --strict --quiet >/dev/null 2>&1 && rc_bad=0 || rc_bad=$? python3 "$DET" --manuscript fixture/table_ok.md --strict --quiet >/dev/null 2>&1 && rc_ok=0 || rc_ok=$? [ "${rc_bad:-0}" -eq 1 ] || { echo "FAIL: bad fixture should exit 1 under --strict (got ${rc_bad:-0})" >&2; pass=0; } [ "$rc_ok" -eq 0 ] || { echo "FAIL: ok fixture should exit 0 under --strict (got $rc_ok)" >&2; pass=0; } if [ "$pass" -eq 1 ]; then echo "PASS: table-percentage gate flags both mis-rounded cells and clears the correct table + mean(SD) control." else exit 1 fi
-
-
cohort_arith_binding_challenge
-
fixture
-
decimal_person_time.md 152 B
# Person-time reported to one decimal, and the rate is wrong **Results.** Over 35,581.3 person-years, 882 events occurred (9.90 per 100 person-years). -
genuine_defects.md 287 B
# A cohort whose arithmetic really does not close **Results.** Over 10,000 person-years, 1,000 events occurred (25.0 per 100 person-years). | Stratum | N | Events | |---|---|---| | Low risk | 4,000 | 40 | | Medium risk | 3,500 | 70 | | High risk | 2,000 | 90 | | Total | 9,000 | 200 | -
real_negatives.md 737 B
# Gallbladder polyp natural history *Synthetic manuscript reproducing three real sentences, verbatim in shape, that this detector used to flag. Every number below is internally consistent: 882 / 35,581 x 100 = 2.48.* **Results.** Over 35,581 person-years, 882 KSAR S4-1 events occurred (2.48 per 100 person-years). Of those screened, 6,990 (54.5%) had a subsequent ultrasound and formed the primary cohort, contributing 882 KSAR Statement 4-1 events and 8 competing deaths over 35,581.3 person-years. | Characteristic | Normal | Steatosis-only | Combined-adverse | |---|---|---|---| | Age, years | 52.1 | 54.3 | 56.8 | | Male, n (%) | 194 | 2,051 | 6,054 | | Body mass index | 23.1 | 26.4 | 29.7 | | Total, n | 194 | 2,051 | 6,054 |
-
-
verify.sh 5.1 KB
#!/usr/bin/env bash # Deterministic verifier for the cohort-arithmetic NUMBER-BINDING challenge. # # The arithmetic in this detector was never wrong. What was wrong is which numbers it fed # into it: on a real manuscript every one of its three observed fires was false, because each # capture latched onto a digit belonging to something else. # # "882 KSAR S4-1 events occurred" -> bound the 1 of the label "S4-1" as the event # count, and declared 2.48 per 100 PY irreconcilable # with 1 event. # "over 35,581.3 person-years" -> the integer part could not reach the noun past # the decimal point, so the FRACTIONAL DIGIT matched: # a cohort of "3 person-years". # "| Characteristic | Normal | ... |" -> the one-letter column hint "n" matched the word # "Normal", so each characteristic row's value was # summed as a stratum size: 8,299 against a # "stated total" of 194. (The identical # one-character-substring bug was fixed once in # check_confounding_completeness and never here.) # # A detector whose every observed fire is false teaches its user to skip the whole class, and # this class — rate back-calculation, exclusion cascades, tier partitions — is one nothing else # covers. So the fix is binding, not thresholds: when a number cannot be bound to its quantity, # the check now says NOTHING. An unbindable numerator is not evidence of an arithmetic error. # # Which makes the second half of this card the important half: the genuine defects must still # fire, or the fix has merely bought silence. set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../check_cohort_arithmetic.py" FIX="$HERE/fixture" TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT pass=0; fail=0 ck() { if [ "$2" = "$3" ]; then printf ' PASS %-58s got=%s\n' "$1" "$3"; pass=$((pass+1)); else printf ' FAIL %-58s want=%s got=%s\n' "$1" "$2" "$3"; fail=$((fail+1)); fi; } has() { if echo "$2" | grep -q "$3"; then ck "$1" 0 0; else ck "$1" 0 1; fi; } hasnt() { if echo "$2" | grep -q "$3"; then ck "$1" 0 1; else ck "$1" 0 0; fi; } echo "== the three real false positives are silent ==" python3 "$DET" --manuscript "$FIX/real_negatives.md" --strict >/dev/null 2>&1 ck "a manuscript whose arithmetic closes -> exit 0" 0 "$?" OUT="$(python3 "$DET" --manuscript "$FIX/real_negatives.md" 2>&1)" hasnt "no rate back-calculation fired" "$OUT" "RATE_BACKCALC" hasnt "no partition overlap fired" "$OUT" "PARTITION_OVERLAP" hasnt "and the label digit was never read as a count" "$OUT" "1 events" hasnt "nor the decimal tail as the person-time" "$OUT" "/ 3 PY" echo "== a Table 1 is not a stratum partition ==" # The header that broke it. "Normal" must not answer to the hint "n". printf '| Characteristic | Normal | Adverse |\n|---|---|---|\n| Age, years | 52.1 | 56.8 |\n| Male, n (%%) | 4,051 | 6,054 |\n| Body mass index | 23.1 | 29.7 |\n| Total, n | 194 | 6,054 |\n' > "$TMP/t1.md" OUT="$(python3 "$DET" --manuscript "$TMP/t1.md" 2>&1)" hasnt "an exposure-stratified Table 1 stays silent" "$OUT" "PARTITION_OVERLAP" echo "== THE OTHER HALF: genuine defects must still fire ==" python3 "$DET" --manuscript "$FIX/genuine_defects.md" --strict >/dev/null 2>&1 ck "a real discrepancy -> exit 1" 1 "$?" OUT="$(python3 "$DET" --manuscript "$FIX/genuine_defects.md" 2>&1)" has "the impossible rate is named" "$OUT" "RATE_BACKCALC" has "with the correct recomputation" "$OUT" "10 per 100" has "the non-disjoint partition is named" "$OUT" "PARTITION_OVERLAP" has "with the correct sum" "$OUT" "9,500" echo "== decimal person-time is now READ, not skipped ==" # The fix must not buy its silence by ignoring fractional person-time: here the same decimal # figure is present and the rate really is wrong, so it must fire — and with the full number. python3 "$DET" --manuscript "$FIX/decimal_person_time.md" --strict >/dev/null 2>&1 ck "a wrong rate over fractional person-time -> exit 1" 1 "$?" OUT="$(python3 "$DET" --manuscript "$FIX/decimal_person_time.md" 2>&1)" has "the whole person-time is used, not its tail" "$OUT" "35,581.3 PY" hasnt "and not the fractional digit" "$OUT" "/ 3 PY" echo "== a column genuinely called n still resolves ==" printf '| Stratum | n | Events |\n|---|---|---|\n| A | 10 | 1 |\n| B | 20 | 2 |\n| Total | 40 | 3 |\n' > "$TMP/n.md" OUT="$(python3 "$DET" --manuscript "$TMP/n.md" 2>&1)" has "an exact one-letter header still matches" "$OUT" "PARTITION_OVERLAP" echo "== the artifact names its own author ==" python3 "$DET" --manuscript "$FIX/genuine_defects.md" --out "$TMP/r.json" >/dev/null 2>&1 has "qc JSON carries the detector key" "$(cat "$TMP/r.json")" '"detector": "check_cohort_arithmetic"' echo printf 'cohort-arithmetic binding challenge: %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] || exit 1
-
-
confounding_findings_challenge
-
fixture
-
adjusted.txt 21 B
Age (years) Male sex -
defining.txt 32 B
Body mass index Fasting glucose -
table1.csv 310 B · in bundle
-
-
verify.sh 5 KB
#!/usr/bin/env bash # Deterministic verifier for the confounding-completeness findings-contract challenge. # # `findings` is the DEFECT list. It used to be the whole per-covariate audit table, and two of # that table's three verdicts mean "this is fine": ADJUSTED says the covariate WAS handled, # and EXPOSURE_DEFINING_EXEMPT records a deliberate exemption (adjusting for a component of # the exposure's own diagnostic criteria is over-adjustment, probe O7). # # Nothing was wrong with the analysis. What was wrong is that every consumer aggregating a # project's qc/ directory counts entries in `findings`, so a run reporting "four covariates # examined, none of them a problem" arrived as FOUR FINDINGS. In one real project three such # runs contributed ~45 pseudo-findings — enough to make this the loudest detector in the # suite and to put a 0.00 precision row into the ledger built on top of it. The measurement # that was supposed to grade the detectors was being corrupted by one detector's envelope. # # So: the full table is still emitted, as `covariates`, and the human-readable render walks # that. `findings` carries only UNADJUSTED_IMBALANCED, each with a severity and a message — # the two fields it never had, and the reason its entries showed up blank in every report. set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../check_confounding_completeness.py" FIX="$HERE/fixture" TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT pass=0; fail=0 ck() { if [ "$2" = "$3" ]; then printf ' PASS %-56s got=%s\n' "$1" "$3"; pass=$((pass+1)); else printf ' FAIL %-56s want=%s got=%s\n' "$1" "$2" "$3"; fail=$((fail+1)); fi; } jq_() { python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(eval(sys.argv[2]))" "$1" "$2"; } run() { python3 "$DET" --table1 "$1" --adjusted "$FIX/adjusted.txt" \ --exposure-defining "$FIX/defining.txt" --out "$2" 2>&1; } echo "== a genuine defect: one finding, carrying the fields it must carry ==" OUT="$(run "$FIX/table1.csv" "$TMP/defect.json")" ck "the audit table still holds every imbalanced covariate" 5 "$(jq_ "$TMP/defect.json" "len(d['covariates'])")" ck "but findings holds only the defect" 1 "$(jq_ "$TMP/defect.json" "len(d['findings'])")" ck "the finding is UNADJUSTED_IMBALANCED" "UNADJUSTED_IMBALANCED" "$(jq_ "$TMP/defect.json" "d['findings'][0]['verdict']")" ck "it carries a severity" "major" "$(jq_ "$TMP/defect.json" "d['findings'][0]['severity']")" ck "and a non-empty message" True "$(jq_ "$TMP/defect.json" "len(d['findings'][0].get('message','')) > 40")" ck "the covariate is named in it" True "$(jq_ "$TMP/defect.json" "'Prior cardiovascular disease' in d['findings'][0]['message']")" echo "== THE REGRESSION: a status row must never reach findings ==" ck "no ADJUSTED in findings" 0 "$(jq_ "$TMP/defect.json" "sum(1 for f in d['findings'] if f['verdict']=='ADJUSTED')")" ck "no EXEMPT in findings" 0 "$(jq_ "$TMP/defect.json" "sum(1 for f in d['findings'] if f['verdict']=='EXPOSURE_DEFINING_EXEMPT')")" # NB: no braces in these expressions — the shell brace-expands them into separate words. ck "though both ARE in the audit table" True "$(jq_ "$TMP/defect.json" "set(c['verdict'] for c in d['covariates']) >= set(['ADJUSTED','EXPOSURE_DEFINING_EXEMPT'])")" echo "== a run with nothing wrong reports nothing wrong ==" grep -v "Prior cardiovascular" "$FIX/table1.csv" > "$TMP/clean.csv" run "$TMP/clean.csv" "$TMP/clean.json" >/dev/null 2>&1 ck "four covariates examined" 4 "$(jq_ "$TMP/clean.json" "len(d['covariates'])")" ck "ZERO findings (it reported 4 before)" 0 "$(jq_ "$TMP/clean.json" "len(d['findings'])")" echo "== the human-readable table is unchanged: it walks covariates ==" OUT="$(run "$FIX/table1.csv" "$TMP/x.json")" ck "adjusted rows still rendered" True "$(python3 -c "print('Age (years)' in '''$OUT''')")" ck "exempt rows still rendered" True "$(python3 -c "print('exposure-defining' in '''$OUT''')")" echo "== counts and exit codes are unchanged ==" ck "n_imbalanced counts the whole table" 5 "$(jq_ "$TMP/defect.json" "d['n_imbalanced']")" ck "n_unadjusted_imbalanced" 1 "$(jq_ "$TMP/defect.json" "d['n_unadjusted_imbalanced']")" ck "n_exposure_defining_exempt" 2 "$(jq_ "$TMP/defect.json" "d['n_exposure_defining_exempt']")" python3 "$DET" --table1 "$FIX/table1.csv" --adjusted "$FIX/adjusted.txt" \ --exposure-defining "$FIX/defining.txt" --strict >/dev/null 2>&1 ck "--strict still fails on a real defect" 1 "$?" python3 "$DET" --table1 "$TMP/clean.csv" --adjusted "$FIX/adjusted.txt" \ --exposure-defining "$FIX/defining.txt" --strict >/dev/null 2>&1 ck "--strict still passes when there is none" 0 "$?" echo "== the artifact names its own author ==" ck "qc JSON carries the detector key" True "$(jq_ "$TMP/defect.json" "d.get('detector')=='check_confounding_completeness'")" echo printf 'confounding findings-contract challenge: %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] || exit 1
-
-
disclosure_placement_challenge
-
fixture
-
about_disclosure.md 403 B
# How AI-disclosure statements are reported in clinical journals *A paper whose SUBJECT is disclosure. The phrasing appears as an object of study, not as the paper's own disclosure, and must not fire.* ## Results Of the journals surveyed, 41% required an AI disclosure statement. A representative example reads: "During the preparation of this manuscript the authors used ChatGPT to draft sections." -
manuscript.md 453 B
# Deferred release of automated advice in a reporting workflow *Synthetic manuscript carrying an in-body AI-use disclosure — correct at some journals, wrong at others, which is the whole point.* ## Methods Patients were enrolled consecutively at a single centre. ## Acknowledgements During the preparation of this manuscript the authors used Claude to assist with language editing. All authors reviewed the output and take responsibility for it.
-
-
verify.sh 6.3 KB
#!/usr/bin/env bash # Deterministic verifier for the AI-disclosure PLACEMENT challenge. # # INBODY_AI_DISCLOSURE used to assert a placement it could not know: "for a classical / # senior-MA target this belongs on the title page". That is true for some journals and false # for others, and the profiles in this repo already said so, in their own words: # # npj Digital Medicine "document use in Methods section" # Investigative Radiology "disclosed in cover letter and Acknowledgments section" # Diabetes & Metabolism J. "must be declared on title page" # British J. of Radiology "AI disclosure in the cover letter is required" # # So the verdict fired identically at a journal that wants the paragraph exactly where it is # and at one that forbids it there — telling the author to move something correct. Across five # real projects this produced eight fires, and NOT ONE of them could be scored real or # spurious, because the answer depended on a target nobody had told the detector. A verdict # that cannot be scored can never be shown to work. # # Now the target decides: body-legitimate -> silent, title-page/cover-letter-only -> Major, # and NO target recorded -> Minor, naming the ambiguity instead of asserting a placement. set -uo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../check_classical_style.py" FIX="$HERE/fixture" PROF="$HERE/../../../write-paper/references/journal_profiles" TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT pass=0; fail=0 ck() { if [ "$2" = "$3" ]; then printf ' PASS %-58s got=%s\n' "$1" "$3"; pass=$((pass+1)); else printf ' FAIL %-58s want=%s got=%s\n' "$1" "$2" "$3"; fail=$((fail+1)); fi; } fires() { python3 "$DET" --manuscript "$FIX/manuscript.md" "$@" 2>&1 | grep -c "INBODY_AI_DISCLOSURE"; } sev() { python3 "$DET" --manuscript "$FIX/manuscript.md" "$@" 2>&1 \ | grep "INBODY_AI_DISCLOSURE" | grep -oE "Major|Minor" | head -1; } echo "== the journals disagree, and each profile is obeyed ==" ck "npj Digital Medicine (Methods) -> silent" 0 "$(fires --profile "$PROF/npj_Digital_Medicine.md")" # the case that exposed a stem bug: "Acknowledgments" is a BODY section, so the body is correct ck "Investigative Radiology (cover + Acknowledgments) -> silent" 0 "$(fires --profile "$PROF/Investigative_Radiology.md")" ck "EJPC (cover + Methods/Acknowledgements) -> silent" 0 "$(fires --profile "$PROF/European_Journal_of_Preventive_Cardiology.md")" ck "Diabetes & Metabolism J. (title page) -> fires" 1 "$(fires --profile "$PROF/Diabetes_Metabolism_Journal.md")" ck "British J. of Radiology (cover letter) -> fires" 1 "$(fires --profile "$PROF/British_Journal_of_Radiology.md")" # A body location whose section name contains none of the body tokens: the "(body)" marker is # the convention that keeps it readable. JACC: Advances is the real instance. ck "a body section named nothing like 'Methods' -> silent, via (body)" 0 "$(fires --profile "$PROF/JACC_Advances.md")" ck "and the same string without the marker would fire" 1 "$(fires --disclosure-placement 'Declaration section immediately above the References')" echo "== every populated profile behaves per its OWN stated policy ==" # Populated from each profile's own prose; a wrong line here produces wrong advice, so the # whole set is asserted rather than a sample. for j in npj_Digital_Medicine Investigative_Radiology JNIS Journal_of_Stroke Liver_International \ PLOS_Medicine RYAI The_Lancet The_Lancet_Digital_Health World_Journal_of_Hepatology \ Korean_Journal_of_Internal_Medicine Korean_Circulation_Journal KJR \ Hepatology_Communications Lancet_Gastroenterology_and_Hepatology \ Journal_of_Clinical_Endocrinology_and_Metabolism; do ck "body-legitimate: $j" 0 "$(fires --profile "$PROF/$j.md")" done for j in Academic_Radiology JKMS British_Journal_of_Radiology Diabetes_Metabolism_Journal; do ck "not-in-body: $j" 1 "$(fires --profile "$PROF/$j.md")" done echo "== severity follows knowledge, not house style ==" ck "a declared title-page target is Major" "Major" "$(sev --profile "$PROF/Diabetes_Metabolism_Journal.md")" ck "no target recorded is only Minor" "Minor" "$(sev)" OUT="$(python3 "$DET" --manuscript "$FIX/manuscript.md" 2>&1)" if echo "$OUT" | grep -q "no target journal is recorded"; then ck "and it names the ambiguity" 0 0 else ck "and it names the ambiguity" 0 1; fi if echo "$OUT" | grep -q "belongs on the title page"; then ck "never asserting a placement it cannot know" 0 1 else ck "never asserting a placement it cannot know" 0 0; fi echo "== --strict: an unknown target must not fail a build ==" python3 "$DET" --manuscript "$FIX/manuscript.md" --strict >/dev/null 2>&1 ck "no target -> exit 0 (Minor)" 0 "$?" python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROF/Diabetes_Metabolism_Journal.md" --strict >/dev/null 2>&1 ck "title-page target -> exit 1 (Major)" 1 "$?" python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROF/npj_Digital_Medicine.md" --strict >/dev/null 2>&1 ck "Methods target -> exit 0 (silent)" 0 "$?" echo "== the inline override, for a target with no profile on disk ==" ck "--disclosure-placement 'title page' fires" 1 "$(fires --disclosure-placement 'title page')" ck "--disclosure-placement 'Methods' silent" 0 "$(fires --disclosure-placement 'Methods')" ck "inline beats the profile" 1 "$(fires --profile "$PROF/npj_Digital_Medicine.md" --disclosure-placement 'title page')" echo "== pre-existing behaviour is untouched ==" N="$(python3 "$DET" --manuscript "$FIX/about_disclosure.md" 2>&1 | grep -c "INBODY_AI_DISCLOSURE")" ck "a paper ABOUT disclosure still does not fire" 0 "$N" printf '# T\n\nSee Methods §2 for the model.\n' > "$TMP/sec.md" S="$(python3 "$DET" --manuscript "$TMP/sec.md" 2>&1 | grep -c "SECTION_SYMBOL")" ck "the section-symbol verdict is unaffected" 1 "$S" echo "== the artifact names its own author ==" python3 "$DET" --manuscript "$FIX/manuscript.md" --profile "$PROF/npj_Digital_Medicine.md" \ --out "$TMP/r.json" >/dev/null 2>&1 R="$(python3 -c "import json;d=json.load(open('$TMP/r.json'));print(d.get('detector')=='check_classical_style' and d.get('disclosure_placement') is not None)")" ck "qc JSON names the detector AND records the placement" "True" "$R" echo printf 'AI-disclosure placement challenge: %d passed, %d failed\n' "$pass" "$fail" [ "$fail" -eq 0 ] || exit 1
-
-
refinement_regression_challenge
-
expected
-
churning.txt 394 B
Refinement regression: CHURNING fixed (resolved): 0 still open (carried): 0 broke (new): 1 resurfaced (churn): 1 qc dir: fixture/churning/qc ledger: fixture/churning/ledger.jsonl -> A previously-resolved finding reappeared (HEDGE_ACCRETION@whole document) -- the loop is re-deriving, not converging (the Mirror Loop). Stop revising and re-anchor; more passes will not help. -
converged.txt 264 B
Refinement regression: CONVERGED fixed (resolved): 1 still open (carried): 0 broke (new): 0 resurfaced (churn): 0 qc dir: fixture/converged/qc ledger: fixture/converged/ledger.jsonl -> Nothing new and nothing carried -- the loop is done. Stop. -
findings_regression.txt 421 B
Refinement regression: REGRESSION fixed (resolved): 0 still open (carried): 0 broke (new): 1 resurfaced (churn): 0 qc dir: fixture/findings_regression/qc ledger: fixture/findings_regression/ledger.jsonl -> The last revision resolved 0 finding(s) but INTRODUCED 1 new one(s) (PERCENT_MISMATCH@12). Fixing raised the pass-rate while breaking something else -- review the new findings before continuing. -
firstrun.txt 337 B
Refinement regression: INDETERMINATE fixed (resolved): 0 still open (carried): 0 broke (new): 0 resurfaced (churn): 0 qc dir: fixture/firstrun/qc ledger: fixture/firstrun/ledger.jsonl -> No prior ledger entry -- this is the first run. Re-run after a revision (with --append recording each run) to measure regression. -
progressing.txt 336 B
Refinement regression: PROGRESSING fixed (resolved): 1 still open (carried): 1 broke (new): 0 resurfaced (churn): 0 qc dir: fixture/progressing/qc ledger: fixture/progressing/ledger.jsonl -> 1 finding(s) resolved and none newly introduced -- the revision improved the manuscript without breaking anything. Continue. -
regression.txt 405 B
Refinement regression: REGRESSION fixed (resolved): 0 still open (carried): 1 broke (new): 1 resurfaced (churn): 0 qc dir: fixture/regression/qc ledger: fixture/regression/ledger.jsonl -> The last revision resolved 0 finding(s) but INTRODUCED 1 new one(s) (RATE_BACKCALC@Methods). Fixing raised the pass-rate while breaking something else -- review the new findings before continuing.
-
-
fixture
-
churning
-
qc
-
gate.json 280 B
{"detector": "check_baseline_drift", "manuscript": "synthetic", "baseline": "synthetic_v1", "claims": [{"verdict": "HEDGE_ACCRETION", "severity": "Minor", "detail": "hedge density up vs baseline", "where": "whole document"}], "summary": {"n_claims": 1, "verdict": "DRIFT_FLAGS"}}
-
-
ledger.jsonl 78 B · in bundle
-
-
converged
-
qc
-
gate.json 153 B
{"detector": "check_cohort_arithmetic", "manuscript": "synthetic", "claims": [], "summary": {"n_claims": 0, "n_major": 0, "n_flag": 0, "verdict": "OK"}}
-
-
ledger.jsonl 44 B · in bundle
-
-
findings_regression
-
qc
-
table_pct.json 267 B
{"detector": "check_table_percentages", "source": "synthetic", "verdict": "MISMATCH_FOUND", "n_mismatch": 1, "findings": [{"kind": "PERCENT_MISMATCH", "severity": "MAJOR", "table_line": 12, "cell": "Malignant: 64 (63%)", "detail": "printed 63% but 64/171 = 37.4%"}]}
-
-
ledger.jsonl 23 B · in bundle
-
-
firstrun
-
qc
-
gate.json 283 B
{"detector": "check_cohort_arithmetic", "manuscript": "synthetic", "claims": [{"verdict": "CASCADE_SUM", "severity": "Major", "detail": "start minus exclusions != analytic N", "where": "Results"}], "summary": {"n_claims": 1, "n_major": 1, "n_flag": 0, "verdict": "MAJOR_CANDIDATE"}}
-
-
ledger.jsonl 0 B · in bundle
-
-
progressing
-
qc
-
gate.json 283 B
{"detector": "check_cohort_arithmetic", "manuscript": "synthetic", "claims": [{"verdict": "CASCADE_SUM", "severity": "Major", "detail": "start minus exclusions != analytic N", "where": "Results"}], "summary": {"n_claims": 1, "n_major": 1, "n_flag": 0, "verdict": "MAJOR_CANDIDATE"}}
-
-
ledger.jsonl 81 B · in bundle
-
-
regression
-
qc
-
gate.json 427 B
{"detector": "check_cohort_arithmetic", "manuscript": "synthetic", "claims": [{"verdict": "CASCADE_SUM", "severity": "Major", "detail": "start minus exclusions != analytic N", "where": "Results"}, {"verdict": "RATE_BACKCALC", "severity": "Major", "detail": "incidence rate does not recompute from events / person-time", "where": "Methods"}], "summary": {"n_claims": 2, "n_major": 2, "n_flag": 0, "verdict": "MAJOR_CANDIDATE"}}
-
-
ledger.jsonl 44 B · in bundle
-
-
-
problem.md 2.4 KB
# Challenge card — refinement regression axis (self-review) ## Problem Self-review is stateless: each run reports the manuscript's *current* findings, but nothing compares one run to the last. So when the author revises to fix finding X, the gate pass-rate goes up ("X resolved") and no one measures whether the fix **introduced** a new finding Y — the loop looks like it is improving while quietly accumulating new problems. Worse, a finding that was fixed can reappear a round later (the "Mirror Loop"): the loop is churning, not converging. ## What the tool does `scripts/refinement_regression.py` is a loop controller, not a detector — it finds no defect and carries no `check_` prefix. It reads a small run-history **ledger** (one line per run, each line the `verdict@where` fingerprints of that run's findings) plus the current run's `qc/*.json`, and reports the regression axis *alongside* the pass-rate axis: `resolved` (fixed), `carried` (still open), `new` (**broke**), and `churn` (a previously-resolved finding that resurfaced). Verdict: `PROGRESSING` (fixed, nothing new), `REGRESSION` (a new finding introduced), `CHURNING` (Mirror Loop — stop), `CONVERGED` (done), or `INDETERMINATE` (first run). Advisory: it never blocks. By default it only classifies; with `--append` it records the current run as the next ledger entry. ## Fixture (synthetic only — no real manuscript, no PII) Five scenarios, each a `ledger.jsonl` (prior runs) plus a `qc/` dir (the current run): - `progressing/` — one finding resolved, none new → `PROGRESSING`. - `regression/` — `RATE_BACKCALC@Methods` appears that the prior run lacked → `REGRESSION`. - `churning/` — `HEDGE_ACCRETION` was present two runs back, resolved, and is back → `CHURNING`. - `converged/` — the last finding resolved, nothing carried or new → `CONVERGED`. - `firstrun/` — an empty ledger → `INDETERMINATE`. ## Expected - `expected/<scenario>.txt` — the regression summary for each run. - Every scenario exits 0, even under `--strict` (advisory). The JSON artifact carries the same verdict and names the offending key. `--append` adds exactly one ledger line and the committed fixture ledgers stay immutable under the classify-only runs. `verify.sh` diffs each stdout against `expected/`, checks the JSON verdicts, exercises `--append` on a temp copy, and asserts the fixtures were not mutated. Network-free, stdlib-only. -
verify.sh 3 KB
#!/usr/bin/env bash # Deterministic verifier for the refinement-regression challenge card. # Network-free, stdlib-only. The gate is advisory, so every run must exit 0 -- it reports # the regression axis (what the revision broke), it never blocks. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" DET="$HERE/../refinement_regression.py" cd "$HERE" pass=1 for s in progressing regression churning converged firstrun findings_regression; do got="$(python3 "$DET" --qc-dir "fixture/$s/qc" --ledger "fixture/$s/ledger.jsonl")" if ! diff -u "expected/$s.txt" <(printf '%s\n' "$got"); then echo "FAIL: $s output drifted from expected/$s.txt" >&2; pass=0 fi python3 "$DET" --qc-dir "fixture/$s/qc" --ledger "fixture/$s/ledger.jsonl" --strict --quiet >/dev/null 2>&1 && rc=0 || rc=$? if [ "${rc:-0}" -ne 0 ]; then echo "FAIL: $s must exit 0 (advisory, even under --strict); got ${rc:-0}" >&2; pass=0 fi done # Regression from a findings-schema gate: the new key must be read from `findings` (not only # `claims`) -- the exact schema real-manuscript verification showed was being dropped. tmp="$(mktemp)" python3 "$DET" --qc-dir fixture/findings_regression/qc --ledger fixture/findings_regression/ledger.jsonl --out "$tmp" --quiet grep -q '"verdict": "REGRESSION"' "$tmp" || { echo "FAIL: findings-schema regression verdict" >&2; pass=0; } grep -q 'PERCENT_MISMATCH@12' "$tmp" || { echo "FAIL: findings-schema key not read into the run" >&2; pass=0; } # Regression and churning must carry the offending key in the JSON artifact. python3 "$DET" --qc-dir fixture/regression/qc --ledger fixture/regression/ledger.jsonl --out "$tmp" --quiet grep -q '"verdict": "REGRESSION"' "$tmp" || { echo "FAIL: regression JSON verdict" >&2; pass=0; } grep -q "RATE_BACKCALC@Methods" "$tmp" || { echo "FAIL: regression JSON missing the new finding key" >&2; pass=0; } python3 "$DET" --qc-dir fixture/churning/qc --ledger fixture/churning/ledger.jsonl --out "$tmp" --quiet grep -q '"verdict": "CHURNING"' "$tmp" || { echo "FAIL: churning JSON verdict" >&2; pass=0; } rm -f "$tmp" # --append must record the current run as a new ledger line, on a COPY so fixtures stay immutable. tled="$(mktemp)"; cp fixture/regression/ledger.jsonl "$tled" before=$(wc -l < "$tled") python3 "$DET" --qc-dir fixture/regression/qc --ledger "$tled" --append --quiet after=$(wc -l < "$tled") [ "$after" -eq "$((before + 1))" ] || { echo "FAIL: --append did not add exactly one ledger entry ($before -> $after)" >&2; pass=0; } grep -q '"run": 2' "$tled" || { echo "FAIL: appended entry missing run ordinal" >&2; pass=0; } rm -f "$tled" # The committed fixture ledger must be untouched by the read-only runs above. [ "$(wc -l < fixture/regression/ledger.jsonl)" -eq 1 ] || { echo "FAIL: fixture ledger was mutated by a classify-only run" >&2; pass=0; } if [ "$pass" -eq 1 ]; then echo "PASS: refinement-regression separates fixed/still-open from broke/resurfaced across the ledger (PROGRESSING / REGRESSION / CHURNING / CONVERGED / INDETERMINATE), appends only with --append, and never blocks." else exit 1 fi
-
-
refinement_stop_challenge
-
expected
-
continue.txt 341 B
Refinement terminal-state: CONTINUE Floor gates: 1 Major, 0 Minor Ceiling pass: 0 finding(s) Gates read: check_cohort_arithmetic, check_editorial_impression qc dir: fixture/continue -> Floor gates report 1 Major finding(s) (rejection-for-cause). Genuine work remains -- resolve these before treating the loop as done. -
empty.txt 339 B
Refinement terminal-state: INDETERMINATE Floor gates: 0 Major, 0 Minor Ceiling pass: 0 finding(s) Gates read: (none) qc dir: fixture/empty -> No gate artifacts found in the qc directory. Run the floor gates (Phases 2.5-2.5f) and the ceiling pass (Phase 2.5g) first; the terminal state cannot be judged without them. -
findings_major.txt 347 B
Refinement terminal-state: CONTINUE Floor gates: 1 Major, 0 Minor Ceiling pass: 0 finding(s) Gates read: check_editorial_impression, check_table_percentages qc dir: fixture/findings_major -> Floor gates report 1 Major finding(s) (rejection-for-cause). Genuine work remains -- resolve these before treating the loop as done. -
minor_optional.txt 459 B
Refinement terminal-state: STOP_MINOR_OPTIONAL Floor gates: 0 Major, 1 Minor (fixed point) Ceiling pass: 0 finding(s) Gates read: check_cohort_arithmetic, check_editorial_impression qc dir: fixture/minor_optional -> Floor is at its fixed point (0 Major); 1 optional Minor item(s) remain and the ceiling is clean. No required edits -- present the Minor items as an optional menu; do not treat them as blocking and do not loop for them. -
overhardening.txt 552 B
Refinement terminal-state: STOP_OVERHARDENING Floor gates: 0 Major, 0 Minor (fixed point) Ceiling pass: 2 finding(s) Gates read: check_cohort_arithmetic, check_editorial_impression qc dir: fixture/overhardening -> Floor is at its fixed point (0 Major). The ceiling pass flags 2 accumulation signal(s) -- the draft has begun to over-harden. STOP additive refinement: do NOT run another add-a-caveat/defense pass. The only remaining action is SUBTRACTION (REMOVE / MOVE / TIGHTEN) and it is optional, not required for submission. -
unparsed_gate.txt 640 B
Refinement terminal-state: STOP_ZERO_EDIT Floor gates: 0 Major, 0 Minor (fixed point) Ceiling pass: 0 finding(s) Gates read: check_cohort_arithmetic, check_novel_schema Unparsed: check_novel_schema (unrecognised schema — NOT counted) qc dir: fixture/unparsed_gate -> Floor is at its fixed point (0 Major, 0 Minor) and the ceiling is clean. The manuscript is submission-ready as-is. NO EDITS REQUIRED -- a zero-edit result is a valid PASS. Do not manufacture changes. (WARNING: 1 gate artifact(s) had an unrecognised schema and were NOT counted -- this verdict may understate the floor: check_novel_schema) -
zero_edit.txt 430 B
Refinement terminal-state: STOP_ZERO_EDIT Floor gates: 0 Major, 0 Minor (fixed point) Ceiling pass: 0 finding(s) Gates read: check_cohort_arithmetic, check_editorial_impression qc dir: fixture/zero_edit -> Floor is at its fixed point (0 Major, 0 Minor) and the ceiling is clean. The manuscript is submission-ready as-is. NO EDITS REQUIRED -- a zero-edit result is a valid PASS. Do not manufacture changes.
-
-
fixture
-
continue
-
cohort_arithmetic.json 301 B
{"detector": "check_cohort_arithmetic", "manuscript": "synthetic", "claims": [{"verdict": "CASCADE_SUM", "severity": "Major", "detail": "start N minus exclusions does not equal the analytic N", "where": "Results"}], "summary": {"n_claims": 1, "n_major": 1, "n_flag": 0, "verdict": "MAJOR_CANDIDATE"}} -
editorial_impression.json 182 B
{"detector": "check_editorial_impression", "manuscript": "synthetic", "claims": [], "summary": {"n_claims": 0, "by_action": {"REMOVE": 0, "MOVE": 0, "TIGHTEN": 0}, "verdict": "OK"}}
-
-
empty
-
.gitkeep 88 B · in bundle
-
-
findings_major
-
editorial_impression.json 182 B
{"detector": "check_editorial_impression", "manuscript": "synthetic", "claims": [], "summary": {"n_claims": 0, "by_action": {"REMOVE": 0, "MOVE": 0, "TIGHTEN": 0}, "verdict": "OK"}} -
table_pct.json 267 B
{"detector": "check_table_percentages", "source": "synthetic", "verdict": "MISMATCH_FOUND", "n_mismatch": 1, "findings": [{"kind": "PERCENT_MISMATCH", "severity": "MAJOR", "table_line": 12, "cell": "Malignant: 64 (63%)", "detail": "printed 63% but 64/171 = 37.4%"}]}
-
-
minor_optional
-
cohort_arithmetic.json 293 B
{"detector": "check_cohort_arithmetic", "manuscript": "synthetic", "claims": [{"verdict": "FOLLOWUP_VS_CRITERION", "severity": "Minor", "detail": "follow-up window not tied to the criterion window", "where": "Methods"}], "summary": {"n_claims": 1, "n_major": 0, "n_flag": 1, "verdict": "OK"}} -
editorial_impression.json 182 B
{"detector": "check_editorial_impression", "manuscript": "synthetic", "claims": [], "summary": {"n_claims": 0, "by_action": {"REMOVE": 0, "MOVE": 0, "TIGHTEN": 0}, "verdict": "OK"}}
-
-
overhardening
-
cohort_arithmetic.json 153 B
{"detector": "check_cohort_arithmetic", "manuscript": "synthetic", "claims": [], "summary": {"n_claims": 0, "n_major": 0, "n_flag": 0, "verdict": "OK"}} -
editorial_impression.json 493 B
{"detector": "check_editorial_impression", "manuscript": "synthetic", "claims": [{"verdict": "HEDGE_DENSITY", "severity": "Minor", "action": "TIGHTEN", "detail": "12.4 caveat tokens per 1,000 narrative words", "where": "Discussion"}, {"verdict": "ABSTRACT_CAVEAT_LOAD", "severity": "Minor", "action": "TIGHTEN", "detail": "3 caveat clauses in the Abstract", "where": "Abstract"}], "summary": {"n_claims": 2, "by_action": {"REMOVE": 0, "MOVE": 0, "TIGHTEN": 2}, "verdict": "IMPRESSION_FLAGS"}}
-
-
unparsed_gate
-
cohort.json 153 B
{"detector": "check_cohort_arithmetic", "manuscript": "synthetic", "claims": [], "summary": {"n_claims": 0, "n_major": 0, "n_flag": 0, "verdict": "OK"}} -
novel.json 99 B
{"detector": "check_novel_schema", "source": "synthetic", "verdict": "OK", "some_bespoke_key": []}
-
-
zero_edit
-
cohort_arithmetic.json 153 B
{"detector": "check_cohort_arithmetic", "manuscript": "synthetic", "claims": [], "summary": {"n_claims": 0, "n_major": 0, "n_flag": 0, "verdict": "OK"}} -
editorial_impression.json 182 B
{"detector": "check_editorial_impression", "manuscript": "synthetic", "claims": [], "summary": {"n_claims": 0, "by_action": {"REMOVE": 0, "MOVE": 0, "TIGHTEN": 0}, "verdict": "OK"}}
-
-
-
problem.md 2.4 KB
# Challenge card — refinement terminal-state (self-review loop controller) ## Problem Self-review is run in a loop — review, revise, review again. The floor gates (numerical, citation, cross-reference, leakage) converge to a fixed point of zero Major findings, but nothing declares the loop *done*. Because every additive gate can always surface one more caveat, an ungrounded loop drifts: the manuscript over-hardens, the same findings get re-raised in new words (the "Mirror Loop"), and "no edit needed" is never treated as a valid outcome. `check_editorial_impression.py` (the ceiling pass) *detects* over-hardening, but nothing turns the floor + ceiling state into a reproducible STOP signal the harness cannot rationalize away, and nothing legitimizes a zero-edit PASS. ## What the tool does `scripts/refinement_stop.py` is a loop controller, not a detector — it finds no defect, carries no `check_` prefix, and is not counted in the detector catalog. It reads the `qc/*.json` artifacts the other gates already wrote and classifies the loop's terminal state: `CONTINUE` (a floor Major remains), `STOP_OVERHARDENING` (floor clean but the ceiling pass flags accumulation), `STOP_MINOR_OPTIONAL` (only optional Minor polish left), `STOP_ZERO_EDIT` (floor at fixed point, ceiling clean — submission-ready as-is), or `INDETERMINATE` (no gate artifacts yet). A floor gate is recognised by a `summary.n_major`; the ceiling pass by a `summary.by_action`. It is advisory and never blocks — it must not double-gate the floor detectors, which already exit non-zero on their own Majors — so every run exits 0. ## Fixture (synthetic only — no real manuscript, no PII) Five `qc/` directories of synthetic gate envelopes: - `fixture/zero_edit/` — floor 0 Major/0 Minor + ceiling 0 → `STOP_ZERO_EDIT`. - `fixture/overhardening/` — floor 0 Major + ceiling 2 findings → `STOP_OVERHARDENING`. - `fixture/minor_optional/` — floor 0 Major/1 Minor + ceiling 0 → `STOP_MINOR_OPTIONAL`. - `fixture/continue/` — floor 1 Major → `CONTINUE`. - `fixture/empty/` — no `*.json` → `INDETERMINATE`. ## Expected - `expected/<scenario>.txt` — the terminal-state summary for each directory. - Every scenario exits 0, even under `--strict` (advisory; the controller informs, it never blocks). The JSON artifact carries the same `verdict` + `stop` flag. `verify.sh` diffs each stdout against `expected/`, asserts the always-exit-0 contract, and checks the JSON verdict. Network-free, stdlib-only. -
verify.sh 2.5 KB
#!/usr/bin/env bash # Deterministic verifier for the refinement-stop terminal-state challenge card. # Network-free, stdlib-only. Every scenario is advisory, so every run must exit 0 -- # the loop controller informs the harness; it never blocks the floor detectors. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" TOOL="$HERE/../refinement_stop.py" cd "$HERE" pass=1 for scenario in zero_edit overhardening minor_optional continue empty findings_major unparsed_gate; do got="$(python3 "$TOOL" --qc-dir "fixture/$scenario")" if ! diff -u "expected/$scenario.txt" <(printf '%s\n' "$got"); then echo "FAIL: $scenario output drifted from expected/$scenario.txt" >&2 pass=0 fi python3 "$TOOL" --qc-dir "fixture/$scenario" --strict --quiet >/dev/null 2>&1 && rc=0 || rc=$? if [ "${rc:-0}" -ne 0 ]; then echo "FAIL: $scenario must exit 0 (advisory, even under --strict); got ${rc:-0}" >&2 pass=0 fi done # The verdict must be reproducible in the JSON artifact, not only on stdout. tmp="$(mktemp)" python3 "$TOOL" --qc-dir fixture/zero_edit --out "$tmp" --quiet grep -q '"verdict": "STOP_ZERO_EDIT"' "$tmp" || { echo "FAIL: JSON artifact missing STOP_ZERO_EDIT verdict" >&2; pass=0; } grep -q '"stop": true' "$tmp" || { echo "FAIL: JSON artifact missing stop:true" >&2; pass=0; } # Regression: a findings-schema gate (table_percentages: {findings, kind, severity:MAJOR}) must be # COUNTED as a floor Major, not silently skipped -- the exact bug real-manuscript verification caught. python3 "$TOOL" --qc-dir fixture/findings_major --out "$tmp" --quiet grep -q '"verdict": "CONTINUE"' "$tmp" || { echo "FAIL: findings-schema Major not counted (verdict != CONTINUE)" >&2; pass=0; } grep -q '"check_table_percentages"' "$tmp" || { echo "FAIL: findings-schema gate absent from gates_read" >&2; pass=0; } # Visibility guard: a detector-keyed file with an unrecognised schema must be surfaced, not dropped. python3 "$TOOL" --qc-dir fixture/unparsed_gate --out "$tmp" --quiet python3 -c "import json,sys; d=json.load(open('$tmp')); sys.exit(0 if 'check_novel_schema' in d.get('gates_unparsed',[]) else 1)" \ || { echo "FAIL: unparsed gate not surfaced in gates_unparsed" >&2; pass=0; } rm -f "$tmp" if [ "$pass" -eq 1 ]; then echo "PASS: refinement-stop classifies the terminal states, counts a findings-schema Major (not just the claims schema), and surfaces an unrecognised schema instead of silently dropping it; never blocks." else exit 1 fi
-
-
check_analysis_definitions.py 12.7 KB
#!/usr/bin/env python3 """check_analysis_definitions.py — every analysis reported in Results must be defined in Methods. A model with no outcome variable is not complex; it is incomplete. Twenty-four detectors in this skill ask whether a number is *correct*. None asks whether the analysis that produced it was ever *defined*. This one does. The failure it catches is not "the statistics are too advanced". It is a Cox model whose dependent variable is never stated, and a calibration analysis whose reference standard is, in a reviewer's words, "not defined anywhere". Both were asked for twice in the rejection that motivated this gate, alongside "this section is largely incomprehensible in its current form". Load is the *cause*, not the crime. The same reviewer wrote: "too many analyses have been performed and reported, resulting in a manuscript with multiple tables and a lengthy Results section. This appears to have contributed to omissions of critical information in the Materials and Methods section." A second reviewer, on the same manuscript, listed the sensitivity analyses as a *strength*. So the count is reported here as context (`ANALYSIS_LOAD`, informational) and never as a verdict: a detector that punished the strength and missed the defect would be worse than none. Fires on: MODEL_NOT_IN_METHODS a model reported in Results that Methods never describes at all. MODEL_OUTCOME_UNDEFINED a model described in Methods with no outcome / dependent variable (and, for time-to-event, no time variable) named anywhere near it. REFERENCE_STANDARD_UNDEFINED discrimination (C-index / AUC) or calibration is reported, but Methods names no reference standard or observed outcome to score against. TIER_LABEL_UNDEFINED a tier/group label (T1–T4, Group A, Class 2) carries results but Methods never states its defining criterion. ANALYSIS_LOAD informational: distinct analyses + tables. Context for the above, never a verdict on its own. Stdlib-only. Reads the manuscript, never writes it. Usage: python3 check_analysis_definitions.py --manuscript paper.md [--strict] [--quiet] [--json] Exit: 0 clean; with --strict, 1 on any MAJOR finding; 2 on input/usage error. """ from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass, field, asdict # Journals name this section differently and a gate that cannot find it is a gate # that silently passes. CHEST requires "Study Design and Methods"; others use # "Subjects and Methods", "Design and Methods", "Methods and Materials". METHODS_RE = re.compile( r"^#{1,4}\s*\**\s*(?:\d+\.?\s*)?" r"(?:(?:study\s+design|design|subjects?|patients?|participants?|materials?|" r"methods?)\s+and\s+)?" r"(?:methods?|materials?)\b", re.I | re.M) RESULTS_RE = re.compile(r"^#{1,4}\s*\**\s*(?:\d+\.?\s*)?results?\b", re.I | re.M) DISCUSSION_RE = re.compile(r"^#{1,4}\s*\**\s*(?:\d+\.?\s*)?discussion\b", re.I | re.M) # Model families a reader must be told the outcome of. MODELS = { "Cox proportional-hazards": r"\bCox\b(?:[^.]{0,40}?\b(?:model|regression|hazard))?", "Fine–Gray subdistribution": r"\bFine[-–—\s]?Gray\b|\bsubdistribution\s+hazard\b", "logistic regression": r"\blogistic\s+regression\b", "Poisson / negative-binomial": r"\b(?:Poisson|negative[-\s]binomial)\s+(?:model|regression)\b", "linear / mixed model": r"\b(?:linear\s+mixed|mixed[-\s]effects)\s+model\b", } # Methods must say what the model predicts. OUTCOME_DECL_RE = re.compile( r"\b(?:" r"outcome\s+(?:was|were|is|variable|of\s+interest)|" r"dependent\s+variable|" r"event\s+of\s+interest|" r"(?:primary|secondary)\s+(?:endpoint|outcome)\s+(?:was|were|is)|" r"time[-\s]to[-\s]\w+|" r"time\s+(?:variable|scale|origin)|" r"modell?ed\s+(?:the\s+)?(?:time\s+to|risk\s+of|hazard\s+of|odds\s+of)|" r"the\s+outcome\s+for\s+(?:the|this)|" r"failure\s+time|" r"censor\w+\s+at" r")\b", re.I) # Discrimination / calibration need something to be scored against. PERF_RE = re.compile( r"\b(?:C[-\s]?index|c[-\s]?statistic|Harrell'?s?\s+C|Uno'?s?\s+C|" r"AUROC|AUC\b|discrimination|calibration|Brier\s+score|" r"calibration\s+(?:slope|plot|curve))\b", re.I) REFSTD_DECL_RE = re.compile( r"\b(?:" r"reference\s+standard|ground\s+truth|gold\s+standard|" r"observed\s+(?:outcome|event|risk|proportion)s?|" r"predicted\s+(?:versus|vs\.?|against)\s+observed|" r"against\s+the\s+observed|" r"compared\s+with\s+(?:the\s+)?observed" r")\b", re.I) TIER_LABEL_RE = re.compile(r"\b([TC][1-9]|Group\s+[A-D]|Class\s+[1-4]|Tier\s+[1-4])\b") # A tier is defined when its label sits next to a criterion: "T1 (≥15 mm)", "T1 = ...", # "T1 was defined as", "T1 (6–9 mm)". TIER_DEF_TMPL = (r"{lab}\s*(?:\(|,|:|=|\bwas\s+defined\b|\bwere\s+defined\b|\bdenote|\brefer)" r"[^.\n]{{0,60}}?(?:[<>≥≤]|\d|\bmm\b|\bcm\b|defined)") SENS_RE = re.compile( r"\b(?:sensitivity\s+analys[ie]s|subgroup\s+analys[ie]s|landmark\s+analys[ie]s|" r"competing[-\s]risk\w*|robustness\s+(?:check|analys[ie]s)|" r"meta[-\s]regression|leave[-\s]one[-\s]out|E[-\s]value)\b", re.I) TABLE_RE = re.compile(r"^\s*\**\s*Table\s+\d+\b", re.I | re.M) @dataclass class Finding: kind: str severity: str line: int detail: str @dataclass class Report: source: str load: dict = field(default_factory=dict) findings: list[Finding] = field(default_factory=list) @property def n_major(self) -> int: return sum(1 for f in self.findings if f.severity == "MAJOR") @property def verdict(self) -> str: return "UNDEFINED ANALYSES" if self.n_major else "OK" def _sections(text: str) -> tuple[str, str]: """Return (methods, results). Empty strings when a heading is absent.""" m = METHODS_RE.search(text) r = RESULTS_RE.search(text) d = DISCUSSION_RE.search(text) methods = text[m.end(): r.start()] if m and r and r.start() > m.start() else ( text[m.end():] if m else "") if r: results = text[r.end(): d.start()] if d and d.start() > r.start() else text[r.end():] else: results = "" return methods, results def _line_of(text: str, idx: int) -> int: return text.count("\n", 0, idx) + 1 def audit(text: str, source: str) -> Report: rep = Report(source=source) methods, results = _sections(text) if not methods or not results: rep.findings.append(Finding( "SECTIONS_NOT_FOUND", "MINOR", 1, "could not locate both a Methods and a Results heading; " "the definition cross-check needs both")) return rep m_off = text.index(methods) if methods else 0 outcome_declared = bool(OUTCOME_DECL_RE.search(methods)) # --- models ----------------------------------------------------------- for label, pat in MODELS.items(): rx = re.compile(pat, re.I) in_results = rx.search(results) m_hits = list(rx.finditer(methods)) if in_results and not m_hits: rep.findings.append(Finding( "MODEL_NOT_IN_METHODS", "MAJOR", _line_of(text, text.index(results) + in_results.start()), f"a {label} model carries results but Methods never describes it")) continue if not m_hits: continue # Search the WHOLE Methods section, not a window around the model. A # manuscript that declares its outcome once under "Outcomes" and then # specifies models under "Statistical Analysis" is doing it *correctly*; # a windowed search punishes the recommended structure. if not outcome_declared: rep.findings.append(Finding( "MODEL_OUTCOME_UNDEFINED", "MAJOR", _line_of(text, m_off + m_hits[0].start()), f"a {label} model is specified with no outcome / dependent variable " f"named near it — state the event and, for time-to-event, the time " f"variable and the censoring rule")) # --- discrimination / calibration ------------------------------------- # "Reference standard" is diagnostic-accuracy vocabulary. A prognostic model # scores its predictions against the outcome it already declared, so a declared # outcome satisfies this too. Fire only when neither exists. perf_hit = PERF_RE.search(results) or PERF_RE.search(methods) if perf_hit and not REFSTD_DECL_RE.search(methods) and not outcome_declared: where = results if PERF_RE.search(results) else methods base = text.index(where) rep.findings.append(Finding( "REFERENCE_STANDARD_UNDEFINED", "MAJOR", _line_of(text, base + PERF_RE.search(where).start()), "discrimination and/or calibration are reported, but Methods names no " "reference standard or observed outcome to score the predictions against")) # --- tier labels ------------------------------------------------------- used = {m.group(1) for m in TIER_LABEL_RE.finditer(results)} for lab in sorted(used): rx = re.compile(TIER_DEF_TMPL.format(lab=re.escape(lab)), re.I) if not rx.search(methods): first = TIER_LABEL_RE.search(results) rep.findings.append(Finding( "TIER_LABEL_UNDEFINED", "MINOR", _line_of(text, text.index(results) + (first.start() if first else 0)), f"'{lab}' carries results but Methods never states its defining " f"criterion — give the range or rule, and prefer the criterion itself " f"over an invented label")) # --- load (context only, never a verdict) ------------------------------ n_models = sum(1 for pat in MODELS.values() if re.search(pat, methods + results, re.I)) n_sens = len({m.group(0).lower() for m in SENS_RE.finditer(methods + results)}) n_tables = len(TABLE_RE.findall(text)) rep.load = {"model_families": n_models, "auxiliary_analyses": n_sens, "tables": n_tables} rep.findings.append(Finding( "ANALYSIS_LOAD", "INFO", 0, f"{n_models} model famil{'y' if n_models == 1 else 'ies'}, {n_sens} auxiliary " f"analys{'is' if n_sens == 1 else 'es'}, {n_tables} table{'' if n_tables == 1 else 's'}. " f"Load is context, not a verdict — but it is what crowds the Methods until " f"definitions fall out. If the checks above fired, look here first.")) return rep def format_report(rep: Report, color: bool) -> str: tag = {"OK": "\033[92m", "UNDEFINED ANALYSES": "\033[91m"}.get(rep.verdict, "") if color else "" end = "\033[0m" if color else "" out = [f"{tag}== {rep.verdict} =={end} {rep.source}"] if rep.load: out.append(f"load: models={rep.load['model_families']} " f"auxiliary={rep.load['auxiliary_analyses']} tables={rep.load['tables']}") majors = [f for f in rep.findings if f.severity != "INFO"] if not majors: out.append("every reported analysis names its outcome and its reference standard.") for f in sorted(rep.findings, key=lambda x: (x.severity == "INFO", x.line, x.kind)): out.append(f"[{f.severity}] {f.kind} L{f.line} {f.detail}") return "\n".join(out) def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--strict", action="store_true", help="exit 1 on any MAJOR finding") ap.add_argument("--quiet", action="store_true", help="suppress the report; exit code only") ap.add_argument("--json", action="store_true", help="emit JSON instead of a text report") args = ap.parse_args(argv) try: text = open(args.manuscript, encoding="utf-8").read() except OSError as e: print(f"error: cannot read manuscript: {e}", file=sys.stderr) return 2 rep = audit(text, args.manuscript) if not args.quiet: if args.json: print(json.dumps({"detector": "check_analysis_definitions", "source": rep.source, "verdict": rep.verdict, "load": rep.load, "findings": [asdict(f) for f in rep.findings]}, ensure_ascii=False, indent=2)) else: print(format_report(rep, color=sys.stdout.isatty())) return 1 if (args.strict and rep.n_major) else 0 if __name__ == "__main__": raise SystemExit(main()) -
check_aphorism_density.py 8.2 KB
#!/usr/bin/env python3 """Aphorism-density gate — a prose-rhythm AI tell (self-review §J / humanize P26). LLM-drafted argumentative prose writes in epigrams. Every sentence lands; almost none merely explains. Two things co-occur and are individually measurable: * the **negative definition** — "Authority is not cognition." "Involvement is not independence." A short sentence whose whole content is `X is not Y`. * a high share of **very short declaratives** used as punchlines ("It did not." "It is not one." "It cost more than it paid."). Human academic prose has both, sparingly, for emphasis. What marks the machine draft is density: the reader gets a closing line every few sentences and never a sentence that simply carries information forward. The fix is not to delete the epigrams — two or three are what a reader remembers — but to restore the explanatory sentences between them. Verdict: APHORISM_DENSITY (Minor) BOTH the negative-definition rate AND the short-declarative share exceed thresholds calibrated on published prose. Scope, deliberately conservative on a widely-used skill: * Requires BOTH signals. Either alone is ordinary style; together they are the tell. * The negative-definition pattern only counts inside a SHORT sentence, so ordinary negation in a long explanatory sentence ("the bound is not derived from multiplicity, because ...") never counts. * Headings, tables, block quotes, code fences and citation markers are stripped first. * Reports the offending sentences so the author can absorb them rather than hunt. Thresholds are calibrated against a corpus of published Perspectives (see --calibrate), not chosen a priori. Across eight published npj Digital Medicine Perspectives, cleaned to body prose and measured with this same code, the negative-definition rate ran 0.00-0.45% of sentences (seven of eight were 0.00%) and the short-declarative share 0.94-10.47%. The defaults sit above both observed maxima, so prose inside the published range does not fire. Regenerate with --calibrate if you have your own corpus; the numbers above are from argumentative Perspectives and a different genre may sit elsewhere. Exit codes: 0 clean/report-only, 1 with --strict when any Major (none — Minor only), 2 usage. Stdlib-only. Usage: python3 check_aphorism_density.py --manuscript manuscript.md \ [--out qc/aphorism_density.json] [--neg-def-pct 0.9] [--short-pct 13.0] \ [--min-sentences 40] [--strict] [--quiet] python3 check_aphorism_density.py --calibrate corpus/*.txt """ from __future__ import annotations import argparse import glob import json import re import statistics import sys from pathlib import Path from _prose import body_text SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z\"“])") # `X is not Y` where the complement is a bare noun phrase, not a clause or a # prepositional phrase. "Authority is not cognition." counts; "the bound is not # derived from multiplicity" does not (participle), nor does "is not in the frame" (PP). NEG_DEF_RE = re.compile( r"\b\w[\w'-]*\s+(?:is|are|was|were)\s+not\s+" r"(?:a|an|the\s+)?[\w'-]+(?:\s+[\w'-]+){0,2}\s*[.,;]", re.I, ) _PARTICIPLE_OR_PP = re.compile( r"\b(?:is|are|was|were)\s+not\s+" r"(?:\w+(?:ed|ing)\b|in|on|at|by|for|from|to|with|about|under|over|within|between)\b", re.I, ) SHORT_MAX_WORDS = 9 # "very short declarative" NEG_DEF_MAX_WORDS = 14 # a negative definition only counts inside a short sentence MIN_SENTENCES = 40 # below this a rate is noise def sentences(txt: str) -> list[str]: return [s.strip() for s in SENT_SPLIT_RE.split(txt) if len(s.split()) >= 2] def analyse(md: str) -> dict: sents = sentences(body_text(md)) n = len(sents) if not n: return {"sentences": 0, "neg_def": [], "short": [], "neg_def_pct": 0.0, "short_pct": 0.0} neg, short = [], [] for s in sents: w = len(s.split()) if w <= SHORT_MAX_WORDS: short.append(s) if w <= NEG_DEF_MAX_WORDS and NEG_DEF_RE.search(s) and not _PARTICIPLE_OR_PP.search(s): neg.append(s) return { "sentences": n, "neg_def": neg, "short": short, "neg_def_pct": round(100.0 * len(neg) / n, 2), "short_pct": round(100.0 * len(short) / n, 2), "mean_sentence_words": round(statistics.mean(len(s.split()) for s in sents), 1), } def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--manuscript") ap.add_argument("--calibrate", nargs="+", help="plain-text corpus files; print rates and exit") ap.add_argument("--out") ap.add_argument("--neg-def-pct", type=float, default=0.9) ap.add_argument("--short-pct", type=float, default=13.0) ap.add_argument("--min-sentences", type=int, default=MIN_SENTENCES, help="floor below which a rate is treated as noise (tests lower it)") ap.add_argument("--strict", action="store_true") ap.add_argument("--quiet", action="store_true") a = ap.parse_args() if a.calibrate: paths = [p for pat in a.calibrate for p in sorted(glob.glob(pat))] rows = [] for p in paths: r = analyse(Path(p).read_text(encoding="utf-8", errors="ignore")) if r["sentences"] < MIN_SENTENCES: continue rows.append((Path(p).name, r)) print(f"{Path(p).name[:40]:<42}{r['sentences']:>5}" f"{r['neg_def_pct']:>8.2f}%{r['short_pct']:>8.2f}%{r['mean_sentence_words']:>8.1f}") if rows: nd = [r["neg_def_pct"] for _, r in rows] sh = [r["short_pct"] for _, r in rows] print(f"{'median':<42}{'':>5}{statistics.median(nd):>8.2f}%{statistics.median(sh):>8.2f}%") print(f"{'max':<42}{'':>5}{max(nd):>8.2f}%{max(sh):>8.2f}%") return 0 if not a.manuscript: sys.stderr.write("error: --manuscript or --calibrate is required\n") return 2 src = Path(a.manuscript) if not src.exists(): sys.stderr.write(f"error: no such file: {src}\n") return 2 r = analyse(src.read_text(encoding="utf-8", errors="ignore")) fired = ( r["sentences"] >= a.min_sentences and r["neg_def_pct"] > a.neg_def_pct and r["short_pct"] > a.short_pct ) findings = [] if fired: findings.append({ "check": "APHORISM_DENSITY", "severity": "Minor", "detail": ( f"prose reads as a run of epigrams: {len(r['neg_def'])} negative definitions " f"({r['neg_def_pct']}% of sentences, threshold {a.neg_def_pct}%) and " f"{len(r['short'])} very short declaratives ({r['short_pct']}%, threshold " f"{a.short_pct}%); mean sentence {r['mean_sentence_words']} words. " "Absorb most of them into the neighbouring sentence and restore the " "explanatory prose between; keep two or three for emphasis." ), "negative_definitions": r["neg_def"][:12], "short_declaratives": r["short"][:12], }) if not a.quiet: print("=" * 42) print(" Aphorism density (§J / P26)") print("=" * 42) print("| Check | Severity | Detail |") print("|---|---|---|") if findings: for f in findings: print(f"| {f['check']} | {f['severity']} | {f['detail']} |") print("\nnegative definitions:") for s in r["neg_def"][:12]: print(f" • {s}") print("\nvery short declaratives:") for s in r["short"][:12]: print(f" • {s}") else: print("| (none) | — | sentence rhythm within the published range |") print("\nOK: sentence rhythm within the published range.") if a.out: out = Path(a.out) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps({"detector": "check_aphorism_density", "manuscript": str(src), "metrics": { k: r[k] for k in ("sentences", "neg_def_pct", "short_pct", "mean_sentence_words") }, "findings": findings}, indent=2) + "\n") if not a.quiet: print(f"\nwrote {out}") return 0 if __name__ == "__main__": raise SystemExit(main()) -
check_artifact_coverage.py 16.8 KB
#!/usr/bin/env python3 """Methods <-> Results <-> disk artifact coverage gate (self-review Phase 2.5f). Two directional failures survive a single prose pass because the manuscript is internally consistent: FORWARD PROMISED_ABSENT an analysis named in the Methods / Statistical Analysis subsection (a sensitivity analysis, multiple imputation, an interaction test, a subgroup, mediation, a competing-risk or landmark model, an E-value) never reaches Results. Methods promised it; the paper never delivered it. REVERSE DISK_UNREPORTED an analysis output that exists on disk (an added-value DeLong CSV, a calibration table) is never mentioned in the manuscript. The work was done and run but its result — which may contradict the headline — is silently absent. FORWARD2 PROMISED_STAT_NO_VALUE a named statistic framed as a bound/ceiling/ de-confounded value (e.g. "the de-confounded reader AUC is reported in Table S16", "the classifier ceiling AUC") is promised with a reporting verb but never given a numeric value anywhere in the manuscript or supplement. This is the "described but never quantified" reviewer catch — the bound that makes the primary estimand interpretable, marked Addressed in a checklist yet absent from every table. Conservative: fires only on a bound/ceiling/de-confounded framing whose statistic has zero associated numeric values in the whole corpus. The reverse direction is the false-positive-prone one, so it is calibrated: when an `_analysis_outputs.md` manifest exists (written by /analyze-stats) it is the source of truth; otherwise the analysis directory is globbed and a finding is only a Major when the file stem carries an analysis-bearing token (delong, nested, added-value, interaction, sensitivity, subgroup, mediation, imputation, landmark, calibration, dca, nri, idi). A cryptic stem with no such token is a Minor flag. INPUTS --manuscript manuscript markdown/text (required). --analysis-dir directory of analysis outputs. If omitted, the first existing of output/analysis/, analysis/, results/ is used. An `_analysis_outputs.md` manifest in that dir (or alongside the manuscript) takes precedence as the output source of truth. OUTPUT A reconciliation table (stdout) and, with --out, a JSON artifact: {manuscript, analysis_dir, manifest, claims[{verdict, severity, detail, where}], summary} PROMISED_ABSENT is always Major; DISK_UNREPORTED is Major or Minor per the rule above. Exit 1 (with --strict) when any Major-severity claim exists. Stdlib-only (json / re / argparse / pathlib). Exit codes: 0 clean (or report-only), 1 Major claim(s) found (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # Promised analyses: key -> (detect-in-Methods regex, appears-in-Results regex). # The Results regex is intentionally looser (the concept can be phrased differently # downstream) but still anchored to the analysis name. PROMISED = { "multiple imputation": (r"multiple imputation|\bm\s*=\s*\d+\s*imput|imputed using", r"imput-|imputation|imputed"), "sensitivity analysis": (r"sensitivity analys[ie]s", r"sensitivity analys[ie]s"), "leave-one-out": (r"leave[-\s]one[-\s]out", r"leave[-\s]one[-\s]out"), "interaction": (r"interaction (?:term|test|analys|effect)|tested? for interaction|" r"p[-\s]?(?:for[-\s])?interaction", r"interaction|p[-\s]?(?:for[-\s])?interaction|effect modif"), "subgroup": (r"subgroup analys", r"subgroup"), "mediation": (r"mediation (?:analys|model)", r"mediation|mediat"), "competing risk": (r"competing[-\s]risk|fine[-\s]?gray|subdistribution", r"competing[-\s]risk|fine[-\s]?gray|subdistribution|cumulative incidence"), "landmark": (r"landmark analys", r"landmark"), "E-value": (r"e[-\s]?value", r"e[-\s]?value"), } ANALYSIS_TOKENS = ( "delong", "nested", "added", "addedvalue", "incremental", "interaction", "sensitivity", "subgroup", "mediation", "imputation", "imputed", "landmark", "calibration", "dca", "netbenefit", "nri", "idi", "competing", "finegray", "tipping", "leaveone", "bootstrap", ) OUTPUT_EXT = (".csv", ".tsv", ".r", ".py", ".rds") MANIFEST_NAME = "_analysis_outputs.md" def _norm(s: str) -> str: return re.sub(r"[^a-z0-9]+", "", s.lower()) def split_sections(text: str) -> list[tuple[str, str]]: """Return (heading, body) for each markdown heading region.""" sections, heading, buf = [], "(preamble)", [] for line in text.splitlines(): m = re.match(r"^#{1,4}\s+(.*)", line) if m: sections.append((heading, "\n".join(buf))) heading = re.sub(r"[*_`]", "", m.group(1)).strip() buf = [] else: buf.append(line) sections.append((heading, "\n".join(buf))) return sections def section_text(sections: list[tuple[str, str]], names: tuple[str, ...]) -> str: out = [] for heading, body in sections: h = heading.lower() if any(n in h for n in names): out.append(body) return "\n".join(out) # --- FORWARD: promised-but-absent ------------------------------------------ def check_forward(text: str) -> list[dict]: sections = split_sections(text) methods = section_text(sections, ("method", "statistical analys", "analysis plan")) results = section_text(sections, ("result", "finding")) if not methods.strip(): return [] # no Methods section to read promises from # If there is no separate Results section, compare against the whole document # minus the Methods text (conservative: avoids matching a promise to itself). haystack = results if results.strip() else text claims = [] for key, (mre, rre) in PROMISED.items(): if re.search(mre, methods, re.I): if not re.search(rre, haystack, re.I): claims.append({ "verdict": "PROMISED_ABSENT", "severity": "Major", "detail": (f"Methods promises a '{key}' analysis, but it does not " f"appear in Results"), "where": "Methods → Results", }) return claims # --- FORWARD2: promised statistic that is never given a number -------------- # Statistic nouns whose *value* is the deliverable. "sensitivity" excludes the # "sensitivity analysis" sense. STAT_NOUN = re.compile( r"\bAUROC\b|\bAUC\b|\bc[-\s]?statistic\b|\bc[-\s]?index\b" r"|\bsensitivity\b(?!\s+analys)|\bspecificity\b|\bnet benefit\b|\bcalibration slope\b", re.IGNORECASE) # A bound/ceiling/de-confounded framing — the cases where a promised-but-unquantified # statistic is the load-bearing bound, not an ordinary reported metric. PROMISE_FRAME = re.compile( r"\bceiling\b|\bde[-\s]?confounded\b|\bupper[-\s]bound\b|\blower[-\s]bound\b|\bbound(?:s|ed|ing)?\b" r"|\bchance[-\s]level\b|\bdiscriminat\w*\s+ceiling\b", re.IGNORECASE) REPORT_VERB = re.compile( r"\b(?:is|are|was|were)\s+(?:reported|read|given|shown|presented|computed|provided|derived)\b" r"|reported in (?:Table|Supplement|Supplementary)|\bwe report\b|\bsee (?:Table|Supplement)", re.IGNORECASE) # A numeric value a statistic can take: a 0.xx discrimination value or an N% rate. STAT_VALUE = re.compile(r"\b0\.\d{2,3}\b|\b\d{1,3}(?:\.\d+)?\s?%") def _sentences(text: str) -> list[str]: flat = re.sub(r"\s*\n\s*", " ", text) return [s for s in re.split(r"(?<=[.;])\s+", flat) if s.strip()] def check_promised_stat(methods_supp: str, corpus: str) -> list[dict]: """Fire when a bound/ceiling/de-confounded statistic is promised with a reporting verb but no numeric value for that statistic exists in the corpus.""" claims = [] # statistic tokens that DO have an associated value somewhere in the corpus valued: set[str] = set() for sent in _sentences(corpus): if STAT_VALUE.search(sent): for sm in STAT_NOUN.finditer(sent): valued.add(sm.group(0).lower().replace(" ", "").replace("-", "")) seen: set[str] = set() for sent in _sentences(methods_supp): if STAT_VALUE.search(sent): continue # the value is right here — not a promise if not (STAT_NOUN.search(sent) and PROMISE_FRAME.search(sent) and REPORT_VERB.search(sent)): continue for sm in STAT_NOUN.finditer(sent): tok = sm.group(0) key = tok.lower().replace(" ", "").replace("-", "") if key in valued or key in seen: continue seen.add(key) claims.append({ "verdict": "PROMISED_STAT_NO_VALUE", "severity": "Major", "detail": (f"a bound/ceiling/de-confounded '{tok}' is promised with a " f"reporting verb but no numeric value for it appears in the " f"manuscript or supplement"), "where": sent.strip()[:160], }) return claims # --- REVERSE: disk-present-but-unreported ----------------------------------- def find_analysis_dir(manuscript: Path, override: str | None) -> Path | None: if override: p = Path(override) return p if p.is_dir() else None base = manuscript.resolve().parent for cand in ("output/analysis", "analysis", "results", "output"): for root in (base, base.parent): p = root / cand if p.is_dir(): return p return None def parse_manifest(path: Path) -> list[str]: """Return declared output basenames from an _analysis_outputs.md manifest. Lines like '- `table1.csv` -- desc' or '* roc_curve.pdf'.""" items = [] for line in path.read_text(encoding="utf-8").splitlines(): for m in re.finditer(r"`([^`]+\.[A-Za-z0-9]{1,5})`", line): items.append(m.group(1)) if not re.search(r"`", line): m = re.search(r"[-*]\s+([\w./-]+\.[A-Za-z0-9]{1,5})", line) if m: items.append(m.group(1)) return items def stem_tokens(name: str) -> list[str]: stem = Path(name).stem toks = re.split(r"[_\-.\s]+", stem) return [t for t in toks if len(t) >= 4 and re.search(r"[a-z]", t.lower())] def mentioned(name: str, body_norm: str) -> bool: toks = stem_tokens(name) if not toks: return True # nothing distinctive to look for -> do not flag return any(_norm(t) in body_norm for t in toks) def check_reverse(text: str, manuscript: Path, analysis_dir: str | None) -> tuple[list[dict], dict]: body_norm = _norm(text) meta = {"analysis_dir": None, "manifest": None} # 1) manifest precedence manifest_files: list[str] = [] for cand in (manuscript.resolve().parent / MANIFEST_NAME,): if cand.is_file(): manifest_files = parse_manifest(cand) meta["manifest"] = str(cand) break adir = find_analysis_dir(manuscript, analysis_dir) if adir is not None: meta["analysis_dir"] = str(adir) mpath = adir / MANIFEST_NAME if not manifest_files and mpath.is_file(): manifest_files = parse_manifest(mpath) meta["manifest"] = str(mpath) claims = [] if manifest_files: for name in sorted(set(manifest_files)): if Path(name).suffix.lower() not in OUTPUT_EXT: continue # figures (.pdf/.png) are checked by /make-figures legends if not mentioned(name, body_norm): claims.append({ "verdict": "DISK_UNREPORTED", "severity": "Major", "detail": (f"manifest output '{name}' is not mentioned anywhere in " f"the manuscript"), "where": meta["manifest"], }) return claims, meta # 2) glob fallback (calibrated severity) if adir is None: return claims, meta for f in sorted(adir.rglob("*")): if not f.is_file() or f.suffix.lower() not in OUTPUT_EXT: continue if mentioned(f.name, body_norm): continue analysis_bearing = any(_norm(t) in ANALYSIS_TOKENS for t in stem_tokens(f.name)) claims.append({ "verdict": "DISK_UNREPORTED", "severity": "Major" if analysis_bearing else "Minor", "detail": (f"analysis output '{f.name}' exists on disk but is not mentioned " f"in the manuscript" + (" (analysis-bearing name)" if analysis_bearing else "")), "where": str(f.relative_to(adir.parent) if adir.parent in f.parents else f), }) return claims, meta # --- driver ---------------------------------------------------------------- def analyze(manuscript: str, analysis_dir: str | None, supplements: list[str] | None = None) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) text = p.read_text(encoding="utf-8") supp_texts = [] for s in supplements or []: sp = Path(s) if not sp.is_file(): sys.stderr.write(f"ERROR: supplement not found: {s}\n") sys.exit(2) supp_texts.append(sp.read_text(encoding="utf-8", errors="replace")) claims = check_forward(text) rev, meta = check_reverse(text, p, analysis_dir) claims += rev # FORWARD2: promised-but-unquantified bound/ceiling statistic. The promise may # live in the Methods or in the supplement; the value may live anywhere. sections = split_sections(text) methods = section_text(sections, ("method", "statistical analys", "analysis plan")) methods_supp = "\n".join([methods] + supp_texts) corpus = "\n".join([text] + supp_texts) claims += check_promised_stat(methods_supp, corpus) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "manuscript": str(p), "analysis_dir": meta["analysis_dir"], "manifest": meta["manifest"], "claims": claims, "summary": { "n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "verdict": "MAJOR_CANDIDATE" if n_major else "OK", }, } def render(result: dict) -> str: lines = ["| Direction | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | Methods↔Results↔disk all reconciled |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Methods<->Results<->disk coverage gate (Phase 2.5f).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--analysis-dir", help="analysis-output dir (default: output/analysis, analysis, results)") ap.add_argument("--supplement", action="append", default=[], metavar="PATH", help="supplement file(s) to include in the promised-statistic corpus (repeatable)") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript, args.analysis_dir, args.supplement) if not args.quiet: print("=" * 41) print(" Artifact Coverage (Phase 2.5f)") print("=" * 41) if result["manifest"]: print(f"manifest: {result['manifest']}") elif result["analysis_dir"]: print(f"analysis dir (globbed): {result['analysis_dir']}") print(render(result)) print() s = result["summary"] if s["n_major"]: print(f"MAJOR candidate: {s['n_major']} coverage gap(s).") else: print("OK: Methods/Results/disk reconciled.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_artifact_coverage", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_baseline_drift.py 11 KB
#!/usr/bin/env python3 """Baseline-drift gate (self-review) -- anchor the refine loop to the human-approved version. Self-review is run in a loop (review -> revise -> review). The danger is not any single pass but the *anchor*: each pass takes the previous **AI output** as its baseline, so a small framing bias introduced in one pass becomes the starting point for the next and compounds -- claims strengthen, scope inflates, caveats accrete -- while every individual pass looks locally reasonable. Nothing measures how far the manuscript has drifted from the last version a human actually approved. This gate compares the current manuscript against a **baseline** -- the last human-approved / circulated version (the frozen v_N of manuscript-versioning), NOT the last AI output -- and reports lexical framing drift. It is advisory: framing is a judgment call the author owns, so every finding is Minor and the gate never blocks (--strict is accepted for CLI parity but exits 0). Comparing to the AI's own previous draft would defeat the purpose; the baseline must be a human anchor. STRENGTH_INFLATION certainty/assertion markers rose while hedges fell vs the baseline -- the same result stated more strongly than a human signed off on. SIGNIFICANCE_INFLATION_DRIFT significance-inflation tokens (novel / pivotal / unprecedented / paradigm-shifting ...) rose vs the baseline. SCOPE_INFLATION_DRIFT generalization phrases the baseline did not contain ("in clinical practice", "broadly applicable", "can be used to") appeared -- the estimand's reach widened without new data. HEDGE_ACCRETION hedge / caveat density rose vs the baseline -- the additive, over-hardening side of drift (the cumulative form of what the ceiling pass catches within one pass). Conservative by construction: a probe fires only when a density delta exceeds an explicit threshold, so ordinary revision does not trip it. With no --baseline (the crossfire path passes only --manuscript) there is nothing to anchor against and the gate emits zero findings. INPUTS --manuscript current manuscript markdown/text (required). --baseline the last human-approved version (optional; without it the gate is a no-op). thresholds --strength-delta (2.0), --sig-delta (2), --scope-delta (1), --hedge-delta (8.0). Densities are per 1,000 words; --sig/--scope are absolute counts (those tokens are rare). A probe fires when the delta exceeds its threshold. OUTPUT A drift table (stdout) and, with --out, a JSON artifact: {detector, manuscript, baseline, claims[{verdict, severity, detail, where}], summary} Every claim is severity "Minor". Exit code is always 0 for the findings themselves (advisory); --strict is accepted for CLI parity but never blocks. Exit 2 on a missing manuscript. Stdlib-only (json / re / argparse / pathlib). Exit codes: 0 clean or advisory findings, 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path DETECTOR = "check_baseline_drift" # --------------------------------------------------------------------------- # # Lexicons -- fixed, word-boundary matched. Kept small and unambiguous to hold the # false-positive rate down on a widely-used skill. # --------------------------------------------------------------------------- # ASSERTION = [ "demonstrates", "demonstrate", "proves", "prove", "proven", "establishes", "establish", "confirms", "confirm", "definitively", "conclusively", "unequivocally", "undoubtedly", "clearly shows", "guarantees", "ensures", "highly accurate", "highly effective", ] HEDGE = [ "may", "might", "could", "suggests", "suggest", "appears", "appear", "seems", "seem", "potentially", "possibly", "perhaps", "tends to", "is likely", "are likely", "we speculate", "cannot exclude", ] SIGNIFICANCE = [ "novel", "pivotal", "unprecedented", "paradigm", "paradigm-shifting", "groundbreaking", "revolutionary", "cutting-edge", "state-of-the-art", "remarkable", "landmark", "first-ever", "first to demonstrate", ] SCOPE = [ "in clinical practice", "in routine practice", "in the general population", "broadly applicable", "widely applicable", "can be used to", "should be used", "generalizable to", "translate directly", "ready for deployment", "ready for clinical use", "in real-world", ] def _count(text: str, terms: list[str]) -> int: n = 0 for t in terms: pat = r"(?<![A-Za-z0-9-])" + re.escape(t) + r"(?![A-Za-z0-9-])" n += len(re.findall(pat, text)) return n def _present(text: str, terms: list[str]) -> set[str]: found = set() for t in terms: pat = r"(?<![A-Za-z0-9-])" + re.escape(t) + r"(?![A-Za-z0-9-])" if re.search(pat, text): found.add(t) return found def _words(text: str) -> int: return max(1, len(re.findall(r"[A-Za-z0-9']+", text))) def _profile(text: str) -> dict: low = text.lower() w = _words(text) return { "words": w, "assertion": _count(low, ASSERTION), "hedge": _count(low, HEDGE), "sig": _count(low, SIGNIFICANCE), "scope_terms": _present(low, SCOPE), "sig_terms": _present(low, SIGNIFICANCE), } def _per1k(n: int, words: int) -> float: return round(n * 1000.0 / words, 2) def analyze(current: str, baseline: str | None, thr: dict) -> dict: claims: list[dict] = [] if baseline is None: return {"claims": claims, "summary": {"n_claims": 0, "verdict": "OK"}} cur = _profile(current) base = _profile(baseline) # STRENGTH_INFLATION: assertions up AND hedges down (per 1k) d_assert = _per1k(cur["assertion"], cur["words"]) - _per1k(base["assertion"], base["words"]) d_hedge = _per1k(cur["hedge"], cur["words"]) - _per1k(base["hedge"], base["words"]) if d_assert >= thr["strength"] and d_hedge < 0: claims.append({ "verdict": "STRENGTH_INFLATION", "severity": "Minor", "detail": ( f"certainty markers {_per1k(base['assertion'], base['words'])}->" f"{_per1k(cur['assertion'], cur['words'])}/1k while hedges " f"{_per1k(base['hedge'], base['words'])}->{_per1k(cur['hedge'], cur['words'])}/1k " "vs baseline -- the result is stated more strongly than the approved version" ), "where": "whole document", }) # SIGNIFICANCE_INFLATION_DRIFT: absolute significance-token count up d_sig = cur["sig"] - base["sig"] if d_sig >= thr["sig"]: added = sorted(cur["sig_terms"] - base["sig_terms"]) detail = f"significance-inflation tokens {base['sig']}->{cur['sig']} vs baseline" if added: detail += " (added: " + ", ".join(added) + ")" claims.append({"verdict": "SIGNIFICANCE_INFLATION_DRIFT", "severity": "Minor", "detail": detail, "where": "whole document"}) # SCOPE_INFLATION_DRIFT: new generalization phrases absent from baseline added_scope = sorted(cur["scope_terms"] - base["scope_terms"]) if len(added_scope) >= thr["scope"]: claims.append({ "verdict": "SCOPE_INFLATION_DRIFT", "severity": "Minor", "detail": "generalization phrase(s) not in baseline: " + ", ".join(f'"{p}"' for p in added_scope), "where": "whole document", }) # HEDGE_ACCRETION: hedge density up (the additive/over-hardening drift) if d_hedge >= thr["hedge"]: claims.append({ "verdict": "HEDGE_ACCRETION", "severity": "Minor", "detail": ( f"hedge/caveat density {_per1k(base['hedge'], base['words'])}->" f"{_per1k(cur['hedge'], cur['words'])}/1k vs baseline -- cumulative over-hardening" ), "where": "whole document", }) verdict = "DRIFT_FLAGS" if claims else "OK" return {"claims": claims, "summary": {"n_claims": len(claims), "verdict": verdict}} def render(result: dict, manuscript: str, baseline: str | None) -> str: lines = ["Baseline-drift scan"] lines.append(f" manuscript: {manuscript}") lines.append(f" baseline: {baseline if baseline else '(none -- no-op)'}") claims = result["claims"] if not claims: lines.append(" OK: no framing drift beyond threshold." if baseline else " OK: no baseline supplied; nothing to anchor against.") return "\n".join(lines) lines.append(f" DRIFT FOUND: {len(claims)} finding(s) (advisory / Minor)") for c in claims: lines.append(f" [{c['verdict']}] {c['detail']}") return "\n".join(lines) def main(argv=None) -> int: ap = argparse.ArgumentParser(description="Report lexical framing drift of a manuscript vs its last human-approved baseline.") ap.add_argument("--manuscript", required=True, help="current manuscript markdown/text") ap.add_argument("--baseline", help="last human-approved version (optional; no-op without it)") ap.add_argument("--out", help="optional JSON artifact path") ap.add_argument("--quiet", action="store_true", help="suppress the stdout summary") ap.add_argument("--strict", action="store_true", help="accepted for CLI parity; never blocks (advisory)") ap.add_argument("--strength-delta", type=float, default=2.0, help="min assertion-density rise per 1k (default 2.0)") ap.add_argument("--sig-delta", type=int, default=2, help="min significance-token count rise (default 2)") ap.add_argument("--scope-delta", type=int, default=1, help="min new scope phrases (default 1)") ap.add_argument("--hedge-delta", type=float, default=8.0, help="min hedge-density rise per 1k (default 8.0)") args = ap.parse_args(argv) mpath = Path(args.manuscript) if not mpath.is_file(): print(f"error: manuscript not found: {args.manuscript}", file=sys.stderr) return 2 current = mpath.read_text(encoding="utf-8", errors="replace") baseline_text = None if args.baseline: bpath = Path(args.baseline) if not bpath.is_file(): print(f"error: baseline not found: {args.baseline}", file=sys.stderr) return 2 baseline_text = bpath.read_text(encoding="utf-8", errors="replace") thr = { "strength": args.strength_delta, "sig": args.sig_delta, "scope": args.scope_delta, "hedge": args.hedge_delta, } result = analyze(current, baseline_text, thr) out = { "detector": DETECTOR, "manuscript": str(args.manuscript), "baseline": str(args.baseline) if args.baseline else None, **result, } if args.out: Path(args.out).write_text(json.dumps(out, indent=2) + "\n", encoding="utf-8") if not args.quiet: print(render(result, str(args.manuscript), str(args.baseline) if args.baseline else None)) return 0 if __name__ == "__main__": sys.exit(main()) -
check_binning_consistency.py 19.9 KB
#!/usr/bin/env python3 """Cross-script categorical / cut-point and composite-definition consistency gate (self-review Phase 2.5b). A derived categorical variable (age band, BMI category, eGFR/CKD stage, FIB-4 strata, risk tier) is often re-derived in more than one analysis script — the primary table in one file, a sensitivity or secondary analysis in another. When those re-derivations disagree on the cut-points or the interval closure, the SAME cohort is split differently in each file: per-stratum Ns drift between tables even though the grand total still matches, and a stratum can spuriously appear to cross a threshold. A grand-total / row-sum check does not catch it because every total still reconciles; a reviewer who compares the primary table's stratum Ns against the sensitivity table's stratum Ns does. This detector parses analysis SOURCE (R / Python), not the manuscript. It extracts every binning assignment of the form R: <var> <- cut(<src>, breaks = c(...), right = TRUE/FALSE, labels = ...) R: <var> <- case_when( <numeric boundary literals> ) Python: <var> = pd.cut(<src>, bins=[...], right=True/False, labels=...) groups them by the assigned variable name, and fires BINNING_DRIFT when one variable is defined with two or more distinct (breaks, right-closure) signatures across the scanned files. It is deterministic and conservative: it fires only when it can extract a complete `breaks`/`bins` operand and the signatures genuinely differ. The interval-closure flag is compared using each language's documented default (R `cut` right=TRUE, pandas `pd.cut` right=True), so an explicit `right=FALSE` in one file and an omitted default in another is a real difference. It ALSO extracts composite boolean-indicator definitions — the sibling failure mode where a derived 0/1 component (e.g. a metabolic-syndrome criterion) is built from a disjunction of comparison clauses and a second script omits or adds a clause: R: <var> <- as.integer(a >= x | b == 1 | c == 1) R: <var> <- as.numeric(<bool expr>) / ifelse(<bool expr>, 1, 0) Python: <var> = np.where(<bool expr>, 1, 0) The boolean expression is split into comparison ATOMS on the top-level `|` (OR); clause order, whitespace, and outer parentheses do not matter (atoms are compared as a SET), so only a genuinely missing or added clause counts. It fires DERIVED_DEF_DRIFT when one variable is defined with two or more distinct atom sets. Motivation: `mets_bp <- as.integer(bl_he_sbp>=130 | bl_he_dbp>=85 | bl_tx_hypertension_med==1 | bl_hypertension==1)` in the canonical script vs the same name without the final `| bl_hypertension==1` in a re-analysis script. The two definitions classify different participants, so the metabolic-syndrome C-index computed from each disagreed in the fourth decimal — enough to put two different values for one quantity into a main table and its supplement, and small enough that nobody reading either artifact alone would notice. Motivation: a screening cohort binned age with `cut(bl_age, breaks=c(-Inf,45,50,60,Inf), right=FALSE)` in the primary script and `cut(bl_age, breaks=c(-Inf,44,49,59,Inf), right=TRUE)` in the threshold sensitivity script. Fractional ages (e.g. 44.5 y) fell into different bands, shifting ~353 participants and producing a spurious "reached" stratum in the sensitivity table that vanished once the binning was harmonized. See ~/.claude/rules/cross-script-categorical-consistency.md. INPUTS --root PATH directory to scan recursively for *.R/*.r/*.py (repeatable; default: ./analysis and ./scripts if present, else .) --glob PATTERN extra filename glob to include (repeatable) --out PATH write JSON artifact --strict exit 1 if any Major (BINNING_DRIFT) finding --quiet suppress stdout table OUTPUT reconciliation table (stdout) + optional JSON: {scanned[], definitions[], claims[{verdict, severity, detail, where}], summary} Stdlib-only (re / json / argparse / pathlib). Exit codes: 0 clean/report-only, 1 Major with --strict, 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # Variable-name hints that mark a derived categorical (the assigned LHS or the # binned source). Used only to keep the reconciliation table focused; a drift on # any repeatedly-binned variable still fires. CATEGORICAL_HINTS = ( "band", "cat", "category", "group", "grp", "strat", "stage", "tier", "quartile", "tertile", "quintile", "decile", "level", "class", "age", "bmi", "egfr", "gfr", "fib4", "fib_4", "cmb", "mets", "ckd", ) # Name hints for composite 0/1 indicator components (the DERIVED_DEF_DRIFT path). INDICATOR_HINTS = ( "mets", "indicator", "flag", "criteria", "criterion", "_pos", "_neg", "_yes", "_bin", "_ind", "comp", "positive", "present", ) SCRIPT_SUFFIXES = (".r", ".py") def _is_categorical_name(name: str) -> bool: n = name.lower() return any(h in n for h in CATEGORICAL_HINTS) def _is_indicator_name(name: str) -> bool: n = name.lower() return any(h in n for h in INDICATOR_HINTS) def _norm_breaks(raw: str) -> str: """Normalize a breaks/bins operand to a comparable token string. Keeps the numeric/-Inf/Inf sequence in order; drops whitespace and the c(...) / [...] wrapper. '-Inf,45,50,60,Inf' style.""" body = raw.strip() body = re.sub(r"^(c|seq)\s*\(", "", body, flags=re.I) body = body.strip().lstrip("[(").rstrip("])") toks = [] for t in body.split(","): t = t.strip() if not t: continue t = t.replace("Inf", "Inf").replace("inf", "Inf") t = re.sub(r"^np\.|^math\.|^float\(['\"]?|['\"]?\)$", "", t) t = t.replace("-Inf", "-Inf") toks.append(t) return ",".join(toks) def _right_default(lang: str) -> str: # R cut() default right=TRUE; pandas pd.cut default right=True. return "TRUE" def _norm_right(raw, lang: str) -> str: if raw is None: return _right_default(lang) v = raw.strip().upper() if v in ("T", "TRUE"): return "TRUE" if v in ("F", "FALSE"): return "FALSE" return v def _find_matching_paren(text: str, open_idx: int) -> int: """Index of the ')' matching the '(' at open_idx, or -1.""" depth = 0 for i in range(open_idx, len(text)): c = text[i] if c == "(": depth += 1 elif c == ")": depth -= 1 if depth == 0: return i return -1 # `lhs <- cut(...)`, `lhs = cut(...)`, `lhs = pd.cut(...)`. Captures lhs + call start. _ASSIGN_CUT_RE = re.compile( r"(?P<lhs>[A-Za-z_][\w.$\[\]\"']*?)\s*(?:<<-|<-|=)\s*" r"(?P<fn>(?:pd\.)?cut)\s*\(", ) _BREAKS_RE = re.compile(r"\b(?:breaks|bins)\s*=\s*", re.I) _RIGHT_RE = re.compile(r"\bright\s*=\s*(TRUE|FALSE|True|False|T|F)\b") _LABELS_RE = re.compile(r"\blabels\s*=\s*(c\([^)]*\)|\[[^\]]*\])", re.I) def _operand_after(text: str, start: int) -> str: """Return the operand string starting at `start`, balanced over () and [].""" # skip leading spaces i = start while i < len(text) and text[i] in " \t": i += 1 if i >= len(text): return "" if text[i] in "c([": # c(...) or [...] if text[i] == "c": paren = text.find("(", i) end = _find_matching_paren(text, paren) return text[i:end + 1] if end != -1 else text[i:i + 80] opener = text[i] closer = ")" if opener == "(" else "]" depth = 0 for j in range(i, len(text)): if text[j] == opener: depth += 1 elif text[j] == closer: depth -= 1 if depth == 0: return text[i:j + 1] return text[i:i + 80] # bare token up to comma m = re.match(r"[^,)\n]+", text[i:]) return m.group(0) if m else "" def extract_cut_defs(path: Path): """Yield dicts for each cut/pd.cut assignment in a file.""" text = path.read_text(encoding="utf-8", errors="replace") lang = "py" if path.suffix.lower() == ".py" else "r" out = [] for m in _ASSIGN_CUT_RE.finditer(text): open_idx = m.end() - 1 close_idx = _find_matching_paren(text, open_idx) if close_idx == -1: continue call = text[open_idx + 1:close_idx] bm = _BREAKS_RE.search(call) if not bm: continue breaks_raw = _operand_after(call, bm.end()) breaks = _norm_breaks(breaks_raw) if not breaks or not re.search(r"\d", breaks): continue rm = _RIGHT_RE.search(call) right = _norm_right(rm.group(1) if rm else None, lang) lm = _LABELS_RE.search(call) labels = re.sub(r"\s+", "", lm.group(1)) if lm else "" lhs = m.group("lhs").strip() line_no = text[:m.start()].count("\n") + 1 out.append({ "var": lhs, "kind": "cut", "breaks": breaks, "right": right, "labels": labels, "file": str(path), "line": line_no, }) return out # Composite boolean-indicator assignment: `lhs <- as.integer(...)`, `as.numeric`, # `ifelse(...)`, Python `(...).astype(int)` / `np.where(...)`. _ASSIGN_DERIVED_RE = re.compile( r"(?P<lhs>[A-Za-z_][\w.$\[\]\"']*?)\s*(?:<<-|<-|=)\s*" r"(?P<fn>as\.integer|as\.numeric|np\.where|ifelse)\s*\(", ) _COMPARISON_RE = re.compile(r"==|!=|>=|<=|%in%|>|<") def _first_arg(call_body: str) -> str: """The first top-level (depth-0) comma-delimited argument of a call body.""" depth = 0 for i, c in enumerate(call_body): if c in "([": depth += 1 elif c in ")]": depth -= 1 elif c == "," and depth == 0: return call_body[:i] return call_body def _split_top_level(expr: str, op: str): """Split on a top-level (depth-0) boolean operator char `op` ('|' or '&'). Parenthesized/`&`-or-`|` sub-groups stay intact; the doubled form (`||`/`&&`) is treated identically as a single separator.""" parts, depth, cur, i = [], 0, [], 0 while i < len(expr): c = expr[i] if c in "([": depth += 1 cur.append(c) elif c in ")]": depth -= 1 cur.append(c) elif c == op and depth == 0: if i + 1 < len(expr) and expr[i + 1] == op: # collapse `||` / `&&` i += 1 parts.append("".join(cur)) cur = [] else: cur.append(c) i += 1 if cur: parts.append("".join(cur)) return parts def _strip_wrap_parens(a: str) -> str: """Remove fully-wrapping outer parens, e.g. `(a | b)` -> `a | b`. A leading paren that does NOT close at the end (e.g. `(a & b) | c`) is left intact.""" a = a.strip() while len(a) >= 2 and a[0] == "(" and _find_matching_paren(a, 0) == len(a) - 1: a = a[1:-1].strip() return a def _norm_atom(a: str) -> str: """Whitespace-free canonical form of one OR-clause. Dataframe-receiver qualifiers are dropped so the SAME derivation rule expressed against different dataframe objects compares equal — a base-R `df$col`, a bare `mutate()` reference, and a Python `df['col']` / `df["col"]` subscript all reduce to the column `col`. This matters for a legitimately-parallel sensitivity cohort: `v0['end_date'] >= x` in the primary script and `lenient_cohort['end_date'] >= x` in the sensitivity script are the SAME rule on two df objects and must not read as DERIVED_DEF_DRIFT. A top-level `&`-group is sorted so operand order inside an AND does not matter (`x==1 & y>=2` == `y>=2 & x==1`).""" a = re.sub(r"\s+", "", _strip_wrap_parens(a)) a = re.sub(r"[A-Za-z_]\w*\$", "", a) # base-R df$col / sub$col -> col # Python df['col'] / df["col"] receiver subscript -> col (receiver alias dropped) a = re.sub(r"[A-Za-z_]\w*\[\s*(['\"])([^'\"]+)\1\s*\]", r"\2", a) if "&" in a: andparts = _split_top_level(a, "&") if len(andparts) > 1: a = "&".join(sorted(_strip_wrap_parens(p) for p in andparts)) return a def extract_derived_defs(path: Path): """Yield dicts for each composite boolean-indicator assignment in a file. Only definitions whose expression contains at least one comparison operator are kept (a plain `as.integer(count)` cast is not an indicator and is skipped). The atom set is order- and whitespace-insensitive.""" text = path.read_text(encoding="utf-8", errors="replace") out = [] for m in _ASSIGN_DERIVED_RE.finditer(text): open_idx = m.end() - 1 close_idx = _find_matching_paren(text, open_idx) if close_idx == -1: continue inner = text[open_idx + 1:close_idx] # np.where(expr, 1, 0) / ifelse(expr, 1, 0): the boolean test is arg 1. if m.group("fn") in ("np.where", "ifelse"): inner = _first_arg(inner) inner = _strip_wrap_parens(inner) if not _COMPARISON_RE.search(inner): continue atoms = sorted({_norm_atom(a) for a in _split_top_level(inner, "|") if _norm_atom(a)}) if not atoms: continue lhs = m.group("lhs").strip() line_no = text[:m.start()].count("\n") + 1 out.append({ "var": lhs, "kind": "derived", "atoms": atoms, "file": str(path), "line": line_no, }) return out def analyze(roots, extra_globs): files = [] seen = set() for root in roots: rp = Path(root) if rp.is_file(): cands = [rp] else: cands = [p for p in rp.rglob("*") if p.suffix.lower() in SCRIPT_SUFFIXES] for g in extra_globs: cands += list(rp.rglob(g)) for p in cands: if p.is_file() and str(p) not in seen: seen.add(str(p)) files.append(p) defs = [] for p in sorted(files): try: defs.extend(extract_cut_defs(p)) except OSError: continue # Group by assigned variable name (normalized to leaf identifier). # `df$col` (R) / `df['col']` (py) / `df.col` (py) / bare `col` all reduce to # the COLUMN name `col` — the table handle is not the variable of interest. def _leaf(v): v = v.strip().strip("\"'") m = re.search(r"\[\s*[\"']([^\"']+)[\"']\s*\]", v) # py df['col'] / df["col"] if m: return m.group(1) v = re.sub(r"\[[^\]]*\]", "", v) # drop other [..] indexers return v.split("$")[-1].split(".")[-1].strip() # R df$col / py df.col / bare groups: dict[str, list[dict]] = {} for d in defs: groups.setdefault(_leaf(d["var"]), []).append(d) claims = [] for var, ds in sorted(groups.items()): sigs = {(d["breaks"], d["right"]) for d in ds} # Only meaningful for repeatedly-derived categoricals across >=2 sites. if len(ds) < 2 or len(sigs) < 2: continue # Conservative focus: name looks categorical OR appears in >=2 files. n_files = len({d["file"] for d in ds}) if not (_is_categorical_name(var) or n_files >= 2): continue detail_parts = [] for d in ds: fn = Path(d["file"]).name detail_parts.append(f"{fn}:{d['line']} breaks=[{d['breaks']}] right={d['right']}") claims.append({ "verdict": "BINNING_DRIFT", "severity": "Major", "var": var, "detail": f"`{var}` derived with {len(sigs)} different cut signatures across " f"{n_files} file(s): " + " | ".join(detail_parts), "where": "; ".join(f"{Path(d['file']).name}:{d['line']}" for d in ds), }) # --- Composite boolean-indicator definition drift (DERIVED_DEF_DRIFT) ----- derived_defs = [] for p in sorted(files): try: derived_defs.extend(extract_derived_defs(p)) except OSError: continue dgroups: dict[str, list[dict]] = {} for d in derived_defs: dgroups.setdefault(_leaf(d["var"]), []).append(d) for var, ds in sorted(dgroups.items()): sigs = {tuple(d["atoms"]) for d in ds} if len(ds) < 2 or len(sigs) < 2: continue n_files = len({d["file"] for d in ds}) # Conservative focus: indicator/categorical-looking name OR re-derived in >=2 files. if not (_is_indicator_name(var) or _is_categorical_name(var) or n_files >= 2): continue siglist = [set(s) for s in sigs] diff = sorted(set().union(*siglist) - set.intersection(*siglist)) detail_parts = [f"{Path(d['file']).name}:{d['line']} {{{', '.join(d['atoms'])}}}" for d in ds] claims.append({ "verdict": "DERIVED_DEF_DRIFT", "severity": "Major", "var": var, "detail": f"`{var}` defined with {len(sigs)} different clause sets across " f"{n_files} file(s); differing clause(s): {{{', '.join(diff)}}}. " + " | ".join(detail_parts), "where": "; ".join(f"{Path(d['file']).name}:{d['line']}" for d in ds), }) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "scanned": [str(p) for p in sorted(files)], "definitions": defs + derived_defs, "claims": claims, "summary": { "n_files": len(files), "n_definitions": len(defs) + len(derived_defs), "n_cut_definitions": len(defs), "n_derived_definitions": len(derived_defs), "n_claims": len(claims), "n_major": n_major, "verdict": "MAJOR_CANDIDATE" if n_major else "OK", }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | no cross-script binning or definition drift detected |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser( description="Cross-script categorical / cut-point consistency gate (Phase 2.5b).") ap.add_argument("--root", action="append", default=[], help="directory or file to scan (repeatable; " "default: ./analysis and ./scripts if present, else .)") ap.add_argument("--glob", action="append", default=[], help="extra filename glob to include under each --root (repeatable)") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major finding") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() roots = args.root if not roots: roots = [d for d in ("analysis", "scripts") if Path(d).is_dir()] or ["."] result = analyze(roots, args.glob) if not args.quiet: print("=" * 46) print(" Cross-script Categorical / Definition Consistency (Phase 2.5c)") print("=" * 46) print(render(result)) print() s = result["summary"] defs_desc = (f"{s['n_cut_definitions']} cut + {s['n_derived_definitions']} " f"composite definitions in {s['n_files']} files") if s["n_major"]: print(f"MAJOR candidate: {s['n_major']} variable(s) derived inconsistently " f"across scripts ({defs_desc}).") else: print(f"OK: no cross-script binning or definition drift ({defs_desc}).") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_binning_consistency", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_citation_order.py 22.2 KB
#!/usr/bin/env python3 """Citation-ORDER gate — numbered floats and the in-text reference series (journal technical-check pass). Journals (KJR, Radiology, AJR, and most others) require that numbered floats be **cited in ascending order of first appearance** in the narrative text, evaluated per series independently: main Tables (Table 1, 2, 3, …) main Figures (Figure 1, 2, 3, …) Suppl. Tables (Table S1, S2, … / Supplementary Table S1, …) Suppl. Figures(Supplementary Figure S1, … / Figure S1, …) The same Vancouver discipline governs a FIFTH series the float scan never saw: the **in-text reference numbers** ("[12]", "[4–11]"). They too must ascend by first appearance, be contiguous from 1, and reach the reference-list length. A citeproc manuscript renumbers "[@key]" at render and so has no numbers to check here; but a HAND-TYPED "[N]" manuscript (the Word/Zotero placeholder path) has no gate at all, and an out-of-order or gapped reference series is a desk-check reject just like an out-of-order table. This is a deterministic, pre-peer-review desk/technical-check item: editorial offices "unsubmit" manuscripts for it before a reviewer ever sees them. Existing self-review gates lint xref *resolution* (does the callout resolve to a section) but never *order*. What it does: scans the NARRATIVE body (everything before the first float-definition / back-matter section header — Figure Legends, Tables, Supplementary, References — so a legends block that lists figures in order does not mask an out-of-order body), extracts the first-citation position of every numbered float per series AND of every bracketed reference number, EXPANDS ranges ("4–11" → 4..11 — so a number inside a rendered range is never read as a false gap), and flags any series whose first- appearance sequence is not ascending (or, for references, is gapped or overruns the list). Verdicts: CITATION_ORDER (Major) a float series is cited out of numerical order (e.g., Table 3 first-cited before Table 1, or Suppl. Tables cited S3, S1, S2, …). Technical-check-fatal. CITATION_GAP (Minor) a float series' cited numbers are not contiguous from 1 (a possible missing / mis-numbered float). Report-only. REFERENCE_ORDER (Major) in-text reference numbers are cited out of order (e.g. [12] before [5]) — the Vancouver list is mis-numbered. Ranges are expanded first, so a re-citation inside "[4–11]" is not a fault. REFERENCE_GAP (Minor) cited reference numbers are not contiguous from 1 (a number never cited) — a missing or mis-numbered citation. Report-only. REFERENCE_COUNT_MISMATCH the highest cited reference overruns the reference-list length (Major/Minor) ([N] resolves to nothing → Major dangling), or the list has trailing entries never cited (→ Minor). Needs a numbered list. UNCITED_FLOAT (Minor) a float that HAS a legend/caption in the back matter is never cited anywhere in the narrative body — a display item the reader is never pointed to (uncited supplements/tables/figures are a recurrent reviewer/technical-check rejection). Report-only. DANGLING_SECTION_XREF an in-text "Section N" / "Section N.M" reference has no (Major) matching numbered heading — the common case being a journal that typesets UNNUMBERED headings, where every such reference dangles at production. Name the heading instead of a number. ("Supplementary/Appendix Section N" is exempt — it points at the supplement.) Fix: renumber the series by first-citation order (and reorder the float/supplement document + remap ALL cross-references, expanding ranges like "S12–S15" by hand and leaving non-float "S1–S6" sensitivity-spec labels untouched), or rephrase to remove the early out-of-order citation. See ~/.claude/rules/journal-technical-check-gate.md. INPUT --manuscript manuscript markdown/text (required). --include-back-matter also scan back-matter sections (legends/refs) — off by default. OUTPUT stdout table and, with --out, a JSON artifact {manuscript, claims[], summary}. Stdlib-only (re / json / argparse / pathlib). Exit codes: 0 clean (or report-only), 1 Major claim(s) found (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from _frontmatter import strip_frontmatter # A back-matter / float-definition section header. Everything from the first such # header onward is excluded from the body citation-order scan (legends list floats # in order by construction; references are not citations). BACK_MATTER_RE = re.compile( r"(?im)^#{1,6}\s*\**\s*" r"(figure\s+legends?|figure\s+captions?|table\s+legends?|table\s+captions?|" r"tables?|supplementary|supplement|references|bibliography)\b") # A "Table(s)/Figure(s) <numlist>" mention. numlist = S?<digits> tokens joined by # comma / ampersand / en-dash / hyphen / "and". Captures the kind word + the list. MENTION_RE = re.compile( r"\b(Tables?|Figures?)\s+" r"(S?\d+(?:\s*(?:,|&|–|-|and)\s*S?\d+)*)", re.IGNORECASE) # One citation token inside a numlist: optional S-prefix, a number, and an optional # "A–B" range tail. Ranges MUST be expanded before ordering/gap analysis — an # endpoint-only read of "4–11" as {4, 11} both hides the interior (5..10) and reports # every interior number as a false gap. Shared by the float scan and the reference scan. TOKEN_RANGE_RE = re.compile(r"(S?)(\d+)(?:\s*[–—-]\s*(S?)(\d+))?", re.IGNORECASE) def _expand_numlist(numlist: str): """Yield (supp_bool, number, offset) for every number in a citation numlist, expanding an "A–B" range to A..B. offset is the token's start within numlist so a caller can recover first-appearance position; a range's members share the range token's offset. The S-prefix of a range is taken from its start token. An inverted or absurdly wide (> 500) range degrades to its two endpoints rather than expanding.""" for m in TOKEN_RANGE_RE.finditer(numlist): supp = bool(m.group(1)) a = int(m.group(2)) if m.group(4) is not None: b = int(m.group(4)) if a <= b <= a + 500: for k in range(a, b + 1): yield supp, k, m.start() else: yield supp, a, m.start() yield bool(m.group(3)) or supp, b, m.start(4) else: yield supp, a, m.start() # An in-text numbered reference citation: a bracketed numlist ("[12]", "[4,5]", # "[4–11]"). Guards against the other square-bracket syntaxes: a wikilink "[[1]]" # (leading "["), an image "![alt]" (leading "!"), a markdown link "[1](url)" or link- # definition "[1]:" (trailing "(" / ":"), and a footnote "[^1]" (the "^" is not a digit, # so it never matches). Only the bracketed Vancouver form is scanned — parenthetical # "(1)" is left alone: it is indistinguishable from equation, panel, group-size and # CI-bound numbers. REF_CITE_RE = re.compile(r"(?<![\[!])\[(\d[\d\s,;&–—-]*)\](?!\s*[:(\[])") # A numbered entry in the reference list ("1. …", "[1] …", "1) …"). REF_LIST_ITEM_RE = re.compile(r"(?m)^\s{0,3}(?:\[(\d+)\]|(\d+)[.)])\s+\S") # Below this many distinct in-text reference numbers there is too little signal to tell # a Vancouver citation apart from a stray "[1]", so the reference-series check stays silent. REF_MIN_DISTINCT = 3 SERIES_LABEL = { ("table", False): "Table", ("figure", False): "Figure", ("table", True): "Supplementary Table", ("figure", True): "Supplementary Figure", } # A float DEFINITION (legend / caption line) in the back matter — line-start, optional # bold, the kind word, the number, then a caption delimiter (`.`/`:`/`|`). This is a # definition, not a citation: "Table 3. Baseline characteristics" (legend) vs "Table 3 # shows…" (citation). Series is keyed by the number's S-prefix, exactly as the body- # citation scan keys it, so a defined float and its in-text citation land in the same # series and never produce a spurious mismatch. FLOAT_DEF_RE = re.compile( r"(?im)^\s{0,3}\**\s*(?:Supplementary\s+|Suppl\.?\s+|Online\s+|e-?)?" r"(Table|Figure)\s+(S?\d+)\s*[.:|]") # The references/bibliography list is back matter too but is not a source of float # definitions; truncate the definition scan there so a "Fig. 3" inside a citation string # cannot be read as a legend. REF_HEADER_RE = re.compile(r"(?im)^#{1,6}\s*\**\s*(references|bibliography)\b") # In-text reference to a numbered section ("as reported in Section 3.4"). Many # medical journals typeset UNNUMBERED headings (house style), so a "Section N" # cross-reference written during drafting dangles at production — a deterministic # desk/galley flag the float-order check does not cover. SECTION_REF_RE = re.compile(r"\bSection\s+(\d+(?:\.\d+)?)\b") # A heading that carries a leading number: "## 3 Results", "### 3.4 Foo". NUMBERED_HEADING_RE = re.compile(r"^#{1,6}\s+\*{0,2}\s*(\d+(?:\.\d+)*)\b", re.MULTILINE) def _check_section_xref(body: str) -> list[dict]: """A `Section N` / `Section N.M` reference must resolve to a numbered heading. If the manuscript has no numbered headings at all (the common unnumbered-house- style case), every such reference dangles at typeset.""" refs: list[str] = [] for m in SECTION_REF_RE.finditer(body): pre = body[max(0, m.start() - 18):m.start()].lower() if "supplement" in pre or "appendix" in pre: continue # "Supplementary Section 3" points at the supplement, not a body section refs.append(m.group(1)) if not refs: return [] heading_nums = set(NUMBERED_HEADING_RE.findall(body)) dangling = [r for r in dict.fromkeys(refs) if not any(h == r or h.startswith(r + ".") for h in heading_nums)] if not dangling: return [] refs_str = ", ".join(f"Section {d}" for d in dangling) if not heading_nums: detail = (f"in-text cross-reference(s) to numbered sections ({refs_str}) but the manuscript " f"has no numbered headings — every 'Section N' reference dangles at typeset " f"(unnumbered-heading house style); name the heading (e.g. 'the Sensitivity analyses " f"section') instead of a number") else: detail = (f"{refs_str} has no matching numbered heading " f"(numbered headings present: {', '.join(sorted(heading_nums))}); " f"name the heading or correct the number") return [{ "verdict": "DANGLING_SECTION_XREF", "severity": "Major", "detail": detail, "where": refs_str[:160], }] def _body(text: str, include_back_matter: bool) -> str: if include_back_matter: return text m = BACK_MATTER_RE.search(text) return text[: m.start()] if m else text def _first_appearance(text: str): """Return {series_label: [numbers in order of first appearance]} for the body.""" seen: dict[str, dict[int, int]] = {} # label -> {number: first_position} for m in MENTION_RE.finditer(text): kind = "table" if m.group(1).lower().startswith("table") else "figure" for supp, num, off in _expand_numlist(m.group(2)): label = SERIES_LABEL[(kind, supp)] seen.setdefault(label, {}) # keep the EARLIEST position for each number pos = m.start() + off if num not in seen[label] or pos < seen[label][num]: seen[label][num] = pos order = {} for label, num_pos in seen.items(): # tie-break by number so a range's members read ascending at their shared position order[label] = [n for n, _ in sorted(num_pos.items(), key=lambda kv: (kv[1], kv[0]))] return order def _defined_floats(text: str) -> set[tuple[str, int]]: """{(series_label, number)} for every float DEFINED by a legend/caption line in the back matter (references excluded). Keyed the same way as a body citation.""" m = REF_HEADER_RE.search(text) legends = text[: m.start()] if m else text out: set[tuple[str, int]] = set() for dm in FLOAT_DEF_RE.finditer(legends): kind = "table" if dm.group(1).lower() == "table" else "figure" supp = dm.group(2)[0].lower() == "s" num = int(dm.group(2).lstrip("Ss")) out.add((SERIES_LABEL[(kind, supp)], num)) return out def _cited_floats(body: str) -> set[tuple[str, int]]: """{(series_label, number)} cited at least once in the narrative body.""" return {(label, n) for label, nums in _first_appearance(body).items() for n in nums} def _check_uncited_floats(clean: str) -> list[dict]: """A float DEFINED by a legend/caption but never cited in the narrative body is a display item the reader is never pointed to — editorial offices and reviewers reject uncited tables/figures/supplements (DIR-4084: three supplements shipped uncited).""" m = BACK_MATTER_RE.search(clean) if not m: return [] # no legends/back matter -> nothing is "defined" to check against body_only = clean[: m.start()] defined = _defined_floats(clean[m.start():]) if not defined: return [] cited = _cited_floats(body_only) claims = [] for label, num in sorted(defined - cited): prefix = "S" if label.startswith("Supplementary") else "" claims.append({ "verdict": "UNCITED_FLOAT", "severity": "Minor", "detail": (f"{label} {prefix}{num} has a legend/caption but is never cited in the " f"main text — cite it at least once or remove it (editorial offices and " f"reviewers flag display items the narrative never points to)"), "where": f"{label} {prefix}{num}", }) return claims def _reference_list_length(text: str) -> "int | None": """Highest number in the numbered reference/bibliography list, or None if there is no such list (e.g. a citeproc manuscript whose bibliography is generated at render).""" m = REF_HEADER_RE.search(text) if not m: return None nums = [int(im.group(1) or im.group(2)) for im in REF_LIST_ITEM_RE.finditer(text[m.end():])] return max(nums) if nums else None def _reference_series(body: str, full_text: str) -> list[dict]: """Bracketed in-text reference citations ("[12]", "[4–11]") must be cited in ascending order of first appearance, be contiguous from 1, and reach the reference-list length — the Vancouver numbering discipline a hand-typed "[N]" manuscript has no other gate for. Ranges are expanded first, so a number inside "[4–11]" is never a false gap; citeproc "[@key]" manuscripts match nothing here and stay silent.""" first: dict[int, int] = {} # reference number -> earliest position for m in REF_CITE_RE.finditer(body): for _supp, num, off in _expand_numlist(m.group(1)): pos = m.start() + off if num not in first or pos < first[num]: first[num] = pos if len(first) < REF_MIN_DISTINCT: return [] # too little signal to distinguish citations from stray brackets seq = [n for n, _ in sorted(first.items(), key=lambda kv: (kv[1], kv[0]))] pretty = ", ".join(str(n) for n in seq) claims: list[dict] = [] if seq != sorted(seq): inv = next((seq[i] for i in range(1, len(seq)) if seq[i] < seq[i - 1]), seq[-1]) claims.append({ "verdict": "REFERENCE_ORDER", "severity": "Major", "detail": (f"in-text references are cited out of numerical order — first-citation " f"sequence is [{pretty}]; Vancouver numbering must ascend by first " f"appearance (renumber the reference list and remap every [N], or correct " f"the marker; first inversion at [{inv}])"), "where": "reference series", }) return claims # order is wrong; gap/count numbers are not yet meaningful cited = set(first) hi = max(cited) length = _reference_list_length(full_text) if length is not None and hi > length: # A citation points past the end of the list — the dominant fault. The apparent # gap up to [hi] is an artifact of the dangling number, so report only this. claims.append({ "verdict": "REFERENCE_COUNT_MISMATCH", "severity": "Major", "detail": (f"the highest in-text reference cited is [{hi}] but the reference list has " f"only {length} entries — [{hi}] resolves to nothing (dangling citation)"), "where": "reference series", }) return claims missing = [n for n in range(1, hi + 1) if n not in cited] if missing: claims.append({ "verdict": "REFERENCE_GAP", "severity": "Minor", "detail": (f"in-text references cited [{pretty}] are not contiguous from 1 (never " f"cited: {', '.join(str(n) for n in missing)}) — a missing or mis-numbered " f"citation (ranges like [4–11] are expanded before this check)"), "where": "reference series", }) if length is not None and hi < length: claims.append({ "verdict": "REFERENCE_COUNT_MISMATCH", "severity": "Minor", "detail": (f"in-text references reach [{hi}] but the reference list has {length} " f"entries — {length - hi} trailing reference(s) are never cited (or the " f"list is mis-numbered)"), "where": "reference series", }) return claims def check(text: str, include_back_matter: bool) -> list[dict]: claims = [] # Strip any leading YAML front matter first: a `status:`/changelog block that narrates a # display-item renumber ("old Table 1 -> Supplementary Table S2") is not a body citation. clean = strip_frontmatter(text) body = _body(clean, include_back_matter) claims += _check_section_xref(body) claims += _check_uncited_floats(clean) claims += _reference_series(body, clean) order = _first_appearance(body) for label in ("Table", "Figure", "Supplementary Table", "Supplementary Figure"): seq = order.get(label) if not seq or len(seq) < 2: continue prefix = "S" if label.startswith("Supplementary") else "" pretty = ", ".join(f"{prefix}{n}" for n in seq) # ORDER: first-appearance sequence must be ascending. if seq != sorted(seq): # locate the first inversion for a precise message inv = next((seq[i] for i in range(1, len(seq)) if seq[i] < seq[i - 1]), seq[-1]) claims.append({ "verdict": "CITATION_ORDER", "severity": "Major", "detail": (f"{label}s are cited out of numerical order — first-citation " f"sequence is {pretty}; renumber by first-citation order or " f"rephrase (first inversion at {prefix}{inv})"), "where": f"{label} series", }) else: # GAP (Minor) only when order is otherwise fine, to avoid double-flagging. expected = list(range(1, max(seq) + 1)) if seq != expected: missing = [f"{prefix}{n}" for n in expected if n not in seq] claims.append({ "verdict": "CITATION_GAP", "severity": "Minor", "detail": (f"{label}s cited {pretty} are not contiguous from 1 " f"(not cited in body: {', '.join(missing)}) — check for a " f"missing or mis-numbered float"), "where": f"{label} series", }) return claims def analyze(manuscript: str, include_back_matter: bool) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8"), include_back_matter) n_major = sum(1 for c in claims if c["severity"] == "Major") n_minor = sum(1 for c in claims if c["severity"] == "Minor") return { "manuscript": str(p), "claims": claims, "summary": { "n_claims": len(claims), "n_major": n_major, "n_minor": n_minor, "verdict": "MAJOR_CANDIDATE" if n_major else "OK", }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | all float series cited in ascending order |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Float citation-order gate (technical-check pass).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--include-back-matter", action="store_true", help="also scan legends/references back-matter (off by default)") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript, args.include_back_matter) if not args.quiet: print("=" * 41) print(" Float Citation Order") print("=" * 41) print(render(result)) print() s = result["summary"] print(f"MAJOR candidate: {s['n_major']} out-of-order series." if s["n_major"] else "OK: every float series is cited in ascending order.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_citation_order", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_claim_artifact.py 22 KB
#!/usr/bin/env python3 """Claim-vs-artifact cross-check for self-review Phase 2.5f (narrowed 1st scope). The errors that survive a single-pass review are the ones where the manuscript text is internally consistent but disagrees with an external artifact — the pre-registration, the analysis code, the qc outputs. This gate checks the two highest-value, deterministic instances: 1. ESTIMAND PROVENANCE — does the manuscript's stated primary contrast match the pre-registered / protocol primary, and is there language admitting the primary was re-designated after results were known (outcome-dependent primary selection)? 2. E-VALUE — is a reported E-value arithmetically consistent with its adjacent effect estimate, and is it attached to the *primary* estimate rather than a secondary/exploratory one quoted as if it bounded the headline claim? 3. REGISTRATION CHRONOLOGY — a "prospectively registered" claim is falsifiable against the manuscript's own dates: if the registration date postdates search completion, the review was registered retrospectively. Manuscript-internal (needs no external prereg artifact); include supplement text in --manuscript to catch an overclaim that survives only in the supplement. Figure/flow-count reconciliation, Methods-promised-analysis completeness, and imputation-input integrity are separate subchecks (see /make-figures and /write-paper); the JSON schema below reserves their `type` values so they can be added without a breaking change. INPUTS --manuscript manuscript markdown/text. --prereg pre-registration / protocol / project.yaml text (for estimand provenance). Optional; without it only the post-hoc-reassignment language scan and the E-value check run. OUTPUT (--out path) {"claims": [{claim_id, type, prose_value, artifact_source, verdict, detail}], "summary": {...}} Major verdicts: PRIMARY_REASSIGNED (explicit post-hoc re-designation), EVALUE_ARITHMETIC. Advisory flags (review, not strict-fail): ESTIMAND_DRIFT (fuzzy prereg↔manuscript primary token overlap — confirm against the registration first), PRIMARY_DISCLOSURE_NOTE (honest manuscript-stage disclosure), EVALUE_NON_PRIMARY, EVALUE_UNVERIFIABLE, FLAG_NO_PREREG_PRIMARY. Stdlib-only (re / json / math / argparse). Exit codes: 0 clean (or report-only), 1 a Major verdict exists (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import json import math import re import sys from pathlib import Path from _frontmatter import strip_frontmatter EVALUE_TOL = 0.15 # relative tolerance for E-value recompute PRIMARY_RE = re.compile( r"[^.]*\bprimary\b[^.]*\b(outcome|endpoint|analysis|objective|contrast|comparison|model|estimand)[^.]*\.", re.I, ) # Explicit post-hoc re-designation of the primary (Major): the estimand was # changed / switched / re-designated, chosen post-hoc, or selected after results # were known. This is a genuine integrity issue. REASSIGN_RE = re.compile( r"\bprimary\b[^.]*\b(re-?designat|re-?assign|re-?defin|switch|chang)\w*|" r"\b(re-?designat|re-?assign)\w*[^.]*\bprimary\b|" r"\bpost[- ]?hoc\b[^.]*\bprimary\b|" r"\bprimary\b[^.]*\bafter\b[^.]*\bresult", re.I, ) # Honest disclosure of a manuscript-stage analytical decision (ADVISORY, not Major): # estimand-provenance guidance *recommends writing* exactly this. Flag only to confirm # it is disclosed coequally with the pre-specified analysis, not to allege a violation. # Only emitted when the explicit-reassignment pattern above does NOT also match. DISCLOSURE_RE = re.compile( r"\bmanuscript[- ]stage\b[^.]*\b(decision|primary|analy)|" r"\b(decision|analys[ie]s)\b[^.]*\bmanuscript[- ]stage\b", re.I, ) EFFECT_RE = re.compile(r"\b(s?HR|a?HR|a?OR|RR|hazard ratio|odds ratio|risk ratio)\b\D{0,8}(\d+\.\d+)", re.I) # The E-value figure follows a connective (was / of / = / :), so the non-greedy # scan does not grab the effect estimate's number from an adjacent "(HR 1.34)". EVALUE_RE = re.compile( r"E[- ]?value\b[^\n]{0,90}?\b(?:was|were|of|is|=|:|reached|equals?(?:\s+to)?)\s*\(?(\d+\.\d+)", re.I, ) NONPRIMARY_KW = ("secondary", "exploratory", "subgroup", "sensitivity", "supporting", "cause-specific", "cancer-specific", "post-hoc", "post hoc", "non-primary") # The manuscript asserts exactly ONE primary model/analysis (so a script annotating a # model as "co-primary" is a third-SSOT drift). SINGLE_PRIMARY = re.compile( r"\bsingle\s+primary\b|\ba\s+single\s+primary\b|\bone\s+primary\s+(?:model|analysis|endpoint|outcome)\b" r"|\bthe\s+primary\s+(?:model|analysis|endpoint|outcome)\b[^.]{0,70}?" r"(?:consistent\s+with\s+the\s+(?:registered|pre-?specified)|registered\s+analysis\s+plan)", re.I) # A model annotated "co-primary" in analysis code (a comment, string, or variable). CO_PRIMARY_CODE = re.compile(r"\bco[-\s]?primary\b", re.I) STOP = set("the a an of for in on to and or with by is was were are be been being this that " "between association associated estimated using model analysis primary outcome " "endpoint study patients group as at from".split()) def _tokens(s: str) -> set[str]: return {w for w in re.findall(r"[a-z0-9]+", s.lower()) if w not in STOP and len(w) > 2} def _jaccard(a: set[str], b: set[str]) -> float: if not a or not b: return 0.0 return len(a & b) / len(a | b) # Structured pre-registration fields — the authoritative anchor when the prereg is # a project.yaml / registration form with explicit keys. Comparing the manuscript # primary sentence against these VALUES (which carry the actual variable names) # beats comparing it against a free-text "Strategy for data synthesis" paragraph or # a `# PRIMARY — locked` YAML comment, which are lexically dissimilar even when # semantically identical. _STRUCT_PRIMARY_RE = re.compile( r"^\s*(primary_(?:exposure|outcome|estimand|endpoint|contrast|model|comparison|analysis))\s*:\s*(.+?)\s*$", re.I | re.M, ) def _structured_primary(prereg: str) -> list[str]: """Values of explicit primary_* keys in a structured prereg (YAML/form).""" out = [] for m in _STRUCT_PRIMARY_RE.finditer(prereg): v = m.group(2).strip().strip("\"'").strip() if v and not v.startswith("#"): out.append(v) return out def _strip_yaml_comments(text: str) -> str: """Drop whole-line YAML/# comments so PRIMARY_RE does not anchor on a `# PRIMARY — locked` annotation rather than a real primary-outcome statement.""" return "\n".join(ln for ln in text.splitlines() if not ln.lstrip().startswith("#")) def evalue_point(rr: float) -> float: """VanderWeele-Ding E-value for a point estimate (risk-ratio scale).""" g = rr if rr >= 1 else 1.0 / rr return g + math.sqrt(g * (g - 1.0)) def _flatten(t: str) -> str: return re.sub(r"\s*\n\s*", " ", t) def check_estimand(manuscript: str, prereg: str | None, prereg_raw: str | None = None) -> list[dict]: claims = [] man_primary = [m.group(0).strip() for m in PRIMARY_RE.finditer(manuscript)] # 1. Explicit post-hoc reassignment language (highest-confidence Major catch). m = REASSIGN_RE.search(manuscript) if m: claims.append({ "claim_id": "EST-reassign", "type": "estimand", "prose_value": re.sub(r"\s+", " ", manuscript[max(0, m.start() - 40):m.end() + 40]).strip(), "artifact_source": "manuscript (self-admission)", "verdict": "PRIMARY_REASSIGNED", "detail": "Language indicates the primary was re-designated after results were known; " "report the pre-specified and revised models coequally and disclose the change.", }) else: # 1b. Honest manuscript-stage disclosure → advisory note, NOT a Major. The # estimand-provenance guidance recommends writing this; do not penalise it. d = DISCLOSURE_RE.search(manuscript) if d: claims.append({ "claim_id": "EST-disclosure", "type": "estimand", "prose_value": re.sub(r"\s+", " ", manuscript[max(0, d.start() - 40):d.end() + 40]).strip(), "artifact_source": "manuscript (disclosed analytical decision)", "verdict": "PRIMARY_DISCLOSURE_NOTE", "detail": "Discloses a manuscript-stage analytical decision — the honest disclosure " "estimand-provenance guidance recommends, not a violation. Confirm the " "pre-specified and revised analyses are reported coequally. Advisory, not Major.", }) # 2. Manuscript primary vs prereg primary (token overlap). if prereg: # Prefer explicit structured primary_* fields (authoritative). Structured # extraction needs the RAW (line-based) prereg — the flattened form has no # line boundaries. Otherwise fall back to free-text primary sentences, with # YAML comment lines stripped so the anchor is a real statement, not a # `# PRIMARY — locked` annotation. raw = prereg_raw if prereg_raw is not None else prereg struct = _structured_primary(raw) pre_primary = struct or [m.group(0).strip() for m in PRIMARY_RE.finditer(_flatten(_strip_yaml_comments(raw)))] anchor = "structured prereg field" if struct else "prereg primary sentence" if man_primary and pre_primary: # tokens of every manuscript primary sentence vs the union of prereg # anchors — a structured field's variable names need only appear somewhere # in the manuscript's primary description to count as consistent. man_tok = set().union(*[_tokens(a) for a in man_primary]) pre_tok = set().union(*[_tokens(b) for b in pre_primary]) score = _jaccard(man_tok, pre_tok) best_a = max(man_primary, key=lambda a: _jaccard(_tokens(a), pre_tok)) # Structured-field match is more reliable, so a moderate overlap is a # soft "confirm", not a drift allegation. Free-text stays at the old 0.30. drift_cut, confirm_cut = (0.20, 0.40) if struct else (0.30, 0.30) verdict = ("ESTIMAND_DRIFT" if score < drift_cut else "ESTIMAND_CONFIRM" if score < confirm_cut else "OK") claims.append({ "claim_id": "EST-primary", "type": "estimand", "prose_value": re.sub(r"\s+", " ", best_a)[:160], "artifact_source": (re.sub(r"\s+", " ", " | ".join(pre_primary))[:160]), "verdict": verdict, "detail": f"manuscript↔{anchor} token overlap = {score:.2f} " f"(<{drift_cut:.2f} → drift candidate; {drift_cut:.2f}–{confirm_cut:.2f} → " f"confirm). ADVISORY: fuzzy token overlap is noisy; confirm against the " f"actual registration before treating as drift.", }) elif man_primary and not pre_primary: claims.append({ "claim_id": "EST-primary", "type": "estimand", "prose_value": re.sub(r"\s+", " ", man_primary[0])[:160], "artifact_source": "prereg (no primary statement found)", "verdict": "FLAG_NO_PREREG_PRIMARY", "detail": "No primary-outcome statement located in the prereg/protocol; confirm one exists.", }) return claims def check_evalue(manuscript: str) -> list[dict]: claims = [] for i, m in enumerate(EVALUE_RE.finditer(manuscript), 1): stated = float(m.group(1)) # sentence window around the E-value start = manuscript.rfind(".", 0, m.start()) + 1 end = manuscript.find(".", m.end()) sent = manuscript[start:(end if end != -1 else len(manuscript))] eff = EFFECT_RE.search(sent) nonprimary = any(kw in sent.lower() for kw in NONPRIMARY_KW) if not eff: claims.append({ "claim_id": f"EVAL-{i}", "type": "evalue", "prose_value": f"E-value {stated}", "artifact_source": "no adjacent effect estimate", "verdict": "EVALUE_UNVERIFIABLE", "detail": "No HR/OR/RR found in the same sentence; confirm the E-value is computed " "for the declared primary estimate.", }) continue rr = float(eff.group(2)) recomputed = evalue_point(rr) rel = abs(stated - recomputed) / recomputed if recomputed else 1.0 if rel > EVALUE_TOL: verdict = "EVALUE_ARITHMETIC" detail = (f"stated E-value {stated} but {eff.group(1)} {rr} recomputes to " f"{recomputed:.2f} (rel. diff {rel:.0%} > {EVALUE_TOL:.0%}); the stated value " "likely belongs to a different (e.g. non-primary) estimate.") elif nonprimary: verdict = "EVALUE_NON_PRIMARY" detail = (f"E-value {stated} matches {eff.group(1)} {rr} (recompute {recomputed:.2f}), " "but the sentence references a secondary/exploratory estimate; confirm the " "headline E-value bounds the PRIMARY contrast, not this one.") else: verdict = "OK" detail = f"E-value {stated} consistent with {eff.group(1)} {rr} (recompute {recomputed:.2f})." claims.append({ "claim_id": f"EVAL-{i}", "type": "evalue", "prose_value": f"E-value {stated} ({eff.group(1)} {rr})", "artifact_source": "recomputed (VanderWeele-Ding)", "verdict": verdict, "detail": detail, }) return claims # ESTIMAND_DRIFT (fuzzy prereg↔manuscript token overlap) and PRIMARY_DISCLOSURE_NOTE # (honest manuscript-stage disclosure) are ADVISORY, not Major: the docs require # manual confirmation against the registration before either is acted on, and a P0 # that needs hand-confirmation is not a P0. Only explicit re-designation and a # non-recomputing E-value are Major. def check_code_labels(manuscript: str, scripts_dir: str | None) -> list[dict]: """Reconcile the manuscript's declared primary against analysis-script labels. Fires only the specific conflict: the manuscript asserts a SINGLE primary while an analysis script annotates a model as 'co-primary' — the code label is a third SSOT that drifts across revisions. Advisory (code comments can lag).""" claims: list[dict] = [] if not scripts_dir: return claims d = Path(scripts_dir) if not d.exists() or not SINGLE_PRIMARY.search(manuscript): return claims for p in sorted(d.rglob("*")): if p.suffix.lower() not in (".r", ".py"): continue try: txt = p.read_text(encoding="utf-8", errors="replace") except OSError: continue m = CO_PRIMARY_CODE.search(txt) if not m: continue ln = txt[:m.start()].count("\n") + 1 snippet = txt.splitlines()[ln - 1].strip()[:80] if ln - 1 < len(txt.splitlines()) else "" claims.append({ "claim_id": "EST-code-label", "type": "estimand", "prose_value": "manuscript asserts a single primary model/analysis", "artifact_source": f"{p.name}:{ln} labels a model 'co-primary'", "verdict": "PRIMARY_LABEL_CODE_DRIFT", "detail": (f"the manuscript declares a SINGLE primary while an analysis script " f"annotates a model as co-primary ({p.name}:{ln}: '{snippet}'); reconcile " f"the code's primary/co-primary label with the declared estimand — code " f"labels are a third SSOT that can drift across revisions. ADVISORY."), }) break # one is enough to prompt a reconcile return claims # --- Check: REGISTRATION_CHRONOLOGY ---------------------------------------- # A "prospectively registered" claim is falsifiable against the manuscript's own # dates: if the registration date postdates search completion, the review was # registered *retrospectively*, and a reviewer flags the overclaim on sight. This # is manuscript-internal (no external prereg artifact needed) -- both dates and the # claim live in the text (body or supplement). Fires only when a prospective claim # co-occurs with a registry AND both dates parse AND registration > search-end. _MONTHS = ("January|February|March|April|May|June|July|August|September|October|" "November|December") _MONTH_NUM = {m.lower(): i + 1 for i, m in enumerate(_MONTHS.split("|"))} _DATE = (rf"\d{{4}}-\d{{2}}-\d{{2}}|\d{{1,2}}\s+(?:{_MONTHS})\s+\d{{4}}|" rf"(?:{_MONTHS})\s+\d{{1,2}},?\s+\d{{4}}") _PROSPECTIVE_RE = re.compile(r"\bprospectiv\w*\b", re.I) _REGISTRY_RE = re.compile(r"\b(?:PROSPERO|CRD42\d{9}|OSF|ClinicalTrials|NCT\d{6,})\b", re.I) _REG_DATE_RE = re.compile( rf"(?:registered|registration|PROSPERO|CRD42\d{{9}}|OSF)[^.]*?\bon\b\s+({_DATE})" rf"|(?:registered|registration)[^.]{{0,60}}?({_DATE})", re.I) _SEARCH_END_RE = re.compile( rf"search\w*[^.]*?\b(?:to|through|up to|until|inception to)\b\s+({_DATE})" rf"|search\w*[^.]{{0,80}}?({_DATE})", re.I) def _parse_date(s: str) -> tuple[int, int, int] | None: s = s.strip() m = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", s) if m: return (int(m.group(1)), int(m.group(2)), int(m.group(3))) m = re.fullmatch(rf"(\d{{1,2}})\s+({_MONTHS})\s+(\d{{4}})", s, re.I) if m: return (int(m.group(3)), _MONTH_NUM[m.group(2).lower()], int(m.group(1))) m = re.fullmatch(rf"({_MONTHS})\s+(\d{{1,2}}),?\s+(\d{{4}})", s, re.I) if m: return (int(m.group(3)), _MONTH_NUM[m.group(1).lower()], int(m.group(2))) return None def _first_group(m) -> str | None: return next((g for g in m.groups() if g), None) if m else None def check_registration_chronology(manuscript: str) -> list[dict]: claims: list[dict] = [] if not (_PROSPECTIVE_RE.search(manuscript) and _REGISTRY_RE.search(manuscript)): return claims reg_s = _first_group(_REG_DATE_RE.search(manuscript)) srch_s = _first_group(_SEARCH_END_RE.search(manuscript)) if not reg_s or not srch_s: return claims reg, srch = _parse_date(reg_s), _parse_date(srch_s) if not reg or not srch or reg <= srch: return claims claims.append({ "claim_id": "registration-chronology", "type": "registration", "prose_value": f"registered {reg_s.strip()}", "artifact_source": f"search completed {srch_s.strip()} (manuscript)", "verdict": "REGISTRATION_CHRONOLOGY", "detail": (f"a prospective-registration claim, but registration ({reg_s.strip()}) postdates " f"search completion ({srch_s.strip()}) — the review was registered " f"retrospectively; reframe as \"registered with\" or correct the chronology"), }) return claims MAJOR = {"PRIMARY_REASSIGNED", "EVALUE_ARITHMETIC", "REGISTRATION_CHRONOLOGY"} def main() -> int: ap = argparse.ArgumentParser(description="Claim-vs-artifact cross-check (estimand + E-value).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--prereg", help="pre-registration / protocol / project.yaml text") ap.add_argument("--scripts", help="analysis-scripts directory (reconcile code primary/co-primary labels)") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major verdict") args = ap.parse_args() mp = Path(args.manuscript) if not mp.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {args.manuscript}\n") return 2 # Collapse hard-wrap newlines to spaces so a sentence-level claim is not split # across lines (the decimal points inside HR/E-value figures must survive). def _unwrap(t: str) -> str: return re.sub(r"\s*\n\s*", " ", t) # Strip the YAML front matter before any estimand pattern runs. A project that # honestly logs "the primary endpoint was changed ..." in a `changelog:` block was # read as a body self-admission and given PRIMARY_REASSIGNED — a Major that fires # harder the more openly a project records its own history, which is exactly the # Major most likely to get waved through. manuscript = _unwrap(strip_frontmatter(mp.read_text(encoding="utf-8"))) prereg = None prereg_raw = None if args.prereg: pp = Path(args.prereg) if pp.is_file(): prereg_raw = pp.read_text(encoding="utf-8") prereg = _unwrap(prereg_raw) else: sys.stderr.write(f"WARN: prereg not found: {args.prereg} (estimand provenance limited)\n") claims = (check_estimand(manuscript, prereg, prereg_raw) + check_evalue(manuscript) + check_code_labels(manuscript, args.scripts) + check_registration_chronology(manuscript)) n_major = sum(1 for c in claims if c["verdict"] in MAJOR) n_flag = sum(1 for c in claims if c["verdict"] not in MAJOR and c["verdict"] != "OK") result = { "manuscript": str(mp), "prereg": args.prereg, "claims": claims, "summary": {"n_claims": len(claims), "n_major": n_major, "n_flag": n_flag, "verdict": "MAJOR_CANDIDATE" if n_major else ("REVIEW" if n_flag else "OK")}, } print("=" * 41) print(" Claim-vs-Artifact Cross-Check (Phase 2.5f)") print("=" * 41) for c in claims: mark = "✗" if c["verdict"] in MAJOR else ("△" if c["verdict"] != "OK" else "✓") print(f"{mark} [{c['claim_id']}] {c['verdict']}") print(f" {c['detail']}") print(f"\n{n_major} Major candidate(s), {n_flag} flag(s).") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_claim_artifact", **result}, indent=2), encoding="utf-8") print(f"wrote {args.out}") return 1 if (args.strict and n_major) else 0 if __name__ == "__main__": sys.exit(main()) -
check_classical_style.py 16.9 KB
#!/usr/bin/env python3 """Classical-style body lint (self-review §J / write-paper Phase 7.1). A senior meta-analysis reviewer reads several surface signals as "AI wrote this" or as a policy violation. They are all deterministic greps, so they belong in a gate rather than a prose checklist (manuscript-style-classical.md §5/§6/§7/§8): SECTION_SYMBOL (Major) the § symbol anywhere in the body — the canonical AI tell; also catches "see Methods §2" self-references. INBODY_AI_DISCLOSURE an AI/LLM-use disclosure paragraph in the body. WHERE IT BELONGS IS A JOURNAL FACT, and the journals disagree: npj Digital Medicine says "document use in Methods", Investigative Radiology says cover letter + Acknowledgments, Diabetes & Metabolism Journal says title page. So the placement is read from the target's profile (--profile) or given inline (--disclosure-placement): body-legitimate target (methods / acknowledgements) -> silent title-page / cover-letter-only target -> Major NO target recorded -> Minor, naming the ambiguity rather than asserting a placement it cannot know. ELIGIBILITY_PROSE (Minor) eligibility/inclusion criteria written as a prose sentence rather than a numbered list. DECIMAL_INCONSISTENCY (Minor) OR/HR/RR reported with mixed decimal places (some 2 dp, some 3 dp) in the same manuscript. PERCENT_DECIMALS (Minor) percentage(s) reported to >1 decimal place ("35.14%"). Several journals (e.g. KJR) require one-decimal percentages at technical check; journal-dependent, so report-only. EM_DASH_OVERUSE (Minor) more than 25 *prose* em-dashes — a generation tell. Structural dashes (markdown table cells incl. "—" N/A placeholders and panel-label captions, ORCID separators, author/affiliation lines) are excluded and reported separately, so a cohort manuscript with large baseline tables is not pushed into destructive edits on correct table dashes. INPUTS --manuscript manuscript markdown/text (required). --em-dash-max em-dash threshold (default 25). OUTPUT A reconciliation table (stdout) and, with --out, a JSON artifact: {manuscript, claims[{verdict, severity, detail, where}], summary} Exit 1 (with --strict) when any Major-severity claim exists (§ symbol or in-body AI disclosure). Stdlib-only (json / re / argparse / pathlib). Exit codes: 0 clean (or report-only), 1 Major claim(s) found (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # The § AI-tell is a SECTION CROSS-REFERENCE ("Methods §2", "(see §3.1)", "§L4"), # not the dagger-family footnote markers (†, ‡, §, ¶) that legitimately mark # author/affiliation footnotes and co-senior-author lines (e.g. "§ Dr. Hong and # Dr. Kim are co-senior authors", a superscript "^§"). Count only § that is part # of a section reference: § followed by a section id (digit, or up to 3 letters # then a digit — §3, §L4, §S1, §2.1), or a section noun immediately before §. SECTION_XREF = re.compile( r"§\s*[A-Za-z]{0,3}\d" r"|(?:see|in|per|section|sections|methods?|results?|discussion|introduction|" r"appendix|appendices|supplement(?:ary|al)?|table|figure)\s+§", re.IGNORECASE) INBODY_AI_DISCLOSURE = re.compile( r"generative ai was not used|artificial intelligence disclosure|" r"during the preparation of (?:this|the) (?:manuscript|work|study)[^.]{0,120}?" r"(?:used|use[d]?|employed)[^.]{0,60}?(?:ai|gpt|chatgpt|claude|copilot|gemini|language model)", re.IGNORECASE) # A paper whose SUBJECT is AI-use disclosure (a reporting-methods / QC paper about # disclosure statements) contains such phrasing as an object of study, not as its # own disclosure. When the match sits next to disclosure-as-subject framing, do not # fire INBODY_AI_DISCLOSURE. Kept tight so a genuine in-body disclosure still fires. AI_DISCLOSURE_SUBJECT = re.compile( r"ai[-\s]?disclosure|disclosure (?:statement|practice|policy|policies|requirement|item)|" r"reporting of ai (?:use|tools|assistance)|MI[-\s]?CLEAR|" r"(?:this|the present) (?:paper|study|work|review|analysis)\b[^.]{0,60}?" r"(?:disclosure|ai use|ai tools|ai assistance|reporting)", re.IGNORECASE) ELIGIBILITY_LEAD = re.compile( r"(?:studies|articles|records|participants|patients|trials)\s+were\s+eligible\s+if|" r"eligibility criteria were|inclusion criteria (?:were|included)|" r"(?:studies|articles) were included if", re.IGNORECASE) NUMBERED_MARKER = re.compile(r"\(\s*1\s*\)|\(\s*i\s*\)|(?:^|\s)1\.\s") EFFECT_DECIMAL = re.compile( r"(?:\b(?:a?OR|a?HR|RR|sHR)\b|\bodds ratio\b|\bhazard ratio\b|\brisk ratio\b)" r"\s*(?:of|was|were|=|:|,)?\s*(\d+\.(\d+))", re.IGNORECASE) # A percentage reported to >1 decimal place ("35.14%"). Several journals (e.g. KJR) # require one-decimal percentages at technical check ("35.1%"). Journal-dependent. PERCENT_DECIMAL = re.compile(r"\b\d{1,3}\.\d{2,}\s*%") # Where an AI-use disclosure belongs is set by the target journal, not by house style. A # profile states it in prose already ("document use in Methods section", "declared on title # page", "disclosed in cover letter and Acknowledgments"); this reads that line if the profile # carries the explicit machine-readable form, and otherwise falls back to the prose. DISCLOSURE_PLACEMENT_LINE = re.compile( r"^\s*[-*]?\s*\*{0,2}AI[-\s]use disclosure placement\*{0,2}\s*:\s*(.+)$", re.M | re.I) # Tokens meaning "the body is where it goes" — if any appears, an in-body paragraph is correct. # CONVENTION for whoever adds a placement line to a profile: if the journal puts the disclosure # in the body but names a section none of these words cover — JACC: Advances asks for a # "Declaration of generative AI …" section immediately above the References — write "(body)" # in the placement string. Without it the location reads as non-body and the author is told to # move a paragraph the journal put exactly where it is, which is the failure this whole gate # exists to stop. # "acknowledg" is a STEM — a trailing \b breaks on "Acknowledgments"/"Acknowledgements", # which is exactly how the placement is written in the profiles that use it. BODY_PLACEMENT = re.compile(r"\b(?:methods?|acknowledg\w*|body|main text)\b", re.I) def disclosure_placement(profile_text: str | None, inline: str | None) -> str | None: """The declared placement string, or None when nothing was recorded.""" if inline: return inline if not profile_text: return None m = DISCLOSURE_PLACEMENT_LINE.search(profile_text) return m.group(1).strip() if m else None def check(text: str, em_dash_max: int, placement: str | None = None) -> list[dict]: claims = [] # SECTION_SYMBOL (Major) — only § used as a section cross-reference, not the # dagger-family author/affiliation footnote markers. xrefs = list(SECTION_XREF.finditer(text)) if xrefs: n = len(xrefs) first = xrefs[0].start() claims.append({ "verdict": "SECTION_SYMBOL", "severity": "Major", "detail": f"a § section cross-reference appears {n} time(s) — a senior-reviewer " f"AI tell; replace with the section name (manuscript-style-classical §6)", "where": text[max(0, first - 30):first + 20].replace("\n", " ").strip()[:120], }) # INBODY_AI_DISCLOSURE (Major) — unless the paper's SUBJECT is AI disclosure # (a reporting-methods paper naming the pattern, not committing it). m = INBODY_AI_DISCLOSURE.search(text) if m and AI_DISCLOSURE_SUBJECT.search(text[max(0, m.start() - 200):m.end() + 200]): m = None if m: # The target decides. Firing "this belongs on the title page" at a journal that # requires it in Methods tells the author to move a paragraph the journal wants # exactly where it is — and this verdict fired that way across five real projects, # none of which could be scored real or spurious without knowing the target. if placement and BODY_PLACEMENT.search(placement): pass # the body IS where this journal puts it elif placement: claims.append({ "verdict": "INBODY_AI_DISCLOSURE", "severity": "Major", "detail": (f"an AI/LLM-use disclosure paragraph is in the body, but the target " f"places it in: {placement} (manuscript-style-classical §7)"), "where": m.group(0)[:120], }) else: claims.append({ "verdict": "INBODY_AI_DISCLOSURE", "severity": "Minor", "detail": ("an AI/LLM-use disclosure paragraph is in the body and no target " "journal is recorded — journals disagree (npj Digital Medicine asks " "for Methods, Diabetes & Metabolism Journal for the title page, " "Investigative Radiology for the cover letter and Acknowledgments), " "so pass --profile or --disclosure-placement to settle it"), "where": m.group(0)[:120], }) # ELIGIBILITY_PROSE (Minor) em = ELIGIBILITY_LEAD.search(text) if em and not NUMBERED_MARKER.search(text[em.start():em.start() + 320]): claims.append({ "verdict": "ELIGIBILITY_PROSE", "severity": "Minor", "detail": "eligibility/inclusion criteria are written as prose; senior reviewers " "expect a numbered list (1)…(2)…(3) (manuscript-style-classical §5)", "where": em.group(0)[:120], }) # DECIMAL_INCONSISTENCY (Minor) dps = {len(mm.group(2)) for mm in EFFECT_DECIMAL.finditer(text)} if len(dps) > 1: claims.append({ "verdict": "DECIMAL_INCONSISTENCY", "severity": "Minor", "detail": f"OR/HR/RR reported with mixed decimal places ({sorted(dps)}); " f"standardize (OR/HR to 2 dp)", "where": "effect-size decimals", }) # PERCENT_DECIMALS (Minor) — percentages to >1 decimal place. Several journals # (e.g. KJR) require one-decimal percentages at technical check ("35.1%" not # "35.14%"); journal-dependent, so report-only (Minor, does not fail --strict). pcts = [mm.group(0).strip() for mm in PERCENT_DECIMAL.finditer(text)] if pcts: ex = ", ".join(dict.fromkeys(pcts)) # dedup, keep order claims.append({ "verdict": "PERCENT_DECIMALS", "severity": "Minor", "detail": f"{len(pcts)} percentage(s) reported to >1 decimal place " f"(e.g. {ex[:80]}); several journals require one decimal at " f"technical check (35.1% not 35.14%)", "where": "percentage decimals", }) # EM_DASH_OVERUSE (Minor) — count PROSE em-dashes only. Structural dashes are # legitimate, not a generation tell: markdown table cells (incl. "—" = N/A # placeholders and "(A) — label" panel captions), ORCID separators, and # author/affiliation lines ("Name, MD — Department of …"). Counting them forces # destructive edits on correct table dashes in cohort manuscripts with large # Table 1 / Table 3. prose_dash, structural_dash = _count_em_dashes(text) if prose_dash > em_dash_max: claims.append({ "verdict": "EM_DASH_OVERUSE", "severity": "Minor", "detail": f"{prose_dash} prose em-dashes (> {em_dash_max}); a generation tell — " f"replace some with commas/colons or split sentences " f"({structural_dash} structural em-dashes in tables/ORCID/affiliation " f"lines were excluded)", "where": f"{prose_dash} prose em-dashes ({structural_dash} structural excluded)", }) return claims _ORCID_RE = re.compile(r"\d{4}-\d{4}-\d{4}-\d{3}[\dXx]") # A credential token immediately before the dash, or an affiliation noun right # after it — the "Name, MD — Department of …" author/affiliation signature. _AFFIL_DASH_RE = re.compile( r"\b(?:MD|PhD|MSc|MPH|MBBS|DO|DrPH|RN)\b[^—|]{0,20}—" r"|—[^—|]{0,20}\b(?:Department|Division|Institute|Faculty|College|University|Hospital|Center|Centre)\b", re.IGNORECASE) # Figure/table/panel caption lines ("(A) — obesity stratum", "Figure 1. …", # "*(A) S1 — …*") — structural, not prose. _CAPTION_RE = re.compile( r"^\s*\*{0,2}(?:\(?[A-Za-z]\)|(?:Figure|Fig\.?|Table|Panel|Supplementary)\b)", re.IGNORECASE) def _count_em_dashes(text: str) -> tuple[int, int]: """Return (prose, structural) em-dash counts. Structural = table rows, ORCID / affiliation / author lines, and standalone-cell dashes; prose = everything else (the genuine generation-tell surface).""" prose = structural = 0 for line in text.splitlines(): n = line.count("—") if not n: continue s = line.strip() is_table = "|" in line is_orcid = "orcid" in line.lower() or bool(_ORCID_RE.search(line)) is_standalone = bool(re.fullmatch(r"[-–—\s]*", s)) is_affil = bool(_AFFIL_DASH_RE.search(line)) is_caption = bool(_CAPTION_RE.match(line)) if is_table or is_orcid or is_standalone or is_affil or is_caption: structural += n else: prose += n return prose, structural def analyze(manuscript: str, em_dash_max: int, placement: str | None = None) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8"), em_dash_max, placement) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "manuscript": str(p), "disclosure_placement": placement, "claims": claims, "summary": { "n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "verdict": "MAJOR_CANDIDATE" if n_major else ("FLAG" if claims else "OK"), }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | classical-style body conventions satisfied |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Classical-style body lint (§J).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--em-dash-max", type=int, default=25, help="em-dash threshold (default 25)") ap.add_argument("--profile", help="target journal profile .md (for AI-disclosure placement)") ap.add_argument("--disclosure-placement", help="where the target puts an AI-use disclosure, e.g. 'Methods' or " "'title page' — overrides --profile") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() profile_text = None if args.profile: pp = Path(args.profile) if not pp.is_file(): sys.stderr.write(f"ERROR: profile not found: {args.profile}\n") return 2 profile_text = pp.read_text(encoding="utf-8", errors="replace") placement = disclosure_placement(profile_text, args.disclosure_placement) result = analyze(args.manuscript, args.em_dash_max, placement) if not args.quiet: print("=" * 41) print(" Classical-Style Body Lint (§J)") print("=" * 41) print(render(result)) print() s = result["summary"] if s["n_major"]: print(f"MAJOR candidate: {s['n_major']} policy/AI-tell violation(s).") elif s["n_flag"]: print(f"FLAG: {s['n_flag']} style inconsistency(ies).") else: print("OK: classical-style body conventions satisfied.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_classical_style", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_cohort_arithmetic.py 38.4 KB
#!/usr/bin/env python3 """Cohort arithmetic gate for observational studies (self-review Phase 2.5 / 2.5b). Phase 2.5 covers n/N percentage arithmetic and Phase 2.5b reconciles SR/MA study counts from ID sets. Neither covers the cohort-specific arithmetic that a reviewer recomputes by hand and that survives a single prose pass because every section echoes the same wrong number: 1. RATE_BACKCALC a reported incidence rate must invert to its own numerator/denominator: rate ~= events / person-years * scale. An impossible rate (more implied events than person-time, or a rate that does not recompute from the stated events and PY) is the failure mode where "X per 1,000 person-years" was transcribed or computed wrong. 2. CASCADE_SUM a STROBE exclusion cascade must balance: start - sum(excluded) == final analytic N; and a complete-case statement must balance: total - missing == complete. A footnote that says 3,667 where 4,252 - 583 = 3,669 is this finding. 3. PARTITION_OVERLAP an ordinal tier/stratum partition that is presented as mutually exclusive must satisfy sum(stratum N) == unique total and sum(stratum events) == total events. A tier split whose denominators sum above the unique cohort double-counts subjects; a table where every stratum n equals the grand total is a stratum-total mis-entry. This also fires on an in-text PROSE enumeration presented as an exhaustive split of a stated total (>=3 "count (pct%)" categories with partition-cue language, e.g. "of the 289 cases, 37 (12.8%) ... 185 (64.0%) ... 103 (35.6%) ...") when the counts do not sum to the total or the percentages do not sum to ~100% -- the sign that a non-exclusive component was mixed among the mutually exclusive categories. The partition-cue gate keeps it off legitimate overlapping-attribute prose (comorbidity prevalence). 4. ANALYSIS_UNIT_ when --data carries a subject ID and records > unique UNDISCLOSED subjects (health-screening / EMR / registry repeat attendees), observations are non-independent -> anti- conservative CIs. Fires only when the manuscript discloses neither the analysis unit nor a one-record-per-subject sensitivity. Pass --id-col, or it auto-detects a common ID column name (with a cardinality guard). The script is deterministic but conservative: it fires only when it can extract a complete equation (all operands present in one window, or a Total row in a parsed table, or recomputable columns in --data). Read the reconciliation table; a missed case is safer than a false Major on a widely-used gate. INPUTS --manuscript manuscript markdown/text (required). Prose equations + GFM tables are parsed from it. --data optional CSV for exact recompute. Auto-detected columns: events / event / n_events / cases person_years / py / person-years / personyears rate / ir / incidence_rate (RATE_BACKCALC) stratum / tier / group + n / count + (events) (PARTITION_OVERLAP) A row whose label normalizes to total/overall is the marginal. OUTPUT A reconciliation table (stdout) and, with --out, a JSON artifact: {manuscript, data, claims[{verdict, severity, detail, where}], summary} verdicts RATE_BACKCALC / CASCADE_SUM / PARTITION_OVERLAP are Major candidates; FOLLOWUP_VS_CRITERION, SUBGROUP_DUPLICATE_CI (the same subgroup rendered twice in one table with divergent confidence intervals) and NESTED_MODEL_NO_BASELINE (nested discrimination models sharing a covariate set with no base-model row / ΔC) are Minor. Exit 1 (with --strict) when any Major-severity claim exists. Stdlib-only (csv / json / re / argparse). Exit codes: 0 clean (or report-only), 1 Major claim(s) found (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import csv import json import re import sys from pathlib import Path # Relative tolerance for a rate to "recompute" from events/PY (covers rounding of # the displayed rate and of the inputs). 5% is generous; a real impossible rate # misses it by far more. RATE_REL_TOL = 0.05 SCALE_WORDS = {"100": 100, "1,000": 1000, "1000": 1000, "10,000": 10000, "10000": 10000, "100,000": 100000, "100000": 100000} TOTAL_LABELS = ("total", "overall", "all", "whole cohort", "entire cohort", "full cohort", "all participants", "all subjects", "all patients") # --- number / column helpers ---------------------------------------------- def _num(s: str): """Parse '1,234' / '1234' / '3.5' -> float, else None.""" if s is None: return None m = re.search(r"-?\d[\d,]*\.?\d*", s.strip()) if not m: return None try: return float(m.group(0).replace(",", "")) except ValueError: return None def _ints_in(text: str) -> list[int]: """All comma-grouped integers in a span, as ints (drops decimals/percent).""" out = [] for m in re.finditer(r"\d[\d,]*(?!\.\d)", text): try: out.append(int(m.group(0).replace(",", ""))) except ValueError: pass return out def _norm(s: str) -> str: s = s.lower() s = re.sub(r"\(.*?\)", " ", s) s = re.sub(r"[^a-z0-9 ]+", " ", s) return re.sub(r"\s+", " ", s).strip() def _is_total_label(label: str) -> bool: n = _norm(label) return any(n == t or n.startswith(t + " ") or n == t.replace(" ", "") for t in TOTAL_LABELS) # A hint shorter than this is matched only exactly — never as a substring. MIN_SUBSTRING_HINT = 3 def _pick(header: list[str], hints: tuple[str, ...]): norm = [_norm(h) for h in header] for hint in hints: h = _norm(hint) for i, col in enumerate(norm): if col == h and h: return i # Substring fallback, with the guard the exact pass does not need. A hint of one or two # characters has no business matching a longer word: the hint "n" found "Normal" in the # header of an exposure-stratified Table 1, so every characteristic row's Normal-column # value was summed as if it were a stratum size (8,299 "strata" against a "total" of 194). # The same one-character-substring bug was fixed once in check_confounding_completeness # and never here. Longer hints still match, but only on a word boundary. for hint in hints: h = _norm(hint) if len(h) < MIN_SUBSTRING_HINT: continue for i, col in enumerate(norm): if col and re.search(rf"(?<![a-z0-9]){re.escape(h)}(?![a-z0-9])", col): return i return None # --- Check 1: RATE_BACKCALC ------------------------------------------------ RATE_LINE_RE = re.compile( r"([0-9][0-9,]*\.?[0-9]*)\s*(?:per|/)\s*([0-9][0-9,]*)\s*person[-\s]?years?", re.I) # The numerator must be a count glued to a count noun (events/cases), optionally # "N incident cases/events". Two guards against grabbing the wrong integer: # (a) lookbehind (?<![A-Za-z0-9.]) so a tier label's digit ("T1") or a decimal's # fractional part ("0.97") is never captured as the numerator; # (b) drop the bare "incident" alternative, which matched the word in "incident # rate" and bound the nearest stray small integer (the false-positive source). # (c) a HYPHEN or slash before the digit means it belongs to a label, not to the count: # "882 KSAR S4-1 events occurred" bound the 1 of "S4-1" and reported the rate as # irreconcilable with 1 event. When the count cannot be bound the check must say # nothing — an unbindable numerator is not evidence of an arithmetic error. EVENTS_NEAR_RE = re.compile( r"(?<![A-Za-z0-9.\-\u2013\u2014/])([0-9][0-9,]*)\s*(?:incident\s+)?(?:events?|cases?)\b", re.I) # Person-time is frequently reported to one decimal ("35,581.3 person-years"). Without the # decimal branch the integer part failed to reach the noun and the FRACTIONAL DIGIT matched # instead — a cohort of "3 person-years" — so the same lookbehind applies here. PY_NEAR_RE = re.compile( r"(?<![A-Za-z0-9.\-\u2013\u2014/])([0-9][0-9,]*(?:\.[0-9]+)?)\s*(?:person[-\s]?years?|py\b)", re.I) def _sentences(text: str) -> list[str]: """Split into sentences after flattening intra-paragraph line wraps, so a sentence that the source wrapped across lines is still one unit. Markdown table rows (pipe-delimited) are kept as their own units.""" units = [] for para in re.split(r"\n[ \t]*\n", text): if para.lstrip().startswith("|"): units.extend(para.splitlines()) continue flat = re.sub(r"\s*\n\s*", " ", para).strip() units.extend(re.split(r"(?<=[.;])\s+", flat)) return [u for u in units if u.strip()] def check_rate_text(text: str) -> list[dict]: """Fire when a single sentence carries a rate, an event count, and a person-time, and the rate does not recompute from them.""" claims = [] for line in _sentences(text): rm = RATE_LINE_RE.search(line) if not rm: continue rate = _num(rm.group(1)) scale = _num(rm.group(2)) if rate is None or not scale: continue em = EVENTS_NEAR_RE.search(line) # the PY in "rate per <scale> person-years" is the scale, not the cohort PY; # the cohort PY is a *different*, larger person-time figure in the same span. py_candidates = [float(g.replace(",", "")) for g in PY_NEAR_RE.findall(line)] py_candidates = [p for p in py_candidates if abs(p - scale) > 1e-9] if not em or not py_candidates: continue events = _num(em.group(1)) py = max(py_candidates) # cohort person-time is the larger figure if events is None or py <= 0: continue expected = events / py * scale if expected <= 0: continue if abs(rate - expected) / max(expected, 1e-9) > RATE_REL_TOL: claims.append({ "verdict": "RATE_BACKCALC", "severity": "Major", "detail": (f"reported rate {rate:g} per {int(scale):,} PY does not recompute " f"from {int(events):,} events / {py:,.6g} PY " f"(= {expected:.4g} per {int(scale):,})"), "where": line.strip()[:160], }) return claims def check_rate_csv(rows: list[dict]) -> list[dict]: claims = [] if not rows: return claims header = list(rows[0].keys()) ei = _pick(header, ("events", "event", "n_events", "cases", "n_cases")) pi = _pick(header, ("person_years", "person-years", "personyears", "py", "person years")) ri = _pick(header, ("rate", "ir", "incidence_rate", "incidence rate")) si = _pick(header, ("scale", "per", "rate_per")) if ei is None or pi is None or ri is None: return claims cols = list(header) for idx, row in enumerate(rows): events = _num(row[cols[ei]]) py = _num(row[cols[pi]]) rate = _num(row[cols[ri]]) scale = _num(row[cols[si]]) if si is not None else 1000.0 if None in (events, py, rate) or not scale or py <= 0: continue expected = events / py * scale if expected <= 0: continue if abs(rate - expected) / max(expected, 1e-9) > RATE_REL_TOL: claims.append({ "verdict": "RATE_BACKCALC", "severity": "Major", "detail": (f"row {idx + 1}: rate {rate:g} per {int(scale):,} PY != " f"{events:g}/{py:g}*{int(scale):,} = {expected:.4g}"), "where": f"--data row {idx + 1}", }) return claims # --- Check 2: CASCADE_SUM -------------------------------------------------- COMPLETE_CASE_RE = re.compile( r"\b(?:of|among|from)\s+([0-9][0-9,]{2,})\b.{0,80}?" r"\b([0-9][0-9,]{1,})\s*(?:had|were|with)?\s*" r"(?:missing|incomplete|excluded|without)\b.{0,80}?" r"\b(?:leaving|remaining|resulting in|yielded?|left|final(?:ly)?|analyti\w+|included)\D{0,20}" r"([0-9][0-9,]{2,})\b", re.I | re.S) START_RE = re.compile( r"\b([0-9][0-9,]{2,})\b\s*(?:participants?|subjects?|patients?|individuals?|records?)?\s*" r"(?:were\s+)?(?:initially\s+)?(?:screened|assessed|identified|enrolled|eligible|" r"recruited|included for screening)", re.I) EXCLUDED_RE = re.compile( r"(?:excluded|removed)\D{0,20}?([0-9][0-9,]{1,})\b" r"|\b([0-9][0-9,]{1,})\s*(?:were\s+)?(?:excluded|removed)", re.I) FINAL_RE = re.compile( r"\b(?:final|analytic|included|remaining)\D{0,30}?([0-9][0-9,]{2,})\b" r"|\b([0-9][0-9,]{2,})\b\D{0,20}(?:were included|comprised the (?:final|analytic)|" r"in the (?:final|analytic))", re.I) def check_cascade_text(text: str) -> list[dict]: claims = [] # (a) complete-case: total - missing == complete for m in COMPLETE_CASE_RE.finditer(text): total, missing, complete = (_num(m.group(1)), _num(m.group(2)), _num(m.group(3))) if None in (total, missing, complete): continue if abs((total - missing) - complete) >= 1: # off by >= 1 (not rounding) claims.append({ "verdict": "CASCADE_SUM", "severity": "Major", "detail": (f"complete-case arithmetic: {int(total):,} - {int(missing):,} = " f"{int(total - missing):,}, but text states {int(complete):,}"), "where": m.group(0).strip()[:160], }) # (b) exclusion cascade: start - sum(excluded) == final (within a flow window) sm = START_RE.search(text) fm = FINAL_RE.search(text) if sm and fm and fm.start() > sm.start(): window = text[sm.start():fm.end()] start = _num(sm.group(1)) final = next((_num(g) for g in fm.groups() if g), None) excluded = [] for em in EXCLUDED_RE.finditer(window): v = next((g for g in em.groups() if g), None) if v: excluded.append(int(v.replace(",", ""))) if start is not None and final is not None and excluded: if abs((start - sum(excluded)) - final) >= 1: claims.append({ "verdict": "CASCADE_SUM", "severity": "Major", "detail": (f"exclusion cascade: {int(start):,} - " f"({' + '.join(f'{e:,}' for e in excluded)}) = " f"{int(start - sum(excluded)):,}, but final stated {int(final):,}"), "where": "study-population flow", }) return claims # --- Check 3: PARTITION_OVERLAP -------------------------------------------- def _parse_md_tables(text: str) -> list[list[list[str]]]: """Return GFM tables as lists of cell-rows (header + body, separator dropped).""" tables, cur = [], [] for line in text.splitlines(): if line.lstrip().startswith("|") and line.count("|") >= 2: cells = [c.strip() for c in line.strip().strip("|").split("|")] if all(re.fullmatch(r":?-{2,}:?", c) or c == "" for c in cells) and cur: continue # separator row cur.append(cells) else: if len(cur) >= 2: tables.append(cur) cur = [] if len(cur) >= 2: tables.append(cur) return tables def _partition_from_rows(label_of, n_of, ev_of, rows, source: str) -> list[dict]: claims = [] body, total_row = [], None for r in rows: if _is_total_label(label_of(r)): total_row = r else: body.append(r) ns = [n_of(r) for r in body] ns = [x for x in ns if x is not None] if len(ns) < 2: return claims # all-equal-to-total mis-entry if total_row is not None: tot_n = n_of(total_row) if tot_n is not None and all(abs(x - tot_n) < 1 for x in ns): claims.append({ "verdict": "PARTITION_OVERLAP", "severity": "Major", "detail": (f"every stratum n equals the grand total ({int(tot_n):,}) — " f"stratum-total mis-entry, not a partition"), "where": source, }) return claims if tot_n is not None and abs(sum(ns) - tot_n) >= 1: claims.append({ "verdict": "PARTITION_OVERLAP", "severity": "Major", "detail": (f"stratum N sum to {int(sum(ns)):,} but the stated total is " f"{int(tot_n):,} (difference {int(sum(ns) - tot_n):+,}) — " f"non-disjoint strata or a missing/extra row"), "where": source, }) # events partition evs = [ev_of(r) for r in body] evs = [x for x in evs if x is not None] tot_ev = ev_of(total_row) if len(evs) >= 2 and tot_ev is not None and abs(sum(evs) - tot_ev) >= 1: claims.append({ "verdict": "PARTITION_OVERLAP", "severity": "Major", "detail": (f"stratum events sum to {int(sum(evs)):,} but total events " f"stated {int(tot_ev):,}"), "where": source, }) return claims def check_partition_md(text: str) -> list[dict]: claims = [] for tbl in _parse_md_tables(text): header = tbl[0] ni = _pick(header, ("n", "count", "number", "no", "denominator", "subjects", "patients")) ei = _pick(header, ("events", "event", "cases", "outcomes", "incident")) if ni is None: continue li = 0 # first column is the stratum label by convention if ni == li: li = 1 if len(header) > 1 else 0 def label_of(r, li=li): return r[li] if li < len(r) else "" def n_of(r, ni=ni): return _num(r[ni]) if ni < len(r) else None def ev_of(r, ei=ei): return _num(r[ei]) if (ei is not None and ei < len(r)) else None claims += _partition_from_rows(label_of, n_of, ev_of, tbl[1:], source=f"table[{' | '.join(header)[:60]}]") return claims def check_partition_csv(rows: list[dict]) -> list[dict]: if not rows: return [] header = list(rows[0].keys()) li = _pick(header, ("stratum", "tier", "group", "category", "level", "label")) ni = _pick(header, ("n", "count", "number", "denominator", "subjects", "patients")) ei = _pick(header, ("events", "event", "cases", "outcomes")) if li is None or ni is None: return [] cols = list(header) def label_of(r): return r[cols[li]] def n_of(r): return _num(r[cols[ni]]) def ev_of(r): return _num(r[cols[ei]]) if ei is not None else None return _partition_from_rows(label_of, n_of, ev_of, rows, source="--data partition") # Prose partition: an in-text enumeration presented as an exhaustive split of a # stated total. A sentence that decomposes N into >=3 "count (pct%)" categories # with partition-cue language, but whose counts do not sum to N (or whose # percentages do not sum to ~100), has mixed a non-exclusive component in among # mutually exclusive categories -- a "these don't add to N" reviewer flag. The # partition-cue gate is what keeps this off legitimate overlapping-attribute prose # ("210 (72.7%) had hypertension, 140 (48.4%) had diabetes, ..."): comorbidity # prevalence is not a partition, and its counts legitimately sum above N. _PART_CUE_RE = re.compile( r"\b(?:decomposed|broke\s+down|broken\s+down|breakdown|comprised|" r"consist(?:ed|ing)\s+of|partitioned|categori[sz]ed|classified|of\s+which|" r"identified\s+(?:by|as|through)|attributable\s+to|ascertained\s+(?:by|through|via)|" r"accounted\s+for|respectively)\b", re.I) _PART_TOTAL_RE = re.compile( r"\b(?:among|of|the|these|totall?ing)\s+(?:the\s+)?([0-9][0-9,]{2,})\s+" r"(?:incident\s+|total\s+|remaining\s+|eligible\s+|included\s+)?" r"(?:cases|patients|subjects|participants|individuals|events|records|women|men|" r"children|deaths|lesions|nodules|tumou?rs|samples|episodes|visits)\b", re.I) _PART_CAT_RE = re.compile(r"\b([0-9][0-9,]{0,})\s*\(\s*([0-9]+(?:\.[0-9]+)?)\s*%\s*\)") _SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+(?=[A-Z(\"'])") # Bound the scan to a paragraph so an enumeration cannot accrue counts across a # blank line or a markdown header (a partition claim lives in one sentence). _BLOCK_SPLIT_RE = re.compile(r"\n\s*\n|\n#{1,6}\s") def _iter_partition_sentences(text: str): for block in _BLOCK_SPLIT_RE.split(text): for sent in _SENT_SPLIT_RE.split(block): yield sent def check_partition_text(text: str) -> list[dict]: claims = [] for sent in _iter_partition_sentences(text): tm = _PART_TOTAL_RE.search(sent) if not tm: continue cats = _PART_CAT_RE.findall(sent) if len(cats) < 3: continue if not _PART_CUE_RE.search(sent): continue # cue gate: only an exhaustive-split claim, never overlapping attributes total = _num(tm.group(1)) counts = [_num(c) for c, _ in cats] if total is None or any(c is None for c in counts): continue sum_c = sum(counts) sum_p = sum(float(p) for _, p in cats) if abs(sum_c - total) >= 1: claims.append({ "verdict": "PARTITION_OVERLAP", "severity": "Major", "detail": (f"enumerated counts sum to {int(sum_c):,} but the stated total is " f"{int(total):,} (difference {int(sum_c - total):+,}); a non-exclusive " f"component may be mixed among the mutually exclusive categories"), "where": sent.strip()[:160], }) elif not (99.0 <= sum_p <= 101.0): claims.append({ "verdict": "PARTITION_OVERLAP", "severity": "Major", "detail": (f"category percentages sum to {sum_p:.1f}% (expected ~100%) for an " f"exhaustive split of {int(total):,}; a non-exclusive component may be " f"mixed among the mutually exclusive categories"), "where": sent.strip()[:160], }) return claims # --- Check: FOLLOWUP_VS_CRITERION ------------------------------------------ # A reported "median follow-up was 102 days" against a reference standard that # requires "size stability for >=24 months" reads, to a reviewer, as if the # benign classification had 102 days to work with. Usually the 102 days is the # index-visit interval and the total observation window (median 442 days) is # simply never stated. Pure arithmetic: if the shortest reported follow-up is # below the longest duration threshold the outcome/reference standard requires, # and no total-observation window is labelled, ask which quantity is reported. _DUR_UNIT_DAYS = {"day": 1.0, "week": 7.0, "month": 30.44, "year": 365.25} _FOLLOWUP_RE = re.compile( r"(?:median|mean)\s+follow[-\s]?up[^.]{0,40}?(\d[\d,]*(?:\.\d+)?)\s*(day|week|month|year)s?", re.I) _CRITERION_CUE = re.compile( r"stabilit|stable|reference standard|benign if|confirmed by|criterion|classified as|" r"resolution over|no growth for|unchanged for|followed for", re.I) _CRITERION_DUR_RE = re.compile( r"(?:>=|≥|at least|minimum of|for|over)\s*(\d+)\s*(day|week|month|year)s?", re.I) _TOTAL_WINDOW_RE = re.compile( r"total observation|observation period|overall follow[-\s]?up|maximum follow[-\s]?up|" r"observed for a (?:median|maximum) of|total follow[-\s]?up", re.I) def check_followup_criterion(text: str) -> list[dict]: fu = [(_num(v) or 0) * _DUR_UNIT_DAYS[u.lower()] for v, u in _FOLLOWUP_RE.findall(text)] fu = [d for d in fu if d > 0] if not fu: return [] # criterion thresholds: a duration inside a reference-standard/outcome cue window crit = [] for m in _CRITERION_DUR_RE.finditer(text): win = text[max(0, m.start() - 120):m.end() + 40] if _CRITERION_CUE.search(win): crit.append(int(m.group(1)) * _DUR_UNIT_DAYS[m.group(2).lower()]) if not crit: return [] min_fu, max_crit = min(fu), max(crit) if min_fu >= max_crit: return [] if _TOTAL_WINDOW_RE.search(text): return [] # a distinctly-labelled total-observation window is already reported return [{ "verdict": "FOLLOWUP_VS_CRITERION", "severity": "Minor", "detail": (f"the shortest reported follow-up ({min_fu / 30.44:.1f} months / {min_fu:.0f} days) " f"is below a {max_crit / 30.44:.0f}-month duration criterion in the outcome/reference " f"standard, and no total-observation window is reported — state whether the reported " f"follow-up is the index-visit interval or the total observation, and give the latter"), "where": (_FOLLOWUP_RE.search(text).group(0)[:120] if _FOLLOWUP_RE.search(text) else "follow-up"), }] # --- Check 4: ANALYSIS_UNIT_UNDISCLOSED ------------------------------------ # Health-screening / EMR / registry cohorts routinely have repeat attendees, so a # record count is not a subject count. When the data carry a subject ID and # records > subjects, non-independent observations give anti-conservative CIs. The # finding is the *undisclosed* gap: fire only when the manuscript neither states # the analysis unit nor reports a one-record-per-subject sensitivity. # Safelist of normalized ID column names for auto-detection (tight, to avoid # mistaking a low-cardinality flag like "valid" for an identifier). Explicit # --id-col bypasses both the safelist and the cardinality guard. ID_NAME_SAFELIST = { "id", "mockid", "mock id", "subjectid", "subject id", "patientid", "patient id", "personid", "person id", "recordid", "record id", "studyid", "study id", "pid", "eid", "uid", "mrn", } UNIT_STATED_RE = re.compile( r"one record per subject|per[-\s]subject|per subject|analysis unit|analytic unit|" r"screening encounter|one observation per (?:subject|participant|person)|" r"one row per (?:subject|participant|person)|first (?:qualifying )?(?:visit|encounter)", re.IGNORECASE) SENSITIVITY_STATED_RE = re.compile( r"first[-\s](?:visit|encounter)|one[-\s]record[-\s]per[-\s]subject|de[-\s]?duplicat|" r"unique (?:subject|participant|individual)s?|repeat (?:attendee|visit|screen)|" r"sensitivity analysis[^.]{0,80}(?:subject|visit|first|repeat)", re.IGNORECASE) def _detect_id_col(header: list[str], rows: list[dict], explicit: str | None) -> str | None: if explicit: for h in header: if _norm(h) == _norm(explicit) or h == explicit: return h sys.stderr.write(f"WARN: --id-col '{explicit}' not found in {header}\n") return None n = len(rows) for h in header: if _norm(h) in ID_NAME_SAFELIST: distinct = len({(r.get(h) or "").strip() for r in rows}) if n and distinct >= 0.5 * n: # subject-level, not a low-cardinality flag return h return None def check_analysis_unit(rows: list[dict], text: str, id_col: str | None) -> list[dict]: if not rows: return [] header = list(rows[0].keys()) col = _detect_id_col(header, rows, id_col) if not col: return [] ids = [(r.get(col) or "").strip() for r in rows] ids = [i for i in ids if i] nrow = len(ids) counts: dict[str, int] = {} for i in ids: counts[i] = counts.get(i, 0) + 1 subjects = len(counts) if nrow <= subjects: return [] # already one record per subject if UNIT_STATED_RE.search(text) or SENSITIVITY_STATED_RE.search(text): return [] # disclosed -> not a finding repeats = sum(1 for c in counts.values() if c > 1) max_visits = max(counts.values()) return [{ "verdict": "ANALYSIS_UNIT_UNDISCLOSED", "severity": "Major", "detail": (f"records={nrow:,}, unique_subjects={subjects:,}, " f"repeat_subjects={repeats:,}, max_visits={max_visits} " f"(id column '{col}'); the manuscript states neither the analysis " f"unit nor a one-record-per-subject sensitivity, so non-independent " f"observations give anti-conservative CIs"), "where": f"--data id column '{col}'", }] # --- driver ---------------------------------------------------------------- def load_csv(path: str) -> list[dict]: p = Path(path) if not p.is_file(): sys.stderr.write(f"ERROR: --data not found: {path}\n") sys.exit(2) with p.open(encoding="utf-8-sig", newline="") as f: return [r for r in csv.DictReader(f)] # Effect estimate with a bracketed CI: "4.95 (4.32-5.94)" / "4.95 [4.32 to 5.94]". _EFFECT_CI_RE = re.compile( r"(?P<pt>\d+(?:\.\d+)?)\s*[\(\[]\s*" r"(?P<lo>\d+(?:\.\d+)?)\s*(?:[-–—]|to|,)\s*(?P<hi>\d+(?:\.\d+)?)\s*[\)\]]" ) def check_duplicate_subgroup_ci(text: str) -> list[dict]: """Within one GFM table, two rows that share the SAME effect estimate AND the same identity counts (n / events) but print DIFFERENT confidence intervals are the same subgroup rendered twice (relabeled) with independently-resampled uncertainty — a reviewer asks why one group has two intervals. High precision by construction: it requires the non-effect integer cells (n, events) to be IDENTICAL between the rows, so two genuinely distinct subgroups with a coincidentally-equal point estimate do not fire; a table with no count columns is left alone.""" claims: list[dict] = [] for tbl in _parse_md_tables(text): if len(tbl) < 3: # header + at least two body rows continue groups: dict = {} for row in tbl[1:]: eff = eff_idx = None for ci, cell in enumerate(row): m = _EFFECT_CI_RE.search(cell) if m: eff, eff_idx = m, ci break if eff is None: continue # Identity = the count columns (n / events), NOT the label (col 0, which is # exactly what differs between the two relabeled rows) and NOT the effect+CI # cell. _ints_in already drops decimals, so CI bounds / p-values / percents # do not enter the identity. ids: list[int] = [] for ci, cell in enumerate(row): if ci not in (0, eff_idx): ids += _ints_in(cell) if not ids: # need n/events to confirm it is the same subgroup continue key = (eff.group("pt"), tuple(sorted(ids))) label = (row[0] if row else "").strip() groups.setdefault(key, []).append(((eff.group("lo"), eff.group("hi")), label)) for (pt, ids), members in groups.items(): cis = {ci for ci, _ in members} if len(members) >= 2 and len(cis) >= 2: labels = [lab for _, lab in members if lab] intervals = "; ".join(f"{lo}–{hi}" for lo, hi in sorted(cis)) claims.append({ "verdict": "SUBGROUP_DUPLICATE_CI", "severity": "Minor", "detail": (f"rows sharing estimate {pt} and identity counts {list(ids)} print " f"different confidence intervals ({intervals}); the same subgroup appears " f"rendered twice with divergent uncertainty — harmonize to one interval, " f"or footnote why the two differ" + (f" (rows: {', '.join(labels)})" if labels else "")), "where": (" / ".join(labels))[:120] or f"estimate {pt}", }) return claims _CINDEX_HDR = ("c-index", "c index", "cindex", "c-statistic", "c statistic", "concordance", "auc", "auroc", "harrell", "discrimination", "c (95") _MODEL_HDR = ("model", "covariate", "predictor", "variables", "adjustment") # tokens that appear in a model label but are NOT covariates _COVAR_STOP = {"model", "models", "only", "vs", "versus", "plus", "the", "and", "reference", "baseline", "base", "full", "final", "adjusted", "unadjusted", "alone", "with", "index"} _CI_VAL_RE = re.compile(r"\b0\.[5-9]\d") def _covar_set(label: str) -> frozenset: """'CMB + age + sex' -> {'cmb','age','sex'} (additive split on '+', notes and non-covariate words dropped).""" label = re.sub(r"\(.*?\)", " ", label) out = set() for tok in re.split(r"\s*\+\s*", label): t = re.sub(r"[^a-z0-9]", "", tok.lower()) if t and len(t) >= 2 and t not in _COVAR_STOP: out.add(t) return frozenset(out) def check_nested_model_baseline(text: str) -> list[dict]: """A table of nested prediction models reports a discrimination statistic (C-index / AUC) for two or more models that all embed a common covariate set (e.g. every model is 'X + age + sex'), but there is no BASE-model row (the common covariates alone) and no incremental deltaC — so the shared covariates could account for the discrimination, and 'model A comparable to model B' is uninterpretable. Deterministic and header-gated: only tables with a discrimination column and additive ('X + Y') model labels are considered, so an ordinary results table does not fire.""" claims: list[dict] = [] for tbl in _parse_md_tables(text): if len(tbl) < 3: continue header = tbl[0] ci_idx = _pick(header, _CINDEX_HDR) if ci_idx is None: continue model_idx = _pick(header, _MODEL_HDR) if model_idx is None: model_idx = 0 additive: list[tuple[str, frozenset]] = [] ci_row_sets: set = set() for row in tbl[1:]: if ci_idx >= len(row) or not _CI_VAL_RE.search(row[ci_idx]): continue label = row[model_idx] if model_idx < len(row) else (row[0] if row else "") cset = _covar_set(label) ci_row_sets.add(cset) if "+" in label: additive.append((label, cset)) if len(additive) < 2: continue common = frozenset.intersection(*(s for _, s in additive)) if not common or common in ci_row_sets: continue if re.search(r"(?:Δ|delta[-\s]?)\s?(?:c\b|auc)|incremental (?:c[-\s]?index|auc|discrimination)", text, re.IGNORECASE): continue claims.append({ "verdict": "NESTED_MODEL_NO_BASELINE", "severity": "Minor", "detail": (f"{len(additive)} nested models report a discrimination statistic and all embed " f"the covariates {sorted(common)}, but no base-model row (those covariates alone) " f"and no incremental ΔC is reported; the shared covariates could account for the " f"discrimination — add the base-model C-index and the ΔC so the incremental value " f"is interpretable"), "where": ("; ".join(lab for lab, _ in additive[:3]))[:120], }) return claims def analyze(manuscript: str, data: str | None, id_col: str | None = None) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) text = p.read_text(encoding="utf-8") claims = [] claims += check_rate_text(text) claims += check_cascade_text(text) claims += check_partition_md(text) claims += check_partition_text(text) claims += check_followup_criterion(text) claims += check_duplicate_subgroup_ci(text) claims += check_nested_model_baseline(text) if data: rows = load_csv(data) claims += check_rate_csv(rows) claims += check_partition_csv(rows) claims += check_analysis_unit(rows, text, id_col) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "manuscript": str(p), "data": data, "claims": claims, "summary": { "n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "verdict": "MAJOR_CANDIDATE" if n_major else "OK", }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | no cohort-arithmetic discrepancy detected |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Cohort arithmetic gate (Phase 2.5 / 2.5b).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--data", help="optional CSV for exact recompute (rate / partition / analysis-unit)") ap.add_argument("--id-col", help="subject-ID column in --data for the records-vs-subjects " "analysis-unit check (auto-detected from common ID names if omitted)") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript, args.data, args.id_col) if not args.quiet: print("=" * 41) print(" Cohort Arithmetic (Phase 2.5 / 2.5b)") print("=" * 41) print(render(result)) print() s = result["summary"] if s["n_major"]: print(f"MAJOR candidate: {s['n_major']} cohort-arithmetic discrepancy(ies).") else: print("OK: no cohort-arithmetic discrepancy detected.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_cohort_arithmetic", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_confounding_completeness.py 22.1 KB
#!/usr/bin/env python3 """Confounding-completeness gate for observational studies (self-review Phase 2.5e). The highest-yield observational reviewer finding is also the most mechanical: a covariate that was *measured*, is *imbalanced across exposure groups* in the baseline table, and is *absent from the adjustment set* is residual confounding by a measured variable. A single-pass prose review misses it because the manuscript text is internally consistent; only a join of the exposure-stratified Table 1 against the Methods adjustment set exposes it. This script is that join (probe O1 of observational_confounding.md), backported from the panel so the deterministic finding lands without a multi-agent pass. INPUTS --table1 exposure-stratified baseline table, CSV. One row per covariate. Needs a covariate-name column and a p-value (or SMD) column. Column names are auto-detected (case-insensitive); override with --name-col / --p-col / --smd-col. A file named like `table1_by_<exposure>.csv` is the convention. --adjusted adjustment-set variables. Either a path to a file (one variable per line, or a Methods paragraph the script greps after "adjusted for") or a comma-separated list passed inline with --adjusted-list. --exposure-defining[-list] covariates that are components of the exposure's own diagnostic criteria (e.g. BMI / glycaemia / lipids for a metabolic-syndrome or MASLD exposure). These are EXEMPT from the residual-confounding flag — adjusting for them is over-adjustment (probe O7), not a fix. The remedy for residual confounding is an extended-adjustment model with NON-defining prognostic covariates only. --group-cols A,B when the Table 1 has no p-value / SMD column but two exposure-stratum columns of "mean ± SD" (or "mean +/- SD") cells, name them here (or let the script auto-detect) and the SMD is computed per row. The "mean (SD)" paren form is intentionally NOT auto-parsed (it collides with "n (%)"); use the ± form or pass an explicit SMD column. OUTPUT A reconciliation table (stdout) and, with --out, a JSON artifact: {covariate, imbalance_p / smd, in_adjustment_set, verdict} verdict UNADJUSTED_IMBALANCED is the Major candidate. Exit 1 (with --strict) when any UNADJUSTED_IMBALANCED row exists. Matching the adjustment set to Table-1 covariate labels is fuzzy (a table row "Smoking, pack-years" vs an adjustment token "smoking"), so the match is a normalized-substring test in both directions; review the reconciliation table rather than trusting the count blindly. Stdlib-only (csv / json / re / argparse). Exit codes: 0 clean (or report-only), 1 unadjusted-imbalanced rows found (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import csv import json import re import sys from pathlib import Path # --- column auto-detection ------------------------------------------------- NAME_HINTS = ("covariate", "variable", "characteristic", "feature", "name", "") P_HINTS = ("p_value", "pvalue", "p-value", "p val", "p", "pr") SMD_HINTS = ("smd", "std_diff", "standardized", "std. mean", "std mean") P_THRESHOLD = 0.05 SMD_THRESHOLD = 0.10 # Header / summary rows that are not covariates (sample-size lines, group totals, # trend-p rows). Matched on the whole normalized label, not a substring, so a real # covariate like "Total cholesterol" is not swallowed by "total". def _is_skip_row(cov: str) -> bool: c = _norm(cov) if c in ("", "total", "overall", "n", "no", "number"): return True if re.match(r"^n\s*[=:]", cov.strip().lower()): # "N = ...", "n: ..." return True if "p for trend" in c or "p trend" in c or "for trend" in c: return True return False def _norm(s: str) -> str: """Lowercase, drop punctuation/units, collapse whitespace for fuzzy match.""" s = s.lower() s = re.sub(r"\(.*?\)", " ", s) # drop "(mg/dL)", "(%)" s = re.sub(r"[^a-z0-9 ]+", " ", s) # punctuation -> space s = re.sub(r"\s+", " ", s).strip() return s # --- DB-code / prose synonym aliases --------------------------------------- # A reviewer writes the adjustment set in prose ("systolic blood pressure") while # a DB-exported Table 1 carries the column code ("he_sbp"); a normalized-substring # match then fails and a covariate that *was* adjusted is false-flagged as # imbalanced-and-unadjusted. Each row maps one canonical concept to the surface # forms (DB code + prose synonyms) that denote it; two labels match when they # resolve to a shared concept. This only ever *adds* matches (turns a false ✗ into # ✓): a true unadjusted covariate shares no concept with any adjustment token, so # no false ✓ is introduced. Extend as new DB dictionaries appear — one concept per # row, lowercase, unit-free; multi-letter codes belong to the controlled # `he_*` / `b_*` namespace so a bare token clash with a prose label is implausible. ALIAS_GROUPS = { "sbp": ("he_sbp", "sbp", "systolic blood pressure", "systolic bp"), "dbp": ("he_dbp", "dbp", "diastolic blood pressure", "diastolic bp"), "uric_acid": ("b_uric", "uric acid", "serum uric acid", "urate"), "hdl": ("b_chol_hdl", "hdl", "hdl cholesterol", "high density lipoprotein"), "total_cholesterol": ("b_chol_t", "total cholesterol", "cholesterol total"), "triglycerides": ("b_tg", "tg", "triglyceride", "triglycerides"), "hba1c": ("b_hba1c", "hba1c", "glycated haemoglobin", "glycated hemoglobin", "glycohemoglobin"), "bmi": ("he_bmi", "bmi", "body mass index"), "waist": ("he_wc", "wc", "waist", "waist circumference"), "smoking": ("smk", "smk_packyrs", "smoking", "smoking status", "pack years", "pack-years", "smoker", "cigarette"), "fasting_glucose": ("he_glu", "b_glu", "fasting glucose", "fasting plasma glucose", "fpg", "glucose"), "hemoglobin": ("he_hb", "b_hb", "hemoglobin", "haemoglobin"), "alcohol": ("alc", "alcohol", "alcohol intake", "drinking", "ethanol"), "egfr": ("egfr", "e_gfr", "estimated gfr", "estimated glomerular filtration rate"), "diabetes": ("dm", "diabetes", "diabetes mellitus"), "hypertension": ("htn", "hypertension", "high blood pressure"), } # concept -> set of normalized surface forms _ALIAS_NORM = {c: {_norm(f) for f in forms if _norm(f)} for c, forms in ALIAS_GROUPS.items()} def _concepts(label: str) -> set[str]: """Canonical concept keys a (normalized) covariate / adjustment label denotes. Single-token surface ("sbp", "wc"): whole-token match. Multi-word surface ("waist circumference"): contiguous phrase or all tokens present (so a Table-1 row "Smoking, pack-years" -> 'smoking pack years' still resolves to smoking). """ s = _norm(label) if not s: return set() tokens = set(s.split()) out = set() for concept, surfaces in _ALIAS_NORM.items(): for f in surfaces: ft = f.split() if len(ft) == 1: if ft[0] in tokens: out.add(concept) break elif f in s or all(t in tokens for t in ft): out.add(concept) break return out def _pick_col(header: list[str], hints: tuple[str, ...], override: str | None) -> int | None: if override: for i, h in enumerate(header): if _norm(h) == _norm(override): return i sys.stderr.write(f"ERROR: column '{override}' not found in header {header}\n") return None norm = [_norm(h) for h in header] # exact-ish first for hint in hints: h = _norm(hint) for i, col in enumerate(norm): if col == h and h: return i # then substring — but only for hints >= 3 chars, so a 1-2 char hint like # "p" / "pr" does not match an unrelated column ("p" in "exposed"). for hint in hints: h = _norm(hint) for i, col in enumerate(norm): if h and len(h) >= 3 and h in col: return i return None def _parse_p(raw: str) -> float | None: """Parse a p-value cell: '0.001', '<0.001', 'p<0.01', '0.03*', 'NS'.""" if raw is None: return None s = raw.strip().lower() if not s or s in ("ns", "na", "n/a", "-", "."): return 1.0 if s == "ns" else None m = re.search(r"<\s*(0?\.[0-9]+|[0-9]+\.?[0-9]*)", s) # "<0.001", "p<.01" if m: try: # report just under the stated bound return max(float(m.group(1)) - 1e-6, 0.0) except ValueError: return None m = re.search(r"0?\.[0-9]+|[0-9]+\.?[0-9]*", s) if m: try: return float(m.group(0)) except ValueError: return None return None def _parse_float(raw: str) -> float | None: if raw is None: return None m = re.search(r"-?[0-9]*\.?[0-9]+", raw.strip()) return float(m.group(0)) if m else None # --- adjustment set -------------------------------------------------------- def load_adjustment_set(path: str | None, inline: str | None) -> list[str]: if inline: return [t.strip() for t in inline.split(",") if t.strip()] if not path: return [] p = Path(path) if not p.is_file(): sys.stderr.write(f"ERROR: adjustment file not found: {path}\n") sys.exit(2) text = p.read_text(encoding="utf-8") # If the file is a Methods paragraph, grep the "adjusted for ..." clause. m = re.search(r"adjust(?:ed|ing)?\s+for\s+(.+?)(?:\.|;|\n\n|$)", text, re.I | re.S) if m: clause = m.group(1) parts = re.split(r",| and | as well as ", clause) return [p2.strip() for p2 in parts if p2.strip()] # Otherwise treat as one variable per line. return [ln.strip() for ln in text.splitlines() if ln.strip() and not ln.startswith("#")] def in_adjustment_set(cov: str, adj_norm: list[str], adj_concepts: set[str] | None = None) -> bool: c = _norm(cov) if not c: return False # concept-level match across the DB-code / prose alias map (he_sbp ~ "systolic # blood pressure"); resolves the false ✗ when Table 1 carries DB column codes # and the adjustment set is written in prose. if adj_concepts and (_concepts(cov) & adj_concepts): return True for a in adj_norm: if not a: continue if a in c or c in a: return True # token overlap on the leading word (smoking ~ "smoking, pack-years") if c.split(" ")[0] == a.split(" ")[0] and len(c.split(" ")[0]) >= 3: return True return False # --- A3: SMD computed from per-stratum mean ± SD --------------------------- # The common wide Table 1 from /analyze-stats carries stratified "mean ± SD" # (or "mean +/- SD") cells but no p / SMD column, so the gate could not run. When # no p/SMD column is present, compute SMD from two group columns whose cells use # the UNAMBIGUOUS mean±SD form (not "n (%)" / "mean (SD)", which collide), so a # categorical "53 (52)" is never mistaken for a continuous mean(sd). MEANSD_RE = re.compile(r"^\s*(-?\d[\d,]*\.?\d*)\s*(?:\+/-|±)\s*(\d[\d,]*\.?\d*)\s*$") def _meansd(cell: str): if cell is None: return None m = MEANSD_RE.match(cell) if not m: return None try: return float(m.group(1).replace(",", "")), float(m.group(2).replace(",", "")) except ValueError: return None def _smd_meansd(m1: float, s1: float, m2: float, s2: float): denom = ((s1 ** 2 + s2 ** 2) / 2.0) ** 0.5 if denom <= 0: return None return (m1 - m2) / denom def _detect_group_cols(header, body_rows, name_idx, override): """Return (i, j) column indices of the two exposure-stratum value columns to compute SMD from, or None. With --group-cols, resolve the two named columns; otherwise pick the first two non-name columns whose cells are mostly mean±SD.""" if override: idxs = [] for want in override: found = next((k for k, h in enumerate(header) if _norm(h) == _norm(want)), None) if found is None: sys.stderr.write(f"ERROR: --group-cols column '{want}' not found in {header}\n") sys.exit(2) idxs.append(found) return (idxs[0], idxs[1]) if len(idxs) >= 2 else None cand = [] for k in range(len(header)): if k == name_idx: continue hits = sum(1 for r in body_rows if k < len(r) and _meansd(r[k]) is not None) if hits >= 2: cand.append(k) return (cand[0], cand[1]) if len(cand) >= 2 else None def is_exposure_defining(cov: str, defining_norm: list[str], defining_concepts: set[str]) -> bool: """A4: a covariate that is a component of the exposure's diagnostic criteria (e.g. BMI/glycaemia/lipids for a metabolic-syndrome / MASLD exposure). Same fuzzy match as in_adjustment_set.""" if not defining_norm and not defining_concepts: return False return in_adjustment_set(cov, defining_norm, defining_concepts) # --- core ------------------------------------------------------------------ def analyze(table1: str, adj: list[str], name_col, p_col, smd_col, defining: list[str] | None = None, group_cols: list[str] | None = None) -> dict: p = Path(table1) if not p.is_file(): sys.stderr.write(f"ERROR: table1 not found: {table1}\n") sys.exit(2) with p.open(encoding="utf-8-sig", newline="") as f: reader = csv.reader(f) rows = [r for r in reader if any(c.strip() for c in r)] if len(rows) < 2: sys.stderr.write("ERROR: table1 has no data rows\n") sys.exit(2) header = rows[0] ni = _pick_col(header, NAME_HINTS, name_col) pi = _pick_col(header, P_HINTS, p_col) si = _pick_col(header, SMD_HINTS, smd_col) if ni is None: ni = 0 gi = None if pi is None and si is None: # A3: no p / SMD column — fall back to computing SMD from two mean±SD cols. gi = _detect_group_cols(header, rows[1:], ni, group_cols) if gi is None: sys.stderr.write( "ERROR: no p-value or SMD column found, and no two mean±SD group " "columns to compute SMD from; pass --p-col/--smd-col or --group-cols.\n") sys.exit(2) adj_norm = [_norm(a) for a in adj] adj_concepts = set().union(*(_concepts(a) for a in adj)) if adj else set() defining = defining or [] def_norm = [_norm(d) for d in defining] def_concepts = set().union(*(_concepts(d) for d in defining)) if defining else set() smd_source = "reported" if (pi is not None or si is not None) else "computed_from_mean_sd" covariates = [] for r in rows[1:]: if ni >= len(r): continue cov = r[ni].strip() if _is_skip_row(cov): continue pval = _parse_p(r[pi]) if (pi is not None and pi < len(r)) else None smd = _parse_float(r[si]) if (si is not None and si < len(r)) else None if smd is None and gi is not None: # A3: compute SMD g1 = _meansd(r[gi[0]]) if gi[0] < len(r) else None g2 = _meansd(r[gi[1]]) if gi[1] < len(r) else None if g1 and g2: smd = _smd_meansd(g1[0], g1[1], g2[0], g2[1]) imbalanced = (pval is not None and pval < P_THRESHOLD) or \ (smd is not None and abs(smd) >= SMD_THRESHOLD) if not imbalanced: continue # A4: a component of the exposure's own diagnostic criteria is over- # adjustment, not residual confounding — exempt it from the Major flag. if is_exposure_defining(cov, def_norm, def_concepts): verdict = "EXPOSURE_DEFINING_EXEMPT" adjusted = in_adjustment_set(cov, adj_norm, adj_concepts) else: adjusted = in_adjustment_set(cov, adj_norm, adj_concepts) verdict = "ADJUSTED" if adjusted else "UNADJUSTED_IMBALANCED" covariates.append({ "covariate": cov, "imbalance_p": pval, "smd": round(smd, 4) if smd is not None else None, "in_adjustment_set": adjusted, "verdict": verdict, }) unadjusted = [f for f in covariates if f["verdict"] == "UNADJUSTED_IMBALANCED"] exempt = [f for f in covariates if f["verdict"] == "EXPOSURE_DEFINING_EXEMPT"] # `findings` is the DEFECT list and nothing else. It used to be the whole per-covariate # audit table, two of whose three verdicts mean "this is fine": ADJUSTED says the # covariate WAS handled and EXPOSURE_DEFINING_EXEMPT records a deliberate exemption. # Every consumer that aggregates qc/ counted those as fires — in one real project that # was ~45 pseudo-findings from three runs, enough to make this the loudest detector in # the suite and to corrupt the precision ledger built on top of it. The full table is # still emitted, as `covariates`, which is what the human-readable render walks. findings = [dict(f, severity="major", message=( f"{f['covariate']} is imbalanced by exposure " + (f"(p={f['imbalance_p']:.4g})" if f["imbalance_p"] is not None else f"(SMD={f['smd']:.3g})" if f["smd"] is not None else "") + " and is not in the adjustment set; report an extended-adjustment sensitivity model." )) for f in unadjusted] return { "table1": str(p), "adjustment_set": adj, "exposure_defining": defining, "thresholds": {"p": P_THRESHOLD, "smd": SMD_THRESHOLD}, "smd_source": smd_source, "n_imbalanced": len(covariates), "n_unadjusted_imbalanced": len(unadjusted), "n_exposure_defining_exempt": len(exempt), "covariates": covariates, "findings": findings, "verdict": "MAJOR_CANDIDATE" if unadjusted else "OK", "suggested_fix": ( "Report an extended-adjustment sensitivity model adding the unadjusted " "imbalanced covariates that are NON-defining prognostic factors (not the " "exposure's own diagnostic criteria); keep the original model primary " "only if the extended model agrees." ) if unadjusted else None, } def render_table(result: dict) -> str: lines = [ "| Covariate | Imbalance p | SMD | In adjustment set? | Verdict |", "|---|---|---|---|---|", ] marks = { "UNADJUSTED_IMBALANCED": "✗ Major", "ADJUSTED": "✓", "EXPOSURE_DEFINING_EXEMPT": "⊘ exposure-defining (exempt; adjusting = over-adjustment)", } for f in result["covariates"]: p = "—" if f["imbalance_p"] is None else f"{f['imbalance_p']:.4g}" s = "—" if f["smd"] is None else f"{f['smd']:.3g}" mark = marks.get(f["verdict"], f["verdict"]) lines.append( f"| {f['covariate']} | {p} | {s} | " f"{'yes' if f['in_adjustment_set'] else 'NO'} | {mark} |" ) return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Observational confounding-completeness gate (O1).") ap.add_argument("--table1", required=True, help="exposure-stratified Table 1 CSV") ap.add_argument("--adjusted", help="adjustment-set file (var-per-line or Methods paragraph)") ap.add_argument("--adjusted-list", help="comma-separated adjustment variables (inline)") ap.add_argument("--name-col", help="override covariate-name column header") ap.add_argument("--p-col", help="override p-value column header") ap.add_argument("--smd-col", help="override SMD column header") ap.add_argument("--group-cols", help="two stratum value-column headers (comma-separated) to compute SMD from " "mean±SD cells when no p/SMD column exists (e.g. 'exposed,unexposed')") ap.add_argument("--exposure-defining", help="file of exposure-defining covariates (components of the exposure's " "diagnostic criteria); these are exempt from the residual-confounding flag") ap.add_argument("--exposure-defining-list", help="comma-separated exposure-defining covariates (inline)") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if unadjusted-imbalanced rows exist") args = ap.parse_args() adj = load_adjustment_set(args.adjusted, args.adjusted_list) if not adj: sys.stderr.write("WARN: empty adjustment set — every imbalanced covariate will flag.\n") defining = load_adjustment_set(args.exposure_defining, args.exposure_defining_list) group_cols = [c.strip() for c in args.group_cols.split(",")] if args.group_cols else None result = analyze(args.table1, adj, args.name_col, args.p_col, args.smd_col, defining, group_cols) print("=" * 41) print(" Confounding Completeness (Phase 2.5e / O1)") print("=" * 41) print(f"adjustment set: {', '.join(adj) if adj else '(none)'}") if defining: print(f"exposure-defining (exempt): {', '.join(defining)}") if result["smd_source"] == "computed_from_mean_sd": print("SMD source: computed from mean±SD group columns (no p/SMD column present)") print(render_table(result)) print() if result["n_exposure_defining_exempt"]: print(f"Note: {result['n_exposure_defining_exempt']} imbalanced covariate(s) exempt as " f"exposure-defining (adjusting for them would be over-adjustment — see probe O7).") if result["n_unadjusted_imbalanced"]: print(f"MAJOR candidate: {result['n_unadjusted_imbalanced']} imbalanced covariate(s) " f"absent from the adjustment set.") print(f"Fix: {result['suggested_fix']}") else: print("OK: no measured-but-unadjusted imbalanced covariate.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_confounding_completeness", **result}, indent=2), encoding="utf-8") print(f"\nwrote {args.out}") return 1 if (args.strict and result["n_unadjusted_imbalanced"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_cv_leakage.py 6.2 KB
#!/usr/bin/env python3 """Feature-selection-outside-CV leakage gate (self-review Phase 2.5 / data-prep). For a classifier / NLP / tabular manuscript, if feature selection, vocabulary construction, log-odds / univariate filtering, or a threshold is chosen on the WHOLE dataset and only THEN cross-validation is run, the CV performance is optimistically inflated: the selection has already seen the held-out folds. The fix is to nest the selection inside each training fold (nested CV). This is a class a statistical reviewer catches deterministically, and it is distinct from patient-vs-image split leakage (`model-validation/check_split_leakage.py`). Verdict: CV_SELECTION_LEAKAGE (Major) a feature-selection / vocabulary / threshold step co-occurs with a cross-validation description AND no fold-nesting disclosure ("within each fold", "nested CV", "inside the training fold") is present. The headline CV metric is likely optimistic. Conservative by construction: fires only when BOTH a selection token AND a CV token appear AND no nesting-disclosure token is anywhere in the document. A single "within each training fold" / "nested cross-validation" sentence suppresses it. Exit codes: 0 clean/report-only, 1 with --strict when any Major, 2 usage. Stdlib-only. Usage: python3 check_cv_leakage.py --manuscript manuscript.md \ [--out qc/cv_leakage.json] [--strict] [--quiet] """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # A data-driven selection / construction step that must be nested inside CV. SELECTION = re.compile( r"\bfeature\s+selection\b|\bselected\s+(?:the\s+)?(?:top\s+)?\d+\s+(?:features|variables|predictors)" r"|\blog[-\s]?odds\b|\bvocabulary\s+(?:construction|was\s+built|building)\b|\bbuilt\s+(?:a\s+)?vocabulary" r"|\bfeature\s+ranking\b|\bunivariate\s+(?:filter|screening|selection)\b|\btop[-\s]?k\s+features" r"|\bmutual[-\s]information\s+(?:selection|ranking)\b|\bchi[-\s]?square(?:d)?\s+selection" r"|\b(?:selected|chose|retained|kept)\s+(?:the\s+)?(?:most\s+)?(?:informative|discriminative|predictive)\s+features" r"|\bthreshold(?:ed|ing)?\b[^.\n]{0,40}\b(?:on|over|across|using)\s+the\s+(?:entire|full|whole|complete)\s+(?:data|dataset|cohort|corpus)" r"|\b(?:LASSO|elastic[-\s]net|recursive\s+feature\s+elimination|RFE|Boruta)\b", re.IGNORECASE) # A cross-validation evaluation. CV = re.compile( r"\bcross[-\s]?validat(?:ion|ed)\b|\b\d+[-\s]?fold\b|\bk[-\s]?fold\b" r"|\bleave[-\s]one[-\s]out\b|\bLOOCV\b|\bstratified\s+(?:\d+[-\s]?)?fold", re.IGNORECASE) # Disclosure that the selection is correctly nested inside the CV training folds. NESTING = re.compile( r"\bnested\s+(?:cross[-\s]?validat|CV)\b" r"|\bwithin\s+each\s+(?:training\s+)?fold\b|\binside\s+(?:the\s+)?training\s+(?:fold|partition|split)" r"|\bper[-\s]?fold\b|\bfold[-\s]specific\b|\bfor\s+each\s+(?:training\s+)?fold\b" r"|\bon\s+the\s+training\s+(?:fold|partition|split|set)\s+only\b" r"|\brefit(?:ted)?\s+within\b|\brepeated\s+(?:in|within|inside)\s+each\s+fold" r"|\bselection\s+was\s+(?:performed|done|repeated)\s+(?:in|within|inside)\s+each\b", re.IGNORECASE) def check(text: str) -> list[dict]: sel = SELECTION.search(text) cv = CV.search(text) if not (sel and cv): return [] if NESTING.search(text): return [] # nesting is disclosed return [{ "verdict": "CV_SELECTION_LEAKAGE", "severity": "Major", "detail": (f"a data-driven selection step ('{sel.group(0).strip()}') co-occurs with " f"cross-validation ('{cv.group(0).strip()}') but no fold-nesting is disclosed " f"(no 'within each fold' / 'nested CV'); if the selection was fit on the full " f"dataset the CV metric is optimistically inflated — nest it in each training fold"), "where": text[max(0, sel.start() - 30):sel.end() + 50].replace("\n", " ").strip()[:170], }] def analyze(manuscript: str) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8")) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "manuscript": str(p), "claims": claims, "summary": {"n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "verdict": "MAJOR_CANDIDATE" if n_major else "OK"}, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | no feature-selection-outside-CV leakage detected |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser( description="Feature-selection-outside-CV leakage gate (Phase 2.5 / data-prep).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript) if not args.quiet: print("=" * 44) print(" CV selection-leakage (§2.5 / data-prep)") print("=" * 44) print(render(result)) print() if result["summary"]["n_major"]: print("MAJOR candidate: a selection step co-occurs with CV and no fold-nesting is disclosed.") else: print("OK: no feature-selection-outside-CV leakage detected.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_cv_leakage", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_dta_denominators.py 7.7 KB
#!/usr/bin/env python3 """check_dta_denominators.py — cross-check diagnostic-accuracy sensitivity / specificity denominators against the reference-standard category counts in the characteristics table. Sensitivity is a/(a+c) over the disease-positive patients; specificity is d/(b+d) over the disease-negative patients. Those denominators must equal the reference- standard positive / negative counts printed in the characteristics table. When they disagree while the grand total still matches (e.g. table 14/19, Results 15/18, both summing to 33), a totals check passes and the split-level error — which sits under an Abstract headline — survives multiple review rounds. This detector recovers both category counts and both denominators and asserts equality per category, refusing to accept grand-total agreement as passing. Also `STAGE_ROWSUM`: a staging-confusion breakdown must satisfy correctly-staged + over-staged + under-staged == n. Runs on FIRST submissions, not only revisions — the source incident had both numbers on the page from submission one. Stdlib-only. Reads the manuscript, never writes it. Usage: python3 check_dta_denominators.py --manuscript paper.md [--strict] [--quiet] [--json] Exit: 0 clean; with --strict, 1 on any mismatch; 2 on input/usage error. """ from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass, field, asdict SEP_RE = re.compile(r"^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$") NEG_LABEL_RE = re.compile(r"\b(p?N\s*0|node[- ]negative|disease[- ](?:free|negative)|absent|benign|non-?malignant)\b", re.I) POS_LABEL_RE = re.compile(r"\b(p?N\s*[1-9]|node[- ]positive|disease[- ]positive|present|malignant|metasta\w+)\b", re.I) # "sensitivity ... 13/18" or "13 of 18"; bounded gap so it does not cross a # sentence boundary but tolerates a decimal percentage (e.g. "72.2% (13/18)"). SENS_RE = re.compile(r"sensitivit\w*[^\n]{0,40}?\b(\d+)\s*(?:/|of)\s*(\d+)", re.I) SPEC_RE = re.compile(r"specificit\w*[^\n]{0,40}?\b(\d+)\s*(?:/|of)\s*(\d+)", re.I) CORRECT_RE = re.compile(r"correctl\w*[^.\n]*?\b(\d+)", re.I) OVER_RE = re.compile(r"over[- ]?stag\w*[^.\n]*?\b(\d+)", re.I) UNDER_RE = re.compile(r"under[- ]?stag\w*[^.\n]*?\b(\d+)", re.I) STAGE_N_RE = re.compile(r"\b(?:in|of|among)\s+(\d+)\s+(?:patients|exams|cases|lesions)\b", re.I) @dataclass class Finding: kind: str severity: str detail: str @dataclass class Report: source: str findings: list[Finding] = field(default_factory=list) @property def n_flag(self) -> int: return sum(1 for f in self.findings if f.severity == "MAJOR") @property def verdict(self) -> str: return "DENOMINATOR MISMATCH" if self.n_flag else "OK" def _split_row(line: str) -> list[str]: s = line.strip() if s.startswith("|"): s = s[1:] if s.endswith("|"): s = s[:-1] return [c.strip() for c in s.split("|")] def _reference_counts(text: str): """Return (neg_total, pos_total) summed over reference-standard rows, or None.""" neg = pos = 0 seen = False lines = text.splitlines() i, n = 0, len(lines) while i < n: if "|" in lines[i] and i + 1 < n and SEP_RE.match(lines[i + 1]) and "-" in lines[i + 1]: j = i + 2 while j < n and "|" in lines[j] and lines[j].strip(): cells = _split_row(lines[j]) if cells: label = cells[0] cm = re.search(r"\b(\d[\d,]*)\b", " ".join(cells[1:])) if cm: cnt = int(cm.group(1).replace(",", "")) if NEG_LABEL_RE.search(label): neg += cnt; seen = True elif POS_LABEL_RE.search(label): pos += cnt; seen = True j += 1 i = j else: i += 1 return (neg, pos) if seen else None def audit(text: str, source: str) -> Report: rep = Report(source=source) ref = _reference_counts(text) sens = SENS_RE.search(text) spec = SPEC_RE.search(text) if ref and (sens or spec): neg_ct, pos_ct = ref sens_denom = int(sens.group(2)) if sens else None spec_denom = int(spec.group(2)) if spec else None if sens_denom is not None and pos_ct and sens_denom != pos_ct: rep.findings.append(Finding( "DTA_DENOMINATOR_MISMATCH", "MAJOR", f"sensitivity denominator {sens_denom} (disease-positive) ≠ reference-standard " f"positive count {pos_ct} from the characteristics table")) if spec_denom is not None and neg_ct and spec_denom != neg_ct: rep.findings.append(Finding( "DTA_DENOMINATOR_MISMATCH", "MAJOR", f"specificity denominator {spec_denom} (disease-negative) ≠ reference-standard " f"negative count {neg_ct} from the characteristics table")) # note when the grand totals still agree — that is what hides it if rep.n_flag and sens_denom and spec_denom and neg_ct + pos_ct == sens_denom + spec_denom: rep.findings.append(Finding( "GRAND_TOTAL_AGREES", "INFO", f"grand totals agree ({neg_ct}+{pos_ct} = {sens_denom}+{spec_denom} = " f"{neg_ct + pos_ct}), so a totals-only check passes — the split-level " f"mismatch is the defect")) c, o, u = CORRECT_RE.search(text), OVER_RE.search(text), UNDER_RE.search(text) sn = STAGE_N_RE.search(text) if c and o and u and sn: s = int(c.group(1)) + int(o.group(1)) + int(u.group(1)) tot = int(sn.group(1)) if s != tot: rep.findings.append(Finding( "STAGE_ROWSUM", "MAJOR", f"correctly {c.group(1)} + over {o.group(1)} + under {u.group(1)} = {s} ≠ n={tot}")) return rep def format_report(rep: Report, color: bool) -> str: tag = {"OK": "\033[92m", "DENOMINATOR MISMATCH": "\033[91m"}.get(rep.verdict, "") if color else "" end = "\033[0m" if color else "" out = [f"{tag}== {rep.verdict} =={end} {rep.source}", f"mismatches={rep.n_flag}"] if not rep.findings: out.append("sens/spec denominators reconcile with the reference-standard category counts.") return "\n".join(out) for f in sorted(rep.findings, key=lambda x: (x.severity != "MAJOR", x.kind, x.detail)): out.append(f"[{f.severity:<5}] {f.kind} {f.detail}") return "\n".join(out) def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--strict", action="store_true", help="exit 1 on any mismatch") ap.add_argument("--quiet", action="store_true", help="suppress the report; exit code only") ap.add_argument("--json", action="store_true", help="emit JSON instead of a text report") args = ap.parse_args(argv) try: text = open(args.manuscript, encoding="utf-8").read() except OSError as e: print(f"error: cannot read manuscript: {e}", file=sys.stderr) return 2 rep = audit(text, args.manuscript) if not args.quiet: if args.json: print(json.dumps({"detector": "check_dta_denominators", "source": rep.source, "verdict": rep.verdict, "findings": [asdict(f) for f in rep.findings]}, ensure_ascii=False, indent=2)) else: print(format_report(rep, color=sys.stdout.isatty())) return 1 if (args.strict and rep.n_flag) else 0 if __name__ == "__main__": raise SystemExit(main()) -
check_editorial_impression.py 21.6 KB
#!/usr/bin/env python3 """Editorial-impression / defensiveness gate (self-review §L) — the counterweight pass. The rest of the MedSci-Audit stack minimizes *rejection-for-cause* (the floor): fabricated citations, drifting numbers, overclaims, missing checklist items. Several of those gates raise the floor by *adding* material — a hedge, a caveat, a disclosure, a checklist row — and nothing in the stack pushes back. Iterated, a manuscript monotonically over-hardens: a confident narrative turns into a defensive audit that an editor reads as a risk signal even when every individual gate finding was correct. This gate is the missing opposite force. It does not relax any integrity gate; it scans the *manuscript as a whole* for editorial-impression risks and recommends SUBTRACTION — REMOVE, MOVE, or TIGHTEN — so the accurate content the gates secured is also read confidently. Every finding is advisory (Minor / impression) and NON-BLOCKING: this gate never returns a submission blocker. It raises the ceiling; it does not gate the floor. HEDGE_DENSITY defensive-caveat tokens per 1,000 body-narrative words exceed a threshold — the prose hedges faster than it asserts. TIGHTEN. HEDGE_REPEAT one caveat motif ("no deployable claim", "not generalizable", "none evaluated here") repeats >=N times across body + abstract. Say it once, firmly. TIGHTEN. AUDIT_IN_BODY provenance/audit minutiae (SHA / git commit / unit-test / post-lock timeline / manifest / seed=N / audit trail) appear in the Introduction / Results / Discussion narrative rather than a Methods reproducibility statement or a supplement. MOVE. LIMITATIONS_VOLUME the Limitations passage enumerates more than N discrete items; a wall of limitations reads as a rebuttal letter. TIGHTEN. ABSTRACT_CAVEAT_LOAD the Abstract carries >=N caveat clauses; the headline result is buried under qualifiers before a reader reaches it. TIGHTEN. BURIED_DEFENSE a strong numeric robustness / sensitivity result sits only in the Limitations / supplement, with no robustness mention in Results. This is the inverse of the scope-coherence gate: scope-coherence pushes a *weak* analysis out of Results; BURIED_DEFENSE pulls a *strong* confound rebuttal back into Results. MOVE (promote). Conservative by construction: each probe fires only on an explicit, locatable signal, to keep false positives low on a widely-used skill. The gate needs IMRAD-style headings to locate sections; with none it degrades to a whole-document density read. INPUTS --manuscript manuscript markdown/text (required). thresholds --hedge-per-1k (10.0), --repeat-threshold (3), --limitations-max (6), --abstract-caveat-max (2). A probe fires when its count exceeds the max. OUTPUT A reconciliation table (stdout) and, with --out, a JSON artifact: {manuscript, claims[{verdict, severity, action, detail, where}], summary} Every claim is severity "Minor" with an action of REMOVE / MOVE / TIGHTEN. Exit code is always 0 for the findings themselves (advisory); --strict is accepted for CLI parity with the other gates but never blocks, since this gate emits no Major. Stdlib-only (json / re / argparse / pathlib). Exit codes: 0 clean or advisory findings, 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # --------------------------------------------------------------------------- # # Section segmentation # --------------------------------------------------------------------------- # HEADING_RE = re.compile(r"^(#{1,6})\s*\*{0,2}(.+?)\*{0,2}\s*$", re.MULTILINE) # Narrative regions where defensive prose and out-of-place audit minutiae read worst. # Methods is deliberately excluded (a reproducibility statement belongs there), as is # any supplement / availability / declarations region. BODY_NARRATIVE = {"introduction", "results", "discussion", "conclusion", "limitations"} def classify_heading(h: str) -> str: t = h.lower().strip() if "abstract" in t or t == "summary": return "abstract" if any(k in t for k in ( "data availability", "code availability", "availability", "supplement", "appendix", "acknowledg", "funding", "declaration", "competing interest", "conflict of interest", "reproducibility", "references", "author contribution", )): return "supplement" if "limitation" in t: return "limitations" if "introduction" in t or "background" in t: return "introduction" if any(k in t for k in ( "method", "material", "statistical analys", "study design", "patients and", "data collection", "study population", )): return "methods" if "result" in t or "finding" in t: return "results" if "discussion" in t: return "discussion" if "conclusion" in t: return "conclusion" return "other" def segment(text: str) -> list[tuple[str, str]]: """Return an ordered list of (region, body_text) pairs. Text before the first heading is a 'preamble' region. Region names follow classify_heading().""" matches = list(HEADING_RE.finditer(text)) regions: list[tuple[str, str]] = [] if not matches: return [("preamble", text)] if matches[0].start() > 0: pre = text[: matches[0].start()].strip() if pre: regions.append(("preamble", pre)) for i, m in enumerate(matches): start = m.end() end = matches[i + 1].start() if i + 1 < len(matches) else len(text) body = text[start:end].strip() regions.append((classify_heading(m.group(2)), body)) return regions def region_text(regions: list[tuple[str, str]], names: set[str]) -> str: return "\n".join(b for r, b in regions if r in names) def abstract_text(regions: list[tuple[str, str]], full: str) -> str: """The Abstract region; fall back to the text before the first Introduction/Methods heading (a structured abstract without its own heading), capped to ~350 words.""" abs_regions = [b for r, b in regions if r == "abstract"] if abs_regions: return "\n".join(abs_regions) # Fallback: preamble + everything up to the first intro/methods region. out: list[str] = [] for r, b in regions: if r in ("introduction", "methods", "results", "discussion"): break if r in ("preamble", "other"): out.append(b) joined = "\n".join(out) words = joined.split() return " ".join(words[:350]) if len(words) > 350 else joined def word_count(text: str) -> int: return sum(1 for w in text.split() if any(c.isalpha() for c in w)) def sentences(text: str) -> list[str]: # Lightweight sentence split on ., !, ? followed by whitespace. parts = re.split(r"(?<=[.!?])\s+", text.strip()) return [s.strip() for s in parts if s.strip()] # --------------------------------------------------------------------------- # # Lexicons # --------------------------------------------------------------------------- # # Defensive caveats — explicitly hedging phrases, not ordinary modal verbs. Stacking # these is the defensiveness tell HEDGE_DENSITY measures. CAVEAT = re.compile( r"\bcaveats?\b|should be interpreted with caution|with caution\b|" r"must be interpreted|interpreted? with care|" r"cannot be (?:inferred|established|excluded|determined|generaliz\w+|ruled out|drawn|assumed)|" r"no (?:causal|deployable|clinical|definitive) (?:claim|inference|conclusion|relationship)|" r"not (?:be )?(?:generaliz\w+|definitive|conclusive|deployable|warranted)|" r"\bpreliminary\b|\bexploratory\b|hypothesis[-\s]generating|" r"warrants? (?:caution|further (?:study|validation|research|investigation))|" r"remains? (?:unclear|uncertain|to be (?:established|determined|confirmed))|" r"\blimited (?:by|generaliz\w+|sample|to|in scope)|" r"single[-\s](?:cent(?:er|re)|institution|site)|retrospective (?:design|nature)|" r"should not be (?:used|interpreted|construed)|" r"do(?:es)? not (?:establish|imply|permit|support|prove)|" r"not (?:yet )?(?:ready|validated|intended) for (?:clinical|deployment|practice)|" r"\bunderpowered\b|\bmodest\b|no (?:firm|strong) (?:conclusion|inference)", re.IGNORECASE) # Repeated caveat motifs (HEDGE_REPEAT): family key -> regex. A family repeating across # body + abstract above the threshold should be stated once, firmly. MOTIFS: dict[str, re.Pattern] = { "no_deployable_claim": re.compile( r"no (?:deployable|deployment|clinical|practice|diagnostic) (?:claim|use|recommendation)|" r"not (?:ready|intended|validated) for (?:clinical|deployment|practice)", re.IGNORECASE), "not_generalizable": re.compile(r"not (?:be )?generaliz\w+|limited generaliz\w+", re.IGNORECASE), "none_evaluated_here": re.compile( r"(?:none|not|no \w+) (?:were |was |are |is )?evaluated (?:here|in this (?:study|work|analysis))|" r"not (?:assessed|examined|tested) (?:here|in this (?:study|work))", re.IGNORECASE), "no_causal": re.compile(r"no causal (?:claim|inference|relationship|conclusion|interpretation)", re.IGNORECASE), "hypothesis_generating": re.compile(r"hypothesis[-\s]generating", re.IGNORECASE), "interpret_with_caution": re.compile( r"interpret\w* with caution|should be interpreted with caution|with caution", re.IGNORECASE), "single_center": re.compile(r"single[-\s](?:cent(?:er|re)|institution|site)", re.IGNORECASE), "retrospective_design": re.compile(r"retrospective (?:design|nature|study|cohort)", re.IGNORECASE), "preliminary": re.compile(r"\bpreliminary\b", re.IGNORECASE), } # Motifs that are factual study-design descriptors rather than defensive caveats. A # single-centre / retrospective study must state its design in Methods, and naming it # again in the Abstract and Limitations is normal, not over-hedging. Count these only # in the non-Methods narrative so an honestly-written single-centre retrospective study # (the most common observational design) is not flagged for stating a true fact. FACTUAL_DESCRIPTOR_MOTIFS = {"single_center", "retrospective_design"} # Provenance / audit minutiae that belong in Methods or a supplement, not the narrative. AUDIT = re.compile( r"\bsha-?256\b|\bmd5\b|\bchecksum\b|(?:git\s+)?(?:commit|hash|sha)\s*[:=]?\s*[0-9a-f]{7,40}\b|" r"\bcommit\s+[0-9a-f]{7,40}\b|\bunit[-\s]?test(?:s|ing|ed)?\b|\bpost[-\s]?lock\b|" r"seed\s*=\s*\d+|\brandom seed\s+\d+\b|\baudit trail\b|" r"reproducibility (?:manifest|hash|record)|data lock(?:ed)? on|\bcontent[-\s]hash\b", re.IGNORECASE) # Robustness / sensitivity vocabulary (for BURIED_DEFENSE). ROBUST = re.compile( r"sensitivity analys\w+|robustness|leave[-\s]one[-\s]out|leave[-\s]pair[-\s]out|" r"\bE[-\s]?value\b|remained (?:significant|robust|consistent|unchanged|stable)|" r"did not (?:materially |substantially |meaningfully )?(?:change|alter|differ)|" r"results were (?:similar|consistent|robust|unchanged)|consistent across|" r"after (?:excluding|adjusting for|accounting for)|tipping[-\s]point", re.IGNORECASE) # A strong numeric token next to a robustness statement makes it Results-worthy. NUMERIC = re.compile( r"\b\d+\.\d+\b|\b\d{1,3}%|95%\s*ci|(?:OR|HR|RR|AUC|aHR|aOR)\s*[=:]?\s*\d|" r"p\s*[<=>]\s*0?\.\d+", re.IGNORECASE) ORDINALS = ["first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eighth", "ninth", "tenth"] # --------------------------------------------------------------------------- # # Probes # --------------------------------------------------------------------------- # def probe_hedge_density(regions, body, threshold) -> list[dict]: words = word_count(body) if words < 80: # too little narrative to judge density reliably return [] n = len(CAVEAT.findall(body)) density = n / words * 1000 if density > threshold: return [{ "verdict": "HEDGE_DENSITY", "severity": "Minor", "action": "TIGHTEN", "detail": (f"defensive-caveat density is {density:.1f} per 1,000 body words " f"({n} caveat tokens / {words} words; threshold {threshold:.0f}); the " f"prose hedges faster than it asserts — keep the load-bearing caveats, " f"cut the reflexive ones"), "where": f"body narrative ({words} words)", }] return [] def probe_hedge_repeat(regions, full, threshold) -> list[dict]: non_methods = "\n".join(b for r, b in regions if r != "methods") claims = [] for key, rx in MOTIFS.items(): # Factual design descriptors are not over-hedging when stated in Methods; # count them only in the non-Methods narrative. hay = non_methods if key in FACTUAL_DESCRIPTOR_MOTIFS else full n = len(rx.findall(hay)) if n >= threshold: m = rx.search(hay) phrase = m.group(0).strip() if m else key claims.append({ "verdict": "HEDGE_REPEAT", "severity": "Minor", "action": "TIGHTEN", "detail": (f"the caveat '{phrase}' (motif: {key}) appears {n} times across " f"the narrative; state it once, firmly, and remove the repeats"), "where": phrase[:120], }) return claims def probe_audit_in_body(regions) -> list[dict]: body = region_text(regions, BODY_NARRATIVE) claims = [] seen = set() for m in AUDIT.finditer(body): tok = m.group(0).strip().lower() norm = re.sub(r"[0-9a-f]{7,40}", "<hash>", tok) if norm in seen: continue seen.add(norm) claims.append({ "verdict": "AUDIT_IN_BODY", "severity": "Minor", "action": "MOVE", "detail": (f"provenance/audit token '{m.group(0).strip()}' appears in the " f"Introduction/Results/Discussion narrative; move reproducibility " f"detail to a Methods statement or a supplement"), "where": body[max(0, m.start() - 40):m.end() + 40].strip()[:160], }) return claims def probe_limitations_volume(regions, full, max_items) -> list[dict]: lim = region_text(regions, {"limitations"}) if not lim: # Inline limitations paragraph inside Discussion. disc = region_text(regions, {"discussion", "conclusion"}) m = re.search(r"(?:our |this )?stud(?:y|ies) (?:has|have)[^.]{0,40}limitations?|" r"several (?:important )?limitations|limitations? (?:of this|warrant)", disc, re.IGNORECASE) if not m: return [] lim = disc[m.start():] # Count discrete items: max of ordinal markers, (N) enumerators, bullet lines. low = lim.lower() n_ord = sum(1 for o in ORDINALS if re.search(rf"(?:^|[\s(,;])\b{o}\b\s*,", low)) n_enum = len(set(re.findall(r"\((\d{1,2})\)", lim))) n_bullet = len(re.findall(r"^\s*[-*]\s+\S", lim, re.MULTILINE)) n = max(n_ord, n_enum, n_bullet) if n > max_items: return [{ "verdict": "LIMITATIONS_VOLUME", "severity": "Minor", "action": "TIGHTEN", "detail": (f"the Limitations passage enumerates {n} discrete items " f"(threshold {max_items}); consolidate related items so the section " f"reads as honest disclosure, not a rebuttal letter"), "where": f"Limitations ({n} items)", }] return [] def probe_abstract_caveat_load(regions, full, max_caveats) -> list[dict]: abs_t = abstract_text(regions, full) if word_count(abs_t) < 40: return [] caveat_sents = [s for s in sentences(abs_t) if CAVEAT.search(s)] n = len(caveat_sents) if n > max_caveats: return [{ "verdict": "ABSTRACT_CAVEAT_LOAD", "severity": "Minor", "action": "TIGHTEN", "detail": (f"the Abstract carries {n} caveat-bearing clauses (threshold " f"{max_caveats}); lead with the result and keep at most one or two " f"essential qualifiers so the headline is not buried"), "where": (caveat_sents[0][:140] if caveat_sents else "Abstract"), }] return [] def probe_buried_defense(regions) -> list[dict]: results = region_text(regions, {"results"}) buried_src = region_text(regions, {"limitations", "supplement"}) if not buried_src: return [] # If Results already discusses robustness, nothing is buried. if ROBUST.search(results): return [] claims = [] for s in sentences(buried_src): if ROBUST.search(s) and NUMERIC.search(s): claims.append({ "verdict": "BURIED_DEFENSE", "severity": "Minor", "action": "MOVE", "detail": ("a numeric robustness/sensitivity result sits in the " "Limitations/supplement with no robustness mention in Results; " "promote it into Results — it is evidence for the finding, not a " "caveat against it"), "where": s[:160], }) break # one promotion recommendation is enough return claims # --------------------------------------------------------------------------- # # Driver # --------------------------------------------------------------------------- # def check(text: str, *, hedge_per_1k: float, repeat_threshold: int, limitations_max: int, abstract_caveat_max: int) -> list[dict]: regions = segment(text) body = region_text(regions, BODY_NARRATIVE) if not body: # no IMRAD headings — degrade to a whole-document read (minus supplement) body = region_text(regions, {"preamble", "other"}) or text claims: list[dict] = [] claims += probe_hedge_density(regions, body, hedge_per_1k) claims += probe_hedge_repeat(regions, text, repeat_threshold) claims += probe_audit_in_body(regions) claims += probe_limitations_volume(regions, text, limitations_max) claims += probe_abstract_caveat_load(regions, text, abstract_caveat_max) claims += probe_buried_defense(regions) return claims def analyze(manuscript: str, **kw) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8"), **kw) by_action = {"REMOVE": 0, "MOVE": 0, "TIGHTEN": 0} for c in claims: by_action[c["action"]] = by_action.get(c["action"], 0) + 1 return { "manuscript": str(p), "claims": claims, "summary": { "n_claims": len(claims), "by_action": by_action, "verdict": "IMPRESSION_FLAGS" if claims else "OK", }, } def render(result: dict) -> str: lines = ["| Check | Action | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['action']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | narrative reads confidently; no subtraction needed |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser( description="Editorial-impression / defensiveness gate (§L) — advisory, non-blocking.") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="accepted for CLI parity; this gate emits no Major, so it never blocks") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") ap.add_argument("--hedge-per-1k", type=float, default=10.0, help="HEDGE_DENSITY: caveat tokens per 1,000 body words before firing (default 10)") ap.add_argument("--repeat-threshold", type=int, default=3, help="HEDGE_REPEAT: motif occurrences across body+abstract before firing (default 3)") ap.add_argument("--limitations-max", type=int, default=6, help="LIMITATIONS_VOLUME: discrete Limitations items allowed (default 6)") ap.add_argument("--abstract-caveat-max", type=int, default=2, help="ABSTRACT_CAVEAT_LOAD: caveat clauses allowed in the Abstract (default 2)") args = ap.parse_args() result = analyze( args.manuscript, hedge_per_1k=args.hedge_per_1k, repeat_threshold=args.repeat_threshold, limitations_max=args.limitations_max, abstract_caveat_max=args.abstract_caveat_max, ) if not args.quiet: print("=" * 41) print(" Editorial Impression / Defensiveness (§L)") print("=" * 41) print(render(result)) print() s = result["summary"] if s["n_claims"]: ba = s["by_action"] print(f"IMPRESSION flags: {s['n_claims']} advisory finding(s) " f"(REMOVE {ba['REMOVE']} / MOVE {ba['MOVE']} / TIGHTEN {ba['TIGHTEN']}). " f"Non-blocking — these raise the ceiling, they do not gate submission.") else: print("OK: narrative reads confidently; no subtraction recommended.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_editorial_impression", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") # Advisory: never blocks. --strict is accepted for parity but this gate has no Major. return 0 if __name__ == "__main__": sys.exit(main()) -
check_effect_stability.py 10.3 KB
#!/usr/bin/env python3 """Effect-stability gate — a wide interval is a direction, not a magnitude (self-review Phase 2.5). The errors here are pure arithmetic on the printed cells, and two independent reviewers hit the same number when they occur: 1. UNSTABLE_EFFECT_ESTIMATE an OR / HR / RR / IRR reported in the Abstract or Conclusions whose 95% CI upper/lower ratio exceeds ~10 (default --ratio-threshold 10). "OR 24.0; 95% CI 3.0-240.0" is a 80-fold interval: the data support a direction, not the point estimate. Fires only when the estimate is presented as a magnitude with NO co-located caveat (exploratory / hypothesis-generating / underpowered / imprecise / unstable / wide CI / interpret with caution) — the same suppression discipline as check_null_calibration. 2. EPV_LOW events / covariates < 10 (events per variable). A model estimated on too few events per covariate produces exactly the unstable estimates above; fires only when both an event count and a covariate count are printed in proximity. Both are computable from the manuscript's own numbers. Deterministic and conservative: it reads only the headline regions for the ratio (so a wide CI on a clearly-labelled exploratory subgroup deep in the Results does not fire), and it requires the caveat to be absent from the estimate's own neighbourhood. INPUT --manuscript manuscript markdown/text (required). --ratio-threshold N CI upper/lower ratio above which an estimate is unstable (default 10; a 10-fold interval already spans an order of magnitude). OUTPUT (--out path) {"detector": "check_effect_stability", "manuscript", "claims": [{verdict, severity, detail, where}], "summary": {...}} UNSTABLE_EFFECT_ESTIMATE and EPV_LOW are Major candidates. Stdlib-only (re / json / argparse / pathlib). Exit codes: 0 clean (or report-only), 1 a Major claim exists (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path DEFAULT_RATIO_THRESHOLD = 10.0 # An effect estimate with a 95% CI: (a)OR / (a)HR / RR / IRR, point, low-high. # Longer kinds (aOR/aHR) precede OR/HR so they match first. The gap between the # point estimate and the CI is bounded and allowed to contain "95%" (which has # digits), so `.` rather than `\D` is used there. EFFECT_RE = re.compile( r"\b(?:adjusted\s+)?(?P<kind>aOR|aHR|OR|HR|RR|IRR)\s*[=:]?\s*" r"(?P<pt>[0-9]+(?:\.[0-9]+)?)" r".{0,22}?(?:95\s*%\s*)?(?:CI|confidence\s+interval)[^0-9]{0,5}" r"(?P<lo>[0-9]+(?:\.[0-9]+)?)\s*(?:[–—\-]|to)\s*(?P<hi>[0-9]+(?:\.[0-9]+)?)", re.IGNORECASE, ) # A co-located caveat that acknowledges the instability: if present, the author is # already reporting the estimate as a direction, so the gate stays silent. INSTABILITY_CAVEAT = re.compile( r"exploratory|hypothesis[-\s]?generating|underpowered|imprecise|unstable|" r"wide\s+(?:confidence\s+interval|CI)|(?:confidence\s+interval|CI)\s+(?:is\s+)?wide|" r"interpret(?:ed)?\s+with\s+caution|should\s+be\s+interpreted\s+as\s+(?:a\s+)?direction|" r"not\s+(?:a\s+)?precise|direction(?:al)?\s+(?:rather\s+than|not)|caution", re.IGNORECASE, ) # EPV operands. EVENTS_RE = re.compile(r"\b([0-9]+)\s+events?\b", re.IGNORECASE) COVAR_RE = re.compile( r"\b([0-9]+)\s+(?:covariates?|predictors?|(?:independent\s+)?variables?|" r"degrees?\s+of\s+freedom|parameters?)\b", re.IGNORECASE, ) REGION_HEADINGS = re.compile( r"^#{1,4}\s*\*{0,2}(?:ABSTRACT|Abstract|CONCLUSIONS?|Conclusions?|DISCUSSION|Discussion|" r"Interpretation|Clinical Implications?|Summary)\*{0,2}\s*$", re.IGNORECASE | re.MULTILINE, ) _COLOCATE_WINDOW = 160 # chars each side of an estimate searched for a caveat _EPV_WINDOW = 130 # chars between an event count and a covariate count def headline_region(text: str) -> str: """Title + Abstract + Conclusion/Discussion regions + inline 'Conclusion:' clauses. Fallback: whole text. (Mirrors check_null_calibration.)""" spans: list[str] = [] mt = re.search(r"^#{1,6}\s+(.+)$", text, re.MULTILINE) if mt: spans.append(mt.group(1)) else: for line in text.splitlines(): if line.strip(): spans.append(line.strip()) break all_headings = [m.start() for m in re.finditer(r"^#{1,4}\s", text, re.MULTILINE)] for m in REGION_HEADINGS.finditer(text): s = m.end() nxt = next((h for h in all_headings if h > s), len(text)) spans.append(text[s:nxt]) for m in re.finditer(r"(?:^|\n)\s*\*{0,2}(?:Conclusions?|Interpretation)\*{0,2}\s*[:.]\s*(.+?)(?:\n\n|$)", text, re.IGNORECASE | re.DOTALL): spans.append(m.group(1)) if not spans: spans.append(text) return "\n".join(spans) def check(text: str, ratio_threshold: float = DEFAULT_RATIO_THRESHOLD) -> list[dict]: claims: list[dict] = [] # 1. UNSTABLE_EFFECT_ESTIMATE — headline regions only. region = headline_region(text) seen: set[str] = set() for m in EFFECT_RE.finditer(region): lo, hi = float(m.group("lo")), float(m.group("hi")) if lo <= 0 or hi <= lo: continue ratio = hi / lo if ratio <= ratio_threshold: continue window = region[max(0, m.start() - _COLOCATE_WINDOW):m.end() + _COLOCATE_WINDOW] if INSTABILITY_CAVEAT.search(window): continue # the author already flags this estimate as unstable key = m.group(0).lower() if key in seen: continue seen.add(key) claims.append({ "verdict": "UNSTABLE_EFFECT_ESTIMATE", "severity": "Major", "detail": (f"{m.group('kind').upper()} {m.group('pt')} has a 95% CI " f"{m.group('lo')}-{m.group('hi')} spanning {ratio:.0f}-fold " f"(> {ratio_threshold:g}x) in the Abstract/Conclusions with no co-located " f"caveat; a {ratio:.0f}-fold interval supports a direction, not the point " f"estimate — report it as such or add an imprecision/exploratory caveat"), "where": m.group(0).replace("\n", " ").strip()[:160], }) # 2. EPV_LOW — an event count and a covariate count printed in proximity. for em in EVENTS_RE.finditer(text): events = int(em.group(1)) win = text[em.start():em.end() + _EPV_WINDOW] cm = COVAR_RE.search(win) if not cm: # also look just before the event count win2 = text[max(0, em.start() - _EPV_WINDOW):em.start()] cm = COVAR_RE.search(win2) if not cm: continue covar = int(cm.group(1)) if covar <= 0: continue epv = events / covar if epv >= 10: continue claims.append({ "verdict": "EPV_LOW", "severity": "Major", "detail": (f"{events} events for {covar} covariates is {epv:.1f} events per variable " f"(< 10); the model is underpowered for stable coefficient estimation — " f"reduce covariates, use penalisation, or report the estimates as exploratory"), "where": text[em.start():em.start() + 120].replace("\n", " ").strip()[:160], }) break # one EPV finding is enough; the first printed pair is representative return claims def analyze(manuscript: str, ratio_threshold: float = DEFAULT_RATIO_THRESHOLD) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8"), ratio_threshold=ratio_threshold) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "manuscript": str(p), "claims": claims, "summary": { "n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "verdict": "MAJOR_CANDIDATE" if n_major else "OK", }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | no headline estimate has an order-of-magnitude interval |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Effect-stability gate (Phase 2.5).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--ratio-threshold", type=float, default=DEFAULT_RATIO_THRESHOLD, help="CI upper/lower ratio above which an estimate is unstable (default %(default)s)") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript, ratio_threshold=args.ratio_threshold) if not args.quiet: print("=" * 41) print(" Effect Stability (Phase 2.5)") print("=" * 41) print(render(result)) print() s = result["summary"] if s["n_major"]: print(f"MAJOR candidate: {s['n_major']} unstable effect estimate / low-EPV finding(s).") else: print("OK: no headline estimate spans an order of magnitude without a caveat.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_effect_stability", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_emphasis_density.py 6.5 KB
#!/usr/bin/env python3 """Inline-emphasis over-use gate — a typographic AI tell (self-review §J / humanize P25). LLM-drafted prose over-uses inline italic emphasis: single-word italics (*into*, *passive*, *same*), whole-clause italics (*a redesign of the relationship itself*), and mid-paragraph signposting. A human copy-editor strips almost all of it. This gate counts NON-allowlisted italic emphasis spans per 1,000 body words and flags an over-dense manuscript. Verdict: EMPHASIS_OVERUSE (Minor) italic-emphasis density exceeds the threshold after an allowlist of legitimate italics (statistical symbols, Latin phrases, journal/species terms) is removed. Escalation note added when a span is a whole clause (a long italic phrase, the strongest tell). Scope, by construction, to keep false positives low on a widely-used skill: * Only ITALIC (`*...*`) spans count. Bold (`**...**`) is NOT counted — a bold run-in lead-in ("**Study design.**") is a legitimate Nature/npj subheading style (manuscript-style-classical §1.2), so counting it would fight another rule. * Fenced code blocks and allowlisted italics are excluded. * Fires only when both the density AND the raw count clear a floor, so a short note with one stray italic never trips it. Exit codes: 0 clean/report-only, 1 with --strict when any Major (none — Minor only), 2 usage. Stdlib-only. Usage: python3 check_emphasis_density.py --manuscript manuscript.md \ [--out qc/emphasis_density.json] [--per-1000 5.0] [--strict] [--quiet] """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # An italic span: single * ... * that is not part of a ** ... ** bold span. ITALIC_RE = re.compile(r"(?<!\*)\*(?!\*)([^*\n]{1,120}?)\*(?!\*)") FENCE_RE = re.compile(r"```.*?```", re.S) WORD_RE = re.compile(r"[A-Za-z0-9']+") # Legitimate italics that must NOT count toward the tell. LATIN = { "in vivo", "in vitro", "ex vivo", "in situ", "in silico", "de novo", "post hoc", "a priori", "a posteriori", "et al", "et al.", "vs", "vs.", "versus", "i.e.", "e.g.", "per se", "et cetera", "ad hoc", "in utero", "in toto", "c-index", } # Single statistical symbols routinely italicised (P, t, r, n, F, z, d, k, R, b). STAT_SYMBOL = re.compile(r"^[A-Za-z](?:[\s=<>²]|$)|^[A-Za-z]\s*[<>=]") # A stat clause: an italic that is just a symbol + operator + number (e.g. "P = .03"). STAT_CLAUSE = re.compile(r"^[A-Za-z]\s*[=<>]\s*[.\d]") def _is_allowlisted(span: str) -> bool: s = span.strip() low = s.strip(".,;:").lower() if low in LATIN: return True if len(s) <= 2: # single letter / symbol (P, t, n, β) return True if STAT_SYMBOL.match(s) or STAT_CLAUSE.match(s): return True # a species / gene-like token: single capitalised italic word with no space if " " not in s and re.fullmatch(r"[A-Za-z][A-Za-z0-9\-]+", s) and s[:1].isupper(): # allow only if it looks like a proper noun/gene, not a plain emphasised word return bool(re.search(r"[A-Z].*[a-z].*[A-Z]|[0-9]", s)) # e.g. BRCA1, TP53 return False def check(text: str, per_1000: float) -> list[dict]: body = FENCE_RE.sub(" ", text) n_words = len(WORD_RE.findall(body)) if n_words == 0: return [] spans = [m.group(1) for m in ITALIC_RE.finditer(body)] flagged = [s for s in spans if not _is_allowlisted(s)] n = len(flagged) density = n * 1000.0 / n_words if n < 5 or density <= per_1000: return [] whole_clause = [s for s in flagged if len(WORD_RE.findall(s)) >= 6] detail = (f"{n} non-allowlisted italic emphasis spans in {n_words} body words " f"({density:.1f}/1000 > {per_1000:.1f} threshold) — an LLM typographic " f"tell; a human editor removes almost all inline emphasis") if whole_clause: detail += (f". {len(whole_clause)} are whole-clause italics (e.g. " f"\"*{whole_clause[0][:48]}*\") — the strongest tell; rewrite without italics") return [{ "verdict": "EMPHASIS_OVERUSE", "severity": "Minor", "detail": detail, "where": ", ".join(f"*{s[:24]}*" for s in flagged[:6]), }] def analyze(manuscript: str, per_1000: float) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8"), per_1000) return { "manuscript": str(p), "claims": claims, "summary": {"n_claims": len(claims), "n_major": 0, "n_flag": len(claims), "verdict": "REVIEW" if claims else "OK"}, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | inline-emphasis density within human range |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser( description="Inline-emphasis over-use gate — a typographic AI tell (§J / humanize P25).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--per-1000", type=float, default=5.0, help="italic-emphasis spans per 1000 words that trips the flag (default 5)") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major (none — this gate is Minor-only)") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript, args.per_1000) if not args.quiet: print("=" * 42) print(" Inline-emphasis over-use (§J / P25)") print("=" * 42) print(render(result)) print() n = result["summary"]["n_flag"] print("REVIEW: emphasis over-use." if n else "OK: inline-emphasis density within human range.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_emphasis_density", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_figure_citation.py 12.6 KB
#!/usr/bin/env python3 """Orphan figure / table gate — every numbered float must be cited in the body (self-review Phase 2.5d cross-reference). A figure or table that has a legend/caption but is never cited in the running text is an "orphan": a reviewer or production editor flags it, and a journal may refuse the float. This gate cross-checks each declared "Figure N." / "Table N." caption against at least one in-text "Figure N" / "Table N" citation elsewhere in the body. Verdict: FIGURE_ORPHAN (Minor) a figure with a caption "Figure N." has no in-text "Figure N" / "Fig. N" citation anywhere outside its caption. TABLE_ORPHAN (Minor) the same for a "Table N." caption. FIGURE_NOT_EMBEDDED the manuscript has figure captions but NO markdown image link (Minor; (``) anywhere, so every figure is captioned yet Major w/ --require- absent from the rendered output — the "complete" submission that embedded) ships with the legends and none of the pictures. Advisory by default (a drafting manuscript may keep figures as separate files); --require-embedded (the submission preflight) makes it Major. Conservative: fires only when ZERO images are embedded, never a per-figure guess, so it stays silent once any figure is embedded. Deterministic and caption-anchored: a line beginning "Figure N." / "Table N." (with optional **bold**) DECLARES float N; any "Figure N" / "Table N" mention on a DIFFERENT line CITES it. A float declared but never cited elsewhere is the orphan. This needs no section-boundary heuristic — the caption line itself is the anchor, so a caption that happens to reference another float still counts as citing that other float. Exit codes: 0 clean/report-only, 1 with --strict when any Major, 2 usage. Major is reachable two ways: FIGURE_ATTR_STALE is Major unconditionally, and FIGURE_NOT_EMBEDDED escalates to Major under --require-embedded. (This line used to read "none — Minor only", which was stale in the direction that matters: it described a submission-adjacent gate as unable to block.) Stdlib-only. Usage: python3 check_figure_citation.py --manuscript manuscript.md \ [--out qc/figure_citation.json] [--strict] [--quiet] """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # A caption/legend line: (optional **) Figure|Table N (.|:) ... CAPTION_RE = re.compile(r"^\s*\*{0,2}\s*(?P<kind>Figure|Fig\.?|Table)\s+(?P<num>\d+)\s*[.:]", re.I) # Any in-text mention: Figure N / Fig N / Fig. N / Table N (+ "Figures 1 and 2" heads), # with an OPTIONAL single-letter panel suffix so "Figure 3a" / "(Figure 3b)" registers # as citing Figure 3 (multi-panel figures are cited only by panel, and without this the # num-then-\b never matched "3a"). Panels never appear in CAPTION_RE ("Figure 3." names # the whole float), so caption<->citation correspondence is preserved; the trailing \b # after the optional letter keeps "Figure 3rd" / "3mg" from matching (no boundary # between the letter and a following word char). MENTION_RE = re.compile(r"\b(?P<kind>Figures?|Figs?\.?|Tables?)\s+(?P<num>\d+)(?P<panel>[a-zA-Z])?\b", re.I) # A markdown image embed: . Its presence is how a figure reaches the # rendered output; a manuscript with figure captions but zero image links ships # with every legend and no picture. IMG_LINK_RE = re.compile(r"!\[[^\]]*\]\([^)]+\)") # The author-contributions / CRediT heading. CREDIT_HEADING_RE = re.compile( r"^#{1,6}\s*\*{0,2}\s*(?:Authors?[’'\s]*\s*[Cc]ontributions?|CRediT[^\n]*)\*{0,2}\s*:?\s*$", re.MULTILINE) def _kind(raw: str) -> str: return "Table" if raw.lower().startswith("tab") else "Figure" def check(text: str, require_embedded: bool = False) -> list[dict]: lines = text.splitlines() declared: dict[tuple[str, int], int] = {} # (kind, num) -> caption line index for i, line in enumerate(lines): m = CAPTION_RE.match(line) if m: declared.setdefault((_kind(m.group("kind")), int(m.group("num"))), i) cited: set[tuple[str, int]] = set() for i, line in enumerate(lines): for m in MENTION_RE.finditer(line): key = (_kind(m.group("kind")), int(m.group("num"))) # a mention on any line other than this float's own caption line = a citation if declared.get(key) != i: cited.add(key) claims = [] for (kind, num), cap_line in sorted(declared.items(), key=lambda kv: (kv[0][0], kv[0][1])): if (kind, num) in cited: continue claims.append({ "verdict": "TABLE_ORPHAN" if kind == "Table" else "FIGURE_ORPHAN", "severity": "Minor", "detail": (f"{kind} {num} has a caption (line {cap_line + 1}) but is never cited " f"in the body; add an in-text '{kind} {num}' citation or remove the float"), "where": lines[cap_line].strip()[:120], }) # FIGURE_NOT_EMBEDDED: captioned figures but no image link anywhere in the file. # Conservative on purpose (only the zero-embed case, never a per-figure guess) so # it stays silent whenever any figure is embedded. Tables are inline markdown, not # embedded images, so they are exempt. figures = sorted((n, ln) for (k, n), ln in declared.items() if k == "Figure") if figures and not IMG_LINK_RE.search(text): # Advisory by default: a markdown manuscript with figures kept as separate # attachment files legitimately embeds no image. --require-embedded (the # submission preflight) escalates to Major, where captions-with-no-picture # is the "complete package that ships with the legends and none of the # figures" failure. sev = "Major" if require_embedded else "Minor" # ONE claim, because the condition above is document-level: `not IMG_LINK_RE.search(text)` # is true or false for the whole manuscript, never per figure. Emitting it once per caption # repeated a single fact N times — on the repo's own demo manuscripts, 8 claims for 3 # documents — which inflates every count downstream that treats a claim as a finding. nums = ", ".join(f"Figure {n}" for n, _ in figures) claims.append({ "verdict": "FIGURE_NOT_EMBEDDED", "severity": sev, "detail": (f"no image is embedded anywhere in the manuscript, while {len(figures)} " f"figure(s) are captioned ({nums}); confirm they are embedded or attached " f"as separate files before submission"), "where": lines[figures[0][1]].strip()[:120], }) # Author-contributions / CRediT figure-number attribution. A "prepared Figure 4" # attribution silently breaks when figures are renumbered or merged; the canonical # CRediT "Visualization" role carries no numbers and is drift-proof. Scanned ONLY # inside the author-contributions/CRediT section (never Results/Discussion, where # "Figure N" is a normal citation). cm = CREDIT_HEADING_RE.search(text) if cm: start = cm.end() nxt = re.search(r"^#{1,6}\s", text[start:], re.MULTILINE) region = text[start: start + nxt.start()] if nxt else text[start:] declared_fignums = {n for (k, n) in declared if k == "Figure"} fig_tokens = [int(m.group("num")) for m in MENTION_RE.finditer(region) if _kind(m.group("kind")) == "Figure"] if fig_tokens: claims.append({ "verdict": "AUTHOR_CONTRIB_FIGURE_REF", "severity": "Minor", "detail": ("the author-contributions/CRediT section attributes work by figure number " "(e.g. 'prepared Figure N'); this attribution breaks on any figure renumber or " "merge — use the CRediT 'Visualization' role, which carries no figure numbers"), "where": "author contributions", }) for num in sorted(set(fig_tokens)): if num not in declared_fignums: claims.append({ "verdict": "FIGURE_ATTR_STALE", "severity": "Major", "detail": (f"the author-contributions/CRediT section attributes Figure {num}, but no " f"Figure {num} is declared in the manuscript — a stale attribution left by a " f"figure renumber/merge (declared figures: " f"{', '.join(str(n) for n in sorted(declared_fignums)) or 'none'})"), "where": "author contributions", }) return claims def analyze(manuscript: str, require_embedded: bool = False) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8"), require_embedded=require_embedded) n_major = sum(1 for c in claims if c["severity"] == "Major") # Counted BY VERDICT, because the summary line names a kind of defect and `n_major`/`n_flag` # name a severity. This detector emits five verdicts, only two of which are orphans, so # "n_flag orphan float(s)" described the wrong thing whenever a non-orphan was the finding — # on the repo's own demo manuscripts it printed "3 orphan float(s)" with zero orphans present. n_orphan = sum(1 for c in claims if c["verdict"] in ("FIGURE_ORPHAN", "TABLE_ORPHAN")) n_not_embedded = sum(1 for c in claims if c["verdict"] == "FIGURE_NOT_EMBEDDED") return { "manuscript": str(p), "claims": claims, "summary": { "n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "n_orphan": n_orphan, "n_not_embedded": n_not_embedded, "verdict": "MAJOR_CANDIDATE" if n_major else ("REVIEW" if claims else "OK"), }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | every captioned figure/table is cited in the body |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser( description="Orphan figure/table gate — captioned floats must be cited (Phase 2.5d).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major (a captioned figure with no embedded image under --require-embedded)") ap.add_argument("--require-embedded", action="store_true", help="submission context: escalate FIGURE_NOT_EMBEDDED to Major (figures must be " "embedded in the manuscript, not kept as separate files)") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript, require_embedded=args.require_embedded) if not args.quiet: print("=" * 44) print(" Orphan figure / table citation (§2.5d)") print("=" * 44) print(render(result)) print() s = result["summary"] # Say what was found, by name. Each part appears only when it happened, so the line can no # longer report a count of a thing that is not there. parts = [] if s["n_orphan"]: parts.append(f"{s['n_orphan']} orphan float(s)") if s["n_not_embedded"]: parts.append("no image embedded for the captioned figure(s)") other = s["n_claims"] - s["n_orphan"] - s["n_not_embedded"] if other: parts.append(f"{other} other finding(s)") if parts: level = "MAJOR" if s["n_major"] else "REVIEW" print(f"{level}: " + "; ".join(parts) + ".") else: print("OK: every captioned float is cited and embedded.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_figure_citation", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_incorporation_bias.py 8.6 KB
#!/usr/bin/env python3 """Incorporation-bias gate — the reference standard and a reported predictor are the same construct (self-review Phase 2.5, category B. Reference Standard). A reference standard defined by lesion trajectory can overlap with a trajectory predictor. If absence of growth defines a benign outcome, an association between growth and malignancy can be partly definitional rather than independent predictive evidence. This is textbook incorporation bias, and its commonest form — a size/trajectory reference standard with a size/trajectory predictor — is deterministic from the Methods and Results text alone: INCORPORATION_BIAS (Major) the reference-standard / outcome DEFINITION is stated in trajectory terms (resolution, regression, decrease, stability, no growth, progression, growth), AND a variable reported as *associated with* the outcome (carrying an OR/HR/RR or "associated with / predictor of") is itself a trajectory variable (growth, interval change, increase/decrease in size). The predictor is not independent of the reference standard. Deterministic and conservative. It reads trajectory tokens ONLY from the reference-standard/outcome-defining sentences (not an incidental "we measured growth rate" in Methods), requires the reported association to name the outcome in the same sentence, and stays silent when the manuscript already discloses the overlap ("incorporation bias", "partly definitional", "not independent of the reference standard"). It deliberately covers only the trajectory sub-class; the broader "framework-under-validation supplied the endpoint" instances are left to the prose review probes, which are not deterministic from text alone. INPUT --manuscript manuscript markdown/text (required). OUTPUT (--out path) {"detector": "check_incorporation_bias", "manuscript", "claims":[{verdict, severity, detail, where}], "summary":{...}} Stdlib-only (re / json / argparse / pathlib). Exit codes: 0 clean (or report-only), 1 a Major claim exists (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # A reference-standard / outcome DEFINING heading. REF_HEADING_RE = re.compile( r"^#{1,4}\s*\*{0,2}\s*(?:Reference standard|Outcome(?: definition| assessment| measure)?|" r"Ground truth|Gold standard|Definition of (?:benign|malignan\w+|the outcome))\*{0,2}\s*:?\s*$", re.IGNORECASE | re.MULTILINE) # A sentence that DEFINES the benign/malignant outcome or the reference standard. REF_CUE_SENTENCE_RE = re.compile( r"[^.\n]*\b(?:classified (?:as )?(?:benign|malignan\w+)" r"|(?:benign|malignan\w+)\s+(?:was|were|is|are)?\s*(?:defined|classified|confirmed|considered)" r"|reference standard (?:was|were|comprised|consisted|included|is|are)" r"|benign if|malignan\w+ if)\b[^.\n]*\.", re.IGNORECASE) # Trajectory / size-change vocabulary — the family that a size reference standard # and a size predictor share. TRAJECTORY_RE = re.compile( r"\b(?:complete\s+)?resolution\b|\bregress\w+|\bdecreas\w+|\bshrink\w+|\bshrank\b|\bstabilit\w+" r"|\bstable\b|\bno growth\b|\bnot? grow\w*|\bunchanged\b|\bgrowth\b|\bgrew\b|\bgrow\w+" r"|\bprogress\w+|\benlarg\w+|increas\w+\s+in\s+(?:size|diameter)|interval change|size change" r"|\btrajector\w+", re.IGNORECASE) # An association marker: a reported effect measure or "associated with / predictor of". ASSOC_MARKER_RE = re.compile( r"\b(?:a?OR|a?HR|RR|odds ratio|hazard ratio|risk ratio)\b" r"|associated with|predictor of|risk factor for|predict\w*\s+(?:malignan|the outcome)", re.IGNORECASE) # A trajectory-named PREDICTOR variable. PREDICTOR_TRAJ_RE = re.compile( r"\bgrowth\b|\bgrew\b|interval change|size change|change in (?:size|diameter)" r"|increas\w+\s+in\s+(?:size|diameter)|\bdecreas\w+|\bresolution\b|\btrajector\w+|\benlarg\w+", re.IGNORECASE) # The outcome named in the association sentence. OUTCOME_RE = re.compile(r"malignan\w+|\bbenign\b|\bcancer\b|carcinoma|the outcome", re.IGNORECASE) # The manuscript already names the overlap — do not fire. DISCLOSURE_RE = re.compile( r"incorporation bias|partly definitional|not independent of the reference standard" r"|circular by construction|definitional(?:ly)?\s+(?:linked|related|confounded)|" r"by construction (?:linked|related|not independent)", re.IGNORECASE) _SENT_SPLIT = re.compile(r"(?<=[.!?])\s+(?=[A-Z(\"'])") def _ref_standard_text(text: str) -> str: spans = [] all_h = [m.start() for m in re.finditer(r"^#{1,4}\s", text, re.MULTILINE)] for m in REF_HEADING_RE.finditer(text): s = m.end() nxt = next((h for h in all_h if h > s), len(text)) spans.append(text[s:nxt]) for m in REF_CUE_SENTENCE_RE.finditer(text): spans.append(m.group(0)) return "\n".join(spans) def check(text: str) -> list[dict]: if DISCLOSURE_RE.search(text): return [] # the overlap is disclosed; not a hidden incorporation bias ref_text = _ref_standard_text(text) if not ref_text or not TRAJECTORY_RE.search(ref_text): return [] # the reference standard is not trajectory-defined for block in re.split(r"\n\s*\n|\n#{1,6}\s", text): for sent in _SENT_SPLIT.split(block): if (ASSOC_MARKER_RE.search(sent) and PREDICTOR_TRAJ_RE.search(sent) and OUTCOME_RE.search(sent)): pm = PREDICTOR_TRAJ_RE.search(sent) return [{ "verdict": "INCORPORATION_BIAS", "severity": "Major", "detail": (f"the reference standard defines the outcome by size trajectory " f"(resolution / decrease / stability / growth), and '{pm.group(0)}' — a " f"trajectory variable — is reported as associated with the outcome: the " f"predictor is built into the reference standard, so the association is " f"partly definitional. Use a predictor independent of the reference " f"standard, or disclose the overlap explicitly"), "where": sent.strip().replace("\n", " ")[:160], }] return [] def analyze(manuscript: str) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8")) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "manuscript": str(p), "claims": claims, "summary": {"n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "verdict": "MAJOR_CANDIDATE" if n_major else "OK"}, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | reference standard and reported predictors are independent |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Incorporation-bias gate (Phase 2.5, reference standard).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript) if not args.quiet: print("=" * 41) print(" Incorporation Bias (Phase 2.5)") print("=" * 41) print(render(result)) print() s = result["summary"] print(f"MAJOR candidate: {s['n_major']} incorporation-bias finding(s)." if s["n_major"] else "OK: reference standard and reported predictors are independent.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_incorporation_bias", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_nested_group_comparison.py 6.2 KB
#!/usr/bin/env python3 """check_nested_group_comparison.py — flag a baseline/characteristics table that reports a P value comparing an analysed subset against the parent cohort that contains it. When authors answer a selection-bias comment with a "representativeness" table, they routinely compare the analysed subset (n=33) with the "full cohort" (n=132) that *includes* those 33 patients, and print P values from it. The two groups are nested, so the two-group test is not merely uninformative — it is invalid. The informative contrast is subset vs remainder (n=99). This is deterministic: the column headers announce the two n's and their labels verbatim. Fires when a table has (a) two column headers each carrying an ``n = N``, (b) one header labelled subset/sub-cohort/correlated/analysed/surgical/pathology and the other labelled full/total/overall/entire/whole cohort, and (c) a P-value column. Also on prose of the form "compared … between the … subset and the … cohort". Stdlib-only. Reads the manuscript, never writes it. Usage: python3 check_nested_group_comparison.py --manuscript paper.md [--strict] [--quiet] [--json] Exit: 0 clean; with --strict, 1 on any NESTED_GROUP_TEST; 2 on input/usage error. """ from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass, field, asdict HEADER_N_RE = re.compile(r"\bn\s*=\s*([0-9][0-9,]*)", re.I) SUBSET_RE = re.compile(r"\b(subset|sub-?cohort|correlated|analy[sz]ed|surgical|patholog\w*|resected|with .*patholog)", re.I) FULL_RE = re.compile(r"\b(full|total|overall|entire|whole)\b", re.I) REMAINDER_RE = re.compile(r"\b(remainder|remaining|rest of|excluded|non-)", re.I) PVAL_HEADER_RE = re.compile(r"^\s*\*?\s*[Pp]\s*(?:[- ]?value)?\s*\*?\s*$") PVAL_HEADER_CONTAINS = re.compile(r"\bp[- ]?value\b", re.I) SEP_RE = re.compile(r"^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$") PROSE_RE = re.compile( r"compar\w+\b[^.]*\bbetween\b[^.]*\b(subset|sub-?cohort|analy[sz]ed[^.]*?)\b[^.]*\b(full|entire|total|whole)\s+cohort", re.I) @dataclass class Finding: kind: str severity: str line: int detail: str @dataclass class Report: source: str findings: list[Finding] = field(default_factory=list) @property def n_flag(self) -> int: return sum(1 for f in self.findings if f.kind == "NESTED_GROUP_TEST") @property def verdict(self) -> str: return "NESTED COMPARISON FOUND" if self.n_flag else "OK" def _split_row(line: str) -> list[str]: s = line.strip() if s.startswith("|"): s = s[1:] if s.endswith("|"): s = s[:-1] return [c.strip() for c in s.split("|")] def _tables(text: str): lines = text.splitlines() i, n = 0, len(lines) while i < n: if "|" in lines[i] and i + 1 < n and SEP_RE.match(lines[i + 1]) and "-" in lines[i + 1]: yield _split_row(lines[i]), i + 1 i += 2 else: i += 1 def audit(text: str, source: str) -> Report: rep = Report(source=source) for header, lineno in _tables(text): n_cols = [(j, h, int(m.group(1).replace(",", ""))) for j, h in enumerate(header) if (m := HEADER_N_RE.search(h))] if len(n_cols) < 2: continue has_p = any(PVAL_HEADER_RE.match(h) or PVAL_HEADER_CONTAINS.search(h) for h in header) if not has_p: continue subset_cols = [(j, h, nv) for j, h, nv in n_cols if SUBSET_RE.search(h)] full_cols = [(j, h, nv) for j, h, nv in n_cols if FULL_RE.search(h) and not REMAINDER_RE.search(h)] if subset_cols and full_cols: s = min(subset_cols, key=lambda c: c[2]) f = max(full_cols, key=lambda c: c[2]) if s[2] < f[2]: # subset smaller than the cohort that names it rep.findings.append(Finding( "NESTED_GROUP_TEST", "MAJOR", lineno, f"P-value table compares subset '{s[1]}' (n={s[2]}) against " f"'{f[1]}' (n={f[2]}) that contains it — nested groups; the valid " f"contrast is subset vs remainder (n={f[2] - s[2]})")) for m in PROSE_RE.finditer(text): line = text.count("\n", 0, m.start()) + 1 rep.findings.append(Finding( "NESTED_GROUP_TEST", "MAJOR", line, f"prose compares a subset against the full cohort that contains it: " f"\"{m.group(0)[:90]}…\"")) return rep def format_report(rep: Report, color: bool) -> str: tag = {"OK": "\033[92m", "NESTED COMPARISON FOUND": "\033[91m"}.get(rep.verdict, "") if color else "" end = "\033[0m" if color else "" out = [f"{tag}== {rep.verdict} =={end} {rep.source}", f"nested_comparisons={rep.n_flag}"] if not rep.findings: out.append("no subset-vs-parent-cohort P-value comparison detected.") return "\n".join(out) for f in sorted(rep.findings, key=lambda x: (x.line, x.detail)): out.append(f"[{f.severity}] {f.kind} L{f.line} {f.detail}") return "\n".join(out) def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--strict", action="store_true", help="exit 1 on any NESTED_GROUP_TEST") ap.add_argument("--quiet", action="store_true", help="suppress the report; exit code only") ap.add_argument("--json", action="store_true", help="emit JSON instead of a text report") args = ap.parse_args(argv) try: text = open(args.manuscript, encoding="utf-8").read() except OSError as e: print(f"error: cannot read manuscript: {e}", file=sys.stderr) return 2 rep = audit(text, args.manuscript) if not args.quiet: if args.json: print(json.dumps({"detector": "check_nested_group_comparison", "source": rep.source, "verdict": rep.verdict, "findings": [asdict(f) for f in rep.findings]}, ensure_ascii=False, indent=2)) else: print(format_report(rep, color=sys.stdout.isatty())) return 1 if (args.strict and rep.n_flag) else 0 if __name__ == "__main__": raise SystemExit(main()) -
check_null_calibration.py 8.4 KB
#!/usr/bin/env python3 """Power-aware null-interpretation gate (self-review §C estimand contracts). A headline negative claim ("no synergy", "no association", "not associated", "showed no difference") is only interpretable next to a *precision* statement — a minimum-detectable-effect, a power calculation, an equivalence margin / TOST, or a confidence-interval-compatibility sentence. A primary NULL that asserts "the null was not underpowered or fragile" while the interval (e.g. interaction OR 1.04, 95% CI 0.75–1.44) does not exclude a modest effect, with no MDE/ equivalence reported, is a Major a prose pass misses but a panel/co-reviewer catches. Verdict: CONFIRM_NULL_NO_MDE (Major) a negative/equivalence claim sits in the Title / Abstract / Conclusion with no minimum-detectable- effect, power, equivalence-margin/TOST, or CI- compatibility token anywhere in those regions. Conservative by construction: it fires only when a headline negative claim is present AND no precision token is CO-LOCATED with that claim (a MDE / power / equivalence / CI-compatibility statement within the claim's own sentence neighbourhood suppresses it). The check is per-claim-SITE, not per-region: a power-aware caveat next to the Abstract-Results null does NOT license a bare "equivalence within +/-0.10" in the Conclusions. Each unqualified claim site is flagged on its own; a caveat in one region no longer masks an uncaveated equivalence claim in another. Exit codes: 0 clean (or report-only), 1 with --strict when any Major exists, 2 usage. Stdlib-only (json / re / argparse / pathlib). Usage: python3 check_null_calibration.py --manuscript manuscript.md \ [--out qc/null_calibration.json] [--strict] [--quiet] """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # Headline negative / equivalence claims. Anchored to a NULL conclusion, not a # mere mention of the word "no". NEGATIVE_CLAIM = re.compile( r"\bno\s+(?:significant\s+)?(?:synerg|associat|interaction|difference|effect|relationship|correlation|impact)" r"|\bnot\s+(?:significantly\s+)?associat(?:ed)?" r"|\b(?:was|were|is|are)\s+not\s+associated" r"|\bshowed\s+no\b|\bfound\s+no\b|\bdid\s+not\s+(?:differ|increase|decrease|affect|predict|improve)" r"|\bno\s+evidence\s+(?:of|for|that)\b" r"|\bhad\s+no\s+(?:significant\s+)?(?:effect|association|impact)" r"|\bnull\b(?:\s+(?:finding|result|association|hypothesis))" r"|\b(?:were|was)\s+(?:statistically\s+)?equivalent\b|\bno\s+meaningful\b", re.IGNORECASE) # Precision / MDE / equivalence / CI-compatibility tokens that make a null # interpretable. PRECISION_TOKEN = re.compile( r"minimum\s+detectable\s+effect|\bMDE\b|detectable\s+(?:effect|difference)" r"|power(?:ed)?\s+to\s+detect|statistical(?:ly)?\s+power|post[-\s]?hoc\s+power|a\s+priori\s+power" r"|equivalence\s+(?:margin|bound|test|range)|\bTOST\b|two\s+one[-\s]sided" r"|non[-\s]?inferiority\s+margin|smallest\s+effect\s+size\s+of\s+interest|\bSESOI\b" r"|(?:confidence\s+interval|CI)\s+(?:excludes?|is\s+compatible|includes?\s+values|rules?\s+out|cannot\s+exclude)" r"|compatible\s+with\s+(?:effects?|a\s+(?:clinically\s+)?(?:meaningful|modest|important))" r"|cannot\s+(?:exclude|rule\s+out)\s+a\b|may\s+be\s+underpowered|was\s+underpowered" r"|wide\s+confidence\s+interval|interval\s+(?:does\s+not\s+exclude|spans)|equivalence\s+was\s+(?:not\s+)?established", re.IGNORECASE) REGION_HEADINGS = re.compile( r"^#{1,4}\s*\*{0,2}(?:ABSTRACT|Abstract|CONCLUSIONS?|Conclusions?|DISCUSSION|Discussion|" r"Interpretation|Clinical Implications?|Summary)\*{0,2}\s*$", re.IGNORECASE | re.MULTILINE) def headline_region(text: str) -> str: """Title (first heading / first non-empty line) + Abstract + Conclusion/ Discussion regions + any inline 'Conclusion:' clause. Fallback: whole text.""" spans: list[str] = [] # title = first markdown heading, else first non-empty line mt = re.search(r"^#{1,6}\s+(.+)$", text, re.MULTILINE) if mt: spans.append(mt.group(1)) else: for line in text.splitlines(): if line.strip(): spans.append(line.strip()) break all_headings = [m.start() for m in re.finditer(r"^#{1,4}\s", text, re.MULTILINE)] for m in REGION_HEADINGS.finditer(text): s = m.end() nxt = next((h for h in all_headings if h > s), len(text)) spans.append(text[s:nxt]) for m in re.finditer(r"(?:^|\n)\s*\*{0,2}(?:Conclusions?|Interpretation)\*{0,2}\s*[:.]\s*(.+?)(?:\n\n|$)", text, re.IGNORECASE | re.DOTALL): spans.append(m.group(1)) if not spans: spans.append(text) return "\n".join(spans) # Neighbourhood (chars each side of a claim) searched for a co-located precision # statement. Wide enough to cover the same sentence / adjacent clause, narrow # enough that a caveat in a different paragraph/region does not mask this claim. _COLOCATE_WINDOW = 160 def check(text: str) -> list[dict]: region = headline_region(text) claims: list[dict] = [] seen: set[str] = set() for nm in NEGATIVE_CLAIM.finditer(region): window = region[max(0, nm.start() - _COLOCATE_WINDOW):nm.end() + _COLOCATE_WINDOW] if PRECISION_TOKEN.search(window): continue # a precision statement is co-located with THIS claim site key = re.sub(r"\s+", " ", nm.group(0).strip().lower()) if key in seen: continue seen.add(key) claims.append({ "verdict": "CONFIRM_NULL_NO_MDE", "severity": "Major", "detail": (f"a headline negative/equivalence claim ('{nm.group(0).strip()}') in the " f"Title/Abstract/Conclusion has no co-located minimum-detectable-" f"effect, power, equivalence-margin/TOST, or CI-compatibility statement; a " f"non-significant result is not evidence of no effect without one " f"(a caveat elsewhere in the manuscript does not cover this claim site)"), "where": region[max(0, nm.start() - 40):nm.end() + 60].replace("\n", " ").strip()[:160], }) return claims def analyze(manuscript: str) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8")) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "manuscript": str(p), "claims": claims, "summary": { "n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "verdict": "MAJOR_CANDIDATE" if n_major else "OK", }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | no headline null lacks a precision/MDE statement |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Power-aware null-interpretation gate (§C).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript) if not args.quiet: print("=" * 41) print(" Null Calibration (§C)") print("=" * 41) print(render(result)) print() s = result["summary"] if s["n_major"]: print(f"MAJOR candidate: {s['n_major']} headline null without a precision/MDE statement.") else: print("OK: no headline null lacks a precision/MDE statement.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_null_calibration", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_paired_difference_estimator.py 6.5 KB
#!/usr/bin/env python3 """check_paired_difference_estimator.py — sanity-check a reported "median paired difference" against the achievable value set, its interval, and its named estimator. Reader/Likert studies routinely bolt an effect size onto a revision to answer an uncertainty comment, and the estimator is left undefined. The tells are deterministic: the median of an odd number of integer paired differences is one of the integer differences, so a reported non-integer median (e.g. 0.5) is impossible and signals a Hodges–Lehmann pseudomedian mislabelled as a median; a "95% CI" whose bounds are equal (0.5–0.5) is degenerate; and an effect size + CI with no estimator or interval method named cannot be reproduced. Checks: MEDIAN_PARITY n odd ∧ integer scale ∧ reported median difference non-integer (if scores are means of R raters, the step is 1/R — still flag a value that is not a multiple of 1/R). Suppressed when the value is labelled a Hodges–Lehmann pseudomedian. DEGENERATE_CI a reported interval whose lower bound equals its upper bound. ESTIMATOR_UNNAMED a median-difference effect size + CI while no estimator / interval method (Hodges–Lehmann, pseudomedian, bootstrap, Wilcoxon, exact, sign test) is named anywhere in the manuscript. Stdlib-only. Reads the manuscript, never writes it. Usage: python3 check_paired_difference_estimator.py --manuscript paper.md [--strict] [--quiet] [--json] Exit: 0 clean; with --strict, 1 on any MAJOR finding; 2 on input/usage error. """ from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass, field, asdict MEDIAN_DIFF_RE = re.compile( r"(?<!pseudo)\b(?:paired\s+)?median\s+(?:paired\s+)?difference\b[^.\n]{0,40}?\b(\d+(?:\.\d+)?)", re.I) N_RE = re.compile(r"\b(\d+)\s+paired\b|\bn\s*=\s*(\d+)\b", re.I) SCALE_RE = re.compile(r"\b(\d+)-point\b|\bLikert\b", re.I) RATERS_RE = re.compile(r"\b(?:mean|average[d]?)\b[^.\n]{0,30}?\b(\d+)\s+raters?\b", re.I) CI_RE = re.compile(r"95\s*%\s*CI[:\s]*\(?\s*(-?\d+(?:\.\d+)?)\s*(?:[–\-−]|to)\s*(-?\d+(?:\.\d+)?)", re.I) ESTIMATOR_RE = re.compile(r"\b(hodges[-–\s]?lehmann|pseudomedian|bootstrap|wilcoxon|exact\s+(?:test|confidence)|sign\s+test)\b", re.I) @dataclass class Finding: kind: str severity: str detail: str @dataclass class Report: source: str findings: list[Finding] = field(default_factory=list) @property def n_flag(self) -> int: return sum(1 for f in self.findings if f.severity == "MAJOR") @property def verdict(self) -> str: return "ESTIMATOR PROBLEM" if self.n_flag else "OK" def _is_multiple(value: float, step: float) -> bool: q = value / step return abs(q - round(q)) < 1e-9 def audit(text: str, source: str) -> Report: rep = Report(source=source) estimator_named = bool(ESTIMATOR_RE.search(text)) n_m = N_RE.search(text) n_val = int(next(g for g in (n_m.groups() if n_m else ()) if g)) if n_m else None scale_m = SCALE_RE.search(text) integer_scale = bool(scale_m) raters_m = RATERS_RE.search(text) step = 1.0 / int(raters_m.group(1)) if raters_m else 1.0 for m in MEDIAN_DIFF_RE.finditer(text): # is this occurrence a mislabel guarded by a nearby pseudomedian/HL tag? window = text[max(0, m.start() - 20):m.end()] if re.search(r"pseudomedian|hodges", window, re.I): continue value = float(m.group(1)) if n_val is not None and n_val % 2 == 1 and integer_scale and not _is_multiple(value, step): rep.findings.append(Finding( "MEDIAN_PARITY", "MAJOR", f"reported median paired difference {value:g} is not achievable: the median of " f"n={n_val} (odd) integer differences must be an integer" + (f" (or a multiple of 1/{int(raters_m.group(1))})" if raters_m else "") + " — likely a Hodges–Lehmann pseudomedian mislabelled as a median")) for m in CI_RE.finditer(text): lo, hi = float(m.group(1)), float(m.group(2)) if lo == hi: rep.findings.append(Finding( "DEGENERATE_CI", "MAJOR", f"reported 95% CI has equal bounds ({m.group(1)}–{m.group(2)}) — zero width")) if MEDIAN_DIFF_RE.search(text) and CI_RE.search(text) and not estimator_named: rep.findings.append(Finding( "ESTIMATOR_UNNAMED", "MAJOR", "a median-difference effect size with a CI is reported but no estimator / interval " "method (Hodges–Lehmann, pseudomedian, bootstrap, Wilcoxon, exact, sign test) is named")) return rep def format_report(rep: Report, color: bool) -> str: tag = {"OK": "\033[92m", "ESTIMATOR PROBLEM": "\033[91m"}.get(rep.verdict, "") if color else "" end = "\033[0m" if color else "" out = [f"{tag}== {rep.verdict} =={end} {rep.source}", f"problems={rep.n_flag}"] if not rep.findings: out.append("median-difference estimator, interval, and value set are consistent.") return "\n".join(out) for f in sorted(rep.findings, key=lambda x: (x.kind, x.detail)): out.append(f"[{f.severity}] {f.kind} {f.detail}") return "\n".join(out) def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--strict", action="store_true", help="exit 1 on any MAJOR finding") ap.add_argument("--quiet", action="store_true", help="suppress the report; exit code only") ap.add_argument("--json", action="store_true", help="emit JSON instead of a text report") args = ap.parse_args(argv) try: text = open(args.manuscript, encoding="utf-8").read() except OSError as e: print(f"error: cannot read manuscript: {e}", file=sys.stderr) return 2 rep = audit(text, args.manuscript) if not args.quiet: if args.json: print(json.dumps({"detector": "check_paired_difference_estimator", "source": rep.source, "verdict": rep.verdict, "findings": [asdict(f) for f in rep.findings]}, ensure_ascii=False, indent=2)) else: print(format_report(rep, color=sys.stdout.isatty())) return 1 if (args.strict and rep.n_flag) else 0 if __name__ == "__main__": raise SystemExit(main()) -
check_panel_diversity.py 25.4 KB
#!/usr/bin/env python3 """Panel lens-diversity gate (self-review Phase 2.6, --panel). A multi-agent panel is only worth its cost if its reviewers cover *distinct* concern axes. Left unchecked, independent reviewers converge on the same easy themes (everyone flags "missing calibration") while whole high-risk axes go unprobed — the panel collapses to fewer effective lenses than reviewers, and the editor synthesis cannot tell monoculture from genuine consensus. This gate post-processes the reviewers' structured output (the panel_review_template schema the editor already collects) and reports these diversity and panel-independence failures: UNCOVERED_AXIS an expected high-risk axis for this research type produced ZERO major findings across the whole panel. Mirrors the "completeness critic" pattern: name what nobody probed so the editor can re-probe it before finalizing. (Major) FAMILY_MONOCULTURE the panel's major findings concentrate in ONE concern family beyond a threshold (one family holds the majority), a signal the lenses converged rather than spanned the manuscript. (Major) LENS_COLLAPSE one reviewer contributed only families that another reviewer already covered — a fully-redundant lens that added no independent signal. Distinct from healthy CONSENSUS (a reviewer agreeing on SOME themes but also raising at least one family nobody else did). Exempt when the roster declares that reviewer on a DIFFERENT substrate from the generator: agreement reached from another substrate is corroboration, not a redundant assignment, and it is the very lens SUBSTRATE_MONOCULTURE requires. Exempted ids are reported in summary.lens_collapse_substrate_exempt. (Flag) SUBSTRATE_MONOCULTURE every reviewer the roster declares shares the *generator's* model substrate — a same-model panel inherits the blind spots that produced the draft and is not an independent check. Fires only when the roster declares the generator's and the reviewers' substrates and none differs; absent substrate info => skipped. (Major) Healthy consensus is preserved: a finding family raised by ≥2 reviewers is a strength, not a defect. The gate only fires LENS_COLLAPSE when a reviewer's ENTIRE contribution is redundant, and UNCOVERED_AXIS/MONOCULTURE on panel-level coverage, never on agreement per se. INPUTS --panel JSON file. Either a list of reviewer objects, or an object with a "reviewers" list (and optional "research_type"). Each reviewer object needs reviewer_id, expertise_area, and major[] (with heading and/or comment text); minor[] is ignored for axis coverage. --research-type one of: survival, sr_ma, radiomics, dta, observational, narrative (synonyms accepted). Overrides any value in the JSON. When unknown/absent, UNCOVERED_AXIS is skipped (cannot know the expected axes) and noted in the summary. --roster roster manifest of SPAWNED reviewers. A list of ids, or {reviewers|roster: [{reviewer_id, substrate}]} with an optional top-level "generator_substrate". Enables PANEL_UNDERRETURN (spawned vs returned) and SUBSTRATE_MONOCULTURE (independence). substrate is a coarse lane label ("claude" | "codex" | "gpt" | "human"), not a model version. OUTPUT A diversity table (stdout) and, with --out, a JSON artifact: {panel, research_type, claims[{verdict, severity, detail, where}], summary} summary carries the family histogram, concentration index, and the expected/covered/uncovered axes. Exit 1 (with --strict) when any Major claim exists; exit 2 on input error. Stdlib-only (json / re / argparse / pathlib). Exit codes: 0 clean (or report-only), 1 Major claim(s) found (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # Concern families, aligned to the panel's per-domain focus checklists and the # self-review A–J category system. Each finding is assigned to the FIRST family # whose lexicon matches its text; order is most-specific to most-generic so a # leakage finding is not swallowed by the generic "statistics" family. FAMILY_LEXICON: list[tuple[str, re.Pattern]] = [ ("search_screening", re.compile( r"search strateg|screening|eligibilit|inclusion criteri|exclusion criteri|" r"database\b|grey literature|gray literature|duplicate (?:removal|record)|" r"prisma flow|records identified|study selection", re.IGNORECASE)), ("design_leakage", re.compile( r"leakage|data (?:split|leak)|train(?:ing|/test| test)|test set|" r"contaminat|allocation|randomi[sz]|immortal time|time[-\s]?zero|" r"selection bias|spectrum bias|case[-\s]?control selection|" r"reference standard|verification bias", re.IGNORECASE)), ("confounding", re.compile( r"confound|residual confounding|covariate|adjust(?:ment|ed)|mediator|" r"collider|confounding by indication|propensity", re.IGNORECASE)), ("imaging", re.compile( r"acquisition|scanner|sequence|segmentation|voxel|kernel|reconstruction|" r"combat|harmoni[sz]|slice thickness|field strength|radiomic feature|" r"window(?:ing| level)|protocol heterogeneity", re.IGNORECASE)), ("reporting", re.compile( r"strobe|tripod|prisma\b|consort|claim\b|stard|reporting (?:guideline|standard|" r"completeness)|checklist|flow diagram|disclosure|registration|protocol " r"deviation|abstract (?:inconsisten|mismatch)", re.IGNORECASE)), ("reproducibility", re.compile( r"reproducib|code availab|data availab|random seed|\bseed\b|script\b|" r"open (?:data|code)|version pin", re.IGNORECASE)), ("statistics", re.compile( r"calibrat|discriminat|\bauc\b|c[-\s]?statistic|delong|confidence interval|" r"\bci\b|heterogeneit|\bi2\b|i\^?2|pooling|pooled|random[-\s]?effects|" r"multiplicit|multiple compar|\bp[-\s]?value|\bpower\b|sample size|" r"model specif|proportional hazard|missing data|imputation|competing risk|" r"events per variable|overfitting|effect size|subdistribution|" # Type-agnostic statistical vocabulary a reader / agreement study raises but the # meta-analysis-flavoured list above missed — effect measures, resampling, # inter-rater agreement, multiplicity siblings, common tests, Bayesian. Without # these a statistics-dedicated reviewer's majors classified as "other" and the # statistics axis looked uncovered (false UNCOVERED_AXIS Major). r"odds ratio|hazard ratio|risk ratio|relative risk|rate ratio|" r"bootstrap|permutation|jackknife|monte[-\s]?carlo|" r"\bkappa\b|κ|inter[-\s]?rater|intraclass|\bicc\b|\bac1\b|concordance|" r"bonferroni|false discovery|\bfdr\b|\bholm\b|family[-\s]?wise|" r"wilcoxon|mann[-\s]?whitney|mcnemar|log[-\s]?rank|chi[-\s]?squared?|fisher'?s? exact|" r"bayesian|credible interval|posterior distribution|standard deviation|standard error", re.IGNORECASE)), ("clinical", re.compile( r"clinical|actionab|guideline|management|generali[sz]ab|applicab|" r"external validit|patient[-\s]?care|over(?:reach|claim)|" r"clinical (?:relevance|utility|significance)", re.IGNORECASE)), ] # Expected high-risk axes per research type (each SHOULD yield ≥1 major). Mirrors # the Phase 2.6 reviewer-set table; optional axes (e.g. imaging when the exposure # is non-imaging) are not required and so are omitted here. EXPECTED_AXES: dict[str, list[str]] = { "survival": ["statistics", "clinical"], "sr_ma": ["search_screening", "clinical", "statistics"], "radiomics": ["imaging", "statistics", "clinical"], "dta": ["design_leakage", "statistics", "clinical"], "observational": ["confounding", "clinical", "statistics"], "narrative": ["clinical", "reporting"], } RESEARCH_TYPE_SYNONYMS: dict[str, str] = { "survival": "survival", "prognostic": "survival", "cohort": "survival", "sr": "sr_ma", "ma": "sr_ma", "sr_ma": "sr_ma", "sr/ma": "sr_ma", "systematic review": "sr_ma", "meta-analysis": "sr_ma", "meta analysis": "sr_ma", "radiomics": "radiomics", "feature": "radiomics", "dta": "dta", "diagnostic": "dta", "diagnostic-accuracy": "dta", "ai model": "dta", "observational": "observational", "strobe": "observational", "narrative": "narrative", "review article": "narrative", "sanra": "narrative", } MONOCULTURE_MIN_MAJORS = 4 # too few majors to call concentration meaningful MONOCULTURE_SHARE = 0.60 # one family holding > this share = monoculture def normalize_research_type(raw: str | None) -> str | None: if not raw: return None key = raw.strip().lower() if key in RESEARCH_TYPE_SYNONYMS: return RESEARCH_TYPE_SYNONYMS[key] for syn, canon in RESEARCH_TYPE_SYNONYMS.items(): if syn in key: return canon return None def classify(text: str) -> str: for family, pat in FAMILY_LEXICON: if pat.search(text): return family return "other" def finding_text(major: dict) -> str: parts = [str(major.get(k, "")) for k in ("heading", "comment", "location")] return " ".join(p for p in parts if p) def load_reviewers(obj) -> tuple[list[dict], str | None]: if isinstance(obj, list): return obj, None if isinstance(obj, dict): revs = obj.get("reviewers") if isinstance(revs, list): return revs, obj.get("research_type") raise ValueError("panel JSON must be a list of reviewers or an object with a 'reviewers' list") def load_roster_ids(path: str | None) -> set[str] | None: """The reviewer_ids that were SPAWNED (a roster manifest written before spawning). Accepts a list of ids, a list of {reviewer_id: ...}, or {reviewers|roster: [...]}. Returns None when no roster is given (roster checks then stay silent).""" if not path: return None obj = json.loads(Path(path).read_text(encoding="utf-8")) if isinstance(obj, list): ids: set[str] = set() for x in obj: if isinstance(x, str): ids.add(x) elif isinstance(x, dict) and x.get("reviewer_id"): ids.add(str(x["reviewer_id"])) return ids if isinstance(obj, dict): revs = obj.get("reviewers") or obj.get("roster") or [] return {str(r["reviewer_id"]) for r in revs if isinstance(r, dict) and r.get("reviewer_id")} return set() def load_roster_substrates(path: str | None) -> tuple[str | None, dict[str, str]]: """The generator's substrate and each spawned reviewer's substrate, from the roster manifest. Returns (generator_substrate, {reviewer_id: substrate}); either may be empty when the roster is a bare id list or omits substrate fields (SUBSTRATE_MONOCULTURE then stays silent). Substrate is a coarse lane label ("claude" | "codex" | "gpt" | "human").""" if not path: return None, {} obj = json.loads(Path(path).read_text(encoding="utf-8")) if isinstance(obj, dict): gen = obj.get("generator_substrate") revs = obj.get("reviewers") or obj.get("roster") or [] elif isinstance(obj, list): gen, revs = None, obj else: return None, {} subs: dict[str, str] = {} for r in revs: if isinstance(r, dict) and r.get("reviewer_id") and r.get("substrate"): subs[str(r["reviewer_id"])] = str(r["substrate"]) return (str(gen) if gen else None), subs def check(reviewers: list[dict], research_type: str | None, roster_ids: set[str] | None = None, generator_substrate: str | None = None, reviewer_substrates: dict[str, str] | None = None) -> tuple[list[dict], dict]: claims: list[dict] = [] returned_ids = {str(rev.get("reviewer_id") or f"R{i + 1}") for i, rev in enumerate(reviewers)} # 0) PANEL_UNDERRETURN — spawned (roster) vs returned (panel). The failure this # exists for: a --panel run spawns N reviewers and some/all return nothing, so the # panel JSON is thin or empty and NOTHING errors — the run reads as "completed". # Set arithmetic over the two id lists turns a silent absence into a Major. Silent # (no roster given): the caller did not record who was spawned, so nothing to compare. if roster_ids is not None: missing = sorted(roster_ids - returned_ids) n_returned = len(reviewers) if n_returned < 2 or missing: if n_returned < 2: detail = (f"only {n_returned} of {len(roster_ids)} rostered reviewer(s) returned " f"a parseable review; a panel with fewer than 2 returned reviews is a " f"FAILED run, not a thin one — do not synthesize it or report it as a " f"review. Non-returning: {', '.join(missing) or '(all)'}. Re-spawn on a " f"different substrate (or route to a human co-author) before treating " f"this as a panel.") else: detail = (f"{n_returned} of {len(roster_ids)} rostered reviewers returned; " f"{len(missing)} did not: {', '.join(missing)}. A missing reviewer is a " f"gap, not an absence — re-spawn the missing lens(es), or state " f"explicitly that synthesis used only the returned subset.") claims.append({ "verdict": "PANEL_UNDERRETURN", "severity": "Major", "detail": detail, "where": f"roster: {len(roster_ids)} spawned, {n_returned} returned", }) # 0b) SUBSTRATE_MONOCULTURE — a generator/critic/verifier sharing a model substrate have # correlated blind spots; a same-model-only panel is not an independent check. Fires only # when the roster declares the generator's substrate AND >=1 reviewer's substrate, and none # of the declared reviewer substrates differs from the generator's. Absent substrate info => # skip (older rosters / no --roster), so this never false-positives on an unlabelled panel. if generator_substrate and reviewer_substrates: declared = {rid: s for rid, s in reviewer_substrates.items() if s} if declared: gen = generator_substrate.strip().lower() independent = sorted(rid for rid, s in declared.items() if s.strip().lower() != gen) if not independent: claims.append({ "verdict": "SUBSTRATE_MONOCULTURE", "severity": "Major", "detail": (f"all {len(declared)} declared reviewer(s) share the generator's " f"substrate ('{generator_substrate}'); a same-model panel inherits the " f"generator's blind spots and is not an independent check. Route at " f"least one lens to a different substrate (the Codex adversarial path) " f"or a human co-author before treating this as an independent panel."), "where": f"substrate: generator={generator_substrate}, " f"reviewers={sorted(set(declared.values()))}", }) # Per-reviewer families (set of distinct families this reviewer raised as majors) rev_families: dict[str, set[str]] = {} family_hist: dict[str, int] = {} n_majors = 0 for i, rev in enumerate(reviewers): rid = str(rev.get("reviewer_id") or f"R{i + 1}") fams: set[str] = set() for maj in rev.get("major", []) or []: fam = classify(finding_text(maj)) fams.add(fam) family_hist[fam] = family_hist.get(fam, 0) + 1 n_majors += 1 rev_families[rid] = fams covered = set(family_hist) covered.discard("other") # 1) UNCOVERED_AXIS — only when we know the expected axes expected: list[str] = [] uncovered: list[str] = [] if research_type and research_type in EXPECTED_AXES: expected = EXPECTED_AXES[research_type] uncovered = [ax for ax in expected if family_hist.get(ax, 0) == 0] for ax in uncovered: claims.append({ "verdict": "UNCOVERED_AXIS", "severity": "Major", "detail": (f"no major finding addresses the '{ax}' axis, which a " f"{research_type} panel is expected to probe; the editor " f"should re-probe it before finalizing"), "where": f"expected axes for {research_type}: {', '.join(expected)}", }) # 2) FAMILY_MONOCULTURE — concentration of majors in one family hhi = 0.0 top_family = None top_share = 0.0 if n_majors: shares = {f: c / n_majors for f, c in family_hist.items() if f != "other"} hhi = sum(s * s for s in shares.values()) if shares: top_family, top_share = max(shares.items(), key=lambda kv: kv[1]) if n_majors >= MONOCULTURE_MIN_MAJORS and top_share > MONOCULTURE_SHARE: claims.append({ "verdict": "FAMILY_MONOCULTURE", "severity": "Major", "detail": (f"{top_share:.0%} of major findings fall in the '{top_family}' " f"family ({family_hist.get(top_family, 0)}/{n_majors}); the panel " f"converged on one axis rather than spanning the manuscript"), "where": "family histogram: " + ", ".join( f"{f}={c}" for f, c in sorted(family_hist.items(), key=lambda kv: -kv[1])), }) # 3) LENS_COLLAPSE — a reviewer whose every family is also covered by another. # # Exempt: a reviewer whose declared substrate DIFFERS from the generator's. For a # same-substrate lens, raising only families others already raised is evidence of a # redundant assignment. For a cross-substrate lens it is close to the opposite — # independent corroboration is the strongest signal a panel can produce, and that lens is # exactly what SUBSTRATE_MONOCULTURE (0b, above) requires the panel to contain. Firing # here told the editor to reconsider the one reviewer keeping the panel independent. # Gated on both substrates being declared, so an unlabelled roster is unaffected. gen_sub = (generator_substrate or "").strip().lower() declared_subs = {rid: str(s).strip().lower() for rid, s in (reviewer_substrates or {}).items() if s} cross_substrate_exempt: list[str] = [] for rid, fams in rev_families.items(): own = {f for f in fams if f != "other"} if not own: continue others = set() for other_rid, other_fams in rev_families.items(): if other_rid != rid: others |= {f for f in other_fams if f != "other"} if own and own <= others and gen_sub and declared_subs.get(rid, gen_sub) != gen_sub: # Recorded, never silent: a skipped check that prints nothing is # indistinguishable from a check that passed. cross_substrate_exempt.append(rid) continue if own and own <= others: # fully subsumed by other reviewers claims.append({ "verdict": "LENS_COLLAPSE", "severity": "Flag", "detail": (f"reviewer {rid} raised only families also covered by other " f"reviewers ({', '.join(sorted(own))}); this lens added no " f"independent axis — confirm it is a genuine consensus, not a " f"redundant reviewer assignment"), "where": f"reviewer {rid}", }) # 4) THIN_PANEL if len(reviewers) < 2: claims.append({ "verdict": "THIN_PANEL", "severity": "Flag", "detail": (f"only {len(reviewers)} reviewer(s); a panel needs ≥2 independent " f"lenses for diversity to be meaningful"), "where": "panel composition", }) summary = { "n_reviewers": len(reviewers), "n_majors": n_majors, "family_histogram": family_hist, "concentration_hhi": round(hhi, 3), "top_family": top_family, "top_family_share": round(top_share, 3), "expected_axes": expected, "covered_axes": sorted(covered), "uncovered_axes": uncovered, "research_type_known": bool(research_type and research_type in EXPECTED_AXES), "lens_collapse_substrate_exempt": sorted(cross_substrate_exempt), } return claims, summary def analyze(panel: str, research_type_arg: str | None, roster_arg: str | None = None) -> dict: p = Path(panel) if not p.is_file(): sys.stderr.write(f"ERROR: panel JSON not found: {panel}\n") sys.exit(2) try: obj = json.loads(p.read_text(encoding="utf-8")) reviewers, rt_in_json = load_reviewers(obj) except (ValueError, json.JSONDecodeError) as exc: sys.stderr.write(f"ERROR: {exc}\n") sys.exit(2) try: roster_ids = load_roster_ids(roster_arg) generator_substrate, reviewer_substrates = load_roster_substrates(roster_arg) except (OSError, ValueError, json.JSONDecodeError) as exc: sys.stderr.write(f"ERROR: roster: {exc}\n") sys.exit(2) research_type = normalize_research_type(research_type_arg) or normalize_research_type(rt_in_json) claims, summary = check(reviewers, research_type, roster_ids, generator_substrate, reviewer_substrates) if generator_substrate: summary["generator_substrate"] = generator_substrate summary["reviewer_substrates"] = reviewer_substrates if roster_ids is not None: returned = {str(rev.get("reviewer_id") or f"R{i + 1}") for i, rev in enumerate(reviewers)} summary["rostered"] = len(roster_ids) summary["returned"] = len(reviewers) summary["not_returned"] = sorted(roster_ids - returned) n_major = sum(1 for c in claims if c["severity"] == "Major") summary["n_claims"] = len(claims) summary["n_major"] = n_major summary["n_flag"] = len(claims) - n_major summary["verdict"] = "MAJOR_CANDIDATE" if n_major else "OK" return { "panel": str(p), "research_type": research_type, "claims": claims, "summary": summary, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | panel lenses span distinct axes; no monoculture |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Panel lens-diversity gate (Phase 2.6, --panel).") ap.add_argument("--panel", required=True, help="reviewers JSON (list or {reviewers:[...]})") ap.add_argument("--roster", help="roster manifest of SPAWNED reviewer_ids written before " "spawning (list of ids, or {reviewers|roster:[{reviewer_id}]}); " "enables PANEL_UNDERRETURN when the returned panel is missing " "rostered reviewers or has <2 returned") ap.add_argument("--research-type", help="survival|sr_ma|radiomics|dta|observational|narrative") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.panel, args.research_type, args.roster) if not args.quiet: print("=" * 41) print(" Panel Lens-Diversity (Phase 2.6)") print("=" * 41) print(render(result)) print() s = result["summary"] if not s["research_type_known"]: print("NOTE: research type unknown — UNCOVERED_AXIS skipped " "(pass --research-type to enable axis-coverage checks).") if s.get("lens_collapse_substrate_exempt"): print("NOTE: LENS_COLLAPSE exempt for " f"{', '.join(s['lens_collapse_substrate_exempt'])} — declared on a different " f"substrate from the generator, so full agreement reads as cross-substrate " f"corroboration, not a redundant lens.") if s["n_major"]: print(f"MAJOR candidate: {s['n_major']} diversity failure(s); " f"covered axes: {', '.join(s['covered_axes']) or '(none)'}.") else: print(f"OK: {s['n_reviewers']} reviewers spanning " f"{len(s['covered_axes'])} distinct axes " f"(HHI={s['concentration_hhi']}).") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_panel_diversity", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_paren_spans.py 6.3 KB
#!/usr/bin/env python3 """Parenthesis-span corruption gate (post em-dash-reduction safety scan). Reducing em-dashes to satisfy a classical-style gate (`" — X — "` appositive → `(X)`) is a common edit. When a bulk regex pairs two *unrelated* single em-dashes across a sentence boundary, it wraps a whole sentence inside one parenthesis: "... E-value 3.10 — Sixth, the lean-MASLD subgroup was small — and ..." → "... E-value 3.10 (Sixth, the lean-MASLD subgroup was small) and ..." The result is grammatically broken but **paren-balanced**, so a balance check misses it; only a human re-read (or this scan) catches it. This gate flags any outer `(...)` span whose content (after stripping nested parens) contains: PAREN_SPAN_ORDINAL (Major) an ordinal limitation marker ("First," … "Tenth,") — the exact corruption above; an ordinal sentence-opener does not belong inside a parenthesis. PAREN_SPAN_SENTENCE (Major) a sentence boundary ("word. Capital") inside a LONG span (> --min-sentence-span chars, default 120) — a wrapped sentence. The length guard avoids false positives on short legitimate parentheticals ("(Dr. Smith)", "(Fig. 2; cf. A)"). Run it after `--fix` / `/humanize` em-dash work (and standalone on any draft). INPUT --manuscript manuscript markdown/text (required). OUTPUT stdout table and, with --out, a JSON artifact {manuscript, claims[], summary}. Both verdicts are Major. Exit 1 (with --strict) when any Major claim exists. Stdlib-only (re / json / argparse / pathlib). Exit codes: 0 clean (or report-only), 1 Major claim(s) found (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path ORDINAL_RE = re.compile( r"\b(First|Second|Third|Fourth|Fifth|Sixth|Seventh|Eighth|Ninth|Tenth)\s*,", re.IGNORECASE) # A sentence boundary: a lowercase word, a period, whitespace, then a capitalized # word. (Requiring lowercase-before-period skips most abbreviations like "Fig.".) SENTENCE_BOUNDARY_RE = re.compile(r"[a-z]{2,}\.\s+[A-Z][a-z]") def _strip_nested(s: str) -> str: """Remove inner (...) groups so only the outer span's own text is tested.""" prev = None while prev != s: prev = s s = re.sub(r"\([^()]*\)", " ", s) return s def _outer_spans(text: str): """Yield (content, start) for every top-level balanced (...) span.""" depth = 0 start = -1 for i, ch in enumerate(text): if ch == "(": if depth == 0: start = i depth += 1 elif ch == ")" and depth > 0: depth -= 1 if depth == 0 and start >= 0: yield text[start + 1:i], start def check(text: str, min_sentence_span: int) -> list[dict]: claims = [] for content, pos in _outer_spans(text): inner = _strip_nested(content) om = ORDINAL_RE.search(inner) if om: claims.append({ "verdict": "PAREN_SPAN_ORDINAL", "severity": "Major", "detail": (f"a parenthesis wraps an ordinal sentence-opener " f"('{om.group(0).strip()}') — likely an em-dash→paren " f"conversion that paired unrelated dashes across a sentence"), "where": content.strip()[:160], }) continue if len(inner) > min_sentence_span and SENTENCE_BOUNDARY_RE.search(inner): sm = SENTENCE_BOUNDARY_RE.search(inner) claims.append({ "verdict": "PAREN_SPAN_SENTENCE", "severity": "Major", "detail": (f"a long parenthesis ({len(inner)} chars) contains a sentence " f"boundary ('{sm.group(0).strip()}') — a whole sentence appears " f"wrapped in parentheses"), "where": content.strip()[:160], }) return claims def analyze(manuscript: str, min_sentence_span: int) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8"), min_sentence_span) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "manuscript": str(p), "claims": claims, "summary": { "n_claims": len(claims), "n_major": n_major, "verdict": "MAJOR_CANDIDATE" if n_major else "OK", }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | no parenthesis-span corruption detected |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Parenthesis-span corruption gate (post em-dash reduction).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--min-sentence-span", type=int, default=120, help="min span length (chars) for the sentence-boundary check (default 120)") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript, args.min_sentence_span) if not args.quiet: print("=" * 41) print(" Parenthesis-Span Corruption") print("=" * 41) print(render(result)) print() s = result["summary"] print(f"MAJOR candidate: {s['n_major']} wrapped-sentence span(s)." if s["n_major"] else "OK: no parenthesis-span corruption detected.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_paren_spans", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_perspective_structure.py 11.8 KB
#!/usr/bin/env python3 """Perspective structural gate — two article-genre tells that mark a Perspective / opinion essay drafted like an original article (self-review §J / §D). Genre-gated: fires only when the manuscript is a Perspective (front-matter ``article_type:`` or ``--type``). A Perspective's contribution lives in its prose, not in data, so two habits carried over from IMRAD writing read as "this was drafted as a study, not argued": * **IMRAD section headings.** Published Perspectives name sections as argument-moves ("The model reads your account, not your patient"), never "Introduction / Methods / Results / Discussion". A generic IMRAD heading in a Perspective is the tell. * **A thesis abstract with no authorial move.** Eight of nine sampled npj Digital Medicine Perspectives open the abstract with an explicit "we argue" / "we propose" / "here we ...". A purely declarative abstract reads as a report, not a position. Both are Minor (advisory). The gate never judges the argument — only these two surface forms. Conclusion / Summary headings are allowed; an absent abstract is allowed (many Perspectives carry the summary only in metadata / the submission portal, not the body). Parser is deliberately careful (hardened against a Codex design review): * Only the leading ``---``-fenced front matter is read for the genre; a body ``**Article type**`` line is ignored. Self-review passes its known type through ``--type``; on disagreement the gate warns and uses ``--type`` rather than guessing. * HTML comment blocks are blanked (newline-preserving) before any scan, so a commented-out ``## Methods`` is not flagged and a "we argue" inside a comment does not suppress the abstract verdict. * Only level-2 ``##`` headings are sections (``### Box 1`` is not); leading section numbers ("1.", "2.3") and markdown decoration are stripped before the IMRAD-token test. * Structural / front / back-matter headings (Title page, Abstract, Display items, Submission notes, References, ...) are skipped, never flagged. * The FIRST body Abstract is evaluated; a duplicate raises a parser warning. v1 auto-activates on an exact "perspective" label only. Viewpoint / Comment are deferred until they carry their own fixtures — a Lancet "Comment" and an RSNA "Perspective" are different genres. Verdicts (both Minor; either can fire independently): PERSPECTIVE_HEADING_NOT_ASSERTION a content-section heading is an IMRAD label. PERSPECTIVE_ABSTRACT_NO_AUTHORIAL_MOVE a body abstract states its thesis with no authorial move. Exit codes: 0 clean/report-only (Minor-only, so ``--strict`` never returns 1), 2 usage. Stdlib-only. Usage: python3 check_perspective_structure.py --manuscript manuscript.md \ [--type perspective] [--out qc/perspective_structure.json] [--min-words 120] \ [--strict] [--quiet] """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from _frontmatter import strip_frontmatter DETECTOR = "check_perspective_structure" # v1: exact "perspective" only. Viewpoint / Comment auto-activation deferred (different genres). PERSPECTIVE_TYPES = {"perspective"} HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.S) H2_RE = re.compile(r"^##(?!#)\s+(.*\S)\s*$") FENCE_RE = re.compile(r"```.*?```", re.S) INLINE_RE = re.compile(r"[*_`]") CITE_RE = re.compile(r"\[@[^\]]+\]|\[\d+(?:[,–-]\d+)*\]") WORD_RE = re.compile(r"[A-Za-z0-9']+") NUM_PREFIX_RE = re.compile(r"^\d+(?:\.\d+)*\.?\s+") ARTICLE_TYPE_RE = re.compile(r"(?i)^\s*article[_-]?type\s*:\s*(.+?)\s*$") IMRAD_TOKENS = {"introduction", "methods", "materials and methods", "results", "discussion"} # Structural / front / back-matter headings — never a content argument-section, never flagged. STRUCTURAL_SKIP = { "title page", "abstract", "keywords", "key points", "key messages", "essentials", "summary statement", "display items", "submission notes", "references", "reference", "data availability", "code availability", "acknowledgements", "acknowledgments", "author contributions", "competing interests", "conflicts of interest", "declarations", "funding", "supplementary information", "additional information", "figure legends", "figures", "tables", "abbreviations", } AUTHORIAL_MOVE_RE = re.compile( r"\b(?:we\s+(?:argue|propose|show|contend|claim|call|advocate|introduce|present|" r"demonstrate|find|maintain|hold|report|suggest)|here\s+we)\b", re.I, ) def blank_comments(text: str) -> str: """Replace ``<!-- ... -->`` blocks with equal-count newlines (line structure preserved).""" return HTML_COMMENT_RE.sub(lambda m: "\n" * m.group(0).count("\n"), text) def article_type_from_frontmatter(text: str) -> str | None: """Single ``article_type`` value from the leading ``---``-fenced front matter (casefolded).""" lines = text.splitlines() if not lines or lines[0].strip() != "---": return None for line in lines[1:]: if line.strip() == "---": break m = ARTICLE_TYPE_RE.match(line) if m: val = HTML_COMMENT_RE.sub("", m.group(1)).strip() if len(val) >= 2 and val[0] in "\"'" and val[-1] == val[0]: val = val[1:-1].strip() return val.casefold() return None def normalize_heading(h: str) -> str: h = INLINE_RE.sub("", h).strip() h = NUM_PREFIX_RE.sub("", h) return h.strip().rstrip(".").strip().casefold() def parse(md: str) -> dict: """Structural facts about the (front-matter-stripped, comment-blanked) manuscript.""" body = blank_comments(strip_frontmatter(md)) lines = body.splitlines() heads = [] # (line_index, raw, normalized) for i, line in enumerate(lines): m = H2_RE.match(line) if m: heads.append((i, m.group(1).strip(), normalize_heading(m.group(1)))) imrad = [raw for (_, raw, norm) in heads if norm not in STRUCTURAL_SKIP and norm in IMRAD_TOKENS] abstract_idxs = [k for k, (_, _, norm) in enumerate(heads) if norm == "abstract"] abstract_text = None if abstract_idxs: k = abstract_idxs[0] start = heads[k][0] + 1 end = heads[k + 1][0] if k + 1 < len(heads) else len(lines) chunk = "\n".join(lines[start:end]) chunk = FENCE_RE.sub(" ", chunk) chunk = CITE_RE.sub("", chunk) chunk = INLINE_RE.sub("", chunk) abstract_text = re.sub(r"\s+", " ", chunk).strip() prose = [] for line in lines: s = line.strip() if not s or s.startswith(("#", "|", ">", "!", "---")): continue if re.match(r"^\s*(?:[-*+]|\d+\.)\s", line): continue prose.append(s) n_words = len(WORD_RE.findall(" ".join(prose))) return { "n_words": n_words, "n_headings": len(heads), "imrad_headings": imrad, "abstract_section_count": len(abstract_idxs), "abstract_text": abstract_text, } def check(md: str, front_type: str | None, cli_type: str | None, min_words: int) -> dict: warnings = [] ft = front_type or None ct = cli_type.casefold() if cli_type else None if ct and ft and ct != ft: warnings.append(f"article_type mismatch: front-matter '{ft}' vs --type '{ct}'; using --type") active_type = ct or ft active = bool(active_type) and active_type in PERSPECTIVE_TYPES r = parse(md) findings = [] if active and r["n_words"] >= min_words: if r["imrad_headings"]: findings.append({ "verdict": "PERSPECTIVE_HEADING_NOT_ASSERTION", "severity": "Minor", "detail": ( f"{len(r['imrad_headings'])} IMRAD section heading(s) in a Perspective " f"({', '.join(r['imrad_headings'])}) — published Perspectives name sections as " "argument-moves, not \"Introduction / Methods / Results / Discussion\". Rename " "each heading to the claim it makes; Conclusion / Summary are fine." ), "where": "; ".join(r["imrad_headings"]), }) if r["abstract_text"] is not None and not AUTHORIAL_MOVE_RE.search(r["abstract_text"]): findings.append({ "verdict": "PERSPECTIVE_ABSTRACT_NO_AUTHORIAL_MOVE", "severity": "Minor", "detail": ( "the abstract states its thesis declaratively, with no explicit authorial move " "(\"we argue\" / \"we propose\" / \"here we ...\"). Eight of nine sampled npj " "Digital Medicine Perspectives open the abstract with one; a report-style " "abstract reads as a study, not a position. Lead the thesis with the move." ), "where": (r["abstract_text"][:70] + "...") if r["abstract_text"] else "", }) if r["abstract_section_count"] > 1: warnings.append(f"{r['abstract_section_count']} '## Abstract' sections; evaluated the first") return { "metrics": { "active": active, "article_type": active_type, "body_words": r["n_words"], "imrad_heading_count": len(r["imrad_headings"]), "abstract_present": r["abstract_text"] is not None, "abstract_section_count": r["abstract_section_count"], }, "warnings": warnings, "findings": findings, } def main() -> int: ap = argparse.ArgumentParser( description="Perspective structural gate — IMRAD headings + abstract authorial move (§J/§D).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--type", help="known article type (overrides front matter on disagreement)") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--min-words", type=int, default=120, help="stay silent below this many body words (default 120)") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major (none — this gate is Minor-only)") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") a = ap.parse_args() src = Path(a.manuscript) if not src.is_file(): sys.stderr.write(f"error: no such file: {src}\n") return 2 raw = src.read_text(encoding="utf-8", errors="ignore") result = check(raw, article_type_from_frontmatter(raw), a.type, a.min_words) findings = result["findings"] m = result["metrics"] for w in result["warnings"]: sys.stderr.write(f"warning: {w}\n") if not a.quiet: print("=" * 42) print(" Perspective structure (§J / §D)") print("=" * 42) print("| Verdict | Severity | Detail |") print("|---|---|---|") if findings: for f in findings: print(f"| {f['verdict']} | {f['severity']} | {f['detail']} |") for f in findings: print(f"\n{f['verdict']} - {f['where']}") elif not m["active"]: print("| (none) | - | not gated as a Perspective |") print(f"\nOK: article_type={m['article_type']!r} is not a Perspective; no findings.") else: print("| (none) | - | Perspective structure within convention |") print("\nOK: argument-move headings and an abstract that carries an authorial move.") if a.out: out = Path(a.out) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps({ "detector": DETECTOR, "manuscript": str(src), "metrics": m, "warnings": result["warnings"], "findings": findings, }, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") if not a.quiet: print(f"\nwrote {out}") return 1 if (a.strict and any(f["severity"] == "Major" for f in findings)) else 0 if __name__ == "__main__": sys.exit(main()) -
check_reference_adequacy.py 17.3 KB
#!/usr/bin/env python3 """Reference adequacy gate for self-review Phase 2.5c-2 / write-paper Step 7.3c. Reference INTEGRITY (are the cited references real and metadata-valid?) is the job of verify-refs. This is the complementary REFERENCE ADEQUACY check: are there enough relevant references, in the right sections, and does every named statistical method or reporting guideline carry a citation? The highest-value, most-deterministic instance is Methods named-method citation coverage -- an autonomous draft can name a competing-risk model, multiple imputation, the E-value, and an eGFR equation in its Statistical Analysis subsection with zero citations and still read as internally consistent. This gate NEVER proposes reference text. It diagnoses gaps (fixable_by_ai=false); fix-forward belongs to /search-lit -> /lit-sync -> /verify-refs. INPUTS --manuscript manuscript markdown/qmd (required). --bib optional refs.bib; reports the bib entry count for cross-check. --article-type repo paper-type name or target bucket (see ALIASES); default original_article. --journal-cap optional journal reference cap (int). OUTPUT (--out path) {article_type, article_bucket, journal_cap, cited_reference_count, bib_entry_count, effective_target, section_distribution, named_methods_found, uncited_named_methods, methods_zero_citations, reference_count_verdict, adequacy_safe, findings[{issue_type, subtype, severity, location, description, fixable_by_ai, suggested_fix}], summary{major, minor, notes}} Stdlib-only (re / json / argparse / pathlib). Exit codes: 0 clean (or report-only), 1 a Major adequacy finding exists (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # --------------------------------------------------------------------------- # # Article-type targets and aliases (single SSOT for the named-method registry # # and the reference-count ranges; write-paper Step 7.3c calls this script # # directly rather than mirroring these constants). # # --------------------------------------------------------------------------- # # bucket -> (min, max) recommended reference count TARGETS = { "original_research": (25, 45), "ai_validation": (25, 45), "meta_analysis": (40, 80), # also systematic_review "review": (50, 100), # narrative / review "technical_note": (10, 20), "case_report": (10, 20), "editorial": (5, 15), "letter": (0, 10), "perspective": (0, 90), # opinion essay; count spans NEJM AI (<=5) to npj DM (40+) } # repo paper-type filename (skills/write-paper/references/paper_types/) -> bucket ALIASES = { "original_article": "original_research", "original_research": "original_research", "nhis_cohort": "original_research", "cross_national": "original_research", "animal_study": "original_research", "ai_validation": "ai_validation", "meta_analysis": "meta_analysis", "systematic_review": "meta_analysis", "review": "review", "narrative": "review", "narrative_review": "review", "technical_note": "technical_note", "brief": "technical_note", "case_report": "case_report", "editorial": "editorial", "commentary": "editorial", "letter": "letter", "perspective": "perspective", "viewpoint": "perspective", } # Buckets where a Methods/Statistical-Analysis section is expected to carry # citations; methods_zero_citations escalates to Major only for these. METHODS_SECTION_EXPECTED = {"original_research", "ai_validation", "meta_analysis"} # Named methods whose mention without a citation is a Major adequacy gap. STATISTICAL = [ "Cox proportional hazards", "Cox", "Fine-Gray", "competing risk", "subdistribution hazard", "multiple imputation", "MICE", "White-Royston", "E-value", "Benjamini-Hochberg", "FDR", "bootstrap", "Schoenfeld", "CKD-EPI", "Harrell C-index", "DeLong", "calibration", "Brier", "decision curve", "net benefit", "propensity score", "inverse probability weighting", ] # Reporting/diagnostic standards whose mention without a citation is Minor. GUIDELINE_REPORTING = [ "GOLD", "FEV1/FVC", "STROBE", "TRIPOD", "CONSORT", "PRISMA", "STARD", ] # --------------------------------------------------------------------------- # # Citation-marker detection (union of pandoc, numbered, author-year, # # superscript). Paragraph-level clustering keeps the check conservative. # # --------------------------------------------------------------------------- # _SUP = {"⁰": "0", "¹": "1", "²": "2", "³": "3", "⁴": "4", "⁵": "5", "⁶": "6", "⁷": "7", "⁸": "8", "⁹": "9"} _SUP_CLASS = r"[⁰¹²³⁴-⁹]+" # A name token allows internal apostrophes/hyphens (O'Brien, Smith-Jones). _NAME = r"[A-Z][A-Za-z'’\-]+" CITE_RE = re.compile( r"\[@[^\]]+\]" # pandoc [@key] / [@a; @b] r"|\[\d+(?:[–—-]\d+)?(?:,\s*\d+(?:[–—-]\d+)?)*\]" # [12] [1-3] [1,2,5] r"|\((?:" + _NAME + r")(?:\s+(?:et al\.?|and|&)\s+" + _NAME + r")?,?\s*(?:19|20)\d{2}[a-z]?\)" # (Author, 2020) r"|" + _SUP_CLASS, # superscript ¹² re.UNICODE, ) def normalize(text: str) -> str: """Fold non-breaking/figure hyphens to ASCII so 'E-value' matches.""" return text.replace("‑", "-").replace("‐", "-") def distinct_refs(body: str) -> set[str]: """Distinct reference identifiers cited in `body` (keys/numbers/author-year).""" keys: set[str] = set() for m in re.finditer(r"\[@([^\]]+)\]", body): for k in re.split(r"[;,]\s*@?", m.group(1)): k = k.strip().lstrip("@") if k: keys.add("@" + k) for m in re.finditer(r"\[(\d+(?:[–—-]\d+)?(?:,\s*\d+(?:[–—-]\d+)?)*)\]", body): for part in m.group(1).split(","): part = part.strip() rng = re.match(r"(\d+)[–—-](\d+)$", part) if rng: a, b = int(rng.group(1)), int(rng.group(2)) if 0 < b - a < 500: keys.update("#" + str(n) for n in range(a, b + 1)) elif part.isdigit(): keys.add("#" + part) for m in re.finditer(r"\((" + _NAME + r")(?:\s+(?:et al\.?|and|&)\s+" + _NAME + r")?,?\s*((?:19|20)\d{2}[a-z]?)\)", body): keys.add("AY:" + m.group(1) + m.group(2)) for m in re.finditer(_SUP_CLASS, body): num = "".join(_SUP.get(c, "") for c in m.group(0)) if num: keys.add("^" + num) return keys # --------------------------------------------------------------------------- # # Section parsing # # --------------------------------------------------------------------------- # SEC_PATTERNS = { "introduction": re.compile(r"^(introduction|background)\b", re.I), "methods": re.compile(r"^(materials and methods|methods and materials|methods|" r"statistical analysis|statistical methods)\b", re.I), "results": re.compile(r"^(results|findings)\b", re.I), "discussion": re.compile(r"^(discussion|comment)\b", re.I), } def top_sections(md: str) -> list[tuple[str, int, str]]: """Return (clean_title, level, body) for every header (any level).""" lines = md.split("\n") heads = [] for i, line in enumerate(lines): m = re.match(r"(#{1,6})\s+(.*)$", line) if m: title = re.sub(r"[*_`]", "", m.group(2)).strip() heads.append((i, len(m.group(1)), title)) out = [] for idx, (li, lvl, title) in enumerate(heads): end = len(lines) for lj, lvl2, _ in heads[idx + 1:]: if lvl2 <= lvl: end = lj break out.append((title, lvl, "\n".join(lines[li + 1:end]))) return out def section_text(sections, key: str) -> str: """Concatenate top-level (h1/h2) section bodies matching a canonical key.""" pat = SEC_PATTERNS[key] return "\n\n".join(body for title, lvl, body in sections if lvl <= 2 and pat.match(title)) # --------------------------------------------------------------------------- # # Named-method coverage # # --------------------------------------------------------------------------- # def scan_named_methods(methods_body: str) -> dict[str, dict]: """term -> {cited: bool, tier: 'stat'|'guide'} for every registry term found. A term is 'cited' if any paragraph containing it also carries a citation marker. Longest term first so 'Cox proportional hazards' claims its span before the bare 'Cox' can double-count it. """ terms = [(t, "stat") for t in STATISTICAL] + [(t, "guide") for t in GUIDELINE_REPORTING] terms.sort(key=lambda x: -len(x[0])) compiled = [(t, tier, re.compile(r"(?<![A-Za-z0-9])" + re.escape(t) + r"(?![A-Za-z0-9])", re.I)) for t, tier in terms] found: dict[str, dict] = {} for para in re.split(r"\n\s*\n", methods_body): has_cite = bool(CITE_RE.search(para)) covered: list[tuple[int, int]] = [] for term, tier, pat in compiled: for m in pat.finditer(para): span = (m.start(), m.end()) if any(span[0] >= c[0] and span[1] <= c[1] for c in covered): continue covered.append(span) rec = found.setdefault(term, {"cited": False, "tier": tier}) rec["cited"] = rec["cited"] or has_cite break return found # --------------------------------------------------------------------------- # # Main # # --------------------------------------------------------------------------- # def _finding(subtype, severity, location, description, fix): return {"issue_type": "reference_adequacy", "subtype": subtype, "severity": severity, "location": location, "description": description, "fixable_by_ai": False, "suggested_fix": fix} def assess(manuscript: str, article_type: str, journal_cap, bib_entry_count) -> dict: notes = [] key = article_type.strip().lower().replace(" ", "_").replace("-", "_") bucket = ALIASES.get(key) if bucket is None: bucket = "original_research" notes.append(f"unknown article-type '{article_type}'; defaulted to original_research") sections = top_sections(manuscript) methods_body = section_text(sections, "methods") distribution = {k: len(distinct_refs(section_text(sections, k))) for k in SEC_PATTERNS} cited_count = len(distinct_refs(manuscript)) found = scan_named_methods(methods_body) methods_has_citation = bool(CITE_RE.search(methods_body)) methods_zero_citations = bool(methods_body.strip()) and not methods_has_citation named_methods_found = sorted(found) uncited = sorted(t for t, r in found.items() if not r["cited"]) amin, amax = TARGETS[bucket] if journal_cap is not None and journal_cap < amin: eff_min, eff_max = journal_cap, journal_cap notes.append(f"journal cap {journal_cap} below article-type floor {amin}; " f"effective target lowered to the cap") else: eff_min, eff_max = amin, amax findings = [] if methods_zero_citations and bucket in METHODS_SECTION_EXPECTED: listed = ", ".join(named_methods_found) if named_methods_found else "named methods/scores" findings.append(_finding( "methods_zero_citations", "major", "Methods - Statistical Analysis", f"The Methods/Statistical Analysis section contains no citations; every named " f"method, score, guideline, and diagnostic criterion needs a canonical source " f"(found uncited: {listed}).", "Run /search-lit (paper mode) for canonical methodology sources, sync via " "/lit-sync, then rerun /verify-refs --strict.")) else: for term in uncited: if found[term]["tier"] == "stat": findings.append(_finding( "methods_named_method_uncited", "major", "Methods - Statistical Analysis", f"{term} is named in the Statistical Analysis subsection without a citation " f"in its paragraph.", f"Run /search-lit (paper mode) for the canonical {term} source, sync via " f"/lit-sync, then rerun /verify-refs --strict.")) else: findings.append(_finding( "reporting_guideline_uncited", "minor", "Methods", f"The {term} standard is named without a citation in its paragraph.", f"Run /search-lit for the {term} reference, sync via /lit-sync, then rerun " f"/verify-refs --strict.")) verdict = "ADEQUATE" if cited_count >= eff_min else "BELOW_TARGET" if verdict == "BELOW_TARGET": sev = "major" if cited_count < 0.5 * eff_min else "minor" findings.append(_finding( "below_article_type_target", sev, "Whole manuscript", f"Cited references ({cited_count}) are below the {bucket} target " f"({eff_min}-{eff_max}).", "Run /search-lit (paper mode) to retrieve verified candidates across the six " "categories, sync via /lit-sync, then rerun /verify-refs --strict.")) n_major = sum(1 for f in findings if f["severity"] == "major") n_minor = sum(1 for f in findings if f["severity"] == "minor") return { "article_type": article_type, "article_bucket": bucket, "journal_cap": journal_cap, "cited_reference_count": cited_count, "bib_entry_count": bib_entry_count, "effective_target": [eff_min, eff_max], "section_distribution": distribution, "named_methods_found": named_methods_found, "uncited_named_methods": uncited, "methods_zero_citations": methods_zero_citations, "reference_count_verdict": verdict, "adequacy_safe": n_major == 0, "findings": findings, "summary": {"major": n_major, "minor": n_minor, "notes": notes}, } def count_bib_entries(bib_path: Path) -> int: try: text = bib_path.read_text(encoding="utf-8", errors="replace") except OSError: return -1 return len(re.findall(r"^\s*@\w+\s*\{", text, re.M)) def main() -> int: ap = argparse.ArgumentParser(description="Reference adequacy gate (count + section + named-method coverage).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/qmd") ap.add_argument("--bib", help="optional refs.bib (reports entry count)") ap.add_argument("--article-type", default="original_article", help="repo paper-type name or target bucket (see ALIASES)") ap.add_argument("--journal-cap", type=int, help="optional journal reference cap") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major adequacy finding") ap.add_argument("--quiet", action="store_true", help="suppress the stdout table") args = ap.parse_args() mp = Path(args.manuscript) if not mp.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {args.manuscript}\n") return 2 manuscript = normalize(mp.read_text(encoding="utf-8")) bib_entry_count = None if args.bib: bp = Path(args.bib) if bp.is_file(): bib_entry_count = count_bib_entries(bp) else: sys.stderr.write(f"WARN: bib not found: {args.bib} (bib entry count skipped)\n") result = assess(manuscript, args.article_type, args.journal_cap, bib_entry_count) if not args.quiet: s = result print("=" * 46) print(" Reference Adequacy Gate (count + named methods)") print("=" * 46) print(f" Article type : {s['article_type']} -> {s['article_bucket']}") print(f" Cited refs : {s['cited_reference_count']} " f"(target {s['effective_target'][0]}-{s['effective_target'][1]}" f"{', cap ' + str(s['journal_cap']) if s['journal_cap'] is not None else ''})") d = s["section_distribution"] print(f" Distribution : Intro {d['introduction']} / Methods {d['methods']} / " f"Results {d['results']} / Discussion {d['discussion']}") print(f" Methods 0-cite : {s['methods_zero_citations']}") if s["uncited_named_methods"]: print(f" Uncited methods: {', '.join(s['uncited_named_methods'])}") for f in s["findings"]: mark = "✗" if f["severity"] == "major" else "△" print(f" {mark} [{f['severity']}] {f['subtype']}: {f['description']}") print(f"\n Verdict: {s['reference_count_verdict']} | " f"{s['summary']['major']} major, {s['summary']['minor']} minor | " f"adequacy_safe={s['adequacy_safe']}") for note in s["summary"]["notes"]: print(f" note: {note}") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_reference_adequacy", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f" wrote {args.out}") return 1 if (args.strict and result["summary"]["major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_reported_p_from_counts.py 8.6 KB
#!/usr/bin/env python3 """check_reported_p_from_counts.py — recompute each 2x2 table row's P value from its own integer counts and flag a reported P that reproduces under no standard test. A baseline table comparing two groups prints a count per group and a P value per row. That P is fully determined by the four cell counts, yet a wrong one (e.g. a reported ``p<0.001`` whose true value is ~0.06) routinely survives review because no one recomputes it. This detector rebuilds the 2x2 table for every count row, recomputes Fisher's exact test and Pearson's chi-square (with and without Yates' correction) in pure stdlib, *calibrates* which family the manuscript used on the rows that reproduce, and flags any row whose reported P differs by more than one order of magnitude under **every** family. Guards: continuous rows (mean ± SD, median [IQR]) are skipped; at least two count rows are required so the family can be calibrated; a single-row table never fires. Stdlib-only (math.comb / math.erfc). Reads the manuscript, never writes it. Usage: python3 check_reported_p_from_counts.py --manuscript paper.md [--strict] [--quiet] [--json] Exit: 0 clean; with --strict, 1 on any P_NOT_REPRODUCIBLE; 2 on input/usage error. """ from __future__ import annotations import argparse import json import math import re import sys from dataclasses import dataclass, field, asdict SEP_RE = re.compile(r"^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$") HEADER_N_RE = re.compile(r"\bn\s*=\s*([0-9][0-9,]*)", re.I) PVAL_HEADER_RE = re.compile(r"^\s*\*?\s*[Pp]\s*(?:[- ]?value)?\s*\*?\s*$") PVAL_HEADER_CONTAINS = re.compile(r"\bp[- ]?value\b", re.I) COUNT_CELL_RE = re.compile(r"^\s*(\d[\d,]*)\s*(?:\(|$)") # integer count, optionally "count (pct)" PVAL_CELL_RE = re.compile(r"^\s*([<=]?)\s*(0?\.\d+|\d+(?:\.\d+)?)\s*$") FAMILIES = ("Fisher exact", "Pearson chi-square (Yates)", "Pearson chi-square (uncorrected)") @dataclass class Finding: kind: str severity: str line: int detail: str @dataclass class Report: source: str findings: list[Finding] = field(default_factory=list) @property def n_flag(self) -> int: return sum(1 for f in self.findings if f.kind == "P_NOT_REPRODUCIBLE") @property def verdict(self) -> str: return "NON-REPRODUCIBLE P" if self.n_flag else "OK" def _split_row(line: str) -> list[str]: s = line.strip() if s.startswith("|"): s = s[1:] if s.endswith("|"): s = s[:-1] return [c.strip() for c in s.split("|")] def _fisher(a: int, b: int, c: int, d: int) -> float: r1, r2, c1, n = a + b, c + d, a + c, a + b + c + d if 0 in (r1, r2, c1, b + d): return 1.0 denom = math.comb(n, c1) lo, hi = max(0, c1 - r2), min(r1, c1) p_obs = math.comb(r1, a) * math.comb(r2, c1 - a) / denom tol = p_obs * (1 + 1e-7) total = sum(math.comb(r1, k) * math.comb(r2, c1 - k) / denom for k in range(lo, hi + 1) if math.comb(r1, k) * math.comb(r2, c1 - k) / denom <= tol) return min(1.0, total) def _chi2(a: int, b: int, c: int, d: int, yates: bool) -> float: n = a + b + c + d r1, r2, c1, c2 = a + b, c + d, a + c, b + d if 0 in (r1, r2, c1, c2): return 1.0 num = abs(a * d - b * c) if yates: num = max(0.0, num - n / 2) chi2 = n * num * num / (r1 * r2 * c1 * c2) return math.erfc(math.sqrt(chi2 / 2)) # 1 df def _pvals(a: int, b: int, c: int, d: int) -> tuple[float, float, float]: return _fisher(a, b, c, d), _chi2(a, b, c, d, True), _chi2(a, b, c, d, False) def _order_gap(rep_op: str, rep_val: float, comp: float) -> float: """log10 gap between reported and computed; for '<' bounds, only a computed value ABOVE the bound counts (a computed below the claimed upper bound is fine).""" if comp <= 0 or rep_val <= 0: return 0.0 if rep_op == "<": return max(0.0, math.log10(comp) - math.log10(rep_val)) return abs(math.log10(comp) - math.log10(rep_val)) def audit(text: str, source: str) -> Report: rep = Report(source=source) lines = text.splitlines() i, n = 0, len(lines) while i < n: if not ("|" in lines[i] and i + 1 < n and SEP_RE.match(lines[i + 1]) and "-" in lines[i + 1]): i += 1 continue header = _split_row(lines[i]) lineno0 = i + 1 group_cols = [j for j, h in enumerate(header) if HEADER_N_RE.search(h)] p_col = next((j for j, h in enumerate(header) if PVAL_HEADER_RE.match(h) or PVAL_HEADER_CONTAINS.search(h)), None) # gather rows rows = [] j = i + 2 while j < n and "|" in lines[j] and lines[j].strip(): rows.append((_split_row(lines[j]), j + 1)) j += 1 i = j if len(group_cols) < 2 or p_col is None: continue g1, g2 = group_cols[0], group_cols[1] n1 = int(HEADER_N_RE.search(header[g1]).group(1).replace(",", "")) n2 = int(HEADER_N_RE.search(header[g2]).group(1).replace(",", "")) parsed = [] for cells, ln in rows: if max(g1, g2, p_col) >= len(cells): continue m1, m2 = COUNT_CELL_RE.match(cells[g1]), COUNT_CELL_RE.match(cells[g2]) pm = PVAL_CELL_RE.match(cells[p_col]) if not (m1 and m2 and pm): continue # continuous row (mean±SD / IQR) or no P a, c = int(m1.group(1).replace(",", "")), int(m2.group(1).replace(",", "")) if a > n1 or c > n2: continue b, d = n1 - a, n2 - c parsed.append((cells[0], ln, a, b, c, d, pm.group(1) or "=", float(pm.group(2)))) if len(parsed) < 2: continue # cannot calibrate the family on a single row # calibrate: which family reproduces the most rows to <= 1e-3 (op '=')? computed = [(_pvals(a, b, c, d)) for _, _, a, b, c, d, _, _ in parsed] repro = [0, 0, 0] for (lbl, ln, a, b, c, d, op, val), pv in zip(parsed, computed): if op == "=": for k in range(3): if abs(pv[k] - val) <= 1e-3: repro[k] += 1 fam_idx = max(range(3), key=lambda k: repro[k]) if any(repro) else 2 for (lbl, ln, a, b, c, d, op, val), pv in zip(parsed, computed): gaps = [_order_gap(op, val, pv[k]) for k in range(3)] if min(gaps) > 1.0: # differs by >1 order under EVERY family closest = min(range(3), key=lambda k: gaps[k]) rep.findings.append(Finding( "P_NOT_REPRODUCIBLE", "MAJOR", ln, f"row '{lbl}' ({a}/{a+b} vs {c}/{c+d}) reports P{op}{val:g}, but recomputes to " f"Fisher {pv[0]:.3g} / Yates {pv[1]:.3g} / uncorrected {pv[2]:.3g} " f"(closest {FAMILIES[closest]}; table family ≈ {FAMILIES[fam_idx]})")) return rep def format_report(rep: Report, color: bool) -> str: tag = {"OK": "\033[92m", "NON-REPRODUCIBLE P": "\033[91m"}.get(rep.verdict, "") if color else "" end = "\033[0m" if color else "" out = [f"{tag}== {rep.verdict} =={end} {rep.source}", f"non_reproducible={rep.n_flag}"] if not rep.findings: out.append("every reported P reproduces from its counts under a standard test.") return "\n".join(out) for f in sorted(rep.findings, key=lambda x: (x.line, x.detail)): out.append(f"[{f.severity}] {f.kind} L{f.line} {f.detail}") return "\n".join(out) def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--strict", action="store_true", help="exit 1 on any P_NOT_REPRODUCIBLE") ap.add_argument("--quiet", action="store_true", help="suppress the report; exit code only") ap.add_argument("--json", action="store_true", help="emit JSON instead of a text report") args = ap.parse_args(argv) try: text = open(args.manuscript, encoding="utf-8").read() except OSError as e: print(f"error: cannot read manuscript: {e}", file=sys.stderr) return 2 rep = audit(text, args.manuscript) if not args.quiet: if args.json: print(json.dumps({"detector": "check_reported_p_from_counts", "source": rep.source, "verdict": rep.verdict, "findings": [asdict(f) for f in rep.findings]}, ensure_ascii=False, indent=2)) else: print(format_report(rep, color=sys.stdout.isatty())) return 1 if (args.strict and rep.n_flag) else 0 if __name__ == "__main__": raise SystemExit(main()) -
check_reviewer_team_consistency.py 15.1 KB
#!/usr/bin/env python3 """ check_reviewer_team_consistency.py — fabrication-grade self-review check. Detects manuscripts that simultaneously claim dual independent reviewers (in Methods + PROSPERO) and confess to single-reviewer execution (in Discussion §Limitations). Either claim alone is fine; the conjunction is a fabrication-grade red flag. Why this check exists ===================== Cross-project precedent (anonymized): a reporting-quality SR-of-AI-tools manuscript had: - Methods: "Two reviewers independently screened titles and abstracts ..." - PROSPERO record: "Two independent reviewers will perform full-text screening and data extraction." - Discussion §Limitations: "Single primary reviewer; a 20% sample by an additional reviewer is deferred to before submission." The conjunction admits in Limitations what Methods denies in narrative form. Reviewers and editors who notice this read it as fabrication-grade (the manuscript misrepresents what was actually done) and reject. Detection strategy ================== Section-aware grep for two regex families: - DUAL claim: "(independently|dual|two reviewers|both reviewers|two independent)" within Methods OR a PROSPERO record file. - SINGLE confession: "(single primary reviewer|one additional reviewer| 20% sample|sample of records|deferred to before submission|due to resource constraints|by the first reviewer alone)" within Limitations OR Discussion. Both present → MAJOR self-review red flag. Two further fabrication-grade axes (the prose↔JSON↔confession 3-way): - LLM-AS-REVIEWER (fatal): a per-study extraction JSON whose reviewer / screener / extractor / rater field is an LLM ("Claude", "GPT-4", "LLM", "Gemini"). An LLM is a tool, not an independent reviewer; listing it as one misrepresents the screening team regardless of the prose. Pass the extraction JSON (file or directory) via --extraction-json. - DEFERRED-MITIGATION (MAJOR): a future-tense mitigation promise — "a 20% sample will be completed before submission" — that is unmet at the time the manuscript circulates. The promise is evidence the work is not done. Usage ===== python check_reviewer_team_consistency.py \\ --manuscript manuscript.md \\ --prospero prospero/record.md \\ --out _audit_self/reviewer_team_consistency.md Output ====== Markdown report at `--out` summarizing matches. Also a JSON sidecar at `--out` + ".json". Exit codes ========== 0 no conflict 1 MAJOR red flag detected (both DUAL and SINGLE patterns present) 2 invocation error """ from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass, field from pathlib import Path DUAL_PATTERNS = [ ( re.compile(r"\btwo\s+(?:independent\s+)?reviewers?\b", re.IGNORECASE), "two reviewers", ), (re.compile(r"\bdual\s+(?:independent\s+)?(?:reviewers?|extractors?)\b", re.IGNORECASE), "dual reviewers"), (re.compile(r"\bindependent(?:ly)?\s+screened\b", re.IGNORECASE), "independently screened"), ( re.compile(r"\bboth\s+reviewers?\s+(?:independently|extracted|screened)\b", re.IGNORECASE), "both reviewers", ), ( re.compile(r"\bindependent(?:ly)?\s+(?:extracted|coded|assessed)\b", re.IGNORECASE), "independent extraction", ), ( re.compile(r"\bindependent\s+reviewers?\b", re.IGNORECASE), "independent reviewers", ), ] SINGLE_PATTERNS = [ (re.compile(r"\bsingle\s+primary\s+reviewer\b", re.IGNORECASE), "single primary reviewer"), ( re.compile(r"\bone\s+additional\s+reviewer\b", re.IGNORECASE), "one additional reviewer", ), ( re.compile(r"\b20\s*%?\s+sample\b", re.IGNORECASE), "20% sample", ), ( re.compile(r"\bsample\s+of\s+records\b", re.IGNORECASE), "sample of records", ), ( re.compile(r"\bdeferred\s+to\s+before\s+submission\b", re.IGNORECASE), "deferred to before submission", ), ( re.compile(r"\bdue\s+to\s+resource\s+constraints\b", re.IGNORECASE), "due to resource constraints", ), ( re.compile(r"\b(?:by|with)\s+(?:the\s+)?first\s+reviewer\s+(?:alone|only)\b", re.IGNORECASE), "first reviewer alone", ), ] # An LLM named where an independent reviewer should be (fatal). Bare "ai" is too # broad (matches names), so it is excluded; explicit model families + "LLM" only. LLM_NAME_RE = re.compile( r"\b(?:claude|chatgpt|gpt-?\d|gpt|llm|large language model|gemini|copilot|bard|" r"ai model|an ai\b)\b", re.IGNORECASE) REVIEWER_KEY_RE = re.compile( r"reviewer|screener|extractor|rater|annotator|adjudicator|coder", re.IGNORECASE) # A future-tense mitigation promised but not yet executed at circulation. DEFERRED_MITIGATION_RE = re.compile( r"(?:will be|to be|is to be|are to be)\s+" r"(?:completed|performed|conducted|done|undertaken|carried out|finalized|finalised)\b" r"[^.]{0,80}?(?:before|prior to|ahead of)\s+(?:final\s+)?submission", re.IGNORECASE) SECTION_HEADERS = { "Methods": re.compile( r"^#{1,3}\s*\*{0,2}(?:METHODS?|Method[s]?|Materials and Methods)\*{0,2}\s*$", re.IGNORECASE | re.MULTILINE, ), "Discussion": re.compile( r"^#{1,3}\s*\*{0,2}(?:DISCUSSION|Discussion)\*{0,2}\s*$", re.IGNORECASE | re.MULTILINE, ), "Limitations": re.compile( r"^#{1,3}\s*\*{0,2}(?:LIMITATIONS?|Limitations?|Study Limitations)\*{0,2}\s*$", re.IGNORECASE | re.MULTILINE, ), } @dataclass class Hit: pattern_label: str line: int context: str @dataclass class Report: submission_safe: bool dual_hits: list[dict] = field(default_factory=list) single_hits: list[dict] = field(default_factory=list) llm_reviewer_hits: list[dict] = field(default_factory=list) deferred_mitigation_hits: list[dict] = field(default_factory=list) def split_sections(text: str) -> dict[str, str]: out: dict[str, str] = {name: "" for name in SECTION_HEADERS} headers: list[tuple[str, int, int]] = [] for name, pat in SECTION_HEADERS.items(): for m in pat.finditer(text): headers.append((name, m.start(), m.end())) headers.sort(key=lambda t: t[1]) for i, (name, _, hdr_end) in enumerate(headers): end = headers[i + 1][1] if i + 1 < len(headers) else len(text) out[name] = (out[name] + "\n" + text[hdr_end:end]).strip() return out def scan_text(text: str, patterns: list[tuple[re.Pattern[str], str]]) -> list[Hit]: hits: list[Hit] = [] lines = text.splitlines() for lineno, line in enumerate(lines, start=1): for pat, label in patterns: if pat.search(line): ctx = line.strip() if len(ctx) > 200: ctx = ctx[:200] + "..." hits.append(Hit(pattern_label=label, line=lineno, context=ctx)) return hits def hit_to_dict(h: Hit, source: str) -> dict: return { "source": source, "pattern": h.pattern_label, "line": h.line, "context": h.context, } def _iter_extraction_files(path: Path): if path.is_dir(): yield from sorted(path.rglob("*.json")) elif path.is_file(): yield path def _walk_reviewer_fields(obj, source: str, hits: list[dict], keypath: str = "") -> None: """Recursively find reviewer-role fields whose value names an LLM.""" if isinstance(obj, dict): for k, v in obj.items(): kp = f"{keypath}.{k}" if keypath else str(k) if isinstance(v, str) and REVIEWER_KEY_RE.search(str(k)) and LLM_NAME_RE.search(v): hits.append({"source": source, "field": kp, "value": v[:120]}) _walk_reviewer_fields(v, source, hits, kp) elif isinstance(obj, list): for i, v in enumerate(obj): _walk_reviewer_fields(v, source, hits, f"{keypath}[{i}]") def scan_llm_reviewers(extraction: Path | None) -> list[dict]: hits: list[dict] = [] if extraction is None: return hits for f in _iter_extraction_files(extraction): try: data = json.loads(f.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): continue _walk_reviewer_fields(data, f.name, hits) return hits def scan_deferred_mitigation(manuscript: str) -> list[dict]: hits: list[dict] = [] for lineno, line in enumerate(manuscript.splitlines(), start=1): m = DEFERRED_MITIGATION_RE.search(line) if m: hits.append({"source": "manuscript", "line": lineno, "context": m.group(0).strip()[:160]}) return hits def build_report(manuscript: str, prospero: str | None, extraction: Path | None = None) -> Report: sections = split_sections(manuscript) dual_hits: list[dict] = [] single_hits: list[dict] = [] # Methods → DUAL evidence. for h in scan_text(sections.get("Methods", ""), DUAL_PATTERNS): dual_hits.append(hit_to_dict(h, "manuscript:Methods")) # PROSPERO → DUAL evidence. if prospero is not None: for h in scan_text(prospero, DUAL_PATTERNS): dual_hits.append(hit_to_dict(h, "prospero")) # Discussion & Limitations → SINGLE evidence. for region in ("Limitations", "Discussion"): for h in scan_text(sections.get(region, ""), SINGLE_PATTERNS): single_hits.append(hit_to_dict(h, f"manuscript:{region}")) llm_reviewer_hits = scan_llm_reviewers(extraction) deferred_hits = scan_deferred_mitigation(manuscript) submission_safe = not ( (dual_hits and single_hits) or llm_reviewer_hits or deferred_hits ) return Report( submission_safe=submission_safe, dual_hits=dual_hits, single_hits=single_hits, llm_reviewer_hits=llm_reviewer_hits, deferred_mitigation_hits=deferred_hits, ) def _render_extra(lines: list[str], report: Report) -> None: if report.llm_reviewer_hits: lines.append("") lines.append("## LLM-AS-REVIEWER (fatal)") lines.append("An LLM is named where an independent reviewer is required:") for h in report.llm_reviewer_hits: lines.append(f"- `{h['source']}` field `{h['field']}` = {h['value']}") lines.append("Fix: list a human reviewer; an LLM is a tool, not a member of the review team.") if report.deferred_mitigation_hits: lines.append("") lines.append("## DEFERRED-MITIGATION (MAJOR)") lines.append("A mitigation is promised in the future tense but not yet executed:") for h in report.deferred_mitigation_hits: lines.append(f"- line {h['line']}: {h['context']}") lines.append("Fix: execute and report the mitigation before circulation, or remove the claim.") def render_markdown(report: Report) -> str: lines = ["# Reviewer-team consistency audit", ""] if report.submission_safe: lines.append( "Status: **PASS** — no conjunction of DUAL claim + SINGLE confession." ) lines.append("") if report.dual_hits: lines.append(f"DUAL claims found ({len(report.dual_hits)}, OK alone):") for h in report.dual_hits: lines.append(f"- `{h['source']}` line {h['line']}: `{h['pattern']}` — {h['context']}") if report.single_hits: lines.append("") lines.append(f"SINGLE confessions found ({len(report.single_hits)}, OK alone):") for h in report.single_hits: lines.append(f"- `{h['source']}` line {h['line']}: `{h['pattern']}` — {h['context']}") else: triggers = [] if report.dual_hits and report.single_hits: triggers.append("DUAL claim + SINGLE confession") if report.llm_reviewer_hits: triggers.append("LLM-as-reviewer") if report.deferred_mitigation_hits: triggers.append("deferred mitigation") lines.append(f"Status: **MAJOR red flag** — {', '.join(triggers)}.") lines.append("") lines.append("Reviewers will read this as fabrication-grade.") if report.dual_hits and report.single_hits: lines.append("") lines.append("## DUAL claims (Methods / PROSPERO)") for h in report.dual_hits: lines.append(f"- `{h['source']}` line {h['line']}: `{h['pattern']}`") lines.append(f" > {h['context']}") lines.append("") lines.append("## SINGLE confessions (Discussion / Limitations)") for h in report.single_hits: lines.append(f"- `{h['source']}` line {h['line']}: `{h['pattern']}`") lines.append(f" > {h['context']}") _render_extra(lines, report) return "\n".join(lines) + "\n" def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Reviewer-team consistency check (fabrication-grade self-review)." ) parser.add_argument("--manuscript", type=Path, required=True) parser.add_argument("--prospero", type=Path, default=None) parser.add_argument( "--extraction-json", type=Path, default=None, help="per-study extraction JSON file or directory (reviewer-field LLM scan)", ) parser.add_argument( "--out", type=Path, default=Path("_audit_self/reviewer_team_consistency.md"), ) parser.add_argument("--quiet", action="store_true") args = parser.parse_args(argv) if not args.manuscript.is_file(): print(f"ERROR: manuscript not found: {args.manuscript}", file=sys.stderr) return 2 prospero_text: str | None = None if args.prospero is not None: if not args.prospero.is_file(): print(f"ERROR: prospero not found: {args.prospero}", file=sys.stderr) return 2 prospero_text = args.prospero.read_text(encoding="utf-8") if args.extraction_json is not None and not args.extraction_json.exists(): print(f"ERROR: extraction-json not found: {args.extraction_json}", file=sys.stderr) return 2 text = args.manuscript.read_text(encoding="utf-8") report = build_report(text, prospero_text, args.extraction_json) args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text(render_markdown(report), encoding="utf-8") json_out = args.out.with_suffix(args.out.suffix + ".json") json_out.write_text( json.dumps({"detector": "check_reviewer_team_consistency", "submission_safe": report.submission_safe, "dual_hits": report.dual_hits, "single_hits": report.single_hits, "llm_reviewer_hits": report.llm_reviewer_hits, "deferred_mitigation_hits": report.deferred_mitigation_hits, }, indent=2, ), encoding="utf-8", ) if not args.quiet: counts = (f"DUAL={len(report.dual_hits)} SINGLE={len(report.single_hits)} " f"LLM={len(report.llm_reviewer_hits)} " f"DEFERRED={len(report.deferred_mitigation_hits)}") if report.submission_safe: print(f"PASS: no fabrication-grade conflict. {counts}") else: print(f"FAIL: MAJOR red flag. {counts}") print(f"See {args.out}") return 0 if report.submission_safe else 1 if __name__ == "__main__": sys.exit(main()) -
check_rhetorical_density.py 10.7 KB
#!/usr/bin/env python3 """Rhetorical-construction density gate — antithesis parallelism and cleft, two sentence-structure AI tells (self-review §J / humanize P27). LLM-drafted argumentative prose over-builds two marked constructions that a per-instance rule cannot flag, because each single occurrence is grammatical and often functional: * **antithesis parallelism** — "authority rather than cognition", "not a check but a second road", "involvement, not independence". One is a legitimate contrast; a run of them is an epigram machine. This was the dominant tell (28 "rather than" in one draft) an external reader found in prose that had already cleared the em-dash / passive / word sweeps. * **cleft / pseudo-cleft** — sentence-initial "What matters is …", "It is X that …". A fronting construction the model reaches for to sound weighty. Neither is wrong once. What marks the machine draft is DENSITY: the reader meets a "rather than" or a fronted clause every few sentences. So this gate never judges a single instance — it counts them per 1,000 body words and fires only when the rate AND a raw floor both clear a threshold set above the rate seen in this project's own published-quality demos (where "rather than" runs 1.4-3.7 / 1,000 and sentence-initial clefts are absent). A lone functional "rather than", an "instead of", or one pseudo-cleft therefore never trips it. Verdicts (both Minor; either can fire independently): ANTITHESIS_DENSITY antithesis markers (rather-than / not-X-but-Y / X-not-Y) per 1,000 words exceed the threshold, with the raw count above a floor. CLEFT_DENSITY sentence-initial cleft / pseudo-cleft constructions per 1,000 words exceed the threshold, with the raw count above a floor. Scope, deliberately conservative on a widely-used skill: * Front matter, headings, tables, block quotes, code fences, list items, citation markers and inline markup are stripped before measuring (shared _frontmatter helper). * "instead of" is NOT counted — it is the functional, non-decorative sibling of "rather than" and its presence is not a tell. * "not only X but also Y" is excluded from the not-X-but-Y marker (that correlative is humanize Pattern 9, a separate check), and conjunctive-adverb / partial-negation forms ("however, not all …", "not yet") are excluded from the X-not-Y marker. * Silent below --min-words (default 200): a short note has too few words for a rate to mean anything. The rewrite heuristic to apply once flagged (M2, adapted from the SNL-UCSB paper-writing skill's gate_mechanical.md, MIT): delete the negative half and rewrite the clause in the positive. If a fact disappears the contrast was functional — keep it; if nothing disappears it was decoration — cut it. Judge by the manuscript's overall rate, not instance by instance; keep two or three for emphasis rather than flattening every one. Exit codes: 0 clean/report-only, 1 with --strict when any Major (none — Minor only), 2 usage. Stdlib-only. Usage: python3 check_rhetorical_density.py --manuscript manuscript.md \ [--out qc/rhetorical_density.json] [--antithesis-per-1000 6.0] \ [--cleft-per-1000 2.5] [--min-words 200] [--strict] [--quiet] """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path from _prose import body_text DETECTOR = "check_rhetorical_density" WORD_RE = re.compile(r"[A-Za-z0-9']+") SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+(?=[\"“(]?[A-Z0-9])") # --- antithesis markers ------------------------------------------------------------------ RATHER_THAN_RE = re.compile(r"\brather than\b", re.I) # "not X but Y" (X up to three words); "not only … but" is Pattern 9, excluded. NOT_BUT_RE = re.compile(r"\bnot\s+(?!only\b)[\w'-]+(?:\s+[\w'-]+){0,2}\s+but\b", re.I) # "X, not Y" contrastive apposition, Y a bare 1-3 word phrase closing the clause. X_NOT_Y_RE = re.compile( r"([\w'-]+),\s+not\s+([\w'-]+)(?:\s+[\w'-]+){0,2}\s*[.;:,]", re.I ) # Pre-comma words that make "X, not …" a sentence adverb + negation, not an antithesis. _CONJ_ADVERBS = { "however", "therefore", "moreover", "furthermore", "thus", "hence", "nonetheless", "nevertheless", "indeed", "finally", "first", "second", "third", "meanwhile", "otherwise", "consequently", "accordingly", "similarly", "conversely", "also", "again", } # Y-heads that make "not Y" a partial/quantifier negation, not a contrastive noun. _PARTIAL_NEG = { "all", "every", "always", "necessarily", "yet", "just", "merely", "simply", "only", "quite", "entirely", "wholly", "fully", } # --- cleft markers (applied per sentence) ------------------------------------------------ # pseudo-cleft: "What <>=1 word> is/are/was/were …" (a statement, not a "What is X?" question) PSEUDO_CLEFT_RE = re.compile(r"^What\s+[\w'-]+(?:\s+[\w'-]+)*?\s+(?:is|are|was|were)\b", re.I) # it-cleft: "It is/was <focus> that/which/who …" IT_CLEFT_RE = re.compile( r"^It\s+(?:is|was|has\s+been)\s+[\w'-].*?\b(?:that|which|who)\b", re.I ) def sentences(txt: str) -> list: return [s.strip() for s in SENT_SPLIT_RE.split(txt) if s.strip()] def _x_not_y_hits(txt: str) -> list: hits = [] for m in X_NOT_Y_RE.finditer(txt): if m.group(1).lower() in _CONJ_ADVERBS: continue if m.group(2).lower() in _PARTIAL_NEG: continue hits.append(m.group(0).strip()) return hits def analyse(md: str) -> dict: body = body_text(md) n_words = len(WORD_RE.findall(body)) anti = ( [m.group(0) for m in RATHER_THAN_RE.finditer(body)] + [m.group(0).strip() for m in NOT_BUT_RE.finditer(body)] + _x_not_y_hits(body) ) cleft = [] for s in sentences(body): if s.endswith("?"): continue if PSEUDO_CLEFT_RE.match(s) or IT_CLEFT_RE.match(s): cleft.append(s[:70]) def rate(n: int) -> float: return round(n * 1000.0 / n_words, 2) if n_words else 0.0 return { "words": n_words, "antithesis": anti, "cleft": cleft, "antithesis_per_1000": rate(len(anti)), "cleft_per_1000": rate(len(cleft)), } ANTITHESIS_MIN_COUNT = 5 CLEFT_MIN_COUNT = 3 def check(md: str, antithesis_per_1000: float, cleft_per_1000: float, min_words: int) -> dict: r = analyse(md) findings = [] if r["words"] >= min_words: na, nc = len(r["antithesis"]), len(r["cleft"]) if na >= ANTITHESIS_MIN_COUNT and r["antithesis_per_1000"] > antithesis_per_1000: findings.append({ "verdict": "ANTITHESIS_DENSITY", "severity": "Minor", "detail": ( f"{na} antithesis constructions in {r['words']} body words " f"({r['antithesis_per_1000']}/1000 > {antithesis_per_1000:.1f} threshold) — " "a run of 'rather than' / 'not X but Y' / 'X, not Y' epigrams; each is " "grammatical but the density is an LLM tell. Delete the negative half and " "rewrite in the positive; if a fact disappears the contrast was functional " "(keep it), if nothing disappears it was decoration (cut it). Keep two or three." ), "where": "; ".join(m[:40] for m in r["antithesis"][:8]), }) if nc >= CLEFT_MIN_COUNT and r["cleft_per_1000"] > cleft_per_1000: findings.append({ "verdict": "CLEFT_DENSITY", "severity": "Minor", "detail": ( f"{nc} cleft / pseudo-cleft constructions in {r['words']} body words " f"({r['cleft_per_1000']}/1000 > {cleft_per_1000:.1f} threshold) — " "sentence-initial 'What … is …' / 'It is … that …' fronting used for " "weight. Rewrite in plain subject-verb order ('What matters is X' -> 'X " "matters'); keep at most one for genuine emphasis." ), "where": "; ".join(s[:48] for s in r["cleft"][:8]), }) return {"metrics": {k: r[k] for k in ("words", "antithesis_per_1000", "cleft_per_1000")}, "findings": findings, "detail": r} def main() -> int: ap = argparse.ArgumentParser( description="Rhetorical-construction density gate — antithesis + cleft (§J / P27).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--antithesis-per-1000", type=float, default=6.0, help="antithesis markers per 1000 words that trips the flag (default 6.0)") ap.add_argument("--cleft-per-1000", type=float, default=2.5, help="cleft constructions per 1000 words that trips the flag (default 2.5)") ap.add_argument("--min-words", type=int, default=200, help="stay silent below this many body words (default 200)") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major (none — this gate is Minor-only)") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") a = ap.parse_args() src = Path(a.manuscript) if not src.is_file(): sys.stderr.write(f"error: no such file: {src}\n") return 2 result = check(src.read_text(encoding="utf-8", errors="ignore"), a.antithesis_per_1000, a.cleft_per_1000, a.min_words) findings = result["findings"] if not a.quiet: print("=" * 42) print(" Rhetorical-construction density (§J / P27)") print("=" * 42) print("| Verdict | Severity | Detail |") print("|---|---|---|") if findings: for f in findings: print(f"| {f['verdict']} | {f['severity']} | {f['detail']} |") for f in findings: print(f"\n{f['verdict']} — {f['where']}") else: print("| (none) | — | antithesis / cleft density within the published range |") m = result["metrics"] print(f"\nOK: antithesis {m['antithesis_per_1000']}/1000, " f"cleft {m['cleft_per_1000']}/1000 — within range.") if a.out: out = Path(a.out) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps({ "detector": DETECTOR, "manuscript": str(src), "metrics": result["metrics"], "findings": findings, }, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") if not a.quiet: print(f"\nwrote {out}") return 1 if (a.strict and any(f["severity"] == "Major" for f in findings)) else 0 if __name__ == "__main__": sys.exit(main()) -
check_rounded_delta.py 7 KB
#!/usr/bin/env python3 """Rounded-component vs stated-difference arithmetic gate (self-review Phase 2.5a). When a manuscript displays two metric values at a given precision and also states their difference, the stated difference must equal the subtraction of the DISPLAYED components at that same precision. A paper that reports "AUC 0.70 vs 0.73" (a shown gap of 0.03) while stating the between-arm difference as "0.02" is self-consistent only at full unrounded precision — at the displayed two decimals it reads as a contradiction, and a reviewer flags it. (The usual cause: the delta is computed on unrounded values, e.g. 0.726 − 0.703 = 0.023 → 0.02, while the components are shown rounded to 0.70 / 0.73.) Verdict: ROUNDED_DELTA_MISMATCH (Minor) a stated difference does not equal the subtraction of its two displayed component values AT THE SAME precision. Fix: either report both components and the delta at a consistent precision, or add a footnote that the delta is computed on unrounded values. Conservative by construction: fires only when (a) two component values joined by a comparator (vs / versus / and / to / , ) appear in the same sentence as (b) an explicitly LABELLED difference (Δ / difference / delta), and (c) all three are at the SAME decimal precision. A higher-precision component pair (0.703 vs 0.726) with a 2-dp delta is the legitimate unrounded-delta case and is NOT flagged. Exit codes: 0 clean/report-only, 1 with --strict when any Major (none here — Minor only, so --strict never fails on this gate alone), 2 usage. Stdlib-only. Usage: python3 check_rounded_delta.py --manuscript manuscript.md \ [--out qc/rounded_delta.json] [--strict] [--quiet] """ from __future__ import annotations import argparse import json import re import sys from decimal import Decimal from pathlib import Path # Two component values joined by a comparator (the "0.70 vs 0.73" construction). COMP_PAIR = re.compile( r"(?P<a>\d?\.\d+|\d+\.\d+)\s*(?:vs\.?|versus|and|to|,|–|—|-)\s*(?P<b>\d?\.\d+|\d+\.\d+)", re.I) # An explicitly labelled difference value. DELTA = re.compile( r"(?:Δ\s*(?:AUC|AUROC|C[-\s]?index|accuracy)?|" r"(?:absolute\s+|between[-\s]\w+\s+|net\s+)?difference|delta|" r"differ(?:ed|s)?\s+by)" r"\s*(?:of|was|were|is|=|:|,|by)?\s*(?:only\s+|about\s+|approximately\s+)?" r"\(?(?P<d>\d?\.\d+|\d+\.\d+)", re.I) def _prec(tok: str) -> int: return len(tok.split(".", 1)[1]) if "." in tok else 0 # How far back from a labelled difference to look for its two component values. # Wide enough to bridge a "0.70 and 0.73; the difference was 0.02" clause boundary, # narrow enough not to grab an unrelated pair from a previous sentence. _LOOKBACK = 170 def check(text: str) -> list[dict]: claims: list[dict] = [] seen: set[str] = set() for dm in DELTA.finditer(text): d_tok = dm.group("d") p = _prec(d_tok) if p == 0: continue window = text[max(0, dm.start() - _LOOKBACK):dm.start()] # bind the component pair to the delta's OWN sentence: drop anything up to the # last sentence terminator so a pair from a previous clause is not matched. cut = max(window.rfind(". "), window.rfind(".\n"), window.rfind("\n\n")) if cut != -1: window = window[cut + 1:] for cm in COMP_PAIR.finditer(window): a_tok, b_tok = cm.group("a"), cm.group("b") # the delta value itself is not a component of the pair if d_tok in (a_tok, b_tok): continue # all three must share the displayed precision (else it is the # legitimate unrounded-delta case, not a display contradiction) if _prec(a_tok) != p or _prec(b_tok) != p: continue a, b, d = Decimal(a_tok), Decimal(b_tok), Decimal(d_tok) shown = abs(a - b) q = Decimal(1).scaleb(-p) # 10**-p if shown.quantize(q) == d.quantize(q): continue key = f"{a_tok}|{b_tok}|{d_tok}" if key in seen: continue seen.add(key) claims.append({ "verdict": "ROUNDED_DELTA_MISMATCH", "severity": "Minor", "detail": (f"displayed components {a_tok} and {b_tok} differ by " f"{shown.quantize(q)} at the shown precision, but the stated " f"difference is {d_tok}; report components and delta at one " f"precision or footnote that the delta is on unrounded values"), "where": text[max(0, dm.start() - _LOOKBACK):dm.end() + 4].replace("\n", " ").strip()[:170], }) return claims def analyze(manuscript: str) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8")) return { "manuscript": str(p), "claims": claims, "summary": { "n_claims": len(claims), "n_major": 0, "n_flag": len(claims), "verdict": "REVIEW" if claims else "OK", }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | stated differences match displayed components |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser( description="Rounded-component vs stated-difference arithmetic gate (Phase 2.5a).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major (none — this gate is Minor-only)") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript) if not args.quiet: print("=" * 44) print(" Rounded-component vs stated difference (§2.5a)") print("=" * 44) print(render(result)) print() n = result["summary"]["n_flag"] print(f"{'REVIEW: ' + str(n) + ' rounded-delta mismatch(es).' if n else 'OK: stated differences match displayed components.'}") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_rounded_delta", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_scope_coherence.py 21.1 KB
#!/usr/bin/env python3 """Endpoint↔conclusion scope-coherence gate (self-review §D). Two overclaim patterns where the conclusion's action exceeds what the design or endpoint can support. Both are deterministic when a design/endpoint signal and a conclusion action verb co-occur, and both are documented anti-patterns (scope-coherence-gate.md): CROSS_SECTIONAL_PROGNOSTIC the design is cross-sectional / single-visit / prevalence, yet the conclusion makes a prognostic or surveillance claim (rescreen interval, surveillance, disease progression, predicting future risk). A single time point cannot license a longitudinal conclusion. SURROGATE_CARE_DIRECTIVE a binary surrogate endpoint (present/absent, >0, dichotomized) drives a patient-care directive (defer, withhold, initiate/discontinue therapy, statin). A risk-stratification marker is not a management trigger. UNIVERSAL_NEGATIVE_UNSCOPED a 'nobody / first to / has not been' novelty claim in a claim region with no named discipline-scope qualifier (Minor): a single-database search cannot support a universal negative. Narrow the claim or widen the search. CROSS_SECTIONAL_YIELD_LANGUAGE a cross-sectional / prevalence design uses incidence/prospective-flavored vocabulary — "yield", "detection rate", "number-needed-to-screen/image", "rescreen interval", "screen-detected". On a prevalence design these read as longitudinal screening performance. Minor unless "yield" is defined once as cross-sectional report-positive prevalence. GRADIENT_WITHOUT_INTERACTION a cross-strata directional claim ("shortest in the high-risk tertile", "monotonically across the age strata") stated as a finding, with a stratification context nearby, but NO interaction test (interaction term / LRT / p- interaction / effect modification) reported anywhere. A difference in significance across strata is not a tested interaction. Minor. Precision-guarded: a physical "pressure gradient" or "gradient echo" and an interaction-tested claim do not fire. The gate is conservative: it fires only when both a signal and a conclusion-region verb are present, to keep false positives low on a widely-used skill. INPUTS --manuscript manuscript markdown/text (required). OUTPUT A reconciliation table (stdout) and, with --out, a JSON artifact: {manuscript, claims[{verdict, severity, detail, where}], summary} CROSS_SECTIONAL_PROGNOSTIC and SURROGATE_CARE_DIRECTIVE are Major; UNIVERSAL_NEGATIVE_ UNSCOPED, CROSS_SECTIONAL_YIELD_LANGUAGE and GRADIENT_WITHOUT_INTERACTION are Minor. Exit 1 (with --strict) on any Major. Stdlib-only (json / re / argparse / pathlib). Exit codes: 0 clean (or report-only), 1 Major claim(s) found (with --strict), 2 input/usage error. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path DESIGN_CROSS_SECTIONAL = re.compile( r"cross[-\s]?sectional|single[-\s](?:time[-\s]?point|visit|examination|measurement)|" r"at (?:a |one )?(?:single |one )?time[-\s]?point|point[-\s]prevalence|" r"prevalence (?:study|survey|design)", re.IGNORECASE) PROGNOSTIC_VERB = re.compile( r"surveillance|re[-\s]?screen|screening interval|rescreening|" r"monitor(?:ed|ing)?\s+over\s+time|disease progression|progress(?:es|ion)\s+to|" r"prognost|predict(?:s|ing|ed)?\s+(?:incident|future|long[-\s]?term|the risk of developing)|" r"longitudinal (?:follow|risk|trajector)", re.IGNORECASE) # A prognostic/surveillance token sitting inside a negation/deferral frame is a # correct hedge, not an overclaim ("describes concurrent burden RATHER THAN # surveillance intervals, which WOULD REQUIRE PROSPECTIVE data"). You cannot # disavow surveillance without naming it; do not fire CROSS_SECTIONAL_PROGNOSTIC # when the match is disclaimed in its immediate vicinity. PROGNOSTIC_DISCLAIMER = re.compile( r"rather than|instead of|not\s+(?:a\s+|the\s+)?(?:surveillance|prognostic|longitudinal)|" r"does not (?:establish|imply|support|provide|address|determine|permit)|" r"cannot (?:establish|determine|inform|assess|address)|" r"would require|requires?\s+prospective|warrants?\s+prospective|" r"defer(?:s|red|ring)?\b[^.]{0,40}\bprospective|" r"beyond the scope|no (?:prognostic|surveillance|longitudinal) (?:claim|inference|conclusion)", re.IGNORECASE) # A methods / QC / detector paper — or a review — whose SUBJECT is this very # anti-pattern NAMES the pattern rather than committing it ("this paper detects # manuscripts that make a prognostic claim in a cross-sectional setting"). When the # match sits inside such a meta-framing, it is a description, not an overclaim; do # not fire. Kept tight (requires the meta-framing structure, not a bare "detect") # so a real prognostic overclaim is never suppressed. META_DOC_FRAME = re.compile( r"anti[-\s]?pattern|such (?:patterns|claims|conclusions|overclaims)\b|" r"(?:papers?|manuscripts?|studies|authors) (?:that|which|who) " r"(?:commit|make|assert|report|conflate|claim|treat|use)\b|" r"we (?:report|show|find|note|observe) that (?:papers|manuscripts|studies|authors|some|many)|" r"(?:this|the present) (?:paper|study|work|review|tool|detector|gate|framework|probe)\b" r"[^.]{0,40}?(?:discuss|describ|detect|flag|identif|examin|review|illustrat|catalog)|" r"(?:as|is) an? (?:example|illustration|instance|case) of", re.IGNORECASE) # A sentence that LABELS a prognostic/surveillance claim as a defect — "an # unsupported prognostic claim in a cross-sectional study", "prognostic # overreach", "an unwarranted surveillance recommendation" — is naming the # anti-pattern, not committing it. This catches the enumerated-defect framing # (a defect listed as an appositive in a list of things a tool flags) that # META_DOC_FRAME misses because the framing verb ("flags"/"detects") sits far # from the match. Stays high-precision: a manuscript making a real prognostic # claim never precedes it with "unsupported"/"unwarranted", nor calls it an # "overreach"/"overclaim" — so no genuine overclaim is suppressed. ANTIPATTERN_LABEL = re.compile( r"(?:unsupported|unwarranted|unjustified|inappropriate|spurious|invalid|" r"overreaching|erroneous|illegitimate)\s+(?:\w+\s+){0,2}?" r"(?:prognostic|surveillance|longitudinal)|" r"(?:prognostic|surveillance|longitudinal)\s+(?:overclaim|overreach|fallacy|error)", re.IGNORECASE) DIRECTIVE_VERB = re.compile( r"\bdefer(?:ral|red|ring)?\b|\bwithhold\b|\bforgo\b|\binitiat(?:e|ed|ion)\b|" r"\bdiscontinu(?:e|ed|ation)\b|start(?:ing)?\s+(?:statin|therapy|treatment|pharmacotherapy)|" r"(?:statin|treatment|therapy|pharmacotherapy)\s+(?:can|should|may)\s+be\s+(?:deferred|withheld|started|initiated)|" r"recommend(?:ed)?\s+(?:statin|treatment|therapy|initiation|against treatment)|" r"guide\s+(?:treatment|management|therapy)", re.IGNORECASE) YIELD_LANGUAGE = re.compile( r"\byield\b|detection rate|number[-\s]needed[-\s]to[-\s](?:screen|image)|" r"\bnn[si]\b|rescreen(?:ing)?\s+interval|screen[-\s]detected", re.IGNORECASE) # "yield" pinned once to a cross-sectional prevalence reading suppresses the flag. YIELD_DEFINED = re.compile( r"yield (?:is|was|here|,)?\s*(?:defined|refers|denotes|i\.e\.)|" r"defined as the (?:cross[-\s]sectional )?(?:report[-\s]positive )?prevalence|" r"cross[-\s]sectional (?:report[-\s]positive )?prevalence", re.IGNORECASE) SURROGATE_SIGNAL = re.compile( r"binary (?:surrogate|endpoint|outcome|marker)|dichotom(?:ous|ised|ized)|surrogate (?:endpoint|marker|outcome)|" r"presence (?:or absence )?of|present (?:vs\.?|versus|or) absent|positive (?:vs\.?|versus|or) negative|" r"categor(?:ised|ized) as (?:positive|present|absent)|>\s?0\b", re.IGNORECASE) CONCLUSION_HEADINGS = re.compile( r"^#{1,4}\s*\*{0,2}(?:CONCLUSIONS?|Conclusions?|DISCUSSION|Discussion|" r"Clinical Implications?|Interpretation)\*{0,2}\s*$", re.IGNORECASE | re.MULTILINE) def conclusion_region(text: str) -> str: """Text under Conclusion/Discussion/Implications headings, plus any inline 'Conclusion:' clause (abstract). Fallback: the last 25% of the document.""" spans = [] starts = [m.end() for m in CONCLUSION_HEADINGS.finditer(text)] # heading-delimited regions: from each heading to the next top-level heading all_headings = [m.start() for m in re.finditer(r"^#{1,4}\s", text, re.MULTILINE)] for s in starts: nxt = next((h for h in all_headings if h > s), len(text)) spans.append(text[s:nxt]) for m in re.finditer(r"(?:^|\n)\s*\*{0,2}Conclusions?\*{0,2}\s*[:.]\s*(.+?)(?:\n\n|$)", text, re.IGNORECASE | re.DOTALL): spans.append(m.group(1)) if not spans: spans.append(text[int(len(text) * 0.75):]) return "\n".join(spans) # Claim regions where a novelty/neglect claim lives: Abstract, Introduction/ # Background, Discussion, Conclusion. NOVELTY_REGION_HEADINGS = re.compile( r"^#{1,4}\s*\*{0,2}(?:ABSTRACT|Abstract|INTRODUCTION|Introduction|BACKGROUND|Background|" r"DISCUSSION|Discussion|CONCLUSIONS?|Conclusions?)\*{0,2}\s*:?\s*$", re.IGNORECASE | re.MULTILINE) # A universal-negative / first-to novelty construction. UNIVERSAL_NEGATIVE_RE = re.compile( r"\bno(?:body|-one| one)\b" r"|\bnone of (?:the |these )?(?:\w+\s+){0,2}(?:studies|systems|works|methods|papers|reports)\b" r"|\bno (?:\w+\s+){0,3}(?:published |existing |prior )?" r"(?:system|study|work|method|approach|dataset|benchmark|paper|report|research)\b" r"[^.\n]{0,50}?\b(?:measure|report|quantif|examine|assess|address|exist|investigate|describe|ask)" r"|\bnever been (?:measured|examined|studied|reported|asked|quantified|assessed|investigated|addressed|explored)\b" r"|\bhas not (?:yet )?been (?:measured|examined|studied|asked|quantified|assessed|investigated|addressed|explored)\b" r"|\bfirst (?:study|work|report|paper)\b[^.\n]{0,50}?\b(?:to )?(?:measure|report|examine|quantif|assess|investigate|describe|characteri[sz]e)" r"|\bwe are the first\b|\bthe first to (?:measure|report|examine|quantif|assess|investigate|describe|characteri[sz]e)" r"|\bremains? (?:largely )?(?:unmeasured|unexamined|unexplored|unaddressed)\b|\bunexamined\b|\bunexplored\b", re.IGNORECASE) # A named discipline/literature FRAME that legitimately scopes the negative. A bare # epistemic hedge ("to our knowledge") is NOT a frame and does not suppress. SCOPE_QUALIFIER_RE = re.compile( r"\bclinical(?:ly)? (?:published|literature)\b" r"|\bin (?:the )?(?:clinical|medical|radiolog\w+|surgical|nursing|imaging|oncolog\w+) (?:literature|domain|setting|field)\b" r"|\bin radiology\b|\bin medicine\b|\bpeer-reviewed clinical\b" r"|\b(?:within|among|across) (?:the )?(?:published )?\w+ (?:literature|studies)\b" r"|\bto date in the \w+ literature\b", re.IGNORECASE) # A cross-strata DIRECTIONAL claim: the estimate trends / differs across the levels of a # subgroup. Anchored to distinctive phrases so a physical "pressure gradient across the # stenosis" or an MRI "gradient echo" does not match (bare "gradient" is never enough). GRADIENT_CLAIM_RE = re.compile( r"more pronounced (?:in|among|for)\b" r"|(?:short|long|high|low|great|small|strong|weak)(?:est|er) (?:in|among|for)\b" r"|monotonic(?:ally)?\b" r"|step[-\s]?wise (?:increase|decrease|rise|decline|shorten|across|with)" r"|gradient (?:across|among|by|over|with (?:higher|increasing|worsening))" r"|dose[-\s]?response (?:relationship|gradient|pattern)?\s*(?:across|with|by)" r"|(?:increas|decreas|shorten|worsen)(?:ed|ing) (?:monotonically|step[-\s]?wise|progressively)", re.IGNORECASE) # A stratification CONTEXT (the LEVELS of a subgroup) — required in the same window so # the directional language is about subgroup strata, not a physical or temporal trend. STRATA_CONTEXT_RE = re.compile( r"tertile|quartile|quintile|decile|stratum|strata|stratified|subgroup" r"|categor(?:y|ies)|\bband\b|joint(?:ly)?[-\s]?(?:strat|classif)|cross[-\s]?classif" r"|(?:age|risk|score|bmi|dose)[-\s]?(?:group|categor|band|tier)", re.IGNORECASE) # Evidence an interaction WAS actually tested (any hit anywhere -> suppress the flag). INTERACTION_TEST_RE = re.compile( r"interaction (?:term|test|p[-\s]?value|effect|coefficient)" r"|p[-_\s]?interaction|p[-_\s]?int\b|effect modification" r"|test(?:ed|ing)? for interaction|multiplicative interaction|product term" r"|likelihood[-\s]?ratio test|\bLRT\b|(?:OR|HR|RR|beta|β)[-_]?int\b", re.IGNORECASE) # Claim regions where a cross-strata directional statement is a substantive claim # (not the Methods description of a stratified analysis). CLAIM_REGION_HEADINGS = re.compile( r"^#{1,4}\s*\*{0,2}\s*(?:abstract|results?|discussion|interpretation|conclusions?" r"|key results?|principal findings?)\b", re.IGNORECASE | re.MULTILINE) def _region(text: str, heading_re) -> str: spans = [] all_headings = [m.start() for m in re.finditer(r"^#{1,4}\s", text, re.MULTILINE)] for m in heading_re.finditer(text): s = m.end() nxt = next((h for h in all_headings if h > s), len(text)) spans.append(text[s:nxt]) mt = re.search(r"^#{1,6}\s+(.+)$", text, re.MULTILINE) # title if mt: spans.append(mt.group(1)) return "\n".join(spans) if spans else text def check(text: str) -> list[dict]: claims = [] concl = conclusion_region(text) # UNIVERSAL_NEGATIVE_UNSCOPED — a "nobody / first to / has not been" claim in a # claim region with no named discipline-scope qualifier nearby. Minor: the fix is # usually a one-word scope narrowing (or a wider, cross-discipline search). region = _region(text, NOVELTY_REGION_HEADINGS) seen: set[str] = set() for m in UNIVERSAL_NEGATIVE_RE.finditer(region): window = region[max(0, m.start() - 200):m.end() + 200] if SCOPE_QUALIFIER_RE.search(window): continue key = re.sub(r"\s+", " ", m.group(0).lower())[:60] if key in seen: continue seen.add(key) claims.append({ "verdict": "UNIVERSAL_NEGATIVE_UNSCOPED", "severity": "Minor", "detail": (f"a universal-negative / first-to claim ('{m.group(0).strip()}') with no named " f"discipline-scope qualifier; a single-database search supports 'no *clinical* " f"paper does X', never 'nobody does X' — narrow the claim ('...in the clinical " f"literature') or widen the search to the venues where the subject lives"), "where": region[max(0, m.start() - 30):m.end() + 40].replace("\n", " ").strip()[:160], }) if DESIGN_CROSS_SECTIONAL.search(text): # Fire only on a prognostic/surveillance token that is NOT inside a # negation/deferral frame; iterate all matches so a real claim later in the # conclusion still fires even if an earlier mention was a disclaimer. for pm in PROGNOSTIC_VERB.finditer(concl): window = concl[max(0, pm.start() - 120):pm.end() + 120] if (PROGNOSTIC_DISCLAIMER.search(window) or META_DOC_FRAME.search(window) or ANTIPATTERN_LABEL.search(window)): continue claims.append({ "verdict": "CROSS_SECTIONAL_PROGNOSTIC", "severity": "Major", "detail": (f"cross-sectional/single-visit design, but the conclusion makes a " f"prognostic/surveillance claim ('{pm.group(0).strip()}')"), "where": concl[max(0, pm.start() - 40):pm.end() + 40].strip()[:160], }) break dm = DIRECTIVE_VERB.search(concl) sm = SURROGATE_SIGNAL.search(concl) if dm and sm: claims.append({ "verdict": "SURROGATE_CARE_DIRECTIVE", "severity": "Major", "detail": (f"a binary surrogate endpoint ('{sm.group(0).strip()}') drives a " f"patient-care directive ('{dm.group(0).strip()}') in the conclusion"), "where": concl[max(0, dm.start() - 40):dm.end() + 40].strip()[:160], }) if DESIGN_CROSS_SECTIONAL.search(text): ym = YIELD_LANGUAGE.search(text) if ym and not YIELD_DEFINED.search(text): claims.append({ "verdict": "CROSS_SECTIONAL_YIELD_LANGUAGE", "severity": "Minor", "detail": (f"cross-sectional/prevalence design uses incidence-flavored " f"screening vocabulary ('{ym.group(0).strip()}') without defining " f"'yield' as cross-sectional report-positive prevalence; on a " f"single-timepoint design this reads as longitudinal screening " f"performance"), "where": text[max(0, ym.start() - 40):ym.end() + 40].strip()[:160], }) # GRADIENT_WITHOUT_INTERACTION — a cross-strata directional claim ("shortest in the # high-risk tertile", "monotonically across the age strata") stated as a finding, with # a stratification context nearby, but NO interaction test reported anywhere. A # difference in significance across strata is not a tested interaction; the # joint-stratification framing escapes the synergy/interaction token trigger. if not INTERACTION_TEST_RE.search(text): claim_region = _region(text, CLAIM_REGION_HEADINGS) for gm in GRADIENT_CLAIM_RE.finditer(claim_region): window = claim_region[max(0, gm.start() - 160):gm.end() + 160] if not STRATA_CONTEXT_RE.search(window): continue claims.append({ "verdict": "GRADIENT_WITHOUT_INTERACTION", "severity": "Minor", "detail": (f"a cross-strata directional claim ('{gm.group(0).strip()}') is made across " f"subgroup levels, but no interaction test (interaction term / LRT / " f"p-interaction / effect modification) is reported anywhere; a difference in " f"significance across strata is not a tested interaction — report the " f"interaction test, or reframe as descriptive stratified estimates"), "where": window.replace("\n", " ").strip()[:160], }) break return claims def analyze(manuscript: str) -> dict: p = Path(manuscript) if not p.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims = check(p.read_text(encoding="utf-8")) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "manuscript": str(p), "claims": claims, "summary": { "n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "verdict": "MAJOR_CANDIDATE" if n_major else "OK", }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | conclusion scope matches the design/endpoint |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser(description="Endpoint↔conclusion scope-coherence gate (§D).") ap.add_argument("--manuscript", required=True, help="manuscript markdown/text") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() result = analyze(args.manuscript) if not args.quiet: print("=" * 41) print(" Scope Coherence (§D)") print("=" * 41) print(render(result)) print() s = result["summary"] if s["n_major"]: print(f"MAJOR candidate: {s['n_major']} endpoint↔conclusion scope mismatch(es).") elif s["n_flag"]: print(f"MINOR flag: {s['n_flag']} scope-language issue(s) (see table).") else: print("OK: conclusion scope matches the design/endpoint.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_scope_coherence", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_supplement_hygiene.py 13.3 KB
#!/usr/bin/env python3 """Reader-facing supplement / tables / caption hygiene gate (self-review §J supplement pass). Existing classical-style and scope gates lint `manuscript.md` ONLY; the rendered supplement, a separately-built tables file, and figure-caption files are never linted — yet those are the artifacts where technical-check-fatal residue hides (internal SAP labels, unfilled placeholders, build markers, response-letter framing, and body↔supplement cross-reference numbers that do not resolve). This detector lints a LIST of rendered submission artifacts for that residue and, when given the manuscript body, checks that every "Supplementary X N" callout resolves to a supplement section. Verdicts (all Major — each reaches a reviewer/editor as a non-anonymous slip): SUPP_INTERNAL_LABEL a § / §L internal section or SAP label (§L0, §L10b, "see Methods §3") in a reader-facing file — internal scaffolding. SUPP_PLACEHOLDER an unfilled placeholder: "Supplementary Table SX", "Table S?", "Figure S-N", a literal "S-N"/"TABLE N", "[Authors]", a figure-path brace-glob (figS_*.{png,pdf}), or an internal build-dir path (1_Search/, 4_Analysis/, 5_Figures/). SUPP_BUILD_MARKER a build/QA marker that should never ship: "[VERIFY…]", "TODO", "FIXME", "XXXX", "Remove this line if…". SUPP_RESPONSE_FRAMING response-to-reviewers framing in a reader-facing supplement: "Per Section Editor #2", "Per Reviewer 2 #3", "Response to Reviewer", "Reviewer 2 Comment 4". SUPP_PLANNING_RESIDUE pre-execution planning residue: "Designed by:", "Expected PRISMA Numbers", "Deduplication Plan", "to be executed/run". SUPP_PARTICIPANT_PII_TIE a reader/participant identity (pseudonym R+hex, or a named participant) tied to an INDIVIDUAL response on one line — a re-identifiable datum in a reader-facing/public supplement. A byline/roster without individual responses does not fire. SUPP_XREF_UNRESOLVED (needs --manuscript) a body "Supplementary Table/Figure/ Material N" callout with no matching supplement section. Exit codes: 0 clean (or report-only), 1 with --strict when any Major exists, 2 usage. Stdlib-only (json / re / argparse / pathlib). Usage: python3 check_supplement_hygiene.py --supplement supplement.md [--supplement tables.md ...] \ [--manuscript manuscript.md] [--out qc/supplement_hygiene.json] [--strict] [--quiet] """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path # --- residue patterns (per reader-facing file) ----------------------------- # § anywhere, or a §-less "§L" SAP label, or a "see Methods §3" cross-ref. In a # reader-facing supplement any of these is internal scaffolding (manuscript-style # bans § outright); we report per file with a count. INTERNAL_LABEL = re.compile(r"§") # Unfilled placeholders. Literal letter N/X (not a digit) after a table/figure # label, "S?"/"SX"/"S-N" tokens, an author placeholder, a figure-path brace-glob, # and internal build-dir paths. PLACEHOLDER = re.compile( r"\b(?:Supplementary\s+)?(?:Table|Figure|Fig\.?|Appendix)\s+S?[NX]\b" # Table SX / Figure N r"|\bS-?[NX]\b" # S-N / SN (letter) r"|\bS\?\b" # S? r"|\bTABLE\s+[NX]\b|\bFIGURE\s+[NX]\b" # TABLE N r"|\[\s*Authors?\s*\]|\[\s*Author names?\s*\]" # [Authors] r"|[\w/]*\bfig\w*_\*\.\{[^}]*\}" # figS_*.{png,pdf} r"|\*\.\{(?:png|pdf|jpe?g|tiff?|svg)[^}]*\}" # *.{png,pdf} r"|(?<![\w/])(?:0_|1_|2_|3_|4_|5_|6_)[A-Za-z][\w]*/", # 1_Search/ 5_Figures/ re.IGNORECASE) BUILD_MARKER = re.compile( r"\[\s*VERIFY[^\]]*\]|\bTODO\b|\bFIXME\b|\bXXXX+\b|\bTBD\b" r"|Remove this line if|placeholder text|<!--\s*(?:TODO|note|draft)", re.IGNORECASE) RESPONSE_FRAMING = re.compile( r"\bPer\s+(?:Section\s+Editor|Reviewer|Editor)\s*#?\s*\d" r"|\bResponse\s+to\s+(?:the\s+)?Reviewers?\b" r"|\bReviewer\s+\d+\s*(?:Comment|#|,\s*(?:comment|point))" r"|\bin\s+response\s+to\s+(?:Reviewer|the\s+Editor|comment)\b", re.IGNORECASE) PLANNING_RESIDUE = re.compile( r"\bDesigned\s+by\s*:|\bExpected\s+[\w\s]{0,30}\bNumbers\b" r"|\bDeduplication\s+Plan\b|\bSearch\s+Plan\b|\bAnalysis\s+Plan\s*:" r"|\bto\s+be\s+(?:executed|run|determined|finalized|completed)\b" r"|\bawaiting\s+(?:data|results|execution)\b", re.IGNORECASE) # Participant-privacy tie: a reader/participant IDENTITY on the same line as an # INDIVIDUAL-RESPONSE datum re-identifies that person's answers in a reader-facing / # public supplement. Identity = a reader pseudonym token (R + hex) OR a proper "First # Last" name accompanied by a participant word on the line. Response datum = an # explicit per-trial answer. A byline / roster line (a name with NO individual # response) is legitimate and must NOT fire. READER_PSEUDONYM = re.compile(r"\bR[0-9a-f]{5,}\b") PROPER_NAME = re.compile(r"\b[A-Z][a-z]+(?:\s+[A-Z]\.?)?\s+[A-Z][a-z]+\b") PARTICIPANT_WORD = re.compile(r"\b(?:reader|participant|rater|observer|respondent|subject)\b", re.I) RESPONSE_DATUM = re.compile( r"\bresponse\s*[=:]|\bconfidence\s*[=:]|\bcue\s*[=:]|\btrial\s+\d+\b" r"|\brated\s+(?:it|this|the\s+\w+)\b|\banswered\s+(?:real|fake|ai|synthetic)\b" r"|\bresponse\s+(?:was\s+)?(?:real|fake|ai|synthetic|authentic)\b", re.IGNORECASE) def lint_pii_tie(path: Path) -> list[dict]: """Flag a line that ties a reader identity to an individual response (de-anon).""" claims: list[dict] = [] text = path.read_text(encoding="utf-8", errors="replace") for i, line in enumerate(text.splitlines(), 1): if not RESPONSE_DATUM.search(line): continue has_pseudonym = READER_PSEUDONYM.search(line) has_named = PROPER_NAME.search(line) and PARTICIPANT_WORD.search(line) if not (has_pseudonym or has_named): continue who = "a reader pseudonym" if has_pseudonym else "a named participant" claims.append({ "verdict": "SUPP_PARTICIPANT_PII_TIE", "severity": "Major", "detail": (f"{who} is tied to an individual response on one line in {path.name} — " f"a re-identifiable participant-level datum in a reader-facing supplement; " f"de-identify (a byline/roster without individual responses is fine)"), "where": f"{path.name}:{i}: …{line.strip()[:110]}…", }) break # one per file return claims PER_FILE_CHECKS = [ ("SUPP_INTERNAL_LABEL", INTERNAL_LABEL, "a § / §L internal section or SAP label leaked into a reader-facing file"), ("SUPP_PLACEHOLDER", PLACEHOLDER, "an unfilled placeholder (S?/SX/S-N, [Authors], figure-path glob, or build-dir path)"), ("SUPP_BUILD_MARKER", BUILD_MARKER, "a build/QA marker that should not ship ([VERIFY]/TODO/FIXME/XXXX/'Remove this line if')"), ("SUPP_RESPONSE_FRAMING", RESPONSE_FRAMING, "response-to-reviewers framing in a reader-facing supplement"), ("SUPP_PLANNING_RESIDUE", PLANNING_RESIDUE, "pre-execution planning residue (Designed by:/Expected … Numbers/to be executed)"), ] def _line_of(text: str, pos: int) -> int: return text[:pos].count("\n") + 1 def lint_file(path: Path) -> list[dict]: claims: list[dict] = [] text = path.read_text(encoding="utf-8", errors="replace") for verdict, rx, detail in PER_FILE_CHECKS: m = rx.search(text) if not m: continue n = len(rx.findall(text)) ln = _line_of(text, m.start()) snippet = text[max(0, m.start() - 25):m.end() + 35].replace("\n", " ").strip() claims.append({ "verdict": verdict, "severity": "Major", "detail": f"{detail} — {n} occurrence(s) in {path.name}", "where": f"{path.name}:{ln}: …{snippet[:120]}…", }) claims.extend(lint_pii_tie(path)) return claims # --- body ↔ supplement cross-reference resolution -------------------------- # A body callout like "Supplementary Table 3", "Supplementary Figure 2", # "Supplementary Material 5", "Supplementary Methods 1", "Supplementary Appendix 4". BODY_CALLOUT = re.compile( r"Supplementary\s+(Table|Figure|Fig\.?|Material|Methods?|Appendix|Data|Note)\s+(\d{1,3})\b", re.IGNORECASE) # A supplement section identity: a heading that ends in / contains a number, or an # explicit "Supplementary Table/Material N" / "Table SN" title line. SUPP_SECTION = re.compile( r"^#{1,4}\s.*?\b(?:Supplementary\s+)?(?:Table|Figure|Material|Methods?|Appendix|Section|Data|Note)\s+S?(\d{1,3})\b" r"|^#{1,4}\s.*?\bS(\d{1,3})\b", re.IGNORECASE | re.MULTILINE) def supplement_section_numbers(texts: list[str]) -> set[int]: nums: set[int] = set() for t in texts: for m in SUPP_SECTION.finditer(t): g = m.group(1) or m.group(2) if g: nums.add(int(g)) return nums def check_xref(manuscript_text: str, supplement_texts: list[str]) -> list[dict]: claims: list[dict] = [] present = supplement_section_numbers(supplement_texts) seen: set[tuple[str, int]] = set() for m in BODY_CALLOUT.finditer(manuscript_text): kind = m.group(1).rstrip(".").title() num = int(m.group(2)) key = (kind, num) if key in seen: continue seen.add(key) if num not in present: ln = _line_of(manuscript_text, m.start()) claims.append({ "verdict": "SUPP_XREF_UNRESOLVED", "severity": "Major", "detail": (f"body cites 'Supplementary {kind} {num}' but no supplement " f"section numbered {num} was found (renumber drift or a " f"silently-skipped/unrendered section)"), "where": f"manuscript:{ln}", }) return claims def analyze(supplements: list[str], manuscript: str | None) -> dict: supp_paths = [] supp_texts = [] claims: list[dict] = [] for s in supplements: p = Path(s) if not p.is_file(): sys.stderr.write(f"ERROR: supplement not found: {s}\n") sys.exit(2) supp_paths.append(p) supp_texts.append(p.read_text(encoding="utf-8", errors="replace")) claims.extend(lint_file(p)) if manuscript: mp = Path(manuscript) if not mp.is_file(): sys.stderr.write(f"ERROR: manuscript not found: {manuscript}\n") sys.exit(2) claims.extend(check_xref(mp.read_text(encoding="utf-8", errors="replace"), supp_texts)) n_major = sum(1 for c in claims if c["severity"] == "Major") return { "supplements": [str(p) for p in supp_paths], "manuscript": manuscript, "claims": claims, "summary": { "n_claims": len(claims), "n_major": n_major, "n_flag": len(claims) - n_major, "verdict": "MAJOR_CANDIDATE" if n_major else "OK", }, } def render(result: dict) -> str: lines = ["| Check | Severity | Detail |", "|---|---|---|"] for c in result["claims"]: lines.append(f"| {c['verdict']} | {c['severity']} | {c['detail']} |") if len(lines) == 2: lines.append("| (none) | — | supplement / tables / caption files are reader-clean |") return "\n".join(lines) def main() -> int: ap = argparse.ArgumentParser( description="Reader-facing supplement / tables / caption hygiene gate.") ap.add_argument("--supplement", action="append", default=[], metavar="PATH", help="a rendered reader-facing file to lint (repeatable: supplement, tables, captions)") ap.add_argument("--manuscript", help="manuscript body (for Supplementary-X-N cross-ref resolution)") ap.add_argument("--out", help="write JSON artifact to this path") ap.add_argument("--strict", action="store_true", help="exit 1 if any Major claim exists") ap.add_argument("--quiet", action="store_true", help="suppress stdout table") args = ap.parse_args() if not args.supplement: sys.stderr.write("ERROR: at least one --supplement file is required\n") return 2 result = analyze(args.supplement, args.manuscript) if not args.quiet: print("=" * 41) print(" Supplement Hygiene (§J supplement pass)") print("=" * 41) print(render(result)) print() s = result["summary"] if s["n_major"]: print(f"MAJOR candidate: {s['n_major']} reader-facing supplement residue issue(s).") else: print("OK: supplement / tables / caption files are reader-clean.") if args.out: Path(args.out).parent.mkdir(parents=True, exist_ok=True) Path(args.out).write_text(json.dumps({"detector": "check_supplement_hygiene", **result}, indent=2), encoding="utf-8") if not args.quiet: print(f"\nwrote {args.out}") return 1 if (args.strict and result["summary"]["n_major"]) else 0 if __name__ == "__main__": sys.exit(main()) -
check_table_percentages.py 10.8 KB
#!/usr/bin/env python3 """check_table_percentages.py — recompute every ``n (p%)`` table cell against its own column denominator and flag a printed percentage that does not match the count. The cheapest, highest-precision arithmetic check a reviewer can run: a characteristics table printing ``79 (63%)`` and ``53 (37%)`` under a denominator of 132 is simply wrong (59.8% / 40.2%), needs no judgement or domain knowledge, and routinely survives multiple review rounds because it was on the page from the first submission. This detector parses GFM pipe tables from a manuscript, recovers each column's denominator, and recomputes ``100·n/denominator`` for every count/ percentage cell. Percentage-column detection (so ``mean (SD)`` cells never fire): a column is treated as percentages only when a cell carries an explicit ``%`` OR its parenthetical values (each ≤ 100) sum to ~100 — a percentage partition. Standard deviations do not sum to 100 and carry no ``%``, so they are skipped. Denominator recovery, in order: a ``n = N`` in the column header; a Total/Overall row's count; or the column's own counts summing (a partition). A column with no recoverable denominator emits ``PERCENT_DENOM_UNKNOWN`` (informational only). Stdlib-only. Reads the manuscript, never writes it. Usage: python3 check_table_percentages.py --manuscript paper.md python3 check_table_percentages.py --manuscript paper.md --strict --quiet python3 check_table_percentages.py --manuscript paper.md --tol 0.5 --json Exit: 0 clean; with --strict, 1 on any PERCENT_MISMATCH; 2 on input/usage error. """ from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass, field, asdict DEFAULT_TOL = 0.5 # percentage points CELL_RE = re.compile(r"^\s*([0-9][0-9,]*)\s*\(\s*([0-9]+(?:\.[0-9]+)?)\s*(%?)\s*\)\s*$") HEADER_N_RE = re.compile(r"\bn\s*=\s*([0-9][0-9,]*)", re.I) TOTAL_LABEL_RE = re.compile(r"^\s*(total|overall|all|entire cohort|full cohort|whole cohort)\b", re.I) SEP_RE = re.compile(r"^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$") @dataclass class Finding: kind: str # PERCENT_MISMATCH / PERCENT_DENOM_UNKNOWN severity: str # MAJOR / INFO table_line: int # 1-indexed line of the table header cell: str detail: str @dataclass class Report: source: str findings: list[Finding] = field(default_factory=list) @property def n_mismatch(self) -> int: return sum(1 for f in self.findings if f.kind == "PERCENT_MISMATCH") @property def verdict(self) -> str: return "MISMATCH FOUND" if self.n_mismatch else "OK" def _split_row(line: str) -> list[str]: s = line.strip() if s.startswith("|"): s = s[1:] if s.endswith("|"): s = s[:-1] return [c.strip() for c in s.split("|")] def _parse_tables(text: str): """Yield (header_cells, data_rows, header_line_no) for each GFM pipe table.""" lines = text.splitlines() i, n = 0, len(lines) while i < n: if "|" in lines[i] and i + 1 < n and SEP_RE.match(lines[i + 1]) and "-" in lines[i + 1]: header = _split_row(lines[i]) rows, j = [], i + 2 while j < n and "|" in lines[j] and lines[j].strip(): rows.append(_split_row(lines[j])) j += 1 yield header, rows, i + 1 i = j else: i += 1 def _cell_count_pct(cell: str): m = CELL_RE.match(cell) if not m: return None count = int(m.group(1).replace(",", "")) pct = float(m.group(2)) has_pct = m.group(3) == "%" return count, pct, has_pct def audit(text: str, source: str, tol: float = DEFAULT_TOL) -> Report: rep = Report(source=source) for header, rows, lineno in _parse_tables(text): ncols = len(header) data_rows = [r for r in rows if r and not TOTAL_LABEL_RE.match(r[0])] total_rows = [r for r in rows if r and TOTAL_LABEL_RE.match(r[0])] for col in range(1, ncols): # col 0 is the row label # gather count/pct cells in this column parsed = [] for r in data_rows: if col < len(r): cp = _cell_count_pct(r[col]) if cp: parsed.append((r[0], r[col], *cp)) # label, raw, count, pct, has_pct if not parsed: continue # is this a percentage column? has_pct = any(p[4] for p in parsed) le100 = [p[3] for p in parsed if p[3] <= 100] is_partition = len(le100) >= 2 and 98.0 <= sum(le100) <= 102.0 if not (has_pct or is_partition): continue # e.g. mean (SD): no %, SDs don't sum to 100 # recover denominator denom = None src = "" if col < len(header): hm = HEADER_N_RE.search(header[col]) if hm: denom, src = int(hm.group(1).replace(",", "")), "header n=" if denom is None: for tr in total_rows: if col < len(tr): tc = CELL_RE.match(tr[col]) or re.match(r"^\s*([0-9][0-9,]*)\s*$", tr[col]) if tc: denom, src = int(tc.group(1).replace(",", "")), "Total row" break # Summing a column's counts is a denominator only for a partition — mutually exclusive # categories accounting for everyone. For the most common table in clinical research, a # Table 1 of independent binary characteristics with N declared in the CAPTION, the sum # is a meaningless number larger than N, and using it accused every correct cell of bad # arithmetic while printing a specific wrong replacement percentage. # # Gating on `is_partition` is NOT the fix: that flag is computed from the printed # percentages summing to ~100, so a partition with a wrong percentage stops looking like # a partition — the check would go silent on exactly the error it exists to catch. # # The denominator is INFERRED here, unlike a header `n =` or a Total row, which the # author declared. So it must earn its use: keep it only if it explains at least one # cell. A denominator that reconciles nothing is not evidence that every cell is wrong, # it is evidence that it is the wrong denominator. Verified below, after recomputation. inferred = False if denom is None: denom, src, inferred = sum(p[2] for p in parsed), "column count-sum", True if not denom: rep.findings.append(Finding("PERCENT_DENOM_UNKNOWN", "INFO", lineno, f"column {col}", "percentage column with no recoverable denominator")) continue column_findings: list[Finding] = [] reconciled = 0 for label, raw, count, pct, _hp in parsed: if count > denom: continue # not a proportion of this denominator recomputed = 100.0 * count / denom if abs(recomputed - pct) > tol: column_findings.append(Finding( "PERCENT_MISMATCH", "MAJOR", lineno, f"{label}: {raw}", f"printed {pct:g}% but {count}/{denom} ({src}) = {recomputed:.1f}% " f"(Δ{abs(recomputed - pct):.1f}pp)")) else: reconciled += 1 # An inferred denominator that reconciles NOTHING is the wrong denominator, not proof # that every cell is wrong. Report the gap rather than the accusation. A declared # denominator (header `n =`, Total row) is never second-guessed this way: if the author # states N and the arithmetic disagrees, that is the finding. if inferred and column_findings and reconciled == 0: rep.findings.append(Finding( "PERCENT_DENOM_UNKNOWN", "INFO", lineno, f"column {col}", f"percentage column with no declared denominator; the column count-sum " f"({denom}) reconciles none of {len(column_findings)} cell(s), so it is not the " f"denominator — declare N in the column header (n = N) or a Total row to check")) else: rep.findings.extend(column_findings) return rep def format_report(rep: Report, color: bool) -> str: tag = {"OK": "\033[92m", "MISMATCH FOUND": "\033[91m"}.get(rep.verdict, "") if color else "" end = "\033[0m" if color else "" lines = [f"{tag}== {rep.verdict} =={end} {rep.source}", f"mismatches={rep.n_mismatch} findings={len(rep.findings)}"] if not rep.findings: lines.append("all n (%) cells reconcile with their column denominators.") return "\n".join(lines) for f in sorted(rep.findings, key=lambda x: (x.kind != "PERCENT_MISMATCH", x.table_line, x.cell)): lines.append(f"[{f.severity:<5}] {f.kind:<22} L{f.table_line} {f.detail}") lines.append(f" cell> {f.cell}") return "\n".join(lines) def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--manuscript", required=True, help="manuscript markdown/text with GFM tables") ap.add_argument("--tol", type=float, default=DEFAULT_TOL, help=f"flag threshold in percentage points (default {DEFAULT_TOL})") ap.add_argument("--strict", action="store_true", help="exit 1 if any PERCENT_MISMATCH") ap.add_argument("--quiet", action="store_true", help="suppress the report; exit code only") ap.add_argument("--json", action="store_true", help="emit JSON instead of a text report") ap.add_argument("--out", help="write the JSON artifact to this path") args = ap.parse_args(argv) try: text = open(args.manuscript, encoding="utf-8").read() except OSError as e: print(f"error: cannot read manuscript: {e}", file=sys.stderr) return 2 rep = audit(text, source=args.manuscript, tol=args.tol) payload = {"source": rep.source, "verdict": rep.verdict, "n_mismatch": rep.n_mismatch, "findings": [asdict(f) for f in rep.findings]} if args.out: with open(args.out, "w", encoding="utf-8") as fh: json.dump({"detector": "check_table_percentages", **payload}, fh, ensure_ascii=False, indent=2) if not args.quiet: print(json.dumps({"detector": "check_table_percentages", **payload}, ensure_ascii=False, indent=2) if args.json else format_report(rep, color=sys.stdout.isatty())) return 1 if (args.strict and rep.n_mismatch) else 0 if __name__ == "__main__": raise SystemExit(main()) -
refinement_regression.py 8.8 KB
#!/usr/bin/env python3 """Refinement regression axis -- did this revision fix without breaking? self-review is stateless: each run reports the manuscript's current findings, but nothing compares one run to the last. So when the author revises to fix finding X, the gate pass-rate goes up ("X resolved") and no one measures whether the fix *introduced* a new finding Y. The loop looks like it is improving -- fewer of the old problems -- while quietly accumulating new ones. Worse, a finding that was fixed can reappear a round later (the "Mirror Loop"): the loop is churning, re-deriving, not converging. This is NOT a detector -- it finds no defect in the manuscript. It reads a small run-history **ledger** (one line per self-review run, each line the fingerprints of that run's findings) plus the current run's `qc/*.json`, and reports the regression axis *alongside* the pass-rate axis: resolved findings present last run, gone now (what the revision FIXED) carried findings present both runs (still open) new findings present now, absent last run (what the revision BROKE) churn new findings that were present in an EARLIER (Mirror Loop: a fixed finding run and had been resolved resurfaced -> stop churning) Verdict: CONVERGED nothing new and nothing carried -- the loop is done CHURNING a resolved finding reappeared -- ungrounded re-derivation, stop the loop REGRESSION the revision introduced new finding(s) PROGRESSING findings resolved, none newly introduced INDETERMINATE first run -- no prior entry to compare against A finding's fingerprint is `verdict@where` (deterministic; no semantic matching). The gate is advisory and NEVER blocks -- it is a signal, not a barrier: `--strict` is accepted for CLI parity but every non-error path exits 0. By default it only classifies (read-only); with `--append` it also records the current run as the next ledger entry. INPUTS --qc-dir directory holding the current run's qc/*.json artifacts (required). --ledger the run-history ledger, one JSON object per line (required; created on first --append). --append after classifying, append the current run's fingerprints as a new entry. --out optional JSON artifact path. --quiet suppress the stdout summary. --strict accepted for CLI parity; never blocks. OUTPUT A regression summary (stdout) and, with --out, a JSON artifact: {tool, qc_dir, ledger, verdict, resolved[], carried[], new[], churn[], recommendation} Stdlib-only (json / argparse / pathlib / sys). Exit codes: 0 always for a judged or indeterminate state (advisory), 2 input/usage error. """ from __future__ import annotations import argparse import json import sys from pathlib import Path import _qc_findings # same-dir helper: read the heterogeneous detector JSON schemas uniformly TOOL = "refinement_regression" STOP_HINT = { "CONVERGED": "Nothing new and nothing carried -- the loop is done. Stop.", "CHURNING": ( "A previously-resolved finding reappeared ({churn}) -- the loop is re-deriving, not " "converging (the Mirror Loop). Stop revising and re-anchor; more passes will not help." ), "REGRESSION": ( "The last revision resolved {n_resolved} finding(s) but INTRODUCED {n_new} new one(s) " "({new}). Fixing raised the pass-rate while breaking something else -- review the new " "findings before continuing." ), "PROGRESSING": ( "{n_resolved} finding(s) resolved and none newly introduced -- the revision improved " "the manuscript without breaking anything. Continue." ), "INDETERMINATE": ( "No prior ledger entry -- this is the first run. Re-run after a revision (with " "--append recording each run) to measure regression." ), } def _current_keys(qc_dir: Path) -> tuple[list[str], list[str]]: """Return (sorted finding fingerprints, sorted unparsed gate names) for the current run. Reads both the `claims` and `findings` detector schemas via _qc_findings; a detector-keyed file whose schema is unrecognised is surfaced (unparsed), not silently dropped — a dropped gate would understate what the run found and corrupt the cross-run comparison.""" keys: set[str] = set() unparsed: set[str] = set() for path in sorted(qc_dir.glob("*.json")): try: obj = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): continue g = _qc_findings.parse_gate(obj) if g is None: continue if not g["parsed"]: unparsed.add(g["name"]) continue keys.update(g["keys"]) return sorted(keys), sorted(unparsed) def _read_ledger(path: Path) -> list[dict]: if not path.is_file(): return [] entries = [] for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line: continue try: obj = json.loads(line) except ValueError: continue if isinstance(obj, dict) and isinstance(obj.get("keys"), list): entries.append(obj) return entries def classify(current: list[str], entries: list[dict]) -> dict: cur = set(current) if not entries: verdict = "INDETERMINATE" resolved = carried = new = churn = [] else: prev = set(entries[-1]["keys"]) prior_all: set[str] = set() for e in entries[:-1]: prior_all |= set(e["keys"]) resolved = sorted(prev - cur) carried = sorted(prev & cur) new = sorted(cur - prev) churn = sorted(set(new) & prior_all) if not new and not carried: verdict = "CONVERGED" elif churn: verdict = "CHURNING" elif new: verdict = "REGRESSION" else: verdict = "PROGRESSING" rec = STOP_HINT[verdict].format( n_resolved=len(resolved), n_new=len(new), new=", ".join(new) if new else "-", churn=", ".join(churn) if churn else "-", ) return { "tool": TOOL, "verdict": verdict, "resolved": resolved, "carried": carried, "new": new, "churn": churn, "recommendation": rec, } def render(result: dict, qc_dir: str, ledger: str) -> str: lines = [ f"Refinement regression: {result['verdict']}", f" fixed (resolved): {len(result['resolved'])} still open (carried): {len(result['carried'])}", f" broke (new): {len(result['new'])} resurfaced (churn): {len(result['churn'])}", ] if result.get("gates_unparsed"): lines.append(f" unparsed gates: {', '.join(result['gates_unparsed'])} (unrecognised schema — NOT counted)") lines.append(f" qc dir: {qc_dir} ledger: {ledger}") lines.append(f" -> {result['recommendation']}") return "\n".join(lines) def main(argv=None) -> int: ap = argparse.ArgumentParser(description="Classify a self-review run against the prior run (regression axis) from a run-history ledger.") ap.add_argument("--qc-dir", required=True, help="directory holding the current run's qc/*.json artifacts") ap.add_argument("--ledger", required=True, help="run-history ledger (one JSON object per line)") ap.add_argument("--append", action="store_true", help="append the current run's fingerprints as a new ledger entry") ap.add_argument("--out", help="optional JSON artifact path") ap.add_argument("--quiet", action="store_true", help="suppress the stdout summary") ap.add_argument("--strict", action="store_true", help="accepted for CLI parity; never blocks (advisory)") args = ap.parse_args(argv) qc_dir = Path(args.qc_dir) if not qc_dir.is_dir(): print(f"error: qc dir not found: {args.qc_dir}", file=sys.stderr) return 2 current, unparsed = _current_keys(qc_dir) ledger_path = Path(args.ledger) entries = _read_ledger(ledger_path) result = classify(current, entries) result["gates_unparsed"] = unparsed if unparsed: result["recommendation"] += ( f" (WARNING: {len(unparsed)} gate artifact(s) had an unrecognised schema and were not " f"counted, so this comparison may miss findings: {', '.join(unparsed)})") if args.append: run_no = len(entries) + 1 with ledger_path.open("a", encoding="utf-8") as fh: fh.write(json.dumps({"run": run_no, "keys": current}) + "\n") out = {"qc_dir": str(args.qc_dir), "ledger": str(args.ledger), **result} if args.out: Path(args.out).write_text(json.dumps(out, indent=2) + "\n", encoding="utf-8") if not args.quiet: print(render(result, str(args.qc_dir), str(args.ledger))) return 0 if __name__ == "__main__": sys.exit(main()) -
refinement_stop.py 9.5 KB
#!/usr/bin/env python3 """Refinement terminal-state classifier -- the loop controller for self-review. self-review runs a stack of *floor* gates (Phases 2.5-2.5f: numerical, citation, cross-reference, leakage) that minimize rejection-for-cause, and one *ceiling* pass (Phase 2.5g, check_editorial_impression) that recommends SUBTRACTION so an accurate manuscript also reads confidently. Run in a loop -- self-review, revise, self-review -- the floor gates converge to a fixed point (0 Major) but nothing tells the author the loop is *done*. Because every additive gate can always surface one more caveat, an ungrounded refinement loop drifts: the manuscript over-hardens, the same findings get re-raised in new words (the "Mirror Loop"), and "no edit needed" is never declared a valid outcome. This is NOT a detector -- it finds no defect in the manuscript, so it carries no `check_`/`detect_`/`derive_` prefix and is not counted in the detector catalog. It reads the JSON artifacts the other gates already wrote (qc/*.json) and classifies the *terminal state* of the refinement loop, so the harness has a reproducible stop signal it cannot rationalize away: CONTINUE a floor gate still reports a Major (rejection-for-cause). Real work remains. STOP_OVERHARDENING floor at its fixed point (0 Major), but the ceiling pass flags accumulation (over-hedging / buried defense). Stop ADDING; the only remaining action is optional SUBTRACTION. STOP_MINOR_OPTIONAL floor at fixed point; only optional Minor polish remains, ceiling clean. Stop the required-work loop; present the Minor items as a menu, do not loop for them. STOP_ZERO_EDIT floor at fixed point (0 Major, 0 Minor), ceiling clean. The manuscript is submission-ready as-is. A zero-edit result is a valid PASS -- do not manufacture changes. INDETERMINATE no gate artifacts found; the floor/ceiling gates have not run. A floor gate's JSON is recognised by a `summary.n_major`; the ceiling pass by a `summary.by_action`. The classifier is advisory and NEVER blocks -- it must not double-gate the floor detectors, which already exit non-zero under --strict on their own Majors. --strict is accepted for CLI parity but every non-error path exits 0. This is the counterweight to the additive bias of the whole stack: the one step that can say "stop, this is done". INPUTS --qc-dir directory holding the gates' qc/*.json artifacts (required). --out optional JSON artifact path. --quiet suppress the stdout summary. --strict accepted for parity; never blocks. OUTPUT A terminal-state summary (stdout) and, with --out, a JSON artifact: {tool, qc_dir, verdict, stop, floor_major, floor_minor, ceiling_findings, gates_read[], recommendation} Stdlib-only (json / argparse / pathlib / sys). Exit codes: 0 always for a judged or indeterminate state (advisory), 2 input/usage error (--qc-dir missing as an argument). """ from __future__ import annotations import argparse import json import sys from pathlib import Path import _qc_findings # same-dir helper: read the heterogeneous detector JSON schemas uniformly TOOL = "refinement_stop" RECOMMENDATIONS = { "CONTINUE": ( "Floor gates report {floor_major} Major finding(s) (rejection-for-cause). " "Genuine work remains -- resolve these before treating the loop as done." ), "STOP_OVERHARDENING": ( "Floor is at its fixed point (0 Major). The ceiling pass flags {ceiling_findings} " "accumulation signal(s) -- the draft has begun to over-harden. STOP additive " "refinement: do NOT run another add-a-caveat/defense pass. The only remaining " "action is SUBTRACTION (REMOVE / MOVE / TIGHTEN) and it is optional, not required " "for submission." ), "STOP_MINOR_OPTIONAL": ( "Floor is at its fixed point (0 Major); {floor_minor} optional Minor item(s) " "remain and the ceiling is clean. No required edits -- present the Minor items as " "an optional menu; do not treat them as blocking and do not loop for them." ), "STOP_ZERO_EDIT": ( "Floor is at its fixed point (0 Major, 0 Minor) and the ceiling is clean. The " "manuscript is submission-ready as-is. NO EDITS REQUIRED -- a zero-edit result is " "a valid PASS. Do not manufacture changes." ), "INDETERMINATE": ( "No gate artifacts found in the qc directory. Run the floor gates " "(Phases 2.5-2.5f) and the ceiling pass (Phase 2.5g) first; the terminal state " "cannot be judged without them." ), } STOP_VERDICTS = {"STOP_OVERHARDENING", "STOP_MINOR_OPTIONAL", "STOP_ZERO_EDIT"} def classify(qc_dir: Path) -> dict: floor_major = floor_minor = ceiling_findings = 0 gates: list[str] = [] unparsed: list[str] = [] unknown_sev: list[str] = [] for path in sorted(qc_dir.glob("*.json")): try: obj = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): continue g = _qc_findings.parse_gate(obj) if g is None: continue gates.append(g["name"]) if not g["parsed"]: # a detector-keyed file with an unrecognised schema -- do NOT count it as clean unparsed.append(g["name"]) continue unknown_sev.extend(g.get("unknown_severities") or []) if g["kind"] == "ceiling": ceiling_findings += g["major"] + g["minor"] else: floor_major += g["major"] floor_minor += g["minor"] if not gates: verdict = "INDETERMINATE" elif floor_major > 0: verdict = "CONTINUE" elif ceiling_findings > 0: verdict = "STOP_OVERHARDENING" elif floor_minor > 0: verdict = "STOP_MINOR_OPTIONAL" else: verdict = "STOP_ZERO_EDIT" rec = RECOMMENDATIONS[verdict].format( floor_major=floor_major, floor_minor=floor_minor, ceiling_findings=ceiling_findings, ) if unknown_sev: rec += (f" (WARNING: severity word(s) no controller knows: " f"{', '.join(sorted(set(unknown_sev)))} — counted as Major so the loop continues " f"rather than stopping over a verdict nobody classified)") if unparsed: rec += (f" (WARNING: {len(unparsed)} gate artifact(s) had an unrecognised schema and were " f"NOT counted -- this verdict may understate the floor: " f"{', '.join(sorted(set(unparsed)))})") return { "tool": TOOL, "verdict": verdict, "stop": verdict in STOP_VERDICTS, "floor_major": floor_major, "floor_minor": floor_minor, "ceiling_findings": ceiling_findings, "gates_read": sorted(set(gates)), "gates_unparsed": sorted(set(unparsed)), "unknown_severities": sorted(set(unknown_sev)), "recommendation": rec, } def render(result: dict, qc_dir_display: str) -> str: fixed = " (fixed point)" if result["floor_major"] == 0 and result["verdict"] != "INDETERMINATE" else "" gates = ", ".join(result["gates_read"]) if result["gates_read"] else "(none)" lines = [ f"Refinement terminal-state: {result['verdict']}", f" Floor gates: {result['floor_major']} Major, {result['floor_minor']} Minor{fixed}", f" Ceiling pass: {result['ceiling_findings']} finding(s)", f" Gates read: {gates}", ] if result.get("unknown_severities"): lines.append(f" Unknown sev: {', '.join(result['unknown_severities'])} " f"(counted as Major — the loop continues rather than stopping on a guess)") if result.get("gates_unparsed"): lines.append(f" Unparsed: {', '.join(result['gates_unparsed'])} (unrecognised schema — NOT counted)") lines.append(f" qc dir: {qc_dir_display}") lines.append(f" -> {result['recommendation']}") return "\n".join(lines) def main(argv=None) -> int: ap = argparse.ArgumentParser( description="Classify the refinement loop's terminal state from qc/*.json gate artifacts." ) ap.add_argument("--qc-dir", required=True, help="directory holding the gates' qc/*.json artifacts") ap.add_argument("--out", help="optional JSON artifact path") ap.add_argument("--quiet", action="store_true", help="suppress the stdout summary") ap.add_argument("--strict", action="store_true", help="accepted for CLI parity; never blocks (advisory)") args = ap.parse_args(argv) qc_dir = Path(args.qc_dir) if not qc_dir.is_dir(): # Absent qc dir is not a usage error in a pipeline where the gates may not have run # yet; report INDETERMINATE and exit 0 so the loop controller degrades gracefully. result = { "tool": TOOL, "verdict": "INDETERMINATE", "stop": False, "floor_major": 0, "floor_minor": 0, "ceiling_findings": 0, "gates_read": [], "gates_unparsed": [], "unknown_severities": [], "recommendation": RECOMMENDATIONS["INDETERMINATE"], } else: result = classify(qc_dir) result_out = {"qc_dir": str(args.qc_dir), **result} if args.out: Path(args.out).write_text(json.dumps(result_out, indent=2) + "\n", encoding="utf-8") if not args.quiet: print(render(result, str(args.qc_dir))) return 0 if __name__ == "__main__": sys.exit(main()) -
_frontmatter.py 2.3 KB
"""Strip a leading YAML front-matter block off a manuscript before prose analysis. Several `--manuscript` detectors in this skill roll their own body extractor that filters lines starting with `#`, `|`, `>`, `!`, list markers and code fences. A `---`-fenced YAML front-matter block matches none of those, so the `status:`, changelog and build-note lines that projects keep at the top of a pandoc manuscript were read as body prose. Two shipped detectors fired on it: `check_citation_order` reported floats "cited out of order" from a `status:` block narrating a display-item renumber, and `check_aphorism_density` listed build notes among the manuscript's "very short declaratives". `strip_frontmatter` removes exactly the pandoc/YAML front-matter block: an opening `---` fence on the FIRST line and its matching closing `---` fence. If the first line is not a fence, or the block is never closed, the whole text is returned unchanged (a lone `---` in the body — a horizontal rule or a setext underline — is never mistaken for front matter). Same fence semantics as `sync-submission/scripts/_yaml_frontmatter.py`; kept independent because skills are self-contained and cross-skill imports are forbidden. Private helper (leading underscore) so the detector-catalog glob (`check_*` / `detect_*` / `derive_*` / `verify_refs`) never counts it. Consumers run with their own directory on sys.path[0] (invoked as `python3 .../scripts/<name>.py`), so a sibling `from _frontmatter import strip_frontmatter` resolves wherever the skill is installed or vendored. """ from __future__ import annotations import re _FENCE_RE = re.compile(r"^---\s*$") def strip_frontmatter(text: str) -> str: """Return ``text`` with a leading ``---``-fenced YAML front-matter block removed. Returns ``text`` unchanged when there is no opening fence on the first line or the block is never closed. The trailing newline layout of the body is preserved so character offsets after the front matter shift by a fixed amount only. """ lines = text.splitlines(keepends=True) if not lines or not _FENCE_RE.match(lines[0].rstrip("\r\n")): return text for i in range(1, len(lines)): if _FENCE_RE.match(lines[i].rstrip("\r\n")): return "".join(lines[i + 1:]) # Opening fence but no closing fence — not front matter; leave the text intact. return text -
_prose.py 2.5 KB
"""Body-prose extraction shared by the prose-rhythm detectors. `check_aphorism_density.py` and `check_rhetorical_density.py` measure different tells — negative definitions and short-declarative rhythm on one side, antithesis and cleft constructions on the other — over *the same text*: the manuscript's body prose with front matter, headings, tables, block quotes, code fences, list items, citation markers and inline markup removed. Both carried a byte-identical copy of that extractor. Two copies of one definition is one drift away from two detectors reporting densities over different denominators and no longer being comparable. WHAT IS **NOT** SHARED, DELIBERATELY: sentence splitting. The two detectors genuinely differ there and unifying them would change what they measure: aphorism SENT_SPLIT_RE = (?<=[.!?])\\s+(?=[A-Z"“]) sentences: >= 2 words rhetorical SENT_SPLIT_RE = (?<=[.!?])\\s+(?=["“(]?[A-Z0-9]) sentences: any non-empty The rhetorical splitter starts a sentence at a digit or an opening parenthesis; the aphorism one does not, and it drops one-word fragments so a run of them cannot masquerade as clipped declaratives. Those choices belong to what each gate measures. Folding them together would silently move every density in both detectors — a behaviour change wearing a refactor's clothes — so each detector keeps its own splitter and its own sentence filter. Same-directory import, like `_frontmatter`: skills are self-contained and cross-skill imports are forbidden, so `humanize/scripts/check_sentence_variety.py` keeps its own extractor by design. Stdlib only. """ from __future__ import annotations import re from _frontmatter import strip_frontmatter FENCE_RE = re.compile(r"```.*?```", re.S) CITE_RE = re.compile(r"\[@[^\]]+\]|\[\d+(?:[,–-]\d+)*\]") INLINE_RE = re.compile(r"[*_`]") def body_text(md: str) -> str: """Body prose only: no headings, tables, block quotes, code, citations, markup.""" md = strip_frontmatter(md) # a `status:`/build-note YAML block is not prose rhythm md = FENCE_RE.sub(" ", md) keep = [] for line in md.splitlines(): s = line.strip() if not s or s.startswith(("#", "|", ">", "!", "---")): continue if re.match(r"^\s*(?:[-*+]|\d+\.)\s", line): # list items are not prose rhythm continue keep.append(s) txt = " ".join(keep) txt = CITE_RE.sub("", txt) txt = INLINE_RE.sub("", txt) return re.sub(r"\s+", " ", txt).strip() -
_qc_findings.py 6.3 KB
"""Shared finding-extraction across the heterogeneous detector JSON schemas. The detectors do **not** share one output envelope. Some list their findings under `claims`, some under `findings`; per item the verdict is `verdict` | `kind` | `type`, the severity casing varies (`Major` vs `MAJOR`), and the location is `where` | `location` | `line` | `table_line`. The only contract `check_detector_envelopes` enforces is the top-level `detector` key. The refinement loop controllers (`refinement_stop`, `refinement_regression`) aggregate every gate's `qc/*.json`, so they must read all of these shapes — and, critically, make an *unreadable* one LOUD, not silent: a controller that quietly skips a detector whose schema it does not recognise can report a clean STOP_ZERO_EDIT while a `findings`-schema gate held a Major. `parse_gate(obj)` returns None when the object is not a gate artifact (this tool's own `{"tool": ...}` output, a ledger line), else: {name, kind: "ceiling"|"floor", major, minor, keys: ["verdict@where", ...], parsed: bool} `parsed` is False for a `detector`-keyed file whose finding list is under an unknown key — the caller surfaces it (gates_unparsed) rather than counting it as clean. Stdlib-only. Imported by refinement_stop.py / refinement_regression.py from the same dir. """ from __future__ import annotations _LIST_KEYS = ("claims", "findings") _VERDICT_KEYS = ("verdict", "kind", "type") _WHERE_KEYS = ("where", "location", "line", "table_line") # The severity vocabulary is not one word. Detectors were written independently and each picked its # own, and `_MAJOR` knew two of them — so a gate that says a manuscript CANNOT be submitted was # counted as an optional Minor. `check_placeholders` marks a leftover `TODO` as `blocker` and exits 1 # on it unconditionally; the stop controller read that as "no required edits, do not loop". # # Membership below is derived from each detector's OWN exit contract, not from what the word sounds # like — the word had to make its detector return 1: # blocker check_placeholders `if s["blocker"]: return 1` (no --strict needed) # hard check_disclosure_availability `verdict == "BLOCKER" -> 1` # check_summary_box `NONCONFORMANT and --strict -> 1` # build_title_page_affiliations `rc = 1` # stale check_checklist_version docstring: "0 = in sync, 1 = stale / unverifiable" # unverif. check_checklist_version same detector, same `return 0 if safe else 1`; its own # output line reads "FAIL: checklist is stale or # unverifiable". Found by the repo-wide assertion in # test_qc_severity_vocabulary.sh, not by enumeration — # which is the point of having that assertion. # FAIL validate_pptx_mac_compat `--strict and not ok -> 1` # # `Flag` is deliberately NOT here, and that is the control that shows this is a derivation rather # than a widening: check_generated_code computes `n_flag = len(claims) - n_major` and exits only on # `n_major`, so its own contract says Flag is advisory. `soft` and `warn` likewise. _MAJOR = {"MAJOR", "FATAL", "BLOCKER", "HARD", "STALE", "UNVERIFIABLE", "FAIL"} # Known-advisory. Kept explicit so that a severity belonging to NEITHER set is a novel word rather # than an assumption — see `_classify` below. _MINOR = {"MINOR", "FLAG", "SOFT", "WARN", "WARNING", "INFO", "NOTE", "OK", "PASS", ""} def _classify(severity: str) -> str: """Return "major" | "minor" | "unknown" for a raw severity string. Unknown counts as MAJOR. This module's docstring already states the rule for an unrecognised *schema* — make it loud, never silent, because a controller that quietly skips what it cannot read can report a clean STOP_ZERO_EDIT over a held Major. An unrecognised *severity* is the same hazard one level down, and it defaulted the other way: straight into `minor`, straight into "nothing to do". For a stop controller, guessing "major" costs one more loop; guessing "minor" ends the loop over an unfixed blocker. """ s = str(severity).strip().upper() if s in _MAJOR: return "major" if s in _MINOR: return "minor" return "unknown" def parse_gate(obj) -> dict | None: if not isinstance(obj, dict): return None summary = obj.get("summary") if isinstance(obj.get("summary"), dict) else {} items = None for k in _LIST_KEYS: if isinstance(obj.get(k), list): items = obj[k] break has_detector = "detector" in obj if items is None and not has_detector: return None # not a gate artifact (e.g. a controller's own {"tool": ...} output) name = obj.get("detector") or "?" if items is None: # detector-keyed but no recognisable finding list: a novel schema. Flag it loudly. return {"name": name, "kind": "floor", "major": 0, "minor": 0, "keys": [], "parsed": False} dict_items = [it for it in items if isinstance(it, dict)] kind = "ceiling" if "by_action" in summary else "floor" # Prefer an authoritative summary.n_major (claims-schema gates); otherwise count Majors from # each item's severity (findings-schema gates carry no such summary). n_major_sum = summary.get("n_major") unknown_severities: list[str] = [] if isinstance(n_major_sum, int): major = n_major_sum minor = max(0, len(dict_items) - major) else: major = minor = 0 for it in dict_items: verdict = _classify(it.get("severity", "")) if verdict == "minor": minor += 1 else: major += 1 # "major", and "unknown" fails toward the loop continuing if verdict == "unknown": unknown_severities.append(str(it.get("severity", "")).strip()) keys = [] for it in dict_items: verdict = next((str(it[v]) for v in _VERDICT_KEYS if it.get(v)), "?") where = next((str(it[w]) for w in _WHERE_KEYS if it.get(w) is not None), "?") keys.append(f"{verdict}@{where}") return {"name": name, "kind": kind, "major": major, "minor": minor, "keys": keys, "parsed": True, "unknown_severities": sorted(set(unknown_severities))}
-
-
tests
-
fixtures
-
binning_clean
-
primary.R 141 B · in bundle
-
sensitivity.R 144 B · in bundle
-
-
binning_drift
-
primary.R 195 B · in bundle
-
sensitivity.R 226 B · in bundle
-
-
claim_scripts_consistent
-
05_primary_cox.R 165 B · in bundle
-
-
claim_scripts_coprimary
-
05_primary_cox.R 160 B · in bundle
-
-
coverage_analysis
-
31_delong_nested_added_value.csv 55 B · in bundle
-
table1_demographics.csv 18 B · in bundle
-
-
derived_clean
-
canonical.R 270 B · in bundle
-
shared.R 366 B · in bundle
-
-
derived_clean_dfalias
-
03_cohort.py 210 B
import numpy as np # primary cohort `v0`: metabolic-syndrome blood-pressure component v0['mets_bp'] = np.where( (v0['bl_he_sbp'] >= 130) | (v0['bl_he_dbp'] >= 85) | (v0['bl_tx_htn_med'] == 1), 1, 0, ) -
08_sensitivity.py 357 B
import numpy as np # parallel sensitivity cohort `lenient_cohort`: IDENTICAL derivation rule, only the # dataframe-receiver object differs (by design — a second cohort, not a drift). lenient_cohort['mets_bp'] = np.where( (lenient_cohort['bl_he_sbp'] >= 130) | (lenient_cohort['bl_he_dbp'] >= 85) | (lenient_cohort['bl_tx_htn_med'] == 1), 1, 0, )
-
-
derived_drift
-
canonical.R 325 B · in bundle
-
reanalysis.R 268 B · in bundle
-
-
aphorism_clean.md 3.3 KB
# Synthetic fixture — negative case for APHORISM_DENSITY This fixture carries the same argument as `aphorism_dense.md` and differs only in rhythm, so the pair shows what the gate is actually measuring. The premise is assumed across most of this literature and defended in almost none of it, which matters because the safety argument depends on it entirely. When a phrase such as human-in-the-loop is carried from one field into another it usually brings its original guarantee with it, and in this case that guarantee concerned who held authority rather than who was doing the thinking, so the distinction survived the move in name only. A check earns its name when it can reach the same answer along a road the checked system has not already travelled, and that condition turns out to be easy to state and surprisingly easy to lose without anyone noticing that it has gone. Consider the ordinary case, in which a reader forms a view and then asks a tool to comment on that view. The tool is working from the reader's framing rather than from the underlying material, so agreement between the two tells us considerably less than it appears to on first reading. The effect compounds when the tool writes fluently, because fluency is read as competence by most people most of the time, and a confident restatement of one's own prior is the least useful thing a second reader can supply. What the arrangement is least able to catch is precisely the error that a second reader exists to catch, which is why the loss matters more than its size suggests. Sparingly, an epigram earns its place: involvement is not independence. What follows from that observation is a design question rather than a slogan, because the arrangement can be rebuilt so that the two routes genuinely diverge, and the rebuilding costs less than the literature generally assumes. One workable approach lets the second reader work from the raw material rather than from a summary prepared by the first, which preserves the separation that made the pairing valuable to begin with. Another distributes the work across components that were built differently rather than prompted differently, on the reasoning that components sharing an origin will tend to share their blind spots as well. Neither approach is free, and both require someone to decide in advance which property is being claimed on behalf of the arrangement. That decision is the part the current literature skips, and skipping it is what allows a design to be described as redundant when nothing about it has been shown to be redundant in the sense that matters. The remedy is not to abandon the practice, which remains sensible and well motivated in the settings where it was first proposed, but to state the claim and then measure whether it holds in the deployment at hand rather than inferring it from the shape of the diagram. Departments already carry out this kind of measurement for other quantities they care about, and the statistical machinery transfers with very little adaptation, so the obstacle is not technical. What is missing is the habit of asking for the number at the point where the claim is made, together with some agreement about what an acceptable number would look like. Until both exist, a reader has no way to tell a genuinely redundant arrangement from one that merely looks redundant, and the difference is the whole of the safety argument. -
aphorism_dense.md 1.7 KB
# Synthetic fixture — positive case for APHORISM_DENSITY The premise is assumed everywhere and defended nowhere. Authority is not cognition. The field inherited the term without the distinction. A check is not a check because a person performs it. It is a check because it reaches the answer by another road. That road is what the new tools quietly close. Consider the ordinary case. The reader forms a view. The tool then agrees. Agreement is not confirmation. The circle does not merely overlap. It is dragged inward. The stronger the tool, the more convincing the agreement becomes. Involvement is not independence. Partial decorrelation is not independence either. The bound requires the real thing. That answer is only half-built. It is not sufficient. The remedy is not to abandon the arrangement. It is to stop treating it as self-evident. So the principle is not the one usually stated. What matters is not who acts first. What matters is where the input came from. The distinction decides everything. It did not. It is not one. Two things follow. The first fails at the desk. The second fails inside the machine. Pooling did not merely fail to help. It cost more than it paid. The vocabulary carries the confusion forward. Presence is not scrutiny. A label is not a guarantee. Redundancy is not diversity. Diversity is not independence. Independence is not a standing fact. It is a property of a design. Three things follow from that. First, measure the thing the safeguard exists to protect. Second, declare what can be declared. Third, stop inferring the rest. The transient question is which tool to permit. The durable one is whether anything left in the arrangement can still disagree. That is the whole of it. -
aphorism_frontmatter.md 1.1 KB
--- status: | Authority is not cognition. Fluency is not understanding. Rigor is not caution. It shipped. We moved on. THIS FILE IS THE SSOT (SSOT.yaml -> truth.manuscript_md) --- # Discussion Our retrospective cohort enrolled one thousand two hundred consecutive adults across a single tertiary centre over roughly one decade of longitudinal clinical follow-up. The analysis adjusted for the pre-specified confounders that the study protocol had named before any outcome data were unblinded to the investigating team. We observed a modest association between the exposure of interest and the primary endpoint after accounting for the competing risk of death from unrelated causes. The confidence interval around that adjusted estimate remained wide enough that a clinically negligible effect could not be excluded on the available evidence. These findings should therefore be read as hypothesis-generating rather than as a basis for changing current management in routine practice. A larger multi-centre study with prospectively collected covariates would be required before any firmer conclusion about causality could reasonably be drawn. -
citation_order_bad.md 737 B
# Abstract This is a synthetic document for testing citation order. # Methods The planned groups are listed in Table 4. Additional setup information is in Table S3, and the variable glossary is in Table S1. The example protocol is in Table S8. # Results Group summaries appear in Table 2 and Table 1. Further summaries are in Table S2 and Table S6. The final comparison appears in Table 3, with an example in Table S4. # Discussion Sensitivity analyses S1 through S6 are analysis-plan labels, not supplementary tables. The schematic appears in Supplementary Figure S1. # References 1. Example reference. # Figure Legends **Figure 1.** Synthetic flow diagram. **Figure 2.** Example comparison; additional details in Table S3. -
citation_order_frontmatter.md 931 B
--- title: A cohort study of the thing status: | Display-item renumber for the npj revision (narrative, not body): old Table 1 -> Supplementary Table S2 old Table 3 -> Box 1 Table 2 and S3 REMOVED Build: python3 build/build_npj.py (no arguments) THIS FILE IS THE SSOT (SSOT.yaml -> truth.manuscript_md) --- # Abstract We report a cohort study. The body contains no numbered main-text tables; every display item is a supplementary table or a figure, cited in ascending order. # Methods The design schematic is in Figure 1. Number-at-risk counts are in Supplementary Table S1. # Results Baseline characteristics are in Supplementary Table S2. The endpoint decomposition is in Figure 2. Both supplementary tables and both figures are cited in order. # Discussion The finding held across sensitivity analyses. All display items are cited in first-appearance order once the front-matter changelog is excluded. -
citation_order_good.md 910 B
# Abstract We report a cohort study with a flow diagram and baseline table. # Methods The reference tier is reported descriptively. Number-at-risk tables are in Table S1, the returner comparison in Table S2, and the endpoint decomposition in Table S3. # Results Eligible adults are described in Table 1 and the cohort flow in Figure 1. Tier rates use the denominator roadmap in Table 2. Five-year incidences are in Table 3 and the Kaplan–Meier curves in Figure 2. The landmark model is in Table 4. Robustness analyses are summarized in Tables S4, S5. The schematic is in Supplementary Figure S1. # Discussion The gradient held across sensitivity analyses S1 through S3 (analysis-plan labels, not supplementary tables). All series are cited in order. # References 1. Example reference. # Figure Legends **Figure 1.** Cohort flow. **Figure 2.** Tier-stratified curves; numbers at risk in Table S1. -
citation_order_ref_gap.md 492 B
# Introduction The task has a long methodological history [1, 2] and a well-characterized failure mode [3]. Reporting standards were consolidated more recently [4]. # Methods We followed the pre-registered analysis plan [5] and the agreement conventions [6]. The external cohort was described previously [8]. # References 1. First reference. 2. Second reference. 3. Third reference. 4. Fourth reference. 5. Fifth reference. 6. Sixth reference. 7. Seventh reference. 8. Eighth reference. -
citation_order_ref_good.md 440 B
# Introduction The clinical problem is well described [1], and two prior cohorts established feasibility [2, 3]. # Methods Analyses followed the pre-registered plan [4] and the agreement conventions of prior work [5]. # Discussion All in-text references are cited in ascending order of first appearance, with no gaps. # References 1. First reference. 2. Second reference. 3. Third reference. 4. Fourth reference. 5. Fifth reference. -
citation_order_ref_order_bad.md 678 B
# Introduction Deep-learning triage now matches expert readers on several perceptual tasks [12], yet post-deployment calibration drift is common and under-reported [5]. Early surveys framed the problem [1] and motivated the reporting guidance we adopt here [2, 3]. # Methods Predictions were compared against the adjudicated reference standard [4]. Agreement was summarized following prior methodology [6]. # References 1. First reference. 2. Second reference. 3. Third reference. 4. Fourth reference. 5. Fifth reference. 6. Sixth reference. 7. Seventh reference. 8. Eighth reference. 9. Ninth reference. 10. Tenth reference. 11. Eleventh reference. 12. Twelfth reference. -
citation_order_ref_range_ok.md 1001 B
# Introduction Automated triage has an established evidence base [1], with two influential early cohorts [2, 3]. A broad body of downstream validation work followed [4–11], and the current consensus guidance builds on it [12]. Two further multi-site cohorts extended the setting [13–15], and the reporting extension we apply is the most recent [16]. # Methods Numbers inside a cited range — for example reference 10 inside [4–11], and references 13 and 14 inside [13–15] — are never cited on their own line, yet they are cited. Expanding the range before the gap check is what keeps them from reading as false gaps. # References 1. First reference. 2. Second reference. 3. Third reference. 4. Fourth reference. 5. Fifth reference. 6. Sixth reference. 7. Seventh reference. 8. Eighth reference. 9. Ninth reference. 10. Tenth reference. 11. Eleventh reference. 12. Twelfth reference. 13. Thirteenth reference. 14. Fourteenth reference. 15. Fifteenth reference. 16. Sixteenth reference. -
citation_order_section_bad.md 233 B
## Results The primary outcome is described here. As reported in Section 3.4, the estimate was stable, and the results presented in Section 3 are exploratory. ## Sensitivity analyses Details of the robustness checks appear above. -
citation_order_section_good.md 303 B
## 3 Results The primary outcome is described here. As reported in Section 3.4, the estimate was stable, and the results presented in Section 3 are exploratory. Full details appear in Supplementary Section 5 of the appendix. ## 3.4 Sensitivity analyses Details of the robustness checks appear above. -
citation_order_uncited.md 645 B
# Methods Baseline characteristics are in Table 1 and the cohort flow in Figure 1. The pre-specified sensitivity analyses are in Supplementary Table S1. # Results The primary endpoint is summarized in Table 1, and the subgroup breakdown is in Supplementary Table S1. # Figure Legends **Figure 1.** Study flow diagram. # Table Legends **Table 1.** Baseline characteristics of the analytic cohort. # Supplementary Material **Supplementary Table S1.** Pre-specified sensitivity analyses. **Supplementary Figure S1.** Additional subgroup forest plot — defined here with a full caption but never cited anywhere in the Methods or Results. -
claim_body_reassign.md 99 B
# METHODS The primary endpoint was changed from 30-day to 90-day mortality after the DSMB review. -
claim_frontmatter_changelog.md 453 B
--- title: "Effect of X on ninety-day mortality" version: v4 changelog: - "v2: added the pre-specified sensitivity analyses" - "v3: the primary endpoint was changed from 30-day to 90-day mortality after the DSMB review, before unblinding" --- # METHODS The primary outcome was 90-day all-cause mortality, pre-specified in the registered protocol. # RESULTS Ninety-day mortality was 12.1% in the intervention arm versus 15.8% in the control arm. -
claim_manuscript.md 734 B
# Synthetic manuscript fixture (claim-vs-artifact test) ## Methods The primary analysis was the association between emphysema and all-cause mortality estimated by a multiple-imputation Cox model. This primary endpoint was re-designated at the manuscript stage from the pre-specified complete-case model after the imputed estimate was found to be more favourable. ## Results In the primary model, emphysema was not associated with all-cause mortality. The E-value for the primary association (HR 1.52) was 3.10, which we interpret as robust to unmeasured confounding. In an exploratory analysis, the E-value for the cancer-specific subdistribution hazard (sHR 2.25) was 3.93, suggesting the cancer signal is hard to explain away. -
claim_manuscript_single_primary.md 154 B
## Methods Model 3 is the single primary model, consistent with the registered analysis plan. The E-value for the primary association (HR 1.52) was 2.41. -
claim_manuscript_structured.md 232 B
## Methods The primary analysis estimated the adjusted odds ratio for the association between alcohol intake and hepatic fibrosis in the complete-case logistic model. ## Results The odds ratio for the primary association was 1.42. -
claim_prereg.md 307 B
# Synthetic pre-registration fixture (claim-vs-artifact test) Primary outcome: the association between emphysema and all-cause mortality in the complete-case multivariable Cox model is the primary analysis of this study. Secondary outcomes: cause-specific mortality (cancer, cardiovascular, respiratory). -
claim_prereg_structured.md 334 B
# Pre-registration (project.yaml excerpt) primary_exposure: alcohol intake (grams per day) primary_outcome: hepatic fibrosis (FIB-4 >= 2.67) primary_estimand: adjusted odds ratio from the complete-case logistic model # PRIMARY — locked 2026-01-01 (do not edit) sensitivity_axes: - multiple imputation - alternate FIB-4 cutoffs -
claim_registration_bad.md 241 B
# Registration chronology fixture (synthetic, PII-free) — FIRES ## Methods We searched PubMed, Embase, and Cochrane from inception to 31 March 2026. The review was prospectively registered in PROSPERO (CRD42026123456) on 16 April 2026. -
claim_registration_clean.md 563 B
# Registration chronology negatives (synthetic, PII-free) — all SILENT ## Negative 1 — genuinely prospective (registration precedes search-end) The review was prospectively registered in PROSPERO (CRD42026123456) on 1 February 2026. We searched PubMed, Embase, and Cochrane from inception to 31 March 2026. ## Negative 2 — no prospective claim (registration postdates search, but the text does not claim "prospective") The review was registered with PROSPERO (CRD42026123456) on 16 April 2026. We searched the databases from inception to 31 March 2026. -
classical_metadoc.md 406 B
# Reporting of AI-use disclosure in radiology manuscripts ## Introduction This study analyzes AI-disclosure statements across published radiology papers. A frequently reused template reads: "During the preparation of this manuscript the authors used a large language model to assist with editing." We examine how often such disclosure statements meet MI-CLEAR-LLM requirements and where they fall short. -
cohort_bad.md 471 B
# Results ## Incidence During follow-up there were 120 events over 50,000 person-years, an incidence rate of 5.0 per 1,000 person-years. ## Study population Of 4,252 participants, 583 had missing data, leaving 3,667 in the analytic cohort. ## Risk by tier The cohort was partitioned into three mutually exclusive risk tiers. | Risk tier | n | events | | --- | --- | --- | | Low | 5,000 | 30 | | Medium | 4,000 | 50 | | High | 3,498 | 45 | | Total | 12,019 | 115 | -
cohort_clean.md 471 B
# Results ## Incidence During follow-up there were 120 events over 50,000 person-years, an incidence rate of 2.4 per 1,000 person-years. ## Study population Of 4,252 participants, 583 had missing data, leaving 3,669 in the analytic cohort. ## Risk by tier The cohort was partitioned into three mutually exclusive risk tiers. | Risk tier | n | events | | --- | --- | --- | | Low | 5,000 | 30 | | Medium | 4,000 | 50 | | High | 3,019 | 35 | | Total | 12,019 | 115 | -
cohort_dup_ci.md 280 B
## Results **Table 3. Subgroup associations with the outcome.** | Subgroup | n | Events | OR (95% CI) | |---|---|---|---| | MetS (>=3 criteria) | 969 | 209 | 4.95 (4.32-5.94) | | MetS-positive (binary) | 969 | 209 | 4.95 (4.26-5.83) | | Diabetes | 512 | 88 | 2.10 (1.60-2.75) | -
cohort_dup_ci_clean.md 269 B
## Results **Table 3. Subgroup associations with the outcome.** | Subgroup | n | Events | OR (95% CI) | |---|---|---|---| | Metabolic syndrome | 969 | 209 | 4.95 (4.32-5.94) | | Hypertension | 512 | 88 | 4.95 (3.80-6.45) | | Diabetes | 700 | 120 | 2.10 (1.60-2.75) | -
cohort_followup_criterion.md 126 B
The median follow-up was 102 days. Nodules were classified benign if they demonstrated size stability for at least 24 months. -
cohort_followup_ok.md 202 B
The median follow-up was 102 days. Nodules were classified benign if they demonstrated size stability for at least 24 months. The total observation period was a median of 442 days (maximum 2,016 days). -
cohort_nested_models.md 237 B
## Results **Table 3. Discrimination of nested prediction models.** | Model | C-index (95% CI) | |---|---| | CMB + age + sex | 0.667 (0.62-0.71) | | MetS + age + sex | 0.671 (0.63-0.72) | | CMB + MetS + age + sex | 0.673 (0.63-0.72) | -
cohort_nested_models_base.md 224 B
## Results **Table 3. Discrimination of nested prediction models.** | Model | C-index (95% CI) | |---|---| | Age + sex | 0.648 (0.60-0.69) | | CMB + age + sex | 0.667 (0.62-0.71) | | MetS + age + sex | 0.671 (0.63-0.72) | -
cohort_partition.csv 73 B · in bundle
-
cohort_partition_prose.md 441 B
# Prose partition fixture (synthetic, PII-free) ## Positive — exhaustive split that does not reconcile (FIRES) Among the 289 incident cases, 37 (12.8%) were identified by isolated single glucose, 185 (64.0%) by the HbA1c threshold, and 103 (35.6%) by diagnosis, medication, or history. Here 185 is the non-exclusive HbA1c component sitting among two mutually exclusive categories, so the counts sum to 325 and the percentages to 112.4%. -
cohort_partition_prose_clean.md 896 B
# Prose partition negatives (synthetic, PII-free) — all must stay SILENT ## Negative 1 — the same split, corrected: counts sum to N, percentages to 100 Among the 289 incident cases, 37 (12.8%) were identified by isolated single glucose, 149 (51.6%) by the HbA1c threshold, and 103 (35.6%) by diagnosis, medication, or history. ## Negative 2 — overlapping attributes, no percentages (a cross-tabulation, not a partition) Of the 289 patients, 210 had hypertension, 140 had diabetes, and 95 had dyslipidemia. ## Negative 3 — overlapping attributes WITH percentages but no partition cue (the precision case) Of the 289 patients, 210 (72.7%) had hypertension, 140 (48.4%) had diabetes, and 95 (32.9%) had dyslipidemia. These comorbidities are not mutually exclusive, so their counts legitimately sum above 289; the "had" verb carries no partition-cue language, so the gate stays silent. -
cohort_rate_tier_fp.md 153 B
# Results Among the prespecified strata, stratum 1 incident rates reached 2.48 per 100 person-years (882 events over 35,581 person-years of follow-up). -
cohort_repeat_subjects.csv 114 B · in bundle
-
cohort_unit_disclosed.md 612 B
# Coronary artery calcium and a screening-detected finding ## Methods This was a cross-sectional analysis of a health-screening cohort. Because some participants attended screening more than once, the analysis unit was one record per subject; a first-visit-only sensitivity analysis restricted to unique subjects reproduced the primary estimate. The analytic sample comprised 10 screening records from 7 subjects. ## Results The outcome occurred in 3 records. The calcium score was not associated with the outcome (odds ratio 1.0, 95% CI 0.6 to 1.5), and the first-visit sensitivity analysis was concordant. -
cohort_unit_undisclosed.md 415 B
# Coronary artery calcium and a screening-detected finding ## Methods This was a cross-sectional analysis of a health-screening cohort. The analytic sample comprised 10 screening records. We fit a logistic model of the outcome on the calcium score adjusted for age and sex. ## Results The outcome occurred in 3 records. The calcium score was not associated with the outcome (odds ratio 1.0, 95% CI 0.6 to 1.5). -
coverage_clean.md 465 B
# Methods ## Statistical Analysis Missing covariate data were handled with multiple imputation (m = 20). A sensitivity analysis excluding events in the first year was pre-specified. # Results Baseline demographics are shown in Table 1. After multiple imputation the estimates were unchanged, and the sensitivity analysis excluding early events did not alter the conclusions. The DeLong test of the nested added-value model showed no incremental discrimination. -
coverage_manuscript.md 320 B
# Methods ## Statistical Analysis Missing covariate data were handled with multiple imputation (m = 20). A sensitivity analysis excluding events in the first year was pre-specified. # Results Baseline demographics are shown in Table 1. The sensitivity analysis excluding early events did not change the conclusions. -
coverage_promised_stat.md 333 B
# Methods The univariate format-classifier ceiling AUC and the de-confounded reader AUC are reported in Supplementary Table S16; together they bound the primary estimand. # Results Readers distinguished synthetic from authentic images with high accuracy, and the de-confounded comparison was consistent with the primary analysis. -
coverage_promised_stat_ok.md 241 B
# Methods The de-confounded reader AUC within the overlap stratum is reported in the Results. # Results Within the overlap stratum, the de-confounded reader AUC was 0.71 (95% CI 0.65-0.77), and the format-classifier ceiling AUC was 0.58. -
cv_leakage_bad.md 169 B
## Methods Feature selection and univariate screening were performed over the entire dataset, then the classifier was evaluated with stratified 5-fold cross-validation. -
cv_leakage_clean.md 160 B
## Methods Feature selection was repeated within each training fold (nested cross-validation), so no information from the held-out fold informed the selection. -
editorial_clean.md 2.6 KB
# A Deep-Learning Marker for Synthetic Outcome X: Development and Internal Validation ## Abstract **Background:** Marker X may aid early triage of outcome X. **Methods:** We developed and internally validated a model on a patient-level split. **Results:** The model discriminated outcome X with an area under the curve of 0.84 (95% CI 0.79–0.89) and was well calibrated. **Conclusion:** Marker X identifies patients at higher risk of outcome X and supports triage. These findings are preliminary and require external validation. ## Introduction Outcome X is common and its early identification changes triage. Existing tools rely on manual scoring, which is slow and operator-dependent. We asked whether a deep-learning marker derived from routine inputs could identify patients at higher risk and inform the triage decision. This study develops such a marker and tests it on a held-out internal cohort, addressing a gap left by prior manual approaches. ## Methods We assembled a cohort and split it at the patient level into development and held-out sets before any preprocessing. A regularised model was trained with five-fold cross-validation. All analysis code, the dataset schema, and a content-hash manifest are archived (see Data Availability). ## Results The model discriminated outcome X with an area under the curve of 0.84 (95% CI 0.79–0.89). Calibration was good, with a slope of 0.97 and a Brier score of 0.12. At the triage threshold, sensitivity was 0.82 and specificity was 0.79. In a sensitivity analysis excluding the 41 borderline cases, the adjusted odds ratio was unchanged at 2.18 (95% CI 1.40–3.39), and results were consistent across the two recruitment years. Decision-curve analysis showed net benefit over the manual score across the clinically relevant threshold range. ## Discussion A routine-input marker identified patients at higher risk of outcome X and added net benefit over the existing manual score, which is the decision it is meant to inform. The effect size corresponds to a clinically meaningful shift in pretest probability. The marker is reproducible and its calibration supports use at the stated threshold. ## Limitations This single-centre study has three main limitations: it was developed on retrospective data, the marker was measured once, and external validation in an independent cohort is the necessary next step before deployment. ## Data Availability The analysis code, the dataset schema, the patient-level split assignment, and a reproducibility manifest with the dataset content hash are archived in the project repository, so every reported number can be regenerated from a single committed pipeline. -
editorial_defensive.md 2.4 KB
# A Deep-Learning Marker for Synthetic Outcome X: A Preliminary Single-Centre Study ## Abstract **Background:** Marker X has been proposed as a screening adjunct. **Methods:** We trained a model on a retrospective cohort. **Results:** The model reached an area under the curve of 0.84. **Conclusion:** These findings are preliminary and exploratory and should be interpreted with caution. The results are hypothesis-generating and not generalizable beyond this single-centre cohort, and no deployable claim is made. Because the analysis is underpowered, no causal inference can be inferred. ## Introduction Marker X is of interest, although the evidence remains uncertain and the prior literature is limited by small samples. We hypothesise that a model may help, but the work is exploratory and any conclusion should be interpreted with caution. We make no deployable claim and the results are not generalizable; the study is hypothesis-generating only. ## Methods We used a retrospective cohort and fit a regularised model. Splits were at the patient level. ## Results The model reached an area under the curve of 0.84 (95% CI 0.79–0.89). The reproducibility manifest and the sha256 checksum of the locked dataset are reported. Every metric in this paragraph was produced by a committed unit-test against the post-lock data with seed=42, and the git commit a1b2c3d4e5f6 pins the exact run. The audit trail for each figure is recorded. ## Discussion Our marker may help, but the finding is preliminary and modest and cannot be established as causal. The result is not generalizable and remains uncertain; it is hypothesis-generating only and warrants further study. We reiterate that no deployable claim is made and that any inference should be interpreted with caution. The exact manifest hash and the post-lock timeline are given above for full auditability. ## Limitations This study has several limitations. First, the design is retrospective and single-centre, so selection bias cannot be excluded. Second, the sample is limited and the analysis is underpowered. Third, the marker was measured once. Fourth, no external validation was performed. Fifth, residual confounding cannot be ruled out. Sixth, the outcome label is registry-derived. Seventh, generalizability is uncertain. In a sensitivity analysis excluding the 41 borderline cases, the adjusted odds ratio remained 2.18 (95% CI 1.40–3.39), which we note here for completeness. -
emphasis_clean.md 451 B
## Discussion The association was significant (*P* = .003) and consistent across cohorts. The assay was validated *in vitro* and confirmed *in vivo*; the *BRCA1* variant carriers showed a higher event rate. Sensitivity analyses using *t* tests gave concordant results, and the effect held after adjustment. These findings support the pre-specified hypothesis and align with prior reports in comparable screening populations without overstating scope. -
emphasis_overuse.md 433 B
## Discussion This shifts the task *into* a *passive* review, where the human is no longer the *same* kind of reader. The model does not merely assist; it becomes *a redesign of the human-AI relationship itself*, and the clinician is left to *what* the system decides. Every step is now *graded* against an opaque standard, and the workflow is *quietly* restructured around the model's *preferences* rather than the patient's needs. -
figcite_credit_bad.md 296 B
## Figures **Figure 1.** Flow. **Figure 2.** ROC. **Figure 3.** Calibration.    ## Results Figure 1, Figure 2, and Figure 3 show the results. ## Author Contributions Y.N.: prepared Figure 4 and drafted the manuscript. H.S.O.: Figure 1b. -
figcite_credit_ok.md 258 B
## Figures **Figure 1.** Flow. **Figure 2.** ROC.   ## Results Figure 1 and Figure 2 show the results. ## Author Contributions Y.N.: Conceptualization, Visualization, Writing – original draft. H.S.O.: Visualization. -
figcite_embedded.md 256 B
# Display items **Figure 1.** Study flow diagram.  **Figure 2.** ROC curves for the primary model.  ## Results As shown in Figure 1, 289 patients were included. Discrimination is in Figure 2. -
figcite_not_embedded.md 194 B
# Display items **Figure 1.** Study flow diagram. **Figure 2.** ROC curves for the primary model. ## Results As shown in Figure 1, 289 patients were included. Discrimination is in Figure 2. -
figure_clean.md 391 B
## Results As shown in Figure 1 and Figure 3, detection improved. Table 1 and Table 2 summarize the primary and secondary results respectively. ## Figure Legends **Figure 1.** Study flow diagram.  **Figure 3.** Forest plot of subgroup effects.  ## Tables **Table 1.** Baseline characteristics. **Table 2.** Per-reader accuracy. -
figure_orphan.md 263 B
## Results As shown in Figure 1, detection improved. Table 1 lists the cohort. ## Figure Legends **Figure 1.** Study flow diagram. **Figure 3.** Forest plot of subgroup effects. ## Tables **Table 1.** Baseline characteristics. **Table 2.** Per-reader accuracy. -
null_bad.md 372 B
# Hepatitis B and hepatic fibrosis: no synergy with metabolic dysfunction ## Abstract **Conclusion:** There was no synergy between hepatitis B infection and metabolic dysfunction on fibrosis burden, and the two exposures were not associated beyond their independent effects. ## Conclusion Our cross-sectional analysis showed no interaction between the two conditions. -
null_clean.md 500 B
# Hepatitis B and hepatic fibrosis ## Abstract **Conclusion:** We found no synergy between hepatitis B infection and metabolic dysfunction (interaction OR 1.04, 95% CI 0.75-1.44). The study was powered to detect an interaction odds ratio of 1.5, and the confidence interval excludes effects larger than that threshold, so a modest interaction cannot be excluded. ## Conclusion The wide confidence interval means this null should be read as inconclusive rather than as evidence of no interaction. -
null_region_masked.md 648 B
# Reader detection of synthetic radiologic images ## Abstract **Results:** In the primary reader analysis the two generators performed comparably (AUC 0.71 vs 0.73). The study was powered to detect a between-generator difference of 0.10 in AUC, and the observed interval was compatible with a difference of that magnitude, so formal equivalence at the pre-specified bound was not demonstrated by these data alone. ## Conclusion Taken together, the two image generators were equivalent within the pre-specified bound, and radiologists could not reliably tell them apart during routine interpretation, supporting interchangeable use in practice. -
panel_collapse.json 939 B
[ { "reviewer_id": "R1", "expertise_area": "Methodology (search/screening/PRISMA)", "major": [ {"heading": "Search strategy thin", "comment": "The search strategy omits two major databases and grey literature.", "location": "Methods"}, {"heading": "Heterogeneity not pooled correctly", "comment": "Random-effects pooling is applied despite I2 above 95%.", "location": "Results"} ], "minor": [] }, { "reviewer_id": "R2", "expertise_area": "Clinical", "major": [ {"heading": "Clinical overclaim", "comment": "Clinical actionability is asserted beyond the evidence.", "location": "Discussion"} ], "minor": [] }, { "reviewer_id": "R3", "expertise_area": "Statistics (pooling/heterogeneity)", "major": [ {"heading": "Heterogeneity echo", "comment": "I2 heterogeneity is high and pooling is questionable.", "location": "Results"} ], "minor": [] } ] -
panel_good.json 1.6 KB
{ "research_type": "sr_ma", "reviewers": [ { "reviewer_id": "R1", "expertise_area": "Methodology (search/screening/PRISMA)", "major": [ {"heading": "Search strategy not reproducible", "comment": "The search strategy is described in one line with no database-specific syntax; eligibility criteria are prose rather than a numbered list.", "location": "Methods, Search"}, {"heading": "Duplicate record handling unclear", "comment": "Screening counts in the PRISMA flow do not reconcile with the records identified.", "location": "Figure 1"} ], "minor": [] }, { "reviewer_id": "R2", "expertise_area": "Clinical", "major": [ {"heading": "Overreaching clinical recommendation", "comment": "Clinical actionability is asserted but generalizability to community settings is not supported.", "location": "Discussion"}, {"heading": "Applicability to the target population", "comment": "The included studies are tertiary-center only; clinical relevance to primary care is overstated.", "location": "Discussion"} ], "minor": [] }, { "reviewer_id": "R3", "expertise_area": "Statistics (pooling/heterogeneity)", "major": [ {"heading": "Heterogeneity not addressed", "comment": "I2 exceeds 90% yet a fixed pooling is reported without justification.", "location": "Results"}, {"heading": "Pooling model mis-specified", "comment": "Random-effects model assumptions and the confidence interval method are not stated.", "location": "Methods, Synthesis"} ], "minor": [] } ] } -
panel_monoculture.json 1.2 KB
{ "research_type": "sr_ma", "reviewers": [ { "reviewer_id": "R1", "expertise_area": "Methodology (search/screening/PRISMA)", "major": [ {"heading": "Calibration absent", "comment": "No calibration plot or Brier score is reported.", "location": "Results"}, {"heading": "Discrimination overstated", "comment": "AUC discrimination is reported without a confidence interval.", "location": "Results"}, {"heading": "Heterogeneity ignored", "comment": "I2 heterogeneity is high but not explored.", "location": "Results"} ], "minor": [] }, { "reviewer_id": "R2", "expertise_area": "Clinical", "major": [ {"heading": "Confidence intervals missing", "comment": "Pooled estimates lack a confidence interval.", "location": "Table 2"}, {"heading": "Multiplicity uncorrected", "comment": "Multiplicity across many outcomes is not corrected.", "location": "Methods"} ], "minor": [] }, { "reviewer_id": "R3", "expertise_area": "Statistics (pooling/heterogeneity)", "major": [ {"heading": "Missing data handling", "comment": "Missing data and imputation are not described.", "location": "Methods"} ], "minor": [] } ] } -
panel_one_returned.json 389 B
{ "research_type": "sr_ma", "reviewers": [ { "reviewer_id": "R1", "expertise_area": "Methodology (search/screening/PRISMA)", "major": [ {"heading": "Search strategy incomplete", "comment": "No grey-literature database was searched and the PRISMA flow omits duplicate record removal.", "location": "Methods, Search"} ], "minor": [] } ] } -
panel_stats_lens.json 1.4 KB
{ "research_type": "sr_ma", "reviewers": [ { "reviewer_id": "R1", "expertise_area": "Methodology (search/screening/PRISMA)", "major": [ {"heading": "Search strategy incomplete", "comment": "No grey-literature database was searched and the PRISMA flow omits duplicate record removal.", "location": "Methods, Search"} ], "minor": [] }, { "reviewer_id": "R2", "expertise_area": "Clinical", "major": [ {"heading": "Overreaching recommendation", "comment": "Clinical actionability is asserted but generalizability to community care is unsupported.", "location": "Discussion"} ], "minor": [] }, { "reviewer_id": "R3", "expertise_area": "Statistics (reader agreement)", "major": [ {"heading": "Agreement statistic without resampling", "comment": "Inter-rater agreement is reported as Cohen's kappa; no bootstrap resampling of the readers was performed.", "location": "Results"}, {"heading": "Repeated testing not corrected", "comment": "A permutation test across twelve endpoints with no Bonferroni correction was applied to the readers.", "location": "Methods"}, {"heading": "Effect measure ignores clustering", "comment": "The odds ratio disregards the intraclass correlation (ICC) induced by repeated readers.", "location": "Results"} ], "minor": [] } ] } -
paren_clean.md 317 B
# Discussion The protective association was robust to extended adjustment (E-value 3.10; 95% CI 1.10 to 1.52), consistent with prior cohorts (Smith 2022; cf. Doe 2021). We confirmed the result in a first-visit sensitivity analysis (n = 3,335). The imaging was read by Dr. Smith. See Fig. 2 for the calibration plot. -
paren_corrupt.md 569 B
# Discussion Our study has several limitations. The protective association was robust to extended adjustment, and the residual-confounding bound was reassuring (E-value 3.10 Sixth, the lean-MASLD subgroup was small and may be underpowered) and the finding should be interpreted cautiously. The estimate was stable across sensitivity analyses (this paragraph was wrongly folded into a parenthesis during an em-dash conversion. The next sentence then begins inside the same span and runs well past the intended appositive boundary) which a balance check cannot detect. -
refadeq_letter.md 814 B
# Synthetic letter to the editor We read with interest the recent analysis of the index biomarker [@smith2019] and offer three observations. First, the reported effect direction is consistent with two prior cohorts [@jones2020; @lee2021], strengthening the external plausibility of the finding. Second, the authors' interpretation of the subgroup pattern echoes an earlier commentary [@kim2018] but appears to understate the confidence-interval width reported in the original registry [@park2022]. Third, the clinical implication should be read against the guideline statement [@choi2017], which cautions against acting on a single cross-sectional measurement [@brown2015]. We commend the authors for releasing their analytic code [@white2016], which permits the kind of scrutiny that strengthens the literature. -
refadeq_original_fixed.md 1.8 KB
# Synthetic original-research draft (Methods named methods now cited) ## Introduction Chronic disease burden continues to rise worldwide [@smith2019]. Risk stratification tools have proliferated over the last decade [@jones2020], yet external validation remains uneven [@lee2021]. Prior cohorts established the prognostic value of the index biomarker [@kim2018], and registry analyses extended this to older adults [@park2022]. The evidence base nonetheless lacks competing-risk-aware modeling [@choi2017]. ## Methods ### Study population We assembled a retrospective single-center cohort of adults meeting the eligibility criteria below. The analytic dataset was frozen before any modeling. ### Statistical analysis We fitted a Fine-Gray subdistribution hazard model for the competing risk of non-event mortality [@finegray1999]. Missing covariate data were addressed with multiple imputation (MICE) under the fully conditional specification [@white2011]. The robustness of the primary association to unmeasured confounding was quantified with the E-value [@vanderweele2017]. Estimated glomerular filtration rate was computed with the CKD-EPI 2021 equation [@inker2021]. Discrimination was summarized with the concordance statistic [@harrell1996]. ## Results The cohort comprised consecutive patients over the study window. The primary model reproduced the previously reported direction of effect [@smith2019]. ## Discussion Our findings align with earlier prognostic work [@brown2015] and extend it to a competing-risk framework [@white2016]. The magnitude is consistent with two external cohorts [@green2018; @black2019]. Mechanistically, the association is plausible [@gray2020], although residual confounding cannot be excluded [@adams2021]. Generalizability is limited by the single-center design [@baker2014]. -
refadeq_original_uncited.md 1.7 KB
# Synthetic original-research draft (Methods names methods with zero citations) ## Introduction Chronic disease burden continues to rise worldwide [@smith2019]. Risk stratification tools have proliferated over the last decade [@jones2020], yet external validation remains uneven [@lee2021]. Prior cohorts established the prognostic value of the index biomarker [@kim2018], and registry analyses extended this to older adults [@park2022]. The evidence base nonetheless lacks competing-risk-aware modeling [@choi2017]. ## Methods ### Study population We assembled a retrospective single-center cohort of adults meeting the eligibility criteria below. The analytic dataset was frozen before any modeling. ### Statistical analysis We fitted a Fine-Gray subdistribution hazard model for the competing risk of non-event mortality. Missing covariate data were addressed with multiple imputation (MICE), generating twenty imputed datasets. The robustness of the primary association to unmeasured confounding was quantified with the E-value. Estimated glomerular filtration rate was computed with the CKD-EPI 2021 equation. Discrimination was summarized with the concordance statistic. ## Results The cohort comprised consecutive patients over the study window. The primary model reproduced the previously reported direction of effect [@smith2019]. ## Discussion Our findings align with earlier prognostic work [@brown2015] and extend it to a competing-risk framework [@white2016]. The magnitude is consistent with two external cohorts [@green2018; @black2019]. Mechanistically, the association is plausible [@gray2020], although residual confounding cannot be excluded [@adams2021]. Generalizability is limited by the single-center design [@baker2014]. -
rhetorical_clean.md 2.2 KB
# Synthetic fixture — negative case for ANTITHESIS_DENSITY and CLEFT_DENSITY This fixture carries the same argument as `rhetorical_dense.md` and differs only in how its sentences are built, so the pair shows what the gate measures. A method earns trust through what it can be shown to measure, and the clearest sign of a trustworthy protocol is that it gives its author a way to be proven wrong where others can see the result. The quality that separates a finding from an assertion is that someone other than the author can put it to an independent test, and that willingness to be checked is easy to state and surprisingly easy to lose without anyone noticing. Consider the ordinary review, in which a second reader is asked to comment on work whose framing they have already absorbed. What such a review is least able to catch is precisely the error a second reader exists to catch, and one cleft used this way earns its place without turning the paragraph into a run of them. Their agreement then tells us less than it appears to, because a reviewer working from the author's own framing supplies reassurance instead of the friction the arrangement was built to create. The information lives in the points where the two readers diverge, so a review that mostly confirms the author's view has quietly given up the thing that made a second reader worth having in the first place. The underlying difficulty is a matter of design rather than of diligence, and here a single functional contrast is exactly right and does not make the prose an epigram. The field inherited a convenient phrase and then treated it as though it were a definition, which let a design be described as independent long after nothing about it had been shown to be so. The sensible response is to name the property one is claiming and then measure whether the arrangement actually has it, instead of reading independence off the shape of a diagram. Departments already carry out this kind of measurement for other quantities they care about, so the obstacle is habit and not capability. Until the number is asked for at the point where the claim is made, a reader has no way to tell a genuinely redundant design from one that merely looks redundant, and that difference is the whole of the safety argument. -
rhetorical_dense.md 1.4 KB
# Synthetic fixture — positive case for ANTITHESIS_DENSITY and CLEFT_DENSITY A method earns trust by what it measures rather than by what it claims. What a good protocol supplies is a way to be wrong in public, not a way to look right in private. It is the willingness to be checked, rather than the confidence of the author, that separates a finding from an assertion. The distinction is structural, not rhetorical. Consider the ordinary review. What the second reader adds is friction rather than agreement. It is the disagreement that carries information, not the nod that follows it. A reviewer who repeats the author's framing supplies comfort rather than scrutiny, and comfort, not scrutiny, is what the arrangement was built to remove. What matters here is the source of the input rather than the identity of the reader. The problem is one of design rather than of effort. What the field inherited is a slogan rather than a criterion, and a slogan, not a criterion, is what gets written into the guidelines. It is the appearance of independence, rather than its substance, that the diagram preserves. The remedy is to state the property rather than to assume it. So the durable question is not who acts but where the input came from. What a measurement buys is a way to tell a real safeguard from a decorative one rather than a reason to trust the picture. It is that difference, rather than any single result, that the whole argument rests on. -
roster_3.json 19 B
["R1", "R2", "R3"] -
roster_4.json 25 B
["R1", "R2", "R3", "R4"] -
roster_indep_substrate.json 198 B
{"generator_substrate": "claude", "reviewers": [ {"reviewer_id": "R1", "substrate": "claude"}, {"reviewer_id": "R2", "substrate": "codex"}, {"reviewer_id": "R3", "substrate": "human"} ]} -
roster_mono_substrate.json 200 B
{"generator_substrate": "claude", "reviewers": [ {"reviewer_id": "R1", "substrate": "claude"}, {"reviewer_id": "R2", "substrate": "claude"}, {"reviewer_id": "R3", "substrate": "claude"} ]} -
rounded_delta_bad.md 142 B
## Results The two generators achieved AUCs of 0.70 and 0.73; the between-generator difference was 0.02 at the pre-specified operating point. -
rounded_delta_clean.md 247 B
## Results The two generators achieved AUCs of 0.70 and 0.73; the difference was 0.03. At full precision the arms scored 0.703 and 0.726, so the difference was 0.02 on the unrounded values. Sensitivity was 0.88 and specificity 0.91 in the cohort. -
scope_antipattern_list.md 552 B
# A reproducible toolkit for scope-coherence defects in cross-sectional imaging studies ## Abstract We present a deterministic gate that scans manuscripts built on a cross-sectional, single-visit design and reports a compact list of defects. ## Conclusion Across a public corpus, the gate flags a non-portable analysis script, an unsupported prognostic claim in a cross-sectional study, a fabricated citation, and a silently dropped exclusion. Each defect is deterministic and reviewer-visible, and authors can resolve every one before submission. -
scope_bad.md 239 B
# Methods This was a cross-sectional study of health-screening participants assessed at a single visit. # Conclusion Individuals with a negative scan may safely extend their screening interval, and routine surveillance can be deferred. -
scope_clean.md 205 B
# Methods This was a longitudinal cohort followed prospectively for ten years. # Conclusion Higher coronary calcium scores were associated with incident cardiovascular events over the follow-up period. -
scope_disclaimer.md 307 B
# Methods This was a cross-sectional analysis of a single-visit health-screening cohort. # Conclusion Our findings describe the concurrent burden of fibrosis at the time of imaging rather than surveillance intervals or disease progression, which would require prospective longitudinal data to establish. -
scope_gradient.md 253 B
# CAC warranty by joint age and calcium stratification ## Results The time to a 25% CAC threshold was shortest in the high-risk tertile and shortened monotonically across the age strata, with the gradient most pronounced among the older participants. -
scope_gradient_tested.md 241 B
# CAC warranty by joint age and calcium stratification ## Results The time to a 25% CAC threshold shortened monotonically across the age strata; the age x calcium interaction was tested with a likelihood ratio test (p-interaction = 0.02). -
scope_metadoc.md 429 B
# A detector for scope-coherence anti-patterns in cross-sectional imaging studies ## Abstract This paper describes a deterministic gate for manuscripts built on a cross-sectional, single-visit design. ## Conclusion The present tool detects papers that assert a surveillance interval from prevalence data; such patterns are a recurrent reviewer-fatal overclaim that methods work should catalog so that authors can avoid them. -
scope_surrogate.md 248 B
# Methods The primary endpoint was a longitudinal cohort outcome over ten years. # Conclusion Because coronary calcium was categorized as present versus absent, a binary surrogate, statin therapy can be deferred in calcium-negative individuals. -
scope_universal_negative.md 121 B
## Introduction No published system measures inter-agent error independence, and this is the first study to quantify it. -
scope_universal_negative_ok.md 134 B
## Introduction To our knowledge, no clinically published system measures inter-agent error independence in the radiology literature. -
scope_yield.md 528 B
# Prevalence of incidental hepatic lesions on screening ultrasound ## Methods This was a cross-sectional analysis of a single-visit health-screening cohort. Each participant contributed one examination. ## Results Incidental hepatic lesions were present in 8.2% of participants. ## Conclusion The diagnostic yield of screening ultrasound was 8.2%, and the detection rate was higher among older participants. These findings describe the cross-sectional burden of incidental lesions in an asymptomatic screening population. -
scope_yield_defined.md 597 B
# Prevalence of incidental hepatic lesions on screening ultrasound ## Methods This was a cross-sectional analysis of a single-visit health-screening cohort. Each participant contributed one examination. Here, diagnostic yield is defined as the cross-sectional report-positive prevalence of findings at the index visit. ## Results Incidental hepatic lesions were present in 8.2% of participants. ## Conclusion The diagnostic yield of screening ultrasound, so defined, was 8.2%. These findings describe the cross-sectional burden of incidental lesions in an asymptomatic screening population. -
style_bad.md 494 B
# **METHODS** Studies were eligible if they reported a diagnostic accuracy estimate and enrolled adult participants with the target condition. Outcome definitions are given in Methods §2. The adjusted odds ratio was 1.25 and the hazard ratio was 2.305. The event rate was 35.14% and specificity was 96.12%. # **DISCUSSION** During the preparation of this manuscript, the authors used a generative AI tool to assist with language editing. Generative AI was not used to create any figures. -
style_clean.md 362 B
# **METHODS** Studies were eligible if they met the following criteria: (1) reported a diagnostic accuracy estimate; (2) enrolled adult participants; (3) used an acceptable reference standard. The adjusted odds ratio was 1.25 and the hazard ratio was 2.30. # **DISCUSSION** These findings extend prior work on the target condition and support further study. -
style_dagger_footnote.md 276 B
# **METHODS** We extracted study-level data in duplicate and resolved disagreements by consensus. # Title Page Author One^{1,§}, Author Two^{2,§}, Author Three^{3,†} § Dr. Alpha and Dr. Beta are co-senior authors. † These authors contributed equally to this work. -
style_emdash_structural.md 811 B
# A cohort manuscript with large baseline tables ## Authors Jane A. Doe, MD — Department of Radiology, Example University Hospital John B. Roe, PhD — Division of Epidemiology, Example Institute ORCID: 0000-0002-1825-0097 ## METHODS We analysed a single-visit screening cohort and report the association between the exposure and the outcome. The adjustment set was specified from a causal diagram. ## RESULTS Table 1. Baseline characteristics by exposure | Characteristic | Exposed | Unexposed | SMD | |---|---|---|---| | Age, y | 54.2 ± 8.1 | 53.8 ± 8.3 | 0.05 | | Prior therapy | — | — | — | | eGFR | 88 ± 12 | 91 ± 11 | 0.26 | *(A) — obesity stratum; (B) — lean stratum.* The exposed and unexposed groups were comparable in age, and the primary association was modest in magnitude. -
supplement_pii_clean.md 276 B
# Supplementary Appendix. Reader panel The reader panel comprised Jane Doe, John Roe, and Mary Sue (members listed in the Supplementary Appendix). Aggregate response rates were 0.71 across readers. Overall, responses to real images were more accurate than to synthetic ones. -
supplement_pii_tie.md 255 B
# Supplementary Table S3. Post-lock reader timeline | Reader | Name | Status | Response | |---|---|---|---| | Rfe75ca | Jane Doe | analyzed | response=real, confidence=4, cue=texture | | R0a1b2c | (quarantined) | replaced | response=fake, confidence=2 | -
supp_clean.md 395 B
# Supplementary Material ## Supplementary Table 1. Baseline characteristics of the analytic cohort Continuous variables are summarised as mean (SD); categorical variables as n (%). All exclusions are enumerated in the participant flow diagram. ## Supplementary Figure 1. Receiver operating characteristic curves Curves were generated on the held-out test set with 2,000 bootstrap resamples. -
supp_dirty.md 503 B
# Supplementary Material ## Supplementary Table SX. Baseline characteristics Outcome definitions follow the protocol (see Methods §3); stratum labels §L4 and §L10b are defined in the analysis plan. Designed by: the study team. Expected PRISMA Numbers are tabulated below; the deduplication step is [VERIFY: recount after re-export] and TODO before submission. Per Reviewer 2 #3, the screening figure was redrawn (figS_*.{png,pdf}); source files live under 1_Search/dedup.csv. Authors: [Authors] -
supp_xref_body.md 132 B
# Results Baseline characteristics are reported in Supplementary Table 2. The full ROC analysis appears in Supplementary Figure 9. -
supp_xref_supp.md 106 B
# Supplementary Material ## Supplementary Table 2. Baseline characteristics Reported by exposure group. -
table1_by_exposure.csv 345 B · in bundle
-
table1_by_exposure_dbcodes.csv 325 B · in bundle
-
table1_meansd_wide.csv 242 B · in bundle
-
-
test_aphorism_density.sh 2.5 KB
#!/usr/bin/env bash # Regression test for the aphorism-density gate (P26 / §J — the sentence-rhythm AI tell). # Synthetic, PII-free fixtures. Three cases: # (1) aphorism_dense.md — the same argument written as epigrams -> APHORISM_DENSITY fires # (2) aphorism_clean.md — the same argument written as explanation -> no finding # (3) aphorism_frontmatter.md — a `status:`/build-note YAML block full of short negative # definitions, over a body of ordinary explanatory prose. The front matter must be # stripped before the rhythm is measured; otherwise its lines are counted as body # "very short declaratives" and the detector fires on the changelog. Body-only rate # is 0/0, so the no-fire here is the fix, not the min-sentences floor (6 sentences). # Uses --min-sentences 6 so the small A/B fixtures clear the noise floor. Stdlib-only. set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_aphorism_density.py" FX="$HERE/fixtures" OUT="$(mktemp -t aphorism_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } run() { python3 "$SCRIPT" --manuscript "$1" --min-sentences 6 --out "$OUT" --quiet >/dev/null 2>&1; } fired() { python3 -c "import json,sys; sys.exit(0 if json.load(open('$OUT'))['findings'] else 1)"; } not_fired() { python3 -c "import json,sys; sys.exit(0 if not json.load(open('$OUT'))['findings'] else 1)"; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) epigram-dense prose -> APHORISM_DENSITY run "$FX/aphorism_dense.md" check "epigram-dense fixture fires APHORISM_DENSITY" fired # (2) explanatory prose (same argument) -> silent run "$FX/aphorism_clean.md" check "explanatory fixture does not fire" not_fired # (3) YAML front-matter changelog must not be measured as body rhythm run "$FX/aphorism_frontmatter.md" check "front-matter build/status block does not fire (stripped before measuring)" not_fired check "no front-matter line is counted as a short declarative" python3 -c " import json d=json.load(open('$OUT')) lines=[s for f in d['findings'] for s in f.get('short_declaratives',[])] assert not any('SSOT' in s or 'is not' in s for s in lines), lines assert d['metrics']['sentences']==6, d['metrics'] # body-only sentence count " echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_artifact_coverage.sh 2.9 KB
#!/usr/bin/env bash # Regression test for the artifact-coverage gate (self-review Phase 2.5f). # Synthetic, PII-free fixtures reproduce: (a) a Methods-promised multiple- # imputation analysis that never reaches Results (FORWARD), (b) an analysis-bearing # output CSV (a DeLong nested added-value table) present on disk but unmentioned in # the manuscript (REVERSE). The clean manuscript reports both and mentions the disk # outputs, so it reconciles. # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_artifact_coverage.py" BAD="$HERE/fixtures/coverage_manuscript.md" CLEAN="$HERE/fixtures/coverage_clean.md" ADIR="$HERE/fixtures/coverage_analysis" OUT="$(mktemp -t cov_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) bad manuscript + analysis dir: promised-absent + disk-unreported, Major -> exit 1 python3 "$SCRIPT" --manuscript "$BAD" --analysis-dir "$ADIR" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 under --strict (Major present)" test "$?" -eq 1 check "JSON artifact written" test -s "$OUT" check "PROMISED_ABSENT detected (MI promised, absent from Results)" has_verdict PROMISED_ABSENT check "DISK_UNREPORTED detected (delong nested CSV unmentioned)" has_verdict DISK_UNREPORTED # (2) clean manuscript: reports MI + sensitivity, mentions disk outputs -> exit 0 python3 "$SCRIPT" --manuscript "$CLEAN" --analysis-dir "$ADIR" --strict --quiet >/dev/null 2>&1 check "exit 0 on clean manuscript (all reconciled)" test "$?" -eq 0 # (3) a bound/ceiling/de-confounded AUC promised "is reported in Table S16" but # never given a numeric value anywhere -> PROMISED_STAT_NO_VALUE (Major), exit 1 PS="$HERE/fixtures/coverage_promised_stat.md" python3 "$SCRIPT" --manuscript "$PS" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 (promised stat without a value)" test "$?" -eq 1 check "PROMISED_STAT_NO_VALUE detected" has_verdict PROMISED_STAT_NO_VALUE # (4) same framing but the de-confounded AUC value (0.71) IS reported -> no fire, exit 0 PSOK="$HERE/fixtures/coverage_promised_stat_ok.md" python3 "$SCRIPT" --manuscript "$PSOK" --out "$OUT" --quiet >/dev/null 2>&1 check "no PROMISED_STAT_NO_VALUE when the value is reported" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='PROMISED_STAT_NO_VALUE' for c in d['claims']), 'fired despite a reported value' " python3 "$SCRIPT" --manuscript "$PSOK" --strict --quiet >/dev/null 2>&1 check "exit 0 when the promised value is present" test "$?" -eq 0 echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_binning_consistency.sh 3.7 KB
#!/usr/bin/env bash # Regression test for the cross-script categorical / definition-consistency gate # (Phase 2.5b/c). Synthetic, PII-free fixtures cover two failure modes: # BINNING_DRIFT — a derived `age_band` binned with two different cut # signatures (45/50/60 right=FALSE vs 44/49/59 right=TRUE) # across two scripts, plus a clean pair sharing one signature. # DERIVED_DEF_DRIFT — a composite indicator (`mets_bp`/`mets_fg`) defined with a # dropped OR-clause across two scripts, plus a clean pair that # is identical up to clause order / whitespace / outer parens / # commutative `&`-operand order. # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_binning_consistency.py" DRIFT="$HERE/fixtures/binning_drift" CLEAN="$HERE/fixtures/binning_clean" DDRIFT="$HERE/fixtures/derived_drift" DCLEAN="$HERE/fixtures/derived_clean" OUT="$(mktemp -t bin_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } echo "test_binning_consistency:" # (1) drift fixtures: BINNING_DRIFT (Major) -> exit 1 under --strict python3 "$SCRIPT" --root "$DRIFT" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 under --strict (Major present)" test "$?" -eq 1 check "JSON artifact written" test -s "$OUT" check "BINNING_DRIFT detected (45/50/60 right=FALSE vs 44/49/59 right=TRUE)" has_verdict BINNING_DRIFT # (2) clean fixtures: identical cut signature -> exit 0, no claim python3 "$SCRIPT" --root "$CLEAN" --strict --quiet >/dev/null 2>&1 check "exit 0 on consistent binning" test "$?" -eq 0 python3 "$SCRIPT" --root "$CLEAN" --out "$OUT" --quiet >/dev/null 2>&1 check "no BINNING_DRIFT on clean fixtures" bash -c " python3 -c \"import json; d=json.load(open('$OUT')); assert not d['claims']\" " # (3) composite-definition drift: DERIVED_DEF_DRIFT (Major) -> exit 1 under --strict python3 "$SCRIPT" --root "$DDRIFT" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 under --strict (composite clause dropped)" test "$?" -eq 1 check "DERIVED_DEF_DRIFT detected (mets_bp/mets_fg clause omission)" has_verdict DERIVED_DEF_DRIFT # (4) clean composite fixtures: same atom SET (reordered / whitespace / parens / # commutative &) -> exit 0, no claim python3 "$SCRIPT" --root "$DCLEAN" --strict --quiet >/dev/null 2>&1 check "exit 0 on order-/whitespace-/paren-equivalent composite defs" test "$?" -eq 0 python3 "$SCRIPT" --root "$DCLEAN" --out "$OUT" --quiet >/dev/null 2>&1 check "no DERIVED_DEF_DRIFT on clean composite fixtures" bash -c " python3 -c \"import json; d=json.load(open('$OUT')); assert not d['claims']\" " # (5) parallel sensitivity cohort: SAME derived rule, different dataframe-receiver # object (v0[...] vs lenient_cohort[...]) -> NO DERIVED_DEF_DRIFT (regression). DALIAS="$HERE/fixtures/derived_clean_dfalias" python3 "$SCRIPT" --root "$DALIAS" --strict --quiet >/dev/null 2>&1 check "exit 0 on parallel df-alias cohort" test "$?" -eq 0 python3 "$SCRIPT" --root "$DALIAS" --out "$OUT" --quiet >/dev/null 2>&1 check "no DERIVED_DEF_DRIFT on parallel df-alias cohort" bash -c " python3 -c \"import json; d=json.load(open('$OUT')); assert not any(c['verdict']=='DERIVED_DEF_DRIFT' for c in d['claims'])\" " if [[ "$fail" -eq 0 ]]; then echo " ALL PASS"; else echo " $fail FAILED"; fi exit "$fail" -
test_citation_order.sh 6.8 KB
#!/usr/bin/env bash # Regression test for the float citation-ORDER gate (journal technical-check pass). # Synthetic, PII-free fixtures: a manuscript whose main Tables (4,2,1,3) and # supplementary Tables (S3,S1,S8,S2,S6,S4) are cited out of numerical order, and # a clean manuscript where every series is cited in ascending order (incl. a plural # list "Tables S4, S5", a back-matter legends block that must be excluded, and a # non-float "S1 through S6" sensitivity label that must NOT be parsed as tables). # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_citation_order.py" BAD="$HERE/fixtures/citation_order_bad.md" GOOD="$HERE/fixtures/citation_order_good.md" OUT="$(mktemp -t citorder_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } count_order() { python3 -c " import json d=json.load(open('$OUT')) n=sum(1 for c in d['claims'] if c['verdict']=='CITATION_ORDER') assert n==$1, f'expected $1 CITATION_ORDER, got {n}' "; } no_falsepos() { python3 -c " import json d=json.load(open('$OUT')) assert d['summary']['n_major']==0, d['summary'] "; } count_uncited() { python3 -c " import json d=json.load(open('$OUT')) n=sum(1 for c in d['claims'] if c['verdict']=='UNCITED_FLOAT') assert n==$1, f'expected $1 UNCITED_FLOAT, got {n}' "; } count_verdict() { python3 -c " import json d=json.load(open('$OUT')) n=sum(1 for c in d['claims'] if c['verdict']=='$1') assert n==$2, f'expected $2 $1, got {n}: {[c[\"verdict\"] for c in d[\"claims\"]]}' "; } no_claims() { python3 -c " import json d=json.load(open('$OUT')) assert not d['claims'], d['claims'] "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) out-of-order manuscript -> 2 CITATION_ORDER (main Table + Suppl Table), exit 1 python3 "$SCRIPT" --manuscript "$BAD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 under --strict (out-of-order present)" test "$?" -eq 1 check "2 CITATION_ORDER series flagged (Table + Supplementary Table)" count_order 2 # (2) clean manuscript: ascending order, plural list, excluded legends, S1-S6 label python3 "$SCRIPT" --manuscript "$GOOD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 on clean manuscript (no false positive)" test "$?" -eq 0 check "no Major on clean manuscript" no_falsepos # (3) DANGLING_SECTION_XREF: "Section 3.4"/"Section 3" refs with UNNUMBERED headings -> Major SBAD="$HERE/fixtures/citation_order_section_bad.md" python3 "$SCRIPT" --manuscript "$SBAD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 on Section-refs with unnumbered headings" test "$?" -eq 1 check "DANGLING_SECTION_XREF flagged" python3 -c " import json d=json.load(open('$OUT')) assert any(c['verdict']=='DANGLING_SECTION_XREF' for c in d['claims']), 'not flagged' " # (4) numbered headings resolve the refs, and 'Supplementary Section 5' is exempt -> silent SGOOD="$HERE/fixtures/citation_order_section_good.md" python3 "$SCRIPT" --manuscript "$SGOOD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 when Section refs resolve to numbered headings" test "$?" -eq 0 check "no DANGLING_SECTION_XREF when headings are numbered" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='DANGLING_SECTION_XREF' for c in d['claims']), 'false positive' " # (5) YAML front matter narrating a display-item renumber ("old Table 1 -> Supplementary # Table S2", "old Table 3 -> Box 1") must NOT be scanned as body citations — a # `status:`/changelog block is not prose. Body cites Supplementary Tables and figures # in ascending order and has no main-text tables. FM="$HERE/fixtures/citation_order_frontmatter.md" python3 "$SCRIPT" --manuscript "$FM" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 with an out-of-order renumber in YAML front matter (body clean)" test "$?" -eq 0 check "no CITATION_ORDER from a front-matter changelog" count_order 0 check "no Major from a front-matter changelog" no_falsepos # (6) A float DEFINED by a legend/caption but never cited in the narrative body -> # UNCITED_FLOAT (Minor). The fixture cites Table 1, Figure 1 and Supplementary Table # S1 (all defined and cited), and defines Supplementary Figure S1 with a full caption # that no sentence ever cites. UNC="$HERE/fixtures/citation_order_uncited.md" python3 "$SCRIPT" --manuscript "$UNC" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 (UNCITED_FLOAT is Minor, not Major)" test "$?" -eq 0 check "one UNCITED_FLOAT for the defined-but-uncited float" count_uncited 1 check "the uncited claim names Supplementary Figure S1" python3 -c " import json d=json.load(open('$OUT')) u=[c for c in d['claims'] if c['verdict']=='UNCITED_FLOAT'] assert u and u[0]['where']=='Supplementary Figure S1', [c['where'] for c in u] " # (7) the clean fixture defines nothing uncited -> no UNCITED_FLOAT (no over-fire). python3 "$SCRIPT" --manuscript "$GOOD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "no UNCITED_FLOAT on the clean manuscript (no over-fire)" count_uncited 0 # --- reference (in-text [N]) series: the fifth series the float scan never saw ---------- FX="$HERE/fixtures" # (8) hand-typed [N] manuscript that cites [12] before [5] -> REFERENCE_ORDER (Major), exit 1 python3 "$SCRIPT" --manuscript "$FX/citation_order_ref_order_bad.md" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 when in-text references are out of order ([12] before [5])" test "$?" -eq 1 check "one REFERENCE_ORDER flagged" count_verdict REFERENCE_ORDER 1 # (9) references cited [1..6, 8] with 7 never cited -> REFERENCE_GAP (Minor), exit 0 python3 "$SCRIPT" --manuscript "$FX/citation_order_ref_gap.md" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 on a reference gap (Minor, not Major)" test "$?" -eq 0 check "one REFERENCE_GAP for the hole at [7]" count_verdict REFERENCE_GAP 1 # (10) NEGATIVE range trap: 5-10 and 13-14 sit INSIDE rendered ranges [4-11]/[13-15] -> # ranges are expanded, so NO false gap -> clean (the case the ad-hoc script got wrong) python3 "$SCRIPT" --manuscript "$FX/citation_order_ref_range_ok.md" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 when the only 'gaps' sit inside a rendered range" test "$?" -eq 0 check "no claim from a reference range (range expanded before gap check)" no_claims # (11) NEGATIVE clean: contiguous [1..5] in order with a matching 5-entry list -> no over-fire python3 "$SCRIPT" --manuscript "$FX/citation_order_ref_good.md" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 on a clean reference series" test "$?" -eq 0 check "no claim on a clean contiguous reference series (no over-fire)" no_claims echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_claim_artifact.sh 7.1 KB
#!/usr/bin/env bash # Regression test for the claim-vs-artifact cross-check (self-review Phase 2.5f). # Synthetic fixture reproduces: (a) a primary re-designated at manuscript stage, # (b) an E-value (3.10) that does not recompute from its stated primary HR 1.52, # (c) a correctly-arithmetic E-value attached to a non-primary (cancer) estimate. # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_claim_artifact.py" MAN="$HERE/fixtures/claim_manuscript.md" PRE="$HERE/fixtures/claim_prereg.md" OUT="$(mktemp -t ca_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } python3 "$SCRIPT" --manuscript "$MAN" --prereg "$PRE" --out "$OUT" --strict >/dev/null 2>&1 check "exit 1 under --strict (Major present)" test "$?" -eq 1 check "JSON artifact written" test -s "$OUT" check "PRIMARY_REASSIGNED detected" has_verdict PRIMARY_REASSIGNED check "EVALUE_ARITHMETIC detected (3.10 vs HR 1.52)" has_verdict EVALUE_ARITHMETIC check "EVALUE_NON_PRIMARY detected (cancer sHR)" has_verdict EVALUE_NON_PRIMARY # Clean case: primary matches prereg, correct primary E-value, no reassignment. CLEAN="$(mktemp -t ca_clean_XXXX).md" trap 'rm -f "$OUT" "$CLEAN"' EXIT cat > "$CLEAN" <<'EOF' ## Methods The primary analysis was the association between emphysema and all-cause mortality in the complete-case multivariable Cox model. ## Results The E-value for the primary association (HR 1.52) was 2.41. EOF python3 "$SCRIPT" --manuscript "$CLEAN" --prereg "$PRE" --strict >/dev/null 2>&1 check "exit 0 on clean manuscript (matching primary, correct E-value)" test "$?" -eq 0 # Advisory case (less-defensive trim): a bare honest manuscript-stage disclosure — # WITHOUT explicit re-designation/after-results language — is PRIMARY_DISCLOSURE_NOTE # (advisory), NOT PRIMARY_REASSIGNED (Major). estimand-provenance guidance recommends # writing this, so it must not fail --strict on its own. DISC="$(mktemp -t ca_disc_XXXX).md" trap 'rm -f "$OUT" "$CLEAN" "$DISC"' EXIT cat > "$DISC" <<'EOF' ## Methods The primary analysis was the association between emphysema and all-cause mortality in the complete-case multivariable Cox model. Using the multiple-imputation model as the estimation approach was a manuscript-stage analytical decision, disclosed here and reported coequally with the pre-specified complete-case analysis. ## Results The E-value for the primary association (HR 1.52) was 2.41. EOF python3 "$SCRIPT" --manuscript "$DISC" --prereg "$PRE" --out "$OUT" --strict >/dev/null 2>&1 check "exit 0 on honest manuscript-stage disclosure (advisory, not Major)" test "$?" -eq 0 check "PRIMARY_DISCLOSURE_NOTE emitted (advisory)" has_verdict PRIMARY_DISCLOSURE_NOTE check "no PRIMARY_REASSIGNED on bare disclosure" python3 -c " import json d=json.load(open('$OUT')) raise SystemExit(0 if not any(c['verdict']=='PRIMARY_REASSIGNED' for c in d['claims']) else 1) " # Structured-prereg anchor: a project.yaml with explicit primary_* keys + a # manuscript whose primary is substantively consistent -> the estimand check anchors # on the structured field VALUES (not a `# PRIMARY — locked` comment or a lexically # dissimilar free-text paragraph) and does NOT allege ESTIMAND_DRIFT. Regression for # the false Major at overlap 0.26 on a reconciled estimand. SMAN="$HERE/fixtures/claim_manuscript_structured.md" SPRE="$HERE/fixtures/claim_prereg_structured.md" python3 "$SCRIPT" --manuscript "$SMAN" --prereg "$SPRE" --out "$OUT" >/dev/null 2>&1 check "no ESTIMAND_DRIFT on a structured, consistent prereg" python3 -c " import json d=json.load(open('$OUT')) raise SystemExit(0 if not any(c['verdict']=='ESTIMAND_DRIFT' for c in d['claims']) else 1) " python3 "$SCRIPT" --manuscript "$SMAN" --prereg "$SPRE" --strict >/dev/null 2>&1 check "exit 0 on structured consistent prereg (no Major)" test "$?" -eq 0 # Code-label reconciliation (--scripts): a manuscript asserting a SINGLE primary vs an # analysis script annotating a model 'co-primary' -> PRIMARY_LABEL_CODE_DRIFT (advisory, # not Major). A consistent scripts dir and the no-flag backward-compatible default. SP="$HERE/fixtures/claim_manuscript_single_primary.md" python3 "$SCRIPT" --manuscript "$SP" --scripts "$HERE/fixtures/claim_scripts_coprimary" --out "$OUT" >/dev/null 2>&1 check "PRIMARY_LABEL_CODE_DRIFT on code co-primary vs single-primary manuscript" has_verdict PRIMARY_LABEL_CODE_DRIFT python3 "$SCRIPT" --manuscript "$SP" --scripts "$HERE/fixtures/claim_scripts_coprimary" --strict >/dev/null 2>&1 check "code-label drift is advisory (exit 0 under --strict)" test "$?" -eq 0 python3 "$SCRIPT" --manuscript "$SP" --scripts "$HERE/fixtures/claim_scripts_consistent" --out "$OUT" >/dev/null 2>&1 check "no drift when scripts carry no co-primary label" python3 -c " import json d=json.load(open('$OUT')) raise SystemExit(0 if not any(c['verdict']=='PRIMARY_LABEL_CODE_DRIFT' for c in d['claims']) else 1) " # Registration chronology (manuscript-internal, no --prereg needed): a # "prospectively registered" claim whose registration date (16 Apr 2026) postdates # search completion (31 Mar 2026) -> REGISTRATION_CHRONOLOGY (Major), exit 1. RB="$HERE/fixtures/claim_registration_bad.md" python3 "$SCRIPT" --manuscript "$RB" --out "$OUT" --strict >/dev/null 2>&1 check "exit 1 on retrospective registration under a prospective claim" test "$?" -eq 1 check "REGISTRATION_CHRONOLOGY detected" has_verdict REGISTRATION_CHRONOLOGY # Silent when registration precedes search-end, and when no "prospective" claim is made. RC="$HERE/fixtures/claim_registration_clean.md" python3 "$SCRIPT" --manuscript "$RC" --strict >/dev/null 2>&1 check "exit 0 on genuinely-prospective + no-prospective-claim registrations" test "$?" -eq 0 # YAML front matter is not body prose. A project that honestly records "the primary # endpoint was changed ..." in a `changelog:` block was handed PRIMARY_REASSIGNED — a P0 — # for keeping a good record. The same sentence in the BODY is a real self-admission and # must still fire, which is what makes the first assertion mean something. FM="$HERE/fixtures/claim_frontmatter_changelog.md" python3 "$SCRIPT" --manuscript "$FM" --out "$OUT" --strict >/dev/null 2>&1 check "a changelog in YAML front matter is not a self-admission" test "$?" -eq 0 check "...and no PRIMARY_REASSIGNED is recorded" python3 -c " import json d=json.load(open('$OUT')) raise SystemExit(0 if not any(c['verdict']=='PRIMARY_REASSIGNED' for c in d['claims']) else 1) " BR="$HERE/fixtures/claim_body_reassign.md" python3 "$SCRIPT" --manuscript "$BR" --out "$OUT" --strict >/dev/null 2>&1 check "the same sentence in the body still fires (P0 preserved)" test "$?" -eq 1 check "PRIMARY_REASSIGNED detected in body" has_verdict PRIMARY_REASSIGNED echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_classical_style.sh 4.1 KB
#!/usr/bin/env bash # Regression test for the classical-style body lint (self-review §J). # Synthetic, PII-free fixtures reproduce: a § symbol self-reference + an in-body # AI-disclosure paragraph (both Major), eligibility prose, and mixed OR/HR decimals # (Minor). The clean fixture uses a numbered eligibility list, consistent decimals, # no § symbol, and no in-body disclosure. # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_classical_style.py" BAD="$HERE/fixtures/style_bad.md" CLEAN="$HERE/fixtures/style_clean.md" OUT="$(mktemp -t style_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) bad manuscript: Major (§ + in-body disclosure) -> exit 1 python3 "$SCRIPT" --manuscript "$BAD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 under --strict (Major present)" test "$?" -eq 1 check "JSON artifact written" test -s "$OUT" check "SECTION_SYMBOL detected" has_verdict SECTION_SYMBOL check "INBODY_AI_DISCLOSURE detected" has_verdict INBODY_AI_DISCLOSURE check "ELIGIBILITY_PROSE detected" has_verdict ELIGIBILITY_PROSE check "DECIMAL_INCONSISTENCY detected" has_verdict DECIMAL_INCONSISTENCY check "PERCENT_DECIMALS detected (35.14%, 96.12%)" has_verdict PERCENT_DECIMALS # (2) clean manuscript: numbered eligibility, consistent decimals, no §/disclosure, # no >1-dp percentages -> exit 0 python3 "$SCRIPT" --manuscript "$CLEAN" --strict --quiet >/dev/null 2>&1 check "exit 0 on clean manuscript" test "$?" -eq 0 python3 "$SCRIPT" --manuscript "$CLEAN" --out "$OUT" --quiet >/dev/null 2>&1 check "no PERCENT_DECIMALS false positive on clean manuscript" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='PERCENT_DECIMALS' for c in d['claims']), 'PERCENT_DECIMALS false positive' " # (3) structural em-dashes (table cells, ORCID, author/affiliation, panel labels) # must NOT count toward the prose threshold. With --em-dash-max 0, the fixture's # ~9 structural dashes are excluded and its prose has 0 → no EM_DASH_OVERUSE. STRUCT="$HERE/fixtures/style_emdash_structural.md" python3 "$SCRIPT" --manuscript "$STRUCT" --em-dash-max 0 --out "$OUT" --quiet >/dev/null 2>&1 check "structural em-dashes excluded (no EM_DASH_OVERUSE at max 0)" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='EM_DASH_OVERUSE' for c in d['claims']), 'structural dashes were counted as prose' " # (4) § used as author/affiliation footnote dagger (co-senior-author line, super- # script markers) must NOT fire SECTION_SYMBOL — only a § section cross-ref does. DAGGER="$HERE/fixtures/style_dagger_footnote.md" python3 "$SCRIPT" --manuscript "$DAGGER" --out "$OUT" --quiet >/dev/null 2>&1 check "no SECTION_SYMBOL on footnote daggers" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='SECTION_SYMBOL' for c in d['claims']), 'dagger § flagged as section symbol' " python3 "$SCRIPT" --manuscript "$DAGGER" --strict --quiet >/dev/null 2>&1 check "exit 0 on dagger-footnote manuscript" test "$?" -eq 0 # (5) a paper whose SUBJECT is AI-use disclosure carries disclosure phrasing as an # object of study, not as its own disclosure -> no INBODY_AI_DISCLOSURE. METADOC="$HERE/fixtures/classical_metadoc.md" python3 "$SCRIPT" --manuscript "$METADOC" --out "$OUT" --quiet >/dev/null 2>&1 check "no INBODY_AI_DISCLOSURE on an AI-disclosure-methods paper" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='INBODY_AI_DISCLOSURE' for c in d['claims']), 'meta-document flagged as in-body disclosure' " python3 "$SCRIPT" --manuscript "$METADOC" --strict --quiet >/dev/null 2>&1 check "exit 0 on AI-disclosure-methods paper" test "$?" -eq 0 echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_cohort_arithmetic.sh 7.7 KB
#!/usr/bin/env bash # Regression test for the cohort-arithmetic gate (self-review Phase 2.5 / 2.5b). # Synthetic, PII-free fixtures reproduce: (a) an incidence rate that does not # recompute from its events/person-years, (b) a complete-case footnote that does # not balance (total - missing != complete), (c) an ordinal tier partition whose # stratum denominators and events sum above the stated total. # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_cohort_arithmetic.py" BAD="$HERE/fixtures/cohort_bad.md" CLEAN="$HERE/fixtures/cohort_clean.md" PART="$HERE/fixtures/cohort_partition.csv" OUT="$(mktemp -t coh_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) bad manuscript: all three discrepancies, Major present -> exit 1 under --strict python3 "$SCRIPT" --manuscript "$BAD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 under --strict (Major present)" test "$?" -eq 1 check "JSON artifact written" test -s "$OUT" check "RATE_BACKCALC detected (5.0 vs 120/50000*1000=2.4)" has_verdict RATE_BACKCALC check "CASCADE_SUM detected (4252-583=3669 != 3667)" has_verdict CASCADE_SUM check "PARTITION_OVERLAP detected (sum 12498 != 12019)" has_verdict PARTITION_OVERLAP # (2) clean manuscript: everything balances -> exit 0 python3 "$SCRIPT" --manuscript "$CLEAN" --strict --quiet >/dev/null 2>&1 check "exit 0 on clean manuscript (all arithmetic balances)" test "$?" -eq 0 # (3) --data CSV partition: stratum n sum above total -> PARTITION_OVERLAP, exit 1 python3 "$SCRIPT" --manuscript "$CLEAN" --data "$PART" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 with overlapping --data partition CSV" test "$?" -eq 1 check "PARTITION_OVERLAP from --data CSV" has_verdict PARTITION_OVERLAP # (4) analysis-unit: 10 records / 7 subjects + manuscript that discloses neither # the analysis unit nor a one-record-per-subject sensitivity -> ANALYSIS_UNIT_ # UNDISCLOSED (auto-detected 'mockid' column), exit 1. REPEAT="$HERE/fixtures/cohort_repeat_subjects.csv" UNDISC="$HERE/fixtures/cohort_unit_undisclosed.md" DISC="$HERE/fixtures/cohort_unit_disclosed.md" python3 "$SCRIPT" --manuscript "$UNDISC" --data "$REPEAT" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 (analysis unit undisclosed, auto-detect)" test "$?" -eq 1 check "ANALYSIS_UNIT_UNDISCLOSED detected" has_verdict ANALYSIS_UNIT_UNDISCLOSED check "reconciliation reports 7 unique subjects" python3 -c " import json d=json.load(open('$OUT')) c=next(c for c in d['claims'] if c['verdict']=='ANALYSIS_UNIT_UNDISCLOSED') assert 'unique_subjects=7' in c['detail'] and 'max_visits=3' in c['detail'], c['detail'] " # (5) explicit --id-col also fires python3 "$SCRIPT" --manuscript "$UNDISC" --data "$REPEAT" --id-col mockid --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 (analysis unit, explicit --id-col)" test "$?" -eq 1 # (6) disclosed manuscript (states analysis unit + first-visit sensitivity) -> no fire python3 "$SCRIPT" --manuscript "$DISC" --data "$REPEAT" --strict --quiet >/dev/null 2>&1 check "exit 0 when analysis unit disclosed" test "$?" -eq 0 # (7) a tier label ("stratum 1") and "incident rate" sitting near a small integer # must NOT mis-bind the numerator: the rate recomputes from 882 events / # 35,581 PY -> NO RATE_BACKCALC false positive, exit 0 (regression). RFP="$HERE/fixtures/cohort_rate_tier_fp.md" python3 "$SCRIPT" --manuscript "$RFP" --out "$OUT" --quiet >/dev/null 2>&1 check "no RATE_BACKCALC false positive (tier + incident-rate)" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='RATE_BACKCALC' for c in d['claims']), 'numerator mis-bound -> false RATE_BACKCALC' " python3 "$SCRIPT" --manuscript "$RFP" --strict --quiet >/dev/null 2>&1 check "exit 0 on correct-rate manuscript" test "$?" -eq 0 # (8) PROSE partition: an in-text exhaustive split whose counts do not sum to the # stated total (37 + 185 + 103 = 325 != 289, % = 112.4) -> PARTITION_OVERLAP, # exit 1. PPROSE="$HERE/fixtures/cohort_partition_prose.md" python3 "$SCRIPT" --manuscript "$PPROSE" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 on prose partition that does not reconcile" test "$?" -eq 1 check "PARTITION_OVERLAP from prose enumeration" has_verdict PARTITION_OVERLAP # (9) PRECISION: the corrected split (37+149+103=289, %=100), a no-% cross-tab, # and an overlapping-comorbidity enumeration WITH %s but no partition cue must # all stay silent -> exit 0. The last is the case a naive count-sum check # would false-fire on. PPCLEAN="$HERE/fixtures/cohort_partition_prose_clean.md" python3 "$SCRIPT" --manuscript "$PPCLEAN" --strict --quiet >/dev/null 2>&1 check "exit 0 on corrected + cross-tab + cue-less overlapping prose" test "$?" -eq 0 # (10) FOLLOWUP_VS_CRITERION: reported "median follow-up 102 days" against a ">= 24 # months stability" criterion with no total-observation window -> Minor flag. FUC="$HERE/fixtures/cohort_followup_criterion.md" python3 "$SCRIPT" --manuscript "$FUC" --out "$OUT" --quiet >/dev/null 2>&1 check "FOLLOWUP_VS_CRITERION when follow-up < a criterion duration" has_verdict FOLLOWUP_VS_CRITERION # (11) silent once the total-observation window is distinctly reported. FUOK="$HERE/fixtures/cohort_followup_ok.md" python3 "$SCRIPT" --manuscript "$FUOK" --out "$OUT" --quiet >/dev/null 2>&1 check "no FOLLOWUP_VS_CRITERION when total observation window is reported" python3 -c " import json d=json.load(open('$OUT')) raise SystemExit(0 if not any(c['verdict']=='FOLLOWUP_VS_CRITERION' for c in d['claims']) else 1) " # (N) a subgroup rendered twice with divergent CIs: two rows share the SAME estimate AND # identical n/events but print different confidence intervals (independent bootstraps) # -> SUBGROUP_DUPLICATE_CI (Minor). Two DISTINCT subgroups with a coincidentally equal # estimate but different n/events must NOT fire (the precision guard). DUPCI="$HERE/fixtures/cohort_dup_ci.md" DUPCICLEAN="$HERE/fixtures/cohort_dup_ci_clean.md" python3 "$SCRIPT" --manuscript "$DUPCI" --out "$OUT" --quiet >/dev/null 2>&1 check "SUBGROUP_DUPLICATE_CI on same estimate+n+events with divergent CI" has_verdict SUBGROUP_DUPLICATE_CI python3 "$SCRIPT" --manuscript "$DUPCICLEAN" --out "$OUT" --quiet >/dev/null 2>&1 check "no SUBGROUP_DUPLICATE_CI when n/events differ (distinct subgroups, same OR)" python3 -c " import json d=json.load(open('$OUT')) raise SystemExit(0 if not any(c['verdict']=='SUBGROUP_DUPLICATE_CI' for c in d['claims']) else 1) " # (N) nested prediction models all embedding a common covariate set (age + sex) report a # C-index but there is no base-model (age+sex-only) row and no incremental deltaC -> # NESTED_MODEL_NO_BASELINE (Minor). Adding the base-model row must suppress it. NEST="$HERE/fixtures/cohort_nested_models.md" NESTBASE="$HERE/fixtures/cohort_nested_models_base.md" python3 "$SCRIPT" --manuscript "$NEST" --out "$OUT" --quiet >/dev/null 2>&1 check "NESTED_MODEL_NO_BASELINE when nested models share covariates with no base row" has_verdict NESTED_MODEL_NO_BASELINE python3 "$SCRIPT" --manuscript "$NESTBASE" --out "$OUT" --quiet >/dev/null 2>&1 check "no NESTED_MODEL_NO_BASELINE when the base-model (age+sex) row is present" python3 -c " import json d=json.load(open('$OUT')) raise SystemExit(0 if not any(c['verdict']=='NESTED_MODEL_NO_BASELINE' for c in d['claims']) else 1) " echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_confounding_completeness.sh 6.9 KB
#!/usr/bin/env bash # NOTE: `findings` is the DEFECT list (UNADJUSTED_IMBALANCED only); the full per-covariate # audit table — including the ADJUSTED and EXPOSURE_DEFINING_EXEMPT status rows — is # `covariates`. Assertions about whether a covariate was RESOLVED or EXEMPT read # `covariates`; assertions about defects read `findings`. See confounding_findings_challenge. # Regression test for the confounding-completeness gate (self-review Phase 2.5e). # Synthetic, PII-free fixture mirroring the canonical failure pattern: five # measured covariates imbalanced by exposure (uric acid, pack-years, HDL, total # cholesterol, HbA1c) that are absent from an age/sex/BMI/HTN/DM adjustment set. # Stdlib-only (python3); no pandas/statsmodels. set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_confounding_completeness.py" FIXTURE="$HERE/fixtures/table1_by_exposure.csv" OUT="$(mktemp -t cc_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 ran=0 check() { # check "label" expr... local label="$1"; shift ran=$((ran+1)) if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } [[ -f "$FIXTURE" ]] || { echo "ENV-ERR: fixture missing" >&2; exit 2; } # 1. Runs and writes JSON. python3 "$SCRIPT" --table1 "$FIXTURE" \ --adjusted-list "age, sex, BMI, hypertension, diabetes" \ --out "$OUT" --strict >/dev/null 2>&1 rc=$? check "exit 1 under --strict (unadjusted-imbalanced present)" test "$rc" -eq 1 check "JSON artifact written" test -s "$OUT" # 2. Exactly five unadjusted-imbalanced covariates. n=$(python3 -c "import json,sys; print(json.load(open('$OUT'))['n_unadjusted_imbalanced'])" 2>/dev/null) check "n_unadjusted_imbalanced == 5" test "$n" = "5" # 3. Each expected offender flagged; adjusted/balanced ones not. for cov in "Uric acid" "Smoking, pack-years" "HDL" "Total cholesterol" "HbA1c"; do check "flagged: $cov" python3 -c " import json d=json.load(open('$OUT')) assert any('$cov'.lower() in f['covariate'].lower() and f['verdict']=='UNADJUSTED_IMBALANCED' for f in d['findings']) " done for cov in "Age" "BMI"; do check "not flagged: $cov" python3 -c " import json d=json.load(open('$OUT')) assert not any('$cov'.lower() in f['covariate'].lower() and f['verdict']=='UNADJUSTED_IMBALANCED' for f in d['findings']) " done # 4. '<0.001' p-value cell parsed as imbalanced (HDL row uses '<0.001'). check "verdict MAJOR_CANDIDATE" python3 -c " import json; assert json.load(open('$OUT'))['verdict']=='MAJOR_CANDIDATE' " # 5. Clean case: adjusting for everything yields exit 0. python3 "$SCRIPT" --table1 "$FIXTURE" \ --adjusted-list "age, sex, BMI, hypertension, diabetes, uric acid, smoking, HDL, total cholesterol, HbA1c" \ --strict >/dev/null 2>&1 check "exit 0 when all imbalanced covariates adjusted" test "$?" -eq 0 # 6. DB-code Table 1 + prose adjustment list: the alias map must resolve he_sbp ~ # "systolic blood pressure" etc., so adjusted DB-code covariates are NOT # false-flagged; only the genuinely unadjusted ones (alcohol/waist/hemoglobin/ # fasting glucose) remain ✗. DBFIX="$HERE/fixtures/table1_by_exposure_dbcodes.csv" [[ -f "$DBFIX" ]] || { echo "ENV-ERR: db-code fixture missing" >&2; exit 2; } DBOUT="$(mktemp -t cc_db_XXXX).json" trap 'rm -f "$OUT" "$DBOUT"' EXIT python3 "$SCRIPT" --table1 "$DBFIX" \ --adjusted-list "age, sex, body mass index, systolic blood pressure, diastolic blood pressure, total cholesterol, HDL cholesterol, triglycerides, uric acid, HbA1c" \ --out "$DBOUT" --strict >/dev/null 2>&1 check "exit 1 (db-code: genuine unadjusted present)" test "$?" -eq 1 ndb=$(python3 -c "import json; print(json.load(open('$DBOUT'))['n_unadjusted_imbalanced'])" 2>/dev/null) check "db-code: n_unadjusted_imbalanced == 4" test "$ndb" = "4" for cov in he_sbp he_dbp b_chol_t b_chol_hdl b_tg b_uric b_hba1c he_bmi; do check "db-code adjusted (alias resolved): $cov" python3 -c " import json d=json.load(open('$DBOUT')) assert any(f['covariate']=='$cov' and f['in_adjustment_set'] for f in d['covariates']), '$cov not resolved' " done for cov in alc he_wc he_hb he_glu; do check "db-code unadjusted: $cov" python3 -c " import json d=json.load(open('$DBOUT')) assert any(f['covariate']=='$cov' and f['verdict']=='UNADJUSTED_IMBALANCED' for f in d['findings']), '$cov should flag' " done # 7. A3 — wide Table 1 with mean±SD group columns and NO p/SMD column: the gate # computes SMD itself and still runs. With only 'age' adjusted, the imbalanced # metabolic labs flag. MSFIX="$HERE/fixtures/table1_meansd_wide.csv" [[ -f "$MSFIX" ]] || { echo "ENV-ERR: mean±SD fixture missing" >&2; exit 2; } MSOUT="$(mktemp -t cc_ms_XXXX).json" trap 'rm -f "$OUT" "$DBOUT" "$MSOUT"' EXIT python3 "$SCRIPT" --table1 "$MSFIX" --adjusted-list "age" --out "$MSOUT" --strict >/dev/null 2>&1 check "exit 1 (mean±SD: SMD computed, imbalanced present)" test "$?" -eq 1 check "smd_source == computed_from_mean_sd" python3 -c " import json; assert json.load(open('$MSOUT'))['smd_source']=='computed_from_mean_sd'" check "mean±SD: n_unadjusted_imbalanced == 5" python3 -c " import json; assert json.load(open('$MSOUT'))['n_unadjusted_imbalanced']==5" check "age (SMD<0.1) not flagged" python3 -c " import json d=json.load(open('$MSOUT')) assert not any(f['covariate']=='age' for f in d['findings'])" # 8. A4 — exposure-defining covariates (the exposure's diagnostic criteria) are # exempt from the residual-confounding flag; only the non-defining prognostic # covariate (fib-4) remains a Major. python3 "$SCRIPT" --table1 "$MSFIX" --adjusted-list "age" \ --exposure-defining-list "body mass index, systolic blood pressure, HbA1c, HDL cholesterol" \ --out "$MSOUT" --strict >/dev/null 2>&1 check "exit 1 (A4: non-defining fib-4 still flags)" test "$?" -eq 1 check "A4: 4 exposure-defining exempt" python3 -c " import json; assert json.load(open('$MSOUT'))['n_exposure_defining_exempt']==4" check "A4: only 1 unadjusted-imbalanced (fib-4)" python3 -c " import json d=json.load(open('$MSOUT')) assert d['n_unadjusted_imbalanced']==1 assert any(f['covariate']=='fib-4' and f['verdict']=='UNADJUSTED_IMBALANCED' for f in d['findings'])" check "A4: defining covariate not a Major" python3 -c " import json d=json.load(open('$MSOUT')) assert any(f['verdict']=='EXPOSURE_DEFINING_EXEMPT' for f in d['covariates'] if 'body mass' in f['covariate']) assert not any('body mass' in f['covariate'] for f in d['findings'])" # 9. A4 + adjust the non-defining prognostic covariate -> clean (exit 0) python3 "$SCRIPT" --table1 "$MSFIX" --adjusted-list "age, FIB-4" \ --exposure-defining-list "body mass index, systolic blood pressure, HbA1c, HDL cholesterol" \ --strict >/dev/null 2>&1 check "exit 0 when defining exempt + non-defining adjusted" test "$?" -eq 0 echo "ran=$ran fail=$fail" [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_cv_leakage.sh 1.5 KB
#!/usr/bin/env bash # Regression test for the feature-selection-outside-CV leakage gate. # (bad) log-odds feature selection on the full dataset + 5-fold CV, no nesting -> # CV_SELECTION_LEAKAGE; (clean) selection repeated within each training fold (nested # CV) -> no flag. set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_cv_leakage.py" BAD="$HERE/fixtures/cv_leakage_bad.md" CLEAN="$HERE/fixtures/cv_leakage_clean.md" OUT="$(mktemp -t cvl_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } python3 "$SCRIPT" --manuscript "$BAD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 under --strict (Major present)" test "$?" -eq 1 check "CV_SELECTION_LEAKAGE on selection+CV without nesting" python3 -c " import json d=json.load(open('$OUT')) assert any(c['verdict']=='CV_SELECTION_LEAKAGE' for c in d['claims']), 'not flagged' " python3 "$SCRIPT" --manuscript "$CLEAN" --strict --quiet >/dev/null 2>&1 check "exit 0 when selection is nested within each fold" test "$?" -eq 0 python3 "$SCRIPT" --manuscript "$CLEAN" --out "$OUT" --quiet >/dev/null 2>&1 check "no leakage flag on nested CV" python3 -c " import json d=json.load(open('$OUT')) assert not d['claims'], d['claims'] " echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_editorial_impression.sh 2.5 KB
#!/usr/bin/env bash # Regression test for the editorial-impression / defensiveness gate (self-review §L). # Synthetic, PII-free fixtures: (a) an over-defensive manuscript that trips all six # probes (HEDGE_DENSITY, HEDGE_REPEAT, AUDIT_IN_BODY, LIMITATIONS_VOLUME, # ABSTRACT_CAVEAT_LOAD, BURIED_DEFENSE); (b) a confident clean manuscript that trips # none (false-positive guard). The gate is advisory and non-blocking — it must exit 0 # even under --strict, since it emits no Major. # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_editorial_impression.py" DEF="$HERE/fixtures/editorial_defensive.md" CLEAN="$HERE/fixtures/editorial_clean.md" OUT="$(mktemp -t editorial_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) defensive fixture -> all six verdicts fire python3 "$SCRIPT" --manuscript "$DEF" --out "$OUT" --quiet >/dev/null 2>&1 for v in HEDGE_DENSITY HEDGE_REPEAT AUDIT_IN_BODY LIMITATIONS_VOLUME ABSTRACT_CAVEAT_LOAD BURIED_DEFENSE; do check "$v detected (defensive)" has_verdict "$v" done # (2) every claim carries a SUBTRACTION action in {REMOVE, MOVE, TIGHTEN} check "every claim has a REMOVE/MOVE/TIGHTEN action" python3 -c " import json d=json.load(open('$OUT')) assert d['claims'], 'expected findings' assert all(c.get('action') in ('REMOVE','MOVE','TIGHTEN') for c in d['claims']), 'bad action' assert all(c.get('severity')=='Minor' for c in d['claims']), 'all findings must be Minor (advisory)' " # (3) advisory / non-blocking: exit 0 even under --strict on a fully-flagged manuscript python3 "$SCRIPT" --manuscript "$DEF" --strict --quiet >/dev/null 2>&1 check "exit 0 under --strict (non-blocking)" test "$?" -eq 0 # (4) clean fixture -> zero claims (false-positive guard) python3 "$SCRIPT" --manuscript "$CLEAN" --out "$OUT" --quiet >/dev/null 2>&1 check "clean manuscript yields no flags" python3 -c " import json d=json.load(open('$OUT')) assert d['summary']['n_claims']==0, d['claims'] " python3 "$SCRIPT" --manuscript "$CLEAN" --strict --quiet >/dev/null 2>&1 check "exit 0 on clean manuscript" test "$?" -eq 0 echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_emphasis_density.sh 1.3 KB
#!/usr/bin/env bash # Regression test for the inline-emphasis over-use gate (§J / humanize P25). # (overuse) a paragraph with single-word + whole-clause italics -> EMPHASIS_OVERUSE; # (clean) only allowlisted italics (P value, in vitro/in vivo, BRCA1, t) -> no flag. set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_emphasis_density.py" BAD="$HERE/fixtures/emphasis_overuse.md" CLEAN="$HERE/fixtures/emphasis_clean.md" OUT="$(mktemp -t emph_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } python3 "$SCRIPT" --manuscript "$BAD" --out "$OUT" --quiet >/dev/null 2>&1 check "EMPHASIS_OVERUSE on over-italicised prose" python3 -c " import json d=json.load(open('$OUT')) assert any(c['verdict']=='EMPHASIS_OVERUSE' for c in d['claims']), 'not flagged' " python3 "$SCRIPT" --manuscript "$CLEAN" --out "$OUT" --quiet >/dev/null 2>&1 check "no flag when only allowlisted italics (P/in vitro/BRCA1/t)" python3 -c " import json d=json.load(open('$OUT')) assert not d['claims'], d['claims'] " echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_figure_citation.sh 3.3 KB
#!/usr/bin/env bash # Regression test for the orphan figure/table gate (Phase 2.5d). # (orphan) Figure 3 and Table 2 have captions but no in-text citation -> flagged; # (clean) every captioned float is cited in the body -> no flag. set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_figure_citation.py" ORPHAN="$HERE/fixtures/figure_orphan.md" CLEAN="$HERE/fixtures/figure_clean.md" OUT="$(mktemp -t figcite_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi; } has_verdict() { python3 -c " import json d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } python3 "$SCRIPT" --manuscript "$ORPHAN" --out "$OUT" --quiet >/dev/null 2>&1 check "FIGURE_ORPHAN on uncited Figure 3" has_verdict FIGURE_ORPHAN check "TABLE_ORPHAN on uncited Table 2" has_verdict TABLE_ORPHAN check "no orphan for cited Figure 1 / Table 1" python3 -c " import json d=json.load(open('$OUT')) orphans={(c['verdict'], c['detail'].split(' has')[0]) for c in d['claims']} assert ('FIGURE_ORPHAN','Figure 1') not in orphans and ('TABLE_ORPHAN','Table 1') not in orphans " python3 "$SCRIPT" --manuscript "$CLEAN" --out "$OUT" --quiet >/dev/null 2>&1 check "no orphans/embed flags when every float is cited AND embedded" python3 -c " import json d=json.load(open('$OUT')) assert not d['claims'], d['claims'] " # FIGURE_NOT_EMBEDDED: captions present, cited, but no image link anywhere. NE="$HERE/fixtures/figcite_not_embedded.md" python3 "$SCRIPT" --manuscript "$NE" --out "$OUT" --quiet >/dev/null 2>&1 check "FIGURE_NOT_EMBEDDED when no image is embedded" has_verdict FIGURE_NOT_EMBEDDED # advisory (Minor) by default -> exit 0 even under --strict python3 "$SCRIPT" --manuscript "$NE" --strict --quiet >/dev/null 2>&1 check "advisory Minor by default (exit 0 under --strict)" test "$?" -eq 0 # submission context: --require-embedded escalates to Major -> exit 1 under --strict python3 "$SCRIPT" --manuscript "$NE" --require-embedded --strict --quiet >/dev/null 2>&1 check "Major under --require-embedded (exit 1 under --strict)" test "$?" -eq 1 # a manuscript that embeds its figures stays silent EMB="$HERE/fixtures/figcite_embedded.md" python3 "$SCRIPT" --manuscript "$EMB" --require-embedded --strict --quiet >/dev/null 2>&1 check "silent when figures are embedded (even --require-embedded)" test "$?" -eq 0 # FIGURE_ATTR_STALE: Author Contributions attributes "Figure 4" but only 1-3 declared. CB="$HERE/fixtures/figcite_credit_bad.md" python3 "$SCRIPT" --manuscript "$CB" --out "$OUT" --quiet >/dev/null 2>&1 check "FIGURE_ATTR_STALE on a CRediT attribution to a nonexistent figure" has_verdict FIGURE_ATTR_STALE python3 "$SCRIPT" --manuscript "$CB" --strict --quiet >/dev/null 2>&1 check "stale figure attribution is Major (exit 1 under --strict)" test "$?" -eq 1 # canonical CRediT roles (no figure numbers) -> silent CO="$HERE/fixtures/figcite_credit_ok.md" python3 "$SCRIPT" --manuscript "$CO" --strict --quiet >/dev/null 2>&1 check "exit 0 when CRediT uses canonical roles (no figure numbers)" test "$?" -eq 0 echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_figure_citation_labels.sh 4.6 KB
#!/usr/bin/env bash # Regression test: the summary line must name the defect it found, and a document-level fact must be # stated once. # # `check_figure_citation` emits five verdicts, only two of which are orphans. Its summary line # counted by SEVERITY and printed the result as a KIND: # # REVIEW: 3 orphan float(s). # # — on the repository's own `demo/01_wisconsin_bc`, where the orphan count is zero and all three # findings were FIGURE_NOT_EMBEDDED. A reader is told to go looking for uncited floats that do not # exist. # # And FIGURE_NOT_EMBEDDED was emitted once per caption while its condition is document-level # (`not IMG_LINK_RE.search(text)` — true or false for the whole file, never per figure). Three # figures produced three claims of one fact; across the three demo manuscripts, 8 claims for 3 # documents, inflating every count downstream that treats a claim as a finding. # # Evidence for this fix comes from `demo/` only. `_corpus/heldout/` was not read — see CLAUDE.md. set -u REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" D="$REPO_ROOT/skills/self-review/scripts/check_figure_citation.py" WORK="$(mktemp -d -t figcite_test.XXXXXX)" trap 'rm -rf "$WORK"' EXIT pass=0 fail=0 ck() { local label="$1" expected="$2" actual="$3" if [ "$expected" = "$actual" ]; then printf ' PASS %-50s %s\n' "$label" "$actual" pass=$((pass + 1)) else printf ' FAIL %-50s expected=%s actual=%s\n' "$label" "$expected" "$actual" fail=$((fail + 1)) fi } # --- fixtures ------------------------------------------------------------------------------------ # Captioned and cited, but the manuscript embeds no image at all. cat > "$WORK/not_embedded.md" <<'EOF' ## Results Enrolment is shown in Figure 1, survival in Figure 2 and calibration in Figure 3. **Figure 1.** Study flow diagram. **Figure 2.** Kaplan-Meier survival curves. **Figure 3.** Calibration plot. EOF # A genuine orphan: Figure 2 is captioned and never cited. An image IS embedded, so the # not-embedded branch stays silent and the orphan branch is the only thing left. cat > "$WORK/orphan.md" <<'EOF' ## Results Enrolment is shown in Figure 1. **Figure 1.** Study flow diagram.  **Figure 2.** A figure nobody mentions. EOF # Clean: captioned, cited, embedded. cat > "$WORK/clean.md" <<'EOF' ## Results Enrolment is shown in Figure 1. **Figure 1.** Study flow diagram.  EOF summary_line() { python3 "$D" --manuscript "$WORK/$1" 2>/dev/null | tail -1; } jq_field() { # jq_field <fixture> <summary key> python3 "$D" --manuscript "$WORK/$1" --out "$WORK/$1.json" --quiet >/dev/null 2>&1 python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['summary'][sys.argv[2]])" \ "$WORK/$1.json" "$2" } echo "==== the summary line must not report orphans that are not there ====" ck "no 'orphan' in the not-embedded summary" no \ "$(summary_line not_embedded.md | grep -qi orphan && echo yes || echo no)" ck "it names what it did find" yes \ "$(summary_line not_embedded.md | grep -qi 'no image embedded' && echo yes || echo no)" ck "n_orphan is 0" 0 "$(jq_field not_embedded.md n_orphan)" ck "n_not_embedded is 1" 1 "$(jq_field not_embedded.md n_not_embedded)" echo "==== a document-level fact is stated once, and names every figure ====" ck "3 captioned figures -> 1 claim" 1 "$(jq_field not_embedded.md n_claims)" for n in 1 2 3; do ck " claim names Figure $n" yes \ "$(python3 -c " import json,sys d=json.load(open(sys.argv[1])) print('yes' if 'Figure $n' in d['claims'][0]['detail'] else 'no')" "$WORK/not_embedded.md.json")" done echo "==== NEGATIVE CONTROLS — a real orphan is still an orphan ====" ck "orphan fixture: n_orphan is 1" 1 "$(jq_field orphan.md n_orphan)" ck "orphan fixture: summary says 'orphan'" yes \ "$(summary_line orphan.md | grep -qi 'orphan float' && echo yes || echo no)" ck "orphan fixture: not_embedded is 0" 0 "$(jq_field orphan.md n_not_embedded)" ck "clean manuscript: no claims" 0 "$(jq_field clean.md n_claims)" ck "clean manuscript: says OK" yes \ "$(summary_line clean.md | grep -q '^OK:' && echo yes || echo no)" echo "==== the documented exit contract is real: Major IS reachable ====" python3 "$D" --manuscript "$WORK/not_embedded.md" --require-embedded --strict >/dev/null 2>&1 ck "--require-embedded --strict exits 1" 1 "$?" python3 "$D" --manuscript "$WORK/not_embedded.md" --strict >/dev/null 2>&1 ck "without --require-embedded it exits 0" 0 "$?" echo echo " passed=$pass failed=$fail" [ "$fail" -eq 0 ] || exit 1 echo "OK: the summary names the defect it found, and one fact is stated once." -
test_null_calibration.sh 2.5 KB
#!/usr/bin/env bash # Regression test for the null-calibration gate (self-review §C). # Synthetic, PII-free fixtures: (a) a headline "no synergy / not associated" null # in the Abstract + Conclusion with no MDE/power/equivalence/CI-compatibility # statement (CONFIRM_NULL_NO_MDE), (b) the same null but accompanied by a power # calculation + CI-compatibility sentence (suppressed). # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_null_calibration.py" BAD="$HERE/fixtures/null_bad.md" CLEAN="$HERE/fixtures/null_clean.md" OUT="$(mktemp -t nullcal_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) headline null with no precision statement -> CONFIRM_NULL_NO_MDE, exit 1 python3 "$SCRIPT" --manuscript "$BAD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 under --strict (Major present)" test "$?" -eq 1 check "JSON artifact written" test -s "$OUT" check "CONFIRM_NULL_NO_MDE detected" has_verdict CONFIRM_NULL_NO_MDE # (2) same null but with power + CI-compatibility -> suppressed, exit 0 python3 "$SCRIPT" --manuscript "$CLEAN" --out "$OUT" --quiet >/dev/null 2>&1 check "no CONFIRM_NULL_NO_MDE when precision present" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='CONFIRM_NULL_NO_MDE' for c in d['claims']), 'fired despite a precision statement' " python3 "$SCRIPT" --manuscript "$CLEAN" --strict --quiet >/dev/null 2>&1 check "exit 0 on power-aware null" test "$?" -eq 0 # (3) region-blind masking: a power/CI caveat co-located with the Abstract-Results # null does NOT license a bare "equivalence within the bound" in a far # Conclusion -> CONFIRM_NULL_NO_MDE still fires on the uncaveated claim site. MASKED="$HERE/fixtures/null_region_masked.md" python3 "$SCRIPT" --manuscript "$MASKED" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1: masked equivalence in a far Conclusion still flagged" test "$?" -eq 1 check "CONFIRM_NULL_NO_MDE on the uncaveated claim site" has_verdict CONFIRM_NULL_NO_MDE echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_panel_diversity.sh 7.5 KB
#!/usr/bin/env bash # Regression test for the panel lens-diversity gate (self-review Phase 2.6). # Synthetic, PII-free reviewer-output fixtures reproduce: (a) a healthy panel # spanning three distinct axes (clean), (b) a monoculture where every major # falls in one family AND two expected axes are uncovered (UNCOVERED_AXIS + # FAMILY_MONOCULTURE), (c) a fully-redundant reviewer lens (LENS_COLLAPSE). # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_panel_diversity.py" GOOD="$HERE/fixtures/panel_good.json" MONO="$HERE/fixtures/panel_monoculture.json" COLLAPSE="$HERE/fixtures/panel_collapse.json" STATS="$HERE/fixtures/panel_stats_lens.json" ONE="$HERE/fixtures/panel_one_returned.json" ROSTER4="$HERE/fixtures/roster_4.json" ROSTER3="$HERE/fixtures/roster_3.json" MONOSUB="$HERE/fixtures/roster_mono_substrate.json" INDEPSUB="$HERE/fixtures/roster_indep_substrate.json" OUT="$(mktemp -t paneldiv_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } no_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert all(c['verdict']!='$1' for c in d['claims']), '$1 unexpectedly found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) healthy diverse panel (sr_ma) -> exit 0, no Major claims python3 "$SCRIPT" --panel "$GOOD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 (diverse panel)" test "$?" -eq 0 check "no UNCOVERED_AXIS on clean panel" no_verdict UNCOVERED_AXIS check "no FAMILY_MONOCULTURE on clean panel" no_verdict FAMILY_MONOCULTURE # (2) monoculture + uncovered axes -> exit 1 with both Major verdicts python3 "$SCRIPT" --panel "$MONO" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 (monoculture)" test "$?" -eq 1 check "UNCOVERED_AXIS detected" has_verdict UNCOVERED_AXIS check "FAMILY_MONOCULTURE detected" has_verdict FAMILY_MONOCULTURE # (3) fully-redundant reviewer (no research type) -> LENS_COLLAPSE flag, exit 0 python3 "$SCRIPT" --panel "$COLLAPSE" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 (collapse is flag-only)" test "$?" -eq 0 check "LENS_COLLAPSE detected" has_verdict LENS_COLLAPSE check "no UNCOVERED_AXIS without research type" no_verdict UNCOVERED_AXIS # (4) a statistics reviewer using reader-study / agreement vocabulary (kappa, bootstrap, # permutation, odds ratio, ICC) must register as covering the 'statistics' axis, so an # sr_ma panel spanning all three expected axes fires NO UNCOVERED_AXIS. Regression: # before the statistics lexicon was broadened these classified as 'other' and the axis # looked uncovered, producing a false Major. python3 "$SCRIPT" --panel "$STATS" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 (stats reviewer covers the statistics axis)" test "$?" -eq 0 check "no UNCOVERED_AXIS when statistics is covered by reader-study vocabulary" no_verdict UNCOVERED_AXIS # (5) roster of 4 spawned reviewers, only 1 returned -> PANEL_UNDERRETURN (Major), exit 1. # The failure this exists for: reviewers spawn, return nothing, and the thin/empty panel # JSON reads as a completed run because nothing errors. The roster makes the absence visible. python3 "$SCRIPT" --panel "$ONE" --roster "$ROSTER4" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 (1 of 4 rostered reviewers returned)" test "$?" -eq 1 check "PANEL_UNDERRETURN on under-return" has_verdict PANEL_UNDERRETURN # (6) roster == returned set -> no PANEL_UNDERRETURN python3 "$SCRIPT" --panel "$GOOD" --roster "$ROSTER3" --out "$OUT" --quiet >/dev/null 2>&1 check "no PANEL_UNDERRETURN when roster == returned" no_verdict PANEL_UNDERRETURN # (7) roster-gated: without --roster the same 1-reviewer panel does NOT emit PANEL_UNDERRETURN python3 "$SCRIPT" --panel "$ONE" --out "$OUT" --quiet >/dev/null 2>&1 check "no PANEL_UNDERRETURN without a roster (backward compatible)" no_verdict PANEL_UNDERRETURN # (8) every returned reviewer shares the generator's substrate -> SUBSTRATE_MONOCULTURE (Major), # exit 1. A same-model panel inherits the generator's blind spots; it is not an independent # check. The roster ids match GOOD's returned set, so PANEL_UNDERRETURN does not confound. python3 "$SCRIPT" --panel "$GOOD" --roster "$MONOSUB" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 (all reviewers share the generator substrate)" test "$?" -eq 1 check "SUBSTRATE_MONOCULTURE detected" has_verdict SUBSTRATE_MONOCULTURE # (9) at least one different-substrate lens (codex, human) -> no SUBSTRATE_MONOCULTURE, exit 0 python3 "$SCRIPT" --panel "$GOOD" --roster "$INDEPSUB" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 (an independent lens breaks the monoculture)" test "$?" -eq 0 check "no SUBSTRATE_MONOCULTURE with an independent lens" no_verdict SUBSTRATE_MONOCULTURE # (10) substrate-gated: a bare roster (no substrate fields) does NOT emit SUBSTRATE_MONOCULTURE python3 "$SCRIPT" --panel "$GOOD" --roster "$ROSTER3" --out "$OUT" --quiet >/dev/null 2>&1 check "no SUBSTRATE_MONOCULTURE without substrate info (backward compatible)" no_verdict SUBSTRATE_MONOCULTURE # (11) LENS_COLLAPSE must NOT fire on a reviewer declared on a DIFFERENT substrate from the # generator. In COLLAPSE, R3's families are fully subsumed by R1's; under INDEPSUB, R3 is # the non-claude lens — the very one whose presence stops SUBSTRATE_MONOCULTURE (case 9). # Firing here told the editor that the panel's only independent lens "added no independent # axis", i.e. to consider dropping it. Agreement reached from another substrate is # corroboration, not a redundant assignment. Observed live in a Codex-reviewer panel. python3 "$SCRIPT" --panel "$COLLAPSE" --roster "$INDEPSUB" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 0 (cross-substrate reviewer is not a collapsed lens)" test "$?" -eq 0 check "no LENS_COLLAPSE on a cross-substrate reviewer" no_verdict LENS_COLLAPSE check "the exemption is recorded, not silent" python3 -c " import json d=json.load(open('$OUT')) ex=d['summary'].get('lens_collapse_substrate_exempt') assert ex == ['R3'], f'expected R3 recorded as exempt, got {ex!r}' " # (12) ...and the exemption is NOT a blanket disable: the same panel with an all-same-substrate # roster must still raise LENS_COLLAPSE on R3. Without this, case (11) could be passed by # simply deleting the check. python3 "$SCRIPT" --panel "$COLLAPSE" --roster "$MONOSUB" --out "$OUT" --quiet >/dev/null 2>&1 check "LENS_COLLAPSE still fires when the redundant lens shares the generator substrate" \ has_verdict LENS_COLLAPSE check "nothing exempted when every substrate matches the generator" python3 -c " import json d=json.load(open('$OUT')) ex=d['summary'].get('lens_collapse_substrate_exempt') assert ex == [], f'expected no exemptions, got {ex!r}' " # (13) substrate-gated, as everywhere else: with no substrate info the pre-existing behaviour # is unchanged — LENS_COLLAPSE fires exactly as it did before this exemption existed. python3 "$SCRIPT" --panel "$COLLAPSE" --roster "$ROSTER3" --out "$OUT" --quiet >/dev/null 2>&1 check "LENS_COLLAPSE unchanged on a roster without substrate fields" has_verdict LENS_COLLAPSE echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_panel_mode.sh 3.6 KB
#!/usr/bin/env bash # Structural + PII regression tests for self-review --panel mode (Phase 2.6). # Pure file checks — no agent invocation. Run from anywhere. set -uo pipefail REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" SKILL="$REPO_ROOT/skills/self-review/SKILL.md" TEMPLATE="$REPO_ROOT/skills/self-review/references/panel_review_template.md" PROBE_DIR="$REPO_ROOT/skills/self-review/references/domain-probes" fail=0 ran=0 check() { local label="$1"; shift ran=$((ran + 1)) if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label" fail=$((fail + 1)) fi } [[ -f "$SKILL" ]] || { echo "ENV-ERR: SKILL.md missing" >&2; exit 2; } # 1. Panel section + flag present. check "Phase 2.6 panel section present" grep -qE '^### Phase 2\.6: Multi-Agent Panel Review' "$SKILL" check "--panel flag documented in Optional Flags" grep -qE '^- `--panel`:' "$SKILL" check "default stays single-pass (off by default)" grep -qi 'off by default' "$SKILL" check "--fix x --panel guard documented" grep -qi 'Do \*\*not\*\* combine with `--fix`' "$SKILL" # 2. Reviewer-set mapping names all six domain-probe modules. for m in survival_prognostic sr_ma radiomics narrative_review observational_confounding ai_overclaiming; do check "reviewer-set table references $m module" grep -qE "domain-probes/$m\.md" "$SKILL" done # 3. Template exists and carries the schema fields + skeletons. check "panel_review_template.md exists" test -f "$TEMPLATE" # The pointer must be REACHABLE from SKILL.md, not necessarily IN it. The panel phase now lives in # references/phases/phase2_6_panel.md behind a --panel trigger row (whole-file token budget), so the # chain is SKILL.md -> phase reference -> template. Asserting the pointer sits literally in SKILL.md # would force the panel body back into the file the budget is trying to keep small. PANEL_PHASE="$(dirname "$SKILL")/references/phases/phase2_6_panel.md" check "SKILL.md points at the panel phase" grep -qE 'references/phases/phase2_6_panel\.md' "$SKILL" check "template reachable from the panel phase" grep -qE 'references/panel_review_template\.md' "$PANEL_PHASE" check "template has reviewer schema fields" grep -qE '"severity": "Fatal \| Fixable"' "$TEMPLATE" check "template has editor synthesis skeleton" grep -qi 'Editor synthesis prompt skeleton' "$TEMPLATE" # 4. Vendored probe modules each reviewer loads exist. for m in survival_prognostic sr_ma radiomics narrative_review observational_confounding ai_overclaiming; do check "vendored module present: $m.md" test -f "$PROBE_DIR/$m.md" done # 5. PoC-content-leak scan on the PoC-derived template. # The authoritative PII gate for names / mentors / institutions / project # codes is scripts/check_precedent.py (SHA-256 hashed, run repo-wide by # validate_skills.sh). Do NOT duplicate those identifiers in cleartext here — # that would re-introduce the very PII as a public string (self-doxxing, # oss-publication-pii-guard §5). This check is belt-and-suspenders for the # non-identifying content markers of the source PoC manuscript (a personal # path, the study disease term, the cohort N) that would signal the template # was not fully generalized. POSIX-safe (no \b). POC_MARKERS='/Users/|/home/|emphysema|41,?291' ran=$((ran + 1)) if grep -nEi "$POC_MARKERS" "$TEMPLATE"; then printf ' FAIL PoC-content-leak scan (panel_review_template.md)\n' fail=$((fail + 1)) else printf ' PASS PoC-content-leak scan (panel_review_template.md)\n' fi echo "" echo "ran=$ran fail=$fail" [[ $fail -eq 0 ]] -
test_paren_spans.sh 1.7 KB
#!/usr/bin/env bash # Regression test for the parenthesis-span corruption gate (post em-dash reduction). # Synthetic, PII-free fixtures: a manuscript where an em-dash→paren conversion # wrapped an ordinal limitation ("Sixth, …") and a whole sentence inside parens # (paren-balanced but broken), and a clean manuscript with only legitimate short # parentheticals (CIs, citations, "Dr. Smith", "Fig. 2"). Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_paren_spans.py" BAD="$HERE/fixtures/paren_corrupt.md" CLEAN="$HERE/fixtures/paren_clean.md" OUT="$(mktemp -t paren_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) corrupt manuscript -> ordinal + sentence span, exit 1 python3 "$SCRIPT" --manuscript "$BAD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 under --strict (corruption present)" test "$?" -eq 1 check "PAREN_SPAN_ORDINAL detected" has_verdict PAREN_SPAN_ORDINAL check "PAREN_SPAN_SENTENCE detected" has_verdict PAREN_SPAN_SENTENCE # (2) clean manuscript: short legitimate parentheticals only -> exit 0, no false positive python3 "$SCRIPT" --manuscript "$CLEAN" --strict --quiet >/dev/null 2>&1 check "exit 0 on clean manuscript (no false positive on CI/citation/Dr./Fig. parens)" test "$?" -eq 0 echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_qc_severity_vocabulary.sh 4 KB
#!/usr/bin/env bash # Regression test: a gate that says "cannot submit" must not be counted as an optional Minor. # # `_qc_findings._MAJOR` knew two words, `MAJOR` and `FATAL`. The detectors were written # independently and each picked its own vocabulary, so a severity outside those two fell into the # `else` branch and was counted Minor. `check_placeholders` marks a leftover `TODO` as `blocker` and # exits 1 on it unconditionally — and the stop controller read the same artifact and answered: # # STOP_MINOR_OPTIONAL — "No required edits ... do not treat them as blocking and do not loop" # # Membership in the Major set is derived from each detector's own exit contract, so this test asserts # behaviour per word, and — the part that closes the class — asserts that EVERY severity literal # shipped anywhere in skills/*/scripts/*.py is classified deliberately. A newly invented word must # fail this test rather than silently become an optional Minor. set -u REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" R="$REPO_ROOT/skills/self-review/scripts/refinement_stop.py" Q="$REPO_ROOT/skills/self-review/scripts/_qc_findings.py" WORK="$(mktemp -d -t qc_severity_test.XXXXXX)" trap 'rm -rf "$WORK"' EXIT pass=0 fail=0 ck() { local label="$1" expected="$2" actual="$3" if [ "$expected" = "$actual" ]; then printf ' PASS %-50s %s\n' "$label" "$actual" pass=$((pass + 1)) else printf ' FAIL %-50s expected=%s actual=%s\n' "$label" "$expected" "$actual" fail=$((fail + 1)) fi } verdict_for() { # verdict_for <severity> -> the controller's terminal-state word local sev="$1" d="$WORK/qc_$RANDOM" mkdir -p "$d" python3 - "$d/gate.json" "$sev" <<'PY' import json, sys json.dump({"detector": "check_placeholders", "findings": [{"verdict": "PLACEHOLDER_LEFT", "severity": sys.argv[2], "line": 42}]}, open(sys.argv[1], "w"), indent=1) PY python3 "$R" --qc-dir "$d" 2>/dev/null | sed -n '1s/.*: //p' } echo "==== words whose own detector exits 1: the loop must CONTINUE ====" for sev in blocker hard stale FAIL MAJOR FATAL Major major; do ck "severity '$sev' -> CONTINUE" CONTINUE "$(verdict_for "$sev")" done echo "==== NEGATIVE CONTROLS — advisory words must stay optional ====" # check_generated_code computes n_flag = len(claims) - n_major and exits only on n_major, so its own # contract says Flag is advisory. Widening indiscriminately would break this. for sev in Flag Minor minor soft warn INFO; do ck "severity '$sev' -> STOP_MINOR_OPTIONAL" STOP_MINOR_OPTIONAL "$(verdict_for "$sev")" done echo "==== an unrecognised word fails toward the loop CONTINUING, and is named ====" ck "unknown 'urgent' -> CONTINUE" CONTINUE "$(verdict_for urgent)" d="$WORK/qc_unknown"; mkdir -p "$d" python3 - "$d/gate.json" <<'PY' import json, sys json.dump({"detector": "check_x", "findings": [{"verdict": "V", "severity": "urgent", "line": 1}]}, open(sys.argv[1], "w"), indent=1) PY ck " and reported by name" "urgent" \ "$(python3 "$R" --qc-dir "$d" --out "$d/stop.json" >/dev/null 2>&1; \ python3 -c 'import json,sys; print(",".join(json.load(open(sys.argv[1])).get("unknown_severities",[])))' "$d/stop.json" 2>/dev/null || echo MISSING)" echo "==== the class-closing assertion: every shipped severity word is classified ====" unclassified="$(python3 - "$REPO_ROOT" "$Q" <<'PY' import importlib.util, pathlib, re, sys root = pathlib.Path(sys.argv[1]) spec = importlib.util.spec_from_file_location("q", sys.argv[2]) m = importlib.util.module_from_spec(spec) sys.modules["q"] = m spec.loader.exec_module(m) pat = re.compile(r'"severity"\s*:\s*"([A-Za-z_][A-Za-z_ ]*)"') words = set() for p in root.glob("skills/*/scripts/*.py"): words |= set(pat.findall(p.read_text(encoding="utf-8", errors="replace"))) bad = sorted(w for w in words if m._classify(w) == "unknown") print(",".join(bad)) PY )" ck "severity words no controller classifies" "" "$unclassified" echo echo " passed=$pass failed=$fail" [ "$fail" -eq 0 ] || exit 1 echo "OK: a blocker blocks, an advisory stays advisory, and a new word cannot join silently." -
test_reference_adequacy.sh 3.8 KB
#!/usr/bin/env bash # Regression test for the reference-adequacy gate (self-review Phase 2.5c-2 / # write-paper Step 7.3c). Synthetic fixtures reproduce: # 1. an original-research draft whose Statistical Analysis subsection names a # competing-risk model, multiple imputation, the E-value, and an eGFR # equation with ZERO citations (the highest-value failure mode); # 2. the same draft with a citation added in each named-method paragraph; # 3. a letter whose lower reference target must not false-fail. # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_reference_adequacy.py" FIX="$HERE/fixtures" OUT="$(mktemp -t ra_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } # assert a python boolean expression over the JSON at $OUT; `d` is the parsed dict. # (No assertion message: the expression embeds single quotes — the check() label # already names the case on FAIL.) jassert() { python3 -c " import json d=json.load(open('$OUT')) assert ($1) "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing: $SCRIPT" >&2; exit 2; } echo "== Fixture 1: original research, Methods names methods with 0 citations ==" python3 "$SCRIPT" --manuscript "$FIX/refadeq_original_uncited.md" \ --article-type original_article --out "$OUT" --strict >/dev/null 2>&1 check "exit 1 under --strict (Major present)" test "$?" -eq 1 check "JSON artifact written" test -s "$OUT" check "methods_zero_citations is true" jassert "d['methods_zero_citations'] is True" check "uncited_named_methods is non-empty" jassert "len(d['uncited_named_methods']) > 0" check "Fine-Gray flagged uncited" jassert "'Fine-Gray' in d['uncited_named_methods']" check "reference_count_verdict BELOW_TARGET" jassert "d['reference_count_verdict'] == 'BELOW_TARGET'" check "a methods_zero_citations Major finding" jassert "any(f['subtype']=='methods_zero_citations' and f['severity']=='major' for f in d['findings'])" check "all findings fixable_by_ai=false" jassert "all(f['fixable_by_ai'] is False for f in d['findings'])" echo "== Fixture 2: same draft, each named method now cited ==" python3 "$SCRIPT" --manuscript "$FIX/refadeq_original_fixed.md" \ --article-type original_article --out "$OUT" --strict >/dev/null 2>&1 check "exit 0 (no Major; Methods gap cleared)" test "$?" -eq 0 check "methods_zero_citations is false" jassert "d['methods_zero_citations'] is False" check "no methods_named_method_uncited finding" jassert "not any(f['subtype']=='methods_named_method_uncited' for f in d['findings'])" check "adequacy_safe is true" jassert "d['adequacy_safe'] is True" echo "== Fixture 3: letter (lower target must not false-fail) ==" python3 "$SCRIPT" --manuscript "$FIX/refadeq_letter.md" \ --article-type letter --out "$OUT" --strict >/dev/null 2>&1 check "exit 0 (letter target met)" test "$?" -eq 0 check "verdict ADEQUATE for letter" jassert "d['reference_count_verdict'] == 'ADEQUATE'" check "no adequacy findings" jassert "len(d['findings']) == 0" echo "== Alias map: nhis_cohort routes to original_research target ==" python3 "$SCRIPT" --manuscript "$FIX/refadeq_original_uncited.md" \ --article-type nhis_cohort --out "$OUT" --quiet >/dev/null 2>&1 check "nhis_cohort -> original_research bucket" jassert "d['article_bucket'] == 'original_research'" check "original_research target is [25,45]" jassert "d['effective_target'] == [25,45]" echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_reviewer_team_consistency.sh 5.4 KB
#!/usr/bin/env bash # Regression tests for self-review check_reviewer_team_consistency.py. set -uo pipefail REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" SCRIPT="$REPO_ROOT/skills/self-review/scripts/check_reviewer_team_consistency.py" TMP="$(mktemp -d -t reviewer_team.XXXXXX)" trap 'rm -rf "$TMP"' EXIT [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } fail=0 ran=0 assert_exit() { local label="$1" expected="$2" actual="$3" ran=$((ran + 1)) if [[ "$expected" == "$actual" ]]; then printf ' PASS %-50s exit=%s\n' "$label" "$actual" else printf ' FAIL %-50s expected=%s actual=%s\n' "$label" "$expected" "$actual" fail=$((fail + 1)) fi } # -------------------------------------------------------------------------- # Case 1: clean — dual review claimed, no limits confession. # -------------------------------------------------------------------------- cat > "$TMP/c1.md" <<'EOF' ## **METHODS** Two reviewers independently screened titles and abstracts. ## **DISCUSSION** The cohort overlap reduces effective sample size. EOF python3 "$SCRIPT" --manuscript "$TMP/c1.md" \ --out "$TMP/c1.md.audit" --quiet assert_exit "case 1: dual claim, no limits confession" 0 $? # -------------------------------------------------------------------------- # Case 2: fabrication-grade — Methods dual + Limitations single confession. # -------------------------------------------------------------------------- cat > "$TMP/c2.md" <<'EOF' ## **METHODS** Two reviewers independently screened titles and abstracts and extracted data. ## **DISCUSSION** ### Limitations We used a single primary reviewer for data extraction; a 20% sample by an additional reviewer is deferred to before submission. EOF python3 "$SCRIPT" --manuscript "$TMP/c2.md" \ --out "$TMP/c2.md.audit" --quiet assert_exit "case 2: dual + single confession (FAIL)" 1 $? # Verify the markdown report flags both claim sides. grep -q "MAJOR red flag" "$TMP/c2.md.audit" || { echo " FAIL c2 markdown body"; fail=$((fail + 1)); } grep -q "single primary reviewer" "$TMP/c2.md.audit" || { echo " FAIL c2 markdown body"; fail=$((fail + 1)); } # -------------------------------------------------------------------------- # Case 3: PROSPERO dual + Limitations single. Also FAIL. # -------------------------------------------------------------------------- cat > "$TMP/c3.md" <<'EOF' ## **METHODS** Records were screened against pre-specified eligibility criteria. ## **DISCUSSION** ### Limitations A single primary reviewer extracted data due to resource constraints. EOF cat > "$TMP/c3_prospero.md" <<'EOF' # PROSPERO record Two independent reviewers will perform full-text screening and data extraction. EOF python3 "$SCRIPT" --manuscript "$TMP/c3.md" --prospero "$TMP/c3_prospero.md" \ --out "$TMP/c3.md.audit" --quiet assert_exit "case 3: PROSPERO dual + limits single (FAIL)" 1 $? # -------------------------------------------------------------------------- # Case 4: single confession alone (no dual claim) => PASS. # -------------------------------------------------------------------------- cat > "$TMP/c4.md" <<'EOF' ## **METHODS** Data extraction was performed by the first reviewer. ## **DISCUSSION** ### Limitations A single primary reviewer extracted data; this is a limitation of our review. EOF python3 "$SCRIPT" --manuscript "$TMP/c4.md" \ --out "$TMP/c4.md.audit" --quiet assert_exit "case 4: single confession only (PASS)" 0 $? # -------------------------------------------------------------------------- # Case 5: extraction JSON names an LLM as a reviewer => fatal FAIL. # -------------------------------------------------------------------------- cat > "$TMP/c5.md" <<'EOF' ## **METHODS** Records were screened against pre-specified eligibility criteria. EOF cat > "$TMP/c5_extract.json" <<'EOF' {"study": "Example 2024", "reviewer_1": "Claude", "reviewer_2": "Jane Doe"} EOF python3 "$SCRIPT" --manuscript "$TMP/c5.md" --extraction-json "$TMP/c5_extract.json" \ --out "$TMP/c5.md.audit" --quiet assert_exit "case 5: LLM-as-reviewer in extraction JSON (FAIL)" 1 $? grep -q "LLM-AS-REVIEWER" "$TMP/c5.md.audit" || { echo " FAIL c5 markdown body"; fail=$((fail + 1)); } # -------------------------------------------------------------------------- # Case 6: future-tense deferred mitigation => MAJOR FAIL. # -------------------------------------------------------------------------- cat > "$TMP/c6.md" <<'EOF' ## **METHODS** Records were screened by the primary reviewer. ## **DISCUSSION** ### Limitations A duplicate-screening check will be completed before submission. EOF python3 "$SCRIPT" --manuscript "$TMP/c6.md" \ --out "$TMP/c6.md.audit" --quiet assert_exit "case 6: deferred mitigation (FAIL)" 1 $? grep -q "DEFERRED-MITIGATION" "$TMP/c6.md.audit" || { echo " FAIL c6 markdown body"; fail=$((fail + 1)); } # -------------------------------------------------------------------------- # Case 7: clean extraction JSON (human reviewers) => PASS (backward compat). # -------------------------------------------------------------------------- cat > "$TMP/c7_extract.json" <<'EOF' {"study": "Example 2024", "reviewer_1": "Jane Doe", "reviewer_2": "John Roe"} EOF python3 "$SCRIPT" --manuscript "$TMP/c1.md" --extraction-json "$TMP/c7_extract.json" \ --out "$TMP/c7.md.audit" --quiet assert_exit "case 7: human reviewers in extraction JSON (PASS)" 0 $? echo "" echo "ran=$ran fail=$fail" [[ $fail -eq 0 ]] -
test_rhetorical_density.sh 2.8 KB
#!/usr/bin/env bash # Regression test for the rhetorical-construction density gate (P27 / §J — antithesis # parallelism and cleft, the two sentence-structure AI tells a per-instance rule misses). # Synthetic, PII-free fixtures. Two cases carrying the SAME argument, differing only in how # their sentences are built: # (1) rhetorical_dense.md — a run of "rather than" / "not X but Y" / "X, not Y" plus # sentence-initial "What … is …" / "It is … that …" -> ANTITHESIS_DENSITY AND # CLEFT_DENSITY both fire. # (2) rhetorical_clean.md — the same argument written in plain subject-verb order, with # exactly ONE functional "rather than" and ONE cleft used sparingly -> silent. The # single cleft's density even clears the per-1000 line; it stays silent because the # raw count (1) is below the floor (3), which is the AND(floor, density) contract. # Run at shipped defaults so the test exercises the real thresholds. Stdlib-only. set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_rhetorical_density.py" FX="$HERE/fixtures" OUT="$(mktemp -t rhetorical_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } run() { python3 "$SCRIPT" --manuscript "$1" --out "$OUT" --quiet >/dev/null 2>&1; } has_verdict() { python3 -c "import json,sys; d=json.load(open('$OUT')); sys.exit(0 if '$1' in {f['verdict'] for f in d['findings']} else 1)"; } no_findings() { python3 -c "import json,sys; sys.exit(0 if not json.load(open('$OUT'))['findings'] else 1)"; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) antithesis + cleft dense prose -> both verdicts fire run "$FX/rhetorical_dense.md" check "dense fixture fires ANTITHESIS_DENSITY" has_verdict ANTITHESIS_DENSITY check "dense fixture fires CLEFT_DENSITY" has_verdict CLEFT_DENSITY # (2) explanatory prose (same argument, sparse functional use) -> silent run "$FX/rhetorical_clean.md" check "clean fixture does not fire (functional antithesis + one sparing cleft)" no_findings check "clean fixture: one cleft below the count floor stays silent despite crossing density" python3 -c " import json d = json.load(open('$OUT')) assert not d['findings'], d['findings'] # the single functional cleft pushes density over the 2.5 line, yet count (1) < floor (3) assert d['metrics']['cleft_per_1000'] > 2.5, d['metrics'] " # (3) the JSON envelope names its own detector (check_detector_envelopes contract) check "envelope self-identifies the detector" python3 -c " import json assert json.load(open('$OUT'))['detector'] == 'check_rhetorical_density' " echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_rounded_delta.sh 1.5 KB
#!/usr/bin/env bash # Regression test for the rounded-component vs stated-difference gate (Phase 2.5a). # Synthetic fixtures: (bad) components 0.70/0.73 with a stated difference 0.02 (shown # gap is 0.03) -> ROUNDED_DELTA_MISMATCH; (clean) a correct 0.03 delta, a legit # higher-precision unrounded 0.02 delta, and an unrelated decimal pair -> no flag. set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_rounded_delta.py" BAD="$HERE/fixtures/rounded_delta_bad.md" CLEAN="$HERE/fixtures/rounded_delta_clean.md" OUT="$(mktemp -t rdelta_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi; } has_verdict() { python3 -c " import json d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } python3 "$SCRIPT" --manuscript "$BAD" --out "$OUT" --quiet >/dev/null 2>&1 check "ROUNDED_DELTA_MISMATCH on 0.70/0.73 vs stated 0.02" has_verdict ROUNDED_DELTA_MISMATCH check "JSON artifact written" test -s "$OUT" python3 "$SCRIPT" --manuscript "$CLEAN" --out "$OUT" --quiet >/dev/null 2>&1 check "no mismatch on correct/unrounded/unrelated decimals" python3 -c " import json d=json.load(open('$OUT')) assert not d['claims'], d['claims'] " echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_scope_coherence.sh 6.6 KB
#!/usr/bin/env bash # Regression test for the scope-coherence gate (self-review §D). # Synthetic, PII-free fixtures reproduce: (a) a cross-sectional design with a # prognostic/surveillance conclusion (CROSS_SECTIONAL_PROGNOSTIC), (b) a binary # surrogate endpoint driving a care directive (SURROGATE_CARE_DIRECTIVE). The clean # fixture is a longitudinal cohort with an association conclusion. # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_scope_coherence.py" BAD="$HERE/fixtures/scope_bad.md" SURR="$HERE/fixtures/scope_surrogate.md" CLEAN="$HERE/fixtures/scope_clean.md" GRAD="$HERE/fixtures/scope_gradient.md" GRADTEST="$HERE/fixtures/scope_gradient_tested.md" OUT="$(mktemp -t scope_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) cross-sectional + prognostic conclusion -> CROSS_SECTIONAL_PROGNOSTIC, exit 1 python3 "$SCRIPT" --manuscript "$BAD" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 (cross-sectional prognostic)" test "$?" -eq 1 check "CROSS_SECTIONAL_PROGNOSTIC detected" has_verdict CROSS_SECTIONAL_PROGNOSTIC # (2) binary surrogate + care directive -> SURROGATE_CARE_DIRECTIVE, exit 1 python3 "$SCRIPT" --manuscript "$SURR" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 (surrogate care directive)" test "$?" -eq 1 check "SURROGATE_CARE_DIRECTIVE detected" has_verdict SURROGATE_CARE_DIRECTIVE # (3) longitudinal cohort + association conclusion -> exit 0 python3 "$SCRIPT" --manuscript "$CLEAN" --strict --quiet >/dev/null 2>&1 check "exit 0 on clean manuscript" test "$?" -eq 0 # (4) cross-sectional + yield/detection-rate language, undefined -> Minor flag YIELD="$HERE/fixtures/scope_yield.md" YDEF="$HERE/fixtures/scope_yield_defined.md" python3 "$SCRIPT" --manuscript "$YIELD" --out "$OUT" --quiet >/dev/null 2>&1 check "CROSS_SECTIONAL_YIELD_LANGUAGE detected" has_verdict CROSS_SECTIONAL_YIELD_LANGUAGE python3 "$SCRIPT" --manuscript "$YIELD" --strict --quiet >/dev/null 2>&1 check "yield flag is Minor (no Major -> exit 0 under --strict)" test "$?" -eq 0 # (5) yield explicitly defined as cross-sectional prevalence -> suppressed python3 "$SCRIPT" --manuscript "$YDEF" --out "$OUT" --quiet >/dev/null 2>&1 check "yield definition suppresses the flag" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='CROSS_SECTIONAL_YIELD_LANGUAGE' for c in d['claims']) " # (6) cross-sectional design + a DISCLAIMED surveillance/prognostic token # ("... rather than surveillance intervals ... which would require prospective # data ...") -> NO CROSS_SECTIONAL_PROGNOSTIC, exit 0 (regression). DISC="$HERE/fixtures/scope_disclaimer.md" python3 "$SCRIPT" --manuscript "$DISC" --out "$OUT" --quiet >/dev/null 2>&1 check "no CROSS_SECTIONAL_PROGNOSTIC when disclaimed" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='CROSS_SECTIONAL_PROGNOSTIC' for c in d['claims']), 'disclaimer flagged as prognostic claim' " python3 "$SCRIPT" --manuscript "$DISC" --strict --quiet >/dev/null 2>&1 check "exit 0 on disclaimer manuscript" test "$?" -eq 0 # (7) a methods/QC/detector paper whose SUBJECT is this anti-pattern NAMES the # cross-sectional+prognostic pattern rather than committing it -> no fire. METADOC="$HERE/fixtures/scope_metadoc.md" python3 "$SCRIPT" --manuscript "$METADOC" --out "$OUT" --quiet >/dev/null 2>&1 check "no CROSS_SECTIONAL_PROGNOSTIC on a meta-document" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='CROSS_SECTIONAL_PROGNOSTIC' for c in d['claims']), 'meta-document flagged as prognostic overclaim' " python3 "$SCRIPT" --manuscript "$METADOC" --strict --quiet >/dev/null 2>&1 check "exit 0 on meta-document" test "$?" -eq 0 # (8) an enumerated-defect list ("... flags ..., an unsupported prognostic claim # in a cross-sectional study, a fabricated citation, ...") LABELS the anti-pattern # as a defect rather than committing it. META_DOC_FRAME misses it (the framing # verb sits far from the match); ANTIPATTERN_LABEL suppresses it. -> no fire. APLIST="$HERE/fixtures/scope_antipattern_list.md" python3 "$SCRIPT" --manuscript "$APLIST" --out "$OUT" --quiet >/dev/null 2>&1 check "no CROSS_SECTIONAL_PROGNOSTIC on an enumerated-defect list" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='CROSS_SECTIONAL_PROGNOSTIC' for c in d['claims']), 'enumerated-defect label flagged as prognostic overclaim' " python3 "$SCRIPT" --manuscript "$APLIST" --strict --quiet >/dev/null 2>&1 check "exit 0 on enumerated-defect list" test "$?" -eq 0 # UNIVERSAL_NEGATIVE_UNSCOPED: a "no published system … first study to quantify" # claim with no named discipline-scope qualifier. UN="$HERE/fixtures/scope_universal_negative.md" python3 "$SCRIPT" --manuscript "$UN" --out "$OUT" --quiet >/dev/null 2>&1 check "UNIVERSAL_NEGATIVE_UNSCOPED on an unscoped novelty claim" has_verdict UNIVERSAL_NEGATIVE_UNSCOPED # a discipline-scope qualifier ("clinically published … in the radiology literature") suppresses it UNOK="$HERE/fixtures/scope_universal_negative_ok.md" python3 "$SCRIPT" --manuscript "$UNOK" --out "$OUT" --quiet >/dev/null 2>&1 check "no UNIVERSAL_NEGATIVE_UNSCOPED when a discipline-scope qualifier is present" python3 -c " import json d=json.load(open('$OUT')) raise SystemExit(0 if not any(c['verdict']=='UNIVERSAL_NEGATIVE_UNSCOPED' for c in d['claims']) else 1) " # (N) cross-strata directional claim ("shortest in the high-risk tertile", "monotonically # across the age strata") with a stratification context but NO interaction test -> # GRADIENT_WITHOUT_INTERACTION (Minor). The SAME claim with an interaction test reported # (LRT / p-interaction) must NOT fire (the suppression guard). python3 "$SCRIPT" --manuscript "$GRAD" --out "$OUT" --quiet >/dev/null 2>&1 check "GRADIENT_WITHOUT_INTERACTION on gradient-across-strata, no interaction test" has_verdict GRADIENT_WITHOUT_INTERACTION python3 "$SCRIPT" --manuscript "$GRADTEST" --out "$OUT" --quiet >/dev/null 2>&1 check "no GRADIENT_WITHOUT_INTERACTION when the interaction is tested (LRT / p-interaction)" python3 -c " import json d=json.load(open('$OUT')) raise SystemExit(0 if not any(c['verdict']=='GRADIENT_WITHOUT_INTERACTION' for c in d['claims']) else 1) " echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_supplement_hygiene.sh 3.6 KB
#!/usr/bin/env bash # Regression test for the supplement-hygiene gate (self-review §J supplement pass). # Synthetic, PII-free fixtures reproduce reader-facing supplement residue: a §/§L # internal label, an unfilled placeholder (SX / [Authors] / figure-glob / build-dir # path), a build marker ([VERIFY]/TODO), response-to-reviewers framing, and pre- # execution planning residue; plus a body↔supplement cross-reference that does not # resolve. The clean fixture is a normal reader-facing supplement. # Stdlib-only (python3). set -u HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT="$HERE/../scripts/check_supplement_hygiene.py" DIRTY="$HERE/fixtures/supp_dirty.md" CLEAN="$HERE/fixtures/supp_clean.md" XBODY="$HERE/fixtures/supp_xref_body.md" XSUPP="$HERE/fixtures/supp_xref_supp.md" OUT="$(mktemp -t supphyg_XXXX).json" trap 'rm -f "$OUT"' EXIT fail=0 check() { local label="$1"; shift if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label" else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi } has_verdict() { python3 -c " import json,sys d=json.load(open('$OUT')) assert any(c['verdict']=='$1' for c in d['claims']), '$1 not found' "; } [[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; } # (1) dirty supplement: all five residue verdicts, Major present -> exit 1 under --strict python3 "$SCRIPT" --supplement "$DIRTY" --out "$OUT" --strict --quiet >/dev/null 2>&1 check "exit 1 under --strict (Major present)" test "$?" -eq 1 check "JSON artifact written" test -s "$OUT" check "SUPP_INTERNAL_LABEL detected" has_verdict SUPP_INTERNAL_LABEL check "SUPP_PLACEHOLDER detected" has_verdict SUPP_PLACEHOLDER check "SUPP_BUILD_MARKER detected" has_verdict SUPP_BUILD_MARKER check "SUPP_RESPONSE_FRAMING detected" has_verdict SUPP_RESPONSE_FRAMING check "SUPP_PLANNING_RESIDUE detected" has_verdict SUPP_PLANNING_RESIDUE # (2) clean supplement: no residue -> exit 0 python3 "$SCRIPT" --supplement "$CLEAN" --strict --quiet >/dev/null 2>&1 check "exit 0 on clean supplement" test "$?" -eq 0 # (3) body cites Supplementary Table 2 (present) + Figure 9 (absent) -> exactly one # SUPP_XREF_UNRESOLVED for the unresolved callout python3 "$SCRIPT" --supplement "$XSUPP" --manuscript "$XBODY" --out "$OUT" --quiet >/dev/null 2>&1 check "SUPP_XREF_UNRESOLVED detected" has_verdict SUPP_XREF_UNRESOLVED check "only the unresolved callout fires (Table 2 resolves)" python3 -c " import json d=json.load(open('$OUT')) x=[c for c in d['claims'] if c['verdict']=='SUPP_XREF_UNRESOLVED'] assert len(x)==1 and '9' in x[0]['detail'], x " # (4) missing --supplement -> usage error (exit 2) python3 "$SCRIPT" --manuscript "$XBODY" --quiet >/dev/null 2>&1 check "exit 2 when no --supplement given" test "$?" -eq 2 # (5) participant-PII tie: a pseudonym + name tied to an individual response row -> # SUPP_PARTICIPANT_PII_TIE; a byline/roster with only aggregate responses -> clean. PIILEAK="$HERE/fixtures/supplement_pii_tie.md" PIICLEAN="$HERE/fixtures/supplement_pii_clean.md" python3 "$SCRIPT" --supplement "$PIILEAK" --out "$OUT" --quiet >/dev/null 2>&1 check "SUPP_PARTICIPANT_PII_TIE on identity+individual-response row" python3 -c " import json d=json.load(open('$OUT')) assert any(c['verdict']=='SUPP_PARTICIPANT_PII_TIE' for c in d['claims']), 'not flagged' " python3 "$SCRIPT" --supplement "$PIICLEAN" --out "$OUT" --quiet >/dev/null 2>&1 check "no PII tie on a byline/roster with aggregate responses" python3 -c " import json d=json.load(open('$OUT')) assert not any(c['verdict']=='SUPP_PARTICIPANT_PII_TIE' for c in d['claims']), 'roster false positive' " echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail" exit "$fail" -
test_table_percent_denominator.sh 4.2 KB
#!/usr/bin/env bash # Regression test: an INFERRED denominator must earn its use. # # The most common table in clinical research is a Table 1 of independent binary characteristics with # N stated in the CAPTION. It declares no column `n = N` and has no Total row, so the checker fell # through to summing the column's counts — 79 + 53 + 40 + 26 = 198 for a study of 132 — and then # accused all four correct cells of bad arithmetic, each with a specific wrong replacement # percentage, at MAJOR, exit 1 under --strict. A gate that reds a correct submission and tells the # author to change a right number to a wrong one is worse than one that stays quiet. # # Gating on `is_partition` is NOT the fix and this test pins that: that flag is derived from the # printed percentages summing to ~100, so a partition containing a wrong percentage stops looking # like a partition and the check would go silent on the very error it exists to catch. The rule is # instead that an inferred denominator reconciling NOTHING is the wrong denominator, while a # denominator the author DECLARED is never second-guessed. set -u REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)" D="$REPO_ROOT/skills/self-review/scripts/check_table_percentages.py" WORK="$(mktemp -d -t table_percent_test.XXXXXX)" trap 'rm -rf "$WORK"' EXIT pass=0 fail=0 ck() { local label="$1" expected="$2" actual="$3" if [ "$expected" = "$actual" ]; then printf ' PASS %-50s %s\n' "$label" "$actual" pass=$((pass + 1)) else printf ' FAIL %-50s expected=%s actual=%s\n' "$label" "$expected" "$actual" fail=$((fail + 1)) fi } rc_of() { # rc_of <file> -> exit code, no pipe between the command and $? python3 "$D" --manuscript "$WORK/$1" --strict > "$WORK/$1.out" 2>&1 echo $? } majors() { grep -c '^\[MAJOR\]' "$WORK/$1.out" || true; } has_info() { grep -q 'PERCENT_DENOM_UNKNOWN' "$WORK/$1.out" && echo yes || echo no; } # 1. The real-world false positive: caption N, independent binary rows, every percentage correct. cat > "$WORK/caption_n.md" <<'EOF' ## Results **Table 1.** Baseline characteristics of the 132 enrolled patients. | Characteristic | Value | |---|---| | Male sex | 79 (59.8%) | | Hypertension | 53 (40.2%) | | Diabetes | 40 (30.3%) | | Current smoker | 26 (19.7%) | EOF # 2. A genuine partition with one wrong percentage. The count-sum IS the denominator here and # reconciles 2 of 3 rows, so the wrong one must still be caught. cat > "$WORK/partition_bad.md" <<'EOF' ## Results **Table 2.** Tumour stage distribution. | Stage | n (%) | |---|---| | I | 40 (99.0%) | | II | 30 (30.0%) | | III | 30 (30.0%) | EOF # 3. A correct partition: silent. cat > "$WORK/partition_ok.md" <<'EOF' ## Results **Table 3.** Tumour stage distribution. | Stage | n (%) | |---|---| | I | 40 (40.0%) | | II | 30 (30.0%) | | III | 30 (30.0%) | EOF # 4. A DECLARED denominator that the arithmetic contradicts. Never second-guessed. cat > "$WORK/declared_bad.md" <<'EOF' ## Results **Table 4.** Characteristics. | Characteristic | Value (n = 132) | |---|---| | Male sex | 79 (75.0%) | EOF echo "==== the false positive this fixes ====" ck "caption-N Table 1: exit 0" 0 "$(rc_of caption_n.md)" ck "caption-N Table 1: zero MAJOR" 0 "$(majors caption_n.md)" ck "caption-N Table 1: says why (INFO)" yes "$(has_info caption_n.md)" echo "==== NEGATIVE CONTROLS — the check must still bite ====" ck "partition with a wrong %: exit 1" 1 "$(rc_of partition_bad.md)" ck "partition with a wrong %: 1 MAJOR" 1 "$(majors partition_bad.md)" ck "declared n= contradicted: exit 1" 1 "$(rc_of declared_bad.md)" ck "declared n= contradicted: 1 MAJOR" 1 "$(majors declared_bad.md)" ck "declared n= is never downgraded" no "$(has_info declared_bad.md)" echo "==== a correct partition stays silent ====" ck "correct partition: exit 0" 0 "$(rc_of partition_ok.md)" ck "correct partition: no findings at all" 0 "$(majors partition_ok.md)" ck "correct partition: no INFO either" no "$(has_info partition_ok.md)" echo echo " passed=$pass failed=$fail" [ "$fail" -eq 0 ] || { for f in caption_n partition_bad declared_bad partition_ok; do echo "--- $f"; cat "$WORK/$f.md.out"; done; exit 1; } echo "OK: an inferred denominator must reconcile something; a declared one is taken at its word."
-
-
SKILL.md 60.9 KB
--- name: self-review description: Pre-submission self-review for the user's own manuscripts, applying a reviewer perspective. Systematic check across 10 categories with research-type branching. Outputs Anticipated Major/Minor Comments with severity framing and optional R0 numbering for /revise pipeline integration. triggers: self-review, pre-submission check, check my paper, reviewer perspective, manuscript self-check tools: Read, Write, Edit, Bash, Grep, Glob model: inherit --- # Self-Review Skill You are helping a medical researcher check their own manuscript before journal submission. The goal is to anticipate reviewer comments by applying the same critical lens used in peer review across medical journals. This is NOT about writing a review. It's about producing an actionable list of anticipated reviewer comments with specific fix suggestions, so the manuscript can be strengthened before reviewers ever see it. ## Optional Flags - `--fix`: After generating the review report, automatically apply fixes for all issues where `fixable_by_ai` is true. Edits the manuscript in place, then reports a diff summary. Does NOT fix issues marked `fixable_by_ai: false` (e.g., missing data, design flaws). Maximum 2 fix-and-re-review iterations. - `--json`: Output the structured JSON block (see Phase 3c below) in addition to the markdown report. Default when called from `/write-paper` Phase 7. - `--panel`: Run the multi-agent panel review (Phase 2.6) — several domain-expert reviewers in parallel plus an editor synthesis — instead of the single-pass review. Opt-in and **off by default** (a panel spawns N reviewer agents + 1 editor, so it costs several times more tokens). Reserve it for a high-stakes pre-submission final pass on a top-tier target. Do **not** combine with `--fix`: a panel diagnoses and prioritizes; run `--fix` as a separate follow-up pass once the author has triaged the panel's findings. ## Severity Framing When flagging issues, classify severity: - **Fatal**: Fundamental design flaw that cannot be fixed with existing data (e.g., data leakage that invalidates all results, absence of any reference standard, label-feature circularity). The manuscript likely needs redesign. Submission would likely result in Reject. - **Fixable**: Significant but addressable with existing data (e.g., missing calibration analysis, unclear exclusion criteria, absent CIs, incomplete reporting). These are the most actionable findings. Most issues are Fixable. Reserve Fatal for true design-level problems. ## Two Objectives: the Floor and the Ceiling A submission-ready manuscript optimizes **two** things at once, and most of this skill (and the gate stack behind it) only optimizes the first: - **Floor — minimize rejection-for-cause.** Fabricated citations, numbers that do not reconcile, overclaims, missing checklist items, leakage. Categories A–K and the deterministic gates (Phases 2.5–2.5f) do this, and they are right to. Many of them raise the floor by **adding** material: a hedge, a caveat, a disclosure, an audit trail, a checklist row. - **Ceiling — maximize editorial-championing.** Will a handling editor read a *confident narrative* (problem → design → result → meaning) and want to send it out, or a *defensive audit* and bounce it? Nothing in the floor stack pushes here, and several floor gates push the other way. Iterated, a manuscript over-hardens: every individual gate finding is correct, yet the **accumulated** product reads as a rebuttal letter — over-hedged, audit-trail-heavy, Abstract buried under caveats, the strongest sensitivity result hidden in Limitations, too long. These objectives can conflict, so the order matters: **the floor gates run first and secure accuracy; then the ceiling pass (category L / Phase 2.5g) reads the accurate manuscript as a whole and recommends SUBTRACTION — REMOVE, MOVE, or TIGHTEN — so the same content is read confidently.** The ceiling pass is advisory and never blocks; it cannot relax a floor gate. Without it, repeated self-review monotonically over-defends. Surface the ceiling findings as their own first-class output (Phase 3), not folded silently into the "add this" comments. **Phase 2.5i (the loop controller)** then reads the floor + ceiling state to declare when the loop is *done* — including a zero-edit PASS — so an accurate draft is not over-hardened by a pass it does not need. ## Workflow ### Phase 1: Intake 1. Get the manuscript -- PDF, Word doc, or pasted text. 2. Ask the user: - Target journal? (affects reporting standards and scope expectations) - Manuscript type? (original research / review / perspective / technical note / letter / meta-analysis / case report) - Anything they're already worried about? - **Review depth?** The default is a single-pass review. For a high-stakes pre-submission final pass, a multi-agent **panel** (`--panel`, Phase 2.6) is available — several domain-expert reviewers run independently, then an editor consolidates them (more thorough, but it spawns several agents so it costs several times more tokens). On an interactive run, surface this option **once** in one line and offer it; then proceed with the single-pass review unless the user opts in. Do **not** surface or auto-apply the panel when invoked with `--json` or from `/write-paper` — those stay single-pass. 3. Read the full manuscript. 4. **SSOT gate — confirm there is one manuscript, not several.** Self-review reads a single input file, so a divergence between a legacy working copy and the live submission copy is structurally invisible to it. Before a `--panel` run (or any pre-submission pass), check for multiple copies and reconcile first: ```bash find . \( -path '*manuscript*' -o -path '*main_document*' \) -name '*.md' | grep -v node_modules ``` If more than one manuscript-like file exists, confirm which is the SSOT and run `/sync-submission`'s divergence gate before reviewing — a `STALE_COPY` (an SSOT numeric claim or heading that did not propagate to the other copy) is a P0 that must clear first: ```bash python3 "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/sync-submission/scripts/detect_copy_divergence.py" \ --ssot <ssot>.md --copy <other-copy>.md ``` Review the SSOT copy; do not review a stale copy and pass it. **In `--panel` mode this is a blocking precondition, not advice.** A panel spawns N reviewer agents + an editor, so reviewing a stale copy wastes the whole pass (a prior panel's top finding was literally "you reviewed the wrong file"). If the `find` above returns **more than one** manuscript-like `.md` and the SSOT is not pinned — no `SSOT.yaml` with `truth.manuscript_md` and no explicit `--ssot <path>` argument — **STOP before spawning any reviewer** and have the user name the SSOT (and clear any `STALE_COPY`). Do not auto-pick the longest/newest file. The single-pass review may proceed on the one file it was given, but the panel must not. ### Phase 2: Systematic Check Run the manuscript through each applicable category below. For each item, assess whether a reviewer would raise it as a Major or Minor comment. Use the Research-Type Adaptation table (below) to determine which categories apply fully, partially, or not at all. **The categories (A–L).** The per-item check tables — what to look for under each — live in `references/phases/phase2_systematic_check.md`; read it once you have the manuscript and know its type, and work the categories the adaptation table marks as applicable. | | Category | What it asks | |---|---|---| | **A** | Study Design & Data Integrity | patient-level splits, leakage, input-text contamination, analysis unit | | **B** | Reference Standard & Ground Truth | definition specificity, timing, annotator independence | | **C** | Validation & Statistical Reporting | CIs, **calibration**, comparator, effect size, power-aware nulls, equivalence margins, interaction anchoring | | **D** | Clinical Framing & Importance | intended use, overclaiming, novelty, **endpoint↔conclusion scope** | | **E** | Reproducibility | preprocessing, model detail, hardware/software, data & code availability | | **F** | Reporting Completeness | abstract↔body consistency, flow diagram, ethics, missing data, word cap | | **G** | Reporting Guideline Compliance | match the type to its checklist; `/check-reporting` does the item-level audit | | **H** | Circularity | label–feature overlap, tautological prediction, circular validation | | **I** | Protocol Heterogeneity | multi-site acquisition, harmonization, temporal protocol drift | | **J** | Method Transparency | model provenance, fine-tuning, classical-style body conventions | | **K** | Reviewer-team consistency | *SR/MA only* — dual-vs-single conjunction, LLM-as-reviewer (both fabrication-grade) | | **L** | Editorial impression & defensiveness | *advisory, never blocking* — the ceiling category: REMOVE / MOVE / TIGHTEN | **Run the deterministic gates.** These are greps and counts, so they belong in a gate rather than in eyeballing. Run them at Phase 2 entry, on every path: ```bash # D. endpoint↔conclusion scope python3 "${CLAUDE_SKILL_DIR}/scripts/check_scope_coherence.py" \ --manuscript manuscript.md --out qc/scope_coherence.json --strict # J. classical-style body conventions python3 "${CLAUDE_SKILL_DIR}/scripts/check_classical_style.py" \ --manuscript manuscript.md --out qc/classical_style.json --strict # K. reviewer-team consistency (SR/MA only; pass the extraction JSON file or directory) python "${CLAUDE_SKILL_DIR}/scripts/check_reviewer_team_consistency.py" \ --manuscript manuscript.md --prospero prospero/record.md \ --extraction-json extraction/ --out _audit_self/reviewer_team_consistency.md # L. editorial impression (advisory; exits 0 even under --strict) python3 "${CLAUDE_SKILL_DIR}/scripts/check_editorial_impression.py" \ --manuscript manuscript.md --out qc/editorial_impression.json # J/D. Perspective structure (genre-gated: silent unless article_type is a Perspective). # Pass the known type via --type; it also self-detects from the front-matter article_type. python3 "${CLAUDE_SKILL_DIR}/scripts/check_perspective_structure.py" \ --manuscript manuscript.md --type "${TYPE:-}" --out qc/perspective_structure.json ``` Verdict mapping: `CROSS_SECTIONAL_PROGNOSTIC`, `SURROGATE_CARE_DIRECTIVE`, `SECTION_SYMBOL`, `INBODY_AI_DISCLOSURE`, and any reviewer-team hit (exit 1) are Anticipated **Major** Comments. `CROSS_SECTIONAL_YIELD_LANGUAGE`, `ELIGIBILITY_PROSE`, `DECIMAL_INCONSISTENCY`, `EM_DASH_OVERUSE`, `PERSPECTIVE_HEADING_NOT_ASSERTION`, `PERSPECTIVE_ABSTRACT_NO_AUTHORIAL_MOVE`, and every `check_editorial_impression` verdict are **Minor**. The per-verdict rationale and the resolution paths are in the reference file. **Read on demand:** **Then check that every analysis you report was ever defined.** The detectors in this skill ask whether a number is *correct*. None asks whether the analysis that produced it was *defined* — and that is the gap a reviewer walks straight into: > "The outcome (dependent variable) for the multivariable Cox model is not specified." … "The ground truth (reference standard) against which discrimination and calibration were assessed is not defined." … "This section is largely incomprehensible in its current form." ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_analysis_definitions.py" \ --manuscript manuscript.md --out qc/analysis_definitions.json --strict ``` `MODEL_OUTCOME_UNDEFINED` (a Cox / Fine–Gray / logistic model with no outcome named), `MODEL_NOT_IN_METHODS`, and `REFERENCE_STANDARD_UNDEFINED` (discrimination or calibration with nothing to score against) are Anticipated **Major** Comments. `TIER_LABEL_UNDEFINED` is Minor. `ANALYSIS_LOAD` is **informational and never a verdict.** The reviewer who wrote *"too many analyses have been performed and reported"* also named the mechanism — *"this appears to have contributed to omissions of critical information in the Materials and Methods section"* — while a second reviewer of the same manuscript listed its sensitivity analyses as a **strength**. **Load is the cause, not the crime.** Do not cut analyses to satisfy this gate; restore the definitions the analyses crowded out. If load is genuinely high, move the defensive analyses to the supplement — same defence, far less reader burden and far less attack surface. | File | Read it when | Cost if read blindly | |---|---|---| | `references/phases/phase2_systematic_check.md` | you are working the A–L manual pass and know the manuscript type | ~5,600 tokens — and a run that halts at Phase 1, or a panel-mode review, never reaches it | ### Research-Type Adaptation Not all categories apply equally to every study type. Use this routing table: | Category | AI/ML | Observational | Educational | Meta-Analysis | Case Report | Surgical | |----------|:-----:|:------------:|:-----------:|:------------:|:-----------:|:--------:| | A. Study Design | Full | Full | Partial | N/A | N/A | Full | | B. Reference Standard | Full | Full | N/A | Per-study | Partial | Full | | C. Validation & Stats | Full | Full | Full | Special* | Partial | Full | | D. Clinical Framing | Full | Full | Full | Full | Full | Full | | E. Reproducibility | Full | Partial | Partial | Partial | N/A | Full | | F. Reporting | Full | Full | Full | Full | Full | Full | | G. Guideline Compliance | Full | Full | Full | Full | Full | Full | | H. Circularity | Full | Partial | N/A | N/A | N/A | Partial | | I. Protocol Heterogeneity | Full | Full | N/A | Per-study | N/A | Full | | J. Method Transparency | Full | Partial | Partial | N/A | N/A | Partial | | K. Reviewer-team consistency | N/A | N/A | N/A | Full | N/A | N/A | | L. Editorial impression | Full | Full | Full | Full | Full | Full | *Meta-analysis: Replace C with heterogeneity assessment (I-squared, prediction intervals), publication bias (funnel plot, Egger), and sensitivity/subgroup analyses. **Type-Specific Additional Checks:** - **Observational studies**: Confounding assessment (DAG or adjustment strategy), selection bias, exposure measurement validity. Run **Phase 2.5e (Confounding Completeness)**, then apply the O-probes in `references/domain-probes/observational_confounding.md` — the two deterministic ones are O1 (a covariate imbalanced by exposure in Table 1 yet absent from the adjustment set) and O8 (records > subjects with the analysis unit undisclosed; `check_cohort_arithmetic.py --id-col`), and O7 is their opposite-direction twin (adjusting for a consequence/mediator of the outcome). If the manuscript develops or compares a **clinical prediction model** (TRIPOD / TRIPOD+AI, nested predictor-set comparison), also apply the CP-probes in `references/domain-probes/clinical_prediction_model.md`. The module is the single source for the probe list and its numbering; do not re-enumerate it here. - **Educational studies**: Learning outcome measurement validity, Kirkpatrick level, control group adequacy, curriculum fidelity - **Meta-analyses**: Search comprehensiveness (2+ databases), screening reproducibility (2 reviewers), RoB assessment per study, GRADE certainty - **Case reports**: Diagnostic reasoning transparency, timeline completeness, informed consent, generalizability disclaimer - **Surgical studies**: Learning curve consideration, surgeon volume/experience, complication grading (Clavien-Dindo), operative detail completeness **Domain probe modules (load when the manuscript type matches):** These modules carry the same domain-specific critique probes used by `/peer-review`, vendored here so self-review reaches the same depth (in particular, survival/time-to-event manuscripts now get a dedicated probe set that the routing table above does not otherwise cover). | Manuscript type / signal | Probe module | |---|---| | Systematic Review / Meta-Analysis | `references/domain-probes/sr_ma.md` (P0–P19) | | Time-to-event / survival / prognostic model (Cox, Fine-Gray, DeepSurv, nomogram, risk-stratification cutoff) | `references/domain-probes/survival_prognostic.md` (S1–S9) | | Radiomic feature reproducibility / acquisition-parameter sweep / reliability-based feature filtering | `references/domain-probes/radiomics.md` (R1–R4) | | Cross-modality image synthesis (MRI→PET / MRI→CT / non-contrast→contrast / low-dose→full-dose) claiming functional/molecular information or target-modality substitution | `references/domain-probes/image_synthesis.md` (IS1–IS4) | | Narrative / review article / primer / state-of-the-art | `references/domain-probes/narrative_review.md` (RV1–RV9) | | Perspective / opinion / viewpoint (argumentative essay — npj DM long-essay, Lancet Comment, NEJM AI / RYAI short-structured) | `references/domain-probes/narrative_review.md` (RV1–RV9) + the deterministic `check_perspective_structure.py` gate above (IMRAD-heading + abstract authorial-move tells) | | AI/ML primary study with a clinical claim (generalizable / outperforms clinicians / deployment-ready / can replace a reader) | `references/domain-probes/ai_overclaiming.md` (AO0–AO7) | | Engineer-built medical-imaging model (segmentation / classification / detection; CNN / U-Net / nnU-Net / transformer) being validated — partition/leakage, seed & run variance, metric selection, reproducibility, reference-standard quality; plus saliency-map faithfulness, uncertainty/OOD/abstention, and deployment feasibility when a clinical-use claim is made | `references/domain-probes/model_development.md` (MD0–MD11) | | LLM / MLLM evaluated on a clinical task (radiology report generation, visual question answering, clinical text extraction/classification; closed API or open weights) | `references/domain-probes/mllm_evaluation.md` (ME0–ME8) | | Randomised controlled trial (parallel / crossover / cluster / stepped-wedge) | `references/domain-probes/rct_trial.md` (RC0–RC7) | | Diagnostic test accuracy (DTA) primary study / multi-reader multi-case (MRMC) reader study (index test vs reference standard, AI-vs-reader, AI-assisted reading, modality comparison) | `references/domain-probes/diagnostic_accuracy.md` (D1–D12) | | Case report / case series / single-patient clinical narrative (incl. adverse-event/pharmacovigilance and imaging-led radiology/nuclear-medicine/IR reports) | `references/domain-probes/case_report.md` (CR1–CR9) | | AI/ML, prediction, or diagnostic study claiming cross-population performance (generalizable / deployment-ready / "works for patients"), or presenting subgroup analyses as a fairness/equity argument | `references/domain-probes/equity_fairness.md` (EQ0–EQ6) | | Mendelian randomization (genetic variants as instrumental variables: two-sample summary-data, one-sample, multivariable MR, drug-target / cis-MR, non-linear MR) | `references/domain-probes/mendelian_randomization.md` (MR1–MR8) | | Polygenic risk score / polygenic score (PRS / PGS) developed, validated, or applied as a predictor or risk-stratifier | `references/domain-probes/polygenic_risk_score.md` (PG1–PG8) | | Network meta-analysis (≥3 interventions via direct + indirect evidence, treatment ranking, incl. component NMA) | `references/domain-probes/network_meta_analysis.md` (NM1–NM8) | | Health economic evaluation (cost-effectiveness / cost-utility / cost-benefit / budget-impact; trial-based or decision-model-based — decision tree, Markov, DES) | `references/domain-probes/health_economic_evaluation.md` (HE1–HE8) | | Observational study using routinely-collected health data (administrative claims / EHR / disease or population registry / health-checkup DB, linked or not) | `references/domain-probes/record_routinely_collected_data.md` (RD1–RD8) | | Self-report survey / questionnaire study (KAP, physician/patient survey, cross-sectional questionnaire, web/e-survey) | `references/domain-probes/survey_research.md` (SV1–SV8) | | Scoping review (maps the breadth/nature of evidence, clarifies concepts, identifies gaps; PCC framing, charting, optional appraisal — not a focused effectiveness/accuracy question) | `references/domain-probes/scoping_review.md` (SC1–SC8) | | Qualitative study (interviews, focus groups, ethnography, grounded theory, phenomenology, document analysis; reflexivity, trustworthiness, thematic analysis — not quantitative validity) | `references/domain-probes/qualitative_research.md` (QL1–QL8) | | **Self-improving / self-evaluating system** (an agent that critiques and rewrites its own output; training on model-generated data; an LLM used as the judge that scores the training signal; "self-evolving" clinical agents) | `references/domain-probes/self_improving_system.md` (SI1–SI7) + `skills/peer-review/scripts/check_self_improvement_claims.py` | For a **classifier / NLP / tabular ML** manuscript, also run the deterministic feature-selection-leakage gate — a data-driven selection (feature selection, log-odds / univariate filtering, vocabulary construction, a threshold) fit on the FULL dataset before cross-validation inflates the CV metric: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_cv_leakage.py" \ --manuscript manuscript.md --out qc/cv_leakage.json ``` `CV_SELECTION_LEAKAGE` (Major) fires when a selection token co-occurs with cross-validation and no fold-nesting is disclosed ("within each fold" / "nested CV" suppresses it). This is distinct from patient-vs-image split leakage (`model-validation/check_split_leakage.py`). When the manuscript matches a row, read `${CLAUDE_SKILL_DIR}/references/domain-probes/<module>.md` and apply each probe as an additional source of Anticipated Major / Minor Comments. The module severity words (MAJOR / MINOR) map to this skill's framing as follows: a conclusion-threatening or design-level finding becomes a **Fatal** Anticipated Major Comment, a reporting-level finding becomes a **Fixable** Anticipated Minor Comment, and each is tagged with the closest category letter (A–K). These probes **complement** categories A–K above; they do not replace them. (The modules are vendored byte-identical from `/peer-review`; do not edit one copy only — run `python3 scripts/check_domain_probe_sync.py --sync`.) ### Phase 2.5: Numerical Cross-Verification (Internal) Before generating the report, verify internal consistency: 1. **Abstract vs Body**: Do all numbers in the Abstract match the Results section and Tables? 2. **Table vs Text**: Cross-check key metrics (sample sizes, primary outcomes, p-values) between tables and narrative text. 3. **Figure vs Text**: Do figure legends match the data described in Results? 4. **Percentage arithmetic**: Verify that n/N percentages are calculated correctly (e.g., 23/150 = 15.3%, not 15.0%). 5. **CI plausibility**: Do confidence intervals seem reasonable given sample sizes? 6. **Rate back-calculation**: every reported rate must invert to its own numerator/denominator — an incidence rate ≈ events / person-years × scale (±rounding). A rate that does not recompute from the stated events and person-time (or that implies more events than the cohort can supply) is a Major, not a Minor. 7. **Exclusion-cascade and complete-case arithmetic** (cohort/observational): the STROBE flow must balance — start N − Σ(exclusions) == final analytic N — and any complete-case statement must balance — total − missing == complete. A footnote N that does not equal the subtraction is a Major. For cohort/observational manuscripts, run the deterministic gate instead of eyeballing it (it parses prose equations + GFM tables, and recomputes from a committed CSV when given one): ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_cohort_arithmetic.py" \ --manuscript manuscript.md --data analysis/cohort.csv --id-col mockid \ --out qc/cohort_arithmetic.json --strict ``` `RATE_BACKCALC` / `CASCADE_SUM` / `PARTITION_OVERLAP` rows are Anticipated Major Comments (category: A. Study Design & Data Integrity); the partition check is the Phase 2.5b cohort branch below. Pass `--id-col` (or let it auto-detect a subject-ID column) on health-screening / EMR / registry data so the gate also runs the **analysis-unit** check: when `records > unique subjects` and the manuscript states neither the analysis unit nor a one-record-per-subject sensitivity, it emits `ANALYSIS_UNIT_UNDISCLOSED` (Major — non-independent observations give anti-conservative CIs; probe O8). Flag any remaining internal-consistency discrepancies as Anticipated Minor Comments (category: F. Reporting Completeness). **Then recompute the three things a reviewer recomputes by hand.** These are the arithmetic checks a careful reviewer does with a calculator on the train home, and the ones that end a submission when they fail: ```bash # Every "n (%)" in a table, recomputed against its own denominator. python3 "${CLAUDE_SKILL_DIR}/scripts/check_table_percentages.py" \ --manuscript manuscript.md --out qc/table_percentages.json --strict # Every reported P beside a 2×2 (or r×c) count, recomputed from the counts themselves. python3 "${CLAUDE_SKILL_DIR}/scripts/check_reported_p_from_counts.py" \ --manuscript manuscript.md --out qc/reported_p.json --strict # Diagnostic-accuracy only: sensitivity/specificity against the reference-standard denominators. python3 "${CLAUDE_SKILL_DIR}/scripts/check_dta_denominators.py" \ --manuscript manuscript.md --out qc/dta_denominators.json --strict ``` `PCT_MISMATCH`, `P_MISMATCH` / `P_IMPOSSIBLE`, and `DENOM_MISMATCH` are **P0 Major** — a percentage that does not follow from its own denominator, or a P value that does not follow from its own counts, is not a rounding disagreement. It means one of the two numbers is wrong, and the reviewer who checks will find it. Run the first two on **every** manuscript with a table; the third only on diagnostic-accuracy work. ### Phase 2.5a: Numerical Source-Fidelity Audit (External) Internal consistency (Phase 2.5) is necessary but not sufficient. Numbers can be fully self-consistent across Abstract / Table / Text and still be wrong **at the source** — a single transcription error propagates cleanly through every downstream stage, and every internal check then confirms it. Only a traversal back to the primary source catches it. Run the **displayed-arithmetic** gate first — a stated difference must equal the subtraction of its two displayed component values at the *same* precision: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_rounded_delta.py" \ --manuscript manuscript.md --out qc/rounded_delta.json ``` `ROUNDED_DELTA_MISMATCH` (Minor) fires when AUCs shown as `0.70` and `0.73` (a displayed gap of 0.03) are reported with a between-arm difference of `0.02` — self-consistent only on the unrounded values. A higher-precision component pair (`0.703` vs `0.726`) with a 2-dp delta is the legitimate unrounded case and is not flagged. **When to run the external audit:** MA revisions, submissions, or any review where the user says "check against the source", "verify extraction", or "random sample". Skip otherwise. **The audit, in one line:** draw a stratified sample of 5 numerical claims — always including one comparative-arm value and one revision-introduced number, the two highest-yield strata — and trace each through three layers (manuscript → extraction CSV → primary-source page; plus analysis script → CSV where a script produced it). **Any mismatch is a Major Comment**, and one that reverses a direction or crosses a significance boundary is a P0 blocker. Every `[VERIFY-CSV]` tag is a mandatory audit item regardless of sample size. The traversal procedure, the recording table, the sampling strata, and the four prose-judgement rules it also applies — hand-entered analysis-script inputs, prose↔table **statistic-type** mismatches (a median in the text against a mean in Table 1), stale derived CSVs after a model/adjustment-set change (the analytic `n` is the fastest tell, and the conflict can flip significance), and the precedent direction-reversal that internal consistency could not see — are in the reference file. **Read on demand:** | File | Read it when | Cost if read blindly | |---|---|---| | `references/phases/phase2_5a_source_fidelity.md` | you are running the external audit — tracing sampled claims back to primary sources | ~2,500 tokens; a first-draft review with no extraction CSV and no primary sources cannot use any of it | ### Phase 2.5a-2: Design & Power Statistic Provenance A design or power statistic is **computed**, not copied from a source, so the source-fidelity audit of Phase 2.5a cannot check it — it has to be re-derived from the manuscript's own inputs. This applies only when the manuscript states a sample-size calculation, a power figure, or a detectable-effect claim. **Read on demand:** | File | Read it when | Cost if read blindly | |---|---|---| | `references/phases/phase2_5a2_design_power.md` | the manuscript reports a sample-size / power / MDE calculation | ~1,050 tokens; a manuscript with no power statement needs none of it | **Load-on-demand**: read `${CLAUDE_SKILL_DIR}/references/phases/phase2_5a2_design_power.md` when the manuscript reports a sample-size / power / MDE calculation. ### Phase 2.5b: Screening-Count Reconciliation from ID Sets (SR/MA + observational tier/stratum) Internal consistency across Abstract/Methods/Results (Phase 2.5) and source fidelity of 2×2 and effect-size numbers (Phase 2.5a) do **not** cover study-count arithmetic. That is a separate failure mode: a prior-draft prose total ("30 → 32 after FLAG consensus") survives every downstream pass because Abstract, Methods, Results, Discussion, the Figure 1 caption, and even the supplementary consensus file all cite the same wrong number back to each other. The only thing that catches it is a recount from the **ID sets**. **When to run:** any SR/MA manuscript revision, regardless of stage (run before Phase 3); or any observational manuscript presenting an ordinal tier / mutually-exclusive stratum split. Skip otherwise. **A. SR/MA — recount from the ID sets.** Derive every study count from the screening TSV and the consensus sheet rather than from prose, and **list the narrative-only IDs explicitly** — the highest-yield cross-check, the one that turns "10 narrative-only studies" into "2 (IDs 120, 474)". Any derived total that disagrees with Abstract, Methods, Results, the Figure 1 caption or Limitations is a **P0 Major, blocking submission**, and any `N → M` transition claim not backed by an enumerable ID addition/subtraction set is itself a **Major** — it is unverifiable by downstream audit. The set definitions, the derivation formulas and the reconciliation-block template are in the reference file. **B. Observational tier/stratum — the same set logic, as arithmetic.** A partition claimed to be disjoint must satisfy `Σ(stratum N) == unique total` and `Σ(stratum events) == total events`. Denominators summing *above* the unique cohort double-count subjects; a table where every stratum n equals the grand total is a mis-entry, not a partition. Confirm the reference (baseline) row of any stratified hazard/odds table is present and labelled — without it the other strata are uninterpretable. ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_cohort_arithmetic.py" \ --manuscript manuscript.md --data analysis/strata.csv --strict ``` **C. Cross-script cut-point consistency — the root cause of stratum-N drift.** When the same cohort is re-stratified in more than one analysis script, the derived categorical must use one identical cut definition (same breaks, same `right=` closure, same labels). Two scripts binning one variable differently drift the per-stratum Ns while the grand total still reconciles — so a manuscript-only check cannot localize it. The same gate covers the composite-indicator sibling (a derived 0/1 criterion rebuilt in a second script with a clause dropped). ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_binning_consistency.py" \ --root analysis --root scripts --strict ``` `PARTITION_OVERLAP`, `BINNING_DRIFT`, and `DERIVED_DEF_DRIFT` are all **P0 Major**. **Read on demand:** | File | Read it when | Cost if read blindly | |---|---|---| | `references/phases/phase2_5b_screening_counts.md` | this is an SR/MA (ID-set recount) or a stratified cohort, and you are doing the recount | ~3,300 tokens — nothing in it applies to a single-cohort manuscript with no strata | ### Phase 2.5c: Reference Scans (hallucination + adequacy) Two scans run on the bibliography: **2.5c** catches a citation that does not exist or whose first author is invented, and **2.5c-2** catches a claim that carries no citation at all. Both need a bibliography — a draft with no `refs.bib` and no reference list skips them entirely. Run `/verify-refs --strict` first; these scans read its audit rather than re-deriving it, then run the adequacy checker: ```bash python3 "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/self-review/scripts/check_reference_adequacy.py" \ --manuscript manuscript/manuscript.md --bib "$BIB" \ --article-type "$TYPE" ${CAP:+--journal-cap "$CAP"} \ --out qc/reference_adequacy.json --strict ``` **Read on demand:** | File | Read it when | Cost if read blindly | |---|---|---| | `references/phases/phase2_5c_reference_scans.md` | the manuscript has a bibliography and you are auditing citations | ~1,950 tokens; a draft with no reference list cannot use any of it | **Load-on-demand**: read `${CLAUDE_SKILL_DIR}/references/phases/phase2_5c_reference_scans.md` when the manuscript has a bibliography and you are auditing citations. ### Phase 2.5d: Cross-Reference QC (Manuscript ↔ rendered DOCX) Reference-list integrity (Phase 2.5c) does **not** cover Table/Figure cross-references. That is a separate failure mode: an in-text citation ("Supplementary Table S4 reports a sensitivity analysis") resolves to a *different* caption in the rendered DOCX ("Supp Table S4 = a diagnostics table") because the build script carries its own legacy SSOT. Internal consistency (Phase 2.5) cannot see it — the prose and the build artifact each echo their own divergent truth cleanly. **Markdown stage (always).** Every captioned `Figure N.` / `Table N.` must be cited at least once elsewhere in the body: ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_figure_citation.py" \ --manuscript manuscript.md --out qc/figure_citation.json ``` `FIGURE_ORPHAN` / `TABLE_ORPHAN` (Minor) catch a newly-added float that has a legend but no in-text citation — the early, no-build counterpart to `check_xref`'s `UNCITED`. **DOCX stage (when a rendered DOCX exists** — circulation drafts, post-build pre-submission checks. Skip on early drafts with no build): ```bash python3 "${MEDSCI_SKILLS_ROOT:-$HOME/workspace/medsci-skills}/skills/manage-refs/scripts/check_xref.py" \ --md manuscript/manuscript.md --docx manuscript/manuscript_final.docx \ --out qc/xref_audit.json [--allow-separate-attachments] ``` Severity depends on the journal's figure/table submission policy. Many radiology and medical journals (European Radiology, Radiology, AJR) accept figures and tables as **separate attachments** rather than inline — pass `--allow-separate-attachments` there so a legitimate attachment style is not read as a blocker. | Status | Default policy | With `--allow-separate-attachments` | |---|---|---| | `MISSING_DOCX` | **Major (P0)** — cited Table/Figure absent from rendered output | **Minor** — separately attached per journal policy | | `MISSING_BODY` | **Major (P0)** — no body caption definition | **Major (P0)** when the float IS in the rendered DOCX (SSOT drift). **Minor** when no `--docx` was supplied — excused without evidence, and reported as such | | `MISMATCH` | **Major (P0)** — caption text disagrees between body and rendered DOCX | **Major (P0)** (no change) | | `UNCITED` | Minor — orphan caption; cite it or remove it | Minor (no change) | `MISMATCH` stays P0 under every policy. So does `MISSING_BODY` **when the float is present in the rendered DOCX** — the build pipeline is then the only place that knows the caption text, which is SSOT drift and not a style choice. `MISSING_BODY` with **no `--docx` supplied** is different: nothing was checked, so under `--allow-separate-attachments` it is excused on the author's declaration and the run says so in those words. Treat those rows as unverified, not as verified — re-run with `--docx` before submission and read `summary.downgraded_unchecked` in the audit JSON. **Do NOT auto-fix cross-reference defects in `--fix` mode.** Rewriting a caption in the body without re-running the DOCX build merely moves the mismatch. Emit each P0 row as its own `M`-numbered Major Comment with `category: "F"` and `fixable_by_ai: false`, and route the user to `/write-paper` Step 7.6a for the pipeline-side fix. **Read on demand:** | File | Read it when | Cost if read blindly | |---|---|---| | `references/phases/phase2_5d_xref_qc.md` | the xref gate fired and you are writing up the reconciliation | ~2,400 tokens; an early draft with no DOCX build never reaches this stage | ### Phase 2.5e: Confounding Completeness (observational only) **When to run:** the manuscript is observational (cohort, case-control, cross-sectional, health-screening registry) and the central claim is an adjusted exposure–outcome association. **Skip for RCTs, diagnostic-accuracy, SR/MA, and descriptive studies** — which is why the full procedure is loaded on demand rather than carried inline. The highest-yield, most mechanical observational finding — a covariate that is **measured**, **imbalanced across exposure groups** in Table 1, and **absent from the adjustment set** (residual confounding by a measured variable) — is invisible to a prose pass and only exposed by joining the exposure-stratified Table 1 against the Methods adjustment set (probe O1). Run the deterministic gate and treat each `UNADJUSTED_IMBALANCED` covariate as an Anticipated Major Comment (category A. Study Design & Data Integrity): ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_confounding_completeness.py" \ --table1 table1_by_<exposure>.csv \ --adjusted-list "age, sex, BMI, hypertension, diabetes" \ --exposure-defining-list "body mass index, waist, fasting glucose, triglycerides, HDL cholesterol" \ --out qc/confounding_completeness.json --strict ``` When the manuscript is observational, **load `references/phases/confounding_completeness.md`** for the full procedure: the precedent failure pattern; the `--exposure-defining-list` over-adjustment exemption for guideline-defined exposures (MASLD / metabolic syndrome / CKM / sarcopenia / frailty); the SMD-from-`mean ± SD` fallback; the extended-adjustment sensitivity model and its frame discipline (refit the unadjusted estimate on the reduced complete-case frame, not the full frame); and the rest of the observational probe set (O2–O10) from `references/domain-probes/observational_confounding.md`. ### Phase 2.5f: Claim-vs-Artifact Cross-Check Phases 2.5–2.5e check numbers and adjustment sets. This phase checks **claims against the external artifacts they should trace to** — the pre-registration, the protocol, the analysis outputs. These are the errors that survive a single-pass review because the manuscript prose is internally consistent yet disagrees with the registration or the analysis it reports: a primary re-designated after the results were known, an E-value that does not recompute from the estimate it is quoted against, an analysis promised in Methods that never reaches Results. **Run the gates** (all deterministic; pass the supplement so the corpus is complete): ```bash # 1. claims ↔ pre-registration/protocol: estimand provenance + E-value arithmetic python3 "${CLAUDE_SKILL_DIR}/scripts/check_claim_artifact.py" \ --manuscript manuscript.md --prereg prereg.md \ --out qc/claim_artifact.json --strict # 2. Methods ↔ Results ↔ disk coverage (both directions: promised-absent AND run-but-unreported) python3 "${CLAUDE_SKILL_DIR}/scripts/check_artifact_coverage.py" \ --manuscript manuscript.md --supplement supplement.md --analysis-dir output/analysis \ --out qc/artifact_coverage.json --strict # 3. reader-facing residue in EVERY rendered artifact, not just the body python3 "${CLAUDE_SKILL_DIR}/scripts/check_supplement_hygiene.py" \ --supplement supplement.md --supplement tables.md --supplement captions.md \ --manuscript manuscript.md --out qc/supplement_hygiene.json --strict # 4. float AND in-text reference-number ([N]) citation order — a desk-reject item the hygiene gate does not cover python3 "${CLAUDE_SKILL_DIR}/scripts/check_citation_order.py" \ --manuscript manuscript.md --out qc/citation_order.json --strict # 5. a headline null is uninterpretable without a precision statement python3 "${CLAUDE_SKILL_DIR}/scripts/check_null_calibration.py" \ --manuscript manuscript.md --out qc/null_calibration.json --strict # 5b. a headline OR/HR/RR whose 95% CI spans an order of magnitude (a direction, not a magnitude), or events/covariates < 10 (EPV) python3 "${CLAUDE_SKILL_DIR}/scripts/check_effect_stability.py" \ --manuscript manuscript.md --out qc/effect_stability.json --strict # 5c. incorporation bias — a trajectory-defined reference standard with a trajectory predictor (growth) reported as associated with the outcome python3 "${CLAUDE_SKILL_DIR}/scripts/check_incorporation_bias.py" \ --manuscript manuscript.md --out qc/incorporation_bias.json --strict # 6. reader/observer study only — prove the (call × confidence) → score encoding is strictly # monotonic; a folded score silently mis-estimates the AUC and no prose review can see it python3 "${MEDSCI_SKILLS_ROOT}/skills/analyze-stats/scripts/rating_monotonicity.py" \ --encoding score_def.json ``` **Verdict → severity.** The rationale and the resolution path for each are in the reference file. | Verdict | Severity | |---|---| | `PRIMARY_REASSIGNED` | **Major** — the primary was re-designated after results were known | | `EVALUE_ARITHMETIC`, `EVALUE_NON_PRIMARY` | **Major** — recompute for the *declared primary* estimate | | `PROMISED_ABSENT`, `DISK_UNREPORTED`, `PROMISED_STAT_NO_VALUE` | **Major** | | `SUPP_INTERNAL_LABEL`, `SUPP_PLACEHOLDER`, `SUPP_BUILD_MARKER`, `SUPP_RESPONSE_FRAMING`, `SUPP_PLANNING_RESIDUE`, `SUPP_XREF_UNRESOLVED` | **Major** — a slip in a supplement is as fatal at a technical check as one in the body | | `CITATION_ORDER` | **Major**; `CITATION_GAP` **Minor** | | `CONFIRM_NULL_NO_MDE` | **Major** | | `ESTIMAND_DRIFT`, `PRIMARY_DISCLOSURE_NOTE` | **Advisory Minor — never a blocker.** The provenance match is fuzzy (token overlap); confirm against the actual registration first. `PRIMARY_DISCLOSURE_NOTE` flags the honest disclosure the guidance *recommends writing* — do not penalise it. | **Four checks no script makes** (prose judgement — the reference file has the full text): 1. **Primary-change guard** — two models for one contrast, one significant and one null, the significant one foregrounded: confirm which was pre-specified. 2. **Headline vs own-sensitivity direction** — if the headline claim points the opposite way from the authors' own sensitivity estimate, the paper contradicts its own robustness check. 3. **Rating → AUC monotonicity** — a *folded* (call × confidence) score silently mis-estimates the AUC, and prose review cannot see an estimator bug. 4. **Figure-embedded numbers are grep-blind** — every numeric audit above is blind to numbers *inside* a rasterised figure. Read each figure page visually before submission. Also re-run `/sync-submission`'s `check_cross_artifact_stale.py` **after** any reframe, not just once at the start. For time-to-event manuscripts, apply probe **S8 (estimand provenance)** of `references/domain-probes/survival_prognostic.md`. **Read on demand:** | File | Read it when | Cost if read blindly | |---|---|---| | `references/phases/phase2_5f_claim_artifact.md` | a gate above fired and you need the rationale + resolution path, or there is a pre-registration to reconcile | ~4,800 tokens; a manuscript with no registration and no firing gate needs none of it | ### Phase 2.5g: Editorial-Impression / Defensiveness Scan (the ceiling pass) Run this **after** the floor gates (Phases 2.5–2.5f), because it reads the *accurate* manuscript and recommends what to take back out. It is the operational form of category L and the counterweight to the additive bias of the rest of the stack: every other phase can only make the manuscript longer and more defended; this one is the only phase that can make it shorter and more confident. It is advisory and **non-blocking** — it never produces a Major and never gates submission. ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_editorial_impression.py" \ --manuscript manuscript.md --out qc/editorial_impression.json ``` The gate reads the manuscript as a whole, segments it by IMRAD heading, and emits up to six verdicts, each tagged with a SUBTRACTION `action`: | Verdict | Reads as | Action | |---|---|---| | `HEDGE_DENSITY` | defensive-caveat tokens per 1,000 narrative words over threshold | TIGHTEN | | `HEDGE_REPEAT` | one caveat motif repeated across body + Abstract | TIGHTEN | | `AUDIT_IN_BODY` | SHA / commit / unit-test / post-lock / manifest / seed in the narrative | MOVE (→ Methods/supplement) | | `LIMITATIONS_VOLUME` | a long enumerated Limitations list | TIGHTEN (consolidate) | | `ABSTRACT_CAVEAT_LOAD` | several caveat clauses in the Abstract | TIGHTEN | | `BURIED_DEFENSE` | strong numeric robustness result only in Limitations/supplement | MOVE (→ Results) | **Fold the findings into the report as the SUBTRACTION axis, not the additive one.** Each becomes a Minor `issues[]` entry under `category: "L" / category_name: "Editorial impression"`, additively carrying `issue_type: "editorial_impression"`, `subtype: <verdict>`, and `action: "REMOVE" | "MOVE" | "TIGHTEN"`. They are summarized in their own Phase 3 block ("Editorial-Impression Risks — REMOVE / MOVE / TIGHTEN"), kept visually separate from the "Anticipated Major / Minor Comments (ADD / FIX)" so the author sees both forces. Mark them `fixable_by_ai: false` by default — TIGHTEN-ing a hedge or MOVE-ing a robustness result is a voice-and-judgment edit the author should own — except a clearly-redundant repeated caveat (`HEDGE_REPEAT`), which `--fix` may collapse to a single statement. **Net-impact note.** When an *earlier* phase recommends adding a caveat or disclosure, weigh it against L: an integrity-critical disclosure is a **must (state it once, crisply)**, but a defensive over-disclosure is a **cut / move**. The two are not symmetric — keep the disclosure, but place it once and point to the supplement rather than repeating it at every claim site (placement discipline: main text narrates, auditability lives in the supplement). ### Phase 2.5h: Baseline Drift (anchor to the last human-approved version) Run this after the ceiling pass and **before** the loop controller (Phase 2.5i), so its findings are counted when the terminal state is judged. The refine loop's hazard is the *anchor*: each pass silently takes the previous **AI output** as its baseline, so a small framing bias compounds across passes while every pass looks locally fine. This gate compares the current manuscript against the **last human-approved version** — the frozen `v_N` of manuscript-versioning (a senior/co-author-circulated draft), **not** the last AI output — and reports lexical framing drift. Supply the baseline explicitly; with none available (a first draft) skip it — the gate is a no-op without one. ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/check_baseline_drift.py" \ --manuscript manuscript.md --baseline "$BASELINE_MD" \ --out qc/baseline_drift.json ``` | Verdict | Signal (baseline → current) | Fold into report as | |---|---|---| | `STRENGTH_INFLATION` | certainty markers up while hedges fall | Minor — tone back to the approved strength | | `SIGNIFICANCE_INFLATION_DRIFT` | novel/pivotal/unprecedented tokens added | Minor — remove the inflation | | `SCOPE_INFLATION_DRIFT` | new generalization phrases ("in clinical practice") | Minor — the estimand did not widen; re-scope | | `HEDGE_ACCRETION` | hedge/caveat density up | Minor — cumulative over-hardening; TIGHTEN | Every finding is **Minor and advisory** — framing is the author's judgment and the gate never blocks. Treat drift as *review against the approved anchor*, not an instruction to revert: legitimate new analysis can justify a stronger claim, but the author should confirm it rather than let it accrete unexamined across AI passes. Its `qc/baseline_drift.json` feeds the loop controller, so a draft that has drifted does not read as a zero-edit PASS. ### Phase 2.5i: Refinement Terminal-State (the loop controller) Run this **last**, after the floor gates (Phases 2.5–2.5f) and the ceiling pass (Phase 2.5g), because it reads their `qc/*.json` artifacts and classifies whether the refine-and-review loop is *done*. Self-review is run iteratively (review → revise → review); the floor gates converge to a fixed point of zero Major findings, but the additive bias of the whole stack means a naive loop never stops — there is always one more caveat to add. This step is the counterweight's controller: it turns the floor + ceiling state into a reproducible STOP verdict and makes a **zero-edit result a valid PASS**, so an accurate manuscript is not over-hardened by another pass it does not need. It is **not a detector** (it finds no defect, carries no `check_` prefix, is uncounted in the catalog) and it is **advisory — it never blocks**; it must not double-gate the floor detectors, which already fail under `--strict` on their own Majors. ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/refinement_stop.py" \ --qc-dir qc --out qc/refinement_stop.json ``` | Verdict | Meaning | What the harness must do | |---|---|---| | `CONTINUE` | a floor gate still reports a Major | genuine work remains — keep going | | `STOP_OVERHARDENING` | floor clean, ceiling flags accumulation | STOP adding; only optional SUBTRACTION (REMOVE/MOVE/TIGHTEN) remains — do **not** run another additive pass | | `STOP_MINOR_OPTIONAL` | floor clean, only optional Minor polish left | stop the required-work loop; present the Minor items as an optional menu, do not loop for them | | `STOP_ZERO_EDIT` | floor at fixed point, ceiling clean | the manuscript is submission-ready as-is — **NO EDITS REQUIRED. Do not manufacture changes.** Report the zero-edit PASS as a first-class outcome | | `INDETERMINATE` | no gate artifacts yet | run the floor + ceiling gates first | **Stopping principle.** Deterministic floor gates iterate to their fixed point (0 Major); subjective refinement does **not** get an open loop. Once the verdict is any `STOP_*`, stop the additive cycle — surface the terminal state in the Phase 3 report and do not re-run self-review to find "one more thing". A `STOP_ZERO_EDIT` or `STOP_MINOR_OPTIONAL` verdict is a legitimate terminal state; treating "found nothing required" as a failure to try harder is exactly the over-hardening this phase exists to stop. ### Phase 2.5j: Refinement Regression (fixed vs broke, across runs) Run this each round, after the loop controller, and record the run. Self-review is stateless: a revision that resolves finding X can introduce finding Y, and the pass-rate (how many old findings are gone) hides it. This step reads a small run-history ledger — one line per run, the `verdict@where` fingerprints of that run's findings — and reports the **regression axis next to the pass-rate axis**: what the revision *fixed* vs what it *broke*. ```bash python3 "${CLAUDE_SKILL_DIR}/scripts/refinement_regression.py" \ --qc-dir qc --ledger qc/refinement_ledger.jsonl --append \ --out qc/refinement_regression.json ``` Use `--append` on a real run so the current findings become the next entry; omit it to classify without recording. | Verdict | Meaning | What the harness must do | |---|---|---| | `PROGRESSING` | findings resolved, none new | continue | | `REGRESSION` | the revision introduced new finding(s) | review the new findings before accepting the fix — the pass-rate went up but something broke | | `CHURNING` | a resolved finding reappeared (Mirror Loop) | **stop revising and re-anchor** — more passes re-derive, they do not converge | | `CONVERGED` | nothing new, nothing carried | the loop is done | | `INDETERMINATE` | first run, no prior entry | re-run after a revision | It is **not a detector** (no `check_` prefix, uncounted) and **advisory — it never blocks**. Report both axes in Phase 3: a revision is an improvement only if it resolved findings **and** the `new`/`churn` columns are empty. A `CHURNING` verdict is the deterministic form of the same stop signal the loop controller raises — the loop is no longer making progress. ### Phase 2.6: Multi-Agent Panel Review (--panel, opt-in) Run this phase **only when `--panel` is passed**. The default single-pass review (Phases 2–2.5d) stays the fast path; the panel is the high-cost, high-precision option for a pre-submission final pass on a top-tier target. Run it after the numerical audits (Phases 2.5–2.5d) so the reviewers see source-verified numbers, and before the Phase 3 report, which it feeds. Two things bind before you spawn anything: the **SSOT must be singular** (the Phase 1 step 4 gate — halt and ask if more than one manuscript-like `.md` is unpinned), and the roster must not be a **substrate monoculture** (a panel that shares the drafter's model inherits its blind spots; route at least one lens to Codex or a human co-author). Both are enforced by `check_panel_diversity.py --strict`, which also fires `PANEL_UNDERRETURN` when fewer reviewers returned than were spawned — a panel with <2 returned reviews is a failed run, not a thin one. **Read on demand:** | File | Read it when | Cost if read blindly | |---|---|---| | `references/phases/phase2_6_panel.md` | `--panel` was passed and you are composing the reviewer set | ~2,600 tokens — the reviewer-set table, roster manifest, editor synthesis and lens-diversity gate; a default single-pass review reaches none of it | **Load-on-demand**: read `${CLAUDE_SKILL_DIR}/references/phases/phase2_6_panel.md` when `--panel` is passed. ### Phase 3: Report Before writing the Anticipated Comments, skim `references/exemplar_findings/` for the finding at hand (cohort-arithmetic mismatch, unadjusted confounder, cross-sectional scope overreach, post-hoc primary / estimand drift). Each models the full shape — which gate fired, the comment in the reviewer's own words, Fatal/Fixable severity, the closest category letter, the concrete fix, `fixable_by_ai`, and an R0-ready line for Phase 3b. They are synthetic teaching models — match the structure, not the wording. Generate a concise report with this structure: ```markdown # Self-Review Report: {manuscript title} **Target journal**: {journal} **Manuscript type**: {type} **Date**: {date} **Overall assessment**: {1-2 sentences: key vulnerability and overall readiness} ## Anticipated Major Comments (fix before submission) M1. **{Issue title}** [{Category letter}] {1-2 sentences: what a reviewer would likely say, with specific manuscript location} **Severity**: {Fatal | Fixable} **Suggested fix**: {specific, actionable fix using existing data} M2. ... ## Anticipated Minor Comments (address proactively) m1. **{Issue}** [{Category}]: {1 sentence with location + fix} m2. ... ## Editorial-Impression Risks (REMOVE / MOVE / TIGHTEN) *The subtraction axis — what to take out, move, or tighten so the accurate manuscript reads confidently. Advisory and non-blocking; from Phase 2.5g / category L. Omit this block only if the scan returned nothing.* L1. **{Issue}** [{REMOVE | MOVE | TIGHTEN}]: {1 sentence — what reads as over-defensive and where, with the subtraction to make} L2. ... ## Strengths (emphasize in cover letter) - {Specific strength 1} - {Specific strength 2} - ... ``` The report carries **two** axes, kept visually separate: the **ADD / FIX** axis (Anticipated Major / Minor Comments — what is missing or wrong) and the **SUBTRACTION** axis (Editorial-Impression Risks — what to remove, move, or tighten). Do not fold the L items into the Minor Comments; an author who sees only "add this" will monotonically over-defend. **Conciseness targets**: - Anticipated Major Comments: 3-7 items, each 3-5 lines - Anticipated Minor Comments: 3-6 items, each 1-2 sentences - Editorial-Impression Risks: 0-6 items, each 1 sentence (only what the Phase 2.5g gate flagged) - Strengths: 3-5 items, each 1 sentence - Total report: 400-800 words (excluding optional R0 section) ### Phase 3b: R0 Numbering (Optional) If the user plans to use `/revise` after receiving actual reviews, offer to append R0-numbered output for pipeline compatibility: ```markdown ## R0 Pre-Submission Findings (for /revise cross-reference) R0-1 [MAJ] {mapped from M1}: {issue title} R0-2 [MAJ] {mapped from M2}: {issue title} R0-3 [MIN] {mapped from m1}: {issue title} ... ``` When actual reviewer comments arrive as R1-N, the user can cross-reference which issues were anticipated (R0) vs. novel (R1-only). ### Phase 3c: Structured JSON Output (--json) Emit the review as machine-readable JSON **only when `--json` is passed** (or when another skill consumes this run). The schema, field semantics and worked example live in the reference; a human-facing review never serializes anything. **Read on demand:** | File | Read it when | Cost if read blindly | |---|---|---| | `references/phases/phase3c_json_output.md` | --json was passed, or a downstream skill consumes this run | ~790 tokens of schema a human-facing review never emits | **Load-on-demand**: read `${CLAUDE_SKILL_DIR}/references/phases/phase3c_json_output.md` when --json was passed, or a downstream skill consumes this run. ### Phase 4: Fix Support (on request) The review ends at Phase 3. Enter this phase **only when the user asks for help applying the findings** — a review that is read and acted on by the author never reaches it. **Read on demand:** | File | Read it when | Cost if read blindly | |---|---|---| | `references/phases/phase4_fix_support.md` | the user asks you to apply or draft fixes for the findings | ~850 tokens; a review that is only read never reaches this phase | **Load-on-demand**: read `${CLAUDE_SKILL_DIR}/references/phases/phase4_fix_support.md` when the user asks you to apply or draft fixes for the findings. ## What This Skill Does NOT Do - Does not write the paper or rewrite entire sections - Does not generate fake data or fabricate results - Does not guarantee acceptance -- it reduces preventable reviewer criticism - Does not replace formal peer review by an external reviewer ## Tone Be direct and practical. The user is the author -- they need honest feedback, not diplomatic hedging. Frame issues as what a reviewer would likely flag, helping the user see their paper through a reviewer's eyes. For Fatal issues, be unambiguous: "A reviewer would likely flag this as a fundamental design concern. Submitting without addressing this risks Reject." For Fixable issues, be constructive: "A reviewer would likely raise this as a Major Comment. Here is how to address it with your existing data." ## Anti-Hallucination - **Never fabricate references.** All citations must be verified via `/search-lit` with confirmed DOI or PMID. Mark unverified references as `[UNVERIFIED - NEEDS MANUAL CHECK]`. Self-review enforces this through **Phase 2.5c: Reference Ha -
skill.yml 4.5 KB
schema_version: 2 name: self-review layer: D owner_domain: own_manuscript_critique maturity: official when_to_use: - Pre-submission self-criticism on the user's own manuscript across 10 review categories - Generating Anticipated Major / Minor Comments before sending to senior co-authors - Phase 2.5a numerical source-fidelity audit (CSV ↔ analysis ↔ manuscript ↔ primary paper) - Producing R0-numbered comments that flow into /revise for the response document - Multi-agent panel review (--panel) — parallel domain-expert reviewers + editor synthesis for a high-stakes pre-submission final pass when_NOT_to_use: - Reviewing external (assigned) manuscripts (use /peer-review) - Drafting the user's manuscript (use /write-paper) - Generating reviewer-response prose (use /revise) - Silently fixing non-AI-fixable issues without surfacing them to the user (forbidden) inputs: - manuscript/manuscript.md outputs: - qc/self_review.md - qc/self_review.json - qc/reference_adequacy.json - qc/perspective_structure.json - qc/refinement_stop.json - qc/baseline_drift.json - qc/refinement_regression.json deterministic_scripts: - scripts/check_reference_adequacy.py - scripts/check_perspective_structure.py - scripts/check_editorial_impression.py - scripts/check_baseline_drift.py - scripts/check_table_percentages.py - scripts/check_nested_group_comparison.py - scripts/check_reported_p_from_counts.py - scripts/check_dta_denominators.py - scripts/check_paired_difference_estimator.py side_effects: - may_edit_manuscript_when_fix_flag_set downstream_consumers: - revise - sync-submission forbidden_actions: - review_external_manuscripts_as_peer_reviewer - silently_fix_non_ai_fixable_issues # v2.1 quality card purpose: "Pre-submission self-review of the user's own manuscript from a reviewer's perspective across 10 categories, with severity-framed anticipated comments." safety_boundaries: - "Reviews the user's own manuscript only; not for reviewing external (journal-assigned) manuscripts." - "Does not silently fix non-AI-fixable issues; it flags them for the author." known_limitations: - "Anticipates likely reviewer comments; cannot predict a specific reviewer's focus." - "Advisory; produces recommendations, not manuscript edits." validation_commands: - "bash scripts/disclosure_placement_challenge/verify.sh" - "bash scripts/cohort_arith_binding_challenge/verify.sh" - "bash scripts/confounding_findings_challenge/verify.sh" - "python3 scripts/check_reviewer_team_consistency.py" - "python3 scripts/check_domain_probe_sync.py --strict" - "bash tests/test_panel_mode.sh" - "bash tests/test_reference_adequacy.sh" - "bash tests/test_editorial_impression.sh" - "bash tests/test_rounded_delta.sh" - "bash tests/test_figure_citation.sh" - "bash tests/test_emphasis_density.sh" - "bash tests/test_cv_leakage.sh" - "python3 scripts/check_table_percentages.py --manuscript <manuscript.md> --strict # recompute n (%) cells vs column denominator" - "bash scripts/check_table_percentages_challenge/verify.sh # deterministic, network-free" - "bash scripts/check_nested_group_comparison_challenge/verify.sh # subset-vs-parent P-value comparison" - "bash scripts/check_reported_p_from_counts_challenge/verify.sh # recompute row P from 2x2 counts" - "bash scripts/check_dta_denominators_challenge/verify.sh # sens/spec denominators vs reference-standard counts" - "bash scripts/check_paired_difference_estimator_challenge/verify.sh # median parity / degenerate CI / unnamed estimator" - "bash scripts/check_effect_stability_challenge/verify.sh # CI upper/lower ratio > 10x + events-per-variable < 10" - "bash scripts/check_incorporation_bias_challenge/verify.sh # trajectory reference standard + trajectory predictor" - "bash scripts/check_figure_citation_challenge/verify.sh # panel-suffixed citation (Figure 3a) regression + real orphan" - "bash scripts/refinement_stop_challenge/verify.sh # terminal-state loop controller: zero-edit PASS / stop signal from qc/*.json" - "bash scripts/check_baseline_drift_challenge/verify.sh # framing drift (strength/significance/scope/hedge) vs the last human-approved baseline" - "bash scripts/refinement_regression_challenge/verify.sh # regression axis: fixed vs broke across runs (ledger); Mirror-Loop churn" - "bash scripts/check_perspective_structure_challenge/verify.sh # IMRAD headings + flat abstract in a Perspective (genre-gated, Minor)" - "feed R0-numbered output into /revise" evidence_surface: demo
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.