Skip to content

Commit 9661f55

Browse files
dfa1claude
andcommitted
feat(writer): emit SUM zone-map stat for numeric primitive columns
Probed Rust real-file fixtures (tpch_lineitem): numeric primitive and decimal columns carry [MAX, MIN, SUM, NULL_COUNT]; Utf8/extension/date carry [MAX, MIN, NULL_COUNT] (no SUM, even when extension storage is numeric). Closes the SUM half of the Rust-parity stats increment (ADR 0013 §6 aggregate push-down). The writer now computes a per-chunk SUM over each column's logical values (PrimitiveEncodingEncoder.sumStat: signed -> i64, unsigned -> u64, float -> f64; checked i64/u64 overflow -> null zone) and emits it as the SUM(5) field in the zone-map stats table for plain numeric primitives only — flat and dict paths alike, carried on ChunkRef / DictColRef. Validity placeholders are zero (sum-neutral), so nullable columns sum correctly without excluding nulls. Decimal SUM is deferred with decimal min/max. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e51da93 commit 9661f55

3 files changed

Lines changed: 279 additions & 37 deletions

File tree

writer/src/main/java/io/github/dfa1/vortex/writer/VortexWriter.java

Lines changed: 100 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,10 @@ public final class VortexWriter implements Closeable {
8181
private static final int LAYOUT_DICT = 3;
8282
private static final int LAYOUT_ZONED = 4;
8383

84-
// Stat ordinals in the Rust `Stat` enum (see ZonedStatsSchema). Emitted: MAX, MIN, NULL_COUNT.
84+
// Stat ordinals in the Rust `Stat` enum (see ZonedStatsSchema). Emitted: MAX, MIN, SUM, NULL_COUNT.
8585
private static final int STAT_MAX = 3;
8686
private static final int STAT_MIN = 4;
87+
private static final int STAT_SUM = 5;
8788
private static final int STAT_NULL_COUNT = 6;
8889

8990
// Columns with global cardinality below this threshold are dict-encoded across all chunks.
@@ -122,6 +123,7 @@ public final class VortexWriter implements Closeable {
122123
// Stats (ScalarValue bytes) of the most recently written segment, captured for ChunkRef.
123124
private byte[] lastStatsMin;
124125
private byte[] lastStatsMax;
126+
private byte[] lastStatsSum;
125127
// Null count of the most recently written segment's input data (0 for dense arrays).
126128
private long lastNullCount;
127129

@@ -469,7 +471,7 @@ public void writeChunk(Map<String, Object> columns) throws IOException {
469471
} else {
470472
long rowCount = arrayLength(data);
471473
int segIdx = writeSegment(colDtype, data);
472-
colChunks.get(colName).add(new ChunkRef(segIdx, rowCount, lastStatsMin, lastStatsMax, lastNullCount));
474+
colChunks.get(colName).add(new ChunkRef(segIdx, rowCount, lastStatsMin, lastStatsMax, lastStatsSum, lastNullCount));
473475
}
474476
}
475477
firstChunkSeen = true;
@@ -572,6 +574,7 @@ private int writeSegment(DType dtype, Object data, EncodingEncoder encodingOverr
572574
segs.add(new SegRef(offset, bytesWritten - offset));
573575
lastStatsMin = result.statsMin();
574576
lastStatsMax = result.statsMax();
577+
lastStatsSum = columnSum(dtype, data);
575578
lastNullCount = segNullCount;
576579
return segIdx;
577580
}
@@ -701,42 +704,49 @@ private void flushZoneMaps() throws IOException {
701704
if (chunks.isEmpty()) {
702705
continue;
703706
}
704-
DType minMaxDtype = zoneMinMaxDtype(columnDtype(colName));
707+
DType colDtype = columnDtype(colName);
708+
DType minMaxDtype = zoneMinMaxDtype(colDtype);
705709
boolean hasMinMax = minMaxDtype != null && chunks.stream().allMatch(ChunkRef::hasStats);
710+
DType sumDtype = zoneSumDtype(colDtype);
706711
long[] nullCounts = new long[chunks.size()];
707712
for (int i = 0; i < chunks.size(); i++) {
708713
nullCounts[i] = chunks.get(i).nullCount();
709714
}
710715
emitZoneMap(colName, hasMinMax ? minMaxDtype : null,
711716
chunks.stream().map(ChunkRef::statsMin).toList(),
712717
chunks.stream().map(ChunkRef::statsMax).toList(),
718+
sumDtype, chunks.stream().map(ChunkRef::statsSum).toList(),
713719
nullCounts);
714720
}
715-
// Dict-encoded columns (one zone per code chunk). MIN/MAX come from each chunk's logical
721+
// Dict-encoded columns (one zone per code chunk). MIN/MAX/SUM come from each chunk's logical
716722
// values (computed at dict-build time); NULL_COUNT always. Matches Rust, whose zone-map
717723
// stats are computed on the logical column dtype, independent of the dict encoding.
718724
for (Map.Entry<String, DictColRef> e : dictColRefs.entrySet()) {
719725
DictColRef ref = e.getValue();
720-
DType minMaxDtype = zoneMinMaxDtype(columnDtype(e.getKey()));
726+
DType colDtype = columnDtype(e.getKey());
727+
DType minMaxDtype = zoneMinMaxDtype(colDtype);
721728
boolean hasMinMax = minMaxDtype != null
722729
&& ref.chunkStatsMin().stream().allMatch(java.util.Objects::nonNull)
723730
&& ref.chunkStatsMax().stream().allMatch(java.util.Objects::nonNull);
724731
long[] nullCounts = ref.chunkNullCounts().stream().mapToLong(Long::longValue).toArray();
725732
emitZoneMap(e.getKey(), hasMinMax ? minMaxDtype : null,
726-
ref.chunkStatsMin(), ref.chunkStatsMax(), nullCounts);
733+
ref.chunkStatsMin(), ref.chunkStatsMax(),
734+
zoneSumDtype(colDtype), ref.chunkStatsSum(), nullCounts);
727735
}
728736
}
729737

