Skip to content

Commit 60dc01c

Browse files
authored
Merge pull request #106 from VariantEffect/feature/bencap/vrs-correctness
feat: VRS Correctness
2 parents 432119c + ad228a9 commit 60dc01c

5 files changed

Lines changed: 174 additions & 8 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,6 @@ notebooks/analysis/mavedb_files
172172
urn:*.json
173173
tmp:*.json
174174
*_mapping_*.json
175+
176+
# Agent settings
177+
.claude/

src/dcd_mapping/align.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,12 @@ def _run_blat(
177177
cmd.extend(shlex.split(target_args))
178178

179179
cmd.extend(
180-
[f"-minScore={min_score}", f"-out={out_format}", str(query_file), out_file]
180+
[
181+
f"-minScore={min_score}",
182+
f"-out={out_format}",
183+
str(query_file),
184+
out_file,
185+
]
181186
)
182187
_logger.debug("Running BLAT command: %s", " ".join(cmd))
183188

src/dcd_mapping/vrs_map.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from Bio.Seq import Seq
99
from bioutils.accessions import infer_namespace
1010
from cool_seq_tool.schemas import AnnotationLayer, Strand
11-
from ga4gh.core import ga4gh_identify, sha512t24u
11+
from ga4gh.core import sha512t24u
1212
from ga4gh.vrs._internal.models import (
1313
Allele,
1414
Haplotype,
@@ -47,6 +47,7 @@
4747
VrsMapResult,
4848
)
4949
from dcd_mapping.transcripts import TxSelectError
50+
from dcd_mapping.vrs_utils import identify_allele, normalize_and_identify
5051

5152
__all__ = ["vrs_map"]
5253

@@ -1005,10 +1006,7 @@ def _construct_vrs_allele(
10051006
else:
10061007
allele = translate_ref_identical_to_vrs(hgvs_string)
10071008

1008-
normalize(allele, data_proxy=get_seqrepo())
1009-
1010-
allele.id = ga4gh_identify(allele)
1011-
alleles.append(allele)
1009+
alleles.append(normalize_and_identify(allele))
10121010
continue
10131011

10141012
allele = translate_hgvs_to_vrs(hgvs_string)
@@ -1040,8 +1038,7 @@ def _construct_vrs_allele(
10401038
)
10411039
allele.state = _rle_to_lse(allele.state, allele.location)
10421040

1043-
# Run ga4gh_identify to assign VA digest
1044-
allele.id = ga4gh_identify(allele)
1041+
allele.id = identify_allele(allele)
10451042
alleles.append(allele)
10461043

10471044
if not alleles:

src/dcd_mapping/vrs_utils.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""VRS allele identification helpers.
2+
3+
Centralizes the digest-correctness invariant for GA4GH VRS alleles: the
4+
``ga4gh_identify`` Merkle tree caches sub-object digests on the object after
5+
first identification, so any subsequent mutation (refgetAccession swap,
6+
normalization, state coercion) leaves a stale id unless the cached digests
7+
are cleared first. All allele identification in dcd_mapping must route
8+
through :func:`identify_allele` so the digest is always recomputed from
9+
current content.
10+
"""
11+
12+
from ga4gh.core import ga4gh_identify
13+
from ga4gh.vrs._internal.models import Allele, SequenceLocation
14+
from ga4gh.vrs.normalize import normalize
15+
16+
from dcd_mapping.lookup import get_seqrepo
17+
18+
19+
def identify_allele(allele: Allele) -> str:
20+
"""Clear cached digests and return a fresh GA4GH identifier for *allele*.
21+
22+
``ga4gh_identify`` is a Merkle-tree: it calls ``get_or_create_digest`` on
23+
sub-objects, returning any cached value without recomputing. Clearing both
24+
the location digest and the allele digest first ensures the id is always
25+
derived from the current object content — not from a value set before a
26+
refgetAccession mutation or normalization.
27+
"""
28+
if isinstance(allele.location, SequenceLocation):
29+
allele.location.digest = None
30+
31+
allele.digest = None
32+
digest = ga4gh_identify(allele)
33+
if digest is None:
34+
raise ValueError("Failed to compute GA4GH identifier for allele") # noqa: EM101
35+
36+
return digest
37+
38+
39+
def normalize_and_identify(allele: Allele) -> Allele:
40+
"""Normalize *allele* and stamp it with a freshly computed GA4GH digest.
41+
42+
Pairs the two finalize steps every VRS allele construction path needs.
43+
Routing identification through :func:`identify_allele` (rather than
44+
``ga4gh_identify`` directly) is the invariant that protects against the
45+
Merkle-tree's stale-digest behavior after mutation -- so any allele
46+
construction site that bypasses this helper risks reintroducing the
47+
stale-digest bug.
48+
"""
49+
allele = normalize(allele, data_proxy=get_seqrepo())
50+
allele.id = identify_allele(allele)
51+
return allele

tests/test_vrs_utils.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Tests for VRS allele identification helpers.
2+
3+
These tests run offline: ``ga4gh_identify`` is pure computation given a
4+
well-formed VRS object, and ``normalize`` is patched so no SeqRepo or UTA
5+
access is required.
6+
"""
7+
8+
import pytest
9+
from ga4gh.vrs._internal.models import (
10+
Allele,
11+
LiteralSequenceExpression,
12+
SequenceLocation,
13+
SequenceReference,
14+
)
15+
16+
from dcd_mapping.vrs_utils import identify_allele, normalize_and_identify
17+
18+
19+
def _make_allele(start: int = 1, end: int = 2, sequence: str = "A") -> Allele:
20+
"""Build a minimal in-memory VRS Allele with no cached digests."""
21+
return Allele(
22+
location=SequenceLocation(
23+
sequenceReference=SequenceReference(
24+
refgetAccession="SQ.0123456789abcdef0123456789abcdef"
25+
),
26+
start=start,
27+
end=end,
28+
),
29+
state=LiteralSequenceExpression(sequence=sequence),
30+
)
31+
32+
33+
def test_identify_allele_returns_va_digest():
34+
assert identify_allele(_make_allele()).startswith("ga4gh:VA.")
35+
36+
37+
def test_identify_allele_is_content_addressed():
38+
# Identical content -> identical digest; differing content -> differing digest.
39+
a = _make_allele()
40+
b = _make_allele()
41+
c = _make_allele(start=5, end=6)
42+
assert identify_allele(a) == identify_allele(b)
43+
assert identify_allele(a) != identify_allele(c)
44+
45+
46+
def test_identify_allele_clears_stale_digests():
47+
"""The whole point of the helper: a cached pre-mutation digest must not
48+
leak into the post-mutation identifier.
49+
50+
Reproduces the bug pattern where an allele is constructed, its location
51+
gets a refgetAccession mutation, and then it's identified. If the helper
52+
is bypassed and ``ga4gh_identify`` is called directly, the Merkle-tree
53+
returns the stale digest without recomputing, so the identifier doesn't
54+
reflect the current content. By clearing cached digests first, the helper
55+
ensures the identifier is always correct even if the input allele has been
56+
mutated since the last identification.
57+
"""
58+
allele = _make_allele(start=5, end=6)
59+
allele.location.digest = "ga4gh:SL.stale-location-digest"
60+
allele.digest = "ga4gh:VA.stale-allele-digest"
61+
62+
identifier = identify_allele(allele)
63+
64+
assert identifier.startswith("ga4gh:VA.")
65+
assert identifier != "ga4gh:VA.stale-allele-digest"
66+
# The identifier must match a freshly-built allele with the same content,
67+
# not anything derived from the stale digests.
68+
assert identifier == identify_allele(_make_allele(start=5, end=6))
69+
70+
71+
def test_normalize_and_identify_pairs_both_steps(mocker):
72+
"""normalize_and_identify normalizes then identifies in that order."""
73+
allele = _make_allele()
74+
# Pass-through normalize so the test stays offline (no SeqRepo).
75+
mock_normalize = mocker.patch(
76+
"dcd_mapping.vrs_utils.normalize", side_effect=lambda a, **_: a
77+
)
78+
mocker.patch("dcd_mapping.vrs_utils.get_seqrepo", return_value=mocker.MagicMock())
79+
80+
result = normalize_and_identify(allele)
81+
82+
mock_normalize.assert_called_once()
83+
assert result is allele
84+
assert result.id is not None
85+
assert result.id.startswith("ga4gh:VA.")
86+
87+
88+
def test_normalize_and_identify_clears_stale_digest_through_helper(mocker):
89+
"""Stale digests survive a no-op normalize, so the identify step must
90+
still clear them. Verifies the pairing actually delivers the invariant.
91+
"""
92+
allele = _make_allele()
93+
allele.location.digest = "ga4gh:SL.stale"
94+
allele.digest = "ga4gh:VA.stale"
95+
mocker.patch("dcd_mapping.vrs_utils.normalize", side_effect=lambda a, **_: a)
96+
mocker.patch("dcd_mapping.vrs_utils.get_seqrepo", return_value=mocker.MagicMock())
97+
98+
result = normalize_and_identify(allele)
99+
100+
assert result.id != "ga4gh:VA.stale"
101+
assert result.id.startswith("ga4gh:VA.")
102+
103+
104+
def test_identify_allele_raises_when_digest_unobtainable(mocker):
105+
"""When ``ga4gh_identify`` returns ``None`` (malformed input), surface it
106+
as a ValueError rather than silently stamping an unidentifiable allele.
107+
"""
108+
mocker.patch("dcd_mapping.vrs_utils.ga4gh_identify", return_value=None)
109+
with pytest.raises(ValueError, match="Failed to compute GA4GH identifier"):
110+
identify_allele(_make_allele())

0 commit comments

Comments
 (0)