Skip to content

Commit fe12e71

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

8 files changed

Lines changed: 127 additions & 38 deletions

File tree

.github/workflows/ci.yml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -368,10 +368,6 @@ jobs:
368368
with:
369369
sccache: s3
370370
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
371-
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
372-
with:
373-
distribution: "corretto"
374-
java-version: "17"
375371
- uses: ./.github/actions/setup-prebuild
376372
- run: ./gradlew javadoc
377373
working-directory: ./java

docs/developer-guide/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ internals/session
2424
internals/async-runtime
2525
internals/vtables
2626
internals/execution
27+
internals/stats-pruning
2728
internals/io
2829
internals/serialization
2930
internals/cuda
@@ -38,4 +39,4 @@ caption: Integrations
3839
integrations/datafusion
3940
integrations/duckdb
4041
integrations/spark
41-
```
42+
```
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Stats Pruning
2+
3+
Vortex uses statistics to prove when a filter cannot match a row group, zone, or
4+
file. The proof expression returns `true` when the input can be skipped. It
5+
returns `false` or `null` when pruning is not proven.
6+
7+
The pruning pipeline has two phases:
8+
9+
1. `Expression::falsify(scope, session)` asks the session's
10+
`StatsRewriteRule`s to rewrite a filter into an abstract proof expression.
11+
Rules describe semantics in terms of `vortex.stat(input, aggregate_fn)`
12+
placeholders, so the rewrite does not depend on a particular layout.
13+
2. `bind_stats` lowers those abstract stat placeholders with a `StatBinder`.
14+
The binder maps stats to the representation used by the caller, such as
15+
zone-map table fields, file-level stat literals, or typed null literals for
16+
missing stats.
17+
18+
Missing stats lower to typed null literals. This preserves the three-valued
19+
logic used by pruning: only a non-null `true` value proves that the scope can be
20+
skipped. A missing stat therefore cannot accidentally prune data.
21+
22+
## Binding Targets
23+
24+
Zone maps bind stats to fields in their per-zone stats table. The lowered
25+
expression is evaluated against that table and produces a mask where `true`
26+
means the zone can be skipped.
27+
28+
File-level stats bind stats to literal values from the file footer. The lowered
29+
expression is reduced and evaluated once for the full file. If it evaluates to
30+
`true`, the file stats reader can return an all-false pruning mask without
31+
reading child layouts.
32+
33+
Scan planning uses `checked_pruning_expr` to lower a falsified expression against
34+
the available stats table schema. It returns the stats-table expression and the
35+
set of stat fields still required after expression reduction. If all required
36+
stats are missing, only a constant `true` proof is useful; all other results are
37+
treated as no pruning expression.
38+
39+
For the layout model around these pruning points, see
40+
[Layouts](../../concepts/layouts.md) and [Scanning](../../concepts/scanning.md).

vortex-array/src/expr/pruning/pruning_expr.rs

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ use crate::dtype::Field;
1414
use crate::dtype::FieldName;
1515
use crate::dtype::FieldPath;
1616
use crate::dtype::FieldPathSet;
17+
use crate::dtype::Nullability;
18+
use crate::dtype::StructFields;
1719
use crate::expr::Expression;
1820
use crate::expr::analysis::referenced_field_paths;
1921
use crate::expr::get_item;
@@ -70,11 +72,13 @@ pub fn checked_pruning_expr(
7072
scope,
7173
available_stats,
7274
required_stats: Relation::new(),
75+
bound_stats: Vec::new(),
7376
};
74-
let Some(lowered) = bind_stats(predicate, &mut binder)? else {
75-
return Ok(None);
76-
};
77+
let lowered = bind_stats(predicate, &mut binder)?;
7778
let required_stats = filter_required_stats(&lowered, binder.required_stats);
79+
// If no stats-table fields remain, only a constant `true` proof can prune.
80+
// `false`, `null`, and non-constant expressions cannot justify building a
81+
// stats-table pruning expression.
7882
if required_stats.map().is_empty() && !matches!(bool_literal(&lowered), Some(Some(true))) {
7983
return Ok(None);
8084
}
@@ -86,18 +90,26 @@ struct RequiredStatsBinder<'a> {
8690
scope: &'a DType,
8791
available_stats: &'a FieldPathSet,
8892
required_stats: RequiredStats,
93+
bound_stats: Vec<(FieldName, DType)>,
8994
}
9095

