opencode-bioresearcher 1.8.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.
@@ -0,0 +1,1974 @@
1
+ #!/usr/bin/env python3
2
+ """Structured evidence ledger for deep-research citation integrity.
3
+
4
+ Workers append one JSON record per potentially-citable source as they search
5
+ (per-aspect JSONL files, fields copied verbatim from biomcp tool output); the
6
+ orchestrator merges, verifies against NCBI esummary, and exports the bibliography.
7
+
8
+ Contract:
9
+ - Fail-safe: network/API failure exits 0 and preserves data unchanged.
10
+ - Fill-missing-only merging: never overwrite a non-null stored value.
11
+ - Loud gaps: missing fields render as [MISSING field: ...]; unknown bib keys as
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).
20
+ - Verb banners: every subcommand prints "[evidence-ledger] <verb>: ..." so
21
+ test graders can anchor on deterministic stdout.
22
+
23
+ Zero external dependencies (pure Python standard library).
24
+ """
25
+
26
+ import argparse
27
+ import contextlib
28
+ import datetime as _dt
29
+ import glob as _glob
30
+ import hashlib
31
+ import html as _html
32
+ import io
33
+ import json
34
+ import re
35
+ import sys
36
+ import tempfile
37
+ import time
38
+ from pathlib import Path
39
+
40
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
41
+ from ncbi_esummary import fetch_ncbi_summaries # noqa: E402
42
+
43
+ SCHEMA = "bioresearcher-evidence/1"
44
+ LEDGER_TYPES = {
45
+ "article", "trial", "patent", "gene", "variant",
46
+ "drug", "disease", "dataset", "web", "other",
47
+ }
48
+ KEY_NAMESPACES = (
49
+ "pmid", "doi", "pmcid", "nct", "patent", "geo", "sra", "gb",
50
+ "gene", "clinvar", "chembl", "chebi", "unii",
51
+ "mondo", "doid", "omim", "efo", "url", "title",
52
+ )
53
+
54
+ # biomcp-native id field names -> canonical ids slots (see TYPE_SPECS below).
55
+ # Aliased values are COPIED into the canonical slot (originals stay verbatim);
56
+ # None means "no canonical target: keep under the original key, never fold".
57
+ ID_ALIASES = {
58
+ "nct_id": "nct", # biomcp trial_search
59
+ "ncbi_gene_id": "ncbi_gene",
60
+ "entrez_id": "ncbi_gene",
61
+ "clinvar_id": "clinvar",
62
+ "clinvarid": "clinvar",
63
+ "rsid": "rs",
64
+ "rs_id": "rs",
65
+ "chembl_id": "chembl",
66
+ "chebi_id": "chebi",
67
+ "hgnc_id": "hgnc",
68
+ "patent_id": "patent",
69
+ "publication_number": "patent",
70
+ "geo_id": "geo",
71
+ "sra_id": "sra",
72
+ "gb_acc": "genbank",
73
+ "disease_id": None, # context-dependent: prefix-sniffed below
74
+ }
75
+
76
+ # Worker-written top-level fields COPIED into meta (fill-only; canonical meta
77
+ # wins; nulls never fold; idempotent under the every-read re-normalization).
78
+ FOLD_FIELDS = {
79
+ "trial": ["phase", "status", "sponsor", "enrollment"],
80
+ "drug": ["indication", "source_section"],
81
+ "patent": ["assignee", "status"],
82
+ "gene": ["symbol", "full_name"],
83
+ "variant": ["gene", "protein_change", "significance"],
84
+ }
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Output helpers
89
+ # ---------------------------------------------------------------------------
90
+
91
+ def banner(verb: str, message: str) -> None:
92
+ print(f"[evidence-ledger] {verb}: {message}")
93
+
94
+
95
+ def warn(message: str) -> None:
96
+ print(f"[evidence-ledger] warning: {message}", file=sys.stderr)
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Record schema + normalization
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def _strip_id_prefix(value: str) -> str:
104
+ return re.sub(r"^\s*(?:pmid|pmcid|doi|nct)\s*[:=]\s*", "", str(value), flags=re.IGNORECASE).strip()
105
+
106
+
107
+ def _canonicalize_id_value(key: str, value: str) -> str:
108
+ """Apply the per-key canonicalization rules (case, digits, DOI dots)."""
109
+ if key == "doi":
110
+ return value.lower().rstrip(".")
111
+ if key == "pmid":
112
+ if not value.isdigit():
113
+ raise ValueError(f"pmid must be digits, got {value!r}")
114
+ return value.lstrip("0") or "0"
115
+ if key in ("pmcid", "nct", "patent"):
116
+ return value.upper()
117
+ return value
118
+
119
+
120
+ _DISEASE_NS_RE = re.compile(r"^(MONDO|DOID|OMIM|EFO)[:_\s-]?(\d+)", re.IGNORECASE)
121
+
122
+
123
+ def normalize_ids(ids_raw: dict) -> dict:
124
+ """Canonicalize id values; copy alias slots to canonical keys verbatim-preserving."""
125
+ ids: dict = {}
126
+ for k, v in ids_raw.items():
127
+ if v is None:
128
+ continue
129
+ v = _strip_id_prefix(v)
130
+ if not v:
131
+ continue
132
+ ids[k] = _canonicalize_id_value(k, v)
133
+ # disease_id values carry their ontology in the prefix: sniff it
134
+ if k == "disease_id":
135
+ m = _DISEASE_NS_RE.match(v)
136
+ if m:
137
+ ns, num = m.group(1).upper(), m.group(2)
138
+ slot = {"MONDO": "mondo", "DOID": "doid", "OMIM": "omim", "EFO": "efo"}[ns]
139
+ ids.setdefault(slot, f"{ns}:{num}")
140
+ for k, target in ID_ALIASES.items():
141
+ if target is None or k not in ids_raw:
142
+ continue
143
+ v = ids_raw[k]
144
+ if v is None:
145
+ continue
146
+ v = _strip_id_prefix(v)
147
+ if not v:
148
+ continue
149
+ ids.setdefault(target, _canonicalize_id_value(target, v))
150
+ return ids
151
+
152
+
153
+ def normalize_record(raw, require_provenance_aspect=None) -> dict:
154
+ """Validate + normalize an incoming record. Raises ValueError on bad input."""
155
+ if not isinstance(raw, dict):
156
+ raise ValueError("record must be a JSON object")
157
+ if raw.get("schema") not in (None, SCHEMA):
158
+ raise ValueError(f"unsupported schema {raw.get('schema')!r} (expected {SCHEMA})")
159
+ rtype = raw.get("type")
160
+ if rtype not in LEDGER_TYPES:
161
+ raise ValueError(f"unknown type {rtype!r} (allowed: {', '.join(sorted(LEDGER_TYPES))})")
162
+
163
+ ids_raw = raw.get("ids") or {}
164
+ if not isinstance(ids_raw, dict):
165
+ raise ValueError("ids must be an object")
166
+ ids = normalize_ids(ids_raw)
167
+
168
+ title = raw.get("title")
169
+ if title is not None:
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()
179
+ if not title and not ids:
180
+ raise ValueError("record needs a title or at least one id")
181
+ if raw.get("meta") is not None and not isinstance(raw.get("meta"), dict):
182
+ raise ValueError("meta must be an object")
183
+ if raw.get("authors") is not None and not isinstance(raw.get("authors"), list):
184
+ raise ValueError("authors must be a list of name strings")
185
+
186
+ # Loose-field folding: copy worker-written top-level fields into meta
187
+ # (fill-only; canonical meta wins; nulls never fold; originals preserved).
188
+ meta = dict(raw.get("meta") or {})
189
+ for field in FOLD_FIELDS.get(rtype, ()):
190
+ value = raw.get(field)
191
+ if value in (None, ""):
192
+ continue
193
+ if meta.get(field) in (None, ""):
194
+ meta[field] = value
195
+
196
+ key = canonical_key(raw.get("key"), rtype, ids, title)
197
+
198
+ provenance_raw = raw.get("provenance") or []
199
+ if not isinstance(provenance_raw, list):
200
+ raise ValueError("provenance must be a list")
201
+ provenance = []
202
+ for p in provenance_raw:
203
+ if not isinstance(p, dict) or not p.get("aspect"):
204
+ raise ValueError("every provenance entry needs an 'aspect'")
205
+ provenance.append({
206
+ "aspect": str(p["aspect"]),
207
+ "tool": str(p.get("tool") or "unknown"),
208
+ "args": p.get("args") or {},
209
+ "retrieved_at": str(p.get("retrieved_at") or ""),
210
+ })
211
+ if require_provenance_aspect and not any(p["aspect"] == require_provenance_aspect for p in provenance):
212
+ provenance.append({
213
+ "aspect": require_provenance_aspect, "tool": "unknown", "args": {}, "retrieved_at": "",
214
+ })
215
+
216
+ rec = dict(raw) # preserve extra/meta fields verbatim
217
+ rec.update({"schema": SCHEMA, "key": key, "type": rtype, "ids": ids, "title": title, "meta": meta})
218
+ for opt in ("title_original", "authors", "journal", "year", "volume", "issue", "pages", "url"):
219
+ rec.setdefault(opt, None)
220
+ rec.setdefault("verified", False)
221
+ rec.setdefault("verified_source", None)
222
+ rec.setdefault("verified_at", None)
223
+ if not isinstance(rec.get("backfilled"), list):
224
+ rec["backfilled"] = []
225
+ rec["provenance"] = provenance
226
+ return rec
227
+
228
+
229
+ def _title_key(rtype: str, title) -> str:
230
+ digest = hashlib.sha256(f"{rtype}:{title}".encode("utf-8")).hexdigest()[:16]
231
+ return f"title:{digest}"
232
+
233
+
234
+ def derive_dataset_key(ids: dict, title) -> str:
235
+ for v in ids.values():
236
+ v = str(v)
237
+ if v.upper().startswith("GSE"):
238
+ return f"geo:GSE{v[3:]}"
239
+ if v.upper().startswith("GDS"):
240
+ return f"geo:GDS{v[3:]}"
241
+ if v.upper().startswith("SRR"):
242
+ return f"sra:SRR{v[3:]}"
243
+ if v.upper().startswith("SRP"):
244
+ return f"sra:SRP{v[3:]}"
245
+ for k, v in ids.items():
246
+ if (k or "").lower() in ("genbank", "gb", "accession"):
247
+ return f"gb:{v}"
248
+ raise ValueError("dataset record needs a geo (GSE/GDS), sra (SRR/SRP), or genbank accession id")
249
+
250
+
251
+ def derive_web_key(ids: dict, title) -> str:
252
+ url = str((ids.get("url") or "")).strip()
253
+ if not url:
254
+ raise ValueError("web record needs ids.url")
255
+ return "url:" + hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
256
+
257
+
258
+ def derive_other_key(ids: dict, title) -> str:
259
+ """Deterministic fallback key: url -> first id in sorted key order -> title hash."""
260
+ url = str((ids.get("url") or "")).strip()
261
+ if url:
262
+ return "url:" + hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
263
+ for k in sorted(ids):
264
+ v = str(ids[k])
265
+ if v:
266
+ ns = ID_ALIASES.get(k, k)
267
+ if ns in KEY_NAMESPACES:
268
+ return f"{ns}:{v}"
269
+ if title:
270
+ return _title_key("other", title)
271
+ raise ValueError("record needs an id or a title to derive a key")
272
+
273
+
274
+ def canonical_key(raw_key, rtype: str, ids: dict, title=None) -> str:
275
+ """Canonical primary key (registry-driven); an explicit well-formed key wins."""
276
+ if raw_key:
277
+ k = str(raw_key).strip()
278
+ ns = k.split(":", 1)[0]
279
+ if ns in KEY_NAMESPACES and ":" in k:
280
+ # An explicit title: key must not bypass the hard-id requirement of
281
+ # verify-gated types (a title-only article could never verify).
282
+ if ns == "title" and (TYPE_SPECS.get(rtype) or {}).get("verify"):
283
+ raise ValueError(f"{rtype} record keeps its hard id requirement; explicit title: keys are not accepted")
284
+ return k
285
+ raise ValueError(f"malformed key {raw_key!r}")
286
+
287
+ spec = TYPE_SPECS.get(rtype) or {}
288
+ key_from = spec.get("key_from")
289
+ if key_from:
290
+ for ns, field in key_from:
291
+ if ids.get(field):
292
+ return f"{ns}:{ids[field]}"
293
+ elif spec.get("key_fn"):
294
+ return spec["key_fn"](ids, title)
295
+
296
+ # No derivation succeeded. Article keeps its hard id requirement (a
297
+ # title-only article could never verify); verify-less types may fall
298
+ # back to a deterministic title key.
299
+ if rtype != "article" and title:
300
+ return _title_key(rtype, title)
301
+ fallback_errors = {
302
+ "article": "article record needs a pmid, doi, or pmcid",
303
+ "trial": "trial record needs ids.nct (or a title)",
304
+ "patent": "patent record needs ids.patent (or a title)",
305
+ "gene": "gene record needs ids.ncbi_gene (or a title)",
306
+ "variant": "variant record needs ids.clinvar (or a title)",
307
+ "drug": "drug record needs a chembl/chebi/unii id (or a title)",
308
+ "disease": "disease record needs a mondo/doid/omim/efo id (or a title)",
309
+ }
310
+ raise ValueError(fallback_errors.get(rtype, f"{rtype} record needs an id or a title"))
311
+
312
+
313
+ def secondary_ids(rec: dict) -> set:
314
+ """Secondary identity for cross-key article dedupe (doi + pmcid)."""
315
+ out = set()
316
+ if rec.get("type") == "article":
317
+ for k in ("doi", "pmcid"):
318
+ v = (rec.get("ids") or {}).get(k)
319
+ if v:
320
+ out.add(f"{k}:{str(v).lower()}")
321
+ return out
322
+
323
+
324
+ NO_FILL_FIELDS = {
325
+ "schema", "key", "type", "source", "score", "_error",
326
+ "verified", "verified_source", "verified_at", "backfilled",
327
+ "title_original", "provenance",
328
+ }
329
+
330
+ # Key-namespace precedence for twin merges: a pmid-bearing twin promotes the
331
+ # union record to the pmid key so bib lookups work regardless of which aspect
332
+ # file sorted first (pmid > doi > pmcid).
333
+ _KEY_STRENGTH = {"pmid": 3, "doi": 2, "pmcid": 1}
334
+
335
+
336
+ def _key_namespace(key: str) -> str:
337
+ return key.split(":", 1)[0]
338
+
339
+
340
+ def merge_fill(base: dict, incoming: dict) -> None:
341
+ """Fill missing base fields from incoming; NEVER overwrite non-null values.
342
+
343
+ `meta` merges NESTED fill-only (complementary worker fields union instead
344
+ of the whole-dict drop a scalar comparison would cause).
345
+ """
346
+ incoming_meta = incoming.get("meta")
347
+ if isinstance(incoming_meta, dict):
348
+ base_meta = base.get("meta")
349
+ if not isinstance(base_meta, dict):
350
+ base_meta = {}
351
+ base["meta"] = base_meta
352
+ for k, v in incoming_meta.items():
353
+ if base_meta.get(k) in (None, "", [], {}) and v not in (None, "", [], {}):
354
+ base_meta[k] = v
355
+ for field, value in incoming.items():
356
+ if field in NO_FILL_FIELDS or field in ("meta",) or field.startswith("_"):
357
+ continue
358
+ if base.get(field) in (None, "", [], {}) and value not in (None, "", [], {}):
359
+ base[field] = value
360
+ base_provs = base.setdefault("provenance", [])
361
+ known_aspects = {p.get("aspect") for p in base_provs}
362
+ for p in incoming.get("provenance") or []:
363
+ if p.get("aspect") not in known_aspects:
364
+ base_provs.append(p)
365
+ known_aspects.add(p.get("aspect"))
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # JSONL file IO
370
+ # ---------------------------------------------------------------------------
371
+
372
+ class Ledger:
373
+ """In-memory ledger: records by key + secondary-id index + quarantine."""
374
+
375
+ def __init__(self):
376
+ self.by_key: dict = {}
377
+ self.sec_index: dict = {}
378
+ self.quarantined: list = []
379
+
380
+ def upsert(self, rec: dict) -> None:
381
+ existing = self.by_key.get(rec["key"])
382
+ if existing is not None:
383
+ merge_fill(existing, rec)
384
+ else:
385
+ twin_key = None
386
+ for sid in secondary_ids(rec):
387
+ twin_key = self.sec_index.get(sid)
388
+ if twin_key:
389
+ break
390
+ if twin_key is not None and twin_key in self.by_key:
391
+ base = self.by_key[twin_key]
392
+ merge_fill(base, rec)
393
+ # promote to the stronger key namespace (pmid > doi > pmcid)
394
+ if _KEY_STRENGTH.get(_key_namespace(rec["key"]), 0) > _KEY_STRENGTH.get(_key_namespace(twin_key), 0):
395
+ del self.by_key[twin_key]
396
+ base["key"] = rec["key"]
397
+ self.by_key[rec["key"]] = base
398
+ for sid, k in list(self.sec_index.items()):
399
+ if k == twin_key:
400
+ self.sec_index[sid] = rec["key"]
401
+ twin_key = rec["key"]
402
+ rec = base
403
+ else:
404
+ self.by_key[rec["key"]] = rec
405
+ for sid in secondary_ids(rec):
406
+ self.sec_index.setdefault(sid, rec["key"])
407
+
408
+ def records(self) -> list:
409
+ return list(self.by_key.values())
410
+
411
+ def write(self, path: Path) -> None:
412
+ path.parent.mkdir(parents=True, exist_ok=True)
413
+ tmp = path.with_suffix(path.suffix + ".tmp")
414
+ with tmp.open("w", encoding="utf-8") as fh:
415
+ for rec in self.records():
416
+ fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
417
+ tmp.replace(path)
418
+
419
+
420
+ def read_ledger(path: Path) -> Ledger:
421
+ led = Ledger()
422
+ if not path.is_file():
423
+ return led
424
+ for lineno, line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
425
+ s = line.strip()
426
+ if not s:
427
+ continue
428
+ try:
429
+ led.upsert(normalize_record(json.loads(s)))
430
+ except (json.JSONDecodeError, ValueError, TypeError) as e:
431
+ led.quarantined.append({"file": str(path), "line": lineno, "record": s, "error": str(e)})
432
+ return led
433
+
434
+
435
+ def append_quarantine(out_path: Path, entries: list) -> Path:
436
+ qpath = out_path.parent / "_invalid.jsonl"
437
+ seen = set()
438
+ if qpath.is_file():
439
+ for line in qpath.read_text(encoding="utf-8-sig").splitlines():
440
+ try:
441
+ e = json.loads(line)
442
+ seen.add((e.get("file"), e.get("line"), e.get("error")))
443
+ except (json.JSONDecodeError, AttributeError):
444
+ continue
445
+ with qpath.open("a", encoding="utf-8") as fh:
446
+ for e in entries:
447
+ fingerprint = (e.get("file"), e.get("line"), e.get("error"))
448
+ if fingerprint in seen:
449
+ continue
450
+ seen.add(fingerprint)
451
+ fh.write(json.dumps(e, ensure_ascii=False) + "\n")
452
+ return qpath
453
+
454
+
455
+ # ---------------------------------------------------------------------------
456
+ # Subcommands
457
+ # ---------------------------------------------------------------------------
458
+
459
+ def _parse_incoming(args) -> list:
460
+ if args.stdin:
461
+ payload = json.loads(sys.stdin.read())
462
+ return payload if isinstance(payload, list) else [payload]
463
+ if getattr(args, "record", None) is None:
464
+ raise ValueError("provide a record JSON, @file, or --stdin")
465
+ if args.record.startswith("@"):
466
+ payload = json.loads(Path(args.record[1:]).read_text(encoding="utf-8-sig"))
467
+ return payload if isinstance(payload, list) else [payload]
468
+ return [json.loads(args.record)]
469
+
470
+
471
+ def cmd_add(args) -> int:
472
+ path = Path(args.file)
473
+ led = read_ledger(path)
474
+ # add rewrites the file: pre-existing malformed lines would be silently
475
+ # dropped — quarantine them loudly instead (same file merge uses).
476
+ if led.quarantined:
477
+ qpath = append_quarantine(path, led.quarantined)
478
+ warn(f"{len(led.quarantined)} pre-existing malformed line(s) quarantined to {qpath} (excluded from rewrite)")
479
+ accepted = rejected = 0
480
+ derived_keys: list = []
481
+ for raw in _parse_incoming(args):
482
+ try:
483
+ rec = normalize_record(raw, require_provenance_aspect=args.aspect)
484
+ except (ValueError, TypeError) as e:
485
+ rejected += 1
486
+ warn(f"rejected record ({e}): {json.dumps(raw, ensure_ascii=False)[:200]}")
487
+ continue
488
+ led.upsert(rec)
489
+ derived_keys.append(rec["key"])
490
+ accepted += 1
491
+ led.write(path)
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)}")
497
+ return 0
498
+
499
+
500
+ def _expand_input_files(patterns: list, out_path: Path) -> list:
501
+ files: list = []
502
+ for pattern in patterns:
503
+ matches = sorted(_glob.glob(pattern)) if any(c in pattern for c in "*?[") else [pattern]
504
+ for m in matches:
505
+ p = Path(m)
506
+ if not p.is_file():
507
+ continue
508
+ if p.resolve() == out_path.resolve():
509
+ continue # never re-ingest own output
510
+ if p.name.startswith("_"):
511
+ continue # quarantine + underscore-prefixed files always excluded
512
+ files.append(p)
513
+ return files
514
+
515
+
516
+ def cmd_merge(args) -> int:
517
+ out_path = Path(args.out)
518
+ inputs = _expand_input_files(args.inputs, out_path)
519
+ merged = Ledger()
520
+ quarantine_entries = []
521
+ for f in inputs:
522
+ led = read_ledger(f)
523
+ quarantine_entries.extend(led.quarantined)
524
+ for rec in led.records():
525
+ merged.upsert(rec)
526
+ merged.write(out_path)
527
+ qnote = ""
528
+ if quarantine_entries:
529
+ qpath = append_quarantine(out_path, quarantine_entries)
530
+ qnote = f"; {len(quarantine_entries)} malformed line(s) quarantined to {qpath}"
531
+ banner("merge", f"{len(merged.by_key)} record(s) from {len(inputs)} file(s) -> {out_path}{qnote}")
532
+ return 0
533
+
534
+
535
+ def _esummary_locator(doc: dict) -> dict:
536
+ m = re.search(r"\b(19\d\d|20\d\d)\b", str(doc.get("pubdate", "")))
537
+ year = m.group(1) if m else str(doc.get("sortpubdate") or "")[:4]
538
+ return {
539
+ "year": year or None,
540
+ "volume": str(doc.get("volume", "")).strip() or None,
541
+ "issue": str(doc.get("issue", "")).strip() or None,
542
+ "pages": str(doc.get("pages", "")).strip() or None,
543
+ }
544
+
545
+
546
+ def _esummary_title(doc: dict) -> str:
547
+ t = re.sub(r"<[^>]+>", "", str(doc.get("title", "")))
548
+ t = _html.unescape(t).replace("\u00a0", " ")
549
+ return t.strip().rstrip(".")
550
+
551
+
552
+ def _esummary_doi(doc: dict):
553
+ for aid in doc.get("articleids", []) or []:
554
+ if aid.get("idtype") == "doi":
555
+ v = str(aid.get("value", "")).strip().rstrip(".").lower()
556
+ return v or None
557
+ return None
558
+
559
+
560
+ def cmd_verify(args) -> int:
561
+ path = Path(args.file)
562
+ led = read_ledger(path)
563
+ verifiable = {t for t, spec in TYPE_SPECS.items() if spec.get("verify")}
564
+ pmids = sorted({r["ids"]["pmid"] for r in led.records()
565
+ if r.get("type") in verifiable and (r.get("ids") or {}).get("pmid") and not r.get("verified")})
566
+ docs = fetch_ncbi_summaries(pmids, timeout=args.timeout) if pmids else {}
567
+ now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
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"))
571
+ skipped = sum(1 for r in led.records() if r.get("type") not in verifiable)
572
+ for rec in led.records():
573
+ if rec.get("type") not in verifiable:
574
+ continue
575
+ pmid = (rec.get("ids") or {}).get("pmid")
576
+ if not pmid or rec.get("verified"):
577
+ continue
578
+ doc = docs.get(pmid)
579
+ if not doc:
580
+ unreachable += 1
581
+ continue
582
+ changed = False
583
+ loc = _esummary_locator(doc)
584
+ for field in ("year", "volume", "issue", "pages"):
585
+ if rec.get(field) in (None, "") and loc[field]:
586
+ rec[field] = loc[field]
587
+ rec["backfilled"].append(field)
588
+ changed = True
589
+ if not (rec.get("ids") or {}).get("doi"):
590
+ doi = _esummary_doi(doc)
591
+ if doi:
592
+ rec["ids"]["doi"] = doi
593
+ rec["backfilled"].append("doi")
594
+ changed = True
595
+ if not rec.get("journal"):
596
+ j = str(doc.get("source", "")).strip()
597
+ if j:
598
+ rec["journal"] = j
599
+ rec["backfilled"].append("journal")
600
+ changed = True
601
+ if not rec.get("title"):
602
+ t = _esummary_title(doc)
603
+ if t:
604
+ rec["title"] = t
605
+ rec["title_original"] = None
606
+ rec["backfilled"].append("title")
607
+ changed = True
608
+ title_fixed += 1
609
+ rec["verified"] = True
610
+ rec["verified_source"] = "ncbi-esummary"
611
+ rec["verified_at"] = now
612
+ if changed:
613
+ filled += 1
614
+ else:
615
+ clean += 1
616
+ if args.apply:
617
+ led.write(path)
618
+ if led.quarantined:
619
+ qpath = append_quarantine(path, led.quarantined)
620
+ warn(f"{len(led.quarantined)} pre-existing malformed line(s) quarantined to {qpath} (excluded from rewrite)")
621
+ banner(
622
+ "verify",
623
+ f"{len(docs)} PubMed record(s) checked; {filled} backfilled, {title_fixed} title(s) set, "
624
+ f"{clean} verified clean, {unreachable} unreachable (fail-safe, left unverified); "
625
+ f"{skipped} record(s) of unverified type(s) skipped (no verifier configured)"
626
+ + (f"; {already} already verified (not rechecked)" if already else "")
627
+ + ("; --apply written" if args.apply else " (dry-run, no changes written)"),
628
+ )
629
+ return 0
630
+
631
+
632
+ # ---------------------------------------------------------------------------
633
+ # Bibliography rendering
634
+ # ---------------------------------------------------------------------------
635
+
636
+ _GROUP_AUTHOR_RE = re.compile(
637
+ r"\b(group|consortium|investigators?|network|committee|collaborative|initiative|"
638
+ r"team|registry|alliance|panel|authors?|working|study|trial|project|program|"
639
+ r"organization|organisation|society|association|institute|council|foundation|"
640
+ r"university|college)\b",
641
+ re.IGNORECASE,
642
+ )
643
+
644
+ # lowercase surname particles absorbed into the family name ("van der Berg Jan"
645
+ # -> "van der Berg J"), never treated as given-name initials
646
+ _SURNAME_PARTICLES = {"van", "der", "den", "de", "del", "la", "di", "da", "dos", "von", "ter", "ten", "op", "'t"}
647
+
648
+
649
+ def vancouver_author(name: str) -> str:
650
+ """'Chapman Paul B' -> 'Chapman PB'; group/corporate names pass through."""
651
+ name = (name or "").strip()
652
+ if not name:
653
+ return ""
654
+ if _GROUP_AUTHOR_RE.search(name):
655
+ return name
656
+ tokens = name.split()
657
+ if len(tokens) == 1:
658
+ return name
659
+ if tokens[0].lower() in _SURNAME_PARTICLES:
660
+ # particle-leading surname: "van der Berg Jan" -> surname "van der Berg"
661
+ i = 0
662
+ while i < len(tokens) and tokens[i].lower() in _SURNAME_PARTICLES:
663
+ i += 1
664
+ if i < len(tokens):
665
+ surname = " ".join(tokens[: i + 1])
666
+ given = tokens[i + 1 :]
667
+ else:
668
+ surname, given = name, []
669
+ else:
670
+ surname, given = tokens[0], tokens[1:]
671
+ initials = "".join(t[0].upper() for t in given if t and t[0].isalpha())
672
+ return f"{surname} {initials}" if initials else surname
673
+
674
+
675
+ def _author_list(rec: dict, max_authors: int = 3) -> str:
676
+ authors = [a for a in (rec.get("authors") or []) if str(a).strip()]
677
+ if not authors:
678
+ return "[MISSING field: authors]"
679
+ out = ", ".join(filter(None, (vancouver_author(str(a)) for a in authors[:max_authors])))
680
+ if len(authors) > max_authors:
681
+ out += ", et al"
682
+ return out
683
+
684
+
685
+ def expand_pages(pages: str) -> str:
686
+ """Expand abbreviated numeric page ranges: '2507-16' -> '2507-2516'."""
687
+ m = re.match(r"^(\d+)\s*-\s*(\d+)$", (pages or "").strip())
688
+ if not m:
689
+ return pages
690
+ left, right = m.group(1), m.group(2)
691
+ if len(right) < len(left):
692
+ right = left[: len(left) - len(right)] + right
693
+ if int(right) < int(left):
694
+ return pages # ambiguous abbreviation; keep verbatim
695
+ return f"{left}-{right}"
696
+
697
+
698
+ def _locator(rec: dict, expand: bool) -> str:
699
+ year = rec.get("year") or "[MISSING field: year]"
700
+ vol, iss = rec.get("volume"), rec.get("issue")
701
+ pg = expand_pages(rec.get("pages")) if expand else rec.get("pages")
702
+ if vol and iss and pg:
703
+ return f"{year};{vol}({iss}):{pg}"
704
+ if vol and pg:
705
+ return f"{year};{vol}:{pg}"
706
+ if vol and iss:
707
+ return f"{year};{vol}({iss})"
708
+ if vol:
709
+ return f"{year};{vol}"
710
+ return f"{year}"
711
+
712
+
713
+ def _need(rec: dict, field: str) -> str:
714
+ v = rec.get(field)
715
+ if v in (None, ""):
716
+ return f"[MISSING field: {field}]"
717
+ return str(v)
718
+
719
+
720
+ def _close(s: str) -> str:
721
+ return s if s.endswith(".") else s + "."
722
+
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
+
732
+ def render_article(rec: dict, expand: bool) -> str:
733
+ ids = rec.get("ids") or {}
734
+ title = _close(_need(rec, "title").strip())
735
+ journal = _close(_need(rec, "journal"))
736
+ if any(rec.get(f) for f in ("volume", "issue", "pages")):
737
+ head = f"{_author_list(rec)} {title} {journal} {_locator(rec, expand)}."
738
+ else: # epub-ahead-of-print: locator legitimately absent at NCBI
739
+ head = f"{_author_list(rec)} {title} {journal} {rec.get('year') or '[MISSING field: year]'}."
740
+ tail = ""
741
+ if ids.get("doi"):
742
+ tail += f" DOI: {ids['doi']}."
743
+ if ids.get("pmid"):
744
+ tail += f" PMID: {ids['pmid']}."
745
+ return head + tail
746
+
747
+
748
+ def render_trial(rec: dict) -> str:
749
+ ids = rec.get("ids") or {}
750
+ meta = rec.get("meta") or {}
751
+ nct = ids.get("nct") or "[MISSING field: ids.nct]"
752
+ out = f"{nct}: {_close_title(rec.get('title'))}"
753
+ phase = str(meta.get("phase") or "").strip().rstrip(".")
754
+ if phase:
755
+ # Workers copy phase verbatim from biomcp/CTgov ("Phase 2", "PHASE3",
756
+ # "2"): never double the prefix.
757
+ out += f" {phase}." if phase.lower().startswith("phase") else f" Phase {phase}."
758
+ if meta.get("sponsor"):
759
+ out += f" Sponsor: {meta['sponsor']}."
760
+ if meta.get("status"):
761
+ out += f" Status: {meta['status']}."
762
+ out += " " + (rec.get("url") or f"https://clinicaltrials.gov/study/{nct}")
763
+ return out
764
+
765
+
766
+ def render_patent(rec: dict) -> str:
767
+ ids = rec.get("ids") or {}
768
+ meta = rec.get("meta") or {}
769
+ num = ids.get("patent") or "[MISSING field: ids.patent]"
770
+ assignee = meta.get("assignee") or "[MISSING field: meta.assignee]"
771
+ status = f" ({meta['status']})" if meta.get("status") else ""
772
+ url = rec.get("url") or f"https://patents.google.com/patent/{num}"
773
+ return f"{assignee}. {_need(rec, 'title')}. {num}{status}. {url}"
774
+
775
+
776
+ def render_gene(rec: dict) -> str:
777
+ ids = rec.get("ids") or {}
778
+ meta = rec.get("meta") or {}
779
+ symbol = meta.get("symbol") or "[MISSING field: meta.symbol]"
780
+ gene_id = ids.get("ncbi_gene") or "[MISSING field: ids.ncbi_gene]"
781
+ hgnc = ids.get("hgnc") or meta.get("hgnc")
782
+ hgnc_part = f" HGNC: {hgnc}." if hgnc else ""
783
+ url = rec.get("url") or f"https://www.ncbi.nlm.nih.gov/gene/{ids.get('ncbi_gene', '')}"
784
+ return f"{symbol}: {_need(rec, 'title')}. NCBI Gene ID: {gene_id}.{hgnc_part} {url}"
785
+
786
+
787
+ def render_variant(rec: dict) -> str:
788
+ ids = rec.get("ids") or {}
789
+ meta = rec.get("meta") or {}
790
+ gene = meta.get("gene") or "[MISSING field: meta.gene]"
791
+ change = meta.get("protein_change") or "[MISSING field: meta.protein_change]"
792
+ sig = meta.get("significance") or "[MISSING field: meta.significance]"
793
+ clinvar = ids.get("clinvar") or "[MISSING field: ids.clinvar]"
794
+ rs = ids.get("rs") or meta.get("rs")
795
+ rs_part = f" ({rs})" if rs else ""
796
+ url = rec.get("url") or f"https://www.ncbi.nlm.nih.gov/clinvar/variation/{clinvar}"
797
+ return f"{gene} p.{change}{rs_part}: {sig} [ClinVar: {clinvar}]. {url}"
798
+
799
+
800
+ def render_drug(rec: dict) -> str:
801
+ ids = rec.get("ids") or {}
802
+ meta = rec.get("meta") or {}
803
+ dbid = next((f"{k.upper()}: {ids[k]}" for k in ("chembl", "chebi", "unii") if ids.get(k)),
804
+ "[MISSING field: ids.chembl|chebi|unii]")
805
+ ind = f" Indication: {meta['indication']}." if meta.get("indication") else ""
806
+ url = rec.get("url") or ""
807
+ return f"{_close_title(rec.get('title'))}{ind} {dbid}. {url}".strip()
808
+
809
+
810
+ def render_disease(rec: dict) -> str:
811
+ ids = rec.get("ids") or {}
812
+ oid = next((f"{k.upper()}:{ids[k]}" for k in ("mondo", "doid", "omim", "efo") if ids.get(k)),
813
+ "[MISSING field: ids.mondo|doid|omim|efo]")
814
+ return f"{_need(rec, 'title')}. {oid}. {rec.get('url') or ''}".strip()
815
+
816
+
817
+ def render_dataset(rec: dict) -> str:
818
+ ids = rec.get("ids") or {}
819
+ key = rec.get("key", "")
820
+ title = _need(rec, "title")
821
+ if key.startswith("geo:"):
822
+ acc = ids.get("geo") or ids.get("accession") or key[len("geo:"):]
823
+ return f"GEO series {acc}: {title}. https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc={acc}"
824
+ if key.startswith("sra:"):
825
+ acc = ids.get("sra") or ids.get("accession") or key[len("sra:"):]
826
+ return f"SRA run {acc}: {title}. https://trace.ncbi.nlm.nih.gov/Traces/?run={acc}"
827
+ acc = ids.get("genbank") or ids.get("accession") or key[len("gb:"):]
828
+ return f"GenBank accession {acc}: {title}. https://www.ncbi.nlm.nih.gov/nuccore/{acc}"
829
+
830
+
831
+ def render_web(rec: dict) -> str:
832
+ meta = rec.get("meta") or {}
833
+ updated = f" Updated {meta['updated']}." if meta.get("updated") else ""
834
+ url = rec.get("url") or (rec.get("ids") or {}).get("url") or "[MISSING field: url]"
835
+ accessed = meta.get("accessed") or "[MISSING field: meta.accessed]"
836
+ return f"{_need(rec, 'title')}. {meta.get('organization') or '[MISSING field: meta.organization]'}.{updated} {url}. Accessed: {accessed}."
837
+
838
+
839
+ def render_other(rec: dict) -> str:
840
+ """Generic renderer for the `other` escape-hatch type (and any future type
841
+ that registers without a dedicated render function)."""
842
+ parts = [_need(rec, "title")]
843
+ ids = rec.get("ids") or {}
844
+ extras = [f"{k}: {v}" for k, v in sorted(ids.items()) if v]
845
+ if rec.get("url"):
846
+ extras.append(str(rec["url"]))
847
+ if extras:
848
+ parts.append(" ".join(extras) + ".")
849
+ parts.append(f"[type: {rec.get('type', 'other')}]")
850
+ return " ".join(parts)
851
+
852
+
853
+ TYPE_SPECS = {
854
+ # key_from = ordered (namespace, id-field) pairs for auto-derivation —
855
+ # every namespace listed MUST be in KEY_NAMESPACES or re-reads quarantine
856
+ # the record; key_fn for value-pattern derivation (dataset) or
857
+ # deterministic fallbacks (web, other); verify gates cmd_verify; render
858
+ # renders in bib.
859
+ "article": {"key_from": [("pmid", "pmid"), ("doi", "doi"), ("pmcid", "pmcid")], "verify": True, "render": lambda r, e: render_article(r, e)},
860
+ "trial": {"key_from": [("nct", "nct")], "verify": False, "render": lambda r, e: render_trial(r)},
861
+ "patent": {"key_from": [("patent", "patent")], "verify": False, "render": lambda r, e: render_patent(r)},
862
+ "gene": {"key_from": [("gene", "ncbi_gene")], "verify": False, "render": lambda r, e: render_gene(r)},
863
+ "variant": {"key_from": [("clinvar", "clinvar")], "verify": False, "render": lambda r, e: render_variant(r)},
864
+ "drug": {"key_from": [("chembl", "chembl"), ("chebi", "chebi"), ("unii", "unii")], "verify": False, "render": lambda r, e: render_drug(r)},
865
+ "disease": {"key_from": [("mondo", "mondo"), ("doid", "doid"), ("omim", "omim"), ("efo", "efo")], "verify": False, "render": lambda r, e: render_disease(r)},
866
+ "dataset": {"key_fn": derive_dataset_key, "verify": False, "render": lambda r, e: render_dataset(r)},
867
+ "web": {"key_fn": derive_web_key, "verify": False, "render": lambda r, e: render_web(r)},
868
+ # NOTE: web and other share the url: key namespace (by design: same URL =
869
+ # same source); same-URL records of the two types therefore merge into one
870
+ # record on add/merge, fill-only, with both provenance chains preserved.
871
+ "other": {"key_fn": derive_other_key, "verify": False, "render": lambda r, e: render_other(r)},
872
+ }
873
+
874
+
875
+ def render_record(rec: dict, expand_pages: bool = False) -> str:
876
+ rtype = rec.get("type")
877
+ spec = TYPE_SPECS.get(rtype)
878
+ if spec and spec.get("render"):
879
+ return spec["render"](rec, expand_pages)
880
+ return render_other(rec)
881
+
882
+
883
+ def cmd_bib(args) -> int:
884
+ path = Path(args.file)
885
+ led = read_ledger(path)
886
+ keys = [k.strip() for k in args.keys.split(",") if k.strip()]
887
+ lines = []
888
+ missing = []
889
+ n = args.offset
890
+ for k in keys:
891
+ rec = led.by_key.get(k)
892
+ if rec is None:
893
+ missing.append(k)
894
+ lines.append(f"[MISSING record {k}]")
895
+ continue
896
+ n += 1
897
+ lines.append(f"[{n}] {render_record(rec, args.expand_pages)}")
898
+ print("\n".join(lines))
899
+ if missing:
900
+ banner("bib", f"{len(keys) - len(missing)} rendered, {len(missing)} unknown key(s): {', '.join(missing)}")
901
+ return 1
902
+ banner("bib", f"{len(keys)} record(s) rendered from {path}")
903
+ return 0
904
+
905
+
906
+ def cmd_get(args) -> int:
907
+ led = read_ledger(Path(args.file))
908
+ rec = led.by_key.get(args.key)
909
+ if rec is None:
910
+ banner("get", f"key {args.key} not found in {args.file}")
911
+ return 1
912
+ print(json.dumps(rec, indent=2, ensure_ascii=False))
913
+ banner("get", f"key {args.key}")
914
+ return 0
915
+
916
+
917
+ def cmd_keys(args) -> int:
918
+ led = read_ledger(Path(args.file))
919
+ for k in sorted(led.by_key):
920
+ print(k)
921
+ banner("keys", f"{len(led.by_key)} key(s)")
922
+ return 0
923
+
924
+
925
+ def cmd_stats(args) -> int:
926
+ led = read_ledger(Path(args.file))
927
+ types: dict = {}
928
+ verified = located = 0
929
+ for rec in led.records():
930
+ types[rec.get("type", "?")] = types.get(rec.get("type", "?"), 0) + 1
931
+ verified += bool(rec.get("verified"))
932
+ located += bool(rec.get("volume") or rec.get("pages"))
933
+ banner("stats", f"{len(led.by_key)} record(s); verified {verified}; with locator {located}; quarantined lines {len(led.quarantined)}")
934
+ for t, c in sorted(types.items()):
935
+ print(f" {t}: {c}")
936
+ return 0
937
+
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
+
1231
+ # ---------------------------------------------------------------------------
1232
+ # Hermetic selftest (CI; no network)
1233
+ # ---------------------------------------------------------------------------
1234
+
1235
+ def _capture(fn, *a, **kw):
1236
+ buf = io.StringIO()
1237
+ with contextlib.redirect_stdout(buf):
1238
+ rc = fn(*a, **kw)
1239
+ return rc, buf.getvalue()
1240
+
1241
+
1242
+ def _fixture_article(pmid="21639808", doi="10.1056/nejmoa1103782", title="Improved survival with vemurafenib in melanoma with BRAF V600E mutation", **over):
1243
+ rec = {
1244
+ "schema": SCHEMA,
1245
+ "key": f"pmid:{pmid}",
1246
+ "type": "article",
1247
+ "ids": {"pmid": pmid, "doi": doi, "pmcid": "PMC3549296"},
1248
+ "title": title,
1249
+ "title_original": None,
1250
+ "authors": ["Chapman Paul B", "Hauschild Axel", "Robert Caroline", "BRIM-3 Investigators"],
1251
+ "journal": "N Engl J Med",
1252
+ "year": "2011", "volume": "364", "issue": "26", "pages": "2507-16",
1253
+ "url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
1254
+ "verified": False, "verified_source": None, "verified_at": None, "backfilled": [],
1255
+ "provenance": [{"aspect": "test", "tool": "article_search", "args": {"query": "x"}, "retrieved_at": "2026-09-10T00:00:00Z"}],
1256
+ }
1257
+ rec.update(over)
1258
+ return rec
1259
+
1260
+
1261
+ def selftest() -> int:
1262
+ with tempfile.TemporaryDirectory() as td:
1263
+ d = Path(td)
1264
+ results = []
1265
+
1266
+ def check(name, fn):
1267
+ try:
1268
+ fn()
1269
+ results.append((name, None))
1270
+ except AssertionError as e:
1271
+ results.append((name, str(e)))
1272
+ except Exception as e: # noqa: BLE001
1273
+ results.append((name, f"exception: {type(e).__name__}: {e}"))
1274
+
1275
+ # ---- add ------------------------------------------------------------
1276
+ def st_add():
1277
+ f = d / "a.jsonl"
1278
+ rc, _ = _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
1279
+ assert rc == 0
1280
+ led = read_ledger(f)
1281
+ assert "pmid:21639808" in led.by_key and not led.quarantined
1282
+ # ID normalization: PMID: prefix + leading zeros stripped; DOI lowercased
1283
+ rec2 = _fixture_article(pmid="12345", doi="10.1000/UPPER.Case", title="Second paper")
1284
+ rec2["ids"].update({"pmid": "PMID: 0012345", "pmcid": "PMC9999999"})
1285
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec2), stdin=False, aspect=None))
1286
+ led = read_ledger(f)
1287
+ assert "pmid:12345" in led.by_key, "PMID: prefix / leading zeros not stripped"
1288
+ assert led.by_key["pmid:12345"]["ids"]["doi"] == "10.1000/upper.case", "DOI not lowercased"
1289
+ # secondary-id dedupe: doi-only twin merges into the pmid record
1290
+ rec3 = {"type": "article", "ids": {"doi": "10.1056/nejmoa1103782"}, "title": None, "journal": None}
1291
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec3), stdin=False, aspect=None))
1292
+ led = read_ledger(f)
1293
+ assert len(led.by_key) == 2, f"secondary-id dedupe failed ({len(led.by_key)} records)"
1294
+ assert led.by_key["pmid:21639808"]["title"].startswith("Improved survival"), "merge-fill overwrote a non-null value"
1295
+ # bad records rejected loudly
1296
+ rc, _ = _capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "article", "title": "x", "ids": {}}', stdin=False, aspect=None))
1297
+ led2 = read_ledger(f)
1298
+ assert len(led2.by_key) == 2, "invalid record accepted"
1299
+ check("add", st_add)
1300
+
1301
+ # ---- merge ------------------------------------------------------------
1302
+ def st_merge():
1303
+ sub = d / "aspects"
1304
+ sub.mkdir()
1305
+ f1, f2, f3 = sub / "m1.jsonl", sub / "m2.jsonl", sub / "m3.jsonl"
1306
+ rec_a = _fixture_article(volume=None, issue=None, pages=None)
1307
+ f1.write_text(json.dumps(rec_a) + "\n", encoding="utf-8")
1308
+ rec_b = _fixture_article(key=None)
1309
+ rec_b.pop("key")
1310
+ rec_b["ids"] = {"doi": "10.1056/nejmoa1103782"}
1311
+ rec_b["volume"] = "364"
1312
+ f2.write_text(json.dumps(rec_b) + "\n", encoding="utf-8")
1313
+ f3.write_text(
1314
+ json.dumps({"key": "pmid:1", "type": "article", "ids": {"pmid": "1"}, "title": "t",
1315
+ "provenance": [{"aspect": "x"}]}) + "\nnot json at all\n",
1316
+ encoding="utf-8",
1317
+ )
1318
+ out = sub / "sources.jsonl"
1319
+ rc, _ = _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(f1), str(f2), str(f3)]))
1320
+ led = read_ledger(out)
1321
+ assert rc == 0
1322
+ assert "pmid:21639808" in led.by_key and "doi:10.1056/nejmoa1103782" not in led.by_key, "secondary-id union failed"
1323
+ assert led.by_key["pmid:21639808"]["volume"] == "364", "locator not merged in"
1324
+ assert "pmid:1" in led.by_key
1325
+ assert (sub / "_invalid.jsonl").is_file(), "quarantine file not written"
1326
+ # glob re-run: own output + quarantine excluded -> same content
1327
+ rc2, _ = _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(sub / "*.jsonl")]))
1328
+ led2 = read_ledger(out)
1329
+ assert rc2 == 0 and set(led2.by_key) == {"pmid:21639808", "pmid:1"}, f"glob re-run broke ledger: {sorted(led2.by_key)}"
1330
+ # quarantine stays idempotent across re-merges
1331
+ qbefore = len((sub / "_invalid.jsonl").read_text(encoding="utf-8").splitlines())
1332
+ _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(sub / "*.jsonl")]))
1333
+ qafter = len((sub / "_invalid.jsonl").read_text(encoding="utf-8").splitlines())
1334
+ assert qbefore == qafter, "quarantine grew on re-merge"
1335
+ # doi-first twin ordering promotes the union record to the pmid key
1336
+ d1, d2 = sub / "aa_doi.jsonl", sub / "zz_pmid.jsonl"
1337
+ d1.write_text(json.dumps({"type": "article", "ids": {"doi": "10.9999/promote"},
1338
+ "title": "Union record", "volume": None,
1339
+ "provenance": [{"aspect": "doi_side"}]}) + "\n", encoding="utf-8")
1340
+ d2.write_text(json.dumps({"key": "pmid:777", "type": "article", "ids": {"pmid": "777", "doi": "10.9999/promote"},
1341
+ "title": None, "volume": "9",
1342
+ "provenance": [{"aspect": "pmid_side"}]}) + "\n", encoding="utf-8")
1343
+ out2 = sub / "promoted.jsonl"
1344
+ _capture(cmd_merge, argparse.Namespace(out=str(out2), inputs=[str(d1), str(d2)]))
1345
+ led3 = read_ledger(out2)
1346
+ assert set(led3.by_key) == {"pmid:777"}, f"key promotion failed: {sorted(led3.by_key)}"
1347
+ assert led3.by_key["pmid:777"]["volume"] == "9" and led3.by_key["pmid:777"]["title"] == "Union record"
1348
+ check("merge", st_merge)
1349
+
1350
+ # ---- verify offline (fail-safe) -----------------------------------------
1351
+ def st_verify_offline():
1352
+ import ncbi_esummary as ne
1353
+ f = d / "v.jsonl"
1354
+ f.write_text(json.dumps(_fixture_article(volume=None, issue=None, pages=None)) + "\n", encoding="utf-8")
1355
+ old_url, old_sleep = ne.NCBI_ESUMMARY_URL, ne.time.sleep
1356
+ ne.NCBI_ESUMMARY_URL = "http://127.0.0.1:1/unreachable"
1357
+ ne.time.sleep = lambda *_: None
1358
+ try:
1359
+ rc, _ = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=True, timeout=0.2))
1360
+ finally:
1361
+ ne.NCBI_ESUMMARY_URL, ne.time.sleep = old_url, old_sleep
1362
+ assert rc == 0, "verify must exit 0 on network failure"
1363
+ rec = read_ledger(f).by_key["pmid:21639808"]
1364
+ assert rec["verified"] is False and rec["volume"] is None, "fail-safe violated: record changed on network failure"
1365
+ check("verify-offline", st_verify_offline)
1366
+
1367
+ # ---- verify backfill (mocked esummary) -----------------------------------
1368
+ def st_verify_backfill():
1369
+ f = d / "v2.jsonl"
1370
+ epub = _fixture_article(pmid="42487519", doi="10.1111/cas.70480",
1371
+ title="New Treatment Strategy and Future Research Direction for BRAF-Mutated Cancer",
1372
+ volume=None, issue=None, pages=None)
1373
+ epub.update({"journal": "Cancer Sci", "year": "2026", "authors": ["Takahashi Masanobu", "Taniguchi Sakura Hiraide"]})
1374
+ hint = _fixture_article(pmid="99900001", doi=None, title=None, journal=None, year=None,
1375
+ volume=None, issue=None, pages=None, authors=None)
1376
+ hint["ids"] = {"pmid": "99900001"}
1377
+ f.write_text(json.dumps(epub) + "\n" + json.dumps(hint) + "\n", encoding="utf-8")
1378
+ docs = {
1379
+ "42487519": {"pubdate": "2026 Jul 23", "volume": "", "issue": "", "pages": "",
1380
+ "source": "Cancer Sci",
1381
+ "title": "New Treatment Strategy and Future Research Direction for BRAF-Mutated Cancer.",
1382
+ "articleids": [{"idtype": "doi", "value": "10.1111/cas.70480"}]},
1383
+ "99900001": {"pubdate": "2011 Jun 30", "volume": "364", "issue": "26", "pages": "2507-16",
1384
+ "source": "N Engl J Med", "title": "Mocked title for hint record.",
1385
+ "articleids": [{"idtype": "doi", "value": "10.1056/NEJMoa1103782"}]},
1386
+ }
1387
+ mod = sys.modules[__name__]
1388
+ original = mod.fetch_ncbi_summaries
1389
+ mod.fetch_ncbi_summaries = lambda pmids, timeout=15.0: docs
1390
+ try:
1391
+ rc, _ = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=True, timeout=1.0))
1392
+ finally:
1393
+ mod.fetch_ncbi_summaries = original
1394
+ assert rc == 0
1395
+ led = read_ledger(f)
1396
+ e = led.by_key["pmid:42487519"]
1397
+ assert e["verified"] is True and e["volume"] is None, "epub record must verify WITHOUT inventing locators"
1398
+ h = led.by_key["pmid:99900001"]
1399
+ assert h["title"] == "Mocked title for hint record", "hint title not backfilled"
1400
+ assert h["volume"] == "364" and h["pages"] == "2507-16", "locators not backfilled"
1401
+ assert "volume" in h["backfilled"] and "title" in h["backfilled"]
1402
+ check("verify-backfill", st_verify_backfill)
1403
+
1404
+ # ---- bib -------------------------------------------------------------------
1405
+ def st_bib():
1406
+ # Vancouver initials: standard, particle surnames, group passthrough
1407
+ assert vancouver_author("Chapman Paul B") == "Chapman PB"
1408
+ assert vancouver_author("van der Berg Jan") == "van der Berg J", vancouver_author("van der Berg Jan")
1409
+ assert vancouver_author("De la Cruz Maria E") == "De la Cruz ME", vancouver_author("De la Cruz Maria E")
1410
+ assert vancouver_author("World Health Organization") == "World Health Organization"
1411
+ assert vancouver_author("Li Jiang") == "Li J"
1412
+ assert vancouver_author("WHO") == "WHO"
1413
+ # expand_pages guards
1414
+ assert expand_pages("2507-16") == "2507-2516"
1415
+ assert expand_pages("2507-2516") == "2507-2516"
1416
+ assert expand_pages("e71310") == "e71310"
1417
+ assert expand_pages("2507-6") == "2507-6", "backwards expansion must be rejected"
1418
+ f = d / "b.jsonl"
1419
+ f.write_text(json.dumps(_fixture_article()) + "\n", encoding="utf-8")
1420
+ rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:21639808", expand_pages=False, offset=0))
1421
+ assert rc == 0
1422
+ first = out.splitlines()[0]
1423
+ assert "Chapman PB, Hauschild A, Robert C, et al" in first, f"Vancouver initials wrong: {first}"
1424
+ assert "2011;364(26):2507-16" in first, f"locator wrong: {first}"
1425
+ assert "PMID: 21639808." in first and "DOI: 10.1056/nejmoa1103782." in first, first
1426
+ rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:21639808", expand_pages=True, offset=0))
1427
+ assert "2011;364(26):2507-2516" in out.splitlines()[0], "expand-pages failed"
1428
+ # epub form: locator-less render
1429
+ epub = _fixture_article(pmid="42487519", doi="10.1111/cas.70480", title="New Treatment Strategy",
1430
+ volume=None, issue=None, pages=None)
1431
+ epub.update({"journal": "Cancer Sci", "year": "2026", "authors": ["Takahashi Masanobu", "Taniguchi Sakura Hiraide"]})
1432
+ f.write_text(json.dumps(epub) + "\n", encoding="utf-8")
1433
+ _, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:42487519", expand_pages=False, offset=0))
1434
+ line = out.splitlines()[0]
1435
+ assert "Cancer Sci. 2026." in line and "364" not in line and "DOI: 10.1111/cas.70480." in line, f"epub form wrong: {line}"
1436
+ assert "Takahashi M, Taniguchi SH" in line, "multi-initial author wrong"
1437
+ # unknown key -> loud + non-zero
1438
+ rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:42487519,pmid:nope", expand_pages=False, offset=0))
1439
+ assert rc != 0, "unknown key must exit non-zero"
1440
+ assert "[MISSING record pmid:nope]" in out
1441
+ check("bib", st_bib)
1442
+
1443
+ # ---- get / keys / stats ------------------------------------------------------
1444
+ def st_misc():
1445
+ f = d / "g.jsonl"
1446
+ f.write_text(json.dumps(_fixture_article()) + "\n", encoding="utf-8")
1447
+ rc, out = _capture(cmd_get, argparse.Namespace(file=str(f), key="pmid:21639808"))
1448
+ assert rc == 0 and '"key": "pmid:21639808"' in out
1449
+ rc, out = _capture(cmd_keys, argparse.Namespace(file=str(f)))
1450
+ assert rc == 0 and "pmid:21639808" in out
1451
+ rc, out = _capture(cmd_stats, argparse.Namespace(file=str(f)))
1452
+ assert rc == 0 and "record(s)" in out
1453
+ check("get/keys/stats", st_misc)
1454
+
1455
+ # ---- aliases (biomcp-native field names -> canonical slots) ---------
1456
+ def st_aliases():
1457
+ f = d / "al.jsonl"
1458
+ # variant: rsid -> rs
1459
+ rec = {"type": "variant", "ids": {"clinvar": "13961", "rsid": "rs113488022"},
1460
+ "title": "BRAF V600E", "meta": {"gene": "BRAF", "protein_change": "V600E", "significance": "Pathogenic"},
1461
+ "provenance": [{"aspect": "a"}]}
1462
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
1463
+ led = read_ledger(f)
1464
+ assert "clinvar:13961" in led.by_key
1465
+ assert led.by_key["clinvar:13961"]["ids"].get("rs") == "rs113488022", "rsid alias not copied"
1466
+ assert led.by_key["clinvar:13961"]["ids"].get("rsid") == "rs113488022", "original alias key lost"
1467
+ # gene: entrez_id -> ncbi_gene
1468
+ rec = {"type": "gene", "ids": {"entrez_id": "673"}, "title": "BRAF",
1469
+ "meta": {"symbol": "BRAF"}, "provenance": [{"aspect": "a"}]}
1470
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
1471
+ led = read_ledger(f)
1472
+ assert "gene:673" in led.by_key, "entrez_id alias not canonicalized"
1473
+ # lowercase nct_id must NOT fork a duplicate key (alias values are canonicalized)
1474
+ a = {"type": "trial", "ids": {"nct": "NCT04903119"}, "title": "T", "provenance": [{"aspect": "a"}]}
1475
+ b = {"type": "trial", "ids": {"nct_id": "nct04903119"}, "title": None, "provenance": [{"aspect": "b"}]}
1476
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(a), stdin=False, aspect=None))
1477
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(b), stdin=False, aspect=None))
1478
+ led = read_ledger(f)
1479
+ assert len([k for k in led.by_key if k.startswith("nct:")]) == 1, "lowercase alias forked a duplicate nct key"
1480
+ # explicit well-formed key beats alias-derived key
1481
+ c = {"key": "nct:NCT00000001", "type": "trial", "ids": {"nct_id": "NCT04903119"},
1482
+ "title": "Other", "provenance": [{"aspect": "a"}]}
1483
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(c), stdin=False, aspect=None))
1484
+ led = read_ledger(f)
1485
+ assert "nct:NCT00000001" in led.by_key, "explicit key did not win over alias-derived key"
1486
+ check("aliases", st_aliases)
1487
+
1488
+ # ---- folding (verbatim q05 regression fixture + semantics) ----------
1489
+ def st_folding():
1490
+ f = d / "fold.jsonl"
1491
+ # EXACT shape the q05 worker wrote (real-run regression fixture)
1492
+ q05 = {"schema": SCHEMA, "key": "nct:NCT04903119", "type": "trial",
1493
+ "ids": {"nct_id": "NCT04903119"},
1494
+ "title": "Nilotinib Plus Dabrafenib/Trametinib or Encorafenib/Binimetinib in Metastatic Melanoma",
1495
+ "phase": None, "status": "RECRUITING", "sponsor": None,
1496
+ "url": "https://clinicaltrials.gov/study/NCT04903119",
1497
+ "provenance": [{"aspect": "combination_strategies", "tool": "biomcp_trial_search"}]}
1498
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(q05), stdin=False, aspect=None))
1499
+ led = read_ledger(f)
1500
+ rec = led.by_key["nct:NCT04903119"]
1501
+ assert rec["ids"].get("nct") == "NCT04903119", "nct_id alias not applied"
1502
+ assert rec["meta"].get("status") == "RECRUITING", "top-level status not folded into meta"
1503
+ assert "phase" not in rec["meta"] and "sponsor" not in rec["meta"], "nulls must never fold"
1504
+ assert rec.get("status") == "RECRUITING", "original top-level field not preserved verbatim"
1505
+ _, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="nct:NCT04903119", expand_pages=False, offset=0))
1506
+ line = out.splitlines()[0]
1507
+ assert "[MISSING" not in line, f"q05 regression: [MISSING in render: {line}"
1508
+ assert "NCT04903119: Nilotinib Plus" in line and "Status: RECRUITING." in line, f"trial render wrong: {line}"
1509
+ assert "Phase" not in line and "Sponsor" not in line, "null segments must be omitted"
1510
+ # canonical meta wins over conflicting top-level fold
1511
+ conflict = {"type": "trial", "ids": {"nct": "NCT00000002"}, "title": "C",
1512
+ "phase": "WRONG", "meta": {"phase": "3"},
1513
+ "provenance": [{"aspect": "a"}]}
1514
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(conflict), stdin=False, aspect=None))
1515
+ led = read_ledger(f)
1516
+ assert led.by_key["nct:NCT00000002"]["meta"]["phase"] == "3", "canonical meta did not win the fold"
1517
+ # fold is idempotent across re-reads
1518
+ before = json.dumps(led.by_key["nct:NCT04903119"], sort_keys=True)
1519
+ after = json.dumps(read_ledger(f).by_key["nct:NCT04903119"], sort_keys=True)
1520
+ assert before == after, "re-normalization not idempotent"
1521
+ # synthetic trial with all three segments renders all three
1522
+ full = {"type": "trial", "ids": {"nct": "NCT04280705"},
1523
+ "title": "Encorafenib Plus Cetuximab With or Without Nivolumab",
1524
+ "phase": "Phase 2", "status": "Completed", "sponsor": "Pfizer",
1525
+ "url": "https://clinicaltrials.gov/study/NCT04280705",
1526
+ "provenance": [{"aspect": "a"}]}
1527
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(full), stdin=False, aspect=None))
1528
+ _, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="nct:NCT04280705", expand_pages=False, offset=0))
1529
+ line = out.splitlines()[0]
1530
+ assert "Phase Phase" not in line and "Phase 2." in line and "Sponsor: Pfizer." in line and "Status: Completed." in line, f"full trial render wrong: {line}"
1531
+ check("folding", st_folding)
1532
+
1533
+ # ---- auto-key derivation + title round-trip ---------------------------
1534
+ def st_auto_key():
1535
+ f = d / "ak.jsonl"
1536
+ # trial without explicit key derives nct: from the alias slot
1537
+ rec = {"type": "trial", "ids": {"nct_id": "NCT01234567"}, "title": "Derived",
1538
+ "provenance": [{"aspect": "a"}]}
1539
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
1540
+ led = read_ledger(f)
1541
+ assert "nct:NCT01234567" in led.by_key, "auto-key from alias slot failed"
1542
+ # title-only verify-less record derives a title: key that round-trips
1543
+ rec = {"type": "trial", "title": "Title-only trial record", "provenance": [{"aspect": "a"}]}
1544
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
1545
+ led = read_ledger(f)
1546
+ title_keys = [k for k in led.by_key if k.startswith("title:")]
1547
+ assert len(title_keys) == 1, "title fallback key not derived"
1548
+ # THE round-trip trap: re-read must NOT quarantine the title: key
1549
+ again = read_ledger(f)
1550
+ assert not again.quarantined, f"title: key quarantined on re-read: {again.quarantined}"
1551
+ assert title_keys[0] in again.by_key, "title: key lost on re-read"
1552
+ rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys=title_keys[0], expand_pages=False, offset=0))
1553
+ # A title-only trial is a degraded record: the renderer correctly
1554
+ # emits the loud [MISSING field: ids.nct] marker (never fabricates)
1555
+ # — asserted here instead of pretending it renders clean.
1556
+ assert rc == 0 and "Title-only trial record" in out.splitlines()[0], "bib on title: key failed"
1557
+ assert "[MISSING field: ids.nct]" in out.splitlines()[0], "degraded trial must render its missing id loudly"
1558
+ # article keeps its hard id requirement
1559
+ _capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "article", "title": "x", "ids": {}}', stdin=False, aspect=None))
1560
+ assert len(read_ledger(f).by_key) == 2, "title-only article was accepted"
1561
+ check("auto-key", st_auto_key)
1562
+
1563
+ # ---- other type (escape hatch) + verify skip accounting --------------
1564
+ def st_other_and_verify_skip():
1565
+ f = d / "ot.jsonl"
1566
+ rec = {"type": "other", "title": "FDA label excerpt for vemurafenib",
1567
+ "ids": {"url": "https://www.accessdata.fda.gov/scripts/cder/daf/index.cfm?event=overview.process&ApplNo=1234"},
1568
+ "provenance": [{"aspect": "a"}]}
1569
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
1570
+ article = _fixture_article()
1571
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(article), stdin=False, aspect=None))
1572
+ led = read_ledger(f)
1573
+ assert any(k.startswith("url:") for k in led.by_key), "other-type url key not derived"
1574
+ _, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys=next(k for k in led.by_key if k.startswith("url:")), expand_pages=False, offset=0))
1575
+ assert "[type: other]" in out.splitlines()[0] and "[MISSING" not in out.splitlines()[0], out
1576
+ # verify (offline, fail-safe) skips the other-type record and says so
1577
+ import ncbi_esummary as ne
1578
+ old_url, old_sleep = ne.NCBI_ESUMMARY_URL, ne.time.sleep
1579
+ ne.NCBI_ESUMMARY_URL = "http://127.0.0.1:1/unreachable"
1580
+ ne.time.sleep = lambda *_: None
1581
+ try:
1582
+ _, out = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=False, timeout=0.2))
1583
+ finally:
1584
+ ne.NCBI_ESUMMARY_URL, ne.time.sleep = old_url, old_sleep
1585
+ assert "1 record(s) of unverified type(s) skipped (no verifier configured)" in out, f"skip accounting wrong: {out}"
1586
+ # article-only ledger keeps deterministic wording (0 skipped) for graders
1587
+ f2 = d / "ot2.jsonl"
1588
+ _capture(cmd_add, argparse.Namespace(file=str(f2), record=json.dumps(article), stdin=False, aspect=None))
1589
+ _, out = _capture(cmd_stats, argparse.Namespace(file=str(f2)))
1590
+ assert "record(s)" in out
1591
+ check("other-type/verify-skip", st_other_and_verify_skip)
1592
+
1593
+ # ---- meta-aware twin merging (complementary worker fields) -----------
1594
+ def st_meta_merge():
1595
+ f1, f2 = d / "mm1.jsonl", d / "mm2.jsonl"
1596
+ a = {"type": "trial", "ids": {"nct": "NCT09876543"}, "title": "Complementary",
1597
+ "meta": {"phase": "Phase 3"}, "provenance": [{"aspect": "worker_a"}]}
1598
+ b = {"type": "trial", "ids": {"nct_id": "NCT09876543"}, "title": None,
1599
+ "sponsor": "NCI", "status": "Recruiting", "provenance": [{"aspect": "worker_b"}]}
1600
+ f1.write_text(json.dumps(a) + "\n", encoding="utf-8")
1601
+ f2.write_text(json.dumps(b) + "\n", encoding="utf-8")
1602
+ out = d / "mm-merged.jsonl"
1603
+ _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(f1), str(f2)]))
1604
+ led = read_ledger(out)
1605
+ assert "nct:NCT09876543" in led.by_key, "twin merge failed"
1606
+ meta = led.by_key["nct:NCT09876543"]["meta"]
1607
+ assert meta.get("phase") == "Phase 3" and meta.get("sponsor") == "NCI" and meta.get("status") == "Recruiting", \
1608
+ f"complementary meta fields did not union: {meta}"
1609
+ _, rendered = _capture(cmd_bib, argparse.Namespace(file=str(out), keys="nct:NCT09876543", expand_pages=False, offset=0))
1610
+ line = rendered.splitlines()[0]
1611
+ assert "Phase Phase" not in line and "Phase 3." in line and "Sponsor: NCI." in line and "Status: Recruiting." in line, f"merged render wrong: {line}"
1612
+ aspects = {p["aspect"] for p in led.by_key["nct:NCT09876543"]["provenance"]}
1613
+ assert aspects == {"worker_a", "worker_b"}, f"provenance aspects lost: {aspects}"
1614
+ check("meta-merge", st_meta_merge)
1615
+
1616
+ # ---- batch appends, title-key rules, rewrite quarantine, BOM --------
1617
+ def st_batch_and_title_rules():
1618
+ def add_stdin(f, payload):
1619
+ old_stdin, sys.stdin = sys.stdin, io.StringIO(payload)
1620
+ try:
1621
+ return _capture(cmd_add, argparse.Namespace(file=str(f), record=None, stdin=True, aspect=None))
1622
+ finally:
1623
+ sys.stdin = old_stdin
1624
+ f = d / "bt.jsonl"
1625
+ # --stdin JSON-array batch (the worker-protocol rule-8 shape)
1626
+ batch = [_fixture_article(pmid="10000001", doi="10.1000/b1", title="Batch one", ids={"pmid": "10000001", "doi": "10.1000/b1", "pmcid": "PMC1000001"}),
1627
+ _fixture_article(pmid="10000002", doi="10.1000/b2", title="Batch two", ids={"pmid": "10000002", "doi": "10.1000/b2", "pmcid": "PMC1000002"})]
1628
+ rc, out = add_stdin(f, json.dumps(batch))
1629
+ assert rc == 0 and "add: 2 record(s) accepted, 0 rejected" in out, f"stdin batch failed: {out}"
1630
+ # @array-file batch
1631
+ bf = d / "bt-batch.json"
1632
+ bf.write_text(json.dumps([_fixture_article(pmid="10000003", doi="10.1000/b3", title="Batch three", ids={"pmid": "10000003", "doi": "10.1000/b3", "pmcid": "PMC1000003"}),
1633
+ _fixture_article(pmid="10000004", doi="10.1000/b4", title="Batch four", ids={"pmid": "10000004", "doi": "10.1000/b4", "pmcid": "PMC1000004"})]) + "\n", encoding="utf-8")
1634
+ rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record="@" + str(bf), stdin=False, aspect=None))
1635
+ assert rc == 0 and "add: 2 record(s) accepted" in out, f"@file batch failed: {out}"
1636
+ assert len(read_ledger(f).by_key) == 4, f"batch appends lost records: {sorted(read_ledger(f).by_key)}"
1637
+ # mixed batch: valid accepted, invalid rejected loudly, rc 0
1638
+ mixed = [_fixture_article(pmid="10000005", doi="10.1000/b5", title="Batch five", ids={"pmid": "10000005", "doi": "10.1000/b5", "pmcid": "PMC1000005"}),
1639
+ {"type": "article", "title": "no ids", "ids": {}}]
1640
+ rc, out = add_stdin(f, json.dumps(mixed))
1641
+ assert rc == 0 and "1 record(s) accepted, 1 rejected" in out, f"mixed batch accounting wrong: {out}"
1642
+ # title-key rules: web/dataset keep hard identity fields; article
1643
+ # rejects title-only input AND explicit title: keys; other falls
1644
+ # back to a title: key.
1645
+ before = len(read_ledger(f).by_key)
1646
+ for bad in ('{"type": "web", "title": "Just a page", "ids": {}}',
1647
+ '{"type": "dataset", "title": "Just a series", "ids": {}}',
1648
+ '{"type": "article", "title": "x", "ids": {}}',
1649
+ '{"type": "article", "key": "title:abc123", "title": "Bypass", "ids": {}}',
1650
+ '{"type": ["article"], "title": "unhashable", "ids": {}}'):
1651
+ rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record=bad, stdin=False, aspect=None))
1652
+ assert rc == 0 and "0 record(s) accepted, 1 rejected" in out, f"should have been rejected: {bad}: {out}"
1653
+ assert len(read_ledger(f).by_key) == before, "rejected records must not be written"
1654
+ other = {"type": "other", "title": "Guideline page", "ids": {},
1655
+ "provenance": [{"aspect": "a"}]}
1656
+ rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(other), stdin=False, aspect=None))
1657
+ assert rc == 0 and "add: 1 record(s) accepted" in out, f"other title-only rejected: {out}"
1658
+ assert any(k.startswith("title:") for k in read_ledger(f).by_key), "other title: key not derived"
1659
+ # pre-existing malformed line: add quarantines loudly, keeps good lines
1660
+ f2 = d / "bt2.jsonl"
1661
+ f2.write_text(json.dumps(_fixture_article(pmid="10000009", doi="10.1000/b9", title="Keep me", ids={"pmid": "10000009", "doi": "10.1000/b9", "pmcid": "PMC1000009"})) + "\nnot json\n", encoding="utf-8")
1662
+ rc, out = _capture(cmd_add, argparse.Namespace(file=str(f2), record=json.dumps(_fixture_article(pmid="10000010", doi="10.1000/b10", title="Add me", ids={"pmid": "10000010", "doi": "10.1000/b10", "pmcid": "PMC1000010"})), stdin=False, aspect=None))
1663
+ led2 = read_ledger(f2)
1664
+ assert rc == 0 and len(led2.by_key) == 2, f"rewrite kept wrong records: {sorted(led2.by_key)}"
1665
+ assert (f2.parent / "_invalid.jsonl").is_file(), "quarantine file not written by add"
1666
+ # BOM tolerance: re-read a BOM-prefixed file cleanly
1667
+ f3 = d / "bt3.jsonl"
1668
+ f3.write_bytes(b"\xef\xbb\xbf" + (json.dumps(_fixture_article(pmid="10000011", doi="10.1000/b11", title="Bom", ids={"pmid": "10000011", "doi": "10.1000/b11", "pmcid": "PMC1000011"})) + "\n").encode("utf-8"))
1669
+ led3 = read_ledger(f3)
1670
+ assert len(led3.by_key) == 1 and not led3.quarantined, f"BOM re-read failed: {led3.quarantined}"
1671
+ check("batch/title-rules", st_batch_and_title_rules)
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
+
1897
+ failed = [r for r in results if r[1] is not None]
1898
+ for name, err in results:
1899
+ print(f"{'PASS' if err is None else 'FAIL'} {name}" + (f": {err}" if err else ""))
1900
+ banner("selftest", f"{len(results) - len(failed)}/{len(results)} group(s) passed" + (" — FAILURES PRESENT" if failed else ""))
1901
+ return 1 if failed else 0
1902
+
1903
+
1904
+ def main() -> int:
1905
+ parser = argparse.ArgumentParser(description="Structured evidence ledger for deep-research citation integrity.")
1906
+ sub = parser.add_subparsers(dest="cmd", required=True)
1907
+
1908
+ p = sub.add_parser("add", help="Append/merge records into a ledger file")
1909
+ p.add_argument("file")
1910
+ p.add_argument("record", nargs="?", help="record JSON, @file, or omit with --stdin")
1911
+ p.add_argument("--stdin", action="store_true", help="read a record (or list) from stdin")
1912
+ p.add_argument("--aspect", help="aspect stamped onto records lacking provenance")
1913
+ p.set_defaults(fn=cmd_add)
1914
+
1915
+ p = sub.add_parser("merge", help="Union per-aspect ledgers into one file")
1916
+ p.add_argument("-o", "--out", required=True)
1917
+ p.add_argument("inputs", nargs="+", help="input files or globs (own output and _-prefixed files always excluded)")
1918
+ p.set_defaults(fn=cmd_merge)
1919
+
1920
+ p = sub.add_parser("verify", help="Cross-check article records against NCBI esummary (fail-safe)")
1921
+ p.add_argument("file")
1922
+ p.add_argument("--apply", action="store_true", help="write backfills in-place")
1923
+ p.add_argument("--timeout", type=float, default=15.0)
1924
+ p.set_defaults(fn=cmd_verify)
1925
+
1926
+ p = sub.add_parser("bib", help="Render a numbered bibliography from ledger records")
1927
+ p.add_argument("file")
1928
+ p.add_argument("--keys", required=True, help="comma-separated ledger keys in citation order")
1929
+ p.add_argument("--offset", type=int, default=0)
1930
+ p.add_argument("--expand-pages", action="store_true", help="expand abbreviated ranges (2507-16 -> 2507-2516)")
1931
+ p.set_defaults(fn=cmd_bib)
1932
+
1933
+ p = sub.add_parser("get", help="Print one record by key")
1934
+ p.add_argument("file")
1935
+ p.add_argument("--key", required=True)
1936
+ p.set_defaults(fn=cmd_get)
1937
+
1938
+ p = sub.add_parser("keys", help="List all keys")
1939
+ p.add_argument("file")
1940
+ p.set_defaults(fn=cmd_keys)
1941
+
1942
+ p = sub.add_parser("stats", help="Ledger summary counts")
1943
+ p.add_argument("file")
1944
+ p.set_defaults(fn=cmd_stats)
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
+
1959
+ p = sub.add_parser("selftest", help="Hermetic feature-matrix selftest (no network)")
1960
+ p.set_defaults(fn=lambda a: selftest())
1961
+
1962
+ args = parser.parse_args()
1963
+ try:
1964
+ return args.fn(args)
1965
+ except FileNotFoundError as e:
1966
+ banner(args.cmd, f"file not found (treated as empty, fail-safe): {e}")
1967
+ return 0
1968
+ except (json.JSONDecodeError, ValueError) as e:
1969
+ banner(args.cmd, f"input error: {e}")
1970
+ return 1
1971
+
1972
+
1973
+ if __name__ == "__main__":
1974
+ sys.exit(main())