Skip to content

Commit baae2dd

Browse files
committed
Address stats pruning review comments
Signed-off-by: "Nicholas Gates" <nick@nickgates.com>
1 parent d81033e commit baae2dd

3 files changed

Lines changed: 108 additions & 44 deletions

File tree

docs/developer-guide/internals/stats-pruning.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@ Vortex uses statistics to prove when a filter cannot match a row group, zone, or
44
file. The proof expression returns `true` when the input can be skipped. It
55
returns `false` or `null` when pruning is not proven.
66

7+
Both `false` and `null` are non-pruning outcomes, but they mean different
8+
things. `false` means the available stats disproved the skip proof. `null` means
9+
the proof was unknown, usually because a required stat was missing or inexact.
10+
711
The pruning pipeline has two phases:
812

913
1. `Expression::falsify(scope, session)` asks the session's
1014
`StatsRewriteRule`s to rewrite a filter into an abstract proof expression.
1115
Rules describe semantics in terms of `vortex.stat(input, aggregate_fn)`
12-
placeholders, so the rewrite does not depend on a particular layout.
16+
placeholders. These placeholders name the statistic needed by the proof, but
17+
not where that statistic is stored.
1318
2. `bind_stats` lowers those abstract stat placeholders with a `StatBinder`.
1419
The binder maps stats to the representation used by the caller, such as
1520
zone-map table fields, file-level stat literals, or typed null literals for

vortex-array/src/scalar_fn/fns/binary/mod.rs

Lines changed: 89 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ use crate::scalar_fn::ChildName;
2626
use crate::scalar_fn::ExecutionArgs;
2727
use crate::scalar_fn::ScalarFnId;
2828
use crate::scalar_fn::ScalarFnVTable;
29+
use crate::scalar_fn::SimplifyCtx;
2930
use crate::scalar_fn::fns::literal::Literal;
3031
use crate::scalar_fn::fns::operators::CompareOperator;
3132
use crate::scalar_fn::fns::operators::Operator;
@@ -43,40 +44,6 @@ use crate::scalar::Scalar;
4344
#[derive(Clone)]
4445
pub struct Binary;
4546

