opencode-bioresearcher 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,13 @@ Contract:
10
10
  - Fill-missing-only merging: never overwrite a non-null stored value.
11
11
  - Loud gaps: missing fields render as [MISSING field: ...]; unknown bib keys as
12
12
  [MISSING record <key>] with a non-zero exit. Never fabricate.
13
+ - render is the single numbering authority: drafts cite [@key] markers; render
14
+ assigns numbers by first appearance, rewrites the markers, and appends the
15
+ References section from the ledger. Any unresolved key or [MISSING ...] entry
16
+ fails the render (exit 1) WITHOUT writing the output file.
17
+ - check validates a ledger end-to-end (exit 1 on quarantined lines; with
18
+ --markers also on unresolved/mixed [@key] markers in the given markdown
19
+ files - exit 1 iff anything this invocation validated failed).
13
20
  - Verb banners: every subcommand prints "[evidence-ledger] <verb>: ..." so
14
21
  test graders can anchor on deterministic stdout.
15
22
 
@@ -161,6 +168,14 @@ def normalize_record(raw, require_provenance_aspect=None) -> dict:
161
168
  title = raw.get("title")
162
169
  if title is not None:
163
170
  title = str(title).strip() or None
171
+ if title is None and rtype != "article":
172
+ # biomcp record shapes often carry `name` instead of `title` (drugs,
173
+ # genes, diseases): fold it fill-only so bibliographies never render
174
+ # [MISSING field: title] for schema-shaped records. Articles keep
175
+ # their hard id requirement (a name-only article must not verify-gate).
176
+ name = raw.get("name")
177
+ if isinstance(name, str) and name.strip():
178
+ title = name.strip()
164
179
  if not title and not ids:
165
180
  raise ValueError("record needs a title or at least one id")
166
181
  if raw.get("meta") is not None and not isinstance(raw.get("meta"), dict):
@@ -462,16 +477,23 @@ def cmd_add(args) -> int:
462
477
  qpath = append_quarantine(path, led.quarantined)
463
478
  warn(f"{len(led.quarantined)} pre-existing malformed line(s) quarantined to {qpath} (excluded from rewrite)")
464
479
  accepted = rejected = 0
480
+ derived_keys: list = []
465
481
  for raw in _parse_incoming(args):
466
482
  try:
467
- led.upsert(normalize_record(raw, require_provenance_aspect=args.aspect))
483
+ rec = normalize_record(raw, require_provenance_aspect=args.aspect)
468
484
  except (ValueError, TypeError) as e:
469
485
  rejected += 1
470
486
  warn(f"rejected record ({e}): {json.dumps(raw, ensure_ascii=False)[:200]}")
471
487
  continue
488
+ led.upsert(rec)
489
+ derived_keys.append(rec["key"])
472
490
  accepted += 1
473
491
  led.write(path)
474
492
  banner("add", f"{accepted} record(s) accepted, {rejected} rejected -> {path}")
493
+ if derived_keys:
494
+ # Echo the derived canonical keys so workers cite exactly what the
495
+ # ledger keyed (dataset/alias-derived keys are otherwise guesswork).
496
+ banner("add", f"derived keys: {', '.join(derived_keys)}")
475
497
  return 0
476
498
 
477
499
 
@@ -544,6 +566,8 @@ def cmd_verify(args) -> int:
544
566
  docs = fetch_ncbi_summaries(pmids, timeout=args.timeout) if pmids else {}
545
567
  now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
546
568
  filled = title_fixed = clean = unreachable = 0
569
+ already = sum(1 for r in led.records()
570
+ if r.get("type") in verifiable and (r.get("ids") or {}).get("pmid") and r.get("verified"))
547
571
  skipped = sum(1 for r in led.records() if r.get("type") not in verifiable)
548
572
  for rec in led.records():
549
573
  if rec.get("type") not in verifiable:
@@ -599,6 +623,7 @@ def cmd_verify(args) -> int:
599
623
  f"{len(docs)} PubMed record(s) checked; {filled} backfilled, {title_fixed} title(s) set, "
600
624
  f"{clean} verified clean, {unreachable} unreachable (fail-safe, left unverified); "
601
625
  f"{skipped} record(s) of unverified type(s) skipped (no verifier configured)"
626
+ + (f"; {already} already verified (not rechecked)" if already else "")
602
627
  + ("; --apply written" if args.apply else " (dry-run, no changes written)"),
603
628
  )
604
629
  return 0
@@ -696,6 +721,14 @@ def _close(s: str) -> str:
696
721
  return s if s.endswith(".") else s + "."
697
722
 
698
723
 
724
+ def _close_title(v) -> str:
725
+ """Render a title and close with a single period (registry titles often
726
+ already end with one - never emit 'Title..')."""
727
+ if v in (None, ""):
728
+ return f"[MISSING field: title]"
729
+ return _close(str(v).strip().rstrip("."))
730
+
731
+
699
732
  def render_article(rec: dict, expand: bool) -> str:
700
733
  ids = rec.get("ids") or {}
701
734
  title = _close(_need(rec, "title").strip())
@@ -716,7 +749,7 @@ def render_trial(rec: dict) -> str:
716
749
  ids = rec.get("ids") or {}
717
750
  meta = rec.get("meta") or {}
