Skip to content

Commit 15e9bc1

Browse files
committed
Merge phase24-null-aware-anti: null-aware anti join — NOT IN decorrelation arc complete
2 parents 192e449 + 21aa9f5 commit 15e9bc1

21 files changed

Lines changed: 1810 additions & 163 deletions

‎ARCHITECTURE.md‎

Lines changed: 114 additions & 11 deletions
Large diffs are not rendered by default.

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
21. Complete subquery arc. Delivered across Phase 21a/21b/21c: scalar-comparison, IN/NOT IN, and EXISTS/NOT EXISTS use immutable bound subplans with explicit lexical-depth correlation metadata. Empty correlation retains the eager once-per-query 3VL/cardinality model; correlated forms use the per-outer-row interpreted specification with nested/grandparent frames and row-ordered scalar errors. Explicit left SEMI/ANTI algebra has left-only output identity, deterministic interpreted/vectorized hash/NLJ execution, memo identity/printers/EXPLAIN/costing, and conservative guards. Uncorrelated and immediate equality-correlated top-level EXISTS, NOT EXISTS, and IN decorrelate through proof-bearing memo rules. Residual correlated forms remain oracle-defined and fail once at physical lowering; uncorrelated residuals still run vectorized recursively. Exact EXPLAIN goldens, eager/per-row error equivalence, and deterministic uncorrelated/correlated fuzz lanes close the story.
2424
22. Window functions arc. Delivered across Phase 22a/22b: whole-SELECT-item `ROW_NUMBER`, `RANK`, and `DENSE_RANK` plus whole-partition `SUM`, `COUNT`, `MIN`, and `MAX` parse and bind into one post-HAVING Window node; both interpreted and vectorized execution implement first-appearance partitioning, input-order preservation, stable ranking ties, NULL partition/peer equality, checked/NULL-skipping partition aggregates, and replicated results; Window participates in physical lowering, memo identity, EXPLAIN, extraction, conservative costing, golden coverage, deterministic fuzzing, and a Release benchmark workload while remaining a filter-pushdown barrier, with ROW_NUMBER and whole-partition checked SUM pinning order-changing child transforms because stable ties and intermediate overflow expose input order.
2525
23. Running window frames. Delivered in Phase 23: aggregate OVER ORDER BY defaults to `RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW`, and the same explicit RANGE spelling plus `ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW` bind as distinct frame modes. RANGE publishes one result through each complete peer group and canonically orders checked SUM arguments within peers, making output and overflow category independent of incidental tie order. ROWS publishes each stable input-order prefix and pins order-changing child transforms; a checked Aggregate SUM below any Window pins independently. The oracle, vectorized operator, memo alternatives, deterministic fuzzer, goldens, and standalone benchmark cover the new paths while whole-partition behavior remains unchanged. Every other frame bound or modifier fails as a positioned unsupported frame.
26-
24. Null-aware anti join (future). Extend algebra and execution before considering NOT IN decorrelation; ordinary Anti remains intentionally insufficient for a NULL-bearing right set, where unmatched NOT IN is UNKNOWN rather than TRUE.
26+
24. NULL-aware anti join. Delivered in Phase 24: explicit `NullAwareAnti` algebra separates equality-correlation candidate predicates from one structural membership equality and preserves left-only identity plus left-row-major output. The interpreted candidate-set oracle and vectorized grouped hash/NLJ kernels coincide with `NOT IN` 3VL for empty, NULL-bearing, NULL-left, match, and miss cases while ordinary Anti remains byte-identical. Proof-bearing memo rules add uncorrelated and immediate equality-correlated NOT IN alternatives without removing the materialized path; memo identity, EXPLAIN, deterministic costing, transform guards, checked-SUM order pins, trap-matrix goldens, 160-seed fuzz floors, and an optimized checksum-before-timing benchmark complete the arc.
2727

2828
## Phase 1 first tasks
2929

‎benchmarks/sql_bench.cpp‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,22 @@ plan::LogicalPlan bind_decorrelated_semi_query(const execution::Catalog& catalog
324324
throw std::logic_error("benchmark IN query did not produce a SemiJoin alternative");
325325
}
326326

