allelix 2.0.2__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 (83) hide show
  1. {allelix-2.0.2 → allelix-2.1.0}/PKG-INFO +1 -1
  2. {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/clinvar.py +14 -4
  3. {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/gwas.py +44 -6
  4. {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/pharmgkb.py +21 -5
  5. {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/snpedia.py +3 -0
  6. {allelix-2.0.2 → allelix-2.1.0}/allelix/models.py +27 -1
  7. {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/vcf.py +1 -0
  8. {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/_pipeline.py +39 -58
  9. {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/diff.py +1 -1
  10. {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/json_report.py +6 -2
  11. allelix-2.1.0/allelix/utils/allele.py +211 -0
  12. {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/PKG-INFO +1 -1
  13. {allelix-2.0.2 → allelix-2.1.0}/pyproject.toml +1 -1
  14. {allelix-2.0.2 → allelix-2.1.0}/tests/test_models.py +27 -0
  15. allelix-2.0.2/allelix/utils/allele.py +0 -89
  16. {allelix-2.0.2 → allelix-2.1.0}/LICENSE +0 -0
  17. {allelix-2.0.2 → allelix-2.1.0}/README.md +0 -0
  18. {allelix-2.0.2 → allelix-2.1.0}/allelix/__init__.py +0 -0
  19. {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/__init__.py +0 -0
  20. {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/alphamissense.py +0 -0
  21. {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/base.py +0 -0
  22. {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/cadd.py +0 -0
  23. {allelix-2.0.2 → allelix-2.1.0}/allelix/annotators/gnomad.py +0 -0
  24. {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/__init__.py +0 -0
  25. {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/_helpers.py +0 -0
  26. {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/_options.py +0 -0
  27. {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/analyze.py +0 -0
  28. {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/config.py +0 -0
  29. {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/db.py +0 -0
  30. {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/focused.py +0 -0
  31. {allelix-2.0.2 → allelix-2.1.0}/allelix/cli/utility.py +0 -0
  32. {allelix-2.0.2 → allelix-2.1.0}/allelix/compare.py +0 -0
  33. {allelix-2.0.2 → allelix-2.1.0}/allelix/config.py +0 -0
  34. {allelix-2.0.2 → allelix-2.1.0}/allelix/data/__init__.py +0 -0
  35. {allelix-2.0.2 → allelix-2.1.0}/allelix/data/clinvar_clnsig_snapshot.yaml +0 -0
  36. {allelix-2.0.2 → allelix-2.1.0}/allelix/data/high_value_snps.yaml +0 -0
  37. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/__init__.py +0 -0
  38. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/_versions.py +0 -0
  39. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/alphamissense_loader.py +0 -0
  40. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/cadd_loader.py +0 -0
  41. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/cpic_loader.py +0 -0
  42. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/gnomad_loader.py +0 -0
  43. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/gwas_loader.py +0 -0
  44. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/loader_utils.py +0 -0
  45. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/manager.py +0 -0
  46. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/pharmgkb_loader.py +0 -0
  47. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/schema.py +0 -0
  48. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/snpedia_loader.py +0 -0
  49. {allelix-2.0.2 → allelix-2.1.0}/allelix/databases/snpedia_parser.py +0 -0
  50. {allelix-2.0.2 → allelix-2.1.0}/allelix/exporters/__init__.py +0 -0
  51. {allelix-2.0.2 → allelix-2.1.0}/allelix/exporters/plink.py +0 -0
  52. {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/__init__.py +0 -0
  53. {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/_helpers.py +0 -0
  54. {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/ancestrydna.py +0 -0
  55. {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/base.py +0 -0
  56. {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/ftdna.py +0 -0
  57. {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/ftdna_illumina.py +0 -0
  58. {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/livingdna.py +0 -0
  59. {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/myhappygenes.py +0 -0
  60. {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/myheritage.py +0 -0
  61. {allelix-2.0.2 → allelix-2.1.0}/allelix/parsers/twentythreeandme.py +0 -0
  62. {allelix-2.0.2 → allelix-2.1.0}/allelix/py.typed +0 -0
  63. {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/__init__.py +0 -0
  64. {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/high_value.py +0 -0
  65. {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/html.py +0 -0
  66. {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/methylation.py +0 -0
  67. {allelix-2.0.2 → allelix-2.1.0}/allelix/reports/terminal.py +0 -0
  68. {allelix-2.0.2 → allelix-2.1.0}/allelix/utils/__init__.py +0 -0
  69. {allelix-2.0.2 → allelix-2.1.0}/allelix/utils/build_detect.py +0 -0
  70. {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/SOURCES.txt +0 -0
  71. {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/dependency_links.txt +0 -0
  72. {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/entry_points.txt +0 -0
  73. {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/requires.txt +0 -0
  74. {allelix-2.0.2 → allelix-2.1.0}/allelix.egg-info/top_level.txt +0 -0
  75. {allelix-2.0.2 → allelix-2.1.0}/setup.cfg +0 -0
  76. {allelix-2.0.2 → allelix-2.1.0}/tests/test_cli.py +0 -0
  77. {allelix-2.0.2 → allelix-2.1.0}/tests/test_cli_helpers.py +0 -0
  78. {allelix-2.0.2 → allelix-2.1.0}/tests/test_compare.py +0 -0
  79. {allelix-2.0.2 → allelix-2.1.0}/tests/test_config.py +0 -0
  80. {allelix-2.0.2 → allelix-2.1.0}/tests/test_end_to_end.py +0 -0
  81. {allelix-2.0.2 → allelix-2.1.0}/tests/test_mock_data_invariants.py +0 -0
  82. {allelix-2.0.2 → allelix-2.1.0}/tests/test_registry.py +0 -0
  83. {allelix-2.0.2 → allelix-2.1.0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: allelix
3
- Version: 2.0.2
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>
@@ -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
  )
@@ -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:
@@ -413,6 +413,7 @@ def _parse_data_line(line: str, sample_idx: int) -> Variant | None:
413
413
  allele1=a1,
414
414
  allele2=a2,
415
415
  # build is overridden by the pipeline (ADR-0021); leave default.
416
+ ref=ref,
416
417
  )
417
418
 
418
419
 
@@ -167,58 +167,26 @@ class AnalysisResult:
167
167
  return out
168
168
 
169
169
 
170
- # GH #24: both _gwas_base_trait and _gwas_phecode_parent reach into the
171
- # formatted description string to recover structured fields. The two
172
- # previously used inconsistent delimiters (" (PheCode " vs "(PheCode "),
173
- # the spirit of "no regex on prose" violated with .split()/.find().
174
- # Until trait/p-value/PheCode are carried as structured fields on
175
- # Annotation (deferred to v2.1+, see ADR-0024 followup), at least keep
176
- # the delimiter consistent across both readers.
177
- _PHECODE_DELIM = " (PheCode "
178
-
179
-
180
- def _gwas_base_trait(description: str) -> str | None:
181
- """Extract trait text from a GWAS description, stripping MTAG suffix and PheCode label."""
182
- marker = "GWAS Catalog: "
183
- if marker not in description:
184
- return None
185
- s = description.split(marker, 1)[1]
186
- s = s.split(" (p=", 1)[0]
187
- if s.endswith(" (MTAG)"):
188
- s = s[: -len(" (MTAG)")]
189
- s = s.split(_PHECODE_DELIM, 1)[0]
190
- return s.strip().lower()
191
-
192
-
193
- def _gwas_phecode_parent(description: str) -> str | None:
194
- """Extract PheCode parent (numeric prefix before the dot), or None."""
195
- idx = description.find(_PHECODE_DELIM)
196
- if idx == -1:
197
- return None
198
- rest = description[idx + len(_PHECODE_DELIM) :]
199
- end = rest.find(")")
200
- if end == -1:
201
- return None
202
- code = rest[:end].strip()
203
- parent = code.split(".", 1)[0]
204
- return parent if parent.isdigit() else None
170
+ # ADR-0035 PR 3 closed the GH #24 structural-half: GWAS rollup now reads
171
+ # trait / phecode / p_value from structured Annotation fields instead of
172
+ # regex-parsing the rendered ``description`` prose. The previous
173
+ # ``_gwas_base_trait`` / ``_gwas_phecode_parent`` / ``_gwas_p_value`` helpers
174
+ # and the ``_PHECODE_DELIM`` shared-delimiter constant (the v2.0.1 suppress-
175
+ # half) are gone — rollup keys directly off ``a.trait``, ``a.phecode``,
176
+ # ``a.p_value``. MTAG suffix still lives in ``description`` for display and
177
+ # is detected with ``"(MTAG)" in a.description``; promoting that to a
178
+ # structured flag is outside the ADR-0035 cluster manifest.
205
179
 
206
180
 
207
- def _gwas_p_value(description: str) -> float:
208
- """Extract p-value from a GWAS description. Returns inf if unparseable."""
209
- idx = description.find("(p=")
210
- if idx == -1:
211
- return float("inf")
212
- rest = description[idx + len("(p=") :]
213
- end = rest.find(",")
214
- if end == -1:
215
- end = rest.find(")")
216
- if end == -1:
217
- return float("inf")
218
- try:
219
- return float(rest[:end].strip())
220
- except ValueError:
221
- return float("inf")
181
+ def _gwas_phecode_parent(phecode: str) -> str | None:
182
+ """Return the integer parent of a structured PheCode (e.g., ``"411.4"`` → ``"411"``).
183
+
184
+ Returns None when no PheCode is set or the prefix isn't an integer.
185
+ """
186
+ if not phecode:
187
+ return None
188
+ parent = phecode.split(".", 1)[0]
189
+ return parent if parent.isdigit() else None
222
190
 
223
191
 
224
192
  def rollup_gwas_duplicates(annotations: list[Annotation]) -> list[Annotation]:
@@ -238,29 +206,25 @@ def rollup_gwas_duplicates(annotations: list[Annotation]) -> list[Annotation]:
238
206
  if not gwas_rows:
239
207
  return annotations
240
208
 
241
- plain_keys = {
242
- (a.rsid, _gwas_base_trait(a.description))
243
- for a in gwas_rows
244
- if "(MTAG)" not in a.description
245
- }
209
+ plain_keys = {(a.rsid, a.trait.lower()) for a in gwas_rows if "(MTAG)" not in a.description}
246
210
  after_mtag = [
247
211
  a
248
212
  for a in gwas_rows
249
213
  if a.is_must_include
250
214
  or "(MTAG)" not in a.description
251
- or (a.rsid, _gwas_base_trait(a.description)) not in plain_keys
215
+ or (a.rsid, a.trait.lower()) not in plain_keys
252
216
  ]
253
217
 
254
218
  by_parent: dict[tuple[str, str], list[Annotation]] = {}
255
219
  no_phecode: list[Annotation] = []
256
220
  for a in after_mtag:
257
- parent = _gwas_phecode_parent(a.description)
221
+ parent = _gwas_phecode_parent(a.phecode)
258
222
  if parent is None or a.is_must_include:
259
223
  no_phecode.append(a)
260
224
  else:
261
225
  by_parent.setdefault((a.rsid, parent), []).append(a)
262
226
  for group in by_parent.values():
263
- winner = min(group, key=lambda x: _gwas_p_value(x.description))
227
+ winner = min(group, key=lambda x: x.p_value if x.p_value is not None else float("inf"))
264
228
  no_phecode.append(winner)
265
229
 
266
230
  survivors.extend(no_phecode)
@@ -438,6 +402,23 @@ def run_analysis(
438
402
  resolution = clinvar_resolver.bulk_resolve_rsids(rsidless)
439
403
  for (chrom, pos, ref, alt), rsid in resolution.items():
440
404
  resolved_coords[rsid] = (chrom, pos, ref, alt)
405
+ # ADR-0035 PR 4: populate Variant.ref for array data via gnomAD's
406
+ # rsid → forward-REF map. VCF inputs already carry ref (PR 1).
407
+ # Annotators downstream (ClinVar / PharmGKB strand-aware carrier
408
+ # match; SNPedia / ClinPGx per-row alt threading from PR 2)
409
+ # consume this. Variants without rsIDs (post-resolution) or
410
+ # whose rsID isn't in gnomAD keep ref=None and degrade
411
+ # gracefully — direct-match-only with no strand-flip.
412
+ if gnomad is not None and gnomad.is_ready():
413
+ need_ref = {v.rsid for v in batch_buf if v.ref is None and v.rsid.startswith("rs")}
414
+ if need_ref:
415
+ coord_map = gnomad.bulk_resolve_coordinates(need_ref)
416
+ for v in batch_buf:
417
+ if v.ref is None and v.rsid in coord_map:
418
+ entries = coord_map[v.rsid]
419
+ # All multi-allelic rows at a position share the
420
+ # same REF; first entry's REF is canonical.
421
+ v.ref = entries[0][2] if entries else None
441
422
  # High-value rsID match runs AFTER resolution so a variant
442
423
  # entering as ID=. that resolves to a high-value rsID is still
443
424
  # caught. GH #11.
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
26
26
 
27
27
  from allelix.models import Annotation
28
28
 
29
- _SUPPORTED_SCHEMA_VERSIONS = {"1", "2", "3", "4"}
29
+ _SUPPORTED_SCHEMA_VERSIONS = {"1", "2", "3", "4", "5"}
30
30
 
31
31
  # GH #25: magnitude comparison tolerance. Magnitudes are scored on a
32
32
  # 0-10 scale; representation noise from JSON round-tripping (e.g. 7.5
@@ -5,7 +5,7 @@
5
5
  Output schema (versioned via `schema_version`):
6
6
 
7
7
  {
8
- "schema_version": "4",
8
+ "schema_version": "5",
9
9
  "allelix_version": "1.1.0",
10
10
  "generated_at": "2026-05-11T12:34:56+00:00",
11
11
  "regulatory_notice": "...",
@@ -52,7 +52,11 @@ if TYPE_CHECKING:
52
52
  from allelix.reports.diff import DiffResult
53
53
 
54
54
 
55
- SCHEMA_VERSION = "4"
55
+ SCHEMA_VERSION = "5"
56
+ # v4 → v5 bump: enters the ADR-0035 cluster contract. Incoming cluster fields
57
+ # (variant.ref via Variant model, annotation.trait / .p_value / .phecode via
58
+ # Annotation model) land across PRs 1-4 of the v2.1 Cluster B work. v4
59
+ # consumers reading v5 output still succeed — every v4 field is unchanged.
56
60
 
57
61
 
58
62
  def _annotation_dict(a: Annotation) -> dict:
@@ -0,0 +1,211 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 Allelix
3
+ """Strand flipping, complement logic, and ambiguous-SNP detection.
4
+
5
+ A SNP read on the reverse strand has its alleles complemented (A↔T, C↔G).
6
+ Two databases reporting the "same" variant on opposite strands will list
7
+ opposite allele letters. For most SNPs this is unambiguous and reversible.
8
+ For A/T and C/G SNPs (palindromic), the complement equals the alternative —
9
+ so a strand-flip is undetectable from sequence alone and is best handled by
10
+ extra information (allele frequency, surrounding context).
11
+
12
+ ADR-0010 documents the design.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from allelix.models import NO_CALL_MARKER
18
+
19
+ _COMPLEMENT: dict[str, str] = {"A": "T", "T": "A", "C": "G", "G": "C"}
20
+
21
+ # A/T and C/G SNPs are palindromic; their complement equals the alternative,
22
+ # so strand orientation cannot be inferred from the alleles alone.
23
+ _AMBIGUOUS_PAIRS: frozenset[frozenset[str]] = frozenset(
24
+ {frozenset({"A", "T"}), frozenset({"C", "G"})}
25
+ )
26
+
27
+
28
+ def complement(allele: str) -> str:
29
+ """Return the reverse-complement of a single allele string.
30
+
31
+ A → T, T → A, C → G, G → C. The no-call marker `-` and any unrecognized
32
+ character are returned unchanged. Handles indels (multi-base alleles) by
33
+ complementing each base in reverse order.
34
+ """
35
+ if allele == NO_CALL_MARKER or not allele:
36
+ return allele
37
+ if len(allele) == 1:
38
+ return _COMPLEMENT.get(allele, allele)
39
+ return "".join(_COMPLEMENT.get(b, b) for b in reversed(allele))
40
+
41
+
42
+ def flip_genotype(allele1: str, allele2: str) -> tuple[str, str]:
43
+ """Return both alleles complemented (the reverse-strand reading)."""
44
+ return complement(allele1), complement(allele2)
45
+
46
+
47
+ def resolve_strand(user_allele: str, gnomad_ref: str, gnomad_alt: str) -> str | None:
48
+ """Return reference-forward allele, or None if not directly present.
49
+
50
+ Maps an array-reported allele to its reference-forward equivalent
51
+ using gnomAD's ref/alt as the ground truth. If the user allele
52
+ matches ref or alt directly, it's already forward and is returned.
53
+
54
+ GH #18: previously, when the user allele did NOT directly match
55
+ ref or alt, this function fell back to a complement check
56
+ (``complement(user_allele) ∈ {ref, alt}`` → return the complement)
57
+ on the assumption that the array was read on the minus strand. At
58
+ multi-allelic sites that fallback is unsound — the complement of
59
+ the user's true forward allele can coincidentally equal a different
60
+ alt at the same position, returning an allele the user does not
61
+ carry. The downstream CADD enrichment path then stamps a wrong-
62
+ allele score onto the annotation. The complement fallback is
63
+ removed: if the user allele is not in ``{ref, alt}``, return None
64
+ and let the caller skip enrichment rather than risk a coincidental
65
+ match. (ADR-0010 already defers proper strand handling; this makes
66
+ the deferral explicit at the resolution layer.)
67
+
68
+ Only operates on single-base alleles. Multi-base alleles (indels)
69
+ pass through as-is — array indels are rare and not minus-strand
70
+ reported.
71
+ """
72
+ if len(user_allele) != 1:
73
+ return user_allele
74
+ if user_allele in (gnomad_ref, gnomad_alt):
75
+ return user_allele
76
+ return None
77
+
78
+
79
+ def strand_aware_carrier_match(
80
+ variant_ref: str | None,
81
+ allele1: str,
82
+ allele2: str,
83
+ source_ref: str,
84
+ source_alt: str,
85
+ ) -> bool:
86
+ """Return True if the user carries ``source_alt`` directly or via strand-flip.
87
+
88
+ Multi-allelic safety (the v2.0.1 #18 audit): only fires strand-flip when the
89
+ ``variant_ref`` context confirms the user is reverse-stranded relative to the
90
+ source's ref. Without that context — ``variant_ref`` is None, or it agrees
91
+ with ``source_ref`` (forward) — strand-flip never fires and the carrier
92
+ match falls back to direct membership only. Skips palindromic ``(ref, alt)``
93
+ pairs (A/T, C/G) where strand cannot be inferred from sequence alone
94
+ (ADR-0010 documents the limitation).
95
+
96
+ Args:
97
+ variant_ref: User's claimed REF at the variant locus. ``None`` means
98
+ the parser couldn't supply one (array data prior to gnomAD-backed
99
+ population, or the source didn't carry a REF column).
100
+ allele1: First user allele (upper-cased at ``Variant`` construction).
101
+ allele2: Second user allele.
102
+ source_ref: REF from the annotator's source row (forward strand).
103
+ source_alt: ALT from the annotator's source row (forward strand).
104
+
105
+ Returns:
106
+ True when the user carries ``source_alt`` on either strand. False
107
+ otherwise — including the abstain cases (palindromic site, no
108
+ ``variant_ref`` context with no direct match, ref disagreement that
109
+ isn't a clean complement).
110
+ """
111
+ user = {allele1, allele2}
112
+ if source_alt in user:
113
+ return True
114
+ if variant_ref is None:
115
+ return False
116
+ if is_strand_ambiguous(source_ref, source_alt):
117
+ return False
118
+ if variant_ref == source_ref:
119
+ return False
120
+ if variant_ref == complement(source_ref):
121
+ return complement(source_alt) in user
122
+ return False
123
+
124
+
125
+ def strand_aware_genotype_match(
126
+ allele1: str,
127
+ allele2: str,
128
+ source_geno: str,
129
+ variant_ref: str | None,
130
+ ) -> bool:
131
+ """Per-genotype carrier match with strand-flip support for ClinPGx / SNPedia.
132
+
133
+ PharmGKB / ClinPGx and SNPedia store the user's full diploid (sorted SNV
134
+ pair like ``"AG"``) rather than per-allele rows. Direct equality remains
135
+ the primary match. Strand-flip fires only when ``variant_ref`` confirms
136
+ reverse orientation against the source diploid AND the source diploid is
137
+ not palindromic (A/T or C/G).
138
+
139
+ Args:
140
+ allele1: First user allele (upper-cased single base for SNVs).
141
+ allele2: Second user allele.
142
+ source_geno: Source row's sorted diploid string (e.g. ``"AG"``).
143
+ variant_ref: User's claimed REF at this locus. ``None`` means no
144
+ reference context; strand-flip is skipped (direct match only).
145
+
146
+ Returns:
147
+ True when the user matches ``source_geno`` directly or via a
148
+ complement reading. False otherwise.
149
+ """
150
+ if len(allele1) != 1 or len(allele2) != 1 or len(source_geno) != 2:
151
+ return False
152
+ user_normalized = "".join(sorted((allele1, allele2)))
153
+ if user_normalized == source_geno:
154
+ return True
155
+ if variant_ref is None:
156
+ return False
157
+ user_complement_pair = sorted((complement(allele1), complement(allele2)))
158
+ if "".join(user_complement_pair) != source_geno:
159
+ return False
160
+ if variant_ref in source_geno:
161
+ return False
162
+ if complement(variant_ref) not in source_geno:
163
+ return False
164
+ distinct = set(source_geno)
165
+ return distinct != {"A", "T"} and distinct != {"C", "G"}
166
+
167
+
168
+ def derive_alt_from_diploid(ref: str | None, allele1: str, allele2: str) -> str:
169
+ """Derive the user's carried alt from a matched diploid given a known REF.
170
+
171
+ For per-genotype annotators (SNPedia, ClinPGx) the matched row describes
172
+ the user's full diploid pair; the alt the user carries is whichever side
173
+ of the pair does not equal REF. Returns the empty string when alt cannot
174
+ be cleanly identified:
175
+
176
+ - ``ref`` is None — no reference context yet (e.g., array data prior to
177
+ strand-aware ref population, ADR-0035 PR 4).
178
+ - Both alleles equal ref — hom-ref; should be filtered earlier
179
+ (ADR-0023 for ClinPGx; SNPedia rarely matches hom-ref) but defensive.
180
+ - Neither allele equals ref — multi-allelic het where the matched row's
181
+ alleles disagree with the reference (rare for SNPs); conservatively
182
+ skip rather than guess.
183
+
184
+ Conservative-on-uncertainty mirrors the v2.0.1 #23 suppress-half: when
185
+ the alt can't be cleanly identified, return "" rather than a guess that
186
+ could stamp the wrong allele's gnomAD / AlphaMissense / CADD value.
187
+ """
188
+ if ref is None:
189
+ return ""
190
+ if allele1 == ref and allele2 == ref:
191
+ return ""
192
+ if allele1 == ref:
193
+ return allele2
194
+ if allele2 == ref:
195
+ return allele1
196
+ if allele1 == allele2:
197
+ return allele1
198
+ return ""
199
+
200
+
201
+ def is_strand_ambiguous(ref: str, alt: str) -> bool:
202
+ """True if (ref, alt) is an A/T or C/G pair — strand cannot be inferred.
203
+
204
+ Multi-base indels and any allele containing a no-call or unknown letter
205
+ are reported as not ambiguous (they have other ways to disambiguate).
206
+ """
207
+ if len(ref) != 1 or len(alt) != 1:
208
+ return False
209
+ if ref not in _COMPLEMENT or alt not in _COMPLEMENT:
210
+ return False
211
+ return frozenset({ref, alt}) in _AMBIGUOUS_PAIRS
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: allelix
3
- Version: 2.0.2
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>
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "allelix"
7
- version = "2.0.2"
7
+ version = "2.1.0"
8
8
  description = "Open-source genotype analysis toolkit. Format-agnostic ingestion, database-agnostic annotation, offline-first."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -38,6 +38,33 @@ class TestVariant:
38
38
  v = Variant("rs1", "1", 100, "A", "A", build="GRCh38")
39
39
  assert v.build == "GRCh38"
40
40
 
41
+ def test_ref_defaults_to_none(self):
42
+ """ADR-0035: Variant.ref is optional; array parsers leave it unset."""
43
+ v = Variant("rs1", "1", 100, "A", "A")
44
+ assert v.ref is None
45
+
46
+ def test_ref_explicit(self):
47
+ """ADR-0035: VCF parsers populate ref from the REF column."""
48
+ v = Variant("rs1", "1", 100, "A", "G", ref="A")
49
+ assert v.ref == "A"
50
+
51
+ def test_ref_lowercase_normalized_to_uppercase(self):
52
+ """ADR-0035 + GH #14: soft-masked references emit lowercase REF.
53
+
54
+ Without normalization the strand-aware carrier match (PR 4) would
55
+ compare a lowercase user ref against uppercase gnomAD / annotator
56
+ alleles and silently mismatch — exactly the #14 failure mode for
57
+ the new field. Normalize at the model boundary so the invariant
58
+ is impossible to violate downstream.
59
+ """
60
+ v = Variant("rs1", "1", 100, "A", "G", ref="a")
61
+ assert v.ref == "A"
62
+
63
+ def test_ref_lowercase_indel_normalized(self):
64
+ """Multi-base ref (indel locus) normalized in place."""
65
+ v = Variant("rs1", "7", 117199644, "CTT", "C", ref="cttg")
66
+ assert v.ref == "CTTG"
67
+
41
68
  def test_indel_genotype(self):
42
69
  v = Variant("rs1", "7", 117199644, "CTT", "C")
43
70
  assert v.is_heterozygous
@@ -1,89 +0,0 @@
1
- # SPDX-License-Identifier: AGPL-3.0-or-later
2
- # Copyright (C) 2026 Allelix
3
- """Strand flipping, complement logic, and ambiguous-SNP detection.
4
-
5
- A SNP read on the reverse strand has its alleles complemented (A↔T, C↔G).
6
- Two databases reporting the "same" variant on opposite strands will list
7
- opposite allele letters. For most SNPs this is unambiguous and reversible.
8
- For A/T and C/G SNPs (palindromic), the complement equals the alternative —
9
- so a strand-flip is undetectable from sequence alone and is best handled by
10
- extra information (allele frequency, surrounding context).
11
-
12
- ADR-0010 documents the design.
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- from allelix.models import NO_CALL_MARKER
18
-
19
- _COMPLEMENT: dict[str, str] = {"A": "T", "T": "A", "C": "G", "G": "C"}
20
-
21
- # A/T and C/G SNPs are palindromic; their complement equals the alternative,
22
- # so strand orientation cannot be inferred from the alleles alone.
23
- _AMBIGUOUS_PAIRS: frozenset[frozenset[str]] = frozenset(
24
- {frozenset({"A", "T"}), frozenset({"C", "G"})}
25
- )
26
-
27
-
28
- def complement(allele: str) -> str:
29
- """Return the reverse-complement of a single allele string.
30
-
31
- A → T, T → A, C → G, G → C. The no-call marker `-` and any unrecognized
32
- character are returned unchanged. Handles indels (multi-base alleles) by
33
- complementing each base in reverse order.
34
- """
35
- if allele == NO_CALL_MARKER or not allele:
36
- return allele
37
- if len(allele) == 1:
38
- return _COMPLEMENT.get(allele, allele)
39
- return "".join(_COMPLEMENT.get(b, b) for b in reversed(allele))
40
-
41
-
42
- def flip_genotype(allele1: str, allele2: str) -> tuple[str, str]:
43
- """Return both alleles complemented (the reverse-strand reading)."""
44
- return complement(allele1), complement(allele2)
45
-
46
-
47
- def resolve_strand(user_allele: str, gnomad_ref: str, gnomad_alt: str) -> str | None:
48
- """Return reference-forward allele, or None if not directly present.
49
-
50
- Maps an array-reported allele to its reference-forward equivalent
51
- using gnomAD's ref/alt as the ground truth. If the user allele
52
- matches ref or alt directly, it's already forward and is returned.
53
-
54
- GH #18: previously, when the user allele did NOT directly match
55
- ref or alt, this function fell back to a complement check
56
- (``complement(user_allele) ∈ {ref, alt}`` → return the complement)
57
- on the assumption that the array was read on the minus strand. At
58
- multi-allelic sites that fallback is unsound — the complement of
59
- the user's true forward allele can coincidentally equal a different
60
- alt at the same position, returning an allele the user does not
61
- carry. The downstream CADD enrichment path then stamps a wrong-
62
- allele score onto the annotation. The complement fallback is
63
- removed: if the user allele is not in ``{ref, alt}``, return None
64
- and let the caller skip enrichment rather than risk a coincidental
65
- match. (ADR-0010 already defers proper strand handling; this makes
66
- the deferral explicit at the resolution layer.)
67
-
68
- Only operates on single-base alleles. Multi-base alleles (indels)
69
- pass through as-is — array indels are rare and not minus-strand
70
- reported.
71
- """
72
- if len(user_allele) != 1:
73
- return user_allele
74
- if user_allele in (gnomad_ref, gnomad_alt):
75
- return user_allele
76
- return None
77
-
78
-
79
- def is_strand_ambiguous(ref: str, alt: str) -> bool:
80
- """True if (ref, alt) is an A/T or C/G pair — strand cannot be inferred.
81
-
82
- Multi-base indels and any allele containing a no-call or unknown letter
83
- are reported as not ambiguous (they have other ways to disambiguate).
84
- """
85
- if len(ref) != 1 or len(alt) != 1:
86
- return False
87
- if ref not in _COMPLEMENT or alt not in _COMPLEMENT:
88
- return False
89
- return frozenset({ref, alt}) in _AMBIGUOUS_PAIRS
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes