allelix 2.1.2__tar.gz → 2.2.1__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 (85) hide show
  1. {allelix-2.1.2 → allelix-2.2.1}/PKG-INFO +28 -9
  2. {allelix-2.1.2 → allelix-2.2.1}/README.md +27 -8
  3. {allelix-2.1.2 → allelix-2.2.1}/allelix/annotators/clinvar.py +240 -68
  4. {allelix-2.1.2 → allelix-2.2.1}/allelix/cli/_helpers.py +59 -0
  5. {allelix-2.1.2 → allelix-2.2.1}/allelix/cli/_options.py +19 -0
  6. {allelix-2.1.2 → allelix-2.2.1}/allelix/cli/config.py +10 -18
  7. {allelix-2.1.2 → allelix-2.2.1}/allelix/cli/db.py +242 -0
  8. {allelix-2.1.2 → allelix-2.2.1}/allelix/cli/focused.py +3 -3
  9. {allelix-2.1.2 → allelix-2.2.1}/allelix/config.py +31 -13
  10. {allelix-2.1.2 → allelix-2.2.1}/allelix/data/clinvar_clnsig_snapshot.yaml +13 -1
  11. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/_versions.py +7 -1
  12. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/gwas_loader.py +10 -0
  13. allelix-2.2.1/allelix/databases/manager.py +774 -0
  14. {allelix-2.1.2 → allelix-2.2.1}/allelix/parsers/__init__.py +2 -0
  15. allelix-2.2.1/allelix/parsers/ftdna_famfinder.py +247 -0
  16. {allelix-2.1.2 → allelix-2.2.1}/allelix/parsers/vcf.py +26 -4
  17. {allelix-2.1.2 → allelix-2.2.1}/allelix/reports/__init__.py +3 -3
  18. {allelix-2.1.2 → allelix-2.2.1}/allelix/reports/_pipeline.py +178 -1
  19. {allelix-2.1.2 → allelix-2.2.1}/allelix/reports/diff.py +1 -1
  20. {allelix-2.1.2 → allelix-2.2.1}/allelix/reports/html.py +81 -1
  21. {allelix-2.1.2 → allelix-2.2.1}/allelix/reports/json_report.py +14 -2
  22. {allelix-2.1.2 → allelix-2.2.1}/allelix/reports/terminal.py +48 -0
  23. {allelix-2.1.2 → allelix-2.2.1}/allelix/utils/allele.py +18 -0
  24. {allelix-2.1.2 → allelix-2.2.1}/allelix.egg-info/PKG-INFO +28 -9
  25. {allelix-2.1.2 → allelix-2.2.1}/allelix.egg-info/SOURCES.txt +2 -0
  26. {allelix-2.1.2 → allelix-2.2.1}/pyproject.toml +24 -7
  27. allelix-2.2.1/tests/test_changelog_sanitize.py +83 -0
  28. {allelix-2.1.2 → allelix-2.2.1}/tests/test_cli.py +521 -42
  29. {allelix-2.1.2 → allelix-2.2.1}/tests/test_config.py +23 -0
  30. {allelix-2.1.2 → allelix-2.2.1}/tests/test_mock_data_invariants.py +0 -137
  31. allelix-2.1.2/allelix/databases/manager.py +0 -532
  32. {allelix-2.1.2 → allelix-2.2.1}/LICENSE +0 -0
  33. {allelix-2.1.2 → allelix-2.2.1}/allelix/__init__.py +0 -0
  34. {allelix-2.1.2 → allelix-2.2.1}/allelix/annotators/__init__.py +0 -0
  35. {allelix-2.1.2 → allelix-2.2.1}/allelix/annotators/alphamissense.py +0 -0
  36. {allelix-2.1.2 → allelix-2.2.1}/allelix/annotators/base.py +0 -0
  37. {allelix-2.1.2 → allelix-2.2.1}/allelix/annotators/cadd.py +0 -0
  38. {allelix-2.1.2 → allelix-2.2.1}/allelix/annotators/gnomad.py +0 -0
  39. {allelix-2.1.2 → allelix-2.2.1}/allelix/annotators/gwas.py +0 -0
  40. {allelix-2.1.2 → allelix-2.2.1}/allelix/annotators/pharmgkb.py +0 -0
  41. {allelix-2.1.2 → allelix-2.2.1}/allelix/annotators/snpedia.py +0 -0
  42. {allelix-2.1.2 → allelix-2.2.1}/allelix/cli/__init__.py +0 -0
  43. {allelix-2.1.2 → allelix-2.2.1}/allelix/cli/analyze.py +0 -0
  44. {allelix-2.1.2 → allelix-2.2.1}/allelix/cli/utility.py +0 -0
  45. {allelix-2.1.2 → allelix-2.2.1}/allelix/compare.py +0 -0
  46. {allelix-2.1.2 → allelix-2.2.1}/allelix/data/__init__.py +0 -0
  47. {allelix-2.1.2 → allelix-2.2.1}/allelix/data/high_value_snps.yaml +0 -0
  48. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/__init__.py +0 -0
  49. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/alphamissense_loader.py +0 -0
  50. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/cadd_loader.py +0 -0
  51. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/cpic_loader.py +0 -0
  52. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/gnomad_loader.py +0 -0
  53. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/loader_utils.py +0 -0
  54. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/pharmgkb_loader.py +0 -0
  55. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/schema.py +0 -0
  56. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/snpedia_loader.py +0 -0
  57. {allelix-2.1.2 → allelix-2.2.1}/allelix/databases/snpedia_parser.py +0 -0
  58. {allelix-2.1.2 → allelix-2.2.1}/allelix/exporters/__init__.py +0 -0
  59. {allelix-2.1.2 → allelix-2.2.1}/allelix/exporters/plink.py +0 -0
  60. {allelix-2.1.2 → allelix-2.2.1}/allelix/models.py +0 -0
  61. {allelix-2.1.2 → allelix-2.2.1}/allelix/parsers/_helpers.py +0 -0
  62. {allelix-2.1.2 → allelix-2.2.1}/allelix/parsers/ancestrydna.py +0 -0
  63. {allelix-2.1.2 → allelix-2.2.1}/allelix/parsers/base.py +0 -0
  64. {allelix-2.1.2 → allelix-2.2.1}/allelix/parsers/ftdna.py +0 -0
  65. {allelix-2.1.2 → allelix-2.2.1}/allelix/parsers/ftdna_illumina.py +0 -0
  66. {allelix-2.1.2 → allelix-2.2.1}/allelix/parsers/livingdna.py +0 -0
  67. {allelix-2.1.2 → allelix-2.2.1}/allelix/parsers/myhappygenes.py +0 -0
  68. {allelix-2.1.2 → allelix-2.2.1}/allelix/parsers/myheritage.py +0 -0
  69. {allelix-2.1.2 → allelix-2.2.1}/allelix/parsers/twentythreeandme.py +0 -0
  70. {allelix-2.1.2 → allelix-2.2.1}/allelix/py.typed +0 -0
  71. {allelix-2.1.2 → allelix-2.2.1}/allelix/reports/high_value.py +0 -0
  72. {allelix-2.1.2 → allelix-2.2.1}/allelix/reports/methylation.py +0 -0
  73. {allelix-2.1.2 → allelix-2.2.1}/allelix/utils/__init__.py +0 -0
  74. {allelix-2.1.2 → allelix-2.2.1}/allelix/utils/build_detect.py +0 -0
  75. {allelix-2.1.2 → allelix-2.2.1}/allelix.egg-info/dependency_links.txt +0 -0
  76. {allelix-2.1.2 → allelix-2.2.1}/allelix.egg-info/entry_points.txt +0 -0
  77. {allelix-2.1.2 → allelix-2.2.1}/allelix.egg-info/requires.txt +0 -0
  78. {allelix-2.1.2 → allelix-2.2.1}/allelix.egg-info/top_level.txt +0 -0
  79. {allelix-2.1.2 → allelix-2.2.1}/setup.cfg +0 -0
  80. {allelix-2.1.2 → allelix-2.2.1}/tests/test_cli_helpers.py +0 -0
  81. {allelix-2.1.2 → allelix-2.2.1}/tests/test_compare.py +0 -0
  82. {allelix-2.1.2 → allelix-2.2.1}/tests/test_end_to_end.py +0 -0
  83. {allelix-2.1.2 → allelix-2.2.1}/tests/test_models.py +0 -0
  84. {allelix-2.1.2 → allelix-2.2.1}/tests/test_registry.py +0 -0
  85. {allelix-2.1.2 → allelix-2.2.1}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: allelix
