Skip to content

INS merging silently drops correct merges when INFO field starts with END= #240

Description

@aksenia

SURVIVOR version: 1.0.7
Command: SURVIVOR merge
Affected SV type: INS

Summary

When two insertion records at the same genomic position have INFO fields where one starts with END= (e.g. DRAGEN output), SURVIVOR computes an incorrect internal stop coordinate and fails to merge the pair - even when both records differ in position by 0 bp and in SVLEN by only 1 bp. The failure is silent: no error or warning is printed, both records appear in the output as singletons (SUPP_VEC=10 / SUPP_VEC=01).

Minimal reproduction

Save these two VCF files and the file list, then run SURVIVOR:

v_buggy.vcf - INFO starts with END= (DRAGEN-style):

##fileformat=VCFv4.1
##INFO=<ID=END,Number=1,Type=Integer,Description="End position">
##INFO=<ID=SVTYPE,Number=1,Type=String,Description="SV type">
##INFO=<ID=SVLEN,Number=1,Type=Integer,Description="SV length">
##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">
#CHROM	POS	ID	REF	ALT	QUAL	FILTER	INFO	FORMAT	SAMPLE_A
22	1000000	INS_A	G	GACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTAC	999	PASS	END=1000000;SVTYPE=INS;SVLEN=136	GT	1/1

v_query.vcf - INFO starts with SVTYPE= (CuteSV-style):

##fileformat=VCFv4.1
##INFO=<ID=SVTYPE,Number=1,Type=String,Description="SV type">
##INFO=<ID=SVLEN,Number=1,Type=Integer,Description="SV length">
##INFO=<ID=END,Number=1,Type=Integer,Description="End position">
##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">
#CHROM	POS	ID	REF	ALT	QUAL	FILTER	INFO	FORMAT	SAMPLE_B
22	1000000	INS_B	G	GACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTA	999	PASS	SVTYPE=INS;SVLEN=135;END=1000000	GT	1/1

list.txt:

v_buggy.vcf
v_query.vcf
SURVIVOR merge list.txt 100 1 1 0 0 50 merged_buggy.vcf

Output - two singletons:

SUPP=1;SUPP_VEC=10;...   # only SAMPLE_A
SUPP=1;SUPP_VEC=01;...   # only SAMPLE_B

Fixed VCF (workaround)

Reorder the INFO field so END= is not the first field:

v_fixed.vcf - same record, INFO reordered:

##fileformat=VCFv4.1
##INFO=<ID=SVTYPE,Number=1,Type=String,Description="SV type">
##INFO=<ID=SVLEN,Number=1,Type=Integer,Description="SV length">
##INFO=<ID=END,Number=1,Type=Integer,Description="End position">
##FORMAT=<ID=GT,Number=1,Type=String,Description="Genotype">
#CHROM	POS	ID	REF	ALT	QUAL	FILTER	INFO	FORMAT	SAMPLE_A
22	1000000	INS_A	G	GACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTACGTAC	999	PASS	SVTYPE=INS;SVLEN=136;END=1000000	GT	1/1
SURVIVOR merge list_fixed.txt 100 1 1 0 0 50 merged_fixed.vcf
# list_fixed.txt contains v_fixed.vcf and v_query.vcf

Output with fixed VCF:

SUPP=2;SUPP_VEC=11;...   # correctly merged

Root cause

Two interacting bugs in src/vcfs/Merge_VCF.cpp and src/merge_vcf/combine_svs.cpp.

Bug 1 - parse_stop(): strncmp with wrong length silently fails for INFO starting with END=

// src/vcfs/Merge_VCF.cpp, parse_stop()
if (strncmp(&buffer[i], ";END=", 5) == 0 ||
    (i == 0 && strncmp(&buffer[i], "END=", 5) == 0)) {   // <-- BUG HERE
    pos.pos = atoi(&buffer[i + 5]);
}

The string literal "END=" occupies 5 bytes in memory: {'E','N','D','=','\0'}. strncmp(..., "END=", 5) therefore compares 5 bytes including the null terminator. When buffer[i] points to END=1000000..., buffer[i+4] is '1' (a digit), not '\0' - so the comparison always returns non-zero and the END= value is never parsed.

The ;END= check (5 meaningful bytes: {';','E','N','D','='}) works correctly, so callers whose INFO field has END= anywhere other than position 0 are unaffected.

Fix: change strncmp(&buffer[i], "END=", 5) to strncmp(&buffer[i], "END=", 4).

Bug 2 - combine_calls_svs(): SVLEN added twice to stop position for INS

// src/merge_vcf/combine_svs.cpp
breakpoint_str stop = convert_position(entries[j].stop);
if (entries[j].type == 4) {   // type 4 = INS
    stop.position += entries[j].sv_len;   // always adds SVLEN
}

When parse_stop() fails (Bug 1), the fallback sets stop.pos = POS + SVLEN (line 492–493). Then combine_calls_svs adds SVLEN again for INS records, giving an effective stop of POS + 2×SVLEN.

For a caller whose parse_stop() succeeds (INFO does not start with END=), stop.pos = POS (the END field value for insertions, which equals POS). The combine step then correctly gives stop = POS + SVLEN.

Combined effect

Caller INFO starts with parse_stop result After combine_calls_svs
DRAGEN END=P;SVTYPE=INS;SVLEN=N -1 (failed) → fallback: P+N P+N + N = P + 2N
CuteSV SVTYPE=INS;SVLEN=N;END=P P (success) P + N

The merge criterion requires both start AND stop breakpoints within max_dist. The stop difference becomes |2N_A - N_B| ≈ N_A, which exceeds max_dist for any insertion longer than max_dist bp. For max_dist=100, all insertions ≥ 101 bp from DRAGEN-style callers silently fail to merge.

Condition for failure

The bug triggers when all of the following hold:

  1. One input VCF has an INS record with INFO starting with END= (no leading field or semicolon)
  2. SVLEN > max_dist (the insertion is longer than the merge window)
  3. The two records are at the same or nearby position with similar SVLEN

Impact

Callers that begin their INS INFO field with END= (observed in DRAGEN, possibly others) are silently under-merged. All insertions longer than the merge window (max_dist) from such callers are affected. The failure produces no error message.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions