Skip to content

Commit 023041f

Browse files
dfa1claude
andcommitted
fix: scan misaligned per-column chunk grids (#221)
ScanIterator.buildChunks rejected any per-column chunk mismatch beyond the narrow 1-vs-N case ("mixed per-column chunking beyond 1-vs-N is not supported"), so two Raincloud files the Rust oracle reads fine failed to scan. Replace the 1-vs-N special case with the Rust reference's policy: the scan planner now splits at the merged boundary grid — the sorted, deduplicated union of every column's chunk boundaries. This mirrors vortex-layout's StructReader::register_splits, which unions each field child's chunk boundaries into one RowSplits set (vortex-layout/src/scan/split_by.rs), the scan then walking each adjacent-boundary window and slicing every column's covering chunk to it. The merged grid refines every column's grid, so each window lies entirely within exactly one chunk of each column. Tier decision: the merged-grid generalization is tractable and subsumes both tiers from the issue, so it is implemented in full rather than only the nested tier: - nested boundaries (emotions-dataset-for-nlp: label [131072 x3, 23593] nests inside text's 16384 grid), and - disjoint boundaries (uci-beijing-multi-site-air-quality: numeric [131072 x3, 27552] vs station's 40960/49152 grid, which do not nest) both fall out of the union with no tier-specific code. Fast paths are preserved: when a window equals a whole covering chunk (offset 0, window rows == flat rows) the chunk is decoded straight into the chunk's arena with no slice wrapper, so aligned N-vs-N and per-chunk decode stay zero-copy. A coarse chunk that spans several windows is decoded once into the iterator's shared arena (cached by identity) and sliced zero-copy per window, never re-decoded. Corpus evidence (CLI export vs the Parquet oracle): uci-beijing-multi-site-air-quality: OK (420768 rows x 18 cols) emotions-dataset-for-nlp: OK (416809 rows x 2 cols) Both matrix entries flipped gap:221 -> ok; conformance suite green. Closes #221 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9e20edc commit 023041f

5 files changed

Lines changed: 299 additions & 104 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Fixed
1111

1212
- Per-zone stats from current Rust writers (`vortex.zoned`, vortex-jni 0.76.0) decode again. The 0.76 zoned layout replaced the legacy `vortex.stats` bit-set metadata with an aggregate-function spec list and dropped the per-stat truncation flags; the reader now reconstructs the stats table from that spec list (min/max/sum/null_count), so `columnZoneStats` and aggregate push-down work against those files instead of throwing `ClassCastException`. ([#197](https://github.com/dfa1/vortex-java/pull/197))
13+
- Scans of files whose columns use different chunk grids no longer fail with `mixed per-column chunking beyond 1-vs-N is not supported`. The scan planner now splits at the merged boundary grid — the sorted union of every column's chunk boundaries, matching the Rust reference — and decodes each column's covering chunk once, slicing it zero-copy to each window. This handles both nested grids (Raincloud `emotions-dataset-for-nlp`, where `label`'s coarse chunks nest inside `text`'s finer grid) and disjoint grids (`uci-beijing-multi-site-air-quality`, where numeric and `station` boundaries do not nest). Aligned N-vs-N and 1-vs-N scans keep their existing slice-free fast path. ([#221](https://github.com/dfa1/vortex-java/issues/221))
1314
- CSV export renders nested struct columns as JSON object cells (`{"field":value,...}`, strings JSON-escaped, null fields as JSON `null`, nested structs recursed) instead of throwing `unsupported array type: StructArray`. ([#217](https://github.com/dfa1/vortex-java/issues/217))
1415
- Scanning a struct whose columns include a nested struct no longer fails chunk planning. A nested `vortex.struct` layout column (e.g. Raincloud `countries-of-the-world`'s `data` column) is now treated as a single full-range chunk source and decoded through the layout registry's new struct decoder into a `StructArray`, matching the Rust reference (a nested struct spans its parent's row range, not an independent chunking). `schema`/`count`/`inspect` already worked; plain scans and `select` of sibling columns now work too. CSV export of the struct column itself remains unsupported (a separate rendering limitation). ([#207](https://github.com/dfa1/vortex-java/issues/207))
1516
- CSV export renders unsigned integer columns (U8–U64) with their unsigned values — high-half values previously printed as two's-complement negatives (uci-wine `magnesium` U8 132 exported as -124), silent corruption found by the Raincloud conformance suite. ([#208](https://github.com/dfa1/vortex-java/issues/208))

docs/compatibility.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,11 @@ Per-slug status lives in `integration/src/test/resources/raincloud/expected-stat
6262
(`ok` must pass; `gap:<issue>` must still fail, so a fix flips the entry in the same change;
6363
`untriaged` runs and reports without failing the build). A scheduled workflow
6464
(`raincloud-conformance.yml`) hydrates a size-capped subset weekly. Current triage —
65-
28 `ok`, 2 known gaps (both misaligned per-column chunk grids,
66-
[#221](https://github.com/dfa1/vortex-java/issues/221)); 217 slugs untriaged. Fixed so far
67-
by this suite: lazy dict U8/U16 values ([#206](https://github.com/dfa1/vortex-java/issues/206)),
65+
30 `ok`, 0 known gaps; 217 slugs untriaged. Fixed so far
66+
by this suite: misaligned per-column chunk grids
67+
([#221](https://github.com/dfa1/vortex-java/issues/221) — scan now splits at the merged boundary
68+
grid, slicing each column's covering chunk per window), lazy dict U8/U16 values
69+
([#206](https://github.com/dfa1/vortex-java/issues/206)),
6870
nested struct columns in scan ([#207](https://github.com/dfa1/vortex-java/issues/207)),
6971
unsigned integers rendered signed ([#208](https://github.com/dfa1/vortex-java/issues/208) /
7072
[#216](https://github.com/dfa1/vortex-java/issues/216) — silent corruption, incl. wrong filter

integration/src/test/resources/raincloud/expected-status.csv

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ diabetes-health-indicators-dataset,untriaged
9191
disease-symptom-description-dataset,untriaged
9292
docmatix-zero-shot,untriaged
9393
electric-motor-temperature,untriaged
94-
emotions-dataset-for-nlp,gap:221
94+
emotions-dataset-for-nlp,ok
9595
fhv_tripdata_2025,untriaged
9696
fhvhv_tripdata_2025,untriaged
9797
finemath-4plus,untriaged
@@ -193,7 +193,7 @@ uci-air-quality,ok
193193
uci-auto-mpg,ok
194194
uci-automobile,untriaged
195195
uci-bank-marketing,ok
196-
uci-beijing-multi-site-air-quality,gap:221
196+
uci-beijing-multi-site-air-quality,ok
197197
uci-bike-sharing-dataset,ok
198198
uci-breast-cancer,untriaged
199199
uci-breast-cancer-wisconsin-diagnostic,untriaged

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

Lines changed: 107 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -37,12 +37,14 @@
3737
import java.util.ArrayList;
3838
import java.util.Collections;
3939
import java.util.HashMap;
40+
import java.util.IdentityHashMap;
4041
import java.util.Iterator;
4142
import java.util.LinkedHashMap;
4243
import java.util.List;
4344
import java.util.Map;
4445
import java.util.NoSuchElementException;
4546
import java.util.SequencedMap;
47+
import java.util.TreeSet;
4648
import java.util.function.Consumer;
4749

4850
/// Iterates over decoded chunks from a [io.github.dfa1.vortex.reader.VortexReader].
@@ -79,15 +81,14 @@ public final class ScanIterator implements Iterator<Chunk>, AutoCloseable {
7981
private List<ChunkSpec> chunks;
8082
private List<ColumnName> projectedNames;
8183
private List<DType> projectedDtypes;
82-
private Map<ColumnName, Layout> columnTopLayouts;
8384
private Map<ColumnName, DType> columnDtypes;
8485
private int chunkIndex;
8586
private int peekedChunkIdx = -1;
8687
private long rowsReturned;
8788
private Chunk openChunk;
8889
private boolean closed;
8990
private Arena sharedArena;
90-
private Map<ColumnName, Array> sharedFullArrays;
91+
private Map<Layout, Array> sharedFlats;
9192

9293
public ScanIterator(VortexHandle file, ScanOptions options) {
9394
this.file = file;
@@ -123,63 +124,89 @@ private static void collectFlats(Layout layout, List<Layout> out) {
123124
}
124125
}
125126

126-
private static List<ChunkSpec> buildChunks(Map<ColumnName, List<Layout>> columnFlats) {
127+
/// Plans the scan by merging every column's chunk grid into one split grid, mirroring the Rust
128+
/// reference: `StructReader::register_splits` unions each field's chunk boundaries into a single
129+
/// sorted, deduplicated set (`vortex-layout/src/scan/split_by.rs`), and the scan then walks each
130+
/// adjacent-boundary window slicing every column's covering chunk to it. The merged grid refines
131+
/// every column's grid, so each window lies entirely within exactly one chunk of each column.
132+
///
133+
/// This subsumes the previous special cases without regressing them:
134+
/// - aligned N-vs-N (all columns share a grid) — every window equals a whole chunk, so decode is
135+
/// direct with no slicing;
136+
/// - 1-vs-N (one full-column flat over a chunked column) — the single flat covers every window
137+
/// and is decoded once, then sliced;
138+
/// - nested boundaries (emotions-dataset-for-nlp: `label` `[131072 ×3, 23593]` vs `text`'s
139+
/// 26-chunk `16384` grid) and disjoint grids (uci-beijing-multi-site-air-quality: numeric
140+
/// `[131072 ×3, 27552]` vs `station`'s `40960/49152/...` grid) both fall out of the union.
141+
static List<ChunkSpec> buildChunks(Map<ColumnName, List<Layout>> columnFlats) {
127142
if (columnFlats.isEmpty()) {
128143
return List.of();
129144
}
130145
ColumnName[] colNames = columnFlats.keySet().toArray(ColumnName[]::new);
131146
int numCols = colNames.length;
132-
int maxChunks = 0;
133-
int refCol = 0;
134-
for (int j = 0; j < numCols; j++) {
135-
int n = columnFlats.get(colNames[j]).size();
136-
if (n > maxChunks) {
137-
maxChunks = n;
138-
refCol = j;
139-
}
140-
}
141-
// Detect single-flat columns sharing the chunked range of a wider column.
142-
// Other mismatched widths (e.g. 5 flats vs 23 flats) are not supported.
143-
boolean[] shared = new boolean[numCols];
147+
148+
// Per-column cumulative chunk starts: colStarts[j][c] is the first row of chunk c and the
149+
// final entry is the column's total row count. Chunk c spans [colStarts[j][c], colStarts[j][c+1]).
150+
long[][] colStarts = new long[numCols][];
144151
for (int j = 0; j < numCols; j++) {
145-
int n = columnFlats.get(colNames[j]).size();
146-
if (n == maxChunks) {
147-
continue;
152+
List<Layout> flats = columnFlats.get(colNames[j]);
153+
long[] starts = new long[flats.size() + 1];
154+
long acc = 0;
155+
for (int c = 0; c < flats.size(); c++) {
156+
starts[c] = acc;
157+
acc += flats.get(c).rowCount();
148158
}
149-
if (n == 1) {
150-
shared[j] = true;
151-
} else {
152-
throw new VortexException(
153-
"scan: column '" + colNames[j] + "' has " + n
154-
+ " flats but the widest column has " + maxChunks
155-
+ "; mixed per-column chunking beyond 1-vs-N is not supported");
159+
starts[flats.size()] = acc;
160+
colStarts[j] = starts;
161+
}
162+
163+
long[] boundaries = mergedBoundaries(colStarts);
164+
int numWindows = boundaries.length - 1;
165+
var result = new ArrayList<ChunkSpec>(numWindows);
166+
int[] cursor = new int[numCols]; // covering chunk index per column; advances monotonically
167+
for (int w = 0; w < numWindows; w++) {
168+
long windowStart = boundaries[w];
169+
long windowRows = boundaries[w + 1] - windowStart;
170+
Layout[] layouts = new Layout[numCols];
171+
long[] sliceOffsets = new long[numCols];
172+
for (int j = 0; j < numCols; j++) {
173+
long[] starts = colStarts[j];
174+
int c = cursor[j];
175+
// Advance to the chunk whose range contains windowStart (starts[c+1] > windowStart).
176+
while (c + 1 < starts.length && starts[c + 1] <= windowStart) {
177+
c++;
178+
}
179+
cursor[j] = c;
180+
List<Layout> flats = columnFlats.get(colNames[j]);
181+
if (c >= flats.size()) {
182+
throw new VortexException("scan: column '" + colNames[j]
183+
+ "' has no chunk covering rows [" + windowStart + ", "
184+
+ (windowStart + windowRows) + ")");
185+
}
186+
layouts[j] = flats.get(c);
187+
sliceOffsets[j] = windowStart - starts[c];
156188
}
157-
}
158-
var result = new ArrayList<ChunkSpec>(maxChunks);
159-
long sliceStart = 0;
160-
for (int i = 0; i < maxChunks; i++) {
161-
long chunkRowCount = columnFlats.get(colNames[refCol]).get(i).rowCount();
162-
result.add(buildChunkSpec(colNames, columnFlats, shared, i, sliceStart, chunkRowCount));
163-
sliceStart += chunkRowCount;
189+
result.add(new ChunkSpec(windowRows, colNames, layouts, sliceOffsets));
164190
}
165191
return List.copyOf(result);
166192
}
167193

168-
private static ChunkSpec buildChunkSpec(ColumnName[] colNames, Map<ColumnName, List<Layout>> columnFlats,
169-
boolean[] shared, int chunkIdx, long sliceStart, long chunkRowCount) {
170-
int numCols = colNames.length;
171-
Layout[] layouts = new Layout[numCols];
172-
long[] sliceOffsets = new long[numCols];
173-
for (int j = 0; j < numCols; j++) {
174-
if (shared[j]) {
175-
layouts[j] = null;
176-
sliceOffsets[j] = sliceStart;
177-
} else {
178-
layouts[j] = columnFlats.get(colNames[j]).get(chunkIdx);
179-
sliceOffsets[j] = 0L;
194+
/// Returns the sorted, deduplicated union of every column's cumulative chunk starts (each ending
195+
/// in the column's total row count). The result always contains `0` and the total; adjacent
196+
/// entries are the scan's split windows.
197+
private static long[] mergedBoundaries(long[][] colStarts) {
198+
var set = new TreeSet<Long>();
199+
for (long[] starts : colStarts) {
200+
for (long s : starts) {
201+
set.add(s);
180202
}
181203
}
182-
return new ChunkSpec(chunkRowCount, colNames, layouts, sliceOffsets);
204+
long[] out = new long[set.size()];
205+
int i = 0;
206+
for (long v : set) {
207+
out[i++] = v;
208+
}
209+
return out;
183210
}
184211

185212
// ── Layout tree traversal ─────────────────────────────────────────────────
@@ -540,7 +567,7 @@ public void close() {
540567
if (sharedArena != null) {
541568
sharedArena.close();
542569
sharedArena = null;
543-
sharedFullArrays = null;
570+
sharedFlats = null;
544571
}
545572
}
546573

@@ -555,7 +582,6 @@ private void initialize() {
555582
DType rootDtype = file.dtype();
556583

557584
var columnFlats = new LinkedHashMap<ColumnName, List<Layout>>();
558-
var columnTopLayouts = new LinkedHashMap<ColumnName, Layout>();
559585
Map<ColumnName, DType> columnDtypes = new LinkedHashMap<>();
560586

561587
if (rootLayout.isStruct() && rootDtype instanceof DType.Struct structDtype) {
@@ -570,51 +596,19 @@ private void initialize() {
570596
var flats = new ArrayList<Layout>();
571597
collectFlats(colTop, flats);
572598
columnFlats.put(colName, flats);
573-
columnTopLayouts.put(colName, colTop);
574599
columnDtypes.put(colName, colDtype);
575600
}
576601
} else {
577602
var flats = new ArrayList<Layout>();
578603
collectFlats(rootLayout, flats);
579604
ColumnName colName = ColumnName.of("_col");
580605
columnFlats.put(colName, flats);
581-
columnTopLayouts.put(colName, rootLayout);
582606
columnDtypes.put(colName, rootDtype);
583607
}
584608

585609
projectedNames = List.copyOf(columnDtypes.keySet());
586610
projectedDtypes = List.copyOf(columnDtypes.values());
587-
this.columnTopLayouts = Map.copyOf(columnTopLayouts);
588611
chunks = buildChunks(columnFlats);
589-
decodeSharedColumns(columnFlats, columnTopLayouts, columnDtypes);
590-
}
591-
592-
private void decodeSharedColumns(
593-
Map<ColumnName, List<Layout>> columnFlats,
594-
Map<ColumnName, Layout> columnTopLayouts,
595-
Map<ColumnName, DType> columnDtypes) {
596-
int maxFlats = 0;
597-
for (List<Layout> flats : columnFlats.values()) {
598-
if (flats.size() > maxFlats) {
599-
maxFlats = flats.size();
600-
}
601-
}
602-
if (maxFlats <= 1) {
603-
return;
604-
}
605-
for (var entry : columnFlats.entrySet()) {
606-
if (entry.getValue().size() != 1) {
607-
continue;
608-
}
609-
if (sharedArena == null) {
610-
sharedArena = Arena.ofConfined();
611-
sharedFullArrays = new HashMap<>();
612-
}
613-
ColumnName name = entry.getKey();
614-
Layout topLayout = columnTopLayouts.get(name);
615-
DType dtype = columnDtypes.get(name);
616-
sharedFullArrays.put(name, decodeLayout(topLayout, dtype, sharedArena));
617-
}
618612
}
619613

620614
// A LinkedHashMap preserves schema/projection order (the public columns() contract is a
@@ -640,8 +634,8 @@ private SequencedMap<ColumnName, Chunk.Column> buildColumnMap(ChunkSpec chunk, A
640634
}
641635

642636
/// Builds the column map for [#decodeChunkAt(int)]. Identical decode to [#buildColumnMap]
643-
/// except that a shared (single-flat) column is decoded into `arena` and sliced there,
644-
/// so the resulting [Chunk] owns every buffer and survives this iterator's close.
637+
/// except that a covering chunk spanning several windows is decoded into `arena` and sliced
638+
/// there, so the resulting [Chunk] owns every buffer and survives this iterator's close.
645639
private SequencedMap<ColumnName, Chunk.Column> buildSelfContainedColumnMap(ChunkSpec chunk, Arena arena) {
646640
Layout[] layouts = chunk.columnLayouts();
647641
long[] sliceOffsets = chunk.sliceOffsets();
@@ -668,30 +662,44 @@ private SequencedMap<ColumnName, Chunk.Column> singleColumn(Array array) {
668662
return unmodifiable(map);
669663
}
670664

671-
private Array decodeOrSliceSelfContained(int colIdx, Layout layout, long sliceStart,
665+
private Array decodeOrSliceSelfContained(int colIdx, Layout coveringFlat, long sliceOffset,
672666
long rowCount, Arena arena) {
673-
if (layout != null) {
674-
return decodeLayout(layout, projectedDtypes.get(colIdx), arena);
675-
}
676-
// Shared single-flat column: decode its full top layout into THIS chunk's arena and
677-
// slice, so the returned Chunk does not reference the iterator's shared arena.
678-
ColumnName name = projectedNames.get(colIdx);
679667
DType dtype = projectedDtypes.get(colIdx);
680-
Array full = decodeLayout(columnTopLayouts.get(name), dtype, arena);
681-
return sliceArray(full, sliceStart, rowCount, dtype);
668+
Array full = decodeLayout(coveringFlat, dtype, arena);
669+
if (isWholeFlat(coveringFlat, sliceOffset, rowCount)) {
670+
return full;
671+
}
672+
// Covering chunk spans several windows: slice it into THIS chunk's arena so the returned
673+
// Chunk owns every buffer and survives this iterator's close.
674+
return sliceArray(full, sliceOffset, rowCount, dtype);
682675
}
683676

684-
private Array decodeOrSlice(int colIdx, Layout layout, long sliceStart, long rowCount,
677+
private Array decodeOrSlice(int colIdx, Layout coveringFlat, long sliceOffset, long rowCount,
685678
Arena arena) {
686-
if (layout != null) {
687-
return decodeLayout(layout, projectedDtypes.get(colIdx), arena);
679+
DType dtype = projectedDtypes.get(colIdx);
680+
if (isWholeFlat(coveringFlat, sliceOffset, rowCount)) {
681+
// Fast path: the window is a whole chunk — decode straight into the chunk's arena with
682+
// no slice wrapper (aligned N-vs-N and per-chunk decode keep their zero-copy behavior).
683+
return decodeLayout(coveringFlat, dtype, arena);
688684
}
689-
Array full = sharedFullArrays.get(projectedNames.get(colIdx));
690-
if (full == null) {
691-
throw new VortexException("scan: missing shared array for column "
692-
+ projectedNames.get(colIdx));
685+
// Covering chunk spans several windows: decode it once into the shared arena and slice
686+
// zero-copy per window, matching how Rust decodes a chunk once and slices each split range.
687+
Array full = sharedCoveringFlat(coveringFlat, dtype);
688+
return sliceArray(full, sliceOffset, rowCount, dtype);
689+
}
690+
691+
private static boolean isWholeFlat(Layout coveringFlat, long sliceOffset, long rowCount) {
692+
return sliceOffset == 0 && rowCount == coveringFlat.rowCount();
693+
}
694+
695+
/// Decodes `flat` once into the iterator's shared arena and caches it by identity, so a coarse
696+
/// chunk that covers several split windows is decoded a single time and then sliced per window.
697+
private Array sharedCoveringFlat(Layout flat, DType dtype) {
698+
if (sharedArena == null) {
699+
sharedArena = Arena.ofConfined();
700+
sharedFlats = new IdentityHashMap<>();
693701
}
694-
return sliceArray(full, sliceStart, rowCount, projectedDtypes.get(colIdx));
702+
return sharedFlats.computeIfAbsent(flat, f -> decodeLayout(f, dtype, sharedArena));
695703
}
696704

697705
private static Array sliceArray(Array full, long offset, long length, DType dtype) {
@@ -873,7 +881,7 @@ public SegmentSpec segmentSpec(int index) {
873881
// ── Internal record ───────────────────────────────────────────────────────
874882

875883
@SuppressWarnings("java:S6218") // internal data carrier; record components are arrays of immutable primitives or refs that flow through pipelines without ever being compared.
876-
private record ChunkSpec(
884+
record ChunkSpec(
877885
long rowCount, ColumnName[] columnNames, Layout[] columnLayouts, long[] sliceOffsets) {
878886
Layout layoutFor(ColumnName col) {
879887
for (int i = 0; i < columnNames.length; i++) {

0 commit comments

Comments
 (0)