3
- Version: 2.1.2
3
+ Version: 2.2.1
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>
@@ -95,6 +95,7 @@ See [Development](#development) for source installs and running tests, [Managing
95
95
  | MyHeritage DNA | ✓ | CSV, same structure as FTDNA. Detected by "MyHeritage" in comment header. Handles double-double-quoted field variant. |
96
96
  | Living DNA | ✓ | Tab-delimited despite `.csv` extension. Handles AX-, AFFX-prefixed and CHR:POS positional SNP IDs. |
97
97
  | FTDNA Illumina raw | ✓ | Tab-delimited variant of the FTDNA export (distinct from the CSV format above). `RSID/CHROMOSOME/POSITION/RESULT` columns. Build 37 default. |
98
+ | 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. |
98
99
  | 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 skipped — they match nothing in any annotation database. Multi-sample files require `--sample <ID>`. 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. |
99
100
 
100
101
  Adding a new format means adding one file to `allelix/parsers/` and registering an instance in the `PARSERS` list in `allelix/parsers/__init__.py`.
@@ -253,28 +254,44 @@ Allelix stores reference database caches at `~/.local/share/allelix/` (Linux/mac
253
254
  **The caches are disposable and re-downloadable.** Everything here was fetched by `allelix db update` and can be re-fetched at any time. There's no per-user state in the cache — your genotype files never enter this directory. Safe to delete to reclaim space (or to force a full refresh if you suspect a cache went stale):
254
255
 
255
256
  ```bash
256
- # Reclaim all ~16-22 GB
257
- rm -rf ~/.local/share/allelix/
257
+ # Reclaim all ~16-22 GB, with size report and confirmation prompt
258
+ allelix db clean
258
259
 
259
- # Reclaim everything except CADD (avoids re-downloading the slowest one)
260
- find ~/.local/share/allelix/ -mindepth 1 ! -name 'cadd*.sqlite*' -delete
260
+ # Preserve the CADD cache (avoids re-downloading the slowest one, ~5.8 GB)
261
+ allelix db clean --keep-cadd
262
+
263
+ # Show what would be deleted without acting
264
+ allelix db clean --dry-run
265
+
266
+ # Scripted use — skip the confirmation prompt
267
+ allelix db clean --yes
261
268
 
262
269
  # Re-populate from scratch next time you analyze a file
263
270
  allelix db update
264
271
  ```
265
272
 
266
- **The one thing worth backing up: `config.toml`.** It lives separately (XDG config dir, typically `~/.config/allelix/config.toml`) and stores your license assertions (commercial-mode toggle, CADD opt-in confirmation) and per-source enable/disable settings. It's the only non-reproducible state in the install. The caches will rebuild on the next `db update`; `config.toml` won't.
273
+ `db clean` refuses to act on a directory that doesn't contain any recognized allelix cache files — a typo'd `--data-dir ~/Documents` won't result in `rm -rf` against an unrelated tree, even with `--yes`. Pass `--force` if you've staged caches under a non-standard name and want to delete them anyway.
274
+
275
+ For scripting and backup integration, `allelix db path` prints the resolved data directory:
276
+
277
+ ```bash
278
+ ALLELIX_DATA=$(allelix db path)
279
+ du -sh "$ALLELIX_DATA"
280
+
281
+ # --check additionally verifies the path is writable; exit non-zero if not
282
+ allelix db path --check
283
+ ```
284
+
285
+ **The one thing worth backing up: `config.toml`.** It lives separately (XDG config dir, typically `~/.config/allelix/config.toml`) and stores your license assertions (commercial-mode toggle, CADD opt-in confirmation) and per-source enable/disable settings. It's the only non-reproducible state in the install. The caches will rebuild on the next `db update`; `config.toml` won't. `allelix db clean` preserves `config.toml` even when it lives inside the data directory (older installs).
267
286
 
268
287
  To fully uninstall allelix and reclaim everything:
269
288
 
270
289
  ```bash
271
290
  pip uninstall allelix
272
- rm -rf ~/.local/share/allelix/ # reference database caches
291
+ allelix db clean --yes # reference database caches
273
292
  rm -rf ~/.config/allelix/ # license + source-toggle config
274
293
  ```
275
294
 
276
- A `allelix db clean` subcommand to automate the cache-clear step (with confirmation prompt and size report) is planned for v2.2 alongside `allelix db path` (prints the resolved data directory for scripting / backup integration).
277
-
278
295
  ## Data Sources & Licensing
279
296
 
280
297
  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:
@@ -383,6 +400,8 @@ pytest
383
400
 
384
401
  The pre-commit hook enforces `ruff check` + `ruff format --check`. If a commit is blocked, fix the underlying problem rather than skipping the hook.
385
402
 
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.
404
+
386
405
  ## License
387
406
 
388
407
  GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See `LICENSE`.
@@ -56,6 +56,7 @@ See [Development](#development) for source installs and running tests, [Managing
56
56
  | MyHeritage DNA | ✓ | CSV, same structure as FTDNA. Detected by "MyHeritage" in comment header. Handles double-double-quoted field variant. |
57
57
  | Living DNA | ✓ | Tab-delimited despite `.csv` extension. Handles AX-, AFFX-prefixed and CHR:POS positional SNP IDs. |
58
58
  | FTDNA Illumina raw | ✓ | Tab-delimited variant of the FTDNA export (distinct from the CSV format above). `RSID/CHROMOSOME/POSITION/RESULT` columns. Build 37 default. |
59
+ | 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. |
59
60
  | 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 skipped — they match nothing in any annotation database. Multi-sample files require `--sample <ID>`. 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. |
60
61
 
61
62
  Adding a new format means adding one file to `allelix/parsers/` and registering an instance in the `PARSERS` list in `allelix/parsers/__init__.py`.
@@ -214,28 +215,44 @@ Allelix stores reference database caches at `~/.local/share/allelix/` (Linux/mac
214
215
  **The caches are disposable and re-downloadable.** Everything here was fetched by `allelix db update` and can be re-fetched at any time. There's no per-user state in the cache — your genotype files never enter this directory. Safe to delete to reclaim space (or to force a full refresh if you suspect a cache went stale):
215
216
 
216
217
  ```bash
217
- # Reclaim all ~16-22 GB
218
- rm -rf ~/.local/share/allelix/
218
+ # Reclaim all ~16-22 GB, with size report and confirmation prompt
219
+ allelix db clean
219
220
 
220
- # Reclaim everything except CADD (avoids re-downloading the slowest one)
221
- find ~/.local/share/allelix/ -mindepth 1 ! -name 'cadd*.sqlite*' -delete
221
+ # Preserve the CADD cache (avoids re-downloading the slowest one, ~5.8 GB)
222
+ allelix db clean --keep-cadd
223
+
224
+ # Show what would be deleted without acting
225
+ allelix db clean --dry-run
226
+
227
+ # Scripted use — skip the confirmation prompt
228
+ allelix db clean --yes
222
229
 
223
230
  # Re-populate from scratch next time you analyze a file
224
231
  allelix db update
225
232
  ```
226
233
 
227
- **The one thing worth backing up: `config.toml`.** It lives separately (XDG config dir, typically `~/.config/allelix/config.toml`) and stores your license assertions (commercial-mode toggle, CADD opt-in confirmation) and per-source enable/disable settings. It's the only non-reproducible state in the install. The caches will rebuild on the next `db update`; `config.toml` won't.
234
+ `db clean` refuses to act on a directory that doesn't contain any recognized allelix cache files — a typo'd `--data-dir ~/Documents` won't result in `rm -rf` against an unrelated tree, even with `--yes`. Pass `--force` if you've staged caches under a non-standard name and want to delete them anyway.
235
+
236
+ For scripting and backup integration, `allelix db path` prints the resolved data directory:
237
+
238
+ ```bash
239
+ ALLELIX_DATA=$(allelix db path)
240
+ du -sh "$ALLELIX_DATA"
241
+
242
+ # --check additionally verifies the path is writable; exit non-zero if not
243
+ allelix db path --check
244
+ ```
245
+
246
+ **The one thing worth backing up: `config.toml`.** It lives separately (XDG config dir, typically `~/.config/allelix/config.toml`) and stores your license assertions (commercial-mode toggle, CADD opt-in confirmation) and per-source enable/disable settings. It's the only non-reproducible state in the install. The caches will rebuild on the next `db update`; `config.toml` won't. `allelix db clean` preserves `config.toml` even when it lives inside the data directory (older installs).
228
247
 
229
248
  To fully uninstall allelix and reclaim everything:
230
249
 
231
250
  ```bash
232
251
  pip uninstall allelix
233
- rm -rf ~/.local/share/allelix/ # reference database caches
252
+ allelix db clean --yes # reference database caches
234
253
  rm -rf ~/.config/allelix/ # license + source-toggle config
235
254
  ```
236
255
 
237
- A `allelix db clean` subcommand to automate the cache-clear step (with confirmation prompt and size report) is planned for v2.2 alongside `allelix db path` (prints the resolved data directory for scripting / backup integration).
238
-
239
256
  ## Data Sources & Licensing
240
257
 
241
258
  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:
@@ -344,6 +361,8 @@ pytest
344
361
 
345
362
  The pre-commit hook enforces `ruff check` + `ruff format --check`. If a commit is blocked, fix the underlying problem rather than skipping the hook.
346
363
 
364
+ 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.
365
+
347
366
  ## License
348
367
 
349
368
  GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See `LICENSE`.
@@ -25,7 +25,7 @@ from allelix.databases.manager import (
25
25
  download,
26
26
  fetch_remote_text,
27
27
  get_database_info,
28
- load_clinvar_vcf,
28
+ load_clinvar_tsv,
29
29
  stamp_existing_clinvar_cache,
30
30
  verify_file_hash,
31
31
  )
@@ -92,9 +92,99 @@ def _magnitude(clnsig: str) -> float:
92
92
  return _CLNSIG_MAGNITUDE.get(_normalize_clnsig(clnsig), 5.0)
93
93
 
94
94
 
95
- def _vcf_filename_for_url(url: str) -> str:
96
- """Pick the right local filename suffix based on the URL."""
97
- return "clinvar.vcf.gz" if url.endswith(".gz") else "clinvar.vcf"
95
+ # GH #109: ClinVar review-status ranking — lower index = stronger
96
+ # evidence per ClinVar's star-rating documentation. Used to pick the
97
+ # winning row when N SCVs with identical (significance, condition,
98
+ # gene) tuples collapse during per-SCV dedup; the surviving Annotation
99
+ # carries the highest-rated review_status across the group instead of
100
+ # silently dropping provenance.
101
+ #
102
+ # Real cache rows use the comma-and-underscore form
103
+ # (`criteria_provided,_multiple_submitters,_no_conflicts`). The rank
104
+ # helper normalizes via _normalize_clnsig so it's robust to spacing
105
+ # variation. Unknown values fall through to "weakest known."
106
+ _REVIEW_STATUS_RANK: tuple[str, ...] = (
107
+ "practice_guideline",
108
+ "reviewed_by_expert_panel",
109
+ "criteria_provided,_multiple_submitters,_no_conflicts",
110
+ "criteria_provided,_conflicting_classifications",
111
+ "criteria_provided,_conflicting_interpretations",
112
+ "criteria_provided,_single_submitter",
113
+ "no_assertion_criteria_provided",
114
+ "no_assertion_provided",
115
+ "no_classification_provided",
116
+ )
117
+
118
+
119
+ def _review_status_rank(status: str) -> int:
120
+ """Return the rank index for a review_status string.
121
+
122
+ Lower index = stronger evidence. Unknown / unrecognized values get
123
+ the max rank (weakest), so a known SCV always beats an unknown one
124
+ when collapsing.
125
+ """
126
+ if not status:
127
+ return len(_REVIEW_STATUS_RANK)
128
+ normalized = _normalize_clnsig(status)
129
+ for i, candidate in enumerate(_REVIEW_STATUS_RANK):
130
+ if candidate == normalized:
131
+ return i
132
+ return len(_REVIEW_STATUS_RANK)
133
+
134
+
135
+ def _collapse_identical_scvs(annotations: list[Annotation]) -> list[Annotation]:
136
+ """Collapse identical per-SCV rows from the #42 TSV loader (GH #109).
137
+
138
+ Two Annotations are identical for dedup purposes if they share
139
+ (rsid, significance, condition, gene, alt). The surviving row carries:
140
+
141
+ - the strongest ``review_status`` across the collapsed group
142
+ (per ``_REVIEW_STATUS_RANK``)
143
+ - the union of ``references`` across the collapsed group
144
+ (preserving first-seen order — never silently drops provenance)
145
+
146
+ Distinct (significance, condition) pairs on the same variant are
147
+ preserved as separate rows — that's exactly what #42 was built to
148
+ surface, and collapsing them would be the Frankenstein-pair
149
+ regression #42 retired.
150
+
151
+ GH #109 cross-PR review (Finding 1): ``alt`` is in the key for the
152
+ #18 wrong-allele safety invariant. Two SCVs at a multi-allelic site
153
+ can share (significance, condition, gene) but differ in alt — e.g.
154
+ rs1065852 (G→A drug_response/Codeine_response + G→C drug_response/
155
+ Codeine_response would be a hypothetical). Collapsing across alts
156
+ would (a) drop a distinct-allele annotation and (b) mis-attach the
157
+ surviving row's ``alt`` field to the merged result, breaking the
158
+ downstream exact-(rsid, alt) lookup that gnomAD / AlphaMissense /
159
+ CADD use for allele-specific enrichment. The pre-#109 v2.2.0 code
160
+ emitted one Annotation per cache row, so this case was always
161
+ handled correctly; the post-#109 dedup must preserve that property.
162
+
163
+ Input order is preserved for the kept rows: each group is replaced
164
+ by its winner at the position of the group's first occurrence.
165
+ """
166
+ from dataclasses import replace
167
+
168
+ groups: dict[tuple[str, str, str, str, str], list[Annotation]] = {}
169
+ for a in annotations:
170
+ key = (a.rsid, a.significance, a.condition, a.gene, a.alt)
171
+ groups.setdefault(key, []).append(a)
172
+
173
+ out: list[Annotation] = []
174
+ for group in groups.values():
175
+ if len(group) == 1:
176
+ out.append(group[0])
177
+ continue
178
+ winner = min(group, key=lambda a: _review_status_rank(a.review_status))
179
+ seen_refs: set[str] = set()
180
+ merged_refs: list[str] = []
181
+ for a in group:
182
+ for ref in a.references:
183
+ if ref not in seen_refs:
184
+ seen_refs.add(ref)
185
+ merged_refs.append(ref)
186
+ out.append(replace(winner, references=merged_refs))
187
+ return out
98
188
 
99
189
 
100
190
  class ClinVarAnnotator(Annotator):
@@ -186,39 +276,88 @@ class ClinVarAnnotator(Annotator):
186
276
  return self._conns[build]
187
277
 
188
278
  def setup(self) -> None:
189
- """Download each managed build's ClinVar VCF and ingest atomically."""
190
- for build in self._builds:
191
- self._setup_one(build)
192
-
193
- def _setup_one(self, build: str) -> None:
194
- url = _manager_module.CLINVAR_URL_BY_BUILD[build]
195
- signal = self._fetch_remote_signal_for(build)
279
+ """Download the per-SCV TSV pair once and ingest both builds from it.
280
+
281
+ v2.2 #42 stage B: the VCF-per-build path is replaced by the
282
+ variant_summary + submission_summary TSV pair. variant_summary
283
+ carries per-(VariationID, Assembly) rows so the same downloaded
284
+ file populates both GRCh37 and GRCh38 caches with no second
285
+ download. submission_summary is build-agnostic; one fetch
286
+ applies across builds.
287
+
288
+ Freshness signal is derived from submission_summary's md5
289
+ endpoint — that file drives the meaningful change cadence
290
+ (per-SCV submissions land daily) and variant_summary turns over
291
+ weekly on a slower clock.
292
+ """
293
+ signal = self._fetch_remote_signal_for_tsv()
196
294
  if signal is None:
197
295
  msg = (
198
- f"clinvar ({build}): cannot verify remote freshness signal. "
296
+ "clinvar: cannot verify remote freshness signal. "
199
297
  "Refresh aborted to avoid persisting an incomplete cache stamp. "
200
298
  "Retry, or pass --force if you accept that next `db update` "
201
299
  "will re-download to re-establish the signal."
202
300
  )
203
301
  raise RuntimeError(msg)
204
- vcf_path = self.data_dir / _vcf_filename_for_url(url)
205
- download(url, vcf_path)
302
+
303
+ vs_url = _manager_module.CLINVAR_VARIANT_SUMMARY_URL
304
+ ss_url = _manager_module.CLINVAR_SUBMISSION_SUMMARY_URL
305
+ vs_path = self.data_dir / "variant_summary.txt.gz"
306
+ ss_path = self.data_dir / "submission_summary.txt.gz"
307
+
308
+ download(vs_url, vs_path)
309
+ download(ss_url, ss_path)
310
+ # We don't md5-verify variant_summary; signal is calibrated against
311
+ # submission_summary (the more frequently-updated file) and that
312
+ # endpoint's md5 is what we hashed below. variant_summary integrity
313
+ # is asserted structurally by the loader (rows that fail to parse
314
+ # are skipped, mismatched assemblies are dropped).
206
315
  try:
207
- verify_file_hash(vcf_path, "md5", signal.removeprefix("md5:"))
208
- load_clinvar_vcf(
209
- vcf_path,
210
- self._db_paths[build],
211
- source_url=url,
212
- remote_signal=signal,
213
- record_name=clinvar_record_name(build),
214
- )
316
+ verify_file_hash(ss_path, "md5", signal.removeprefix("md5:"))
317
+ for build in self._builds:
318
+ self._setup_one_from_tsv(vs_path, ss_path, build, signal, vs_url)
215
319
  finally:
216
- try:
217
- vcf_path.unlink()
218
- except FileNotFoundError:
219
- pass
220
- except OSError:
221
- logger.warning("Could not remove staged VCF at %s", vcf_path)
320
+ for tmp in (vs_path, ss_path):
321
+ try:
322
+ tmp.unlink()
323
+ except FileNotFoundError:
324
+ pass
325
+ except OSError:
326
+ logger.warning("Could not remove staged TSV at %s", tmp)
327
+
328
+ def _setup_one_from_tsv(
329
+ self,
330
+ vs_path: Path,
331
+ ss_path: Path,
332
+ build: str,
333
+ signal: str,
334
+ source_url: str,
335
+ ) -> None:
336
+ """Run the per-build cache build against an already-downloaded TSV pair."""
337
+ load_clinvar_tsv(
338
+ vs_path,
339
+ ss_path,
340
+ self._db_paths[build],
341
+ build,
342
+ source_url=source_url,
343
+ remote_signal=signal,
344
+ )
345
+ # load_clinvar_tsv stamps the record name as "clinvar"; for
346
+ # backward compatibility with the per-build record-name scheme
347
+ # (clinvar.GRCh37 / clinvar.GRCh38), rename the row in place.
348
+ # This keeps is_ready() / version() per-build dispatch working
349
+ # against the existing database_versions table layout.
350
+ import contextlib
351
+ import sqlite3
352
+
353
+ per_build_name = clinvar_record_name(build)
354
+ if per_build_name != "clinvar":
355
+ with contextlib.closing(sqlite3.connect(self._db_paths[build])) as conn:
356
+ conn.execute(
357
+ "UPDATE database_versions SET name = ? WHERE name = 'clinvar'",
358
+ (per_build_name,),
359
+ )
360
+ conn.commit()
222
361
 
223
362
  def is_ready(self) -> bool:
224
363
  """True iff EVERY managed build has a populated, version-stamped cache.
@@ -300,11 +439,15 @@ class ClinVarAnnotator(Annotator):
300
439
  # can't suppress a single-base array readout. The per-build cache
301
440
  # may have BOTH SNV and indel rows for the same rsid; the WHERE
302
441
  # filters those out so we keep only the SNV REF.
303
- rows = conn.execute(
442
+ #
443
+ # GH #28 item 1: iterate the cursor directly instead of fetchall()
444
+ # so peak memory is one row + the dict, not the entire result set
445
+ # twice.
446
+ cursor = conn.execute(
304
447
  "SELECT DISTINCT rsid, ref FROM clinvar_variants WHERE length(ref) = 1"
305
- ).fetchall()
448
+ )
306
449
  out: dict[str, str] = {}
307
- for rsid, ref in rows:
450
+ for rsid, ref in cursor:
308
451
  # If a rsid has multiple single-base REFs (shouldn't happen at
309
452
  # one position but defending against future data shapes), keep
310
453
  # the first.
@@ -313,23 +456,21 @@ class ClinVarAnnotator(Annotator):
313
456
  return out
314
457
 
315
458
  def fetch_remote_signal(self) -> str | None:
316
- r"""Composite freshness signal across managed builds.
317
-
318
- Format: `"GRCh37:md5:<hex>|GRCh38:md5:<hex>"`. Returns None if
319
- ANY managed build's signal probe fails — the CLI then prints
320
- "can't verify" and skips refresh per ADR-0012's policy.
459
+ r"""Freshness signal derived from the per-SCV submission_summary md5.
460
+
461
+ v2.2 #42 stage B: pre-stage-B this returned a composite VCF-md5
462
+ per build. After stage B, the cache is built from a build-agnostic
463
+ TSV pair, and submission_summary's md5 endpoint is the change
464
+ cadence we want to track (per-SCV submissions land daily; the
465
+ slower-moving variant_summary only adds new VariationIDs).
466
+ Returns the signal as ``"md5:<hex>"`` to keep ``setup()``'s
467
+ ``signal.removeprefix("md5:")`` contract unchanged.
321
468
  """
322
- parts: list[str] = []
323
- for build in self._builds:
324
- sig = self._fetch_remote_signal_for(build)
325
- if sig is None:
326
- return None
327
- parts.append(f"{build}:{sig}")
328
- return "|".join(parts) if parts else None
469
+ return self._fetch_remote_signal_for_tsv()
329
470
 
330
471
  @staticmethod
331
- def _fetch_remote_signal_for(build: str) -> str | None:
332
- body = fetch_remote_text(_manager_module.CLINVAR_URL_BY_BUILD[build] + ".md5")
472
+ def _fetch_remote_signal_for_tsv() -> str | None:
473
+ body = fetch_remote_text(_manager_module.CLINVAR_SUBMISSION_SUMMARY_URL + ".md5")
333
474
  if not body:
334
475
  return None
335
476
  first_token = body.strip().split(None, 1)[0] if body.strip() else ""
@@ -338,19 +479,29 @@ class ClinVarAnnotator(Annotator):
338
479
  # as a transient signal failure rather than poisoning the
339
480
  # cache: callers handle `None` as "freshness unknown, skip"
340
481
  # in `db update`, and `setup()` raises rather than passing
341
- # garbage to `verify_file_hash` (which would delete the VCF).
482
+ # garbage to `verify_file_hash` (which would delete the TSV).
342
483
  logger.warning(
343
- "clinvar(%s): .md5 endpoint returned a body whose first token "
344
- "is not a 32-char hex digest (got %r); treating as no signal",
345
- build,
484
+ "clinvar: submission_summary.md5 endpoint returned a body whose "
485
+ "first token is not a 32-char hex digest (got %r); treating as "
486
+ "no signal",
346
487
  first_token[:32],
347
488
  )
348
489
  return None
349
490
  return f"md5:{first_token}"
350
491
 
351
492
  def cached_remote_signal(self) -> str | None:
352
- """Composite cached signal across managed builds. None if any missing."""
353
- parts: list[str] = []
493
+ """Return the signal stamped at the last successful setup, or None.
494
+
495
+ None if any managed build's cache is absent / unstamped or if the
496
+ builds' stamps diverge (an interrupted partial reingest).
497
+
498
+ Stage B: both builds now share the same source TSVs and therefore
499
+ the same remote_signal. We still cross-check that EVERY managed
500
+ build's cache carries the stamp; a missing build means the
501
+ last-setup didn't complete, treat as "no signal" and let
502
+ ``db update`` decide.
503
+ """
504
+ signals: set[str] = set()
354
505
  for build in self._builds:
355
506
  info = get_database_info(self._db_paths[build], clinvar_record_name(build))
356
507
  if info is None or info["remote_signal"] is None:
@@ -358,8 +509,16 @@ class ClinVarAnnotator(Annotator):
358
509
  sig = info["remote_signal"]
359
510
  if not sig:
360
511
  return None
361
- parts.append(f"{build}:{sig}")
362
- return "|".join(parts) if parts else None
512
+ signals.add(sig)
513
+ if not signals:
514
+ return None
515
+ # All builds should carry the same signal (single download, single
516
+ # ingest run). If they diverge — e.g. someone reingested one build
517
+ # against a newer TSV — treat as "no signal" so the next
518
+ # `db update` re-syncs both.
519
+ if len(signals) > 1:
520
+ return None
521
+ return signals.pop()
363
522
 
364
523
  def annotate(self, variant: Variant) -> list[Annotation]:
365
524
  """Return ClinVar annotations whose REF/ALT matches the user's genotype.
@@ -438,7 +597,11 @@ class ClinVarAnnotator(Annotator):
438
597
  alt=alt,
439
598
  )
440
599
  )
441
- return annotations
600
+ # GH #109: collapse identical per-SCV rows from the #42 TSV loader.
601
+ # Without this, a variant with N identical Pathogenic submissions
602
+ # emits N identical Annotation objects (visible duplicates on every
603
+ # surface). Distinct (significance, condition) pairs survive.
604
+ return _collapse_identical_scvs(annotations)
442
605
 
443
606
  def bulk_resolve_rsids(self, variants: list[Variant]) -> dict[tuple[str, int, str, str], str]:
444
607
  """Resolve rsIDs by (chromosome, position) for variants with no ID.
@@ -572,6 +735,12 @@ class ClinVarAnnotator(Annotator):
572
735
  continue
573
736
  user_is_multibase = len(variant.allele1) > 1 or len(variant.allele2) > 1
574
737
  user_diploid = _user_diploid(variant)
738
+ # GH #109: collect per-variant Annotations and dedup before
739
+ # yielding, mirroring the annotate() collapse. Without this,
740
+ # bulk callers (the analyze pipeline goes through
741
+ # batch_annotate) leak the same per-SCV duplicates the
742
+ # single-variant path used to.
743
+ variant_annotations: list[Annotation] = []
575
744
  for row in rows:
576
745
  (
577
746
  _chrom,
@@ -601,21 +770,24 @@ class ClinVarAnnotator(Annotator):
601
770
  f"{clnsig.replace('_', ' ') if clnsig else 'unknown significance'}"
602
771
  )
603
772
  references = [f"clinvar:allele/{allele_id}"] if allele_id else []
604
- yield Annotation(
605
- source=self.name,
606
- rsid=variant.rsid,
607
- significance=f"clinvar_{sig_label}",
608
- category="clinical",
609
- magnitude=_magnitude(clnsig),
610
- description=description,
611
- attribution=self.attribution,
612
- genotype_match=user_diploid,
613
- references=references,
614
- condition="" if not condition or condition == "." else condition,
615
- gene=gene or "",
616
- review_status=review_status or "",
617
- alt=alt,
773
+ variant_annotations.append(
774
+ Annotation(
775
+ source=self.name,
776
+ rsid=variant.rsid,
777
+ significance=f"clinvar_{sig_label}",
778
+ category="clinical",
779
+ magnitude=_magnitude(clnsig),
780
+ description=description,
781
+ attribution=self.attribution,
782
+ genotype_match=user_diploid,
783
+ references=references,
784
+ condition="" if not condition or condition == "." else condition,
785
+ gene=gene or "",
786
+ review_status=review_status or "",
787
+ alt=alt,
788
+ )
618
789
  )
790
+ yield from _collapse_identical_scvs(variant_annotations)
619
791
 
620
792
 
621
793
  def _user_diploid(variant: Variant) -> str:
@@ -417,6 +417,63 @@ def _emit_build_diagnostics(result: object) -> None:
417
417
  )
418
418
 
419
419
 
420
+ def _emit_runtime_nudges(result: object) -> None:
421
+ """Surface runtime-degradation notes the analyze run can detect.
422
+
423
+ Both are once-per-run, post-pipeline. Neither blocks the report; the
424
+ intent is transparency for cases where the silent default behavior
425
+ used to leave the user uncertain about WHY their count looked low.
426
+
427
+ - **GH #90: strand-aware matching inactive.** When carrier/genotype
428
+ matching ran with ``Variant.ref`` unavailable across the input —
429
+ typically array data with ``--no-gnomad`` or no gnomAD cache —
430
+ the strand-flip path in ``utils/allele.py`` falls back to the
431
+ v2.0.x direct-match-only behavior. Behavior is correct (forward-
432
+ normalized array data matches the direct path regardless), just
433
+ degraded relative to v2.1+'s contract. VCF inputs always carry
434
+ REF from the file, so this never fires for them.
435
+ - **GH #91: GRCh38 rsID-less undercount.** When the effective
436
+ build is GRCh38 AND most variants entered the pipeline without
437
+ rsIDs (the variant-caller ``ID=.`` case), annotation coverage
438
+ is materially lower than the GRCh37 equivalent because rsID
439
+ resolution falls back to ClinVar position lookup — a smaller
440
+ surface than dbSNP. Real fix is #62; this is the interim
441
+ honesty signal until then.
442
+ """
443
+ no_ref = getattr(result, "no_ref_variant_count", 0)
444
+ rsidless = getattr(result, "rsidless_variant_count", 0)
445
+ total = getattr(result, "total_variants", 0)
446
+ parser_name = getattr(result, "parser_name", "")
447
+ diag = getattr(result, "build_diagnostics", None)
448
+ effective_build = diag.effective_build if diag is not None else None
449
+
450
+ # GH #90: array inputs (everything except the VCF parser) running
451
+ # without a usable gnomAD cache leave every variant ref=None. Gate
452
+ # on "the whole input lacked ref" rather than "any variant lacked
453
+ # ref" so a hybrid file (e.g. a few ref-less rows in an otherwise
454
+ # populated VCF) doesn't flap the message on.
455
+ if parser_name != "vcf" and total > 0 and no_ref == total:
456
+ console.print(
457
+ "[dim]strand-aware matching inactive — no reference context "
458
+ "(run `allelix db update` for gnomAD-backed ref resolution, "
459
+ "or this is expected with `--no-gnomad`)[/dim]"
460
+ )
461
+
462
+ # GH #91: GRCh38 + a high rsID-less fraction. The threshold (>50%)
463
+ # distinguishes a variant-caller VCF (mostly ID=. — fires) from a
464
+ # 23andMe-style array (rsID-bearing — no warning) or a mostly-
465
+ # rsID-bearing VCF where a handful of novel calls came in with
466
+ # ID=. (no warning, ID=. for novel sites is expected).
467
+ if effective_build == "GRCh38" and total > 0 and rsidless / total > 0.5:
468
+ console.print(
469
+ "[yellow]GRCh38 input without rsIDs: annotation coverage is "
470
+ "currently lower than GRCh37 because rsID resolution falls "
471
+ "back to ClinVar positions. Full dbSNP resolution is planned "
472
+ "(#62). If you have a GRCh37 build available, it currently "
473
+ "surfaces more annotations.[/yellow]"
474
+ )
475
+
476
+
420
477
  def _run_analysis_command(
421
478
  file_path: Path,
422
479
  fmt: str | None,
@@ -496,11 +553,13 @@ def _run_analysis_command(
496
553
  alphamissense=cast("AlphaMissenseAnnotator | None", am_annotator),
497
554
  cadd=cast("CaddAnnotator | None", cadd_annotator),
498
555
  high_value_rsids=hv_rsids,
556
+ panel_rsids=rsids,
499
557
  )
500
558
  finally:
501
559
  _unwire_parser_logging(counter, stderr_handler, snapshot)
502
560
 
503
561
  _emit_build_diagnostics(result)
562
+ _emit_runtime_nudges(result)
504
563
 
505
564
  hv_warnings = scan_no_calls(result.hv_variants, high_value)
506
565
  if hv_warnings:
@@ -33,6 +33,25 @@ _MIN_MAG_OPT = click.option(
33
33
  show_default=True,
34
34
  help="Filter annotations below this magnitude. Use 0 for the full unfiltered set.",
35
35
  )
36
+ # The focused subcommands (`allelix methylation`, `allelix pharmacogenomics`)
37
+ # default to a lower floor than the general `analyze` report. The intent of
38
+ # a focused report is to surface focused-level signal — ClinPGx LoE 3 hits
39
+ # on COMT / MTHFR / MTR / CBS for `methylation`, similar for the pharma
40
+ # subcommand — which the magnitude-5 floor that protects `analyze` from
41
+ # noise would filter out as if they were noise. Floor 3.0 keeps LoE 3
42
+ # in, keeps ClinVar Benign (mag 1.0) out.
43
+ _FOCUSED_MIN_MAG_OPT = click.option(
44
+ "--min-magnitude",
45
+ type=float,
46
+ default=3.0,
47
+ show_default=True,
48
+ help=(
49
+ "Filter annotations below this magnitude. Focused subcommands default "
50
+ "to 3.0 (vs. analyze's 5.0) so ClinPGx LoE 3 hits on panel genes — "
51
+ "the intended signal of a focused report — aren't filtered out. "
52
+ "Use 0 for the full unfiltered set."
53
+ ),
54
+ )
36
55
  _OUTPUT_OPT = click.option(
37
56
  "--output",
38
57
  type=click.Path(dir_okay=False, path_type=Path),