Skip to content

Commit 234c341

Browse files
committed
fix(genotype): novel-allele review round — functional validation, gene identity, gapped projection
Addresses critic findings on the novel-allele slice: - #1 functional validation: synthesized V/J coding sequence is checked for an intact conserved anchor codon (Cys/Trp|Phe) and stop-free coding frame; a broken variant is rejected unless allow_nonfunctional=True (then kept + marked non-functional). Closes the 'nonfunctional emitted as productive' gap. - #2 gene identity: the novel allele's gene is taken from its NAME and must equal the base allele's gene; dropped the gene= override that left allele.gene stale. - #3 anchor: inherited from base (correct for same-length/substitution-only variants — the conserved residue does not move) and validated to remain intact; no reliance on the unavailable _native anchor resolver. - #4 name uniqueness enforced across all segments + novel set (prevents truth-table mislabeling). - #5 to_tsv now emits the 'novel' column. - #6 substitutions projected onto the gapped sequence (no stale gapped_seq). - #7 mutation positions/bases type-checked with clean ValueErrors. Tests expanded: stop/anchor rejection + allow_nonfunctional, gene-mismatch, cross-segment collision, tsv export, type-check.
1 parent 4d9c190 commit 234c341

3 files changed

Lines changed: 187 additions & 50 deletions

File tree