730738
private DType columnDtype(String colName) {
731739
return schema.fieldTypes().get(schema.fieldNames().indexOf(colName));
732740
}
733741

734-
/// Writes one `vortex.stats` zone-map for `colName`: one zone per chunk, with NULL_COUNT always
735-
/// and MAX/MIN (plus always-false `_is_truncated` flags) when `minMaxDtype` is non-null.
736-
/// `minBytes`/`maxBytes` hold each zone's serialised min/max scalar — read only when
737-
/// `minMaxDtype` is set. Field/bit order follows ZonedStatsSchema: MAX(3), MIN(4), NULL_COUNT(6).
738-
private void emitZoneMap(String colName, DType minMaxDtype,
739-
List<byte[]> minBytes, List<byte[]> maxBytes, long[] nullCounts) throws IOException {
742+
/// Writes one `vortex.stats` zone-map for `colName`: one zone per chunk, with NULL_COUNT always,
743+
/// MAX/MIN (plus always-false `_is_truncated` flags) when `minMaxDtype` is non-null, and SUM when
744+
/// `sumDtype` is non-null. `minBytes`/`maxBytes`/`sumBytes` hold each zone's serialised scalar —
745+
/// read only when the matching dtype is set; a `null` `sumBytes` entry marks an overflowed zone
746+
/// (recorded as a null sum). Field/bit order follows ZonedStatsSchema: MAX(3), MIN(4), SUM(5),
747+
/// NULL_COUNT(6).
748+
private void emitZoneMap(String colName, DType minMaxDtype, List<byte[]> minBytes, List<byte[]> maxBytes,
749+
DType sumDtype, List<byte[]> sumBytes, long[] nullCounts) throws IOException {
740750
int nZones = nullCounts.length;
741751
boolean[] allValid = new boolean[nZones];
742752
java.util.Arrays.fill(allValid, true);
@@ -759,13 +769,21 @@ private void emitZoneMap(String colName, DType minMaxDtype,
759769
types.add(new DType.Bool(false));
760770
fields.add(notTruncated.clone());
761771
}
772+
if (sumDtype != null) {
773+
boolean[] sumValid = new boolean[nZones];
774+
Object sumArr = sumColumn(sumDtype, sumBytes, sumValid);
775+
names.add("sum");
776+
types.add(sumDtype);
777+
fields.add(new NullableData(sumArr, sumValid));
778+
}
762779
names.add("null_count");
763780
types.add(new DType.Primitive(PType.U64, true));
764781
fields.add(new NullableData(nullCounts, allValid.clone()));
765782

766783
DType.Struct statsDtype = new DType.Struct(List.copyOf(names), List.copyOf(types), false);
767784
int zonesSegIdx = writeSegment(statsDtype, new StructData(fields), new StructEncodingEncoder());
768-
zoneMaps.put(colName, new ZoneMapRef(zonesSegIdx, nZones, options.chunkSize(), minMaxDtype != null));
785+
zoneMaps.put(colName,
786+
new ZoneMapRef(zonesSegIdx, nZones, options.chunkSize(), minMaxDtype != null, sumDtype != null));
769787
}
770788

771789
/// Wraps a column's data layout in a `vortex.stats` (zoned) layout when a zone-map was
@@ -778,20 +796,23 @@ private int wrapZoneMap(FlatBufferBuilder fbb, String colName, int dataLayout, l
778796
int zonesSegV = Layout.createSegmentsVector(fbb, new long[]{zm.zonesSegIdx()});
779797
int zonesFlat = Layout.createLayout(fbb, LAYOUT_FLAT, zm.nZones(), 0, 0, zonesSegV);
780798
int childV = Layout.createChildrenVector(fbb, new int[]{dataLayout, zonesFlat});
781-
int metaV = Layout.createMetadataVector(fbb, zonedMetadataBytes(zm.zoneLen(), zm.hasMinMax()));
799+
int metaV = Layout.createMetadataVector(fbb, zonedMetadataBytes(zm.zoneLen(), zm.hasMinMax(), zm.hasSum()));
782800
return Layout.createLayout(fbb, LAYOUT_ZONED, colRows, metaV, childV, 0);
783801
}
784802

785803
/// `vortex.stats` metadata: `u32` zone length (LE) + a 1-byte stat bitset (LSB-first) with the
786-
/// NULL_COUNT bit always set and the MAX/MIN bits set when present, matching
804+
/// NULL_COUNT bit always set and the MAX/MIN and SUM bits set when present, matching
787805
/// [io.github.dfa1.vortex.inspect] `ZonedStatsSchema`.
788-
private static byte[] zonedMetadataBytes(long zoneLen, boolean hasMinMax) {
806+
private static byte[] zonedMetadataBytes(long zoneLen, boolean hasMinMax, boolean hasSum) {
789807
byte[] meta = new byte[5];
790808
ByteBuffer.wrap(meta).order(ByteOrder.LITTLE_ENDIAN).putInt((int) zoneLen);
791809
int bits = 1 << STAT_NULL_COUNT;
792810
if (hasMinMax) {
793811
bits |= (1 << STAT_MAX) | (1 << STAT_MIN);
794812
}
813+
if (hasSum) {
814+
bits |= (1 << STAT_SUM);
815+
}
795816
meta[4] = (byte) bits;
796817
return meta;
797818
}
@@ -820,6 +841,32 @@ private static DType zoneMinMaxDtype(DType dtype) {
820841
};
821842
}
822843

844+
/// The (nullable) dtype a zone-map stores SUM in for `dtype`, or `null` when the column has no
845+
/// recordable sum. Only plain numeric primitives are summed — signed → `i64`, unsigned → `u64`,
846+
/// float → `f64` — matching Rust, which emits SUM for primitives and decimals but not for
847+
/// Utf8/extension/date columns even when their storage is numeric.
848+
private static DType zoneSumDtype(DType dtype) {
849+
if (!(dtype instanceof DType.Primitive p)) {
850+
return null;
851+
}
852+
return switch (p.ptype()) {
853+
case U8, U16, U32, U64 -> new DType.Primitive(PType.U64, true);
854+
case I8, I16, I32, I64 -> new DType.Primitive(PType.I64, true);
855+
case F16, F32, F64 -> new DType.Primitive(PType.F64, true);
856+
};
857+
}
858+
859+
/// The serialised per-chunk SUM scalar for `data` of logical type `dtype`, or `null` when the
860+
/// column is not summable (non-primitive) or the sum overflowed. Validity placeholders are zero
861+
/// and therefore sum-neutral, so a nullable carrier sums correctly via its values.
862+
private static byte[] columnSum(DType dtype, Object data) {
863+
if (!(dtype instanceof DType.Primitive p)) {
864+
return null;
865+
}
866+
Object values = data instanceof NullableData nd ? nd.values() : data;
867+
return PrimitiveEncodingEncoder.sumStat(p.ptype(), values);
868+
}
869+
823870
/// Builds the per-zone min (or max) values array for the resolved min/max `dtype`, decoding each
824871
/// zone's serialised [ScalarValue] stat into the array shape its encoder expects.
825872
private static Object zoneStatValues(DType minMaxDtype, List<byte[]> statBytes) throws IOException {
@@ -830,6 +877,28 @@ private static Object zoneStatValues(DType minMaxDtype, List<byte[]> statBytes)
830877
};
831878
}
832879

