Patch release. Focus: correctness fixes, plus a chain-of-trust ClinVar bug surfaced during real-data verification. Cache-format unchanged; existing v2.0.0 ClinVar caches refresh automatically on the next allelix db update.
Upgrade
pip install --upgrade allelix
allelix db update # refreshes ClinVar via the new loader; everything else is signal-matched and skipped
The CLINVAR_INTERPRETER_VERSION bump (1 → 2) invalidates v2.0.0 ClinVar caches so the new ClinVar loader takes effect. No re-download is forced for gnomAD / AlphaMissense / CADD; their remote signals are unchanged.
Highlights
- Wrong-allele safety: three independent paths that could attach the wrong allele's frequency / pathogenicity / CADD score to your variants are now suppressed. When we can't confirm which alt the user carries, we show nothing instead of a number that might describe a different allele.
- Cache-integrity hardening: ClinVar
.md5validation, GWAS / ClinPGx row-count floor, gnomAD schema-tag enforcement, anddb update's tagless-cache re-download close four ways the cache could silently end up wrong. - Build detection:
normalize_build_labelno longer mis-tags files based on substring noise;2038-01-01does not parse as GRCh38,hg19/hg38returns "ambiguous" instead of picking one. - Terminal output: trimmed to a bare-min column set that fits typical 100–120-col terminals without Rich's auto-squeeze producing hairline-blank columns. Enrichment data still surfaces in HTML / JSON reports.
Known limitations
- ClinVar per-(classification, condition) pairing (tracked for v2.1). ClinVar variants with multiple SCV submissions display the primary aggregate classification and the union of all associated conditions (semicolon-joined). The exact 1:1 pairing between a specific classification and the specific condition it was submitted for is not preserved — the VCF source doesn't carry that mapping. Full per-(classification, condition) rows via ClinVar's
submission_summary.txt.gzstructured export are planned for v2.1. Affects ~0.01% of cached ClinVar records — exclusively multi-submission variants.
Changed
scripts/fetch_testdata.shURL. Now downloads fromallelix/allelix/releases/download/v2.0.0/test_data.tar.gz(the ~1.27 GB asset including the v2.0.0 VCF/gVCF additions: real GIAB HG002 GRCh38 + GRCh37 benchmarks, a real GATK-HC gVCF, a 1000 Genomes chr22 multi-sample, plus the synthetic mocks). Previous URL pointed at the v1.1.1 asset, which lacked all the VCF samples.- HuggingFace asset URLs moved to the
allelix/org. The gnomAD and AlphaMissense prebuilt-cache download URLs were previously hardcoded tohuggingface.co/datasets/dial481/...; both datasets now live under theallelix/HF org. HF serves redirects from the old paths, so existing pinned client installs continue to work; new installs pull from the canonical org URL. Commit SHAs in the resolve paths are unchanged — the asset bytes are identical and SHA256 integrity checks pass without modification. - Terminal report: bare-min column set. The analyze table previously had 9–12 columns; on a typical 100–120 col terminal Rich auto-squeezed the middle columns to hairline-zero widths and cut rsIDs off mid-string. New layout:
rsID | Gene? | Source | Significance | Mag | GT | Condition?(Gene and Condition dropped when no row carries data). Source uses compact display (GWAS Catalog→GWAS), significance drops the redundantsource_prefix (clinvar_pathogenic→pathogenic), Genotype renamed toGT. Terminal output only — every dropped field still surfaces in HTML and JSON reports unchanged.
Fixed — wrong-allele safety
resolve_strand: drop complement-fallback. At multi-allelic sites, the complement of the user's true forward allele can coincidentally equal a different alt at the same position. The previous fallback (when the user allele didn't match{ref, alt}directly, trycomplement(user_allele) ∈ {ref, alt}) would return that coincidental complement, and CADD enrichment then stamped a wrong-allele score onto an annotation that read as describing the user's variant. Reproduction:resolve_strand('A', 'C', 'T') → 'T'(user'sAis neither REF nor ALT butcomplement('A') = 'T' = ALT). The function now returnsNonewhenever the user allele isn't directly in{ref, alt}. ADR-0010 already deferred proper strand handling; this makes the deferral explicit at the resolution layer.- Enrichment: drop MAX-fallback for alt-less annotations (suppress-half). GWAS rows set
alt=""because the catalog is rsID-keyed. The previous enrichment path took aMAX(af) / MAX(am_pathogenicity) / MAX(phred) GROUP BY rsidfallback for those rows, which at a multi-allelic site stamped the highest-frequency / highest-pathogenicity / highest-CADD alt's value next to the annotation as if it described the user's variant. Symmetric fix to the strand-resolve change above: skip the enrichment number rather than show one that may belong to a different allele. The position-fallback path (used for rsIDs resolved on the fly via ClinVar from the v2.0.0 rsID-less resolution work) is allele-specific and stays — it pairs the alt-less annotation's rsID with the user's actual carried alt. CADD enrichment grew the same position-fallback path for symmetry with gnomAD / AlphaMissense. - ClinVar
conditionjoins all CLNDN entries (suppress-half). The loader was treating|inCLNSIGandCLNDNas parallel arrays index-paired with the VCF's ALT, then picking[0]from each. That's wrong: the|separator is per-SCV (per submission), not per-ALT, and the two lists are not guaranteed to be in matching order. Symptomatic case was a "Frankenstein pairing": one SCV's classification shown next to another SCV's condition (real-world example: rs1063192 surfaced asLikely_pathogenic + Three_Vessel_Coronary_Diseasewhen the Likely_pathogenic submission was actually for Breast cancer; the Three_Vessel_CD submission was Protective). The minimal v2.0.1 fix joins the fullCLNDNlist into a singleconditionstring per record.clinical_significancekeeps the primary aggregate (CLNSIG[0]).
Fixed — cache integrity
- GWAS / ClinPGx loaders: post-load row-count floor. Both upstream sources stream over chunked transfer with no
Content-Length; a connection drop mid-stream produces a parseable-but-short TSV that previously committed to the cache and shipped fewer associations / clinical annotations than the user expected. Loaders now raiseOSErrorbeforeos.replaceif the ingested count falls underGWAS_MIN_ROWS=100_000/PHARMGKB_MIN_ROWS=5_000; on failure the existing cache is untouched and the user is told toallelix db update --forceto retry. db update: tagless caches re-download instead of being stamped. A cache with no stored freshness signal almost always predates the signal mechanism, i.e. is old. The previous behavior was to stamp the live remote signal onto it and call it current, which permanently marked stale data as fresh — every subsequent run sawcached == remoteand skipped, so only--forcecould escape.cached is Nonenow triggerssetup()like a remote-signal mismatch does, with a distinct console message so the user knows why.- ClinVar
.md5body validated as 32 hex chars. When the ClinVar CDN returns a transient HTML error page, the previous parser treated the first whitespace-separated token (<!DOCTYPE) as the expected hash. That token then flowed intoverify_file_hash, which on mismatch deletes the just-downloaded ~400 MB VCF and raises. A five-second 503 upstream wiped the freshly fetched cache. The signal parser now requires^[0-9a-fA-F]{32}$before treating the token as an md5. - gnomAD
is_ready(): tagless caches fail. The previous check wastag == f"sv:{GNOMAD_SCHEMA_VERSION}" or not tag, so any cache written before the schema-version mechanism existed was accepted as ready, defeating future invalidation. The escape is dropped —is_ready()now requires the exact current tag. Production caches built viainstall_prebuilt_cachealready stamp the current tag.
Fixed — build detection
normalize_build_label: anchored tokens, ambiguity rejection. The previous implementation was first-substring-wins over a bare"3X" in schain. Bugs:2038-01-01registered as GRCh38,hg19/hg38picked GRCh37 (the first substring hit),38 (37 liftover)picked GRCh37. The normalizer is now driven by anchored regex tokens (\bgrch3[678]\b,\bhg(18|19|38)\b,\b(36|37|38)\b, etc.) and collects all distinct builds mentioned: exactly one → that build, zero or multiple →None. Direct callers (compare, export plink, header-vs-detected mismatch warning) now receiveNoneinstead of a silently-wrong build label.- LivingDNA parser: only inspect build-marker comment lines.
get_metadatapreviously fed every comment line intonormalize_build_labeland let the last match win, so a download-date comment could retag the file. It now skips comment lines that don't mentionbuild/reference/genome, and breaks on the first valid build-marker line. - Build detection: warn when inconclusive despite rsIDs. When position-detection inspected known-rsID positions but couldn't pick a build (votes tied across builds or no row matched any), the pipeline silently routed through
header_build. A GRCh36 file with a GRCh37-mislabeled header used to silently get the GRCh37 ClinVar cache. Now fires a yellow warning naming the rsID-checks-attempted count, what the header claims, and how to force--build.
Fixed — reports
diff: validate baseline entries, magnitude tolerance, no implicit zero. Two bugs in the report-diff path. (1)load_previous_reportonly validated the top-level shape; a baseline with"annotations": {}or entries missingsource/rsidwould flow intocompute_diffand raiseKeyError/TypeErrorrather than the documentedValueError. (2) "Changed" detection used exact float!=(so JSON round-trip noise —7.5vs7.499999999999999— flagged as changed) and substituted0.0for a missing previous magnitude (so a baseline entry with no magnitude key looked like a change against any nonzero current value). Now usesmath.isclose(abs_tol=1e-9)and carriesprevious_magnitude: float | None.- GWAS
_magnitude:<=at p-value boundaries. Strict<on the magnitude thresholds meant the canonical genome-wide-significance valuep = 5e-8landed in the suggestive bucket (4.0) rather than the significant bucket (6.0) — one full magnitude below a barely-significant 4.9e-8 hit. All boundary comparisons now use<=. - GWAS rollup: consistent PheCode delimiter (partial).
_gwas_base_traitwas splitting on" (PheCode "(leading space) while_gwas_phecode_parentwas searching for"(PheCode "(no leading space). Promoted the delimiter to a module-level_PHECODE_DELIMconstant used by both. The deeper structural fix (carrying trait / p-value / PheCode as structured fields onAnnotation) is deferred to v2.1.
Fixed — parsers and models
Variant: alleles normalized to uppercase at construction (case half). Reference databases all ship uppercase alleles, and carrier matching is raw set membership against{variant.allele1, variant.allele2}— a lowercase user allele would silently miss every clinical annotator. Production parsers all emit uppercase today, but a user-supplied filter file or future format variant could leak lowercase through. Normalization now happens inVariant.__post_init__so every emission point is covered at the single chokepoint. Strand-naive half of the same finding is deferred to v2.1 per ADR-0010.- FTDNA
can_parserejects MyHeritage files. FTDNA and MyHeritage exports share the sameRSID,CHROMOSOME,POSITION,RESULTheader and a byte-identical data section. Bothcan_parseimplementations were accepting MyHeritage files, with routing masked only by the registry's listing order. FTDNA now scans the first 50 lines for aMyHeritagesubstring and returnsFalseif found; detection is mutually exclusive regardless of registry order. - SNPedia parser: allowlist guard on
raw_tableinterpolation. Theraw_tablename fromdetect_raw_tableflows into three SQL queries via f-string interpolation (SQLite doesn't accept parameter binding for identifiers). Today the function only ever returns one of two literals or None — safe — but the signature isstr | Nonewith no constraint. Added a_VALID_RAW_TABLES = frozenset({"_raw_pages", "pages"})guard at the top of_parse_raw_pages_inner; an unexpected name raisesValueError. split_csv_linedocstring corrected. The previous docstring claimed it handles single, double, and double-double-quoted fields per real CSV quoting rules. The implementation isline.split(",")+strip('"'), which mishandles any quoted field containing a literal comma. Docstring now states the limitation and the intended consumer set (FTDNA / MyHeritage / Living DNA). No behavior change.
Verification
Full pytest: 1525 pass / 2 skip, 94.08% coverage. Ruff clean. Real-data battery on HG002:
- GIAB v4.2.1 GRCh38 benchmark VCF (NIST truth set)
- DeepVariant gVCF (GRCh38)
- GATK HaplotypeCaller VCF (GRCh37)
Strict-superset chain-of-trust at production filter: 0 benchmark annotations missing from the gVCF run. Wrong-allele safety invariants (strand-resolve complement, alt-less enrichment MAX-fallback, ClinVar CLNDN joining) verified end-to-end on the rebuilt cache.