refactor(bed): extract classify(), add merge(), and drop the bed_exists guard (#231) - #241
Conversation
63f53ff to
65e32d0
Compare
|
|
||
| this->check(); | ||
| if (merge_overlaps) this->merge(); | ||
| else this->check(); |
There was a problem hiding this comment.
still check even if you just merged
There was a problem hiding this comment.
Claude Opus 5 🤖: check() now runs unconditionally, after normalize() rather than instead of it. Normalizing repairs disorder and overlap and nothing else, so a flipped or zero-length interval is left as found and still errors — BedCtor.NormalizeStillRunsCheck pins both.
| hts_close(bed_fp); | ||
|
|
||
| this->check(); | ||
| if (merge_overlaps) this->merge(); |
There was a problem hiding this comment.
rename merge_overlaps() to normalize() (sort and merge)
There was a problem hiding this comment.
Claude Opus 5 🤖: Renamed the constructor parameter merge_overlaps to normalize, matching the method.
| * untidy. The total size is recomputed from the merged intervals, since bases covered by more than | ||
| * one input interval were counted once per interval by add(). | ||
| */ | ||
| void bedData::merge() { |
There was a problem hiding this comment.
please rename to "normalize()", since this function sorts and merges
There was a problem hiding this comment.
Claude Opus 5 🤖: Renamed merge() to normalize(), since it sorts as well as merges.
| } | ||
| this->size = merged_size; | ||
|
|
||
| if (g.verbosity >= 2 && coalesced) |
There was a problem hiding this comment.
Can we also add a warning here if we discover the input wasn't sorted?
There was a problem hiding this comment.
Claude Opus 5 🤖: Added an unconditional WARN when the regions were not already sorted, detected with std::is_sorted on the same key the sort uses. BedNormalize.WarnsWhenUnsorted covers it; ReportsCoalescedCount pins that merging alone does not report the file as unsorted.
| this->size = merged_size; | ||
|
|
||
| if (g.verbosity >= 2 && coalesced) | ||
| INFO("Merged %d overlapping or adjacent BED regions.", coalesced); |
There was a problem hiding this comment.
We should report the name of the BED file when displaying info.
There was a problem hiding this comment.
Claude Opus 5 🤖: Both messages now name the BED file. bedData gained a filename member set by the file constructor, empty for an in-memory one.
| EXPECT_EXIT(bedData bed(bed_fn), testing::ExitedWithCode(1), "BED overlap detected"); | ||
| } | ||
|
|
||
| TEST(BedCtor, MergeOverlapsSkipsCheck) { |
There was a problem hiding this comment.
this will be a mis-nomer, since I still want the check, it should just pass once things have been normalized. and obviously rename "Merge" to "Normalize"
There was a problem hiding this comment.
Claude Opus 5 🤖: Renamed to BedCtor.NormalizedOverlapPassesCheck and reframed: it now asserts the check still runs and passes, with stderr empty. Suite BedMerge renamed to BedNormalize throughout.
…ts guard (#231) Three zero-output-change edits to bed.cpp, all prerequisites of the #47 membership sweep. contains() interleaved locating a variant -- start_idx by upper_bound on starts, stop_idx by lower_bound on stops -- with classifying it from how those indices relate. classify() now takes the decision tree plus the before-all/after-all BED_OUTSIDE early returns, which are load-bearing: a variant left of the first region has start_idx -1 and would otherwise read as BED_BORDER. contains() keeps both binary searches and calls classify(), so it returns exactly what it did before. #47 adds a second way of locating a variant, a monotonic cursor; without the split there would be two copies of one rule, free to drift. contains() opened with `if (!g.bed_exists) return BED_INSIDE;` -- a method on one bedData consulting a global flag about a different one. For a stratification region set that is wrong: membership would depend on whether -b was supplied. The check moves to the sole -b call site in parse_variants(), which also makes contains() testable without global setup. merge() sorts each contig's intervals by start, merges those that overlap or abut, recomputes size, and reports the number coalesced at verbosity >= 2. A new bedData(bed_fn, merge_overlaps) selects it in place of check(). No caller passes true yet: -b keeps the strict check(), which is right for an evaluation region whose malformation silently changes every denominator and wrong for a third-party region set we neither author nor control. Merging is not tidiness -- contains()' binary searches require sorted, disjoint intervals, so an unmerged set returns wrong answers. BedContains.NoBedInside asserted exactly the deleted early return, so it is retargeted to the parse_variants() call site as ParseVariants.NoBedEverythingInside, which also pins that a leftover g.bed cannot filter an unrestricted run. The g.bed_exists = true scaffolding the other BedContains cases carried is now dead setup and is deleted. The existing contains() return-value cases are unchanged, which is the guard on the extraction; reordering classify()'s early returns fails four of them.
Review feedback on #241. merge() becomes normalize(), since it sorts as well as merges, and the constructor's merge_overlaps parameter becomes normalize to match. check() now runs unconditionally, after normalize() rather than instead of it. Normalizing repairs disorder and overlap and nothing else, so it does not weaken the validation it precedes: a flipped or zero-length interval is left exactly as it was found and check() still rejects it. normalize() warns when the regions were not already sorted -- sorting is a repair, and a file needing it is not one check() would have accepted -- and both of its messages now name the BED file, since #47 normalizes many region sets and a message about one of them has to say which. bedData gains a filename member, set by the file constructor and empty for an in-memory bedData, to carry that name. BedCtor.MergeOverlapsSkipsCheck is renamed NormalizedOverlapPassesCheck and now asserts that the check still runs and passes, rather than that it was skipped. NormalizeStillRunsCheck pins the other half: a flipped and a zero-length region are still fatal with normalize enabled. The message tests load from a file rather than building in memory, since what they assert on is the file being named.
65e32d0 to
796ff22
Compare
|
Claude Opus 5 🤖: All six comments addressed — Responses to review comments (6 addressed, 0 deferred)
Threads are replied to but left unresolved: enumerating thread node ids needs a GraphQL read that this environment denies, so resolving them is yours to do. Count impact — no change on the chr20 fixturesTier: chr20 fixtures ( All eight count-bearing outputs are byte-identical between base and PR: This is expected rather than lucky: Verification
Rebased twice during this run, both clean and conflict-free — One thing worth knowing, since it cost a false test run: adding a member to |
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.
Summary
Three edits to
src/bed.cpp, all prerequisites of the #47 membership sweep, none of whichchanges any output. Every TSV, the summary VCF, and every counter are what they were.
Change
Extract a pure
classify()contains()interleaved two concerns: locating the variant (start_idxviaupper_boundon
starts,stop_idxvialower_boundonstops) and classifying it from how thoseindices relate.
classify()now takes the decision tree plus the before-all / after-allBED_OUTSIDEearly returns, which are load-bearing rather than incidental: a variant entirelyleft of the first region yields
start_idx == -1and would otherwise be misclassifiedBED_BORDERby the very next test. Only theBED_OFFCTGcontig-presence check and thestop < startERRORstay with the caller.contains()keeps both binary searches and delegates, so it returns exactly what it did before.The two
BED_OUTSIDEcases now run the searches before short-circuiting, which is two extralog nprobes on a variant that was going to be discarded.Why this is not optional. #47 adds a second way of locating a variant — a monotonic cursor
instead of a binary search. If that cursor reimplemented
INSIDE/BORDER/OUTSIDEalongsidecontains(), there would be two implementations of one rule, free to drift, and thecontains()copy is the one the current tests pin. There is now one decision tree with two ways of reaching it.
Drop the
g.bed_existsguardcontains()opened withif (!g.bed_exists) return BED_INSIDE;— a method on onebedDataconsulting a global flag about a different
bedData. For a stratification region set that isoutright wrong: membership would depend on whether
-bwas supplied. The check moves to the sole-bcall site inparse_variants():This also makes
contains()unit-testable without global setup — the nineBedContainscases nolonger carry
g.bed_exists = truescaffolding.Add
normalize()and a lenient constructornormalize()recomputessizefrom the merged intervals — bases covered by two input intervalswere counted twice by
add()— warns when the regions were not already sorted, and at verbositycheck()still runs either way, afternormalize()rather than instead of it. Normalizingdoes not weaken the validation, because disorder and overlap are the only malformations it
repairs: a flipped or zero-length interval is left exactly as it was found and is still fatal.
-bpassesnormalize = falseand so behaves exactly as it does ondev— strict validation ofan evaluation region whose malformation would silently change every denominator. Normalizing is
for the third-party region sets we neither author nor control.
Inert in this PR: no caller passes
trueyet. Normalizing is not tidiness —contains()'stwo binary searches require sorted, non-overlapping intervals, so an unnormalized region set
would return wrong answers rather than merely being untidy.
Testing
21 cases added, one retargeted, none of the existing
contains()assertions changed.BedNormalize.AlreadyMergedUnchanged/SortsUnsortedBedNormalize.CombinesOverlapping/CombinesAdjacentsizestops double-countingBedNormalize.AbsorbsNested/CollapsesDuplicatesBedNormalize.SingleIntervalUnchanged/EmptyBedUnchangedBedNormalize.MergesEachContigSeparatelyBedNormalize.MergedRegionsAreQueryablecontains()agrees with the normalized layout, which is what it assumesBedNormalize.WarnsWhenUnsortedBedNormalize.ReportsCoalescedCountBedNormalize.SilentWhenAlreadyNormalizedBedCtor.NormalizedOverlapPassesCheckBedCtor.RunsCheckrejects is accepted once normalized, with the check still running and stderr emptyBedCtor.NormalizeStillRunsCheckBedCtor.RecordsFilenameBedClassify.AgreesWithContainsSUBandINS, classifies the same through both entry pointsBedClassify.BeforeAllOutsideNotBorder/AfterAllOutsideNotBorderParseVariants.NoBedEverythingInsideBedContains.NoBedInside: with no-b, a variant is kept asBED_INSIDEeven thoughg.bedholds regions that exclude itThe extraction's guard is that
contains()'s existing return values are unchanged. To confirm thetests can see a break,
classify()'s early returns were reordered after the index tests: four casesfail, two of them pre-existing (
BedContains.BeforeAllOutside,BedContains.AfterAllOutside).Verification
make cleanin both build trees.pytest(integration + unit workflow): 124 passed. No integration case was added — nothinguser-visible changed.
-Wall -Wextra -Werror=missing-field-initializers;doxygen src/Doxyfileemits no warnings.dev: all eight count-bearing outputs byte-identical, zero variantsreclassified.
Part of #47. Resolves #231.