Skip to content
This repository was archived by the owner on Jun 15, 2026. It is now read-only.

Commit f3b5efd

Browse files
authored
v1.4.0: AlphaMissense enrichment, config system, HTML report polish
Add AlphaMissense variant pathogenicity enrichment — enrichment-only annotator (same pattern as gnomAD), pre-built SQLite cache on HuggingFace, AM Score column in all three report formats. Build script with Zenodo streaming and local TSV modes. PharmGKB rows display AM scores as neutral with caveat across all renderers. Add config file system — config.toml with per-source on/off toggles, commercial mode safety switch (license.commercial = true auto-disables non-commercial sources). CLI flags override config per-invocation. Fix HTML report layout (#20) — table overflow with horizontal scroll, sticky rsID column, description max-width, refs collapsed into <details>, conditional Review Status column, stat card flex-wrap. Repute row tints (red/green by significance). Sortable columns via inline JS. Magnitude legend with scoring tables. Source floor note. Add 25 unit tests for AlphaMissense build script (#24) covering TSV parsing, gnomAD rsID join, chr prefix normalization, --no-gnomad path, multi-allelic composite PK, batched insert, and end-to-end integration. Code review fixes: AM caveat consistency, Content-Length download integrity check, dead code removal, _connection() guards, disk space preflight at 5x gz size, multi-allelic MAX aggregation tests. Closes #20, closes #24
1 parent 76801f0 commit f3b5efd

32 files changed

Lines changed: 2617 additions & 93 deletions

CHANGELOG.md

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,68 @@
22

33
All notable changes are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
44

5+
## [1.4.0]
6+
7+
### Added
8+
- **AlphaMissense variant pathogenicity enrichment.** New
9+
`AlphaMissenseAnnotator` enriches annotations with missense variant
10+
pathogenicity scores from DeepMind's AlphaMissense (71M variants,
11+
CC BY 4.0). Pre-built SQLite cache downloaded from HuggingFace via
12+
`db update`. AM Score column in terminal, HTML, and JSON reports.
13+
PharmGKB rows show AM scores as neutral with caveat (protein
14+
structure impact only — tooltip in HTML, dimmed `*` footnote in
15+
terminal, `am_caveat` field in JSON). `--no-alphamissense` flag to
16+
skip.
17+
- **Config file system.** `config.toml` with per-source on/off toggles
18+
and `license.commercial = true` safety switch that auto-disables
19+
non-commercial sources (SNPedia). `allelix config show/set/reset`
20+
CLI commands. CLI flags override config per-invocation.
21+
- `scripts/build_alphamissense_cache.py` — AlphaMissense cache build
22+
script with Zenodo HTTPS streaming (default) and local TSV modes.
23+
Joins against gnomAD cache for coordinate-to-rsID mapping.
24+
- AlphaMissense CC BY 4.0 attribution in HTML and JSON reports.
25+
- Magnitude scoring legend in HTML report (collapsible, per-source
26+
scoring tables for ClinVar, PharmGKB, GWAS, SNPedia).
27+
- Source floor note in HTML report when per-source magnitude minimums
28+
are active.
29+
- Repute row background tints in HTML report (red for pathogenic/risk,
30+
green for protective/benign) derived from existing significance
31+
field.
32+
- Sortable columns in HTML report (magnitude, gene, source, AM score)
33+
via inline JavaScript.
34+
- ADR-0027 documenting the AlphaMissense enrichment cache architecture.
35+
- `scripts/run-tests.sh` — detached background test runner with log
36+
rotation.
37+
38+
### Fixed
39+
- HTML report table overflows viewport, columns clipped on left (#20).
40+
Added `overflow-x: auto` container, sticky rsID column,
41+
`max-width` on description cells, refs collapsed into `<details>`
42+
toggle, conditional Review Status column (hidden when all empty),
43+
stat card `flex-wrap`.
44+
- AlphaMissense build script has zero unit-test coverage (#24). Added
45+
25 tests covering TSV parsing, gnomAD rsID join, chr prefix
46+
normalization, `--no-gnomad` NULL-rsid path, multi-allelic composite
47+
PK, batched insert, and end-to-end integration.
48+
- Download integrity: Content-Length check after downloads catches
49+
truncated files.
50+
- Disk space preflight before decompressing `.sqlite.gz` caches uses
51+
5x gz size (accounts for gz + decompressed tmp on disk
52+
simultaneously).
53+
- `_connection()` guards on gnomAD and AlphaMissense annotators raise
54+
`FileNotFoundError` with actionable message when cache is missing.
55+
- Dead `cache_exists()` removed from gnomAD and AlphaMissense loaders.
56+
- Legacy caches stamp remote signal instead of re-downloading on
57+
`db update`.
58+
- README database sizes updated to match actual on-disk measurements.
59+
60+
### Changed
61+
- `db update` display includes gnomAD and AlphaMissense in "Analyzing
62+
against" annotator list.
63+
- Both build scripts (`build_gnomad_cache.py`,
64+
`build_alphamissense_cache.py`) run `VACUUM` for smaller output
65+
files.
66+
567
## [1.3.1]
668

769
### Fixed
@@ -41,8 +103,8 @@ All notable changes are documented here. Format follows [Keep a Changelog](https
41103
non-finding filter degrades gracefully. Signal carries
42104
`cpic:unavailable` so recovery auto-triggers a refresh.
43105
- `scripts/build_gnomad_cache.py` — streaming VCF build script for
44-
the gnomAD frequency cache. Downloads ~120GB over HTTPS, never saves
45-
VCFs to disk, outputs ~1GB SQLite.
106+
the gnomAD frequency cache. Downloads ~185GB over HTTPS, never saves
107+
VCFs to disk, outputs ~6GB SQLite.
46108
- `scripts/extract_array_manifest.py` — extracts rsID superset from
47109
genotype files for filtered gnomAD cache builds.
48110
- gnomAD ODbL v1.0 attribution in HTML and JSON reports.
@@ -1294,6 +1356,7 @@ All notable changes are documented here. Format follows [Keep a Changelog](https
12941356
- GitHub Actions CI matrix on Python 3.11 and 3.12.
12951357

12961358

1359+
[1.4.0]: https://github.com/dial481/allelix/compare/v1.3.1...v1.4.0
12971360
[1.3.1]: https://github.com/dial481/allelix/compare/v1.3.0...v1.3.1
12981361
[1.3.0]: https://github.com/dial481/allelix/compare/v1.2.0...v1.3.0
12991362
[1.2.0]: https://github.com/dial481/allelix/compare/v1.1.1...v1.2.0

README.md

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
Open-source command-line toolkit for analyzing raw genotype files from consumer DNA testing services. Format-agnostic ingestion, database-agnostic annotation, offline-first.
44

55
> **Status:** Production — six parser formats, four annotators (ClinVar +
6-
> PharmGKB + GWAS Catalog + SNPedia), dual-build ClinVar caches
7-
> (GRCh37 + GRCh38), HTML/JSON/terminal reports, methylation +
8-
> pharmacogenomics focused commands, report diffing. Build
6+
> PharmGKB + GWAS Catalog + SNPedia), two enrichment sources (gnomAD
7+
> population frequencies + AlphaMissense pathogenicity), dual-build
8+
> ClinVar caches (GRCh37 + GRCh38), HTML/JSON/terminal reports,
9+
> methylation + pharmacogenomics focused commands, report diffing,
10+
> persistent config with commercial-mode safety switch. Build
911
> auto-detection from position data (ADR-0021). No regex on prose
1012
> anywhere in production. Release notes: [`CHANGELOG.md`](CHANGELOG.md).
1113
@@ -26,9 +28,9 @@ python tests/generate_mock_data.py
2628
# Show summary statistics for a genotype file
2729
allelix stats tests/fixtures/mock_myhappygenes.txt
2830

29-
# Download reference databases (ClinVar VCF ~100MB compressed; PharmGKB clinical
30-
# annotations ~1MB). Re-runs skip downloads when local + remote signals match;
31-
# use --force to refresh unconditionally.
31+
# Download reference databases. First run downloads all sources (~15GB
32+
# on disk with gnomAD + AlphaMissense). Use --no-gnomad / --no-alphamissense
33+
# to skip the large enrichment databases. Re-runs skip unchanged sources.
3234
allelix db update
3335
allelix db status # see what's cached
3436

@@ -76,6 +78,8 @@ Adding a new format means adding one file to `allelix/parsers/` and registering
7678
| CPIC (per-allele function table) || Internal data source for the PharmGKB filter. Fetched from `api.cpicpgx.org` at `db update` time. Used to populate the `pharmgkb_allele_function` table — not surfaced to end users as its own annotator. |
7779
| SNPedia || CC BY-NC-SA 3.0 US. **Optional — requires a one-time download** via `python scripts/scrape_snpedia.py`. Scrapes both `Category:Is_a_snp` (111,726 pages) and `Category:Is_a_genotype` (104,806 pages) from the MediaWiki API. Stores raw wiki markup; the annotator parses structured genotype templates at query time. If the SNPedia database is absent, analysis runs without it. For commercial use, pass `--exclude-snpedia` or skip the scrape step — either way, `analyze` runs using all other databases and omits SNPedia annotations. |
7880
| GWAS Catalog || Public domain (EBI/NHGRI). Trait–SNP associations with p-values and effect sizes. Carrier rule (ADR-0007) requires the user to carry the risk allele. P-value magnitude scoring (ADR-0024) maps continuous p-values to the 0–10 scale; unknown-risk-allele entries fire on rsID match alone but are capped at 3.0. |
81+
| gnomAD || ODbL v1.0. **Enrichment annotator** — adds population allele frequency context to existing annotations. Shows how common each variant is in the general population (~16M exome variants from 730K individuals). A pathogenic variant that 35% of people carry reads very differently from one seen in 0.001%. Pre-built cache downloaded via `db update` (~6GB on disk). Use `--no-gnomad` to skip. |
82+
| AlphaMissense || CC BY 4.0. **Enrichment annotator** — adds DeepMind's protein-structure-based pathogenicity predictions to existing annotations. Scores 71M missense variants on a 0–1 scale: <0.34 = likely benign, >0.564 = likely pathogenic. Complements ClinVar's expert classifications with computational predictions — especially valuable for variants ClinVar hasn't reviewed yet. Pre-built cache downloaded via `db update` (~8GB on disk). Use `--no-alphamissense` to skip. |
7983

8084
### Known PharmGKB limitation: reference-genotype rows where ClinVar and CPIC both lack data
8185

@@ -110,6 +114,39 @@ This is not a disclaimer afterthought. It is a design constraint that affects mo
110114
- Reference databases are downloaded via `allelix db update` and cached locally.
111115
- Analysis runs offline against local database caches. A brief freshness check runs before analysis by default (skipped with `--no-update`).
112116

117+
## Configuration
118+
119+
Allelix stores persistent configuration in `config.toml` (in the data directory, default `~/.local/share/allelix/`). A default config is created on first run.
120+
121+
```bash
122+
# View current config
123+
allelix config show
124+
125+
# Disable a source permanently
126+
allelix config set sources.gnomad false
127+
128+
# Enable commercial mode (auto-disables non-commercial sources like SNPedia)
129+
allelix config set license.commercial true
130+
```
131+
132+
CLI flags (`--no-gnomad`, `--no-alphamissense`, `--exclude-snpedia`) override the config for a single run. The config sets the baseline; flags override per-invocation.
133+
134+
### Database sizes and download times
135+
136+
Not all databases are equal in size. `allelix db update` downloads them all by default, but you can skip the large ones if disk space or bandwidth is a concern:
137+
138+
| Database | On disk | Download time | What it adds |
139+
|---|---|---|---|
140+
| ClinVar (GRCh37 + GRCh38) | ~900MB | 1–2 min | Core clinical variant classifications. Required. |
141+
| PharmGKB + CPIC | ~6MB | seconds | Drug-gene interactions. |
142+
| GWAS Catalog | ~200MB | 1–2 min | Trait-SNP associations from genome-wide studies. |
143+
| gnomAD | ~6GB | 5–15 min | Population allele frequencies (how common is this variant?). |
144+
| AlphaMissense | ~8GB | 5–15 min | Missense pathogenicity predictions (how likely to break protein function?). |
145+
146+
gnomAD and AlphaMissense are the largest but add the most interpretive context. gnomAD answers "is this variant rare or common?" — a pathogenic variant carried by 35% of the population reads very differently from one seen in 3 people. AlphaMissense answers "does this missense change likely damage the protein?" — especially valuable for the thousands of variants ClinVar hasn't reviewed yet.
147+
148+
To skip either during download: `allelix db update --no-gnomad --no-alphamissense`. To disable permanently: `allelix config set sources.gnomad false`.
149+
113150
## Data Sources & Licensing
114151

115152
Allelix source code is licensed under the **GNU Affero General Public License v3.0 or later** (AGPL-3.0-or-later). Allelix ships with **zero third-party data**. All reference databases are downloaded by the user at runtime via `allelix db update`. Each database retains its original license on the user's machine:
@@ -122,8 +159,9 @@ Allelix source code is licensed under the **GNU Affero General Public License v3
122159
| CPIC | cpicpgx.org | CC BY-SA 4.0 | Attribution required. Per-allele function data fetched from `api.cpicpgx.org` at `db update` time; used internally for the PharmGKB non-finding filter (ADR-0020), not surfaced as its own annotator. |
123160
| SNPedia | snpedia.com | CC BY-NC-SA 3.0 US | Attribution required, **non-commercial only**. Use `--exclude-snpedia` to omit. |
124161
| gnomAD | gnomad.broadinstitute.org | ODbL v1.0 | Attribution required. Population allele frequencies for context; not a clinical annotator. Use `--no-gnomad` to omit. |
162+
| AlphaMissense | zenodo.org/records/10813168 | CC BY 4.0 | Attribution required. Cheng et al., Science 2023. Missense variant pathogenicity predictions. Use `--no-alphamissense` to omit. |
125163

126-
**Commercial users:** SNPedia content is non-commercial. Pass `--exclude-snpedia` to any analysis command, or skip the `scripts/scrape_snpedia.py` step entirely — either way, `analyze` runs using all other databases and omits SNPedia annotations automatically.
164+
**Commercial users:** SNPedia content is non-commercial. Set `allelix config set license.commercial true` to permanently disable non-commercial sources, or pass `--exclude-snpedia` per-invocation. Either way, `analyze` runs using all other databases and omits SNPedia annotations automatically. All other databases (ClinVar, PharmGKB, GWAS Catalog, gnomAD, AlphaMissense) are compatible with commercial use.
127165

128166
### SNPedia data download
129167

allelix/annotators/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from typing import TYPE_CHECKING
88

9+
from allelix.annotators.alphamissense import AlphaMissenseAnnotator
910
from allelix.annotators.base import Annotator
1011
from allelix.annotators.clinvar import CLINVAR_SUPPORTED_BUILDS, ClinVarAnnotator
1112
from allelix.annotators.gnomad import GnomadAnnotator
@@ -46,10 +47,12 @@ def get_annotators(
4647
gwas = GWASCatalogAnnotator(data_dir, filter_traits=gwas_filter_traits)
4748
snpedia = SNPediaAnnotator(data_dir, clinvar_ref_provider=clinvar.reference_for)
4849
gnomad = GnomadAnnotator(data_dir)
49-
return [clinvar, pharmgkb, gwas, snpedia, gnomad]
50+
alphamissense = AlphaMissenseAnnotator(data_dir)
51+
return [clinvar, pharmgkb, gwas, snpedia, gnomad, alphamissense]
5052

5153

5254
__all__ = [
55+
"AlphaMissenseAnnotator",
5356
"Annotator",
5457
"ClinVarAnnotator",
5558
"GWASCatalogAnnotator",
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
# Copyright (C) 2026 dial481
3+
"""AlphaMissense variant pathogenicity enrichment.
4+
5+
AlphaMissense is not a clinical annotator — it does not produce
6+
Annotation objects. It enriches existing annotations with missense
7+
variant pathogenicity predictions. The pipeline calls
8+
``bulk_lookup()`` after all annotators have run, and stamps each
9+
annotation's ``am_pathogenicity`` and ``am_class`` fields.
10+
11+
License: CC BY 4.0. Attribution: Cheng et al., Science 2023
12+
(doi:10.1126/science.adg7492).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
import sqlite3
19+
from typing import TYPE_CHECKING, ClassVar
20+
21+
from allelix.annotators.base import Annotator
22+
from allelix.databases.alphamissense_loader import (
23+
ALPHAMISSENSE_CACHE_URL,
24+
ALPHAMISSENSE_DB_FILENAME,
25+
install_prebuilt_cache,
26+
)
27+
from allelix.databases.manager import download, get_database_info, head_request_headers
28+
29+
if TYPE_CHECKING:
30+
from pathlib import Path
31+
32+
from allelix.models import Annotation, Variant
33+
34+
logger = logging.getLogger(__name__)
35+
36+
_BULK_BATCH_SIZE = 900
37+
38+
39+
class AlphaMissenseAnnotator(Annotator):
40+
"""Missense variant pathogenicity enrichment from AlphaMissense.
41+
42+
Subclasses Annotator for ``db update`` / ``db status`` / ``is_ready()``
43+
integration. ``annotate()`` always returns ``[]`` — AlphaMissense does
44+
not participate in the per-variant annotation loop.
45+
"""
46+
47+
name: ClassVar[str] = "alphamissense"
48+
display_name: ClassVar[str] = "AlphaMissense"
49+
attribution: ClassVar[str] = "AlphaMissense"
50+
requires_download: ClassVar[bool] = True
51+
52+
def __init__(self, data_dir: Path) -> None:
53+
"""Bind to the data directory."""
54+
super().__init__(data_dir)
55+
self._db_path = data_dir / ALPHAMISSENSE_DB_FILENAME
56+
self._conn: sqlite3.Connection | None = None
57+
58+
def _connection(self) -> sqlite3.Connection:
59+
if self._conn is None:
60+
if not self._db_path.exists():
61+
raise FileNotFoundError(
62+
f"AlphaMissense cache not found at {self._db_path}. "
63+
"Run `allelix db update` first."
64+
)
65+
self._conn = sqlite3.connect(self._db_path)
66+
return self._conn
67+
68+
def setup(self) -> None:
69+
"""Download the pre-built AlphaMissense cache from HuggingFace."""
70+
signal = self.fetch_remote_signal()
71+
gz_path = self.data_dir / "alphamissense.sqlite.gz"
72+
download(ALPHAMISSENSE_CACHE_URL, gz_path)
73+
install_prebuilt_cache(
74+
gz_path,
75+
self._db_path,
76+
source_url=ALPHAMISSENSE_CACHE_URL,
77+
remote_signal=signal,
78+
)
79+
try:
80+
gz_path.unlink()
81+
except OSError:
82+
logger.warning("Could not remove staged file at %s", gz_path)
83+
84+
def is_ready(self) -> bool:
85+
"""True when the AlphaMissense SQLite cache exists and is queryable."""
86+
return get_database_info(self._db_path, "alphamissense") is not None
87+
88+
def version(self) -> str | None:
89+
"""Return the cached database version, or None."""
90+
info = get_database_info(self._db_path, "alphamissense")
91+
return info["version"] if info else None
92+
93+
def record_count(self) -> int | None:
94+
"""Return the number of variants in the cache, or None."""
95+
info = get_database_info(self._db_path, "alphamissense")
96+
return info["record_count"] if info else None
97+
98+
def close(self) -> None:
99+
"""Close the SQLite connection if open."""
100+
if self._conn is not None:
101+
self._conn.close()
102+
self._conn = None
103+
104+
def fetch_remote_signal(self) -> str | None:
105+
"""Probe the HuggingFace asset URL for ETag or Last-Modified."""
106+
headers = head_request_headers(ALPHAMISSENSE_CACHE_URL)
107+
if headers is None:
108+
return None
109+
etag = headers.get("ETag") or headers.get("Etag")
110+
last_modified = headers.get("Last-Modified") or headers.get("Last-modified")
111+
if etag:
112+
return f"etag:{etag.strip()}"
113+
if last_modified:
114+
return f"lm:{last_modified.strip()}"
115+
return None
116+
117+
def cached_remote_signal(self) -> str | None:
118+
"""Return the remote signal stored at last successful download."""
119+
info = get_database_info(self._db_path, "alphamissense")
120+
if not info or not info["remote_signal"]:
121+
return None
122+
return info["remote_signal"]
123+
124+
def annotate(self, variant: Variant) -> list[Annotation]:
125+
"""Not used — AlphaMissense enriches, does not annotate. Always returns []."""
126+
return []
127+
128+
def lookup(self, rsid: str) -> tuple[float, str] | None:
129+
"""Return (am_pathogenicity, am_class) for a single rsID, or None."""
130+
conn = self._connection()
131+
row = conn.execute(
132+
"SELECT MAX(am_pathogenicity), am_class FROM alphamissense_scores WHERE rsid = ?",
133+
(rsid,),
134+
).fetchone()
135+
if row is None or row[0] is None:
136+
return None
137+
return (row[0], row[1])
138+
139+
def bulk_lookup(self, rsids: set[str]) -> dict[str, tuple[float, str]]:
140+
"""Return ``{rsid: (am_pathogenicity, am_class)}`` for found rsIDs.
141+
142+
When an rsID maps to multiple rows (different ref/alt at the same
143+
position), returns the highest pathogenicity score and its class —
144+
consistent with gnomAD's ``MAX(af)`` approach.
145+
146+
Batches into chunks of 900 to stay within SQLite's variable limit.
147+
"""
148+
if not rsids:
149+
return {}
150+
conn = self._connection()
151+
result: dict[str, tuple[float, str]] = {}
152+
rsid_list = list(rsids)
153+
for i in range(0, len(rsid_list), _BULK_BATCH_SIZE):
154+
batch = rsid_list[i : i + _BULK_BATCH_SIZE]
155+
placeholders = ",".join("?" * len(batch))
156+
rows = conn.execute(
157+
f"SELECT rsid, MAX(am_pathogenicity), am_class"
158+
f" FROM alphamissense_scores"
159+
f" WHERE rsid IN ({placeholders}) GROUP BY rsid",
160+
batch,
161+
).fetchall()
162+
for rsid, score, cls in rows:
163+
if score is not None:
164+
result[rsid] = (score, cls)
165+
return result

0 commit comments

Comments
 (0)