Skip to content

Release v0.5#207

Merged
conradbzura merged 186 commits into
masterfrom
release
Jul 15, 2026
Merged

Release v0.5#207
conradbzura merged 186 commits into
masterfrom
release

Conversation

@conradbzura

Copy link
Copy Markdown
Collaborator

Auto-generated by the cut release workflow.

conradbzura and others added 30 commits May 18, 2026 13:05
DISJOIN is a FROM-clause table function that cuts each target interval
at every reference breakpoint strictly interior to it, so no resulting
sub-interval partially overlaps a reference interval. The target row
passes through unchanged and the sub-interval is appended as
disjoin_chrom, disjoin_start and disjoin_end. A coverage filter drops
sub-intervals overlapping no reference interval.

When no reference is given it defaults to the target set, so selecting
the distinct sub-intervals reproduces a Bioconductor-style disjoin.

The generator emits a portable WITH-CTE subquery using only UNION, LEAD
and EXISTS, so it transpiles unchanged to DuckDB, PostgreSQL and SQLite.
Sub-interval boundaries are canonicalized to 0-based half-open
regardless of the target or reference coordinate system.
Add DISJOIN to the MCP server operator metadata and documentation path
map so it is discoverable through list_operators, explain_operator and
search_docs. The list_operators count assertion is updated to match the
new operator total.
Add parsing tests for the GIQLDisjoin AST node, transpilation tests for
the generated SQL shape, end-to-end execution tests on DuckDB, and a
coordinate-space matrix asserting convention-invariant results across
all four coordinate-system and interval-type encodings.
Add a DISJOIN section to the aggregation operators reference and a new
disjoin recipe covering set partitioning and reference-grid splitting,
wired into the recipes index and toctree.
Add tests/usage_patterns.py, a catalogue of the query contexts an
operator appears in, and tests/test_usage_patterns.py, a functional
suite that transpiles every DISJOIN usage pattern, executes it against a
database-engine matrix (DuckDB now; SQLite and PostgreSQL are future
entries), and snapshots the result rows with pytest-manifest.

