@@ -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