327+
plan::LogicalPlan bind_best_null_aware_anti_query(const execution::Catalog& catalog,
328+
const std::string& sql) {
329+
const auto logical = bind_query(catalog, sql);
330+
optimizer::Memo memo;
331+
const auto root = memo.insert(logical);
332+
const auto explored = optimizer::explore_memo_to_fixpoint(memo, optimizer::default_memo_rules());
333+
if (!explored.reached_fixpoint) {
334+
throw std::logic_error("benchmark NULL-aware anti memo exploration did not reach fixpoint");
335+
}
336+
const auto best = memo.extract_best(root, catalog);
337+
if (plan::to_string(best).find("NullAwareAntiJoin[") == std::string::npos) {
338+
throw std::logic_error("benchmark NOT IN query did not choose its NullAwareAnti alternative");
339+
}
340+
return best;
341+
}
342+
327343
std::vector<Workload> make_workloads(const execution::Catalog& catalog) {
328344
std::vector<Workload> workloads;
329345
auto add = [&](std::string name, std::string rows, std::string sql) {
@@ -352,6 +368,16 @@ std::vector<Workload> make_workloads(const execution::Catalog& catalog) {
352368
sql,
353369
bind_decorrelated_semi_query(catalog, sql)});
354370
}
371+
{
372+
const std::string sql =
373+
"SELECT l.payload AS payload FROM join_left AS l WHERE l.k1 NOT IN ("
374+
"SELECT r.k1 FROM join_right AS r "
375+
"WHERE r.k2 = l.k2 AND r.payload < 10016)";
376+
workloads.push_back(Workload{"selective_not_in_null_aware_anti",
377+
"outer=100000,right_source=256,right_rows=16,survivors=93744",
378+
sql,
379+
bind_best_null_aware_anti_query(catalog, sql)});
380+
}
355381
add("aggregate_few_groups",
356382
"fact=200000,groups=8",
357383
"SELECT group_few, SUM(value) AS total, COUNT(*) AS n "

‎docs/benchmarks.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,23 @@ for `SUM(value) OVER (PARTITION BY bucket ORDER BY sort_key)` across the
260260
| workload | rows | correctness | interpreted min ms | interpreted median ms | vectorized min ms | vectorized median ms | median speedup |
261261
|---|---:|---|---:|---:|---:|---:|---:|
262262
| window_running_sum | fact=200000,partitions=100 | match rows=200000 checksum=0xefdfa729da68f560 | 704.578 | 711.366 | 102.685 | 105.883 | 6.718x |
263+
264+
## Phase 24 Selective NULL-Aware Anti Join (2026-07-16)
265+
266+
Same optimized build, five-repetition min/median, deterministic-data, and
267+
checksum-before-timing methodology as above. The SQL is a correlated `NOT IN`
268+
over the 100,000-row `join_left` table. Its equality correlation admits the
269+
Phase 24 rewrite, and the benchmark harness fails loudly unless memo costing
270+
chooses `NullAwareAntiJoin`. The right source has 256 rows; its local filter
271+
retains 16 membership values in one correlation bucket. Because the left key
272+
cycle ends partway through its final 256-row block, exactly 6,256 rows belong
273+
to that bucket and 93,744 survive.
274+
275+
| workload | rows | correctness | interpreted min ms | interpreted median ms | vectorized min ms | vectorized median ms | median speedup |
276+
|---|---:|---|---:|---:|---:|---:|---:|
277+
| selective_not_in_null_aware_anti | outer=100000,right_source=256,right_rows=16,survivors=93744 | match rows=93744 checksum=0x3aa4b8448334dc4d | 193.038 | 196.903 | 11.762 | 12.043 | 16.350x |
278+
279+
The checksum match is performed before timing and pins the left-only output
280+
and correlated candidate-set semantics. The vectorized grouped hash kernel is
281+
16.350x faster on median than the interpreted left-row-major semantic oracle
282+
for this selective workload.

‎include/optimizer/memo.hpp‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
#include <cstdint>
77
#include <cstddef>
8+
#include <optional>
89
#include <string>
910
#include <unordered_map>
1011
#include <utility>
@@ -29,6 +30,7 @@ struct MemoExpression {
2930
std::vector<plan::WindowExpression> window_expressions;
3031
std::vector<plan::SortKey> sort_keys;
3132
std::vector<plan::BoundPredicate> predicates;
33+
std::optional<plan::BoundPredicate> null_aware_predicate;
3234
plan::JoinKind join_kind{plan::JoinKind::Inner};
3335
std::size_t limit_count{0};
3436
std::vector<GroupId> children;

‎include/optimizer/rewrite.hpp‎

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,47 @@ class InToSemiJoinRule final : public MemoRule {
200200
[[nodiscard]] bool apply(Memo& memo, GroupId group, const MemoExpression& expression) const override;
201201
};
202202

203+
// Pattern matched: one whole top-level `x NOT IN (SELECT c FROM Q)` Filter
204+
// conjunct whose left operand is not a scalar subquery and whose bound right
205+
// plan is uncorrelated with exactly one typed final output.
206+
// Replacement expression: retain the materialized Filter expression and add
207+
// NullAwareAnti(input, Q) with an empty candidate-predicate list and the
208+
// structural membership equality `x = c`; residual top-level conjuncts remain
209+
// above the new join. If Q's projected identity collides with a left identity,
210+
// a deterministic `__not_in_value_N` Project alias makes the right side
211+
// explicit before constructing that equality.
212+
// Semantic equivalence: for each left row, the join candidate bag is exactly
213+
// Q. An empty Q emits, matching TRUE even for NULL x. For nonempty Q, NULL x
214+
// makes every equality UNKNOWN and suppresses. A TRUE equality suppresses as
215+
// NOT IN FALSE; absent a TRUE, any NULL c supplies UNKNOWN and suppresses; only
216+
// non-NULL x with every non-NULL c unequal yields all FALSE and emits. These
217+
// cases exhaust SQL 3VL. Right duplicates repeat an existing truth value and
218+
// cannot multiply the left-only result. The join preserves left row order and
219+
// executes the right plan once eagerly, so empty-left behavior and positioned
220+
// checked-expression errors coincide with materialization.
221+
// Preconditions/guards: only an entire Filter conjunct is inspected; leaves
222+
// below AND/OR, scalar-subquery operands, correlation, missing plans, or a
223+
// non-single-output bound shape do not rewrite. A sibling subquery predicate
224+
// or an embedded subquery in the left relational input also blocks the rule:
225+
// materialization observes Q's eager execution relative to those other eager
226+
// errors, and a native join cannot in general reproduce that owner ordering.
227+
// Binding has already pinned any checked SUM input, and memo ingest copies
228+
// every node's order permission.
229+
// Unsupported correlation stays materialized and physical lowering fails at
230+
// its deterministic source position. Each application removes one NOT IN leaf
231+
// from the added expression, never creates one, and structural deduplication
232+
// guarantees termination.
233+
// Golden query: `SELECT a FROM t WHERE a NOT IN (SELECT a FROM t1)` explores
234+
// both materialized and NullAwareAnti alternatives; the trap matrix separately
235+
// pins empty and NULL-bearing right sets.
236+
class NotInToNullAwareAntiJoinRule final : public MemoRule {
237+
public:
238+
[[nodiscard]] std::string_view name() const override {
239+
return "NotInToNullAwareAntiJoinRule";
240+
}
241+
[[nodiscard]] bool apply(Memo& memo, GroupId group, const MemoExpression& expression) const override;
242+
};
243+
203244
// Pattern matched: a top-level Filter EXISTS whose correlated subplan is
204245
// exactly Project(Filter(Q)) and every outer reference occurs in a whole,
205246
// top-level `outer_col = inner_col` WHERE conjunct.
@@ -247,6 +288,46 @@ class CorrelatedInToSemiJoinRule final : public MemoRule {
247288
[[nodiscard]] bool apply(Memo& memo, GroupId group, const MemoExpression& expression) const override;
248289
};
249290

291+
// Pattern matched: a whole top-level `x NOT IN (SELECT c ...)` conjunct whose
292+
// correlated subplan is exactly the Phase 21c Project(Filter(Scan|Join)) shape,
293+
// with every outer use covered by an immediate-parent/local column equality.
294+
// Replacement: remove the correlation WHERE equalities from a copied right
295+
// plan, append deterministic `__corr_key_N` projections, keep the resulting
296+
// outer-to-hidden equalities as candidate predicates, and store `x = c`
297+
// separately as the NullAwareAnti membership equality. A colliding selected
298+
// right identity receives the same deterministic hidden alias. The original
299+
// materialized Filter remains in its memo group.
300+
// Semantic equivalence: for a fixed outer row l, TRUE-only evaluation of the
301+
// extracted candidate equalities selects exactly the same inner bag C(l) as
302+
// substituting l into the original WHERE. A NULL on either correlation key
303+
// makes that equality UNKNOWN and therefore can make C(l) empty; it never acts
304+
// as a membership NULL. Applying the exhaustive uncorrelated NOT IN argument
305+
// above to each C(l) proves identical TRUE/FALSE/UNKNOWN survival for every
306+
// row, including empty per-key bags, NULL membership values, and NULL outer
307+
// correlation keys. Hidden columns do not escape the left-only join, right
308+
// duplicates do not multiply rows, and left-row-major order is unchanged.
309+
// Preconditions/guards: scalar-subquery operands; non-equality, nested,
310+
// transitive, projection, aggregate, HAVING, JOIN ON, AND/OR-contained, and
311+
// result-shaped correlation are rejected. Nested subqueries in the right plan
312+
// and sibling subquery predicates are also rejected because decorrelation
313+
// would change their eager/per-row error order. The accepted
314+
// Scan|Join/Filter/Project right slice is therefore pure and has no
315+
// runtime-error-producing aggregate; all copied order permissions remain
316+
// structural. Residual shapes stay materialized and fail physical lowering
317+
// with the positioned guard.
318+
// The replacement removes one eligible leaf and memo deduplication guarantees
319+
// termination.
320+
// Golden query: `SELECT b FROM t WHERE b NOT IN (SELECT t1.b FROM t1 WHERE
321+
// t1.a=t.a)` executes the original and every native alternative through both
322+
// engines.
323+
class CorrelatedNotInToNullAwareAntiJoinRule final : public MemoRule {
324+
public:
325+
[[nodiscard]] std::string_view name() const override {
326+
return "CorrelatedNotInToNullAwareAntiJoinRule";
327+
}
328+
[[nodiscard]] bool apply(Memo& memo, GroupId group, const MemoExpression& expression) const override;
329+
};
330+
250331
// Pattern matched: Filter(P, LeftJoin(L, R, J)) where at least one whole top-level conjunct in P
251332
// is provably null-rejecting for R's binding identities.
252333
// Replacement expression: Add Filter(P, InnerJoin(L, R, J)) as an equivalent expression in the
@@ -294,12 +375,14 @@ class LeftJoinToInnerRule final : public MemoRule {
294375
// NULL operands yield UNKNOWN and are rejected in either placement. Whole-tree reference analysis
295376
// prevents unsound OR/AND splitting.
296377
// Preconditions: The child join must be an INNER join, except that a whole
297-
// left-only conjunct may move into the preserved child of a SEMI/ANTI join.
298-
// This exception is sound because Semi/Anti output is a subset of left rows:
378+
// left-only conjunct may move into the preserved child of a SEMI, ANTI, or
379+
// NULL-aware ANTI join. This exception is sound because every preserved-left
380+
// join output is a subset of left rows:
299381
// filtering a left row before testing match existence keeps exactly the rows
300382
// that would pass the same predicate after the existence test. Right-only,
301383
// mixed-side, literal-only, and empty-reference conjuncts remain above a
302-
// SEMI/ANTI join; no predicate is moved into its match condition or right side.
384+
// preserved-left join; no predicate is moved into its candidate conditions,
385+
// NULL-aware membership equality, match condition, or right side.
303386
// LEFT joins are skipped because pushing a
304387
// predicate into the null-supplying side, or turning a post-join predicate into an ON predicate,
305388
// can preserve NULL-extended rows that the original WHERE would reject or reject rows the original

‎include/plan/logical_plan.hpp‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include <functional>
88
#include <memory>
99
#include <optional>
10+
#include <stdexcept>
1011
#include <string>
1112
#include <utility>
1213
#include <variant>
@@ -18,7 +19,7 @@ struct LogicalPlan;
1819

1920
enum class LogicalKind { Scan, Join, Filter, Project, Aggregate, Window, Distinct, Sort, Limit, Explain };
2021
enum class OrderPermission { Deterministic, Arbitrary };
21-
enum class JoinKind { Inner, Left, Semi, Anti };
22+
enum class JoinKind { Inner, Left, Semi, Anti, NullAwareAnti };
2223

2324
struct BoundColumnRef {
2425
std::string binding;
@@ -174,6 +175,9 @@ struct LogicalPlan {
174175
std::vector<WindowExpression> window_expressions;
175176
std::vector<SortKey> sort_keys;
176177
std::vector<BoundPredicate> predicates;
178+
// Structural only for NullAwareAnti. `predicates` identify the candidate
179+
// right rows; this equality carries NOT IN's distinct UNKNOWN semantics.
180+
std::optional<BoundPredicate> null_aware_predicate;
177181
// Set only on a bound subplan root. Empty is the structural Phase 21a
178182
// dispatch; non-empty means execution requires the listed outer values.
179183
std::vector<BoundColumnRef> correlation_columns;
@@ -196,15 +200,19 @@ struct LogicalPlan {
196200
}
197201

198202
// INNER/LEFT output identity/order is deterministic: all columns from the
199-
// left child followed by all columns from the right child. SEMI/ANTI emit
200-
// exactly the left child's identities and order; right identities exist
201-
// only while evaluating join predicates. Expressions in a bound logical
202-
// plan refer to identities by binding and column name; downstream layers
203-
// must not re-resolve parsed SQL names.
203+
// left child followed by all columns from the right child. SEMI/ANTI and
204+
// NULL-aware ANTI emit exactly the left child's identities and order;
205+
// right identities exist only while evaluating join predicates.
206+
// Expressions in a bound logical plan refer to identities by binding and
207+
// column name; downstream layers must not re-resolve parsed SQL names.
204208
static LogicalPlan join(std::vector<BoundPredicate> predicates,
205209
LogicalPlan left,
206210
LogicalPlan right,
207211
JoinKind join_kind = JoinKind::Inner) {
212+
if (join_kind == JoinKind::NullAwareAnti) {
213+
throw std::invalid_argument(
214+
"NullAwareAnti must be constructed with an explicit membership equality");
215+
}
208216
LogicalPlan p;
209217
p.kind = LogicalKind::Join;
210218
p.join_kind = join_kind;
@@ -214,6 +222,27 @@ struct LogicalPlan {
214222
return p;
215223
}
216224

225+
static LogicalPlan null_aware_anti(BoundPredicate membership,
226+
LogicalPlan left,
227+
LogicalPlan right,
228+
std::vector<BoundPredicate> candidate_predicates = {}) {
229+
if (membership.kind != sql::PredicateKind::Comparison ||
230+
membership.comparison.op != sql::ComparisonOp::Equal) {
231+
throw std::invalid_argument("NullAwareAnti membership predicate must be an equality");
232+
}
233+
if (membership.comparison.left.type != membership.comparison.right.type) {
234+
throw std::invalid_argument("NullAwareAnti membership equality must have matching types");
235+
}
236+
LogicalPlan p;
237+
p.kind = LogicalKind::Join;
238+
p.join_kind = JoinKind::NullAwareAnti;
239+
p.predicates = std::move(candidate_predicates);
240+
p.null_aware_predicate = std::move(membership);
241+
p.left = std::make_shared<LogicalPlan>(std::move(left));
242+
p.right = std::make_shared<LogicalPlan>(std::move(right));
243+
return p;
244+
}
245+
217246
static LogicalPlan filter(std::vector<BoundPredicate> predicates, LogicalPlan child) {
218247
LogicalPlan p;
219248
p.kind = LogicalKind::Filter;

0 commit comments

Comments
 (0)