Skip to content

Commit 7945318

Browse files
dfa1claude
andcommitted
test: add Raincloud real-world conformance harness (#205)
Cross-implementation conformance against the Raincloud corpus: 247 public datasets whose Vortex files are written by the Python bindings (vortex-data 0.69.0), each with a Parquet sibling built from the same Arrow table. - expected-status.csv enumerates every vortex-enabled slug with a triaged status: ok (must match the Parquet oracle exactly), gap:<issue> (must still fail, so a fix forces the flip in the same change), untriaged (runs and reports as aborted without failing the build). - RaincloudConformanceIntegrationTest discovers whatever the hydrate manifest lists; the oracle reads the Parquet sibling through hardwood and writes through the same fastcsv writer as CsvExporter, so a zero-diff proves every value survived the Python-write/Java-read boundary. Oracle-side limitations (nested parquet columns) abort the slug instead of failing it. - scripts/hydrate-raincloud-corpus.sh resolves slugs through the raincloud loader (cache -> mirror -> local build) pinned at v0.2.1, with a per-slug size budget and tolerant per-slug failures. - raincloud-conformance.yml runs a size-capped sweep weekly; off the PR path because upstream dataset URLs rot independently of our code. First triage of 10 hydrated slugs: 4 ok (value-for-value exact), 6 gaps filed as #206 (lazy dict U8/U16 values), #207 (nested struct columns), #208 (unsigned rendered signed - silent corruption), #209 (RLE over F64). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5e58788 commit 7945318

7 files changed

Lines changed: 667 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
name: Raincloud conformance
2+
3+
# Real-world cross-implementation conformance (issue #205): hydrates the Raincloud
4+
# corpus (Vortex files written by the Python bindings + Parquet oracles) and runs
5+
# RaincloudConformanceIntegrationTest against everything hydrated. Scheduled, not on
6+
# the PR path: hydration depends on upstream dataset URLs that rot independently of
7+
# our code, so a failure here is a finding to triage, not a broken build.
8+
9+
on:
10+
schedule:
11+
- cron: '0 5 * * 1' # Mondays 05:00 UTC
12+
workflow_dispatch:
13+
inputs:
14+
max-mb:
15+
description: 'Per-slug artifact size cap in MB (0 = uncapped)'
16+
default: '200'
17+
18+
jobs:
19+
conformance:
20+
runs-on: ubuntu-latest
21+
steps:
22+
- uses: actions/checkout@v7
23+
24+
- name: Set up Azul Zulu JDK 25
25+
uses: actions/setup-java@v5
26+
with:
27+
distribution: zulu
28+
java-version: '25'
29+
30+
- name: Cache Maven repository
31+
uses: actions/cache@v6
32+
with:
33+
path: ~/.m2/repository
34+
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
35+
restore-keys: |
36+
${{ runner.os }}-maven-
37+
38+
- uses: actions/setup-python@v6
39+
with:
40+
python-version: '3.12'
41+
42+
# keyed on the matrix + pinned tag: a corpus change rebuilds, otherwise
43+
# weekly runs reuse artifacts instead of re-fetching upstream sources
44+
- name: Cache Raincloud corpus
45+
uses: actions/cache@v6
46+
with:
47+
path: ~/.cache/raincloud
48+
key: raincloud-corpus-${{ hashFiles('scripts/hydrate-raincloud-corpus.sh', 'integration/src/test/resources/raincloud/expected-status.csv') }}
49+
50+
- name: Hydrate corpus
51+
run: scripts/hydrate-raincloud-corpus.sh --max-mb "${{ github.event.inputs.max-mb || '200' }}"
52+
53+
- name: Run conformance suite
54+
run: ./mvnw verify -pl integration -am -Dit.test=RaincloudConformanceIntegrationTest
55+
56+
- name: Publish per-slug results to job summary
57+
if: always()
58+
run: |
59+
{
60+
echo '## Raincloud conformance'
61+
echo '```'
62+
grep -hE "Tests run|slug" integration/target/failsafe-reports/*.txt || true
63+
echo '```'
64+
} >> "$GITHUB_STEP_SUMMARY"

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Real-world conformance suite against the [Raincloud](https://github.com/spiraldb/raincloud) corpus: 247 public datasets whose Vortex files are written by the Python bindings, each validated value-for-value against its Parquet sibling. `scripts/hydrate-raincloud-corpus.sh` hydrates any subset (cache → mirror → local build), `RaincloudConformanceIntegrationTest` tests whatever is hydrated against the checked-in per-dataset status matrix, and a weekly workflow runs a size-capped sweep. First triage found four reader gaps on real data, including one silent-corruption bug (unsigned integers rendered as signed). ([#205](https://github.com/dfa1/vortex-java/issues/205))
13+
1014
## [0.12.0] — 2026-07-04
1115

1216
Identity gets **types**: encoding ids, layout ids, and column names are now validated domain

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ Trunk-based. PRs fine but always squash or rebase — no merge commits. Keep com
6363
./mvnw verify -pl integration -am # integration (failsafe, NOT surefire)
6464
./mvnw verify -pl integration -am -Dit.test="RustWritesJavaReadsIntegrationTest#method"
6565
./bench JavaVsJniReadBenchmark.javaReadVolume # benchmark — always ClassName.methodName filter
66+
scripts/hydrate-raincloud-corpus.sh --max-mb 200 # hydrate real-world conformance corpus (#205),
67+
# then verify -Dit.test=RaincloudConformanceIntegrationTest
6668
```
6769

6870
Regenerate after editing `.fbs`/`.proto` (both generators are in-house, no external tools):

docs/compatibility.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,31 @@ resolves only the standalone decoders in `reader`; no encoder class is loaded.
4343
| Duplicate struct field names | Rust writer rejects ("StructLayout must have unique field names"); Rust reader tolerates foreign files (first-match access) | ⚠️ Deliberate divergence on read: Java rejects such files with `VortexException("duplicate field name in file schema")` instead of tolerating them — the name-keyed `Chunk` API cannot represent both columns, and silent column loss is worse than a loud failure on a file the reference writer refuses to produce. Java's writer mirrors the Rust writer's rejection. |
4444
| Blank / control-character field names | Wire-legal; the Rust writer produces `""` and whitespace-only names. NUL (`U+0000`) additionally aborts the Rust toolchain: Arrow FFI schema export hits a panic-cannot-unwind in `arrow-rs` (`ffi_stream::get_schema`) and SIGABRTs the process (measured against vortex-jni 0.75.0) | ⚠️ Deliberate strictness BOTH ways: vortex-java's writer refuses blank and control-character field names (`IllegalArgumentException`), and its reader rejects files carrying them (`VortexException` naming the producing pipeline as the likely bug) — the JSON-`""`-key principle: wire-legal is a floor, not a policy. Printable names of any shape (`$`-runs, spaces inside, emoji) are legal and round-trip intact both directions (measured; pinned by `ColumnNameEdgeCasesIntegrationTest`). |
4545

46+
## Real-world conformance: the Raincloud corpus
47+
48+
Cross-implementation conformance against [Raincloud](https://github.com/spiraldb/raincloud)
49+
(tracked in [#205](https://github.com/dfa1/vortex-java/issues/205)): 247 curated public
50+
datasets whose `.vortex` files are written by the Vortex **Python** bindings
51+
(`vortex-data 0.69.0`, pinned via Raincloud `v0.2.1`) with a Parquet sibling built from the
52+
same Arrow table. `RaincloudConformanceIntegrationTest` reads each hydrated `.vortex` with
53+
vortex-java, exports CSV, and diffs it against the Parquet sibling read by hardwood (the
54+
oracle) — a zero-diff proves every value survived the Python-write / Java-read boundary.
55+
56+
```bash
57+
scripts/hydrate-raincloud-corpus.sh --max-mb 200 # cache → mirror → local build
58+
./mvnw verify -pl integration -am -Dit.test=RaincloudConformanceIntegrationTest
59+
```
60+
61+
Per-slug status lives in `integration/src/test/resources/raincloud/expected-status.csv`
62+
(`ok` must pass; `gap:<issue>` must still fail, so a fix flips the entry in the same change;
63+
`untriaged` runs and reports without failing the build). A scheduled workflow
64+
(`raincloud-conformance.yml`) hydrates a size-capped subset weekly. Current triage —
65+
4 `ok`, 6 known gaps: lazy dict U8/U16 values
66+
([#206](https://github.com/dfa1/vortex-java/issues/206)), nested struct columns in scan
67+
([#207](https://github.com/dfa1/vortex-java/issues/207)), unsigned integers rendered signed
68+
([#208](https://github.com/dfa1/vortex-java/issues/208) — silent corruption), RLE over F64
69+
([#209](https://github.com/dfa1/vortex-java/issues/209)); 237 slugs untriaged.
70+
4671
## Encodings
4772

4873
| Encoding ID | Decoder | Encoder | Decode | Encode | Notes |
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package io.github.dfa1.vortex.integration;
2+
3+
import de.siegmar.fastcsv.writer.CsvWriter;
4+
import dev.hardwood.InputFile;
5+
import dev.hardwood.metadata.RepetitionType;
6+
import dev.hardwood.reader.ParquetFileReader;
7+
import dev.hardwood.reader.RowReader;
8+
import dev.hardwood.schema.ColumnSchema;
9+
import io.github.dfa1.vortex.core.error.VortexException;
10+
import io.github.dfa1.vortex.csv.CsvExporter;
11+
import io.github.dfa1.vortex.csv.ExportOptions;
12+
import org.junit.jupiter.api.DynamicTest;
13+
import org.junit.jupiter.api.Test;
14+
import org.junit.jupiter.api.TestFactory;
15+
import org.opentest4j.TestAbortedException;
16+
17+
import java.io.IOException;
18+
import java.io.StringWriter;
19+
import java.io.UncheckedIOException;
20+
import java.nio.file.Files;
21+
import java.nio.file.Path;
22+
import java.util.HashMap;
23+
import java.util.List;
24+
import java.util.Map;
25+
import java.util.stream.Stream;
26+
27+
import static org.assertj.core.api.Assertions.assertThat;
28+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
29+
import static org.junit.jupiter.api.Assumptions.assumeTrue;
30+
31+
/// Real-world cross-implementation conformance over the Raincloud corpus
32+
/// ([issue #205](https://github.com/dfa1/vortex-java/issues/205)).
33+
///
34+
/// Each corpus dataset is a `.vortex` file written by the Vortex Python bindings with
35+
/// its `.parquet` sibling built from the same Arrow table. The parquet file is the
36+
/// oracle: hardwood reads it row-by-row and formats cells with the exact rules of
37+
/// `CsvExporter.cellValue` (null → empty field, `Double.toString`, …), writing through
38+
/// the same fastcsv writer so quoting is identical. A zero-diff against vortex-java's
39+
/// CSV export proves every value survived the Python-write / Java-read boundary.
40+
///
41+
/// Skipped (visibly, via assumption) when the corpus is not hydrated — run
42+
/// `scripts/hydrate-raincloud-corpus.sh` first, or point `RAINCLOUD_CORPUS_MANIFEST`
43+
/// at a manifest TSV (`slug<TAB>vortex-path<TAB>parquet-path` per line).
44+
///
45+
/// Expected per-slug status lives in `src/test/resources/raincloud/expected-status.csv`.
46+
/// Known gaps assert that the failure still occurs, so fixing the reader forces the
47+
/// matrix entry to flip to `ok` in the same change.
48+
class RaincloudConformanceIntegrationTest {
49+
50+
private static final Path DEFAULT_MANIFEST =
51+
Path.of(System.getProperty("user.home"), ".cache", "raincloud", "corpus-manifest.tsv");
52+
53+
@Test
54+
void corpusIsHydrated() {
55+
// Given / When / Then — visible skip marker when the corpus is absent; the
56+
// factory below yields zero tests in that case, which would otherwise pass silently
57+
assumeTrue(Files.exists(manifestPath()),
58+
"raincloud corpus not hydrated — run scripts/hydrate-raincloud-corpus.sh");
59+
}
60+
61+
@TestFactory
62+
Stream<DynamicTest> conformancePerSlug() throws IOException {
63+
Path manifest = manifestPath();
64+
if (!Files.exists(manifest)) {
65+
return Stream.empty();
66+
}
67+
Map<String, String> expected = readExpectedStatus();
68+
return Files.readAllLines(manifest).stream()
69+
.filter(line -> !line.isBlank())
70+
.map(line -> line.split("\t"))
71+
.map(parts -> DynamicTest.dynamicTest(parts[0], () -> {
72+
String slug = parts[0];
73+
Path vortex = Path.of(parts[1]);
74+
Path parquet = Path.of(parts[2]);
75+
// slugs missing from the matrix are treated as untriaged, not failing
76+
String status = expected.getOrDefault(slug, "untriaged");
77+
switch (status) {
78+
case "ok" -> assertMatchesParquetOracle(vortex, parquet);
79+
case "untriaged" -> reportUntriaged(vortex, parquet);
80+
default -> assertStillFails(vortex, parquet, status);
81+
}
82+
}));
83+
}
84+
85+
private static void assertMatchesParquetOracle(Path vortex, Path parquet) throws IOException {
86+
// Given
87+
List<String> oracleLines = oracleLines(parquet);
88+
89+
// When
90+
List<String> result = exportVortex(vortex);
91+
92+
// Then
93+
assertLinesMatch(result, oracleLines);
94+
}
95+
96+
/// Runs the full conformance check but reports the outcome as an aborted test
97+
/// either way: only triaged matrix entries may count as green or red. The abort
98+
/// message says which way to flip the entry.
99+
private static void reportUntriaged(Path vortex, Path parquet) {
100+
try {
101+
assertMatchesParquetOracle(vortex, parquet);
102+
} catch (TestAbortedException e) {
103+
throw e; // oracle limitation, not a conformance outcome
104+
} catch (AssertionError | Exception e) {
105+
throw new TestAbortedException(
106+
"untriaged slug fails — classify as gap:<issue> in expected-status.csv: " + e);
107+
}
108+
throw new TestAbortedException("untriaged slug passes — flip its matrix entry to ok");
109+
}
110+
111+
private static void assertStillFails(Path vortex, Path parquet, String status) {
112+
// Given / When — a decode gap still reproduces when the export itself throws;
113+
// no oracle needed (the oracle may not even read this parquet, e.g. nested columns)
114+
List<String> result;
115+
try {
116+
result = exportVortex(vortex);
117+
} catch (VortexException e) {
118+
return;
119+
} catch (IOException e) {
120+
throw new UncheckedIOException(e);
121+
}
122+
123+
// Then — the export now succeeds, so the gap must still be a silent-corruption
124+
// one (e.g. #208): values must still mismatch the oracle. A clean pass means the
125+
// gap was fixed: flip the expected-status.csv entry to ok in the same change
126+
List<String> oracleLines = oracleLines(parquet);
127+
assertThatThrownBy(() -> assertLinesMatch(result, oracleLines))
128+
.as("known gap %s no longer reproduces — flip its matrix entry to ok", status)
129+
.isInstanceOf(AssertionError.class);
130+
}
131+
132+
private static void assertLinesMatch(List<String> result, List<String> oracleLines) {
133+
assertThat(result).hasSameSizeAs(oracleLines);
134+
for (int i = 0; i < oracleLines.size(); i++) {
135+
assertThat(result.get(i)).as("line %d", i + 1).isEqualTo(oracleLines.get(i));
136+
}
137+
}
138+
139+
private static List<String> exportVortex(Path vortex) throws IOException {
140+
StringWriter out = new StringWriter();
141+
CsvExporter.exportCsv(vortex, out, ExportOptions.defaults());
142+
return out.toString().lines().toList();
143+
}
144+
145+
/// Reads the parquet sibling through hardwood; an oracle-side failure (nested
146+
/// columns, unsupported physical type) aborts the slug rather than failing it —
147+
/// it says nothing about vortex-java.
148+
private static List<String> oracleLines(Path parquet) {
149+
StringWriter out = new StringWriter();
150+
try {
151+
writeOracleCsv(parquet, out);
152+
} catch (TestAbortedException e) {
153+
throw e;
154+
} catch (Exception e) {
155+
throw new TestAbortedException("oracle cannot read the parquet sibling: " + e);
156+
}
157+
return out.toString().lines().toList();
158+
}
159+
160+
/// Oracle: hardwood reads the parquet sibling and emits CSV through the same
161+
/// fastcsv writer configuration as `CsvExporter`, using its exact cell rules.
162+
private static void writeOracleCsv(Path parquet, StringWriter out) throws IOException {
163+
try (ParquetFileReader pfr = ParquetFileReader.open(InputFile.of(parquet));
164+
RowReader rows = pfr.rowReader();
165+
CsvWriter csv = CsvWriter.builder().fieldSeparator(',').build(out)) {
166+
167+
List<ColumnSchema> cols = pfr.getFileSchema().getColumns();
168+
csv.writeRecord(cols.stream().map(ColumnSchema::name).toList());
169+
170+
String[] row = new String[cols.size()];
171+
while (rows.hasNext()) {
172+
rows.next();
173+
for (int c = 0; c < cols.size(); c++) {
174+
row[c] = oracleCell(cols.get(c), rows);
175+
}
176+
csv.writeRecord(row);
177+
}
178+
}
179+
}
180+
181+
/// Formats a parquet cell with the exact rules of `CsvExporter.cellValue`:
182+
/// null rows export as an empty field, valid rows use the JDK canonical
183+
/// `toString` of the value.
184+
///
185+
/// @param col the column schema
186+
/// @param rows the row reader positioned at the current row
187+
/// @return the formatted cell string
188+
private static String oracleCell(ColumnSchema col, RowReader rows) {
189+
String name = col.name();
190+
if (col.repetitionType() == RepetitionType.OPTIONAL && rows.isNull(name)) {
191+
return "";
192+
}
193+
return switch (col.type()) {
194+
case INT32 -> Integer.toString(rows.getInt(name));
195+
case INT64 -> Long.toString(rows.getLong(name));
196+
case FLOAT -> Float.toString(rows.getFloat(name));
197+
case DOUBLE -> Double.toString(rows.getDouble(name));
198+
case BOOLEAN -> Boolean.toString(rows.getBoolean(name));
199+
case BYTE_ARRAY -> rows.getString(name);
200+
// aborts (not fails) the slug: the oracle can't format this physical type
201+
// yet, which is an oracle limitation rather than a vortex-java gap
202+
default -> throw new TestAbortedException(
203+
"oracle cannot format parquet type " + col.type() + " (column: " + name + ")");
204+
};
205+
}
206+
207+
private static Path manifestPath() {
208+
String env = System.getenv("RAINCLOUD_CORPUS_MANIFEST");
209+
return env != null ? Path.of(env) : DEFAULT_MANIFEST;
210+
}
211+
212+
private static Map<String, String> readExpectedStatus() throws IOException {
213+
try (var in = RaincloudConformanceIntegrationTest.class
214+
.getResourceAsStream("/raincloud/expected-status.csv")) {
215+
if (in == null) {
216+
throw new UncheckedIOException(new IOException("expected-status.csv not on test classpath"));
217+
}
218+
Map<String, String> statuses = new HashMap<>();
219+
for (String line : new String(in.readAllBytes()).lines().toList()) {
220+
if (line.isBlank() || line.startsWith("#")) {
221+
continue;
222+
}
223+
String[] parts = line.split(",", 2);
224+
statuses.put(parts[0].trim(), parts[1].trim());
225+
}
226+
return statuses;
227+
}
228+
}
229+
}

0 commit comments

Comments
 (0)