Add a validate subcommand for post-export sanity checks - #2
Open
gaurav wants to merge 14 commits into
Open
Conversation
Offline structural validation of exported NDJSON shards plus optional Entrez-backed coverage/field/deletion cross-checks, all funneled through a single monkeypatchable _eutils seam. Produces a gated report dict whose errors/warnings lead and whose optional (DB, previous-report, network) sections stay blank when unavailable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reads a shard directory, uses the DuckDB DB only when it exists and has articles, writes validation_report.json, prints a quiet-on-success summary, and exits non-zero on errors (--fail-on-warn also fails on warnings). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Covers structural pass/fail, dual-denominator coverage, sampled field matches and mismatches, missing-from-API detection, DB-sourced deletion confirmation, and the CLI happy/fail paths — no network. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
# Conflicts: # README.md
validate.EXPECTED_FIELDS mirrors export._document's keys by hand; add a test that fails if the exporter grows, drops, or renames a field, and correct the CLAUDE.md claim that validate reuses _document directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-validate-command # Conflicts: # CLAUDE.md
validate was the one command opening the database with a bare connect(), so --threads/--temp-dir were silently ignored there. It reads latest_article over a 40M-row database, which is exactly where the spill directory matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deriving the key set from export._document makes drift impossible rather than merely detectable, so the drift guard is replaced by a spec lock on the ten DocumentMetadataAPI field names — those are an external contract, so changing the export shape should still trip a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 2026-07-30 export held 40,901,984 documents against an Entrez total of 40,944,369 — a ratio of 0.9990 — so the old [0.1, 1.5] band could not catch any realistic shortfall. Narrow it to +/-5%, which absorbs Entrez growth between export and validation while flagging a materially short export, and document that a --limit test export needs --entrez-low widened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage counts cannot detect two same-sized exports whose PMID sets differ, so --manifest writes a sorted gzipped pmids.txt.gz (from the set check_structure already holds, so no extra pass) and --previous-manifest diffs against it. Drops the deleted_pmid table explains are expected; unexplained ones are errors, since they mean records were lost rather than retired. Without a database the drops cannot be attributed, so they degrade to a warning. Closes the deferred previous-report drop-detection item in FUTURE.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A full-corpus run said only "Validation WARN: 40901984 record(s) across 16 shard(s)" plus one line naming 20 disagreements. It did not say what had been checked and passed, never mentioned that two checks were skipped, and gave no way to judge whether 20 disagreements were benign or corrupt data heading downstream. The cause was structural: error/warning/skip calls were scattered through the check functions and a passing check left no trace at all, so passing checks were not enumerable. Every check now goes through Report.record as a named Check (name, expectation, status, observed), and errors/warnings/skipped_checks are projections of that single list rather than separately maintained arrays -- they cannot drift from it, and an earlier draft that dual-wrote both was dropped for exactly that reason. Error codes and the skipped_checks strings are unchanged, so existing consumers and tests are unaffected. format_summary is now a pure renderer over the report dict, so anything printed is provably in the archived JSON. It groups a section per check family, prints each check's expectation next to what was observed, and shows skipped checks -- previously invisible on stdout entirely. Field mismatches are classified by kind, and the "exported a different value" count prints even when it is zero. That zero is the decision a reviewer has to make: on the real run all 20 mismatches were fields left blank, which is a completeness gap safe to pass on, not a wrong value. Truncated example lists now say "(20 of 43 shown)" instead of silently implying completeness. Two statuses where there was one: `skip` means evidence is obtainable (pass a flag, go online) and stays an actionable to-do list; `n/a` means there was nothing to evidence, and stays out of skipped_checks. Also adds what the report could not previously reproduce: the thresholds each check was judged against, the denominator behind core_mismatch_rate, and a checks_run array carrying the full check list with structured mismatch tallies. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three behaviours this branch added had no test: - the thresholds recorded in `inputs`, without which an archived report cannot be re-read to see what the run considered acceptable; - `core_comparisons`, the denominator that makes `core_mismatch_rate` auditable rather than something to reverse-engineer; - the OTHER FINDINGS block, which exists so a check added later without touching format_summary degrades to today's output instead of vanishing from stdout. Verified it stays quiet on a clean run and does not duplicate a finding already rendered in its own section. Also records the trap behind this session's MedlineDate diagnosis in CLAUDE.md: efetch output is a rendering, not the archival XML, so a validate mismatch is not evidence about what we parsed. PMID 152567 comes back from efetch as <Year>1978</Year><Season>Jul-Aug</Season> but sits in the baseline file as <MedlineDate>1978 Jul-Aug</MedlineDate> with no <Year> at all -- diagnosing from efetch alone points at the wrong layer. 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>
gaurav
added a commit
that referenced
this pull request
Aug 4, 2026
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>
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
Adds
pubmed2db validate <dir>, which inspects a finished JSON export (adirectory of NDJSON shards) and writes an archivable, gated
validation_report.json. It answers "does this export make sense?" after an HPCrun. Targets
initial-implementationso it can be reviewed after the base branch.Five checks, split into an offline phase (fast, deterministic) and an
online phase (Entrez eutils cross-checks; skip with
--offline):10-field record shape; flags malformed lines, missing/extra fields, nulls,
bad
ids, invalid months, and cross-shard duplicate PMIDs. Per-shardreservoir sampling keeps memory bounded.
(
einfo) and the locallatest_articlecount (a shortfall is an error —rows were dropped); plus drift vs.
--previous-report.efetchandcompared field-by-field (fuzzy abstract via
difflib). Journal name/abbrevare warning-only (different source); a sampled PMID PubMed no longer serves is
an error.
deleted_pmids not reinstated by a later version,confirms they're absent from the export and gone from PubMed.
export's manifest (see below).
PMID manifest sidecar
Coverage counts can't detect two same-sized exports whose PMID sets differ, so
--manifestwrites a sorted gzippedpmids.txt.gzand--previous-manifestdiffs against an earlier one:
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.gzA drop the
deleted_pmidtable explains is expected; an unexplained drop isan 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_structurealready holds, so it costs a sort and a writerather than another pass over the shards.
Report & gating
The report leads with
errors/warningsarrays that are empty on a cleanrun; the stdout summary is quiet on success and loud on findings. Exit is
non-zero on errors (
--fail-on-warnextends to warnings) so it can gate apipeline. The DuckDB database, a previous report, and a previous manifest are all
optional inputs used when present and left blank (noted in
skipped_checks)when not.
Coverage band is calibrated, not guessed
The default
--entrez-low/--entrez-highband is ±5%, derived from a realfull-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
--limittest download) islegitimately far below the band and will warn. Pass
--entrez-low 0.001or--offlinewhen validating one. This is documented in the README.Notes for review
validate._eutilsseam (rate-limited,retrying), which tests monkeypatch — the suite stays fully offline.
month_to_abbrevis imported from
export, andEXPECTED_FIELDSis derived by callingexport._documenton a placeholder row, so the record shape can't drift.test_expected_fields_matches_specadditionally locks the ten field names,since they're an external contract with Node Annotator / ElasticSearch.
validateuses the group-level_connect, so--threads/--temp-dirapplyto it too — it reads
latest_articleover a 40M-row database, which is exactlywhere the spill directory matters.
merges via
esummaryremains deferred inFUTURE.md.requests,lxmlalready present).initial-implementationhas been merged in twice as it advanced, so the diffhere is only the validate work. Both conflicts were docs lines the two branches
had each edited.
Testing
uv run pytest— 58 passing (43 existing + 15 new), no network.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 2reachesvalidate's connection.TODO (undecided: fix here or file)
data/jsonwasproduced before the
pub_yearbackfill, so its blank years are still on diskand the verdict must come back unchanged —
WARN, 0 errors, 1 warning, thesame 20 mismatches, now rendered as
18x pub_year / 1x article_title / 1x issue,all
exported_blank, with0 exported a different valueprinted and the twopreviously-invisible skipped checks shown. This is the behaviour-preservation
check: a different verdict means the renderer rewrite changed behaviour and is
a bug, not an improvement.
recovers the year from
MedlineDate, so a fresh export should dropcore-fieldsfrom 20 mismatches to ~2. The residual
issue="Suppl"(PMID 10137601) andarticle_title="[Not Available]."(PMID 28972331) are expected to survive —they are unrelated to
MedlineDateand tracked on PR Initial implementation: download, store, and export PubMed abstracts #1.Merge PR Initial implementation: download, store, and export PubMed abstracts #1 into this branch once it lands.Done — this branch nowcarries PR Initial implementation: download, store, and export PubMed abstracts #1, and Export DOIs and PMCIDs as an identifiers field #7 has restacked on top of it (diff back to +266/−23 from an
inflated +664/−228). The merge exposed two real breakages, both fixed here: a
hardcoded placeholder arity in
EXPECTED_FIELDSthat crashedvalidateatimport once the export query gained a column, and
validatecomparing efetch'sraw
<Year>against an export that now recovers one fromMedlineDate— afalse-mismatch source whenever efetch returns the archival form.
🤖 Generated with Claude Code