allelix 2.4.0__tar.gz → 2.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. allelix-2.4.2/MANIFEST.in +4 -0
  2. {allelix-2.4.0 → allelix-2.4.2}/PKG-INFO +29 -12
  3. {allelix-2.4.0 → allelix-2.4.2}/README.md +28 -11
  4. {allelix-2.4.0 → allelix-2.4.2}/allelix/__init__.py +2 -2
  5. {allelix-2.4.0 → allelix-2.4.2}/allelix/annotators/__init__.py +6 -1
  6. {allelix-2.4.0 → allelix-2.4.2}/allelix/annotators/_resolution.py +13 -6
  7. {allelix-2.4.0 → allelix-2.4.2}/allelix/annotators/alphamissense.py +7 -3
  8. {allelix-2.4.0 → allelix-2.4.2}/allelix/annotators/base.py +27 -0
  9. {allelix-2.4.0 → allelix-2.4.2}/allelix/annotators/cadd.py +4 -3
  10. {allelix-2.4.0 → allelix-2.4.2}/allelix/annotators/clinvar.py +109 -27
  11. {allelix-2.4.0 → allelix-2.4.2}/allelix/annotators/gnomad.py +4 -6
  12. {allelix-2.4.0 → allelix-2.4.2}/allelix/annotators/gwas.py +9 -3
  13. {allelix-2.4.0 → allelix-2.4.2}/allelix/annotators/pharmgkb.py +51 -76
  14. {allelix-2.4.0 → allelix-2.4.2}/allelix/annotators/rsid_identity.py +2 -1
  15. {allelix-2.4.0 → allelix-2.4.2}/allelix/annotators/snpedia.py +24 -12
  16. {allelix-2.4.0 → allelix-2.4.2}/allelix/cli/_helpers.py +157 -155
  17. {allelix-2.4.0 → allelix-2.4.2}/allelix/cli/_options.py +7 -4
  18. {allelix-2.4.0 → allelix-2.4.2}/allelix/cli/utility.py +206 -50
  19. {allelix-2.4.0 → allelix-2.4.2}/allelix/data/clinvar_clnsig_snapshot.yaml +4 -4
  20. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/_versions.py +12 -3
  21. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/cpic_loader.py +3 -3
  22. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/gwas_loader.py +2 -2
  23. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/manager.py +75 -18
  24. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/pharmgkb_loader.py +1 -1
  25. {allelix-2.4.0 → allelix-2.4.2}/allelix/exporters/plink.py +51 -4
  26. {allelix-2.4.0 → allelix-2.4.2}/allelix/models.py +14 -5
  27. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/ancestrydna.py +6 -3
  28. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/base.py +53 -15
  29. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/ftdna.py +8 -5
  30. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/ftdna_famfinder.py +12 -10
  31. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/ftdna_illumina.py +6 -3
  32. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/livingdna.py +6 -22
  33. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/myhappygenes.py +3 -3
  34. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/myheritage.py +11 -7
  35. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/twentythreeandme.py +4 -17
  36. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/vcf.py +14 -20
  37. {allelix-2.4.0 → allelix-2.4.2}/allelix/reports/_pipeline.py +334 -267
  38. allelix-2.4.2/allelix/reports/build_text.py +220 -0
  39. {allelix-2.4.0 → allelix-2.4.2}/allelix/reports/diff.py +2 -2
  40. {allelix-2.4.0 → allelix-2.4.2}/allelix/reports/html.py +16 -10
  41. {allelix-2.4.0 → allelix-2.4.2}/allelix/reports/json_report.py +14 -4
  42. {allelix-2.4.0 → allelix-2.4.2}/allelix/reports/terminal.py +2 -2
  43. {allelix-2.4.0 → allelix-2.4.2}/allelix/reports/vcf.py +7 -2
  44. {allelix-2.4.0 → allelix-2.4.2}/allelix/utils/allele.py +6 -5
  45. allelix-2.4.2/allelix/utils/build_detect.py +689 -0
  46. {allelix-2.4.0 → allelix-2.4.2}/allelix.egg-info/PKG-INFO +29 -12
  47. {allelix-2.4.0 → allelix-2.4.2}/allelix.egg-info/SOURCES.txt +3 -22
  48. {allelix-2.4.0 → allelix-2.4.2}/pyproject.toml +5 -5
  49. allelix-2.4.0/allelix/utils/build_detect.py +0 -262
  50. allelix-2.4.0/tests/test_allele_identity_invariants.py +0 -492
  51. allelix-2.4.0/tests/test_changelog_sanitize.py +0 -179
  52. allelix-2.4.0/tests/test_chip_gvcf_parity.py +0 -591
  53. allelix-2.4.0/tests/test_cli.py +0 -3300
  54. allelix-2.4.0/tests/test_cli_helpers.py +0 -138
  55. allelix-2.4.0/tests/test_cli_vcf_out.py +0 -363
  56. allelix-2.4.0/tests/test_compare.py +0 -160
  57. allelix-2.4.0/tests/test_config.py +0 -400
  58. allelix-2.4.0/tests/test_end_to_end.py +0 -548
  59. allelix-2.4.0/tests/test_extract_rsidless_gh128.py +0 -201
  60. allelix-2.4.0/tests/test_mock_data_invariants.py +0 -327
  61. allelix-2.4.0/tests/test_models.py +0 -221
  62. allelix-2.4.0/tests/test_myheritage_delimiter.py +0 -100
  63. allelix-2.4.0/tests/test_myheritage_lowpass.py +0 -148
  64. allelix-2.4.0/tests/test_original_rsid_never_serializes.py +0 -296
  65. allelix-2.4.0/tests/test_plausibility.py +0 -412
  66. allelix-2.4.0/tests/test_registry.py +0 -41
  67. allelix-2.4.0/tests/test_resolution_hoist.py +0 -306
  68. allelix-2.4.0/tests/test_rsid_identity.py +0 -674
  69. allelix-2.4.0/tests/test_symbolic_vcf_rejection.py +0 -104
  70. allelix-2.4.0/tests/test_version.py +0 -100
  71. {allelix-2.4.0 → allelix-2.4.2}/LICENSE +0 -0
  72. {allelix-2.4.0 → allelix-2.4.2}/allelix/cli/__init__.py +0 -0
  73. {allelix-2.4.0 → allelix-2.4.2}/allelix/cli/analyze.py +0 -0
  74. {allelix-2.4.0 → allelix-2.4.2}/allelix/cli/config.py +0 -0
  75. {allelix-2.4.0 → allelix-2.4.2}/allelix/cli/db.py +0 -0
  76. {allelix-2.4.0 → allelix-2.4.2}/allelix/cli/focused.py +0 -0
  77. {allelix-2.4.0 → allelix-2.4.2}/allelix/compare.py +0 -0
  78. {allelix-2.4.0 → allelix-2.4.2}/allelix/config.py +0 -0
  79. {allelix-2.4.0 → allelix-2.4.2}/allelix/data/__init__.py +0 -0
  80. {allelix-2.4.0 → allelix-2.4.2}/allelix/data/high_value_snps.yaml +0 -0
  81. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/__init__.py +0 -0
  82. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/alphamissense_loader.py +0 -0
  83. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/cadd_loader.py +0 -0
  84. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/gnomad_loader.py +0 -0
  85. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/loader_utils.py +0 -0
  86. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/rsid_identity_loader.py +0 -0
  87. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/schema.py +0 -0
  88. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/snpedia_loader.py +0 -0
  89. {allelix-2.4.0 → allelix-2.4.2}/allelix/databases/snpedia_parser.py +0 -0
  90. {allelix-2.4.0 → allelix-2.4.2}/allelix/exporters/__init__.py +0 -0
  91. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/__init__.py +0 -0
  92. {allelix-2.4.0 → allelix-2.4.2}/allelix/parsers/_helpers.py +0 -0
  93. {allelix-2.4.0 → allelix-2.4.2}/allelix/py.typed +0 -0
  94. {allelix-2.4.0 → allelix-2.4.2}/allelix/reports/__init__.py +0 -0
  95. {allelix-2.4.0 → allelix-2.4.2}/allelix/reports/high_value.py +0 -0
  96. {allelix-2.4.0 → allelix-2.4.2}/allelix/reports/methylation.py +0 -0
  97. {allelix-2.4.0 → allelix-2.4.2}/allelix/utils/__init__.py +0 -0
  98. {allelix-2.4.0 → allelix-2.4.2}/allelix/utils/plausibility.py +0 -0
  99. {allelix-2.4.0 → allelix-2.4.2}/allelix.egg-info/dependency_links.txt +0 -0
  100. {allelix-2.4.0 → allelix-2.4.2}/allelix.egg-info/entry_points.txt +0 -0
  101. {allelix-2.4.0 → allelix-2.4.2}/allelix.egg-info/requires.txt +0 -0
  102. {allelix-2.4.0 → allelix-2.4.2}/allelix.egg-info/top_level.txt +0 -0
  103. {allelix-2.4.0 → allelix-2.4.2}/setup.cfg +0 -0