9196
impl StatBinder for RequiredStatsBinder<'_> {
9297
fn scope(&self) -> &DType {
9398
self.scope
9499
}
95100

101+
fn bound_scope(&self) -> DType {
102+
DType::Struct(
103+
StructFields::from_iter(self.bound_stats.iter().cloned()),
104+
Nullability::NonNullable,
105+
)
106+
}
107+
96108
fn bind_stat(
97109
&mut self,
98110
input: &Expression,
99111
stat: Stat,
100-
_stat_dtype: &DType,
112+
stat_dtype: &DType,
101113
) -> VortexResult<Option<Expression>> {
102114
let field_path = match direct_stat_field_path(input) {
103115
Some(field_path) => field_path,
@@ -114,11 +126,18 @@ impl StatBinder for RequiredStatsBinder<'_> {
114126
return Ok(None);
115127
}
116128

117-
self.required_stats.insert(field_path.clone(), stat);
118-
Ok(Some(get_item(
119-
field_path_stat_field_name(&field_path, stat),
120-
root(),
121-
)))
129+
let stat_field_name = field_path_stat_field_name(&field_path, stat);
130+
if self
131+
.bound_stats
132+
.iter()
133+
.all(|(field_name, _)| field_name != stat_field_name)
134+
{
135+
self.bound_stats
136+
.push((stat_field_name.clone(), stat_dtype.clone()));
137+
}
138+
139+
self.required_stats.insert(field_path, stat);
140+
Ok(Some(get_item(stat_field_name, root())))
122141
}
123142
}
124143

vortex-array/src/stats/bind.rs

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
// SPDX-FileCopyrightText: Copyright the Vortex contributors
33

44
//! Bind abstract `vortex.stat` expressions to a concrete stats representation.
5+
//!
6+
//! 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.
510
611
use vortex_error::VortexResult;
712

