allelix 2.2.0__tar.gz → 2.2.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 (86) hide show
  1. {allelix-2.2.0 → allelix-2.2.2}/PKG-INFO +1 -1
  2. {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/clinvar.py +129 -15
  3. {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/gnomad.py +96 -0
  4. {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/db.py +15 -4
  5. {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/utility.py +105 -6
  6. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/manager.py +18 -2
  7. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/schema.py +2 -0
  8. {allelix-2.2.0 → allelix-2.2.2}/allelix/models.py +21 -0
  9. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/ftdna_famfinder.py +79 -7
  10. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/vcf.py +105 -6
  11. {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/_pipeline.py +105 -5
  12. {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/html.py +64 -10
  13. {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/json_report.py +5 -1
  14. {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/terminal.py +13 -3
  15. {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/PKG-INFO +1 -1
  16. {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/SOURCES.txt +3 -0
  17. {allelix-2.2.0 → allelix-2.2.2}/pyproject.toml +1 -1
  18. allelix-2.2.2/tests/test_changelog_sanitize.py +83 -0
  19. {allelix-2.2.0 → allelix-2.2.2}/tests/test_cli.py +90 -1
  20. allelix-2.2.2/tests/test_extract_rsidless_gh128.py +201 -0
  21. allelix-2.2.2/tests/test_original_rsid_never_serializes.py +296 -0
  22. {allelix-2.2.0 → allelix-2.2.2}/LICENSE +0 -0
  23. {allelix-2.2.0 → allelix-2.2.2}/README.md +0 -0
  24. {allelix-2.2.0 → allelix-2.2.2}/allelix/__init__.py +0 -0
  25. {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/__init__.py +0 -0
  26. {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/alphamissense.py +0 -0
  27. {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/base.py +0 -0
  28. {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/cadd.py +0 -0
  29. {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/gwas.py +0 -0
  30. {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/pharmgkb.py +0 -0
  31. {allelix-2.2.0 → allelix-2.2.2}/allelix/annotators/snpedia.py +0 -0
  32. {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/__init__.py +0 -0
  33. {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/_helpers.py +0 -0
  34. {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/_options.py +0 -0
  35. {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/analyze.py +0 -0
  36. {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/config.py +0 -0
  37. {allelix-2.2.0 → allelix-2.2.2}/allelix/cli/focused.py +0 -0
  38. {allelix-2.2.0 → allelix-2.2.2}/allelix/compare.py +0 -0
  39. {allelix-2.2.0 → allelix-2.2.2}/allelix/config.py +0 -0
  40. {allelix-2.2.0 → allelix-2.2.2}/allelix/data/__init__.py +0 -0
  41. {allelix-2.2.0 → allelix-2.2.2}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
  42. {allelix-2.2.0 → allelix-2.2.2}/allelix/data/high_value_snps.yaml +0 -0
  43. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/__init__.py +0 -0
  44. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/_versions.py +0 -0
  45. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/alphamissense_loader.py +0 -0
  46. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/cadd_loader.py +0 -0
  47. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/cpic_loader.py +0 -0
  48. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/gnomad_loader.py +0 -0
  49. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/gwas_loader.py +0 -0
  50. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/loader_utils.py +0 -0
  51. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/pharmgkb_loader.py +0 -0
  52. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/snpedia_loader.py +0 -0
  53. {allelix-2.2.0 → allelix-2.2.2}/allelix/databases/snpedia_parser.py +0 -0
  54. {allelix-2.2.0 → allelix-2.2.2}/allelix/exporters/__init__.py +0 -0
  55. {allelix-2.2.0 → allelix-2.2.2}/allelix/exporters/plink.py +0 -0
  56. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/__init__.py +0 -0
  57. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/_helpers.py +0 -0
  58. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/ancestrydna.py +0 -0
  59. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/base.py +0 -0
  60. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/ftdna.py +0 -0
  61. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/ftdna_illumina.py +0 -0
  62. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/livingdna.py +0 -0
  63. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/myhappygenes.py +0 -0
  64. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/myheritage.py +0 -0
  65. {allelix-2.2.0 → allelix-2.2.2}/allelix/parsers/twentythreeandme.py +0 -0
  66. {allelix-2.2.0 → allelix-2.2.2}/allelix/py.typed +0 -0
  67. {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/__init__.py +0 -0
  68. {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/diff.py +0 -0
  69. {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/high_value.py +0 -0
  70. {allelix-2.2.0 → allelix-2.2.2}/allelix/reports/methylation.py +0 -0
  71. {allelix-2.2.0 → allelix-2.2.2}/allelix/utils/__init__.py +0 -0
  72. {allelix-2.2.0 → allelix-2.2.2}/allelix/utils/allele.py +0 -0
  73. {allelix-2.2.0 → allelix-2.2.2}/allelix/utils/build_detect.py +0 -0
  74. {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/dependency_links.txt +0 -0
  75. {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/entry_points.txt +0 -0
  76. {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/requires.txt +0 -0
  77. {allelix-2.2.0 → allelix-2.2.2}/allelix.egg-info/top_level.txt +0 -0
  78. {allelix-2.2.0 → allelix-2.2.2}/setup.cfg +0 -0
  79. {allelix-2.2.0 → allelix-2.2.2}/tests/test_cli_helpers.py +0 -0
  80. {allelix-2.2.0 → allelix-2.2.2}/tests/test_compare.py +0 -0
  81. {allelix-2.2.0 → allelix-2.2.2}/tests/test_config.py +0 -0
  82. {allelix-2.2.0 → allelix-2.2.2}/tests/test_end_to_end.py +0 -0
  83. {allelix-2.2.0 → allelix-2.2.2}/tests/test_mock_data_invariants.py +0 -0
  84. {allelix-2.2.0 → allelix-2.2.2}/tests/test_models.py +0 -0
  85. {allelix-2.2.0 → allelix-2.2.2}/tests/test_registry.py +0 -0
  86. {allelix-2.2.0 → allelix-2.2.2}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: allelix
3
- Version: 2.2.0
3
+ Version: 2.2.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>
@@ -92,6 +92,101 @@ def _magnitude(clnsig: str) -> float:
92
92
  return _CLNSIG_MAGNITUDE.get(_normalize_clnsig(clnsig), 5.0)
93
93
 
94
94
 
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
188
+
189
+
95
190
  class ClinVarAnnotator(Annotator):
96
191
  """Annotates variants with ClinVar's clinical significance classifications.
97
192
 
@@ -502,7 +597,11 @@ class ClinVarAnnotator(Annotator):
502
597
  alt=alt,
503
598
  )
504
599
  )
505
- 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)
506
605
 
507
606
  def bulk_resolve_rsids(self, variants: list[Variant]) -> dict[tuple[str, int, str, str], str]:
508
607
  """Resolve rsIDs by (chromosome, position) for variants with no ID.
@@ -586,6 +685,12 @@ class ClinVarAnnotator(Annotator):
586
685
  ]
587
686
  if len(matches) == 1:
588
687
  ref, alt, rsid = matches[0]
688
+ # GH #128: stash the pre-resolution pseudo-rsID
689
+ # for audit / debug before stamping. Debug-only —
690
+ # see Variant.original_rsid docstring; the
691
+ # serialization-guard test asserts this never
692
+ # reaches public output.
693
+ v.original_rsid = v.rsid
589
694
  v.rsid = rsid
590
695
  resolved[(v.chromosome, v.position, ref, alt)] = rsid
591
696
  return resolved
@@ -636,6 +741,12 @@ class ClinVarAnnotator(Annotator):
636
741
  continue
637
742
  user_is_multibase = len(variant.allele1) > 1 or len(variant.allele2) > 1
638
743
  user_diploid = _user_diploid(variant)
744
+ # GH #109: collect per-variant Annotations and dedup before
745
+ # yielding, mirroring the annotate() collapse. Without this,
746
+ # bulk callers (the analyze pipeline goes through
747
+ # batch_annotate) leak the same per-SCV duplicates the
748
+ # single-variant path used to.
749
+ variant_annotations: list[Annotation] = []
639
750
  for row in rows:
640
751
  (
641
752
  _chrom,
@@ -665,21 +776,24 @@ class ClinVarAnnotator(Annotator):
665
776
  f"{clnsig.replace('_', ' ') if clnsig else 'unknown significance'}"
666
777
  )
667
778
  references = [f"clinvar:allele/{allele_id}"] if allele_id else []
668
- yield Annotation(
669
- source=self.name,
670
- rsid=variant.rsid,
671
- significance=f"clinvar_{sig_label}",
672
- category="clinical",
673
- magnitude=_magnitude(clnsig),
674
- description=description,
675
- attribution=self.attribution,
676
- genotype_match=user_diploid,
677
- references=references,
678
- condition="" if not condition or condition == "." else condition,
679
- gene=gene or "",
680
- review_status=review_status or "",
681
- alt=alt,
779
+ variant_annotations.append(
780
+ Annotation(
781
+ source=self.name,
782
+ rsid=variant.rsid,
783
+ significance=f"clinvar_{sig_label}",
784
+ category="clinical",
785
+ magnitude=_magnitude(clnsig),
786
+ description=description,
787
+ attribution=self.attribution,
788
+ genotype_match=user_diploid,
789
+ references=references,
790
+ condition="" if not condition or condition == "." else condition,
791
+ gene=gene or "",
792
+ review_status=review_status or "",
793
+ alt=alt,
794
+ )
682
795
  )
796
+ yield from _collapse_identical_scvs(variant_annotations)
683
797
 
684
798
 
685
799
  def _user_diploid(variant: Variant) -> str:
@@ -76,6 +76,24 @@ class GnomadAnnotator(Annotator):
76
76
  f"gnomAD cache not found at {self._db_path}. Run `allelix db update` first."
77
77
  )
78
78
  self._conn = sqlite3.connect(self._db_path)
79
+ # GH #134: lazy-create the position index on caches built before
80
+ # the index landed (anything <= v2.2.1). The index is required
81
+ # for ``bulk_resolve_rsids_from_positions`` (GH #128) to perform
82
+ # well — without it that method scans the full 57M-row
83
+ # ``gnomad_frequencies`` table per query, which is the root
84
+ # cause of the ~2x analyze slowdown on rsID-less WGS VCFs
85
+ # observed at the v2.2.2 ship gate. ``CREATE INDEX IF NOT
86
+ # EXISTS`` is a no-op once the index is in place (microseconds
87
+ # to check the schema); the one-time build cost on first run
88
+ # against a pre-v2.2.2 cache is a few minutes against the 57M
89
+ # rows but only happens once per cache. Doing this here in
90
+ # ``_connection`` rather than as a schema migration avoids
91
+ # forcing every user to re-download the 2.7 GB compressed
92
+ # cache for what's a transparent index addition.
93
+ self._conn.execute(
94
+ "CREATE INDEX IF NOT EXISTS idx_gnomad_position ON gnomad_frequencies(chrom, pos)"
95
+ )
96
+ self._conn.commit()
79
97
  return self._conn
80
98
 
81
99
  def setup(self) -> None:
@@ -198,6 +216,84 @@ class GnomadAnnotator(Annotator):
198
216
  result.setdefault(rsid, []).append((chrom, pos, ref, alt))
199
217
  return result
200
218
 
219
+ def bulk_resolve_rsids_from_positions(
220
+ self, positions: set[tuple[str, int]]
221
+ ) -> dict[tuple[str, int], list[tuple[str, str, str]]]:
222
+ """Reverse-lookup rsIDs by ``(chromosome, position)``.
223
+
224
+ Counterpart to :meth:`bulk_resolve_coordinates` for the GH #128
225
+ flow: a VCF variant arriving with an empty ID column (DeepVariant
226
+ gVCFs, GIAB GRCh38 benchmarks, anything that doesn't stamp dbSNP
227
+ IDs) is parsed as a positional pseudo-ID. The ClinVar resolver
228
+ (:meth:`allelix.annotators.clinvar.ClinVarAnnotator.bulk_resolve_rsids`)
229
+ recovers an rsID only for variants in ClinVar's curated subset —
230
+ which excludes most pharmacogenomic, GWAS-only, and SNPedia-only
231
+ rsIDs commonly found in wellness panels. This method fills the
232
+ gap by querying ``gnomad_frequencies`` (keyed on the full dbSNP
233
+ rsID universe with published allele frequencies).
234
+
235
+ Returns ``{(chrom, pos): [(ref, alt, rsid), ...]}``. Multi-allelic
236
+ positions return multiple rows; the caller is responsible for
237
+ applying carrier-rule disambiguation (matching the user's allele
238
+ pair as a subset of ``{ref, alt}`` and abstaining on ties — same
239
+ pattern as the ClinVar resolver).
240
+
241
+ Bare chromosomes are expected on both the input set and the
242
+ ``gnomad_frequencies.chrom`` column — Allelix normalizes all
243
+ internal chromosome identifiers to bare form
244
+ (:func:`allelix.parsers._helpers.normalize_chromosome`).
245
+ """
246
+ if not positions:
247
+ return {}
248
+ conn = self._connection()
249
+ result: dict[tuple[str, int], list[tuple[str, str, str]]] = {}
250
+ # GH #134: issue a single combined SQL statement per chunk
251
+ # using SQLite's row-value ``(chrom, pos) IN ((?, ?), ...)``
252
+ # form instead of grouping by chromosome and issuing one
253
+ # statement per (chrom, chunk) pair. On WGS input each batch
254
+ # touches all ~24 chromosomes; the per-chrom shape was a 24x
255
+ # query multiplier the new ``idx_gnomad_position`` index could
256
+ # not amortize away. The row-value IN form keeps the same
257
+ # carrier-rule contract — both ``chrom`` and ``pos`` must
258
+ # match the input pair simultaneously — and the
259
+ # ``idx_gnomad_position`` covering index on (chrom, pos)
260
+ # serves the lookup directly. ``_BULK_BATCH_SIZE`` here
261
+ # refers to (chrom, pos) PAIRS not single ints, so each
262
+ # chunk binds 2 * batch_size parameters.
263
+ #
264
+ # SQLite parameter-limit awareness: 2 * _BULK_BATCH_SIZE =
265
+ # 1800 bind parameters per chunk. The historical SQLite
266
+ # ``SQLITE_MAX_VARIABLE_NUMBER`` default was 999, raised to
267
+ # 32766 in SQLite 3.32 (2020-05-22). Every Python version
268
+ # Allelix supports (>= 3.11) ships with SQLite >= 3.34, so
269
+ # the 1800-param chunk is well within the modern limit. If a
270
+ # future change bumps ``_BULK_BATCH_SIZE`` toward 16000 or
271
+ # the project drops Python's bundled SQLite for a system
272
+ # ``libsqlite3`` of unknown vintage, re-check this against
273
+ # the runtime ``sqlite3.sqlite_version`` before assuming
274
+ # capacity.
275
+ position_list = list(positions)
276
+ for i in range(0, len(position_list), _BULK_BATCH_SIZE):
277
+ batch = position_list[i : i + _BULK_BATCH_SIZE]
278
+ placeholders = ",".join("(?, ?)" for _ in batch)
279
+ params: list[str | int] = []
280
+ for chrom, pos in batch:
281
+ params.append(chrom)
282
+ params.append(pos)
283
+ rows = conn.execute(
284
+ f"SELECT chrom, pos, ref, alt, rsid FROM gnomad_frequencies"
285
+ f" WHERE (chrom, pos) IN ({placeholders})",
286
+ params,
287
+ ).fetchall()
288
+ for c, p, ref, alt, rsid in rows:
289
+ if not rsid:
290
+ continue
291
+ result.setdefault((c, p), []).append((ref, alt, rsid))
292
+ # Deterministic ordering for the carrier-rule pass.
293
+ for rows in result.values():
294
+ rows.sort()
295
+ return result
296
+
201
297
  def bulk_lookup_by_alt(self, keys: set[tuple[str, str]]) -> dict[tuple[str, str], float]:
202
298
  """Return ``{(rsid, alt): af}`` for exact allele matches."""
203
299
  if not keys:
@@ -221,16 +221,27 @@ _CADD_PREFIXES: tuple[str, ...] = ("cadd",)
221
221
  # pre-deletion guard. Matched by prefix so future variants (e.g. multi-
222
222
  # build forms like clinvar.GRCh37.sqlite, sidecar files like
223
223
  # snpedia.sqlite-bak, the PharmGKB raw zip `clinicalAnnotations.zip`) are
224
- # all caught. `config.toml` is included as a presence-only marker — it
225
- # never appears in the deletion entries (it's preserved), but its
226
- # presence is the strongest signal "yes, this is an allelix data dir."
224
+ # all caught.
225
+ #
226
+ # GH #112 (v2.2.1): `config.toml` is intentionally NOT a marker here.
227
+ # It used to be — on the reasoning that any allelix data dir likely
228
+ # carries one — but `config.toml` is a generic filename (Rust projects,
229
+ # Hugo, Rocket, etc. all use it). A typo'd `--data-dir ~/my-rust-project`
230
+ # slipped past the guard simply because that project's root has a
231
+ # `Cargo.lock` and a `config.toml`. The marker set must be allelix-
232
+ # specific.
233
+ #
234
+ # The fresh-install case — a dir with only config.toml and no cache
235
+ # files — is unaffected: `_iter_cache_entries` excludes config.toml,
236
+ # so `entries` is empty and the `if not entries: ... return` short-
237
+ # circuit in `db_clean` reports "Nothing to clean" before the guard
238
+ # ever runs.
227
239
  _KNOWN_ARTIFACT_PREFIXES: frozenset[str] = frozenset(
228
240
  {
229
241
  "alphamissense",
230
242
  "cadd",
231
243
  "clinicalAnnotations",
232
244
  "clinvar",
233
- "config.toml",
234
245
  "gnomad",
235
246
  "gwas",
236
247
  "pharmgkb",
@@ -20,6 +20,7 @@ from allelix.cli._options import (
20
20
  _SAMPLE_OPT,
21
21
  )
22
22
  from allelix.databases import resolve_data_dir
23
+ from allelix.models import NO_CALL_MARKER
23
24
 
24
25
  if TYPE_CHECKING:
25
26
  from allelix.models import Variant
@@ -67,7 +68,21 @@ def stats(file_path: Path, fmt: str | None, sample: str | None) -> None:
67
68
  summary.add_column("Value", style="green")
68
69
  summary.add_row("Format", parser.display_name)
69
70
  summary.add_row("Sample ID", metadata["sample_id"] or "(unknown)")
70
- summary.add_row("Build", metadata["build"])
71
+ # ``metadata["build"]`` is blank on every VCF that doesn't declare
72
+ # ``##reference=`` in the header — i.e. most caller outputs
73
+ # (DeepVariant / DRAGEN / GIAB GRCh38 benchmark). A blank cell read
74
+ # as broken on the v2.2.1 battery; ``stats`` deliberately does not
75
+ # run build detection (that's an ``analyze`` job per the protocol),
76
+ # so the honest display when no header build is declared is an em
77
+ # dash plus the chr-prefix hint when available. The user can run
78
+ # ``analyze`` to get the real detected build.
79
+ if metadata["build"]:
80
+ build_display = metadata["build"]
81
+ elif metadata.get("chr_prefix_observed"):
82
+ build_display = "— (chr-prefixed contigs; run `analyze` to confirm)"
83
+ else:
84
+ build_display = "—"
85
+ summary.add_row("Build", build_display)
71
86
  summary.add_row("Total SNPs", f"{total:,}")
72
87
  summary.add_row("No-calls", f"{no_calls:,} ({_helpers._percent(no_calls, total)})")
73
88
  summary.add_row("Heterozygous", f"{het:,} ({_helpers._percent(het, total)})")
@@ -136,28 +151,90 @@ def extract(
136
151
  found = _try_tabix_extract(file_path, wanted, sample, data_dir)
137
152
  if found is None:
138
153
  # Sequential fallback — works for any parser.
139
- found = _sequential_extract(parser, file_path, wanted)
154
+ found = _sequential_extract(parser, file_path, wanted, data_dir)
140
155
  _maybe_print_vcf_index_hint(file_path)
141
156
 
142
157
  _render_extract_table(file_path, wanted, found)
143
158
 
144
159
 
145
160
  def _sequential_extract(
146
- parser: GenotypeParser, file_path: Path, wanted: set[str]
161
+ parser: GenotypeParser,
162
+ file_path: Path,
163
+ wanted: set[str],
164
+ data_dir: Path | None = None,
147
165
  ) -> dict[str, Variant]:
148
166
  """Stream the file once; collect Variants whose rsID is in ``wanted``.
149
167
 
150
168
  Streaming early-exit once all wanted rsIDs are found — for arrays
151
169
  that yield <1 M variants this is fast enough on any input.
170
+
171
+ GH #128: for VCF inputs the parser may emit a positional pseudo-ID
172
+ (``chr1:11856378:G:A``) when the row's ID column is empty — every
173
+ modern caller (DeepVariant, DRAGEN, GIAB GRCh38 benchmark) emits
174
+ these. The rsID-only fast path on the iterator misses them. To
175
+ cover the rsID-less VCF case, the function pre-resolves the wanted
176
+ set's coordinates via gnomAD (when ready) and matches variants by
177
+ ``(chrom, pos, ref, alt)`` whenever the rsID match misses. The
178
+ ``ref + alt-in-alleles`` rule matches the tabix path's correctness
179
+ contract and the resolver's carrier-rule disambiguation.
152
180
  """
181
+ from allelix.annotators.gnomad import GnomadAnnotator
182
+
153
183
  counter, stderr_handler, snapshot = _helpers._wire_parser_logging()
154
184
  found: dict[str, Variant] = {}
185
+ # Pre-resolve wanted → (chrom, pos, ref, alt) → rsid via gnomAD so
186
+ # the iterator can cover rsID-less VCFs by coord-matching. ``data_dir``
187
+ # is optional only because tests construct mock parsers without a
188
+ # configured cache; production CLI invocations always reach
189
+ # ``resolve_data_dir`` (which defaults to the standard cache
190
+ # location when the user didn't pass ``--data-dir``). The if-test
191
+ # below gates only on gnomAD readiness, not on whether the user
192
+ # explicitly opted in.
193
+ coord_to_rsid: dict[tuple[str, int, str, str], str] = {}
194
+ try:
195
+ resolved_data_dir = resolve_data_dir(data_dir)
196
+ except (OSError, ValueError):
197
+ resolved_data_dir = None
198
+ if resolved_data_dir is not None:
199
+ gnomad = GnomadAnnotator(resolved_data_dir)
200
+ try:
201
+ if gnomad.is_ready():
202
+ coord_map = gnomad.bulk_resolve_coordinates(wanted)
203
+ for rsid, entries in coord_map.items():
204
+ for chrom, pos, ref, alt in entries:
205
+ coord_to_rsid[(chrom, pos, ref, alt)] = rsid
206
+ finally:
207
+ gnomad.close()
155
208
  try:
156
209
  for variant in parser.parse(file_path):
157
210
  if variant.rsid in wanted:
158
211
  found[variant.rsid] = variant
159
212
  if len(found) == len(wanted):
160
213
  break
214
+ continue
215
+ # rsID fast path missed — try coord match for the rsID-less
216
+ # VCF case. Requires ref populated; non-VCF parsers (arrays)
217
+ # don't carry ref but always stamp rsIDs in the file, so
218
+ # they never reach this branch.
219
+ if not coord_to_rsid or variant.ref is None:
220
+ continue
221
+ matched_rsid: str | None = None
222
+ for alt in (variant.allele1, variant.allele2):
223
+ if alt in (NO_CALL_MARKER, variant.ref):
224
+ continue
225
+ key = (variant.chromosome, variant.position, variant.ref, alt)
226
+ candidate = coord_to_rsid.get(key)
227
+ if candidate is None or candidate in found or candidate not in wanted:
228
+ continue
229
+ matched_rsid = candidate
230
+ break
231
+ if matched_rsid is None:
232
+ continue
233
+ variant.original_rsid = variant.rsid
234
+ variant.rsid = matched_rsid
235
+ found[matched_rsid] = variant
236
+ if len(found) == len(wanted):
237
+ break
161
238
  finally:
162
239
  _helpers._unwire_parser_logging(counter, stderr_handler, snapshot)
163
240
  return found
@@ -247,7 +324,7 @@ def _execute_tabix_extract( # pragma: no cover
247
324
  for rsid, coords in coord_map.items():
248
325
  if rsid in found or not coords:
249
326
  continue
250
- for chrom, pos, _ref, _alt in coords:
327
+ for chrom, pos, ref, alt in coords:
251
328
  # gnomAD chromosomes are bare ('1', 'X'); the VCF might
252
329
  # use 'chr1'. Try both.
253
330
  for chrom_candidate in (chrom, f"chr{chrom}"):
@@ -259,9 +336,31 @@ def _execute_tabix_extract( # pragma: no cover
259
336
  variant = _parse_data_line(row, sample_idx)
260
337
  if variant is None:
261
338
  continue
262
- if variant.rsid == rsid:
339
+ # GH #128: match the tabix-fetched row to the
340
+ # gnomAD record by (ref, alt) rather than by
341
+ # rsID equality. On rsID-stamped VCFs (e.g. GIAB
342
+ # GRCh37, 23andMe) the parser populates
343
+ # ``variant.rsid`` with the file's ID column and
344
+ # the prior equality check happened to work; on
345
+ # rsID-less VCFs (DeepVariant gVCFs, GIAB GRCh38
346
+ # benchmark, Sequencing.com DRAGEN output)
347
+ # ``variant.rsid`` is a positional pseudo-ID and
348
+ # the equality check silently discarded every
349
+ # tabix-fetched row. Ref+alt match is correct on
350
+ # both — that's what the gnomAD coord was
351
+ # resolved against.
352
+ if variant.ref == ref and alt in (
353
+ variant.allele1,
354
+ variant.allele2,
355
+ ):
263
356
  # _parse_data_line already normalizes chromosome;
264
- # no re-normalization needed.
357
+ # no re-normalization needed. Stash + stamp
358
+ # so the displayed table shows the requested
359
+ # rsID rather than the pseudo-ID, with audit
360
+ # preserved per Variant.original_rsid.
361
+ if variant.rsid != rsid:
362
+ variant.original_rsid = variant.rsid
363
+ variant.rsid = rsid
265
364
  found[rsid] = variant
266
365
  break
267
366
  if rsid in found:
@@ -187,8 +187,10 @@ _SS_CONTRIBUTES = 15 # "ContributesToAggregateClassification"
187
187
 
188
188
  # GH #42 follow-up (evaluator defect 5, PR #101): exact set of
189
189
  # ClinicalSignificance values the TSV loader filters at ingest.
190
- # These are placeholders that ClinVar uses to mark SCVs with no
191
- # recorded classification AND that aren't in
190
+ # These are values that ClinVar uses to mark SCVs with no actionable
191
+ # classification — placeholders ("-", "", "not specified") AND
192
+ # non-classification curatorial categories ("other", "association",
193
+ # "association not found") — AND that aren't in
192
194
  # `allelix.annotators.clinvar._CLNSIG_MAGNITUDE` (so without this
193
195
  # filter they'd land at the 5.0 default — equal to the analyze
194
196
  # display floor — and surface to users as bogus annotations).
@@ -199,6 +201,13 @@ _SS_CONTRIBUTES = 15 # "ContributesToAggregateClassification"
199
201
  # would lose real submitter records.
200
202
  # - "no classification for the single variant" — same.
201
203
  #
204
+ # GH #116 added the three non-classification terms ("other",
205
+ # "association", "association not found"). The per-SCV TSV
206
+ # switch (#42) surfaced these from the underlying submission_summary
207
+ # rows where the old summarized data hid them; they're meaningfully
208
+ # distinct from placeholders (a curator chose them) but equally
209
+ # unactionable as report content.
210
+ #
202
211
  # The protocol's §7b significance-sentinel ship-gate scans the
203
212
  # same set to verify the loader's commitment against the live cache.
204
213
  # Keep both in sync.
@@ -208,6 +217,13 @@ _CLINVAR_PLACEHOLDER_CLNSIGS: frozenset[str] = frozenset(
208
217
  "-",
209
218
  "not specified",
210
219
  "no classification provided",
220
+ # GH #116: non-classification curatorial terms — kept out of
221
+ # _CLNSIG_MAGNITUDE deliberately (they aren't classifications)
222
+ # and out of _BENIGN_CLNSIGS (they aren't benign; #56's repute
223
+ # work must not see them as "good").
224
+ "other",
225
+ "association",
226
+ "association not found",
211
227
  }
212
228
  )
213
229
 
@@ -127,6 +127,8 @@ CREATE TABLE IF NOT EXISTS gnomad_frequencies (
127
127
  );
128
128
 
129
129
  CREATE INDEX IF NOT EXISTS idx_gnomad_rsid ON gnomad_frequencies(rsid);
130
+ CREATE INDEX IF NOT EXISTS idx_gnomad_position
131
+ ON gnomad_frequencies(chrom, pos);
130
132
  """
131
133
  + _DATABASE_VERSIONS_TABLE
132
134
  )
@@ -47,6 +47,27 @@ class Variant:
47
47
  allele2: str
48
48
  build: str = DEFAULT_BUILD
49
49
  ref: str | None = None
50
+ # GH #128: pre-resolution rsID stashed when a resolver
51
+ # (`ClinVarAnnotator.bulk_resolve_rsids` or
52
+ # `GnomadAnnotator.bulk_resolve_rsids_from_positions`, both invoked
53
+ # in ``_pipeline._flush()``) recovers the real ``rs...`` ID for a
54
+ # variant that arrived without one — e.g. a DeepVariant gVCF row
55
+ # whose ID column is ``.``, parsed as a positional pseudo-ID like
56
+ # ``chr1:11856378:G:A``. The stamp rewrites ``rsid`` to the
57
+ # recovered value so every downstream call site that compares
58
+ # ``v.rsid`` against a requested set (panel coverage, high-value
59
+ # match, extract, PLINK ref/alt, GWAS must-include) works
60
+ # transparently; ``original_rsid`` holds the pre-stamp value
61
+ # purely for audit / debug.
62
+ #
63
+ # **DEBUG-ONLY — do not branch production logic on this field.**
64
+ # Reading ``original_rsid`` outside of logging, debugging, or
65
+ # ``__repr__``-style introspection couples production behavior to
66
+ # provenance metadata and turns this into an unintended API
67
+ # surface. The serialization-guard test in
68
+ # ``tests/test_original_rsid_never_serializes.py`` asserts this
69
+ # field never reaches JSON / HTML / ``--diff`` output.
70
+ original_rsid: str | None = None
50
71
 
51
72
  def __post_init__(self) -> None:
52
73
  """Normalize allele case at construction (GH #14, ADR-0035).