refactor(bed)!: stop pruning contigs and reference sequence in intersect_contigs (#246) - #247
refactor(bed)!: stop pruning contigs and reference sequence in intersect_contigs (#246)#247TimD1-bot wants to merge 2 commits into
Conversation
…ect_contigs (#246) intersect_contigs() erased four things: query contigs absent from the BED along with their parallel lengths, observed_ploidies and variants[hap] entries; the same for truth; reference FASTA sequences absent from the BED; and reference sequences absent from the truth VCF in the no-BED branch. A variant on an erased contig could never be reported, since the contig was gone from the output contig list before the writer ran, and its reference sequence had been freed as well -- set_var_record() needs that sequence to anchor an INS or DEL. All four loops are deleted, which is what lets a later change retain BED-absent variants instead of discarding them. Evaluation is unchanged. parse_variants() already discards every variant on a BED-absent contig as BED_OFFCTG, so a retained contig arrives empty, and clustering, superclustering and phasing all skip empty contigs. On the committed chr20 fixture precision-recall-summary.tsv and phasing-summary.tsv are byte-identical before and after; the sole output difference anywhere is a ##contig header line for the retained contig in summary.vcf, which carries no record. The cross-callset false-positive/false-negative warnings and the observed-ploidy comparison are now scoped to BED contigs. Both previously ran after the erase, so they only ever saw evaluated contigs. Left global they would fire for contigs nothing evaluates, where "All query variants on 'chrN' will be false positives" is simply untrue; ploidy is likewise recorded before the BED filter applies, so a BED-absent contig carries ploidies no evaluation consults. Scoping the ploidy comparison also removes an out-of-bounds read that deleting the pruning would otherwise expose. The comparison indexed query->observed_ploidies with the result of a std::find that could fail, returning size() for a truth contig with no query counterpart. Pruning plus the injection block used to guarantee identical contig lists; without pruning a truth-only BED-absent contig survives unmatched, since injection iterates g.bed.contigs alone. Verified with AddressSanitizer: before the fix the new truth-only scenario aborts with a heap-buffer-overflow in intersect_contigs, after it the suite is clean. BREAKING CHANGE: a contig present in either input VCF but absent from the reference FASTA is now a hard error naming the contig and the callset. It was previously dropped silently whenever it was also absent from the BED, so a query VCF carrying decoys or alts (chrEBV, HLA-*) against a chromosome-scoped BED ran and reported nothing about them. Retaining the contig means its reference sequence is now required, so the omission has to be reported. In the no-BED branch this replaces a worse failure: a query-only contig missing from the FASTA already reached lengths.at() and threw an unhandled std::out_of_range.
…gfault (#246) Removing the pruning broke an invariant that several consumers depend on: both callsets carrying identical contig lists. superclusterData builds its contig list as the union of the two (src/cluster.cpp:319-338), then load_and_merge_callset_vars_across_haps() indexes vars[h][ctg] for every contig in that union (src/cluster.cpp:97). vars[h] is an unordered_map, so operator[] on an absent contig inserts a null shared_ptr and ->n dereferences it. The BED branch injects g.bed.contigs alone, so a contig carried by one callset and absent from both the other callset and the BED survived unpaired. On a two-contig fixture with the off-BED contig missing from the truth VCF's header, vcfdist segfaults (exit 139) where the pre-change build exits 0. The trigger is narrower than "present in one callset, absent from the other": parse_variants() creates a ctgVariants per contig in the VCF *header* (src/variant.cpp:869-874) but appends to `contigs` only per record (src/variant.cpp:924), so a contig declared-but-uncalled is still indexable. It has to be absent from the other callset's header, which two callsets from different pipelines routinely are. tests/unit/src/test_cluster.cpp already documented this hazard on declare_contig(), noting the tests deliberately do not construct the unpaired state. Fixed by restoring the invariant centrally rather than guarding each consumer. The no-BED branch's cross-injection moves into pair_missing_contigs(), called unconditionally for both directions after the g.bed_exists branch; those two loops keep only their WARN calls. ref->lengths.at() stays safe because the reference-FASTA assertion has already rejected any VCF contig the FASTA lacks. Metrics are unaffected: a paired contig is empty on both sides, so it is skipped everywhere. All eight chr20 metrics files remain byte-identical to the pre-change baseline, and the header-mismatch fixture now produces exactly the baseline's output. Tests: intersect_contigs() pairs an off-BED contig in either direction without warning about it; a superclusterData built from an unpaired off-BED contig segfaults without the fix; and an integration scenario runs the header-mismatch case end to end, since a segfault is worth catching in CI.
|
Claude Opus 5 🤖: The null dereference described in #246's comment was real and Why my integration test missed it
So the trigger is narrower than "one callset carries a contig the other does not": the contig has to Confirmed with a fixture whose truth header omits sc2: Worth noting that The fixTaken as recommended: restore the invariant centrally rather than guard each consumer. The no-BED One deviation: the Verification
Each new test was confirmed red first — the |
Note
Authorship: the content below was drafted by Claude Opus 5 (an AI coding agent) and
filed via
ghunder @TimD1-bot, a bot account operated by @TimD1. It reflects theagent's analysis, not a statement authored by @TimD1.
Addresses #246. Part of #48, and a prerequisite for retaining variants on contigs absent from
the
-bBED file (#107).What was wrong
intersect_contigs()erased four separate things:lengths,observed_ploidiesandvariants[hap]src/bed.cppsrc/bed.cppsrc/bed.cppsrc/bed.cppA variant on an erased contig could never be reported: the contig was gone from the output
contig list before the writer ran, and its reference sequence had been freed too —
ctgVariants::set_var_recordreadsref->fastato anchor an INS or DEL, so retaining thecontig alone would not have been enough. All four loops are deleted.
Evaluation is unchanged
parse_variants()already discards every variant on a BED-absent contig asBED_OFFCTG(
src/variant.cpp:1177), so a retained contig arrives empty, and clustering, superclusteringand phasing all skip empty contigs. On the committed chr20 fixture,
precision-recall-summary.tsvandphasing-summary.tsvare byte-identical before and after,as are
precision-recall.tsv,query.tsv,truth.tsv,phase-blocks.tsv,switchflips.tsvand
genotype-errors.tsv. The only output difference anywhere is a##contigheader line forthe retained contig in
summary.vcf, which carries no record — that header is what #107 willwrite into.
Warnings are now scoped to BED contigs
The cross-callset false-positive/false-negative warnings and the observed-ploidy comparison
both used to run after the erase, so they only ever saw evaluated contigs. Left global they
would fire for contigs nothing evaluates, where
All query variants on 'chrN' will be false positivesis simply untrue. Ploidy is likewise recorded before the BED filter applies(
src/variant.cpp:1025precedes:1177), so a BED-absent contig carries ploidies noevaluation consults.
This deviates from #246, which proposed leaving the ploidy comparison global. The same argument
the issue makes for the cross-callset warnings applies to it unchanged.
Two memory defects this would otherwise have exposed
Both stem from the same broken invariant. Pruning plus the injection block used to guarantee the
two callsets carried identical contig lists; without pruning, a one-sided contig outside the BED
survives unpaired, since injection iterates
g.bed.contigsalone.An out-of-bounds read. The ploidy comparison indexed
query->observed_ploidieswith theresult of a
std::findthat could fail, yieldingsize(). Verified with AddressSanitizer —before the fix the truth-only scenario aborts:
A plain build reads past the end with no visible symptom, so this had to land with the deletion.
A null dereference (
d3f456f).superclusterDatabuilds its contig list as the union of bothcallsets (
src/cluster.cpp:319-338), thenload_and_merge_callset_vars_across_hapsindexesvars[h][ctg]for every contig in it (src/cluster.cpp:97).vars[h]is anunordered_map, sooperator[]on an absent contig inserts a nullshared_ptrand->ndereferences it.The trigger is narrower than "present in one callset, absent from the other":
parse_variantscreates a
ctgVariantsper contig in the VCF header (src/variant.cpp:869-874) but appendsto
contigsonly per record (src/variant.cpp:924), so a declared-but-uncalled contig is stillindexable. It has to be absent from the other callset's header — which two callsets from
different pipelines routinely are. On such a fixture the pre-change build exits 0 and pruning-
removed-only exits 139.
tests/unit/src/test_cluster.cppalready documented this hazard ondeclare_contig(), noting acontig one callset never declared "is not a state these tests construct" — because
intersect_contigsused to rule it out.Restoring the invariant, not guarding each consumer
The no-BED branch's cross-injection moves into
pair_missing_contigs(), called unconditionally inboth directions after the
g.bed_existsbranch; those two loops keep only theirWARNcalls.ref->lengths.at()stays safe because the reference-FASTA assertion has already rejected any VCFcontig the FASTA lacks.
A paired contig is empty on both sides, so it is skipped everywhere and no metric moves. The
continueguard in the ploidy loop is kept as a bounds guard, now unreachable because pairingguarantees the lookup succeeds rather than because the BED skip does.
Memory
No peak regression:
fastaDataloads the entire FASTA in its constructor (src/fasta.h:31-40),before
intersect_contigsruns, so the erase was a post-hoc free rather than a prevention.Residency during alignment rises by the size of the kept contigs — roughly 3 GB for hg38,
against the 64 GB
--max-ramdefault.Breaking change
A contig present in either input VCF but absent from the reference FASTA is now a hard error
naming the contig and the callset:
It was previously dropped silently whenever it was also absent from the BED, so a query VCF
carrying decoys or alts (
chrEBV,HLA-*) against a chromosome-scoped BED ran and reportednothing about them. Retaining the contig means its reference sequence is now required, so the
omission has to be reported. Documented in
README.md, beside the existing coordinate-sortingrequirement.
The old no-BED message ended
Please provide BED file.; that advice is dropped, becausesupplying a BED no longer avoids the error. In that branch this replaces a worse failure rather
than introducing one — a query-only contig missing from the FASTA already reached
ref_ptr->lengths.at(ctg)and threw an unhandledstd::out_of_range.Tests
Unit, 810 → 819. In
test_bed.cpp:contigs,lengths,observed_ploidiesandvariants[hap], and its reference sequence survivescontig and the callset, with and without a BED
In
test_cluster.cpp: asuperclusterDatabuilt from an unpaired BED-absent contig — whichsegfaults without
pair_missing_contigs(). It callsintersect_contigsand the constructortogether, because neither function alone can show the invariant holds.
IntersectContigs.BedDropsExtraneousasserted the deleted behavior and is replaced byBedKeepsContigOutsideBed;NobedFastaMissingErrorsis renamed and its expected messageupdated.
Integration (
tests/integration/test-integration.yml), three cases. Two reuse the existingone_sided_contig_*VCF pair againstsynthetic.bed(sc1 only) instead ofsynthetic_2ctg.bed,so sc2 falls outside the evaluated regions in each direction; each pins that sc2 reaches the
output contig list but contributes no record and no metric. The query-only case contrasts directly
with
test_one-sided-contig_query-only, which runs the identical VCFs against a BED covering sc2and scores those same calls as false positives.
The third adds
undeclared_contig_{query,truth}.vcf, where the truth's header omits sc2altogether, and runs the segfault case end to end — a crash is worth catching in CI. Both new
fixtures are documented in
tests/integration/data/README.mdand neededgit add -f, since abroad
data/rule in.gitignorecovers that directory, as the existing 30 fixtures did.Verification
tests/unit/build/test_vcfdist: 819 passed, from a clean build, no compiler warnings-fsanitize=address: 819 passed, no sanitizer reportspytestintests/: 156 passedidentical to the pre-change build with the pairing fix
Every new test was confirmed red first. The two BED-scoped integration cases fail on the
pre-change build specifically on the contig-retention assertion, with every metric assertion
passing — which is what makes the "unchanged by construction" claim checkable rather than
asserted. The
superclusterDatatest was red by segfault, not assertion failure.Closes #246is omitted deliberately: this PR targetsdevwhile the default branch ismaster, so a closing keyword would not fire. #246 needs closing by hand on merge.