880+
/// Builds the per-zone SUM array for `sumDtype` (i64/u64 → `long[]`, f64 → `double[]`), decoding
881+
/// each zone's serialised scalar. Zones whose sum overflowed carry a `null` entry in `sumBytes`;
882+
/// `valid[i]` is set accordingly so the stat field reports them as null.
883+
private static Object sumColumn(DType sumDtype, List<byte[]> sumBytes, boolean[] valid) throws IOException {
884+
PType ptype = ((DType.Primitive) sumDtype).ptype();
885+
int n = sumBytes.size();
886+
if (ptype == PType.F64) {
887+
double[] a = new double[n];
888+
for (int i = 0; i < n; i++) {
889+
valid[i] = sumBytes.get(i) != null;
890+
a[i] = valid[i] ? scalarDouble(sumBytes.get(i)) : 0.0;
891+
}
892+
return a;
893+
}
894+
long[] a = new long[n];
895+
for (int i = 0; i < n; i++) {
896+
valid[i] = sumBytes.get(i) != null;
897+
a[i] = valid[i] ? scalarLong(sumBytes.get(i)) : 0L;
898+
}
899+
return a;
900+
}
901+
833902
/// Builds the per-zone string array by decoding each zone's serialised string [ScalarValue]
834903
/// stat. Used for Utf8 columns whose `vortex.varbin` encoder records full string min/max scalars.
835904
private static String[] statStringColumn(List<byte[]> statBytes) throws IOException {
@@ -1078,7 +1147,7 @@ private void writeGlobalDictColumn(String colName, DType.Primitive dtype, List<O
10781147
for (Object chunk : chunks) {
10791148
long rowCount = arrayLength(chunk);
10801149
int segIdx = writeSegment(dtype, chunk);
1081-
colChunks.get(colName).add(new ChunkRef(segIdx, rowCount, lastStatsMin, lastStatsMax, lastNullCount));
1150+
colChunks.get(colName).add(new ChunkRef(segIdx, rowCount, lastStatsMin, lastStatsMax, lastStatsSum, lastNullCount));
10821151
}
10831152
return;
10841153
}
@@ -1099,23 +1168,25 @@ private void writeGlobalDictColumn(String colName, DType.Primitive dtype, List<O
10991168
List<Long> chunkNullCounts = new ArrayList<>();
11001169
List<byte[]> chunkStatsMin = new ArrayList<>();
11011170
List<byte[]> chunkStatsMax = new ArrayList<>();
1171+
List<byte[]> chunkStatsSum = new ArrayList<>();
11021172
for (Object chunk : chunks) {
11031173
int len = primitiveArrayLen(chunk, ptype);
11041174
Object codesArr = buildCodesArray(chunk, ptype, valueMap, codePType, len);
11051175
codesSegIdxes.add(writeSegment(codesDtype, codesArr));
11061176
chunkRowCounts.add((long) len);
11071177
chunkNullCounts.add(chunk instanceof NullableData nd ? countNulls(nd.validity()) : 0L);
1108-
// Per-zone min/max over the chunk's logical values (matches the flat primitive path:
1109-
// computed on nd.values(), placeholders included). Lets the dict zone-map prune like a
1110-
// plain primitive column.
1178+
// Per-zone min/max + sum over the chunk's logical values (matches the flat primitive
1179+
// path: computed on nd.values(), placeholders included). Lets the dict zone-map prune
1180+
// and aggregate like a plain primitive column.
11111181
Object values = chunk instanceof NullableData nd ? nd.values() : chunk;
11121182
byte[][] mm = PrimitiveEncodingEncoder.minMaxStats(ptype, values);
11131183
chunkStatsMin.add(mm != null ? mm[0] : null);
11141184
chunkStatsMax.add(mm != null ? mm[1] : null);
1185+
chunkStatsSum.add(PrimitiveEncodingEncoder.sumStat(ptype, values));
11151186
}
11161187

11171188
dictColRefs.put(colName, new DictColRef(valuesSegIdx, dictSize, codesSegIdxes,
1118-
chunkRowCounts, chunkNullCounts, chunkStatsMin, chunkStatsMax));
1189+
chunkRowCounts, chunkNullCounts, chunkStatsMin, chunkStatsMax, chunkStatsSum));
11191190
}
11201191