site_docs/guides/genotype.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ so it flows through alignment and AIRR output as a genuine allele:
250250
g = (
251251
Genotype.from_dataconfig(cfg)
252252
.add_novel_allele("IGHVF1-G1*i01", base="IGHVF1-G1*01",
253-
mutations=[(120, "T"), (250, "G")]) # two point variants
253+
mutations=[(38, "C"), (41, "A")]) # two point variants
254254
.complete_from_reference()
255255
.heterozygous("IGHVF1-G1", "IGHVF1-G1*01", "IGHVF1-G1*i01") # one reference + one private
256256
.with_subject("DONOR_N")
@@ -264,8 +264,14 @@ result = (
264264
# its name appears in v_call / truth_v_call and the reads carry its variants.
265265
```
266266

267-
Novel alleles are flagged in the ground truth: each `to_table()` row carries a
268-
`novel` list of the private alleles carried at that gene.
267+
The novel allele's **gene is taken from its name** and must match the base
268+
allele's gene; it must be a same-length (substitution-only) variant. The
269+
synthesized coding sequence is **validated** — for V/J the conserved anchor codon
270+
must still encode the conserved residue (Cys for V, Trp/Phe for J) and the coding
271+
frame must be stop-free. A variant that breaks either is rejected unless you pass
272+
`allow_nonfunctional=True` (then it is kept and marked non-functional). Novel
273+
alleles are flagged in the ground truth: each `to_table()`/`to_tsv()` row carries
274+
a `novel` list of the private alleles carried at that gene.
269275

270276
**Benchmarking novel-allele discovery.** Plant a novel allele, simulate, then run
271277
the discovery tool against the **base** germline (the cartridge *without* your

src/GenAIRR/genotype.py

Lines changed: 90 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -157,71 +157,136 @@ def add_novel_allele(
157157
mutations: Optional[List[Tuple[int, str]]] = None,
158158
sequence: Optional[str] = None,
159159
segment: str = "V",
160-
gene: Optional[str] = None,
160+
allow_nonfunctional: bool = False,
161161
) -> "Genotype":
162162
"""Define a private/novel allele not present in the reference.
163163
164-
Derive it from a reference ``base`` allele by either applying
165-
point ``mutations`` (a list of ``(0-based position, base)``) or by
166-
supplying an explicit ``sequence`` (same length as the base, so the
167-
inherited anchor/sub-regions stay valid). Gene, anchor, functional
168-
status and V sub-regions are inherited from the base allele.
164+
Derive it from a reference ``base`` allele by either applying point
165+
``mutations`` (a list of ``(0-based position, base)``) or supplying
166+
an explicit same-length ``sequence`` (substitutions only — no
167+
indels — so the gene's reading frame and the conserved-anchor
168+
position are preserved). The novel allele's **gene is taken from
169+
its name** and must equal the base allele's gene; sub-regions and
170+
the anchor position are inherited (valid for same-length variants).
171+
172+
The synthesized coding sequence is **validated**: for V/J the
173+
conserved anchor codon must still encode the conserved residue
174+
(Cys for V, Trp/Phe for J) and the coding frame must contain no
175+
internal stop codon. A variant that breaks either is rejected
176+
unless ``allow_nonfunctional=True`` (in which case it is kept and
177+
marked non-functional).
169178
170179
Registers the novel allele under ``name`` so it can then be placed
171-
with :meth:`homozygous` / :meth:`heterozygous` / :meth:`duplicate_gene`
172-
like any allele. At compile time it is injected as a real entry in
173-
an *effective* reference, so it flows through alignment and AIRR
174-
output exactly like a catalogue allele.
180+
with :meth:`homozygous` / :meth:`heterozygous` / :meth:`duplicate_gene`.
181+
At compile time it is injected as a real entry in an *effective*
182+
reference, so it flows through alignment and AIRR output like a
183+
catalogue allele.
175184
"""
176185
import copy as _copy
177186

187+
from .utilities.misc import translate
188+
178189
base_allele = self._find_ref_allele(segment, base)
179190
if base_allele is None:
180191
raise ValueError(f"base allele {base!r} not found in {segment} reference")
181-
gene = gene or base_allele.gene
182-
if gene not in _alleles_by_gene(self._cfg, segment):
183-
raise ValueError(f"{gene!r} is not a known {segment} gene in this cartridge")
184-
# name must not collide with a catalogue allele or another novel.
185-
if self._find_ref_allele(segment, name) is not None or name in self._novel:
186-
raise ValueError(f"novel allele name {name!r} collides with an existing allele")
192+
# Gene identity comes from the name; it must match the base's gene
193+
# (no cross-gene synthesis — that would corrupt gene identity).
194+
name_gene = name.split("*")[0]
195+
if name_gene != base_allele.gene:
196+
raise ValueError(
197+
f"novel name {name!r} implies gene {name_gene!r} but base {base!r} "
198+
f"belongs to gene {base_allele.gene!r}; a novel allele must belong "
199+
f"to its base allele's gene"
200+
)
201+
gene = base_allele.gene
202+
# Name must be unique across the WHOLE catalogue (all segments) and
203+
# all previously-defined novel alleles.
204+
if name in self._novel:
205+
raise ValueError(f"novel allele name {name!r} already defined")
206+
for seg in _SEGMENTS:
207+
if self._find_ref_allele(seg, name) is not None:
208+
raise ValueError(f"novel allele name {name!r} collides with a catalogue allele")
187209
if (mutations is None) == (sequence is None):
188210
raise ValueError("provide exactly one of `mutations` or `sequence`")
189211

190-
seq = list(base_allele.ungapped_seq.upper())
212+
base_ungapped = base_allele.ungapped_seq.upper()
213+
gapped = list(base_allele.gapped_seq)
214+
# ungapped index -> gapped index (positions of non-gap characters)
215+
ung_to_gap = [i for i, ch in enumerate(base_allele.gapped_seq) if ch != "."]
216+
seq = list(base_ungapped)
191217
if sequence is not None:
192218
sequence = sequence.upper()
193219
if len(sequence) != len(seq):
194220
raise ValueError(
195221
f"explicit sequence length {len(sequence)} != base length {len(seq)}; "
196-
"use `mutations` for indels-free variants or match the base length"
222+
"novel alleles are substitution-only (same length as the base)"
197223
)
198224
if any(b not in "ACGT" for b in sequence):
199225
raise ValueError("sequence must contain only A/C/G/T")
200-
new_seq = sequence
226+
seq = list(sequence)
201227
else:
202228
if not mutations:
203229
raise ValueError("`mutations` must be a non-empty list of (position, base)")
204230
for pos, b in mutations:
231+
if not isinstance(pos, int) or isinstance(pos, bool):
232+
raise ValueError(f"mutation position must be an int, got {pos!r}")
233+
if not (isinstance(b, str) and len(b) == 1):
234+
raise ValueError(f"mutation base must be a single character, got {b!r}")
205235
if not (0 <= pos < len(seq)):
206236
raise ValueError(f"mutation position {pos} out of range [0,{len(seq)})")
207237
if b.upper() not in "ACGT":
208238
raise ValueError(f"mutation base {b!r} must be A/C/G/T")
209239
seq[pos] = b.upper()
210-
new_seq = "".join(seq)
211-
if new_seq == base_allele.ungapped_seq.upper():
240+
new_ungapped = "".join(seq)
241+
if new_ungapped == base_ungapped:
212242
raise ValueError("novel allele is identical to its base allele")
243+
# Project the substitutions onto the gapped sequence too, so
244+
# gap-dependent metadata stays consistent.
245+
for k, b in enumerate(seq):
246+
gapped[ung_to_gap[k]] = b
213247

214248
novel = _copy.deepcopy(base_allele)
215249
novel.name = name
216-
novel.ungapped_seq = new_seq
250+
novel.gene = gene
251+
novel.ungapped_seq = new_ungapped
252+
novel.gapped_seq = "".join(gapped)
217253
if hasattr(novel, "ungapped_len"):
218-
novel.ungapped_len = len(new_seq)
254+
novel.ungapped_len = len(new_ungapped)
255+
256+
# Functional validation (V/J have a conserved coding frame).
257+
functional, reason = True, None
258+
anchor = getattr(novel, "anchor", None)
259+
if segment in ("V", "J") and anchor is not None:
260+
conserved = {"V": {"C"}, "J": {"W", "F"}}[segment]
261+
anchor_aa = translate(new_ungapped[anchor : anchor + 3])
262+
if anchor_aa not in conserved:
263+
functional = False
264+
reason = (
265+
f"conserved anchor codon now encodes {anchor_aa!r}, "
266+
f"expected one of {sorted(conserved)}"
267+
)
268+
coding = new_ungapped[:anchor] if segment == "V" else new_ungapped[anchor:]
269+
if "*" in translate(coding):
270+
reason = (reason + "; " if reason else "") + "internal stop codon in coding frame"
271+
functional = False
272+
if not functional and not allow_nonfunctional:
273+
raise ValueError(
274+
f"novel allele {name!r} is non-functional ({reason}); pass "
275+
f"allow_nonfunctional=True to keep it anyway"
276+
)
277+
if not functional:
278+
try:
279+
novel.functional_status = "pseudogene"
280+
except Exception:
281+
pass
282+
219283
self._novel[name] = {
220284
"allele": novel,
221285
"gene": gene,
222286
"segment": segment,
223287
"base": base,
224288
"mutations": list(mutations) if mutations else None,
289+
"functional": functional,
225290
}
226291
return self
227292

@@ -366,6 +431,7 @@ def _fmt(detail):
366431
"zygosity",
367432
"haplotype_0",
368433
"haplotype_1",
434+
"novel",
369435
"permissive",
370436
]
371437
)
@@ -378,6 +444,7 @@ def _fmt(detail):
378444
r["zygosity"],
379445
_fmt(r["haplotype_0_detail"]),
380446
_fmt(r["haplotype_1_detail"]),
447+
";".join(r["novel"]),
381448
r["permissive"],
382449
]
383450
)

tests/test_genotype_novel.py

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Novel / private allele support on genotypes (PR additions)."""
1+
"""Novel / private allele support on genotypes."""
22
import pytest
33

44
import GenAIRR as ga
@@ -10,46 +10,97 @@ def _cfg():
1010
return gdata.HUMAN_IGH_OGRDB
1111

1212

13+
# Guaranteed-safe SNPs for IGHVF1-G1*01: wobble of non-T-starting codons in
14+
# the framework — cannot create a stop, anchor untouched.
15+
_SAFE = [(38, "C"), (41, "A")]
16+
17+
1318
def test_add_novel_allele_synthesizes_from_base_and_mutations():
1419
cfg = _cfg()
1520
base = cfg.v_alleles["IGHVF1-G1"][0]
1621
g = Genotype.from_dataconfig(cfg).add_novel_allele(
17-
"IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=[(120, "T"), (130, "A")]
22+
"IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=_SAFE
1823
)
1924
assert g.has_novel()
2025
assert "IGHVF1-G1*i01" in g.novel_allele_names()
2126
nv = g._novel["IGHVF1-G1*i01"]["allele"]
22-
assert nv.ungapped_seq[120] == "T" and nv.ungapped_seq[130] == "A"
23-
assert len(nv.ungapped_seq) == len(base.ungapped_seq) # point mutations: no length change
24-
assert nv.gene == "IGHVF1-G1" and nv.anchor == base.anchor # metadata inherited
27+
assert nv.ungapped_seq[38] == "C" and nv.ungapped_seq[41] == "A"
28+
assert len(nv.ungapped_seq) == len(base.ungapped_seq) # substitution-only
29+
assert nv.gene == "IGHVF1-G1" and nv.anchor == base.anchor # gene/anchor inherited
30+
# gapped sequence projected consistently (ungapped derived from it)
31+
assert nv.gapped_seq.replace(".", "") == nv.ungapped_seq
32+
assert g._novel["IGHVF1-G1*i01"]["functional"] is True
2533

2634

27-
def test_novel_allele_validation():
35+
def test_novel_name_gene_must_match_base_gene():
36+
cfg = _cfg()
37+
with pytest.raises(ValueError, match="implies gene"):
38+
Genotype.from_dataconfig(cfg).add_novel_allele(
39+
"IGHVF1-G2*i01", base="IGHVF1-G1*01", mutations=_SAFE # name gene != base gene
40+
)
41+
42+
43+
def test_novel_allele_basic_validation():
2844
cfg = _cfg()
2945
G = lambda: Genotype.from_dataconfig(cfg)
3046
with pytest.raises(ValueError, match="base allele"):
31-
G().add_novel_allele("X*i01", base="NOPE*01", mutations=[(1, "T")])
32-
with pytest.raises(ValueError, match="collides"):
33-
G().add_novel_allele("IGHVF1-G1*01", base="IGHVF1-G1*01", mutations=[(1, "T")])
47+
G().add_novel_allele("NOPE*i01", base="NOPE*01", mutations=[(1, "T")])
48+
with pytest.raises(ValueError, match="collides with a catalogue"):
49+
G().add_novel_allele("IGHVF1-G1*01", base="IGHVF1-G1*01", mutations=_SAFE)
3450
with pytest.raises(ValueError, match="out of range"):
35-
G().add_novel_allele("X*i01", base="IGHVF1-G1*01", mutations=[(99999, "T")])
51+
G().add_novel_allele("IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=[(99999, "T")])
3652
with pytest.raises(ValueError, match="exactly one"):
37-
G().add_novel_allele("X*i01", base="IGHVF1-G1*01") # neither mutations nor sequence
38-
with pytest.raises(ValueError, match="identical"):
39-
# mutate to the same base it already is
40-
b = cfg.v_alleles["IGHVF1-G1"][0].ungapped_seq.upper()
41-
G().add_novel_allele("X*i01", base="IGHVF1-G1*01", mutations=[(0, b[0])])
53+
G().add_novel_allele("IGHVF1-G1*i01", base="IGHVF1-G1*01")
54+
with pytest.raises(ValueError, match="must be an int"):
55+
G().add_novel_allele("IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=[(1.5, "A")])
56+
57+
58+
def test_cross_segment_name_collision_rejected():
59+
# A novel can't be named for a different gene/segment than its base, so
60+
# naming a V-derived novel after a real J allele is rejected outright —
61+
# the truth table can never mislabel the real J row as novel.
62+
cfg = _cfg()
63+
j_name = next(iter(cfg.j_alleles[next(iter(cfg.j_alleles))])).name
64+
with pytest.raises(ValueError):
65+
Genotype.from_dataconfig(cfg).add_novel_allele(
66+
j_name, base="IGHVF1-G1*01", mutations=_SAFE
67+
)
68+
69+
70+
def test_nonfunctional_novel_rejected_by_default_and_allowed_explicitly():
71+
cfg = _cfg()
72+
base = cfg.v_alleles["IGHVF1-G1"][0]
73+
# Force a stop codon at framework codon 13 (positions 39,40,41 -> TAA).
74+
stop = [(39, "T"), (40, "A"), (41, "A")]
75+
with pytest.raises(ValueError, match="non-functional.*stop codon"):
76+
Genotype.from_dataconfig(cfg).add_novel_allele(
77+
"IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=stop
78+
)
79+
# Explicit override keeps it, marked non-functional.
80+
g = Genotype.from_dataconfig(cfg).add_novel_allele(
81+
"IGHVF1-G1*i01", base="IGHVF1-G1*01", mutations=stop, allow_nonfunctional=True
82+
)
83+
assert g._novel["IGHVF1-G1*i01"]["functional"] is False
84+
85+
86+
def test_broken_anchor_codon_rejected():
87+
cfg = _cfg()
88+
base = cfg.v_alleles["IGHVF1-G1"][0]
89+
a = base.anchor
90+
# rewrite the conserved Cys anchor codon to GGG (Gly)
91+
with pytest.raises(ValueError, match="conserved anchor codon"):
92+
Genotype.from_dataconfig(cfg).add_novel_allele(
93+
"IGHVF1-G1*i01", base="IGHVF1-G1*01",
94+
mutations=[(a, "G"), (a + 1, "G"), (a + 2, "G")],
95+
)
4296

4397

4498
def test_novel_allele_placed_and_sampled_appears_in_airr():
4599
cfg = _cfg()
46100
gene = "IGHVF1-G1"
47-
base_seq = cfg.v_alleles[gene][0].ungapped_seq.upper()
48-
pos = 120
49-
new = "T" if base_seq[pos] != "T" else "A"
50101
g = (
51102
Genotype.from_dataconfig(cfg)
52-
.add_novel_allele(f"{gene}*i01", base=f"{gene}*01", mutations=[(pos, new)])
103+
.add_novel_allele(f"{gene}*i01", base=f"{gene}*01", mutations=_SAFE)
53104
.complete_from_reference()
54105
.homozygous(gene, f"{gene}*i01") # carry ONLY the novel allele for this gene
55106
.with_subject("DONOR_N")
@@ -60,21 +111,34 @@ def test_novel_allele_placed_and_sampled_appears_in_airr():
60111
.recombine()
61112
.run_records(n=300, seed=3, expose_provenance=True)
62113
)
63-
# every read assigned to this gene must be the novel allele (truth)
64114
truth_for_gene = {
65115
r["truth_v_call"] for r in res if r["truth_v_call"].startswith(gene + "*")
66116
}
67117
assert truth_for_gene == {f"{gene}*i01"}, truth_for_gene
68-
# and the engine actually produced the mutated base in those reads
69118
novel_reads = [r for r in res if r["truth_v_call"] == f"{gene}*i01"]
70-
assert novel_reads, "expected some reads from the novel allele"
71-
assert any(r["sequence"][pos].upper() == new for r in novel_reads)
119+
assert novel_reads
120+
assert any(r["sequence"][38].upper() == "C" for r in novel_reads)
121+
122+
123+
def test_to_table_and_tsv_expose_novel(tmp_path):
124+
cfg = _cfg()
125+
gene = "IGHVF1-G1"
126+
g = (
127+
Genotype.from_dataconfig(cfg)
128+
.add_novel_allele(f"{gene}*i01", base=f"{gene}*01", mutations=_SAFE)
129+
.heterozygous(gene, f"{gene}*01", f"{gene}*i01")
130+
)
131+
row = next(r for r in g.to_table() if r["gene"] == gene)
132+
assert row["novel"] == [f"{gene}*i01"]
133+
p = tmp_path / "truth.tsv"
134+
g.to_tsv(str(p))
135+
header = p.read_text().splitlines()[0].split("\t")
136+
assert "novel" in header
72137

73138

74139
def test_genotype_without_novel_is_unaffected():
75140
cfg = _cfg()
76141
g = Genotype.from_dataconfig(cfg).complete_from_reference().with_subject("S1")
77142
assert g.has_novel() is False
78-
# still runs (uses base refdata)
79143
res = ga.Experiment.on(cfg).with_genotype(g).recombine().run_records(n=10, seed=1)
80144
assert len(res) == 10

0 commit comments

Comments
 (0)