718
751
  nct = ids.get("nct") or "[MISSING field: ids.nct]"
719
- out = f"{nct}: {_need(rec, 'title')}."
752
+ out = f"{nct}: {_close_title(rec.get('title'))}"
720
753
  phase = str(meta.get("phase") or "").strip().rstrip(".")
721
754
  if phase:
722
755
  # Workers copy phase verbatim from biomcp/CTgov ("Phase 2", "PHASE3",
@@ -771,7 +804,7 @@ def render_drug(rec: dict) -> str:
771
804
  "[MISSING field: ids.chembl|chebi|unii]")
772
805
  ind = f" Indication: {meta['indication']}." if meta.get("indication") else ""
773
806
  url = rec.get("url") or ""
774
- return f"{_need(rec, 'title')}.{ind} {dbid}. {url}".strip()
807
+ return f"{_close_title(rec.get('title'))}{ind} {dbid}. {url}".strip()
775
808
 
776
809
 
777
810
  def render_disease(rec: dict) -> str:
@@ -903,6 +936,298 @@ def cmd_stats(args) -> int:
903
936
  return 0
904
937
 
905
938
 
939
+ # ---------------------------------------------------------------------------
940
+ # Cite-key rendering (draft -> numbered report; the single numbering authority)
941
+ # ---------------------------------------------------------------------------
942
+
943
+ # A cite-key marker: [@ns:value] or [@a; @b]. A bracket is a citation group
944
+ # only if EVERY non-empty token is a recognized namespace with a shape-valid
945
+ # value; anything else (prose [@home], pandoc [@Chapman2011], [@gene:BRAF]
946
+ # symbols) passes through verbatim.
947
+ MARKER_RE = re.compile(r"\[@([^\[\]]+)\]")
948
+
949
+ # Canonical value shapes per key namespace (see KEY_NAMESPACES). Namespaces
950
+ # without an entry accept any non-empty value.
951
+ NS_VALUE_SHAPES = {
952
+ "pmid": re.compile(r"^\d+$"),
953
+ "doi": re.compile(r"^10\.\S+$", re.IGNORECASE),
954
+ "pmcid": re.compile(r"^PMC\d+$", re.IGNORECASE),
955
+ "nct": re.compile(r"^NCT\d+$", re.IGNORECASE),
956
+ "patent": re.compile(r"^[A-Z]{2}\d+", re.IGNORECASE),
957
+ "geo": re.compile(r"^GS[ED]\d+$", re.IGNORECASE),
958
+ "sra": re.compile(r"^SR[RP]\d+$", re.IGNORECASE),
959
+ "gb": re.compile(r"^[A-Z]{2,}\d+(\.\d+)?$", re.IGNORECASE),
960
+ "gene": re.compile(r"^\d+$"),
961
+ "clinvar": re.compile(r"^\d+$"),
962
+ "chembl": re.compile(r"^CHEMBL\d+$", re.IGNORECASE),
963
+ "chebi": re.compile(r"^CHEBI[:_ ]?\d+$", re.IGNORECASE),
964
+ "unii": re.compile(r"^[A-Z0-9]{4,10}$", re.IGNORECASE),
965
+ "mondo": re.compile(r"^MONDO[:_ ]?\d+$", re.IGNORECASE),
966
+ "doid": re.compile(r"^DOID[:_ ]?\d+$", re.IGNORECASE),
967
+ "omim": re.compile(r"^\d{5,7}$"),
968
+ "efo": re.compile(r"^[A-Z]{2,}[_:]?\d+", re.IGNORECASE),
969
+ "url": re.compile(r"^[0-9a-f]{16}$", re.IGNORECASE),
970
+ "title": re.compile(r"^[0-9a-f]{16}$", re.IGNORECASE),
971
+ }
972
+
973
+ REFS_STRIP_RE = re.compile(
974
+ r"(?ims)^#{1,6}[ \t]*(?:references?|bibliography|literature cited|citations)\b[^\n]*\n"
975
+ r".*?(?=\n#{1,6}[ \t]*\S|\Z)"
976
+ )
977
+
978
+ # Hand-typed numeric citation brackets (render's input is authored with
979
+ # [@key] markers, so any [N]/[N, M]/[N-M] bracket in a draft is suspect).
980
+ # Negative lookarounds exclude markdown links [1](url), reference defs [1]:,
981
+ # and wikilinks [[1,2]].
982
+ HAND_TYPED_NUM_RE = re.compile(r"(?<!\[)\[\d{1,3}(?:\s*[,\u2013\-]\s*\d{1,3})*\](?![:(\[])")
983
+
984
+ CODE_BLOCK_RE = re.compile(r"(?ms)^(?:```|~~~)[^\n]*\n.*?^(?:```|~~~)[ \t]*$")
985
+
986
+
987
+ def mask_code_blocks(text: str) -> str:
988
+ """Fenced code blocks -> same-length newline filler (offsets preserved), so
989
+ section detection and marker scans never fire on example/documentation
990
+ content inside fences."""
991
+ return CODE_BLOCK_RE.sub(lambda m: "\n" * (m.end() - m.start()), text)
992
+
993
+
994
+ def classify_token(token: str):
995
+ """Classify a marker token: ('cite', ns, value) | ('shape', ns, value) | ('plain', tok, None)."""
996
+ tok = token.strip()
997
+ if tok.startswith("@"):
998
+ tok = tok[1:].strip()
999
+ if ":" not in tok:
1000
+ return ("plain", tok, None)
1001
+ ns, _, value = tok.partition(":")
1002
+ ns, value = ns.strip().lower(), value.strip()
1003
+ if not value or ns not in KEY_NAMESPACES:
1004
+ return ("plain", tok, None)
1005
+ shape = NS_VALUE_SHAPES.get(ns)
1006
+ if shape is None or shape.match(value):
1007
+ return ("cite", ns, value)
1008
+ return ("shape", ns, value)
1009
+
1010
+
1011
+ def _canonical_marker_key(ns: str, value: str) -> str:
1012
+ try:
1013
+ return f"{ns}:{_canonicalize_id_value(ns, value)}"
1014
+ except ValueError:
1015
+ return f"{ns}:{value}"
1016
+
1017
+
1018
+ def resolve_key(led: Ledger, ns: str, value: str):
1019
+ """Resolve a marker key to a ledger key: direct -> secondary-id twin -> None."""
1020
+ cand = _canonical_marker_key(ns, value)
1021
+ if cand in led.by_key:
1022
+ return cand
1023
+ if ns in ("doi", "pmcid"):
1024
+ # merge promotes doi:/pmcid: twins to their pmid key; the marker may
1025
+ # legitimately cite the pre-promotion namespace.
1026
+ canon_val = cand.split(":", 1)[1].lower()
1027
+ twin = led.sec_index.get(f"{ns}:{canon_val}")
1028
+ if twin and twin in led.by_key:
1029
+ return twin
1030
+ return None
1031
+
1032
+
1033
+ def _compress_numbers(nums: list) -> str:
1034
+ """[1,2,3,5] -> '1-3, 5'; [1,2] -> '1, 2'."""
1035
+ nums = sorted(set(nums))
1036
+ parts, i = [], 0
1037
+ while i < len(nums):
1038
+ j = i
1039
+ while j + 1 < len(nums) and nums[j + 1] == nums[j] + 1:
1040
+ j += 1
1041
+ if j - i >= 2:
1042
+ parts.append(f"{nums[i]}-{nums[j]}")
1043
+ elif j == i + 1:
1044
+ parts.append(f"{nums[i]}, {nums[j]}")
1045
+ else:
1046
+ parts.append(str(nums[i]))
1047
+ i = j + 1
1048
+ return ", ".join(parts)
1049
+
1050
+
1051
+ def cmd_render(args) -> int:
1052
+ led = read_ledger(Path(args.ledger))
1053
+ text = Path(args.draft).read_text(encoding="utf-8-sig")
1054
+ out_path = Path(args.out)
1055
+
1056
+ # Strip pre-existing References-like sections, fence-aware: detect on a
1057
+ # code-block-masked copy (offsets preserved), cut from the real text in
1058
+ # reverse so earlier spans stay valid.
1059
+ masked = mask_code_blocks(text)
1060
+ had_entries = False
1061
+ for m in reversed(list(REFS_STRIP_RE.finditer(masked))):
1062
+ chunk = text[m.start():m.end()]
1063
+ if re.search(r"(?m)^\s*[-*]?\s*\[\d+\]", chunk):
1064
+ had_entries = True
1065
+ if "[@" in chunk:
1066
+ warn("a pre-existing References-like section contained [@key] marker(s); "
1067
+ "the section was stripped - cite those sources in the body instead")
1068
+ text = text[:m.start()] + text[m.end():]
1069
+ body = text.rstrip() + "\n"
1070
+
1071
+ failures: list = []
1072
+ numbers: dict = {}
1073
+ order: list = []
1074
+
1075
+ def render_marker(bracket: str) -> str:
1076
+ inner = bracket[2:-1] if bracket.endswith("]") else bracket[2:]
1077
+ tokens = [t for t in inner.split(";") if t.strip()]
1078
+ kinds = [classify_token(t) for t in tokens]
1079
+ cites = [k for k in kinds if k[0] == "cite"]
1080
+ if not tokens or not cites:
1081
+ for kind, ns, _ in kinds:
1082
+ if kind == "shape":
1083
+ warn(f"bracket {bracket!r} uses the {ns}: namespace but its value is not shape-valid; left verbatim")
1084
+ return bracket
1085
+ if any(k[0] != "cite" for k in kinds):
1086
+ # A group with at least one real cite-key must not silently drop
1087
+ # its non-citation tokens - that would lose citations quietly.
1088
+ failures.append(f"mixed citation group {bracket!r}: every token must be a cite-key")
1089
+ return bracket
1090
+ keys = []
1091
+ for _, ns, value in cites:
1092
+ key = resolve_key(led, ns, value)
1093
+ if key is None:
1094
+ suggestions = [k for k in sorted(led.by_key) if k.startswith(ns + ":")][:5]
1095
+ failures.append(
1096
+ f"unknown citation key {ns}:{value}"
1097
+ + (f" (did you mean: {', '.join(suggestions)}?)" if suggestions else "")
1098
+ )
1099
+ continue
1100
+ keys.append(key)
1101
+ if failures:
1102
+ return bracket
1103
+ for key in keys:
1104
+ if key not in numbers:
1105
+ numbers[key] = len(numbers) + 1
1106
+ order.append(key)
1107
+ return "[" + _compress_numbers([numbers[k] for k in keys]) + "]"
1108
+
1109
+ # Marker substitution, fence-aware: scan the masked copy (offsets equal),
1110
+ # splice replacements into the real body.
1111
+ masked_body = mask_code_blocks(body)
1112
+ # Hand-typed numeric brackets in the DRAFT are the residual leak class:
1113
+ # render owns numbering, so warn loudly (non-fatal - prose ranges like
1114
+ # [140, 155] are legitimate; links/wikilinks/reference defs are excluded).
1115
+ hand_typed = [m.start() for m in HAND_TYPED_NUM_RE.finditer(masked_body)]
1116
+ if hand_typed:
1117
+ lines = sorted({masked_body.count("\n", 0, pos) + 1 for pos in hand_typed[:5]})
1118
+ warn(f"draft contains {len(hand_typed)} hand-typed numeric citation bracket(s) "
1119
+ f"(first at line(s) {lines}): render owns numbering - remove hand-typed [N] brackets from the draft")
1120
+ parts: list = []
1121
+ last = 0
1122
+ for m in MARKER_RE.finditer(masked_body):
1123
+ parts.append(body[last:m.start()])
1124
+ parts.append(render_marker(body[m.start():m.end()]))
1125
+ last = m.end()
1126
+ parts.append(body[last:])
1127
+ rendered_body = "".join(parts)
1128
+
1129
+ # Double-render guard: an already-rendered document has no markers left.
1130
+ if not order and had_entries:
1131
+ banner("render", "FAILED: no [@key] citation markers found, but a References section with entries was present "
1132
+ "(already-rendered document?); nothing written")
1133
+ return 1
1134
+ if failures:
1135
+ for f in failures:
1136
+ warn(f"unresolved citation: {f}")
1137
+ banner("render", f"FAILED: {len(failures)} unresolved citation key(s); nothing written")
1138
+ return 1
1139
+
1140
+ entries = []
1141
+ missing = []
1142
+ for key in order:
1143
+ entry = render_record(led.by_key[key], args.expand_pages)
1144
+ if "[MISSING" in entry:
1145
+ missing.append(key)
1146
+ entries.append(f"[{numbers[key]}] {entry}")
1147
+ if missing:
1148
+ for key in missing:
1149
+ warn(f"record {key} would render with [MISSING ...] markers (incomplete ledger record)")
1150
+ banner("render", f"FAILED: {len(missing)} record(s) render with [MISSING ...] gaps "
1151
+ f"({', '.join(missing[:5])}{' ...' if len(missing) > 5 else ''}); nothing written")
1152
+ return 1
1153
+
1154
+ out_path.parent.mkdir(parents=True, exist_ok=True)
1155
+ refs = "\n".join(entries) if entries else "(no cited sources)"
1156
+ out_path.write_text(rendered_body + "\n## References\n\n" + refs + "\n", encoding="utf-8")
1157
+ banner("render", f"{len(order)} citation(s) numbered, {len(entries)} reference(s) rendered from {args.ledger} -> {args.out}")
1158
+ return 0
1159
+
1160
+
1161
+ def _check_markers(led: Ledger, paths: list) -> int:
1162
+ """Cross-validate [@key] markers in markdown files against the ledger.
1163
+
1164
+ Mirrors render's group semantics exactly: groups with zero cite tokens are
1165
+ prose (ignored); all-cite groups must fully resolve (direct or sec_index);
1166
+ groups mixing >=1 cite token with any non-cite token are failures;
1167
+ shape-kind tokens (recognized namespace, invalid shape) warn only.
1168
+ Returns the number of failures; a missing marker file counts as one
1169
+ (never a vacuous pass)."""
1170
+ failures = 0
1171
+ for raw in paths:
1172
+ p = Path(raw)
1173
+ if not p.is_file():
1174
+ banner("check", f"markers: marker file not found: {p}")
1175
+ failures += 1
1176
+ continue
1177
+ text = p.read_text(encoding="utf-8-sig")
1178
+ masked = mask_code_blocks(text)
1179
+ groups = resolved = 0
1180
+ for m in MARKER_RE.finditer(masked):
1181
+ tokens = [t for t in m.group(1).split(";") if t.strip()]
1182
+ kinds = [classify_token(t) for t in tokens]
1183
+ cites = [k for k in kinds if k[0] == "cite"]
1184
+ if not tokens or not cites:
1185
+ continue # prose bracket - render leaves it verbatim
1186
+ line = masked.count("\n", 0, m.start()) + 1
1187
+ groups += 1
1188
+ if any(k[0] != "cite" for k in kinds):
1189
+ warn(f"{p.name}:{line}: mixed citation group {m.group(0)!r}: every token must be a cite-key")
1190
+ failures += 1
1191
+ continue
1192
+ for kind, ns, value in cites:
1193
+ if kind == "shape":
1194
+ warn(f"{p.name}:{line}: bracket {m.group(0)!r} uses the {ns}: namespace but its value is not shape-valid (left verbatim)")
1195
+ continue
1196
+ key = resolve_key(led, ns, value)
1197
+ if key is None:
1198
+ warn(f"{p.name}:{line}: unresolved marker {ns}:{value} (no matching ledger record)")
1199
+ failures += 1
1200
+ else:
1201
+ resolved += 1
1202
+ print(f"markers[{p.name}]: {groups} citation group(s), {resolved} marker(s) resolved")
1203
+ return failures
1204
+
1205
+
1206
+ def cmd_check(args) -> int:
1207
+ markers = getattr(args, "markers", None) or []
1208
+ led = read_ledger(Path(args.file))
1209
+ types: dict = {}
1210
+ for rec in led.records():
1211
+ types[rec.get("type", "?")] = types.get(rec.get("type", "?"), 0) + 1
1212
+ for k in sorted(led.by_key):
1213
+ print(k)
1214
+ status = "OK" if not led.quarantined else "FAIL"
1215
+ banner("check", f"{len(led.by_key)} record(s) across {len(types)} type(s) "
1216
+ f"({', '.join(f'{t}:{c}' for t, c in sorted(types.items())) or 'none'}); "
1217
+ f"quarantined {len(led.quarantined)}; {status}")
1218
+ if led.quarantined:
1219
+ for q in led.quarantined:
1220
+ warn(f"quarantined {q.get('file')}:{q.get('line')}: {q.get('error')}")
1221
+ return 1
1222
+ if markers:
1223
+ marker_failures = _check_markers(led, markers)
1224
+ banner("check", f"markers: {marker_failures} problem(s) across {len(markers)} file(s); "
1225
+ f"{'FAIL' if marker_failures else 'OK'}")
1226
+ if marker_failures:
1227
+ return 1
1228
+ return 0
1229
+
1230
+
906
1231
  # ---------------------------------------------------------------------------
