#!/usr/bin/env python3 """Query claim/evidence/criticism relations for a Coulomb-native paper. Directly addresses CR-000-X001 in research/programs/RP-000-researching-research/papers/paper-000/criticism.md: CR-000's own claims/evidence/criticism cross-references are strings in flat Markdown files, not edges in anything queryable. This script parses those files' explicit relation fields ("Bears on:", "Challenges:") and answers relation queries without manual grepping. This is intentionally the smallest useful prototype, not a database or service, per the institutional-minimality principle (seed/CoulombResearchKernel.md SS10) and this workplan's own instruction (workplans/CR-WP-0004-claim-evidence-discipline.md T03) to build only as much as one paper's scale justifies. It has no dependencies beyond the standard library. It is read-only: it does not modify the source Markdown files, which remain the authoritative record. Recognized relation fields, one per object block ("## CR-000-"): - claims.md entries: "Evidence for/against:" -> evidence ids (supports/bears-on) "Criticism:" -> criticism ids (challenged-by) - evidence.md entries: "Bears on:" -> claim ids (bears-on) - criticism.md entries: "Challenges:" -> claim ids (challenges) Usage: python3 scripts/query_paper_graph.py --id CR-000-C002 python3 scripts/query_paper_graph.py --challenges CR-000-C002 python3 scripts/query_paper_graph.py --bears-on CR-000-C003 python3 scripts/query_paper_graph.py --list Default paper-dir if omitted: research/programs/RP-000-researching-research/papers/paper-000 """ import argparse import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent DEFAULT_PAPER_DIR = ( REPO_ROOT / "research" / "programs" / "RP-000-researching-research" / "papers" / "paper-000" ) BLOCK_RE = re.compile(r"^## (CR-\d{3}-[CEX]\d{3})\s*$", re.MULTILINE) FIELD_RE = re.compile( r"\*\*(Bears on|Challenges|Evidence for/against|Criticism):\*\*\s*(.*)", re.IGNORECASE ) # Each relation field only ever points at one object type. Filtering by type # here (rather than grabbing every CR-*-[CEX]\d{3} the line happens to # mention) matters: a field's prose commonly *mentions* an id of a # different type in passing (e.g. a claim's "Criticism:" field explaining # itself by referring back to another claim id), which is not the same as # that field *asserting a relation* to it. Confusing the two produced a # false "C002 --challenges--> C003" edge in earlier testing. FIELD_ID_TYPE = { "bears on": "C", "challenges": "C", "evidence for/against": "E", "criticism": "X", } def parse_file(path): """Return {object_id: {field_name: [related_id, ...]}} for one file.""" if not path.exists(): return {} text = path.read_text() starts = [(m.group(1), m.start()) for m in BLOCK_RE.finditer(text)] objects = {} for i, (obj_id, start) in enumerate(starts): end = starts[i + 1][1] if i + 1 < len(starts) else len(text) lines = text[start:end].splitlines() fields = {} current_field = None current_text = [] for line in lines: m = FIELD_RE.search(line) if m: if current_field: fields.setdefault(current_field, []).append(" ".join(current_text)) current_field, first = m.group(1).lower(), m.group(2) current_text = [first] continue # A field's value wraps onto following lines until a blank # line, a metadata list item ("- **Critic:** ..."), or the # next field/heading ends it. if current_field: if not line.strip() or line.lstrip().startswith("- **") or line.startswith("#"): fields.setdefault(current_field, []).append(" ".join(current_text)) current_field, current_text = None, [] else: current_text.append(line.strip()) if current_field: fields.setdefault(current_field, []).append(" ".join(current_text)) resolved = {} for field, chunks in fields.items(): type_letter = FIELD_ID_TYPE[field] id_re = re.compile(rf"CR-\d{{3}}-{type_letter}\d{{3}}") found = [] for chunk in chunks: found.extend(id_re.findall(chunk)) resolved[field] = found objects[obj_id] = resolved return objects def build_graph(paper_dir): """Return list of (subject, relation, object) edges across all files.""" edges = [] for obj_id, fields in parse_file(paper_dir / "claims.md").items(): for eid in fields.get("evidence for/against", []): edges.append((eid, "bears-on", obj_id)) for xid in fields.get("criticism", []): edges.append((xid, "challenges", obj_id)) for obj_id, fields in parse_file(paper_dir / "evidence.md").items(): for cid in fields.get("bears on", []): edges.append((obj_id, "bears-on", cid)) for obj_id, fields in parse_file(paper_dir / "criticism.md").items(): for cid in fields.get("challenges", []): edges.append((obj_id, "challenges", cid)) # de-duplicate while preserving order (same edge often stated in both # directions, once in each file) seen = set() unique = [] for e in edges: if e not in seen: seen.add(e) unique.append(e) return unique def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("paper_dir", nargs="?", default=str(DEFAULT_PAPER_DIR)) ap.add_argument("--id", help="show all edges touching this id") ap.add_argument("--challenges", metavar="ID", help="what challenges ID") ap.add_argument("--bears-on", metavar="ID", help="what evidence bears on ID") ap.add_argument("--list", action="store_true", help="list every edge") args = ap.parse_args() paper_dir = Path(args.paper_dir) edges = build_graph(paper_dir) if args.list: for s, r, o in edges: print(f"{s} --{r}--> {o}") return 0 if args.challenges: hits = [s for s, r, o in edges if r == "challenges" and o == args.challenges] print("\n".join(hits) if hits else f"(nothing found challenging {args.challenges})") return 0 if args.bears_on: hits = [s for s, r, o in edges if r == "bears-on" and o == args.bears_on] print("\n".join(hits) if hits else f"(nothing found bearing on {args.bears_on})") return 0 if args.id: hits = [(s, r, o) for s, r, o in edges if s == args.id or o == args.id] if not hits: print(f"(no edges touch {args.id})") for s, r, o in hits: print(f"{s} --{r}--> {o}") return 0 ap.print_help() return 1 if __name__ == "__main__": sys.exit(main())