Skip to content

Commit 05dd920

Browse files
dfa1claude
andcommitted
feat(reader): surface per-zone stats from the zone-map table (ADR 0013 §6)
Add ScanIterator.columnZoneStats(col): one ArrayStats per zone with min/max/sum/null-count, the read-side feed for aggregate push-down. Sum is decoded from the column's vortex.stats zone-map table rather than per-flat node stats — matching Rust, whose flat writer retains only pre-computed stats (flat/writer.rs) and emits SUM only in the zoned table (zoned/writer.rs). Falls back to per-chunk node stats (sum null) when a column has no zone map. - ArrayStats gains a sum component; fromFbs decodes it (forward-compat). - ZonedStatsSchema moves inspector -> reader so the read path can reconstruct the stats-table dtype; cli/inspector imports updated. - VortexWriter is unchanged functionally (comment only); sum continues to live in the zone-map table. Calcite VortexAggregates.SUM/AVG now fold the per-zone sums instead of a full scan: metadata-only when every zone carries a sum, falling back to a streaming scan only when a column has no zone map. Verified both interop directions, incl. a new test folding per-zone sums from a Rust-written file to the exact column total. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bfc209b commit 05dd920

14 files changed

Lines changed: 420 additions & 28 deletions

File tree

TODO.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,11 @@ Per-encoding gotchas:
9696
- [ ] **Compute primitives — masks, kernels, no-materialise** — pushdown filter/compare/aggregate
9797
kernels operating on Lazy arrays without materialising. See [ADR-0013](docs/adr/0013-compute-primitives.md)
9898
(Proposed). Gate: a concrete downstream consumer (e.g. the vortex-arrow bridge or filter pushdown).
99+
Done: §6 read-side surface — `ScanIterator.columnZoneStats(col)` exposes per-zone
100+
min/max/sum/null count, decoding sum from the `vortex.stats` zone-map table (matches files from
101+
Rust, whose flat writer omits per-flat sum). Calcite `VortexAggregates.SUM`/`AVG` now fold those
102+
per-zone sums (metadata-only), falling back to a full scan only when a column has no zone map.
103+
Next: `Mask`/`Predicate`/kernel vocab and the two-tier whole-zone+residual reduce.
99104

100105
## Encodings
101106

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

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,16 @@
1111
import io.github.dfa1.vortex.reader.array.IntArray;
1212
import io.github.dfa1.vortex.reader.array.LongArray;
1313

