coulomb-research/scripts/validate_research_registry.py
tegwick 087cea6239 Implement M0: research facility skeleton (CR-WP-0002)
Add docs/ResearchArtifactConvention.md defining program/paper/paper-local
identifier namespaces, file layout, publication metadata, and provenance
conventions. Register RP-000 (Researching Research) with CR-000 (Paper
Zero) as an honest draft skeleton: outline manuscript with explicit
[MISSING]/[CONJECTURE] markers, five conjectural claims transcribed from
the M2 example list, and empty evidence/criticism files. Add the
facility-level paper registry and a small validation script covering
registry parsing, id uniqueness, publication-metadata agreement, and
Markdown link integrity.

Close CR-WP-0002 and hand off M1 (Paper Zero v0.1 manuscript) as
CR-WP-0003, per the workplan's residual-handoff requirement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 228422@bnt-lap001
Assistant-Session: 49089735-86e1-4d29-8a6e-74c26947f8c4
2026-09-28 20:15:18 +02:00

173 lines
5.6 KiB
Python

#!/usr/bin/env python3
"""Validate the Coulomb Research paper registry and its referenced artifacts.
Checks, per docs/ResearchArtifactConvention.md:
1. research/registry/papers.yaml parses as YAML and each entry has the
required fields (id, title, program, stage, version, status, path).
2. Paper ids are unique within the registry.
3. Each entry's `path` exists and contains a publication.yaml.
4. Each entry's stage/version/status agree with the paper's own
publication.yaml (paper_id, publication_stage, version,
publication_state).
5. Markdown links of the form [text](relative/path) inside the paper's
own files (README.md, manuscript.md, claims.md, evidence.md,
criticism.md, revision-log.md) that point at a relative file resolve
to an existing file.
No external dependencies beyond PyYAML (`import yaml`), which is already
relied on by this facility's tooling. This is intentionally a single script,
not a test framework or package, because the artifact set it validates is a
handful of small, hand-authored files -- see the "smallest repeatable
validation tool" note in workplans/CR-WP-0002-research-facility-skeleton.md
task T03.
Usage: python3 scripts/validate_research_registry.py
Exit status is non-zero if any check fails.
"""
import re
import sys
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
REGISTRY_PATH = REPO_ROOT / "research" / "registry" / "papers.yaml"
REQUIRED_REGISTRY_FIELDS = ["id", "title", "program", "stage", "version", "status", "path"]
PAPER_STAGE_STATE_MAP = {
"P1": "internal-circulation",
}
MD_LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
PAPER_MD_FILES = [
"README.md",
"manuscript.md",
"claims.md",
"evidence.md",
"criticism.md",
"revision-log.md",
]
def fail(errors, message):
errors.append(message)
def check_registry(errors):
if not REGISTRY_PATH.exists():
fail(errors, f"missing registry file: {REGISTRY_PATH}")
return []
with REGISTRY_PATH.open() as f:
try:
data = yaml.safe_load(f)
except yaml.YAMLError as exc:
fail(errors, f"registry YAML did not parse: {exc}")
return []
papers = (data or {}).get("papers")
if not isinstance(papers, list) or not papers:
fail(errors, "registry has no non-empty 'papers' list")
return []
seen_ids = set()
for entry in papers:
missing = [f for f in REQUIRED_REGISTRY_FIELDS if f not in entry]
if missing:
fail(errors, f"registry entry {entry.get('id', '<no id>')} missing fields: {missing}")
continue
pid = entry["id"]
if pid in seen_ids:
fail(errors, f"duplicate paper id in registry: {pid}")
seen_ids.add(pid)
return papers
def check_paper(errors, entry):
pid = entry["id"]
paper_dir = REPO_ROOT / entry["path"]
if not paper_dir.is_dir():
fail(errors, f"{pid}: registry path does not exist or is not a directory: {entry['path']}")
return
pub_path = paper_dir / "publication.yaml"
if not pub_path.exists():
fail(errors, f"{pid}: missing publication.yaml at {pub_path}")
return
with pub_path.open() as f:
try:
pub = yaml.safe_load(f)
except yaml.YAMLError as exc:
fail(errors, f"{pid}: publication.yaml did not parse: {exc}")
return
if pub.get("paper_id") != pid:
fail(errors, f"{pid}: publication.yaml paper_id {pub.get('paper_id')!r} != registry id {pid!r}")
if pub.get("publication_stage") != entry["stage"]:
fail(
errors,
f"{pid}: publication_stage {pub.get('publication_stage')!r} != registry stage {entry['stage']!r}",
)
if str(pub.get("version")) != str(entry["version"]):
fail(errors, f"{pid}: version {pub.get('version')!r} != registry version {entry['version']!r}")
if pub.get("publication_state") != entry["status"]:
fail(
errors,
f"{pid}: publication_state {pub.get('publication_state')!r} != registry status {entry['status']!r}",
)
expected_state = PAPER_STAGE_STATE_MAP.get(pub.get("publication_stage"))
if expected_state and pub.get("publication_state") != expected_state:
fail(
errors,
f"{pid}: publication_state {pub.get('publication_state')!r} does not match "
f"expected state {expected_state!r} for stage {pub.get('publication_stage')!r}",
)
for name in PAPER_MD_FILES:
md_path = paper_dir / name
if not md_path.exists():
fail(errors, f"{pid}: missing required artifact {name}")
continue
check_markdown_links(errors, pid, md_path)
def check_markdown_links(errors, pid, md_path):
text = md_path.read_text()
for match in MD_LINK_RE.finditer(text):
target = match.group(1).strip()
if target.startswith(("http://", "https://", "#", "mailto:")):
continue
target_path = (md_path.parent / target).resolve()
if not target_path.exists():
fail(errors, f"{pid}: {md_path.relative_to(REPO_ROOT)} has broken link: {target}")
def main():
errors = []
papers = check_registry(errors)
for entry in papers:
if all(f in entry for f in REQUIRED_REGISTRY_FIELDS):
check_paper(errors, entry)
if errors:
print(f"FAIL: {len(errors)} issue(s):")
for e in errors:
print(f" - {e}")
return 1
print(f"OK: registry valid, {len(papers)} paper(s) checked.")
return 0
if __name__ == "__main__":
sys.exit(main())