Skip to content

Download PubMed abstracts into a version-history DuckDB and export them as JSON and Parquet - #1

Merged
gaurav merged 107 commits into
mainfrom
initial-implementation
Aug 19, 2026
Merged

Download PubMed abstracts into a version-history DuckDB and export them as JSON and Parquet#1
gaurav merged 107 commits into
mainfrom
initial-implementation

Conversation

@gaurav

@gaurav gaurav commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Adds pubmed2db: a uv/click tool that downloads every PubMed abstract, loads it into a DuckDB database that keeps full version history, and exports the latest version of each abstract to JSON (DocumentMetadataAPI field names, for Node Annotator / ElasticSearch) and Parquet (PubMed field names, for downloadable queries).

The eventual goal is to replace the PubMed download in Babel (createcompendia/publications.py).

The pipeline

Four independent commands, plus two that read or chain them:

  • download — bulk baseline/update fetching via cthoyt/pubmed-downloader, with each file's published .md5 checksum recorded in a source_file registry. A new or changed checksum is what marks a file for (re)loading; verification is on by default and hashes only files that are new or whose published checksum moved (--no-verify opts out), and discards a file that fails twice rather than leaving it for load to pick up.
  • journals — refreshes the journal dimension from the NLM Catalog (J_Entrez), joined on nlm_catalog_id. Re-fetched on every run, since the catalog changes and the file is small.
  • load — parses the XML and inserts it, keeping every version. Rows carry their source_file provenance and a file_order_key that reproduces PubMed's chronological ordering, so the latest_article view can pick the newest non-deleted version of each PMID (honouring <DeleteCitation>).
  • export — sharded NDJSON (DocumentMetadataAPI names, empty strings rather than nulls, pub_month as a 3-letter abbreviation) or one Parquet file per table (latest version by default, --all for full history). Both formats sweep files left by a previous export — shards a wider run wrote, or a Parquet file whose table left the schema — so a consumer globbing the directory can't read two exports at once.
  • status reports the state of all of it read-only; update chains download → journals → load for scheduled runs, and treats a failed journal refresh as non-fatal so an NLM outage can't discard a completed download.

Design decisions

  • The database uses PubMed's own field names. DocumentMetadataAPI names are applied only in the JSON export, so the store stays faithful to the source and the API contract lives in one place.
  • Full version history, not upsert. Every file's rows are tagged and kept; latest_article derives the current view. This makes a re-load idempotent and a corrected file cheap to apply.
  • Publication dates keep full fidelity. We drive the XML iteration ourselves rather than using the upstream process pipeline, because it collapses partial and MedlineDate-only dates into a datetime.date. Raw Year/Month/Day/MedlineDate are stored, and the JSON export recovers a year from MedlineDate shapes where the parsed fields are empty.
  • Nothing is skipped silently. The parser drops three kinds of record — ones the extractor rejects, ones it returns None for (an empty <ArticleTitle>, a missing <MedlineJournalInfo>), and <PubmedBookArticle> citations, which the DTD allows in these files but we don't parse. None of them become rows, so no downstream count is short; source_file.n_failed and n_book_records make them queryable and status reports the total.
  • No citation graph. One real article carries ~444 references, so at corpus scale reference_citation would have been the largest table here, for data no consumer asked for. The extraction is parked (uncalled) with re-enabling instructions rather than deleted.
  • Step ordering is enforced from database state, not a run flag. load errors when nothing is downloaded, export errors when nothing is loaded and warns about unloaded files or an empty journal table. Deriving readiness from the registry's watermarks means a check can't disagree with the data it guards.
  • Columnar bulk load. Each file's rows go in as an Arrow table via INSERT ... SELECT rather than row-by-row executemany, which took ~20 minutes per file. The benchmark measures ~5–6 s per file, but a corpus-scale load has since been observed at ~91 s per file, so treat the benchmark figure as a floor until Load is running ~15x slower than slurm/README.md claims — re-measure #11 re-measures it.
  • MD5 is treated as low-priority insurance. HTTP downloads are reliable and PubMed files are immutable, so verification only hashes files that are new or whose published checksum moved — re-hashing an unchanged corpus costs tens of GiB of I/O and catches nothing.
  • schema.sql only ever adds, and runs on every connect: CREATE TABLE IF NOT EXISTS plus explicit ALTER TABLE ... ADD COLUMN IF NOT EXISTS migrations for anything added after the first release. Nothing drops a table or column, which is why a schema change wants a rebuild rather than load --force (below).

Upstream bugs worked around

Three pubmed-downloader (≤ 0.0.14) bugs, each tracked in FUTURE.md with a pinning test so the workaround fails loudly once upstream fixes it. The dependency is pinned <0.1 because we also call private APIs (_extract_article, _ensure_urls).

  • Journal parsing raises. catalog.process_journal_overview()'s Journal model requires start_year/end_year, which the real J_Entrez.txt does not carry, so we parse the overview file ourselves. Filed upstream as Make catalog.Journal.start_year / end_year optional so it works with J_Entrez.txt cthoyt/pubmed-downloader#16
  • References are never found. _extract_article looks for .//ReferenceList/Reference under MedlineCitation, but PubMed nests <ReferenceList> under <PubmedData>, so Article.cites_pubmed_ids is always empty on real data. Harmless here — we store no citation graph — but a working counter-example is kept for whenever it is reported.
  • Article IDs are over-collected. That same .// descent attributes every cited reference's DOI/PMID to the citing article, which would have been silently wrong rows in article_id proportional to reference count. We use the direct PubmedData/ArticleIdList/ArticleId path instead.