907
1232
  # Hermetic selftest (CI; no network)
908
1233
  # ---------------------------------------------------------------------------
@@ -1345,6 +1670,230 @@ def selftest() -> int:
1345
1670
  assert len(led3.by_key) == 1 and not led3.quarantined, f"BOM re-read failed: {led3.quarantined}"
1346
1671
  check("batch/title-rules", st_batch_and_title_rules)
1347
1672
 
1673
+ # ---- name -> title fold (biomcp `name`-shaped records) -------------
1674
+ def st_name_fold():
1675
+ f = d / "nf.jsonl"
1676
+ # drug carrying `name` instead of `title` (biomcp drug_get shape)
1677
+ drug = {"type": "drug", "ids": {"chembl": "CHEMBL1229517"}, "name": "vemurafenib",
1678
+ "meta": {"indication": "BRAF V600E-mutant melanoma"}, "provenance": [{"aspect": "a"}]}
1679
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(drug), stdin=False, aspect=None))
1680
+ led = read_ledger(f)
1681
+ assert "chembl:CHEMBL1229517" in led.by_key, "name-bearing drug not accepted"
1682
+ assert led.by_key["chembl:CHEMBL1229517"]["title"] == "vemurafenib", "name not folded into title"
1683
+ # fold is idempotent across re-reads and preserves the original field
1684
+ before = json.dumps(led.by_key["chembl:CHEMBL1229517"], sort_keys=True)
1685
+ after = json.dumps(read_ledger(f).by_key["chembl:CHEMBL1229517"], sort_keys=True)
1686
+ assert before == after, "name fold not idempotent on re-read"
1687
+ assert led.by_key["chembl:CHEMBL1229517"].get("name") == "vemurafenib", "original name field lost"
1688
+ # gene name-only now accepted too (acceptance widening, CHANGELOG-noted)
1689
+ gene = {"type": "gene", "ids": {"entrez_id": "673"}, "name": "B-Raf proto-oncogene",
1690
+ "provenance": [{"aspect": "a"}]}
1691
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(gene), stdin=False, aspect=None))
1692
+ assert "gene:673" in read_ledger(f).by_key, "name-bearing gene not accepted"
1693
+ # article keeps its hard id requirement: name-only rejected
1694
+ _capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "article", "name": "x", "ids": {}}', stdin=False, aspect=None))
1695
+ # web/dataset keep their hard identity fields: name-only rejected
1696
+ _capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "web", "name": "Just a page", "ids": {}}', stdin=False, aspect=None))
1697
+ _capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "dataset", "name": "Just a series", "ids": {}}', stdin=False, aspect=None))
1698
+ keys = set(read_ledger(f).by_key)
1699
+ assert not any(k.startswith("title:") for k in keys), "name-only article/web/dataset must not derive title keys"
1700
+ assert len(keys) == 2, f"name-fold acceptance boundary wrong: {sorted(keys)}"
1701
+ # renders clean (no [MISSING field: title])
1702
+ _, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="chembl:CHEMBL1229517", expand_pages=False, offset=0))
1703
+ assert "[MISSING" not in out.splitlines()[0], f"drug still renders MISSING: {out.splitlines()[0]}"
1704
+ assert "Vemurafenib" in out.splitlines()[0] or "vemurafenib" in out.splitlines()[0], out.splitlines()[0]
1705
+ check("name-fold", st_name_fold)
1706
+
1707
+ # ---- render: cite-key markers -> numbered citations + References ---
1708
+ def _render_ledger(d):
1709
+ f = d / "render.jsonl"
1710
+ batch = [
1711
+ _fixture_article(),
1712
+ _fixture_article(pmid="30000001", doi="10.1000/r1", title="Render one", ids={"pmid": "30000001", "doi": "10.1000/r1", "pmcid": "PMC3000001"}),
1713
+ _fixture_article(pmid="30000002", doi="10.1000/r2", title="Render two", ids={"pmid": "30000002", "doi": "10.1000/r2", "pmcid": "PMC3000002"}),
1714
+ _fixture_article(pmid="30000003", doi="10.1000/r3", title="Render three", ids={"pmid": "30000003", "doi": "10.1000/r3", "pmcid": "PMC3000003"}),
1715
+ {"key": "nct:NCT04280705", "type": "trial", "ids": {"nct": "NCT04280705"},
1716
+ "title": "Encorafenib Plus Cetuximab", "meta": {"phase": "Phase 2", "sponsor": "Pfizer", "status": "Completed"},
1717
+ "provenance": [{"aspect": "a"}]},
1718
+ {"type": "drug", "ids": {"chembl": "CHEMBL1229517"}, "name": "vemurafenib",
1719
+ "meta": {"indication": "BRAF V600E-mutant melanoma"}, "provenance": [{"aspect": "a"}]},
1720
+ ]
1721
+ old_stdin, sys.stdin = sys.stdin, io.StringIO(json.dumps(batch))
1722
+ try:
1723
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=None, stdin=True, aspect=None))
1724
+ finally:
1725
+ sys.stdin = old_stdin
1726
+ return f
1727
+
1728
+ def st_render():
1729
+ f = _render_ledger(d)
1730
+ draft = d / "report.draft.md"
1731
+ draft.write_text(
1732
+ "# Report\n\n"
1733
+ "Vemurafenib [@chembl:CHEMBL1229517] improves survival [@pmid:21639808].\n\n"
1734
+ "Prose [@home] and pandoc [@Chapman2011] and symbol-ish [@gene:BRAF] stay verbatim.\n\n"
1735
+ "Trial plus article [@nct:NCT04280705; @pmid:21639808] group. Again [@pmid:21639808].\n\n"
1736
+ "Three more [@pmid:30000001; @pmid:30000002; @pmid:30000003] in one group.\n",
1737
+ encoding="utf-8",
1738
+ )
1739
+ out = d / "final.md"
1740
+ rc, stdout = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(draft), out=str(out), expand_pages=False))
1741
+ assert rc == 0, f"render failed: {stdout}"
1742
+ text = out.read_text(encoding="utf-8")
1743
+ assert "Vemurafenib [1] improves survival [2]." in text, f"numbering wrong:\n{text}"
1744
+ assert "[@home]" in text and "[@Chapman2011]" in text and "[@gene:BRAF]" in text, "prose brackets rewritten"
1745
+ assert "[2, 3]" in text, f"group not sorted/compressed: {text}"
1746
+ assert "Again [2]." in text, "duplicate key not reusing its number"
1747
+ assert "[4-6]" in text, f"consecutive run not range-compressed: {text}"
1748
+ assert "## References" in text and "[1] vemurafenib." in text and "[2] Chapman PB" in text
1749
+ # registry titles ending in a period never render doubled ("Title.. Status")
1750
+ tdot = d / "tdot.jsonl"
1751
+ tdot.write_text(json.dumps({
1752
+ "key": "nct:NCT01844986", "type": "trial", "ids": {"nct": "NCT01844986"},
1753
+ "title": "Olaparib Maintenance Monotherapy in Patients With BRCA Mutated Ovarian Cancer Following First Line Platinum Based Chemotherapy.",
1754
+ "meta": {"status": "ACTIVE_NOT_RECRUITING"},
1755
+ "provenance": [{"aspect": "a"}]}) + "\n", encoding="utf-8")
1756
+ _, tout = _capture(cmd_bib, argparse.Namespace(file=str(tdot), keys="nct:NCT01844986", expand_pages=False, offset=0))
1757
+ tline = tout.splitlines()[0]
1758
+ assert "Chemotherapy. Status:" in tline and ".." not in tline, f"double period rendered: {tline}"
1759
+ assert "[MISSING" not in text
1760
+ refs = text.split("## References", 1)[1]
1761
+ nums = re.findall(r"(?m)^\[(\d+)\]", refs)
1762
+ assert nums == [str(i) for i in range(1, 7)], f"bibliography not 1..N contiguous: {nums}"
1763
+ # idempotent re-render of the SAME draft
1764
+ out2 = d / "final2.md"
1765
+ _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(draft), out=str(out2), expand_pages=False))
1766
+ assert out2.read_text(encoding="utf-8") == text, "re-render of the same draft is not idempotent"
1767
+ # sec-index: doi: marker resolves to the promoted pmid twin
1768
+ doi_draft = d / "doi.draft.md"
1769
+ doi_draft.write_text("Only one [@doi:10.1056/nejmoa1103782].\n", encoding="utf-8")
1770
+ doi_out = d / "doi.md"
1771
+ rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(doi_draft), out=str(doi_out), expand_pages=False))
1772
+ dtext = doi_out.read_text(encoding="utf-8")
1773
+ assert rc == 0 and "Only one [1]." in dtext and "PMID: 21639808." in dtext, f"doi twin resolution failed: {dtext}"
1774
+ assert len(re.findall(r"(?m)^\[\d+\]", dtext.split("## References", 1)[1])) == 1
1775
+ # unknown key: exit 1, output NOT written
1776
+ bad = d / "bad.draft.md"
1777
+ bad.write_text("Broken [@pmid:99999999].\n", encoding="utf-8")
1778
+ bad_out = d / "bad.md"
1779
+ rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(bad), out=str(bad_out), expand_pages=False))
1780
+ assert rc != 0 and not bad_out.exists(), "unknown key must exit 1 without writing output"
1781
+ # [MISSING ...] entry: exit 1, output NOT written
1782
+ tonly = {"type": "trial", "title": "Title-only degraded trial", "provenance": [{"aspect": "a"}]}
1783
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(tonly), stdin=False, aspect=None))
1784
+ tkey = next(k for k in read_ledger(f).by_key if k.startswith("title:"))
1785
+ miss = d / "miss.draft.md"
1786
+ miss.write_text(f"Degraded [@{tkey}] cite.\n", encoding="utf-8")
1787
+ miss_out = d / "miss.md"
1788
+ rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(miss), out=str(miss_out), expand_pages=False))
1789
+ assert rc != 0 and not miss_out.exists(), "MISSING-rendering record must exit 1 without writing output"
1790
+ # double-render guard: rendering an already-rendered report fails
1791
+ rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(out), out=str(d / "double.md"), expand_pages=False))
1792
+ assert rc != 0, "double-render must fail (no markers, References present)"
1793
+ assert not (d / "double.md").exists()
1794
+ # fence-awareness: a fenced example References section / marker is
1795
+ # never stripped and never numbered
1796
+ fence_draft = d / "fence.draft.md"
1797
+ fence_draft.write_text(
1798
+ "# F\n\nReal cite [@pmid:21639808].\n\nExample (do not touch):\n\n```\n"
1799
+ "## References\n\n[1] example entry.\n\nCite like [@pmid:21639808].\n```\n\nTail kept.\n",
1800
+ encoding="utf-8",
1801
+ )
1802
+ fence_out = d / "fence.md"
1803
+ rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(fence_draft), out=str(fence_out), expand_pages=False))
1804
+ ftext = fence_out.read_text(encoding="utf-8")
1805
+ assert rc == 0, "fenced content must not break render"
1806
+ assert "## References\n\n[1] example entry." in ftext, "fenced References example was stripped"
1807
+ assert "[@pmid:21639808]" in ftext, "marker inside a fence was rewritten"
1808
+ assert "Tail kept." in ftext, "content after a fenced References example was truncated"
1809
+ assert "Real cite [1]." in ftext and ftext.rstrip().endswith("[1] Chapman PB, Hauschild A, Robert C, et al Improved survival with vemurafenib in melanoma with BRAF V600E mutation. N Engl J Med. 2011;364(26):2507-16. DOI: 10.1056/nejmoa1103782. PMID: 21639808."), \
1810
+ f"real marker/References wrong:\n{ftext}"
1811
+ # mixed citation group: at least one cite-key + a non-citation token
1812
+ # must hard-fail (never silently drop the citation)
1813
+ mix = d / "mix.draft.md"
1814
+ mix.write_text("Mixed [@pmid:21639808; see note] group.\n", encoding="utf-8")
1815
+ mix_out = d / "mix.md"
1816
+ rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(mix), out=str(mix_out), expand_pages=False))
1817
+ assert rc != 0 and not mix_out.exists(), "mixed citation group must exit 1 without writing output"
1818
+ check("render", st_render)
1819
+
1820
+ # ---- check: worker validity gate -----------------------------------
1821
+ def st_check():
1822
+ f = d / "ck.jsonl"
1823
+ f.write_text("", encoding="utf-8")
1824
+ rc, out = _capture(cmd_check, argparse.Namespace(file=str(f)))
1825
+ assert rc == 0 and "0 record(s)" in out and "quarantined 0" in out, f"empty ledger must pass: {out}"
1826
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
1827
+ rc, out = _capture(cmd_check, argparse.Namespace(file=str(f)))
1828
+ assert rc == 0 and "1 record(s)" in out and "pmid:21639808" in out and "; OK" in out, out
1829
+ bad = d / "ck2.jsonl"
1830
+ bad.write_text(json.dumps(_fixture_article()) + "\nnot json\n", encoding="utf-8")
1831
+ rc, out = _capture(cmd_check, argparse.Namespace(file=str(bad)))
1832
+ assert rc == 1 and "quarantined 1" in out and "FAIL" in out, f"quarantined ledger must fail: {out}"
1833
+ check("check", st_check)
1834
+
1835
+ # ---- check --markers: worker marker cross-validation gate ------------
1836
+ def st_check_markers():
1837
+ f = d / "ckm.jsonl"
1838
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
1839
+ good = d / "good.md"
1840
+ good.write_text(
1841
+ "Real [@pmid:21639808] plus its doi twin [@doi:10.1056/NEJMOA1103782].\n"
1842
+ "Prose [@home] and shape-ish [@gene:BRAF] stay verbatim.\n"
1843
+ "CI text [95% CI 78-89] and a link [1](http://x) are not citations.\n"
1844
+ "Fenced example:\n\n```\n[@pmid:99999999]\n```\n",
1845
+ encoding="utf-8",
1846
+ )
1847
+ rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(good)]))
1848
+ assert rc == 0, f"resolvable markers must pass:\n{out}"
1849
+ assert "markers[good.md]: 2 citation group(s), 2 marker(s) resolved" in out
1850
+ assert "markers: 0 problem(s) across 1 file(s); OK" in out
1851
+ # unresolved marker -> exit 1
1852
+ bad = d / "bad.md"
1853
+ bad.write_text("Broken [@pmid:99999999] cite.\n", encoding="utf-8")
1854
+ buf = io.StringIO()
1855
+ with contextlib.redirect_stderr(buf):
1856
+ rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(bad)]))
1857
+ assert rc == 1 and "unresolved marker pmid:99999999" in buf.getvalue(), out + buf.getvalue()
1858
+ # mixed group (cite + plain token) -> exit 1, mirroring render
1859
+ mix = d / "mix.md"
1860
+ mix.write_text("Mixed [@pmid:21639808; see note] group.\n", encoding="utf-8")
1861
+ buf = io.StringIO()
1862
+ with contextlib.redirect_stderr(buf):
1863
+ rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(mix)]))
1864
+ assert rc == 1 and "mixed citation group" in buf.getvalue(), out + buf.getvalue()
1865
+ # missing marker file -> exit 1 (never a vacuous pass)
1866
+ rc, out = _capture(cmd_check, argparse.Namespace(file=str(f), markers=[str(d / "nope.md")]))
1867
+ assert rc == 1 and "marker file not found" in out, out
1868
+ check("check-markers", st_check_markers)
1869
+
1870
+ # ---- render: hand-typed numeric bracket warning (non-fatal) ---------
1871
+ def st_render_handtyped_warning():
1872
+ f = d / "ht.jsonl"
1873
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
1874
+ draft = d / "ht.draft.md"
1875
+ draft.write_text(
1876
+ "Clean [@pmid:21639808] marker.\n\nBut a hand-typed [1] leak and [2, 3] too.\n\n"
1877
+ "Not citations: [95% CI 78-89], link [4](http://x), wiki [[5, 6]], fence:\n\n```\n[7]\n```\n",
1878
+ encoding="utf-8",
1879
+ )
1880
+ out_path = d / "ht.md"
1881
+ buf_err = io.StringIO()
1882
+ with contextlib.redirect_stderr(buf_err):
1883
+ rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(draft), out=str(out_path), expand_pages=False))
1884
+ err = buf_err.getvalue()
1885
+ assert rc == 0, "hand-typed brackets must NOT fail render"
1886
+ assert "hand-typed numeric citation bracket" in err and "line(s) [3]" in err, err
1887
+ assert "[7]" not in err.replace("line(s)", ""), "fenced [7] must not warn"
1888
+ # clean draft: no warning at all
1889
+ clean = d / "ht2.draft.md"
1890
+ clean.write_text("Only [@pmid:21639808] here.\n", encoding="utf-8")
1891
+ buf_err2 = io.StringIO()
1892
+ with contextlib.redirect_stderr(buf_err2):
1893
+ rc, _ = _capture(cmd_render, argparse.Namespace(ledger=str(f), draft=str(clean), out=str(d / "ht2.md"), expand_pages=False))
1894
+ assert rc == 0 and "hand-typed" not in buf_err2.getvalue(), buf_err2.getvalue()
1895
+ check("render-handtyped-warning", st_render_handtyped_warning)
1896
+
1348
1897
  failed = [r for r in results if r[1] is not None]
