Skip to content

fix(hgvs): route in-frame insertions to delins, not substitution - #85

Merged
kuanlinhuang merged 1 commit into
masterfrom
fix/hgvsp-inframe-insertion-routing
Aug 14, 2026
Merged

fix(hgvs): route in-frame insertions to delins, not substitution#85
kuanlinhuang merged 1 commit into
masterfrom
fix/hgvsp-inframe-insertion-routing

Conversation

@kuanlinhuang

Copy link
Copy Markdown
Member

Fixes the half of #81 that produces a silent wrong answer. Protein-level normalisation (3'-shift and dup collapse) 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 to hgvsp(), which compares one residue per side:

if ref_aa == alt_aa {
    return Some(format!("{}{}{}=", prefix, ref_aa3, protein_pos));
}

For amino_acids R/RR that yields p.Arg3=, a synonymous call for a variant that lengthens the protein. For E/DVDFREYE it yields p.Glu598Asp, a well-formed missense. Unlike the malformed p.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:

consequence_terms = ["inframe_insertion"]
amino_acids       = "R/RR"
codons            = "cgg/CGGcgg"
hgvsp             = "ENSP_INS_TEST:p.Arg3="     <- Ensembl VEP: p.Arg3dup

The change

In-frame insertions now route into hgvsp_inframe_deletion alongside deletions and delins, giving p.Arg3delinsArgArg.

That output is deliberately un-normalised. Ensembl VEP collapses this repeat to p.Arg3dup, and for a 31-codon BCOR duplication the delins form 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) and fastvep-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 through annotate_vcf_text_with_acmg, asserting the emitted HGVSp.
  • crates/fastvep-cli/tests/hgvsp_inframe_insertion.rs - end-to-end through run_annotate with a GFF3 + FASTA + VCF on disk, asserting on the CSQ field in the output VCF.

Both fail on master with p.Arg3= and pass here. Both assert the inframe_insertion consequence first, so they cannot pass for the wrong reason if classification changes.

cargo test --workspace (0 failures) and cargo clippy --workspace --all-targets (clean) both pass.

cargo fmt --check is not clean on master and 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 pending chore/rustfmt-the-workspace branch 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 read protein_position and amino_acids directly, and the PVS1 truncation percentage reads protein_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

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
@kuanlinhuang
kuanlinhuang merged commit 9d4e695 into master Aug 14, 2026
1 check passed
@kuanlinhuang
kuanlinhuang deleted the fix/hgvsp-inframe-insertion-routing branch August 14, 2026 00:46
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant