EarthSciIO conformance corpus + runner (v1)
Source of truth: spec/conformance.md
.
Deliverable (d) of esio-9nb.1. Status: normative.
Corpus: ../conformance/corpus/
. Reference runner:
../conformance/verify.py
. Generator:
../conformance/generate.py
.
The corpus is the cross-language correctness guarantee: the same loader +
the same cached blob must yield the same native arrays in Python, Julia, and
Rust — proven offline, so FFI is unnecessary (the architecture decision).
The full cross-language harness is esio-9nb.9; this spec defines the format it
consumes and ships the Python oracle so the corpus is self-validating today.
1. The corpus is a populated cache
conformance/corpus/
cache/v1/blobs/<key[:2]>/<key>.<ext> # golden cached blobs (a real $EARTHSCIDATADIR)
cache/v1/meta/<key>.json # their manifests
cases/<id>.json # one conformance case per blob (below)
cases.json # case index
Point any provider at conformance/corpus/cache as $EARTHSCIDATADIR with
offline=true and every case resolves from disk — no network, no per-language
data tooling beyond the format reader.
Committed cases (worked examples)
| id | loader | kind | format | transport | store | what it pins |
|---|---|---|---|---|---|---|
era5-grid-sub-tile | era5 | grid | netcdf | file | local | CF scale/offset + _FillValue→NaN + a masked cell; packed int16 → float64 |
era5-window-slice | era5 | grid | netcdf | file | local | the same blob and cache key as era5-grid-sub-tile, read through a decode-time select (lat {slice:[1,3]}, lon {indices:[0,2]}, time "all"). Pins that a whole-file reader materialises only the hyperslab and returns exactly the full read sliced afterwards, that the lat/lon coordinates are sliced with the data, that the masked cell still decodes to NaN inside the window, that the time axis is untouched and raw — and that a selection changes the decode, never the fetch |
station-labels-text | openaq | points | netcdf | file | local | NetCDF text variables, all three readings in one blob: char site_id(site, strlen) on a private strlen is 4 strings on dims ["site"] (length consumed, trailing NULs stripped, the trailing space of "cd " kept as data, an all-NUL row ""); char flag(site) shares site with float64 value(site) so site is a real axis and every cell is its own one-character string (its NUL cell ""); char title(titlelen) alone on a private dimension is a scalar string (dims []). The rule is about the file, not the variable |
station-labels-window | openaq | points | netcdf | file | local | the same blob and cache key as station-labels-text under a one-axis select ({indices:[0,2]}). Pins that a text field is windowed like any other — site_id and flag lose the cells value loses — and that the positional axis match is over the decoded field’s dims: site_id is rank 1 on two on-disk dimensions and the scalar title is rank 0 and untouched. Counting on-disk dims would bind the selector to titlelen too and return "h" for title |
openaq-points-slice | openaq | points | csv | file | local | a 2nd reader behind the format registry; numeric→float64, text→string |
ff10-point-slice | nei2016 | points | ff10 | file | local | FF10 point long-format: # header skipped, fixed 77-col schema, RFC-4180 quoted FACILITY_NAME, numeric→float64 (blank→NaN), ids/codes→string; 3 rows share one stack (no pivot). member=null decodes the extracted CSV member |
ff10-zip-egu-glob | nei2016 | points | ff10 | file | local | EPA-2016fd-shaped zip of FF10 members (two *egu* + one excluded + a glob-matching directory placeholder entry, ignored), each member with a non-comment country_cd,… header line. Pins member_glob selection (exclusion, sorted member-name concatenation) + skip_header_row (one asserted header line dropped per member). The blob is the whole zip; member selection is reader config, never part of the cache key |
shapefile-polygon-zip | emis_polygons | points | shapefile | file | local | ESRI shapefile zipped with its .shx/.dbf/.prj sidecars. Pins the whole reader contract in one layer: one row per PART (a mainland + an island decode to two rows with the .dbf attributes replicated), the esm-spec §8.6.1 repeat-final-vertex padding, the *-only deletion rule (a NUL flag byte is NOT a deletion), the record’s stored bbox replicated to its parts, and the dtype rules (C→string, N→float64 with blank→NaN, L→bool, a C code column forced float64 by numeric_columns) |
isrm-zarr-tile | isrm | grid | zarr | s3 | local | store-backed Zarr v2: lazy orthogonal chunk selection (fetch only the intersecting chunk objects), blosc/lz4+shuffle decode, partial edge chunk, fill_value 0.0 NOT→NaN, no coords. objects[] per-object key/integrity. |
moves-rate-table-parquet | moves | points | parquet | file | local | MOVES-shaped Parquet rate table, 6 rows in 2 row groups (so the batch seam and row order are pinned), snappy. One column per supported Arrow family: the narrow/wide integer split (a uint8 255 read unsigned, a uint32 4294967295 that does not fit an int32), a categorical expanded, float32 widened bit-exactly, Decimal128 as unscaled/10^scale, temporal columns as their raw stored integer at their stored width, an all-Null column as float64/all-NaN. Null policy + both gates: null_int=-1 fills every integer null and is reported in fill_value; null_string fills a text null and a dictionary null while a real "" survives. float_columns does both its jobs — decimal TEXT (blank→NaN) and an integer measurement whose nulls become NaN, so no fill_value survives it. variables is a real projection: a readable column and a Binary one are both left out |
The parquet case carries no nested column, no sub-millisecond timestamp and no
decimal outside Dec64’s exact range — each because a Parquet2.jl limit
(recorded in §3 below) makes that shape unenforceable across three tracks, not
because the contract is unclear. Those rules stay normative and are exercised
per-track by rust/tests/parquet_reader.rs, julia/test/test_parquet_reader.jl
and tests/test_parquet_reader.py.
| permuted-order-tile | isrm | grid | zarr | s3 | local | ordered lazy orthogonal selection: a non-contiguous, PERMUTED index list is returned in the requested order exactly (a reader that sorted the indices fails), fetching 2 of 15 chunks |
| landfire-raster-tile | landfire | grid | geotiff | file | local | GeoTIFF georef contract: Band1 naming, lat/lon axis names from GTModelTypeGeoKey, cell-centre axes offset half a cell from the tiepoint’s corner, non-square cells (sx≠sy), lat decreasing with row (y-up model vs y-down rows), GDAL_NODATA→NaN on an interior cell, int16→float64 |
The GeoTIFF entry is no longer reserved: landfire-raster-tile commits a
424-byte single-band raster authored by generate.py with tifffile, so no
GDAL and no git-lfs are needed to carry it. Adding it immediately earned its
keep — all three tracks decode the same bytes, and the Rust reader turned out
never to have implemented GDAL_NODATA→NaN, which two tracks and two docstrings
had asserted for as long as the reader existed.
The S3-store entry remains reserved: the case + manifest shape is defined here, but the store-backed variant is not committed (binary hosting for a realistic object store is still an open decision, plan §8).
2. Case format
Each cases/<id>.json validates against
schemas/cache-case.schema.json
and carries:
- the registry triple (
transport/format/store) that reads it; resolved_url+cache_key(with the invariantsha256(resolved_url) == cache_key),blob_path,manifest_path,content_sha256,bytes;- optional
select(which record/rows to slice) anddecode(conventions hit); expected.variables—file_variable→ native field (CF-decoded), andexpected.coords— coordinate → native field. Native fields validate againstschemas/native-field.schema.json.
3. Decode conventions (the parity contract, Risk R4)
Every reader MUST decode identically, or cross-language equality fails. Pinned:
CF packing — apply
scale_factor/add_offset:value = raw*scale+off. Packed numeric variables are returned as float64 (the scale/offset math is done in double regardless of the on-disk integer width).Fill / missing —
_FillValue(andmissing_valueif present) compares before unpacking; masked elements become NaN (encoded asnullin the corpusdata).Numeric dtype — unpacked numeric file variables keep an integer logical type (
int64/int32); all other numeric reads are float64. This removes float32-vs-float64 ambiguity between xarray / NCDatasets / netcdf-rs.Time — the time coordinate is returned raw (the stored integer/float values) with its
units+calendarcarried as metadata. Calendar decoding to wall-clock instants is ESS’s job, not the reader’s.Variable identity — arrays are keyed by the on-disk
file_variablename. No remap, nounit_conversion(Risk R3 — those stay in ESS).Projection (
variables) — a loader’svariablesare pushed INTO the reader wherever the format can honour them (parquetcolumn chunks;netcdf, where an unrequested variable is simply never decoded — a GEOS-FP A1 file carries 47 and a loader wants one), rather than being applied to an already-decoded dataset. Coordinates are always returned; an empty list means every data variable, never none; a requested name absent from the blob is an error listing what is present, never a silently missing array. A reader with no such option keeps the read-everything-then-select path, which must produce the identical result. “Never a silently missing array” covers a name that is present but has no native array reading too — a Parquet nested/binary column: unrequested it is simply not a field, but requested it is an error naming the type, because the document named an array it would not get. A variable one track cannot decode is not in that category — it is a divergence, and it is fixed by teaching the reader, not by documenting the gap.Strings — text columns (CSV/JSON) are returned as
stringarrays verbatim.NetCDF text variables are
stringfields in every track. Acharvariable (an ERA5expver, achar label(n)) or a NetCDF-4NC_STRINGis decoded and returned like any other field — read-everything returns it, and the projection may name it. No track may skip it: the same bytes must yield the same set of fields everywhere. The classic character-array convention decides its shape, and the convention is about the file, not the variable:- A
charvariable’s last dimension is the string length exactly when nothing else claims it as an axis — the dimension has no coordinate variable of its own, and every variable using it is acharvariable using it last. That dimension is then consumed:char label(n, strlen)isnstrings of up tostrlenbytes, notn × strlensingle characters, and a 1-Dchar label(strlen)on a private dimension is a scalar string (dims == []). - Otherwise the last dimension is a real axis and every cell is its own
one-character string, on the variable’s own
dims/shape:char label(n)beside afloat value(n)isnone-character strings. - Trailing NUL padding is stripped; trailing spaces are data. A cell that
is all NULs is the empty string, never
"\0". - An
NC_STRINGis already one string per element;dims/shapeare the variable’s own.
This is what xarray produces (its
CharacterArrayCoder, gated onconventions.stackable), which makes the Python track the reference for these bytes. All three tracks now produce it: neither NCDatasets nornetcdf-readerapplies the convention itself (both hand back the raw characters of the full on-disk shape), so the Julia and Rust readers apply it above their backend, andstation-labels-textpins the result three-way. Its second face is under aselect: achar label(n, strlen)is a rank-1 field on two on-disk dimensions, so the positional axis match the selection notes below require must count the DECODED field’s dims. A track that counted on-disk dims would let that variable answer for rank 2, bind an axis to a string LENGTH, and then apply that selector by name to every other array in the blob —station-labels-windowpins that too.- A
FF10 zip member selection — an
ff10blob may be a.zip; the reader’smember(singular),members(explicit list), andmember_glob(fnmatch-style*/?/[...], case-sensitive, matched against the full member path) select which member CSV(s) to decode.members/member_globcombine as a union, deduplicated, and the selected members are read and their rows concatenated in ascending lexicographic (byte) order of member name — the deterministic multi-member ordering rule all three tracks share. An explicit member absent from the archive, and a glob matching zero members, are errors. Selection considers only file members — directory placeholder entries (names ending in/, e.g. the real 2016fd zip’s…/ptegu/) are ignored.memberis mutually exclusive withmembers/member_glob. All of these are reader config and never part of the cache key — the blob is the whole zip.FF10 header skip — with
skip_header_row = true, after comment (#) and blank lines are dropped, the first remaining line of each selected input (each selected zip member, or the bare file) must be a header row — its first delimiter-separated field, compared case-insensitively, must equalcountry_cd— and exactly that one line is skipped per member. If the first field is anything else (or no line remains) the reader errors: the option asserts a header row and never silently drops a data row. (The EPA 2016fd members carry thiscountry_cd,region_cd,…line as a non-comment row of 77 fields, which would otherwise die at the numeric parse ofann_value.)Shapefile file set — a shapefile is a file set (
.shp+.shx+.dbf+.prj) but the content-addressed cache holds ONE blob, so the fetchable form is a.zip. The reader’smembernames the.shpinside it (sidecars are the same stem with.dbf/.shx/.prj, matched case-insensitively on the extension); withmemberabsent the archive must contain exactly one.shp— zero or several is an error naming the candidates. A bare.shpblob decodes too, geometry only.memberis reader config and never part of the cache key.Shapefile row identity: one row per PART — a record may carry several parts (a polygon’s outer ring plus its holes, a county’s mainland plus its islands). The geometry ops that consume a ring (
polygon_intersection_area,intersect_polygon) take ONE ring, so each part becomes one row of theindexaxis, with the record’s.dbfattributes replicated across its parts andshape_index/part_index/n_partsnaming where the row came from. A Point or MultiPoint record has no parts and is one row (a MultiPoint’s points stay together); a Null shape is one row ofNaNs. A single-part layer decodes 1:1.Shapefile vertex padding —
geometryisfloat64[index, vertex, xy], right-padded to the longest part by repeating the final vertex — the rectangular-storage conventionesm-spec§8.6.1 pins, which a geometry kernel evaluates as the deduplicated ring, so a padded ring has the ring’s own area. Padding is neverNaN(only a Null shape’s row is). Vertices are the STORED ones: an explicitly closed ring keeps its closing vertex (dropping it is the kernel’s job, §8.6.1) and winding is untouched — no orientation fix, no outer/hole classification.nvert_maxpads to exactly that many slots instead, so a DOCUMENT declares the vertex-axis length rather than inheriting whatever the file happens to hold; a part longer than it is an error naming the row, never a silent truncation.Shapefile bounding box —
xmin/ymin/xmax/ymaxare the parent RECORD’s storedBox, replicated to its parts (a Point record stores no box, so its own coordinate is used). On disk, not recomputed: it is the shapefile’s own broad-phase envelope.dBASE deletion flag — a
.dbfrow is deleted iff its flag byte is*(0x2A); every other byte — including theNULthat some writers emit — means live. A deleted row drops the.dbfrow AND its.shpshape together, so the two stay aligned. (dbase(Rust) and DBFTables.jl already use this rule; pyshp treats any non-space flag as deleted, so the Python reader normalizes the flag bytes before decoding.)dBASE column dtypes —
N/F→ float64 (a blank cell →NaN; an integer-typedNcolumn is NOT kept integral, so the three dbf libraries cannot disagree),L→ bool,D→ the stringYYYYMMDD,C/M→ string.numeric_columnsparses namedCcolumns as float64 instead — theCSVReader/FF10Readerspelling, for a code column (a FIPSGEOID) a model wants as a number. A.dbfcolumn whose name collides with one of the reader’s own fields is an error, never a silent shadowing.Shapefile CRS and shape type — the
.prjWKT (verbatim, when present) and the layer’s shape-type name are one-elementstringfields on ametadimension:crs_wktandshape_type. They are FIELDS rather than field attributes because the RustNativeFieldcarries no attrs and native-array equality compares fields. The CRS is DECLARED, never acted on — reprojection is ESD’s job (Risk R3).
Parquet decode notes (columnar table reader)
The parquet reader takes an Apache Parquet file as a flat table: every
column becomes a rank-1 field over index, keyed by its on-disk column name,
and a table produces no coordinates (like the CSV and Zarr readers). index
has length num_rows.
Parquet carries an explicit logical type per column, so unlike the CF attribute sniffing the NetCDF reader must do, the dtype is a total function of the Arrow type — which is what makes cross-language parity cheap here, and why any track that deviates is wrong rather than merely different:
| Arrow type | dtype |
|---|---|
Boolean | bool |
Int8/Int16/Int32/UInt8/UInt16 | int32 |
Int64/UInt32/UInt64 | int64 |
Float16/Float32/Float64 | float64 |
Decimal128/Decimal256 | float64 — unscaled value ÷ 10^scale |
Utf8/LargeUtf8/Utf8View | string |
Date32/Time32 | int32 — raw, undecoded |
Date64/Time64/Timestamp/Duration | int64 — raw, undecoded |
Dictionary(_, V) | as V, expanded to one value per row |
Null | float64, every cell NaN |
- The narrow/wide integer split is the NetCDF reader’s, verbatim. It is
restated rather than re-derived so a MOVES
int32ID column and a CFint32time axis cannot drift apart. - A
uint64aboveint64::MAXis an error naming the column and row, never a wraparound into a negative ID — when the column is read as an integer. Underfloat_columnsthe document has declared it a float64 measurement, there is no integer to wrap into, and it decodes. (The range check belongs to the integer coercion, not to the decode; a track that puts it in the decode refuses a read the other two perform.) - Temporal columns are carried verbatim as their raw stored integer. The
Arrow unit (
s/ms/us/ns) and any timezone are not applied and not reported — the same rule a CF time axis gets, because turning an epoch offset into a wall-clock instant is ESS’s job (Risk R3). A document needing the unit must state it itself. - A categorical is expanded.
Dictionary(K, V)decodes to oneVper row; the key encoding is a storage detail and never reaches the native array. - Nested and binary columns have no rank-1 reading.
List/Struct/Map/Union/Binarynaming: requesting one invariablesis an error (the document named an array it would not get); unrequested, it is simply not a native field. The NetCDF reader applies the same rule to what is genuinely unreadable there — a compound/opaque/enum/vlen variable. ItscharandNC_STRINGvariables are not in that set: they decode tostringfields (§3, “NetCDF text variables arestringfields in every track”). - A zero-row file is TYPED, not absent. The schema lives in the footer, so every column comes back empty with its declared dtype. This matters: most of a MOVES fixture’s ~770 tables are empty, and a document binding one must still see the array it named.
Null policy
Nearly every Parquet column is nullable in its schema whether or not it holds a null — a table exported from a relational database usually marks every column nullable — so nullability cannot pick the dtype. The policy is about values:
- a null in a float column (a
Decimal, aNullcolumn, or any column forced float byfloat_columnsincluded) becomesNaN, the same fold CF_FillValuegets, andfill_valuestays null; - a null in an integer, string or boolean column is an error naming the column and the row. Those types have no NaN, so any default would be a real value silently standing in for a missing one — the failure mode that surfaces much later as wrong numbers.
Two reader options open that gate, and only when a document declares them:
null_int substitutes an integer sentinel and reports it back in
fill_value (an integer sentinel cannot be NaN, so it survives into the array
exactly as a CF integer fill does); null_string substitutes text, which is
then indistinguishable from a real cell holding the same text — which is why the
document has to choose it. A boolean has no such option; declare the column in
float_columns if a third state is genuinely meant.
Where fill_value is reported is per-track, and normative. The three
NativeField types are not the same shape: Rust’s carries a dedicated
fill_value: Option<f64> field, while the Python and Julia ones carry only
data / dims / attrs. A reader therefore reports the surviving null_int
sentinel:
| track | location |
|---|---|
| Rust | the fill_value field |
| Python | attrs["fill_value"] |
| Julia | attrs["fill_value"] |
These are the same datum in three spellings, not three decisions. A track
whose NativeField grows a real fill_value slot later should move to it and
this table should change with it; what must never happen is one track reporting
the sentinel where another silently drops it, because that difference is
invisible until a document reads a fill-bearing integer column and gets a real
value where a missing one was meant. A cross-language corpus case comparing
these dumps has to normalise the three spellings to one before diffing.
That normalisation is done, in two steps: each provider dumper emits its own
spelling under the single fill_value key of earthsciio/native-dump/v1, and
crosscheck.py
compares that key numerically
(Rust’s slot is an f64, so it dumps -1.0 where the other two dump -1, and
those are one datum) against the oracle and pairwise. The corpus case
moves-rate-table-parquet declares fill_value on every integer column, so a
track that silently dropped the sentinel now fails the gate.
Backend limits that a corpus case must route around
Every track hits the contract above through a third-party decoder, and one of those decoders cannot reach all of it. These are known, permitted divergences — recorded so a cross-language corpus case does not encode a difference it cannot fix, and so nobody “fixes” a track into disagreeing with its own library. A backend defect that produces a wrong number rather than a gap is not on this list: that gets compensated for in the track (see the FLBA decimal below), because a silently wrong value is never a permitted divergence.
- Julia cannot open a file that carries a nested column at all. Parquet2.jl
builds a
Columnfor every schema node when the file is opened, so a file with a list/struct/map column errors on open rather than decoding its flat columns beside it. Rust and Python decode the flat columns and treat the nested one as absent (§3’s rule). A corpus case must therefore not put a nested column in a shared fixture; the rule stays normative and is exercised per-track instead. - Julia recovers a timestamp only to millisecond resolution. Parquet2.jl
decodes every timestamp to a
DateTimebefore the reader sees it, so a MICROS or NANOS column’s raw integer is not recoverable. A shared fixture should keep timestamps millisecond-aligned. - Julia’s decimals arrive as
Dec64. Rust and Python both computef64(unscaled) / 10.0^scale; Julia cannot see the unscaled integer. For|unscaled| < 2^53andscale <= 22the two agree bit-for-bit, because both operands of that division are exact and the quotient is correctly rounded. Outside that range they may differ, bounded byDec64’s 16 digits. - Julia does not decode a
Float16column at all. Parquet2.jl does not surface a FLOAT16 column, so it is not even in the file’s column list there: it is simply not a native field, and naming it invariablesis an “absent from the file” error. Rust and Python both return it as float64. A shared fixture must therefore not carry aFloat16column. - A negative
Decimalin a 7-byteFIXED_LEN_BYTE_ARRAY(pyarrow precision 15–16) cannot be read in the Julia track at all: Parquet2.jl folds the two’s-complement bytes into anInt64unsigned, and the resulting 2^56 overflows aDec64’s 16 digits inside page decode. The reader reports that as an error naming the limit. Narrower widths (precision ≤ 14) are NOT a permitted divergence — they were silently returning the unsigned reinterpretation (-2.50in adecimal128(9, 2)as42949670.46), andEarthSciIOParquet2Extnow repairs the sign exactly, so all three tracks agree there. Widths ≥ 8 bytes were always correct.
The first is a genuine gap in coverage, not merely a formatting difference: the
“nested column is simply not a field” rule is normative but is unenforceable
cross-language until Parquet2.jl can open such a file. The Float16 gap is the
same shape, one column type down.
float_columns, and floats stored as text
float_columns forces the named columns to float64 whatever their on-disk
type — the Parquet twin of the shapefile reader’s numeric_columns — and does
two jobs on purpose, because they are the same statement about the source
(“this column is a float64 measurement”):
- an integer column that is really a measurement, whose missing cells must
become
NaNrather than a sentinel; - a column of fixed-decimal text. A corpus that needs byte-reproducible
floats stores them as decimal strings rather than IEEE doubles — the MOVES
snapshots write
meanBaseRateas"261.000000000000"— and this is how a document says so. The text is trimmed and parsed; an empty or all-whitespace cell isNaN(the FF10/shapefile blank→NaN rule); anything else unparseable is an error naming the column, the row and the text. Without the option such a column staysstring: the reader never guesses that text is really a number.
Projection pushdown
The loader’s variables are pushed into the Parquet reader as a projection, so
only those column chunks are read off disk — not read-then-discarded. These
tables are wide (a MOVES table runs to dozens of columns) and a document
typically wants three. Empty variables reads every column. A requested name
absent from the file is an error listing what is present, never a silently
missing array.
An empty variables list is the same as none at all — every column — and a
track that reads it as “no columns” disagrees with the other two. (select is
separate, and the parquet reader honours none: it declares
supports_selection = false, so the Provider hands it Selection::All
unconditionally and a select aimed at it is an error at the call site.)
Row selection is not a reader concern: esm-spec §8.9 puts codes,
record_filter, select and extent downstream of the decode. Only
reader_options reaches this reader.
NetCDF decode notes (whole-file reader with a decode-time select)
The netcdf reader fetches ONE blob — a select never changes the URL, the
sha256(resolved_url) cache key, or the bytes downloaded (that is what
distinguishes it from the store-backed zarr reader, and why store_backed stays
false). What a selection changes is what gets materialised: NCDatasets /
xarray .isel / the netcdf-reader slice API each read only the intersecting
chunks out of the already-fetched blob. The reader therefore declares
supports_selection = true (registries.md §2.2).
- Vocabulary — the zarr reader’s, unchanged and 0-based:
select = {axes: [<axis>, …]}with each<axis>"all",{indices: [...]}(explicit, possibly non-contiguous, returned in the order given) or{slice: [start, stop, step?]}(half-open,stepdefault 1). One spelling across every reader and every track; a caller that translates a 1-based model-side selection for zarr does not translate differently here. - Axis order and base — the axes are positional over FILE-order dims —
the order
dimsreports,[time, lev, lat, lon]for a GEOS-FP A3dyn variable, which the Julia track reaches by permuting NCDatasets’ reversed arrays back — of every array whose rank equals the axis count (the zarr rule). “Array” means the decoded field, so the rank counted is the one the field reports, never a variable’s on-disk dimension count where the two differ. - A text variable is not exempt, and a string LENGTH is not an axis — the
induced map is applied by name to
char/NC_STRINGfields like any other, so achar label(n)beside afloat value(n)loses exactly the cellsvalueloses, in the same order. But a consumed string-length dimension (the one the character-array convention above folds away) is not an axis of the decoded field:char label(n, strlen)is a rank-1 field, so it answers a 1-axisselectonnand never a 2-axis one, and no selector can bind tostrlen. A selection that names a consumed string length is an error, never silently ignored and never applied — applying it would slice characters, handing back"ef"for"efgh": a wrong string, which is no more permitted than a wrong number. (This falls out for free in Python, where xarray’s decodedds.variablesalready have the dimension consumed; Rust matches ranks over its decoded fields for the same reason.) - Applied by dimension NAME — those axes induce a dimension → selector map,
which is applied to every other array and to the coordinate fields. A
windowed variable beside a full-length
lon/latwould be a silent trap, and the zarr reader never had to answer this because it returns no coords. Two same-rank arrays disagreeing about a dimension is an error, not a coin toss. - Time is the Provider’s axis —
records_per_sampleand the cadence belong to the Provider (§3, “Row selection is not a reader concern”), so aselectwhose time axis is anything but"all"is refused. A dimension is the time axis when a same-named coordinate carries CF"<step> since <ref>"units, or when it is literally namedtime. recordsis how that axis IS narrowed (all three tracks;registries.jsonformat.netcdf.reader_optionsis the machine-readable per-track list) — the refusal above says the reader may not choose records, not that every record must be decoded.records = {dim: "<name>", indices: [<0-based>, …]}is a second, deliberately separate decode option through which the cadence owner states the records it has already chosen, in the file’s own numbering. The reader is told the records, never the cadence: it does nomod1, knows nothing of the cadence grid orrecords_per_sample, and materialises exactly the named records ofdim(and ofdim’s own coordinate) in the order given. Duplicates are legal — the end-of-data bracket[last, last]is one — and an index outside0:len-1, adimthe blob lacks, an empty list, or aselectthat narrows the samedimare all errors rather than a wrapped or widened read.dim_length(reader, path, dim)(registries.md§2.3, answered by all three tracks) is the metadata read that lets an out-of-process caller compute those indices. The list is the wire form. In the Julia trackindicesmay instead be alen -> indicescallable resolved inside the decode’s own open, so a Provider that pushes down on every sample does not pay a second open per sample (a blob with no suchdimdoes not call it and narrows nothing); that is an in-process convenience, and Python and Rust refuse a callable by name rather than accept a spelling that cannot cross a process boundary. The gate is the same as the window’s: a record-selected read must be cell-for-cell identical to the full read sliced afterwards.- Whether a Provider USES it is a per-track performance decision, not a
contract term — the option behaves identically everywhere, and
registries.jsonrecords the choice per track inused_by_provider. The Julia Provider pushes down: it computes the record from the cadence and keeps no decoded-file buffer, so the pushdown is a pure win. The Python and Rust Providers keep a 2-entry LRU of decoded files and amortise ONE whole-file decode over every tick in the file, so pushing down would replace one decode per FILE with one per TICK — measured 25.7x/3.0x worse per tick in Python and 5.3x/2.3x in Rust, on 24-record A1-shaped and 8-record A3dyn-shaped files. A track may not be made slower in the name of symmetry; what parity requires is that the READER honour the option identically, which is what the equivalence gate above tests in each track. - One owner per record axis.
recordsis the cadence owner’s option, so a Provider that owns a record axis (it has atime_dim, and resolves each tick to a file-local record itself) must refuse arecordssupplied by its caller inreader_options/reader_kwargsrather than forward it. A caller-suppliedrecordsnarrows the axis behind the tick→record resolution, so the tick is then located inside the already-narrowed axis: a one-recordrecordsmakes every tickmod1(tick, 1) == 1, so every refresh returns the same record and a 2-record bracket degenerates to[r, r]— a constant field where the model expected to interpolate. That is a silently wrong number, which is never a permitted divergence, so it is an error at provider construction (registries.md§2.1), in every track that lets arecordsbe declared at all. An explicit “off” value (records = nothing/records = None) stays legal as an opt-out that reads the record axis whole and slices afterwards — the two paths must agree cell for cell. - An axis count matching no array is an error, never a silently ignored selection.
- The decode is unchanged by the window — CF
scale_factor/add_offsetin float64,_FillValue→ NaN, the time axis raw withunits/calendar: a windowed read must be cell-for-cell identical to the full read sliced afterwards, which is exactly how theera5-window-slicecase states it.
Four more rules are pinned here because three independent implementations of the
same slicing math is exactly where a silent divergence hides. They belong to the
vocabulary, so they bind the store-backed zarr reader identically:
- Bounds — every resolved index must satisfy
0 <= i < dim_len, for asliceexactly as for anindiceslist. An over-long or negative[start, stop)is an error naming the index and the dimension length: never a silent clamp (which returns fewer cells than the caller asked for) and never a negative wrap-around (numpy’s-2is the second-from-last cell; NCDatasets and the Rustnetcdf-readerslice API refuse it, so a clamp cannot be made to agree across the three tracks anyway). Bounds are checked against the FILE dimension length, before anything is read. - A dimension is never dropped — a single-index axis (
{indices: [i]}or{slice: [i, i+1]}) comes back at length 1, kept indims. Rank is a function of the array, never of the selection, so a track that squeezed would diverge in rank from the other two. - An axis may select NOTHING —
{indices: []}, and any empty half-open range ({slice: [1, 1]}, or a reversed{slice: [2, 0]}) — and that is legal: a zero-length axis, still present indims, giving an array of the same rank with no cells. Not an error, and emphatically not a field whoseshapesays0while itsdatacarries the cells of a bounding slab. - Order and repetition are the caller’s — an
indiceslist is returned in the order given, duplicates included ([2, 0]reverses the axis;[1, 1]repeats a cell). A reader that sorts or deduplicates fails thepermuted-order-tilecase.
Precedence — identical to the zarr reader’s, in all three tracks: a per-call
select (materialize(..., select=…) / materialize_with_select) overrides
a baked reader_options.select / DataSource::select for that call ONLY, and is
a projection peek — the cadence/file buffers keep the baked projection, so the
next plain read returns it again. A select from either source aimed at a reader
that does not answer supports_selection is an error raised before any fetch,
whether it arrived per-call or baked.
Zarr decode notes (store-backed reader)
The zarr reader is store-backed: a Zarr v2 store is not one blob, so the
Provider hands the reader (cache, base_url, variables, select) and the reader
fetches each object it needs — <base_url>/<array>/.zarray, …/.zattrs
(optional), and only the intersecting …/<chunk_key> chunk objects — through the
existing content-addressed cache (each object keyed by sha256(object_url); no
byte-range machinery). Decode contract:
- Compression — blosc (
cnamelz4/lz4hc/zlib/zstd/blosclz), zlib, zstd, gzip, or none. The blosc container is self-describing (codec + shuffle filter + multi-block layout are in its 16-byte header), so a c-blosc-backed library (numcodecs / Blosc.jl / theblosccrate) undoes the shuffle internally. - Chunk unpack — C-order (or F-order per
.zarrayorder). Zarr v2 edge chunks are stored full-size, fill-padded; the padding is sliced off by the selection’s index math (only valid global indices are copied out). - Numeric dtype / endianness — from the
dtypetypestr:<f4/<f8→ float64 (<little-endian,>byteswapped); integer zarr dtypes keep int32/int64. fill_valueis NOT mapped to NaN — a deliberate deviation from the NetCDF_FillValue → NaNrule: in the pinned ISRM storefill_value == 0.0is real data.fill_valuefills only the region of a chunk object that is absent (a cache/transport miss for that chunk).- Dims / coords — dim names from
.zattrs_ARRAY_DIMENSIONS(synthesizeddim_0…if absent); no coordinate arrays are produced (like the CSV reader).variablesis required (the store cannot be enumerated without a consolidated.zmetadata). - Selection (lazy, orthogonal) —
select = {axes: [<axis>, …]}where each<axis>is"all",{indices: [...]}(an explicit, possibly non-contiguous, ordered index list), or{slice: [start, stop, step?]}. Applied to each requested array whose rank matches the axis count (other-rank arrays read whole). For each dim, every requested indexgmaps to chunkg // chunk_len; the chunk keys fetched are the Cartesian product of the per-dim chunk-id sets — so the reader fetches only the chunks the selection intersects, never the whole array (the ISRM linchpin). A store-backed case’sobjects[]gives every object its owncache_key/content_sha256, so checks 1+2 (key agreement + integrity) are asserted per object.
GeoTIFF decode notes (raster reader)
The geotiff reader decodes a single blob into one field per raster band plus
the grid axes. Every track implements it against a different decoder — rasterio
(GDAL) with a pure-Python tifffile fallback in Python, TiffImages.jl in
Julia, the tiff crate in Rust — so the georeferencing arithmetic below is the
whole parity contract. Pinned by the landfire-raster-tile case.
Band naming — bands are keyed
Band1…BandN, 1-based, GDAL’s convention (the name a loader’sfile_variable: "Band1"refers to). Band arrays are float64 regardless of the on-disk sample type.Axis names — from
GTModelTypeGeoKey(1024) in the GeoKeyDirectoryTag: geographic (2) giveslat/lon, projected (1) givesy/x. Absent or unreadable ⇒ treated as geographic. Band dims are(<ydim>, <xdim>). A GDAL-backed reader may instead resolve the declared CRS and ask whether it is geographic. For a WELL-FORMED file the two agree, because the CS code and the model type agree — but a file declaringGTModelType = projectedwhile giving a geographic code (4326) underProjectedCSTypeGeoKeyis self-contradictory, and the two approaches then legitimately differ. Such a file is out of contract; do not use one as a fixture.Cell-CENTRE axes — from
ModelPixelScaleTag(33550)(sx, sy)andModelTiepointTag(33922)(i, j, k, x, y, z), which anchors raster point(i, j)to model point(x, y). Model space is y-up while raster rows run downward, so with a north-up raster:lon[c] = x + (c - i + 0.5) * sx lat[r] = y - (r - j + 0.5) * syThe
+ 0.5is load-bearing: the tiepoint anchors a pixel CORNER and the axes are cell CENTRES.sxandsyare independent — cells need not be square.GDAL_NODATA→ NaN — tag 42113, an ASCII string (conventionally NUL-terminated, e.g."-9999"), parsed to a float; cells equal to it become NaN. A tag that is absent or does not parse means no sentinel, and nothing is remapped — a raster whose real values include -9999 keeps them. A NaN sentinel is ignored (NaN never compares equal). This is the one rule that differs from the zarr reader’sfill_value, which is deliberately NOT mapped.Georef is optional — a plain TIFF with no scale/tiepoint decodes to bands with no coordinate arrays rather than an error; the consumer supplies the grid.
Out of scope, on purpose — multi-band interleaving is not pinned. The three decoders disagree on what a “band” is for multi-sample and multi-page files (page 0 only vs pages-as-bands vs a flat sample count), so the corpus commits a single-band raster and a multi-band contract is deferred rather than invented.
4. The runner (five checks — identical in every language)
The cross-language harness (esio-9nb.9, shipped —
conformance/CROSSLANG.md
) drives each track’s
provider over the corpus offline, dumps its native arrays
(earthsciio/native-dump/v1), and asserts equality across Python / Julia / Rust
(and against the oracle). Each track performs exactly what
verify.py
does:
- cache-key agreement —
sha256(resolved_url) == case.cache_key. - manifest integrity —
sha256(blob) == manifest.sha256_content == case.content_sha256andlen(blob) == manifest.bytes == case.bytes. - format/reader decode — open the blob with
case.format’s reader, applying §3. - native-array equality — decoded arrays/coords equal
case.expected(tolerances below). - offline-only — the run opens no socket; it reads only corpus files.
Tolerances
- Raw / unpacked numeric reads: compared exactly.
- CF-decoded (packed) values, and any unit-affected reads: compared within
atol = 1e-6,rtol = 1e-9(libraries differ at the ULP level). - Strings: exact. NaN/fill masks: must match element-for-element
(
null↔ NaN).
5. Running it
python3 conformance/verify.py # offline; validates schemas + all cases, exit 1 on any failure
python3 conformance/generate.py # deterministically regenerates the corpus (needs numpy + netCDF4 + pyarrow)
./conformance/run_conformance.sh # offline; run all 3 providers + assert cross-language array equality
# narrow the run to named cases (all three dumpers + the comparator honour it):
ESIO_CONFORMANCE_CASES=moves-rate-table-parquet ./conformance/run_conformance.sh
$ESIO_CONFORMANCE_CASES exists for an environment where one track’s backend
for some other format is missing or too old — without it a single unrelated
broken case makes the gate unrunnable and hides real divergences elsewhere. CI
leaves it unset, which is every case.
generate.py is committed for provenance and is byte-deterministic
(NETCDF3_CLASSIC, fixed data, pinned fetched_at) — regenerating does not churn
the committed blobs. The parquet blob is the one exception: its bytes carry
the writing pyarrow’s created_by string and ARROW:schema metadata, so
regenerating it on a different pyarrow rewrites it (committed with 21.0.0). Conformance consumers read the committed blobs, so no
language track needs Python.
6. Adding a fixture
- Add a builder to
generate.pythat returns(blob_bytes, expected, decode)and anemit_case(...)call with the registry triple + a realisticresolved_url. - Run
generate.py(writes blob + manifest + case + updatescases.json). - Run
verify.py— schema validation + the five checks must pass. - Keep blobs tiny (≤ a few KB) and deterministic; commit directly until the
binary-hosting decision (git-lfs vs
/projects) lands for larger real slices.