Streaming output sinks: incremental simulation output to disk (Zarr), checkpoint/restart, and CF coordinates
Add an output boundary that mirrors the pure-I/O data-loader input seam. A cadence-driven Sink streams simulation state to disk incrementally instead of accumulating the whole trajectory in RAM, so runs whose results do not fit in memory become possible. Zarr is the first backend (cloud- and HPC-native, chunk-as-object). The write path lives in EarthSciIO behind a Writer registry symmetric to the read registries; EarthSciAST exposes a build_output_callback + Sink protocol symmetric to build_refresh_callback + the Provider protocol; the solver stays user-owned (library-exposes-rhs-not-solver). Includes checkpoint/restart, an additive CF coordinate-role schema delta that also covers unstructured and curvilinear grids, multi-grid coupled-component output, and cross-language parity (Julia/Python/Rust) gated by a shared conformance corpus.
Status: Draft proposal. Bead: unassigned. Target repos (cross-rig):
- EarthSciAST (ESS) — the
Sinkprotocol +build_output_callbackseam (core + aDiffEqCallbacks/SciMLBaseextension), the flat→gridded output schema derivation, an additivecoordinatesregistry inesm-schema.json, and the Python/Rust runner counterparts.- EarthSciIO (ESIO) — the impure write boundary: a
Writer/format registry symmetric to the readReader/FORMAT_REGISTRY, aZarrWriter, a storeput/multipart-commit path, and a per-store output manifest. Mirrors the existing content-addressed read cache discipline.- Shared spec / conformance — a language-neutral Sink/Writer contract plus a golden corpus asserting Julia/Python/Rust emit array-identical Zarr.
1. Summary
Today every EarthSciAST runner returns the entire simulation trajectory in
memory. In Julia the solver runs to completion and its whole solution is copied
into SimulationResult.u::Vector{Vector{Float64}}
(ext/EarthSciASTSimulateExt.jl); Python’s SimulationResult.y is a dense
(n_vars, n_times) array and Rust’s Solution.state a Vec<Vec<f64>> — both
the full trajectory, both RAM-resident. For a lat×lon×level×species PDE the
product n_timepoints × length(u0) is exactly what exceeds memory on the large
runs this project targets. There is no incremental output path, and no file
writer of any kind (all NetCDF/Zarr/xarray machinery in the tree is input-side).
This RFC adds a streaming output boundary that is the mirror image of the existing pure-I/O data-loader input boundary:
| Input (exists today) | Output (this RFC) |
|---|---|
build_refresh_callback(; providers, buffers) -> (cb, tstops) | build_output_callback(; sinks, schema, snapshot) -> (cb, tstops) |
PresetTimeCallback affect! pulls native arrays from a Provider | PresetTimeCallback affect! snapshots state and pushes to a Sink |
Provider protocol: provider_refresh_times / provider_sample / provider_is_const | Sink protocol: sink_output_times / sink_write! / sink_open! / sink_close! |
| EarthSciIO = impure read boundary: transport/store/format registries, content-addressed cache, atomic commit, offline mode | EarthSciIO grows the impure write boundary: writer/format + store put, staging→atomic-commit, per-store manifest |
[[library-exposes-rhs-not-solver]] — library returns pieces, user attaches to their own solve | Same principle — output is a callback the user attaches; no solver embedded |
The design goals, in priority order:
- Bounded memory — the trajectory never has to be fully resident; the sink
is the trajectory store. The solver is told
save_everystep=false. - Cloud- and HPC-native — one design that works on object storage (S3/GCS) and on parallel filesystems (Lustre/GPFS), driven by Zarr’s chunk-as-object model.
- Checkpoint/restart — the same machinery persists full-state checkpoints for preemption (cloud spot) and walltime (HPC batch) survival, resumable from a store manifest.
- Round-trip — output is written in a format EarthSciIO already reads, so a run’s output is another run’s input with zero new read code.
- Cross-language parity — Julia, Python, and Rust all emit the same Zarr, gated by a shared conformance corpus.
- Additive schema — no breaking change; the one genuine gap (physical
coordinate values/units bound to an axis) is closed with a purely additive
coordinatesregistry that also covers unstructured and curvilinear grids.
2. Motivation and constraints
2.1 The bottleneck
_simulate_solve (Julia) builds an ODEProblem, calls solve, and then
materializes sol.t/sol.u wholesale into SimulationResult. Nothing is
written incrementally, so peak memory is O(n_timepoints × length(u0)). Python
(scipy.solve_ivp + dense resample) and Rust (diffsol + run_solver step
loop) have the same shape. The state itself is a flat vector with a
name → index map and column-major name[i,j] cell keys (_cell_key /
_parse_cell_key), identical across all three languages — which is what lets a
single flat→gridded inversion be shared.
2.2 Deployment constraints that shape the design
HPC (parallel filesystem + batch scheduler).
- Lustre/GPFS punish many-small-files and metadata storms; they reward large, stripe-aligned sequential writes and collective I/O.
- Walltime limits ⇒ output must double as checkpoint/restart (resumable, append-across-jobs).
- Domain-decomposed runs: each rank owns a subdomain and must write its region into a global logical array without a global gather.
- Compute nodes are often air-gapped; node-local NVMe (
/scratch.local, already the read cache root) is the staging tier. The read side already has an offline-mode precedent.
Cloud (object storage + preemptible instances).
- S3/GCS have no append, no random write, no cheap rename (rename = copy+delete). Objects are write-once; large objects use multipart upload (parts ≥5 MB). Many tiny PUTs cost real money (requests + egress).
- Spot/preemptible death is routine ⇒ checkpoints must be frequent, durable, idempotent; restart must be exactly-once.
- Ephemeral local disk ⇒ flush to object store before the instance dies.
The format tension. NetCDF4/HDF5 is the science lingua franca and the
HPC-collective-I/O darling, but hostile to object stores and append. Zarr is
chunk-per-object: disjoint writers own disjoint chunks, needing no lock on
object storage and producing stripe-aligned large writes on Lustre. Zarr is
therefore the one backend that satisfies both targets, and EarthSciIO already
reads it (julia/src/zarr.jl, store-backed, orthogonal select pushdown), so
this RFC adds the write half of a format the stack round-trips through its own
conformance harness. NetCDF/parallel-HDF5 remains a later opt-in backend
behind the same registry seam; it is out of scope for v1.
3. Architecture overview
Three layers, each mirroring an existing read-side layer:
EarthSciAST core build_output_callback(; sinks, schema, snapshot) -> (cb, tstops)
(solver-free) Sink protocol: sink_output_times / sink_open! / sink_write! / ...
│ state_snapshot seam: integrator state -> (slab, global_offset)[]
▼
EarthSciAST ext PresetTimeCallback affect! (needs DiffEqCallbacks + SciMLBase)
(SciMLBase) unions output tstops into the solve; user attaches cb
│
▼
EarthSciIO Writer registry (mirror of Reader/FORMAT_REGISTRY)
(impure write boundary) ZarrWriter: dims/coords/chunks declared once, time-chunks appended
store.put + multipart-commit; staging→atomic rename; per-store manifest
The load-bearing invariant, restated from the read side: the Sink depends only
on the Writer/store interfaces; a new output backend (NetCDF, a new object store)
registers under a new name without touching the Sink or the callback. This is
the same rule that lets EarthSciIO’s S3/Zarr read backends slot in as stub→
active with zero Provider-API change.
4. The Sink protocol (EarthSciAST core)
Declared in a new src/data_output.jl, symmetric to src/data_refresh.jl. The
core owns the contract (generic functions); concrete sinks live in the data
binding (the EarthSciIO extension) or a test mock — exactly as the Provider
protocol’s concrete impl is the EarthSciIO Provider.
# --- cadence (mirror of provider_refresh_times / provider_is_const) ---
sink_output_times(sink) -> AbstractVector{Float64} # solver-seconds tstops; may be empty
# --- lifecycle ---
sink_open!(sink, schema::OutputSchema) # declare dims/coords/vars/chunking ONCE
sink_write!(sink, t::Real, snapshot; selection=nothing) # persist one time record
sink_flush!(sink) # durable barrier (checkpoint boundary)
sink_close!(sink) # finalize; write end-of-run manifest
# --- capability (mirror of provider_supports_selection) ---
sink_supports_partial(sink) -> Bool # can accept a sub-slab write (distributed)
OutputSchema is the flat→gridded description built once at prepare time
(§7): dimension names, per-axis lengths, coordinate variables, chunking, and the
var → (shape, cell-index map) needed to scatter a flat u into gridded chunks.
It is the output analog of a Provider being bound to its native grid.
snapshot is opaque to the sink’s caller but has a fixed shape: a list of
(array_slab, global_offset) pairs (§6). In the non-distributed case it is one
slab at offset 0 (the whole state); under sharding it is the addressable local
shards with their global offsets. selection is the write-side mirror of the
Provider’s read-side projection pushdown: an optional per-axis sub-slab so a
sink can be handed only part of an axis (used for distributed region writes).
build_output_callback — the trigger seam
"""
build_output_callback(; sinks, schema, snapshot, pre_write = () -> nothing)
-> (cb, tstops::Vector{Float64})
"""
- Returns a
PresetTimeCallbackwhoseaffect!callssnapshot(integrator)thensink_write!(sink, integrator.t, snap)for each sink whosesink_output_timescontains the tick;tstopsis the sorted, de-duplicated union of all sinks’ output times — the identical constructionbuild_refresh_callbackuses for provider refresh times, so input refresh and output write compose in oneCallbackSet. - Multiple sinks with different cadences are first-class (§8): each grid /
checkpoint profile is its own sink with its own
sink_output_times. - The method lives in
ext/EarthSciASTDataOutputExt.jl(gated onDiffEqCallbacks+SciMLBase), so the base package never pulls a solver-adjacent stack — identical to theDataRefreshExtpattern. A core fallback throws a helpful error when the extension is not loaded.
Wiring into simulate and killing the RAM trajectory
_simulate_solve (the single choke point where sol.u is collected today)
gains an output path: when one or more sinks are present it passes
save_everystep = false (and minimal save_start/save_end) to solve, so the
solver stops accumulating the dense trajectory — the sink owns persistence. The
returned SimulationResult in that mode carries the run’s metadata + sink
handles (retcode, message, the store URLs and their manifests) instead of an
in-memory u. A saveat-only, no-sink call is byte-identical to today.
5. EarthSciIO: the impure write boundary
EarthSciIO today is the sanctioned impure read boundary. This RFC gives it the symmetric write boundary, reusing its three-registry seam and its atomic-commit/manifest discipline.
5.1 The Writer registry (mirror of the Reader/FORMAT_REGISTRY)
abstract type Writer end # encode gridded arrays -> a format on a store
write_open!(w, store, base_url, schema) # create dims/coords/chunk grid
write_record!(w, handle, t, arrays; region=nothing) # append/emit one time record
write_close!(w, handle) # finalize metadata (.zmetadata, manifest)
WRITER_REGISTRY is keyed by format name exactly like FORMAT_REGISTRY; the
first registration is zarr (active), with netcdf reserved (stub). Adding
a format is one register! line — never a Sink edit.
5.2 Store put + commit discipline
The read cache commits a downloaded blob with staging_path → put_blob!
(atomic rename(2)) + a sidecar Manifest, guarded by an mkpidlock advisory
lock (julia/src/store.jl). The write path reuses this verbatim:
- Local / parallel FS — write each Zarr chunk to
tmp/<uuid>.part, atomic rename intoblobs/…-style chunk paths; the rename is the crash barrier (a reader never sees a partial chunk). A per-store output manifest records committed time-chunks (§9). - Object store — the
s3store (today astub) gainsput_objectvia multipart upload, where theCompleteMultipartUploadcall is the atomic reveal (the object-store analog ofrename). Disjoint chunk writers need no lock (each owns its object key), which is the whole reason Zarr scales on object storage. This is the write half of whatspec/cloud-future.mdalready charted for thes3store.
5.3 Dependency notes
- Julia: Zarr encode +
Blosccompression are already weakdeps on the read side; the writer reuses them. - Python:
xarray+netcdf4are already deps; addzarr. The streaming primitive isDataset.to_zarr(store, region=…, mode="a"). - Rust: no zarr crate today; add
zarrsunder the existingcfg(not(target_arch = "wasm32"))gating — the writer is a native-only feature. (This section used to cites2bindingsas the sibling example; that dependency is now opt-in-only, sozarrswould be the sole occupant of that gate.)
6. The state-snapshot seam (Reactant / sharding)
The one place the output callback touches the solver state. The state u is a
flat host Vector{Float64} in the tree-walk/CPU path, but a
Reactant.ConcreteRArray (possibly sharded across devices/hosts) under the
Reactant execution tier. The snapshot seam abstracts this:
state_snapshot(integrator) -> Vector{Tuple{Array,NTuple{N,UnitRange}}} # (slab, global range)
- CPU / non-distributed (v1): one implementation,
Array(u)at offset covering the whole state. This is the mirror of what the input side already does when it materializes host forcing buffers (_write_forcing!) and, under Reactant, gathers/mirrors them withsync_forcing!. Correct at any scale that fits one host’s write bandwidth. - Reactant single-host, replicated/gathered:
Array(u)triggers XLA’s gather to host; identical downstream. Fine at moderate device counts. - Reactant multi-host, shard-local (later): Reactant does support
multi-host sharding (
Reactant.Sharding—Mesh/NamedSharding/Replicated, IFRT + PJRT;Reactant.Distributed—local_rank/num_processes/is_initialized). The primitives to write a shard without a global gather exist internally —sharding_to_array_slices/device_to_array_slicesgive each device’s global slice offsets, and per-device data lives in the sharded array’sdatabuffers — but there is no clean, documented public API yet to hand back “my local shard + its global offset.” So this is deliberately a later drop-in: when Reactant exposes addressable shards,state_snapshotreturns the local shards with their global ranges, the sink writes each rank’s Zarr region (sink_supports_partial), and the gather disappears — with no change to the callback or the Sink protocol. This is the same “ship behind a stable seam, flip stub→active” pattern the read side used for S3/Zarr.
Design consequence: do not block v1 on shard-local output. Build the seam now; ship the host-gather implementation; add the shard-local implementation when the Reactant API lands.
7. Zarr layout and the flat→gridded inversion
OutputSchema is derived once at prepare time from information already in the
document + build artifacts:
- Which variables, and their axes — each output variable’s
shapeis an ordered list ofindex_setsnames; the writer clusters variables by their shape signature into grids (§8). Variable name, dims, and dtype come straight from the model. Output variables are state elements plus a caller-named subset of observed/derived fields: the named observed fields are RHS expressions the evaluator already produces, evaluated once at each output tick (via the callback’spre_writehook) and scattered into their own gridded arrays alongside the state. - Per-axis length —
IndexSet.size(interval) orlength(members)(categorical);derived/raggedlengths are known after build. - Flat→gridded scatter — invert
_parse_cell_key(column-major) to map each flat index to its(i,j,…)cell, so a flatuslab scatters into the right gridded chunk. This inversion is identical in Julia/Python/Rust (same cell-key scheme, same column-major order), so it is specified once in the shared spec. - Chunking — default: whole spatial extent × a tunable number of time steps per chunk (time-chunked), which matches the write cadence and the common “read a map at a time” analysis pattern; overridable per variable. Each time-chunk flush is one object (cloud) or one stripe-aligned file (HPC).
- The
timeaxis — the document’s singleDomain.independent_variable; a Zarr unlimited-style dimension extended one time-chunk per flush.
The output is therefore a valid, correctly-shaped, correctly-named,
dimension-labeled Zarr with integer indices and (for categorical axes) member
labels with no schema change. What is not recoverable without a schema
addition is the physical coordinate values on interval grid axes — §9.
8. Coordinates: the CF coordinate-role model (the one schema delta)
8.1 The gap
Since v0.8.0 the schema is deliberately index-space, not coordinate-space:
Domain.spatial/SpatialDimension/CoordinateTransform were removed; grid
coordinates are “ordinary data” (a loaded field or a declared variable/
parameter) with no declared link from an axis to its coordinate array. From
the document you can recover axis names, lengths, and categorical
member labels — but for an interval grid axis the physical coordinate
values (lat/lon degrees, level in Pa) and per-axis units/standard_name
are not tied to the axis anywhere. Bare Zarr loads in xarray with axes shown as
0..N instead of real lon/lat.
8.2 Why a singular per-axis coordinate field is wrong
A scalar IndexSet.coordinate encodes the rectilinear assumption — one axis,
one monotonic vector (a CF dimension coordinate). It breaks on:
- Unstructured (MPAS/ICON/FVCOM): state over one
cellsdimension located by two coordinateslat(cell),lon(cell), both 1-D over the same dimension, neither monotonic — CF auxiliary coordinate variables. One axis → many coordinates. - Curvilinear (rotated-pole, tripolar ocean):
lat(y,x),lon(y,x)are 2-D — a single coordinate spans two axes, so attaching it to oneIndexSetis ambiguous.
8.3 The fix — a coordinate-centric, additive coordinates registry
Lean into what v0.8.0 already decided (coordinates are ordinary data with a
shape). Add a document-scoped, optional coordinates registry. Each entry marks
an existing data array (variable / parameter / loader field, referenced by
name exactly as ragged index sets reference their offsets/values) as a
coordinate and attaches CF metadata. The coordinate’s shape is read from its
source, so it needs no per-axis attachment:
"coordinates": {
"atm_lat": { "source": "atm_lat_deg", "standard_name": "latitude", "units": "degrees_north", "axis": "Y" },
"atm_lon": { "source": "atm_lon_deg", "standard_name": "longitude", "units": "degrees_east", "axis": "X" },
"cell_lat": { "source": "mesh_lat", "standard_name": "latitude", "units": "degrees_north" }, // aux (no axis)
"cell_lon": { "source": "mesh_lon", "standard_name": "longitude", "units": "degrees_east" } // aux
}
source may also be inline values (a literal 1-D vector) for the simple
rectilinear case, mirroring what FunctionTableAxis already does with
values+units. One rule drives all topologies:
| grid | coordinate source shapes | CF emission |
|---|---|---|
| rectilinear | atm_lat[atm_lat_idx], atm_lon[atm_lon_idx] (monotonic, axis set) | dimension coordinates |
| unstructured | cell_lat[cells], cell_lon[cells] | auxiliary coords + coordinates="cell_lat cell_lon" on data vars |
| curvilinear | lat[y,x], lon[y,x] | auxiliary coords + coordinates="lat lon" |
The writer derives a data variable’s coordinates attribute mechanically:
every coordinate whose source shape is a subset of the data variable’s shape
applies to it. No per-axis assumption anywhere; this is the CF coordinate
data model (dimension vs auxiliary coordinates + the coordinates attribute).
8.4 Mesh topology / UGRID — already expressible
The non-coordinate parts of unstructured grids are constructs the schema already has:
- Connectivity (edges-of-cell, face-node, CSR
edgesOnCell/nEdgesOnCell) is exactly what theragged/derivedIndexSetkinds model, referencing their factor arrays by name. - Mesh location (node/edge/face) is already
ModelVariable.location("cell_center","edge_normal","vertex") — the UGRIDlocationattribute.
So UGRID-compliant output is a mapping job (emit a mesh_topology variable,
tag data vars with mesh/location, point at the ragged connectivity already
declared) — not a new schema primitive. Default the writer to plain-CF
auxiliary coordinates (universally readable by xarray and by EarthSciIO’s own
reader); make UGRID topology an opt-in emission, since the stated consumers
(EarthSciAST + xarray) both consume auxiliary coordinates without any UGRID
support.
8.5 Migration / bridge
The coordinates registry is purely additive and optional — documents
without it validate and emit exactly as today (bare integer axes), so existing
fixtures are unaffected. Decision (§14): it lands in esm-schema.json in
Phase 1, not behind a convention-first bridge, since it is small and mirrors
the existing FunctionTableAxis (values+units) pattern. A runner-level
config map (index_set_name → coordinate field) remains available as a private
escape hatch for a model whose coordinates are supplied entirely at the API, but
it is not the migration path.
9. Multi-grid / coupled components
Multiple grids in one run (atmosphere + ocean, e.g. the
wildfire_atmosphere_ocean.esm fixture) are supported by construction, not
by a new concept:
index_setsis a document-scoped registry; each variable independently names the axes it lives on. Atmosphere vars over[atm_lev, atm_lat, atm_lon]and ocean vars over[ocn_depth, ocn_lat, ocn_lon]already coexist. Zarr, CF, and xarray all represent heterogeneous-dimension variables in one store natively (multi-grid single files are routine CF — e.g. Arakawa C-gridu/v/ tracer output).- Grids are emergent. The writer partitions variables into grids by their shape signature (the set of spatial index sets). “Two grids” is inferred, not declared.
- Names can’t collide. The flat registry has unique keys, so
atm_latandocn_latare distinct dimensions; shared axes dedup (both components referencing one vertical set share one dimension + coordinate) and distinct axes stay distinct — both by construction. - Mixed coordinate kinds in one file are fine: a rectilinear atmosphere, an
unstructured ocean, and a categorical
speciesaxis coexist because each coordinate declares its own shape and role independently (§8.3).
Recommended decomposition: one Sink per grid. Coupled components differ
in exactly the things a sink controls — cadence (the ocean typically steps and
outputs slower than the atmosphere), chunk shape, and checkpoint frequency.
Per-grid sinks give each of these independently, and their sink_output_times
union into the solver tstops the same way multiple input providers'
refresh_times do.
Layout policy (a config axis, not a feasibility question):
- Default: per-grid Zarr groups in one store (
/atmosphere,/ocean), read back as an xarrayDataTree. One logical output artifact. - Separable: distinct stores per component when lifecycles diverge enough to manage independently (different retention, different checkpoint cadence).
Time across grids: all components share the one Domain time. Same output
cadence ⇒ a shared time coordinate; different cadences ⇒ per-group time
coordinates (atm_time, ocn_time) — another reason per-grid groups/sinks are
the natural unit. Coupling/regrid stays internal: each sink writes its
variable on its native grid; the atmosphere↔ocean regrid is an ordinary
in-model coupling expression the RHS evaluates, never the writer’s concern.
10. Checkpoint / restart
Modeled as a second profile of the same Sink, not a separate subsystem:
| profile | contents | precision / compression | cadence |
|---|---|---|---|
| diagnostic output | state + caller-named observed fields, subsettable (reuse selection), CF-metadata-rich, may downsample in time | Blosc zstd + shuffle | science-driven interval |
| checkpoint | full state (every element of u) | full precision, lossless | fixed interval + walltime/preemption guard |
Restart mechanics mirror the read cache exactly:
- Each sink keeps an output manifest (mirror of
julia/src/manifest.jl) recording committed time-chunks + the last durablet. - Resume = read the last committed checkpoint chunk from the manifest,
reseed
u0at thatt, continue integrating. Because commit is atomic (rename / multipart-complete), a restart never sees a half-written chunk; writes past the last committedtare simply re-done — idempotent, exactly-once.
Julia checkpoint/restart is callback-driven (no integrator step-loop runner),
staying on the same [[library-exposes-rhs-not-solver]] seam as everything else.
The checkpoint trigger is a freely-composable set — any combination of a
fixed interval and/or one or more predicates — that lowers to callbacks in the
same CallbackSet as the diagnostic sink and the input-refresh callback:
- an optional interval → a
PresetTimeCallbackat the user-specified checkpoint times. Usable entirely on its own (interval-only checkpointing is a valid, fully-supported configuration) and portable to the Python/Rust runners. - zero or more predicates → a
DiscreteCallbackwhoseconditionis the OR of the predicates, checkpointing just-in-time when any fires. Itsaffect!writes the full-state checkpoint and callssink_flush!(the durable barrier), then optionallyterminate!(integrator)for a clean pre-preemption exit.
Interval and predicates compose freely: interval-only, predicate-only, or
both together; and multiple predicates combine (e.g. SLURM walltime and spot
notice and a custom condition). A predicate is should_checkpoint() -> Bool
(equivalently a deadline), and EarthSciAST ships two built-in predicate
constructors the caller can opt into: a SLURM remaining-walltime predicate
(queries the job’s time limit / SLURM_JOB_END_TIME, fires within a configurable
margin) and a cloud spot-preemption predicate (polls the instance metadata
termination notice, e.g. AWS/GCP). Predicates OR-compose through a small
any_of(preds...) helper; a user on PBS, Kubernetes, or a bespoke scheduler
passes their own with no library change. The Rust runner already owns an explicit
run_solver() step loop and Python’s provider path a cadence-segmenting loop, so
each language expresses the same composable triggers idiomatically; the
restart side is uniform — read the last committed checkpoint from the
manifest and reseed a fresh run at that t.
11. Cross-language parity and conformance
All three packages are full ODE runners (Julia tree-walk/MTK; Python
solve_ivp; Rust diffsol) with the identical state model (flat vector,
column-major name[i,j] keys, name→index map) and each already has a mature
input Provider seam to mirror (Julia provider_* generics; Python
data_loaders/provider.py Provider protocol; Rust src/provider.rs
CadenceProvider trait). So the output seam is symmetric across all three.
To prevent drift, specify the output contract in the shared spec layer the read Provider used, not as three independent ports:
- a language-neutral Sink/Writer contract (the protocol of §4–5, the flat→gridded inversion of §7, the coordinate rules of §8);
- a golden conformance corpus: run a small model in each language, assert the emitted Zarr is array-identical across Julia/Python/Rust (byte-identical where compression is deterministic), extending the existing cross-language harness.
Injection points already identified: Julia build_output_callback; Python’s
per-pathway result assembly / the _simulate_with_discrete_providers segment
loop; Rust run_solver() (simulate.rs) and the provider-segmented driver.
12. Schema changes (summary)
One additive change to esm-schema.json: a document-scoped, optional
coordinates registry (§8.3). Each entry references an existing data array by
name (or inline values) and carries standard_name / units / optional
axis. Documents without it validate and emit exactly as today. No field is
removed or repurposed; conformance for existing fixtures is unaffected.
Everything else — dimensionality, axis lengths, axis names, categorical member labels, multi-grid coexistence, the flat→gridded inversion — is derivable from the existing document + build artifacts and needs no schema change.
13. Sequencing
- Spec + conformance scaffold. Write the language-neutral Sink/Writer
contract and the flat→gridded inversion into the shared spec; add the
additive
coordinatesregistry toesm-schema.json; stand up an (initially empty) output conformance corpus. - Julia core seam.
src/data_output.jl(Sink protocol +OutputSchema+state_snapshotseam, host-gather impl) andext/EarthSciASTDataOutputExt.jl(build_output_callback+PresetTimeCallback); wiresave_everystep=falseinto_simulate_solve. - EarthSciIO
ZarrWriter. Writer registry +write_open!/record!/close!; local-store staging→atomic-commit + output manifest; Blosc reuse. Round-trip test: write withZarrWriter, read back with the existingZarrReader. - Checkpoint profile + restart from manifest, via callbacks: the
fixed-interval
PresetTimeCallback+ theDiscreteCallbackwalltime/ preemption guard (with a pluggabledeadline/should_checkpointseam). - Coordinates + multi-grid. Consume the
coordinatesregistry (CF dimension/auxiliary emission); per-grid sink clustering; Zarr groups; opt-in UGRID topology. - Object store.
s3storeputvia multipart-commit (flip the read-sides3storestub→activeon the write path); lock-free disjoint-chunk writes. - Python then Rust writers against the conformance corpus (
zarrdep for Python;zarrscrate under wasm32 gating for Rust). - Shard-local distributed output.
state_snapshotshard-local impl +sink_supports_partialregion writes, when Reactant exposes an addressable-shard API. No callback/protocol change required.
14. Resolved decisions
Coordinates schema — land now. The additive
coordinatesregistry (§8.3) goes intoesm-schema.jsonin Phase 1 (it is small and matches the existingFunctionTableAxisvalues+unitspattern), so output is self-describing (real lon/lat in xarray) from the start. No convention-first bridge.Checkpoint trigger — a freely-composable set of triggers, all via callbacks. Any combination of a fixed interval (
PresetTimeCallback) and/or one or more predicates (DiscreteCallback, OR-combined). Each is usable alone — interval-only and predicate-only are both fully supported — and they compose together, including multiple predicates at once (§10). No integrator-loop runner.Coupled-run layout — per-grid groups in one store (
/atmosphere,/ocean), read back as an xarrayDataTree(§9). Store-per-component remains available for divergent lifecycles but is not the default.Output content — raw state + observed. Sinks write state elements and RHS-computed observed/derived fields (the evaluator already produces them, e.g. PM2.5 from species states), evaluated at each output tick (§4, §7).
Compression — profile-dependent. Diagnostic profile: Blosc zstd with byte-shuffle at a moderate level. Checkpoint profile: lossless only, for bit-reproducible restart.
NetCDF — out of scope for v1. Zarr only; NetCDF is reserved behind the Writer registry as a
stubfor a later phase.Walltime/preemption signal — caller predicate + built-ins. The predicate trigger takes a caller-provided
should_checkpoint()as its primary seam (§10), OR-composed viaany_of(...), with two built-in predicate constructors shipped for the common cases: a SLURM remaining-walltime predicate and a cloud spot-preemption notice predicate. PBS/K8s/bespoke schedulers pass their own predicate — no library change.Observed-field output — caller-named subset. Sinks write the state plus a caller-named subset of observed/derived fields (not all observed by default). The subset is named in the sink config; the evaluator computes just those at each output tick.
All RFC decisions are now resolved; open items belong to implementation phases.
15. Relationship to existing work
pure-io-data-loaders.md— this RFC is its output-side mirror: the loader is the impure read boundary; the sink is the impure write boundary. Both keep transforms (regrid/reproject) out of the I/O layer — the sink writes native grids, never regridded ones.semiring-faq-unified-ir.md— coordinates/connectivity as ordinary data +aggregateFAQs is what makes the coordinate-role model (§8) and multi-grid (§9) fall out for free.- EarthSciIO read spec (
registries.md,cache-format.md,offline-mode.md,cloud-future.md) — the write boundary reuses the registry seam, the staging→atomic-commit + manifest discipline, and the charteds3store; §5 and §10 are the write-side counterparts.
16. Frozen implementation contract (v1)
This section is normative for implementation and supersedes any looser wording in §4–§14. It resolves the ambiguities found in design review and records the decisions made for the v1 build (host-gather; Zarr v3; Julia + Python + Rust; s3 write via multipart; Reactant shard-local output deferred).
16.1 Format: Zarr v3, sharded
- All writers emit Zarr v3 (
zarr_format: 3). Thetimeaxis grows by resizing the array; one shard per flush packs many logical chunks into one object (the sharding codec), giving few large objects on S3/Lustre while keeping small logical chunks for readers. This is the small-object mitigation the cloud target needs. - EarthSciIO’s
ZarrReaderis upgraded to read v3 (currently v2-only). The read path must round-trip everything the writer emits; a v3 read fixture lands with the writer. v2 read support is retained. - Compression: diagnostic profile = Blosc zstd + byte-shuffle, moderate level; checkpoint profile = lossless (zstd, no lossy step). Codec parameters are pinned in the shared spec so decoded output is reproducible; see 16.6.
16.2 Per-sink OutputSchema (resolves review #3)
There is no single shared schema. Each Sink carries/derives its own
OutputSchema (its variable list, dims, coordinates, chunk/shard shape, codec
profile). build_output_callback does not take a schema argument. Rationale:
the two profiles in §10 (diagnostic = state + observed subset; checkpoint = full
state, lossless) have different variables, chunking, and compression and cannot
share one schema. sink_open!(sink) opens the sink against its own schema.
16.3 Snapshot carries observed fields (resolves review #2)
The snapshot is not state-only. pre_write computes the caller-named observed
fields from integrator state and the callback assembles a StateSnapshot:
struct StateSnapshot
t::Float64
state::Vector{Tuple{Array,NTuple{N,UnitRange} where N}} # (slab, global range); one entry, offset-0 whole state in v1
observed::Dict{String,Array} # caller-named observed/derived arrays, host-materialized
end
sink_write!(sink, snap::StateSnapshot; selection=nothing) receives both, so a
sink scatters state elements and observed fields into their gridded arrays. In v1
state is exactly one (Array(u), (1:length(u),)) pair (host-gather). observed
is empty unless the sink’s schema names observed outputs; those names drive what
pre_write evaluates (the evaluator already produces them).
16.4 Sink protocol (final Julia signatures)
Declared in src/data_output.jl (core owns the generics + throwing fallbacks,
exactly like data_refresh.jl); concrete sinks live in the EarthSciIO extension
or a test mock.
sink_output_times(sink) -> AbstractVector{Float64} # tstops; may be empty (predicate-only)
sink_open!(sink) # open against the sink's OWN OutputSchema
sink_write!(sink, snap::StateSnapshot; selection=nothing)
sink_flush!(sink) # durable barrier (checkpoint boundary)
sink_close!(sink) # finalize + end-of-run manifest
sink_supports_partial(sink) -> Bool # default false; INERT in v1 (host-gather)
sink_observed_names(sink) -> Vector{String} # names pre_write must evaluate; may be empty
build_output_callback(; sinks, snapshot, pre_write = () -> nothing)
-> (cb, tstops::Vector{Float64})
- Lives in
ext/EarthSciASTDataOutputExt.jl(gated onDiffEqCallbacks+SciMLBase); core fallback throws anOutputErrornaming what to load — the exactbuild_refresh_callbackpattern. affect!at each tick: runpre_write()(fills observed caches) → build theStateSnapshotviasnapshot(integrator)+ the observed caches → for each sink whosesink_output_timescontains the tick,sink_write!(sink, snap).tstops= sorted, de-duplicated union of all sinks’sink_output_times, so output composes with input-refresh in oneCallbackSet(union the two tstops).state_snapshot(integrator)v1 impl =[(Array(u), (1:length(u),))]. Thesink_supports_partial/selection/multi-slab machinery is built but inert; the Reactant shard-local impl is the deferred phase 8 drop-in (no protocol change).
16.5 simulate wiring
simulate(prep, tspan; …, sinks = Sink[], pre_write = nothing). When sinks is
non-empty: build the output callback, union its tstops with the refresh
tstops, compose a CallbackSet(refresh_cb, output_cb), and pass
save_everystep = false (+ minimal save_start/save_end) to _simulate_solve.
The returned SimulationResult gains a mode carrying metadata + sink handles
(retcode, message, store URLs, manifests) instead of an in-memory u. A
saveat-only, no-sink call is byte-identical to today (new kwargs default to the
old path).
16.6 Conformance bar: tolerance, not bytes (resolves review #1, #5)
Cross-language and round-trip conformance assert decoded agreement, never compressed-byte identity:
- Primary: decoded arrays (state, coordinates, observed) agree within a
numeric tolerance (
rtol/atoldeclared per corpus entry), and the structural metadata (dims, shapes,standard_name/units/axis, chunk/shard grid) is equal. - Byte-identity is NOT required across languages (Julia Blosc.jl / Python numcodecs / Rust zarrs are independent codec builds). It may be asserted only as an intra-language reproducibility property under pinned codec params.
16.7 Restart semantics: tolerance-consistent continuation (resolves review #1)
Checkpoint stores the full state u at t losslessly; it does not store
the integrator’s internal cache (step size, error-controller history, multistep
Nordsieck/order). Restart reseeds a fresh integrator from u(t) and continues.
Therefore:
- Restart is a valid continuation, consistent to solver tolerance — not bit-identical to an uninterrupted run (a multistep method re-runs its startup transient). The RFC’s earlier “bit-reproducible restart” wording is retracted in favor of “tolerance-consistent.”
- Commit is atomic (rename / multipart-complete), so restart never sees a
half-written shard; re-doing writes past the last committed
tis idempotent for the fixed-schedule diagnostic output. Predicate-driven checkpoints fire at run-dependent times, so their exact re-emission is not guaranteed — the manifest’s last-committedtis the source of truth for where to resume. - The output manifest (mirror of
manifest.jl, schemaearthsciio/output-manifest/v1) records: committed time-shards (index +trange), last durablet, the writer format + codec params, the schema fingerprint (variable order, dims, dtype), and the store base URL. Restart reads the last committed checkpoint shard, reseedsu0, and continues.
16.8 Concurrent-writer metadata ownership (resolves review #7, #8)
Disjoint data-shard writers need no lock (each owns its object key). The
shared metadata is coordinated: a designated rank-0 / owner writer owns
(a) extending the time dimension (array resize / zarr.json shape) and (b)
writing consolidated metadata at sink_close!. On the local/parallel FS the
existing mkpidlock + staging→rename + output manifest cover this; on S3 the
owner serializes the shape/zarr.json updates. v1 ships host-gather (a single
writer), so this is a seam exercised by the object-store phase, not a v1 hot path.
16.9 Multi-repo / dev workflow
The write boundary lands in EarthSciIO (sibling checkout, branch
feat/streaming-output-sinks); EarthSciAST’s [sources] EarthSciIO entry is
temporarily repointed at the local path for dev/test, and reverted to the
GitHub URL before merge (an upstream EarthSciIO PR merges first). The Writer
registry, ZarrWriter (v3), store put/multipart, and output manifest are
EarthSciIO; the Sink/build_output_callback/OutputSchema and the concrete
EarthSciIO-bound sink (in EarthSciASTEarthSciIOExt) are EarthSciAST.
16.10 Framing corrections
Two stale claims in §5–§6 are corrected: EarthSciIO’s Zarr read path is
already active/landed (not a stub to flip), and the s3 transport is already
active — only the s3 store is a stub. This RFC adds the write half of Zarr
and activates the s3 store’s put; it does not “flip the zarr read stub.”
16.11 Implementation status (Julia)
The Julia reference path is implemented and verified on branch
feat/streaming-output-sinks (EarthSciAST + the sibling EarthSciIO checkout, host-gather):
- Sink seam +
build_output_callback(§4, §16.4–16.5):PresetTimeCallbackcadence,save_everystep=false, tstops unioned with input-refresh. ZarrWriterv3 + reader upgrade + output manifest (§5, §16.1): sharded Zarr v3, Blosc zstd, staging→atomic-commit,earthsciio/output-manifest/v1.- Flat→gridded scatter (§7): column-major inversion of the
name[i,j]keys. - Real dim names + CF coordinates (§7–§8):
derive_output_meta+plan_dimension_coordinatesemit real index-set axis names and CF dimension coordinates (values +standard_name/units/axis) from the additivecoordinatesregistry (threaded verbatim throughEsmFile); per-variable CF attrs. - Checkpoint / restart (§10, §16.7):
:checkpointlossless profile; predicate builtins (any_of, SLURM walltime, spot preemption) → aDiscreteCallback(build_checkpoint_callback) that writes +sink_flush!es + optionallyterminate!s; manifest-drivenzarr_restart_statereconstructs a flatu0. - Multi-grid (§9):
group_gridding_by_gridpartitions variables by spatial-dim signature; one Sink per grid to its own store (the “separable” layout).
Remaining: per-grid Zarr groups in one store (the §9 default layout) — needs
EarthSciIO group-path support; live s3 bucket I/O (the object-store put path is
wired but only exercised offline — see §16.12); and the deferred Reactant shard-local
output (§6, phase 8). Cross-language conformance is tolerance-based decoded-array
agreement (§16.6).
16.12 Implementation status (Python / Rust / conformance)
The Python and Rust runner counterparts are implemented on library-backed backends
(no hand-rolled codecs), on EarthSciIO branches merged into feat/streaming-conformance:
- Python (
earthsciio/backends/zarr_write.py,zarr.py,s3.py): reader and new v3 sharded writer rebuilt on zarr-python 3.x (native sharding +BloscCodec), S3 via s3fs/fsspec. Reads both the legacy v2 corpus and v3 sharded stores. Requires Python ≥3.11 (thezarr/s3extras); the lean core still installs on 3.9. Existing read conformance stays exact (verify.py5/5); a write→read round-trip pytest passes. - Rust (
rust/src/format/zarr.rs,zarr_write.rs, object-store path): reader and v3 sharded writer rebuilt onzarrswithobject_storefor local/S3/HTTP. This gives up the crate’s former C-free build (codec crateszstd-sys/blosc-srcnow build viacc/gcc alone — no clang/bindgen);#![forbid(unsafe_code)]on the crate’s own code is preserved. Corpus read tests + a write↔read round-trip pass. - Cross-language write conformance (§11, §16.6):
conformance/run_write_conformance.shdrives all three writers from a shared spec (write_spec.json), cross-reads every store with every reader, and asserts decoded-array agreement within tolerance (atol 1e-9, rtol 1e-6) plus structural/CF-metadata agreement. All three tracks executed: 15/15 pairwise decoded arrays agree. The compressed shard bytes differ across languages (three distinct sha256s for the same shard) while decoding identically — empirically confirming the §16.6 tolerance-not-bytes policy is load-bearing.
Live S3-bucket I/O is verified only offline (fsspec-memory / object_store LocalFileSystem;
no credentials in the test environment). The s3 cache store remains a spec-pinned
stub pending a coordinated cross-language activation.
Rust output DERIVATION (pkg/earthsci-ast-rs/src/data_output.rs) — the piece that feeds
the Rust writer, and the mirror of data_output.jl’s §7–§9 surface. Ported: the flat→gridded
column-major cell-key inversion (derive_output_gridding / scatter / gather, the restart
direction included), derive_output_meta (real index_sets axis names, per-axis lengths,
retained CF variable attributes, the coordinates registry, the record-axis name),
plan_dimension_coordinates (§8 inline-values dimension coordinates), and
group_gridding_by_grid (§9). On top of those Rust adds derive_output_plan, which assembles
an OutputPlan — per grid: ordered dims, CF coordinates, and per-variable dims/attrs/scatter —
implementing RFC decision 8 (output is the state plus a caller-named subset of observed
fields). The plan converts mechanically into EarthSciIO’s OutputSchema, verified end to end
by a write_zarr_v3 → Provider round trip under the wasm profile (--features esio). The
module is wasm32-unknown-unknown-clean (no filesystem, no threads, no wall clock). The Sink
protocol, build_output_callback, the checkpoint predicates and zarr_restart_state are
not ported — they are the solver-integration layer, not the derivation.
Rust output EMISSION (esm simulate --observed <NAME> --format grid). derive_output_plan
had no production caller: esm simulate --output wrote the flat state rows keyed by bare cell
key, so a gridded field came out with no axis names, no coordinate values, and no way to say
which cell a number belonged to. The CLI now runs the plan and renders it — per grid, its
dimensions, its CF dimension coordinates, and row-major [time, …spatial] variable arrays
carrying the document’s units / description. The rendering is field-for-field
earthsciio::format::OutputSchema, so pointing it at a real Zarr sink is a swap of the
serializer rather than of the derivation; it is JSON here because EarthSciIO sits behind the
opt-in esio feature, whose transitive zarrs raises the effective MSRV above the crate’s
declared 1.89, and esm ships on the default features.
Decision 8 needed the other half too. A plan can only grid a variable the runner left a SLOT
for, and the array runtime exposed scalar observeds only — an array-valued one (a clip
ring, a gridded emissions field) was skipped, so the only possible answer to a caller asking
for it was OutputError::UnknownObserved, exactly as that error’s own text predicts. The new
SolveOptions::output_observed is decision 8’s caller-named subset at the runner: empty by
default (an array observed is n_cells rows, not one, so materializing every one unasked would
multiply a trajectory’s memory by the grid size), and a named field is appended in the same
1-based column-major cell-key spelling the state uses — so derive_output_gridding inverts a
requested observed and the state it came from identically, and the two land on one emergent
grid. Both runners honour it: the array runtime puts the field in its observed-trajectory
dependency cones and flattens it per cell; the scalar runtime walks its observed graph.
The Rust EsmFile also gained the coordinates field itself: the registry is in
esm-schema.json and in the Julia EsmFile, but the Rust binding never modeled it, so a
parse → emit round trip silently dropped the whole registry (the same class of defect as
the IndexSet.member_factor omission).
The derivation had no cross-language gate — and four divergences to show for it
The two derivations disagreed in four places, silently, and nothing in the test estate could
have caught any of them. The gate one reaches for is EarthSciIO’s
conformance/write_spec.json, but that is an already-derived schema — its keys are dims,
time_dim, coords, vars, chunk_shape, shard_shape — so the write corpus starts
downstream of derivation and drives the three writers from a hand-authored shape. No level
of coverage there reaches the derivation. Outside each language’s own private tests,
derive_output_meta / derive_output_plan / OutputMeta / group_gridding_by_grid had no
callers in any shared harness at all.
The missing tier now exists: tests/conformance/output_derivation/ — .esm fixtures plus
the flat slot names, each binding deriving a plan, both asserting one committed golden per case
(pkg/EarthSciAST.jl/test/output_derivation_conformance_test.jl,
pkg/earthsci-ast-rs/tests/output_derivation_conformance.rs, both driven by
run_output_derivation_conformance.sh). It composes with the write corpus rather than
duplicating it, because a derivation corpus’s output type is the write corpus’s input type:
.esm ──[derivation, tests/conformance/output_derivation]──▶ OutputSchema ──[writers, EarthSciIO]──▶ Zarr
Three fixtures, chosen for where the bugs actually live: a purely 0-D model (several scalars,
one of them observed), a gridded model with CF coordinates on both axes, and a mixed
model carrying scalars and two different gridded signatures in one document — the case a
shape-signature grouping bug shows up in, since it must yield exactly three grids and would
yield five under the old scalar handling. The corpus keeps the slot names as an input rather
than running a build: the cell-key scheme is itself specified by §7 and is byte-identical
across bindings, so making it an input keeps the tier on the derivation seam instead of
dragging a compiler and a solver into a metadata test.
The four reconciliations. Every one resolved in Rust’s favour; data_output.jl and its
ZarrSink now match. Two of them change normative on-disk behaviour and are called out as
such.
- A scalar is 0-spatial-dimensional — NORMATIVE CHANGE. Julia gave a scalar the singleton
shape
[1]and a synthetic<base>_d0axis, so every scalar became its own single-member grid: a 0-D model with 66 scalar states emitted 66 length-1 dimensions named after their own variables and 66 separate grids, and under this RFC’s one-Sink-per-grid decomposition, 66 stores. A scalar now hasshape == [],dim_names == [], and on-disk dims exactly[time_dim]; all scalars share one grid. Evidence the[1]was not load-bearing: theZarrSink→write_record!→ Zarr v3 path already writes rank-1[time_dim]arrays — the time coordinate is one — and a 0-D model driven end to end through the real sink now writes three[time]arrays into one store and reads them back throughZarrReaderunchanged. Zarr v3 imposes no minimum rank, and[time]is what CF, xarray and every Zarr reader expect of a time series. - The record axis leads — NORMATIVE CHANGE.
ZarrSinkappended the record axis last ([…spatial, time]); Rust puts it first. Record-first wins on four independent grounds: CF §2.4 recommends the relative order T, Z, Y, X; a NetCDF unlimited dimension (reserved behind the writer registry for a later phase) must be the first dimension; EarthSciIO’s own sharedwrite_spec.json— the contract all three writers already pass — is["time","lat","lon"], so the Julia sink was inconsistent with the spec its own writer is conformance-tested against; and appending to the outermost axis keeps one record contiguous. A store written by Julia and one written by Rust for the same model were previously not structurally identical, in a system whose contract is cross-language parity — and would have failed the existing write corpus’sdimension_namescheck the moment the two ever wrote the same document. - The record axis is named by the document. §7 says the time axis is the document’s
Domain.independent_variable. Rust reads it; Julia hardcoded"time"as abuild_zarr_sinkkeyword default and never consulted the document, so a model whose independent variable istgot an axis namedtimein Julia andtin Rust.OutputMetanow carriestime_dim, the sink defaults to it, andzarr_restart_stateno longer looks for the literal"time". - Density is validated, and namespaced names resolve. Julia derived each axis length as the
max cell index and never checked coverage, so a gap left an uninitialized hole on disk and a
repeated cell silently dropped a flat slot; both are now
OutputErrors, matching Rust’sSparseGrid/DuplicateCell/ZeroIndex. And Julia looked a base name up in a table keyed by bare variable name, so a flattenedModel.usilently fell back to positional axis names;OutputMetanow registers the bare andModel.-qualified keys (dropping a bare key two models would make ambiguous) with a last-dotted-segment fallback.
One difference remains and is not a behavioural divergence: per-variable standard_name is
retained by the Julia and not by Rust, because neither ModelVariable nor the schema’s
ModelVariable has that property and additionalProperties is false — no schema-valid
document can carry one.
To keep the two derivations structurally comparable rather than merely equal today, Julia gained
the plan layer Rust already had — VarPlan / GridPlan / OutputPlan / derive_output_plan,
with output_var_dims as the single place the record-axis-first order is decided and
row_major_flat_indices converting Julia’s 1-based cart form to the corpus’s shared
0-based row-major representation. ZarrSink builds its OutputSchema through those, so the
conformance corpus exercises production code rather than a parallel adapter.
Other bindings. There is no Python, Go or TypeScript derivation to fix: the Python runner
counterpart named above (earthsciio/backends/zarr_write.py) is a writer, which consumes an
already-derived schema and is agnostic to how a variable got its dims. parse_cell_key /
derive_output_gridding / VarGridding have no implementation anywhere outside
data_output.jl and data_output.rs. Should a third derivation ever be written, the corpus is
the contract it must meet, and manifest.json’s bindings_required is where it gets added.
16.13 Codec profiles (and the wasm profile)
The writers pin three inner-codec profiles. Only the inner (per-chunk) compressor
differs; the sharding_indexed outer codec and its [bytes, crc32c] shard index are
identical across all three, as are dtype, dimension_names, CF attrs and fill_value:
| profile | inner chain | purpose |
|---|---|---|
diagnostic | bytes(le) → Blosc(zstd, clevel 5, byte-shuffle) | default streaming output |
checkpoint | bytes(le) → Blosc(zstd, clevel 7, byte-shuffle) | lossless durability (§16.7) |
wasm | bytes(le) → zstd(level 5), no Blosc | browser/WebAssembly-loadable |
Why wasm exists. Blosc is a container (block splitting + byte-shuffle filter)
wrapping the same zstd compressor. Its C sources (blosc-src) do not target
wasm32-unknown-unknown, whereas the standard Zarr v3 zstd codec does (zstd-sys
gained wasm32 support), and sharding_indexed/crc32c are pure Rust. So dropping only
the Blosc container yields a store a wasm Zarr reader (e.g. zarrs compiled to wasm) can
decode, with no other structural change. Implemented in all three writers plus the Julia
v3 reader (a CodecZstd weakdep extension); write conformance runs both profiles with
30/30 pairwise decoded-array agreement.
The wasm profile is not a compression sacrifice. Measured on float64 geophysical
fields at a realistic ~108 KiB inner chunk, plain zstd beats Blosc on ratio, because the
byte-shuffle filter destroys the long-range spatial matches a whole-chunk zstd stream
exploits on smooth data:
| data (≈108 KiB inner chunk) | zstd-5 (wasm) | Blosc zstd-5 shuffle |
|---|---|---|
| smooth geophysical field | 2.58× | 1.96× |
| plume / concentration | 2.49× | 1.90× |
| signal + 1% noise | 1.05× | 1.14× |
Blosc’s genuine advantages are (a) decode throughput — ~4.2 GB/s vs ~1.1 GB/s
single-threaded per core — and (b) ratio on small chunks or noisy fields, where LZ
matching fails and the shuffle filter is the only remaining source of redundancy. For a
network-bound reader the end-to-end crossover is ≈200 MB/s of object-store bandwidth per
decoding core: below that the wasm profile is faster overall because it transfers
~24% fewer bytes; above it Blosc wins. Typical cloud runners sit well below the crossover;
cache-local re-reads and checkpoint restart sit above it — which is why the profiles stay
separate and diagnostic/checkpoint keep Blosc, with wasm strictly opt-in.
(Indicative numbers: one machine, level 5, synthetic-but-representative fields; the
ranking is stable, the absolute ratios are data- and chunk-shape-dependent.)
Status caveat. Wasm-loadability is established structurally — the emitted chain is
verified on disk to be blosc-free plain v3 zstd in all three writers — but no
wasm32-unknown-unknown target has been built or executed against these stores yet.
That remains the acceptance test before browser support is advertised.