Download PubMed abstracts into a version-history DuckDB and export them as JSON and Parquet - #1
Merged
Conversation
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>
There was a problem hiding this comment.
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_articleview 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.
load_files' progress log and the benchmark script both hand-rolled logic already available; consolidate into shared helpers instead of copies.
`_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>
This was referenced Aug 18, 2026
There was a problem hiding this comment.
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
PubmedArticleSetcan also containPubmedBookArticlerecords, including Bookshelf records with a<BookDocument><Abstract>. This loop silently ignores those PMIDs (and does not incrementn_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
changedonly reflects registry membership/checksum, not whetherensure()fetched bytes. If a registered file was removed locally while its published checksum stayed the same,ensure()downloads it again but--verifyskips hashing; a pending file can then be loaded from an unverified transfer. Track local existence beforeensure()and hash whenever bytes were newly downloaded or the checksum changed.
if verify and published_md5 is not None and changed:
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>
This was referenced Aug 18, 2026
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
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.
3 tasks
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds pubmed2db: a
uv/clicktool 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 viacthoyt/pubmed-downloader, with each file's published.md5checksum recorded in asource_fileregistry. 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-verifyopts out), and discards a file that fails twice rather than leaving it forloadto pick up.journals— refreshes the journal dimension from the NLM Catalog (J_Entrez), joined onnlm_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 theirsource_fileprovenance and afile_order_keythat reproduces PubMed's chronological ordering, so thelatest_articleview can pick the newest non-deleted version of each PMID (honouring<DeleteCitation>).export— sharded NDJSON (DocumentMetadataAPI names, empty strings rather than nulls,pub_monthas a 3-letter abbreviation) or one Parquet file per table (latest version by default,--allfor 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.statusreports the state of all of it read-only;updatechains 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
latest_articlederives the current view. This makes a re-load idempotent and a corrected file cheap to apply.MedlineDate-only dates into adatetime.date. RawYear/Month/Day/MedlineDateare stored, and the JSON export recovers a year fromMedlineDateshapes where the parsed fields are empty.Nonefor (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_failedandn_book_recordsmake them queryable andstatusreports the total.reference_citationwould have been the largest table here, for data no consumer asked for. The extraction is parked (uncalled) with re-enabling instructions rather than deleted.loaderrors when nothing is downloaded,exporterrors 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.INSERT ... SELECTrather than row-by-rowexecutemany, 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.schema.sqlonly ever adds, and runs on every connect:CREATE TABLE IF NOT EXISTSplus explicitALTER TABLE ... ADD COLUMN IF NOT EXISTSmigrations for anything added after the first release. Nothing drops a table or column, which is why a schema change wants a rebuild rather thanload --force(below).Upstream bugs worked around
Three
pubmed-downloader(≤ 0.0.14) bugs, each tracked inFUTURE.mdwith a pinning test so the workaround fails loudly once upstream fixes it. The dependency is pinned<0.1because we also call private APIs (_extract_article,_ensure_urls).catalog.process_journal_overview()'sJournalmodel requiresstart_year/end_year, which the realJ_Entrez.txtdoes 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_extract_articlelooks for.//ReferenceList/ReferenceunderMedlineCitation, but PubMed nests<ReferenceList>under<PubmedData>, soArticle.cites_pubmed_idsis always empty on real data. Harmless here — we store no citation graph — but a working counter-example is kept for whenever it is reported..//descent attributes every cited reference's DOI/PMID to the citing article, which would have been silently wrong rows inarticle_idproportional to reference count. We use the directPubmedData/ArticleIdList/ArticleIdpath instead.Operations
uv run pubmed2db …; nothing is installed.--threads/--temp-dircap 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--helpdocuments.slurm/README.mdsizes both jobs:load~16 GB (memory is bounded by the largest single file) andexport~256 GB (whole-corpus snapshot + sort). The export figure comes from the full-scale run below; theloadfigure 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).loadand neither re-downloads, but sinceschema.sqlonly ever adds, a table or column removed from it keeps its rows through any number ofload --forceruns, 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.exportpublishes 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).README.mdfor running the pipeline,AGENTS.mdfor a map of the source and the decisions that aren't visible from it,FUTURE.mdfor deferred work. Anything a docstring or a schema comment can carry lives there instead.Verification
--mem=256G --cpus-per-task 8.issue("Suppl") and PMID 28972331'sarticle_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:
ORDER BY(measured: the sort is ~0.8 GiB and 75% of wall time on a 2M-row sample). Being done in Let DuckDB write the JSON export, and stop sorting it (~3x faster) #12.loadran ~15x slower thanslurm/README.mddocuments, and above its stated memory ceiling; the load figures need re-measuring against a large database rather than a short benchmark.needs_loadnever looks at the filesystem, so a file replaced on disk is only picked up byload --force.statusreports the condition and the README gives therm, but 51 GB of baseline plus 13 GB of updates per year wants a command.parse_fileholds the whole DOM (~1.2 GiB of the ~2.2 GiB per-file peak);iterparsemeasures at 0.02 GiB.<PubmedBookArticle>records; the per-file count this PR records answers whether it matters.pub_yearbackfill at corpus scale (verified on one baseline file: 3,625 of 30,000 records, 127 distinctMedlineDateshapes, all recovering a year).