Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- **Breaking:** `ArrayStats` has one component per field of Rust's array stats, so `minExact`, `maxExact`, `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))

### Fixed
- Files written by vortex-java mark their array min/max as exact, which Rust read as inexact bounds. ([#581](https://github.com/dfa1/vortex-java/pull/581))
- `VortexReader.columnStats()` and SQL `MIN`/`MAX` no longer report a truncated (inexact) bound as a column's minimum or maximum. ([#581](https://github.com/dfa1/vortex-java/pull/581))

## [0.18.0] — 2026-10-19

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -346,12 +346,12 @@ public Optional<FilteredFold> filteredFold(RowFilter filter, ColumnName aggColum
fold.keptRows += zone.rowCount();
if (aggColumn != null) {
ArrayStats stats = zoneStats.get(aggColumn).get(z);
// a zone with values but no min/max stat (a string zone holds only bounds)
// leaves the extreme unknown, rather than silently ignored
// a zone with values but no exact min/max stat (a string zone holds only
// bounds) leaves the extreme unknown, rather than silently ignored
boolean hasValues = stats.nullCount() == null || stats.nullCount() < zone.rowCount();
fold.addSum(stats.sum());
fold.addMin(stats.min(), hasValues);
fold.addMax(stats.max(), hasValues);
fold.addMin(stats.minExact() ? stats.min() : null, hasValues);
fold.addMax(stats.maxExact() ? stats.max() : null, hasValues);
fold.addNulls(stats.nullCount());
}
}
Expand Down Expand Up @@ -1169,7 +1169,8 @@ private static Match classifyColumnRaw(Predicate predicate, ArrayStats s, long r
if (compareStat(value, s.min()) < 0 || compareStat(value, s.max()) > 0) {
yield Match.OUT; // the value lies outside the zone's range
}
if (compareStat(s.min(), s.max()) == 0 && compareStat(s.min(), value) == 0 && noNulls(s)) {
if (exactBounds(s) && compareStat(s.min(), s.max()) == 0 && compareStat(s.min(), value) == 0
&& noNulls(s)) {
yield Match.IN; // every row equals the value
}
yield Match.BOUNDARY;
Expand All @@ -1178,7 +1179,7 @@ private static Match classifyColumnRaw(Predicate predicate, ArrayStats s, long r
if (uncomparable(s, value)) {
yield Match.BOUNDARY;
}
if (compareStat(s.min(), s.max()) == 0 && compareStat(s.min(), value) == 0) {
if (exactBounds(s) && compareStat(s.min(), s.max()) == 0 && compareStat(s.min(), value) == 0) {
yield Match.OUT; // every row equals the value, so none differ
}
if ((compareStat(value, s.min()) < 0 || compareStat(value, s.max()) > 0) && noNulls(s)) {
Expand Down Expand Up @@ -1280,6 +1281,12 @@ private static boolean uncomparable(ArrayStats s, Object value) {
|| value instanceof Number != s.min() instanceof Number;
}

/// Whether the zone's min and max are its exact extremes: an inexact bound only brackets the
/// values, which is enough to rule a range in or out but not to prove `min == max`.
private static boolean exactBounds(ArrayStats s) {
return s.minExact() && s.maxExact();
}

/// Whether the zone provably carries no nulls (its null count is recorded and zero).
private static boolean noNulls(ArrayStats s) {
return s.nullCount() != null && s.nullCount() == 0;
Expand Down
2 changes: 1 addition & 1 deletion docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ all `Array` buffers obtained during scans become invalid.
| `version()` | `int` | File format version |
| `fileSize()` | `long` | File size in bytes |
| `scan(ScanOptions)` | `ScanIterator` | Open a scan |
| `columnStats()` | `Map<ColumnName, ArrayStats>` | Aggregated min/max per column |
| `columnStats()` | `Map<ColumnName, ArrayStats>` | Exact min/max per column (`null` when only a bound is known) |
| `slice(offset, length)` | `MemorySegment` | Zero-copy slice of mmap region |
| `close()` | — | Releases mmap |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,8 @@ private static ArrayStats fold(List<Zone> zones) {
if (min == null && max == null) {
return ArrayStats.empty();
}
return new ArrayStats(min, max, null, null, null, null, null);
// display-only fold: precision is not tracked, so the bounds are not claimed exact
return new ArrayStats(min, false, max, false, null, null, null, null, null, null, null);
}

/// The chunks of a column: the children of the first chunked node on its data path, or the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ static ArrayStats aggregateStats(InspectorTree.Node node) {
if (min == null && max == null) {
return ArrayStats.empty();
}
return new ArrayStats(min, max, null, null, null, null, null);
// display-only fold: precision is not tracked, so the bounds are not claimed exact
return new ArrayStats(min, false, max, false, null, null, null, null, null, null, null);
}

@SuppressWarnings({"unchecked", "rawtypes"})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ void render_escapesUntrustedColumnNames() {
@Test
void render_escapesUntrustedStatValues() {
// Given — min/max are decoded scalars from the file, so a string column can carry markup
InspectorTree sut = singleColumnWithStats(new ArrayStats("<b>", "\"&\"", null, null, 3L, null, null));
InspectorTree sut = singleColumnWithStats(new ArrayStats("<b>", true, "\"&\"", true, null, null, null, null, 3L, null, null));

// When
String result = HtmlReport.render(sut, "evil.vortex");
Expand Down Expand Up @@ -540,7 +540,7 @@ void render_timestampColumn_showsBoundsAsInstantsNotEpochIntegers() {
Layout leaf = new Layout(LayoutId.parse("vortex.flat"), 2, null, List.of(), List.of(0));
Layout root = new Layout(LayoutId.parse("vortex.struct"), 2, null, List.of(leaf), List.of());
InspectorTree.Node leafNode = new InspectorTree.Node(leaf, Optional.of("ts"), Set.of(),
new ArrayStats(1_704_067_200_000L, 1_706_867_193_000L, null, null, null, null, null),
new ArrayStats(1_704_067_200_000L, true, 1_706_867_193_000L, true, null, null, null, null, null, null, null),
List.of());
InspectorTree.Node rootNode = new InspectorTree.Node(root, Optional.empty(), Set.of(),
ArrayStats.empty(), List.of(leafNode));
Expand Down Expand Up @@ -590,7 +590,7 @@ private static InspectorTree twoColumnTree() {
Layout root = new Layout(LayoutId.parse("vortex.struct"), 1000, null, List.of(idLeaf, valLeaf), List.of());

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

InspectorTree.Node n0 = new InspectorTree.Node(c0, Optional.empty(), Set.of(),
new ArrayStats(0L, 499L, null, null, null, null, null), List.of());
new ArrayStats(0L, true, 499L, true, null, null, null, null, null, null, null), List.of());
InspectorTree.Node n1 = new InspectorTree.Node(c1, Optional.empty(), Set.of(),
new ArrayStats(500L, 999L, null, null, null, null, null), List.of());
new ArrayStats(500L, true, 999L, true, null, null, null, null, null, null, null), List.of());
InspectorTree.Node col = new InspectorTree.Node(chunked, Optional.of("id"), Set.of(),
ArrayStats.empty(), List.of(n0, n1));
InspectorTree.Node rootNode = new InspectorTree.Node(root, Optional.empty(), Set.of(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,9 @@ void render_aggregatesMinMaxAcrossChunks() {
Layout structLayout = new Layout(LayoutId.parse("vortex.struct"), 1000, null, List.of(chunked), List.of());

InspectorTree.Node c1 = new InspectorTree.Node(chunk1, Optional.empty(), Set.of(),
new ArrayStats(10L, 50L, null, null, null, null, null), List.of());
new ArrayStats(10L, true, 50L, true, null, null, null, null, null, null, null), List.of());
InspectorTree.Node c2 = new InspectorTree.Node(chunk2, Optional.empty(), Set.of(),
new ArrayStats(5L, 100L, null, null, null, null, null), List.of());
new ArrayStats(5L, true, 100L, true, null, null, null, null, null, null, null), List.of());
InspectorTree.Node chunkedN = new InspectorTree.Node(chunked, Optional.of("id"),
Set.of("vortex.flat"), ArrayStats.empty(), List.of(c1, c2));
InspectorTree.Node rootN = new InspectorTree.Node(structLayout, Optional.empty(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -979,9 +979,16 @@ void javaWriter_jniReader_filteredUtf8_prunesOnTruncatedBounds(@TempDir Path tmp
// When
List<Object> result = readColumnFiltered(file, "k", Expression.binary(Expression.BinaryOp.EQ,
Expression.column("k"), Expression.literal(target)));
io.github.dfa1.vortex.reader.ArrayStats stats;
try (var reader = io.github.dfa1.vortex.reader.VortexReader.open(file)) {
stats = reader.columnStats().get(ColumnName.of("k"));
}

// Then
// Then — vortex-jni accepts the flat stats' precision fields, and the column's min/max are
// the full keys: only the zone map truncates, so SQL MIN/MAX may answer from these stats.
assertThat(result).extracting(Object::toString).containsExactly(target);
assertThat(stats.min()).isEqualTo(prefix + String.format("%08d", 0));
assertThat(stats.max()).isEqualTo(prefix + String.format("%08d", 3 * batch - 1));
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import dev.vortex.jni.NativeLoader;
import io.github.dfa1.vortex.core.model.ColumnName;
import io.github.dfa1.vortex.core.model.DType;
import io.github.dfa1.vortex.reader.ArrayStats;
import io.github.dfa1.vortex.reader.ReadRegistry;
import io.github.dfa1.vortex.writer.WriteOptions;
import io.github.dfa1.vortex.inspect.HtmlReport;
Expand All @@ -31,7 +32,9 @@
import java.nio.file.StandardOpenOption;
import java.util.Map;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Random;

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

@Test
void peek_readsSortednessRustRecordsInFlatArrayStats(@TempDir Path tmp) throws IOException {
// Given — Rust records is_sorted/is_strict_sorted in a flat segment's array stats for an
// ascending integer column. Java's ArrayStats used to drop both (and carried a trueCount
// the wire has no field for), so these read as unknown.
Path file = tmp.resolve("sorted.vtx");
writeJni(file, 50_000);

// When
List<ArrayStats> result = new ArrayList<>();
try (VortexReader vf = VortexReader.open(file, ReadRegistry.loadAll())) {
InspectorTree tree = InspectorTree.buildShallow(vf);
InspectorTree.Node id = tree.root().children().stream()
.filter(c -> c.fieldName().equals(Optional.of("id"))).findFirst().orElseThrow();
for (InspectorTree.Node flat : dataFlats(id)) {
result.add(InspectorTree.peek(flat, vf).stats());
}
}

// Then
assertThat(result).isNotEmpty().allSatisfy(stats -> {
assertThat(stats.isSorted()).isTrue();
assertThat(stats.isStrictSorted()).isTrue();
});
}

/// The flat segments holding a column's values: every chunk of a chunked node, else the first
/// child (a zoned node's second child is its zone-map table, not data).
private static List<InspectorTree.Node> dataFlats(InspectorTree.Node node) {
if (node.layout().isFlat()) {
return List.of(node);
}
if (node.layout().isChunked()) {
return node.children().stream().flatMap(c -> dataFlats(c).stream()).toList();
}
return dataFlats(node.children().getFirst());
}

private static String[] repeat(int rows, String a, String b) {
String[] out = new String[rows];
for (int i = 0; i < rows; i++) {
Expand Down
Loading
Loading