Skip to content

Export DOIs and PMCIDs as an identifiers field - #7

Open
gaurav wants to merge 6 commits into
add-validate-commandfrom
add-doi-and-pmcids
Open

Export DOIs and PMCIDs as an identifiers field#7
gaurav wants to merge 6 commits into
add-validate-commandfrom
add-doi-and-pmcids

Conversation

@gaurav

@gaurav gaurav commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Adds DOIs and PMCIDs to the JSON export as an identifiers array of CURIEs, and
fixes an upstream parser bug that surfaced while doing it. Closes #4

{
  "id": "PMID:16954148",
  "identifiers": ["PMID:16954148", "PMC:PMC1904490", "doi:10.1242/jcs.03153"],
  "journal_name": "Journal of cell science",
  "...": "..."
}

What changed

identifiers is derived at export, not stored. The DOIs and PMCIDs were
already being loaded into the article_id table, so this adds a CTE joined on
(pmid, source_file) — the same key the abstract CTE uses, which is what keeps a
superseded version's DOI out of the export. No schema change and no reload needed
to backfill.

Prefixes match Babel's src/prefixes.py exactly: PMID, lowercase doi, and
PMC. PubMed's PMCID values already begin with PMC, hence the doubled
PMC:PMC6423490. DOI: or a de-doubled PMC:6423490 would silently fail to join
against the Babel publication compendium. Values keep PubMed's own case, so
consumers must match case-insensitively — DOIs are case-insensitive per spec
and PubMed is not internally consistent.

Only DOIs and PMCIDs are promoted. Other ArticleId types (pii, mid, …) stay
in the article_id table and the Parquet export.

validate gained the matching cross-check. efetch_documents rebuilds the
CURIEs from the same ArticleIdList using the exporter's own prefix map, and
check_fields compares them as a set — it is the one list-valued field, so the
string path cannot handle it.

Upstream bug fixed along the way

pubmed_downloader's _extract_article builds Article.xrefs from
.//ArticleIdList/ArticleId under PubmedData. .// matches at any depth and
PubmedData/ReferenceList/Reference carries an ArticleIdList of its own, so
every cited reference's DOI and PMCID was being stored as an identifier of the
citing article
. One real record (PMID:41136637) contributed 426 foreign DOIs
alongside its own. parse._xrefs now re-extracts from the direct child — the path
Babel reads.

This has silently corrupted article_id since the first load. The JSON export
dodged it only because it never read that table; the Parquet export did not.

Important

Existing databases need pubmed2db load --force over the corpus to clean
article_id. Nothing detects this automatically, because the source files
themselves have not changed.

Verification

61 tests pass, offline. Each new test was mutation-checked — reverting the
behaviour it covers makes it fail.

Verified end-to-end against a real update file (14,199 records): 96.7% carry a
DOI, 57% a PMCID, and the maximum identifiers on any one record is 3 (it was 427
before the parser fix). validate against live Entrez reports only pre-existing
ahead-of-print staleness — two sampled records have since been assigned both a
journal issue and a PMCID upstream, which shows up in volume/issue/pub_month
as well as in identifiers.

Follow-ups

Noted while doing this work; none are started.

  • reference_citation is always empty. Same wrong-scope bug class as the
    one fixed here: cites_pubmed_ids searches MedlineCitation for
    .//ReferenceList/Reference, but ReferenceList is a child of PubmedData, a
    sibling — so it never matches. 0 rows loaded from 14,201 real articles whose
    records carry hundreds of references each. The path fix is one line; the real
    question is whether the citation graph is wanted at full scale (~444 rows for a
    single article). Documented in FUTURE.md.
  • Add an identifier-coverage stat to validate. A DOI rate that collapsed
    between two exports would pass silently in the offline structure check. Roughly
    three lines (pct_with_doi / pct_with_pmc); small enough to fold in here.
  • Report the xrefs bug upstream to cthoyt/pubmed-downloader. The fix
    there is anchoring the XPath to the direct child. Tracked in FUTURE.md.
  • Re-load the production database with load --force so its article_id
    table drops the cited-reference pollution, then re-run the export.
  • ELocationID DOIs are still unread. A DOI can also appear as
    Article/ELocationID[@EIdType="doi"], normally duplicating ArticleIdList.
    Babel ignores it too and ArticleIdList is authoritative, so this is a
    fallback worth adding only if records turn up with one and not the other.
  • Confirm the identifiers field with Node Annotator. The
    DocumentMetadataAPI spec documents nine string fields and carries the
    identifier only as the results map key — it has no id field and no
    identifier field. id was already an extension; identifiers is a second one,
    and the first non-string value in the record.

🤖 Generated with Claude Code

gaurav and others added 5 commits August 3, 2026 14:03
`_extract_article` collects `Article.xrefs` with
`.//ArticleIdList/ArticleId` under `PubmedData`. `.//` matches at any
depth, and `PubmedData/ReferenceList/Reference` carries an
`ArticleIdList` of its own, so every cited reference's DOI and PMCID was
stored as an identifier of the citing article. One real record
(PMID:41136637) contributed 426 foreign DOIs alongside its own.

