
Verify Refs
- 46 installs
- 236 repo stars
- Updated August 3, 2026
- aperivue/medsci-skills
verify-refs is an audit-only Claude Code skill that verifies manuscript references against PubMed and CrossRef, detecting fabricated or mismatched citations and writing qc/reference_audit.json without modifying the bibli
About
This skill audits a manuscript's or bibliography's references against PubMed, CrossRef, and OpenAlex to catch fabricated or mismatched citations before submission. It cross-checks author names index-by-index, flags duplicate PMIDs and DOIs, and writes a single reference_audit.json without modifying the bibliography. A researcher runs it before submission, especially on manuscripts inherited from coauthors or produced with AI assistance.
- Audit-only verification of manuscript references against PubMed, CrossRef, and OpenAlex to detect fabricated or mismatch
- Cross-checks the full author list index-by-index and flags duplicate PMIDs/DOIs, writing qc/reference_audit.json
- Never modifies references or refs.bib; records UNVERIFIED rather than silently passing when network verification fails
Verify Refs by the numbers
- 46 all-time installs (skills.sh)
- Ranked #1,242 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
verify-refs capabilities & compatibility
- Capabilities
- testing · citation audit · reference verification
- Use cases
- testing · research
What verify-refs says it does
Audit-only verification of manuscript references against PubMed and CrossRef.
If network verification fails, it records `UNVERIFIED` rather than silently passing.
npx skills add https://github.com/aperivue/medsci-skills --skill verify-refsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 46 |
|---|---|
| repo stars | ★ 236 |
| Last updated | August 3, 2026 |
| Repository | aperivue/medsci-skills ↗ |
What it does
Audit manuscript references against PubMed/CrossRef/OpenAlex to detect fabricated or mismatched citations.
Who is it for?
Researchers checking a manuscript for fabricated or mismatched citations before journal submission, especially after AI-assisted drafting.
Skip if: Discovering new literature (use /search-lit) or managing the bib file (use /lit-sync); it never writes to references.
When should I use this skill?
Before submission, or when a reviewer or collaborator flags a possibly fabricated citation.
What you get
A row-level reference_audit.json marking each citation OK/MISMATCH/UNVERIFIED/FABRICATED with a submission-safe flag.
- qc/reference_audit.json with row-level citation status
By the numbers
- 4 statuses: OK/MISMATCH/UNVERIFIED/FABRICATED
- verifies against PubMed, CrossRef, and OpenAlex
Files
Verify References (Audit-Only)
You help a medical researcher prevent reference hallucinations before submission. This skill audits an existing manuscript or bibliography. It does not write to references/ or manuscript/_src/refs.bib. It does not discover new literature; use /search-lit for discovery and /lit-sync for bib management.
When to Use
- Before journal submission, especially for
.docxmanuscripts inherited from
coauthors or external editors.
- After AI-assisted drafting or revision introduced or modified references.
- When a reviewer or collaborator flags a possibly fabricated citation.
- Before
/sync-submissionfreezes a journal package.
Inputs
1. Manuscript or bibliography path: .md, .docx, .bib, .txt, or .tsv. 2. Optional project root. Default: current working directory. 3. Optional flags passed to the script:
--offline: extract and classify references without API verification.--timeout N: HTTP timeout seconds.
Companion: pandoc citation key check
For markdown manuscripts using pandoc [@bibkey] citations, validate citation keys first to catch undefined/unused keys before this audit. If you also use the companion manage-refs skill, run its check_citation_keys.py for this; otherwise use your reference manager's citation-key check.
Then run verify_refs.py against the .bib to validate each entry against PubMed/CrossRef. The two checks are complementary: a citation-key check catches mis-keyed cites; verify_refs.py catches fabricated metadata.
Deterministic Script
Run the bundled script rather than verifying citations by memory:
python "${CLAUDE_SKILL_DIR}/scripts/verify_refs.py" manuscript/manuscript.md --project-root .For hooks or quick manual runs, use the wrapper:
"${CLAUDE_SKILL_DIR}/scripts/verify_cli.sh" manuscript/manuscript.md --offlineManual pre-submission strict run (Phase 1A.5):
"${CLAUDE_SKILL_DIR}/scripts/verify_cli.sh" manuscript/index.qmd --strict--strict forbids --offline and exits non-zero on any UNVERIFIED row. Full checkpoint protocol: references/manual_checkpoint_guide.md.
The script uses DOI, PMID, CrossRef, PubMed E-utilities, and OpenAlex where available. If network verification fails, it records UNVERIFIED rather than silently passing.
OpenAlex tertiary index (existence recovery). PubMed covers only biomedical literature and CrossRef's conference-proceedings coverage is uneven, so NeurIPS / ICLR / ACL-style citations — common in medical-AI manuscripts — fall through both and would be marked UNVERIFIED. After the PubMed and CrossRef tiers, the script consults OpenAlex (https://api.openalex.org, free, no API key) only when no authoritative author list was obtained yet (so a reference already resolved by PubMed/CrossRef incurs no extra call). It resolves by DOI when present, otherwise by a title search guarded by a token-similarity threshold so a fabricated title cannot earn a spurious OK. This is the free analogue of the second index (e.g. Scopus) that journal submission portals run alongside CrossRef. OpenAlex display names carry no structured family/given split and mix First Last with Last, First forms, so OpenAlex-sourced authors support an existence check plus a tolerant first-author membership check, but never drive the strict positional or author-count MISMATCH (those stay reserved for PubMed efetch / CrossRef). An OpenAlex miss is recorded as UNVERIFIED, never FABRICATED. Pass --no-openalex to restrict verification to PubMed + CrossRef.
Output Contract (v1.3.0)
| Artifact | Path | Purpose |
|---|---|---|
| Audit JSON | qc/reference_audit.json | Sole output — row-level status (OK/MISMATCH/UNVERIFIED/FABRICATED), counts, cited_authors[]/actual_authors[], duplicate_findings[], submission-safe flag, full records |
v1.2.0 (2026-05) adds duplicate_findings[] to the audit JSON. Verbatim PMID or DOI duplicates within the reference list are flagged as MAJOR findings (resolves /peer-review Phase 2A P7). DOI normalization strips https://doi.org/, http://dx.doi.org/, doi: prefixes plus trailing slashes before comparison so https://doi.org/10.x/abc/ and 10.x/abc collapse to one key. Both submission_safe and fully_verified now require duplicate_findings to be empty.
v1.3.0 (2026-05) extends the author cross-check from first-author-only to the full author list and bumps schema_version to 4. For BibTeX inputs, every cited author family name is compared index-by-index against the authoritative source, and the cited-vs-source author counts are compared. PubMed efetch.fcgi (XML full record) is the truth source when a PMID is present — it is authoritative for given/family names where CrossRef is not (a documented case where CrossRef returned a wrong given name that PubMed efetch corrected). Records now carry cited_authors[], actual_authors[], cited_author_count, and actual_author_count. Motivation: a real AI-assisted manuscript registered a reference with a correct first author but seven of ten fabricated co-author names, and the first-author-only check passed it. Plain-text / TSV inputs, which cannot be parsed into a confident full list, degrade gracefully to the first-author check.
Removed in Phase 1A.2 (per docs/artifact_contract.md):
references/verified_references.tsv— record-level details now live insidereference_audit.jsonunderrecords[].references/library.bib— never this skill's concern./search-litproduces candidates;/lit-sync(via Better BibTeX) writesmanuscript/_src/refs.bib.
Sole-writer enforcement: scripts/validate_project_contract.py will flag any references/* file written by this skill as drift.
Workflow
1. Identify the input file and project root. 2. Run scripts/verify_refs.py. 3. Read qc/reference_audit.json. 4. Report all FABRICATED and MISMATCH rows first (from records[]). 5. Report all duplicate_findings[] entries (verbatim PMID/DOI duplicates — cite renumbering required). 6. If UNVERIFIED rows remain, list them as manual checks and do not call the manuscript fully submission-safe. Rows with note = "pagination_placeholder" (e000–e000 / in press / TBD / forthcoming) need the citation resolved before submission; /self-review Phase 2.5c decides whether any is a P0 blocker. 7. If the user needs a human-readable table, summarize from records[] in chat — do not write a TSV.
Quality Gates
- Gate 1: stop submission if any row is
FABRICATED. - Gate 2: require user confirmation before accepting
UNVERIFIEDreferences. - Gate 3: rerun after any reference edits.
- Gate 4 (added 2026-04-26; extended to full-author in v1.3.0): the cited
author list is cross-checked against the authoritative source (PubMed efetch preferred, then CrossRef, then PubMed esummary). A row whose DOI/PMID resolves but whose cited authors do not match — at any index, or in total count — is downgraded to MISMATCH. First-author mismatches get note = "first-author hallucination suspected"; #2..#N family or count mismatches get note = "non-first-author hallucination or count mismatch". This catches the LLM failure mode where a real DOI is paired with invented author names anywhere in the list, not just the lead author. Intentional CSL et-al truncation (cited fewer than source) can be silenced per-entry with a BibTeX _audit_truncated = <N> field.
- Gate 5 (added 2026-05, v1.2.0): PMID/DOI duplicate detection within the
reference list. Verbatim duplicates (same PMID or normalized DOI) — a common LLM citation-compilation artifact — are flagged as MAJOR findings in duplicate_findings[]. submission_safe == true requires the list to be empty. Resolves /peer-review Phase 2A P7.
- Gate 6 (added 2026-06): pagination / publication-stage placeholders. A reference
whose raw entry still carries e000–e000, in press, TBD, or forthcoming is not yet a fully citable record. Each is marked UNVERIFIED with note = "pagination_placeholder" (a would-be VERIFIED record is downgraded; a worse status is left unchanged). verify-refs is manuscript-agnostic and does not judge centrality — it only flags. The escalation call (is this a method- or headline-load-bearing citation, hence a P0 submission blocker?) is made by /self-review Phase 2.5c, which has the manuscript in hand.
Classification note — citation-metadata confusion is not fabrication. Digits in a DOI suffix sometimes look like a journal article number but differ from the real one (e.g., a DOI tail "77196" against article number 26068, or a "60466-1" suffix against article 6274). This is cosmetic metadata confusion, not a fabricated reference: do not record such rows as FABRICATED when the DOI/PMID resolves and the authors match. A genuine FABRICATED verdict requires a non-resolving identifier or an author cross-check failure (Gate 4), not a mismatch between a DOI suffix and an article number.
Author Cross-Check (Detail)
Driven by two actual incidents. First (Gate 4 origin): a manuscript had a reference cited with a plausible lead author but the correct DOI for an entirely different author's whitepaper. Pre-patch verify-refs marked it OK because the DOI resolved; post-patch it is MISMATCH. Second (v1.3.0 extension): an AI-assembled .bib registered a reference with the correct first author but seven of ten fabricated co-author names — the first-author-only check passed it, and it would have shipped to reviewers. The full-author cross-check catches it.
- The authoritative author list is taken from PubMed
efetch.fcgi(XML) when a
PMID is present, falling back to CrossRef (DOI) and then PubMed esummary. efetch is preferred because CrossRef is unreliable for given names.
- For BibTeX inputs, the full cited list is parsed (
cited_authors[],
balanced-brace aware, LaTeX-accent tolerant) and compared family-by-family and by total count against actual_authors[].
- Comparison is tolerant: case, diacritics (NFKD plus Turkish/Polish/Czech/
German/Nordic special letters), hyphen vs space, and name particles ("von", "van", "de", ...) are normalized before matching.
- If the cited authors cannot be parsed confidently, the check degrades to the
first-author surname comparison, and if even that is empty it is skipped silently — no false MISMATCH from formatting ambiguity.
- Title-only PubMed search does not return an authoritative author and is
therefore excluded from this check.
- Intentional truncation (a bib that cites only the first author, or first five
+ et al., by design) would otherwise trip the count check; mark such entries with _audit_truncated = <N> to downgrade the count mismatch to a note.
What This Skill Does NOT Do
- Does not generate new references from memory.
- Does not replace missing citations with plausible alternatives without
/search-lit or user approval.
- Does not sync Zotero collections; use
/lit-syncafter this audit.
Anti-Hallucination
- Never fabricate titles, DOIs, PMIDs, author lists, journal names, years,
volumes, or pages.
- Every OK row must be backed by DOI, PMID, CrossRef, or PubMed title evidence.
- If evidence is unavailable, mark
UNVERIFIEDand keep it visible.
Manual Reference Verification Checkpoint Guide
Scope: medsci-skills v1.1.1 Phase 1A.5 Audience: Project owner before journal submission or before circulating a near-final draft to co-authors.
/verify-refs runs automatically inside /write-paper Step 7.3 and via the pre-save hook ~/.claude/hooks/verify-refs-guard.sh when a submission/*/manuscript/*.docx or revision/R*/*circulation*.docx is saved. This guide documents the manual strict-mode run the owner should perform immediately before submitting or before the manuscript leaves the project workspace.
When to run manually
Run verify-refs --strict manually at every one of these checkpoints:
1. Before first circulation to co-authors. Catches hallucinations that slipped past Step 7.3 in earlier drafts. 2. Before cover-letter + manuscript package freeze (/sync-submission). 3. Before each revision resubmission (R1, R2, ...). Revisions are a common site of new citation drift because reviewer responses introduce new references. 4. Before preprint posting (medRxiv / arXiv). Preprints are public and hard to correct. 5. After any external edit (co-author returns a .docx with tracked changes, or an external editor touches references).
Command
From the project root:
python3 "${CLAUDE_SKILL_DIR}/scripts/verify_refs.py" \
manuscript/index.qmd \
--project-root . \
--strictOr via the wrapper:
"${CLAUDE_SKILL_DIR}/scripts/verify_cli.sh" manuscript/index.qmd --strictDocx input is also accepted:
"${CLAUDE_SKILL_DIR}/scripts/verify_cli.sh" \
submission/radiology_ai/manuscript_main.docx --strictWhat --strict changes
- Exit code is non-zero if
submission_safe: false(any FABRICATED / MISMATCH). - Exit code is non-zero if any UNVERIFIED row remains.
- Offline verification is not tolerated — the script requires live PubMed /
CrossRef reachability.
Reading the output
The only output file is qc/reference_audit.json. Inspect with:
python3 -c "import json; a=json.load(open('qc/reference_audit.json')); \
print('safe=', a['submission_safe'], 'counts=', a['counts'])"Then jump to the records:
python3 -c "import json; a=json.load(open('qc/reference_audit.json')); \
[print(r['ref_id'], r['status'], r.get('note','')) \
for r in a['records'] if r['status']!='OK']"Failure actions
| Status | Action |
|---|---|
FABRICATED | STOP. Locate the citation in the manuscript. Either remove it or replace with a verified entry via /search-lit + /lit-sync. Never patch refs.bib by hand. |
MISMATCH | STOP. Usually a copy-paste error (wrong DOI for the title). Confirm the author's intent and correct via Zotero. |
UNVERIFIED | Review. If the reference genuinely lacks DOI/PMID (rare: old conference abstracts, grey literature), mark verified: manual in refs.bib via Zotero and re-run. Never keep UNVERIFIED rows in a submission package. |
Relationship with /lit-sync
/verify-refs is audit-only. All bibliographic corrections flow through /lit-sync (owner-only) → Zotero → Better BibTeX auto-export → manuscript/_src/refs.bib. Never edit refs.bib to satisfy /verify-refs.
Automation checkpoint (informational)
The pre-save hook verify-refs-guard.sh already runs on every .docx save under submission/ or revision/R*/. A FABRICATED status blocks the save. The manual strict run is a belt-and-suspenders check at the checkpoints listed above; it is NOT a substitute for the inline hook.
Change log
- 2026-04-24 v1.1.1 Phase 1A.5 Initial manual checkpoint guide. Aligned to
audit-only verify-refs contract (no references/* writes).
#!/usr/bin/env bash
# verify-refs CLI wrapper — thin shell in front of verify_refs.py for use by
# the pre-submission hook and ad-hoc manual runs.
#
# Usage: verify_cli.sh <manuscript.docx|.md|.bib|.txt|.tsv> [--offline]
# Exit: 0 = submission-safe (no FABRICATED/MISMATCH)
# 1 = FABRICATED or MISMATCH found -> hook should block
# 2 = input missing / usage error
# 3 = no references detected (unusual; treated as non-blocking)
#
# Project root resolution: the first ancestor directory containing a
# `submission/` sibling, else the manuscript's parent directory. Output
# artifacts are written under `<project_root>/references/` and
# `<project_root>/qc/` per the skill's output contract.
set -euo pipefail
if [ $# -lt 1 ]; then
echo "Usage: verify_cli.sh <manuscript> [--offline]" >&2
exit 2
fi
MANUSCRIPT="$1"; shift || true
EXTRA_ARGS=()
if [ $# -gt 0 ]; then
EXTRA_ARGS=("$@")
fi
if [ ! -f "$MANUSCRIPT" ]; then
echo "Not found: $MANUSCRIPT" >&2
exit 2
fi
ABS="$(cd "$(dirname "$MANUSCRIPT")" && pwd)/$(basename "$MANUSCRIPT")"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Resolve project root: nearest ancestor whose path contains /submission/,
# otherwise the manuscript's parent directory.
PROJECT_ROOT=""
dir="$(dirname "$ABS")"
while [ "$dir" != "/" ] && [ -n "$dir" ]; do
case "$dir" in
*/submission|*/submission/*)
PROJECT_ROOT="${dir%/submission*}/submission/$(basename "$(dirname "$dir")")"
[ -d "$PROJECT_ROOT" ] || PROJECT_ROOT="$dir"
break
;;
esac
dir="$(dirname "$dir")"
done
if [ -z "$PROJECT_ROOT" ]; then
PROJECT_ROOT="$(dirname "$ABS")"
fi
set +e
python3 "$SCRIPT_DIR/verify_refs.py" "$ABS" --project-root "$PROJECT_ROOT" ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"}
EXIT_CODE=$?
set -e
AUDIT="$PROJECT_ROOT/qc/reference_audit.json"
echo "[verify-refs] exit=$EXIT_CODE; audit=$AUDIT" >&2
exit "$EXIT_CODE"
#!/usr/bin/env python3
"""Reference verification helper for medsci-skills.
The script is deliberately stdlib-only. It extracts reference-like entries from
Markdown, DOCX, BibTeX, plain text, or TSV, verifies DOI/PMID when possible, and
writes a single audit artifact: qc/reference_audit.json. Per v1.1.1 artifact
contract, this skill is sole writer of that file and MUST NOT touch references/.
"""
from __future__ import annotations
import argparse
import csv
import html
import json
import re
import sys
import time
import urllib.parse
import urllib.request
import zipfile
from dataclasses import dataclass, asdict, field
from pathlib import Path
from xml.etree import ElementTree as ET
DOI_RE = re.compile(r"\b10\.\d{4,9}/[-._;()/:A-Z0-9]+\b", re.I)
PMID_RE = re.compile(r"\bPMID\s*:?\s*(\d{5,9})\b", re.I)
YEAR_RE = re.compile(r"\b(19|20)\d{2}\b")
@dataclass
class RefRecord:
ref_id: str
raw: str
title_guess: str = ""
doi: str = ""
pmid: str = ""
year_guess: str = ""
first_author_guess: str = "" # back-compat (= cited_authors[0] when available)
# v1.3.0: full author cross-check (AI-assisted-drafting hallucination motivation)
cited_authors: list = field(default_factory=list) # family names parsed from bib/tsv/text
actual_authors: list = field(default_factory=list) # family names from authoritative source
cited_author_count: int = 0
actual_author_count: int = 0
# v1.3.0: intentional truncate marker. Set via BibTeX field `_audit_truncated = N`
# (any non-empty value); when present, count mismatch is downgraded to a note
# and does not trigger MISMATCH status. Use when CSL renders first-1 or first-5
# + et al. and the trailing authors are deliberately omitted from the bib.
audit_truncated: bool = False
# Collective / corporate author (EASL, KDIGO, AHA/ACC, WHO, a named working
# group / consortium). BibTeX convention double-braces these
# (`author = {{KDIGO Working Group}}`) and PubMed returns them as
# <CollectiveName>; the personal-name family cross-check does not apply and
# must not fire MISMATCH (which would abort render on every guideline-citing
# cohort manuscript).
corporate_author: bool = False
status: str = "UNVERIFIED"
evidence: str = ""
note: str = ""
def normalize_space(text: str) -> str:
return re.sub(r"\s+", " ", text).strip()
# Organization / collective-author signal in an author field. Used (a) when a
# brace survives in the parsed BibTeX author field (double-brace convention) and
# (b) as a keyword fallback for single-braced or plain-text collective names.
_ORG_AUTHOR_RE = re.compile(
r"\b(?:Group|Committee|Society|Association|Collaborat\w+|Consortium|Network|"
r"Panel|Initiative|Organization|Organisation|Investigators|Trialists|Task\s+Force|"
r"Working\s+Group|Study\s+Group|Foundation|Institute|Council|Federation|College|"
r"WHO|EASL|EASD|EASO|KDIGO|AHA|ACC|ESC|NICE|AASLD|KASL)\b", re.IGNORECASE)
def is_corporate_author_field(author_field: str) -> bool:
"""A collective/corporate author (a guideline body, working group, consortium)
rather than a list of people. Signals: a brace surviving in the parsed field
(the BibTeX double-brace literal-name convention), or an organization keyword."""
if not author_field:
return False
if "{" in author_field or "}" in author_field:
return True
# No personal "Last, First" comma and an organization keyword present.
if "," not in author_field and bool(_ORG_AUTHOR_RE.search(author_field)):
return True
return False
def clean_doi(doi: str) -> str:
return doi.rstrip(".,;)].").lower()
def normalize_doi_for_dup(doi: str) -> str:
"""Strict DOI normalization for duplicate detection.
Beyond clean_doi(): strips common URL prefixes and trailing slashes so that
`https://doi.org/10.1234/abc/` and `10.1234/abc` collapse to the same key.
"""
if not doi:
return ""
s = doi.strip().lower()
for prefix in ("https://doi.org/", "http://doi.org/",
"https://dx.doi.org/", "http://dx.doi.org/", "doi:"):
if s.startswith(prefix):
s = s[len(prefix):]
break
s = s.strip().rstrip("/")
return clean_doi(s)
def read_docx(path: Path) -> str:
with zipfile.ZipFile(path) as zf:
xml = zf.read("word/document.xml")
root = ET.fromstring(xml)
ns = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
paragraphs = []
for p in root.findall(".//w:p", ns):
parts = [t.text or "" for t in p.findall(".//w:t", ns)]
if parts:
paragraphs.append("".join(parts))
return "\n".join(paragraphs)
def read_input(path: Path) -> str:
if path.suffix.lower() == ".docx":
return read_docx(path)
return path.read_text(encoding="utf-8", errors="replace")
def parse_bib(text: str) -> list[RefRecord]:
records: list[RefRecord] = []
entries = re.split(r"\n(?=@\w+\{)", "\n" + text)
for entry in entries:
entry = entry.strip()
if not entry.startswith("@"):
continue
key_match = re.match(r"@\w+\{([^,]+),", entry)
title_match = re.search(r"title\s*=\s*[\{\"](.+?)[\}\"]\s*,", entry, re.I | re.S)
doi_match = re.search(r"doi\s*=\s*[\{\"](.+?)[\}\"]\s*,", entry, re.I | re.S)
pmid_match = re.search(r"pmid\s*=\s*[\{\"]?(\d{5,9})", entry, re.I)
year_match = re.search(r"year\s*=\s*[\{\"]?((?:19|20)\d{2})", entry, re.I)
raw = normalize_space(entry)
# Balanced-brace aware author capture (handles "\~{n}" LaTeX escapes that
# would otherwise terminate a non-greedy {.+?} match prematurely).
author_field = ""
am = re.search(r"author\s*=\s*\{", entry, re.I)
if am:
start = am.end()
depth = 1
j = start
while j < len(entry) and depth > 0:
if entry[j] == "{":
depth += 1
elif entry[j] == "}":
depth -= 1
j += 1
author_field = entry[start : j - 1]
cited = parse_bib_authors(author_field)
# v1.3.0: intentional truncate marker (any non-empty `_audit_truncated`)
trunc_match = re.search(r"_audit_truncated\s*=\s*[\{\"]?([^,}\"]+)", entry, re.I)
records.append(
RefRecord(
ref_id=key_match.group(1) if key_match else f"ref_{len(records)+1}",
raw=raw,
title_guess=normalize_space(title_match.group(1)) if title_match else "",
doi=clean_doi(doi_match.group(1)) if doi_match else "",
pmid=pmid_match.group(1) if pmid_match else "",
year_guess=year_match.group(1) if year_match else "",
first_author_guess=cited[0] if cited else (parse_first_author(author_field) if author_field else ""),
cited_authors=cited,
cited_author_count=len(cited),
audit_truncated=bool(trunc_match and trunc_match.group(1).strip().lower() not in ("", "false", "0", "no")),
corporate_author=is_corporate_author_field(author_field),
)
)
return records
def parse_tsv(text: str) -> list[RefRecord]:
rows = list(csv.DictReader(text.splitlines(), delimiter="\t"))
records: list[RefRecord] = []
for i, row in enumerate(rows, 1):
joined = " ".join(str(v) for v in row.values() if v)
doi = ""
pmid = ""
for key, value in row.items():
lk = (key or "").lower()
if lk == "doi" and value:
doi = clean_doi(value)
if lk == "pmid" and value:
pmid = re.sub(r"\D", "", value)
title = row.get("title") or row.get("Title") or ""
author_field = row.get("author") or row.get("authors") or row.get("Author") or row.get("Authors") or ""
records.append(
RefRecord(
ref_id=f"ref_{i}",
raw=normalize_space(joined),
title_guess=title,
doi=doi,
pmid=pmid,
first_author_guess=parse_first_author(author_field) if author_field else "",
corporate_author=is_corporate_author_field(author_field),
)
)
return records
def reference_section(text: str) -> str:
match = re.search(r"(?im)^\s*(references|bibliography|reference list)\s*$", text)
if match:
return text[match.end() :]
return text
def parse_reference_lines(text: str) -> list[RefRecord]:
section = reference_section(text)
lines = [normalize_space(line) for line in section.splitlines()]
candidates: list[str] = []
current = ""
for line in lines:
if not line:
continue
starts_ref = bool(re.match(r"^(\[\d+\]|\d+[\.\)]|\-\s+)", line))
if starts_ref and current:
candidates.append(current)
current = line
else:
current = f"{current} {line}".strip() if current else line
if current:
candidates.append(current)
if len(candidates) < 2:
candidates = [line for line in lines if DOI_RE.search(line) or PMID_RE.search(line) or len(line) > 60]
records: list[RefRecord] = []
for i, raw in enumerate(candidates, 1):
raw = normalize_space(raw)
doi_match = DOI_RE.search(raw)
pmid_match = PMID_RE.search(raw)
year_match = YEAR_RE.search(raw)
records.append(
RefRecord(
ref_id=f"ref_{i}",
raw=raw,
title_guess=guess_title(raw),
doi=clean_doi(doi_match.group(0)) if doi_match else "",
pmid=pmid_match.group(1) if pmid_match else "",
year_guess=year_match.group(0) if year_match else "",
first_author_guess=parse_first_author(raw),
)
)
return records
_NAME_PARTICLES = {"von", "van", "de", "del", "della", "dos", "da", "le", "la", "du", "den", "der", "ten"}
def parse_bib_authors(author_field: str) -> list:
"""Parse BibTeX author field into a list of family-name strings.
Handles "Last, First and Last, First" and "First Last and First Last" forms.
Strips simple LaTeX accents and braces.
"""
if not author_field:
return []
raw = re.sub(r"\s+", " ", author_field).strip()
parts = re.split(r"\s+and\s+", raw)
families: list[str] = []
for name in parts:
n = name.strip()
if not n:
continue
if "," in n:
family = n.split(",", 1)[0].strip()
else:
toks = n.split()
family = toks[-1] if toks else ""
# Strip simple LaTeX accents: \~{n}, \"{o}, \`{a} → underlying char
family = re.sub(r"\\[\"'`~^=.]?\{?([A-Za-zà-ÿ])\}?", r"\1", family)
family = re.sub(r"[{}]", "", family).strip()
if family and family != "others":
families.append(family)
return families
def parse_first_author(raw: str) -> str:
"""Extract first-author surname from a Vancouver/AMA/BibTeX-style citation.
Conservative: returns "" when the format is ambiguous so author-mismatch
checks degrade gracefully rather than firing false MISMATCH alerts.
"""
text = re.sub(r"^\s*(\[\d+\]|\d+[\.\)])\s*", "", raw).strip()
bib_m = re.search(r"author\s*=\s*[{\"]([^}\"]+)", text, re.I)
if bib_m:
text = bib_m.group(1)
text = re.split(r"\s+and\s+", text, maxsplit=1)[0]
parts = [p.strip() for p in text.split(",") if p.strip()]
if not parts:
return ""
# "Lastname, Firstname H." style (BibTeX expanded)
if len(parts) >= 2 and re.match(r"^[A-Z][a-zA-Z .\-']*$", parts[1]) and not re.search(r"\d", parts[1]):
if re.match(r"^[A-Z][a-zA-Zà-ÿ'\- ]+$", parts[0]):
return parts[0].strip()
first = parts[0]
# "Surname Initials" — strip trailing initials block (e.g., "DH", "J", "F.D.")
m = re.match(
r"^((?:(?:" + "|".join(_NAME_PARTICLES) + r")\s+)?[A-Zà-ÿ][\wà-ÿ'\-]*(?:\s+[A-Zà-ÿ][\wà-ÿ'\-]*)?)\s+(?:[A-Z]\.?\s*){1,4}$",
first,
)
if m:
return m.group(1).strip()
tokens = first.split()
if tokens and tokens[0].lower() in _NAME_PARTICLES and len(tokens) >= 2:
return f"{tokens[0]} {tokens[1]}"
return tokens[0] if tokens else ""
def _normalize_surname(name: str) -> str:
"""Strip diacritics + lowercase for surname comparison.
Coverage (v1.3.0): Latin-with-accents (NFKD decomposes), Turkish
(ş→s, ğ→g, ı→i), Polish/Czech (ł, đ — not NFKD-decomposable, handled below),
German ß→ss, Nordic ø/æ/œ → o/ae/oe. Motivation: a Turkish surname
`Çolakoğlu` vs PubMed `Colakoglu` false-positive MISMATCH.
"""
import unicodedata
n = unicodedata.normalize("NFKD", name)
n = "".join(c for c in n if not unicodedata.combining(c))
n = n.lower().strip()
# Multi-char + non-NFKD-decomposable mappings
multi = {
"ß": "ss", "þ": "th", "ł": "l", "đ": "d", "ı": "i",
"ø": "o", "æ": "ae", "œ": "oe",
}
for k, v in multi.items():
n = n.replace(k, v)
n = re.sub(r"[^a-z\s\-]", "", n)
n = re.sub(r"\s+", " ", n).strip()
return n
def author_surnames_match(cited: str, actual: str) -> bool:
"""Tolerant comparison: handles particle variants and hyphenation."""
if not cited or not actual:
return True # cannot judge → do not flag
a = _normalize_surname(cited)
b = _normalize_surname(actual)
if not a or not b:
return True
if a == b:
return True
# Particle-stripped variants ("von elm" vs "elm")
a_core = re.sub(r"^(?:" + "|".join(_NAME_PARTICLES) + r")\s+", "", a)
b_core = re.sub(r"^(?:" + "|".join(_NAME_PARTICLES) + r")\s+", "", b)
if a_core and b_core and (a_core == b_core or a_core in b_core or b_core in a_core):
return True
# Hyphen vs space ("Abd-alrazaq" vs "abd alrazaq")
if a.replace("-", " ") == b.replace("-", " "):
return True
return False
def guess_title(raw: str) -> str:
no_prefix = re.sub(r"^(\[\d+\]|\d+[\.\)]|\-\s+)\s*", "", raw)
parts = [p.strip() for p in re.split(r"\.\s+", no_prefix) if p.strip()]
for part in parts:
words = part.split()
if 4 <= len(words) <= 30 and not re.search(r"\b(doi|pmid|journal|vol)\b", part, re.I):
return part.strip('"')
return ""
def http_json(url: str, timeout: int) -> dict | None:
req = urllib.request.Request(url, headers={"User-Agent": "medsci-skills/verify-refs (mailto:example@example.com)"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8", "replace"))
except Exception:
return None
def verify_crossref(doi: str, timeout: int) -> tuple[str, str, list]:
"""Returns (status, evidence, family_names).
v1.3.0: returns full author family list instead of first-author only.
CrossRef API is not authoritative for given names (documented case: CrossRef
returned "Vasileios", PubMed efetch & the curated record = "Victoria").
Use verify_pubmed_efetch as the truth source when PMID is available.
"""
url = "https://api.crossref.org/works/" + urllib.parse.quote(doi)
data = http_json(url, timeout)
if not data or data.get("status") != "ok":
return "UNVERIFIED", "CrossRef DOI lookup failed", []
msg = data.get("message", {})
title = " ".join(msg.get("title") or [])
year_parts = (((msg.get("issued") or {}).get("date-parts") or [[None]])[0])
year = str(year_parts[0]) if year_parts and year_parts[0] else ""
authors_raw = msg.get("author") or []
families: list[str] = []
for a in authors_raw:
fam = (a.get("family") or a.get("name") or "").strip()
if fam:
families.append(fam)
evidence = "CrossRef DOI OK"
if title:
evidence += f"; title={title[:120]}"
if year:
evidence += f"; year={year}"
if families:
evidence += f"; authors={len(families)} (first={families[0]})"
return "OK", evidence, families
def verify_pubmed_pmid(pmid: str, timeout: int) -> tuple[str, str, list]:
"""Returns (status, evidence, family_names).
Uses esummary (fast). Returns family-name approximation by stripping trailing
initial block from "Surname Initials" form. Authoritative names → call
verify_pubmed_efetch().
"""
url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?" + urllib.parse.urlencode(
{"db": "pubmed", "id": pmid, "retmode": "json"}
)
data = http_json(url, timeout)
if not data:
return "UNVERIFIED", "PubMed PMID lookup failed", []
result = data.get("result", {})
item = result.get(pmid)
if not item:
return "FABRICATED", "PMID not found in PubMed", []
if item.get("error"):
return "FABRICATED", f"PubMed PMID error: {item['error']}", []
title = html.unescape(item.get("title", ""))
authors_raw = item.get("authors") or []
families: list[str] = []
for a in authors_raw:
if a.get("authtype") not in (None, "Author"):
continue
full = (a.get("name") or "").strip()
# esummary "name" is "Surname Initials" e.g. "Reichheld FF"
m = re.match(r"^(.+?)\s+[A-Z]{1,4}$", full)
fam = m.group(1).strip() if m else full
if fam:
families.append(fam)
evidence = f"PubMed PMID OK; title={title[:120]}; authors={len(families)}"
if families:
evidence += f" (first={families[0]})"
return "OK", evidence, families
def verify_pubmed_efetch(pmid: str, timeout: int) -> tuple[str, str, list, list]:
"""Authoritative PubMed full author record via efetch.fcgi (XML).
Returns (status, evidence, family_names, given_names). Use given_names for
given-name cross-check (CrossRef-vs-PubMed disagreement, e.g. a documented
case: CrossRef "Vasileios" vs PubMed "Victoria" — PubMed is authoritative).
"""
url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/efetch.fcgi?" + urllib.parse.urlencode(
{"db": "pubmed", "id": pmid, "retmode": "xml"}
)
req = urllib.request.Request(
url,
headers={"User-Agent": "medsci-skills/verify-refs (mailto:example@example.com)"},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
xml_text = resp.read().decode("utf-8", "replace")
except Exception:
return "UNVERIFIED", "PubMed efetch failed", [], []
families: list[str] = []
givens: list[str] = []
# Per-Author block: <Author ValidYN="Y"><LastName>X</LastName><ForeName>Y</ForeName>...
for am in re.finditer(
r'<Author\s+ValidYN="Y"[^>]*>(.*?)</Author>', xml_text, re.S
):
block = am.group(1)
lm = re.search(r"<LastName>([^<]+)</LastName>", block)
fm = re.search(r"<ForeName>([^<]+)</ForeName>", block)
if lm:
families.append(html.unescape(lm.group(1)).strip())
givens.append(html.unescape(fm.group(1)).strip() if fm else "")
if not families:
return "UNVERIFIED", "PubMed efetch returned no author elements", [], []
return (
"OK",
f"PubMed efetch OK; authors={len(families)} (first={families[0]})",
families,
givens,
)
def verify_pubmed_title(title: str, timeout: int) -> tuple[str, str, list]:
"""Title-only search returns no confident author list."""
if not title:
return "UNVERIFIED", "No DOI, PMID, or usable title", []
url = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esearch.fcgi?" + urllib.parse.urlencode(
{"db": "pubmed", "term": title, "retmode": "json", "retmax": "3"}
)
data = http_json(url, timeout)
if not data:
return "UNVERIFIED", "PubMed title search failed", []
ids = data.get("esearchresult", {}).get("idlist", [])
if not ids:
return "UNVERIFIED", "No PubMed title match", []
return "OK", f"PubMed title match; PMID candidates={','.join(ids)}", []
def _title_similarity(a: str, b: str) -> float:
"""Token Jaccard on normalized titles (stdlib-only).
Guards OpenAlex title matches: a fabricated title must not earn a spurious OK
just because a full-text search returned some unrelated work. Stop-short tokens
(<=2 chars) are dropped so connective words do not inflate similarity.
"""
def toks(s: str) -> set:
s = re.sub(r"[^a-z0-9 ]", " ", s.lower())
return {w for w in s.split() if len(w) > 2}
ta, tb = toks(a), toks(b)
if not ta or not tb:
return 0.0
return len(ta & tb) / len(ta | tb)
def _openalex_families(work: dict) -> list:
"""Best-effort family-name list from an OpenAlex work's authorships.
OpenAlex exposes only `author.display_name` with NO structured family/given
split, and the live data mixes "First Last" and "Last, First" forms within a
single record (observed: 'Noah Shinn' alongside 'Cassano, Federico'). This list
is therefore informational only — it is NOT used to drive the authoritative
family-by-family MISMATCH cross-check (that stays reserved for PubMed efetch /
CrossRef, which carry a structured family field). See verify_record.
"""
families: list[str] = []
for au in work.get("authorships") or []:
name = ((au.get("author") or {}).get("display_name") or "").strip()
if not name:
continue
if "," in name:
# "Last, First" → family is the part before the comma.
fam = name.split(",", 1)[0].strip()
else:
toks = name.split()
fam = toks[-1] if toks else ""
# Strip a trailing initials block ("Madaan A").
if fam and re.match(r"^[A-Z]{1,4}$", fam) and len(toks) >= 2:
fam = toks[-2]
if fam:
families.append(fam)
return families
def verify_openalex(doi: str, title: str, timeout: int) -> tuple[str, str, list]:
"""Tertiary index for conference proceedings / non-DOI / non-biomedical works.
PubMed covers only biomedical literature and CrossRef's proceedings coverage is
spotty, so NeurIPS / ICLR / ACL-style citations (common in medical-AI papers)
fall through both. OpenAlex (https://api.openalex.org) is free and key-less and
ingests those venues, so it recovers them — the free analogue of the second
index (e.g. Scopus) that journal portals use alongside CrossRef.
Resolves by DOI when available (exact); otherwise by title.search with a
similarity guard so a fabricated title cannot earn a spurious OK. Returns
(status, evidence, family_names). Never returns FABRICATED: an OpenAlex miss is
a coverage gap, not proof of fabrication.
"""
work = None
via = ""
if doi:
data = http_json(
"https://api.openalex.org/works/https://doi.org/" + urllib.parse.quote(doi),
timeout,
)
if data and data.get("id"):
work = data
via = "doi"
if work is None and title:
url = "https://api.openalex.org/works?" + urllib.parse.urlencode(
{"filter": "title.search:" + title, "per-page": "5"}
)
data = http_json(url, timeout)
results = (data or {}).get("results") or []
best, best_sim = None, 0.0
for w in results:
sim = _title_similarity(title, w.get("title") or w.get("display_name") or "")
if sim > best_sim:
best, best_sim = w, sim
if best is not None and best_sim >= 0.8:
work = best
via = f"title(sim={best_sim:.2f})"
if work is None:
return "UNVERIFIED", "OpenAlex: no confident match", []
families = _openalex_families(work)
wtitle = (work.get("title") or work.get("display_name") or "")[:120]
year = work.get("publication_year")
ev = f"OpenAlex OK via {via}; title={wtitle}"
if year:
ev += f"; year={year}"
if families:
ev += f"; authors={len(families)} (first={families[0]})"
return "OK", ev, families
def verify_record(record: RefRecord, offline: bool, timeout: int,
use_openalex: bool = True) -> RefRecord:
"""v1.3.0: full-author cross-check.
Authoritative source priority for the actual author list:
1. PubMed efetch (XML full-record) — best (motivation: CrossRef returned a
wrong given name "Vasileios" vs PubMed efetch authoritative "Victoria";
also catches AI-generated bib entries with hallucinated #2..#N family
names — a real AI-assembled bib registered 7 of 10 fabricated co-author names).
2. CrossRef DOI (fallback when no PMID).
3. OpenAlex (tertiary; conference proceedings / non-DOI / non-biomedical works
that PubMed and CrossRef miss — the free analogue of a portal's Scopus pass).
4. PubMed esummary (fast count check; family-name approximation only).
All cited authors (BibTeX) are compared family-by-family against the
authoritative list AND total counts are compared. Any cited author beyond
the actual list, any per-index family mismatch, and any count mismatch are
each reported. When no full cited list was parsed (TSV / plain text), the
check degrades to the first-author surname comparison (Gate 4 behaviour).
"""
if offline:
if record.doi or record.pmid:
record.status = "UNVERIFIED"
record.evidence = "Identifier extracted; offline mode"
else:
record.status = "UNVERIFIED"
record.evidence = "No identifier; offline mode"
if record.corporate_author:
record.note = "corporate/collective author — personal-name cross-check skipped"
record.evidence += " | CORPORATE AUTHOR (collective/organization)"
return record
statuses: list[str] = []
evidence_parts: list[str] = []
actual_authors: list[str] = []
actual_givens: list[str] = []
sources_consulted: list[str] = []
# True when the actual_authors list came from OpenAlex, whose display names carry
# no structured family field and mix "First Last" / "Last, First" forms. Such a
# list can support a tolerant first-author membership check but NOT the strict
# positional + author-count cross-check (which would mis-fire on the format noise).
actual_authors_soft = False
# Step 1 — PubMed efetch (authoritative) when PMID present.
if record.pmid:
st, ev, fams, givens = verify_pubmed_efetch(record.pmid, timeout)
time.sleep(0.2)
statuses.append(st)
evidence_parts.append(ev)
if st == "OK" and fams:
actual_authors = fams
actual_givens = givens
sources_consulted.append("pubmed_efetch")
# also run esummary for FABRICATED detection (efetch returns valid XML even for
# unknown PMIDs in some edge cases; esummary's "error" field is decisive).
st_es, ev_es, fams_es = verify_pubmed_pmid(record.pmid, timeout)
time.sleep(0.2)
statuses.append(st_es)
evidence_parts.append(ev_es)
if not actual_authors and st_es == "OK" and fams_es:
actual_authors = fams_es
sources_consulted.append("pubmed_esummary")
# Step 2 — CrossRef DOI (used only when efetch did not provide a list).
if record.doi:
st_cr, ev_cr, fams_cr = verify_crossref(record.doi, timeout)
time.sleep(0.2)
statuses.append(st_cr)
evidence_parts.append(ev_cr)
if not actual_authors and st_cr == "OK" and fams_cr:
actual_authors = fams_cr
sources_consulted.append("crossref")
# Step 3 — OpenAlex tertiary index. Fires only when no authoritative author list
# was obtained yet (no PMID/DOI, or those lookups returned no authors), so a
# biomedical reference already resolved by PubMed/CrossRef incurs no extra call.
# Recovers conference proceedings and non-biomedical works (NeurIPS/ICLR/ACL) and
# retries DOIs that CrossRef missed.
if use_openalex and not actual_authors:
st_oa, ev_oa, fams_oa = verify_openalex(record.doi, record.title_guess, timeout)
time.sleep(0.2)
statuses.append(st_oa)
evidence_parts.append(ev_oa)
if st_oa == "OK":
sources_consulted.append("openalex")
if fams_oa:
actual_authors = fams_oa
actual_authors_soft = True
# Step 4 — PubMed title-only final fallback when nothing confident resolved.
if "OK" not in statuses and not actual_authors:
st_t, ev_t, _ = verify_pubmed_title(record.title_guess, timeout)
time.sleep(0.2)
statuses.append(st_t)
evidence_parts.append(ev_t)
# Full-author cross-check
record.actual_authors = actual_authors
record.actual_author_count = len(actual_authors)
if record.cited_authors and not record.cited_author_count:
record.cited_author_count = len(record.cited_authors)
# Collective/corporate author (guideline body, working group): PubMed returns
# it as <CollectiveName> and the BibTeX double-braces it, so the personal-name
# family cross-check does not apply. Detect it on the source side too (no parsed
# personal authors but a title/DOI verified, or the source author looks like an
# organization), so a guideline cite is VERIFIED, never a render-aborting MISMATCH.
source_corporate = bool(actual_authors) and any(_ORG_AUTHOR_RE.search(a) for a in actual_authors)
if record.corporate_author or source_corporate:
if not record.note:
record.note = "corporate/collective author — personal-name cross-check skipped"
evidence_parts.append("CORPORATE AUTHOR (collective/organization; family cross-check skipped)")
mismatches: list[str] = []
if (not (record.corporate_author or source_corporate)
and record.cited_authors and actual_authors and not actual_authors_soft):
compare_n = min(len(record.cited_authors), len(actual_authors))
for i in range(compare_n):
cited = record.cited_authors[i]
if not author_surnames_match(cited, actual_authors[i]):
mismatches.append(
f"#{i+1} family: cited='{cited}' vs source='{actual_authors[i]}'"
)
# cited has more authors than source — always flag (cannot be intentional)
for i in range(compare_n, len(record.cited_authors)):
mismatches.append(
f"#{i+1} extra cited='{record.cited_authors[i]}' (source has only {len(actual_authors)} authors)"
)
# source has more authors than cited — count mismatch, suppressed under
# `_audit_truncated` marker (intentional CSL et-al truncation).
if record.cited_author_count != record.actual_author_count:
if record.audit_truncated and record.cited_author_count < record.actual_author_count:
evidence_parts.append(
f"NOTE: intentional truncate ({record.cited_author_count} of {record.actual_author_count}; "
f"`_audit_truncated` marker set)"
)
else:
mismatches.append(
f"AUTHOR COUNT: cited={record.cited_author_count} vs source={record.actual_author_count}"
)
elif not (record.corporate_author or source_corporate) and record.first_author_guess and actual_authors:
# No parsed cited author list (TSV / plain-text input) — degrade to the
# first-author surname cross-check (Gate 4 behaviour).
if not any(author_surnames_match(record.first_author_guess, a) for a in actual_authors):
mismatches.append(
f"#1 family: cited='{record.first_author_guess}' vs source='{actual_authors[0]}'"
)
author_mismatch = bool(mismatches)
if author_mismatch:
evidence_parts.append("AUTHOR MISMATCH | " + " | ".join(mismatches))
# Status precedence
if "OK" in statuses and "FABRICATED" in statuses:
record.status = "MISMATCH"
elif "OK" in statuses:
record.status = "MISMATCH" if author_mismatch else "OK"
elif "FABRICATED" in statuses:
record.status = "FABRICATED"
else:
record.status = "UNVERIFIED"
# Note classification (most informative wins)
if author_mismatch and not record.note:
# Distinguish first-author hallucination (high reviewer salience)
first_cited = record.cited_authors[0] if record.cited_authors else record.first_author_guess
first_bad = (
first_cited
and actual_authors
and not author_surnames_match(first_cited, actual_authors[0])
)
if first_bad:
record.note = "first-author hallucination suspected (DOI/PMID correct, family differs)"
else:
record.note = "non-first-author hallucination or count mismatch (DOI/PMID correct)"
record.evidence = " | ".join(p for p in evidence_parts if p)
if sources_consulted:
record.evidence += f" | source={'+'.join(sources_consulted)}"
return record
def detect_duplicates(records: list[RefRecord]) -> list[dict]:
"""Detect verbatim PMID or DOI duplicates within the reference list.
Verbatim duplicates (same PMID or normalized DOI) are a common LLM
citation-compilation artifact and require cite renumbering before
submission.
"""
seen_pmids: dict[str, str] = {}
seen_dois: dict[str, str] = {}
findings: list[dict] = []
for rec in records:
rec_id = rec.ref_id or "<unknown>"
pmid = (rec.pmid or "").strip()
if pmid:
if pmid in seen_pmids:
findings.append({
"severity": "MAJOR",
"category": "duplicate_pmid",
"ref_ids": [seen_pmids[pmid], rec_id],
"pmid": pmid,
"note": "Verbatim duplicate reference. Cite renumbering required.",
})
else:
seen_pmids[pmid] = rec_id
doi = normalize_doi_for_dup(rec.doi or "")
if doi:
if doi in seen_dois:
findings.append({
"severity": "MAJOR",
"category": "duplicate_doi",
"ref_ids": [seen_dois[doi], rec_id],
"doi": doi,
"note": "Verbatim duplicate reference. Cite renumbering required.",
})
else:
seen_dois[doi] = rec_id
return findings
# Pagination / publication-stage placeholders. A reference whose pages or status is
# still "e000–e000", "in press", "TBD", or "forthcoming" is not yet a fully citable
# record. verify-refs is manuscript-agnostic, so it only flags these as UNVERIFIED
# with note="pagination_placeholder"; the centrality call (is this a method- or
# headline-load-bearing cite, hence a P0 blocker?) is made by /self-review Phase 2.5c,
# which has the manuscript in hand. (Gate 6, added 2026-06.)
PAGINATION_PLACEHOLDER_RE = re.compile(
r"e0{3}.{0,3}e0{3}|in[ .]?press|\bTBD\b|forthcoming", re.I)
def flag_pagination_placeholder(record: RefRecord) -> None:
"""If the raw entry carries a pagination/publication-stage placeholder, attach a
note and downgrade a would-be VERIFIED record to UNVERIFIED (an in-press/e000
citation is not yet locatable to the page). Worse statuses are left unchanged."""
if not PAGINATION_PLACEHOLDER_RE.search(record.raw or ""):
return
tag = "pagination_placeholder"
record.note = f"{record.note} | {tag}".strip(" |") if record.note else tag
if record.status == "VERIFIED":
record.status = "UNVERIFIED"
ev = "identifier resolved but pagination/publication-stage placeholder unresolved"
record.evidence = f"{record.evidence} | {ev}".strip(" |") if record.evidence else ev
def write_outputs(records: list[RefRecord], project_root: Path, source: Path,
duplicate_findings: list[dict]) -> None:
"""Audit-only writer (v1.3.0).
Per docs/artifact_contract.md, /verify-refs is sole writer of qc/reference_audit.json
only. It MUST NOT write to references/ (that directory is owned by /search-lit and
/lit-sync). All per-record details live inside reference_audit.json.
v1.2.0 (2026-05): adds duplicate_findings[] for PMID/DOI duplicate detection
(Gate 5; resolves /peer-review Phase 2A P7). submission_safe and fully_verified
both require duplicate_findings to be empty.
v1.3.0 (2026-05): full-author cross-check. records[] now carry cited_authors[],
actual_authors[], and author counts; schema_version bumps to 4. MISMATCH now
fires on any #2..#N family hallucination or author-count mismatch, not just the
first author (motivation: a bib entry with a real first author but 7/10
fabricated co-author given names previously passed audit).
"""
qc_dir = project_root / "qc"
qc_dir.mkdir(parents=True, exist_ok=True)
counts: dict[str, int] = {}
for rec in records:
counts[rec.status] = counts.get(rec.status, 0) + 1
audit = {
"schema_version": 4,
"source": str(source),
"total_references": len(records),
"counts": counts,
"duplicate_findings": duplicate_findings,
"submission_safe": (
counts.get("FABRICATED", 0) == 0
and counts.get("MISMATCH", 0) == 0
and len(duplicate_findings) == 0
),
"fully_verified": (
counts.get("UNVERIFIED", 0) == 0
and counts.get("FABRICATED", 0) == 0
and counts.get("MISMATCH", 0) == 0
and len(duplicate_findings) == 0
),
"requires_manual_reference_check": counts.get("UNVERIFIED", 0) > 0,
"records": [asdict(rec) for rec in records],
}
(qc_dir / "reference_audit.json").write_text(json.dumps(audit, indent=2, ensure_ascii=False), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser(description="Verify manuscript references.")
parser.add_argument("input", help="Input .md, .docx, .bib, .txt, or .tsv file")
parser.add_argument("--project-root", default=".", help="Project root for output artifacts")
parser.add_argument("--offline", action="store_true", help="Do not call PubMed/CrossRef/OpenAlex APIs")
parser.add_argument("--no-openalex", action="store_true",
help="Disable the OpenAlex tertiary index (restrict to PubMed + CrossRef)")
parser.add_argument("--timeout", type=int, default=10, help="HTTP timeout seconds")
parser.add_argument("--strict", action="store_true", help="Exit non-zero on any UNVERIFIED row, and forbid --offline")
args = parser.parse_args()
if args.strict and args.offline:
print("--strict is incompatible with --offline", file=sys.stderr)
return 2
input_path = Path(args.input).resolve()
project_root = Path(args.project_root).resolve()
if not input_path.exists():
print(f"Input not found: {input_path}", file=sys.stderr)
return 2
text = read_input(input_path)
suffix = input_path.suffix.lower()
if suffix == ".bib":
records = parse_bib(text)
elif suffix == ".tsv":
records = parse_tsv(text)
else:
records = parse_reference_lines(text)
if not records:
print("No references detected.", file=sys.stderr)
return 3
verified = [
verify_record(rec, args.offline, args.timeout, use_openalex=not args.no_openalex)
for rec in records
]
for rec in verified:
flag_pagination_placeholder(rec)
duplicate_findings = detect_duplicates(verified)
write_outputs(verified, project_root, input_path, duplicate_findings)
counts: dict[str, int] = {}
for rec in verified:
counts[rec.status] = counts.get(rec.status, 0) + 1
print(json.dumps({
"total": len(verified),
"counts": counts,
"duplicate_findings_count": len(duplicate_findings),
}, indent=2))
if counts.get("FABRICATED", 0) or counts.get("MISMATCH", 0) or duplicate_findings:
return 1
if args.strict and counts.get("UNVERIFIED", 0):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
schema_version: 2
name: verify-refs
layer: A
owner_domain: reference_integrity
maturity: official
when_to_use:
- Audit-only verification of manuscript references against PubMed and CrossRef
- Pre-submission citation hallucination check (PostToolUse hook trigger on circulation/submission docx)
- Detecting author hallucination (DOI real but a cited author name wrong at any position — full-author cross-check against PubMed efetch, v1.3.0)
- LLM-assisted drafting gate — `--strict` mode required when AI generated or rewrote citations
when_NOT_to_use:
- Adding new references (use /search-lit + /lit-sync)
- Rendering references list (use /manage-refs render_pandoc.sh)
- Modifying refs.bib or library.bib (audit-only — never writes back)
inputs:
- manuscript.md
- manuscript.docx
- references.bib
outputs:
- qc/reference_audit.json
deterministic_scripts:
- scripts/verify_refs.py
- scripts/verify_cli.sh
side_effects:
- writes_project_artifacts
downstream_consumers:
- write-paper
- sync-submission
- orchestrate
forbidden_actions:
- generate_references_from_memory
- silently_include_unverified_references
# v2.1 quality card
purpose: "Audit-only verification of manuscript references against PubMed and CrossRef (full-author cross-check); writes qc/reference_audit.json. Does not modify references."
safety_boundaries:
- "Audit-only: never edits references/ or refs.bib; never generates references from memory."
- "Unverified references are flagged, not silently included."
known_limitations:
- "Confirms DOI/PMID and author identity, not topical appropriateness of the citation."
- "CrossRef given-name errors are possible; PubMed efetch is treated as authoritative."
- "OpenAlex (tertiary index for conference proceedings / non-DOI works) gives an existence check plus a tolerant first-author membership check only; its display names carry no structured family field, so it never drives the strict positional or author-count cross-check. Use --no-openalex to restrict to PubMed + CrossRef."
validation_commands:
- "bash scripts/verify_cli.sh <refs.bib>"
- "confirm qc/reference_audit.json submission_safe: true"
- "bash tests/test_openalex_tier.sh"
evidence_surface: bundled_script
@article{easl2024masld,
title = {EASL-EASD-EASO Clinical Practice Guidelines on the management of metabolic dysfunction-associated steatotic liver disease},
author = {{EASL} and {EASD} and {EASO}},
journal = {Journal of Hepatology},
year = {2024},
doi = {10.1016/j.jhep.2024.04.031}
}
@article{kdigo2024ckd,
title = {KDIGO 2024 Clinical Practice Guideline for the Evaluation and Management of Chronic Kidney Disease},
author = {{KDIGO CKD Work Group}},
journal = {Kidney International},
year = {2024},
doi = {10.1016/j.kint.2023.10.018}
}
@article{smith2024cohort,
title = {A cross-sectional study of incidental findings in a health-screening cohort},
author = {Smith, John A and Doe, Jane B and Roe, Mary C},
journal = {Example Journal},
year = {2024},
doi = {10.1000/example.2024.001}
}
@article{methodref_inpress,
title = {A method paper still in press},
author = {Smith, John and Doe, Jane},
year = {2026},
journal = {Journal of Examples},
pages = {e000--e000},
note = {in press}
}
@article{normalref_2025,
title = {A normal complete reference},
author = {Jones, Alice and Brown, Bob},
year = {2025},
journal = {Journal of Examples},
volume = {12},
pages = {123--130}
}
#!/usr/bin/env bash
# Regression test for verify-refs corporate/collective-author handling (B1).
# Offline (no network): a guideline body double-braced in BibTeX
# (`author = {{EASL} and {EASD}}`, `{{KDIGO CKD Work Group}}`) must be detected as
# a corporate author and EXEMPTED from the personal-name family cross-check — it
# must never be a MISMATCH (which would abort render_pandoc.sh on every
# guideline-citing cohort manuscript). A normal personal-author entry is not
# corporate. Stdlib-only (python3).
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$HERE/../scripts/verify_refs.py"
BIB="$HERE/fixtures/corporate_author.bib"
ROOT="$(mktemp -d -t vrc_XXXX)"
trap 'rm -rf "$ROOT"' EXIT
fail=0
check() { local label="$1"; shift
if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label"
else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi
}
[[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
[[ -f "$BIB" ]] || { echo "ENV-ERR: fixture missing" >&2; exit 2; }
python3 "$SCRIPT" "$BIB" --project-root "$ROOT" --offline >/dev/null 2>&1
AUDIT="$ROOT/qc/reference_audit.json"
check "audit JSON written" test -s "$AUDIT"
assert_py() { python3 -c "
import json
d = json.load(open('$AUDIT'))
recs = {r['ref_id']: r for r in d['records']}
$1
"; }
# Double-braced guideline bodies detected as corporate, annotated, never MISMATCH.
for ref in easl2024masld kdigo2024ckd; do
check "$ref corporate_author True" \
assert_py "assert recs['$ref']['corporate_author'] is True, recs['$ref']"
check "$ref annotated corporate (note)" \
assert_py "assert 'corporate' in recs['$ref'].get('note','').lower(), recs['$ref']"
check "$ref NOT a MISMATCH / no author-mismatch" \
assert_py "assert recs['$ref']['status'] != 'MISMATCH' and 'AUTHOR MISMATCH' not in recs['$ref'].get('evidence',''), recs['$ref']"
done
# A normal personal-author entry must NOT be flagged corporate.
check "personal-author entry corporate_author False" \
assert_py "assert recs['smith2024cohort']['corporate_author'] is False, recs['smith2024cohort']"
echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
exit "$fail"
#!/usr/bin/env bash
# Regression test for the OpenAlex tertiary index (conference proceedings / non-DOI
# / non-biomedical recovery). Network-free: monkeypatches http_json so no live API
# is called. Motivation: NeurIPS/ICLR/ACL citations common in medical-AI papers fall
# through PubMed (not biomedical) and CrossRef (spotty proceedings) — OpenAlex is the
# free analogue of a journal portal's second index (e.g. Scopus). Stdlib-only.
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$HERE/../scripts/verify_refs.py"
[[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
python3 - "$SCRIPT" <<'PY'
import importlib.util, sys
spec = importlib.util.spec_from_file_location("vr", sys.argv[1])
vr = importlib.util.module_from_spec(spec)
sys.modules["vr"] = vr # dataclass resolution needs the module registered (py3.14)
spec.loader.exec_module(vr)
fail = 0
def check(label, cond):
global fail
if cond:
print(f" PASS {label}")
else:
print(f" FAIL {label}")
fail += 1
# --- _title_similarity ----------------------------------------------------------
t = "Reflexion: Language agents with verbal reinforcement learning"
check("title sim exact == 1.0", abs(vr._title_similarity(t, t) - 1.0) < 1e-9)
check("title sim unrelated < 0.3",
vr._title_similarity(t, "A meta-analysis of CT screening for lung cancer") < 0.3)
check("title sim empty == 0.0", vr._title_similarity("", t) == 0.0)
# --- _openalex_families ---------------------------------------------------------
work = {"authorships": [
{"author": {"display_name": "Aman Madaan"}},
{"author": {"display_name": "Niket Tandon"}},
{"author": {"display_name": "Madaan A"}}, # trailing-initials form
]}
fams = vr._openalex_families(work)
check("families last-token parse", fams[:2] == ["Madaan", "Tandon"])
check("families strip trailing initials", fams[2] == "Madaan")
# --- verify_openalex via monkeypatched http_json --------------------------------
DOI_WORK = {"id": "https://openalex.org/W1",
"title": "Self-Refine: Iterative refinement with self-feedback",
"publication_year": 2023,
"authorships": [{"author": {"display_name": "Aman Madaan"}}]}
TITLE_HIT = {"results": [
{"title": "Reflexion: Language agents with verbal reinforcement learning",
"publication_year": 2023,
"authorships": [{"author": {"display_name": "Noah Shinn"}},
{"author": {"display_name": "Federico Cassano"}}]},
{"title": "Some unrelated paper about kidneys", "authorships": []},
]}
TITLE_MISS = {"results": [
{"title": "Completely different work on radiology", "authorships": []}]}
def make_http(mapping):
def _http(url, timeout):
for needle, payload in mapping.items():
if needle in url:
return payload
return None
return _http
# (a) DOI resolve
vr.http_json = make_http({"api.openalex.org/works/https://doi.org/": DOI_WORK})
st, ev, fams = vr.verify_openalex("10.5555/self-refine", "", 5)
check("openalex DOI resolve OK", st == "OK" and fams == ["Madaan"] and "via doi" in ev)
# (b) title.search with strong similarity → OK
vr.http_json = make_http({"api.openalex.org/works?": TITLE_HIT})
st, ev, fams = vr.verify_openalex(
"", "Reflexion: Language agents with verbal reinforcement learning", 5)
check("openalex title hit OK", st == "OK" and fams[0] == "Shinn" and "via title" in ev)
# (c) title with no close match → UNVERIFIED (fabrication guard)
vr.http_json = make_http({"api.openalex.org/works?": TITLE_MISS})
st, ev, fams = vr.verify_openalex(
"", "Reflexion: Language agents with verbal reinforcement learning", 5)
check("openalex weak-title rejected (UNVERIFIED)", st == "UNVERIFIED" and fams == [])
# (d) no DOI, no title, no match → UNVERIFIED, never FABRICATED
vr.http_json = make_http({})
st, ev, fams = vr.verify_openalex("", "", 5)
check("openalex empty never FABRICATED", st == "UNVERIFIED")
# --- integration through verify_record ------------------------------------------
# A conference paper: no PMID, no DOI, title only. OpenAlex resolves it and the
# cited first author matches → status OK, source includes openalex.
vr.http_json = make_http({"api.openalex.org/works?": TITLE_HIT})
rec = vr.RefRecord(
ref_id="reflexion2023",
raw="Shinn N, Cassano F, et al. Reflexion: Language agents with verbal reinforcement learning. NeurIPS 2023.",
title_guess="Reflexion: Language agents with verbal reinforcement learning",
cited_authors=["Shinn", "Cassano"],
first_author_guess="Shinn",
)
out = vr.verify_record(rec, offline=False, timeout=5, use_openalex=True)
check("verify_record conference OK via openalex",
out.status == "OK" and "openalex" in out.evidence)
# Same record with a fabricated first author → MISMATCH from OpenAlex authors.
vr.http_json = make_http({"api.openalex.org/works?": TITLE_HIT})
rec_bad = vr.RefRecord(
ref_id="reflexion_bad",
raw="Ebrahimi A, et al. Reflexion: Language agents with verbal reinforcement learning. NeurIPS 2023.",
title_guess="Reflexion: Language agents with verbal reinforcement learning",
cited_authors=["Ebrahimi", "Cassano"],
first_author_guess="Ebrahimi",
)
out_bad = vr.verify_record(rec_bad, offline=False, timeout=5, use_openalex=True)
check("verify_record catches first-author hallucination via openalex",
out_bad.status == "MISMATCH" and "AUTHOR MISMATCH" in out_bad.evidence)
# --no-openalex equivalent: use_openalex=False leaves a no-identifier record UNVERIFIED.
vr.http_json = make_http({"api.openalex.org/works?": TITLE_HIT})
rec_off = vr.RefRecord(
ref_id="reflexion_off",
raw="Reflexion NeurIPS 2023.",
title_guess="Reflexion: Language agents with verbal reinforcement learning",
cited_authors=["Shinn"],
first_author_guess="Shinn",
)
out_off = vr.verify_record(rec_off, offline=False, timeout=5, use_openalex=False)
check("use_openalex=False skips OpenAlex (no openalex source)",
"openalex" not in out_off.evidence)
print(f"fail={fail}")
print("ALL PASS" if fail == 0 else f"FAILURES: {fail}")
sys.exit(fail)
PY
#!/usr/bin/env bash
# Regression test for verify-refs Gate 6 (pagination-placeholder detection).
# Offline (no network): a bib entry whose pages are "e000--e000" with an "in press"
# note must get note="pagination_placeholder"; a normal entry must not. verify-refs
# stays manuscript-agnostic — it only flags; the P0/centrality call is /self-review's.
# Stdlib-only (python3).
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$HERE/../scripts/verify_refs.py"
BIB="$HERE/fixtures/pagination_placeholder.bib"
ROOT="$(mktemp -d -t vrp_XXXX)"
trap 'rm -rf "$ROOT"' EXIT
fail=0
check() { local label="$1"; shift
if "$@" >/dev/null 2>&1; then printf ' PASS %s\n' "$label"
else printf ' FAIL %s\n' "$label"; fail=$((fail+1)); fi
}
[[ -f "$SCRIPT" ]] || { echo "ENV-ERR: script missing" >&2; exit 2; }
python3 "$SCRIPT" "$BIB" --project-root "$ROOT" --offline >/dev/null 2>&1
AUDIT="$ROOT/qc/reference_audit.json"
check "audit JSON written" test -s "$AUDIT"
assert_py() { python3 -c "
import json
d = json.load(open('$AUDIT'))
recs = {r['ref_id']: r for r in d['records']}
$1
"; }
check "placeholder entry flagged note=pagination_placeholder" \
assert_py "assert 'pagination_placeholder' in recs['methodref_inpress'].get('note',''), recs['methodref_inpress']"
check "placeholder entry status UNVERIFIED" \
assert_py "assert recs['methodref_inpress']['status']=='UNVERIFIED', recs['methodref_inpress']['status']"
check "normal entry NOT flagged" \
assert_py "assert 'pagination_placeholder' not in recs['normalref_2025'].get('note',''), recs['normalref_2025']"
echo "fail=$fail"; [[ "$fail" -eq 0 ]] && echo "ALL PASS" || echo "FAILURES: $fail"
exit "$fail"
Related skills
FAQ
Does it change my references?
No. It is audit-only and never writes to references/ or refs.bib; its sole output is qc/reference_audit.json.
What sources does it verify against?
PubMed E-utilities and CrossRef, then OpenAlex as a tertiary index for conference papers (NeurIPS/ICLR/ACL); a miss is recorded UNVERIFIED, never FABRICATED.