Skip to content

Commit 70191f1

Browse files
dfa1claude
andcommitted
feat(reader): extract per-zone SUM fold into reader.compute.ZoneReducer
Move the zone-map SUM fold out of the Calcite adapter into a reusable reader-side primitive (ADR 0013 §6, whole-zone tier). ZoneReducer.sum folds the per-zone SUM rows from ScanIterator.columnZoneStats with no data segment decoded, returning null when no zone carries a usable sum so the caller streams instead — identical semantics to the inlined fold it replaces. Lives in reader.compute as the seam a future vortex-compute module extracts: it depends only on the public reader scan API, so the move is mechanical once a second consumer appears. The predicate/residual tier waits on its consumer (Calcite aggregate + WHERE push-down). VortexAggregates now delegates SUM to ZoneReducer; behaviour unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fb5f77c commit 70191f1

4 files changed

Lines changed: 185 additions & 41 deletions

File tree

TODO.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@ Per-encoding gotchas:
100100
min/max/sum/null count, decoding sum from the `vortex.stats` zone-map table (matches files from
101101
Rust, whose flat writer omits per-flat sum). Calcite `VortexAggregates.SUM`/`AVG` now fold those
102102
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.
103+
The fold is now a reusable `reader.compute.ZoneReducer.sum(col)` (the seam a future
104+
`vortex-compute` extracts); Calcite consumes it.
105+
Next: the residual tier needs a consumer first — wire Calcite aggregate+`WHERE` push-down, then
106+
add `ZoneReducer` predicate support (whole-zone fold + boundary-zone streaming) and the
107+
`Mask`/`Predicate`/kernel vocab on top.
104108

105109
## Encodings
106110

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

Lines changed: 6 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,16 @@
1010
import io.github.dfa1.vortex.reader.array.FloatArray;
1111
import io.github.dfa1.vortex.reader.array.IntArray;
1212
import io.github.dfa1.vortex.reader.array.LongArray;
13-
14-
import java.util.List;
13+
import io.github.dfa1.vortex.reader.compute.ZoneReducer;
1514

