#!/usr/bin/env python3 """Lightweight GRL manuscript readiness checks for the Overleaf package.""" from __future__ import annotations import json import math import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] OUT = ROOT / "outputs" / "grl_revision_20260610" / "readiness" MAIN = ROOT / "main.tex" FORBIDDEN = [ "TBD", "to be added", "placeholder", "error bars are omitted", "should be added before submission", "data DOI: TBD", "code DOI: TBD", ] REQUIRED_SECTIONS = [ "Plain Language Summary", "Keywords", "Introduction", "Data and Methods", "Results", "Discussion and Conclusions", "Open Research Section", "Conflict of Interest declaration", "AI Tool Disclosure", ] REQUIRED_FIGURES = [ "figures/snr_matched_design.png", "figures/fig2_training_combined.pdf", "figures/fig2_training_combined.png", "figures/fig3_continuous_association.pdf", "figures/fig3_continuous_association.png", ] def strip_latex(text: str) -> str: text = re.sub(r"(? int: clean = strip_latex(text) return len(re.findall(r"[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)?", clean)) def extract_env(text: str, env: str) -> str: match = re.search(rf"\\begin\{{{re.escape(env)}\}}(.*?)\\end\{{{re.escape(env)}\}}", text, re.S) return match.group(1) if match else "" def extract_sections_for_pu(text: str) -> str: parts = [] for env in ["abstract"]: parts.append(extract_env(text, env)) for match in re.finditer(r"\\(?:section|subsection)\*?\{([^}]*)\}(.*?)(?=\\(?:section|subsection)\*?\{|\\bibliography|\\end\{document\})", text, re.S): title, body = match.groups() if title in {"Keywords", "Open Research Section", "Conflict of Interest declaration", "AI Tool Disclosure"}: parts.append(body) elif not title.lower().startswith("acknowledg"): parts.append(body) captions = re.findall(r"\\caption\{(.*?)\}", text, re.S) parts.extend(captions) return "\n".join(parts) def keypoints(text: str) -> list[str]: body = extract_env(text, "keypoints") return [strip_latex(item).strip() for item in re.findall(r"\\item\s+(.*)", body)] def association_counts_ok(table_text: str) -> bool: expected = ["576,875", "1,301", "0.556", "1,561", "0.667"] return all(value in table_text for value in expected) def main() -> int: OUT.mkdir(parents=True, exist_ok=True) failed: list[str] = [] external: list[str] = [] auto_fixable: list[str] = [] files_checked: list[str] = [] if not MAIN.exists(): failed.append("main.tex is missing") text = "" else: text = MAIN.read_text(encoding="utf-8") files_checked.append(str(MAIN.relative_to(ROOT))) if not (ROOT / "main.pdf").exists(): auto_fixable.append("main.pdf is missing; run latexmk") else: files_checked.append("main.pdf") if "Requires author confirmation" in text: external.append("Affiliation and/or corresponding-author email require author confirmation") if "public repository DOI has not yet been assigned" in text: external.append("Persistent public data/code repository identifier has not been assigned") if "funding details require author confirmation" in text: external.append("Acknowledgments and funding details require author confirmation") lowered = text.lower() for token in FORBIDDEN: if token.lower() in lowered: failed.append(f"Forbidden manuscript text remains: {token}") abstract = extract_env(text, "abstract") abstract_words = word_count(abstract) if abstract_words > 150: failed.append(f"Abstract has {abstract_words} words; target is <=150") kp = keypoints(text) if len(kp) > 3: failed.append(f"Key Points count is {len(kp)}; maximum is 3") for idx, item in enumerate(kp, 1): if len(item) > 140: failed.append(f"Key Point {idx} is {len(item)} characters; maximum is 140") for section in REQUIRED_SECTIONS: if section not in text: failed.append(f"Required section is missing: {section}") for fig in REQUIRED_FIGURES: path = ROOT / fig if not path.exists(): failed.append(f"Required figure file is missing: {fig}") else: files_checked.append(fig) table_path = ROOT / "continuous_assoc_table.tex" if table_path.exists(): table_text = table_path.read_text(encoding="utf-8") files_checked.append("continuous_assoc_table.tex") if not association_counts_ok(table_text): failed.append("Continuous association table does not contain expected retained-pick and event-recall counts") else: failed.append("continuous_assoc_table.tex is missing") if not (ROOT / "si" / "grl_revision" / "supplementary_information.tex").exists(): failed.append("Supporting Information file is missing") else: files_checked.append("si/grl_revision/supplementary_information.tex") bootstrap_candidates = list((ROOT / "outputs").glob("**/*bootstrap*")) if not bootstrap_candidates: auto_fixable.append("Bootstrap CI tables are not present; generate after deterministic per-sample outputs exist") strat_candidates = list((ROOT / "outputs").glob("**/*strat*")) if not strat_candidates: auto_fixable.append("SNR-stratified diagnostic tables are not present; generate after deterministic per-sample outputs exist") pu_words = word_count(extract_sections_for_pu(text)) figures = len(re.findall(r"\\begin\{figure\}", text)) tables = len(re.findall(r"\\begin\{table\}", text)) + len(re.findall(r"\\input\{continuous_assoc_table\}", text)) publication_units = pu_words / 500.0 + figures + tables if publication_units > 12: failed.append(f"Publication units are {publication_units:.2f}; maximum is 12") elif publication_units > 11.5: auto_fixable.append(f"Publication units are {publication_units:.2f}; internal target is <=11.5") log_path = ROOT / "main.log" if log_path.exists(): log_text = log_path.read_text(encoding="utf-8", errors="replace") files_checked.append("main.log") for pattern in ["Undefined control sequence", "LaTeX Error", "Citation `", "Reference `"]: if pattern in log_text and "undefined" in log_text.lower(): failed.append(f"Potential unresolved LaTeX issue in main.log: {pattern}") if failed: status = "FAIL" elif external: status = "BLOCKED_FOR_HUMAN" else: status = "PASS" report = { "overall_status": status, "abstract_words": abstract_words, "publication_units_estimate": round(publication_units, 3), "counted_words_estimate": pu_words, "figures": figures, "tables": tables, "failed_checks": failed, "external_blockers": external, "auto_fixable_items": auto_fixable, "files_checked": sorted(set(files_checked)), "commands_to_reproduce": [ "latexmk -pdf -interaction=nonstopmode -halt-on-error main.tex", "python scripts/grl_readiness_check.py", ], } (OUT / "grl_ready_report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") lines = [ "# GRL Readiness Report", "", f"- Overall status: `{status}`", f"- Abstract words: {abstract_words}", f"- Publication units estimate: {publication_units:.2f}", f"- Counted words estimate: {pu_words}", f"- Figures: {figures}", f"- Tables: {tables}", "", "## Failed Checks", "", ] lines.extend([f"- {item}" for item in failed] or ["- None"]) lines.extend(["", "## External Blockers", ""]) lines.extend([f"- {item}" for item in external] or ["- None"]) lines.extend(["", "## Auto-Fixable Or Computation Items", ""]) lines.extend([f"- {item}" for item in auto_fixable] or ["- None"]) lines.extend(["", "## Commands", ""]) lines.extend([f"- `{cmd}`" for cmd in report["commands_to_reproduce"]]) (OUT / "grl_ready_report.md").write_text("\n".join(lines) + "\n", encoding="utf-8") print(json.dumps(report, indent=2)) return 0 if status in {"PASS", "BLOCKED_FOR_HUMAN"} else 1 if __name__ == "__main__": raise SystemExit(main())