Skip to content

Add a validate subcommand for post-export sanity checks - #2

Open
gaurav wants to merge 14 commits into
initial-implementationfrom
add-validate-command
Open

Add a validate subcommand for post-export sanity checks#2
gaurav wants to merge 14 commits into
initial-implementationfrom
add-validate-command

Conversation

@gaurav

@gaurav gaurav commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds pubmed2db validate <dir>, which inspects a finished JSON export (a
directory of NDJSON shards) and writes an archivable, gated
validation_report.json. It answers "does this export make sense?" after an HPC
run. Targets initial-implementation so 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):

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

PMID manifest sidecar

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

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

A drop the deleted_pmid table explains is expected; an unexplained drop is
an error — records were lost rather than retired. Without a database the drops
can't be attributed, so they degrade to a warning. The manifest is written from
the PMID set check_structure already holds, so it costs a sort and a write
rather than another pass over the shards.

Report & gating

The report leads with errors/warnings arrays that are empty on a clean
run
; the stdout summary is quiet on success and loud on findings. Exit is
non-zero on errors (--fail-on-warn extends to warnings) so it can gate a
pipeline. The DuckDB database, a previous report, and a previous manifest are all
optional inputs used when present and left blank (noted in skipped_checks)
when not.

Coverage band is calibrated, not guessed

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

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

Notes for review

  • All network funnels through one validate._eutils seam (rate-limited,
    retrying), which tests monkeypatch — the suite stays fully offline.
  • "Expected" is always defined by the exporter, never restated: month_to_abbrev
    is imported from export, and EXPECTED_FIELDS is derived by calling
    export._document on a placeholder row, so the record shape can't drift.
    test_expected_fields_matches_spec additionally locks the ten field names,
    since they're an external contract with Node Annotator / ElasticSearch.
  • validate uses the group-level _connect, so --threads/--temp-dir apply
    to it too — it reads latest_article over a 40M-row database, which is exactly
    where the spill directory matters.
  • Deletion status still treats "efetch returns nothing" as deleted; labelling
    merges via esummary remains deferred in FUTURE.md.
  • No new dependencies (requests, lxml already present).
  • initial-implementation has been merged in twice as it advanced, so the diff
    here is only the validate work. Both conflicts were docs lines the two branches
    had each edited.

Testing

  • uv run pytest58 passing (43 existing + 15 new), no network.
  • Verified end-to-end beyond the unit tests: a clean export reports PASS/exit 0;
    an export seeded with a malformed line, a missing field, a duplicate PMID and a
    bad month reports FAIL/exit 1 with those four itemized; the manifest
    round-trips sorted and gzipped; a 3-PMID drop against a previous manifest is
    reported (warning without a DB, error for unexplained drops with one); and
    --threads 2 reaches validate's connection.

TODO (undecided: fix here or file)

  • Check the refactor against the existing export first. data/json was
    produced before the pub_year backfill, so its blank years are still on disk
    and the verdict must come back unchangedWARN, 0 errors, 1 warning, the
    same 20 mismatches, now rendered as 18x pub_year / 1x article_title / 1x issue,
    all exported_blank, with 0 exported a different value printed and the two
    previously-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.
  • Then re-export and re-run. With PR Initial implementation: download, store, and export PubMed abstracts #1 merged in, this branch's exporter
    recovers the year from MedlineDate, so a fresh export should drop core-fields
    from 20 mismatches to ~2. The residual issue = "Suppl" (PMID 10137601) and
    article_title = "[Not Available]." (PMID 28972331) are expected to survive —
    they are unrelated to MedlineDate and 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 now
    carries 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_FIELDS that crashed validate at
    import once the export query gained a column, and validate comparing efetch's
    raw <Year> against an export that now recovers one from MedlineDate — a
    false-mismatch source whenever efetch returns the archival form.

🤖 Generated with Claude Code

gaurav and others added 14 commits July 1, 2026 01:03
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>
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 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant