Skip to content

Commit 6adf843

Browse files
dfa1claude
andcommitted
refactor(compute): unify RowFilter over Predicate (ADR 0013 §5)
RowFilter and the compute Predicate were two parallel vocabularies bridged by a translation seam in the Calcite adapter (toPredicate, with >=/<=/<> lowered to Or compositions). They are now one: Predicate is the shared, public, column-agnostic value-test vocabulary (gaining first-class Neq/Gte/ Lte), and RowFilter is a column bound to a Predicate — RowFilter.Column( column, predicate) plus the n-ary And. The public factories (eq/neq/gt/gte/ lt/lte/isNull/isNotNull/and) keep their signatures. So push-down is now literally "the same predicate, compiled two ways": ScanIterator.canPruneChunk evaluates the Predicate against a column's zone-map stats (a recursive, strictly-conservative tester — it skips a chunk only when provably no row can match, declining to prune on any unknown stat or uncertain case), and the boundary fold runs the very same Predicate through Compute.filter on the decoded chunk. The Calcite toPredicate seam and the Or-lowering are deleted; the kernels evaluate Neq/Gte/Lte natively (the primitive fast lane folds them via a hoisted, loop-invariant negate/range, no hot-loop regression). Neq pruning now additionally requires a provably null-free chunk before skipping a min==max==v zone — stricter than before, conservative-safe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5a1262a commit 6adf843

13 files changed

Lines changed: 546 additions & 250 deletions

File tree

calcite/src/main/java/io/github/dfa1/vortex/calcite/VortexTable.java

