allelix 2.0.1__tar.gz → 2.1.0__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.0.1 → allelix-2.1.0}/PKG-INFO +13 -1
  2. {allelix-2.0.1 → allelix-2.1.0}/README.md +12 -0
  3. allelix-2.1.0/allelix/__init__.py +41 -0
  4. {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/base.py +24 -1
  5. {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/clinvar.py +14 -4
  6. {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/gwas.py +44 -6
  7. {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/pharmgkb.py +21 -5
  8. {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/snpedia.py +3 -0
  9. {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/_helpers.py +20 -3
  10. allelix-2.1.0/allelix/data/high_value_snps.yaml +136 -0
  11. {allelix-2.0.1 → allelix-2.1.0}/allelix/models.py +27 -1
  12. {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/base.py +8 -1
  13. {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/vcf.py +31 -7
  14. {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/_pipeline.py +118 -62
  15. {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/diff.py +1 -1
  16. {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/json_report.py +6 -2
  17. allelix-2.1.0/allelix/utils/allele.py +211 -0
  18. {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/PKG-INFO +13 -1
  19. {allelix-2.0.1 → allelix-2.1.0}/pyproject.toml +1 -1
  20. {allelix-2.0.1 → allelix-2.1.0}/tests/test_end_to_end.py +28 -6
  21. {allelix-2.0.1 → allelix-2.1.0}/tests/test_models.py +27 -0
  22. allelix-2.1.0/tests/test_version.py +100 -0
  23. allelix-2.0.1/allelix/__init__.py +0 -12
  24. allelix-2.0.1/allelix/data/high_value_snps.yaml +0 -64
  25. allelix-2.0.1/allelix/utils/allele.py +0 -89
  26. allelix-2.0.1/tests/test_version.py +0 -52
  27. {allelix-2.0.1 → allelix-2.1.0}/LICENSE +0 -0
  28. {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/__init__.py +0 -0
  29. {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/alphamissense.py +0 -0
  30. {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/cadd.py +0 -0
  31. {allelix-2.0.1 → allelix-2.1.0}/allelix/annotators/gnomad.py +0 -0
  32. {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/__init__.py +0 -0
  33. {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/_options.py +0 -0
  34. {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/analyze.py +0 -0
  35. {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/config.py +0 -0
  36. {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/db.py +0 -0
  37. {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/focused.py +0 -0
  38. {allelix-2.0.1 → allelix-2.1.0}/allelix/cli/utility.py +0 -0
  39. {allelix-2.0.1 → allelix-2.1.0}/allelix/compare.py +0 -0
  40. {allelix-2.0.1 → allelix-2.1.0}/allelix/config.py +0 -0
  41. {allelix-2.0.1 → allelix-2.1.0}/allelix/data/__init__.py +0 -0
  42. {allelix-2.0.1 → allelix-2.1.0}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
  43. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/__init__.py +0 -0
  44. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/_versions.py +0 -0
  45. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/alphamissense_loader.py +0 -0
  46. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/cadd_loader.py +0 -0
  47. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/cpic_loader.py +0 -0
  48. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/gnomad_loader.py +0 -0
  49. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/gwas_loader.py +0 -0
  50. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/loader_utils.py +0 -0
  51. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/manager.py +0 -0
  52. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/pharmgkb_loader.py +0 -0
  53. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/schema.py +0 -0
  54. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/snpedia_loader.py +0 -0
  55. {allelix-2.0.1 → allelix-2.1.0}/allelix/databases/snpedia_parser.py +0 -0
  56. {allelix-2.0.1 → allelix-2.1.0}/allelix/exporters/__init__.py +0 -0
  57. {allelix-2.0.1 → allelix-2.1.0}/allelix/exporters/plink.py +0 -0
  58. {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/__init__.py +0 -0
  59. {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/_helpers.py +0 -0
  60. {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/ancestrydna.py +0 -0
  61. {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/ftdna.py +0 -0
  62. {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/ftdna_illumina.py +0 -0
  63. {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/livingdna.py +0 -0
  64. {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/myhappygenes.py +0 -0
  65. {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/myheritage.py +0 -0
  66. {allelix-2.0.1 → allelix-2.1.0}/allelix/parsers/twentythreeandme.py +0 -0
  67. {allelix-2.0.1 → allelix-2.1.0}/allelix/py.typed +0 -0
  68. {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/__init__.py +0 -0
  69. {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/high_value.py +0 -0
  70. {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/html.py +0 -0
  71. {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/methylation.py +0 -0
  72. {allelix-2.0.1 → allelix-2.1.0}/allelix/reports/terminal.py +0 -0
  73. {allelix-2.0.1 → allelix-2.1.0}/allelix/utils/__init__.py +0 -0
  74. {allelix-2.0.1 → allelix-2.1.0}/allelix/utils/build_detect.py +0 -0
  75. {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/SOURCES.txt +0 -0
  76. {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/dependency_links.txt +0 -0
  77. {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/entry_points.txt +0 -0
  78. {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/requires.txt +0 -0
  79. {allelix-2.0.1 → allelix-2.1.0}/allelix.egg-info/top_level.txt +0 -0
  80. {allelix-2.0.1 → allelix-2.1.0}/setup.cfg +0 -0
  81. {allelix-2.0.1 → allelix-2.1.0}/tests/test_cli.py +0 -0
  82. {allelix-2.0.1 → allelix-2.1.0}/tests/test_cli_helpers.py +0 -0
  83. {allelix-2.0.1 → allelix-2.1.0}/tests/test_compare.py +0 -0
  84. {allelix-2.0.1 → allelix-2.1.0}/tests/test_config.py +0 -0
  85. {allelix-2.0.1 → allelix-2.1.0}/tests/test_mock_data_invariants.py +0 -0
  86. {allelix-2.0.1 → allelix-2.1.0}/tests/test_registry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: allelix
3
- Version: 2.0.1
3
+ Version: 2.1.0
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>
@@ -161,6 +161,18 @@ This is not a disclaimer afterthought. It is a design constraint that affects mo
161
161
  - Reference databases are downloaded via `allelix db update` and cached locally.
162
162
  - Analysis runs offline against local database caches. A brief freshness check runs before analysis by default (skipped with `--no-update`).
163
163
 
164
+ ### Output files contain real annotations of your genome
165
+
166
+ The JSON / HTML / terminal output of `allelix analyze` and its
167
+ focused subcommands contains real annotations against your specific
168
+ variants — drug-response calls, carrier-status flags, hereditary-
169
+ disease findings. Wherever you write them via `--output <path>`,
170
+ that's where they sit until you delete them. Allelix doesn't
171
+ auto-clean and won't warn you when you write to `/tmp/` or any
172
+ other shared location. Treat the files as personal data: read them,
173
+ move them somewhere you control, or delete when you're done. A
174
+ data-lifecycle subcommand is planned for v2.1.
175
+
164
176
  ## Configuration
165
177
 
166
178
  Allelix stores persistent configuration in `config.toml` (in the data directory, default `~/.local/share/allelix/`). A default config is created on first run.
@@ -124,6 +124,18 @@ This is not a disclaimer afterthought. It is a design constraint that affects mo
124
124
  - Reference databases are downloaded via `allelix db update` and cached locally.
125
125
  - Analysis runs offline against local database caches. A brief freshness check runs before analysis by default (skipped with `--no-update`).
126
126
 
127
+ ### Output files contain real annotations of your genome
128
+
129
+ The JSON / HTML / terminal output of `allelix analyze` and its
130
+ focused subcommands contains real annotations against your specific
131
+ variants — drug-response calls, carrier-status flags, hereditary-
132
+ disease findings. Wherever you write them via `--output <path>`,
133
+ that's where they sit until you delete them. Allelix doesn't
134
+ auto-clean and won't warn you when you write to `/tmp/` or any
135
+ other shared location. Treat the files as personal data: read them,
136
+ move them somewhere you control, or delete when you're done. A
137
+ data-lifecycle subcommand is planned for v2.1.
138
+
127
139
  ## Configuration
128
140
 
129
141
  Allelix stores persistent configuration in `config.toml` (in the data directory, default `~/.local/share/allelix/`). A default config is created on first run.
@@ -0,0 +1,41 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 Allelix
3
+ """Allelix: open-source genotype analysis toolkit."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from importlib.metadata import PackageNotFoundError, version
8
+
9
+
10
+ def _read_pyproject_version() -> str | None:
11
+ """Read the package version from ``pyproject.toml``.
12
+
13
+ GH #34: fall back to ``pyproject.toml`` when run from a bare source
14
+ checkout (no editable install, no installed package metadata). Keeps
15
+ ``--version`` and the outbound HTTP User-Agent reporting the real
16
+ version string instead of the ``0.0.0+local`` sentinel that
17
+ misidentifies our traffic to NCBI / EBI / HuggingFace.
18
+
19
+ Returns ``None`` on any failure — the caller falls back to the
20
+ sentinel rather than crashing import.
21
+ """
22
+ import tomllib
23
+ from pathlib import Path
24
+
25
+ pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
26
+ try:
27
+ with pyproject.open("rb") as fh:
28
+ data = tomllib.load(fh)
29
+ except (OSError, tomllib.TOMLDecodeError):
30
+ return None
31
+ project = data.get("project") or {}
32
+ v = project.get("version")
33
+ return v if isinstance(v, str) and v else None
34
+
35
+
36
+ try:
37
+ __version__ = version("allelix")
38
+ except PackageNotFoundError:
39
+ # Source checkout without an editable install. Try pyproject.toml
40
+ # before falling back to the sentinel.
41
+ __version__ = _read_pyproject_version() or "0.0.0+local"
@@ -142,7 +142,30 @@ class Annotator(ABC):
142
142
  self.data_dir = data_dir
143
143
 
144
144
  def __del__(self) -> None:
145
- """Release resources on GC to prevent ResourceWarning."""
145
+ """Safety-net resource release on GC. Deliberately retained.
146
+
147
+ GH #36 (audit second pass) flagged ``__del__`` as a Python
148
+ antipattern — GC timing is nondeterministic and raised exceptions
149
+ are silently swallowed. The correct usage pattern is the
150
+ ``__enter__`` / ``__exit__`` context manager pair below, wired
151
+ through ``contextlib.ExitStack`` in ``reports/_pipeline.py``.
152
+
153
+ However: removing ``__del__`` exposes residual SQLite connection
154
+ leaks in code paths that construct an annotator outside a
155
+ context manager. ``ResourceWarning`` is elevated to error by
156
+ ``pytest`` config, so leaks fail the suite as
157
+ ``PytestUnraisableExceptionWarning`` — caught in the v2.0.2
158
+ ship gate when ``__del__`` was first removed. Until every call
159
+ site is verified to use ``with`` / ``ExitStack`` / explicit
160
+ ``close()``, this safety net stays. v2.1 task: audit and
161
+ remove.
162
+
163
+ ``contextlib.suppress(Exception)`` is deliberate — ``__del__``
164
+ must never raise. The GC timing and shutdown-ordering edges
165
+ are explicitly silenced; this is exactly the
166
+ "if you must keep ``__del__``, make absolutely sure it can
167
+ never raise" mitigation the audit recommended.
168
+ """
146
169
  with contextlib.suppress(Exception):
147
170
  self.close()
148
171
 
@@ -30,6 +30,7 @@ from allelix.databases.manager import (
30
30
  verify_file_hash,
31
31
  )
32
32
  from allelix.models import Annotation
33
+ from allelix.utils.allele import strand_aware_carrier_match
33
34
 
34
35
  if TYPE_CHECKING:
35
36
  from collections.abc import Iterable, Iterator
@@ -384,7 +385,6 @@ class ClinVarAnnotator(Annotator):
384
385
  (variant.rsid,),
385
386
  ).fetchall()
386
387
  annotations: list[Annotation] = []
387
- carrier_alleles = {variant.allele1, variant.allele2}
388
388
  user_is_multibase = len(variant.allele1) > 1 or len(variant.allele2) > 1
389
389
  # ADR-0023: report the user's actual diploid call consistently
390
390
  # across annotators, not the matched ALT base alone.
@@ -404,7 +404,14 @@ class ClinVarAnnotator(Annotator):
404
404
  clinvar_is_indel = len(ref) > 1 or len(alt) > 1
405
405
  if clinvar_is_indel and not user_is_multibase:
406
406
  continue
407
- if alt not in carrier_alleles:
407
+ # ADR-0035 PR 4: strand-aware carrier match. Direct {ref, alt}
408
+ # membership still covers forward-stranded inputs; the
409
+ # complement-fallback path fires only when Variant.ref confirms
410
+ # reverse orientation AND the site isn't palindromic — preserving
411
+ # the v2.0.1 #18 multi-allelic safety invariant.
412
+ if not strand_aware_carrier_match(
413
+ variant.ref, variant.allele1, variant.allele2, ref, alt
414
+ ):
408
415
  continue
409
416
  sig_label = _normalize_clnsig(clnsig) if clnsig else "unknown"
410
417
  if not self._include_benign and sig_label in _BENIGN_CLNSIGS:
@@ -562,7 +569,6 @@ class ClinVarAnnotator(Annotator):
562
569
  rows = rows_by_build_rsid.get((variant.build, variant.rsid))
563
570
  if not rows:
564
571
  continue
565
- carrier_alleles = {variant.allele1, variant.allele2}
566
572
  user_is_multibase = len(variant.allele1) > 1 or len(variant.allele2) > 1
567
573
  user_diploid = _user_diploid(variant)
568
574
  for row in rows:
@@ -580,7 +586,11 @@ class ClinVarAnnotator(Annotator):
580
586
  clinvar_is_indel = len(ref) > 1 or len(alt) > 1
581
587
  if clinvar_is_indel and not user_is_multibase:
582
588
  continue
583
- if alt not in carrier_alleles:
589
+ # ADR-0035 PR 4: strand-aware carrier match. See annotate() for
590
+ # the safety rationale (#18 multi-allelic invariant preserved).
591
+ if not strand_aware_carrier_match(
592
+ variant.ref, variant.allele1, variant.allele2, ref, alt
593
+ ):
584
594
  continue
585
595
  sig_label = _normalize_clnsig(clnsig) if clnsig else "unknown"
586
596
  if not self._include_benign and sig_label in _BENIGN_CLNSIGS:
@@ -6,6 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import contextlib
8
8
  import logging
9
+ import re
9
10
  import sqlite3
10
11
  import zipfile
11
12
  from typing import TYPE_CHECKING, ClassVar
@@ -56,6 +57,27 @@ logger = logging.getLogger(__name__)
56
57
 
57
58
  _BATCH_CHUNK = 500 # SQLite default SQLITE_MAX_VARIABLE_NUMBER is 999
58
59
 
60
+ # ADR-0035 PR 3: PheCode often arrives embedded in the upstream DISEASE/TRAIT
61
+ # field (e.g. ``"Type 2 diabetes (PheCode 250.2)"``). The annotator extracts it
62
+ # at the boundary so downstream code reads ``Annotation.phecode`` as a
63
+ # structured field instead of regex-parsing prose. The trimmed trait flows into
64
+ # ``Annotation.trait``; ``description`` is rebuilt below from the structured
65
+ # parts so display surfaces still show the PheCode label.
66
+ _PHECODE_SUFFIX_RE = re.compile(r"\s*\(PheCode\s+([\d.]+)\)\s*$")
67
+
68
+
69
+ def _split_trait_and_phecode(raw_trait: str) -> tuple[str, str]:
70
+ """Split an upstream trait into (clean_trait, phecode).
71
+
72
+ PheCode (when present) is returned as a bare numeric string like
73
+ ``"250.2"``. Returns ``""`` for phecode when no PheCode suffix is found.
74
+ The clean trait is stripped of trailing whitespace.
75
+ """
76
+ m = _PHECODE_SUFFIX_RE.search(raw_trait)
77
+ if m is None:
78
+ return raw_trait.strip(), ""
79
+ return raw_trait[: m.start()].rstrip(), m.group(1)
80
+
59
81
 
60
82
  def _magnitude(p_value: float | None, or_beta: float | None) -> float:
61
83
  """Derive magnitude from p-value and optional effect size.
@@ -288,9 +310,14 @@ class GWASCatalogAnnotator(Annotator):
288
310
  mag = min(_magnitude(p_value, or_beta), _UNKNOWN_RISK_ALLELE_MAG_CAP)
289
311
  risk_note = " (risk allele not specified in study)"
290
312
 
313
+ clean_trait, phecode = _split_trait_and_phecode(trait)
314
+ phecode_suffix = f" (PheCode {phecode})" if phecode else ""
291
315
  p_str = f"p={p_value:.1e}" if p_value is not None else "p=N/A"
292
316
  gene_str = gene or "—"
293
- description = f"GWAS Catalog: {trait} ({p_str}, gene: {gene_str}){risk_note}"
317
+ description = (
318
+ f"GWAS Catalog: {clean_trait}{phecode_suffix} "
319
+ f"({p_str}, gene: {gene_str}){risk_note}"
320
+ )
294
321
 
295
322
  references: list[str] = []
296
323
  if pubmed_id:
@@ -309,10 +336,13 @@ class GWASCatalogAnnotator(Annotator):
309
336
  attribution=self.attribution,
310
337
  genotype_match=user_diploid,
311
338
  references=references,
312
- condition=trait,
339
+ condition=clean_trait,
313
340
  gene=gene or "",
314
- alt="",
341
+ alt=risk_allele or "",
315
342
  is_must_include=variant.rsid in _MUST_INCLUDE_RSIDS,
343
+ trait=clean_trait,
344
+ p_value=p_value,
345
+ phecode=phecode,
316
346
  )
317
347
  )
318
348
  return annotations
@@ -377,9 +407,14 @@ class GWASCatalogAnnotator(Annotator):
377
407
  mag = min(_magnitude(p_value, or_beta), _UNKNOWN_RISK_ALLELE_MAG_CAP)
378
408
  risk_note = " (risk allele not specified in study)"
379
409
 
410
+ clean_trait, phecode = _split_trait_and_phecode(trait)
411
+ phecode_suffix = f" (PheCode {phecode})" if phecode else ""
380
412
  p_str = f"p={p_value:.1e}" if p_value is not None else "p=N/A"
381
413
  gene_str = gene or "—"
382
- description = f"GWAS Catalog: {trait} ({p_str}, gene: {gene_str}){risk_note}"
414
+ description = (
415
+ f"GWAS Catalog: {clean_trait}{phecode_suffix} "
416
+ f"({p_str}, gene: {gene_str}){risk_note}"
417
+ )
383
418
 
384
419
  references: list[str] = []
385
420
  if pubmed_id:
@@ -397,10 +432,13 @@ class GWASCatalogAnnotator(Annotator):
397
432
  attribution=self.attribution,
398
433
  genotype_match=user_diploid,
399
434
  references=references,
400
- condition=trait,
435
+ condition=clean_trait,
401
436
  gene=gene or "",
402
- alt="",
437
+ alt=risk_allele or "",
403
438
  is_must_include=variant.rsid in _MUST_INCLUDE_RSIDS,
439
+ trait=clean_trait,
440
+ p_value=p_value,
441
+ phecode=phecode,
404
442
  )
405
443
 
406
444
 
@@ -30,6 +30,7 @@ from allelix.databases.pharmgkb_loader import (
30
30
  schema_is_current,
31
31
  )
32
32
  from allelix.models import Annotation
33
+ from allelix.utils.allele import derive_alt_from_diploid, strand_aware_genotype_match
33
34
 
34
35
  if TYPE_CHECKING:
35
36
  from collections.abc import Callable, Iterable, Iterator
@@ -260,20 +261,25 @@ class PharmGKBAnnotator(Annotator):
260
261
  if is_clinvar_homref(variant, self._clinvar_ref_provider):
261
262
  return []
262
263
 
264
+ # ADR-0035 PR 4: fetch all rows for the rsid (typically <5) and apply
265
+ # strand-aware genotype match in Python. Forward-stranded users hit
266
+ # the same set of rows as the v0.7-era ``WHERE genotype = ?`` SQL;
267
+ # reverse-stranded users now also match when Variant.ref confirms
268
+ # orientation AND the source genotype is non-palindromic.
263
269
  sql = (
264
270
  "SELECT genotype, gene, drugs, phenotype, phenotype_category, "
265
271
  "annotation_text, level_of_evidence, score, pgkb_annotation_id "
266
272
  "FROM pharmgkb_annotations "
267
- "WHERE rsid = ? AND genotype = ? AND is_nonfinding = 0"
273
+ "WHERE rsid = ? AND is_nonfinding = 0"
268
274
  )
269
- params = (variant.rsid, user_geno)
275
+ params = (variant.rsid,)
270
276
 
271
277
  rows = self._connection().execute(sql, params).fetchall()
272
278
  annotations: list[Annotation] = []
273
279
  user_diploid = _user_diploid(variant)
274
280
  for row in rows:
275
281
  (
276
- _geno,
282
+ geno,
277
283
  gene,
278
284
  drugs,
279
285
  phenotype,
@@ -283,6 +289,10 @@ class PharmGKBAnnotator(Annotator):
283
289
  _score,
284
290
  pgkb_annotation_id,
285
291
  ) = row
292
+ if not strand_aware_genotype_match(
293
+ variant.allele1, variant.allele2, geno, variant.ref
294
+ ):
295
+ continue
286
296
  sig_label = level_of_evidence.strip().lower() or "unknown"
287
297
  description_parts = [f"ClinPGx: {drugs}"] if drugs else ["ClinPGx"]
288
298
  if phenotype:
@@ -306,6 +316,7 @@ class PharmGKBAnnotator(Annotator):
306
316
  references=references,
307
317
  condition=phenotype or "",
308
318
  gene=gene or "",
319
+ alt=derive_alt_from_diploid(variant.ref, variant.allele1, variant.allele2),
309
320
  )
310
321
  )
311
322
  return annotations
@@ -352,7 +363,7 @@ class PharmGKBAnnotator(Annotator):
352
363
  for row in cursor:
353
364
  rows_by_rsid.setdefault(row[0], []).append(row[1:])
354
365
 
355
- for variant, user_geno in candidates:
366
+ for variant, _user_geno in candidates:
356
367
  rows = rows_by_rsid.get(variant.rsid)
357
368
  if not rows:
358
369
  continue
@@ -369,7 +380,11 @@ class PharmGKBAnnotator(Annotator):
369
380
  _score,
370
381
  pgkb_annotation_id,
371
382
  ) = row
372
- if geno != user_geno:
383
+ # ADR-0035 PR 4: strand-aware genotype match. See annotate() for
384
+ # the safety rationale (palindrome guard + variant.ref orientation).
385
+ if not strand_aware_genotype_match(
386
+ variant.allele1, variant.allele2, geno, variant.ref
387
+ ):
373
388
  continue
374
389
  sig_label = level_of_evidence.strip().lower() or "unknown"
375
390
  description_parts = [f"ClinPGx: {drugs}"] if drugs else ["ClinPGx"]
@@ -393,6 +408,7 @@ class PharmGKBAnnotator(Annotator):
393
408
  references=references,
394
409
  condition=phenotype or "",
395
410
  gene=gene or "",
411
+ alt=derive_alt_from_diploid(variant.ref, variant.allele1, variant.allele2),
396
412
  )
397
413
 
398
414
 
@@ -29,6 +29,7 @@ from allelix.databases.snpedia_loader import (
29
29
  install_prebuilt_cache,
30
30
  )
31
31
  from allelix.models import Annotation
32
+ from allelix.utils.allele import derive_alt_from_diploid
32
33
 
33
34
  if TYPE_CHECKING:
34
35
  from collections.abc import Callable, Iterable, Iterator
@@ -272,6 +273,7 @@ class SNPediaAnnotator(Annotator):
272
273
  genotype_match=genotype_match,
273
274
  references=[f"https://www.snpedia.com/index.php/{snp_url_path}"],
274
275
  gene=gene or "",
276
+ alt=derive_alt_from_diploid(variant.ref, allele1, allele2),
275
277
  )
276
278
  )
277
279
 
@@ -356,4 +358,5 @@ class SNPediaAnnotator(Annotator):
356
358
  genotype_match=genotype_match,
357
359
  references=[f"https://www.snpedia.com/index.php/{snp_url_path}"],
358
360
  gene=gene or "",
361
+ alt=derive_alt_from_diploid(variant.ref, allele1, allele2),
359
362
  )
@@ -336,6 +336,12 @@ def _emit_build_diagnostics(result: object) -> None:
336
336
  source = "detected"
337
337
  elif diag.header_build:
338
338
  source = "header (no position confirmation)"
339
+ elif diag.chr_prefix_inferred:
340
+ # GH #38: chr-prefixed contig names ("chr1", "chrX", ...) reliably
341
+ # indicate GRCh38 in modern caller output. We DID detect a build;
342
+ # the banner and the warning should say so instead of reading as
343
+ # a blind default.
344
+ source = "inferred from chr-prefixed contig names"
339
345
  else:
340
346
  source = "fallback (no known SNPs matched)"
341
347
  console.print(
@@ -349,15 +355,26 @@ def _emit_build_diagnostics(result: object) -> None:
349
355
  f"This is a real-world data-quality issue — your provider may have "
350
356
  f"mislabeled the build (see ADR-0021).[/yellow]"
351
357
  )
358
+ elif diag.chr_prefix_inferred:
359
+ # GH #38: positive, accurate message — the inference path
360
+ # actually fired. Still recommend `--build` for users who
361
+ # want to lock in the answer; chr-prefix is a strong signal
362
+ # but UCSC hg19 also uses `chr` prefixes, so the heuristic
363
+ # isn't guaranteed against a hg19-converted file.
364
+ console.print(
365
+ f"[dim]Inferred {diag.effective_build} from chr-prefixed contig "
366
+ f"names (GRCh38 convention). Pass --build grch37 if this file is "
367
+ f"UCSC hg19 with chr-prefixed contigs instead.[/dim]"
368
+ )
352
369
  elif not diag.override and diag.detected_build is None and diag.header_build is None:
353
370
  # Common shape: VCF from a variant caller where the ID column is `.`
354
- # and the header has no ##contig assembly tag. The detector had no
355
- # rsID signal AND no header signal — both auto-detect paths failed.
371
+ # and the header has no ##contig assembly tag, AND no chr-prefix
372
+ # signal was observed. All three auto-detect paths failed.
356
373
  # Loudly recommend an explicit --build because picking the wrong one
357
374
  # silently means every annotation lookup uses wrong coordinates.
358
375
  console.print(
359
376
  f"[yellow]Could not auto-detect genome build (no rsIDs in input, "
360
- f"no ##contig assembly tag in header). Defaulted to "
377
+ f"no ##contig assembly tag, no chr-prefixed contigs). Defaulted to "
361
378
  f"{diag.effective_build}. If the file is the other build, pass "
362
379
  f"--build grch37 or --build grch38 explicitly — annotation "
363
380
  f"coordinates differ between builds and silently using the wrong "
@@ -0,0 +1,136 @@
1
+ # High-value SNPs: clinically important variants where a no-call
2
+ # should be explicitly flagged rather than silently omitted.
3
+ #
4
+ # Schema:
5
+ # rsid: dbSNP identifier
6
+ # gene: gene symbol
7
+ # cluster: optional grouping (e.g., "APOE" for the two-SNP APOE haplotype)
8
+ # note: human-readable warning text for no-call reports
9
+ #
10
+ # To add a SNP: append an entry following this format. Entries with the
11
+ # same cluster are grouped in warnings (e.g., "APOE genotype cannot be
12
+ # determined" when either rs429358 or rs7412 is a no-call).
13
+
14
+ - rsid: rs429358
15
+ gene: APOE
16
+ cluster: APOE
17
+ note: Required (with rs7412) to determine APOE genotype
18
+
19
+ - rsid: rs7412
20
+ gene: APOE
21
+ cluster: APOE
22
+ note: Required (with rs429358) to determine APOE genotype
23
+
24
+ - rsid: rs5742904
25
+ gene: APOB
26
+ note: Familial hypercholesterolemia marker (FH)
27
+
28
+ - rsid: rs80357906
29
+ gene: BRCA1
30
+ note: Hereditary breast/ovarian cancer marker
31
+
32
+ - rsid: rs1801133
33
+ gene: MTHFR
34
+ cluster: MTHFR
35
+ note: Methylation pathway (C677T)
36
+
37
+ - rsid: rs1801131
38
+ gene: MTHFR
39
+ cluster: MTHFR
40
+ note: Methylation pathway (A1298C)
41
+
42
+ - rsid: rs4680
43
+ gene: COMT
44
+ note: Catechol-O-methyltransferase activity
45
+
46
+ - rsid: rs1065852
47
+ gene: CYP2D6
48
+ note: Opioid / SSRI metabolism
49
+
50
+ - rsid: rs4244285
51
+ gene: CYP2C19
52
+ note: Clopidogrel, PPIs metabolism
53
+
54
+ - rsid: rs1799853
55
+ gene: CYP2C9
56
+ note: Warfarin metabolism
57
+
58
+ - rsid: rs4149056
59
+ gene: SLCO1B1
60
+ note: Statin myopathy risk
61
+
62
+ - rsid: rs3918290
63
+ gene: DPYD
64
+ note: Fluoropyrimidine toxicity
65
+
66
+ # v2.0.2 additions (GH #7): clinically actionable single-SNP variants
67
+ # verified to be on consumer arrays. Two new clusters: HFE (hereditary
68
+ # hemochromatosis compound-het) and TPMT (thiopurine *3 haplotype).
69
+
70
+ - rsid: rs6025
71
+ gene: F5
72
+ note: Factor V Leiden — hereditary thrombophilia (FDA-cleared GHR variant)
73
+
74
+ - rsid: rs1799963
75
+ gene: F2
76
+ note: Prothrombin G20210A — hereditary thrombophilia
77
+
78
+ - rsid: rs1800562
79
+ gene: HFE
80
+ cluster: HFE
81
+ note: C282Y — hereditary hemochromatosis (compound het with H63D is the clinical form)
82
+
83
+ - rsid: rs1799945
84
+ gene: HFE
85
+ cluster: HFE
86
+ note: H63D — hereditary hemochromatosis (compound het with C282Y is the clinical form)
87
+
88
+ - rsid: rs113993960
89
+ gene: CFTR
90
+ note: F508del — most common CF allele; carrier status for reproductive planning
91
+
92
+ - rsid: rs334
93
+ gene: HBB
94
+ note: Sickle cell (HbS) — most-screened-for variant worldwide; carrier status
95
+
96
+ - rsid: rs80359550
97
+ gene: BRCA2
98
+ note: BRCA2 6174delT — most common Ashkenazi founder mutation (BRCA1 covered by rs80357906)
99
+
100
+ - rsid: rs9923231
101
+ gene: VKORC1
102
+ note: Warfarin dosing (CPIC Level A, pairs with CYP2C9 rs1799853)
103
+
104
+ - rsid: rs1057910
105
+ gene: CYP2C9
106
+ note: CYP2C9*3 — completes warfarin metabolizer profile alongside *2 (rs1799853)
107
+
108
+ - rsid: rs12248560
109
+ gene: CYP2C19
110
+ note: CYP2C19*17 ultrarapid metabolizer — completes clopidogrel profile alongside *2 (rs4244285)
111
+
112
+ - rsid: rs3892097
113
+ gene: CYP2D6
114
+ note: CYP2D6*4 — most common LOF in Europeans (complements rs1065852 *10)
115
+
116
+ - rsid: rs776746
117
+ gene: CYP3A5
118
+ note: CYP3A5*3 — tacrolimus dosing (CPIC Level A)
119
+
120
+ - rsid: rs1142345
121
+ gene: TPMT
122
+ cluster: TPMT
123
+ note: TPMT*3C — thiopurine dosing (CPIC Level A; with rs1800460 resolves *3A/*3B/*3C)
124
+
125
+ - rsid: rs1800460
126
+ gene: TPMT
127
+ cluster: TPMT
128
+ note: TPMT*3B — thiopurine dosing (CPIC Level A; with rs1142345 resolves *3A/*3B/*3C)
129
+
130
+ - rsid: rs116855232
131
+ gene: NUDT15
132
+ note: Thiopurine toxicity (CPIC Level A; critical in East Asian populations, complements TPMT cluster)
133
+
134
+ - rsid: rs34637584
135
+ gene: LRRK2
136
+ note: G2019S — most common monogenic Parkinson's variant
@@ -31,6 +31,13 @@ class Variant:
31
31
  allele1: First observed allele. A/T/G/C, multi-base for indels, or "-" for no-call.
32
32
  allele2: Second observed allele. Same encoding as allele1.
33
33
  build: Reference genome build. "GRCh37" (hg19) or "GRCh38" (hg38).
34
+ ref: Reference allele on the forward strand at this position, when
35
+ known. VCF parsers populate from the REF column; array parsers
36
+ currently leave it None. Required by strand-aware carrier matching
37
+ (ADR-0035) and downstream consumers that need to identify the risk
38
+ allele within the user's pair. None means "cannot resolve" —
39
+ consumers degrade gracefully (return ambiguous, skip strand check,
40
+ etc.) rather than guess.
34
41
  """
35
42
 
36
43
  rsid: str
@@ -39,9 +46,10 @@ class Variant:
39
46
  allele1: str
40
47
  allele2: str
41
48
  build: str = DEFAULT_BUILD
49
+ ref: str | None = None
42
50
 
43
51
  def __post_init__(self) -> None:
44
- """Normalize allele case at construction (GH #14).
52
+ """Normalize allele case at construction (GH #14, ADR-0035).
45
53
 
46
54
  Reference databases (ClinVar, gnomAD, ClinPGx, etc.) all ship
47
55
  uppercase alleles, and carrier matching is raw set membership
@@ -53,11 +61,20 @@ class Variant:
53
61
  at the model boundary so the invariant is impossible to
54
62
  violate downstream. The no-call marker is left as-is;
55
63
  multi-base alleles (indels) are uppercased in place.
64
+
65
+ ``ref`` (ADR-0035) is a sibling allele field feeding the same
66
+ downstream matching path (PR 4 strand-aware carrier matching
67
+ compares ``ref`` against ``{allele1, allele2}`` and against
68
+ gnomAD's uppercased REF). VCFs derived from soft-masked
69
+ references emit lowercase bases in the REF column, so the
70
+ same normalization applies here for the same reason.
56
71
  """
57
72
  if self.allele1 and self.allele1 != NO_CALL_MARKER:
58
73
  self.allele1 = self.allele1.upper()
59
74
  if self.allele2 and self.allele2 != NO_CALL_MARKER:
60
75
  self.allele2 = self.allele2.upper()
76
+ if self.ref is not None and self.ref != NO_CALL_MARKER:
77
+ self.ref = self.ref.upper()
61
78
 
62
79
  @property
63
80
  def is_heterozygous(self) -> bool:
@@ -126,6 +143,15 @@ class Annotation:
126
143
  am_pathogenicity: float | None = None
127
144
  am_class: str = ""
128
145
  cadd_phred: float | None = None
146
+ # ADR-0035 cluster manifest (PR 3): structured GWAS fields promoted out of
147
+ # the rendered ``description`` prose. `description` still carries the same
148
+ # rendered text for HTML / terminal display, but downstream code (rollup,
149
+ # diff, future consumers) reads the structured fields directly instead of
150
+ # regex-parsing prose. Populated by the GWAS annotator; empty / None on
151
+ # non-GWAS rows.
152
+ trait: str = ""
153
+ p_value: float | None = None
154
+ phecode: str = ""
129
155
 
130
156
  @property
131
157
  def zygosity(self) -> str:
@@ -5,7 +5,7 @@
5
5
  from __future__ import annotations
6
6
 
7
7
  from abc import ABC, abstractmethod
8
- from typing import TYPE_CHECKING, ClassVar, TypedDict
8
+ from typing import TYPE_CHECKING, ClassVar, NotRequired, TypedDict
9
9
 
10
10
  if TYPE_CHECKING:
11
11
  from collections.abc import Iterator
@@ -26,11 +26,18 @@ class GenotypeMetadata(TypedDict):
26
26
  format: Parser name (matches `GenotypeParser.name`).
27
27
  sample_id: Vendor sample identifier, or "" if not present in the file.
28
28
  build: Reference genome build (e.g., "GRCh37").
29
+ chr_prefix_observed: True when the file uses ``chr``-prefixed contig
30
+ names (``chr1`` / ``chrX``). Optional — only VCF / gVCF parsers
31
+ populate it; consumer-array exports always use bare names and
32
+ don't set it. GH #38: used as a tertiary build-detection signal
33
+ after rsID matching and ``##assembly`` tag checks both fail.
34
+ ``chr``-prefixed contigs overwhelmingly indicate GRCh38.
29
35
  """
30
36
 
31
37
  format: str
32
38
  sample_id: str
33
39
  build: str
40
+ chr_prefix_observed: NotRequired[bool]
34
41
 
35
42
 
36
43
  class GenotypeParser(ABC):