fix(hgvs): route in-frame insertions to delins, not substitution - #85
Merged
Merged
Conversation
An in-frame insertion satisfies neither arm of the HGVSp routing test (`aa.1 == "-" || InframeDeletion`), so it fell through to `hgvsp()`, which compares a single residue per side. `R/RR` therefore read as unchanged and rendered `p.Arg3=`, a synonymous call for a variant that lengthens the protein; `E/DVDFREYE` rendered as `p.Glu598Asp`, a well-formed missense. Both are indistinguishable from genuine calls downstream, unlike the malformed `p.Xaa123???` that #58 fixed, so no consumer can detect them. Route in-frame insertions into `hgvsp_inframe_deletion` alongside deletions and delins. The output is valid HGVS but deliberately un-normalised: Ensembl VEP collapses this repeat to `p.Arg3dup`. Protein-level 3'-shift and duplication collapse are a separate concern, tracked in #81 where a contributor has an implementation in flight. The CLI pipeline carries its own copy of this routing block, so both copies are fixed and each gets its own regression test; the CLI one drives `run_annotate` over a GFF3 + FASTA + VCF on disk. Refs #81
MarcusOlivecrona
added a commit
to Qnomx/fastVEP
that referenced
this pull request
Aug 17, 2026
Huang-lab#85 stopped in-frame insertions rendering as substitutions by routing them into the indel builder, and noted the result is deliberately un-normalised: an insertion that repeats what it follows renders `p.Arg3delinsArgArg` where Ensembl VEP reports `p.Arg3dup`. This normalises it. Given the reference peptide, insertions and deletions shift as far C-terminal as they can go and an insertion repeating the residues before it collapses to `dup`. `delins` is not shifted, matching VEP. Descriptions also stop restating unchanged residues: a 31-codon BCOR duplication was a ~105-character `delins` and is now `p.Cys1709_Ser1739dup`. Measured with validation/compare_vep.py on 3,120 ClinVar GRCh38 in-frame indels, annotated from Ensembl 115 GFF3 + primary assembly and compared with ensemblorg/ensembl-vep:release_115.1 over the 65,318 (allele, transcript) rows shared with VEP: HGVSp agreement goes from 51.27% to 83.37%, a drop from 31,832 mismatches to 10,863. Every other compared field -- Consequence, IMPACT, Protein_position, Amino_acids, Codons -- is unchanged to the row. The two human fixtures hold at 100% HGVSp before and after, and contain no in-frame indels to exercise. The peptide comes from `Transcript::peptide` rather than a fresh translation, per review on Huang-lab#81. `build_sequences` pads `translateable_seq` with leading `N`s when `codon_table_start_phase > 0`, so translating from `cdna_coding_start` would silently shift every protein position on those transcripts, and it already selects the mitochondrial table for MT. It carries the terminal `*`, so the peptide is bounded at the first terminator before shifting -- otherwise a change can slide onto the stop, or name `Ter` as a flanking residue. Normalisation runs only once the peptide confirms the caller's residues sit at `protein_start`. They do not always: for a shrinking change such as `Protein_position` 328-329 with `Amino_acids` FF/F, this function is handed protein_start 329, one past the F pair. Trimming the shared F from an anchor that is already one off describes a deletion at 330, which is Ala. An uncorroborated anchor therefore takes the un-normalised path, which is byte-identical to the output before this commit. Correcting the anchor itself belongs at the call site and is left alone here. Normalisation is confined to the HGVSp layer. `protein_start` and `amino_acids` are untouched, so PS1/PM1/PM5 matching and the PVS1 truncation percentage read exactly what they read before: across the same ClinVar run, all 71,955 variant-by-transcript rows are identical in Consequence, Protein_position, Amino_acids, Codons and IMPACT, and all 71,955 ACMG calls and criteria sets are unchanged (PM4 fires 37,124 times in both). Every peptide-dependent step degrades to the unshifted description rather than failing or aborting: absent, short, or inconsistent peptides fall back, as does an insertion at either terminus where there is no flanking pair to name. `hgvsp_inframe_deletion` is renamed `hgvsp_inframe_indel` with no alias, per review; both call sites pass `tr.peptide` and both regression tests from Huang-lab#85 now pin the normalised form. Refs Huang-lab#81
kuanlinhuang
pushed a commit
that referenced
this pull request
Aug 18, 2026
#85 stopped in-frame insertions rendering as substitutions by routing them into the indel builder, and noted the result is deliberately un-normalised: an insertion that repeats what it follows renders `p.Arg3delinsArgArg` where Ensembl VEP reports `p.Arg3dup`. This normalises it. Given the reference peptide, insertions and deletions shift as far C-terminal as they can go and an insertion repeating the residues before it collapses to `dup`. `delins` is not shifted, matching VEP. Descriptions also stop restating unchanged residues: a 31-codon BCOR duplication was a ~105-character `delins` and is now `p.Cys1709_Ser1739dup`. Measured with validation/compare_vep.py on 3,120 ClinVar GRCh38 in-frame indels, annotated from Ensembl 115 GFF3 + primary assembly and compared with ensemblorg/ensembl-vep:release_115.1 over the 65,318 (allele, transcript) rows shared with VEP: HGVSp agreement goes from 51.27% to 83.37%, a drop from 31,832 mismatches to 10,863. Every other compared field -- Consequence, IMPACT, Protein_position, Amino_acids, Codons -- is unchanged to the row. The two human fixtures hold at 100% HGVSp before and after, and contain no in-frame indels to exercise. The peptide comes from `Transcript::peptide` rather than a fresh translation, per review on #81. `build_sequences` pads `translateable_seq` with leading `N`s when `codon_table_start_phase > 0`, so translating from `cdna_coding_start` would silently shift every protein position on those transcripts, and it already selects the mitochondrial table for MT. It carries the terminal `*`, so the peptide is bounded at the first terminator before shifting -- otherwise a change can slide onto the stop, or name `Ter` as a flanking residue. Normalisation runs only once the peptide confirms the caller's residues sit at `protein_start`. They do not always: for a shrinking change such as `Protein_position` 328-329 with `Amino_acids` FF/F, this function is handed protein_start 329, one past the F pair. Trimming the shared F from an anchor that is already one off describes a deletion at 330, which is Ala. An uncorroborated anchor therefore takes the un-normalised path, which is byte-identical to the output before this commit. Correcting the anchor itself belongs at the call site and is left alone here. Normalisation is confined to the HGVSp layer. `protein_start` and `amino_acids` are untouched, so PS1/PM1/PM5 matching and the PVS1 truncation percentage read exactly what they read before: across the same ClinVar run, all 71,955 variant-by-transcript rows are identical in Consequence, Protein_position, Amino_acids, Codons and IMPACT, and all 71,955 ACMG calls and criteria sets are unchanged (PM4 fires 37,124 times in both). Every peptide-dependent step degrades to the unshifted description rather than failing or aborting: absent, short, or inconsistent peptides fall back, as does an insertion at either terminus where there is no flanking pair to name. `hgvsp_inframe_deletion` is renamed `hgvsp_inframe_indel` with no alias, per review; both call sites pass `tr.peptide` and both regression tests from #85 now pin the normalised form. Refs #81
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes the half of #81 that produces a silent wrong answer. Protein-level normalisation (3'-shift and
dupcollapse) is not in this PR and is left to @MarcusOlivecrona, who has an implementation in flight.The bug
The HGVSp routing tested
aa.1 == "-" || consequences.contains(InframeDeletion). An in-frame insertion satisfies neither, so it fell through tohgvsp(), which compares one residue per side:For
amino_acidsR/RRthat yieldsp.Arg3=, a synonymous call for a variant that lengthens the protein. ForE/DVDFREYEit yieldsp.Glu598Asp, a well-formed missense. Unlike the malformedp.Xaa123???that #58 fixed, both are indistinguishable from genuine calls, so nothing downstream can detect them. In the reporter's clinical panel run all 15 distinct in-frame insertions were affected (8 synonymous, 7 missense), two of them FLT3 ITDs.Reproduced through the real annotation path before fixing:
The change
In-frame insertions now route into
hgvsp_inframe_deletionalongside deletions and delins, givingp.Arg3delinsArgArg.That output is deliberately un-normalised. Ensembl VEP collapses this repeat to
p.Arg3dup, and for a 31-codon BCOR duplication thedelinsform runs to ~105 characters. That is the tradeoff being accepted here: correct-but-verbose now, normalised when #81 lands. What matters clinically is the property this PR establishes, that an in-frame insertion never renders as a substitution.Both call sites are fixed.
fastvep-annotate(library/web) andfastvep-cli(CLI pipeline) carry separate copies of this routing block, and both had the bug. Nothing structurally keeps them in step, so each gets its own regression test.Testing
crates/fastvep-annotate/src/lib.rs- annotates a VCF against a synthetic transcript throughannotate_vcf_text_with_acmg, asserting the emitted HGVSp.crates/fastvep-cli/tests/hgvsp_inframe_insertion.rs- end-to-end throughrun_annotatewith a GFF3 + FASTA + VCF on disk, asserting on the CSQ field in the output VCF.Both fail on
masterwithp.Arg3=and pass here. Both assert theinframe_insertionconsequence first, so they cannot pass for the wrong reason if classification changes.cargo test --workspace(0 failures) andcargo clippy --workspace --all-targets(clean) both pass.cargo fmt --checkis not clean onmasterand is not made worse here: the violation count in the two touched files is 111 before and after this change, and the new test file is rustfmt-clean on its own. The pendingchore/rustfmt-the-workspacebranch reformats the workspace separately and will absorb these lines.ACMG is untouched by construction
We just shipped a large ACMG pass (#82), so the blast radius matters. ACMG never reads
hgvsp- PS1, PM1 and PM5 readprotein_positionandamino_acidsdirectly, and the PVS1 truncation percentage readsprotein_position. This change touches neither, so no classification can move. That containment is also the constraint requested of the normalisation work in #81.Refs #81
🤖 Generated with Claude Code