14+
import java.util.List;
15+
1416
/// Column aggregates answered with the cheapest available source.
1517
///
1618
/// `MIN` / `MAX` / `COUNT` are read from the per-segment zone-map statistics embedded in the
17-
/// file footer — no data segment is decoded. `SUM` (and therefore `AVG`) has no zone statistic
18-
/// in the current writer, so it falls back to a streaming scan. This split is the whole point
19-
/// of the demo: the stats-backed aggregates are effectively free, and `SUM`/`AVG` show what the
20-
/// next writer increment (emit a per-zone `SUM`) would make free too (ADR 0013 §6).
19+
/// file footer — no data segment is decoded. `SUM` (and therefore `AVG`) folds the per-zone
20+
/// `SUM` rows surfaced by [ScanIterator#columnZoneStats(String)] (ADR 0013 §6): when every zone
21+
/// carries a sum the answer is metadata-only too, with no data segment touched. Only when a zone
22+
/// lacks a sum — a column with no zone map, whose flat nodes do not retain it — does it fall back
23+
/// to a streaming scan.
2124
public final class VortexAggregates {
2225

2326
/// Where an aggregate's value came from.
@@ -65,13 +68,52 @@ public static Summary of(VortexReader reader, String column) {
6568
Object min = stats.min();
6669
Object max = stats.max();
6770

68-
// SUM/AVG: no SUM zone-stat exists today, so stream the column once. Integer columns sum
69-
// into a long (exact); floating columns into a double.
70-
Number sum = scanSum(reader, column);
71+
// SUM/AVG: fold the per-zone SUM rows when every zone carries one (metadata-only); fall
72+
// back to a single streaming scan otherwise. Integer columns sum into a long (exact);
73+
// floating columns into a double.
74+
Number sum = zoneSum(reader, column);
75+
Source sumSource = Source.ZONE_STATS_PUSHDOWN;
76+
if (sum == null) {
77+
sum = scanSum(reader, column);
78+
sumSource = Source.FULL_SCAN;
79+
}
7180
Double avg = count == 0 ? null : sum.doubleValue() / count;
7281

7382
return new Summary(column, min, max, count, sum, avg,
74-
Source.ZONE_STATS_PUSHDOWN, Source.FULL_SCAN);
83+
Source.ZONE_STATS_PUSHDOWN, sumSource);
84+
}
85+
86+
/// Folds the per-zone `SUM` statistics for `column`, or returns `null` when any zone lacks a
87+
/// sum (so the caller streams the column instead). Integer columns fold into a [Long] (exact),
88+
/// floating columns into a [Double]; the zone-stat boxing already distinguishes the two.
89+
private static Number zoneSum(VortexReader reader, String column) {
90+
try (ScanIterator scan = reader.scan(ScanOptions.columns(column))) {
91+
List<ArrayStats> zones = scan.columnZoneStats(column);
92+
if (zones.isEmpty()) {
93+
return null;
94+
}
95+
long longSum = 0L;
96+
double doubleSum = 0.0;
97+
boolean isFloating = false;
98+
for (ArrayStats zone : zones) {
99+
switch (zone.sum()) {
100+
case Long l -> longSum += l;
101+
case Double d -> {
102+
isFloating = true;
103+
doubleSum += d;
104+
}
105+
case null, default -> {
106+
// A zone with no usable sum (no zone map, or an unhandled stat type) —
107+
// can't push down, let the caller fall back to a full scan.
108+
return null;
109+
}
110+
}
111+
}
112+
if (isFloating) {
113+
return doubleSum;
114+
}
115+
return longSum;
116+
}
75117
}
76118

77119
private static long totalRows(VortexReader reader) {

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

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,8 @@ void integerColumn_sumIsExactLong() throws Exception {
217217
assertThat(((Number) s.min()).longValue()).isEqualTo(1000L);
218218
assertThat(((Number) s.max()).longValue()).isEqualTo(3000L);
219219
assertThat(s.minMaxSource()).isEqualTo(VortexAggregates.Source.ZONE_STATS_PUSHDOWN);
220-
assertThat(s.sumSource()).isEqualTo(VortexAggregates.Source.FULL_SCAN);
220+
// SUM now folds the per-zone zone-map sum the Java writer emits — no data decoded.
221+
assertThat(s.sumSource()).isEqualTo(VortexAggregates.Source.ZONE_STATS_PUSHDOWN);
221222
}
222223
}
223224

@@ -266,5 +267,37 @@ void nonNumericColumn_throws() throws Exception {
266267
.hasMessageContaining("not a numeric column");
267268
}
268269
}
270+
271+
@Test
272+
void noZoneMap_sumFallsBackToFullScan(@TempDir Path noStats) throws Exception {
273+
// Given — a file written with zone maps off, so no per-zone SUM exists to fold
274+
Path bare = noStats.resolve("nostats.vortex");
275+
WriteOptions noZoneMaps = new WriteOptions(65_536, false, 0.90, 0, true, false);
276+
try (var ch = FileChannel.open(bare, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
277+
var w = VortexWriter.create(ch, SCHEMA, noZoneMaps)) {
278+
w.writeChunk(Map.ofEntries(
279+
Map.entry("i8", new byte[]{1, 2, 3}),
280+
Map.entry("i16", new short[]{10, 20, 30}),
281+
Map.entry("i32", new int[]{100, 200, 300}),
282+
Map.entry("i64", new long[]{1000L, 2000L, 3000L}),
283+
Map.entry("u8", new byte[]{4, 5, 6}),
284+
Map.entry("u16", new short[]{40, 50, 60}),
285+
Map.entry("u32", new int[]{400, 500, 600}),
286+
Map.entry("u64", new long[]{4000L, 5000L, 6000L}),
287+
Map.entry("f32", new float[]{1.5f, 2.5f, 3.5f}),
288+
Map.entry("f64", new double[]{1.25, 2.25, 3.25}),
289+
Map.entry("s", new String[]{"a", "b", "c"}),
290+
Map.entry("b", new boolean[]{true, false, true})));
291+
}
292+
293+
// When
294+
try (VortexReader reader = VortexReader.open(bare, registry())) {
295+
VortexAggregates.Summary s = VortexAggregates.of(reader, "i64");
296+
297+
// Then — sum still exact, but sourced from a streaming scan, not the (absent) zone map
298+
assertThat(s.sum()).isInstanceOf(Long.class).isEqualTo(6000L);
299+
assertThat(s.sumSource()).isEqualTo(VortexAggregates.Source.FULL_SCAN);
300+
}
301+
}
269302
}
270303
}

