Skip to content

fix(dist): clamp the graph's reference window at a contig end (#189) - #201

Merged
TimD1 merged 1 commit into
devfrom
189_td_test-var-at-ctg-end
Aug 5, 2026
Merged

fix(dist): clamp the graph's reference window at a contig end (#189)#201
TimD1 merged 1 commit into
devfrom
189_td_test-var-at-ctg-end

Conversation

@TimD1-bot

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.

What

The alignment graph's trailing node took its sequence from a substr that clamps at the contig end, but its end coordinate from an unclamped ref_end + 1. At a contig end the two disagreed. On a 10 bp contig with a SNP on the final base, the trailing node spanned [2, 4) while holding zero bases, and graph->ref was two characters that the coordinate 4 overran.

Clamp the window once, in win_end, and use it for the trailing query node, the trailing truth node, this->truth, and this->ref. Every node's coordinate span now equals the length of the sequence it holds, with zero-width insertion variant and bypass nodes the deliberate exception. At a contig end the trailing node becomes a zero-width sink -- the mirror of the zero-width entry node #177 added at position 0.

Why this is behavior-neutral

Nothing reads the trailing node's end coordinate:

  1. Both alignment endpoints are built from sequence lengths, not coordinates -- src/dist.cpp:178-180 and src/dist.cpp:305-307 use qseqs[qnodes-1].length()-1. Edit distances go through graph->ref/graph->truth (src/dist.cpp:309-310, src/dist.cpp:361-362), which were already substr-clamped.
  2. The trailing node is the sink, so its end coordinate is never matched against any node's begin coordinate -- edge wiring needs qends[n2] == qbegs[n1], and no node begins after the last one.
  3. The insertion-leap rule additionally requires tidxs >= 0 || tskips >= 0 (src/dist.cpp:1066-1069), which the trailing node never satisfies, so its width is irrelevant there.

Its sequence was already empty at a contig end, so the endpoint cell was already the sentinel. The clamp changes the coordinate and nothing else.

Verification

  • chr20 A/B: byte-identical across every scored output file (precision-recall{,-summary}.tsv, query.tsv, truth.tsv, summary.vcf, switchflips.tsv, phase-blocks.tsv, phasing-summary.tsv, genotype-errors.tsv). Only runtime.tsv and stderr timings differ.
  • Scope of that evidence, stated precisely: the fixture's last variant is at 64,285,790 on a 64,444,167 bp contig, and the BED caps evaluation at 55,000,000 -- so the clamp never fires on chr20. The A/B covers the loop restructure being neutral mid-contig; the clamp path itself is covered by the new tests, not by chr20.
  • Full suite: 93 pytest workflows, 584 unit tests, all passing.

The retired TODO

The TODO asked why the +1 is needed "compared to generate_ptrs_strs()". That function was removed in 232036e, and read from that commit's parent it reached exactly as far, via for (int ref_pos = beg_pos; ref_pos <= end_pos; ) and ref_end = end_pos+1. There was never an asymmetry between them to explain.

The remaining +1 is the window's right flank and is left alone -- unlike the coordinate, the trailing node's sequence is read, so shortening it would be a scoring change. That asymmetry (one base of left flank, two on the right) is out of scope here.

Tests

Mirroring #177's coverage:

  • 5 GraphCtor tests over a variant on a contig's final base and on the second-to-last. All five fail without the clamp -- verified by reverting src/dist.cpp and rebuilding.
  • 3 PrecRecall tests pinning TP, FN, and FP on the final base. These pass with and without the clamp, which is the issue's own point that labeling was already correct -- they close the coverage gap rather than guard the fix.
  • A contig_end_snp integration scenario placing a SNP on the final base of sc1 (with a POS 350 mid-contig control), which does exercise the clamp end-to-end.

Closes #189

The alignment graph's trailing node took its sequence from a substr that clamps
at the contig end, but its end coordinate from an unclamped ref_end + 1. At a
contig end the two disagreed: on a 10 bp contig with a SNP on the final base the
trailing node spanned [2, 4) while holding zero bases, and graph->ref was two
characters that the coordinate 4 overran.

Clamp the window once, in win_end, and use it for the trailing query node, the
trailing truth node, this->truth and this->ref. Every node's coordinate span now
equals the length of the sequence it holds, with zero-width insertion variant and
bypass nodes the deliberate exception. At a contig end the trailing node becomes a
zero-width sink, the mirror of the zero-width entry node #177 added at position 0.

No behavior change: nothing reads the trailing node's end coordinate. Both
alignment endpoints are built from qseqs/tseqs lengths rather than coordinates,
edit distances go through the already-clamped graph->ref and graph->truth, the
trailing node is the sink so its end coordinate is never matched against any
node's begin coordinate, and the insertion-leap rule additionally requires
tidxs >= 0 || tskips >= 0, which the trailing node never satisfies. Its sequence
was already empty at a contig end, so the endpoint cell was already the sentinel.
A chr20 before/after run is byte-identical across every scored output file, which
covers the loop restructure; the fixture's last variant sits 158 kb from the
contig end, so the clamp itself is covered by the new tests rather than by it.

Retire the TODO asking why the +1 is needed "compared to generate_ptrs_strs()".
That function was removed in 232036e and reached exactly as far, via
`for (int ref_pos = beg_pos; ref_pos <= end_pos; )` and `ref_end = end_pos+1`, so
there was never an asymmetry between them to explain. The remaining +1 is the
window's right flank and is left alone: unlike the coordinate, the trailing node's
sequence is read, so shortening it would be a scoring change.

Add the coverage the issue asks for, mirroring #177's: five GraphCtor tests over a
variant on a contig's final base and on the second-to-last (all five fail without
the clamp), three PrecRecall tests pinning TP, FN and FP there, and a
contig_end_snp integration scenario placing a SNP on the final base of sc1.

Refs #189
@TimD1
TimD1 merged commit 9fe0c3f into dev Aug 5, 2026
1 check passed
@TimD1
TimD1 deleted the 189_td_test-var-at-ctg-end branch August 5, 2026 19:36
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