Initial implementation: download, store, and export PubMed abstracts - #1
Initial implementation: download, store, and export PubMed abstracts#1gaurav wants to merge 57 commits into
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.
| def file_md5(path: Path) -> str: | ||
| """Compute the MD5 hex digest of a local file.""" | ||
| with path.open("rb") as fh: | ||
| return hashlib.file_digest(fh, "md5").hexdigest() | ||
|
|
| file_name = url.rsplit("/", 1)[-1] | ||
| try: | ||
| response = requests.get(url + ".md5", timeout=60) | ||
| response.raise_for_status() | ||
| published_md5 = parse_md5_text(response.text) | ||
| _save_md5_sidecar(file_name, response.text) | ||
| except requests.RequestException as exc: | ||
| logger.warning("could not fetch md5 for %s: %s", file_name, exc) | ||
| published_md5 = None | ||
|
|
||
| prior = registry.get(file_name) | ||
| changed = prior is None or prior != published_md5 | ||
|
|
||
| path = Path(ensure_module.ensure(url=url)) | ||
|
|
| import resource | ||
| import sys | ||
|
|
||
|
|
||
| def peak_rss_gib() -> float: | ||
| """Peak resident set size of this process so far, in GiB. | ||
|
|
||
| ``ru_maxrss`` is a high-water mark in bytes on macOS and KiB on Linux; we log | ||
| it after long-running steps so a Slurm run reveals how much ``--mem`` it | ||
| really needs. | ||
| """ | ||
| rss = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss | ||
| return rss / 1024**3 if sys.platform == "darwin" else rss / 1024**2 |
load_files' progress log and the benchmark script both hand-rolled logic already available; consolidate into shared helpers instead of copies.
Answers the recurring question of whether re-running `load` can duplicate data (it can't: MD5-gated downloaded_at, per-source_file delete before insert, and (pmid, source_file) identity resolved by latest_article), and flags the case that actually costs something — a new baseline year is a full re-parse that stores a second version of every PMID. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Comments suppressed due to low confidence (4)
src/pubmed2db/download.py:57
file_md5()useshashlib.file_digest(), which is only available in Python 3.11+. Sincepyproject.tomldeclaresrequires-python = ">=3.10", this will raiseAttributeErroron Python 3.10 and breakdownload --verify(andtest_file_md5). Implement MD5 hashing in a way that works on 3.10+ (e.g., chunkedhashlib.md5()updates), or bump the project’s minimum Python to 3.11.
def file_md5(path: Path) -> str:
"""Compute the MD5 hex digest of a local file."""
with path.open("rb") as fh:
return hashlib.file_digest(fh, "md5").hexdigest()
src/pubmed2db/download.py:73
- Type annotation for
registryis incorrect:sync()builds it as{file_name: published_md5}(astr|None), but_sync_kind()declaresregistry: dict[str, dict]. This makesprior's type misleading and can hide real type errors.
ensure_module,
registry: dict[str, dict],
limit: int | None,
verify: bool,
pyproject.toml:17
pydanticis declared as a direct dependency but does not appear to be used anywhere insrc/ortests/. Keeping it increases install size and dependency surface area unnecessarily; consider removing it unless it’s needed for upcoming work.
"pyarrow>=14",
"click>=8.1",
"pydantic>=2",
"requests>=2.31",
"tqdm>=4.66",
"lxml>=5",
pyproject.toml:10
- The code imports and uses
pubmed_downloaderprivate APIs (pubmed_downloader.api._extract_articleinsrc/pubmed2db/parse.pyandpubmed_downloader.api._ensure_urlsinsrc/pubmed2db/download.py). With an unconstrainedpubmed-downloader>=0.0.14requirement, a future upstream release can break pubmed2db without any changes here. Consider pinningpubmed-downloaderto the exact tested version (or at least adding an upper bound) until these private API usages are replaced with stable/public ones.
dependencies = [
"pubmed-downloader>=0.0.14",
"duckdb>=1.1",
export is a whole-corpus job (latest_article snapshot + abstract string_agg + sort by PMID), so its memory scales with the database, not the largest input file: 199.6 GiB peak vs the loader's ~0.8. Records the sizing, why --shards doesn't help, and that spilling to temp_directory isn't reachable without a code change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l dir DuckDB sizes its thread pool from the machine's core count, so on a cluster it oversubscribes an allocation smaller than the node; there was also no way to move its spill directory off the database volume. Both are now group-level CLI options (with PUBMED2DB_THREADS / PUBMED2DB_DUCKDB_TEMP_DIR env vars) passed to duckdb.connect(config=...). Also documents that --shards is not a parallelism knob: all shards are written by one loop, so CPUs help DuckDB's query, not the writer. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A full JSON export on ht1 finished in ~23 minutes (~30k documents/s), so the "a few hours" estimate was wrong and --time=08:00:00 was eight times the need; memory, not time, is what makes this job need a big node. Also drops the seff instructions — it isn't installed on ht1 — in favor of sacct, /usr/bin/time -v, and the progress logging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
23m13s (01:25:01 to 01:48:14) at --mem=256G --cpus-per-task 8, and notably without --threads: DuckDB sized its own pool and the run was still fast, so --threads is insurance against a busy node rather than something the export needs. Tones the guidance down to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The TL;DR still asked for 8 hours and --threads 8, which the "Running export" section had already walked back to 2 hours and threads-optional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Only the JSON export has been run at scale; the expectation that Parquet is lighter is reasoning about COPY ... TO, not a measurement, and the docs shouldn't read as though both were verified. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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/download.py:73
- Type hint for
registryis incorrect:sync()builds it asdict[str, str | None](file_name -> published_md5), but_sync_kind()annotates it asdict[str, dict]. This makes type checking misleading and can hide real issues around the MD5 comparison logic.
list_cache: Path,
ensure_module,
registry: dict[str, dict],
limit: int | None,
verify: bool,
src/pubmed2db/download.py:57
file_md5()useshashlib.file_digest, which is not available in Python 3.10. Sincepyproject.tomldeclaresrequires-python = ">=3.10", this will raiseAttributeErroron the minimum supported version and breakdownload --verify(and its tests). Use a 3.10-compatible MD5 implementation (e.g., incrementalhashlib.md5().update()loop), or bump the minimum Python version.
def file_md5(path: Path) -> str:
"""Compute the MD5 hex digest of a local file."""
with path.open("rb") as fh:
return hashlib.file_digest(fh, "md5").hexdigest()
download.file_md5 uses hashlib.file_digest, added in 3.11, so the declared >=3.10 floor promised an interpreter where `download --verify` (on by default) raises AttributeError. Also bound pubmed-downloader below 0.1: we call its private APIs (_extract_article, _ensure_urls), so any 0.0.x release can break the pipeline without a major-version signal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two upstream bugs in pubmed-downloader (<=0.0.14) silently corrupted what we
store, both caused by looking for <ReferenceList> in the wrong place:
- _extract_article searches medline_citation for .//ReferenceList/Reference,
but PubMed nests <ReferenceList> under <PubmedData>. Article.cites_pubmed_ids
is therefore always empty on real data, and reference_citation would never
have received a row.
- Article IDs are collected as pubmed_data.findall(".//ArticleIdList/ArticleId"),
and that .// descends into that same <ReferenceList> -- so every cited
reference's DOI/PMID was attributed to the citing article. article_id was
gaining wrong rows in proportion to reference count, and exporting them.
parse.py already exists to capture what upstream drops, so both go there:
_cited_pmids searches the whole PubmedArticle element (matching either
placement) and _article_ids uses the direct PubmedData/ArticleIdList/ArticleId
path, dropping the redundant `pubmed` self-ID.
The fixture gains a ReferenceList in PubMed's real position, and both new
tests assert the *upstream* behaviour as well as ours, so they fail once
cthoyt fixes this and the workarounds can be deleted.
reference_citation.cited_pmid becomes BIGINT to match article.pmid, so
citations join back without a cast.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full baseline is ~1,300 files. parse_file contains per-article ValueError and KeyError, but anything else -- a truncated .gz, an XMLSyntaxError -- came out of load_files and killed the whole job, which on Slurm means losing hours and restarting by hand. Each file already loads in its own transaction, so a failure leaves no partial rows and no processed_at watermark; a later run retries it. Log it, count it, and keep going. load_files now returns (n_loaded, failed_file_names) so the CLI can name the failures and exit non-zero rather than folding them into a normal-looking "Loaded N of M" line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four of the twelve versioned tables never received a row in any test: reference_citation, grant_, mesh_qualifier, and the Collective branch of _article_rows. The fixture had no <ReferenceList>, <GrantList>, <QualifierName>, or <CollectiveName> -- which is how the two upstream reference bugs went unnoticed. Adds a grant, a MeSH qualifier, an ORCID, and a collective author to the fixture, plus a test that a corrupt file is skipped without leaving rows or a watermark behind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
download: --limit sliced urls[:limit], the *oldest* N, while the CLI help,
README and CLAUDE.md all promised the newest N -- so testing update-file
handling silently got baseline files instead. Takes the tail now.
download: --verify re-hashed every local .xml.gz on every sync, tens of GiB of
I/O to re-confirm immutable files. Hash only what is new or whose published
checksum moved; corruption at download time stays covered, so verification can
stay on by default.
export: the JSON export's abstract CTE grouped the entire abstract_text table,
including superseded versions, then discarded them in the join -- the join sits
on the outer side's right, so the optimizer cannot push the restriction in.
Restrict to _latest_snapshot before aggregating, as export_parquet already did.
export: month_to_abbrev read calendar.month_abbr, which is strftime('%b') under
LC_TIME. Nothing calls setlocale today, but any dependency that did would
localize a spec-defined output field. Frozen tuple instead.
export: warn when a format-specific flag is passed to the other format rather
than silently ignoring it, which is expensive to discover after a 20-minute run.
status: the baseline/update breakdown counted known files while being printed
as a breakdown of downloaded files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing has been filed against cthoyt/pubmed-downloader for either bug, and nothing should be yet: both were found while writing a fixture, so the evidence is a synthetic XML file and the DTD rather than real data. Records what needs answering first -- confirming <ReferenceList> placement across real baseline years, quantifying the article_id contamination, auditing upstream's other .// selectors for the same over-reach, and establishing the affected version range -- plus the separate question of what to do about databases already carrying the bad rows, which no incremental run corrects. Also points at issue #8 for the JSON export's global sort. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The add-doi-and-pmcids branch found the article_id bug independently and measured it against real records, which answers two of the open investigation items: PMID:41136637 contributed 426 cited references' DOIs alongside its own, and reference_citation came back with 0 rows from 14,201 real articles whose records carry hundreds of references. Marks those two done and narrows the rest: the XPath audit is partly done (it is what turned up cites_pubmed_ids), leaving History and the abstract/MeSH/author selectors unchecked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The citation graph is not wanted. One real article carries ~444 references, so at corpus scale this would have been the largest table in the database, loaded and Parquet-exported on every run, for data no consumer reads. Removes the table from schema.sql, load._VERSIONED_TABLES and export._VERSIONED_CHILDREN, and stops building its rows. parse._cited_pmids stays, uncalled, with re-enabling instructions in its docstring. A CLI or config flag was the other option and would have cost more than it guarded: threading a parameter through load_files -> load_file -> load_parsed -> _article_rows, conditional DDL, and a second tested code path. Parking the function costs nothing at runtime -- parse_file no longer calls it, so we don't pay an XPath search per article for a discarded result -- and keeps a working counter-example to the upstream cites_pubmed_ids bug that FUTURE.md still tracks for reporting. test_no_reference_citation_table keeps the table from creeping back; test_cited_pmids_is_parked_but_works exercises the parked function directly. Note for existing databases: schema.sql only ever adds tables, so a database built before this keeps a populated reference_citation. Rebuilding clears it; `DROP TABLE reference_citation` does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full-corpus validate run sampled 240 exported records and found 20 core-field
disagreements with Entrez. 18 were pub_year: the export shipped blank where
Entrez had a year. Spot-checking those PMIDs (e.g. 152567) shows PubDate values
that carry no <Year> element at all -- a season or a range that PubMed puts
wholly inside <MedlineDate> ("1978 Jul-Aug", "1998 Spring", "1998 Dec-1999
Jan"). We store that verbatim for fidelity, and the export never looked at it.
The leading 4-digit year is unambiguous in every one of those shapes, so
_document now falls back to it. The database is untouched -- medline_date keeps
its raw value and DocumentMetadataAPI semantics stay confined to the export --
so this needs a re-export but no reload.
pub_month and pub_day stay empty on purpose. A range has no single month, and
inventing "Jul" for "1978 Jul-Aug" would replace an honest blank with a wrong
value in a field consumers are entitled to trust.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The backfill was reasoned from efetch output and the DTD, which is not the same as checking what we actually parse. Downloaded pubmed26n0005.xml.gz and looked: PMID 152567 really is <MedlineDate>1978 Jul-Aug</MedlineDate> with no <Year> element, so parse yields pub_year=None and the export shipped a blank. Loading and exporting that file end-to-end confirms the fix: PMID 152567 now exports pub_year "1978", matching what Entrez reports for it. Scale is larger than the sampled validation suggested. 3,625 of the file's 30,000 records (12%) carry a MedlineDate with no Year, across 127 distinct date strings. Every one yields a year, and in no case does the leading year disagree with another 4-digit run in the string -- so the regex is not just adequate on the fixtures, it is adequate on real data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings PR #1 in now that there is no further value in validating the old export separately. Two things the merge broke, both real rather than cosmetic: EXPECTED_FIELDS called export._document with a hardcoded 9-value placeholder row. PR #1's pub_year backfill selects medline_date, making the row 11 wide, so validate failed at import. The arity is now discovered by widening the placeholder until _document accepts it -- the field *names* were already derived from the exporter to stop them drifting, but the arity was not, and it is the part that changes whenever the export query gains a column. The export now recovers a year from a free-text MedlineDate, while validate read efetch's <Year> raw. efetch usually renders those records as <Year>+<Season>, but not always, and when it returns the archival form every such record would have read as a pub_year mismatch against an export that recovered it. validate now applies the exporter's own recovery to the efetch side, the same way it already imports month_to_abbrev -- normalization must be applied to both sides or the comparison is not like-for-like. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Now that PR #2 has merged PR #1, this branch picks both up and its diff collapses back to the export-side work it is actually about. Conflict resolutions, all additive rather than either/or: - export.py: _LATEST_METADATA_SQL selects both `la.medline_date` (for the pub_year backfill) and `ids.identifiers`, and _document unpacks all twelve. Verified the two coexist: PMID 1003 exports pub_year "1998" recovered from "1998 Spring" with identifiers ["PMID:1003"], while PMID 1001 keeps its DOI and PMCID alongside a real pub_year. - validate.py: keeps ID_PREFIXES and adds _year_from_medline_date, so both of the exporter's normalizations are applied to the efetch side. This branch had independently bumped EXPECTED_FIELDS' placeholder row to a hardcoded 10; the derived version supersedes it, which is the point -- `identifiers` and `medline_date` each widened that row once already. - FUTURE.md/CLAUDE.md: both sides' notes kept. The ELocationID follow-up and the identifiers design note are unique to this branch; the completed MedlineDate entry and the efetch-is-a-rendering warning come from the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Initial implementation of pubmed2db: a uv/click tool that downloads all PubMed abstracts, loads them into a full-version-history DuckDB database, and exports the latest version of every abstract to JSON (for Node Annotator / ElasticSearch) and Parquet (for downloadable queries).
Pipeline
download → load → export:cthoyt/pubmed-downloaderfor bulk baseline/update fetching; adds.md5sidecar tracking so new/changed checksums drive incremental reloads._extract_articlefor the rich record while additionally capturing rawPubDatecomponents (full date fidelity) and<DeleteCitation>PMIDs.source_fileprovenance;latest_articleview selects the newest non-deleted version per PMID. Journal names come from the NLM Catalog.pub_monthas a 3-letter abbreviation) and per-table Parquet (latest or full history).The database uses PubMed's own field names; DocumentMetadataAPI names are applied only at JSON export.
Step ordering is enforced from DB state rather than a run flag:
loaderrors if nothing is downloaded,exporterrors if nothing is loaded and warns about unloaded files or an empty journal table, and the read-onlystatuscommand reports the same signals.Design decisions
pubmed-downloaderas-is (no upstream changes) for download + parse; DuckDB is our store (we don't use its JSONL cache).INSERT ... SELECT, ~25–90× faster than the original row-by-rowexecutemany(~20 min/file → ~5–6 s/file).Operations
uv run pubmed2db …; nothing is installed.--threads/--temp-dir(env:PUBMED2DB_THREADS,PUBMED2DB_DUCKDB_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.slurm/README.mddocuments sizing for both jobs:loadneeds ~16 GB (memory is bounded by the largest single file),exportneeds ~256 GB (whole-corpus snapshot + sort).download/loadafter a gap can't duplicate data, and the one case that costs real time: a new baseline year is a full re-parse that stores a second version of every PMID.Known issue worked around
pubmed_downloader.catalog.process_journal_overview()(≤ 0.0.14) raises on the realJ_Entrez.txt(itsJournalmodel requiresstart_year/end_yearthe file omits), so we parse the overview file ourselves. Tracked inFUTURE.mdto revert once fixed upstream via PR cthoyt/pubmed-downloader#16Verification
--mem=256G --cpus-per-task 8.TODO (undecided: fix here or file)
pub_yearbackfill at corpus scale after the rebuild. Thefix is verified end-to-end on one real baseline file (
pubmed26n0005.xml.gz:3,625 of 30,000 records, 127 distinct
MedlineDateshapes, all recovering ayear). The corpus-wide check is that
validate'score-fieldsdrops from 20sampled mismatches to ~2 on the next export.
explain:
issueexported blank vs Entrez"Suppl"(PMID 10137601), andarticle_titleblank vs"[Not Available]."(PMID 28972331). Both are"exported blank where Entrez has a value", so neither is corrupt data — but
they may be a third parse gap. Read the archival XML before assuming a cause;
efetch alone points at the wrong layer (see
CLAUDE.md).load --forcevs a fresh rebuild, and write it into the README's"Re-running after a gap".
schema.sqlonly ever adds tables, so a forcedreload refreshes
article_id's rows but leaves the droppedreference_citationtable sitting there. Rebuilding is probably the simpleranswer; it is currently only noted in
FUTURE.md.download --limit(newest-N) and the--verifygating. Twoone-line behaviour changes with no coverage, because
_sync_kindwould need_ensure_urls,requests.getandensure_module.ensureall mocked.ORDER BY la.pmidinfavour of
pmid % shards. Measure first, since restricting the abstractaggregation to the latest snapshot already cut into the same peak RSS.
🤖 Generated with Claude Code