Lines changed: 45 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -375,63 +375,19 @@ private static Mask buildMask(Chunk chunk, RowFilter filter, Arena arena) {
375375

376376
/// Walks the filter tree, evaluating each leaf into a per-column [Mask] and collecting them into
377377
/// `out`; the conjuncts of an n-ary `AND` are flattened so [#buildMask] intersects them together.
378+
/// A [RowFilter.Column] already holds the exact compute [Predicate], so the kernel runs it
379+
/// directly against the bound column — no translation seam, since `>=`, `<=` and `<>` are now
380+
/// first-class predicate variants.
378381
private static void collectLeafMasks(Chunk chunk, RowFilter filter, Arena arena, List<Mask> out) {
379-
if (filter instanceof RowFilter.And(var parts)) {
380-
for (RowFilter part : parts) {
381-
collectLeafMasks(chunk, part, arena, out);
382+
switch (filter) {
383+
case RowFilter.And(var parts) -> {
384+
for (RowFilter part : parts) {
385+
collectLeafMasks(chunk, part, arena, out);
386+
}
382387
}
383-
return;
388+
case RowFilter.Column(var col, var predicate) ->
389+
out.add(Compute.filter(chunk.column(col), predicate, arena));
384390
}
385-
out.add(Compute.filter(chunk.column(leafColumn(filter)), toPredicate(filter), arena));
386-
}
387-
388-
/// The column a leaf [RowFilter] references (never called for an `AND`, which has no single
389-
/// column).
390-
private static String leafColumn(RowFilter leaf) {
391-
return switch (leaf) {
392-
case RowFilter.Eq(var col, var ignored) -> col;
393-
case RowFilter.Neq(var col, var ignored) -> col;
394-
case RowFilter.Gt(var col, var ignored) -> col;
395-
case RowFilter.Gte(var col, var ignored) -> col;
396-
case RowFilter.Lt(var col, var ignored) -> col;
397-
case RowFilter.Lte(var col, var ignored) -> col;
398-
case RowFilter.IsNull(var col) -> col;
399-
case RowFilter.IsNotNull(var col) -> col;
400-
case RowFilter.And ignored ->
401-
throw new IllegalArgumentException("leafColumn called on a composite AND filter");
402-
};
403-
}
404-
405-
/// Translates a leaf [RowFilter] into the equivalent compute [Predicate]. The compute vocabulary
406-
/// has no direct `>=`, `<=`, or `<>`, so each is composed from the ops it does have — all
407-
/// null-correct because every component excludes nulls:
408-
/// - `>= v` is `= v OR > v`;
409-
/// - `<= v` is `= v OR < v`;
410-
/// - `<> v` is `< v OR > v`.
411-
/// This keeps a `BETWEEN` (which Calcite lowers to `>= a AND <= b`) foldable on a boundary zone
412-
/// rather than abandoning it.
413-
private static Predicate toPredicate(RowFilter leaf) {
414-
return switch (leaf) {
415-
case RowFilter.Eq(var ignored, var value) -> new Predicate.Eq(value);
416-
case RowFilter.Neq(var ignored, var value) ->
417-
new Predicate.Or(new Predicate.Lt(asComparable(value)), new Predicate.Gt(asComparable(value)));
418-
case RowFilter.Gt(var ignored, var value) -> new Predicate.Gt(value);
419-
case RowFilter.Gte(var ignored, var value) ->
420-
new Predicate.Or(new Predicate.Eq(value), new Predicate.Gt(value));
421-
case RowFilter.Lt(var ignored, var value) -> new Predicate.Lt(value);
422-
case RowFilter.Lte(var ignored, var value) ->
423-
new Predicate.Or(new Predicate.Eq(value), new Predicate.Lt(value));
424-
case RowFilter.IsNull(var ignored) -> new Predicate.IsNull();
425-
case RowFilter.IsNotNull(var ignored) -> new Predicate.IsNotNull();
426-
case RowFilter.And ignored ->
427-
throw new IllegalArgumentException("toPredicate called on a composite AND filter");
428-
};
429-
}
430-
431-
/// Casts a leaf's value to [Comparable] for the ordering predicates (`< v` / `> v`). The values
432-
/// the filter translation produces are always [Long] / [Double] / [String], all comparable.
433-
private static Comparable<?> asComparable(Object value) {
434-
return (Comparable<?>) value;
435391
}
436392

437393
/// Mutable accumulator for a filtered fold, shared by the whole-zone (tier 1) and boundary-zone
@@ -558,8 +514,28 @@ private static Match classify(RowFilter filter, int zone,
558514
}
559515
yield allIn ? Match.IN : Match.BOUNDARY;
560516
}
561-
case RowFilter.Eq(var col, var value) -> {
562-
ArrayStats s = zoneStats.get(col).get(zone);
517+
case RowFilter.Column(var col, var predicate) ->
518+
classifyColumn(predicate, zoneStats.get(col).get(zone), rowCount);
519+
};
520+
}
521+
522+
/// Classifies one zone against a column-bound [Predicate] from the zone's statistics `s`. The
523+
/// comparison ops carry the same three-valued-logic semantics as before the [RowFilter] /
524+
/// [Predicate] unification: an unrecognised stat shape or a partially-overlapping zone is
525+
/// [Match#BOUNDARY], a zone provably outside the predicate is [Match#OUT], and a zone every row
526+
/// of which matches (which, for a value comparison, also requires the zone to carry no nulls) is
527+
/// [Match#IN]. The composite and range predicates ([Predicate.Between] / [Predicate.And] /
528+
/// [Predicate.Or]) cannot arise from the Calcite translation — `SEARCH` / `BETWEEN` expand to an
529+
/// `AND` of single-leaf columns — so they fall through to [Match#BOUNDARY], which decodes and
530+
/// applies the kernel mask and is therefore always correct.
531+
///
532+
/// @param predicate the column-bound value-test
533+
/// @param s the zone's statistics for that column
534+
/// @param rowCount the zone's row count
535+
/// @return how the zone relates to the predicate
536+
private static Match classifyColumn(Predicate predicate, ArrayStats s, long rowCount) {
537+
return switch (predicate) {
538+
case Predicate.Eq(var value) -> {
563539
if (uncomparable(s, value)) {
564540
yield Match.BOUNDARY;
565541
}
@@ -571,8 +547,7 @@ private static Match classify(RowFilter filter, int zone,
571547
}
572548
yield Match.BOUNDARY;
573549
}
574-
case RowFilter.Neq(var col, var value) -> {
575-
ArrayStats s = zoneStats.get(col).get(zone);
550+
case Predicate.Neq(var value) -> {
576551
if (uncomparable(s, value)) {
577552
yield Match.BOUNDARY;
578553
}
@@ -584,16 +559,16 @@ private static Match classify(RowFilter filter, int zone,
584559
}
585560
yield Match.BOUNDARY;
586561
}
587-
case RowFilter.Gt(var col, var value) -> compare(zoneStats.get(col).get(zone), value,
562+
case Predicate.Gt(var value) -> compare(s, value,
588563
(st, v) -> compareStat(st.max(), v) <= 0, (st, v) -> compareStat(st.min(), v) > 0);
589-
case RowFilter.Gte(var col, var value) -> compare(zoneStats.get(col).get(zone), value,
564+
case Predicate.Gte(var value) -> compare(s, value,
590565
(st, v) -> compareStat(st.max(), v) < 0, (st, v) -> compareStat(st.min(), v) >= 0);
591-
case RowFilter.Lt(var col, var value) -> compare(zoneStats.get(col).get(zone), value,
566+
case Predicate.Lt(var value) -> compare(s, value,
592567
(st, v) -> compareStat(st.min(), v) >= 0, (st, v) -> compareStat(st.max(), v) < 0);
593-
case RowFilter.Lte(var col, var value) -> compare(zoneStats.get(col).get(zone), value,
568+
case Predicate.Lte(var value) -> compare(s, value,
594569
(st, v) -> compareStat(st.min(), v) > 0, (st, v) -> compareStat(st.max(), v) <= 0);
595-
case RowFilter.IsNull(var col) -> {
596-
Long nulls = zoneStats.get(col).get(zone).nullCount();
570+
case Predicate.IsNull ignored -> {
571+
Long nulls = s.nullCount();
597572
if (nulls == null) {
598573
yield Match.BOUNDARY;
599574
}
@@ -602,8 +577,8 @@ private static Match classify(RowFilter filter, int zone,
602577
}
603578
yield nulls == 0 ? Match.OUT : Match.BOUNDARY;
604579
}
605-
case RowFilter.IsNotNull(var col) -> {
606-
Long nulls = zoneStats.get(col).get(zone).nullCount();
580+
case Predicate.IsNotNull ignored -> {
581+
Long nulls = s.nullCount();
607582
if (nulls == null) {
608583
yield Match.BOUNDARY;
609584
}
@@ -612,6 +587,9 @@ private static Match classify(RowFilter filter, int zone,
612587
}
613588
yield nulls == rowCount ? Match.OUT : Match.BOUNDARY;
614589
}
590+
case Predicate.Between ignored -> Match.BOUNDARY;
591+
case Predicate.And ignored -> Match.BOUNDARY;
592+
case Predicate.Or ignored -> Match.BOUNDARY;
615593
};
616594
}
617595

@@ -917,14 +895,7 @@ private static Optional<RowFilter> toRowFilter(List<RexNode> filters, List<Strin
917895
private static void collectColumns(RowFilter filter, java.util.Set<String> out) {
918896
switch (filter) {
919897
case RowFilter.And(var parts) -> parts.forEach(f -> collectColumns(f, out));
920-
case RowFilter.Eq(var col, var ignored) -> out.add(col);
921-
case RowFilter.Neq(var col, var ignored) -> out.add(col);
922-
case RowFilter.Gt(var col, var ignored) -> out.add(col);
923-
case RowFilter.Gte(var col, var ignored) -> out.add(col);
924-
case RowFilter.Lt(var col, var ignored) -> out.add(col);
925-
case RowFilter.Lte(var col, var ignored) -> out.add(col);
926-
case RowFilter.IsNull(var col) -> out.add(col);
927-
case RowFilter.IsNotNull(var col) -> out.add(col);
898+
case RowFilter.Column(var col, var ignored) -> out.add(col);
928899
}
929900
}
930901

calcite/src/test/java/io/github/dfa1/vortex/calcite/FilterPushDownTest.java

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,12 @@ static void write() throws Exception {
6464
@ParameterizedTest(name = "[{index}] WHERE {0} -> {1} rows")
6565
@CsvSource({
6666
// every comparison kind the RowFilter translator supports, over a Long column
67-
"i64 = 1000, 1", // EQUALS -> RowFilter.Eq
68-
"i64 <> 1000, 5", // NOT_EQUALS -> RowFilter.Neq
69-
"i64 < 3000, 2", // LESS_THAN -> RowFilter.Lt
70-
"i64 <= 3000, 3", // LESS_THAN_EQ -> RowFilter.Lte
71-
"i64 > 4000, 2", // GREATER_THAN -> RowFilter.Gt
72-
"i64 >= 4000, 3", // GREATER_EQ -> RowFilter.Gte
67+
"i64 = 1000, 1", // EQUALS -> Column(i64, Predicate.Eq)
68+
"i64 <> 1000, 5", // NOT_EQUALS -> Column(i64, Predicate.Neq)
69+
"i64 < 3000, 2", // LESS_THAN -> Column(i64, Predicate.Lt)
70+
"i64 <= 3000, 3", // LESS_THAN_EQ -> Column(i64, Predicate.Lte)
71+
"i64 > 4000, 2", // GREATER_THAN -> Column(i64, Predicate.Gt)
72+
"i64 >= 4000, 3", // GREATER_EQ -> Column(i64, Predicate.Gte)
7373
"s = 'a', 1", // Utf8 literal coercion
7474
"f64 > 3.0, 3", // floating literal coercion
7575
// multiple conjuncts arrive as separate filters -> RowFilter.and over the list

cli/src/main/java/io/github/dfa1/vortex/cli/FilterCommand.java

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import io.github.dfa1.vortex.csv.RowPredicate;
1616
import io.github.dfa1.vortex.reader.RowFilter;
1717
import io.github.dfa1.vortex.reader.ScanOptions;
18+
import io.github.dfa1.vortex.reader.compute.Predicate;
1819

1920
import java.io.IOException;
2021
import java.io.OutputStreamWriter;
@@ -150,18 +151,7 @@ private static Comparable<?> parseValue(String raw) {
150151

151152
private static RowPredicate toRowPredicate(RowFilter filter) {
152153
return switch (filter) {
153-
case RowFilter.Gt(var col, var val) -> (chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) > 0;
154-
case RowFilter.Gte(var col, var val) ->
155-
(chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) >= 0;
156-
case RowFilter.Lt(var col, var val) -> (chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) < 0;
157-
case RowFilter.Lte(var col, var val) ->
158-
(chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) <= 0;
159-
case RowFilter.Eq(var col, var val) ->
160-
(chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, (Comparable<?>) val) == 0;
161-
case RowFilter.Neq(var col, var val) ->
162-
(chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, (Comparable<?>) val) != 0;
163-
case RowFilter.IsNull(var col) -> (chunk, rowIdx) -> isRowNull(chunk.column(col), rowIdx);
164-
case RowFilter.IsNotNull(var col) -> (chunk, rowIdx) -> !isRowNull(chunk.column(col), rowIdx);
154+
case RowFilter.Column(var col, var predicate) -> columnPredicate(col, predicate);
165155
case RowFilter.And(var filters) -> {
166156
RowPredicate[] preds = filters.stream().map(FilterCommand::toRowPredicate).toArray(RowPredicate[]::new);
167157
yield (chunk, rowIdx) -> {
@@ -176,6 +166,30 @@ private static RowPredicate toRowPredicate(RowFilter filter) {
176166
};
177167
}
178168

169+
/// Compiles a column-bound [Predicate] into a per-row test over the decoded chunk. Only the
170+
/// comparison and null-test leaves the filter grammar ([#parseFilter]) produces are supported;
171+
/// the composite and range predicate variants never arise from a parsed CLI expression.
172+
private static RowPredicate columnPredicate(String col, Predicate predicate) {
173+
return switch (predicate) {
174+
case Predicate.Gt(var val) -> (chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) > 0;
175+
case Predicate.Gte(var val) -> (chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) >= 0;
176+
case Predicate.Lt(var val) -> (chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) < 0;
177+
case Predicate.Lte(var val) -> (chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, val) <= 0;
178+
case Predicate.Eq(var val) ->
179+
(chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, (Comparable<?>) val) == 0;
180+
case Predicate.Neq(var val) ->
181+
(chunk, rowIdx) -> compareValue(chunk.column(col), rowIdx, (Comparable<?>) val) != 0;
182+
case Predicate.IsNull ignored -> (chunk, rowIdx) -> isRowNull(chunk.column(col), rowIdx);
183+
case Predicate.IsNotNull ignored -> (chunk, rowIdx) -> !isRowNull(chunk.column(col), rowIdx);
184+
case Predicate.Between ignored ->
185+
throw new IllegalArgumentException("BETWEEN filters are not supported on the command line");
186+
case Predicate.And ignored ->
187+
throw new IllegalArgumentException("nested predicate AND is not supported on the command line");
188+
case Predicate.Or ignored ->
189+
throw new IllegalArgumentException("nested predicate OR is not supported on the command line");
190+
};
191+
}
192+
179193
// Only a masked column carries nulls; an unmasked array is null-free, so every row is non-null.
180194
private static boolean isRowNull(Array arr, long rowIdx) {
181195
return arr instanceof MaskedArray masked && !masked.isValid(rowIdx);

0 commit comments

Comments
 (0)