diff --git a/docs/ENGINE_REFACTORING.md b/docs/ENGINE_REFACTORING.md index 10ac9c6e..79fe77d6 100644 --- a/docs/ENGINE_REFACTORING.md +++ b/docs/ENGINE_REFACTORING.md @@ -472,7 +472,7 @@ feature work**, and so we can tell the difference between "this is awkward" and ### R13 — Two expression evaluators, every boolean operator implemented twice - **Status:** 🟡 IN PROGRESS — filed 2026-09-13 out of [P46](SQL_PARITY.md#p46); - **slice 1 done 2026-09-13**, slice 2 next. + **slices 1 and 2 done 2026-09-13**, slice 3 next. **The active workstream:** parity fixes that touch expression evaluation land as slices of this entry, not as patches. - **Where:** `src/data/arithmetic_evaluator.rs` (`ArithmeticEvaluator`, value → @@ -549,6 +549,22 @@ feature work**, and so we can tell the difference between "this is awkward" and [P52](SQL_PARITY.md#p52) NULL join keys on both join paths. **Slice 2's acceptance test is now concrete:** every `KNOWN` entry for the value evaluator disappears, and the corpus moves by exactly the P48/P50 cases. +- **Slice 2 result (2026-09-13).** Every boolean-producing arm of + `ArithmeticEvaluator` now answers in three-valued logic, NULL for UNKNOWN: + a predicate-layer `compare_trilean` for comparisons (the shared comparator keeps + `NULL = NULL` equal for ORDER BY), `Trilean`'s tables for `AND`/`OR`/`NOT` + (`to_bool` deleted), OR's table for `IN`/`NOT IN`, AND's for `BETWEEN`, NULL + from `LIKE`, and simple `CASE x WHEN NULL` no longer matching (found and pinned + at the start of the slice). `Trilean::from_value`/`to_value` became the one + crossing between `DataValue` and the truth tables. **The acceptance slice 1 + set held exactly:** all 32 value-evaluator `KNOWN` entries went FIXED in one + change with the WHERE column unmoved, and parity moved by exactly the six + P48/P50 cases (168 → 174 AGREE over 204). No FORMAL example churn. Scoping + first paid off here too: the only external consumers of predicate output were + the WHERE evaluator (already NULL → UNKNOWN), HAVING's `is_truthy` and `IIF` + (both NULL → not-true, already correct), so nothing downstream had to change. + **Two evaluators now agree on all 36 predicates, but there are still two** — + slices 3–6 are what make that agreement structural rather than coincidental. - **Slices, in the R10 pattern — no-op slices kept apart from the one that changes answers:** 1. ✅ **Pin the divergences, no engine change.** *(Done 2026-09-13.)* A per-operator matrix run through @@ -557,7 +573,7 @@ feature work**, and so we can tell the difference between "this is awkward" and agree, with today's disagreements marked. Plus corpus cases for the clause-level shapes above (HAVING, SELECT, JOIN ON), filed as P-findings. Acceptance: parity buckets move only by the new cases. - 2. **Make `ArithmeticEvaluator` three-valued.** The one semantic slice: + 2. ✅ **Make `ArithmeticEvaluator` three-valued.** *(Done 2026-09-13 — see result below.)* The one semantic slice: comparisons, `IN`/`NOT IN`, `BETWEEN`, `LIKE` yield NULL for a NULL operand; `AND`/`OR`/`NOT` follow the truth tables; `to_bool` stops mapping NULL to false for predicates. Closes [P48](SQL_PARITY.md#p48) and the HAVING / SELECT @@ -614,7 +630,7 @@ R8 legacy WHERE ──── independent; stage 2 is self-contained, do it in a R10 Trilean ──────── DONE; closed P18/P19 (parity 125 → 129) R11 ORDER BY resolver ─ independent; small, but a behaviour change — wants its own parity run R12 aggregate registries ─ independent; step 1 is a provable no-op, do it before the next aggregate fix -R13 one evaluator ─── ACTIVE from 2026-09-13; slice 1 (pin) → 2 (3VL, = P48) → 3 (construction) → 4–6 (retire WHERE arms) +R13 one evaluator ─── ACTIVE from 2026-09-13; slices 1 (pin) and 2 (3VL) DONE → 3 (construction) → 4–6 (retire WHERE arms) ``` **A note on ordering, from the P18/P19 work being next.** The WHERE evaluator @@ -653,3 +669,4 @@ AGREE count — which makes it safe to land well before the semantics change. | 2026-09-13 | P46 fixed: all WHERE operands resolve through one path, one `ArithmeticEvaluator` per evaluation. Parity 157 → **168 AGREE**; P49 filed | #80 | | 2026-09-13 | R13 filed and made the active workstream: two evaluators with divergent NULL semantics, three truth collapses. Probing found four live clause-dependent divergences (HAVING, SELECT `IN`, JOIN NULL keys, P48) | — | | 2026-09-13 | R13 slice 1: evaluator matrix (36 predicates × both evaluators × 5 NULL-bearing rows, DuckDB-derived expectations). WHERE 36/36, value evaluator 4/36. P48 widened; P50–P52 filed; six corpus cases, AGREE unchanged | — | +| 2026-09-13 | R13 slice 2: value evaluator three-valued; `Trilean::from_value`/`to_value`; `to_bool` removed. Matrix value column 4/36 → 36/36 in one change. Closes P48, P50 — 168 → **174 AGREE** | — | diff --git a/docs/SQL_PARITY.md b/docs/SQL_PARITY.md index 9085f610..654d1506 100644 --- a/docs/SQL_PARITY.md +++ b/docs/SQL_PARITY.md @@ -65,9 +65,9 @@ session apiece to fix properly, so **discovery is paused and the effort moves to picking them off**. Widen the corpus again when the open list is short, or opportunistically when a fix needs a case that doesn't exist yet. -Corpus coverage today: tiers 01–10, **203 cases** (168 AGREE / 20 DIFFER / -12 GAP / 1 OURS_ONLY / 2 BOTH_ERR as of 2026-09-13, after R13 slice 1 pinned -six — [P48](#p48) widened, [P50](#p50)–[P52](#p52) filed). The largest single movement so far remains the 2026-09-05 +Corpus coverage today: tiers 01–10, **204 cases** (174 AGREE / 15 DIFFER / +12 GAP / 1 OURS_ONLY / 2 BOTH_ERR as of 2026-09-13, after R13 slice 2 closed +[P48](#p48) and [P50](#p50); [P51](#p51), [P52](#p52) open). The largest single movement so far remains the 2026-09-05 NULL-ordering slice, which closed [P13](#p13) stage 2 and [P17](#p17) together — eleven cases in one change. **Tier 10 (aggregate & NULL edges) is still deliberately partial** — it holds the P14, P18–P20 and P41 cases and their @@ -92,7 +92,7 @@ Suggested fix order, by silent blast radius: | ~~9d~~ | ~~[P37](#p37) window in `WHERE` returns 0 rows~~ | ✅ **Fixed 2026-09-05** — corpus count unchanged, and that is the finding: the case is `OURS_ONLY` before *and* after, so the harness cannot see this fix or a future regression of it (first entry of that kind — the regression test is a Rust module). The filed root cause was wrong: `ExpressionLifter` *does* lift from `WHERE`. The real defect was one arm in the WHERE evaluator answering FALSE for any bare value used as a predicate — `WHERE true` returned zero rows too. It did **not** close [P15](#p15), which needs the opposite change | | ~~9e~~ | ~~[P41](#p41) `MODE` tie-break is random per run~~ | ✅ **Fixed 2026-09-06** — 152 → **156 AGREE** (four new cases). Small, as predicted, but not where it was filed: the named `ModeState` was a *shadowed* implementation and fixing it moved nothing. Reference does specify a rule and it is **first-occurrence**, not the "smallest value wins" this row proposed. Unblocked both example files, now FORMAL. Spun off [P42](#p42), [P43](#p43), [R12](ENGINE_REFACTORING.md#r12) | | ~~9g~~ | ~~[P46](#p46) column-vs-column `WHERE` returns 0 rows~~ | ✅ **Fixed 2026-09-13** — 157 → **168 AGREE**. Wider than filed: *every* non-literal right-hand operand read as NULL (comparisons, `IN` items, `BETWEEN` bounds), and a literal on the left errored (`WHERE 1=0`). One resolver for all operands closed it. Did **not** need [P48](#p48) alongside, as this row predicted — the fix kept the comparison in the WHERE evaluator. Spun off [P49](#p49) (correlated outer refs bind to the inner table), accepted knowingly | -| **NEXT** | [R13](ENGINE_REFACTORING.md#r13) one expression evaluator — **the active workstream from 2026-09-13** | Not a finding but the reason several are cheap to fix only once. WHERE and the value evaluator implement every boolean operator separately with different NULL rules, so the same predicate answers differently in WHERE, HAVING, SELECT and JOIN ON. **Working rule:** a parity fix that touches expression evaluation lands as an R13 slice, not a patch. [P48](#p48) *is* R13 slice 2 | +| **NEXT** | [R13](ENGINE_REFACTORING.md#r13) one expression evaluator — **the active workstream from 2026-09-13** | **Slices 1–2 done 2026-09-13** (value evaluator three-valued, 168 → 174 AGREE, closed P48/P50); slice 3 (one construction path) next. Not a finding but the reason several are cheap to fix only once. WHERE and the value evaluator implement every boolean operator separately with different NULL rules, so the same predicate answers differently in WHERE, HAVING, SELECT and JOIN ON. **Working rule:** a parity fix that touches expression evaluation lands as an R13 slice, not a patch. [P52](#p52) (NULL join keys) is slice 6 | | then | [P47](#p47) `MIN`/`MAX` ranked by type | Silent and cheap, and outside the evaluator (aggregate registries — confirm the live one first, [R12](ENGINE_REFACTORING.md#r12)), so it can land alongside R13 without breaking the working rule | | then | [P14](#p14), [P20](#p20), [P23](#p23) | Smaller, self-contained, decisions already taken. Was row 9b, then NEXT until the 2026-09-12 findings displaced it. **Check each against the R13 working rule first** — P20 (`\|\|` with NULL) is an operator in the value evaluator | | 9f | [P42](#p42) `MODE` is numeric-only | Companion to [R12](ENGINE_REFACTORING.md#r12), and cheap if taken with it: the shadowed implementation already handles non-numerics and preserves type, so the fix is largely to stop the live path throwing away what it knows. Also buys the corpus its clearest tie-break case | @@ -2246,11 +2246,13 @@ out of date. --- ### P48 — The value evaluator is two-valued: a NULL operand projects `false` (or `true`) instead of NULL -- **Status:** 🔴 OPEN — silent, and it disagrees with our own `WHERE` evaluator. - **This is [R13](ENGINE_REFACTORING.md#r13) slice 2.** +- **Status:** 🟢 FIXED 2026-09-13 as [R13](ENGINE_REFACTORING.md#r13) slice 2 — + 168 → 174 AGREE with [P50](#p50). All six faults below closed in one change. - **Corpus:** `01_select.toml :: select_col_vs_col_null_operand`, - `select_eq_null_literal`, `select_in_list_with_null`, `select_and_with_null` - (all `expect = "DIFFER"`). Per operator: `tests/evaluator_matrix_tests.rs`. + `select_eq_null_literal`, `select_in_list_with_null`, `select_and_with_null`, + `simple_case_when_null` (all were DIFFER, now AGREE). Per operator: + `tests/evaluator_matrix_tests.rs`, whose value-evaluator column went from 4/36 + to 36/36. - **Widened 2026-09-13 by the R13 matrix — filed as one comparison, it is the whole evaluator.** 36 predicates, each run through both evaluators over a five-row table with NULL in every operand position, expected answers taken @@ -2318,6 +2320,19 @@ out of date. empty string — the same coercion instinct in the string operators), [P46](#p46). +- **Fix (2026-09-13).** `ArithmeticEvaluator` now evaluates predicates in + three-valued logic with NULL standing for UNKNOWN. Comparisons go through a + predicate-layer `compare_trilean` (NULL on either side → NULL) — the shared + `compare_values` still reports `NULL = NULL` as equal, because ORDER BY needs + it, exactly as R10 left it. `AND`/`OR`/`NOT` use `Trilean`'s truth tables; + `to_bool` is gone. `IN`/`NOT IN` take OR's table, `BETWEEN` AND's, `LIKE` + yields NULL. `Trilean::from_value`/`to_value` are the one crossing between the + two representations. Found at the start of the slice and pinned first: simple + `CASE x WHEN NULL` matched a NULL `x` through a separate `values_equal`, now + also fixed. +- **What did not move, deliberately:** join matching ([P52](#p52), slice 6) and + case-insensitive comparison in this evaluator (slice 3). FORMAL examples had + no expectation churn — none captured a projected `false` for NULL. --- ### P49 — An unknown table prefix silently falls back to the unqualified column @@ -2369,8 +2384,11 @@ out of date. --- ### P50 — `HAVING` admits a group whose predicate is UNKNOWN under `NOT` -- **Status:** 🔴 OPEN — silent, too many rows. [R13](ENGINE_REFACTORING.md#r13) slice 2. -- **Corpus:** `07_grouping.toml :: having_not_over_null` (`expect = "DIFFER"`). +- **Status:** 🟢 FIXED 2026-09-13 by [R13](ENGINE_REFACTORING.md#r13) slice 2, with + [P48](#p48). The comparison now reaches `NOT` as NULL, so it stays NULL and + `is_truthy` drops the group. `is_truthy` itself remains, to be replaced by the + single collapse in slice 6. +- **Corpus:** `07_grouping.toml :: having_not_over_null` (was DIFFER, now AGREE). - **Observed:** `GROUP BY team HAVING NOT (MIN(score) > 40)` on `null_edges.csv` returns **4** groups; DuckDB returns **3**. The `delta` group's scores are all NULL, so `MIN(score) > 40` is UNKNOWN and `NOT` of it stays UNKNOWN — the group diff --git a/src/data/arithmetic_evaluator.rs b/src/data/arithmetic_evaluator.rs index 4f5de847..24492166 100644 --- a/src/data/arithmetic_evaluator.rs +++ b/src/data/arithmetic_evaluator.rs @@ -1,6 +1,7 @@ use crate::config::global::get_date_notation; use crate::data::data_view::DataView; use crate::data::datatable::{DataTable, DataValue}; +use crate::data::trilean::Trilean; use crate::data::value_comparisons::compare_with_op; use crate::sql::aggregate_functions::AggregateFunctionRegistry; // New registry use crate::sql::aggregates::AggregateRegistry; // Old registry (for migration) @@ -159,49 +160,34 @@ impl<'a> ArithmeticEvaluator<'a> { let base_value = self.evaluate(base, row_index)?; self.evaluate_method_on_value(&base_value, method, args, row_index) } + // BETWEEN is `value >= lower AND value <= upper`, so it takes AND's + // truth table: FALSE on either side wins even over UNKNOWN. SqlExpression::Between { expr, lower, upper } => { let val = self.evaluate(expr, row_index)?; let lo = self.evaluate(lower, row_index)?; let hi = self.evaluate(upper, row_index)?; - let ge = compare_with_op(&val, &lo, ">=", false); - let le = compare_with_op(&val, &hi, "<=", false); - Ok(DataValue::Boolean(ge && le)) + let ge = compare_trilean(&val, &lo, ">="); + let le = compare_trilean(&val, &hi, "<="); + Ok(ge.and(le).to_value()) } // Logical negation as a value-producing expression. Reached, e.g., by // a post-aggregation `HAVING NOT (COUNT(*) > 2)` predicate (P10). - // Three-valued logic: NOT NULL is NULL; otherwise negate the boolean. + // NOT UNKNOWN is UNKNOWN - which only holds if the operand arrives + // as NULL, hence the three-valued comparison arms above. SqlExpression::Not { expr } => { let inner = self.evaluate(expr, row_index)?; - match inner { - DataValue::Null => Ok(DataValue::Null), - other => Ok(DataValue::Boolean(!self.to_bool(&other)?)), - } + Ok(truth_of(&inner)?.negate().to_value()) } - // IN / NOT IN as a value-producing expression — needed when they - // appear inside CASE branches or other arithmetic contexts. The - // WHERE path has its own evaluator; this mirrors its equality - // semantics via compare_with_op. After subquery rewriting, an - // `x IN (SELECT ...)` arrives here as an InList of literals. + // IN / NOT IN as a value-producing expression — used by SELECT, + // HAVING and CASE. After subquery rewriting, an `x IN (SELECT ...)` + // arrives here as an InList of literals. SqlExpression::InList { expr, values } => { - let val = self.evaluate(expr, row_index)?; - for v in values { - let item = self.evaluate(v, row_index)?; - if compare_with_op(&val, &item, "=", false) { - return Ok(DataValue::Boolean(true)); - } - } - Ok(DataValue::Boolean(false)) - } - SqlExpression::NotInList { expr, values } => { - let val = self.evaluate(expr, row_index)?; - for v in values { - let item = self.evaluate(v, row_index)?; - if compare_with_op(&val, &item, "=", false) { - return Ok(DataValue::Boolean(false)); - } - } - Ok(DataValue::Boolean(true)) + Ok(self.evaluate_in_list(expr, values, row_index)?.to_value()) } + SqlExpression::NotInList { expr, values } => Ok(self + .evaluate_in_list(expr, values, row_index)? + .negate() + .to_value()), SqlExpression::CaseExpression { when_branches, else_branch, @@ -395,28 +381,22 @@ impl<'a> ArithmeticEvaluator<'a> { let args = vec![left.clone(), right.clone()]; self.evaluate_function("MOD", &args, row_index) } - // Comparison operators (return boolean results) - // Use centralized comparison logic for consistency + // Predicates are three-valued: UNKNOWN is returned as NULL, and only + // the clause that filters on the result collapses it (R13). See + // tests/evaluator_matrix_tests.rs for the per-operator contract. ">" | "<" | ">=" | "<=" | "=" | "!=" | "<>" => { - let result = compare_with_op(&left_val, &right_val, op, false); - Ok(DataValue::Boolean(result)) + Ok(compare_trilean(&left_val, &right_val, op).to_value()) } - // IS NULL / IS NOT NULL operators + // IS NULL / IS NOT NULL are the sanctioned NULL tests, so two-valued. "IS NULL" => Ok(DataValue::Boolean(matches!(left_val, DataValue::Null))), "IS NOT NULL" => Ok(DataValue::Boolean(!matches!(left_val, DataValue::Null))), - // Logical operators - "AND" => { - let left_bool = self.to_bool(&left_val)?; - let right_bool = self.to_bool(&right_val)?; - Ok(DataValue::Boolean(left_bool && right_bool)) - } - "OR" => { - let left_bool = self.to_bool(&left_val)?; - let right_bool = self.to_bool(&right_val)?; - Ok(DataValue::Boolean(left_bool || right_bool)) - } + "AND" => Ok(truth_of(&left_val)?.and(truth_of(&right_val)?).to_value()), + "OR" => Ok(truth_of(&left_val)?.or(truth_of(&right_val)?).to_value()), // LIKE operator - SQL pattern matching "LIKE" => { + if matches!(left_val, DataValue::Null) || matches!(right_val, DataValue::Null) { + return Ok(DataValue::Null); + } let text = self.value_to_string(&left_val); let pattern = self.value_to_string(&right_val); let matches = self.sql_like_match(&text, &pattern); @@ -518,17 +498,6 @@ impl<'a> ArithmeticEvaluator<'a> { } } - /// Convert a DataValue to boolean for logical operations - fn to_bool(&self, value: &DataValue) -> Result { - match value { - DataValue::Boolean(b) => Ok(*b), - DataValue::Integer(i) => Ok(*i != 0), - DataValue::Float(f) => Ok(*f != 0.0), - DataValue::Null => Ok(false), - _ => Err(anyhow!("Cannot convert {:?} to boolean", value)), - } - } - /// Convert DataValue to string for pattern matching fn value_to_string(&self, value: &DataValue) -> String { match value { @@ -1562,10 +1531,33 @@ impl<'a> ArithmeticEvaluator<'a> { } } - /// Check if two DataValues are equal + /// `x IN (a, b, ...)` is `x = a OR x = b OR ...`, so it takes OR's truth + /// table: a match anywhere is TRUE, otherwise any UNKNOWN comparison (a NULL + /// probe, or a NULL in the list) makes the answer UNKNOWN rather than FALSE. + /// `NOT IN` is the negation, which is what keeps NULL rows out of it. + fn evaluate_in_list( + &mut self, + expr: &SqlExpression, + values: &[SqlExpression], + row_index: usize, + ) -> Result { + let val = self.evaluate(expr, row_index)?; + let mut result = Trilean::False; + for v in values { + let item = self.evaluate(v, row_index)?; + result = result.or(compare_trilean(&val, &item, "=")); + if result.is_true() { + break; + } + } + Ok(result) + } + + /// Check if two DataValues are equal, for simple `CASE x WHEN v`. The WHEN + /// test is `x = v`, so a NULL on either side is not a match - including + /// `CASE NULL WHEN NULL`. fn values_equal(&self, left: &DataValue, right: &DataValue) -> Result { match (left, right) { - (DataValue::Null, DataValue::Null) => Ok(true), (DataValue::Null, _) | (_, DataValue::Null) => Ok(false), (DataValue::Integer(a), DataValue::Integer(b)) => Ok(a == b), (DataValue::Float(a), DataValue::Float(b)) => Ok((a - b).abs() < f64::EPSILON), @@ -1665,6 +1657,28 @@ impl<'a> ArithmeticEvaluator<'a> { } } +/// A comparison under SQL three-valued logic: NULL on either side is UNKNOWN. +/// +/// The NULL test belongs here, at the predicate layer, and must not move into +/// `compare_with_op`: the `compare_values` beneath it reports `NULL = NULL` as +/// equal on purpose, because ORDER BY needs NULLs to group. Reusing that answer +/// for predicates is what made `SELECT x = NULL` TRUE for NULL rows (P48, the +/// value-evaluator twin of P18). Same rule as the WHERE evaluator's +/// `compare_trilean`, which R13 slice 4 will make the only copy. +fn compare_trilean(left: &DataValue, right: &DataValue, op: &str) -> Trilean { + if matches!(left, DataValue::Null) || matches!(right, DataValue::Null) { + return Trilean::Unknown; + } + // Case sensitivity is not yet threaded into this evaluator - R13 slice 3. + Trilean::from_bool(compare_with_op(left, right, op, false)) +} + +/// The truth value of an operand of AND / OR / NOT. A value with no truth value +/// (a string, a date) is an error, as it was before three-valued logic. +fn truth_of(value: &DataValue) -> Result { + Trilean::from_value(value).ok_or_else(|| anyhow!("Cannot convert {:?} to boolean", value)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/data/trilean.rs b/src/data/trilean.rs index aea7cbe2..5f000f67 100644 --- a/src/data/trilean.rs +++ b/src/data/trilean.rs @@ -11,9 +11,11 @@ //! not be represented at all and `NOT` / `NOT IN` flipped it into `TRUE` — //! producing extra rows. See findings P18/P19 in `docs/SQL_PARITY.md`. //! -//! This module is deliberately standalone: it defines the truth tables and -//! proves them with tests, ahead of the evaluator being converted to use it. +//! Both evaluators now use it: the WHERE evaluator returns `Trilean` directly +//! (R10), and the value evaluator computes predicates as `DataValue`s with NULL +//! standing for UNKNOWN, crossing over through `from_value` / `to_value` (R13). +use crate::data::datatable::DataValue; use std::fmt; use std::ops::{BitAnd, BitOr, Not}; @@ -97,6 +99,32 @@ impl Trilean { pub fn is_predicate(self, expected: Trilean) -> Trilean { Trilean::from_bool(self == expected) } + + /// Read a truth value out of an evaluated expression, where NULL is how the + /// value evaluator spells UNKNOWN. Numbers keep their long-standing + /// zero/non-zero reading. `None` means the value has no truth value at all + /// (a string, a date), which callers report as an error rather than guess. + /// + /// This is the value-evaluator side of the R13 boundary: predicates are + /// evaluated as `DataValue`s, and this is where they become `Trilean`. + pub fn from_value(value: &DataValue) -> Option { + match value { + DataValue::Boolean(b) => Some(Trilean::from_bool(*b)), + DataValue::Null => Some(Trilean::Unknown), + DataValue::Integer(i) => Some(Trilean::from_bool(*i != 0)), + DataValue::Float(f) => Some(Trilean::from_bool(*f != 0.0)), + _ => None, + } + } + + /// The value a predicate evaluates to: `UNKNOWN` is NULL. + pub fn to_value(self) -> DataValue { + match self { + Trilean::True => DataValue::Boolean(true), + Trilean::False => DataValue::Boolean(false), + Trilean::Unknown => DataValue::Null, + } + } } impl From for Trilean { @@ -191,6 +219,24 @@ mod tests { } } + #[test] + fn value_round_trip_spells_unknown_as_null() { + // The R13 boundary: the value evaluator carries UNKNOWN as NULL, so the + // conversion must never turn NULL into FALSE in either direction. + for t in ALL { + assert_eq!(Trilean::from_value(&t.to_value()), Some(t), "{t}"); + } + assert_eq!(Unknown.to_value(), DataValue::Null); + assert_eq!(Trilean::from_value(&DataValue::Null), Some(Unknown)); + assert_eq!(Trilean::from_value(&DataValue::Integer(0)), Some(False)); + assert_eq!(Trilean::from_value(&DataValue::Float(2.5)), Some(True)); + assert_eq!( + Trilean::from_value(&DataValue::String("yes".to_string())), + None, + "a string has no truth value; callers must error, not guess" + ); + } + #[test] fn not_truth_table() { assert_eq!(True.negate(), False); diff --git a/tests/comparison/corpus/01_select.toml b/tests/comparison/corpus/01_select.toml index dab203b8..12614128 100644 --- a/tests/comparison/corpus/01_select.toml +++ b/tests/comparison/corpus/01_select.toml @@ -78,7 +78,7 @@ sql = "SELECT id, partner_id, id < partner_id AS lt FROM null_edges WHERE partne id = "select_col_vs_col_null_operand" data = "null_edges.csv" sql = "SELECT id, partner_id, id < partner_id AS lt FROM null_edges ORDER BY id" -expect = "DIFFER" +# FIXED 2026-09-13 by R13 slice 2 (value evaluator made three-valued); was DIFFER. # P48, found 2026-09-12 while building the P46 anchor above. Same query without # the IS NOT NULL guard: for a NULL operand we project false, DuckDB projects # NULL. The three-valued-logic fix of P18/P19 landed in the WHERE evaluator; @@ -93,7 +93,7 @@ expect = "DIFFER" id = "select_eq_null_literal" data = "null_edges.csv" sql = "SELECT id, score = NULL AS eq FROM null_edges ORDER BY id" -expect = "DIFFER" +# FIXED 2026-09-13 by R13 slice 2 (value evaluator made three-valued); was DIFFER. # P48. `= NULL` is UNKNOWN for every row. The value evaluator compares NULL to # NULL as *equal*, so the NULL-scored rows project TRUE - P18, the bug R10 fixed # in WHERE, still live in SELECT. @@ -102,7 +102,7 @@ expect = "DIFFER" id = "select_in_list_with_null" data = "null_edges.csv" sql = "SELECT id, score IN (50, NULL) AS hit FROM null_edges ORDER BY id" -expect = "DIFFER" +# FIXED 2026-09-13 by R13 slice 2 (value evaluator made three-valued); was DIFFER. # P48. A NULL probe matches the NULL item (TRUE, should be NULL), and a # non-matching score against a list holding NULL is FALSE (should be NULL). @@ -110,7 +110,7 @@ expect = "DIFFER" id = "select_and_with_null" data = "null_edges.csv" sql = "SELECT id, (score > 40 AND team = 'alpha') AS both FROM null_edges ORDER BY id" -expect = "DIFFER" +# FIXED 2026-09-13 by R13 slice 2 (value evaluator made three-valued); was DIFFER. # P48. AND collapses each side through to_bool (NULL -> false), so TRUE AND # UNKNOWN projects false instead of NULL. Parenthesised because the bare form # does not parse: see select_and_unparenthesised (P51). @@ -123,3 +123,13 @@ expect = "GAP" # P51, found 2026-09-13 building the R13 matrix. AND/OR are not accepted in a # SELECT item: "Unexpected keyword 'AND' after end of statement". The # parenthesised form above parses. A parser gap, not an evaluator one. + +[[case]] +id = "simple_case_when_null" +data = "null_edges.csv" +sql = "SELECT id, CASE score WHEN NULL THEN 'matched null' ELSE 'other' END AS k FROM null_edges ORDER BY id" +# FIXED 2026-09-13 by R13 slice 2 (value evaluator made three-valued); was DIFFER. +# P48, pinned 2026-09-13 at the start of R13 slice 2. A simple CASE compares the +# operand to each WHEN value with `=`, so `CASE x WHEN NULL` never matches - every +# row is 'other'. Ours compares (Null, Null) as equal and the NULL-scored rows +# report 'matched null'. Not a predicate, so the evaluator matrix cannot see it. diff --git a/tests/comparison/corpus/07_grouping.toml b/tests/comparison/corpus/07_grouping.toml index 52461fb3..6759283a 100644 --- a/tests/comparison/corpus/07_grouping.toml +++ b/tests/comparison/corpus/07_grouping.toml @@ -67,7 +67,7 @@ sql = "SELECT region, COUNT(*) AS n FROM international_sales GROUP BY region HAV id = "having_not_over_null" data = "null_edges.csv" sql = "SELECT team, COUNT(*) AS n FROM null_edges GROUP BY team HAVING NOT (MIN(score) > 40) ORDER BY team" -expect = "DIFFER" +# FIXED 2026-09-13 by R13 slice 2 (value evaluator made three-valued); was DIFFER. # P50, found 2026-09-13 writing R13. The `delta` group's scores are all NULL, so # MIN(score) > 40 is UNKNOWN there and NOT of it stays UNKNOWN: DuckDB keeps # beta, gamma and the NULL team, and drops delta. HAVING runs on the value diff --git a/tests/evaluator_matrix_tests.rs b/tests/evaluator_matrix_tests.rs index d8eb5715..66b71608 100644 --- a/tests/evaluator_matrix_tests.rs +++ b/tests/evaluator_matrix_tests.rs @@ -127,220 +127,13 @@ struct Known { why: &'static str, } -const COMPARISON: &str = "value evaluator compares without a NULL guard (P48)"; -const NOT_OVER_FALSE: &str = - "NOT receives false for a NULL comparison and negates it to TRUE (P48, P50)"; -const IN_LIST: &str = "value evaluator IN/NOT IN is two-valued and matches NULL to NULL (P48)"; -const BETWEEN: &str = "value evaluator BETWEEN is two-valued (P48)"; -const LIKE: &str = "value evaluator LIKE answers false for NULL (P48)"; -const AND_OR: &str = "value evaluator AND/OR collapse NULL to false via to_bool (P48)"; - -const KNOWN: &[Known] = &[ - // Comparisons. `compare_with_op` with no NULL guard: NULL against a value is - // false, and NULL against NULL compares *equal*, so `=`, `<=`, `>=` answer TRUE - // (the P18 rule R10 fixed in WHERE, still live here). P48. - Known { - evaluator: Evaluator::Value, - predicate: "a < b", - observed: "TFFFF", - why: COMPARISON, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a = b", - observed: "FFFFT", - why: COMPARISON, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a <> b", - observed: "TTFFF", - why: COMPARISON, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a >= b", - observed: "FTFFT", - why: COMPARISON, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a = 1", - observed: "TFFTF", - why: COMPARISON, - }, - Known { - evaluator: Evaluator::Value, - predicate: "1 < b", - observed: "TFFFF", - why: COMPARISON, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a = NULL", - observed: "FFTFT", - why: COMPARISON, - }, - Known { - evaluator: Evaluator::Value, - predicate: "s = 'abc'", - observed: "TFFTF", - why: COMPARISON, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a + 0 < b", - observed: "TFFFF", - why: COMPARISON, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a + b > 2", - observed: "TTFFF", - why: COMPARISON, - }, - Known { - evaluator: Evaluator::Value, - predicate: "COALESCE(a, 0) < b", - observed: "TFTFF", - why: COMPARISON, - }, - // NOT itself is three-valued in the value evaluator, but its operand arrives - // as `false`, so every NULL row negates to TRUE. This is how HAVING admits - // groups it must exclude (P50). - Known { - evaluator: Evaluator::Value, - predicate: "NOT (a < b)", - observed: "FTTTT", - why: NOT_OVER_FALSE, - }, - Known { - evaluator: Evaluator::Value, - predicate: "NOT (a = 1)", - observed: "FTTFT", - why: NOT_OVER_FALSE, - }, - Known { - evaluator: Evaluator::Value, - predicate: "NOT (a + 0 < b)", - observed: "FTTTT", - why: NOT_OVER_FALSE, - }, - // IN / NOT IN: two-valued, built on the same equality, so a NULL probe matches - // a NULL item and a miss is FALSE rather than UNKNOWN. P48. - Known { - evaluator: Evaluator::Value, - predicate: "a IN (1, 3)", - observed: "TFFTF", - why: IN_LIST, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a IN (1, NULL)", - observed: "TFTTT", - why: IN_LIST, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a IN (b, 3)", - observed: "FFFFT", - why: IN_LIST, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a NOT IN (3, 4)", - observed: "TTTTT", - why: IN_LIST, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a NOT IN (1, NULL)", - observed: "FTFFF", - why: IN_LIST, - }, - Known { - evaluator: Evaluator::Value, - predicate: "NOT (a IN (1, 3))", - observed: "FTTFT", - why: IN_LIST, - }, - // BETWEEN: two plain comparisons joined with `&&`. P48. - Known { - evaluator: Evaluator::Value, - predicate: "a BETWEEN 1 AND 5", - observed: "TTFTF", - why: BETWEEN, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a BETWEEN 1 AND b", - observed: "TFFFF", - why: BETWEEN, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a BETWEEN b AND 5", - observed: "FTFFF", - why: BETWEEN, - }, - Known { - evaluator: Evaluator::Value, - predicate: "NOT (a BETWEEN 1 AND b)", - observed: "FTTTT", - why: BETWEEN, - }, - // LIKE: a NULL operand answers false. P48. - Known { - evaluator: Evaluator::Value, - predicate: "s LIKE 'a%'", - observed: "TFFTF", - why: LIKE, - }, - Known { - evaluator: Evaluator::Value, - predicate: "NOT (s LIKE 'a%')", - observed: "FTTFT", - why: LIKE, - }, - // AND / OR: operands collapse through `to_bool` (NULL -> false), so there are - // no truth tables and UNKNOWN can never survive. P48. - Known { - evaluator: Evaluator::Value, - predicate: "a > 0 AND b > 1", - observed: "TFFFF", - why: AND_OR, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a > 1 OR b > 1", - observed: "TTFFF", - why: AND_OR, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a > 5 AND b > 5", - observed: "FFFFF", - why: AND_OR, - }, - Known { - evaluator: Evaluator::Value, - predicate: "a > 5 OR b IS NULL", - observed: "FFFTT", - why: AND_OR, - }, - Known { - evaluator: Evaluator::Value, - predicate: "NOT (a > 0 AND b > 1)", - observed: "FTTTT", - why: AND_OR, - }, - Known { - evaluator: Evaluator::Value, - predicate: "NOT (a > 1 OR b > 1)", - observed: "FFTTT", - why: AND_OR, - }, -]; +// Empty since R13 slice 2 (2026-09-13), which made the value evaluator +// three-valued: all 32 value-evaluator entries recorded by slice 1 - comparisons +// (including NULL = NULL comparing equal), NOT, IN / NOT IN, BETWEEN, LIKE and +// AND / OR - went FIXED in one change, and the WHERE column did not move. +// Keep the mechanism: a new predicate that exposes a divergence is recorded +// here, with its finding, rather than left failing. +const KNOWN: &[Known] = &[]; fn trilean_char(t: Trilean) -> char { match t {