Skip to content

Commit 4f6c578

Browse files
dfa1claude
andcommitted
fix(reader)!: ArrayStats mirrors Rust's array stats table
ArrayStats.fromFbs dropped is_sorted, is_strict_sorted, is_constant, uncompressed_size_in_bytes and nan_count, and the record carried a trueCount the wire has no field for (Rust has no TrueCount stat: a bool column's true count is its sum). Rust writes is_sorted/is_strict_sorted into flat array stats, so Java read them as unknown. The record now has one component per field of the FlatBuffer ArrayStats table (Rust's Stat enum), in schema order; the 7-arg constructor is gone. min_precision/max_precision are still not read: an inexact bound is still a valid bound, so pruning stays correct. Cross-check: vortex-jni writes an ascending id column, and every flat data segment's peeked stats report isSorted/isStrictSorted true. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 1b05eaf commit 4f6c578

10 files changed

Lines changed: 119 additions & 51 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- **Breaking:** `ArrayStats` has one component per field of Rust's array stats, so `isSorted`, `isStrictSorted`, `isConstant`, `uncompressedSizeInBytes` and `nanCount` are read from the file and `trueCount` is gone (a bool column's true count is its `sum`). ([#581](https://github.com/dfa1/vortex-java/pull/581))
12+
1013
## [0.18.0] — 2026-10-19
1114

1215
### Added

‎inspector/src/main/java/io/github/dfa1/vortex/inspect/InspectorTree.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ private static ArrayStats fold(List<Zone> zones) {
289289
if (min == null && max == null) {
290290
return ArrayStats.empty();
291291
}
292-
return new ArrayStats(min, max, null, null, null, null, null);
292+
return new ArrayStats(min, max, null, null, null, null, null, null, null);
293293
}
294294

295295
/// The chunks of a column: the children of the first chunked node on its data path, or the

‎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
@@ -103,7 +103,7 @@ static ArrayStats aggregateStats(InspectorTree.Node node) {
103103
if (min == null && max == null) {
104104
return ArrayStats.empty();
105105
}
106-
return new ArrayStats(min, max, null, null, null, null, null);
106+
return new ArrayStats(min, max, null, null, null, null, null, null, null);
107107
}
108108

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

‎inspector/src/test/java/io/github/dfa1/vortex/inspect/HtmlReportTest.java‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ void render_escapesUntrustedColumnNames() {
101101
@Test
102102
void render_escapesUntrustedStatValues() {
103103
// Given — min/max are decoded scalars from the file, so a string column can carry markup
104-
InspectorTree sut = singleColumnWithStats(new ArrayStats("<b>", "\"&\"", null, null, 3L, null, null));
104+
InspectorTree sut = singleColumnWithStats(new ArrayStats("<b>", "\"&\"", null, null, null, null, 3L, null, null));
105105

106106
// When
107107
String result = HtmlReport.render(sut, "evil.vortex");
@@ -540,7 +540,7 @@ void render_timestampColumn_showsBoundsAsInstantsNotEpochIntegers() {
540540
Layout leaf = new Layout(LayoutId.parse("vortex.flat"), 2, null, List.of(), List.of(0));
541541
Layout root = new Layout(LayoutId.parse("vortex.struct"), 2, null, List.of(leaf), List.of());
542542
InspectorTree.Node leafNode = new InspectorTree.Node(leaf, Optional.of("ts"), Set.of(),
543-
new ArrayStats(1_704_067_200_000L, 1_706_867_193_000L, null, null, null, null, null),
543+
new ArrayStats(1_704_067_200_000L, 1_706_867_193_000L, null, null, null, null, null, null, null),
544544
List.of());
545545
InspectorTree.Node rootNode = new InspectorTree.Node(root, Optional.empty(), Set.of(),
546546
ArrayStats.empty(), List.of(leafNode));
@@ -590,7 +590,7 @@ private static InspectorTree twoColumnTree() {
590590
Layout root = new Layout(LayoutId.parse("vortex.struct"), 1000, null, List.of(idLeaf, valLeaf), List.of());
591591

592592
InspectorTree.Node idNode = new InspectorTree.Node(idLeaf, Optional.of("id"),
593-
Set.of("fastlanes.bitpacked"), new ArrayStats(1L, 999L, null, null, 0L, null, null), List.of());
593+
Set.of("fastlanes.bitpacked"), new ArrayStats(1L, 999L, null, null, null, null, 0L, null, null), List.of());
594594
InspectorTree.Node valNode = new InspectorTree.Node(valLeaf, Optional.of("value"),
595595
Set.of("vortex.constant"), ArrayStats.empty(), List.of());
596596
InspectorTree.Node rootNode = new InspectorTree.Node(root, Optional.empty(),
@@ -612,9 +612,9 @@ private static InspectorTree chunkedTree() {
612612
Layout root = new Layout(LayoutId.parse("vortex.struct"), 1000, null, List.of(chunked), List.of());
613613

614614
InspectorTree.Node n0 = new InspectorTree.Node(c0, Optional.empty(), Set.of(),
615-
new ArrayStats(0L, 499L, null, null, null, null, null), List.of());
615+
new ArrayStats(0L, 499L, null, null, null, null, null, null, null), List.of());
616616
InspectorTree.Node n1 = new InspectorTree.Node(c1, Optional.empty(), Set.of(),
617-
new ArrayStats(500L, 999L, null, null, null, null, null), List.of());
617+
new ArrayStats(500L, 999L, null, null, null, null, null, null, null), List.of());
618618
InspectorTree.Node col = new InspectorTree.Node(chunked, Optional.of("id"), Set.of(),
619619
ArrayStats.empty(), List.of(n0, n1));
620620
InspectorTree.Node rootNode = new InspectorTree.Node(root, Optional.empty(), Set.of(),

‎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
@@ -135,9 +135,9 @@ void render_aggregatesMinMaxAcrossChunks() {
135135
Layout structLayout = new Layout(LayoutId.parse("vortex.struct"), 1000, null, List.of(chunked), List.of());
136136

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

‎integration/src/test/java/io/github/dfa1/vortex/integration/VortexInspectorIntegrationTest.java‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import dev.vortex.jni.NativeLoader;
77
import io.github.dfa1.vortex.core.model.ColumnName;
88
import io.github.dfa1.vortex.core.model.DType;
9+
import io.github.dfa1.vortex.reader.ArrayStats;
910
import io.github.dfa1.vortex.reader.ReadRegistry;
1011
import io.github.dfa1.vortex.writer.WriteOptions;
1112
import io.github.dfa1.vortex.inspect.HtmlReport;
@@ -31,7 +32,9 @@
3132
import java.nio.file.StandardOpenOption;
3233
import java.util.Map;
3334
import java.nio.file.Path;
35+
import java.util.ArrayList;
3436
import java.util.List;
37+
import java.util.Optional;
3538
import java.util.Random;
3639

3740
import static org.assertj.core.api.Assertions.assertThat;
@@ -162,6 +165,44 @@ void htmlReport_globalDictColumn_chunkMinMaxAreValuesNotCodes(@TempDir Path tmp)
162165
.contains("<td><code>cherry</code></td><td><code>date</code></td>");
163166
}
164167

168+
@Test
169+
void peek_readsSortednessRustRecordsInFlatArrayStats(@TempDir Path tmp) throws IOException {
170+
// Given — Rust records is_sorted/is_strict_sorted in a flat segment's array stats for an
171+
// ascending integer column. Java's ArrayStats used to drop both (and carried a trueCount
172+
// the wire has no field for), so these read as unknown.
173+
Path file = tmp.resolve("sorted.vtx");
174+
writeJni(file, 50_000);
175+
176+
// When
177+
List<ArrayStats> result = new ArrayList<>();
178+
try (VortexReader vf = VortexReader.open(file, ReadRegistry.loadAll())) {
179+
InspectorTree tree = InspectorTree.buildShallow(vf);
180+
InspectorTree.Node id = tree.root().children().stream()
181+
.filter(c -> c.fieldName().equals(Optional.of("id"))).findFirst().orElseThrow();
182+
for (InspectorTree.Node flat : dataFlats(id)) {
183+
result.add(InspectorTree.peek(flat, vf).stats());
184+
}
185+
}
186+
187+
// Then
188+
assertThat(result).isNotEmpty().allSatisfy(stats -> {
189+
assertThat(stats.isSorted()).isTrue();
190+
assertThat(stats.isStrictSorted()).isTrue();
191+
});
192+
}
193+
194+
/// The flat segments holding a column's values: every chunk of a chunked node, else the first
195+
/// child (a zoned node's second child is its zone-map table, not data).
196+
private static List<InspectorTree.Node> dataFlats(InspectorTree.Node node) {
197+
if (node.layout().isFlat()) {
198+
return List.of(node);
199+
}
200+
if (node.layout().isChunked()) {
201+
return node.children().stream().flatMap(c -> dataFlats(c).stream()).toList();
202+
}
203+
return dataFlats(node.children().getFirst());
204+
}
205+
165206
private static String[] repeat(int rows, String a, String b) {
166207
String[] out = new String[rows];
167208
for (int i = 0; i < rows; i++) {

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

Lines changed: 27 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,41 +7,30 @@
77
import java.lang.foreign.MemorySegment;
88
import java.nio.charset.StandardCharsets;
99

10-
/// Per-array statistics embedded in the encoding tree.
10+
/// Per-array statistics embedded in the encoding tree, one component per field of the wire
11+
/// `ArrayStats` table (Rust's `Stat` enum). A bool column's true count is its `sum`, as in Rust.
1112
///
12-
/// @param min minimum value in the array, or `null` if unknown
13-
/// @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
15-
/// @param trueCount number of `true` values for bool columns, or `null` if unknown
16-
/// @param nullCount number of null values, or `null` if unknown
17-
/// @param isSorted `true` if the array is sorted in ascending order, or `null` if unknown
18-
/// @param isStrictSorted `true` if the array is strictly sorted (no duplicates), or `null` if unknown
19-
/// @param nanCount number of `NaN` values in a float column (`min` and `max` skip them), or `null` if unknown
13+
/// @param min minimum value in the array, or `null` if unknown
14+
/// @param max maximum value in the array, or `null` if unknown
15+
/// @param sum sum of values (`Long` for integer and bool columns, `Double` for floats), or `null` if unknown
16+
/// @param isSorted `true` if the non-null values are sorted in ascending order, or `null` if unknown
17+
/// @param isStrictSorted `true` if the non-null values are strictly sorted (no duplicates), or `null` if unknown
18+
/// @param isConstant `true` if every value is the same (all null, or one non-null value), or `null` if unknown
19+
/// @param nullCount number of null values, or `null` if unknown
20+
/// @param uncompressedSizeInBytes the array's uncompressed size in bytes, or `null` if unknown
21+
/// @param nanCount number of `NaN` values in a float column (`min` and `max` skip them), or `null` if unknown
2022
public record ArrayStats(
2123
Object min,
2224
Object max,
2325
Object sum,
24-
Long trueCount,
25-
Long nullCount,
2626
Boolean isSorted,
2727
Boolean isStrictSorted,
28+
Boolean isConstant,
29+
Long nullCount,
30+
Long uncompressedSizeInBytes,
2831
Long nanCount
2932
) {
30-
private static final ArrayStats EMPTY = new ArrayStats(null, null, null, null, null, null, null, null);
31-
32-
/// Creates stats with no `NaN` count.
33-
///
34-
/// @param min minimum value in the array, or `null` if unknown
35-
/// @param max maximum value in the array, or `null` if unknown
36-
/// @param sum sum of values, or `null` if unknown
37-
/// @param trueCount number of `true` values for bool columns, or `null` if unknown
38-
/// @param nullCount number of null values, or `null` if unknown
39-
/// @param isSorted `true` if the array is sorted in ascending order, or `null` if unknown
40-
/// @param isStrictSorted `true` if the array is strictly sorted, or `null` if unknown
41-
public ArrayStats(Object min, Object max, Object sum, Long trueCount, Long nullCount,
42-
Boolean isSorted, Boolean isStrictSorted) {
43-
this(min, max, sum, trueCount, nullCount, isSorted, isStrictSorted, null);
44-
}
33+
private static final ArrayStats EMPTY = new ArrayStats(null, null, null, null, null, null, null, null, null);
4534

4635
/// Returns an empty stats instance with all fields set to `null`.
4736
///
@@ -51,22 +40,25 @@ public static ArrayStats empty() {
5140
}
5241

5342
/// Parses stats from a FlatBuffers [io.github.dfa1.vortex.core.fbs.FbsArrayStats] table.
54-
/// Returns an empty instance when `fbs` is `null` or carries no min/max and no null count.
43+
/// Returns an empty instance when `fbs` is `null` or carries no statistic.
5544
///
5645
/// @param fbs the FlatBuffers stats table, or `null`
5746
/// @return parsed stats, or an empty instance if no usable data is present
5847
public static ArrayStats fromFbs(io.github.dfa1.vortex.core.fbs.FbsArrayStats fbs) {
5948
if (fbs == null) {
6049
return EMPTY;
6150
}
62-
Object min = decodeScalar(fbs.minAsSegment());
63-
Object max = decodeScalar(fbs.maxAsSegment());
64-
Object sum = decodeScalar(fbs.sumAsSegment());
65-
Long nullCount = fbs.hasNullCount() ? fbs.nullCount() : null;
66-
if (min == null && max == null && sum == null && nullCount == null) {
67-
return EMPTY;
68-
}
69-
return new ArrayStats(min, max, sum, null, nullCount, null, null);
51+
ArrayStats stats = new ArrayStats(
52+
decodeScalar(fbs.minAsSegment()),
53+
decodeScalar(fbs.maxAsSegment()),
54+
decodeScalar(fbs.sumAsSegment()),
55+
fbs.hasIsSorted() ? fbs.isSorted() : null,
56+
fbs.hasIsStrictSorted() ? fbs.isStrictSorted() : null,
57+
fbs.hasIsConstant() ? fbs.isConstant() : null,
58+
fbs.hasNullCount() ? fbs.nullCount() : null,
59+
fbs.hasUncompressedSizeInBytes() ? fbs.uncompressedSizeInBytes() : null,
60+
fbs.hasNanCount() ? fbs.nanCount() : null);
61+
return stats.equals(EMPTY) ? EMPTY : stats;
7062
}
7163

7264
private static Object decodeScalar(MemorySegment seg) {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,9 +590,9 @@ private List<ArrayStats> decodeZoneTable(ColumnName column, boolean includeBound
590590
minA != null ? boxedScalar(minA, i) : boxedScalar(boundedMinA, i),
591591
maxA != null ? boxedScalar(maxA, i) : boundedMaxScalar(boundedMaxA, i),
592592
boxedScalar(sumA, i),
593-
null,
593+
null, null, null,
594594
nullCount == null ? null : ((Number) nullCount).longValue(),
595-
null, null,
595+
null,
596596
nanCount == null ? null : ((Number) nanCount).longValue()));
597597
}
598598
return out;

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,9 @@ public Map<ColumnName, ArrayStats> columnStats() {
243243
// A string zone map holds only bounds (Rust's bounded_max/bounded_min), which
244244
// are not exact: take min/max from the chunks' own array stats instead.
245245
ArrayStats exact = aggregateStats(iter.chunkStats(name), column);
246-
stats = new ArrayStats(exact.min(), exact.max(), stats.sum(), stats.trueCount(),
247-
stats.nullCount(), stats.isSorted(), stats.isStrictSorted());
246+
stats = new ArrayStats(exact.min(), exact.max(), stats.sum(), stats.isSorted(),
247+
stats.isStrictSorted(), stats.isConstant(), stats.nullCount(),
248+
stats.uncompressedSizeInBytes(), stats.nanCount());
248249
}
249250
result.put(name, stats);
250251
}
@@ -278,7 +279,7 @@ private ArrayStats aggregateStats(List<ArrayStats> zones, DType column) {
278279
}
279280
// Sum is left null at the file level: it lives in the per-zone stats table, surfaced by
280281
// ScanIterator.columnZoneStats rather than folded here.
281-
return new ArrayStats(globalMin, globalMax, null, null, nullCount, null, null);
282+
return new ArrayStats(globalMin, globalMax, null, null, null, null, nullCount, null, null);
282283
}
283284

284285
@Override

‎reader/src/test/java/io/github/dfa1/vortex/reader/ArrayStatsTest.java‎

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@ void hasAllNullComponents() {
2929
// Then
3030
assertThat(sut.min()).isNull();
3131
assertThat(sut.max()).isNull();
32-
assertThat(sut.trueCount()).isNull();
33-
assertThat(sut.nullCount()).isNull();
32+
assertThat(sut.sum()).isNull();
3433
assertThat(sut.isSorted()).isNull();
3534
assertThat(sut.isStrictSorted()).isNull();
35+
assertThat(sut.isConstant()).isNull();
36+
assertThat(sut.nullCount()).isNull();
37+
assertThat(sut.uncompressedSizeInBytes()).isNull();
38+
assertThat(sut.nanCount()).isNull();
3639
}
3740
}
3841

@@ -192,6 +195,34 @@ void emptyByteVector_treatedAsAbsent() {
192195
assertThat(sut).isSameAs(ArrayStats.empty());
193196
}
194197

198+
@Test
199+
void everyWireField_decodes() {
200+
// Given — every field of the wire table, with false/0 values on purpose: they equal the
201+
// FlatBuffer scalar defaults, so a reader that skips a field or tests the value instead
202+
// of its presence reads them as unknown. A dropped nan_count = 0 made every float chunk
203+
// look NaN-capped, so its max never pruned.
204+
FbsBuilder b = new FbsBuilder(128);
205+
int sumOff = io.github.dfa1.vortex.core.fbs.FbsArrayStats.createSumVector(
206+
b, ProtoScalarValue.ofInt64Value(12L).encode());
207+
b.forceDefaults(true);
208+
io.github.dfa1.vortex.core.fbs.FbsArrayStats.startFbsArrayStats(b);
209+
io.github.dfa1.vortex.core.fbs.FbsArrayStats.addSum(b, sumOff);
210+
io.github.dfa1.vortex.core.fbs.FbsArrayStats.addIsSorted(b, true);
211+
io.github.dfa1.vortex.core.fbs.FbsArrayStats.addIsStrictSorted(b, false);
212+
io.github.dfa1.vortex.core.fbs.FbsArrayStats.addIsConstant(b, false);
213+
io.github.dfa1.vortex.core.fbs.FbsArrayStats.addNullCount(b, 0L);
214+
io.github.dfa1.vortex.core.fbs.FbsArrayStats.addUncompressedSizeInBytes(b, 800L);
215+
io.github.dfa1.vortex.core.fbs.FbsArrayStats.addNanCount(b, 0L);
216+
b.finish(io.github.dfa1.vortex.core.fbs.FbsArrayStats.endFbsArrayStats(b));
217+
var fbs = io.github.dfa1.vortex.core.fbs.FbsArrayStats.getRootAsFbsArrayStats(b.dataSegment());
218+
219+
// When
220+
ArrayStats result = ArrayStats.fromFbs(fbs);
221+
222+
// Then
223+
assertThat(result).isEqualTo(new ArrayStats(null, null, 12L, true, false, false, 0L, 800L, 0L));
224+
}
225+
195226
@Test
196227
void malformedScalarBytes_throwsVortexException() {
197228
// Given — varint tag with no continuation byte: ProtoReader hits EOF inside readVarint

0 commit comments

Comments
 (0)