Skip to content

Commit af7a245

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 1f7df95 commit af7a245

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
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111

12+
- 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))
1213
- 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))
1314
- 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))
1415
- 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
@@ -36,12 +36,14 @@
3636
import java.util.ArrayList;
3737
import java.util.Collections;
3838
import java.util.HashMap;
39+
import java.util.IdentityHashMap;
3940
import java.util.Iterator;
4041
import java.util.LinkedHashMap;
4142
import java.util.List;
4243
import java.util.Map;
4344
import java.util.NoSuchElementException;
4445
import java.util.SequencedMap;
46+
import java.util.TreeSet;
4547
import java.util.function.Consumer;
4648

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

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

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

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

184211
// ── Layout tree traversal ─────────────────────────────────────────────────
@@ -530,7 +557,7 @@ public void close() {
530557
if (sharedArena != null) {
531558
sharedArena.close();
532559
sharedArena = null;
533-
sharedFullArrays = null;
560+
sharedFlats = null;
534561
}
535562
}
536563

@@ -545,7 +572,6 @@ private void initialize() {
545572
DType rootDtype = file.dtype();
546573

547574
var columnFlats = new LinkedHashMap<ColumnName, List<Layout>>();
548-
var columnTopLayouts = new LinkedHashMap<ColumnName, Layout>();
549575
Map<ColumnName, DType> columnDtypes = new LinkedHashMap<>();
550576

551577
if (rootLayout.isStruct() && rootDtype instanceof DType.Struct structDtype) {
@@ -560,51 +586,19 @@ private void initialize() {
560586
var flats = new ArrayList<Layout>();
561587
collectFlats(colTop, flats);
562588
columnFlats.put(colName, flats);
563-
columnTopLayouts.put(colName, colTop);
564589
columnDtypes.put(colName, colDtype);
565590
}
566591
} else {
567592
var flats = new ArrayList<Layout>();
568593
collectFlats(rootLayout, flats);
569594
ColumnName colName = ColumnName.of("_col");
570595
columnFlats.put(colName, flats);
571-
columnTopLayouts.put(colName, rootLayout);
572596
columnDtypes.put(colName, rootDtype);
573597
}
574598

575599
projectedNames = List.copyOf(columnDtypes.keySet());
576600
projectedDtypes = List.copyOf(columnDtypes.values());
577-
this.columnTopLayouts = Map.copyOf(columnTopLayouts);
578601
chunks = buildChunks(columnFlats);
579-
decodeSharedColumns(columnFlats, columnTopLayouts, columnDtypes);
580-
}
581-
582-
private void decodeSharedColumns(
583-
Map<ColumnName, List<Layout>> columnFlats,
584-
Map<ColumnName, Layout> columnTopLayouts,
585-
Map<ColumnName, DType> columnDtypes) {
586-
int maxFlats = 0;
587-
for (List<Layout> flats : columnFlats.values()) {
588-
if (flats.size() > maxFlats) {
589-
maxFlats = flats.size();
590-
}
591-
}
592-
if (maxFlats <= 1) {
593-
return;
594-
}
595-
for (var entry : columnFlats.entrySet()) {
596-
if (entry.getValue().size() != 1) {
597-
continue;
598-
}
599-
if (sharedArena == null) {
600-
sharedArena = Arena.ofConfined();
601-
sharedFullArrays = new HashMap<>();
602-
}
603-
ColumnName name = entry.getKey();
604-
Layout topLayout = columnTopLayouts.get(name);
605-
DType dtype = columnDtypes.get(name);
606-
sharedFullArrays.put(name, decodeLayout(topLayout, dtype, sharedArena));
607-
}
608602
}
609603

610604
// A LinkedHashMap preserves schema/projection order (the public columns() contract is a
@@ -630,8 +624,8 @@ private SequencedMap<ColumnName, Chunk.Column> buildColumnMap(ChunkSpec chunk, A
630624
}
631625

632626
/// Builds the column map for [#decodeChunkAt(int)]. Identical decode to [#buildColumnMap]
633-
/// except that a shared (single-flat) column is decoded into `arena` and sliced there,
634-
/// so the resulting [Chunk] owns every buffer and survives this iterator's close.
627+
/// except that a covering chunk spanning several windows is decoded into `arena` and sliced
628+
/// there, so the resulting [Chunk] owns every buffer and survives this iterator's close.
635629
private SequencedMap<ColumnName, Chunk.Column> buildSelfContainedColumnMap(ChunkSpec chunk, Arena arena) {
636630
Layout[] layouts = chunk.columnLayouts();
637631
long[] sliceOffsets = chunk.sliceOffsets();
@@ -658,30 +652,44 @@ private SequencedMap<ColumnName, Chunk.Column> singleColumn(Array array) {
658652
return unmodifiable(map);
659653
}
660654

661-
private Array decodeOrSliceSelfContained(int colIdx, Layout layout, long sliceStart,
655+
private Array decodeOrSliceSelfContained(int colIdx, Layout coveringFlat, long sliceOffset,
662656
long rowCount, Arena arena) {
663-
if (layout != null) {
664-
return decodeLayout(layout, projectedDtypes.get(colIdx), arena);
665-
}
666-
// Shared single-flat column: decode its full top layout into THIS chunk's arena and
667-
// slice, so the returned Chunk does not reference the iterator's shared arena.
668-
ColumnName name = projectedNames.get(colIdx);
669657
DType dtype = projectedDtypes.get(colIdx);
670-
Array full = decodeLayout(columnTopLayouts.get(name), dtype, arena);
671-
return sliceArray(full, sliceStart, rowCount, dtype);
658+
Array full = decodeLayout(coveringFlat, dtype, arena);
659+
if (isWholeFlat(coveringFlat, sliceOffset, rowCount)) {
660+
return full;
661+
}
662+
// Covering chunk spans several windows: slice it into THIS chunk's arena so the returned
663+
// Chunk owns every buffer and survives this iterator's close.
664+
return sliceArray(full, sliceOffset, rowCount, dtype);
672665
}
673666

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

687695
private static Array sliceArray(Array full, long offset, long length, DType dtype) {
@@ -863,7 +871,7 @@ public SegmentSpec segmentSpec(int index) {
863871
// ── Internal record ───────────────────────────────────────────────────────
864872

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

0 commit comments

Comments
 (0)