| |
| """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"(?<!\\)%.*", " ", text) |
| text = text.replace(r"\%", " percent ") |
| text = re.sub(r"\\cite[A-Za-z]*\{[^}]*\}", " ", text) |
| text = re.sub(r"\\[A-Za-z]+\*?(?:\[[^\]]*\])?(?:\{([^{}]*)\})?", r" \1 ", text) |
| text = re.sub(r"[{}$^_\\]", " ", text) |
| return text |
|
|
|
|
| def word_count(text: str) -> 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()) |
|
|