Skip to content

CLUSTER and MERGE split contained intervals into separate clusters — Closes #214#215

Merged
conradbzura merged 3 commits into
releasefrom
214-cluster-merge-containment
Jul 9, 2026
Merged

CLUSTER and MERGE split contained intervals into separate clusters — Closes #214#215
conradbzura merged 3 commits into
releasefrom
214-cluster-merge-containment

Conversation

@conradbzura

Copy link
Copy Markdown
Collaborator

Summary

CLUSTER and MERGE (which composes on CLUSTER) produced incorrect results when an interval is fully contained within an earlier, wider interval. They decided cluster boundaries with CASE WHEN LAG("end") OVER (...) >= "start" — comparing each interval to the immediately preceding row's end rather than the running maximum end of the cluster so far. When a wide interval contains a later narrower one, a subsequent interval that still overlaps the wide one (but not the narrow predecessor) was spuriously split into a new cluster.

For [0,100),[10,20),[30,40), GIQL MERGE returned [0,100),[30,40) (wrong) vs the bedtools/bioframe oracle's [0,100). On a 1e6 interval set MERGE returned 847,897 regions vs the oracle's 844,107 (~0.5% wrongly split). Contained intervals are common in real genomic data (genes over exons, broad peaks over narrow sub-peaks).

The fix keys the boundary flag off the running maximum end of the preceding rows — MAX("end") OVER (PARTITION BY chrom ORDER BY start ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) — the cluster's true right edge so far. The distance offset, first-row NULL behavior, and the separate PREV() predecessor-reference predicate are unchanged; non-containment inputs are unaffected (the running max equals LAG when nothing is contained). Verified: containment now yields the single correct region, and the 1e6 set returns exactly 844,107 (oracle match), at similar/better runtime (0.26s vs 0.43s).

Discovered by the benchmark suite via a row-count discrepancy against the bedtools/bioframe oracle.

Closes #214

Proposed changes

  • fixsrc/giql/expanders/cluster.py: the adjacency-flag window becomes a running-max MAX("end") framed window instead of LAG("end"); docstring updated.
  • test — Update the transpilation assertions (test_cluster.py, test_genomic_columns.py, test_cluster_predicate_transpilation.py, test_expander.py) from the LAG shape to the MAX(...) OVER (... ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) shape, and add containment cases to the bedtools CLUSTER and MERGE oracle suites.

Test cases

# Test Suite Given When Then Coverage Target
1 bedtools test_merge A wide interval containing a later narrower one that ends before a third still-contained interval MERGE is executed Output matches the single bedtools merge region Containment merge
2 bedtools test_cluster Same containment shape CLUSTER is executed All three share one cluster id, matching the single bedtools region Containment cluster
3 TestClusterExpander A CLUSTER with/without a distance Transpiled Emits the MAX("end") OVER (... ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING) boundary (with + distance when positive) Boundary window SQL

Full non-integration suite (1112 passed) and the bedtools CLUSTER/MERGE oracle (13 passed, incl. the two new containment cases) stay green.

https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy

CLUSTER (and MERGE, which composes on it) decided cluster boundaries with
LAG("end") -- the immediately preceding row's end -- so a later interval
contained within an earlier, wider one was spuriously split into a new
cluster once the preceding row ended early. Key the boundary off the
running maximum end of the preceding rows instead
(MAX("end") OVER (... ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING)),
which is the cluster's true right edge so far. The distance offset, the
first-row NULL behavior, and the separate PREV() predecessor-reference
predicate are unchanged; non-containment inputs are unaffected because the
running max equals LAG when no interval is contained.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
Update the transpilation assertions from the LAG("end") adjacency to the
running-max MAX("end") OVER (... ROWS BETWEEN UNBOUNDED PRECEDING AND 1
PRECEDING) form, and add containment cases to the bedtools CLUSTER and
MERGE oracle suites (a wide interval containing a later narrower one that
ends before a third still-contained interval) -- the shape the previous
LAG boundary got wrong.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
@conradbzura conradbzura self-assigned this Jul 9, 2026
…-max

Reword the predicate comment: adjacency now keys off the cluster's
running-max edge (not the immediate predecessor), and note the two notions
can reference different rows under containment while the predicate keeps
its documented immediate-predecessor semantics. Addresses review advisories.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
@conradbzura conradbzura marked this pull request as ready for review July 9, 2026 13:51
@conradbzura conradbzura merged commit 80b7bc5 into release Jul 9, 2026
3 checks passed
@conradbzura conradbzura linked an issue Jul 9, 2026 that may be closed by this pull request
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.

CLUSTER and MERGE split contained intervals into separate clusters

1 participant