cli/src/main/java/io/github/dfa1/vortex/cli/tui/VortexInspectorTui.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import io.github.dfa1.vortex.cli.tui.term.Terminal;
1010
import io.github.dfa1.vortex.inspect.ByteSize;
1111
import io.github.dfa1.vortex.inspect.InspectorTree;
12-
import io.github.dfa1.vortex.inspect.ZonedStatsSchema;
12+
import io.github.dfa1.vortex.reader.ZonedStatsSchema;
1313
import io.github.dfa1.vortex.reader.VortexHandle;
1414
import io.github.dfa1.vortex.reader.Chunk;
1515
import io.github.dfa1.vortex.reader.ScanIterator;

inspector/src/main/java/io/github/dfa1/vortex/inspect/VortexInspector.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ private static ArrayStats aggregateStats(InspectorTree.Node node) {
102102
if (min == null && max == null) {
103103
return ArrayStats.empty();
104104
}
105-
return new ArrayStats(min, max, null, null, null, null);
105+
return new ArrayStats(min, max, null, null, null, null, null);
106106
}
107107

108108
@SuppressWarnings({"unchecked", "rawtypes"})

inspector/src/test/java/io/github/dfa1/vortex/inspect/VortexInspectorTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,9 @@ void render_aggregatesMinMaxAcrossChunks() {
133133
Layout structLayout = new Layout("vortex.struct", 1000, null, List.of(chunked), List.of());
134134

135135
InspectorTree.Node c1 = new InspectorTree.Node(chunk1, Optional.empty(), Set.of(),
136-
new ArrayStats(10L, 50L, null, null, null, null), List.of());
136+
new ArrayStats(10L, 50L, null, null, null, null, null), List.of());
137137
InspectorTree.Node c2 = new InspectorTree.Node(chunk2, Optional.empty(), Set.of(),
138-
new ArrayStats(5L, 100L, null, null, null, null), List.of());
138+
new ArrayStats(5L, 100L, null, null, null, null, null), List.of());
139139
InspectorTree.Node chunkedN = new InspectorTree.Node(chunked, Optional.of("id"),
140140
Set.of("vortex.flat"), ArrayStats.empty(), List.of(c1, c2));
141141
InspectorTree.Node rootN = new InspectorTree.Node(structLayout, Optional.empty(),

integration/src/test/java/io/github/dfa1/vortex/integration/RustWritesJavaReadsIntegrationTest.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import io.github.dfa1.vortex.reader.array.Array;
1515
import io.github.dfa1.vortex.reader.array.DoubleArray;
1616
import io.github.dfa1.vortex.reader.array.LongArray;
17+
import io.github.dfa1.vortex.reader.ArrayStats;
1718
import io.github.dfa1.vortex.reader.ReadRegistry;
1819
import io.github.dfa1.vortex.reader.VortexReader;
1920
import org.apache.arrow.c.ArrowArray;
@@ -269,6 +270,35 @@ void jniWriter_javaReader_singleChunk(@TempDir Path tmp) throws IOException {
269270
}
270271
}
271272

