Let DuckDB write the JSON export, and stop sorting it (~3x faster) - #12
Merged
Merged
Conversation
The export spent ~80% of its wall time serializing documents in a single Python loop while seven cores idled, behind a full ORDER BY la.pmid that had to materialize all 40.9M rows before the first one could be written — ~3 minutes of an 18-minute run, and the job's peak-memory event. Both are gone. The export is now one COPY ... (FORMAT JSON, PER_THREAD_OUTPUT), so the serialization runs in C++ across every thread, and the query has no ORDER BY (issue #8): shard membership no longer depends on scan order, since each writer thread owns a file, and nothing downstream consumes PMID order — the ingest is an ElasticSearch bulk load and validate sorts its own manifest. On a 2M-document copy of the database, same machine, same query: 112.9s -> 35.5s (17.7k -> 56.2k docs/s), with byte-identical record sets (EXCEPT in both directions over all 2M rows returns nothing). _JSON_FIELDS — output name to SQL expression, in emitted order — becomes the single definition of a document, so the spec's field names, the empty-string-not-null rule and the field order are written down once; validate imports JSON_FIELDS instead of probing _document's arity. month_to_abbrev and _year_from_medline_date stay as Python because validate still normalizes efetch's side with them, and their SQL twins are pinned to them by tests over the cases an index-based lookup gets wrong ("0", out of range, "Sept", whitespace) — a divergence there would make every normalized record read as a PubMed mismatch. Two consequences worth knowing: - --shards N is now a maximum rather than a count. One file per writer thread is what PER_THREAD_OUTPUT gives, so it caps that statement's thread count (restored afterwards) and a small dataset can use fewer. - DuckDB appends to the output directory rather than clearing it, so the export deletes its own pubmed_metadata_* files first — otherwise a shorter run leaves a previous run's shards to be read as current. Per-batch progress lines go with the Python loop; a heartbeat logs output size and current RSS once a minute in their place, which is what sizes the next --mem. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the 2026-08-05 full-corpus run (40,923,261 documents, 18m 06s, peak RSS 201.0 GiB) and marks all three measured runs as predating the rewrite: they are the numbers to beat, not the numbers to request. The two figures to read off the next cluster run are called out, since neither follows from a laptop benchmark — peak RSS especially, which is what decides whether --mem=256G can come down now that the sort is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Python writer said `ensure_ascii=False` out loud; DuckDB's JSON writer does the same by default, which means nothing in the repo would notice if that ever changed. One test now asserts the bytes on disk, since a consumer reading an escaped code point where it expects the accented character is exactly the kind of break a passing suite would hide. Also notes in CLAUDE.md that PARTITION_BY (pmid % shards) — the obvious way to get an exact shard count back — is rejected by DuckDB <= 1.5.4 for FORMAT JSON, so the next person doesn't spend the same twenty minutes finding out. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav
force-pushed
the
faster-json-export
branch
from
August 6, 2026 06:39
3433061 to
15045dc
Compare
NDJSON compresses ~4-5x, so a full corpus goes from ~52 GiB to roughly 12 — less to write, less for the next validate to read back, and less to keep around. Compression happens as each shard is written, so it costs CPU (which the COPY rewrite freed up) rather than a second pass. This is only a safe default because nothing downstream has to be told: the report-reading side already matched .ndjson and .ndjson.gz alike, and validate's byte-progress denominator is the compressed size either way since it reads through a raw handle. test_cli_export_then_validate_needs_no_flags runs both commands with no flags to keep that true; the validate suite's fixture is now a gzipped export for the same reason, which also exercises appending a second gzip member to a shard. --no-gzip keeps the old behaviour, and is what the CLI test now covers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav
added a commit
that referenced
this pull request
Aug 19, 2026
…em as JSON and Parquet (#1) 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`](https://github.com/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 #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 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`](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 (#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 (#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: - #8 — drop the JSON export's global `ORDER BY` (measured: the sort is ~0.8 GiB and 75% of wall time on a 2M-row sample). Being done in #12. - #11 — a corpus-scale `load` ran ~15x slower than `slurm/README.md` documents, and above its stated memory ceiling; the load figures need re-measuring against a large database rather than a short benchmark. - #21 — `needs_load` never looks at the filesystem, so a file replaced on disk is only picked up by `load --force`. - #22 — nothing prunes a superseded baseline year; `status` reports the condition and the README gives the `rm`, but 51 GB of baseline plus 13 GB of updates per year wants a command. - #23 — a smoke test for documented paths, after three drifted doc claims. - #24 — neither export publishes atomically. - #25 — `parse_file` holds the whole DOM (~1.2 GiB of the ~2.2 GiB per-file peak); `iterparse` measures at 0.02 GiB. - #26 — parse and insert never overlap, though DuckDB releases the GIL: ~40 minutes of a full load. - #27 — decide whether to parse `<PubmedBookArticle>` records; the per-file count this PR records answers whether it matters. - #28 — confirm the `pub_year` backfill at corpus scale (verified on one baseline file: 3,625 of 30,000 records, 127 distinct `MedlineDate` shapes, all recovering a year). - #29 — run the Parquet export at corpus scale; only the JSON path has full-corpus numbers.
Brings in the Slurm pipeline, the progress narration and the DuckDB --memory-limit work (#9). Three resolutions needed judgment rather than a side: **CLAUDE.md became AGENTS.md on main**, while this branch kept editing CLAUDE.md. CLAUDE.md is now main's `@AGENTS.md` pointer, and this branch's four decisions — the COPY-based JSON writer, gzip as the export default, the deliberate absence of an ORDER BY, and EXPECTED_FIELDS deriving from JSON_FIELDS — are ported into AGENTS.md. Git flagged no conflict for that: AGENTS.md merged clean as a new file, so those paragraphs would have been lost silently. **export.py takes this branch's COPY rewrite wholesale**, which retires main's fetchmany loop along with the per-batch progress line it gained there (the heartbeat thread covers it). But main's non-conflicting changes to the same file had to be ported by hand, since taking the file wholesale dropped them: `_ID_CURIE_SQL`/`_ID_TYPES_SQL` deriving the identifier CURIEs from ID_PREFIXES rather than hand-writing them (pinned by a test), the PMCID-not-PMC decision from #33, `pipeline_run` in the Parquet export, and the stale-Parquet sweep. **slurm/README.md keeps this branch's newer measurements** but drops the runnable `srun --mem=…` block they sat in: main added `test_readme_does_not_duplicate_the_allocations`, which forbids exactly that. The provenance is kept in main's prose form instead. The `--threads` note also had to be re-pointed at main's correction — DuckDB reads the cgroup, so the export's pool was already the CPUs it asked for. Four of main's tests were written against the pre-COPY export and now assert the wrong shape: the zero-padded shard names (DuckDB's FILENAME_PATTERN '{i}' emits a bare index) and plain-.ndjson reads that the gzip default breaks. Rewritten against the properties rather than the names, using the gzip-aware helpers this branch already had. Full suite: 194 passed, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Reworks JSON export for faster parallel DuckDB serialization while removing the global PMID sort.
Changes:
- Uses DuckDB
COPYwith per-thread output. - Makes gzip default and shard count a thread cap.
- Updates validation, tests, and documentation.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
src/pubmed2db/export.py |
Implements parallel JSON export and progress logging. |
src/pubmed2db/cli.py |
Updates export options and defaults. |
src/pubmed2db/validate.py |
Derives expected fields from the exporter. |
tests/test_export.py |
Tests export behavior and SQL normalization. |
tests/test_cli.py |
Tests new CLI defaults and round trip. |
tests/test_validate.py |
Exercises validation against gzip shards. |
README.md |
Documents JSON export behavior. |
slurm/README.md |
Updates performance and operational guidance. |
FUTURE.md |
Records completion of export optimization. |
AGENTS.md |
Captures architectural decisions. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The merge resolution for tests/test_export.py fused two tests and duplicated a block. Found by a code review, not by the suite, because every symptom still passed: - `test_writing_progress_line_renders` kept its docstring and lost its body — a legal function asserting nothing, reading as coverage for the heartbeat's %-format while providing none. - Its body had been grafted onto `test_export_progress_line_renders`, main's test for the fetchmany progress branch that the COPY rewrite deleted, along with a stray export_json() call. That test is now gone; the code path it covered no longer exists. - `test_placeholder_looking_fields_survive_to_the_export` and `test_parquet_export_removes_a_dropped_table_s_file` each appeared twice, byte-identical, so Python kept the second and pytest collected one. Verified identical before dropping the copies. The restored test is mutation-checked: an arity mismatch in the "writing: ..." format string fails it, which is the failure it exists to catch before a 20-minute production run finds it. Checked the same way for lost or duplicated definitions across every merged test file; test_cli.py's apparent losses were main's own renames and its staged_download fixture refactor, and are correct as merged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`--shards` stopped being a free file-count knob when the COPY rewrite made it the writer-thread count: export_json runs `SET threads = $SHARDS` for the statement. config.sh still defaulted to 16 while 04-export.sbatch requests --cpus-per-task=8, so the default cluster run oversubscribed 2:1 on the one step that gets OOM-killed, and whose new peak RSS is unmeasured. The same SET silently raises a group-level --threads set to run *below* an allocation on a busy node. SHARDS now derives from SLURM_CPUS_PER_TASK, falling back to the number the sbatch header asks for, so changing the header moves both. test_shards_tracks_the_export_allocation holds them together and fails if the fallback and the header disagree. 04-export.sbatch's own header still described the pre-rewrite export — a global sort, single-threaded serialization, "--shards does not want a CPU each" — all three of which this branch removed or inverted. AGENTS.md makes those headers the authoritative record of an allocation, so an operator sizing the job was reading the old model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SQL `trim(x)` strips spaces and nothing else; Python's `.strip()` strips
all whitespace. So `pub_month` values carrying a tab or a newline gave
"Mar" from `month_to_abbrev` and "" from `_PUB_MONTH_SQL`.
That is not hypothetical: `parse._raw_pubdate` stores `findtext("Month")`
verbatim with no strip, so a `<Month>` element spanning a line reaches the
export as "\nMar". The record then exports a blank pub_month *and* is
reported as a mismatch against efetch — `validate` normalizes efetch's
side with the Python function — which is a CORE_FIELDS error.
`test_month_sql_matches_month_to_abbrev` did not hold the contract
AGENTS.md claims for it: its only whitespace case was " 3 ", spaces alone.
Tab, newline, CR and mixed cases added.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three robustness fixes to the COPY writer, all on the path that exists so
a 20-minute export is not silent:
The heartbeat thread started *before* `SELECT current_setting('threads')`
and outside the try. If that raised — a closed or invalidated connection
is the realistic case — `stop` was never set, and the thread is
non-daemon: the interpreter would not exit, so the CLI hung instead of
reporting the error. The read now happens first.
`_log_writing_progress` stats shards while DuckDB writes the same
directory, so a path can vanish between the glob and the stat. That
FileNotFoundError escaped and killed the heartbeat, after which the export
went completely silent — precisely the failure the heartbeat prevents.
`_shard_bytes` skips what it cannot stat, and the loop swallows anything
else with a warning.
The reported duration started after `_latest_snapshot` was materialized
and counted — a window function over the whole `article` table, which is
why export memory scales with the database, and plausibly the dominant
phase now the sort is gone. slurm/README.md tells operators to size
--time from that line, so it was understating the job. The clock now
starts before it, pinned by ordering rather than by wall-clock timing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both are outside anything this repo can verify, because the consumer is.
Gzip defaulting on renames data/json/pubmed_metadata_*.ndjson to *.gz, and
an ingest that globs *.ndjson without decompressing reads nothing — an
empty ingest rather than an error, which is the worst shape a failure can
take. Shard names also lost their zero padding, since DuckDB's
PER_THREAD_OUTPUT names files from FILENAME_PATTERN '{i}'.
Folded into the existing "Confirm the Node Annotator JSON contract" item,
which already asked the gzip question, with a note that it now wants
answering before the next production export rather than eventually.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The JSON export writes one shard per writer thread, so the shard count
follows the allocation rather than being fixed at 16. `validate` samples
--sample-size records from *each* shard, which means halving the export's
--cpus-per-task silently halves how many records the next validate
compares against Entrez.
Documented rather than changed: making the flag a total would mean
reworking check_structure's per-shard reservoir sampling, which is
validate's contract rather than this branch's subject. The report already
prints what a run actually checked ("240 records sampled: 15/shard x 16
shards"), so the coupling is visible per-run once you know to look — this
says so in --help and in slurm/README.md.
Also re-points the export's memory/time prose at the #SBATCH headers
rather than restating 256G and 02:00:00, which the merge from main left
as a second copy of numbers that live in 04-export.sbatch. #42 tracks
re-baselining them from a real run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot: src/pubmed2db/export.py — the field documentation said Babel's `PMC = "PMC"` and gave `PMC:PMC1234567` as the emitted example, while ID_PREFIXES two lines down says `PMCID` and the exporter emits `PMCID:PMC1234567`. A merge artifact: main's "the PMCID prefix is not settled" note (#33) landed on top of this branch's older paragraph without replacing it, so the two sat adjacent saying opposite things — and the stale one came first, which is the one a reader takes as the contract. README.md was already correct (`PMCID:PMC6423490`); this was the only place left claiming otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
gaurav
added a commit
that referenced
this pull request
Aug 20, 2026
Brings in PR #12's COPY-based JSON export and the fixes that followed it. Simpler than the last merge in this stack, because this branch was already based on the COPY rewrite — its `_PUB_MONTH_SQL` was already SQL, not the Python `_document` loop. Three resolutions needed judgment: **The whitespace trim had to be woven into the generator, not pasted next to it.** main fixed `_PUB_MONTH_SQL` to strip what Python's `.strip()` strips (SQL `trim(x)` takes spaces only), while this branch replaced that constant with `_normalize_month_sql(expr)`, a generator applied to two sources — the `pub_month` column and the `MedlineDate` remainder. Taking either side whole would have dropped the other: every `trim` in the generated SQL now names `_WS`, including the `ELSE` passthrough this branch added, so `"\tSep-Dec"` survives as `"Sep-Dec"` on both sides. `test_pub_month_sql_matches_python` already iterates the cross product of month × MedlineDate inputs; the union of both sides' cases now runs through it, 276 combinations. Mutation-checked. **CLAUDE.md became AGENTS.md on main again**, and again git flagged no conflict on AGENTS.md, so this branch's three documented decisions would have been lost silently: the approximate-month rule and its `isalpha()` guard, the cross-product pinning, and why `validate` takes `_MONTH_ABBR` from the exporter rather than `calendar.month_abbr`. Ported. **FUTURE.md's `MedlineDate` item was resolved on one side and extended on the other.** This branch closed the "pub_month is deliberately empty" half; main added the PMCID item (#33) and an issue number to the ELocationID one. Both kept. `validate._VALID_MONTHS`: both sides independently replaced `calendar.month_abbr` for the same locale reason. This branch's version is a superset — abbreviations, seasons and ranges — and already carries main's reasoning, so it stands. Checked every merged file for definitions lost, duplicated, or silently emptied — the three failure modes of the previous merge. The only names missing are this branch's own renames (`month_to_abbrev` → `normalize_month`/`pub_month`, and their tests), each with its replacement present. Full suite: 214 passed, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closed
gaurav
added a commit
that referenced
this pull request
Aug 20, 2026
#12 has landed, so this now targets `main` directly and has been merged with it. **#17 is stacked on top of this one** — it adds the verbatim `pub_date` field, and reading them together is what makes the `"Dec-1999 Jan"` case below read as a design rather than a wart. Closes #14. Follow-ups: #16 (normalizing names inside a range), #17 (`pub_date`). ## The problem The DocumentMetadataAPI spec contradicts itself. Its prose says *"Send month names as capitalized three-letter abbreviations"*, but its **own worked example** for PMID:8000234 emits: ```json "pub_year": "1994", "pub_month": "Sep-Dec", "pub_day": "" ``` We were following the prose and shipping `"pub_month": ""` for every season or range. This PR follows the example: **take the month from the input and pass it through unchanged**, normalizing only the values that genuinely *are* month names. There was also a latent corruption hiding behind the gap. `month_to_abbrev` matched on a 3-character prefix, so a value like `"Sep-Dec"` would have been silently truncated to `"Sep"` — we only never saw it because the season never reached the function. ## The rule ``` pub_month = normalize(Month or Season) or normalize(MedlineDate after its leading year) ``` The column wins when both are present (`pub_month("Mar", "1998 Spring")` → `"Mar"`); the `MedlineDate` is a fallback, not an override. ### Month names fold | Input | Output | | | --- | --- | --- | | `"Mar"` | `"Mar"` | | | `"3"` / `"03"` / `" 3 "` | `"Mar"` | numeric | | `"March"` | `"Mar"` | | | `"Sept"` | `"Sep"` | | | `"sep"` / `"SEPTEMBER"` | `"Sep"` | case-insensitive | | `"13"` | `""` | a number that isn't a month is garbage, not a season | ### Everything else passes through — this is the fix | Input | Output | Before this PR | | --- | --- | --- | | `"Sep-Dec"` | `"Sep-Dec"` | **`"Sep"`** — silently truncated | | `"Jul-Aug"` | `"Jul-Aug"` | **`"Jul"`** — silently truncated | | `"Spring"` | `"Spring"` | `""` | | `"Winter"` | `"Winter"` | `""` | | `"Winter-Spring"` | `"Winter-Spring"` | `""` | | `"September-December"` | `"September-December"` | `""` — see #16 | | `"Jul-August"` | `"Jul-August"` | `""` — see #16 | | `"Sept-Dec"` | `"Sept-Dec"` | `""` — see #16 | ### Recovered from a free-text `MedlineDate` | Input | Output | | | --- | --- | --- | | `"1994 Sep-Dec"` | `"Sep-Dec"` | the spec's PMID:8000234 example | | `"1998 Spring"` | `"Spring"` | | | `" 2001 Winter"` | `"Winter"` | leading whitespace tolerated | | `"1998 September"` | `"Sep"` | a long name still folds here | | `"1998 Dec-1999 Jan"` | `"Dec-1999 Jan"` | verbatim; we don't invent `"Dec-Jan"` | | `"1999-2000"` | `""` | a year range has no month — **not** `"-2000"` | | `"n.d."` / `"Spring 1998"` | `""` | no leading year; don't guess | | `"12345"` | `""` | | `pub_day` stays blank throughout, as the spec example has it. ## How it's built Three commits, each green on its own: 1. **Pass approximate months through** — `month_to_abbrev` → `normalize_month`, with an `isalpha()` guard separating "is a month name" from "isn't"; `_month_from_medline_date` as the month-side sibling of the existing year recovery. Its mandatory whitespace after the year is load-bearing: it stops `"1999-2000"` yielding `"-2000"`. 2. **Read `<Season>`** — PubMed serves the same record three ways, and we read only two. The DTD makes `<Month>` and `<Season>` mutually exclusive, so the season shares the existing `pub_month` column — **no schema column, no migration**. 3. **Docs** — supersedes the FUTURE.md bullet that said month/day were deliberately blank, and records `<Season>` in the README's date bullet, which is the one user-facing place that still listed only `Year`/`Month`/`Day`/`MedlineDate`. A Parquet consumer reading `article.pub_month` now sees `Winter` as readily as `Mar`. Merging `main` added a fourth strand. The `COPY` rewrite had fixed `_PUB_MONTH_SQL` to strip what Python's `.strip()` strips (SQL `trim(x)` takes spaces alone), while this branch had replaced that constant with `_normalize_month_sql(expr)` — a generator applied to two sources. Taking either side whole would have dropped the other, so every `trim` in the generated SQL now names `_WS`, including the `ELSE` passthrough this branch adds. ## Verification - `test_pub_month_sql_matches_python` iterates the **cross product** of 27 month × 15 MedlineDate inputs (405 cases) — the SQL falls through from one source to the other, so they interact. Includes a non-ASCII spelling, which is why the SQL uses `\p{L}+` and not `[A-Za-z]+`: Python's `str.isalpha()` is Unicode-aware and the twins must not disagree. - **Real PMID 8000234** pulled from efetch and pushed through `parse → load → export` in *both* renderings — efetch's `<Year>1994</Year><Season>Sep-Dec</Season>` and the archival `<MedlineDate>1994 Sep-Dec</MedlineDate>`. Both emit `1994` / `Sep-Dec` / `""`, reproducing the spec example. - **`validate` against live PubMed**: `pub_month` no longer appears in the mismatch tally. Its efetch side now reads all three renderings through the exporter's own function — without that the fix would only relocate the disagreement. - 214 tests pass, 1 skipped, on both CI Pythons (3.11 and 3.14). ## Two ways the twins disagreed, both found by review The Python/SQL pair is only worth having if it actually agrees, and the cross-product test had blind spots in both directions. Both fixes are mutation-checked. **The `MedlineDate` separator meant different things in the two regex engines.** DuckDB's RE2 reads `\s` as `[\t\n\f\r ]` — no `\v`, no non-ASCII space — where Python's `re` includes both. So `"1998\xa0Spring"` gave `Spring` from the Python twin and `""` from the SQL. Because `validate._text` collapses whitespace before the efetch side reaches `pub_month`, efetch always renders `Spring`, and the disagreement surfaced as a `pub_month` mismatch — a CORE_FIELDS error on a correctly-exported record, the same gating failure the `<Month>` tab case had already caused once. `[\s\p{Z}\x0B]` tracks Python's `\s` on every shape probed, em space and form feed included. The test could not have caught it: every `_MEDLINE_INPUTS` entry used a plain space as the separator. **`normalize_month` could raise on input this PR is what widens.** `"²".isdigit()` is `True` while `int("²")` raises, and the function's input goes from a `<Month>` element (constrained to `Jan`..`Dec`/`01`..`12`) to arbitrary `<MedlineDate>` CDATA. The `ValueError` would have escaped `efetch_documents` and aborted a whole validation run rather than being recorded as one odd record. Guarded with `raw.isascii() and raw.isdigit()`, which is also the exact match for the SQL twin's `[0-9]+` — `"١٢"` is both `isdigit()` and `isdecimal()`, so `isdecimal()` alone would have left Python answering `Dec` where the export passes `١٢` through. ## Also fixed `validate._VALID_MONTHS` was built from `calendar.month_abbr`, which is `LC_TIME`-dependent — exactly what `export._MONTH_ABBR` was frozen to avoid. Under a non-English locale the `month-format` check would have warned on every record in the export. Now built from the exporter's tuple, and broadened to months, seasons and ranges of those, so odder shapes like `"Dec-1999 Jan"` stay visible as warnings rather than being blessed. ## Rollout **A re-export is required** for this to reach consumers. A reload is required only for `<Season>` records — and only if the archival XML actually carries them, which is still unmeasured: every case observed so far is the `<MedlineDate>` form in the baseline, with `<Season>` coming only from efetch. `zgrep -c "<Season>"` over a baseline file would settle it; noted as open in FUTURE.md. ## Deliberately not in scope - **Normalizing month names *inside* a range** — `"September-December"` stays verbatim rather than becoming `"Sep-Dec"`. Tracked in **#16** with the query to decide it and the sketch to implement it. It may describe zero records: PubMed's own convention in `<Season>`/`<MedlineDate>` is already 3-letter. - **`pub_day` for range dates** — the spec example wants `""`. - **Tidying `"Dec-1999 Jan"`** — that would mean inventing a value PubMed never wrote. It is what PubMed wrote and what its API renders back, so the export mirrors it, and `validate` warns on it deliberately: `_VALID_MONTHS` is a closed set that admits months, seasons and ranges of those but not this, so cross-year shapes stay visible instead of being blessed. **Export and checker disagreeing here is the design**, which had been recorded only in this description — it came back as a review finding, so it now lives in `_month_from_medline_date`'s docstring and in `AGENTS.md` too. The real answer to the wart is a different field, not a tidier month: **#17** adds a verbatim `pub_date` carrying PubMed's own string, the way `esummary`'s `pubdate` does. ## Open, and worth answering before the rebuild @shuchenliu asked on #14 whether DocumentMetadataAPI and its consumers are comfortable receiving `"pub_month": "Dec-1999 Jan"`, and that has not been answered. **#17's `pub_date` does not close it** — it gives consumers a clean string to render from, which is the better answer, but whether the odd `pub_month` is acceptable *alongside* it is a separate yes/no from the same people. Split out as **#43** so it does not close along with #14 when this lands, and cross-referenced from `FUTURE.md`'s "Confirm the Node Annotator JSON contract" list — with two other things the next production export changes that consumers cannot see coming: shards are gzipped by default now, and shard filenames lost their zero-padding. All three want the same conversation, before the rebuild rather than after it. If the answer is "blank it instead", the change is a one-line gate in `_month_from_medline_date` restricting the `MedlineDate` fallback to the closed set `validate._VALID_MONTHS` already accepts — named in that function's docstring, and cheap, but it has to be made before a whole-corpus run rather than after. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
gaurav
added a commit
that referenced
this pull request
Aug 20, 2026
Brings in #15 (which this branch was cut from, before its review round) plus the fixes that followed it. Two resolutions needed judgment, and one of them was a live bug rather than a textual clash: **`_PUB_DATE_SQL` reintroduced the divergence main had just fixed for `pub_month`.** This branch was cut before that fix, so its new SQL used bare `trim()` while the Python twin uses `.strip()` — and SQL `trim` strips spaces alone. Measured, not assumed: a `MedlineDate` of "\t1998 Spring" and a `pub_day` of "\n15" each made `pub_date` disagree with `pub_date()`. Every `trim` in the expression now names `_WS`, and the twins agree across all 1,680 combinations of the four date inputs. This is the second time the same mistake has arrived through a merge, so `AGENTS.md` now says it about `_PUB_DATE_SQL` specifically rather than leaving it as a general rule about the month SQL. **A branch test globbed `*.ndjson`.** Gzip became the export default in #12, which reached main after this branch was cut, so `test_month_format_accepts_what_the_export_can_emit` failed on StopIteration for all nine of its cases. Switched to the gzip-aware `_append_to_shard` helper that already exists in the file. CLAUDE.md became AGENTS.md on main, as in the previous two merges of this stack, and again with no conflict flagged on AGENTS.md itself. This branch's three decisions are ported: `pub_date` as the fidelity guarantee (including why `_PUB_DATE_SQL` calls `_normalize_month_sql` rather than `_PUB_MONTH_SQL`), `esummary` as the rendering to consult for "what should we emit", and PMID sampling as the way to measure corpus-wide frequency. FUTURE.md keeps both sides' new bullets. Checked every merged file for definitions lost, duplicated or emptied. Two flags, both false positives: tests/test_cli.py is byte-identical to main, and test_mismatch_kind_classification is a genuine one-line parametrized assertion. Full suite: 231 passed, 1 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Summary
Makes the JSON export ~3x faster by deleting the two things the last run's progress log pointed at: a global sort that had to finish before a single row could be written, and a single-threaded Python serialization loop that then did all the work on one core. Closes #8.
What the log showed
From the 2026-08-05 full-corpus run (40,923,261 documents, 18m 06s):
elapsed 3m 00swith exactly 5,000 documents — onefetchmany(batch_size=5000). Nothing had been written for three minutes becauseORDER BY la.pmidmust materialize and sort all 40.9M rows first. Reproduced locally on 5M rows: first batch at 0.02s without the sort, 2.04s with it.What changed
DuckDB writes the JSON — one
COPY (...) TO <dir> (FORMAT JSON, PER_THREAD_OUTPUT true)instead of afetchmanyloop callingjson.dumpsper row, so serialization runs in C++ on every thread. The query no longer sorts.Measured end-to-end on a 2M-document copy of the database (same machine, same query,
--shards 8):ORDER BYCOPY, no sortThe two outputs are identical: 1,995,600 rows each, and
EXCEPTin both directions over the full record set returns nothing. (The new files are ~1.5% smaller — DuckDB writes compact JSON wherejson.dumpsdefaults to", "/": "separators.)Keeping the record shape honest
_JSON_FIELDS— output field name → SQL expression, in emitted order — is now the single definition of a document, and theCOPYprojection is built from it. So the DocumentMetadataAPI names, the empty-string-not-null rule and the field order are written down once.validateimportsJSON_FIELDSinstead of probing_document's arity, which deletes that hack.month_to_abbrevand_year_from_medline_datestay as Python, becausevalidatenormalizes the efetch side with them. Their SQL twins (_PUB_MONTH_SQL,_PUB_YEAR_SQL) are built from the same_MONTH_ABBR/ regex and pinned to the Python by two tests over the cases an index-based lookup gets wrong ("0","13","99999999999999999999","Sept","SEPTEMBER", whitespace,None). Both were mutation-tested: dropping the capitalization from the month key, or loosening the year regex to([0-9]{4}), fails them.One divergence got through that pinning and is fixed here: SQL
trim(x)strips spaces only, where Python's.strip()strips all whitespace. So"\tMar","3\n"and"\n3"each gaveMarfrommonth_to_abbrevand""from_PUB_MONTH_SQL. That is reachable, not theoretical —parse._raw_pubdatestoresfindtext("Month")verbatim, so a<Month>element spanning a line arrives here intact, and the record would then export a blankpub_monthand be reported as a mismatch against efetch, which is aCORE_FIELDSerror. The test's only whitespace case was" 3 ", spaces alone — exactly what baretrim()already handled. Tab, newline, CR and mixed cases added.Three behaviour changes to know about
--shards Nis a maximum, not a count — and it is now the write parallelism. Output is one file per writer thread, so--shardscaps that statement's threads (restored afterwards) and a small dataset can be written by fewer. Default is DuckDB's own thread count. On Slurm it must match--cpus-per-task, soconfig.shderivesSHARDSfromSLURM_CPUS_PER_TASKrather than leaving the two to be kept in step by hand — a fixed 16 against04-export.sbatch's 8 CPUs would oversubscribe 2:1 on the one step that gets OOM-killed.test_shards_tracks_the_export_allocationreads--cpus-per-taskout of the sbatch header and fails if the fallback disagrees with it.pubmed_metadata_00000.ndjsonis nowpubmed_metadata_0.ndjson.gz:PER_THREAD_OUTPUTnames files fromFILENAME_PATTERN '{i}', which emits a bare index. Anything globbingpubmed_metadata_*is fine; anything matching the padded form, or relying on the files sorting lexically past nine, is not.validate --sample-sizeis per shard, so the sample now moves with the allocation. It always was per shard, but the count was fixed at 16; with one shard per writer thread, halving the export's--cpus-per-taskhalves how many records the nextvalidatecompares against Entrez. Documented in--helpandslurm/README.mdrather than changed — making the flag a total means reworkingcheck_structure's per-shard reservoir sampling, which isvalidate's contract rather than this branch's subject. The report already names what a run checked (240 records sampled: 15/shard x 16 shards).pubmed_metadata_0.ndjson.gz);--no-gzipopts out. NDJSON compresses ~4-5x, so a full corpus lands at roughly 12 GiB rather than 52 — less written by the export, less read back byvalidate, less kept around. Compression happens as each shard is written, costing CPU (which this PR just freed up) rather than a second pass. Safe only because nothing downstream needs telling:find_shardsalready matched both extensions, andcheck_structurereads through a raw handle so its byte-progress denominator is the compressed size either way.test_cli_export_then_validate_needs_no_flagsrunsexportthenvalidatewith no flags on either to keep that true.validatebuilds its own sorted PMID manifest.Also: DuckDB appends to a per-thread output directory rather than clearing it, so a shorter run would have left the previous run's shards behind to be read as current. The export now deletes its own
pubmed_metadata_*files first (tested).Progress output
Per-batch progress lines go with the Python loop. A heartbeat logs output size and current RSS once a minute in their place — no ETA, since the total output size isn't known until it's written:
(Shapes, not measurements — see below.)
-vadditionally enables DuckDB's own progress bar.The heartbeat is the only thing standing between an operator and twenty silent minutes, so it is written not to fail: it stats shards that DuckDB is concurrently writing, skipping any that vanish between the glob and the stat, and the loop logs and continues on anything else rather than letting an exception kill the thread. It also starts after the statement that reads the connection's thread count — started before it, a raise there would leave a non-daemon thread looping forever and hang the CLI instead of reporting the error.
The
exported ... in <duration>figure covers the whole export, including materializing_latest_snapshot— a window function over the entirearticletable, and plausibly the dominant phase now the sort is gone.slurm/README.mdtells operators to size--timefrom that line, so a clock started after it would understate the job.Verification
/proccurrent-RSS check, which skips on macOS), on both CI Pythons — 3.11 and 3.14. Main was merged in partway through, which is where thetestsworkflow, the Slurm pipeline and the DuckDB--memory-limitwork arrived from (Add a Slurm sbatch pipeline, narrate the long-running steps, and cap DuckDB with--memory-limit#9)._documentdid, including identifier ordering, the month abbreviation and the MedlineDate year recovery.\uXXXXescapes, and the export→validate round trip with no flags on either command.validate's fixture is now a gzipped export, so its whole suite runs against what the CLI actually writes (and exercises appending a second gzip member to a shard).Not verified
The new peak RSS and wall time on the cluster. The 3x is a laptop measurement on a 2M-document database; the full corpus is 20x that and the machine is different. Two things to record from the first real run:
04-export.sbatch's--memis probably now over-provisioned — but by how much is a measurement, and under-requesting is an OOM kill several minutes in.--timeis still generous.Tracked in #42. The
#SBATCHheaders are what to edit once the numbers exist;slurm/README.mdcarries the run history and deliberately does not restate the allocation.What the merge from main changed
maingained the Slurm pipeline and CI (#9) while this branch was open, and the merge was not purely mechanical. Three things a reader would otherwise have to reverse-engineer from the merge commit:mainsplitCLAUDE.mdintoAGENTS.md; this branch had kept editingCLAUDE.md. Git flagged no conflict —AGENTS.mdarrived as a clean new file — so this branch's four decisions (theCOPYwriter, the gzip default, the absentORDER BY,EXPECTED_FIELDSfromJSON_FIELDS) would have vanished silently. They are ported intoAGENTS.md.ID_PREFIXESnow saysPMCID, notPMC. That ismain's deliberate bet from Settle the CURIE prefix for PMCIDs in the JSON export's identifiers #33, which this branch predated. It changes every PMCID CURIE in the export, and it arrived through the merge rather than through either PR's own work — worth knowing when reading the identifier diff. TheCASEis derived fromID_PREFIXESrather than hand-written, which is pinned by a test.main's tests asserted the pre-COPYshape — zero-padded shard names and plain-.ndjsonreads that the gzip default breaks. Rewritten against the properties, using the gzip-aware helpers this branch already had.Follow-on work
--memand--timefrom a cluster run, and measure the gzip default's CPU cost for the first time. The only item here that cannot be settled without hardware.*.ndjsonwithout decompressing reads nothing — an empty ingest rather than an error, which is the worst shape a failure can take.--no-gziprestores the old artifact if the answer turns out to be no.🤖 Generated with Claude Code