Skip to content

Harden VA-Spec annotation and CSV exports against shape-dependent failure - #838

Merged
bencap merged 7 commits into
feature/bencap/gnomad-af-supportfrom
chore/bencap/797/export-hardening
Aug 12, 2026
Merged

Harden VA-Spec annotation and CSV exports against shape-dependent failure#838
bencap merged 7 commits into
feature/bencap/gnomad-af-supportfrom
chore/bencap/797/export-hardening

Conversation

@bencap

@bencap bencap commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Export paths fail on stored payload shapes no fixture constructs, and they fail in ways
callers cannot see: an opaque network error, a truncated file, or a download that dies
whole. This makes each failure attributable, bounded, and testable.

Changes

  • Uncaught exceptions now return a readable 500
  • A failing variant no longer truncates an NDJSON stream
  • CSV export survives an unparseable post-mapped payload
  • Round-trip conformance across a shape list
  • Corpus sweep script

@coveralls

coveralls commented Aug 11, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 31627746819

Warning

No base build found for commit aead12c on feature/bencap/gnomad-af-support.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 89.335%

Details

  • Patch coverage: 4 uncovered changes across 2 files (86 of 90 lines covered, 95.56%).

Uncovered Changes

File Changed Covered %
src/mavedb/lib/annotation/conformance.py 17 14 82.35%
src/mavedb/lib/middleware/errors.py 34 33 97.06%
Total (7 files) 90 86 95.56%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 15106
Covered Lines: 13495
Line Coverage: 89.34%
Coverage Strength: 0.89 hits per line

💛 - Coveralls

bencap added 7 commits August 12, 2026 11:27
Starlette dispatches @app.exception_handler(Exception) from ServerErrorMiddleware, which is installed
outside the user middleware stack. Its 500 never passes through CORSMiddleware and so carries no
Access-Control-Allow-Origin; the browser rejects it before axios sees the body, and the caller gets an
opaque network error instead of an attributable failure.

Catch the exception in a pure ASGI middleware installed as the innermost layer, so CORS decorates the
response and the correlation id is available to put in the body. Pure ASGI rather than
BaseHTTPMiddleware so the NDJSON streaming endpoints are left alone; exceptions raised after the
response starts are re-raised, since the status is already committed.

The response body uses 'detail' to match every other error path in the app. The handler in server_main
stays as a backstop for anything raised outside the middleware.
…stream

_stream_generated_annotations caught only MappingDataDoesntExistException, so anything else raised
after the first yield ended the body mid-stream. The 200 and its headers went out with the first
chunk, leaving the consumer a short file it cannot distinguish from a complete one. Three known
raising paths reach it: absent or malformed score data, an unrecognized VRS Allele state type, and an
empty post-mapped id list.

Classify each variant instead. A failure becomes a record carrying an error object, and serialization
is inside the try -- an emitted object that builds and then fails to dump is the same shape-dependent
failure, and is what 5c155f4 fixed. A missing mapping stays an expected null so consumers can keep
telling the two apart.

Outcome counts go to the logs rather than a trailing summary record: every line stays a variant
record, the body still holds exactly X-Total-Count lines, and the format stays identical to the public
dump's va/{urn}.va.ndjson. The contract change is additive.
…t of variant shapes

Annotation tests all ran against one variant shape, so a structural regression that only appears for a
particular stored payload passed the whole suite. Two have reached production: a reference-identical
variant whose VRS state is a ReferenceLengthExpression (8412508), and a required Extension.value
combined with a null baseline score stripped by model_dump(exclude_none=True) (5c155f4).

Parametrize the four annotation entry points over 13 mapped-variant shapes and assert one contract per
pair: an object that survives serialization and re-validation, or None. Never raises. Reverting either
fix above now fails this suite.

round_trip_annotation compares emitted JSON to re-emitted JSON rather than comparing objects. VA-Spec
declares Statement.hasEvidenceLines as list[EvidenceLine], so a VariantPathogenicityEvidenceLine
re-validates as its base class -- verified to lose no data and produce identical JSON, so object
equality would report a non-defect on every pathogenicity statement.