273+
@Test
274+
void jniWriter_perZoneSum_readFromZoneMapTable(@TempDir Path tmp) throws IOException {
275+
// Given — a Rust-written file large enough that the JNI writer emits a multi-zone column.
276+
// Sum lives only in Rust's vortex.stats zone-map table (its flat writer doesn't retain it),
277+
// so this proves the Java reader decodes that table for per-zone SUM (ADR 0013 §6 parity).
278+
int n = 200_000;
279+
long[] ids = new long[n];
280+
double[] vals = new double[n];
281+
for (int i = 0; i < n; i++) {
282+
ids[i] = i;
283+
vals[i] = i;
284+
}
285+
Path file = tmp.resolve("jni_zones.vtx");
286+
writeJni(file, ids, vals);
287+
long expected = (long) n * (n - 1) / 2; // Σ 0..n-1
288+
289+
// When — fold the per-zone SUM rows the reader surfaces from the zone-map table
290+
try (var vf = VortexReader.open(file, ReadRegistry.loadAll());
291+
var iter = vf.scan(io.github.dfa1.vortex.reader.ScanOptions.all())) {
292+
List<ArrayStats> zones = iter.columnZoneStats("id");
293+
294+
// Then — every zone carries a SUM (came from Rust's table, not a Java-side recompute)
295+
// and the whole-zone fold equals the column total.
296+
assertThat(zones).isNotEmpty().allSatisfy(z -> assertThat(z.sum()).isNotNull());
297+
long total = zones.stream().mapToLong(z -> (Long) z.sum()).sum();
298+
assertThat(total).isEqualTo(expected);
299+
}
300+
}
301+
272302
@Test
273303
void jniWriter_javaReader_multipleChunks(@TempDir Path tmp) throws IOException {
274304
// Given

reader/src/main/java/io/github/dfa1/vortex/reader/ArrayStats.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,21 @@
1111
///
1212
/// @param min minimum value in the array, or `null` if unknown
1313
/// @param max maximum value in the array, or `null` if unknown
14+
/// @param sum sum of values (`Long` for integer columns, `Double` for floats), or `null` if unknown
1415
/// @param trueCount number of `true` values for bool columns, or `null` if unknown
1516
/// @param nullCount number of null values, or `null` if unknown
1617
/// @param isSorted `true` if the array is sorted in ascending order, or `null` if unknown
1718
/// @param isStrictSorted `true` if the array is strictly sorted (no duplicates), or `null` if unknown
1819
public record ArrayStats(
1920
Object min,
2021
Object max,
22+
Object sum,
2123
Long trueCount,
2224
Long nullCount,
2325
Boolean isSorted,
2426
Boolean isStrictSorted
2527
) {
26-
private static final ArrayStats EMPTY = new ArrayStats(null, null, null, null, null, null);
28+
private static final ArrayStats EMPTY = new ArrayStats(null, null, null, null, null, null, null);
2729

2830
/// Returns an empty stats instance with all fields set to `null`.
2931
///
@@ -43,11 +45,12 @@ public static ArrayStats fromFbs(io.github.dfa1.vortex.core.fbs.FbsArrayStats fb
4345
}
4446
Object min = decodeScalar(fbs.minAsSegment());
4547
Object max = decodeScalar(fbs.maxAsSegment());
48+
Object sum = decodeScalar(fbs.sumAsSegment());
4649
Long nullCount = fbs.hasNullCount() ? fbs.nullCount() : null;
47-
if (min == null && max == null && nullCount == null) {
50+
if (min == null && max == null && sum == null && nullCount == null) {
4851
return EMPTY;
4952
}
50-
return new ArrayStats(min, max, null, nullCount, null, null);
53+
return new ArrayStats(min, max, sum, null, nullCount, null, null);
5154
}
5255

5356
private static Object decodeScalar(MemorySegment seg) {

0 commit comments

Comments
 (0)