Skip to content

Validate AnVIL metadata records at load (#161)#162

Closed
NoopDog wants to merge 2 commits into
mainfrom
noopdog/161-validate-anvil-metadata
Closed

Validate AnVIL metadata records at load (#161)#162
NoopDog wants to merge 2 commits into
mainfrom
noopdog/161-validate-anvil-metadata

Conversation

@NoopDog

@NoopDog NoopDog commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #161.

Based on main, independent of #151.

Problem

Nothing between the AnVIL API and the classifiers checks the shape of a record. _load_input validated only the envelope — that the JSON is an object with a results or files key. Every record was then trusted.

data/anvil/anvil_files_metadata.json is downloaded by make download. A renamed key, a newly-nullable column, or a file_size returned as a string flows silently into 758,658 records and surfaces as a crash somewhere deep in a five-hour run — or worse, a wrong classification.

The 26 f.get("file_name", "") sites across scripts/ are symptoms, not the disease. That default fires only when the key is absent; a present-but-null value returns None, and six of those sites immediately call .lower() or .endswith().

Two contracts, deliberately separate

what checked by
ANVIL_RECORD_SCHEMA the full 12-field record the download produces make validate-metadata
validate_pipeline_records the four string fields + size that ClassifyPipeline reads _load_input, every run

They are not one schema because a derived input — a filtered subset, a hand-built fixture — legitimately carries only a few fields. Requiring drs_uri inside the pipeline would be wrong.

Two constraints a naive validator gets wrong

Measured against the corpus, not assumed:

  • file_size >= 0, not > 0. Three records have size 0.
  • data_modality and reference_assembly are nullable by design — they are AnVIL's own declarations, null for 751,903 and 753,962 records respectively. Rejecting nulls there would reject 99% of the corpus.

Also: bool subclasses int, so isinstance(True, int) is True — a file_size of True is caught explicitly.

Deliberate non-features

  • No coercion. A None is never turned into "". That would hide the drift this exists to catch.
  • No silent drops. A dropped record is indistinguishable from a file that was never seen (Fetchers that fail return None and their records are dropped from the output entirely #155). _load_input raises, naming the problem kinds and counts.
  • Unknown fields are not a problem. The API may add a column, and that must not fail a download. A renamed column shows up as missing:<old_name>.

Verification

Against the real corpus:

$ make validate-metadata
Schema: 12 fields
Input : data/anvil/anvil_files_metadata.json
758,658 records, no problems.
                                              (4.0s)

Injecting four kinds of drift into a 5,000-record slice:

5,000 records, 4 kinds of problem:
  missing:file_name: 1        e.g. record 0
  null:file_name: 1           e.g. record 1: None
  type:file_size: 1           e.g. record 2: '40003'
  pattern:file_md5sum: 1      e.g. record 3: '61A32A5CD39BE712260F0EBFAF423438'

Samples are bounded at 3 per kind, so a systematically broken download reports a summary rather than 758k lines.

validate_pipeline_records adds 0.48s to a run that takes hours.

Changes

  • src/meta_disco/metadata_schema.py — field specs, validate_anvil_records, validate_pipeline_records, ValidationReport.
  • src/meta_disco/pipeline.py_load_input validates and raises.
  • scripts/validate_metadata.py + make validate-metadata.
  • tests/test_metadata_schema.py — 28 tests, including the zero-size and nullable-declaration cases, bool-as-int, a renamed key, an added key, and bounded samples.

485 tests pass; make lint clean.

Follow-up

download_anvil_metadata.py could validate before writing, so a bad download never lands on disk. Left out to keep this reviewable — noted in #161.

🤖 Generated with Claude Code

Nothing between the AnVIL API and the classifiers checked the shape of a record.
_load_input validated only the envelope. A renamed key or a newly-nullable column
flowed into 758,658 records and surfaced as a crash deep in a multi-hour run — or
as a silently wrong classification.

Two contracts, deliberately separate:

- ANVIL_RECORD_SCHEMA: the full 12-field record the download produces. Checked by
  `make validate-metadata` before a run.
- validate_pipeline_records: the far smaller promise ClassifyPipeline relies on.
  A derived input (a filtered subset, a test fixture) carries only a few fields,
  so imposing the AnVIL contract there would be wrong.

Two constraints are measured, not assumed, and a naive validator gets both wrong:
  * file_size may be 0 — three corpus records are. min_value=0, not 1.
  * data_modality and reference_assembly are AnVIL's own declarations and are null
    for ~99% of files. Nullable by design.

No coercion and no silent drops. A None is never turned into "": that hides the
drift this exists to catch, and is exactly what makes `rec.get(k, "")` dangerous —
the default fires only when the key is absent, so a present-but-null value returns
None and crashes the first `.lower()` downstream. A dropped record is
indistinguishable from a file that was never seen (#155), so _load_input raises.

Unknown fields are not a problem: the API may add a column. A renamed one shows up
as `missing:<old_name>`.

Verified against the real corpus: 758,658 records, no problems, 4.0s. Injecting a
renamed key, a null, a stringified size and an uppercase md5 produces all four
problem kinds with bounded samples. validate_pipeline_records adds 0.48s per run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 10, 2026 13:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds explicit schema validation for AnVIL metadata records so record-shape drift (missing/renamed keys, nulls in required fields, wrong types, etc.) fails early with a summarized report, rather than causing late crashes or silent misclassification during long pipeline runs.

Changes:

  • Introduces metadata_schema.py with a full AnVIL record schema (ANVIL_RECORD_SCHEMA) plus a smaller pipeline contract validator (validate_pipeline_records) and a structured ValidationReport.
  • Wires pipeline input loading to validate records on every run and raise with a bounded, human-readable problem summary.
  • Adds a scripts/validate_metadata.py CLI and a make validate-metadata target, plus a new test suite covering key constraints and edge cases.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/meta_disco/metadata_schema.py Adds schema definitions and validators for AnVIL records and the pipeline contract, plus reporting utilities.
src/meta_disco/pipeline.py Validates loaded input records using the pipeline contract validator and raises on invalid shapes.
scripts/validate_metadata.py Adds a standalone validator script for downloaded metadata files (JSON/NDJSON).
tests/test_metadata_schema.py Adds focused unit tests for schema validation behavior and edge cases.
tests/test_pipeline.py Minor import-order adjustment (no functional change).
Makefile Adds validate-metadata target and help text.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +169 to +178
data = json.load(f)
if not isinstance(data, dict):
raise TypeError(
f"Expected JSON object with 'results' key, got {type(data).__name__}"
)
records = data.get("results") or data.get("files")
if records is None:
raise ValueError(
"JSON object must contain a 'results' or 'files' key"
)
Comment on lines +37 to +42
if not isinstance(data, dict):
raise TypeError(f"Expected a JSON object, got {type(data).__name__}")
records = data.get("files") or data.get("results")
if records is None:
raise ValueError("JSON object must contain a 'files' or 'results' key")
return records
Comment on lines +10 to +12
* :data:`ANVIL_RECORD_SCHEMA` — the full 12-field record the download script
produces. Checked by ``scripts/validate_metadata.py`` and at download time.
* :func:`validate_pipeline_records` — the far smaller promise ``ClassifyPipeline``
Merged rather than rebased: rebasing would require a force push, which the repo
forbids.

Conflicts, both "keep both sides":
- pipeline.py imports: #151's FetchError/classify_without_content plus this
  branch's validate_pipeline_records.
- Makefile: #151's classify-gfa target plus this branch's validate-metadata.

One real interaction, not a textual conflict. #151 added a test asserting the run
loop tolerates a present-but-null file_name; #161 now rejects such a record at
load, so the record can never reach the loop. Rather than delete either guarantee,
the test is split:

- test_null_file_name_is_rejected_at_load — the outer gate (_load_input raises).
- test_null_file_name_in_the_run_loop_neither_crashes_nor_loses_the_record —
  defense in depth, driving _run_parallel directly, since the loader now stops
  such a record from getting there. Still pins that a raise in update_progress
  would silently discard an already-classified record in the parallel path.

531 tests, lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 10, 2026 13:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Comment on lines +219 to +228
data = json.load(f)
if not isinstance(data, dict):
raise TypeError(
f"Expected JSON object with 'results' key, got {type(data).__name__}"
)
records = data.get("results") or data.get("files")
if records is None:
raise ValueError(
"JSON object must contain a 'results' or 'files' key"
)
Comment on lines +35 to +42
with open(path) as f:
data = json.load(f)
if not isinstance(data, dict):
raise TypeError(f"Expected a JSON object, got {type(data).__name__}")
records = data.get("files") or data.get("results")
if records is None:
raise ValueError("JSON object must contain a 'files' or 'results' key")
return records
Comment on lines +10 to +14
* :data:`ANVIL_RECORD_SCHEMA` — the full 12-field record the download script
produces. Checked by ``scripts/validate_metadata.py`` and at download time.
* :func:`validate_pipeline_records` — the far smaller promise ``ClassifyPipeline``
relies on. Derived inputs (a filtered subset, a hand-built test fixture) carry
only a few fields, so requiring the full AnVIL record here would be wrong.
@NoopDog

NoopDog commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of the LinkML → gen-pydantic → validate-with-pydantic approach.

#162's hand-rolled metadata_schema.py has the right behavior (count by kind, bounded samples, never mutate, tolerate unknown fields) but the wrong source of truth: it re-declares six fields the LinkML ClassificationRecord already defines. The real end state is to model the AnVIL input record in LinkML, generate a Pydantic model at build time (gen-pydantic, already wired in schema/Makefile), and validate with pydantic at runtime (already installed, 2.12.5) — so the runner never depends on linkml and there is one source of truth.

Rather than merge an interim and immediately replace it, dropping this. The requirement (validate AnVIL metadata at load, don't coerce, don't silently drop) is still tracked in #161; the implementation will follow the generated-pydantic path.

@NoopDog NoopDog closed this Jul 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Validate AnVIL metadata records at load: nothing checks the shape of what the API returns

2 participants