parse.py already exists to correct and extend that parser, so re-extract
xrefs from the direct child `PubmedData/ArticleIdList/ArticleId` — the
path Babel reads — and overwrite `article.xrefs` before rows are built.
The article's own PMID stays excluded, as upstream had it.

This has polluted `article_id` since the first load, so an existing
database needs `load --force` to clean it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The JSON export emitted only `id: "PMID:<n>"`, so nothing downstream
could resolve a publication by DOI or PMCID. Babel derives those
mappings by re-parsing the PubMed XML itself; since pubmed2db is meant
to replace that download, the export has to carry them.

The data was already in the `article_id` table, so this derives the
field at export with a CTE joined on (pmid, source_file) — the same key
the abstract CTE uses, which is what keeps a superseded version's DOI
out of the export. No schema change and no reload to backfill.

Prefixes match Babel's src/prefixes.py exactly: `PMID`, lowercase `doi`,
and `PMC` (PubMed's PMCID values already start with `PMC`, hence
`PMC:PMC6423490`). `DOI:` or a de-doubled PMCID would silently fail to
join against the Babel compendium. Values keep PubMed's own case, so
consumers must match case-insensitively. Only DOIs and PMCIDs are
promoted; `pii`/`mid` stay in `article_id` and the Parquet export.

validate gains the matching cross-check: `efetch_documents` rebuilds the
CURIEs from the same ArticleIdList using the exporter's own prefix map,
and `check_fields` compares them as a set, since it is the one
list-valued field and the string path cannot handle it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the validate-side tests the previous commits lacked: that an
identifier disagreeing with Entrez is actually flagged, and that
`_identifiers` reads the article's own ArticleIdList rather than its
cited references' — the mirror of the parse-side regression test. Both
fail if the behaviour is reverted.

`test_field_validation_matches` already guarded the set comparison (the
export sorts, efetch returns document order), so note that rather than
adding a redundant test.

Auditing the other upstream XPaths turned up a second instance of the
same wrong-scope bug: `cites_pubmed_ids` searches MedlineCitation for
`.//ReferenceList/Reference`, but ReferenceList is a child of PubmedData,
a sibling — so `reference_citation` is silently always empty (0 rows from
14,201 real articles whose records carry hundreds of references).
Documented in CLAUDE.md and FUTURE.md; not fixed here, since it is a
separate table from this branch's subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both branches independently found and fixed the same bug: cited references'
DOIs being attributed to the citing article. initial-implementation's
parse._article_ids and this branch's parse._xrefs anchor to the same
PubmedData/ArticleIdList/ArticleId path and drop the same `pubmed` self-ID.

Keeps _article_ids and drops _xrefs, since initial-implementation also fixes
the sibling bug this branch documented but deferred -- cites_pubmed_ids never
matching, leaving reference_citation empty. Carries over what _xrefs had and
_article_ids lacked: the "path Babel reads" note and the PMID:41136637
evidence.

This branch's contribution is unchanged and is now purely export-side: the
identifiers CURIE array, the validate cross-check, and the 0002 fixture
covering a version-specific PMCID and pii exclusion. test_xrefs_exclude_cited_references
is rewritten against article_ids and renamed to match.

Also applies the latest-snapshot restriction to the new `ids` CTE. It had the
same shape as `abs` before 04a4ffc: aggregating the whole version history and
discarding the superseded rows in the join.

CLAUDE.md's three overlapping upstream sections are consolidated into one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Takes the removal of the citation-graph table. This branch's FUTURE.md entry
asked whether the table was wanted at full scale; that is now decided, so the
open question is replaced by the decision and the re-enabling instructions
parked in parse._cited_pmids.

Nothing on the export side changes: the identifiers field reads article_id, not
reference_citation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Now that PR #2 has merged PR #1, this branch picks both up and its diff
collapses back to the export-side work it is actually about.

Conflict resolutions, all additive rather than either/or:

- export.py: _LATEST_METADATA_SQL selects both `la.medline_date` (for the
  pub_year backfill) and `ids.identifiers`, and _document unpacks all twelve.
  Verified the two coexist: PMID 1003 exports pub_year "1998" recovered from
  "1998 Spring" with identifiers ["PMID:1003"], while PMID 1001 keeps its DOI
  and PMCID alongside a real pub_year.
- validate.py: keeps ID_PREFIXES and adds _year_from_medline_date, so both of
  the exporter's normalizations are applied to the efetch side. This branch had
  independently bumped EXPECTED_FIELDS' placeholder row to a hardcoded 10; the
  derived version supersedes it, which is the point -- `identifiers` and
  `medline_date` each widened that row once already.
- FUTURE.md/CLAUDE.md: both sides' notes kept. The ELocationID follow-up and the
  identifiers design note are unique to this branch; the completed MedlineDate
  entry and the efetch-is-a-rendering warning come from the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Export PMCIDs and DOIs alongside PMIDs

1 participant