Shapes for gnomAD records, ClinVar controls, hgvs_g/hgvs_p, and a missing score key are deliberately
absent; the first three are never read by the annotation layer, and dataframe validation rejects a
score file with no score column. Reasoning is recorded in the module.
The annotation test suite runs against constructed variant shapes. Real data holds shapes nobody thought
to construct, and both VA-Spec serialization defects that reached production were of that kind. This
walks the corpus and attempts every annotation surface, so those shapes get a chance to fail somewhere
other than a user's download.

One CSV row per attempted (score set, surface) pair, successes included: a report showing only failures
cannot distinguish "nothing broke" from "nothing ran". Outcomes are ok, exception, schema_violation, or
skipped, and the exit code is non-zero only for the two that mean something is broken -- most published
score sets have no current mapped variants, so failing on skipped would make the exit code useless. The
schema check reuses the round-trip helper the conformance tests use.

No per-score-set sampling. An earlier draft sampled, but a sampled sweep can only report a score set as
unbroken-where-sampled, and measurement put a full run at 43 minutes for 3.4M variants across three
surfaces -- the right order for a pre-release check. --max-score-sets bounds how many score sets are
looked at; every one it reports on is swept completely.

Which exceptions mean "nothing to annotate" rather than "failed to annotate" now lives in
EXPECTED_ABSENCE_EXCEPTIONS, since the streaming endpoints and this script both have to draw that line
and a sweep that treated an expected absence as a failure would bury real defects in noise.
…le export

Running the variant shape list through the CSV row composer found that two post-mapped payload shapes
raise out of a cell resolver: a VRS 1.x object, which hgvs_from_vrs_allele refuses deliberately, and an
allele carrying no expressions key. The resolvers reach that parse whenever the stored hgvs_g or hgvs_p
column is null, which is 3.9M and 3.0M of 4.2M current mapped variants, so the path is live even though
neither payload shape occurs in the corpus today.

Raised from inside one cell, either exception aborts the entire file -- every other variant in the score
set's CSV, and the whole archive build for the public dump. _safe_hgvs_from_post_mapped absorbs both and
returns None, which keeps the refusal without spending the caller's download on one variant's shape.
Scoped to the CSV resolvers: the worker's mapping and ClinGen jobs call the same helper, and there an
unparseable payload should surface rather than blank a cell.

Also extends the shape list to the CSV surface. The mock mapped variant is annotation-shaped and left
hgvs_g, hgvs_p, hgvs_c, hgvs_assay_level, and vep_functional_consequence unset -- and an unset attribute
on a MagicMock is truthy, so the resolvers took the stored-column branch every time and the VRS fallback
never ran. hgvs_g and hgvs_p now default to None deliberately, which is what makes the payload matter.
…on surfaces

CSV export is a reviewer-facing surface and had never been run across the corpus. It shares its per-row
composer with the variant-level CSV, so sweeping it covers the resolvers where every shape-dependent
defect so far has lived -- including the one 53a7b6e fixed.

One unit of work per score set rather than per variant, since the composer builds the file in a single
call, with namespaces from discovery so each score set is exercised over exactly what it can emit. Two
checks: composition must not raise, and the output must re-parse to a rectangle of the expected size,
which catches a value carrying a delimiter or newline that silently splits a record. On a raise, start
and limit bisect to the offending row so the report names a variant rather than a file.

The CSV surface is sized by total variants, not by current mapped variants, and is attempted even when a
score set has no current mappings: the composer emits a row per variant and outer-joins the mapping, so
an unmapped variant still gets a row of NA columns. 2309 of 2850 published score sets have more variants
than current mappings, so conflating the two counts would report a false row-count violation across most
of the corpus.

First full run found 26 published score sets whose CSV emits roughly twice the expected rows: 44,218
variants carry two mapped variants both flagged current, one a legacy record whose flag was never cleared
when its replacement was written. The annotation surfaces duplicate those variants too, but count mapped
variants and so cannot see it. Data repair tracked separately; the export is deliberately not made to
dedupe, which would hide a live problem behind correct-looking output.
@bencap
bencap force-pushed the chore/bencap/797/export-hardening branch from 585868e to 50c3137 Compare August 12, 2026 18:27
@bencap
bencap merged commit 7fcf9a9 into release-2026.2.7.1 Aug 12, 2026
5 checks passed
@bencap bencap mentioned this pull request Aug 12, 2026
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.

Harden VA-Spec annotation exports against shape-dependent failure

2 participants