46-
fn simplify_and(lhs: &Expression, rhs: &Expression) -> Option<Expression> {
47-
match (bool_literal(lhs), bool_literal(rhs)) {
48-
(Some(Some(false)), _) | (_, Some(Some(false))) => Some(lit(false)),
49-
(Some(Some(true)), _) => Some(rhs.clone()),
50-
(_, Some(Some(true))) => Some(lhs.clone()),
51-
(Some(None), Some(None)) => Some(lhs.clone()),
52-
_ => None,
53-
}
54-
}
55-
56-
fn simplify_or(lhs: &Expression, rhs: &Expression) -> Option<Expression> {
57-
match (bool_literal(lhs), bool_literal(rhs)) {
58-
(Some(Some(true)), _) | (_, Some(Some(true))) => Some(lit(true)),
59-
(Some(Some(false)), _) => Some(rhs.clone()),
60-
(_, Some(Some(false))) => Some(lhs.clone()),
61-
(Some(None), Some(None)) => Some(lhs.clone()),
62-
_ => None,
63-
}
64-
}
65-
66-
fn bool_literal(expr: &Expression) -> Option<Option<bool>> {
67-
expr.as_opt::<Literal>()?
68-
.as_bool_opt()
69-
.map(|value| value.value())
70-
}
71-
72-
fn is_null_literal(expr: &Expression) -> bool {
73-
expr.as_opt::<Literal>().is_some_and(Scalar::is_null)
74-
}
75-
76-
fn null_bool() -> Expression {
77-
lit(Scalar::null(DType::Bool(Nullability::Nullable)))
78-
}
79-
8047
impl ScalarFnVTable for Binary {
8148
type Options = Operator;
8249

@@ -201,17 +168,67 @@ impl ScalarFnVTable for Binary {
201168
let lhs = expr.child(0);
202169
let rhs = expr.child(1);
203170

204-
if operator.is_comparison() && (is_null_literal(lhs) || is_null_literal(rhs)) {
205-
return Ok(Some(null_bool()));
206-
}
207-
171+
let bool_literal = |expr: &Expression| {
172+
expr.as_opt::<Literal>()?
173+
.as_bool_opt()
174+
.map(|value| value.value())
175+
};
176+
177+
// AND/OR use Kleene three-valued logic. `None` below is a boolean null.
178+
//
179+
// AND:
180+
// - false AND x => false
181+
// - true AND x => x
182+
// - null AND null => null
183+
//
184+
// OR:
185+
// - true OR x => true
186+
// - false OR x => x
187+
// - null OR null => null
188+
//
189+
// Other null cases either fall out of the identity/annihilator rules
190+
// above (`null AND true`, `null OR false`) or cannot be simplified under
191+
// Kleene semantics (`null AND x`, `null OR x` for non-literal `x`).
208192
Ok(match operator {
209-
Operator::And => simplify_and(lhs, rhs),
210-
Operator::Or => simplify_or(lhs, rhs),
193+
Operator::And => match (bool_literal(lhs), bool_literal(rhs)) {
194+
(Some(Some(false)), _) | (_, Some(Some(false))) => Some(lit(false)),
195+
(Some(Some(true)), _) => Some(rhs.clone()),
196+
(_, Some(Some(true))) => Some(lhs.clone()),
197+
(Some(None), Some(None)) => Some(lhs.clone()),
198+
_ => None,
199+
},
200+
Operator::Or => match (bool_literal(lhs), bool_literal(rhs)) {
201+
(Some(Some(true)), _) | (_, Some(Some(true))) => Some(lit(true)),
202+
(Some(Some(false)), _) => Some(rhs.clone()),
203+
(_, Some(Some(false))) => Some(lhs.clone()),
204+
(Some(None), Some(None)) => Some(lhs.clone()),
205+
_ => None,
206+
},
211207
_ => None,
212208
})
213209
}
214210

211+
fn simplify(
212+
&self,
213+
operator: &Operator,
214+
expr: &Expression,
215+
ctx: &dyn SimplifyCtx,
216+
) -> VortexResult<Option<Expression>> {
217+
let is_literal_null =
218+
|expr: &Expression| expr.as_opt::<Literal>().is_some_and(Scalar::is_null);
219+
220+
if operator.is_comparison()
221+
&& (is_literal_null(expr.child(0)) || is_literal_null(expr.child(1)))
222+
{
223+
// Validate the comparison before reducing it. This preserves type
224+
// errors for expressions like `int_col = null_utf8`.
225+
ctx.return_dtype(expr)?;
226+
return Ok(Some(lit(Scalar::null(DType::Bool(Nullability::Nullable)))));
227+
}
228+
229+
Ok(None)
230+
}
231+
215232
fn validity(
216233
&self,
217234
operator: &Operator,
@@ -257,6 +274,7 @@ impl ScalarFnVTable for Binary {
257274
#[cfg(test)]
258275
mod tests {
259276
use vortex_error::VortexExpect;
277+
use vortex_error::VortexResult;
260278

261279
use super::*;
262280
use crate::LEGACY_SESSION;
@@ -265,6 +283,7 @@ mod tests {
265283
use crate::builtins::ArrayBuiltins;
266284
use crate::dtype::DType;
267285
use crate::dtype::Nullability;
286+
use crate::dtype::PType;
268287
use crate::expr::Expression;
269288
use crate::expr::and_collect;
270289
use crate::expr::col;
@@ -389,6 +408,36 @@ mod tests {
389408
);
390409
}
391410

411+
#[test]
412+
fn comparison_with_typed_null_simplifies_after_type_check() -> VortexResult<()> {
413+
let dtype = test_harness::struct_dtype();
414+
415+
let expr = eq(
416+
col("col1"),
417+
lit(Scalar::null(DType::Primitive(
418+
PType::U16,
419+
Nullability::Nullable,
420+
))),
421+
);
422+
423+
assert_eq!(
424+
expr.optimize_recursive(&dtype)?,
425+
lit(Scalar::null(DType::Bool(Nullability::Nullable)))
426+
);
427+
Ok(())
428+
}
429+
430+
#[test]
431+
fn comparison_with_incompatible_null_still_type_checks() {
432+
let dtype = test_harness::struct_dtype();
433+
let expr = eq(
434+
col("col1"),
435+
lit(Scalar::null(DType::Utf8(Nullability::Nullable))),
436+
);
437+
438+
assert!(expr.optimize_recursive(&dtype).is_err());
439+
}
440+
392441
#[test]
393442
fn test_display_print() {
394443
let expr = gt(lit(1), lit(2));

vortex-array/src/stats/bind.rs

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,14 @@
44
//! Bind abstract `vortex.stat` expressions to a concrete stats representation.
55
//!
66
//! Stats rewrite rules describe pruning in terms of `vortex.stat(input, aggregate_fn)` placeholders
7-
//! so the rewrite is independent of where statistics are stored. Binding is the later pass that
8-
//! replaces those placeholders with the representation used by a caller: zone-map field references,
9-
//! file-level stat literals, or typed nulls for missing stats.
7+
//! so the rewrite is independent of where statistics are stored. These stat placeholders are
8+
//! abstract because they name the statistic needed for a proof, but not how that statistic is
9+
//! represented by a specific layout or reader.
10+
//!
11+
//! Binding is the later pass that replaces each abstract placeholder with the representation used
12+
//! by a caller: zone-map field references, file-level stat literals, or typed nulls for missing
13+
//! stats. This lets all callers share the same falsification rules while keeping layout-specific
14+
//! stat storage behind [`StatBinder`].
1015
1116
use vortex_error::VortexResult;
1217

@@ -21,6 +26,11 @@ use crate::scalar::Scalar;
2126
use crate::scalar_fn::fns::stat::StatFn;
2227

2328
/// A target that can bind abstract statistics to concrete expressions.
29+
///
30+
/// Implementations define how a pruning proof should read stats from a specific backing
31+
/// representation. For example, a zone-map binder can translate a `max(col)` placeholder into a
32+
/// field reference in the per-zone stats table, while a file-stats binder can translate the same
33+
/// placeholder into a literal value from the file footer.
2434
pub trait StatBinder {
2535
/// The dtype scope used to type-check expressions before stats are bound.
2636
fn scope(&self) -> &DType;

0 commit comments

Comments
 (0)