1615
/// Column aggregates answered with the cheapest available source.
1716
///
1817
/// `MIN` / `MAX` / `COUNT` are read from the per-segment zone-map statistics embedded in the
1918
/// 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.
19+
/// `SUM` rows via [ZoneReducer#sum(String)] (ADR 0013 §6): when every zone carries a sum the
20+
/// answer is metadata-only too, with no data segment touched. Only when a zone lacks a sum — a
21+
/// column with no zone map, whose flat nodes do not retain it — does it fall back to a streaming
22+
/// scan.
2423
public final class VortexAggregates {
2524

2625
/// Where an aggregate's value came from.
@@ -71,7 +70,7 @@ public static Summary of(VortexReader reader, String column) {
7170
// SUM/AVG: fold the per-zone SUM rows when every zone carries one (metadata-only); fall
7271
// back to a single streaming scan otherwise. Integer columns sum into a long (exact);
7372
// floating columns into a double.
74-
Number sum = zoneSum(reader, column);
73+
Number sum = new ZoneReducer(reader).sum(column);
7574
Source sumSource = Source.ZONE_STATS_PUSHDOWN;
7675
if (sum == null) {
7776
sum = scanSum(reader, column);
@@ -83,39 +82,6 @@ public static Summary of(VortexReader reader, String column) {
8382
Source.ZONE_STATS_PUSHDOWN, sumSource);
8483
}
8584

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-
}
117-
}
118-
11985
private static long totalRows(VortexReader reader) {
12086
try (ScanIterator scan = reader.scan(ScanOptions.all())) {
12187
long total = 0L;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package io.github.dfa1.vortex.reader.compute;
2+
3+
import io.github.dfa1.vortex.reader.ArrayStats;
4+
import io.github.dfa1.vortex.reader.ScanIterator;
5+
import io.github.dfa1.vortex.reader.ScanOptions;
6+
import io.github.dfa1.vortex.reader.VortexReader;
7+
8+
import java.util.List;
9+
import java.util.Objects;
10+
11+
/// Answers column reductions from the per-zone statistics table, without decoding any data
12+
/// segment.
13+
///
14+
/// This is the whole-zone tier of the aggregate push-down sketched in ADR 0013 §6: a reduction
15+
/// folds the per-zone rows surfaced by [ScanIterator#columnZoneStats(String)] instead of streaming
16+
/// the column. The boundary (residual) tier — partially-selected zones under a predicate — is a
17+
/// later increment; today the reducer takes no predicate and folds every zone.
18+
///
19+
/// The reducer lives in `reader.compute` as the seam a future `vortex-compute` module extracts: it
20+
/// depends only on the public reader scan API, so the move is mechanical once a second consumer
21+
/// (the arrow bridge, a query façade) appears.
22+
public final class ZoneReducer {
23+
24+
private final VortexReader reader;
25+
26+
/// Creates a reducer over an open reader.
27+
///
28+
/// @param reader an open reader over the file; not closed by this reducer
29+
public ZoneReducer(VortexReader reader) {
30+
this.reader = Objects.requireNonNull(reader, "reader");
31+
}
32+
33+
/// Folds the per-zone `SUM` statistics for `column` into the column total, or returns `null`
34+
/// when the reduction cannot be answered from the zone-map table — a column with no zone map,
35+
/// or a zone whose `SUM` was not retained (e.g. an overflowed zone) — so the caller streams the
36+
/// column instead.
37+
///
38+
/// Integer columns fold into a [Long] (exact, wrapping at 2^63 like SQL `SUM(BIGINT)`); floating
39+
/// columns into a [Double]. The two are never mixed: a column is one dtype, and the zone-stat
40+
/// boxing already distinguishes them.
41+
///
42+
/// @param column the numeric column name
43+
/// @return the column sum as a [Long] or [Double], or `null` if no zone carries a usable sum
44+
public Number sum(String column) {
45+
Objects.requireNonNull(column, "column");
46+
try (ScanIterator scan = reader.scan(ScanOptions.columns(column))) {
47+
List<ArrayStats> zones = scan.columnZoneStats(column);
48+
if (zones.isEmpty()) {
49+
return null;
50+
}
51+
long longSum = 0L;
52+
double doubleSum = 0.0;
53+
boolean isFloating = false;
54+
for (ArrayStats zone : zones) {
55+
switch (zone.sum()) {
56+
case Long l -> longSum += l;
57+
case Double d -> {
58+
isFloating = true;
59+
doubleSum += d;
60+
}
61+
case null, default -> {
62+
// A zone with no usable sum (no zone map, or an unhandled stat type) — the
63+
// whole reduction can't be pushed down, so signal a fall back to a scan.
64+
return null;
65+
}
66+
}
67+
}
68+
// Return via if/else, not a ?: ternary: a numeric conditional unboxes both branches and
69+
// widens the Long to double, silently turning an integer-column sum into a Double.
70+
if (isFloating) {
71+
return doubleSum;
72+
}
73+
return longSum;
74+
}
75+
}
76+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package io.github.dfa1.vortex.writer;
2+
3+
import io.github.dfa1.vortex.core.model.DType;
4+
import io.github.dfa1.vortex.reader.ReadRegistry;
5+
import io.github.dfa1.vortex.reader.VortexReader;
6+
import io.github.dfa1.vortex.reader.compute.ZoneReducer;
7+
import org.junit.jupiter.api.Test;
8+
import org.junit.jupiter.api.io.TempDir;
9+
10+
import java.io.IOException;
11+
import java.nio.channels.FileChannel;
12+
import java.nio.file.Path;
13+
import java.nio.file.StandardOpenOption;
14+
import java.util.List;
15+
import java.util.Map;
16+
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
19+
/// Round-trip tests for [ZoneReducer] — the whole-zone tier of ADR 0013 §6 aggregate push-down.
20+
/// SUM folds the per-zone `SUM` rows the writer embeds in each chunk's zone-map stats, with no data
21+
/// segment decoded.
22+
class ZoneReducerTest {
23+
24+
private static final DType.Struct I64_SCHEMA = new DType.Struct(
25+
List.of("id"), List.of(DType.I64), false);
26+
27+
private static final DType.Struct F64_SCHEMA = new DType.Struct(
28+
List.of("v"), List.of(DType.F64), false);
29+
30+
private static ReadRegistry registry() {
31+
return ReadRegistry.builder().registerServiceLoaded().build();
32+
}
33+
34+
private static long[] range(long from, long to) {
35+
long[] arr = new long[(int) (to - from + 1)];
36+
for (int i = 0; i < arr.length; i++) {
37+
arr[i] = from + i;
38+
}
39+
return arr;
40+
}
41+
42+
@Test
43+
void integerSumFoldsZonesToExactLong(@TempDir Path tmp) throws IOException {
44+
// Given — three I64 chunks (one zone each); column total is 1+..+150 = 11325
45+
Path file = tmp.resolve("ints.vtx");
46+
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
47+
var w = VortexWriter.create(ch, I64_SCHEMA, WriteOptions.defaults())) {
48+
w.writeChunk(Map.of("id", range(1L, 50L)));
49+
w.writeChunk(Map.of("id", range(51L, 100L)));
50+
w.writeChunk(Map.of("id", range(101L, 150L)));
51+
}
52+
53+
// When
54+
try (VortexReader reader = VortexReader.open(file, registry())) {
55+
Number result = new ZoneReducer(reader).sum("id");
56+
57+
// Then — exact Long (not a Double widening), no data segment decoded
58+
assertThat(result).isInstanceOf(Long.class).isEqualTo(11325L);
59+
}
60+
}
61+
62+
@Test
63+
void floatSumFoldsZonesToDouble(@TempDir Path tmp) throws IOException {
64+
// Given — a single F64 chunk so the sum stat boxes as Double, not Long
65+
Path file = tmp.resolve("floats.vtx");
66+
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
67+
var w = VortexWriter.create(ch, F64_SCHEMA, WriteOptions.defaults())) {
68+
w.writeChunk(Map.of("v", new double[]{1.5, 2.5, 3.0}));
69+
}
70+
71+
// When
72+
try (VortexReader reader = VortexReader.open(file, registry())) {
73+
Number result = new ZoneReducer(reader).sum("v");
74+
75+
// Then
76+
assertThat(result).isInstanceOf(Double.class).isEqualTo(7.0);
77+
}
78+
}
79+
80+
@Test
81+
void noZoneMapYieldsNull(@TempDir Path tmp) throws IOException {
82+
// Given — zone maps disabled, so no per-zone SUM exists to fold
83+
Path file = tmp.resolve("nostats.vtx");
84+
WriteOptions noZoneMaps = new WriteOptions(65_536, false, 0.90, 0, true, false);
85+
try (var ch = FileChannel.open(file, StandardOpenOption.CREATE, StandardOpenOption.WRITE);
86+
var w = VortexWriter.create(ch, I64_SCHEMA, noZoneMaps)) {
87+
w.writeChunk(Map.of("id", range(1L, 50L)));
88+
}
89+
90+
// When
91+
try (VortexReader reader = VortexReader.open(file, registry())) {
92+
Number result = new ZoneReducer(reader).sum("id");
93+
94+
// Then — null signals "not answerable from zones", caller must stream
95+
assertThat(result).isNull();
96+
}
97+
}
98+
}

0 commit comments

Comments
 (0)