The catalogue is a per-operator-class descriptor framework. Only the
table-function class is populated -- DISJOIN, exercised across 21 usage
patterns in self- and reference-mode. AGGREGATE, PREDICATE, and SCALAR
are defined extension points. Three patterns GIQL cannot transpile (a
subquery, CTE, or nested operator in a table-function's target position)
are catalogued as strict xfail.

Add pytest-manifest as a dev dependency and register the usage marker.
DISJOIN bound only the first positional argument and silently discarded
any extras, so DISJOIN(features, refs) dropped the intended reference
set without warning. Unknown named arguments were passed through
unchecked as well.

from_arg_list now raises a ParseError naming the single-positional-arg
limit and rejecting any named argument outside the DISJOIN schema, so a
mistyped or misplaced reference fails loudly at parse time.
A bare DISJOIN reference name was matched against registered tables
first and, failing that, assumed to be a canonical CTE. A CTE sharing a
registered table's name therefore silently inherited that table's
coordinate system, and a name matching neither produced SQL referencing
a relation that does not exist.

Reference resolution now checks enclosing WITH clauses first, letting an
in-query CTE shadow a registered table the way SQL scoping does, and
raises a ValueError when a bare name matches neither a CTE nor a
registered table. Bare SELECT and UNION references are accepted
alongside Subquery nodes, and a reference using the reserved
__giql_dj_ prefix is rejected since that prefix names the operator's
internal CTEs.
The end-to-end DISJOIN suite ran against DuckDB only. Parametrize it
over a run fixture so every execution test also runs on in-memory
SQLite, skipping SQLite below 3.25 where the LEAD() window function
DISJOIN emits is unavailable.
DISJOIN was documented under the aggregation operators, but it
multiplies rows rather than aggregating them. Move its reference into a
new set-operators page, list the page in the dialect index and toctree,
and point the MCP operator catalog at the relocated doc.
Ground each recipe in a concrete genomics scenario -- pooled ChIP-seq
peak calls, ATAC-seq features against a callable mask, a fixed-width
binned coverage matrix -- and note that the uniform-grid recipe relies
on DuckDB-specific range() syntax and must span every chromosome
present in the input.
DISJOIN, NEAREST, MERGE, and CLUSTER each bound the target only when a
positional argument was present, so a call with no target -- such as
DISJOIN(reference := refs) or MERGE(stranded := true) -- built a node
with the target unset. The failure surfaced far downstream as the
misleading "Target table 'None' not found".

Each from_arg_list now raises a ParseError naming the missing target
the moment the call is parsed, so the diagnostic points at the real
problem.
DISJOIN names its internal CTEs with a __giql_dj_ prefix and already
rejects a reference relation that uses it. A target table with the
same prefix was unguarded and would emit a self-referential CTE that
fails with no actionable error.

giqldisjoin_sql now rejects a resolved target name carrying the
reserved prefix, symmetric with the existing reference check.
The DISJOIN SQL was assembled as one ~30-fragment string
concatenation, where a dropped inter-fragment space would silently
produce invalid SQL. Build each __giql_dj_ CTE as a named local and
join them once at the return, so every CTE reads as a discrete block.

The generated SQL is unchanged.
The DISJOIN transpilation and execution test docstrings used a
free-form GIVEN/WHEN/THEN prose block. Convert them to the structured
Given/When/Then form the test guide specifies and add the
Arrange-Act-Assert phase comments, matching test_disjoin_parsing.py.

Also add a transpilation test for the reserved-prefix target guard.
Drop the structural banner-comment rule lines that divide
usage_patterns.py into sections, keeping the explanatory prose. Make
_annotations delegate to _features_like rather than duplicate it.
Hoist the duckdb import in test_usage_patterns.py to module scope.
Note in the set-operators reference that disjoin_chrom, disjoin_start,
and disjoin_end are reserved output column names that collide with a
same-named target column. Trim the recipe's "Coming from Bedtools?"
section to drop analogies it does not deliver, and correct two section
underlines to their title lengths.
The usage-pattern snapshot suite depends on pytest-manifest, declared
in the dev dependency group but never mirrored into the pixi
environment that CI builds. CI therefore ran without the plugin and
every snapshot test errored with "fixture 'manifest' not found".

pytest-manifest has no conda-forge package, so add it under
tool.pixi.pypi-dependencies -- the pixi environment now installs it
from PyPI alongside the conda-managed test dependencies.
Add a DISJOIN section to the demo notebook covering self-mode and
reference-mode. Self-mode is shown twice: once partitioning the
features_a peak set at scale, and once on a small hand-built set of
overlapping intervals where every cut is traceable by eye. The
reference-mode example splits features_a at the breakpoints of
features_b. The summary now lists DISJOIN as the eighth operator.
In self-reference mode -- DISJOIN(features) with the reference omitted,
or DISJOIN(features, reference := features) where features is the same
registered table -- the outer-WHERE EXISTS coverage subquery is
provably always true. Every emitted segment lies inside its parent
target row, and that row is itself a member of the reference set, so
the semi-join filters nothing but still plans as a full equi+range
hash join.

Detect self-reference at the resolver and propagate a flag into the
SQL builder. When the flag is set, omit the AND EXISTS clause; the
other CTEs and the seg_end guards are untouched. Subquery references
and CTE-shadowed names stay conservative -- proving relation
equivalence for those is out of scope -- so EXISTS is retained.

Benchmarks in the original issue measure 1.30x speedup at 10K
intervals rising to 1.83x at 250K, with byte-identical row sets at
every input size.
Cover behaviors orthogonal to the test-first cases: composition with
non-default coordinate systems (1-based closed) on both self-mode and
explicit-self-reference paths; composition with custom genomic column
names; per-call independence inside a query that mixes self-mode and
distinct-reference DISJOIN calls; and CTE shadowing detected via the
ancestor walk past a nested subquery scope.

These tests pin the optimization's contract against future regressions
in adjacent code paths -- canonicalization, column resolution, the
enclosing-CTE search, and per-expression flag computation -- where
breakage would not surface in the basic truth-table tests.
End-to-end coverage through DuckDB for the optimization. The keystone
test asserts row-set equivalence between DISJOIN(features) and
DISJOIN(features, reference := features) across all four coordinate
conventions -- proving the two self-reference paths take the same
optimized branch at runtime.

The remaining tests guard the load-bearing EXISTS that the
optimization deliberately preserves: a partial-coverage distinct
reference (with a coverage gap in the target), the same scenario
under mixed target/reference encodings to exercise the canonical
shift inside the retained EXISTS body, a CTE-shadowed self-reference
where target and reference both resolve to the CTE, and a subquery
reference whose filter induces partial coverage. Each of these would
silently return extra rows if EXISTS were wrongly skipped on a
non-self path.
Replace the inline exists_clause string-with-leading-space ternary with
a where_clauses list joined by AND, mirroring the pattern already used
in giqlnearest_sql. The previous form was one missing space away from
emitting a syntactically broken WHERE; the list form removes that
fragility and makes the conditional EXISTS appendage explicit. The
emitted SQL is character-identical in both self-reference and
distinct-reference modes.

Also extend the leading comment to note that the __giql_dj_ref CTE
itself stays live in self-reference mode because the breakpoint CTE
still draws from it, preventing a future reader from trying to prune
the seemingly unused reference CTE, and tighten the
_resolve_disjoin_reference docstring to make the False semantics of
is_self_reference explicit for subquery and CTE references.
The nested-CTE-shadow transpilation test asserted only that EXISTS
appeared somewhere in the output. Tighten it to assert that exactly
one EXISTS clause appears, matching the assertion style of the
sibling mixed-mode test and ruling out a stray second EXISTS leaking
in from the outer scope.

Rename two integration tests on TestDisjoinCoordinateSpace from
should_yield_X to the declarative-present form already used by the
other five tests in the class and by every sibling coordinate-space
test file.
…eGIQLGenerator

The private static helper was placed before __init__, violating the
class-member ordering convention where __init__/__new__ precede private
methods. Move it next to the other private static helpers
(_enclosing_cte_names) where readers expect private utilities to live,
and tighten its docstring's exp.Boolean / exp.Literal references with
:class: role markup.
…ansformer

Two small drive-by fixes flagged during the PR #93 style review:

- The __init__ docstring referenced DEFAULT_BIN_SIZE as bare text;
  switch to :data:`~giql.constants.DEFAULT_BIN_SIZE` so the reference
  resolves to an anchor in generated docs.
- Five inline comments inside _build_pairs_replacement_joins
  ("Determine which INTERSECTS side maps to FROM vs JOIN table",
  "Ensure key-only bin CTEs exist", "Build and attach the pairs CTE",
  "join1: [SIDE] JOIN pairs ON from.key = pairs.from_key", and
  "join2: [SIDE] JOIN join_table ON join.key = pairs.join_key")
  restated what the next line of code did rather than explaining why.
  The four follow-on what-comments are dropped; the prefix-assignment
  block keeps a single why-comment that captures the symmetry
  invariant the code preserves.

No behavior change.
The canonical-start / canonical-end conversions and their inverses
lived inside BaseGIQLGenerator as private methods. With the DuckDB
IEJoin dialect transformer about to land — which also needs to emit
0-based half-open SQL regardless of how each source table declares its
coordinate system — the conversion logic needs to be shared across
both surfaces.

Lift canonical_start, canonical_end, decanonical_start, and
decanonical_end out of BaseGIQLGenerator into a new module-level
giql.canonical surface. Each function takes a raw column expression
plus a Table (or None) and wraps the expression with whatever offset
the table's (coordinate_system, interval_type) pair requires to reach
the canonical 0-based half-open form. The decanonical counterparts
apply the inverse offset so emitted endpoints land back in the table's
own encoding.

BaseGIQLGenerator now imports and delegates to the module-level
helpers. Behavior is unchanged for every existing call site.
…RSECTS

The binned equi-join plan that IntersectsBinnedJoinTransformer emits
generalises across every SQL backend, but on DuckDB it loses to a
per-chromosome dynamic-SQL plan that lets DuckDB pick from its range-
join family (IE_JOIN / PIECEWISE_MERGE_JOIN). The new transformer
emits a SET VARIABLE block whose value is a per-chromosome UNION ALL
of pure inequality-predicate subqueries, then selects through
query(getvariable(...)). This avoids the cross-chromosome candidate-
pair materialization that hurts a global IEJoin and the UNNEST /
DISTINCT overhead that hurts the binned plan.

The transformer handles INNER, SEMI, and ANTI joins with exactly one
column-to-column INTERSECTS predicate plus a broad set of common
decorations: ORDER BY / LIMIT / OFFSET / DISTINCT on the outer SELECT,
GROUP BY / HAVING with aggregate functions (COUNT, SUM, MIN, MAX, AVG,
COUNT(DISTINCT), and any exp.AggFunc subclass), and extra non-
INTERSECTS predicates ANDed onto the join ON or WHERE (both single-
side and cross-side, including BETWEEN, IN (literal-list), LIKE, IS
NULL). Unsupported shapes — outer-join INTERSECTS, self-joins, multi-
INTERSECTS queries, three-or-more-table FROM clauses, top-level
WITH / CTE clauses, DISTINCT ON, OR/NOT/paren-wrapped extras,
unqualified-column extras, extras containing subqueries / aggregates /
window functions, and subqueries inside GROUP BY / HAVING / ORDER BY —
fall back to the binned plan so callers can opt in unconditionally
without hitting silent correctness regressions.

Hard-error projection shapes (bare SELECT *, unqualified columns,
expression projections, unknown table qualifiers, a.* AS x, window
aggregates, FILTER clauses, arithmetic-over-aggregate, scalar
subqueries) raise ValueError with a dedicated message naming the
offending form. Empty chromosome intersections route through a
COALESCE empty-schema fallback so DuckDB receives a typed empty
result instead of a runtime NULL crash. Chromosome literals are
escaped manually because DuckDB has no quote_literal.

Five INTERSECTS-walking helpers (_is_column_intersects,
_find_column_intersects_in, _has_outer_join_intersects,
_strip_intersects, _count_column_intersects) move out of
IntersectsBinnedJoinTransformer to module scope so both transformers
reuse one definition.
conradbzura and others added 28 commits July 7, 2026 13:37
…cate

Column-to-column INTERSECTS joins on the generic and DataFusion targets now
emit the naive overlap predicate (a.chrom = b.chrom AND a.start < b.end AND
b.start < a.end) as a plain ON/WHERE condition the engine plans as a range
join, promoting the existing residual `_column_join` expander from fallback to
the sole generic join plan. The generic binned equi-join transformer
(`IntersectsBinnedJoinTransformer`), the `intersects_bin_size` parameter, and
the `DEFAULT_BIN_SIZE` public constant are removed (breaking change).

The naive predicate is correct for INNER and OUTER (LEFT/RIGHT/FULL) joins with
no restructuring — verified on DuckDB and DataFusion, which both hash-partition
on `chrom` with the position inequalities as a residual join filter (no
nested-loop degradation). The DuckDB IEJoin transformer is unchanged; the shapes
it declines now fall through to the naive predicate instead of the binned plan,
so outer joins, self-joins, multiple INTERSECTS, extra predicates, non-base
operands, and 3+ tables all get a correct plan. `range_join_strategy` gains the
value "naive" (generic/datafusion) alongside "iejoin" (duckdb).

Closes #167

Claude-Session: https://claude.ai/code/session_01KAYsMtN7vYuECZHeeFFzCM
Rename test_binned_join.py -> test_intersects_join.py and retarget it to the
naive overlap predicate: structure tests assert the predicate is emitted with no
CTE / UNNEST / bins, while the execution, outer-join, and bag-semantics tests
(unchanged in behavior) now exercise the naive predicate. Remove the
`intersects_bin_size` tests across test_transpile.py, test_duckdb_iejoin.py, and
test_intersects.py; retarget the IEJoin fallback-test terminology from "binned
plan" to "naive-predicate plan"; flip the `range_join_strategy` fixtures to
"naive"; drop the `DEFAULT_BIN_SIZE` package-API reference; and de-stale the
serialization / resolver pipeline comments.

Add a cross-target LEFT JOIN oracle case proving outer-join result identity
across generic / datafusion / duckdb, and an ANTI-join parity test now that the
naive predicate returns correct results for the empty-right-table case the old
binned plan dropped.

Claude-Session: https://claude.ai/code/session_01KAYsMtN7vYuECZHeeFFzCM
…verride

Relocate the DuckDB IEJoin column-to-column INTERSECTS join rewrite from the
capability-gated pre-pass transformer (giql.transformer) into a registered
(DuckDBTarget, Intersects) override expander (giql.expanders.intersects_duckdb),
mirroring the CLUSTER / MERGE relocation (#144). INTERSECTS join strategy is now
fully registry-driven: the (GenericTarget, Intersects) expander emits the naive
overlap predicate on every target, and DuckDB's override supersedes it for the
shapes the IEJoin path supports, deferring back to the same naive predicate for
every shape it declines (LEFT/RIGHT/FULL, self-joins, multiple INTERSECTS, extra
predicates, non-base operands, 3+ tables) and for literal-range / residual
predicates.

The IEJoin rewrite emits a whole-query multi-statement string, so the override
produces it through the ExpansionContext.add_statement_finalizer seam, wrapping
the built SQL in an exp.Command that serializes verbatim. A column-to-column
INTERSECTS survives to pass 3 structurally intact (pass 1 attaches metadata only;
pass 2 synthesizes no CTE for its column slots), so the override rebuilds the
join from the same raw AST the former pre-pass read — output is byte-identical
for every supported shape.

- Remove the uses_iejoin / range_join_strategy capability branch from transpile;
  dispatch flows through the registry's resolve() fallback chain in pass 3.
- Drop the now-vestigial range_join_strategy field from Capabilities (the
  (DuckDBTarget, Intersects) registration is the single source of truth).
- git mv transformer.py -> expanders/intersects_duckdb.py; the module now also
  holds the registered expand_intersects_duckdb entry point.
- has_override is retained as registry introspection but no longer gates a
  pre-pass (there is none); its docstrings and the CLUSTER/MERGE cross-references
  to the removed giql.transformer module are updated.

Claude-Session: https://claude.ai/code/session_01KAYsMtN7vYuECZHeeFFzCM
…_strategy

Update the tests for the #169 migration:

- Add TestIEJoinRegistryDeferral::test_registry_should_resolve_duckdb_intersects_
  to_iejoin_override, asserting the built-in (DuckDBTarget, Intersects) override
  is registered and resolves to expand_intersects_duckdb (the migrated pass-3
  join strategy).
- Reframe the two existing deferral tests for the new model: the built-in IEJoin
  is a registered override, and a user (DuckDBTarget, Intersects) registration
  replaces it via last-write-wins rather than skipping a pre-pass. Assertions are
  unchanged (byte-identical output), only the mechanism they document.
- Drop range_join_strategy from every Capabilities construction / assertion in
  test_targets.py and test_expander.py (the field was removed); the
  equality-inequality test now differs on supports_qualify instead.
- Refresh the stale test_serialization helper docstring (no pre-pass join rewrite
  remains).

The DuckDB IEJoin behavioral oracle (tests/test_duckdb_iejoin.py, executed
against real DuckDB) passes unchanged, confirming the refactor preserves output.

Claude-Session: https://claude.ai/code/session_01KAYsMtN7vYuECZHeeFFzCM
The DuckDB IEJoin override collected JOIN-ON and top-level WHERE residuals
into a single list and appended all of them to each per-chromosome join ON.
That is sound for INNER (ON overlap WHERE r == ON overlap AND r) and for the
equivalent SEMI case, but for ANTI it inverts the anti-join: a row failing the
residual r satisfies NOT EXISTS b (overlap AND r) unconditionally, so rows that
should be filtered out are instead resurrected (#200).

Split the residuals by provenance in _extract_extra_predicates — ON residuals
(genuine join conditions, always inlined for every kind) versus WHERE residuals
(post-join filters). For the left-only shapes (SEMI / ANTI) the WHERE residuals
are now layered as an outer WHERE on the wrapper relation instead of folded into
the per-chromosome ON, reusing the existing wrapper-relation rewriter and
pre-allocating their columns into the wrapper projection list so a filter can
reference a column absent from the SELECT list. A right-side reference in such a
residual raises the same left-only ValueError the SELECT list already enforces.
INNER keeps inlining WHERE residuals (byte-identical output, provably equivalent).

Closes #200

Claude-Session: https://claude.ai/code/session_01KAYsMtN7vYuECZHeeFFzCM
Adds regression coverage for #200: an ANTI join with a WHERE residual matches
the naive plan (was resurrecting the anti-excluded rows below the threshold),
a residual can filter on a column absent from the SELECT list, the SEMI case
stays consistent, and a right-side residual raises. A structural test asserts
the residual lands in the outer wrapper WHERE and never in the per-chromosome
ON template, and a Hypothesis property test cross-checks ANTI+WHERE against the
naive plan over random inputs.

Claude-Session: https://claude.ai/code/session_01KAYsMtN7vYuECZHeeFFzCM
…DuckDBTarget, Intersects) override expander, and fix SEMI/ANTI WHERE-residual inlining — Closes #169, #200 (#199)
…plan

Route four cross-backend inconsistencies in the DuckDB IEJoin INTERSECTS
override to the naive-predicate plan so dialect="duckdb" stays consistent
with dialect=None / "datafusion" for any shape the IEJoin cannot faithfully
rebuild but the naive plan handles. Declining is cheap: the naive overlap
predicate still plans as a chromosome-keyed hash/range join (#167).

- #201: a SEMI/ANTI join whose column-to-column INTERSECTS sits in the
  top-level WHERE references the out-of-scope right table; the WHERE-branch
  relocated it into the join ON and invented anti/semi-overlap results. New
  guard _has_left_only_join_where_intersects declines so the naive plan
  surfaces the same binder error the reference plans raise.

- #202: star projections (a.* / b.* / bare *) expanded to only the configured
  genomic columns, dropping user columns. New guard _has_star_projection
  declines every star so DuckDB expands the real star against the live schema.

- #204, #205: projections the naive plan compiles but the IEJoin projection
  rebuild cannot express — expressions (a.start + 1), window aggregates,
  FILTER clauses, scalar subqueries, aggregates nested in expressions
  (COUNT(*) * 2), and stars nested in an aggregate argument (COUNT(a.*),
  MIN(COLUMNS(*))) — now decline via a projection pre-scan
  (_projection_declines_to_naive) instead of hard-erroring (#205) or
  miscompiling (#204). A projection referencing an out-of-scope column
  (unqualified / unknown-table / right-side) still raises a clean ValueError,
  since the naive plan rejects it too. The now-redundant specialized
  diagnostic branches (window / FILTER / scalar-subquery / arithmetic-over-
  aggregate messages) collapse into the generic qualified-projection error —
  the accurate guidance for the only cases that still reach it (qualify the
  column and the wrapper itself declines).

Docs (performance.rst): the unsupported-but-naive-valid projection shapes
move into the soft-fallback list; the corresponding hard-error entries are
removed.

Claude-Session: https://claude.ai/code/session_01KAYsMtN7vYuECZHeeFFzCM
Repoint the tests that pinned the old buggy / hard-error behavior to assert
the corrected decline-to-naive behavior, and add regression classes for each
declined shape.

- #201 / #202 (from the first round): decline across ON TRUE/FALSE/score and
  SEMI/ANTI with binder-error parity; decline across every star form with
  full-column parity to the naive plan; idiomatic-form / explicit-column
  engage guards; execution parity for SEMI star, a.* AS x, b.* and c.*.

- #204 / #205: convert six obsolete hard-error tests (window aggregate,
  paren-wrapped window, arithmetic-over-aggregate, scalar subquery,
  expression form, literal) to decline tests, and add
  TestTranspileDuckDBIEJoinUnsupportedProjectionFallback — a parametrized
  decline check over every unsupported-but-naive-valid projection, execution
  parity for COUNT(a.*) / MIN(COLUMNS(*)) / a.start + 1 / SUM(a.score) OVER (),
  a keep-raise test for an out-of-scope column inside an unsupported wrapper,
  and a plain-aggregate-still-engages guard.

Claude-Session: https://claude.ai/code/session_01KAYsMtN7vYuECZHeeFFzCM
DuckDB selects its IE_JOIN range-join operator only for INNER
pure-inequality joins. The per-chromosome dynamic SQL for SEMI and ANTI
INTERSECTS joins emitted a bare SEMI JOIN / ANTI JOIN, which DuckDB
planned as a quadratic BLOCKWISE_NL_JOIN (a nested loop) rather than the
range-join fast path -- roughly 80x slower at a million intervals per
side.

Emit the left-only shapes as a correlated WHERE EXISTS (SEMI) /
WHERE NOT EXISTS (ANTI) subquery over the per-chromosome right partition
instead. Both reach IE_JOIN while preserving exact semantics -- one row
per qualifying left row, with no dedup-on-projection hazard. The INNER
path, chromosome partitioning, empty-schema fallback, and WHERE-residual
outer filter are unchanged.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
Update the SQL-shape assertions to expect the correlated EXISTS /
NOT EXISTS form instead of the SEMI JOIN / ANTI JOIN keyword, add an
ANTI shape test, and add execution guards that EXPLAIN the generated
per-chromosome SQL and assert it is not planned as a BLOCKWISE_NL_JOIN.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
Correct the DuckDB IEJoin section: SEMI and ANTI no longer plan as
BLOCKWISE_NL_JOIN; they are emitted as correlated EXISTS / NOT EXISTS
subqueries that reach the IE_JOIN range-join fast path.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
Three Hypothesis property tests cross-checked the duckdb SEMI/ANTI plan
against the dialect=None naive plan. DuckDB 1.4.3 miscompiles the naive
plan's bare SEMI JOIN / ANTI JOIN inequality overlap for inputs with
duplicate right rows, so once the duckdb plan became correct these tests
diverged from their now-unsound oracle and failed. Compare against the
Python overlap reference (_python_semi_overlap / _python_anti_overlap,
with the WHERE residual applied) instead -- ground truth on every DuckDB
version -- and rename the tests to reflect the reference oracle.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
The idiomatic per-interval overlap count -- SELECT a.cols, COUNT(b.col)
FROM a LEFT JOIN b ON a.interval INTERSECTS b.interval GROUP BY a.cols --
declined to the naive plan like every outer join, becoming a HASH_JOIN on
the low-cardinality chromosome key with the position inequalities as a
residual filter (quadratic; ~180x slower than achievable at scale).

Detect this shape and reuse the INNER IEJoin path (which already plans
COUNT + GROUP BY through IE_JOIN), then wrap its counts in a zero-fill
LEFT join back onto the distinct left keys so left intervals with no
overlap report 0. Supports a single COUNT(<right column>) /
COUNT(DISTINCT <right column>) with a GROUP BY on the projected left
columns; COUNT(*), non-COUNT aggregates, ORDER BY / LIMIT / HAVING, and
other outer-join shapes still take the naive-predicate plan.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
Add shape, decline (COUNT(*), non-COUNT aggregate, no GROUP BY, ORDER BY,
self-join), a Hypothesis property test against a Python count_overlaps
reference (zero-overlap rows, one-sided chromosomes, empty right table,
duplicate left rows), and an EXPLAIN plan guard asserting the generated
inner SQL is not planned as a BLOCKWISE_NL_JOIN.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
Describe the LEFT-join COUNT over INTERSECTS routing through the INNER
IE_JOIN count plus a zero-fill LEFT join, and the shapes that remain on
the naive-predicate plan.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
Tighten the count_overlaps detection gate for two shapes a review found
producing wrong results on matched queries: a GROUP BY carrying keys
beyond the projected columns (the extra key inflates the count cardinality
and drops zero-count rows the zero-fill base cannot reproduce), and a
projected key whose output name collides with the COUNT alias (the
zero-fill USING / COALESCE binds to the wrong duplicately-named column).
Require the group keys to equal the projected keys exactly and all output
names to be distinct, declining to the naive plan otherwise.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
Add decline tests for a GROUP BY exceeding the projection, an output-name
collision between a key and the COUNT alias, and a top-level WHERE, plus a
COUNT(DISTINCT b.col) execution test asserting equivalence with the naive
plan.

Claude-Session: 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
…-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
…red helper

The DuckDB per-chromosome dynamic-SQL machinery -- the chromosome
string-literal interpolation, the collision-resistant session-variable
name, the SET VARIABLE ... = COALESCE((string_agg per chromosome), empty)
builder, and the query(getvariable(...)) relation reference -- moves from
IntersectsDuckDBIEJoinTransformer into a new giql.expanders._per_chrom
module. The operator-specific parts (the per-chromosome SQL template, the
partition source, the empty-schema fallback, and the outer projection)
stay in the expander. Emitted SQL is byte-identical (no behavior change),
readying the scaffolding for reuse by DISJOIN (#216) and NEAREST.

Claude-Session: https://claude.ai/code/session_01TERWBHov76DQM3nQeT8yyy
@conradbzura conradbzura merged commit 04fa4f1 into master Jul 15, 2026
3 of 5 checks passed
@conradbzura conradbzura deleted the release branch July 15, 2026 02:55
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.

1 participant