11211192
private void writeGlobalDictUtf8Column(String colName, DType.Utf8 dtype, List<Object> chunks)
@@ -1134,7 +1205,7 @@ private void writeGlobalDictUtf8Column(String colName, DType.Utf8 dtype, List<Ob
11341205
for (Object chunk : chunks) {
11351206
long rowCount = arrayLength(chunk);
11361207
int segIdx = writeSegment(dtype, chunk);
1137-
colChunks.get(colName).add(new ChunkRef(segIdx, rowCount, lastStatsMin, lastStatsMax, lastNullCount));
1208+
colChunks.get(colName).add(new ChunkRef(segIdx, rowCount, lastStatsMin, lastStatsMax, lastStatsSum, lastNullCount));
11381209
}
11391210
return;
11401211
}
@@ -1166,8 +1237,10 @@ private void writeGlobalDictUtf8Column(String colName, DType.Utf8 dtype, List<Ob
11661237
chunkStatsMin.add(mm != null ? mm[0] : null);
11671238
chunkStatsMax.add(mm != null ? mm[1] : null);
11681239
}
1240+
// Utf8 columns are not summed (zoneSumDtype is null), so the sum bytes are never read.
1241+
List<byte[]> noSum = java.util.Collections.nCopies(codesSegIdxes.size(), null);
11691242
dictColRefs.put(colName, new DictColRef(valuesSegIdx, dictSize, codesSegIdxes,
1170-
chunkRowCounts, chunkNullCounts, chunkStatsMin, chunkStatsMax));
1243+
chunkRowCounts, chunkNullCounts, chunkStatsMin, chunkStatsMax, noSum));
11711244
}
11721245

