fix(hgvs): order the anchor candidates by strand, so a periodic reference resolves to the span the variant touches (#96) - #97
Conversation
…ence resolves to the span the variant touches (#96) #93 gave `hgvsp_inframe_indel` a second anchor to try when the peptide does not corroborate the first, which moved 99.7% of the declines onto the normalised path. It left the case where *both* ends corroborate: `find()` took the first, and for a shrinking change on the reverse strand the first is the wrong one. Which end applies is not ambiguous. `protein_start` comes from the genomic left edge, and the residues are built from the lower of the two CDS coordinates for a shrinking change and from `cds_start` otherwise, so it is exactly the shrinking change on the reverse strand whose span arrives anchored at its end. `anchored_at_span_end` states that, and `anchor_candidates` now orders the pair by it instead of leaving the choice to iteration order. The other end stays as a fallback: over 37,122 in-frame ClinVar rows the residues sat at neither scalar in 17.0% of cases, so ordering is all the strand can honestly buy and the peptide still decides. Both routing copies pass `tr.strand` - the CLI pipeline and the one in fastvep-annotate that serves the web path. Measured end to end over 8,000 synthetic in-frame deletions (4,000 per strand, real peptides, driven through `fastvep annotate --hgvs`), scored by applying each description back to the reference peptide: 7,985 of 8,000 correct before, 8,000 after. All 15 changed descriptions are on the reverse strand and all 15 move from wrong to right; no forward-strand description changes. The 0.4% rate on reverse-strand rows is inflated by the four-residue alphabet the fixtures use to make periodic references common. Tests, 10 new and all confirmed to fail with the ordering removed: the #96 repro and its forward-strand mirror as unit tests, the same pair end to end through `run_annotate` and again through `annotate_vcf_text`, the `anchored_at_span_end` truth table, the candidate ordering, and a 4,000-case property sweep asserting that every emitted description reconstructs the protein the variant produces - the invariant a well-formed, corroborated, wrong span breaks. The existing real-data tables now run under both strands, which pins that insertions are unaffected. 941 tests pass, up from 931. `clippy --all-targets -D warnings` clean. Closes #96.
The ClinVar gate, runThe corpus was on this machine after all - Whole corpus: 673,660 variants, 15,684,064 consequence rows
Six rows. All six are the same variant - ClinVar 421481,
That variant, verified without our codePCDH15 is reverse-strand and the reference is Different proteins, so the 3'-rule cannot reconcile them: only one of the two descriptions belongs to this variant, and the branch now emits it. ClinVar's own record agrees on the event ( An Ensembl VEP cross-check is not included: VEP refuses How exposed the class is, on real dataFrom the 47,013-indel subset (1,203,053 consequence rows, 80,681 in-frame): The last two lines are the answer to the question the issue asked first. 2,453 rows could have hit it - reverse strand, shrinking, n >= 3, and the one-residue overlap between the two candidate spans satisfied. In 2,447 of them the two anchors either did not both corroborate or converged after the 3'-shift. Six did not. So the issue's own estimate was close: "the answer might be twice". It is six rows on one variant, and #96's option 3 - document it and move on - would have been a defensible call at that rate. Two things still argue for the fix as written. It removes the class rather than bounding it, using information already at the call site: the strand was always the determinant, and the old code was reading a coincidence instead. And the property sweep found the same fault in shrinking delins, which the issue's analysis did not cover - the exposed set there is not this 2,453 and has not been measured, because it needs the peptide-carrying corroboration check to enumerate rather than the necessary condition above. 941 tests pass, CI green, and the five behaviour tests still fail with the ordering removed. |
Closes #96, taking option 1 from the issue: pass the strand, because the coordinate convention already determines which end of the span
protein_startnames.The residue #93 left
#93 gave
hgvsp_inframe_indela second anchor to try when the peptide does not corroborate the first. That fixed every case where the first end is not a match at all - 99.7% of the declines. It left the case where both ends corroborate:find()takes the first, and for a shrinking change on the reverse strand the first is the wrong one.The wrong answer is not another spelling of the right one. With peptide
MEGEGEA,EGEsits at 2-4 and again at 4-6; deleting 2-4 leavesMGEAand deleting 4-6 leavesMEGA, so no later 3'-shift can reconcile them. The description names residues the variant does not touch, which is the family #91 and #58 were both about.What decides it now
protein_startcomes from the genomic left edge. The residues come from the lower of the two CDS coordinates for a shrinking change and fromcds_startotherwise (predict_coding_consequence), so it is exactly the shrinking change on the reverse strand whose span arrives anchored at its end.anchored_at_span_endstates that as a predicate andanchor_candidatesorders the pair by it, instead of leaving the choice to iteration order.The other end stays as a fallback rather than being dropped. Over 37,122 in-frame ClinVar rows the residues sat at
protein_startin 71.1% of cases, one residue earlier in 11.9%, and at neither in 17.0% - so ordering is all the strand can honestly buy, and the peptide still decides. A reverse-strand caller whose residues really do sit atprotein_startstill normalises; there is a test for exactly that.Both routing copies pass
tr.strand: the CLI pipeline, and the one infastvep-annotatethat serves the web path. Nothing structurally keeps those two in step, so each has its own end-to-end test.Measured end to end
The real-data gate is in this comment: over the whole 673,660-variant ClinVar 2-star corpus, 15,684,064 consequence rows, 6 HGVSp rows change and nothing else does -
Protein_positionand every other CSQ field are byte-identical. All six are ClinVar 421481 (PCDH15, reverse strand,PAP/-), verified against a peptide recomputed independently in Python.Before that corpus was located, the same question was answered synthetically, and the synthetic run is still the broader of the two: 8,000 synthetic in-frame deletions, 4,000 per strand, each on its own single-exon gene with a real translated peptide, driven through
fastvep annotate --hgvson both binaries. Scoring applies each emitted description back to the reference peptide and compares the result to the protein the variant actually produces - the invariant a well-formed-but-wrong span breaks.fd982f4(master)15 descriptions changed, every one on the reverse strand, every one from wrong to right. No forward-strand description changes at all. Examples, as
peptide del<span>:The last one is worth noting: the old answer had been 3'-shifted off the wrong anchor onto residues 9-11, which really are
GAAin that peptide. Nothing about it looks wrong.The 0.4% rate on reverse-strand rows is inflated by design - the fixtures use a four-residue alphabet so periodic references are common, which is what made the synthetic run useful before the real corpus was to hand. On real ClinVar data the rate is 6 rows in 15.7M: 2,453 in-frame rows satisfied the necessary condition for the ambiguity and 2,447 of those either failed to corroborate both anchors or converged after the 3'-shift.
A differential run over
vep_example_GRCh38.vcf,clinvar_inframe_deletions.vcf,chr22_1kgp.vcfandtests/test.vcfagainst the in-repo fixture annotation is byte-identical too, but that one is weak evidence and worth labelling as such: the fixture FASTA has no chr17 sequence, so those runs carry no protein descriptions at all. The ClinVar run above is the one that counts.Tests
10 new, each confirmed to fail with the ordering removed (
[Some(protein_start), from_end]unconditionally):The five that assert the new behaviour fail without it; the four guards pass either way, which is their job - they exist so the fix cannot trade one wrong end for the other.
every_description_reconstructs_the_protein_the_variant_producesis the one worth keeping an eye on: 4,000 shrinking changes from a seeded LCG, each applied back to its peptide. It found a shape the issue did not mention - shrinking delins, not just pure deletions:The existing real-data tables (
test_hgvsp_inframe_indel_never_emits_substitution_shapeand the terminal-insertion case) now run under both strands, which pins that insertions are anchored atprotein_starteither way.941 tests pass, up from 931.
clippy --all-targets -D warningsclean,rustfmtclean.Not in scope
The E2E fixtures deliberately reproduce the bug through
run_annotaterather than by handingprotein_startto the unit under test: the anchor convention is a property of the coordinates the pipeline produces, and a unit test that passes the anchor by hand asserts the author's reading of the convention rather than the pipeline's.🤖 Generated with Claude Code