coulomb-research/scripts/query_paper_graph.py
tegwick dbf610b7b6 Implement M2: claim/evidence discipline for CR-000 (CR-WP-0004)
Obtain CR-000's first independent criticism via a freshly spawned,
context-isolated agent instance with no prior authorship context
(CR-000-X003-X007 in criticism.md). It found two internal-consistency
violations self-criticism had missed: CR-000-C006's cited evidence was
circular (X003) and conflated manuscript assertion with a real Evidence
object (X004), so C006 is now contested and its evidence withdrawn. It
also flagged that self-criticism had concentrated on infrastructure while
leaving substantive claims unchallenged (X005), an unlabeled overreach in
the Abstract (X006, now fixed), and a provenance-field ambiguity (X007).

Add evidence.md's first two entries (CR-000-E001, CR-000-E002), both
explicitly assessed as weak and left conjectural rather than inflated to
supported. Add scripts/query_paper_graph.py, a minimal read-only script
answering claim/evidence/criticism relation queries without manual
grepping, as a partial response to CR-000-X001 (which remains open: it is
a query layer over flat files, not a real graph).

Revise manuscript.md (Abstract, SS11-14) and questions.md to reflect all of
the above; Q2 gets a provisional "mixed" answer.

Close CR-WP-0004 and hand off CR-WP-0005 (resolving remaining open
criticism, and obtaining organizationally- rather than only
procedurally-independent review) rather than starting M3, which this
workplan judges premature while CR-000 still has open internal-consistency
findings.

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:37:35 +02:00

179 lines
6.9 KiB
Python

#!/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-<T><NNN>"):
- 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 <paper-dir> --id CR-000-C002
python3 scripts/query_paper_graph.py <paper-dir> --challenges CR-000-C002
python3 scripts/query_paper_graph.py <paper-dir> --bears-on CR-000-C003
python3 scripts/query_paper_graph.py <paper-dir> --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())