opencode-bioresearcher 1.7.0 → 1.9.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,1412 @@
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
+ - Verb banners: every subcommand prints "[evidence-ledger] <verb>: ..." so
14
+ test graders can anchor on deterministic stdout.
15
+
16
+ Zero external dependencies (pure Python standard library).
17
+ """
18
+
19
+ import argparse
20
+ import contextlib
21
+ import datetime as _dt
22
+ import glob as _glob
23
+ import hashlib
24
+ import html as _html
25
+ import io
26
+ import json
27
+ import re
28
+ import sys
29
+ import tempfile
30
+ import time
31
+ from pathlib import Path
32
+
33
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
34
+ from ncbi_esummary import fetch_ncbi_summaries # noqa: E402
35
+
36
+ SCHEMA = "bioresearcher-evidence/1"
37
+ LEDGER_TYPES = {
38
+ "article", "trial", "patent", "gene", "variant",
39
+ "drug", "disease", "dataset", "web", "other",
40
+ }
41
+ KEY_NAMESPACES = (
42
+ "pmid", "doi", "pmcid", "nct", "patent", "geo", "sra", "gb",
43
+ "gene", "clinvar", "chembl", "chebi", "unii",
44
+ "mondo", "doid", "omim", "efo", "url", "title",
45
+ )
46
+
47
+ # biomcp-native id field names -> canonical ids slots (see TYPE_SPECS below).
48
+ # Aliased values are COPIED into the canonical slot (originals stay verbatim);
49
+ # None means "no canonical target: keep under the original key, never fold".
50
+ ID_ALIASES = {
51
+ "nct_id": "nct", # biomcp trial_search
52
+ "ncbi_gene_id": "ncbi_gene",
53
+ "entrez_id": "ncbi_gene",
54
+ "clinvar_id": "clinvar",
55
+ "clinvarid": "clinvar",
56
+ "rsid": "rs",
57
+ "rs_id": "rs",
58
+ "chembl_id": "chembl",
59
+ "chebi_id": "chebi",
60
+ "hgnc_id": "hgnc",
61
+ "patent_id": "patent",
62
+ "publication_number": "patent",
63
+ "geo_id": "geo",
64
+ "sra_id": "sra",
65
+ "gb_acc": "genbank",
66
+ "disease_id": None, # context-dependent: prefix-sniffed below
67
+ }
68
+
69
+ # Worker-written top-level fields COPIED into meta (fill-only; canonical meta
70
+ # wins; nulls never fold; idempotent under the every-read re-normalization).
71
+ FOLD_FIELDS = {
72
+ "trial": ["phase", "status", "sponsor", "enrollment"],
73
+ "drug": ["indication", "source_section"],
74
+ "patent": ["assignee", "status"],
75
+ "gene": ["symbol", "full_name"],
76
+ "variant": ["gene", "protein_change", "significance"],
77
+ }
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # Output helpers
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def banner(verb: str, message: str) -> None:
85
+ print(f"[evidence-ledger] {verb}: {message}")
86
+
87
+
88
+ def warn(message: str) -> None:
89
+ print(f"[evidence-ledger] warning: {message}", file=sys.stderr)
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Record schema + normalization
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def _strip_id_prefix(value: str) -> str:
97
+ return re.sub(r"^\s*(?:pmid|pmcid|doi|nct)\s*[:=]\s*", "", str(value), flags=re.IGNORECASE).strip()
98
+
99
+
100
+ def _canonicalize_id_value(key: str, value: str) -> str:
101
+ """Apply the per-key canonicalization rules (case, digits, DOI dots)."""
102
+ if key == "doi":
103
+ return value.lower().rstrip(".")
104
+ if key == "pmid":
105
+ if not value.isdigit():
106
+ raise ValueError(f"pmid must be digits, got {value!r}")
107
+ return value.lstrip("0") or "0"
108
+ if key in ("pmcid", "nct", "patent"):
109
+ return value.upper()
110
+ return value
111
+
112
+
113
+ _DISEASE_NS_RE = re.compile(r"^(MONDO|DOID|OMIM|EFO)[:_\s-]?(\d+)", re.IGNORECASE)
114
+
115
+
116
+ def normalize_ids(ids_raw: dict) -> dict:
117
+ """Canonicalize id values; copy alias slots to canonical keys verbatim-preserving."""
118
+ ids: dict = {}
119
+ for k, v in ids_raw.items():
120
+ if v is None:
121
+ continue
122
+ v = _strip_id_prefix(v)
123
+ if not v:
124
+ continue
125
+ ids[k] = _canonicalize_id_value(k, v)
126
+ # disease_id values carry their ontology in the prefix: sniff it
127
+ if k == "disease_id":
128
+ m = _DISEASE_NS_RE.match(v)
129
+ if m:
130
+ ns, num = m.group(1).upper(), m.group(2)
131
+ slot = {"MONDO": "mondo", "DOID": "doid", "OMIM": "omim", "EFO": "efo"}[ns]
132
+ ids.setdefault(slot, f"{ns}:{num}")
133
+ for k, target in ID_ALIASES.items():
134
+ if target is None or k not in ids_raw:
135
+ continue
136
+ v = ids_raw[k]
137
+ if v is None:
138
+ continue
139
+ v = _strip_id_prefix(v)
140
+ if not v:
141
+ continue
142
+ ids.setdefault(target, _canonicalize_id_value(target, v))
143
+ return ids
144
+
145
+
146
+ def normalize_record(raw, require_provenance_aspect=None) -> dict:
147
+ """Validate + normalize an incoming record. Raises ValueError on bad input."""
148
+ if not isinstance(raw, dict):
149
+ raise ValueError("record must be a JSON object")
150
+ if raw.get("schema") not in (None, SCHEMA):
151
+ raise ValueError(f"unsupported schema {raw.get('schema')!r} (expected {SCHEMA})")
152
+ rtype = raw.get("type")
153
+ if rtype not in LEDGER_TYPES:
154
+ raise ValueError(f"unknown type {rtype!r} (allowed: {', '.join(sorted(LEDGER_TYPES))})")
155
+
156
+ ids_raw = raw.get("ids") or {}
157
+ if not isinstance(ids_raw, dict):
158
+ raise ValueError("ids must be an object")
159
+ ids = normalize_ids(ids_raw)
160
+
161
+ title = raw.get("title")
162
+ if title is not None:
163
+ title = str(title).strip() or None
164
+ if not title and not ids:
165
+ raise ValueError("record needs a title or at least one id")
166
+ if raw.get("meta") is not None and not isinstance(raw.get("meta"), dict):
167
+ raise ValueError("meta must be an object")
168
+ if raw.get("authors") is not None and not isinstance(raw.get("authors"), list):
169
+ raise ValueError("authors must be a list of name strings")
170
+
171
+ # Loose-field folding: copy worker-written top-level fields into meta
172
+ # (fill-only; canonical meta wins; nulls never fold; originals preserved).
173
+ meta = dict(raw.get("meta") or {})
174
+ for field in FOLD_FIELDS.get(rtype, ()):
175
+ value = raw.get(field)
176
+ if value in (None, ""):
177
+ continue
178
+ if meta.get(field) in (None, ""):
179
+ meta[field] = value
180
+
181
+ key = canonical_key(raw.get("key"), rtype, ids, title)
182
+
183
+ provenance_raw = raw.get("provenance") or []
184
+ if not isinstance(provenance_raw, list):
185
+ raise ValueError("provenance must be a list")
186
+ provenance = []
187
+ for p in provenance_raw:
188
+ if not isinstance(p, dict) or not p.get("aspect"):
189
+ raise ValueError("every provenance entry needs an 'aspect'")
190
+ provenance.append({
191
+ "aspect": str(p["aspect"]),
192
+ "tool": str(p.get("tool") or "unknown"),
193
+ "args": p.get("args") or {},
194
+ "retrieved_at": str(p.get("retrieved_at") or ""),
195
+ })
196
+ if require_provenance_aspect and not any(p["aspect"] == require_provenance_aspect for p in provenance):
197
+ provenance.append({
198
+ "aspect": require_provenance_aspect, "tool": "unknown", "args": {}, "retrieved_at": "",
199
+ })
200
+
201
+ rec = dict(raw) # preserve extra/meta fields verbatim
202
+ rec.update({"schema": SCHEMA, "key": key, "type": rtype, "ids": ids, "title": title, "meta": meta})
203
+ for opt in ("title_original", "authors", "journal", "year", "volume", "issue", "pages", "url"):
204
+ rec.setdefault(opt, None)
205
+ rec.setdefault("verified", False)
206
+ rec.setdefault("verified_source", None)
207
+ rec.setdefault("verified_at", None)
208
+ if not isinstance(rec.get("backfilled"), list):
209
+ rec["backfilled"] = []
210
+ rec["provenance"] = provenance
211
+ return rec
212
+
213
+
214
+ def _title_key(rtype: str, title) -> str:
215
+ digest = hashlib.sha256(f"{rtype}:{title}".encode("utf-8")).hexdigest()[:16]
216
+ return f"title:{digest}"
217
+
218
+
219
+ def derive_dataset_key(ids: dict, title) -> str:
220
+ for v in ids.values():
221
+ v = str(v)
222
+ if v.upper().startswith("GSE"):
223
+ return f"geo:GSE{v[3:]}"
224
+ if v.upper().startswith("GDS"):
225
+ return f"geo:GDS{v[3:]}"
226
+ if v.upper().startswith("SRR"):
227
+ return f"sra:SRR{v[3:]}"
228
+ if v.upper().startswith("SRP"):
229
+ return f"sra:SRP{v[3:]}"
230
+ for k, v in ids.items():
231
+ if (k or "").lower() in ("genbank", "gb", "accession"):
232
+ return f"gb:{v}"
233
+ raise ValueError("dataset record needs a geo (GSE/GDS), sra (SRR/SRP), or genbank accession id")
234
+
235
+
236
+ def derive_web_key(ids: dict, title) -> str:
237
+ url = str((ids.get("url") or "")).strip()
238
+ if not url:
239
+ raise ValueError("web record needs ids.url")
240
+ return "url:" + hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
241
+
242
+
243
+ def derive_other_key(ids: dict, title) -> str:
244
+ """Deterministic fallback key: url -> first id in sorted key order -> title hash."""
245
+ url = str((ids.get("url") or "")).strip()
246
+ if url:
247
+ return "url:" + hashlib.sha256(url.encode("utf-8")).hexdigest()[:16]
248
+ for k in sorted(ids):
249
+ v = str(ids[k])
250
+ if v:
251
+ ns = ID_ALIASES.get(k, k)
252
+ if ns in KEY_NAMESPACES:
253
+ return f"{ns}:{v}"
254
+ if title:
255
+ return _title_key("other", title)
256
+ raise ValueError("record needs an id or a title to derive a key")
257
+
258
+
259
+ def canonical_key(raw_key, rtype: str, ids: dict, title=None) -> str:
260
+ """Canonical primary key (registry-driven); an explicit well-formed key wins."""
261
+ if raw_key:
262
+ k = str(raw_key).strip()
263
+ ns = k.split(":", 1)[0]
264
+ if ns in KEY_NAMESPACES and ":" in k:
265
+ # An explicit title: key must not bypass the hard-id requirement of
266
+ # verify-gated types (a title-only article could never verify).
267
+ if ns == "title" and (TYPE_SPECS.get(rtype) or {}).get("verify"):
268
+ raise ValueError(f"{rtype} record keeps its hard id requirement; explicit title: keys are not accepted")
269
+ return k
270
+ raise ValueError(f"malformed key {raw_key!r}")
271
+
272
+ spec = TYPE_SPECS.get(rtype) or {}
273
+ key_from = spec.get("key_from")
274
+ if key_from:
275
+ for ns, field in key_from:
276
+ if ids.get(field):
277
+ return f"{ns}:{ids[field]}"
278
+ elif spec.get("key_fn"):
279
+ return spec["key_fn"](ids, title)
280
+
281
+ # No derivation succeeded. Article keeps its hard id requirement (a
282
+ # title-only article could never verify); verify-less types may fall
283
+ # back to a deterministic title key.
284
+ if rtype != "article" and title:
285
+ return _title_key(rtype, title)
286
+ fallback_errors = {
287
+ "article": "article record needs a pmid, doi, or pmcid",
288
+ "trial": "trial record needs ids.nct (or a title)",
289
+ "patent": "patent record needs ids.patent (or a title)",
290
+ "gene": "gene record needs ids.ncbi_gene (or a title)",
291
+ "variant": "variant record needs ids.clinvar (or a title)",
292
+ "drug": "drug record needs a chembl/chebi/unii id (or a title)",
293
+ "disease": "disease record needs a mondo/doid/omim/efo id (or a title)",
294
+ }
295
+ raise ValueError(fallback_errors.get(rtype, f"{rtype} record needs an id or a title"))
296
+
297
+
298
+ def secondary_ids(rec: dict) -> set:
299
+ """Secondary identity for cross-key article dedupe (doi + pmcid)."""
300
+ out = set()
301
+ if rec.get("type") == "article":
302
+ for k in ("doi", "pmcid"):
303
+ v = (rec.get("ids") or {}).get(k)
304
+ if v:
305
+ out.add(f"{k}:{str(v).lower()}")
306
+ return out
307
+
308
+
309
+ NO_FILL_FIELDS = {
310
+ "schema", "key", "type", "source", "score", "_error",
311
+ "verified", "verified_source", "verified_at", "backfilled",
312
+ "title_original", "provenance",
313
+ }
314
+
315
+ # Key-namespace precedence for twin merges: a pmid-bearing twin promotes the
316
+ # union record to the pmid key so bib lookups work regardless of which aspect
317
+ # file sorted first (pmid > doi > pmcid).
318
+ _KEY_STRENGTH = {"pmid": 3, "doi": 2, "pmcid": 1}
319
+
320
+
321
+ def _key_namespace(key: str) -> str:
322
+ return key.split(":", 1)[0]
323
+
324
+
325
+ def merge_fill(base: dict, incoming: dict) -> None:
326
+ """Fill missing base fields from incoming; NEVER overwrite non-null values.
327
+
328
+ `meta` merges NESTED fill-only (complementary worker fields union instead
329
+ of the whole-dict drop a scalar comparison would cause).
330
+ """
331
+ incoming_meta = incoming.get("meta")
332
+ if isinstance(incoming_meta, dict):
333
+ base_meta = base.get("meta")
334
+ if not isinstance(base_meta, dict):
335
+ base_meta = {}
336
+ base["meta"] = base_meta
337
+ for k, v in incoming_meta.items():
338
+ if base_meta.get(k) in (None, "", [], {}) and v not in (None, "", [], {}):
339
+ base_meta[k] = v
340
+ for field, value in incoming.items():
341
+ if field in NO_FILL_FIELDS or field in ("meta",) or field.startswith("_"):
342
+ continue
343
+ if base.get(field) in (None, "", [], {}) and value not in (None, "", [], {}):
344
+ base[field] = value
345
+ base_provs = base.setdefault("provenance", [])
346
+ known_aspects = {p.get("aspect") for p in base_provs}
347
+ for p in incoming.get("provenance") or []:
348
+ if p.get("aspect") not in known_aspects:
349
+ base_provs.append(p)
350
+ known_aspects.add(p.get("aspect"))
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # JSONL file IO
355
+ # ---------------------------------------------------------------------------
356
+
357
+ class Ledger:
358
+ """In-memory ledger: records by key + secondary-id index + quarantine."""
359
+
360
+ def __init__(self):
361
+ self.by_key: dict = {}
362
+ self.sec_index: dict = {}
363
+ self.quarantined: list = []
364
+
365
+ def upsert(self, rec: dict) -> None:
366
+ existing = self.by_key.get(rec["key"])
367
+ if existing is not None:
368
+ merge_fill(existing, rec)
369
+ else:
370
+ twin_key = None
371
+ for sid in secondary_ids(rec):
372
+ twin_key = self.sec_index.get(sid)
373
+ if twin_key:
374
+ break
375
+ if twin_key is not None and twin_key in self.by_key:
376
+ base = self.by_key[twin_key]
377
+ merge_fill(base, rec)
378
+ # promote to the stronger key namespace (pmid > doi > pmcid)
379
+ if _KEY_STRENGTH.get(_key_namespace(rec["key"]), 0) > _KEY_STRENGTH.get(_key_namespace(twin_key), 0):
380
+ del self.by_key[twin_key]
381
+ base["key"] = rec["key"]
382
+ self.by_key[rec["key"]] = base
383
+ for sid, k in list(self.sec_index.items()):
384
+ if k == twin_key:
385
+ self.sec_index[sid] = rec["key"]
386
+ twin_key = rec["key"]
387
+ rec = base
388
+ else:
389
+ self.by_key[rec["key"]] = rec
390
+ for sid in secondary_ids(rec):
391
+ self.sec_index.setdefault(sid, rec["key"])
392
+
393
+ def records(self) -> list:
394
+ return list(self.by_key.values())
395
+
396
+ def write(self, path: Path) -> None:
397
+ path.parent.mkdir(parents=True, exist_ok=True)
398
+ tmp = path.with_suffix(path.suffix + ".tmp")
399
+ with tmp.open("w", encoding="utf-8") as fh:
400
+ for rec in self.records():
401
+ fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
402
+ tmp.replace(path)
403
+
404
+
405
+ def read_ledger(path: Path) -> Ledger:
406
+ led = Ledger()
407
+ if not path.is_file():
408
+ return led
409
+ for lineno, line in enumerate(path.read_text(encoding="utf-8-sig").splitlines(), 1):
410
+ s = line.strip()
411
+ if not s:
412
+ continue
413
+ try:
414
+ led.upsert(normalize_record(json.loads(s)))
415
+ except (json.JSONDecodeError, ValueError, TypeError) as e:
416
+ led.quarantined.append({"file": str(path), "line": lineno, "record": s, "error": str(e)})
417
+ return led
418
+
419
+
420
+ def append_quarantine(out_path: Path, entries: list) -> Path:
421
+ qpath = out_path.parent / "_invalid.jsonl"
422
+ seen = set()
423
+ if qpath.is_file():
424
+ for line in qpath.read_text(encoding="utf-8-sig").splitlines():
425
+ try:
426
+ e = json.loads(line)
427
+ seen.add((e.get("file"), e.get("line"), e.get("error")))
428
+ except (json.JSONDecodeError, AttributeError):
429
+ continue
430
+ with qpath.open("a", encoding="utf-8") as fh:
431
+ for e in entries:
432
+ fingerprint = (e.get("file"), e.get("line"), e.get("error"))
433
+ if fingerprint in seen:
434
+ continue
435
+ seen.add(fingerprint)
436
+ fh.write(json.dumps(e, ensure_ascii=False) + "\n")
437
+ return qpath
438
+
439
+
440
+ # ---------------------------------------------------------------------------
441
+ # Subcommands
442
+ # ---------------------------------------------------------------------------
443
+
444
+ def _parse_incoming(args) -> list:
445
+ if args.stdin:
446
+ payload = json.loads(sys.stdin.read())
447
+ return payload if isinstance(payload, list) else [payload]
448
+ if getattr(args, "record", None) is None:
449
+ raise ValueError("provide a record JSON, @file, or --stdin")
450
+ if args.record.startswith("@"):
451
+ payload = json.loads(Path(args.record[1:]).read_text(encoding="utf-8-sig"))
452
+ return payload if isinstance(payload, list) else [payload]
453
+ return [json.loads(args.record)]
454
+
455
+
456
+ def cmd_add(args) -> int:
457
+ path = Path(args.file)
458
+ led = read_ledger(path)
459
+ # add rewrites the file: pre-existing malformed lines would be silently
460
+ # dropped — quarantine them loudly instead (same file merge uses).
461
+ if led.quarantined:
462
+ qpath = append_quarantine(path, led.quarantined)
463
+ warn(f"{len(led.quarantined)} pre-existing malformed line(s) quarantined to {qpath} (excluded from rewrite)")
464
+ accepted = rejected = 0
465
+ for raw in _parse_incoming(args):
466
+ try:
467
+ led.upsert(normalize_record(raw, require_provenance_aspect=args.aspect))
468
+ except (ValueError, TypeError) as e:
469
+ rejected += 1
470
+ warn(f"rejected record ({e}): {json.dumps(raw, ensure_ascii=False)[:200]}")
471
+ continue
472
+ accepted += 1
473
+ led.write(path)
474
+ banner("add", f"{accepted} record(s) accepted, {rejected} rejected -> {path}")
475
+ return 0
476
+
477
+
478
+ def _expand_input_files(patterns: list, out_path: Path) -> list:
479
+ files: list = []
480
+ for pattern in patterns:
481
+ matches = sorted(_glob.glob(pattern)) if any(c in pattern for c in "*?[") else [pattern]
482
+ for m in matches:
483
+ p = Path(m)
484
+ if not p.is_file():
485
+ continue
486
+ if p.resolve() == out_path.resolve():
487
+ continue # never re-ingest own output
488
+ if p.name.startswith("_"):
489
+ continue # quarantine + underscore-prefixed files always excluded
490
+ files.append(p)
491
+ return files
492
+
493
+
494
+ def cmd_merge(args) -> int:
495
+ out_path = Path(args.out)
496
+ inputs = _expand_input_files(args.inputs, out_path)
497
+ merged = Ledger()
498
+ quarantine_entries = []
499
+ for f in inputs:
500
+ led = read_ledger(f)
501
+ quarantine_entries.extend(led.quarantined)
502
+ for rec in led.records():
503
+ merged.upsert(rec)
504
+ merged.write(out_path)
505
+ qnote = ""
506
+ if quarantine_entries:
507
+ qpath = append_quarantine(out_path, quarantine_entries)
508
+ qnote = f"; {len(quarantine_entries)} malformed line(s) quarantined to {qpath}"
509
+ banner("merge", f"{len(merged.by_key)} record(s) from {len(inputs)} file(s) -> {out_path}{qnote}")
510
+ return 0
511
+
512
+
513
+ def _esummary_locator(doc: dict) -> dict:
514
+ m = re.search(r"\b(19\d\d|20\d\d)\b", str(doc.get("pubdate", "")))
515
+ year = m.group(1) if m else str(doc.get("sortpubdate") or "")[:4]
516
+ return {
517
+ "year": year or None,
518
+ "volume": str(doc.get("volume", "")).strip() or None,
519
+ "issue": str(doc.get("issue", "")).strip() or None,
520
+ "pages": str(doc.get("pages", "")).strip() or None,
521
+ }
522
+
523
+
524
+ def _esummary_title(doc: dict) -> str:
525
+ t = re.sub(r"<[^>]+>", "", str(doc.get("title", "")))
526
+ t = _html.unescape(t).replace("\u00a0", " ")
527
+ return t.strip().rstrip(".")
528
+
529
+
530
+ def _esummary_doi(doc: dict):
531
+ for aid in doc.get("articleids", []) or []:
532
+ if aid.get("idtype") == "doi":
533
+ v = str(aid.get("value", "")).strip().rstrip(".").lower()
534
+ return v or None
535
+ return None
536
+
537
+
538
+ def cmd_verify(args) -> int:
539
+ path = Path(args.file)
540
+ led = read_ledger(path)
541
+ verifiable = {t for t, spec in TYPE_SPECS.items() if spec.get("verify")}
542
+ pmids = sorted({r["ids"]["pmid"] for r in led.records()
543
+ if r.get("type") in verifiable and (r.get("ids") or {}).get("pmid") and not r.get("verified")})
544
+ docs = fetch_ncbi_summaries(pmids, timeout=args.timeout) if pmids else {}
545
+ now = _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
546
+ filled = title_fixed = clean = unreachable = 0
547
+ skipped = sum(1 for r in led.records() if r.get("type") not in verifiable)
548
+ for rec in led.records():
549
+ if rec.get("type") not in verifiable:
550
+ continue
551
+ pmid = (rec.get("ids") or {}).get("pmid")
552
+ if not pmid or rec.get("verified"):
553
+ continue
554
+ doc = docs.get(pmid)
555
+ if not doc:
556
+ unreachable += 1
557
+ continue
558
+ changed = False
559
+ loc = _esummary_locator(doc)
560
+ for field in ("year", "volume", "issue", "pages"):
561
+ if rec.get(field) in (None, "") and loc[field]:
562
+ rec[field] = loc[field]
563
+ rec["backfilled"].append(field)
564
+ changed = True
565
+ if not (rec.get("ids") or {}).get("doi"):
566
+ doi = _esummary_doi(doc)
567
+ if doi:
568
+ rec["ids"]["doi"] = doi
569
+ rec["backfilled"].append("doi")
570
+ changed = True
571
+ if not rec.get("journal"):
572
+ j = str(doc.get("source", "")).strip()
573
+ if j:
574
+ rec["journal"] = j
575
+ rec["backfilled"].append("journal")
576
+ changed = True
577
+ if not rec.get("title"):
578
+ t = _esummary_title(doc)
579
+ if t:
580
+ rec["title"] = t
581
+ rec["title_original"] = None
582
+ rec["backfilled"].append("title")
583
+ changed = True
584
+ title_fixed += 1
585
+ rec["verified"] = True
586
+ rec["verified_source"] = "ncbi-esummary"
587
+ rec["verified_at"] = now
588
+ if changed:
589
+ filled += 1
590
+ else:
591
+ clean += 1
592
+ if args.apply:
593
+ led.write(path)
594
+ if led.quarantined:
595
+ qpath = append_quarantine(path, led.quarantined)
596
+ warn(f"{len(led.quarantined)} pre-existing malformed line(s) quarantined to {qpath} (excluded from rewrite)")
597
+ banner(
598
+ "verify",
599
+ f"{len(docs)} PubMed record(s) checked; {filled} backfilled, {title_fixed} title(s) set, "
600
+ f"{clean} verified clean, {unreachable} unreachable (fail-safe, left unverified); "
601
+ f"{skipped} record(s) of unverified type(s) skipped (no verifier configured)"
602
+ + ("; --apply written" if args.apply else " (dry-run, no changes written)"),
603
+ )
604
+ return 0
605
+
606
+
607
+ # ---------------------------------------------------------------------------
608
+ # Bibliography rendering
609
+ # ---------------------------------------------------------------------------
610
+
611
+ _GROUP_AUTHOR_RE = re.compile(
612
+ r"\b(group|consortium|investigators?|network|committee|collaborative|initiative|"
613
+ r"team|registry|alliance|panel|authors?|working|study|trial|project|program|"
614
+ r"organization|organisation|society|association|institute|council|foundation|"
615
+ r"university|college)\b",
616
+ re.IGNORECASE,
617
+ )
618
+
619
+ # lowercase surname particles absorbed into the family name ("van der Berg Jan"
620
+ # -> "van der Berg J"), never treated as given-name initials
621
+ _SURNAME_PARTICLES = {"van", "der", "den", "de", "del", "la", "di", "da", "dos", "von", "ter", "ten", "op", "'t"}
622
+
623
+
624
+ def vancouver_author(name: str) -> str:
625
+ """'Chapman Paul B' -> 'Chapman PB'; group/corporate names pass through."""
626
+ name = (name or "").strip()
627
+ if not name:
628
+ return ""
629
+ if _GROUP_AUTHOR_RE.search(name):
630
+ return name
631
+ tokens = name.split()
632
+ if len(tokens) == 1:
633
+ return name
634
+ if tokens[0].lower() in _SURNAME_PARTICLES:
635
+ # particle-leading surname: "van der Berg Jan" -> surname "van der Berg"
636
+ i = 0
637
+ while i < len(tokens) and tokens[i].lower() in _SURNAME_PARTICLES:
638
+ i += 1
639
+ if i < len(tokens):
640
+ surname = " ".join(tokens[: i + 1])
641
+ given = tokens[i + 1 :]
642
+ else:
643
+ surname, given = name, []
644
+ else:
645
+ surname, given = tokens[0], tokens[1:]
646
+ initials = "".join(t[0].upper() for t in given if t and t[0].isalpha())
647
+ return f"{surname} {initials}" if initials else surname
648
+
649
+
650
+ def _author_list(rec: dict, max_authors: int = 3) -> str:
651
+ authors = [a for a in (rec.get("authors") or []) if str(a).strip()]
652
+ if not authors:
653
+ return "[MISSING field: authors]"
654
+ out = ", ".join(filter(None, (vancouver_author(str(a)) for a in authors[:max_authors])))
655
+ if len(authors) > max_authors:
656
+ out += ", et al"
657
+ return out
658
+
659
+
660
+ def expand_pages(pages: str) -> str:
661
+ """Expand abbreviated numeric page ranges: '2507-16' -> '2507-2516'."""
662
+ m = re.match(r"^(\d+)\s*-\s*(\d+)$", (pages or "").strip())
663
+ if not m:
664
+ return pages
665
+ left, right = m.group(1), m.group(2)
666
+ if len(right) < len(left):
667
+ right = left[: len(left) - len(right)] + right
668
+ if int(right) < int(left):
669
+ return pages # ambiguous abbreviation; keep verbatim
670
+ return f"{left}-{right}"
671
+
672
+
673
+ def _locator(rec: dict, expand: bool) -> str:
674
+ year = rec.get("year") or "[MISSING field: year]"
675
+ vol, iss = rec.get("volume"), rec.get("issue")
676
+ pg = expand_pages(rec.get("pages")) if expand else rec.get("pages")
677
+ if vol and iss and pg:
678
+ return f"{year};{vol}({iss}):{pg}"
679
+ if vol and pg:
680
+ return f"{year};{vol}:{pg}"
681
+ if vol and iss:
682
+ return f"{year};{vol}({iss})"
683
+ if vol:
684
+ return f"{year};{vol}"
685
+ return f"{year}"
686
+
687
+
688
+ def _need(rec: dict, field: str) -> str:
689
+ v = rec.get(field)
690
+ if v in (None, ""):
691
+ return f"[MISSING field: {field}]"
692
+ return str(v)
693
+
694
+
695
+ def _close(s: str) -> str:
696
+ return s if s.endswith(".") else s + "."
697
+
698
+
699
+ def render_article(rec: dict, expand: bool) -> str:
700
+ ids = rec.get("ids") or {}
701
+ title = _close(_need(rec, "title").strip())
702
+ journal = _close(_need(rec, "journal"))
703
+ if any(rec.get(f) for f in ("volume", "issue", "pages")):
704
+ head = f"{_author_list(rec)} {title} {journal} {_locator(rec, expand)}."
705
+ else: # epub-ahead-of-print: locator legitimately absent at NCBI
706
+ head = f"{_author_list(rec)} {title} {journal} {rec.get('year') or '[MISSING field: year]'}."
707
+ tail = ""
708
+ if ids.get("doi"):
709
+ tail += f" DOI: {ids['doi']}."
710
+ if ids.get("pmid"):
711
+ tail += f" PMID: {ids['pmid']}."
712
+ return head + tail
713
+
714
+
715
+ def render_trial(rec: dict) -> str:
716
+ ids = rec.get("ids") or {}
717
+ meta = rec.get("meta") or {}
718
+ nct = ids.get("nct") or "[MISSING field: ids.nct]"
719
+ out = f"{nct}: {_need(rec, 'title')}."
720
+ phase = str(meta.get("phase") or "").strip().rstrip(".")
721
+ if phase:
722
+ # Workers copy phase verbatim from biomcp/CTgov ("Phase 2", "PHASE3",
723
+ # "2"): never double the prefix.
724
+ out += f" {phase}." if phase.lower().startswith("phase") else f" Phase {phase}."
725
+ if meta.get("sponsor"):
726
+ out += f" Sponsor: {meta['sponsor']}."
727
+ if meta.get("status"):
728
+ out += f" Status: {meta['status']}."
729
+ out += " " + (rec.get("url") or f"https://clinicaltrials.gov/study/{nct}")
730
+ return out
731
+
732
+
733
+ def render_patent(rec: dict) -> str:
734
+ ids = rec.get("ids") or {}
735
+ meta = rec.get("meta") or {}
736
+ num = ids.get("patent") or "[MISSING field: ids.patent]"
737
+ assignee = meta.get("assignee") or "[MISSING field: meta.assignee]"
738
+ status = f" ({meta['status']})" if meta.get("status") else ""
739
+ url = rec.get("url") or f"https://patents.google.com/patent/{num}"
740
+ return f"{assignee}. {_need(rec, 'title')}. {num}{status}. {url}"
741
+
742
+
743
+ def render_gene(rec: dict) -> str:
744
+ ids = rec.get("ids") or {}
745
+ meta = rec.get("meta") or {}
746
+ symbol = meta.get("symbol") or "[MISSING field: meta.symbol]"
747
+ gene_id = ids.get("ncbi_gene") or "[MISSING field: ids.ncbi_gene]"
748
+ hgnc = ids.get("hgnc") or meta.get("hgnc")
749
+ hgnc_part = f" HGNC: {hgnc}." if hgnc else ""
750
+ url = rec.get("url") or f"https://www.ncbi.nlm.nih.gov/gene/{ids.get('ncbi_gene', '')}"
751
+ return f"{symbol}: {_need(rec, 'title')}. NCBI Gene ID: {gene_id}.{hgnc_part} {url}"
752
+
753
+
754
+ def render_variant(rec: dict) -> str:
755
+ ids = rec.get("ids") or {}
756
+ meta = rec.get("meta") or {}
757
+ gene = meta.get("gene") or "[MISSING field: meta.gene]"
758
+ change = meta.get("protein_change") or "[MISSING field: meta.protein_change]"
759
+ sig = meta.get("significance") or "[MISSING field: meta.significance]"
760
+ clinvar = ids.get("clinvar") or "[MISSING field: ids.clinvar]"
761
+ rs = ids.get("rs") or meta.get("rs")
762
+ rs_part = f" ({rs})" if rs else ""
763
+ url = rec.get("url") or f"https://www.ncbi.nlm.nih.gov/clinvar/variation/{clinvar}"
764
+ return f"{gene} p.{change}{rs_part}: {sig} [ClinVar: {clinvar}]. {url}"
765
+
766
+
767
+ def render_drug(rec: dict) -> str:
768
+ ids = rec.get("ids") or {}
769
+ meta = rec.get("meta") or {}
770
+ dbid = next((f"{k.upper()}: {ids[k]}" for k in ("chembl", "chebi", "unii") if ids.get(k)),
771
+ "[MISSING field: ids.chembl|chebi|unii]")
772
+ ind = f" Indication: {meta['indication']}." if meta.get("indication") else ""
773
+ url = rec.get("url") or ""
774
+ return f"{_need(rec, 'title')}.{ind} {dbid}. {url}".strip()
775
+
776
+
777
+ def render_disease(rec: dict) -> str:
778
+ ids = rec.get("ids") or {}
779
+ oid = next((f"{k.upper()}:{ids[k]}" for k in ("mondo", "doid", "omim", "efo") if ids.get(k)),
780
+ "[MISSING field: ids.mondo|doid|omim|efo]")
781
+ return f"{_need(rec, 'title')}. {oid}. {rec.get('url') or ''}".strip()
782
+
783
+
784
+ def render_dataset(rec: dict) -> str:
785
+ ids = rec.get("ids") or {}
786
+ key = rec.get("key", "")
787
+ title = _need(rec, "title")
788
+ if key.startswith("geo:"):
789
+ acc = ids.get("geo") or ids.get("accession") or key[len("geo:"):]
790
+ return f"GEO series {acc}: {title}. https://www.ncbi.nlm.nih.gov/geo/query/acc.cgi?acc={acc}"
791
+ if key.startswith("sra:"):
792
+ acc = ids.get("sra") or ids.get("accession") or key[len("sra:"):]
793
+ return f"SRA run {acc}: {title}. https://trace.ncbi.nlm.nih.gov/Traces/?run={acc}"
794
+ acc = ids.get("genbank") or ids.get("accession") or key[len("gb:"):]
795
+ return f"GenBank accession {acc}: {title}. https://www.ncbi.nlm.nih.gov/nuccore/{acc}"
796
+
797
+
798
+ def render_web(rec: dict) -> str:
799
+ meta = rec.get("meta") or {}
800
+ updated = f" Updated {meta['updated']}." if meta.get("updated") else ""
801
+ url = rec.get("url") or (rec.get("ids") or {}).get("url") or "[MISSING field: url]"
802
+ accessed = meta.get("accessed") or "[MISSING field: meta.accessed]"
803
+ return f"{_need(rec, 'title')}. {meta.get('organization') or '[MISSING field: meta.organization]'}.{updated} {url}. Accessed: {accessed}."
804
+
805
+
806
+ def render_other(rec: dict) -> str:
807
+ """Generic renderer for the `other` escape-hatch type (and any future type
808
+ that registers without a dedicated render function)."""
809
+ parts = [_need(rec, "title")]
810
+ ids = rec.get("ids") or {}
811
+ extras = [f"{k}: {v}" for k, v in sorted(ids.items()) if v]
812
+ if rec.get("url"):
813
+ extras.append(str(rec["url"]))
814
+ if extras:
815
+ parts.append(" ".join(extras) + ".")
816
+ parts.append(f"[type: {rec.get('type', 'other')}]")
817
+ return " ".join(parts)
818
+
819
+
820
+ TYPE_SPECS = {
821
+ # key_from = ordered (namespace, id-field) pairs for auto-derivation —
822
+ # every namespace listed MUST be in KEY_NAMESPACES or re-reads quarantine
823
+ # the record; key_fn for value-pattern derivation (dataset) or
824
+ # deterministic fallbacks (web, other); verify gates cmd_verify; render
825
+ # renders in bib.
826
+ "article": {"key_from": [("pmid", "pmid"), ("doi", "doi"), ("pmcid", "pmcid")], "verify": True, "render": lambda r, e: render_article(r, e)},
827
+ "trial": {"key_from": [("nct", "nct")], "verify": False, "render": lambda r, e: render_trial(r)},
828
+ "patent": {"key_from": [("patent", "patent")], "verify": False, "render": lambda r, e: render_patent(r)},
829
+ "gene": {"key_from": [("gene", "ncbi_gene")], "verify": False, "render": lambda r, e: render_gene(r)},
830
+ "variant": {"key_from": [("clinvar", "clinvar")], "verify": False, "render": lambda r, e: render_variant(r)},
831
+ "drug": {"key_from": [("chembl", "chembl"), ("chebi", "chebi"), ("unii", "unii")], "verify": False, "render": lambda r, e: render_drug(r)},
832
+ "disease": {"key_from": [("mondo", "mondo"), ("doid", "doid"), ("omim", "omim"), ("efo", "efo")], "verify": False, "render": lambda r, e: render_disease(r)},
833
+ "dataset": {"key_fn": derive_dataset_key, "verify": False, "render": lambda r, e: render_dataset(r)},
834
+ "web": {"key_fn": derive_web_key, "verify": False, "render": lambda r, e: render_web(r)},
835
+ # NOTE: web and other share the url: key namespace (by design: same URL =
836
+ # same source); same-URL records of the two types therefore merge into one
837
+ # record on add/merge, fill-only, with both provenance chains preserved.
838
+ "other": {"key_fn": derive_other_key, "verify": False, "render": lambda r, e: render_other(r)},
839
+ }
840
+
841
+
842
+ def render_record(rec: dict, expand_pages: bool = False) -> str:
843
+ rtype = rec.get("type")
844
+ spec = TYPE_SPECS.get(rtype)
845
+ if spec and spec.get("render"):
846
+ return spec["render"](rec, expand_pages)
847
+ return render_other(rec)
848
+
849
+
850
+ def cmd_bib(args) -> int:
851
+ path = Path(args.file)
852
+ led = read_ledger(path)
853
+ keys = [k.strip() for k in args.keys.split(",") if k.strip()]
854
+ lines = []
855
+ missing = []
856
+ n = args.offset
857
+ for k in keys:
858
+ rec = led.by_key.get(k)
859
+ if rec is None:
860
+ missing.append(k)
861
+ lines.append(f"[MISSING record {k}]")
862
+ continue
863
+ n += 1
864
+ lines.append(f"[{n}] {render_record(rec, args.expand_pages)}")
865
+ print("\n".join(lines))
866
+ if missing:
867
+ banner("bib", f"{len(keys) - len(missing)} rendered, {len(missing)} unknown key(s): {', '.join(missing)}")
868
+ return 1
869
+ banner("bib", f"{len(keys)} record(s) rendered from {path}")
870
+ return 0
871
+
872
+
873
+ def cmd_get(args) -> int:
874
+ led = read_ledger(Path(args.file))
875
+ rec = led.by_key.get(args.key)
876
+ if rec is None:
877
+ banner("get", f"key {args.key} not found in {args.file}")
878
+ return 1
879
+ print(json.dumps(rec, indent=2, ensure_ascii=False))
880
+ banner("get", f"key {args.key}")
881
+ return 0
882
+
883
+
884
+ def cmd_keys(args) -> int:
885
+ led = read_ledger(Path(args.file))
886
+ for k in sorted(led.by_key):
887
+ print(k)
888
+ banner("keys", f"{len(led.by_key)} key(s)")
889
+ return 0
890
+
891
+
892
+ def cmd_stats(args) -> int:
893
+ led = read_ledger(Path(args.file))
894
+ types: dict = {}
895
+ verified = located = 0
896
+ for rec in led.records():
897
+ types[rec.get("type", "?")] = types.get(rec.get("type", "?"), 0) + 1
898
+ verified += bool(rec.get("verified"))
899
+ located += bool(rec.get("volume") or rec.get("pages"))
900
+ banner("stats", f"{len(led.by_key)} record(s); verified {verified}; with locator {located}; quarantined lines {len(led.quarantined)}")
901
+ for t, c in sorted(types.items()):
902
+ print(f" {t}: {c}")
903
+ return 0
904
+
905
+
906
+ # ---------------------------------------------------------------------------
907
+ # Hermetic selftest (CI; no network)
908
+ # ---------------------------------------------------------------------------
909
+
910
+ def _capture(fn, *a, **kw):
911
+ buf = io.StringIO()
912
+ with contextlib.redirect_stdout(buf):
913
+ rc = fn(*a, **kw)
914
+ return rc, buf.getvalue()
915
+
916
+
917
+ def _fixture_article(pmid="21639808", doi="10.1056/nejmoa1103782", title="Improved survival with vemurafenib in melanoma with BRAF V600E mutation", **over):
918
+ rec = {
919
+ "schema": SCHEMA,
920
+ "key": f"pmid:{pmid}",
921
+ "type": "article",
922
+ "ids": {"pmid": pmid, "doi": doi, "pmcid": "PMC3549296"},
923
+ "title": title,
924
+ "title_original": None,
925
+ "authors": ["Chapman Paul B", "Hauschild Axel", "Robert Caroline", "BRIM-3 Investigators"],
926
+ "journal": "N Engl J Med",
927
+ "year": "2011", "volume": "364", "issue": "26", "pages": "2507-16",
928
+ "url": f"https://pubmed.ncbi.nlm.nih.gov/{pmid}/",
929
+ "verified": False, "verified_source": None, "verified_at": None, "backfilled": [],
930
+ "provenance": [{"aspect": "test", "tool": "article_search", "args": {"query": "x"}, "retrieved_at": "2026-09-10T00:00:00Z"}],
931
+ }
932
+ rec.update(over)
933
+ return rec
934
+
935
+
936
+ def selftest() -> int:
937
+ with tempfile.TemporaryDirectory() as td:
938
+ d = Path(td)
939
+ results = []
940
+
941
+ def check(name, fn):
942
+ try:
943
+ fn()
944
+ results.append((name, None))
945
+ except AssertionError as e:
946
+ results.append((name, str(e)))
947
+ except Exception as e: # noqa: BLE001
948
+ results.append((name, f"exception: {type(e).__name__}: {e}"))
949
+
950
+ # ---- add ------------------------------------------------------------
951
+ def st_add():
952
+ f = d / "a.jsonl"
953
+ rc, _ = _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(_fixture_article()), stdin=False, aspect=None))
954
+ assert rc == 0
955
+ led = read_ledger(f)
956
+ assert "pmid:21639808" in led.by_key and not led.quarantined
957
+ # ID normalization: PMID: prefix + leading zeros stripped; DOI lowercased
958
+ rec2 = _fixture_article(pmid="12345", doi="10.1000/UPPER.Case", title="Second paper")
959
+ rec2["ids"].update({"pmid": "PMID: 0012345", "pmcid": "PMC9999999"})
960
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec2), stdin=False, aspect=None))
961
+ led = read_ledger(f)
962
+ assert "pmid:12345" in led.by_key, "PMID: prefix / leading zeros not stripped"
963
+ assert led.by_key["pmid:12345"]["ids"]["doi"] == "10.1000/upper.case", "DOI not lowercased"
964
+ # secondary-id dedupe: doi-only twin merges into the pmid record
965
+ rec3 = {"type": "article", "ids": {"doi": "10.1056/nejmoa1103782"}, "title": None, "journal": None}
966
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec3), stdin=False, aspect=None))
967
+ led = read_ledger(f)
968
+ assert len(led.by_key) == 2, f"secondary-id dedupe failed ({len(led.by_key)} records)"
969
+ assert led.by_key["pmid:21639808"]["title"].startswith("Improved survival"), "merge-fill overwrote a non-null value"
970
+ # bad records rejected loudly
971
+ rc, _ = _capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "article", "title": "x", "ids": {}}', stdin=False, aspect=None))
972
+ led2 = read_ledger(f)
973
+ assert len(led2.by_key) == 2, "invalid record accepted"
974
+ check("add", st_add)
975
+
976
+ # ---- merge ------------------------------------------------------------
977
+ def st_merge():
978
+ sub = d / "aspects"
979
+ sub.mkdir()
980
+ f1, f2, f3 = sub / "m1.jsonl", sub / "m2.jsonl", sub / "m3.jsonl"
981
+ rec_a = _fixture_article(volume=None, issue=None, pages=None)
982
+ f1.write_text(json.dumps(rec_a) + "\n", encoding="utf-8")
983
+ rec_b = _fixture_article(key=None)
984
+ rec_b.pop("key")
985
+ rec_b["ids"] = {"doi": "10.1056/nejmoa1103782"}
986
+ rec_b["volume"] = "364"
987
+ f2.write_text(json.dumps(rec_b) + "\n", encoding="utf-8")
988
+ f3.write_text(
989
+ json.dumps({"key": "pmid:1", "type": "article", "ids": {"pmid": "1"}, "title": "t",
990
+ "provenance": [{"aspect": "x"}]}) + "\nnot json at all\n",
991
+ encoding="utf-8",
992
+ )
993
+ out = sub / "sources.jsonl"
994
+ rc, _ = _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(f1), str(f2), str(f3)]))
995
+ led = read_ledger(out)
996
+ assert rc == 0
997
+ assert "pmid:21639808" in led.by_key and "doi:10.1056/nejmoa1103782" not in led.by_key, "secondary-id union failed"
998
+ assert led.by_key["pmid:21639808"]["volume"] == "364", "locator not merged in"
999
+ assert "pmid:1" in led.by_key
1000
+ assert (sub / "_invalid.jsonl").is_file(), "quarantine file not written"
1001
+ # glob re-run: own output + quarantine excluded -> same content
1002
+ rc2, _ = _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(sub / "*.jsonl")]))
1003
+ led2 = read_ledger(out)
1004
+ assert rc2 == 0 and set(led2.by_key) == {"pmid:21639808", "pmid:1"}, f"glob re-run broke ledger: {sorted(led2.by_key)}"
1005
+ # quarantine stays idempotent across re-merges
1006
+ qbefore = len((sub / "_invalid.jsonl").read_text(encoding="utf-8").splitlines())
1007
+ _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(sub / "*.jsonl")]))
1008
+ qafter = len((sub / "_invalid.jsonl").read_text(encoding="utf-8").splitlines())
1009
+ assert qbefore == qafter, "quarantine grew on re-merge"
1010
+ # doi-first twin ordering promotes the union record to the pmid key
1011
+ d1, d2 = sub / "aa_doi.jsonl", sub / "zz_pmid.jsonl"
1012
+ d1.write_text(json.dumps({"type": "article", "ids": {"doi": "10.9999/promote"},
1013
+ "title": "Union record", "volume": None,
1014
+ "provenance": [{"aspect": "doi_side"}]}) + "\n", encoding="utf-8")
1015
+ d2.write_text(json.dumps({"key": "pmid:777", "type": "article", "ids": {"pmid": "777", "doi": "10.9999/promote"},
1016
+ "title": None, "volume": "9",
1017
+ "provenance": [{"aspect": "pmid_side"}]}) + "\n", encoding="utf-8")
1018
+ out2 = sub / "promoted.jsonl"
1019
+ _capture(cmd_merge, argparse.Namespace(out=str(out2), inputs=[str(d1), str(d2)]))
1020
+ led3 = read_ledger(out2)
1021
+ assert set(led3.by_key) == {"pmid:777"}, f"key promotion failed: {sorted(led3.by_key)}"
1022
+ assert led3.by_key["pmid:777"]["volume"] == "9" and led3.by_key["pmid:777"]["title"] == "Union record"
1023
+ check("merge", st_merge)
1024
+
1025
+ # ---- verify offline (fail-safe) -----------------------------------------
1026
+ def st_verify_offline():
1027
+ import ncbi_esummary as ne
1028
+ f = d / "v.jsonl"
1029
+ f.write_text(json.dumps(_fixture_article(volume=None, issue=None, pages=None)) + "\n", encoding="utf-8")
1030
+ old_url, old_sleep = ne.NCBI_ESUMMARY_URL, ne.time.sleep
1031
+ ne.NCBI_ESUMMARY_URL = "http://127.0.0.1:1/unreachable"
1032
+ ne.time.sleep = lambda *_: None
1033
+ try:
1034
+ rc, _ = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=True, timeout=0.2))
1035
+ finally:
1036
+ ne.NCBI_ESUMMARY_URL, ne.time.sleep = old_url, old_sleep
1037
+ assert rc == 0, "verify must exit 0 on network failure"
1038
+ rec = read_ledger(f).by_key["pmid:21639808"]
1039
+ assert rec["verified"] is False and rec["volume"] is None, "fail-safe violated: record changed on network failure"
1040
+ check("verify-offline", st_verify_offline)
1041
+
1042
+ # ---- verify backfill (mocked esummary) -----------------------------------
1043
+ def st_verify_backfill():
1044
+ f = d / "v2.jsonl"
1045
+ epub = _fixture_article(pmid="42487519", doi="10.1111/cas.70480",
1046
+ title="New Treatment Strategy and Future Research Direction for BRAF-Mutated Cancer",
1047
+ volume=None, issue=None, pages=None)
1048
+ epub.update({"journal": "Cancer Sci", "year": "2026", "authors": ["Takahashi Masanobu", "Taniguchi Sakura Hiraide"]})
1049
+ hint = _fixture_article(pmid="99900001", doi=None, title=None, journal=None, year=None,
1050
+ volume=None, issue=None, pages=None, authors=None)
1051
+ hint["ids"] = {"pmid": "99900001"}
1052
+ f.write_text(json.dumps(epub) + "\n" + json.dumps(hint) + "\n", encoding="utf-8")
1053
+ docs = {
1054
+ "42487519": {"pubdate": "2026 Jul 23", "volume": "", "issue": "", "pages": "",
1055
+ "source": "Cancer Sci",
1056
+ "title": "New Treatment Strategy and Future Research Direction for BRAF-Mutated Cancer.",
1057
+ "articleids": [{"idtype": "doi", "value": "10.1111/cas.70480"}]},
1058
+ "99900001": {"pubdate": "2011 Jun 30", "volume": "364", "issue": "26", "pages": "2507-16",
1059
+ "source": "N Engl J Med", "title": "Mocked title for hint record.",
1060
+ "articleids": [{"idtype": "doi", "value": "10.1056/NEJMoa1103782"}]},
1061
+ }
1062
+ mod = sys.modules[__name__]
1063
+ original = mod.fetch_ncbi_summaries
1064
+ mod.fetch_ncbi_summaries = lambda pmids, timeout=15.0: docs
1065
+ try:
1066
+ rc, _ = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=True, timeout=1.0))
1067
+ finally:
1068
+ mod.fetch_ncbi_summaries = original
1069
+ assert rc == 0
1070
+ led = read_ledger(f)
1071
+ e = led.by_key["pmid:42487519"]
1072
+ assert e["verified"] is True and e["volume"] is None, "epub record must verify WITHOUT inventing locators"
1073
+ h = led.by_key["pmid:99900001"]
1074
+ assert h["title"] == "Mocked title for hint record", "hint title not backfilled"
1075
+ assert h["volume"] == "364" and h["pages"] == "2507-16", "locators not backfilled"
1076
+ assert "volume" in h["backfilled"] and "title" in h["backfilled"]
1077
+ check("verify-backfill", st_verify_backfill)
1078
+
1079
+ # ---- bib -------------------------------------------------------------------
1080
+ def st_bib():
1081
+ # Vancouver initials: standard, particle surnames, group passthrough
1082
+ assert vancouver_author("Chapman Paul B") == "Chapman PB"
1083
+ assert vancouver_author("van der Berg Jan") == "van der Berg J", vancouver_author("van der Berg Jan")
1084
+ assert vancouver_author("De la Cruz Maria E") == "De la Cruz ME", vancouver_author("De la Cruz Maria E")
1085
+ assert vancouver_author("World Health Organization") == "World Health Organization"
1086
+ assert vancouver_author("Li Jiang") == "Li J"
1087
+ assert vancouver_author("WHO") == "WHO"
1088
+ # expand_pages guards
1089
+ assert expand_pages("2507-16") == "2507-2516"
1090
+ assert expand_pages("2507-2516") == "2507-2516"
1091
+ assert expand_pages("e71310") == "e71310"
1092
+ assert expand_pages("2507-6") == "2507-6", "backwards expansion must be rejected"
1093
+ f = d / "b.jsonl"
1094
+ f.write_text(json.dumps(_fixture_article()) + "\n", encoding="utf-8")
1095
+ rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:21639808", expand_pages=False, offset=0))
1096
+ assert rc == 0
1097
+ first = out.splitlines()[0]
1098
+ assert "Chapman PB, Hauschild A, Robert C, et al" in first, f"Vancouver initials wrong: {first}"
1099
+ assert "2011;364(26):2507-16" in first, f"locator wrong: {first}"
1100
+ assert "PMID: 21639808." in first and "DOI: 10.1056/nejmoa1103782." in first, first
1101
+ rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:21639808", expand_pages=True, offset=0))
1102
+ assert "2011;364(26):2507-2516" in out.splitlines()[0], "expand-pages failed"
1103
+ # epub form: locator-less render
1104
+ epub = _fixture_article(pmid="42487519", doi="10.1111/cas.70480", title="New Treatment Strategy",
1105
+ volume=None, issue=None, pages=None)
1106
+ epub.update({"journal": "Cancer Sci", "year": "2026", "authors": ["Takahashi Masanobu", "Taniguchi Sakura Hiraide"]})
1107
+ f.write_text(json.dumps(epub) + "\n", encoding="utf-8")
1108
+ _, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:42487519", expand_pages=False, offset=0))
1109
+ line = out.splitlines()[0]
1110
+ assert "Cancer Sci. 2026." in line and "364" not in line and "DOI: 10.1111/cas.70480." in line, f"epub form wrong: {line}"
1111
+ assert "Takahashi M, Taniguchi SH" in line, "multi-initial author wrong"
1112
+ # unknown key -> loud + non-zero
1113
+ rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="pmid:42487519,pmid:nope", expand_pages=False, offset=0))
1114
+ assert rc != 0, "unknown key must exit non-zero"
1115
+ assert "[MISSING record pmid:nope]" in out
1116
+ check("bib", st_bib)
1117
+
1118
+ # ---- get / keys / stats ------------------------------------------------------
1119
+ def st_misc():
1120
+ f = d / "g.jsonl"
1121
+ f.write_text(json.dumps(_fixture_article()) + "\n", encoding="utf-8")
1122
+ rc, out = _capture(cmd_get, argparse.Namespace(file=str(f), key="pmid:21639808"))
1123
+ assert rc == 0 and '"key": "pmid:21639808"' in out
1124
+ rc, out = _capture(cmd_keys, argparse.Namespace(file=str(f)))
1125
+ assert rc == 0 and "pmid:21639808" in out
1126
+ rc, out = _capture(cmd_stats, argparse.Namespace(file=str(f)))
1127
+ assert rc == 0 and "record(s)" in out
1128
+ check("get/keys/stats", st_misc)
1129
+
1130
+ # ---- aliases (biomcp-native field names -> canonical slots) ---------
1131
+ def st_aliases():
1132
+ f = d / "al.jsonl"
1133
+ # variant: rsid -> rs
1134
+ rec = {"type": "variant", "ids": {"clinvar": "13961", "rsid": "rs113488022"},
1135
+ "title": "BRAF V600E", "meta": {"gene": "BRAF", "protein_change": "V600E", "significance": "Pathogenic"},
1136
+ "provenance": [{"aspect": "a"}]}
1137
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
1138
+ led = read_ledger(f)
1139
+ assert "clinvar:13961" in led.by_key
1140
+ assert led.by_key["clinvar:13961"]["ids"].get("rs") == "rs113488022", "rsid alias not copied"
1141
+ assert led.by_key["clinvar:13961"]["ids"].get("rsid") == "rs113488022", "original alias key lost"
1142
+ # gene: entrez_id -> ncbi_gene
1143
+ rec = {"type": "gene", "ids": {"entrez_id": "673"}, "title": "BRAF",
1144
+ "meta": {"symbol": "BRAF"}, "provenance": [{"aspect": "a"}]}
1145
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
1146
+ led = read_ledger(f)
1147
+ assert "gene:673" in led.by_key, "entrez_id alias not canonicalized"
1148
+ # lowercase nct_id must NOT fork a duplicate key (alias values are canonicalized)
1149
+ a = {"type": "trial", "ids": {"nct": "NCT04903119"}, "title": "T", "provenance": [{"aspect": "a"}]}
1150
+ b = {"type": "trial", "ids": {"nct_id": "nct04903119"}, "title": None, "provenance": [{"aspect": "b"}]}
1151
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(a), stdin=False, aspect=None))
1152
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(b), stdin=False, aspect=None))
1153
+ led = read_ledger(f)
1154
+ assert len([k for k in led.by_key if k.startswith("nct:")]) == 1, "lowercase alias forked a duplicate nct key"
1155
+ # explicit well-formed key beats alias-derived key
1156
+ c = {"key": "nct:NCT00000001", "type": "trial", "ids": {"nct_id": "NCT04903119"},
1157
+ "title": "Other", "provenance": [{"aspect": "a"}]}
1158
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(c), stdin=False, aspect=None))
1159
+ led = read_ledger(f)
1160
+ assert "nct:NCT00000001" in led.by_key, "explicit key did not win over alias-derived key"
1161
+ check("aliases", st_aliases)
1162
+
1163
+ # ---- folding (verbatim q05 regression fixture + semantics) ----------
1164
+ def st_folding():
1165
+ f = d / "fold.jsonl"
1166
+ # EXACT shape the q05 worker wrote (real-run regression fixture)
1167
+ q05 = {"schema": SCHEMA, "key": "nct:NCT04903119", "type": "trial",
1168
+ "ids": {"nct_id": "NCT04903119"},
1169
+ "title": "Nilotinib Plus Dabrafenib/Trametinib or Encorafenib/Binimetinib in Metastatic Melanoma",
1170
+ "phase": None, "status": "RECRUITING", "sponsor": None,
1171
+ "url": "https://clinicaltrials.gov/study/NCT04903119",
1172
+ "provenance": [{"aspect": "combination_strategies", "tool": "biomcp_trial_search"}]}
1173
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(q05), stdin=False, aspect=None))
1174
+ led = read_ledger(f)
1175
+ rec = led.by_key["nct:NCT04903119"]
1176
+ assert rec["ids"].get("nct") == "NCT04903119", "nct_id alias not applied"
1177
+ assert rec["meta"].get("status") == "RECRUITING", "top-level status not folded into meta"
1178
+ assert "phase" not in rec["meta"] and "sponsor" not in rec["meta"], "nulls must never fold"
1179
+ assert rec.get("status") == "RECRUITING", "original top-level field not preserved verbatim"
1180
+ _, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="nct:NCT04903119", expand_pages=False, offset=0))
1181
+ line = out.splitlines()[0]
1182
+ assert "[MISSING" not in line, f"q05 regression: [MISSING in render: {line}"
1183
+ assert "NCT04903119: Nilotinib Plus" in line and "Status: RECRUITING." in line, f"trial render wrong: {line}"
1184
+ assert "Phase" not in line and "Sponsor" not in line, "null segments must be omitted"
1185
+ # canonical meta wins over conflicting top-level fold
1186
+ conflict = {"type": "trial", "ids": {"nct": "NCT00000002"}, "title": "C",
1187
+ "phase": "WRONG", "meta": {"phase": "3"},
1188
+ "provenance": [{"aspect": "a"}]}
1189
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(conflict), stdin=False, aspect=None))
1190
+ led = read_ledger(f)
1191
+ assert led.by_key["nct:NCT00000002"]["meta"]["phase"] == "3", "canonical meta did not win the fold"
1192
+ # fold is idempotent across re-reads
1193
+ before = json.dumps(led.by_key["nct:NCT04903119"], sort_keys=True)
1194
+ after = json.dumps(read_ledger(f).by_key["nct:NCT04903119"], sort_keys=True)
1195
+ assert before == after, "re-normalization not idempotent"
1196
+ # synthetic trial with all three segments renders all three
1197
+ full = {"type": "trial", "ids": {"nct": "NCT04280705"},
1198
+ "title": "Encorafenib Plus Cetuximab With or Without Nivolumab",
1199
+ "phase": "Phase 2", "status": "Completed", "sponsor": "Pfizer",
1200
+ "url": "https://clinicaltrials.gov/study/NCT04280705",
1201
+ "provenance": [{"aspect": "a"}]}
1202
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(full), stdin=False, aspect=None))
1203
+ _, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys="nct:NCT04280705", expand_pages=False, offset=0))
1204
+ line = out.splitlines()[0]
1205
+ 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}"
1206
+ check("folding", st_folding)
1207
+
1208
+ # ---- auto-key derivation + title round-trip ---------------------------
1209
+ def st_auto_key():
1210
+ f = d / "ak.jsonl"
1211
+ # trial without explicit key derives nct: from the alias slot
1212
+ rec = {"type": "trial", "ids": {"nct_id": "NCT01234567"}, "title": "Derived",
1213
+ "provenance": [{"aspect": "a"}]}
1214
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
1215
+ led = read_ledger(f)
1216
+ assert "nct:NCT01234567" in led.by_key, "auto-key from alias slot failed"
1217
+ # title-only verify-less record derives a title: key that round-trips
1218
+ rec = {"type": "trial", "title": "Title-only trial record", "provenance": [{"aspect": "a"}]}
1219
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
1220
+ led = read_ledger(f)
1221
+ title_keys = [k for k in led.by_key if k.startswith("title:")]
1222
+ assert len(title_keys) == 1, "title fallback key not derived"
1223
+ # THE round-trip trap: re-read must NOT quarantine the title: key
1224
+ again = read_ledger(f)
1225
+ assert not again.quarantined, f"title: key quarantined on re-read: {again.quarantined}"
1226
+ assert title_keys[0] in again.by_key, "title: key lost on re-read"
1227
+ rc, out = _capture(cmd_bib, argparse.Namespace(file=str(f), keys=title_keys[0], expand_pages=False, offset=0))
1228
+ # A title-only trial is a degraded record: the renderer correctly
1229
+ # emits the loud [MISSING field: ids.nct] marker (never fabricates)
1230
+ # — asserted here instead of pretending it renders clean.
1231
+ assert rc == 0 and "Title-only trial record" in out.splitlines()[0], "bib on title: key failed"
1232
+ assert "[MISSING field: ids.nct]" in out.splitlines()[0], "degraded trial must render its missing id loudly"
1233
+ # article keeps its hard id requirement
1234
+ _capture(cmd_add, argparse.Namespace(file=str(f), record='{"type": "article", "title": "x", "ids": {}}', stdin=False, aspect=None))
1235
+ assert len(read_ledger(f).by_key) == 2, "title-only article was accepted"
1236
+ check("auto-key", st_auto_key)
1237
+
1238
+ # ---- other type (escape hatch) + verify skip accounting --------------
1239
+ def st_other_and_verify_skip():
1240
+ f = d / "ot.jsonl"
1241
+ rec = {"type": "other", "title": "FDA label excerpt for vemurafenib",
1242
+ "ids": {"url": "https://www.accessdata.fda.gov/scripts/cder/daf/index.cfm?event=overview.process&ApplNo=1234"},
1243
+ "provenance": [{"aspect": "a"}]}
1244
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(rec), stdin=False, aspect=None))
1245
+ article = _fixture_article()
1246
+ _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(article), stdin=False, aspect=None))
1247
+ led = read_ledger(f)
1248
+ assert any(k.startswith("url:") for k in led.by_key), "other-type url key not derived"
1249
+ _, 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))
1250
+ assert "[type: other]" in out.splitlines()[0] and "[MISSING" not in out.splitlines()[0], out
1251
+ # verify (offline, fail-safe) skips the other-type record and says so
1252
+ import ncbi_esummary as ne
1253
+ old_url, old_sleep = ne.NCBI_ESUMMARY_URL, ne.time.sleep
1254
+ ne.NCBI_ESUMMARY_URL = "http://127.0.0.1:1/unreachable"
1255
+ ne.time.sleep = lambda *_: None
1256
+ try:
1257
+ _, out = _capture(cmd_verify, argparse.Namespace(file=str(f), apply=False, timeout=0.2))
1258
+ finally:
1259
+ ne.NCBI_ESUMMARY_URL, ne.time.sleep = old_url, old_sleep
1260
+ assert "1 record(s) of unverified type(s) skipped (no verifier configured)" in out, f"skip accounting wrong: {out}"
1261
+ # article-only ledger keeps deterministic wording (0 skipped) for graders
1262
+ f2 = d / "ot2.jsonl"
1263
+ _capture(cmd_add, argparse.Namespace(file=str(f2), record=json.dumps(article), stdin=False, aspect=None))
1264
+ _, out = _capture(cmd_stats, argparse.Namespace(file=str(f2)))
1265
+ assert "record(s)" in out
1266
+ check("other-type/verify-skip", st_other_and_verify_skip)
1267
+
1268
+ # ---- meta-aware twin merging (complementary worker fields) -----------
1269
+ def st_meta_merge():
1270
+ f1, f2 = d / "mm1.jsonl", d / "mm2.jsonl"
1271
+ a = {"type": "trial", "ids": {"nct": "NCT09876543"}, "title": "Complementary",
1272
+ "meta": {"phase": "Phase 3"}, "provenance": [{"aspect": "worker_a"}]}
1273
+ b = {"type": "trial", "ids": {"nct_id": "NCT09876543"}, "title": None,
1274
+ "sponsor": "NCI", "status": "Recruiting", "provenance": [{"aspect": "worker_b"}]}
1275
+ f1.write_text(json.dumps(a) + "\n", encoding="utf-8")
1276
+ f2.write_text(json.dumps(b) + "\n", encoding="utf-8")
1277
+ out = d / "mm-merged.jsonl"
1278
+ _capture(cmd_merge, argparse.Namespace(out=str(out), inputs=[str(f1), str(f2)]))
1279
+ led = read_ledger(out)
1280
+ assert "nct:NCT09876543" in led.by_key, "twin merge failed"
1281
+ meta = led.by_key["nct:NCT09876543"]["meta"]
1282
+ assert meta.get("phase") == "Phase 3" and meta.get("sponsor") == "NCI" and meta.get("status") == "Recruiting", \
1283
+ f"complementary meta fields did not union: {meta}"
1284
+ _, rendered = _capture(cmd_bib, argparse.Namespace(file=str(out), keys="nct:NCT09876543", expand_pages=False, offset=0))
1285
+ line = rendered.splitlines()[0]
1286
+ 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}"
1287
+ aspects = {p["aspect"] for p in led.by_key["nct:NCT09876543"]["provenance"]}
1288
+ assert aspects == {"worker_a", "worker_b"}, f"provenance aspects lost: {aspects}"
1289
+ check("meta-merge", st_meta_merge)
1290
+
1291
+ # ---- batch appends, title-key rules, rewrite quarantine, BOM --------
1292
+ def st_batch_and_title_rules():
1293
+ def add_stdin(f, payload):
1294
+ old_stdin, sys.stdin = sys.stdin, io.StringIO(payload)
1295
+ try:
1296
+ return _capture(cmd_add, argparse.Namespace(file=str(f), record=None, stdin=True, aspect=None))
1297
+ finally:
1298
+ sys.stdin = old_stdin
1299
+ f = d / "bt.jsonl"
1300
+ # --stdin JSON-array batch (the worker-protocol rule-8 shape)
1301
+ batch = [_fixture_article(pmid="10000001", doi="10.1000/b1", title="Batch one", ids={"pmid": "10000001", "doi": "10.1000/b1", "pmcid": "PMC1000001"}),
1302
+ _fixture_article(pmid="10000002", doi="10.1000/b2", title="Batch two", ids={"pmid": "10000002", "doi": "10.1000/b2", "pmcid": "PMC1000002"})]
1303
+ rc, out = add_stdin(f, json.dumps(batch))
1304
+ assert rc == 0 and "add: 2 record(s) accepted, 0 rejected" in out, f"stdin batch failed: {out}"
1305
+ # @array-file batch
1306
+ bf = d / "bt-batch.json"
1307
+ 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"}),
1308
+ _fixture_article(pmid="10000004", doi="10.1000/b4", title="Batch four", ids={"pmid": "10000004", "doi": "10.1000/b4", "pmcid": "PMC1000004"})]) + "\n", encoding="utf-8")
1309
+ rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record="@" + str(bf), stdin=False, aspect=None))
1310
+ assert rc == 0 and "add: 2 record(s) accepted" in out, f"@file batch failed: {out}"
1311
+ assert len(read_ledger(f).by_key) == 4, f"batch appends lost records: {sorted(read_ledger(f).by_key)}"
1312
+ # mixed batch: valid accepted, invalid rejected loudly, rc 0
1313
+ mixed = [_fixture_article(pmid="10000005", doi="10.1000/b5", title="Batch five", ids={"pmid": "10000005", "doi": "10.1000/b5", "pmcid": "PMC1000005"}),
1314
+ {"type": "article", "title": "no ids", "ids": {}}]
1315
+ rc, out = add_stdin(f, json.dumps(mixed))
1316
+ assert rc == 0 and "1 record(s) accepted, 1 rejected" in out, f"mixed batch accounting wrong: {out}"
1317
+ # title-key rules: web/dataset keep hard identity fields; article
1318
+ # rejects title-only input AND explicit title: keys; other falls
1319
+ # back to a title: key.
1320
+ before = len(read_ledger(f).by_key)
1321
+ for bad in ('{"type": "web", "title": "Just a page", "ids": {}}',
1322
+ '{"type": "dataset", "title": "Just a series", "ids": {}}',
1323
+ '{"type": "article", "title": "x", "ids": {}}',
1324
+ '{"type": "article", "key": "title:abc123", "title": "Bypass", "ids": {}}',
1325
+ '{"type": ["article"], "title": "unhashable", "ids": {}}'):
1326
+ rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record=bad, stdin=False, aspect=None))
1327
+ assert rc == 0 and "0 record(s) accepted, 1 rejected" in out, f"should have been rejected: {bad}: {out}"
1328
+ assert len(read_ledger(f).by_key) == before, "rejected records must not be written"
1329
+ other = {"type": "other", "title": "Guideline page", "ids": {},
1330
+ "provenance": [{"aspect": "a"}]}
1331
+ rc, out = _capture(cmd_add, argparse.Namespace(file=str(f), record=json.dumps(other), stdin=False, aspect=None))
1332
+ assert rc == 0 and "add: 1 record(s) accepted" in out, f"other title-only rejected: {out}"
1333
+ assert any(k.startswith("title:") for k in read_ledger(f).by_key), "other title: key not derived"
1334
+ # pre-existing malformed line: add quarantines loudly, keeps good lines
1335
+ f2 = d / "bt2.jsonl"
1336
+ 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")
1337
+ 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))
1338
+ led2 = read_ledger(f2)
1339
+ assert rc == 0 and len(led2.by_key) == 2, f"rewrite kept wrong records: {sorted(led2.by_key)}"
1340
+ assert (f2.parent / "_invalid.jsonl").is_file(), "quarantine file not written by add"
1341
+ # BOM tolerance: re-read a BOM-prefixed file cleanly
1342
+ f3 = d / "bt3.jsonl"
1343
+ 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"))
1344
+ led3 = read_ledger(f3)
1345
+ assert len(led3.by_key) == 1 and not led3.quarantined, f"BOM re-read failed: {led3.quarantined}"
1346
+ check("batch/title-rules", st_batch_and_title_rules)
1347
+
1348
+ failed = [r for r in results if r[1] is not None]
1349
+ for name, err in results:
1350
+ print(f"{'PASS' if err is None else 'FAIL'} {name}" + (f": {err}" if err else ""))
1351
+ banner("selftest", f"{len(results) - len(failed)}/{len(results)} group(s) passed" + (" — FAILURES PRESENT" if failed else ""))
1352
+ return 1 if failed else 0
1353
+
1354
+
1355
+ def main() -> int:
1356
+ parser = argparse.ArgumentParser(description="Structured evidence ledger for deep-research citation integrity.")
1357
+ sub = parser.add_subparsers(dest="cmd", required=True)
1358
+
1359
+ p = sub.add_parser("add", help="Append/merge records into a ledger file")
1360
+ p.add_argument("file")
1361
+ p.add_argument("record", nargs="?", help="record JSON, @file, or omit with --stdin")
1362
+ p.add_argument("--stdin", action="store_true", help="read a record (or list) from stdin")
1363
+ p.add_argument("--aspect", help="aspect stamped onto records lacking provenance")
1364
+ p.set_defaults(fn=cmd_add)
1365
+
1366
+ p = sub.add_parser("merge", help="Union per-aspect ledgers into one file")
1367
+ p.add_argument("-o", "--out", required=True)
1368
+ p.add_argument("inputs", nargs="+", help="input files or globs (own output and _-prefixed files always excluded)")
1369
+ p.set_defaults(fn=cmd_merge)
1370
+
1371
+ p = sub.add_parser("verify", help="Cross-check article records against NCBI esummary (fail-safe)")
1372
+ p.add_argument("file")
1373
+ p.add_argument("--apply", action="store_true", help="write backfills in-place")
1374
+ p.add_argument("--timeout", type=float, default=15.0)
1375
+ p.set_defaults(fn=cmd_verify)
1376
+
1377
+ p = sub.add_parser("bib", help="Render a numbered bibliography from ledger records")
1378
+ p.add_argument("file")
1379
+ p.add_argument("--keys", required=True, help="comma-separated ledger keys in citation order")
1380
+ p.add_argument("--offset", type=int, default=0)
1381
+ p.add_argument("--expand-pages", action="store_true", help="expand abbreviated ranges (2507-16 -> 2507-2516)")
1382
+ p.set_defaults(fn=cmd_bib)
1383
+
1384
+ p = sub.add_parser("get", help="Print one record by key")
1385
+ p.add_argument("file")
1386
+ p.add_argument("--key", required=True)
1387
+ p.set_defaults(fn=cmd_get)
1388
+
1389
+ p = sub.add_parser("keys", help="List all keys")
1390
+ p.add_argument("file")
1391
+ p.set_defaults(fn=cmd_keys)
1392
+
1393
+ p = sub.add_parser("stats", help="Ledger summary counts")
1394
+ p.add_argument("file")
1395
+ p.set_defaults(fn=cmd_stats)
1396
+
1397
+ p = sub.add_parser("selftest", help="Hermetic feature-matrix selftest (no network)")
1398
+ p.set_defaults(fn=lambda a: selftest())
1399
+
1400
+ args = parser.parse_args()
1401
+ try:
1402
+ return args.fn(args)
1403
+ except FileNotFoundError as e:
1404
+ banner(args.cmd, f"file not found (treated as empty, fail-safe): {e}")
1405
+ return 0
1406
+ except (json.JSONDecodeError, ValueError) as e:
1407
+ banner(args.cmd, f"input error: {e}")
1408
+ return 1
1409
+
1410
+
1411
+ if __name__ == "__main__":
1412
+ sys.exit(main())