@@ -0,0 +1,4 @@
1
+ # The source distribution ships what the package needs to build and install.
2
+ # setuptools adds tests/test*.py by default; the test suite (with its
3
+ # fixtures and conftest) lives in the repository, not in the distribution.
4
+ prune tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: allelix
3
- Version: 2.4.0
3
+ Version: 2.4.2
4
4
  Summary: Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first.
5
5
  Author: Allelix
6
6
  Maintainer-email: dial481 <dial481@users.noreply.github.com>
@@ -47,7 +47,7 @@ Dynamic: license-file
47
47
 
48
48
  [![python](https://img.shields.io/pypi/pyversions/allelix.svg)](https://www.python.org/downloads/)
49
49
  [![pypi](https://img.shields.io/pypi/v/allelix.svg)](https://pypi.org/project/allelix/)
50
- [![license](https://img.shields.io/pypi/l/allelix.svg)](LICENSE)
50
+ [![license](https://img.shields.io/pypi/l/allelix.svg)](https://github.com/allelix/allelix/blob/main/LICENSE)
51
51
  [![CI](https://github.com/allelix/allelix/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/allelix/allelix/actions/workflows/ci.yml)
52
52
  [![privacy: local-first](https://img.shields.io/badge/privacy-local--first-success)](#privacy)
53
53
 
@@ -55,7 +55,7 @@ Dynamic: license-file
55
55
 
56
56
  # Allelix
57
57
 
58
- > **Status:** Production. Nine parser formats (including VCF + gVCF), four annotators (ClinVar + ClinPGx + GWAS Catalog + SNPedia), three GRCh38 enrichment sources (gnomAD + AlphaMissense + CADD) reached from GRCh37 input through a cross-build rsID identity table, licensable-source gating for commercial users, dual-build ClinVar caches (GRCh37 + GRCh38), HTML/JSON/terminal reports and annotated VCF output for pipeline integration (v2.3.0+), methylation + pharmacogenomics focused commands, report diffing, persistent config with commercial-mode safety switch. Modern-build position detection requires at least three concordant seed SNPs and an 80% winning ratio before it overrides a fallback or header signal; a tentative GRCh36 signal instead fails closed to GRCh36 because no modern position cache is safe for it (ADR-0025, ADR-0037). No regex on prose anywhere in production. The [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md) tracks every release.
58
+ > **Status:** Production. Nine parser formats (including VCF + gVCF), four annotators (ClinVar + ClinPGx + GWAS Catalog + SNPedia), three GRCh38 enrichment sources (gnomAD + AlphaMissense + CADD) reached from GRCh37 input through a cross-build rsID identity table, licensable-source gating for commercial users, dual-build ClinVar caches (GRCh37 + GRCh38), HTML/JSON/terminal reports and annotated VCF output for pipeline integration (v2.3.0+), methylation + pharmacogenomics focused commands, report diffing, persistent config with commercial-mode safety switch. The genome build is decided before annotation from the evidence the file carries: VCF contig lengths, or known-SNP positions from the whole file. Evidence that contradicts the file's label, or a VCF with no usable evidence, stops the run and asks for `--build` instead of producing a report on the wrong build; a tentative GRCh36 signal fails closed to GRCh36 because no modern position cache is safe for it (ADR-0025, ADR-0037, ADR-0044). No regex on prose anywhere in production. The [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md) tracks every release.
59
59
 
60
60
  ## Quickstart
61
61
 
@@ -99,14 +99,14 @@ See [Development](#development) for source installs and running tests, [Managing
99
99
 
100
100
  | Format | Status | Notes |
101
101
  |---|---|---|
102
- | MyHappyGenes (Tempus) | ✓ | Tab-delimited, 5 columns. **Build is auto-detected** — real-world MHG exports mislabel the header as "build 37.1" while shipping GRCh38 coordinates. Allelix detects from position data and warns on header/data disagreement (ADR-0021). See [docs/formats.md](https://github.com/allelix/allelix/blob/main/docs/formats.md#myhappygenes-tempus). |
102
+ | MyHappyGenes (Tempus) | ✓ | Tab-delimited, 5 columns. **Build is auto-detected** — real-world MHG exports mislabel the header as "build 37.1" while shipping GRCh38 coordinates. Allelix decides the build from position data and warns on header/data disagreement (ADR-0021, ADR-0044). See [docs/formats.md](https://github.com/allelix/allelix/blob/main/docs/formats.md#myhappygenes-tempus). |
103
103
  | 23andMe | ✓ | Tab-delimited, 4 columns, concatenated genotype. Supports build 36/37/38 from header. I-prefixed probe IDs passed through. |
104
104
  | AncestryDNA | ✓ | Tab-delimited, 5 columns. Chromosome mapping: 23→X, 24→Y, 25→X (PAR), 26→MT. V1 and V2 chip layouts. |
105
- | Family Tree DNA | ✓ | CSV, double-quoted fields, concatenated genotype. Build 37 default. |
105
+ | Family Tree DNA | ✓ | CSV, double-quoted fields, concatenated genotype. Build 37 default when known-SNP positions are present but do not decide; with none at all and no build line, the run stops (ADR-0044). |
106
106
  | MyHeritage DNA | ✓ | CSV, same structure as FTDNA. Detected by "MyHeritage" in the file's preamble. Handles double-double-quoted fields and files re-saved through a spreadsheet (padded rows, signature not on line 1, tab-delimited copies). Two exports: the **array** export (pre-2026 kits) and the **low-pass WGS** export (kits from January 2026; genotypes imputed from about 2x coverage), which the report labels as imputed and whose `##reference=build37` line is used as the header build. See [docs/formats.md](https://github.com/allelix/allelix/blob/main/docs/formats.md#myheritage). |
107
107
  | Living DNA | ✓ | Tab-delimited despite `.csv` extension. Handles AX-, AFFX-prefixed and CHR:POS positional SNP IDs. |
108
- | FTDNA Illumina raw | ✓ | Tab-delimited variant of the FTDNA export (distinct from the CSV format above). `RSID/CHROMOSOME/POSITION/RESULT` columns. Build 37 default. |
109
- | FTDNA FamFinder | ✓ | Third FTDNA file shape: tab-delimited with **separate `ALLELE1` / `ALLELE2` columns** instead of a concatenated `RESULT`. Detected by the `famfinder` substring in the file header plus the 5-column canonical header. Build 37 default. |
108
+ | FTDNA Illumina raw | ✓ | Tab-delimited variant of the FTDNA export (distinct from the CSV format above). `RSID/CHROMOSOME/POSITION/RESULT` columns. Build 37 default when known-SNP positions are present but do not decide. |
109
+ | FTDNA FamFinder | ✓ | Third FTDNA file shape: tab-delimited with **separate `ALLELE1` / `ALLELE2` columns** instead of a concatenated `RESULT`. Detected by the `famfinder` substring in the file header plus the 5-column canonical header. Build 37 default when known-SNP positions are present but do not decide. |
110
110
  | VCF / gVCF | ✓ | REF/ALT encoding, `0/1` genotype notation. Plain VCF: absence at a position means reference. gVCF: explicit reference blocks (lines with `<NON_REF>` ALT and `END=` INFO) are not annotated — they match nothing in any annotation database — but they are read at high-value positions: a block with low genotype quality or no reads there makes the position a no-call, and so does a gVCF position no record covers. High-value no-calls list the reason (for example `low_gq_block`, `no_coverage`, `filtered`, `low_gq_call`). A plain VCF has no reference blocks, so a position without a record is reported as unknown rather than as reference or as a no-call. Multi-sample files require `--sample <ID>`. Rows the variant caller rejected (FILTER other than `PASS` or `.`) are skipped and counted in the report; `--keep-filtered` keeps them. A call with genotype quality below `--min-gq` (default 20) or with no reads (DP 0) is reported as a no-call. The spanning-deletion allele `*` is treated as symbolic. A VCF that holds only structural-variant or copy-number records (such as a provider's separate `sv` / `cnv` file) is rejected with a message naming the file to use instead; see [docs/formats.md](https://github.com/allelix/allelix/blob/main/docs/formats.md#sequencingcom-30x-whole-genome) for per-provider deliverables. Streams via stdlib; `.vcf.gz` handled transparently. Optional `pip install allelix[vcf-index]` enables pysam-backed tabix random access for fast `extract --snps` on huge VCFs. |
111
111
 
112
112
  Adding a new format means adding one file to `allelix/parsers/` and registering an instance in the `PARSERS` list in `allelix/parsers/__init__.py`.
@@ -126,7 +126,7 @@ Issues and milestones are tracked at [github.com/allelix/allelix/issues](https:/
126
126
 
127
127
  | Database | Status | Notes |
128
128
  |---|---|---|
129
- | ClinVar (GRCh37 + GRCh38) | ✓ | Public domain (NCBI). SNVs + indels + multi-allelic sites. **Both builds cached**; `analyze` dispatches by detected build (ADR-0021). Rows are matched by chromosome, position, REF and ALT, never by rsID alone, and reference-allele records (REF = ALT) are never reported (ADR-0039). Carrier rule (ADR-0007) requires the user to carry the ALT allele. Indel-anchor protection (ADR-0011) prevents single-base array readouts from matching anchor-base indels. |
129
+ | ClinVar (GRCh37 + GRCh38) | ✓ | Public domain (NCBI). SNVs + indels + multi-allelic sites. **Both builds cached**; `analyze` dispatches by the decided build (ADR-0044). Rows are matched by chromosome, position, REF and ALT, never by rsID alone, and reference-allele records (REF = ALT) are never reported (ADR-0039). Carrier rule (ADR-0007) requires the user to carry the ALT allele. Indel-anchor protection (ADR-0011) prevents single-base array readouts from matching anchor-base indels. |
130
130
  | ClinPGx (formerly PharmGKB) | ✓ | CC BY-SA 4.0. Clinical annotations only — single-rsid SNVs; star alleles and haplotypes deferred (ADR-0009). **Primary non-finding filter is the ClinVar REF carrier rule (ADR-0023):** if ClinVar publishes a single-base REF for the rsid and the user is homozygous for it, the row is suppressed. CPIC's `(rsid, base) → function_class` join (ADR-0020) survives as a secondary tier for rsids ClinVar doesn't catalog. Earlier prose tiers (ADR-0013, ADR-0017, ADR-0018) are superseded. |
131
131
  | CPIC (per-allele function table) | ✓ | Internal data source for the ClinPGx 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. |
132
132
  | SNPedia | ✓ | CC BY-NC-SA 3.0 US. Pre-built cache downloaded via `db update` (~216K wiki pages, ~105K genotype rows). The cache carries SNPedia's orientation metadata: minus-strand alleles are complemented before matching, while unknown-orientation palindromic genotypes abstain rather than risk a false match. If the database is absent, analysis runs without it. For commercial use, pass `--exclude-snpedia`. The cache can also be rebuilt from source via `scripts/scrape_snpedia.py` + `scripts/parse_snpedia.py`. |
@@ -383,11 +383,27 @@ If the failure is a network timeout on the same source repeatedly, the issue is
383
383
 
384
384
  Almost always a schema or interpreter version bump in a recent allelix release that invalidates the pre-bump cache. The fix is `allelix db update` — it'll re-ingest only the affected annotators (everything else signal-skips). See the relevant `CHANGELOG.md` entry for "Cache invalidation" notes per release.
385
385
 
386
- ### Build mismatch warning when analyzing a known-good file
386
+ ### How the genome build is decided, and why a run can stop
387
387
 
388
- Either: (a) the file's header claims one build and the position data confidently indicates another (real-world MHG / Tempus exports do this — header says 37, positions are 38), or (b) the position signal is too small or mixed to be authoritative. Allelix requires at least three concordant seed SNPs and an 80% winning ratio. A VCF record without an rsID counts as a seed when it sits at that seed's position on a build, so variant-caller output with an empty ID column is detected by position too. Weaker GRCh37/38 evidence is reported as tentative and the header or fallback remains effective. A tentative GRCh36 winner is the safety exception: Allelix uses GRCh36 so it cannot accidentally query a modern position cache. Modern position-keyed caches are unavailable for that build (see [`docs/grch36-liftover.md`](https://github.com/allelix/allelix/blob/main/docs/grch36-liftover.md)).
388
+ Before anything is annotated, Allelix decides the file's genome build from the evidence the file itself carries (ADR-0044):
389
389
 
390
- If you trust the file's header more than allelix's detection, force the build: `--build grch37` or `--build grch38`. The build banner in the analyze output will reflect what was actually used.
390
+ - **VCF contig lengths.** Every nuclear chromosome has a different length in GRCh37 and GRCh38, so the `##contig` lengths settle the build whatever the contig names look like (`chr1` or `1`). If chrM has the old hg19 length, mitochondrial records are not annotated and a note says so.
391
+ - **Known-SNP positions across the whole file.** A set of well-known SNPs sits at different positions in each build. Allelix reads the whole file for them before annotating (a few seconds on a full chip). At least three concordant positions and an 80% majority make the evidence confident (ADR-0037). A VCF record without an rsID counts when it sits at such a position.
392
+ - **The file's own label** (a build line in an array header, or a VCF `assembly=`) is used only when the evidence does not contradict it.
393
+
394
+ The banner says where the build came from, for example `Build: GRCh38 (from the contig lengths: 24 contig length(s) match GRCh38)`. If confident positions contradict the label (real-world MHG / Tempus exports say 37 and ship 38), Allelix uses the positions and prints a **Build mismatch** notice. An unlabelled chip whose known-SNP positions do not decide uses its format's documented default, with a visible note, provided at least one of those positions matches a build and the build most of them match is the default (a few that disagree do not stop it); the note gives the counts. A tentative GRCh36 winner is the safety exception: Allelix uses GRCh36 so it cannot query a modern position cache (see [`docs/grch36-liftover.md`](https://github.com/allelix/allelix/blob/main/docs/grch36-liftover.md)).
395
+
396
+ **The run stops** instead of guessing when:
397
+
398
+ - a VCF's contig lengths contradict its `assembly=`, contradict each other, or match no supported assembly;
399
+ - weaker (tentative) position evidence contradicts the file's label, or points an unlabelled chip away from its default;
400
+ - the known-SNP positions are tied between builds and the build that would otherwise decide (the label, the format default, or the contig-length build) is not one of them. When it is one of them, Allelix proceeds on it and prints an "inconclusive" warning, also shown in the HTML report. Without contig lengths, a tie that includes GRCh36 instead takes the GRCh36 safety exception described above, with the same warning;
401
+ - a VCF has no contig lengths, no assembly and too few known-SNP positions;
402
+ - a chip states no build and carries none of the known-SNP positions at all (for example an X-chromosome-only export, since they are all on autosomes);
403
+ - the file has known-SNP positions but none of them sits at any supported build's coordinate, whether or not it states a build (contig lengths, when a VCF declares them, still decide);
404
+ - contig lengths decided, but the known-SNP positions met while annotating point to the other build (for example a lifted-over file that kept its old header). The message says the annotation pass ran; nothing is written.
405
+
406
+ The message names the evidence and asks you to say which build the file uses: `--build grch37` or `--build grch38`, or `--build grch36` for an old GRCh36 / hg18 file, which then runs with position-keyed lookups off (see [`docs/grch36-liftover.md`](https://github.com/allelix/allelix/blob/main/docs/grch36-liftover.md)). For `compare`, use `--build1` / `--build2`. A stop leaves no report, annotated VCF, diff, PLINK files or extract table behind. A forced build is recorded as such in every report.
391
407
 
392
408
  ### Is my database stale? How fresh is the data?
393
409
 
@@ -419,6 +435,7 @@ Notable load-bearing ADRs:
419
435
  - **ADR-0038 — GWAS effect-modifier disambiguation.** Only positively identified odds ratios receive an effect-size modifier; beta and ambiguous effects abstain.
420
436
  - **ADR-0042 — VCF quality gate.** The variant caller's FILTER and genotype quality are honoured, low-quality reference blocks are no-calls, and every exclusion is counted in the report.
421
437
  - **ADR-0043 — One rsID-recovery step.** Every command that needs rsIDs recovers them for rsID-less input through the same shared step; build detection votes by position instead.
438
+ - **ADR-0044 — Evidence before labels.** The build is decided before annotation from contig lengths or whole-file known-SNP positions; evidence that contradicts the label, or no usable evidence, stops the run instead of producing a report on the wrong build.
422
439
  - **ADR-0041 — Plausibility flag.** Very rare homozygous calls and heterozygous calls with no gnomAD carriers are flagged for review, never suppressed.
423
440
 
424
441
  Release history: see [`CHANGELOG.md`](https://github.com/allelix/allelix/blob/main/CHANGELOG.md).
@@ -439,7 +456,7 @@ pytest
439
456
 
440
457
  The pre-commit hook enforces `ruff check` + `ruff format --check`. If a commit is blocked, fix the underlying problem rather than skipping the hook.
441
458
 
442
- For the full real-data release-validation battery — every parser format, VCF/gVCF, PLINK round-trip, edge cases, the GIAB/1000G corpus — see [`test_data/FULL_TEST_PROTOCOL.md`](test_data/FULL_TEST_PROTOCOL.md). That document is the project's real-data testing story; running `pytest` covers the fast suite that gates every commit.
459
+ For the full real-data release-validation battery — every parser format, VCF/gVCF, PLINK round-trip, edge cases, the GIAB/1000G corpus — see [`test_data/FULL_TEST_PROTOCOL.md`](https://github.com/allelix/allelix/blob/main/test_data/FULL_TEST_PROTOCOL.md). That document is the project's real-data testing story; running `pytest` covers the fast suite that gates every commit.
443
460
 
444
461
  ## License
445
462
 
@@ -8,7 +8,7 @@
8
8
 
9
9
  [![python](https://img.shields.io/pypi/pyversions/allelix.svg)](https://www.python.org/downloads/)
10
10
  [![pypi](https://img.shields.io/pypi/v/allelix.svg)](https://pypi.org/project/allelix/)
11
- [![license](https://img.shields.io/pypi/l/allelix.svg)](LICENSE)
11
+ [![license](https://img.shields.io/pypi/l/allelix.svg)](https://github.com/allelix/allelix/blob/main/LICENSE)
12
12
  [![CI](https://github.com/allelix/allelix/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/allelix/allelix/actions/workflows/ci.yml)
13
13
  [![privacy: local-first](https://img.shields.io/badge/privacy-local--first-success)](#privacy)
14
14
 
@@ -16,7 +16,7 @@
16
16
 
17
17
  # Allelix
18
18
 
19
- > **Status:** Production. Nine parser formats (including VCF + gVCF), four annotators (ClinVar + ClinPGx + GWAS Catalog + SNPedia), three GRCh38 enrichment sources (gnomAD + AlphaMissense + CADD) reached from GRCh37 input through a cross-build rsID identity table, licensable-source gating for commercial users, dual-build ClinVar caches (GRCh37 + GRCh38), HTML/JSON/terminal reports and annotated VCF output for pipeline integration (v2.3.0+), methylation + pharmacogenomics focused commands, report diffing, persistent config with commercial-mode safety switch. Modern-build position detection requires at least three concordant seed SNPs and an 80% winning ratio before it overrides a fallback or header signal; a tentative GRCh36 signal instead fails closed to GRCh36 because no modern position cache is safe for it (ADR-0025, ADR-0037). No regex on prose anywhere in production. The [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md) tracks every release.
19
+ > **Status:** Production. Nine parser formats (including VCF + gVCF), four annotators (ClinVar + ClinPGx + GWAS Catalog + SNPedia), three GRCh38 enrichment sources (gnomAD + AlphaMissense + CADD) reached from GRCh37 input through a cross-build rsID identity table, licensable-source gating for commercial users, dual-build ClinVar caches (GRCh37 + GRCh38), HTML/JSON/terminal reports and annotated VCF output for pipeline integration (v2.3.0+), methylation + pharmacogenomics focused commands, report diffing, persistent config with commercial-mode safety switch. The genome build is decided before annotation from the evidence the file carries: VCF contig lengths, or known-SNP positions from the whole file. Evidence that contradicts the file's label, or a VCF with no usable evidence, stops the run and asks for `--build` instead of producing a report on the wrong build; a tentative GRCh36 signal fails closed to GRCh36 because no modern position cache is safe for it (ADR-0025, ADR-0037, ADR-0044). No regex on prose anywhere in production. The [Changelog](https://github.com/allelix/allelix/blob/main/CHANGELOG.md) tracks every release.
20
20
 
21
21
  ## Quickstart
22
22
 
@@ -60,14 +60,14 @@ See [Development](#development) for source installs and running tests, [Managing
60
60
 
61
61
  | Format | Status | Notes |
62
62
  |---|---|---|
63
- | MyHappyGenes (Tempus) | ✓ | Tab-delimited, 5 columns. **Build is auto-detected** — real-world MHG exports mislabel the header as "build 37.1" while shipping GRCh38 coordinates. Allelix detects from position data and warns on header/data disagreement (ADR-0021). See [docs/formats.md](https://github.com/allelix/allelix/blob/main/docs/formats.md#myhappygenes-tempus). |
63
+ | MyHappyGenes (Tempus) | ✓ | Tab-delimited, 5 columns. **Build is auto-detected** — real-world MHG exports mislabel the header as "build 37.1" while shipping GRCh38 coordinates. Allelix decides the build from position data and warns on header/data disagreement (ADR-0021, ADR-0044). See [docs/formats.md](https://github.com/allelix/allelix/blob/main/docs/formats.md#myhappygenes-tempus). |
64
64
  | 23andMe | ✓ | Tab-delimited, 4 columns, concatenated genotype. Supports build 36/37/38 from header. I-prefixed probe IDs passed through. |
65
65
  | AncestryDNA | ✓ | Tab-delimited, 5 columns. Chromosome mapping: 23→X, 24→Y, 25→X (PAR), 26→MT. V1 and V2 chip layouts. |
66
- | Family Tree DNA | ✓ | CSV, double-quoted fields, concatenated genotype. Build 37 default. |
66
+ | Family Tree DNA | ✓ | CSV, double-quoted fields, concatenated genotype. Build 37 default when known-SNP positions are present but do not decide; with none at all and no build line, the run stops (ADR-0044). |
67
67
  | MyHeritage DNA | ✓ | CSV, same structure as FTDNA. Detected by "MyHeritage" in the file's preamble. Handles double-double-quoted fields and files re-saved through a spreadsheet (padded rows, signature not on line 1, tab-delimited copies). Two exports: the **array** export (pre-2026 kits) and the **low-pass WGS** export (kits from January 2026; genotypes imputed from about 2x coverage), which the report labels as imputed and whose `##reference=build37` line is used as the header build. See [docs/formats.md](https://github.com/allelix/allelix/blob/main/docs/formats.md#myheritage). |
68
68
  | Living DNA | ✓ | Tab-delimited despite `.csv` extension. Handles AX-, AFFX-prefixed and CHR:POS positional SNP IDs. |
69
- | FTDNA Illumina raw | ✓ | Tab-delimited variant of the FTDNA export (distinct from the CSV format above). `RSID/CHROMOSOME/POSITION/RESULT` columns. Build 37 default. |
70
- | FTDNA FamFinder | ✓ | Third FTDNA file shape: tab-delimited with **separate `ALLELE1` / `ALLELE2` columns** instead of a concatenated `RESULT`. Detected by the `famfinder` substring in the file header plus the 5-column canonical header. Build 37 default. |
69
+ | FTDNA Illumina raw | ✓ | Tab-delimited variant of the FTDNA export (distinct from the CSV format above). `RSID/CHROMOSOME/POSITION/RESULT` columns. Build 37 default when known-SNP positions are present but do not decide. |
70
+ | FTDNA FamFinder | ✓ | Third FTDNA file shape: tab-delimited with **separate `ALLELE1` / `ALLELE2` columns** instead of a concatenated `RESULT`. Detected by the `famfinder` substring in the file header plus the 5-column canonical header. Build 37 default when known-SNP positions are present but do not decide. |
71
71
  | VCF / gVCF | ✓ | REF/ALT encoding, `0/1` genotype notation. Plain VCF: absence at a position means reference. gVCF: explicit reference blocks (lines with `<NON_REF>` ALT and `END=` INFO) are not annotated — they match nothing in any annotation database — but they are read at high-value positions: a block with low genotype quality or no reads there makes the position a no-call, and so does a gVCF position no record covers. High-value no-calls list the reason (for example `low_gq_block`, `no_coverage`, `filtered`, `low_gq_call`). A plain VCF has no reference blocks, so a position without a record is reported as unknown rather than as reference or as a no-call. Multi-sample files require `--sample <ID>`. Rows the variant caller rejected (FILTER other than `PASS` or `.`) are skipped and counted in the report; `--keep-filtered` keeps them. A call with genotype quality below `--min-gq` (default 20) or with no reads (DP 0) is reported as a no-call. The spanning-deletion allele `*` is treated as symbolic. A VCF that holds only structural-variant or copy-number records (such as a provider's separate `sv` / `cnv` file) is rejected with a message naming the file to use instead; see [docs/formats.md](https://github.com/allelix/allelix/blob/main/docs/formats.md#sequencingcom-30x-whole-genome) for per-provider deliverables. Streams via stdlib; `.vcf.gz` handled transparently. Optional `pip install allelix[vcf-index]` enables pysam-backed tabix random access for fast `extract --snps` on huge VCFs. |
72
72
 
73
73
  Adding a new format means adding one file to `allelix/parsers/` and registering an instance in the `PARSERS` list in `allelix/parsers/__init__.py`.
@@ -87,7 +87,7 @@ Issues and milestones are tracked at [github.com/allelix/allelix/issues](https:/
87
87
 
88
88
  | Database | Status | Notes |
89
89
  |---|---|---|
90
- | ClinVar (GRCh37 + GRCh38) | ✓ | Public domain (NCBI). SNVs + indels + multi-allelic sites. **Both builds cached**; `analyze` dispatches by detected build (ADR-0021). Rows are matched by chromosome, position, REF and ALT, never by rsID alone, and reference-allele records (REF = ALT) are never reported (ADR-0039). Carrier rule (ADR-0007) requires the user to carry the ALT allele. Indel-anchor protection (ADR-0011) prevents single-base array readouts from matching anchor-base indels. |
90
+ | ClinVar (GRCh37 + GRCh38) | ✓ | Public domain (NCBI). SNVs + indels + multi-allelic sites. **Both builds cached**; `analyze` dispatches by the decided build (ADR-0044). Rows are matched by chromosome, position, REF and ALT, never by rsID alone, and reference-allele records (REF = ALT) are never reported (ADR-0039). Carrier rule (ADR-0007) requires the user to carry the ALT allele. Indel-anchor protection (ADR-0011) prevents single-base array readouts from matching anchor-base indels. |
91
91
  | ClinPGx (formerly PharmGKB) | ✓ | CC BY-SA 4.0. Clinical annotations only — single-rsid SNVs; star alleles and haplotypes deferred (ADR-0009). **Primary non-finding filter is the ClinVar REF carrier rule (ADR-0023):** if ClinVar publishes a single-base REF for the rsid and the user is homozygous for it, the row is suppressed. CPIC's `(rsid, base) → function_class` join (ADR-0020) survives as a secondary tier for rsids ClinVar doesn't catalog. Earlier prose tiers (ADR-0013, ADR-0017, ADR-0018) are superseded. |
92
92
  | CPIC (per-allele function table) | ✓ | Internal data source for the ClinPGx 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. |
93
93
  | SNPedia | ✓ | CC BY-NC-SA 3.0 US. Pre-built cache downloaded via `db update` (~216K wiki pages, ~105K genotype rows). The cache carries SNPedia's orientation metadata: minus-strand alleles are complemented before matching, while unknown-orientation palindromic genotypes abstain rather than risk a false match. If the database is absent, analysis runs without it. For commercial use, pass `--exclude-snpedia`. The cache can also be rebuilt from source via `scripts/scrape_snpedia.py` + `scripts/parse_snpedia.py`. |
@@ -344,11 +344,27 @@ If the failure is a network timeout on the same source repeatedly, the issue is
344
344
 
345
345
  Almost always a schema or interpreter version bump in a recent allelix release that invalidates the pre-bump cache. The fix is `allelix db update` — it'll re-ingest only the affected annotators (everything else signal-skips). See the relevant `CHANGELOG.md` entry for "Cache invalidation" notes per release.
346
346
 
347
- ### Build mismatch warning when analyzing a known-good file
347
+ ### How the genome build is decided, and why a run can stop
348
348
 
349
- Either: (a) the file's header claims one build and the position data confidently indicates another (real-world MHG / Tempus exports do this — header says 37, positions are 38), or (b) the position signal is too small or mixed to be authoritative. Allelix requires at least three concordant seed SNPs and an 80% winning ratio. A VCF record without an rsID counts as a seed when it sits at that seed's position on a build, so variant-caller output with an empty ID column is detected by position too. Weaker GRCh37/38 evidence is reported as tentative and the header or fallback remains effective. A tentative GRCh36 winner is the safety exception: Allelix uses GRCh36 so it cannot accidentally query a modern position cache. Modern position-keyed caches are unavailable for that build (see [`docs/grch36-liftover.md`](https://github.com/allelix/allelix/blob/main/docs/grch36-liftover.md)).
349
+ Before anything is annotated, Allelix decides the file's genome build from the evidence the file itself carries (ADR-0044):
350
350
 
351
- If you trust the file's header more than allelix's detection, force the build: `--build grch37` or `--build grch38`. The build banner in the analyze output will reflect what was actually used.
351
+ - **VCF contig lengths.** Every nuclear chromosome has a different length in GRCh37 and GRCh38, so the `##contig` lengths settle the build whatever the contig names look like (`chr1` or `1`). If chrM has the old hg19 length, mitochondrial records are not annotated and a note says so.
352
+ - **Known-SNP positions across the whole file.** A set of well-known SNPs sits at different positions in each build. Allelix reads the whole file for them before annotating (a few seconds on a full chip). At least three concordant positions and an 80% majority make the evidence confident (ADR-0037). A VCF record without an rsID counts when it sits at such a position.
353
+ - **The file's own label** (a build line in an array header, or a VCF `assembly=`) is used only when the evidence does not contradict it.
354
+
355
+ The banner says where the build came from, for example `Build: GRCh38 (from the contig lengths: 24 contig length(s) match GRCh38)`. If confident positions contradict the label (real-world MHG / Tempus exports say 37 and ship 38), Allelix uses the positions and prints a **Build mismatch** notice. An unlabelled chip whose known-SNP positions do not decide uses its format's documented default, with a visible note, provided at least one of those positions matches a build and the build most of them match is the default (a few that disagree do not stop it); the note gives the counts. A tentative GRCh36 winner is the safety exception: Allelix uses GRCh36 so it cannot query a modern position cache (see [`docs/grch36-liftover.md`](https://github.com/allelix/allelix/blob/main/docs/grch36-liftover.md)).
356
+
357
+ **The run stops** instead of guessing when:
358
+
359
+ - a VCF's contig lengths contradict its `assembly=`, contradict each other, or match no supported assembly;
360
+ - weaker (tentative) position evidence contradicts the file's label, or points an unlabelled chip away from its default;
361
+ - the known-SNP positions are tied between builds and the build that would otherwise decide (the label, the format default, or the contig-length build) is not one of them. When it is one of them, Allelix proceeds on it and prints an "inconclusive" warning, also shown in the HTML report. Without contig lengths, a tie that includes GRCh36 instead takes the GRCh36 safety exception described above, with the same warning;
362
+ - a VCF has no contig lengths, no assembly and too few known-SNP positions;
363
+ - a chip states no build and carries none of the known-SNP positions at all (for example an X-chromosome-only export, since they are all on autosomes);
364
+ - the file has known-SNP positions but none of them sits at any supported build's coordinate, whether or not it states a build (contig lengths, when a VCF declares them, still decide);
365
+ - contig lengths decided, but the known-SNP positions met while annotating point to the other build (for example a lifted-over file that kept its old header). The message says the annotation pass ran; nothing is written.
366
+
367
+ The message names the evidence and asks you to say which build the file uses: `--build grch37` or `--build grch38`, or `--build grch36` for an old GRCh36 / hg18 file, which then runs with position-keyed lookups off (see [`docs/grch36-liftover.md`](https://github.com/allelix/allelix/blob/main/docs/grch36-liftover.md)). For `compare`, use `--build1` / `--build2`. A stop leaves no report, annotated VCF, diff, PLINK files or extract table behind. A forced build is recorded as such in every report.
352
368
 
353
369
  ### Is my database stale? How fresh is the data?
354
370
 
@@ -380,6 +396,7 @@ Notable load-bearing ADRs:
380
396
  - **ADR-0038 — GWAS effect-modifier disambiguation.** Only positively identified odds ratios receive an effect-size modifier; beta and ambiguous effects abstain.
381
397
  - **ADR-0042 — VCF quality gate.** The variant caller's FILTER and genotype quality are honoured, low-quality reference blocks are no-calls, and every exclusion is counted in the report.
382
398
  - **ADR-0043 — One rsID-recovery step.** Every command that needs rsIDs recovers them for rsID-less input through the same shared step; build detection votes by position instead.
399
+ - **ADR-0044 — Evidence before labels.** The build is decided before annotation from contig lengths or whole-file known-SNP positions; evidence that contradicts the label, or no usable evidence, stops the run instead of producing a report on the wrong build.
383
400
  - **ADR-0041 — Plausibility flag.** Very rare homozygous calls and heterozygous calls with no gnomAD carriers are flagged for review, never suppressed.
384
401
 
385
402
  Release history: see [`CHANGELOG.md`](https://github.com/allelix/allelix/blob/main/CHANGELOG.md).
@@ -400,7 +417,7 @@ pytest
400
417
 
401
418
  The pre-commit hook enforces `ruff check` + `ruff format --check`. If a commit is blocked, fix the underlying problem rather than skipping the hook.
402
419
 
403
- For the full real-data release-validation battery — every parser format, VCF/gVCF, PLINK round-trip, edge cases, the GIAB/1000G corpus — see [`test_data/FULL_TEST_PROTOCOL.md`](test_data/FULL_TEST_PROTOCOL.md). That document is the project's real-data testing story; running `pytest` covers the fast suite that gates every commit.
420
+ For the full real-data release-validation battery — every parser format, VCF/gVCF, PLINK round-trip, edge cases, the GIAB/1000G corpus — see [`test_data/FULL_TEST_PROTOCOL.md`](https://github.com/allelix/allelix/blob/main/test_data/FULL_TEST_PROTOCOL.md). That document is the project's real-data testing story; running `pytest` covers the fast suite that gates every commit.
404
421
 
405
422
  ## License
406
423
 
@@ -10,8 +10,8 @@ from importlib.metadata import PackageNotFoundError, version
10
10
  def _read_pyproject_version() -> str | None:
11
11
  """Read the package version from ``pyproject.toml``.
12
12
 
13
- fall back to ``pyproject.toml`` when run from a bare source
14
- checkout (no editable install, no installed package metadata). Keeps
13
+ This is the fallback when run from a bare source
14
+ checkout (no editable install, no installed package metadata). It keeps
15
15
  ``--version`` and the outbound HTTP User-Agent reporting the real
16
16
  version string instead of the ``0.0.0+local`` sentinel that
17
17
  misidentifies our traffic to NCBI / EBI / HuggingFace.
@@ -54,9 +54,14 @@ def get_annotators(
54
54
  data_dir,
55
55
  clinvar_ref_provider=clinvar.reference_for,
56
56
  palindromic_provider=rsid_identity.palindromic_rsids,
57
+ normal_allele_provider=clinvar.normal_allele_for,
57
58
  )
58
59
  gwas = GWASCatalogAnnotator(data_dir, filter_traits=gwas_filter_traits)
59
- snpedia = SNPediaAnnotator(data_dir, clinvar_ref_provider=clinvar.reference_for)
60
+ snpedia = SNPediaAnnotator(
61
+ data_dir,
62
+ clinvar_ref_provider=clinvar.reference_for,
63
+ normal_allele_provider=clinvar.normal_allele_for,
64
+ )
60
65
  gnomad = GnomadAnnotator(data_dir)
61
66
  alphamissense = AlphaMissenseAnnotator(data_dir)
62
67
  cadd = CaddAnnotator(data_dir, full_mode=cadd_full)
@@ -129,11 +129,18 @@ def resolve_rsidless(
129
129
 
130
130
 
131
131
  def _populate_ref_from_identity(identity: RsidIdentityAnnotator, variants: list[Variant]) -> None:
132
- """Set ``Variant.ref`` from the identity table's REF at the variant's locus.
132
+ """Set ``Variant.ref`` from the identity table's REF for the call's own rsID.
133
133
 
134
- Only for calls that carry an rsID, and only when the locus has a single
135
- REF of the call's shape: single-base for single-base calls (an indel
136
- row's anchor REF can share the position), any length otherwise.
134
+ Only for calls that carry an rsID, only from the identity rows of that
135
+ rsID at the variant's locus, and only when those rows give a single REF
136
+ of the call's shape: single-base for single-base calls (an indel row's
137
+ anchor REF can share the position), any length otherwise.
138
+
139
+ A call whose rsID the identity table does not hold (a withdrawn or
140
+ merged ID) gets no REF, even when another rsID's rows sit at the same
141
+ position: nothing confirms that the other rsID is the same variant.
142
+ Such a call keeps ``ref`` None, and ClinVar matches it by locus only
143
+ when ClinVar holds its rsID.
137
144
 
138
145
  A call without an rsID (array vendor probes such as 23andMe
139
146
  ``i5000123``) never gets a REF from position alone. The REF at a locus
@@ -154,9 +161,9 @@ def _populate_ref_from_identity(identity: RsidIdentityAnnotator, variants: list[
154
161
  ]
155
162
  for build in {v.build for v in pending}:
156
163
  group = [v for v in pending if v.build == build]
157
- refs_at = identity.refs_at_loci(build, {variant_locus(v) for v in group})
164
+ rows_at = identity.alleles_at_loci(build, {variant_locus(v) for v in group})
158
165
  for v in group:
159
- refs = refs_at.get(variant_locus(v), set())
166
+ refs = {ref for ref, _alt, rsid in rows_at.get(variant_locus(v), []) if rsid == v.rsid}
160
167
  if len(v.allele1) <= 1 and len(v.allele2) <= 1:
161
168
  refs = {r for r in refs if len(r) == 1}
162
169
  if len(refs) == 1:
@@ -129,12 +129,16 @@ class AlphaMissenseAnnotator(Annotator):
129
129
  logger.warning("Could not remove staged file at %s", gz_path)
130
130
 
131
131
  def is_ready(self) -> bool:
132
- """True when the AlphaMissense SQLite cache exists with current schema version."""
132
+ """True when the AlphaMissense SQLite cache exists with current schema version.
133
+
134
+ A cache with no ``local_version_tag`` is not ready: its schema version
135
+ is unknown, so it could pass as the current one after a bump. The
136
+ installer stamps the current version, so ``db update`` replaces it.
137
+ """
133
138
  info = get_database_info(self._db_path, "alphamissense")
134
139
  if info is None:
135
140
  return False
136
- tag = info.get("local_version_tag") or ""
137
- return tag == f"sv:{ALPHAMISSENSE_SCHEMA_VERSION}" or not tag
141
+ return info.get("local_version_tag") == f"sv:{ALPHAMISSENSE_SCHEMA_VERSION}"
138
142
 
139
143
  def version(self) -> str | None:
140
144
  """Return the cached database version, or None."""
@@ -10,6 +10,7 @@ from enum import Enum, auto
10
10
  from typing import TYPE_CHECKING, ClassVar
11
11
 
12
12
  from allelix.parsers._helpers import normalize_chromosome
13
+ from allelix.utils.allele import derive_alt_from_diploid
13
14
 
14
15
  if TYPE_CHECKING:
15
16
  from collections.abc import Callable, Iterable, Iterator
@@ -95,6 +96,32 @@ def is_clinvar_homref(
95
96
  return ref is not None and len(ref) == 1 and variant.allele1 == ref and variant.allele2 == ref
96
97
 
97
98
 
99
+ def carried_alt(
100
+ variant: Variant,
101
+ allele1: str,
102
+ allele2: str,
103
+ normal_allele_provider: Callable[[str, str], str | None] | None,
104
+ ) -> tuple[str, bool]:
105
+ """The non-normal allele of a matched genotype, for per-genotype sources.
106
+
107
+ Returns ``(alt, reference_carries_alt)``. The alt is derived against the
108
+ normal allele, which is ``variant.ref`` except where the build's
109
+ reference carries a ClinVar variant allele: there
110
+ ``normal_allele_provider`` (``ClinVarAnnotator.normal_allele_for``) gives
111
+ the normal allele from the other build (C at F5 rs6025 on GRCh37, whose
112
+ reference is the Leiden T). The alt is then the same on both builds,
113
+ and it can equal ``variant.ref``, which the Annotation is told through
114
+ ``reference_carries_alt``.
115
+ """
116
+ normal = variant.ref
117
+ if normal is not None and normal_allele_provider is not None and variant.rsid.startswith("rs"):
118
+ other = normal_allele_provider(variant.rsid, variant.build)
119
+ if other is not None and len(other) == 1 and other != normal:
120
+ normal = other
121
+ alt = derive_alt_from_diploid(normal, allele1, allele2)
122
+ return alt, bool(alt) and alt == variant.ref
123
+
124
+
98
125
  class Annotator(ABC):
99
126
  """Base class for all reference database annotators.
100
127
 
@@ -158,7 +158,9 @@ class CaddAnnotator(Annotator):
158
158
  """True when the active backend is available.
159
159
 
160
160
  In full mode, checks for the tabix file. In cache mode, checks
161
- the SQLite database.
161
+ the SQLite database, which must carry the current schema stamp: a
162
+ cache with no ``local_version_tag`` has an unknown schema version, so
163
+ it is not ready and ``db update`` replaces it.
162
164
  """
163
165
  if self._full_mode:
164
166
  return (
@@ -168,8 +170,7 @@ class CaddAnnotator(Annotator):
168
170
  info = get_database_info(self._db_path, "cadd")
169
171
  if info is None:
170
172
  return False
171
- tag = info.get("local_version_tag") or ""
172
- return tag == f"sv:{CADD_SCHEMA_VERSION}" or not tag
173
+ return info.get("local_version_tag") == f"sv:{CADD_SCHEMA_VERSION}"
173
174
 
174
175
  def version(self) -> str | None:
175
176
  """Return the cached database version, or None."""