Skip to content

Commit cde6e94

Browse files
dfa1claude
andcommitted
fix: Raincloud oracle gains repeated/list column support (#261)
RaincloudConformanceIntegrationTest's Parquet oracle previously aborted on any column with maxRepetitionLevel > 0 (LIST/repeated groups), which is how Vortex FixedSizeList/List columns round-trip through Parquet — blocking conformance verification for glove-* and osm-germany-relations. Rewrite the oracle to iterate top-level schema fields (one CSV cell per Vortex column, matching CsvExporter's model) instead of leaf columns (which for a nested field would wrongly emit multiple cells for what CsvExporter renders as one JSON array/object). Primitive top-level fields keep the existing physical-type rendering; LIST/STRUCT fields render via hardwood's row-object API (RowReader.getList/getStruct, PqList, PqStruct) walked alongside the SchemaNode tree so unsigned INT32/INT64 detection (#253) still applies at any nesting depth, mirroring CsvExporter's jsonObject/jsonArray/jsonValue rules exactly. Verifying against real hydrated data (glove-6b-50d/100d/200d, osm-germany-relations) surfaced two genuine, separate bugs the previously-aborting oracle had never let this test path reach: - CsvExporter.offsetAt only handled I32/I64 list offsets; osm's members/tags columns use I16 offsets, throwing VortexException. offsets can legally be any integer ptype (the encoder picks the narrowest that fits, same as VarBinArray's offsets) (#263). - ScanIterator.sliceArray had no case for ListArray/FixedSizeListArray, so scanning such a column failed whenever its covering flat chunk spans several scan windows (misaligned per-column chunk grids) — a real reading gap, not only an export-side one (#265). Both fixed; all 4 slugs now conform end-to-end and flip to `ok` in the matrix. Full ./mvnw verify (integration + unit, 304 tests) green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 55b20fa commit cde6e94

6 files changed

Lines changed: 182 additions & 58 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
### Fixed
1616

1717
- Nullable Utf8/Binary columns are now compressed through the full cascade instead of stored as raw `vortex.varbin`. `MaskedEncodingEncoder` previously encoded its non-null values with a fixed first-match encoder (primitive/varbin), so a nullable low-cardinality string column never reached Dict or FSST — producing files 10–47× larger than the Rust reference on the string-heavy Raincloud corpus (e.g. `uci-mushroom` 22×, `uci-online-retail-ii` 25×). The masked values now run through the same `CascadingCompressor` as dense columns, dropping those ratios to ~2–3×. ([#258](https://github.com/dfa1/vortex-java/issues/258))
18+
- `ScanIterator` can now slice a shared `ListArray`/`FixedSizeListArray` whose covering flat chunk spans several scan windows (misaligned per-column chunk grids). Previously any scan over such a column threw `VortexException("scan: cannot slice shared array of type ...")` — a real reading gap, not only an export-side one. ([#265](https://github.com/dfa1/vortex-java/issues/265))
19+
- `CsvExporter` now reads `vortex.list` offsets of any integer width (`I8`/`U8`/`I16`/`U16`, matching `VarBinArray`'s offsets), not only `I32`/`I64`. A narrower-offset list column (e.g. Raincloud's `osm-germany-relations`, whose offsets are `I16`) previously threw `VortexException("unexpected list offsets type: ...")` on export. ([#263](https://github.com/dfa1/vortex-java/issues/263))
1820

1921
## [0.12.2] — 2026-07-10
2022

csv/src/main/java/io/github/dfa1/vortex/csv/CsvExporter.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,9 @@ private static String jsonArray(Array elements, long start, long end) {
291291
}
292292

293293
/// Reads offset `idx` from `offsets` as a non-negative long.
294-
/// The encoder always writes I64 offsets; I32 is included for forward compatibility.
294+
/// The encoder picks the narrowest ptype that fits the max offset value (mirroring
295+
/// `VarBinArray`'s offsets), so any integer width can appear on the wire; Byte/ShortArray's
296+
/// `getInt` already zero-extends U8/U16 per their dtype, so widening to long is exact.
295297
///
296298
/// @param offsets the offsets array
297299
/// @param idx the index to read
@@ -300,6 +302,8 @@ private static long offsetAt(Array offsets, long idx) {
300302
return switch (offsets) {
301303
case LongArray la -> la.getLong(idx);
302304
case IntArray ia -> Integer.toUnsignedLong(ia.getInt(idx));
305+
case ShortArray sa -> sa.getInt(idx);
306+
case ByteArray ba -> ba.getInt(idx);
303307
default -> throw new VortexException("unexpected list offsets type: " + offsets.getClass().getSimpleName());
304308
};
305309
}

docs/compatibility.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,18 @@ 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-
40 `ok`, 0 known gaps; 207 slugs untriaged. Every gap found so far is fixed
65+
117 `ok`, 0 known gaps; 130 slugs untriaged. Every gap found so far is fixed
6666
([#206](https://github.com/dfa1/vortex-java/issues/206)[#211](https://github.com/dfa1/vortex-java/issues/211),
6767
[#215](https://github.com/dfa1/vortex-java/issues/215)[#217](https://github.com/dfa1/vortex-java/issues/217),
6868
[#221](https://github.com/dfa1/vortex-java/issues/221),
6969
[#225](https://github.com/dfa1/vortex-java/issues/225) RunEnd run-values validity,
70-
[#226](https://github.com/dfa1/vortex-java/issues/226) Sparse null fill / nullable patches).
70+
[#226](https://github.com/dfa1/vortex-java/issues/226) Sparse null fill / nullable patches,
71+
[#257](https://github.com/dfa1/vortex-java/issues/257) CsvExporter FixedSizeList/List support,
72+
[#259](https://github.com/dfa1/vortex-java/issues/259) conformance-test pipe deadlock,
73+
[#261](https://github.com/dfa1/vortex-java/issues/261) oracle repeated/list column support, plus
74+
two bugs the widened oracle then caught: `CsvExporter` list offsets assumed I32/I64 only (any
75+
integer width is wire-legal, mirroring `VarBinArray`), and `ScanIterator` could not slice a shared
76+
`ListArray`/`FixedSizeListArray` spanning several split windows).
7177

7278
## Encodings
7379

integration/src/test/java/io/github/dfa1/vortex/integration/RaincloudConformanceIntegrationTest.java

Lines changed: 143 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
import de.siegmar.fastcsv.writer.CsvWriter;
44
import dev.hardwood.InputFile;
55
import dev.hardwood.metadata.LogicalType;
6-
import dev.hardwood.metadata.RepetitionType;
76
import dev.hardwood.reader.ParquetFileReader;
87
import dev.hardwood.reader.RowReader;
9-
import dev.hardwood.schema.ColumnSchema;
8+
import dev.hardwood.row.PqList;
9+
import dev.hardwood.row.PqStruct;
10+
import dev.hardwood.schema.SchemaNode;
1011
import io.github.dfa1.vortex.core.error.VortexException;
1112
import io.github.dfa1.vortex.csv.CsvExporter;
1213
import io.github.dfa1.vortex.csv.ExportOptions;
@@ -280,7 +281,7 @@ private static void assertFilesMatch(Path result, Path oracle) throws IOExceptio
280281
}
281282

282283
/// Writes the parquet oracle to a file using the same cell rules as `CsvExporter`.
283-
/// An oracle-side failure (nested columns, unsupported physical type) aborts the
284+
/// An oracle-side failure (unsupported physical type, MAP column) aborts the
284285
/// slug via [TestAbortedException] rather than failing it — it says nothing about
285286
/// vortex-java. An `ok` slug whose parquet cannot be read stops being verified
286287
/// (visibly, as skipped) — widen the oracle rather than let unverifiable entries
@@ -307,81 +308,172 @@ private static void writeOracleCsv(Path parquet, Writer out) throws IOException
307308
RowReader rows = pfr.rowReader();
308309
CsvWriter csv = CsvWriter.builder().fieldSeparator(',').build(out)) {
309310

310-
List<ColumnSchema> cols = pfr.getFileSchema().getColumns();
311-
// Abort before writing anything if the schema has repeated (list/map) columns.
312-
// Such columns cannot be read as scalar values, and leaving the oracle running
313-
// would create a header-column-count mismatch that would deadlock the pipe.
314-
for (ColumnSchema col : cols) {
315-
if (col.maxRepetitionLevel() > 0) {
316-
throw new TestAbortedException(
317-
"oracle cannot format repeated list column: " + col.fieldPath().topLevelName());
318-
}
319-
}
311+
// One CSV cell per top-level field, mirroring CsvExporter's one-cell-per-Vortex-column
312+
// model — not one per leaf column, which for a LIST/STRUCT field would emit multiple
313+
// cells for what CsvExporter renders as a single JSON array/object cell (#261).
314+
List<SchemaNode> topLevel = pfr.getFileSchema().getRootNode().children();
315+
320316
// De-duplicate duplicate column names with the Rust Vortex writer's algorithm:
321317
// the Nth (N >= 1) occurrence of a base name gets a " [N]" suffix, matching
322318
// the de-duplicated names in the Vortex file (#256).
323-
// Use the top-level logical name (e.g. "vector") rather than the leaf name
324-
// (e.g. "element" inside a LIST group) so the header matches the Vortex column name.
325319
Map<String, Integer> seen = new LinkedHashMap<>();
326-
List<String> header = new ArrayList<>(cols.size());
327-
for (ColumnSchema col : cols) {
328-
String logicalName = col.fieldPath().topLevelName();
329-
int count = seen.merge(logicalName, 1, Integer::sum) - 1;
330-
header.add(count == 0 ? logicalName : logicalName + " [" + count + "]");
320+
List<String> header = new ArrayList<>(topLevel.size());
321+
for (SchemaNode node : topLevel) {
322+
int count = seen.merge(node.name(), 1, Integer::sum) - 1;
323+
header.add(count == 0 ? node.name() : node.name() + " [" + count + "]");
331324
}
332325
csv.writeRecord(header);
333326

334-
String[] row = new String[cols.size()];
327+
String[] row = new String[topLevel.size()];
335328
while (rows.hasNext()) {
336329
rows.next();
337-
for (int c = 0; c < cols.size(); c++) {
338-
row[c] = oracleCell(cols.get(c), rows);
330+
for (int c = 0; c < topLevel.size(); c++) {
331+
row[c] = oracleCell(topLevel.get(c), rows, c);
339332
}
340333
csv.writeRecord(row);
341334
}
342335
}
343336
}
344337

345-
/// Formats a parquet cell with the exact rules of `CsvExporter.cellValue`:
346-
/// null rows export as an empty field, valid rows use the JDK canonical
347-
/// `toString` of the value.
338+
/// Formats one top-level field with the exact rules of `CsvExporter.cellValue`: a null field
339+
/// renders as an empty CSV field, a scalar leaf uses the JDK canonical `toString` of the
340+
/// value, and a LIST/STRUCT field renders as a JSON array/object cell via
341+
/// [#oracleJsonValue(SchemaNode, Object)].
348342
///
349-
/// Row access uses column index rather than name so that files with duplicate
350-
/// column names (#256) read the right column.
343+
/// Field access is by index — the field's position among top-level schema children, per
344+
/// `dev.hardwood.row.StructAccessor`'s "projected schema order" — rather than by name, so
345+
/// that files with duplicate column names (#256) read the right field.
351346
///
352-
/// INT32/INT64 columns with a `UINT_32`/`UINT_64` logical-type annotation are treated
353-
/// as unsigned so their string representation matches the U32/U64 Vortex columns that
354-
/// carry the same bits (#253).
347+
/// INT32/INT64 leaves with a `UINT_32`/`UINT_64` logical-type annotation are treated as
348+
/// unsigned so their string representation matches the U32/U64 Vortex columns that carry the
349+
/// same bits (#253); this applies at any nesting depth, not only top-level scalars.
355350
///
356-
/// @param col the column schema
357-
/// @param rows the row reader positioned at the current row
351+
/// @param node the top-level field's schema node
352+
/// @param rows the row reader positioned at the current row
353+
/// @param fieldIndex the field's position among top-level schema children
358354
/// @return the formatted cell string
359-
private static String oracleCell(ColumnSchema col, RowReader rows) {
360-
int idx = col.columnIndex();
361-
// Repeated list columns (maxRepetitionLevel > 0) cannot be read as a single scalar value.
362-
// Abort rather than silently reading only the first element.
363-
if (col.maxRepetitionLevel() > 0) {
364-
throw new TestAbortedException(
365-
"oracle cannot format repeated list column: " + col.fieldPath().topLevelName());
366-
}
367-
if (col.repetitionType() == RepetitionType.OPTIONAL && rows.isNull(idx)) {
355+
private static String oracleCell(SchemaNode node, RowReader rows, int fieldIndex) {
356+
if (rows.isNull(fieldIndex)) {
368357
return "";
369358
}
370-
boolean unsignedInt = col.logicalType() instanceof LogicalType.IntType lt && !lt.isSigned();
371-
return switch (col.type()) {
372-
case INT32 -> unsignedInt ? Integer.toUnsignedString(rows.getInt(idx)) : Integer.toString(rows.getInt(idx));
373-
case INT64 -> unsignedInt ? Long.toUnsignedString(rows.getLong(idx)) : Long.toString(rows.getLong(idx));
374-
case FLOAT -> Float.toString(rows.getFloat(idx));
375-
case DOUBLE -> Double.toString(rows.getDouble(idx));
376-
case BOOLEAN -> Boolean.toString(rows.getBoolean(idx));
377-
case BYTE_ARRAY -> rows.getString(idx);
359+
if (node instanceof SchemaNode.GroupNode group) {
360+
if (group.isList()) {
361+
return oracleJsonArray(group, rows.getList(fieldIndex));
362+
}
363+
if (group.isMap()) {
364+
throw new TestAbortedException("oracle cannot format MAP column: " + group.name());
365+
}
366+
return oracleJsonObject(group, rows.getStruct(fieldIndex));
367+
}
368+
SchemaNode.PrimitiveNode prim = (SchemaNode.PrimitiveNode) node;
369+
boolean unsignedInt = isUnsignedInt(prim);
370+
return switch (prim.type()) {
371+
case INT32 -> unsignedInt ? Integer.toUnsignedString(rows.getInt(fieldIndex)) : Integer.toString(rows.getInt(fieldIndex));
372+
case INT64 -> unsignedInt ? Long.toUnsignedString(rows.getLong(fieldIndex)) : Long.toString(rows.getLong(fieldIndex));
373+
case FLOAT -> Float.toString(rows.getFloat(fieldIndex));
374+
case DOUBLE -> Double.toString(rows.getDouble(fieldIndex));
375+
case BOOLEAN -> Boolean.toString(rows.getBoolean(fieldIndex));
376+
case BYTE_ARRAY -> rows.getString(fieldIndex);
378377
// aborts (not fails) the slug: the oracle can't format this physical type
379378
// yet, which is an oracle limitation rather than a vortex-java gap
380379
default -> throw new TestAbortedException(
381-
"oracle cannot format parquet type " + col.type() + " (column: " + col.name() + ")");
380+
"oracle cannot format parquet type " + prim.type() + " (column: " + prim.name() + ")");
382381
};
383382
}
384383

384+
/// Renders a nested struct value as a JSON object `{"field":value,...}` with fields in
385+
/// schema order, mirroring `CsvExporter.jsonObject`.
386+
private static String oracleJsonObject(SchemaNode.GroupNode structNode, PqStruct struct) {
387+
List<SchemaNode> fields = structNode.children();
388+
StringBuilder sb = new StringBuilder("{");
389+
for (int i = 0; i < fields.size(); i++) {
390+
if (i > 0) {
391+
sb.append(',');
392+
}
393+
oracleJsonString(sb, fields.get(i).name());
394+
sb.append(':').append(oracleJsonValue(fields.get(i), struct.getValue(i)));
395+
}
396+
return sb.append('}').toString();
397+
}
398+
399+
/// Renders a nested list value as a JSON array `[v0,v1,...]`, mirroring `CsvExporter.jsonArray`.
400+
private static String oracleJsonArray(SchemaNode.GroupNode listNode, PqList list) {
401+
SchemaNode element = listNode.getListElement();
402+
int size = list.size();
403+
StringBuilder sb = new StringBuilder("[");
404+
for (int i = 0; i < size; i++) {
405+
if (i > 0) {
406+
sb.append(',');
407+
}
408+
sb.append(oracleJsonValue(element, list.get(i)));
409+
}
410+
return sb.append(']').toString();
411+
}
412+
413+
/// Renders one decoded struct field / list element as JSON text, mirroring
414+
/// `CsvExporter.jsonValue`: a nested object/array for STRUCT/LIST, a quoted escaped string for
415+
/// STRING, JSON `null` for a null value, and the bare JDK `toString` for numbers/booleans —
416+
/// unsigned for an INT32/INT64 `node` with a `UINT_*` logical-type annotation.
417+
///
418+
/// @param node the value's schema node (its element/field type)
419+
/// @param value the decoded value, from [PqStruct#getValue(int)] or [PqList#get(int)]
420+
/// @return the rendered JSON text
421+
private static String oracleJsonValue(SchemaNode node, Object value) {
422+
if (value == null) {
423+
return "null";
424+
}
425+
return switch (value) {
426+
case PqStruct struct -> oracleJsonObject((SchemaNode.GroupNode) node, struct);
427+
case PqList list -> oracleJsonArray((SchemaNode.GroupNode) node, list);
428+
case String s -> {
429+
StringBuilder sb = new StringBuilder();
430+
oracleJsonString(sb, s);
431+
yield sb.toString();
432+
}
433+
case Integer i -> isUnsignedInt(node) ? Integer.toUnsignedString(i) : Integer.toString(i);
434+
case Long l -> isUnsignedInt(node) ? Long.toUnsignedString(l) : Long.toString(l);
435+
case Float f -> Float.toString(f);
436+
case Double d -> Double.toString(d);
437+
case Boolean b -> Boolean.toString(b);
438+
default -> throw new TestAbortedException(
439+
"oracle cannot format nested value of type " + value.getClass().getSimpleName());
440+
};
441+
}
442+
443+
/// Whether `node` is an INT32/INT64 leaf with a `UINT_*` logical-type annotation (#253):
444+
/// `false` for any other node, including `null` (an unresolvable list element schema).
445+
private static boolean isUnsignedInt(SchemaNode node) {
446+
return node instanceof SchemaNode.PrimitiveNode prim
447+
&& prim.logicalType() instanceof LogicalType.IntType lt && !lt.isSigned();
448+
}
449+
450+
/// Appends `value` to `sb` as a JSON string literal, mirroring `CsvExporter.jsonString`:
451+
/// wrapped in double quotes with `"`, backslash, the standard short escapes, and any other
452+
/// control character below `U+0020` escaped as `\\uXXXX`.
453+
private static void oracleJsonString(StringBuilder sb, String value) {
454+
sb.append('"');
455+
for (int i = 0; i < value.length(); i++) {
456+
char c = value.charAt(i);
457+
switch (c) {
458+
case '"' -> sb.append("\\\"");
459+
case '\\' -> sb.append("\\\\");
460+
case '\n' -> sb.append("\\n");
461+
case '\r' -> sb.append("\\r");
462+
case '\t' -> sb.append("\\t");
463+
case '\b' -> sb.append("\\b");
464+
case '\f' -> sb.append("\\f");
465+
default -> {
466+
if (c < 0x20) {
467+
sb.append(String.format("\\u%04x", (int) c));
468+
} else {
469+
sb.append(c);
470+
}
471+
}
472+
}
473+
}
474+
sb.append('"');
475+
}
476+
385477
private static Path manifestPath() {
386478
String env = System.getenv("RAINCLOUD_CORPUS_MANIFEST");
387479
return env != null ? Path.of(env) : DEFAULT_MANIFEST;

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,9 @@ frames-benchmark,untriaged
105105
ghcn-daily,untriaged
106106
glass,ok
107107
global-fossil-co2-emissions-by-country-2002-2022,untriaged
108-
glove-6b-100d,gap:261
109-
glove-6b-200d,gap:261
110-
glove-6b-50d,gap:261
108+
glove-6b-100d,ok
109+
glove-6b-200d,ok
110+
glove-6b-50d,ok
111111
goodbooks-10k,ok
112112
google-cluster-trace-2011-machine-events,ok
113113
green_tripdata_2025,ok
@@ -156,7 +156,7 @@ openlibrary-works,untriaged
156156
openorca,untriaged
157157
openpowerlifting,ok
158158
osm-germany-nodes,untriaged
159-
osm-germany-relations,gap:261
159+
osm-germany-relations,ok
160160
osm-germany-ways,untriaged
161161
osmi-mental-health-in-tech-2016,untriaged
162162
osmi-mental-health-in-tech-2017,untriaged

0 commit comments

Comments
 (0)