Skip to content

fix(variant): error on a coordinate-unsorted input VCF (#232) - #242

Merged
TimD1 merged 1 commit into
devfrom
232_td_error-unsorted-vcf
Aug 11, 2026
Merged

fix(variant): error on a coordinate-unsorted input VCF (#232)#242
TimD1 merged 1 commit into
devfrom
232_td_error-unsorted-vcf

Conversation

@TimD1-bot

@TimD1-bot TimD1-bot commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Note

Authorship: the content below was drafted by Claude Opus 5 (an AI coding agent) and
filed via gh under @TimD1-bot, a bot account operated by @TimD1. It reflects the
agent's analysis, not a statement authored by @TimD1.

Summary

A coordinate-unsorted input VCF becomes a hard failure instead of a quietly wrong result.

Problem

Half the guard already existed. #149 restored the contig-re-entry check, so a record returning to a
contig already left errors out and contigs never interleave. Nothing checked the order of positions
within a contig.

A record that moved backwards was dropped one at a time by the overlap filter, which WARNs only
above the default verbosity. The run therefore succeeded, wrote a full set of output files, and
every denominator silently omitted the dropped records. Nothing about the failure mode looked like
an error.

The ordering that did hold was an accident: accepted records are non-decreasing per haplotype
because the overlap filter skips a record unless pos >= prev_end[hap]. That is per-haplotype, it
covers only accepted records, and it is a side effect of a filter written for an unrelated
purpose.

Change

src/variant.cpp, in the record loop:

if (rec->pos < prev_pos)
    ERROR("Unsorted %s VCF '%s', record %d at %s:%lld precedes position %lld", ...);
prev_pos = rec->pos;

prev_pos is tracked alongside prev_end / prev_type and reset in the same place they are, on a
contig change. One integer comparison per record; the message reuses the existing "Unsorted %s VCF" phrasing from the contig check.

Two deliberate placement choices:

  • Before FILTER and quality filtering. Being sorted is a property of the file, not of the
    records that survive filtering.
  • Only a decrease is rejected. Equal positions are ordinary in a sorted VCF -- a site's alleles
    split across rows -- and a contig that begins before the previous one ended is fine, since
    positions are compared only within a contig.

Why it lands before the code that needs it

#47's per-variant region-membership lookup uses a monotonic cursor per region set, which requires
the query stream to be non-decreasing within a contig. Resting that on the overlap filter's
incidental ordering would make stratified counts silently depend on an unrelated filter's semantics
-- and the membership query runs before that filter in the loop, so it would not even be covered
by it. Enforcing the precondition here means nothing downstream has to assume it.

Testing

Unit (tests/unit/src/test_variant.cpp), 3 added:

test covers
ParseVariants.UnsortedPositionErrors a within-contig backwards position exits 1, naming the record and both positions
ParseVariants.RepeatedPositionParses two records at one position parse, and both are kept
ParseVariants.PositionResetsOnNewContig a new contig may start before the previous one ended

The existing ParseVariants.UnsortedContigErrors and ContigsOutOfHeaderOrderParse are unchanged
and still pass, so the contig-level guard and the sorted-but-reordered-contigs case are unaffected.

Integration: test_unsorted-query-vcf-exits-nonzero, on a new
tests/integration/data/unsorted_position_query.vcf -- the swallowed_snps query's three SNPs with
the last two swapped, so sc1 146 follows sc1 256. It asserts exit code 1 and the error text.
This one earns an integration case rather than only a unit test precisely because the failure mode
it replaces was a successful run.

Verification

  • Unit suite: 760 tests, all passing -- 757 before, 3 added.
  • pytest from tests/: 118 passed (115 before; the new case contributes its exit-code check
    and two stderr assertions as three separate pytest-workflow items).
  • Both builds compile clean from make clean under -Wall -Wextra -Werror=missing-field-initializers; doxygen src/Doxyfile emits no warnings.
  • Manual: the fixture run exits 1 with
    Unsorted QUERY VCF '...', record 3 at sc1:145 precedes position 255.

Release note

Behavior change. Runs that previously produced misleading numbers on an unsorted input now fail
loudly. The README's Usage section now states the requirement.

Part of #47. Resolves #232.

Targets dev directly: the change touches only parse_variants() and its tests, and depends on
nothing in the rest of the #47 chain.

@TimD1-bot
TimD1-bot force-pushed the 231_td_bed-helper-fns branch from 63f53ff to 65e32d0 Compare August 11, 2026 16:34
@TimD1-bot
TimD1-bot force-pushed the 232_td_error-unsorted-vcf branch from 7357247 to c9814f3 Compare August 11, 2026 16:54
@TimD1-bot
TimD1-bot changed the base branch from 231_td_bed-helper-fns to dev August 11, 2026 16:55
parse_variants() rejected a VCF that returned to a contig it had already
left, but nothing checked the order of positions within a contig. A record
that moved backwards was dropped one at a time by the overlap filter, which
warns only above the default verbosity, so the run finished successfully,
wrote a full set of outputs, and every denominator silently omitted it.

Track the previous record's position alongside prev_end and prev_type,
resetting it on a contig change, and ERROR when a record's position
decreases. The check runs before FILTER and quality filtering, since being
sorted is a property of the file rather than of the records that survive.

The ordering that held before was incidental: the overlap filter skips a
record unless pos >= prev_end[hap], which is per-haplotype, applies only to
accepted records, and runs after the region-membership lookup that #47's
monotonic per-region cursor will depend on. Enforcing the precondition here
means nothing downstream has to assume it.

Equal positions are still accepted -- a site split across rows is ordinary
in a sorted VCF -- and so is a contig that starts before the previous one
ended, since positions are compared only within a contig.

The integration case is worth its own fixture rather than only a unit test
precisely because the failure mode it replaces was a successful run.
@TimD1-bot
TimD1-bot force-pushed the 232_td_error-unsorted-vcf branch from c9814f3 to 42041e3 Compare August 11, 2026 16:58
@TimD1
TimD1 merged commit 6780c48 into dev Aug 11, 2026
1 check passed
@TimD1
TimD1 deleted the 232_td_error-unsorted-vcf branch August 11, 2026 17:00
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.

2 participants