Skip to content

Commit 778f863

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. 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 778f863

12 files changed

Lines changed: 335 additions & 19 deletions

File tree

TODO.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ 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). Next: `Mask`/`Predicate`/kernel vocab and the
102+
two-tier whole-zone+residual reduce; rewire calcite `VortexAggregates.SUM` off full-scan.
99103

100104
## Encodings
101105

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) {

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

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,150 @@ public long[] chunkRowCounts() {
395395
return out;
396396
}
397397

398+
/// Returns the per-zone statistics for one column, one entry per zone in scan order.
399+
///
400+
/// When the column carries a `vortex.stats` (zone-map) layout, the rows come from that
401+
/// table — min/max/sum/null count per zone — decoded once from the small stats segment
402+
/// without touching any data segment. This is the source Rust populates for `SUM`, so the
403+
/// values match files written by either implementation. When no zone map is present the
404+
/// list falls back to each chunk's embedded `ArrayStats` (min/max/null count; `sum` is
405+
/// `null`, since the flat writer does not retain it). Either way the result has one entry
406+
/// per chunk/zone, positionally aligned with [#chunkRowCounts()]; a column that is absent
407+
/// or carries no stats yields [ArrayStats#empty()] per zone.
408+
///
409+
/// This is the read-side surface for aggregate push-down (ADR 0013 §6): a reduction can
410+
/// fold whole zones from these rows and fall back to a streaming decode only for the
411+
/// boundary zones a predicate partially selects.
412+
///
413+
/// Like [#chunkRowCounts()], filter pruning and `ScanOptions.limit()` are not applied —
414+
/// the list reflects the raw layout shape.
415+
///
416+
/// @param column the column name
417+
/// @return per-zone stats in zone order; empty list if the file has no chunks
418+
public List<ArrayStats> columnZoneStats(String column) {
419+
if (chunks == null) {
420+
initialize();
421+
}
422+
List<ArrayStats> fromTable = decodeZoneTable(column);
423+
if (fromTable != null) {
424+
return fromTable;
425+
}
426+
// No zone-map table — surface each chunk's embedded ArrayStats (sum absent).
427+
List<ArrayStats> out = new ArrayList<>(chunks.size());
428+
for (ChunkSpec spec : chunks) {
429+
Layout flat = spec.layoutFor(column);
430+
out.add(flat == null ? ArrayStats.empty() : readFlatStats(flat));
431+
}
432+
return out;
433+
}
434+
435+
/// Decodes the column's `vortex.stats` zone-map table into one [ArrayStats] per zone, or
436+
/// returns `null` when the column has no zone map (so the caller falls back to per-chunk
437+
/// node stats). The table is a single flat segment encoding a struct with a subset of the
438+
/// `min`/`max`/`sum`/`null_count` fields (see [ZonedStatsSchema]); it is decoded into a
439+
/// short-lived confined arena and the scalar values are boxed out before the arena closes.
440+
private List<ArrayStats> decodeZoneTable(String column) {
441+
Layout zoned = findZonedLayout(file.layout(), column);
442+
if (zoned == null || zoned.children().size() < 2) {
443+
return null;
444+
}
445+
Layout statsFlat = zoned.children().get(1);
446+
if (!statsFlat.isFlat() || statsFlat.segments().isEmpty()) {
447+
return null;
448+
}
449+
DType columnDtype = columnDType(column);
450+
if (columnDtype == null) {
451+
return null;
452+
}
453+
int segIdx = statsFlat.segments().getFirst();
454+
if (segIdx < 0 || segIdx >= file.footer().segmentSpecs().size()) {
455+
return null;
456+
}
457+
DType.Struct statsDtype = ZonedStatsSchema.statsTableDtype(columnDtype, zoned.metadata());
458+
long nZones = statsFlat.rowCount();
459+
SegmentSpec spec = file.footer().segmentSpecs().get(segIdx);
460+
try (Arena tableArena = Arena.ofConfined()) {
461+
Array decoded = file.decodeFlatSegment(spec, statsDtype, nZones, tableArena);
462+
if (!(decoded instanceof StructArray table)) {
463+
return null;
464+
}
465+
Array minA = fieldOrNull(table, "min");
466+
Array maxA = fieldOrNull(table, "max");
467+
Array sumA = fieldOrNull(table, "sum");
468+
Array nullCountA = fieldOrNull(table, "null_count");
469+
List<ArrayStats> out = new ArrayList<>((int) nZones);
470+
for (long i = 0; i < nZones; i++) {
471+
Object nullCount = boxedScalar(nullCountA, i);
472+
out.add(new ArrayStats(
473+
boxedScalar(minA, i),
474+
boxedScalar(maxA, i),
475+
boxedScalar(sumA, i),
476+
null,
477+
nullCount == null ? null : ((Number) nullCount).longValue(),
478+
null, null));
479+
}
480+
return out;
481+
}
482+
}
483+
484+
/// Finds the first `vortex.stats` layout in the subtree of `column`'s top-level layout, or
485+
/// `null` when the column is not zone-mapped.
486+
private Layout findZonedLayout(Layout root, String column) {
487+
if (!(file.dtype() instanceof DType.Struct struct) || !root.isStruct()) {
488+
return null;
489+
}
490+
int idx = struct.fieldNames().indexOf(column);
491+
if (idx < 0 || idx >= root.children().size()) {
492+
return null;
493+
}
494+
return firstZoned(root.children().get(idx));
495+
}
496+
497+
private static Layout firstZoned(Layout layout) {
498+
if (layout.isZoned()) {
499+
return layout;
500+
}
501+
for (Layout child : layout.children()) {
502+
Layout found = firstZoned(child);
503+
if (found != null) {
504+
return found;
505+
}
506+
}
507+
return null;
508+
}
509+
510+
private static Array fieldOrNull(StructArray table, String field) {
511+
if (((DType.Struct) table.dtype()).fieldNames().contains(field)) {
512+
return table.field(field);
513+
}
514+
return null;
515+
}
516+
517+
/// Reads the boxed scalar at index `i` from a (possibly nullable) stats column, or `null`
518+
/// when the array is absent or the position is invalid.
519+
private static Object boxedScalar(Array array, long i) {
520+
if (array == null) {
521+
return null;
522+
}
523+
if (array instanceof MaskedArray masked) {
524+
if (!masked.isValid(i)) {
525+
return null;
526+
}
527+
return boxedScalar(masked.inner(), i);
528+
}
529+
return switch (array) {
530+
case LongArray a -> a.getLong(i);
531+
case IntArray a -> a.getInt(i);
532+
case DoubleArray a -> a.getDouble(i);
533+
case FloatArray a -> a.getFloat(i);
534+
case ShortArray a -> a.getShort(i);
535+
case ByteArray a -> a.getByte(i);
536+
case BoolArray a -> a.getBoolean(i);
537+
case VarBinArray a -> a.getString(i);
538+
default -> null;
539+
};
540+
}
541+
398542
/// Runs `action` on each remaining chunk inside a try-with-resources
399543
/// block so every chunk's [Arena] is released before the next iteration.
400544
/// Prefer this over a manual `while (hasNext()) { next(); `} loop

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,8 @@ public Map<String, ArrayStats> columnStats() {
180180
private ArrayStats aggregateStats(List<Layout> flats) {
181181
Object globalMin = null;
182182
Object globalMax = null;
183-
// Sum is meaningful only when every chunk carries a null count; one missing makes the
184-
// column total unknown (null), so don't report a partial sum.
183+
// Null count is meaningful only when every chunk carries it; one missing makes the column
184+
// total unknown (null), so don't report a partial count.
185185
long totalNullCount = 0L;
186186
boolean allHaveNullCount = !flats.isEmpty();
187187
for (Layout flat : flats) {
@@ -202,7 +202,9 @@ private ArrayStats aggregateStats(List<Layout> flats) {
202202
if (globalMin == null && globalMax == null && nullCount == null) {
203203
return ArrayStats.empty();
204204
}
205-
return new ArrayStats(globalMin, globalMax, null, nullCount, null, null);
205+
// Sum is left null at the file level: it lives in the per-zone stats table, surfaced by
206+
// ScanIterator.columnZoneStats rather than folded here.
207+
return new ArrayStats(globalMin, globalMax, null, null, nullCount, null, null);
206208
}
207209

208210
private ArrayStats readFlatStats(Layout flat) {

inspector/src/main/java/io/github/dfa1/vortex/inspect/ZonedStatsSchema.java renamed to reader/src/main/java/io/github/dfa1/vortex/reader/ZonedStatsSchema.java

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package io.github.dfa1.vortex.inspect;
1+
package io.github.dfa1.vortex.reader;
22

33
import io.github.dfa1.vortex.core.model.DType;
44

@@ -14,15 +14,15 @@
1414
///
1515
/// The shape is sourced from the Rust reference implementation:
1616
/// - Metadata format
17-
/// (<a href="https://github.com/spiraldb/vortex/blob/develop/vortex-layout/src/layouts/zoned/mod.rs">
18-
/// vortex-layout/src/layouts/zoned/mod.rs</a> — `ZonedMetadata`):
17+
/// ([vortex-layout/src/layouts/zoned/mod.rs](https://github.com/spiraldb/vortex/blob/develop/vortex-layout/src/layouts/zoned/mod.rs)
18+
/// — `ZonedMetadata`):
1919
/// bytes [0..4) are the zone length as a little-endian `u32`;
2020
/// remaining bytes form a `Stat` bitset (LSB-first per byte). Each
2121
/// set bit at index `i` indicates that the [Stat] with that
2222
/// ordinal is present in the auxiliary stats table.
2323
/// - Schema construction
24-
/// (<a href="https://github.com/spiraldb/vortex/blob/develop/vortex-layout/src/layouts/zoned/schema.rs">
25-
/// vortex-layout/src/layouts/zoned/schema.rs</a> — `stats_table_dtype`):
24+
/// ([vortex-layout/src/layouts/zoned/schema.rs](https://github.com/spiraldb/vortex/blob/develop/vortex-layout/src/layouts/zoned/schema.rs)
25+
/// — `stats_table_dtype`):
2626
/// for each present stat in ordinal order, append a struct field with the
2727
/// stat's name and the stat's nullable dtype. `Max` and `Min`
2828
/// each get an extra trailing field `max_is_truncated` /
@@ -167,8 +167,7 @@ public static DType.Struct statsTableDtype(DType columnDtype, List<Stat> present
167167
///
168168
/// Mirrors Rust's `Stat::dtype(&DType)` plus the aggregate-function
169169
/// return-type rules (see
170-
/// <a href="https://github.com/spiraldb/vortex/blob/develop/vortex-array/src/aggregate_fn/fns">
171-
/// vortex-array/src/aggregate_fn/fns</a>):
170+
/// [vortex-array/src/aggregate_fn/fns](https://github.com/spiraldb/vortex/blob/develop/vortex-array/src/aggregate_fn/fns)):
172171
/// - min/max → same dtype as column (except DType.Null → null);
173172
/// - is_constant/is_sorted/is_strict_sorted → non-nullable Bool;
174173
/// - null_count → non-nullable U64;

inspector/src/test/java/io/github/dfa1/vortex/inspect/ZonedStatsSchemaTest.java renamed to reader/src/test/java/io/github/dfa1/vortex/reader/ZonedStatsSchemaTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package io.github.dfa1.vortex.inspect;
1+
package io.github.dfa1.vortex.reader;
22

33
import java.lang.foreign.MemorySegment;
44

0 commit comments

Comments
 (0)