@@ -20,6 +25,14 @@ pub trait StatBinder {
2025
/// The dtype scope used to type-check expressions before stats are bound.
2126
fn scope(&self) -> &DType;
2227

28+
/// The dtype scope used after stats have been bound.
29+
///
30+
/// Binders that rewrite stats to a different root expression, such as a
31+
/// stats-table row, should return that post-binding root dtype.
32+
fn bound_scope(&self) -> DType {
33+
self.scope().clone()
34+
}
35+
2336
/// Bind `stat(input)` to a concrete expression.
2437
///
2538
/// Returning `Ok(None)` marks the stat as unavailable. [`bind_stats`] will
@@ -52,10 +65,9 @@ pub trait StatBinder {
5265
/// Expression to use when a stat is unavailable.
5366
///
5467
/// The default is a nullable null literal, which preserves three-valued
55-
/// pruning semantics for stats-table execution. Catalog-like binders can
56-
/// return `Ok(None)` to reject expressions that require unavailable stats.
57-
fn missing_stat(&mut self, dtype: DType) -> VortexResult<Option<Expression>> {
58-
Ok(Some(null_expr(dtype)))
68+
/// pruning semantics for stats-table execution.
69+
fn missing_stat(&mut self, dtype: DType) -> VortexResult<Expression> {
70+
Ok(null_expr(dtype))
5971
}
6072
}
6173

@@ -64,10 +76,7 @@ pub trait StatBinder {
6476
/// The predicate is usually the output of a stats rewrite rule. Rewrite rules
6577
/// are responsible for expressing stat semantics; binding maps aggregate-backed
6678
/// stat requests to the concrete stats representation supported by the binder.
67-
pub fn bind_stats(
68-
predicate: Expression,
69-
binder: &mut impl StatBinder,
70-
) -> VortexResult<Option<Expression>> {
79+
pub fn bind_stats(predicate: Expression, binder: &mut impl StatBinder) -> VortexResult<Expression> {
7180
let scope = binder.scope().clone();
7281
let lowered = predicate
7382
.transform_down(|expr| {
@@ -79,18 +88,13 @@ pub fn bind_stats(
7988
Some(bound) => Ok(Transformed::yes(bound)),
8089
None => {
8190
let dtype = expr.return_dtype(&scope)?;
82-
match binder.missing_stat(dtype.clone())? {
83-
Some(missing) => Ok(Transformed::yes(missing)),
84-
None => Ok(Transformed::yes(null_expr(dtype))),
85-
}
91+
Ok(Transformed::yes(binder.missing_stat(dtype)?))
8692
}
8793
}
8894
})?
8995
.into_inner();
9096

91-
#[expect(deprecated)]
92-
let lowered = lowered.simplify_untyped()?;
93-
Ok(Some(lowered))
97+
lowered.optimize_recursive(&binder.bound_scope())
9498
}
9599

96100
fn bind_stat_fn(
@@ -100,6 +104,7 @@ fn bind_stat_fn(
100104
) -> VortexResult<Option<Expression>> {
101105
let options = expr.as_::<StatFn>();
102106
let aggregate_fn = options.aggregate_fn();
107+
// `StatFn` has exactly one child: the expression the aggregate statistic is computed over.
103108
let input = expr.child(0);
104109

105110
let stat_dtype = expr.return_dtype(scope)?;

vortex-array/src/stats/rewrite/builtins.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,33 @@ mod tests {
814814
))
815815
);
816816

817+
let expr = like(col("s"), lit(r"\%%"));
818+
assert_eq!(
819+
falsify(&expr)?,
820+
Some(or(
821+
gt_eq(stat(col("s"), Stat::Min), lit("&")),
822+
lt(stat(col("s"), Stat::Max), lit("%")),
823+
))
824+
);
825+
826+
let expr = like(col("s"), lit("pref%ix%"));
827+
assert_eq!(
828+
falsify(&expr)?,
829+
Some(or(
830+
gt_eq(stat(col("s"), Stat::Min), lit("preg")),
831+
lt(stat(col("s"), Stat::Max), lit("pref")),
832+
))
833+
);
834+
835+
let expr = like(col("s"), lit("pref_ix_"));
836+
assert_eq!(
837+
falsify(&expr)?,
838+
Some(or(
839+
gt_eq(stat(col("s"), Stat::Min), lit("preg")),
840+
lt(stat(col("s"), Stat::Max), lit("pref")),
841+
))
842+
);
843+
817844
let expr = like(col("s"), lit("exact"));
818845
assert_eq!(
819846
falsify(&expr)?,

vortex-file/src/v2/file_stats_reader.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ use vortex_array::scalar_fn::internal::row_count::substitute_row_count;
3131
use vortex_array::stats::bind::StatBinder;
3232
use vortex_array::stats::bind::bind_stats;
3333
use vortex_error::VortexResult;
34-
use vortex_error::vortex_bail;
3534
use vortex_layout::ArrayFuture;
3635
use vortex_layout::LayoutReader;
3736
use vortex_layout::LayoutReaderRef;
@@ -121,10 +120,7 @@ impl FileStatsLayoutReader {
121120

122121
fn lower_stats(&self, predicate: Expression) -> VortexResult<Expression> {
123122
let mut binder = FileStatsBinder { reader: self };
124-
let Some(predicate) = bind_stats(predicate, &mut binder)? else {
125-
vortex_bail!("missing stats should lower to null literals");
126-
};
127-
Ok(predicate)
123+
bind_stats(predicate, &mut binder)
128124
}
129125

130126
pub fn file_stats(&self) -> &FileStatistics {
@@ -141,6 +137,10 @@ impl StatBinder for FileStatsBinder<'_> {
141137
self.reader.child.dtype()
142138
}
143139

140+
fn bound_scope(&self) -> DType {
141+
DType::Null
142+
}
143+
144144
fn bind_stat(
145145
&mut self,
146146
input: &Expression,

vortex-layout/src/layouts/zoned/zone_map.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,7 @@ impl ZoneMap {
121121

122122
fn lower_stats(&self, predicate: Expression) -> VortexResult<Expression> {
123123
let mut binder = ZoneMapStatsBinder { zone_map: self };
124-
let Some(predicate) = bind_stats(predicate, &mut binder)? else {
125-
vortex_bail!("missing stats should lower to null literals");
126-
};
127-
Ok(predicate)
124+
bind_stats(predicate, &mut binder)
128125
}
129126
}
130127

@@ -137,6 +134,10 @@ impl StatBinder for ZoneMapStatsBinder<'_> {
137134
&self.zone_map.column_dtype
138135
}
139136

137+
fn bound_scope(&self) -> DType {
138+
self.zone_map.array.dtype().clone()
139+
}
140+
140141
fn bind_stat(
141142
&mut self,
142143
input: &Expression,

0 commit comments

Comments
 (0)