Operations

  • Everything runs from the repo with uv run pubmed2db …; nothing is installed.
  • Group-level --threads / --temp-dir cap DuckDB's thread pool and set its spill directory, since DuckDB otherwise sizes its pool from the node's cores rather than the Slurm allocation. All four group options also read an environment variable (PUBMED2DB_THREADS, PUBMED2DB_DUCKDB_TEMP_DIR, PUBMED2DB_DATA_DIR, PUBMED2DB_DB), which --help documents.
  • slurm/README.md sizes both jobs: load ~16 GB (memory is bounded by the largest single file) and export ~256 GB (whole-corpus snapshot + sort). The export figure comes from the full-scale run below; the load figure comes from the benchmark, and a real run has since exceeded it (Load is running ~15x slower than slurm/README.md claims — re-measure #11).
  • Re-running after a gap can't duplicate data, and the README explains why. Two cases cost real time: a new baseline year is a full re-parse that stores a second version of every PMID, and applying a parser or schema change to already-loaded data. For the latter the README now gives the rule — rebuild from a fresh database unless you know a parsing change is all you need. Both cost one full load and neither re-downloads, but since schema.sql only ever adds, a table or column removed from it keeps its rows through any number of load --force runs, and a forced reload leaves wrong rows in place for files it re-parses identically. Only the rebuild is guaranteed to leave the shape the schema describes.
  • export publishes in place, not atomically — a run that dies partway leaves a half-written dataset in --out. Export into a fresh directory and swap it if consumers read the output while exports run (export publishes in place, so a failed run leaves a half-written dataset #24).
  • Documentation is split by audience: README.md for running the pipeline, AGENTS.md for a map of the source and the decisions that aren't visible from it, FUTURE.md for deferred work. Anything a docstring or a schema comment can carry lives there instead.

Verification

  • 84 tests pass with no network: parse fidelity, latest-version and deletion logic, idempotent and MD5-triggered reloads, journal parsing, the JSON field contract, Parquet filtering, DuckDB tuning options, schema migration, and the CLI end to end.
  • Full-scale run on RENCI's cluster: the complete corpus exported as 40,901,984 documents across 16 NDJSON shards in 23m13s (≈30k documents/s), peak RSS 201.1 GiB under --mem=256G --cpus-per-task 8.
  • Single-file live run: MD5-verified download → 16,664 articles + 56 deletions → 41,923 journals → 16,664 documents across even NDJSON shards (zero nulls, journal names and abbreviations resolved, months normalized) and 15 consistent Parquet files.
  • Two fields a validation run reported as blank are not a parser gap. PMID 10137601's issue ("Suppl") and PMID 28972331's article_title ("[Not Available].") were fetched from Entrez and run through parse → load → export: both come out correct at every stage. They are in the fixtures and pinned, because both shapes — a non-numeric issue whose volume repeats it, and a title that is a literal placeholder with the real one in <VernacularTitle> — are what a future change might reasonably decide to drop.

Follow-on work

Issues opened for what this PR deliberately leaves out:

gaurav and others added 26 commits June 19, 2026 14:45
Set up the uv project: pyproject with the pubmed-downloader/duckdb/click/
pydantic/requests/tqdm/lxml dependencies, the pubmed2db console script, a
src/ layout, and the pytest config. Ignore the JetBrains .idea/ directory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Normalized, full-version-history schema using PubMed's own field names: an
article table plus child tables (abstract_text, author, mesh_heading, grants,
citations, article_id, history, ...), a source_file registry, and the
latest_article view that selects the newest non-deleted version per PMID.
db.py opens/initializes the database and parses filenames into a chronological
file_order_key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
download.py reuses pubmed_downloader to fetch baseline/update files and adds
.md5 sidecar tracking so new/changed checksums drive incremental reloads.
parse.py drives the XML iteration itself, calling cthoyt's _extract_article for
the rich record while additionally capturing the raw PubDate components (full
date fidelity) and <DeleteCitation> PMIDs, neither of which the library's
pipeline exposes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loads parsed files into the normalized tables tagged with their source_file
provenance, so every version of a PMID coexists; reloading a file is
idempotent. needs_load drives incremental/MD5-change reloads. Journals are
built from the NLM Catalog overview file, parsed directly because
pubmed_downloader's process_journal_overview raises on the real J_Entrez data
(its Journal model requires start/end years the file omits).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One configurable export: sharded NDJSON using DocumentMetadataAPI field names
(empty string, never null; pub_month as a 3-letter abbreviation) and per-table
Parquet (latest version or full history). CLI wires up download, journals,
load, export, and a combined update command.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
34 tests covering parse fidelity (raw/partial/MedlineDate dates, deletions),
full-history loading, latest-version selection, idempotent and MD5-change
reloads, journal parsing, JSON spec fields (empty-string-not-null), Parquet
filtering, and the CLI end to end. Readable XML fixtures live under
tests/fixtures and are gzipped into proper filenames at test time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
README usage notes, CLAUDE.md (architecture, module map, why we reuse
pubmed-downloader as-is and drive parsing ourselves, the journal-overview
upstream bug), and FUTURE.md (replacing Babel's downloader, reverting the
journal workaround upstream, scale/perf, data-fidelity follow-ups).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Removes the CASE WHEN ? THEN now() ELSE NULL END idiom from the INSERT,
which hid timestamp generation inside SQL and prevented callers from ever
supplying an explicit datetime. Now register_source_file converts the bool
to datetime.now(timezone.utc) or None before binding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Use hashlib.file_digest() instead of a manual chunk-read loop
- Remove dead fetch_published_md5 (its logic was already inlined in _sync_kind)
- Move MD5_DIR.mkdir() before the loop instead of calling it on every iteration
- Flatten the doubly-nested registry dict comprehension in sync() to {r[0]: r[1] for r in ...}

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
export_json no longer queries SELECT count(*) FROM latest_article before
streaming rows; shards are assigned round-robin (index % shards), which
is one fewer full scan of the view.

_MONTH_ABBRS set removed; calendar.month_abbr supports __contains__ directly
and already covers the same values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The closure used nonlocal to rebind record/issns and returned a value
that had to be captured and checked at each call site. Replacing it with
an inline yield + reset at each --- boundary is shorter and easier to follow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds data/.gitkeep so the download destination exists in the working tree.
Tightens .gitignore from /data/ (ignores the directory entry itself, which
prevents force-adding children) to /data/* + !/data/.gitkeep, which ignores
the contents while keeping the skeleton tracked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
--data-dir (default data/pubmed, or $PUBMED2DB_DATA_DIR) is added to the
top-level group so a single flag redirects both the download location and
the database for all subcommands. It sets PYSTOW_HOME before any
pubmed_downloader import so pystow resolves its module paths under data_dir.

The database now defaults to <data-dir>/pubmed.duckdb instead of pubmed.duckdb
in the working directory; still overridable via --db or $PUBMED2DB_DB.

README usage examples updated to show explicit data/pubmed paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pubmed_downloader creates its own pubmed_downloader/ subdirectory under
PYSTOW_HOME, so data/pubmed as the root was one level too deep. Using
data/ lets pystow manage its own layout naturally.

Update README and CLAUDE.md to reflect the new default and note that the
layout differs from Babel's download cache (no sharing for now).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The loader inserted each parsed file's rows with executemany on a
parameterized INSERT, which DuckDB (a columnar store) runs row by row at
~2.5k rows/s. That made `load` take ~20 min per 30k-article file — ~30+
hours for a full baseline.

Register each file's row batches as Arrow tables and insert them columnar
via `INSERT ... SELECT`. Per-file load drops from ~75-90s to ~5-6s
(~25-90x on the insert step), so a full baseline is ~2-3 hours serial.
Memory is unchanged (one file in flight at a time) and the row data is
identical. Adds a pyarrow dependency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
We had no way to size the loader's Slurm --mem (the first run guessed
100G). Log the process's peak resident set size after each file via
resource.getrusage, so a real run shows the high-water mark directly:

    loaded pubmed26n1334.xml.gz: 4989 articles, 0 deletions (peak RSS 0.8 GiB)

Peak RSS is driven by the largest single file, not the corpus, so this
reveals a tight --mem bound (observed <1 GiB for a 5k-article file).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
scripts/benchmark_load.py times parsing vs insertion separately and
reports rows/s and peak RSS, for spotting load regressions and sizing
Slurm jobs. slurm/README.md documents how to run on the cluster, why
~16G --mem suffices (down from 100G), and how to monitor memory via the
per-file log line, seff, sstat/sacct, and /usr/bin/time -v. Updates
FUTURE.md: throughput item done, parallel Parquet-shard load deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The download/journals/load/export steps are independent commands run in
sequence, but nothing caught a skipped or out-of-order step: export with
no journals silently emitted blank journal names, and export before load
wrote near-empty output.

Derive readiness from the database's own state (new status.py) rather
than a separate "step ran" flag, so a check can't disagree with the data:

- load: error if nothing downloaded (was a soft echo; now exits non-zero)
- export: error if no articles loaded; warn (and proceed) if files are
  downloaded but not yet loaded, or if the journal table is empty

Also decouple load from journals: load now loads article data only, and
journals is its own step (update still chains download -> journals ->
load). One job per command, with export's checks enforcing the ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read-only complement to the export/load prerequisite guards: report what
has been downloaded, loaded, and is ready to export, so you can tell at a
glance where the pipeline is without re-running a step.

`status.summarize` gathers the figures; download/load counts and recency
derive from the source_file registry (no duplicated truth). The one value
the data doesn't already carry is when `journals` last ran (its tables are
replaced wholesale), so load_journals now stamps it via db.record_run into
a new pipeline_run table; status reads it back. The command also prints an
at-a-glance Export verdict mirroring the export guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After each file, load_files now logs how many files have been processed
this run, how many remain, and a rolling ETA based on elapsed time — so
long Slurm jobs are easy to monitor without grepping timestamps.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
load.py had private copies of these; pulling them into util.py lets
export.py reuse them without duplicating the logic.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The export command previously ran silently until it printed a final
file count, giving no indication of whether it was working or how
much memory to budget for future runs. JSON export now logs a
periodic x/y-documents-with-ETA progress line, Parquet export logs
per-table progress, and both report peak RSS on completion. The CLI
also echoes a start message and surfaces DuckDB's own progress bar
for long COPY queries under -v/--verbose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Compressing during the write avoids a second read/write pass over
multi-GB NDJSON shards. Output stays line-readable via zcat/gunzip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Existing CLI tests built the DB by calling load_file() directly, so
nothing exercised load's actual pystow directory scan (_local_files)
or the new --gzip export option. Adds a staged_download fixture that
lays fixture files out under a fake pystow baseline/updates layout,
and runs the CLI via subprocess rather than CliRunner: pubmed_downloader
fixes its pystow paths at import time from PYSTOW_HOME, so an
in-process CliRunner invocation would inherit whatever directory an
earlier-imported test already fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Initial implementation of pubmed2db, a Python CLI tool that downloads PubMed baseline/update XMLs, loads them into a full-version-history DuckDB schema, and exports the latest non-deleted article set to NDJSON (DocumentMetadataAPI fields) and Parquet.

Changes:

  • Added end-to-end pipeline modules (download → parse → load → export → status) plus a Click CLI wrapper.
  • Introduced a normalized DuckDB schema with a latest_article view for newest-version selection and deletion handling.
  • Added comprehensive pytest coverage with XML/journal fixtures, plus docs and Slurm run guidance.

Reviewed changes

Copilot reviewed 26 out of 30 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/pubmed2db/__init__.py Defines package version and module docstring.
src/pubmed2db/cli.py Click CLI implementing download, journals, load, export, update, status.
src/pubmed2db/db.py DuckDB connection + schema init + source-file registry helpers.
src/pubmed2db/download.py PubMed sync via pubmed-downloader with .md5 sidecar tracking and optional verification.
src/pubmed2db/export.py JSON (DocumentMetadataAPI fields, empty-string-not-null) and Parquet exports.
src/pubmed2db/load.py Normalized loading with full history, idempotent reloads, and journal overview parsing/loading.
src/pubmed2db/parse.py Self-driven XML iteration using _extract_article, plus raw PubDate + DeleteCitation capture.
src/pubmed2db/schema.sql Full normalized schema + latest_article view implementing newest-version selection with deletions.
src/pubmed2db/status.py Read-only pipeline readiness/state summarization derived from DB state.
src/pubmed2db/util.py Shared helpers for peak RSS and duration formatting.
tests/conftest.py Shared fixtures (gzipping XML fixtures, DuckDB connections, staged download layout).
tests/test_cli.py CLI end-to-end tests (export, status, error messaging, directory scan, gzip export).
tests/test_db_download.py Tests for filename ordering, registry upsert behavior, and MD5 parsing/helpers.
tests/test_export.py Tests for month normalization, JSON field contract, sharding, and Parquet latest/all behavior.
tests/test_journals.py Tests for journal overview parsing and journal load behavior.
tests/test_load.py Tests for full history retention, latest selection, deletion behavior, and MD5-triggered reloads.
tests/test_parse.py Tests for raw-date fidelity, rich extraction fields, and DeleteCitation capture.
tests/__init__.py Marks tests as a package.
tests/fixtures/pubmed25n0001.xml Baseline fixture XML used for parsing/load/export tests.
tests/fixtures/pubmed25n0002.xml Update fixture XML including a revised article and a DeleteCitation.
tests/fixtures/J_Entrez_sample.txt Sample NLM journal overview fixture for journal parser tests.
scripts/benchmark_load.py Script to benchmark parsing vs insertion throughput and peak RSS.
slurm/README.md Operational notes for running loads on Slurm (memory/time sizing, monitoring).
README.md Project documentation: purpose, pipeline, usage examples, and development notes.
CLAUDE.md Architecture/design orientation document for the repository.
FUTURE.md Deferred work items and known limitations.
pyproject.toml Project metadata, dependencies, and pytest configuration.
.gitignore Ignores downloaded data while keeping directory skeleton.
data/.gitkeep Keeps data/ directory present in the repository.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/pubmed2db/download.py
Comment thread src/pubmed2db/download.py
Comment thread src/pubmed2db/util.py
load_files' progress log and the benchmark script both hand-rolled logic
already available; consolidate into shared helpers instead of copies.
gaurav and others added 7 commits August 18, 2026 15:48
`_local_files` globbed `pubmed_downloader`'s pystow directories from inside
`cli.py`, so the download layout was asserted in the CLI as well as in
`download.py`, which owns it — and a CLI test had to monkeypatch a private
CLI helper to exercise the scan. It moves to `download.local_files()`, next to
`sync()`, whose `(path, kind)` shape it already mirrored.

`load` and `update` then shared five copy-pasted lines (scan, load, echo the
counts, raise on failures), differing only in whether an empty directory is an
error — so the message and the exit-code rule had two homes and one guard had
one. Both call `_load_local(con, force=..., require_files=...)`.

The test fixtures follow: `_build_db` was a verbatim copy of conftest's
`loaded_con`, pasted with its connect/build/close preamble at six call sites,
and `staged_download`'s `_stage` re-implemented `gz_fixture`. One
`gzip_fixture` helper and one `loaded_db` fixture replace both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`PUBMED2DB_DATA_DIR` and `PUBMED2DB_DB` were read by hand — one in a module
constant evaluated at import, one in the group callback — while `--threads`
and `--temp-dir` used click's `envvar=`. That made them the only two settings
invisible to `--help` and untracked by click's parameter source. All four now
declare `envvar=`/`show_envvar=True`, which also retires the hand-written
"Env: ..." suffixes in the help text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`requests.get` at module level opens and tears down a connection per call, and
a full sync fetches ~2,600 sidecars — so a scheduled `update` that finds
nothing new spent most of its runtime on TLS handshakes. One `requests.Session`
created in `sync()` and passed down gives them keep-alive.

`sync()`'s two near-identical per-kind blocks become one loop over a
(kind, wanted, url, cache, module) table while it is being touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`downloaded_at: bool` is named like the timestamp column it controls, so
`downloaded_at=changed` at the call site read as a type confusion. The
parameter decides whether to stamp `downloaded_at`; say that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_INSERT_SELECT` was a module-level dict of 11 SQL strings, precomputed so that
one of them could differ, and it doubled as the list of tables to batch — so
`load_parsed` iterated an SQL cache where it meant `_VERSIONED_TABLES`. The
statement is one f-string inside `_insert_batch`; the batch dict keys off the
table list directly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`export._VERSIONED_CHILDREN` was `load._VERSIONED_TABLES` minus two entries,
hand-copied — two lists in two modules to keep in sync, as `parse.py`'s
re-enable instructions already had to spell out. Derive it.

`_copy_parquet` hand-escaped single quotes to interpolate the path into a
`COPY ... TO '...'` literal; `con.sql(query).write_parquet(path)` takes the
path as a value and needs no escaping rule at all.

Also in this file: `export_json`'s `batch_size` parameter had no caller (now a
module constant), its two opener lambdas are `gzip.open`/`open` with the same
arguments, and the stale-shard sweep globs one pattern instead of two. The
Parquet export's ETA closure estimated remaining time across 15 statements
whose costs differ by orders of magnitude — a per-table log line says more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`duckdb.connect` + `init_schema` is exactly `db.connect`, which the script
already imports from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 26 out of 30 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/pubmed2db/parse.py:144

  • PubmedArticleSet can also contain PubmedBookArticle records, including Bookshelf records with a <BookDocument><Abstract>. This loop silently ignores those PMIDs (and does not increment n_failed), so a successful load/export is missing PubMed abstracts despite the PR's all-abstracts contract. Add an explicit book-record parsing path, or narrow and document the supported corpus while reporting skipped book records.
    for element in root.findall("PubmedArticle"):

src/pubmed2db/download.py:116

  • changed only reflects registry membership/checksum, not whether ensure() fetched bytes. If a registered file was removed locally while its published checksum stayed the same, ensure() downloads it again but --verify skips hashing; a pending file can then be loaded from an unverified transfer. Track local existence before ensure() and hash whenever bytes were newly downloaded or the checksum changed.
        if verify and published_md5 is not None and changed:

gaurav and others added 7 commits August 18, 2026 16:38
Copilot (suppressed): src/pubmed2db/parse.py — `PubmedArticleSet` is
`((PubmedArticle | PubmedBookArticle)+, DeleteCitation?)` per the DTD the files
declare, so Bookshelf citations can share a file with journal citations. We
iterate `PubmedArticle` only, and skipped every book record without a log line
or a counter — a silent hole in a tool that claims every PubMed abstract.

Parsing them is a real piece of work (`BookDocument` has no
`MedlineJournalInfo`, a different title element, and a different date shape),
and nothing yet says how many there are: both real files on hand contain zero.
So log the per-file count and let the next full load answer that, which is what
issue #27 needs before the support question can be decided.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot (suppressed): src/pubmed2db/download.py — `changed` tracks the registry
and the published checksum, not whether `ensure()` actually transferred bytes.
A known file that vanished locally (pruned to reclaim disk, or an interrupted
earlier transfer) is fetched again with its checksum unmoved, so `changed` was
false and `--verify` skipped hashing bytes that had never been hashed.

`ensure()` skips by file name, so checking whether the local path exists just
before it is enough to know: hash when the bytes are new or the checksum moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This query has already been rewritten once into a group-by that avoids the
view's window function, on the reasoning that the window would be expensive at
40.9M rows — and reverted, because measurement says the two are within noise
and the rewrite costs a second copy of the latest-version rule. The next reader
will find it just as tempting, so say so where they will be standing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A `validate` run against Entrez flagged two records as exporting blank where
Entrez has a value: PMID 10137601's `issue` (Entrez: "Suppl") and PMID
28972331's `article_title` (Entrez: "[Not Available]."). Both shapes look like
placeholders a parser might reasonably drop — a non-numeric issue whose volume
repeats it ("3 Suppl"), and a Norwegian article whose real title lives in
`<VernacularTitle>` while `<ArticleTitle>` holds a literal "[Not Available].".

Neither is dropped by this code. Fetched both records from Entrez and ran them
through parse → load → export: `issue` is "Suppl" and `article_title` is
"[Not Available]." in the article table and in the exported JSON. So the
mismatch is not a parse or export gap here; the remaining candidates are the
exported database predating something, or validate's comparison layer, neither
of which is in this PR.

Trimmed both records into a fixture and pinned the three fields, so a future
change that does start blanking them fails here rather than in a validation run
months later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`parse_file` skips three kinds of record — ones the extractor rejects, ones it
returns None for, and `<PubmedBookArticle>` citations — and until now all three
survived only in the logs. They never become rows, so no count downstream is
short and nothing else can answer "which files dropped records".

`source_file` gains `n_failed` and `n_book_records`, `status` reports the total
when it is non-zero, and the two counts stay separate because they mean
different things: a rejection is a defect to chase, a book record is a corpus
we don't support yet (#27).

Adding the columns now rather than after the rebuild is the cheap ordering —
`CREATE TABLE IF NOT EXISTS` leaves an existing database at its old shape, so
`schema.sql` also carries the `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
migration, with a test that a database created before these columns still opens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A re-export overwrites its own fixed set of file names, so the only file that
can survive one is a table's that left the schema — `reference_citation`,
dropped during development, is exactly that case. Left in place it reads as
part of the current export to anything globbing the directory. Same sweep the
JSON export already does for shards left by a wider run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both cost one full `load`, but only the rebuild is guaranteed to leave the
shape `schema.sql` describes: the schema only ever adds — `CREATE TABLE IF NOT
EXISTS` plus explicit `ADD COLUMN IF NOT EXISTS` migrations — so a table
removed from it keeps its rows through any number of forced reloads.
`reference_citation` is the standing example. `load --force` remains the right
tool for a pure parsing change.

Written into the README's "Re-running after a gap", the deferred item in
FUTURE.md closed, and the schema-only-adds rule (with the migration
requirement it implies) recorded in CLAUDE.md, where the next person to add a
column will be looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav and others added 3 commits August 18, 2026 18:55
The docstrings credited "cthoyt" by name, which does not help a reader find
the library. Link https://github.com/cthoyt/pubmed-downloader once at the top
of each module that uses it, and refer to the "pubmed_downloader library"
everywhere else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AGENTS.md had grown to 125 lines, most of it restating the module docstrings,
the README, or FUTURE.md. Move the one rule that had no home in the code — that
schema.sql only ever adds, so a new column needs its own ALTER TABLE line — into
the schema.sql header, and drop everything else that was already documented at
the source. What remains is the file map, the pubmed-downloader constraint, and
the decision not to store a citation graph.

Also repoint README's and FUTURE.md's stale "see CLAUDE.md" links, since
CLAUDE.md is now just an @AGENTS.md stub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gaurav
gaurav merged commit 74b8924 into main Aug 19, 2026
@gaurav
gaurav deleted the initial-implementation branch August 19, 2026 05:17
gaurav added a commit that referenced this pull request Aug 19, 2026
… result (#2)

## Summary

Adds `pubmed2db validate <dir>`, which inspects a finished JSON export
(a directory of NDJSON shards) and writes an archivable, gated
`validation_report.json`. It answers "does this export make sense?"
after an HPC run.

Five checks, split into an **offline phase** (fast, deterministic) and
an **online phase** (Entrez eutils cross-checks; skip with `--offline`):

1. **structure** — every line parses as JSON and matches the exporter's
exact 10-field record shape; flags malformed lines, missing/extra
fields, nulls, bad `id`s, invalid months, cross-shard duplicate PMIDs,
and shards that cannot be read to their end. Per-shard reservoir
sampling keeps memory bounded.
2. **coverage** — exported count vs. two denominators: the live Entrez
total (`einfo`) and the local `latest_article` count (a shortfall is an
**error** — rows were dropped); plus drift vs. `--previous-report`.
3. **field_validation** — a seeded sample re-fetched via batched
`efetch` and compared field-by-field (fuzzy abstract via `difflib`).
Journal name/abbrev are warning-only (different source); a sampled PMID
PubMed no longer serves is an error.
4. **deletions** — samples DB `deleted_pmid`s not reinstated by a later
version, confirms they're absent from the export and gone from PubMed.
5. **drops_since_previous** — diffs this export's PMID set against a
previous export's manifest (see below).

## PMID manifest sidecar

Coverage counts can't detect two same-sized exports whose PMID *sets*
differ, so `--manifest` writes a sorted gzipped `pmids.txt.gz` and
`--previous-manifest` diffs against an earlier one:

```bash
uv run pubmed2db validate data/json --manifest data/json/pmids.txt.gz
# next month
uv run pubmed2db validate data/json-new \
    --previous-manifest data/json/pmids.txt.gz --manifest data/json-new/pmids.txt.gz
```

A drop is explained only if the database marks the PMID deleted **and**
no later version reinstated it; an unexplained drop is an error —
records were lost rather than retired. Without a database the drops
can't be attributed, so they degrade to a warning. The manifest is
written from the PMID set `check_structure` already holds, so it costs a
sort and a write rather than another pass over the shards.

## Report & gating

The report leads with `errors`/`warnings` arrays that are **empty on a
clean run**; the stdout summary renders as a test report — every check's
name, what it expected, and what was observed, so it enumerates what was
*verified* rather than only what broke. Those arrays are projections of
the one check list, so they cannot drift from it. Exit is non-zero on
errors (`--fail-on-warn` extends to warnings) so it can gate a pipeline.
The DuckDB database, a previous report, and a previous manifest are all
**optional** inputs, used when present and noted in `skipped_checks`
when not.

## Coverage band is calibrated, not guessed

The default `--entrez-low`/`--entrez-high` band is ±5%, derived from a
real full-corpus run: the 2026-07-30 export held 40,901,984 documents
against a live Entrez total of 40,944,369 — a ratio of **0.9990**. The
band absorbs Entrez growth between export and validation (PubMed adds
roughly 4% a year) while still catching a materially short export.

Note for reviewers: a **partial** export (from a `--limit` test
download) is legitimately far below the band and will warn. Pass
`--entrez-low 0.001` or `--offline` when validating one. This is
documented in the README.

## Hardening after code review

A review of the branch found seven issues, all fixed here in four
commits:

- **A mid-run Entrez outage no longer discards the run.** `check_fields`
was the only online check without error handling, and since the report
and the manifest are written *after* every check, an NCBI blip threw
away the offline structure and coverage results too — the expensive part
(~8 minutes of shard reading at corpus scale). It now degrades to a
warning like its siblings.
- **A truncated shard is a finding, not a traceback.** `export`
publishes in place, so a killed run really does leave a half-written
`.ndjson.gz` on disk; gzip raises `EOFError` mid-iteration on one. The
check whose job is to catch a broken export was the one thing that
crashed on it.
- **Example lists are bounded.** They grew one entry per occurrence
while the report only ever shows 20, so a systematic defect across 41M
records would cost gigabytes to describe one bug.
- **The month check no longer depends on the locale.** It was built from
`calendar.month_abbr` (`strftime('%b')` under `LC_TIME`) rather than the
frozen tuple `export` deliberately keeps for exactly this reason.
- **A reinstated PMID is no longer an excused drop.**
`check_drops_since` counted any PMID in `deleted_pmid` as explained,
while `check_deletions` correctly filters those still in
`latest_article` — so a live record silently missing from the export was
waved through by the check that exists to catch it.
- **`--seed` now reproduces the deletion sample**, which drew from an
unordered DuckDB result; and **permanent 4xx responses are no longer
retried** three times with backoff.

Each fix carries a test that fails without it.

## Notes for review

- All network funnels through one `validate._eutils` seam (rate-limited,
retrying), which tests monkeypatch — the suite stays fully offline.
- "Expected" is always defined by the exporter, never restated:
`month_to_abbrev` and `_MONTH_ABBR` are imported from `export`, and
`EXPECTED_FIELDS` is derived by calling `export._document` on a
placeholder row, so the record shape can't drift.
`test_expected_fields_matches_spec` additionally locks the ten field
names, since they're an external contract with Node Annotator /
ElasticSearch.
- `validate` uses the group-level `_connect`, so
`--threads`/`--temp-dir` apply to it too — it reads `latest_article`
over a 40M-row database, which is exactly where the spill directory
matters.
- `validate.py` is a single 1,400-line module by choice; `AGENTS.md`
records why (the checks share the report accumulator and the Entrez
client, so splitting adds import edges without reducing what a reader
must hold) and the two signals that would change the answer.
- No new dependencies (`requests`, `lxml` already present).
- `main` has been merged in as it advanced. The one conflict was
`CLAUDE.md`, which `main` replaced with `AGENTS.md`; this branch's
validate notes were folded into `AGENTS.md`, keeping only the gotcha the
code doesn't already carry — that an efetch mismatch is evidence about
efetch's *rendering*, not about our parser.

## Testing

- `uv run pytest` — **118 passing**, 34 of them `validate`'s, no
network.
- Verified end-to-end beyond the unit tests: a clean export reports
PASS/exit 0; an export seeded with a malformed line, a missing field, a
duplicate PMID and a bad month reports FAIL/exit 1 with those four
itemized; the manifest round-trips sorted and gzipped; a 3-PMID drop
against a previous manifest is reported (warning without a DB, error for
unexplained drops with one); and `--threads 2` reaches `validate`'s
connection.
- **Corpus scale, 2026-08-05** (`srun --mem=256G`, 16 shards / 52 GiB /
40,923,261 records, 7m57s, peak RSS 5.2 GiB): `WARN`, 0 errors, 1
warning. Structure, coverage (99.886% of Entrez; exact match against the
database) and deletions all passed. Field accuracy came back at **1 of
1,843 comparisons** (0.05%), down from 20 before PR #1's `MedlineDate`
year recovery, and that one is `exported_blank`, not a wrong value: PMID
10137601's `issue` is `""` against Entrez's `"Suppl"` in an export
predating the fix PR #1 pinned. `0 exported a different value` is the
line that says the export is safe to ship.

## Follow-on work

- #30 — deletion confirmation treats "efetch returns nothing" as
deleted, so a *merged* record is reported as "still live → review
manually"; `esummary` distinguishes them.
- #31 — journal name/abbrev are advisory-only because they come from the
NLM Catalog rather than the article XML, which means a broken journal
join can only ever produce advisory noise. Needs a corpus-scale mismatch
rate to decide.

## TODO

- [x] **Merge PR #1 into this branch once it lands.** Done — and the
merge exposed two real breakages, both fixed here: a hardcoded
placeholder arity in `EXPECTED_FIELDS` that crashed `validate` at import
once the export query gained a column, and `validate` comparing efetch's
raw `<Year>` against an export that now recovers one from `MedlineDate`.
- [x] **Re-export and re-run after the `pub_year` backfill.** Done in
the 2026-08-05 corpus run above: 20 field mismatches down to 1, with the
residual being the `issue` case PR #1 investigated and pinned rather
than a parser gap.
- [ ] **Re-validate after the next full rebuild** — deliberately *not* a
gate on this PR. The 2026-08-05 run predates the review fixes above, so
nothing has exercised them against the real corpus; in particular the
bounded example lists and the reinstated-PMID filter only differ from
the old behaviour at scale. The plan is to merge this stack, re-run
`load` and `export` on it, and read the validation report that comes out
— which also settles #28.
gaurav added a commit that referenced this pull request Aug 20, 2026
Adds an `identifiers` array of CURIEs to every JSON record — the PMID,
plus the DOI and PMCID when PubMed published them. Closes #4.

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

## What changed

**`identifiers` is derived at export, never stored.** The DOIs and
PMCIDs were already being loaded into the `article_id` table, so this is
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. It also makes the
prefix question below cheap to revisit: changing a CURIE prefix costs a
re-export, not a reload.

**Only DOIs and PMCIDs are promoted.** Other `ArticleId` types (`pii`,
`mid`, …) stay in the `article_id` table and the Parquet export. A
record with neither still gets `["PMID:<id>"]`; the array is never empty
and never null.

**One place writes the CURIE format down.** `export.ID_PREFIXES` maps
PubMed's `IdType` to the CURIE prefix, and both the export SQL
(`CASE`/`IN`) and `validate`'s expected CURIEs are generated from it. An
earlier revision hardcoded the prefixes a second time in the SQL, which
meant a casing change would have reached the validator alone and
reported every sampled record as a mismatch against a correct export;
`test_curie_sql_is_derived_from_id_prefixes` now fails if that returns.

**Values keep PubMed's own case, so consumers must match
case-insensitively** — DOIs are case-insensitive per spec and PubMed is
not internally consistent about its own. `validate` folds case before
comparing for the same reason.

### The PMCID prefix is a bet, not a match

We emit `PMCID:PMC1904490`. This is **not** what Babel currently uses
(`src/prefixes.py` says `PMC`), and it is not settled anywhere:

-
[NCATSTranslator/Babel#1044](NCATSTranslator/Babel#1044)
is open on exactly this point, so Babel's own value isn't final.
- Neither the [Core Components
specification](NCATSTranslator/Core-Components-Working-Group#15)
nor the [DocumentMetadataAPI
README](https://github.com/NCATSTranslator/DocumentMetadataAPI/blob/cff4dd70e10ccaaabd8ab3d505159969475fc37e/README.md)
carries a PMC example to arbitrate.
- The production endpoint does not resolve PMCIDs under either prefix —
[`?pubids=PMC:PMC7890668`](https://docmetadata.transltr.io/publications?pubids=PMC:PMC7890668&request_id=26394fad-bfd9-4e32-bb90-ef9d5044f593)
returns nothing.

`PMCID` is our guess at where that lands. Tracked in #33, to be settled
alongside the Babel issue. The doubled `PMCID:PMC` is expected —
PubMed's `pmc` values already begin with `PMC`. `doi` stays lowercase
and `PMID` uppercase, both matching Babel, so those CURIEs join against
the publication compendium today.

## `validate` changes

**Identifiers are cross-checked against Entrez, as an advisory.**
`efetch_documents` rebuilds the CURIEs from the same `ArticleIdList`
using `ID_PREFIXES`, and `check_fields` compares them as a case-folded
set — it is the one list-valued field, so the string path can't handle
it.

It is deliberately *not* part of the gated core-field mismatch rate. The
comparison is our newest *loaded* version against live PubMed, so a
PMCID assigned upstream since our last update file reads as a mismatch —
and that is not independent of the other core fields: an ahead-of-print
record already mismatching on `volume`/`issue` is exactly the one that
has since been assigned a PMCID. Counting it would have tightened the
FAIL threshold on the records most likely to trip it (the sampled-stale
fraction needed to fail dropped from ~47% to ~40%). It reports as
`identifiers-soft`, alongside the journal fields.

**The STRUCTURE heading now carries identifier coverage**
(`identifier_coverage` in the report): the share of records that came
out with each identifier type. Nothing gates on it — one export in
isolation cannot say whether 96% is right and 6% is not — but a DOI rate
that collapsed between two runs is otherwise invisible to every check
here. Comparing against `--previous-report` would be the real check, and
is a bigger change than this.

Two unrelated report fixes rode along: `_eutils` now reports the
attempts it actually made rather than the retry ceiling, and duplicate
PMIDs are recorded once each, so one PMID exported 25 times no longer
fills all 20 example slots.

## Not the upstream parser fix

An earlier revision of this branch fixed
`pubmed_downloader._extract_article` attributing every cited reference's
DOI to the citing article. That fix landed on `main` in #1 (`3ac31c1`,
2026-08-04) and the merge superseded this branch's copy — what remains
here is naming the path as a constant and recording the evidence (one
record, PMID:41136637, contributed 426 foreign DOIs). Reporting it
upstream is #34, which carries everything needed to write the report.

The practical consequence is a **narrower** migration note than an
earlier revision of this description claimed: only databases loaded
**before 2026-08-04** carry the polluted `article_id` rows and need
rebuilding. Anything loaded since is unaffected — this feature changes
what reads that table, not what writes it.

## Verification

125 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 landed on `main`). `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

- [x] ~~Add an identifier-coverage stat to `validate`~~ — done here.
- [x] ~~`reference_citation` is always empty~~ — moot: the table was
dropped from the schema, and not storing the citation graph is a
recorded decision (`AGENTS.md`), not a gap. The upstream bug behind it
is part of #34.
- Settle the PMCID prefix with Babel — #33.
- Report the `xrefs` and `cites_pubmed_ids` bugs upstream, or decide to
keep the workarounds local — #34.
- Read `ELocationID` DOIs as a fallback — #35.
- Re-load the production database and re-export — covered by #32, which
already tracks the full rebuild at the end of this PR stack.
- [ ] **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. Tracked in
`FUTURE.md` under "Confirm the Node Annotator JSON contract".

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.

2 participants