From 0d2d5b1bd965d672ebf5da0705f65fd0a9a7d6c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:49:18 +0000 Subject: [PATCH] =?UTF-8?q?quack:=20D-QSP-0=20slice=201=20=E2=80=94=20scal?= =?UTF-8?q?ar=20SUM=20is=20NULL=20on=20an=20empty=20or=20all-NULL=20select?= =?UTF-8?q?ion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scalar Agg::SumI32 (MaskedSumI32) starts from the additive identity, so a selection with no non-NULL x answered 0 where PostgreSQL answers NULL. lower_sum(filter, val, valid) lowers the existing MaskedSumI32 and Count terminals, both over filter AND valid(val); sum_finish(sum, count) returns None when no value contributed. SumPlan is an alias of AvgPlan. No new terminal, operator or rule; set_algebra, set_rules, relalg and mask-risc are unchanged. Evidence: - tests/sql_aggregate_null.rs: COUNT(*), COUNT(x), SUM, MIN, MAX, AVG against a column-lane Kleene oracle over 1200 random WHERE clauses, with empty, all-NULL and real-zero cases asserted present; targeted NULL-vs-zero cases; P-EMPTYSUM (ungrouped one NULL, grouped zero groups). Disables D1-D4 each red. - examples/qsp_agg_pg_parity.rs: PostgreSQL 16.15, 3600 queries, Quack = PostgreSQL = oracle on all six aggregates; the naive scalar SUM differs on exactly the 1455 empty + 507 all-NULL queries. Prints SKIP without a server. Board: entry 2026-10-21-d-qsp-0-scalar-aggregates-null.md, STATUS_BOARD row, regenerated entries index. EXO-50 claim session_013z2e1YXkm6o6HNrDn5BPWA/026. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013z2e1YXkm6o6HNrDn5BPWA --- .claude/board/STATUS_BOARD.md | 1 + ...26-10-21-d-qsp-0-scalar-aggregates-null.md | 65 ++ .claude/board/entries/README.md | 3 +- .../examples/qsp_agg_pg_parity.rs | 583 ++++++++++++++++++ crates/lance-graph-quack/src/lib.rs | 37 ++ .../tests/sql_aggregate_null.rs | 403 ++++++++++++ 6 files changed, 1091 insertions(+), 1 deletion(-) create mode 100644 .claude/board/entries/2026-10-21-d-qsp-0-scalar-aggregates-null.md create mode 100644 crates/lance-graph-quack/examples/qsp_agg_pg_parity.rs create mode 100644 crates/lance-graph-quack/tests/sql_aggregate_null.rs diff --git a/.claude/board/STATUS_BOARD.md b/.claude/board/STATUS_BOARD.md index e4b3ec1a0..6b2a41dc9 100644 --- a/.claude/board/STATUS_BOARD.md +++ b/.claude/board/STATUS_BOARD.md @@ -5,6 +5,7 @@ Plans in #1667 (`d-rft-relalg-thought-git-frontier-v1.md`, `d-qsp-classview-quac | D-id | scope | status | gate / falsifier | |---|---|---|---| | **D-RFT-0 / D-QSP-0** | K3 normalization safety: rewrites keep SQL three-valued TRUE rows | Shipped (#1668; EXO-50 claim `session_01G22yT6htkcdyXsihxxXdrg/031`; `entries/2026-10-21-d-rft-0-kleene-normalization.md`): `normalize_kleene`; `SetNormalize` admits it under `ThreeValued`. MEASURED: PostgreSQL 16.14 = Kleene oracle = `normalize_kleene` on 2000/2000; the two-valued form is wrong on 474 | Kleene evaluator over random expressions + PostgreSQL differential; 5 disables red. Remainder open: NULL literals, `COALESCE`, nullable arithmetic, empty `SUM`, `COUNT(expr)` | +| **D-QSP-0 remainder, slice 1** | scalar aggregates under SQL NULL: empty / all-NULL `SUM` is NULL, `COUNT(x)` vs `COUNT(*)`, MIN/MAX/AVG | In PR (EXO-50 claim `session_013z2e1YXkm6o6HNrDn5BPWA/026`; `entries/2026-10-21-d-qsp-0-scalar-aggregates-null.md`): Quack `lower_sum` + `sum_finish` over existing terminals. MEASURED: PostgreSQL 16.15 = Quack = Kleene oracle on 3600/3600 queries | `tests/sql_aggregate_null.rs` (4 disables red); `examples/qsp_agg_pg_parity.rs` (SKIP without a server) | | **D-RFT-1..5, D-LFA-0** | composition, substitution, recursion, frontier, replay; flow census | PROPOSAL (`entries/2026-10-21-d-rft-d-lfa-0-assessment.md`) | per-row falsifiers in the entry; unclaimed | | **D-LFA-1 / D-LFA-2** (amended 2026-10-21) | guarded-control verification of a Rubicon flow; integration with the existing drivers | PROPOSAL (plan amendment, `d-lfa-…-v1.md` § Amendment 2026-10-21; map `entries/2026-10-21-coresearch-plans-mapping-foresight.md`) | GKAT equivalence + separate no-commit-without-revision-and-MUL trace check; 3 disables; `GoTo`/`GoBack` refused; unclaimed | | **D-GKAT-0** | GKAT fixture probe: G1 factoring, G2 nondeterminism refusal, G5 declared atom bound | Queued, unclaimed (research only; no runtime, dependency or executor) | the three fixtures in the D-LFA plan amendment | diff --git a/.claude/board/entries/2026-10-21-d-qsp-0-scalar-aggregates-null.md b/.claude/board/entries/2026-10-21-d-qsp-0-scalar-aggregates-null.md new file mode 100644 index 000000000..937659e70 --- /dev/null +++ b/.claude/board/entries/2026-10-21-d-qsp-0-scalar-aggregates-null.md @@ -0,0 +1,65 @@ +# 2026-10-21 — D-QSP-0 remainder, slice 1: scalar aggregates under SQL NULL + +**Status:** MEASURED, TEST-PINNED. Plan: `d-qsp-classview-quack-postgresql-parity-v1.md` (D-QSP-0 remainder; the D-QSP-1 aggregate rows overlap). Claim: EXO-50 `session_013z2e1YXkm6o6HNrDn5BPWA/026`, track C of `session_01G22yT6htkcdyXsihxxXdrg/033`. Gap G7 and trap T7 of `entries/2026-10-21-pg-classview-polyfill-map.md`. + +## The defect + +Scalar `Agg::SumI32` lowers to `Terminal::MaskedSumI32`, which starts from the additive identity. A selection with no non-NULL `x` therefore answers `SumI64(0)`. PostgreSQL answers NULL, and `0` is also the answer for values that really sum to zero, so the scalar fold alone cannot tell the two apart. `tests/sql_null_3vl.rs::aggregates_keep_their_null_conventions` pinned COUNT, MIN, MAX and AVG on the empty case, but not SUM. + +## The change + +`crates/lance-graph-quack/src/lib.rs`, beside `AvgPlan`: + +- `lower_sum(filter, val, valid: Option) -> SumPlan` lowers the two existing terminals `MaskedSumI32` and `Count`, both over `filter ∧ valid(val)`. Taking `valid` here means the NULL gate cannot be kept on one half and forgotten on the other. +- `sum_finish(sum, count) -> Option` returns `None` (SQL NULL) when `count == 0`. +- `SumPlan` is a type alias of `AvgPlan`: the same two folds, finished differently. + +No new terminal, operator, rule or registry. `set_algebra`, `set_rules`, relalg and mask-risc are unchanged. + +## Supported SQL subset (exactly what is pinned) + +One table, two nullable `int4` columns `x`, `y`. `WHERE` is any tree of `=, <>, <, <=, >, >=` against an integer constant, `IS NULL`, `IS NOT NULL`, `AND`, `OR`, `NOT` (depth ≤ 3), read through `Filter::sql_where`. Aggregates, ungrouped: + +| SQL | Quack | empty / all-NULL `x` | +|---|---|---| +| `count(*)` | `Agg::Count` over `sql_where` | `0` | +| `count(x)` | `Agg::Count` over `sql_where ∧ valid(x)` | `0` | +| `sum(x)` | `lower_sum` + `sum_finish` | NULL | +| `min(x)`, `max(x)` | `Agg::MinI32` / `MaxI32` over `sql_where ∧ valid(x)` | NULL | +| `avg(x)` | `lower_avg` over `sql_where ∧ valid(x)` + `avg_finish` | NULL; compared to `avg(x)::float8` with relative tolerance 1e-12 | + +Plus P-EMPTYSUM: an empty `WHERE` gives one NULL answer ungrouped and zero groups for `GroupReduce { SumI32 }` after `normalize_group_sink`. + +## Evidence + +| what | result | +|---|---| +| `tests/sql_aggregate_null.rs`, 150 tables × 8 random `WHERE` (column-lane Kleene oracle, folds over positions) | all six aggregates equal the oracle; corpus asserted to contain empty, all-NULL and real-zero-sum cases | +| same file, targeted cases (NULL payload 9) | empty → NULL; all-NULL → NULL; `0+0` and `2+(-2)` → `0`; payloads excluded; the lone scalar fold reads both cases as `SumI64(0)` | +| same file, P-EMPTYSUM | ungrouped one NULL; grouped zero groups; a non-empty selection yields 2 groups | +| `examples/qsp_agg_pg_parity.rs`, PostgreSQL 16.15, 400 tables, 3600 queries | Quack = PostgreSQL = oracle on all six aggregates, 0 mismatches; 1455 empty, 507 all-NULL, 64 real-zero sums; naive SUM differs on exactly 1962 = 1455 + 507 (asserted) | +| quack crate | every suite passes; clippy `-D warnings` clean (Rust 1.99.0) | + +Disable runs, each red on its named test(s) at an assertion line: + +- D1 (`sum_finish` always `Some`): both sum tests red; the PostgreSQL example reports 1962 `sum(x)` mismatches and exits 101; +- D2 (`lower_sum` ignores `valid`): both sum tests red; +- D3 (`lower_sum` gates only the sum half): both sum tests red; +- D4 (`normalize_group_sink` marks every slot present): only the P-EMPTYSUM test red. + +## Reproduce + +```text +cargo test -p lance-graph-quack --test sql_aggregate_null +PGHOST= PGPORT= PGUSER= PGDATABASE=postgres \ + cargo run --release -p lance-graph-quack --example qsp_agg_pg_parity +``` + +Without a server the example prints `SKIP` and exits 0. It is a lab instrument; no PostgreSQL arm runs in CI. + +## Open + +- `SUM` over a grouped key: the coalescing `Agg::GroupSumI32` still answers `0` for an unreached group (documented zero-fallback). The NULL-preserving form is `GroupReduce { GroupAgg::SumI32 }`. +- A reached group whose `x` are all NULL (PostgreSQL: the group appears with `sum = NULL`) is trap T4; it belongs to the GROUP BY slice together with NULL keys (G2). +- Not covered: NULL literals and `NOT IN (…, NULL)` (G1), `COALESCE`/`NULLIF`, nullable arithmetic, `int4` sum overflow past `MASKED_SUM_I32_MAX_ROWS`, `numeric` AVG. +- Two folds cost two passes over the filter, as for AVG. A fused sum+count sink is a cost question, not a correctness one; no timing was taken here. diff --git a/.claude/board/entries/README.md b/.claude/board/entries/README.md index fe1ea317e..310a70a66 100644 --- a/.claude/board/entries/README.md +++ b/.claude/board/entries/README.md @@ -25,7 +25,7 @@ index row, (3) no duplicate entry id. Checks 1 and 2 are deliberately opposite directions; the stranding this convention prevents shows up in exactly one of them, never both. -420 entries, 2026-08-06 .. 2026-10-21. +421 entries, 2026-08-06 .. 2026-10-21. | date | entry id | finding | file | |---|---|---|---| @@ -44,6 +44,7 @@ exactly one of them, never both. | 2026-10-21 | `D-RFT-0` | | [2026-10-21-d-rft-0-kleene-normalization.md](2026-10-21-d-rft-0-kleene-normalization.md) | | 2026-10-21 | `D-RA-2` | | [2026-10-21-d-ra-2-relalg-symbolic-repoint.md](2026-10-21-d-ra-2-relalg-symbolic-repoint.md) | | 2026-10-21 | `D-QSP` | | [2026-10-21-d-qsp-d-rft-d-lfa-classview-quack-plan.md](2026-10-21-d-qsp-d-rft-d-lfa-classview-quack-plan.md) | +| 2026-10-21 | `D-QSP-0` | | [2026-10-21-d-qsp-0-scalar-aggregates-null.md](2026-10-21-d-qsp-0-scalar-aggregates-null.md) | | 2026-10-21 | `D-CMR-6` | | [2026-10-21-d-cmr-6-rule-catalogue.md](2026-10-21-d-cmr-6-rule-catalogue.md) | | 2026-10-21 | `D-CMR-5` | | [2026-10-21-d-cmr-5-rescope.md](2026-10-21-d-cmr-5-rescope.md) | | 2026-10-21 | `D-CMR-2b` | | [2026-10-21-d-cmr-2b-scratch-probe.md](2026-10-21-d-cmr-2b-scratch-probe.md) | diff --git a/crates/lance-graph-quack/examples/qsp_agg_pg_parity.rs b/crates/lance-graph-quack/examples/qsp_agg_pg_parity.rs new file mode 100644 index 000000000..5b03a71af --- /dev/null +++ b/crates/lance-graph-quack/examples/qsp_agg_pg_parity.rs @@ -0,0 +1,583 @@ +//! D-QSP-0 remainder, slice 1: scalar aggregates under SQL NULL against +//! PostgreSQL. +//! +//! `COUNT(*)`, `COUNT(x)`, `SUM(x)`, `MIN(x)`, `MAX(x)` and `AVG(x)`, each over +//! a random three-valued `WHERE`, on random tables with two nullable `int` +//! columns. PostgreSQL is the oracle. An in-file Kleene evaluator answers the +//! same questions without PostgreSQL, so a Quack-vs-oracle disagreement is +//! visible even where PostgreSQL is missing a case. +//! +//! The data stays in column lanes (values plus validity bits) throughout; no +//! row structs are built, in the tables or in the oracle. The oracle folds the +//! TRUE positions straight into the aggregates. +//! +//! A negative control, the "naive SUM" (`Agg::SumI32` read as `Some(sum)` +//! always), is counted against PostgreSQL. It must disagree exactly where +//! no non-NULL `x` was selected (SQL answers NULL, the naive fold answers 0); +//! that count is reported and is not a failure. +//! +//! Needs `psql` and a server; connection from the usual `PG*` variables. +//! Without one it prints `SKIP` and exits 0, so nothing here gates CI. +//! +//! ```text +//! PGHOST= PGPORT= PGUSER= PGDATABASE=postgres \ +//! cargo run --release -p lance-graph-quack --example qsp_agg_pg_parity +//! ``` + +use lance_graph_mask_risc::{execute, words_for, LaneRef, Planes, Scratch, Value}; +use lance_graph_quack::{ + avg_finish, lower, lower_avg, lower_sum, sum_finish, Agg, Cmp, Col, Filter, Mask, Query, +}; +use std::io::Write; +use std::process::{Command, Stdio}; + +const X: Col = Col(0); +const Y: Col = Col(1); +const VX: Mask = Mask(0); +const VY: Mask = Mask(1); +const NULLABLE: [(Col, Mask); 2] = [(X, VX), (Y, VY)]; + +const TABLES: usize = 400; +const QUERIES_PER_TABLE: usize = 8; +const AGG_NAMES: [&str; 6] = [ + "count(*)", "count(x)", "sum(x)", "min(x)", "max(x)", "avg(x)", +]; + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } +} + +/// Column lanes: values, plus validity bits (set = not NULL). A NULL row +/// carries a non-zero payload, so a path that reads it is caught. +struct Table { + n: usize, + xs: Vec, + ys: Vec, + vx: Vec, + vy: Vec, +} + +fn bit(w: &[u64], r: usize) -> bool { + w[r / 64] >> (r % 64) & 1 == 1 +} + +fn table(rng: &mut Rng) -> Table { + let n = 1 + rng.below(150) as usize; + let px = [0, 30, 70, 100][rng.below(4) as usize]; + let py = [0, 30][rng.below(2) as usize]; + let mut t = Table { + n, + xs: Vec::with_capacity(n), + ys: Vec::with_capacity(n), + vx: vec![0; words_for(n)], + vy: vec![0; words_for(n)], + }; + for r in 0..n { + let val = |rng: &mut Rng| rng.below(9) as i32 - 4; + let payload = |rng: &mut Rng| 1 + rng.below(4) as i32; + if rng.below(100) < px { + t.xs.push(payload(rng)); + } else { + t.xs.push(val(rng)); + t.vx[r / 64] |= 1 << (r % 64); + } + if rng.below(100) < py { + t.ys.push(payload(rng)); + } else { + t.ys.push(val(rng)); + t.vy[r / 64] |= 1 << (r % 64); + } + } + t +} + +/// The `WHERE` as written: the source of the SQL text, the Kleene oracle and +/// the Quack filter, so all three read one tree. +enum Node { + Cmp(u8, u8, i32), // column (0 = x, 1 = y), operator index, constant + IsNull(u8), + IsNotNull(u8), + Not(Box), + And(Vec), + Or(Vec), +} + +const OPS_SQL: [&str; 6] = ["=", "<>", "<", "<=", ">", ">="]; + +fn col_name(c: u8) -> &'static str { + if c == 0 { + "x" + } else { + "y" + } +} + +fn leaf(rng: &mut Rng) -> Node { + let c = rng.below(2) as u8; + match rng.below(10) { + 0..=5 => Node::Cmp(c, rng.below(6) as u8, rng.below(11) as i32 - 5), + 6 | 7 => Node::IsNull(c), + _ => Node::IsNotNull(c), + } +} + +fn node(rng: &mut Rng, depth: u32) -> Node { + if depth == 0 || rng.below(4) == 0 { + return leaf(rng); + } + match rng.below(3) { + 0 => Node::Not(Box::new(node(rng, depth - 1))), + k => { + let cs = (0..2 + rng.below(2)) + .map(|_| node(rng, depth - 1)) + .collect(); + if k == 1 { + Node::And(cs) + } else { + Node::Or(cs) + } + } + } +} + +fn sql(n: &Node) -> String { + match n { + Node::Cmp(c, op, k) => format!("{} {} {k}", col_name(*c), OPS_SQL[*op as usize]), + Node::IsNull(c) => format!("{} IS NULL", col_name(*c)), + Node::IsNotNull(c) => format!("{} IS NOT NULL", col_name(*c)), + Node::Not(i) => format!("(NOT {})", sql(i)), + Node::And(cs) => format!("({})", cs.iter().map(sql).collect::>().join(" AND ")), + Node::Or(cs) => format!("({})", cs.iter().map(sql).collect::>().join(" OR ")), + } +} + +fn mask_of(c: u8) -> Mask { + if c == 0 { + VX + } else { + VY + } +} + +fn to_filter(n: &Node) -> Filter { + match n { + Node::Cmp(c, op, k) => { + let col = if *c == 0 { X } else { Y }; + Filter::cmp( + col, + match op { + 0 => Cmp::EqI32(*k), + 1 => Cmp::NeI32(*k), + 2 => Cmp::LtI32(*k), + 3 => Cmp::LeI32(*k), + 4 => Cmp::GtI32(*k), + _ => Cmp::GeI32(*k), + }, + ) + } + Node::IsNull(c) => Filter::is_null(mask_of(*c)), + Node::IsNotNull(c) => Filter::is_not_null(mask_of(*c)), + Node::Not(i) => Filter::negate(to_filter(i)), + Node::And(cs) => Filter::and(cs.iter().map(to_filter)), + Node::Or(cs) => Filter::or(cs.iter().map(to_filter)), + } +} + +/// Kleene truth value. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Tv { + T, + F, + U, +} + +fn eval(n: &Node, t: &Table, r: usize) -> Tv { + let tv = |b: bool| if b { Tv::T } else { Tv::F }; + match n { + Node::Cmp(c, op, k) => { + let (lane, valid) = if *c == 0 { + (&t.xs, &t.vx) + } else { + (&t.ys, &t.vy) + }; + if !bit(valid, r) { + return Tv::U; + } + let v = lane[r]; + tv(match op { + 0 => v == *k, + 1 => v != *k, + 2 => v < *k, + 3 => v <= *k, + 4 => v > *k, + _ => v >= *k, + }) + } + Node::IsNull(c) => tv(!bit(if *c == 0 { &t.vx } else { &t.vy }, r)), + Node::IsNotNull(c) => tv(bit(if *c == 0 { &t.vx } else { &t.vy }, r)), + Node::Not(i) => match eval(i, t, r) { + Tv::T => Tv::F, + Tv::F => Tv::T, + Tv::U => Tv::U, + }, + Node::And(cs) => { + let mut unknown = false; + for c in cs { + match eval(c, t, r) { + Tv::F => return Tv::F, + Tv::U => unknown = true, + Tv::T => {} + } + } + if unknown { + Tv::U + } else { + Tv::T + } + } + Node::Or(cs) => { + let mut unknown = false; + for c in cs { + match eval(c, t, r) { + Tv::T => return Tv::T, + Tv::U => unknown = true, + Tv::F => {} + } + } + if unknown { + Tv::U + } else { + Tv::F + } + } + } +} + +/// The six answers for one query. +#[derive(Debug, Clone, Copy)] +struct Answers { + cstar: i64, + cx: i64, + sum: Option, + min: Option, + max: Option, + avg: Option, +} + +impl Answers { + /// Indices into `AGG_NAMES` where `self` and `other` differ. + fn diff(&self, other: &Answers) -> Vec { + // PostgreSQL's `avg(int)` is numeric, cast to float8 in the query; + // Quack divides i64 by u64 in f64. Same value up to rounding, so AVG + // is compared with a relative tolerance; everything else is exact. + let avg_eq = match (self.avg, other.avg) { + (None, None) => true, + (Some(a), Some(b)) => (a - b).abs() <= 1e-12 * a.abs().max(b.abs()).max(1.0), + _ => false, + }; + let mut d = Vec::new(); + if self.cstar != other.cstar { + d.push(0); + } + if self.cx != other.cx { + d.push(1); + } + if self.sum != other.sum { + d.push(2); + } + if self.min != other.min { + d.push(3); + } + if self.max != other.max { + d.push(4); + } + if !avg_eq { + d.push(5); + } + d + } +} + +/// The Kleene oracle: fold the TRUE positions straight into the aggregates. +fn oracle(n: &Node, t: &Table) -> Answers { + let (mut cstar, mut cx, mut sum) = (0i64, 0i64, 0i64); + let (mut min, mut max): (Option, Option) = (None, None); + for r in 0..t.n { + if eval(n, t, r) != Tv::T { + continue; + } + cstar += 1; + if bit(&t.vx, r) { + let v = t.xs[r]; + cx += 1; + sum += i64::from(v); + min = Some(min.map_or(v, |m| m.min(v))); + max = Some(max.map_or(v, |m| m.max(v))); + } + } + Answers { + cstar, + cx, + sum: (cx != 0).then_some(sum), + min, + max, + avg: (cx != 0).then(|| sum as f64 / cx as f64), + } +} + +/// The Quack side: every aggregate through `lower` + `execute`. The second +/// value is the naive `SUM` (`Agg::SumI32` read as `Some` always). +fn quack(n: &Node, t: &Table) -> (Answers, Option) { + let masks: [&[u64]; 2] = [&t.vx, &t.vy]; + let lanes = [LaneRef::I32(&t.xs), LaneRef::I32(&t.ys)]; + let pl = Planes { + n_rows: t.n, + masks: &masks, + lanes: &lanes, + }; + let run = |p: &lance_graph_mask_risc::Program| -> Value { + let mut s = Scratch::for_program(p, t.n).expect("carves"); + execute(p, &pl, &mut s, None).expect("runs") + }; + let run_q = |filter: &Filter, agg: Agg| -> Value { + run(&lower(&Query { + filter: filter.clone(), + agg, + }) + .expect("lowers")) + }; + let count = |v: Value| match v { + Value::Count(c) => c, + other => panic!("expected Count, got {other:?}"), + }; + + let w = to_filter(n).sql_where(&NULLABLE); + let gated = Filter::and([w.clone(), Filter::is_not_null(VX)]); + + let cstar = count(run_q(&w, Agg::Count)) as i64; + let cx = count(run_q(&gated, Agg::Count)) as i64; + + let plan = lower_sum(&w, X, Some(VX)).expect("lowers"); + let (Value::SumI64(s), Value::Count(c)) = (run(&plan.sum), run(&plan.count)) else { + panic!("SUM plan shapes") + }; + let sum = sum_finish(s, c as u64); + + let opt = |v: Value| match v { + Value::OptI32(o) => o, + other => panic!("expected OptI32, got {other:?}"), + }; + let min = opt(run_q(&gated, Agg::MinI32(X))); + let max = opt(run_q(&gated, Agg::MaxI32(X))); + + let plan = lower_avg(&gated, X).expect("lowers"); + let (Value::SumI64(s), Value::Count(c)) = (run(&plan.sum), run(&plan.count)) else { + panic!("AVG plan shapes") + }; + let avg = avg_finish(s, c as u64); + + let Value::SumI64(naive) = run_q(&gated, Agg::SumI32(X)) else { + panic!("naive SUM shape") + }; + ( + Answers { + cstar, + cx, + sum, + min, + max, + avg, + }, + Some(naive), + ) +} + +fn lit(v: Option) -> String { + v.map_or("NULL".into(), |x| x.to_string()) +} + +fn field(s: &str) -> Option { + (!s.is_empty()).then(|| { + s.parse() + .unwrap_or_else(|_| panic!("unparsable field {s:?}")) + }) +} + +fn parse_pg(line: &str) -> Answers { + let f: Vec<&str> = line.split('|').collect(); + assert_eq!(f.len(), 6, "six fields per result row: {line:?}"); + Answers { + cstar: field(f[0]).expect("count(*) is never NULL"), + cx: field(f[1]).expect("count(x) is never NULL"), + sum: field(f[2]), + min: field(f[3]), + max: field(f[4]), + avg: field(f[5]), + } +} + +fn main() { + let mut rng = Rng(0x00D1_5EED_0A66_0001); + let tables: Vec = (0..TABLES).map(|_| table(&mut rng)).collect(); + + // (table index, filter tree, SQL text) + let mut queries: Vec<(usize, Node, String)> = Vec::new(); + for k in 0..TABLES { + for _ in 0..QUERIES_PER_TABLE { + let n = node(&mut rng, 3); + let s = sql(&n); + queries.push((k, n, s)); + } + // Matches nothing, for sure. + let n = Node::And(vec![Node::IsNull(0), Node::IsNotNull(0)]); + queries.push((k, n, "(x IS NULL AND x IS NOT NULL)".to_string())); + } + + let mut script = String::new(); + for (k, t) in tables.iter().enumerate() { + script.push_str(&format!("CREATE TEMP TABLE t{k} (x int, y int);\n")); + let rows: Vec = (0..t.n) + .map(|r| { + format!( + "({}, {})", + lit(bit(&t.vx, r).then_some(t.xs[r])), + lit(bit(&t.vy, r).then_some(t.ys[r])) + ) + }) + .collect(); + script.push_str(&format!("INSERT INTO t{k} VALUES {};\n", rows.join(","))); + } + for (k, _, s) in &queries { + script.push_str(&format!( + "SELECT count(*), count(x), sum(x), min(x), max(x), avg(x)::float8 \ + FROM t{k} WHERE {s};\n" + )); + } + + let out = Command::new("psql") + .args(["-X", "-q", "-At", "-F|", "-v", "ON_ERROR_STOP=1", "-f", "-"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .and_then(|mut child| { + // Write on a separate thread: psql answers while it reads, so a + // parent that wrote everything first would block once the stdout + // pipe filled. A refused connection closes the pipe early; the + // exit status and stderr below say why. + let mut stdin = child.stdin.take().expect("piped stdin"); + let writer = std::thread::spawn(move || { + let _ = stdin.write_all(script.as_bytes()); + }); + let out = child.wait_with_output(); + let _ = writer.join(); + out + }); + let out = match out { + Ok(o) if o.status.success() => o, + Ok(o) => { + println!( + "SKIP: psql failed: {}", + String::from_utf8_lossy(&o.stderr).trim() + ); + return; + } + Err(e) => { + println!("SKIP: could not run psql ({e})"); + return; + } + }; + let lines: Vec<&str> = std::str::from_utf8(&out.stdout) + .expect("utf-8") + .lines() + .filter(|l| !l.is_empty()) + .collect(); + assert_eq!( + lines.len(), + queries.len(), + "one result row per query (got {} lines for {} queries)", + lines.len(), + queries.len() + ); + + // Mismatch counts per aggregate, per arm. + let mut quack_bad = [0usize; 6]; + let mut oracle_bad = [0usize; 6]; + let mut shown = 0usize; + let (mut naive_bad, mut empty, mut all_null, mut real_zero) = (0, 0, 0, 0); + + for ((k, n, s), line) in queries.iter().zip(&lines) { + let t = &tables[*k]; + let pg = parse_pg(line); + let (q, naive) = quack(n, t); + let o = oracle(n, t); + + let dq = pg.diff(&q); + let dor = pg.diff(&o); + for &i in &dq { + quack_bad[i] += 1; + } + for &i in &dor { + oracle_bad[i] += 1; + } + if (!dq.is_empty() || !dor.is_empty()) && shown < 5 { + shown += 1; + println!( + "MISMATCH t{k} WHERE {s}\n postgres {pg:?}\n quack {q:?} (differs: {:?})\n oracle {o:?} (differs: {:?})", + dq.iter().map(|&i| AGG_NAMES[i]).collect::>(), + dor.iter().map(|&i| AGG_NAMES[i]).collect::>(), + ); + } + + // Negative control: the naive fold reads as Some(sum) always. + naive_bad += usize::from(naive != pg.sum); + empty += usize::from(pg.cstar == 0); + all_null += usize::from(pg.cstar > 0 && pg.cx == 0); + real_zero += usize::from(pg.sum == Some(0)); + } + + let qm: usize = quack_bad.iter().sum(); + let om: usize = oracle_bad.iter().sum(); + let version = Command::new("psql") + .args(["-X", "-tA", "-c", "SHOW server_version"]) + .output() + .map(|o| o.stdout) + .unwrap_or_default(); + println!( + "PostgreSQL {}", + std::str::from_utf8(&version).unwrap_or("?").trim() + ); + for (i, name) in AGG_NAMES.iter().enumerate() { + println!( + " {name}: quack mismatches {}, oracle mismatches {}", + quack_bad[i], oracle_bad[i] + ); + } + println!( + "qsp_agg_pg_parity: {} queries, quack mismatches {qm}, oracle mismatches {om}, \ + naive SUM disagreements {naive_bad} (empty {empty}, all-NULL {all_null}, \ + real-zero sums {real_zero})", + queries.len() + ); + + // Anti-vacuity: the corpus must contain the cases this slice is about. + assert!(empty > 0, "no query selected zero rows"); + assert!(all_null > 0, "no query selected rows with only NULL x"); + assert!(real_zero > 0, "no query had a real SUM of zero"); + assert_eq!( + naive_bad, + empty + all_null, + "the naive SUM must differ exactly where no non-NULL x was selected" + ); + assert_eq!(qm, 0, "Quack disagrees with PostgreSQL"); + assert_eq!(om, 0, "the Kleene oracle disagrees with PostgreSQL"); +} diff --git a/crates/lance-graph-quack/src/lib.rs b/crates/lance-graph-quack/src/lib.rs index 6866d3dab..f25916de2 100644 --- a/crates/lance-graph-quack/src/lib.rs +++ b/crates/lance-graph-quack/src/lib.rs @@ -1532,6 +1532,43 @@ pub fn lower_avg(filter: &Filter, val: Col) -> Result { }) } +/// `SUM(val)` with SQL's NULL result: the same two folds as [`AvgPlan`], +/// finished by [`sum_finish`] instead of [`avg_finish`]. +/// +/// A lone [`Agg::SumI32`] answers `0` for a selection with no non-NULL +/// `val`, because [`Terminal::MaskedSumI32`] starts from the additive +/// identity. SQL answers NULL there, and `0` is also the answer for a +/// selection whose values really sum to zero. The count fold is what tells +/// the two apart. +pub type SumPlan = AvgPlan; + +/// Lower `SUM(val) WHERE filter` to its [`SumPlan`], with `valid` the +/// validity plane of `val` when `val` is nullable. +/// +/// `filter` is the caller's `WHERE`, already read through +/// [`Filter::sql_where`] if it touches nullable columns. Both folds run over +/// `filter ∧ valid`, so a NULL `val` neither adds its payload to the sum nor +/// counts as a contribution. Taking `valid` here rather than leaving it to +/// the caller's filter (as [`lower_avg`] does) means the NULL gate cannot be +/// forgotten on one half and kept on the other. +/// +/// # Errors +/// +/// As [`lower`]. +pub fn lower_sum(filter: &Filter, val: Col, valid: Option) -> Result { + let gated = match valid { + Some(v) => Filter::and([filter.clone(), Filter::is_not_null(v)]), + None => filter.clone(), + }; + lower_avg(&gated, val) +} + +/// Finish a sum: `Some(sum)`, or `None` (SQL `NULL`) when no row +/// contributed a value. `count` is the [`SumPlan::count`] fold. +pub fn sum_finish(sum: i64, count: u64) -> Option { + (count != 0).then_some(sum) +} + /// `GROUP BY key AVG(val)`: the grouped form of [`AvgPlan`], one K-slot /// sum sink and one K-slot count sink over the same filter. Both programs /// take an `Out::I64` of exactly `groups` slots; finish each group with diff --git a/crates/lance-graph-quack/tests/sql_aggregate_null.rs b/crates/lance-graph-quack/tests/sql_aggregate_null.rs new file mode 100644 index 000000000..da6ad49ee --- /dev/null +++ b/crates/lance-graph-quack/tests/sql_aggregate_null.rs @@ -0,0 +1,403 @@ +//! D-QSP-0 remainder, slice 1: scalar aggregates under SQL NULL. +//! +//! The SQL rules under test: +//! - `COUNT(*)` counts the rows `WHERE` keeps, NULLs included. +//! - `COUNT(x)` counts only the kept rows whose `x` is not NULL. +//! - `SUM(x)`, `MIN(x)`, `MAX(x)` and `AVG(x)` see non-NULL `x` only, and are +//! NULL when no kept row has a non-NULL `x`. A sum of real values that +//! happens to be `0` is `0`, not NULL. +//! +//! A nullable column is a value lane plus its validity plane. The fixtures put +//! a non-zero payload at every NULL row, so a path that reads a NULL payload +//! changes a sum or a minimum and is caught. +//! +//! The oracle never lowers anything. It evaluates the original filter in +//! Kleene logic position by position, reading the lanes and validity bits in +//! place, and folds the TRUE positions straight into the aggregates. No row +//! objects are built. PostgreSQL checks the same aggregates in +//! `examples/qsp_agg_pg_parity.rs`. + +use lance_graph_mask_risc::{ + execute, execute_into, Foreign, LaneRef, Out, Planes, Program, Scratch, Value, +}; +use lance_graph_quack::{ + avg_finish, lower, lower_avg, lower_sum, normalize_group_sink, sum_finish, Agg, Cmp, Col, + Filter, GroupAddr, GroupAgg, Mask, Query, +}; + +const X: Col = Col(0); +const Y: Col = Col(1); +const VX: Mask = Mask(0); +const VY: Mask = Mask(1); +const NULLABLE: [(Col, Mask); 2] = [(X, VX), (Y, VY)]; + +/// Two nullable `i32` columns as lanes. A clear validity bit is NULL; the +/// value lane keeps a non-zero payload there. +struct Lanes { + x: Vec, + y: Vec, + vx: Vec, + vy: Vec, +} + +impl Lanes { + fn n(&self) -> usize { + self.x.len() + } + + /// `None` at a NULL position, read in place. + fn x_at(&self, i: usize) -> Option { + bit(&self.vx, i).then(|| self.x[i]) + } + + fn y_at(&self, i: usize) -> Option { + bit(&self.vy, i).then(|| self.y[i]) + } +} + +fn bit(w: &[u64], i: usize) -> bool { + w[i / 64] >> (i % 64) & 1 == 1 +} + +/// Build lanes from `Option` cells; a NULL cell stores `payload` in the lane. +fn lanes(x: &[Option], y: &[Option], payload: i32) -> Lanes { + let n = x.len(); + let words = n.div_ceil(64); + let mut l = Lanes { + x: vec![0; n], + y: vec![0; n], + vx: vec![0; words], + vy: vec![0; words], + }; + for i in 0..n { + match x[i] { + Some(v) => { + l.x[i] = v; + l.vx[i / 64] |= 1 << (i % 64); + } + None => l.x[i] = payload, + } + match y[i] { + Some(v) => { + l.y[i] = v; + l.vy[i / 64] |= 1 << (i % 64); + } + None => l.y[i] = payload, + } + } + l +} + +// ── Kleene oracle ──────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Tv { + T, + F, + U, +} + +fn tv(b: bool) -> Tv { + if b { + Tv::T + } else { + Tv::F + } +} + +fn cmp_holds(c: Cmp, v: i32) -> bool { + match c { + Cmp::EqI32(k) => v == k, + Cmp::NeI32(k) => v != k, + Cmp::LtI32(k) => v < k, + Cmp::LeI32(k) => v <= k, + Cmp::GtI32(k) => v > k, + Cmp::GeI32(k) => v >= k, + other => panic!("fixture uses only i32 comparisons, got {other:?}"), + } +} + +fn eval(f: &Filter, l: &Lanes, i: usize) -> Tv { + match f { + Filter::Cmp(col, c) => { + let v = if *col == X { l.x_at(i) } else { l.y_at(i) }; + v.map_or(Tv::U, |v| tv(cmp_holds(*c, v))) + } + Filter::Plane(m) => tv(bit(if *m == VX { &l.vx } else { &l.vy }, i)), + Filter::Not(g) => match eval(g, l, i) { + Tv::T => Tv::F, + Tv::F => Tv::T, + Tv::U => Tv::U, + }, + Filter::And(gs) => gs.iter().fold(Tv::T, |acc, g| match (acc, eval(g, l, i)) { + (Tv::F, _) | (_, Tv::F) => Tv::F, + (Tv::U, _) | (_, Tv::U) => Tv::U, + _ => Tv::T, + }), + Filter::Or(gs) => gs.iter().fold(Tv::F, |acc, g| match (acc, eval(g, l, i)) { + (Tv::T, _) | (_, Tv::T) => Tv::T, + (Tv::U, _) | (_, Tv::U) => Tv::U, + _ => Tv::F, + }), + other => panic!("fixture builds no {other:?}"), + } +} + +/// The SQL answers, folded over the positions `WHERE` keeps. +#[derive(Debug, PartialEq)] +struct Answers { + count_star: usize, + count_x: usize, + sum: Option, + min: Option, + max: Option, + avg: Option, +} + +fn oracle(f: &Filter, l: &Lanes) -> Answers { + let (mut count_star, mut count_x, mut sum) = (0usize, 0usize, 0i64); + let (mut min, mut max) = (None::, None::); + for i in 0..l.n() { + if eval(f, l, i) != Tv::T { + continue; + } + count_star += 1; + if let Some(v) = l.x_at(i) { + count_x += 1; + sum += i64::from(v); + min = Some(min.map_or(v, |m| m.min(v))); + max = Some(max.map_or(v, |m| m.max(v))); + } + } + Answers { + count_star, + count_x, + sum: (count_x > 0).then_some(sum), + min, + max, + avg: (count_x > 0).then(|| sum as f64 / count_x as f64), + } +} + +// ── Quack ──────────────────────────────────────────────────────────────── + +fn run(p: &Program, l: &Lanes) -> Value { + let masks: [&[u64]; 2] = [&l.vx, &l.vy]; + let lanes = [LaneRef::I32(&l.x), LaneRef::I32(&l.y)]; + let pl = Planes { + n_rows: l.n(), + masks: &masks, + lanes: &lanes, + }; + let mut s = Scratch::for_program(p, l.n()).expect("carves"); + execute(p, &pl, &mut s, None).expect("runs") +} + +fn count(filter: Filter, l: &Lanes) -> usize { + let p = lower(&Query { + filter, + agg: Agg::Count, + }) + .expect("lowers"); + match run(&p, l) { + Value::Count(c) => c, + v => panic!("COUNT shape {v:?}"), + } +} + +fn opt_i32(filter: Filter, agg: Agg, l: &Lanes) -> Option { + let p = lower(&Query { filter, agg }).expect("lowers"); + match run(&p, l) { + Value::OptI32(v) => v, + v => panic!("MIN/MAX shape {v:?}"), + } +} + +/// `SUM(x)` through [`lower_sum`] and [`sum_finish`]. +fn sql_sum(w: &Filter, l: &Lanes) -> Option { + let plan = lower_sum(w, X, Some(VX)).expect("lowers"); + match (run(&plan.sum, l), run(&plan.count, l)) { + (Value::SumI64(s), Value::Count(c)) => sum_finish(s, c as u64), + v => panic!("SUM plan shapes {v:?}"), + } +} + +fn quack(f: &Filter, l: &Lanes) -> Answers { + let w = f.sql_where(&NULLABLE); + let with_x = Filter::and([w.clone(), Filter::is_not_null(VX)]); + let plan = lower_avg(&with_x, X).expect("lowers"); + let avg = match (run(&plan.sum, l), run(&plan.count, l)) { + (Value::SumI64(s), Value::Count(c)) => avg_finish(s, c as u64), + v => panic!("AVG plan shapes {v:?}"), + }; + Answers { + count_star: count(w.clone(), l), + count_x: count(with_x.clone(), l), + sum: sql_sum(&w, l), + min: opt_i32(with_x.clone(), Agg::MinI32(X), l), + max: opt_i32(with_x, Agg::MaxI32(X), l), + avg, + } +} + +// ── Tests ──────────────────────────────────────────────────────────────── + +/// FAILS IF: an empty or all-NULL `SUM(x)` answers `0`, a real zero sum +/// answers NULL, or a NULL payload reaches the sum. The fixture's NULL +/// payload is `9`. +#[test] +fn sum_of_no_contribution_is_null_and_a_real_zero_is_zero() { + // rows: x = NULL, NULL, 0, 0, 2, -2; y = 1, 1, 2, 2, 3, 3 + let l = lanes( + &[None, None, Some(0), Some(0), Some(2), Some(-2)], + &[Some(1), Some(1), Some(2), Some(2), Some(3), Some(3)], + 9, + ); + let y_is = |k| Filter::cmp(Y, Cmp::EqI32(k)).sql_where(&NULLABLE); + + // WHERE keeps nothing. + assert_eq!(sql_sum(&y_is(7), &l), None, "empty selection"); + // WHERE keeps two rows whose x are both NULL. + assert_eq!(count(y_is(1), &l), 2, "the all-NULL selection is not empty"); + assert_eq!(sql_sum(&y_is(1), &l), None, "all-NULL selection"); + // Real values summing to zero. + assert_eq!(sql_sum(&y_is(2), &l), Some(0), "0 + 0"); + assert_eq!(sql_sum(&y_is(3), &l), Some(0), "2 + -2"); + // Everything: the two NULL payloads (9 each) must not appear. + let all = Filter::is_not_null(VY); + assert_eq!(sql_sum(&all, &l), Some(0), "NULL payloads excluded"); + + // Negative control: the lone scalar fold cannot tell the cases apart. + let naive = |w: Filter| { + let p = lower(&Query { + filter: Filter::and([w, Filter::is_not_null(VX)]), + agg: Agg::SumI32(X), + }) + .expect("lowers"); + run(&p, &l) + }; + assert_eq!(naive(y_is(1)), naive(y_is(2)), "both read as SumI64(0)"); +} + +/// FAILS IF: an empty `WHERE` stops answering ungrouped `SUM(x)` with one +/// NULL, or starts answering the grouped form with any group. PostgreSQL: +/// `SELECT sum(x) FROM t WHERE false` is one row holding NULL; +/// `SELECT k, sum(x) FROM t WHERE false GROUP BY k` is zero rows. A +/// non-empty selection must still produce groups, or the zero-groups check +/// proves nothing. (Map probe P-EMPTYSUM. A reached group whose `x` are all +/// NULL is a `GROUP BY` question and is not pinned here.) +#[test] +fn empty_where_is_one_null_ungrouped_and_no_group_grouped() { + let l = lanes( + &[None, Some(4), Some(0), Some(-1)], + &[Some(1), Some(1), Some(2), Some(2)], + 9, + ); + let keys: Vec = vec![0, 1, 1, 2]; + let masks: [&[u64]; 2] = [&l.vx, &l.vy]; + let lanes_ = [LaneRef::I32(&l.x), LaneRef::I32(&l.y), LaneRef::U32(&keys)]; + let pl = Planes { + n_rows: l.n(), + masks: &masks, + lanes: &lanes_, + }; + let present_groups = |filter: Filter| -> usize { + let p = lower(&Query { + filter: Filter::and([filter, Filter::is_not_null(VX)]), + agg: Agg::GroupReduce { + key: GroupAddr::Local(Col(2)), + agg: GroupAgg::SumI32(X), + }, + }) + .expect("lowers"); + let mut s = Scratch::for_program(&p, l.n()).expect("carves"); + let mut sink = vec![0i64; 3]; + execute_into(&p, &pl, &Foreign::NONE, &mut s, Out::I64(&mut sink)).expect("runs"); + normalize_group_sink(GroupAgg::SumI32(X).fold(), &mut sink) + .iter() + .map(|w| w.count_ones() as usize) + .sum() + }; + let nothing = Filter::cmp(Y, Cmp::EqI32(7)).sql_where(&NULLABLE); + + assert_eq!(sql_sum(¬hing, &l), None, "ungrouped: one NULL answer"); + assert_eq!(present_groups(nothing), 0, "grouped: no group at all"); + let everything = Filter::is_not_null(VY); + assert_eq!( + present_groups(everything), + 2, + "groups 1 and 2 have a valid x" + ); +} + +/// FAILS IF: any of `COUNT(*)`, `COUNT(x)`, `SUM(x)`, `MIN(x)`, `MAX(x)`, +/// `AVG(x)` disagrees with the Kleene oracle over random three-valued `WHERE` +/// clauses, or the corpus stops containing the cases this slice is about +/// (empty selections, all-NULL selections, real zero sums). +#[test] +fn random_where_clauses_agree_with_the_kleene_oracle() { + let mut rng = Rng(0x9E37_79B9_7F4A_7C15); + let (mut queries, mut empty, mut all_null, mut real_zero) = (0, 0, 0, 0); + for t in 0..150 { + let n = 1 + rng.below(130) as usize; + // NULL rate for x cycles through 0 %, 30 %, 70 % and 100 %. + let null_x = [0, 30, 70, 100][t % 4]; + let cell = + |rng: &mut Rng, pct: u64| (rng.below(100) >= pct).then(|| rng.below(9) as i32 - 4); + let x: Vec<_> = (0..n).map(|_| cell(&mut rng, null_x)).collect(); + let y: Vec<_> = (0..n).map(|_| cell(&mut rng, 30)).collect(); + let l = lanes(&x, &y, 3); + for _ in 0..8 { + let f = random_filter(&mut rng, 3); + let want = oracle(&f, &l); + assert_eq!(quack(&f, &l), want, "table {t}: {f:?}"); + queries += 1; + empty += usize::from(want.count_star == 0); + all_null += usize::from(want.count_star > 0 && want.count_x == 0); + real_zero += usize::from(want.sum == Some(0)); + } + } + assert!(empty > 0, "no empty selection in {queries} queries"); + assert!(all_null > 0, "no all-NULL selection in {queries} queries"); + assert!(real_zero > 0, "no real zero sum in {queries} queries"); +} + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + self.0 + } + fn below(&mut self, n: u64) -> u64 { + self.next() % n + } +} + +fn random_filter(rng: &mut Rng, depth: u32) -> Filter { + if depth == 0 || rng.below(3) == 0 { + let (col, valid) = if rng.below(2) == 0 { (X, VX) } else { (Y, VY) }; + let k = rng.below(11) as i32 - 5; + return match rng.below(8) { + 0 => Filter::is_null(valid), + 1 => Filter::is_not_null(valid), + 2 => Filter::cmp(col, Cmp::EqI32(k)), + 3 => Filter::cmp(col, Cmp::NeI32(k)), + 4 => Filter::cmp(col, Cmp::LtI32(k)), + 5 => Filter::cmp(col, Cmp::LeI32(k)), + 6 => Filter::cmp(col, Cmp::GtI32(k)), + _ => Filter::cmp(col, Cmp::GeI32(k)), + }; + } + let kids = |rng: &mut Rng| { + (0..2 + rng.below(2)) + .map(|_| random_filter(rng, depth - 1)) + .collect::>() + }; + match rng.below(3) { + 0 => Filter::And(kids(rng)), + 1 => Filter::Or(kids(rng)), + _ => Filter::negate(random_filter(rng, depth - 1)), + } +}