1349
1898
  for name, err in results:
1350
1899
  print(f"{'PASS' if err is None else 'FAIL'} {name}" + (f": {err}" if err else ""))
@@ -1394,6 +1943,19 @@ def main() -> int:
1394
1943
  p.add_argument("file")
1395
1944
  p.set_defaults(fn=cmd_stats)
1396
1945
 
1946
+ p = sub.add_parser("render", help="Number cite-key markers in a draft and append the References section")
1947
+ p.add_argument("ledger", help="merged ledger file (evidence/sources.jsonl)")
1948
+ p.add_argument("draft", help="markdown draft authored with [@key] markers")
1949
+ p.add_argument("-o", "--out", required=True, help="output report path (never written on failure)")
1950
+ p.add_argument("--expand-pages", action="store_true", help="expand abbreviated page ranges (2507-16 -> 2507-2516)")
1951
+ p.set_defaults(fn=cmd_render)
1952
+
1953
+ p = sub.add_parser("check", help="Validate a ledger file (exit 1 on quarantined lines; with --markers also on unresolved/mixed markers)")
1954
+ p.add_argument("file")
1955
+ p.add_argument("--markers", nargs="+", metavar="MD",
1956
+ help="markdown file(s) whose [@key] markers must resolve in this ledger")
1957
+ p.set_defaults(fn=cmd_check)
1958
+
1397
1959
  p = sub.add_parser("selftest", help="Hermetic feature-matrix selftest (no network)")
1398
1960
  p.set_defaults(fn=lambda a: selftest())
1399
1961