11731246
private static Object buildUtf8CodesArray(String[] strs, Map<String, Integer> valueMap, PType codePType) {
@@ -1359,7 +1432,8 @@ private static Object buildCodesArray(Object data, PType ptype, Map<Object, Inte
13591432
private record SegRef(long offset, long len) {
13601433
}
13611434

1362-
private record ChunkRef(int segIdx, long rowCount, byte[] statsMin, byte[] statsMax, long nullCount) {
1435+
private record ChunkRef(int segIdx, long rowCount, byte[] statsMin, byte[] statsMax,
1436+
byte[] statsSum, long nullCount) {
13631437
boolean hasStats() {
13641438
return statsMin != null && statsMax != null;
13651439
}
@@ -1368,12 +1442,12 @@ boolean hasStats() {
13681442
/// Per-column zone-map: the flat segment holding the per-zone stats table, the zone
13691443
/// count (one zone per chunk), the logical rows per zone, and whether the table carries
13701444
/// MIN/MAX (else NULL_COUNT only).
1371-
private record ZoneMapRef(int zonesSegIdx, long nZones, long zoneLen, boolean hasMinMax) {
1445+
private record ZoneMapRef(int zonesSegIdx, long nZones, long zoneLen, boolean hasMinMax, boolean hasSum) {
13721446
}
13731447

13741448
private record DictColRef(int valuesSegIdx, long valuesLen, List<Integer> codesSegIdxes,
13751449
List<Long> chunkRowCounts, List<Long> chunkNullCounts,
1376-
List<byte[]> chunkStatsMin, List<byte[]> chunkStatsMax) {
1450+
List<byte[]> chunkStatsMin, List<byte[]> chunkStatsMax, List<byte[]> chunkStatsSum) {
13771451
long totalRows() {
13781452
return chunkRowCounts.stream().mapToLong(Long::longValue).sum();
13791453
}

0 commit comments

Comments
 (0)