Skip to main content

earthsci_ast/
data_output.rs

1//! Simulation-output derivation — the Rust mirror of `EarthSciAST.jl`'s
2//! `src/data_output.jl` (streaming-output-sinks RFC §7–§9).
3//!
4//! A solved model hands back a **flat** state vector whose elements are named
5//! by the column-major cell-key scheme `name[i,j,…]` (1-based, dim 0 varying
6//! fastest — see `simulate_array::compile::build_slot_tables`). A Zarr writer
7//! wants the opposite: **dimension-labeled, row-major (C-order) arrays** with
8//! CF metadata. This module is the inversion between the two, plus the
9//! document-derived metadata that turns positional axes into real `index_sets`
10//! names and CF coordinates.
11//!
12//! It is deliberately I/O-free and writer-agnostic: it produces a *plan*
13//! ([`OutputPlan`]), never a store. The plan's field shapes are chosen to drop
14//! straight into `earthsciio::format::OutputSchema` / `WriteVar` / `WriteCoord`
15//! (see [`OutputPlan`] docs for the exact correspondence), but nothing here
16//! links EarthSciIO — the same plan feeds any writer, native or wasm.
17//!
18//! ## Layers (each independently useful, in dependency order)
19//!
20//! 1. [`parse_cell_key`] + [`derive_output_gridding`] — the **flat→gridded
21//!    inversion** (§7). Language-neutral: the cell-key scheme is identical in
22//!    Julia, Python and Rust, so this inversion is specified once and must
23//!    agree across all three.
24//! 2. [`derive_output_meta`] — the document slice a writer needs: real axis
25//!    names (a variable's declared `shape`), per-axis lengths, CF variable
26//!    attributes, the additive `coordinates` registry (§8.3), and the time-axis
27//!    name.
28//! 3. [`plan_dimension_coordinates`] — CF **dimension coordinates** (values +
29//!    `standard_name` / `units` / `axis`) resolved from that registry (§8).
30//! 4. [`group_gridding_by_grid`] — partition variables into **grids** by their
31//!    spatial-dim signature (§9). Grids are emergent, never declared.
32//! 5. [`derive_output_plan`] — all of the above, plus the RFC decision-8 rule
33//!    that output is the state **plus a caller-named subset** of observed
34//!    fields (not every observed by default).
35//!
36//! ## wasm32
37//!
38//! This module must compile for `wasm32-unknown-unknown` — the browser runner
39//! *is* this crate compiled to wasm. It therefore uses no filesystem, no
40//! threads, and **no wall clock**: `std::time::Instant::now()` aborts a wasm32
41//! module with a bare `unreachable`, which once killed every spatial run in the
42//! browser. There is no timing or instrumentation here, deliberately.
43//!
44//! ## Cross-language agreement with the Julia reference
45//!
46//! This module and `EarthSciAST.jl`'s `src/data_output.jl` are the same
47//! derivation in two languages, and they are gated as such: the corpus at
48//! `tests/conformance/output_derivation/` drives both from one set of `.esm`
49//! fixtures + flat slot names and asserts one committed golden per case
50//! (`tests/output_derivation_conformance.rs` here, the
51//! `output_derivation_conformance_test.jl` twin there). Four behaviours below
52//! were divergences the two implementations carried silently until that corpus
53//! existed; RFC §16.12 records the reconciliation and the evidence. **All four
54//! resolved in this module's favour, and the Julia now matches.**
55//!
56//! * **Scalars carry no synthetic axis.** `data_output.jl` used to give a
57//!   scalar the singleton shape `[1]` with the positional dim name
58//!   `"<base>_d0"`, which made every scalar its own single-member grid under
59//!   `group_gridding_by_grid`: 66 scalar states meant 66 length-1 dimensions
60//!   named after their own variables and 66 separate grids. Here a scalar has
61//!   `shape == []` and `dim_names == []`, so it is genuinely
62//!   0-spatial-dimensional, every scalar lands in ONE grid, and its on-disk
63//!   dims are exactly `[time_dim]` — what CF, Zarr and xarray all expect, and a
64//!   rank the store already contains (the `time` coordinate is one). Positional
65//!   `"<base>_d0"` naming is retained verbatim for *gridded* variables with no
66//!   declared `shape`, where it is load-bearing.
67//! * **The record axis leads.** [`VarPlan::dims`] is the record axis followed by
68//!   the spatial axes. Julia's `ZarrSink` used to append the record axis last.
69//!   Record-first is CF §2.4's T,Z,Y,X recommendation, the order a NetCDF
70//!   unlimited dimension requires, the order EarthSciIO's shared
71//!   `write_spec.json` already pins for all three writers, and the layout that
72//!   keeps one appended record contiguous on disk.
73//! * **Density is validated.** The Julia used to derive each axis length as the
74//!   max cell index and never check that the cells cover the grid, so a gap left
75//!   an uninitialized hole and a repeat silently dropped a slot. An incomplete
76//!   or double-covered grid is [`OutputError::SparseGrid`] /
77//!   [`OutputError::DuplicateCell`].
78//! * **Namespaced lookups.** The Julia used to look a var_map base name up in a
79//!   table keyed by *bare* variable name, so a flattened `Model.u` silently fell
80//!   back to positional axis names. [`OutputMeta`] registers both the bare and
81//!   the `Model.`-qualified key (dropping a bare key that two models would make
82//!   ambiguous) and additionally falls back to the last dotted segment.
83//!
84//! One difference remains, and is not a divergence in behaviour:
85//! **`standard_name` is not retained per variable.** `derive_output_meta` in
86//! Julia copies `units` / `standard_name` / `description` off the raw variable
87//! dict. Neither `ModelVariable` nor `esm-schema.json`'s `ModelVariable` has a
88//! `standard_name` property — `additionalProperties` is `false` — so no
89//! schema-valid document can carry one and the typed Rust binding models
90//! `units` and `description` only.
91
92use std::collections::{BTreeMap, BTreeSet, HashSet};
93
94use serde_json::{Map as JsonMap, Value as JsonValue};
95
96use crate::types::{Coordinate, EsmFile, IndexSet, VariableType};
97
98/// The element type every output array carries. The flat solver state is
99/// `f64` throughout, so this is currently a constant rather than a per-variable
100/// property; it exists so a writer can name a dtype without hard-coding one.
101pub const DTYPE_FLOAT64: &str = "float64";
102
103/// Fallback name for the record axis when the document declares no
104/// `domain.independent_variable`.
105pub const DEFAULT_TIME_DIM: &str = "time";
106
107// --------------------------------------------------------------------------
108// Errors
109// --------------------------------------------------------------------------
110
111/// Failure deriving an [`OutputPlan`] — the output-side mirror of the loader
112/// seam's errors. Every variant names the offending variable or dimension:
113/// a mislabeled axis is silently wrong science, so these fail loudly.
114#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
115pub enum OutputError {
116    /// A scalar (bracket-free) cell key appeared more than once.
117    #[error(
118        "scalar variable '{base}' maps to {count} flat indices; a scalar cell key must be unique"
119    )]
120    DuplicateScalar { base: String, count: usize },
121
122    /// Cells of one variable disagreed on how many axes the variable has.
123    #[error(
124        "variable '{base}' has cells of differing dimensionality ({got} vs {expected}); a gridded variable must be rectangular"
125    )]
126    RaggedRank {
127        base: String,
128        got: usize,
129        expected: usize,
130    },
131
132    /// A cell key carried a 0 (or negative) index; cell keys are 1-based.
133    #[error("variable '{base}' has cell index 0 on axis {axis}; cell keys are 1-based")]
134    ZeroIndex { base: String, axis: usize },
135
136    /// The variable's cells do not cover its grid.
137    #[error(
138        "variable '{base}' supplies {got} cell(s) for a {shape:?} grid of {want}; a gridded output variable must be dense"
139    )]
140    SparseGrid {
141        base: String,
142        shape: Vec<usize>,
143        got: usize,
144        want: usize,
145    },
146
147    /// Two flat slots claimed the same grid cell.
148    #[error("variable '{base}' maps two flat slots to grid cell {cell:?}")]
149    DuplicateCell { base: String, cell: Vec<usize> },
150
151    /// The document's declared `shape` has a different rank than the flat state.
152    #[error(
153        "output metadata for '{base}' declares {declared} dim(s) {dims:?}, but the flat state grids it to {actual} axis/axes {shape:?}"
154    )]
155    DimCountMismatch {
156        base: String,
157        dims: Vec<String>,
158        declared: usize,
159        shape: Vec<usize>,
160        actual: usize,
161    },
162
163    /// A declared index set's length disagrees with the recovered axis length.
164    #[error(
165        "output metadata: '{base}' axis {axis} is index set '{dim}' of size {size}, but the flat state grids that axis to length {len}"
166    )]
167    DimLengthMismatch {
168        base: String,
169        axis: usize,
170        dim: String,
171        size: usize,
172        len: usize,
173    },
174
175    /// Two variables on one grid disagree about a shared dimension's length.
176    #[error(
177        "dimension '{dim}' has length {a} on variable '{first}' but length {b} on variable '{second}'"
178    )]
179    DimLengthConflict {
180        dim: String,
181        first: String,
182        a: usize,
183        second: String,
184        b: usize,
185    },
186
187    /// A spatial dimension collides with the record (time) axis name.
188    #[error(
189        "variable '{base}' has a spatial dimension named '{dim}', which collides with the record axis"
190    )]
191    TimeDimCollision { base: String, dim: String },
192
193    /// A `coordinates` entry's inline `values` length does not match its axis.
194    #[error("coordinate '{name}' supplies {got} value(s) but dimension '{name}' has length {want}")]
195    CoordLengthMismatch {
196        name: String,
197        got: usize,
198        want: usize,
199    },
200
201    /// The caller asked for an observed field that has no slot in the flat
202    /// state. Silently dropping a requested output is worse than refusing.
203    #[error(
204        "requested observed output '{name}' has no slot in the flat state; the runner must evaluate and append it before the plan is derived"
205    )]
206    UnknownObserved { name: String },
207
208    /// A requested output names no variable exactly, and its last dotted
209    /// segment is shared by more than one variable, so it designates none of
210    /// them (CONFORMANCE_SPEC §5.17.4). Code `ambiguous_output_name`.
211    #[error(
212        "requested output '{name}' names no variable exactly, and its last segment is shared by {candidates:?}; name one of them in full"
213    )]
214    AmbiguousRequest {
215        name: String,
216        candidates: Vec<String>,
217    },
218
219    /// A scatter/gather call was handed a buffer of the wrong length.
220    #[error("variable '{base}': {what} buffer holds {got} element(s), need {want}")]
221    BufferLength {
222        base: String,
223        what: &'static str,
224        got: usize,
225        want: usize,
226    },
227}
228
229impl OutputError {
230    /// The registered diagnostic code (a value of [`crate::ERROR_CODES`]) for
231    /// the variants that carry one.
232    #[must_use]
233    pub fn code(&self) -> Option<&'static str> {
234        match self {
235            Self::AmbiguousRequest { .. } => Some(crate::diagnostic::codes::AMBIGUOUS_OUTPUT_NAME),
236            _ => None,
237        }
238    }
239}
240
241// --------------------------------------------------------------------------
242// §7 — the flat→gridded inversion
243// --------------------------------------------------------------------------
244
245/// Split a flat state-element name into `(base, 1-based indices)`, or `None`
246/// when it is not a well-formed cell key (no bracket suffix, an empty base
247/// name, or a non-integer index). The exact inverse of the `"{name}[{i},{j}]"`
248/// formatting in `simulate_array::compile::build_slot_tables`, and the Rust
249/// twin of Julia's `_parse_cell_key` / Python's equivalent.
250///
251/// Matches the Julia regex `^([^\[]+)\[([0-9]+(?:,[0-9]+)*)\]$`: the base name
252/// may not itself contain `[`, so the FIRST bracket opens the index list.
253///
254/// ```
255/// use earthsci_ast::parse_cell_key;
256/// assert_eq!(parse_cell_key("u[2,3]"), Some(("u", vec![2, 3])));
257/// assert_eq!(parse_cell_key("Model.u[7]"), Some(("Model.u", vec![7])));
258/// assert_eq!(parse_cell_key("plain"), None);
259/// ```
260pub fn parse_cell_key(key: &str) -> Option<(&str, Vec<usize>)> {
261    let open = key.find('[')?;
262    if open == 0 {
263        return None; // empty variable name
264    }
265    let inner = key.strip_suffix(']')?.get(open + 1..)?;
266    if inner.is_empty() {
267        return None;
268    }
269    let mut indices = Vec::new();
270    for part in inner.split(',') {
271        if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
272            return None;
273        }
274        indices.push(part.parse::<usize>().ok()?);
275    }
276    Some((&key[..open], indices))
277}
278
279/// The gridded layout of one output base-variable, derived from the flat
280/// state-element names (RFC §7).
281///
282/// The inversion is baked into [`Self::flat_indices`]: it is indexed by
283/// **row-major (C-order) offset** within [`Self::shape`] and holds the
284/// *column-major* flat state index that feeds that cell. Scattering is
285/// therefore a single linear pass with the transposition already applied —
286/// `out[o] = u[flat_indices[o]]` — which is exactly the layout
287/// `earthsciio::format::WriteVar::data` requires.
288#[derive(Debug, Clone, PartialEq, Eq)]
289pub struct VarGridding {
290    /// The variable's base name — the cell key minus its `[…]` suffix, so
291    /// possibly namespaced (`"Model.u"`) depending on the build path.
292    pub base: String,
293    /// The gridded spatial shape, in cell-key axis order. **Empty** for a
294    /// scalar variable (see the module-level divergence note).
295    pub shape: Vec<usize>,
296    /// One name per axis of [`Self::shape`]: the variable's declared
297    /// `index_sets` names once [`OutputMeta`] has been applied, otherwise the
298    /// positional placeholders `"<base>_d0"`, `"<base>_d1"`, ….
299    pub dim_names: Vec<String>,
300    /// Flat state index of each grid cell, indexed by row-major offset.
301    /// Length is `shape.iter().product()` (1 for a scalar).
302    pub flat_indices: Vec<usize>,
303}
304
305impl VarGridding {
306    /// Number of grid cells (1 for a scalar).
307    #[must_use]
308    pub fn n_cells(&self) -> usize {
309        self.flat_indices.len()
310    }
311
312    /// True when the variable has no spatial axes.
313    #[must_use]
314    pub fn is_scalar(&self) -> bool {
315        self.shape.is_empty()
316    }
317
318    /// The 0-based grid position of the cell at row-major offset `cell`.
319    /// The Rust counterpart of Julia's `VarGridding.cart` entries (which are
320    /// 1-based `CartesianIndex`es).
321    #[must_use]
322    pub fn cart(&self, cell: usize) -> Vec<usize> {
323        let mut out = vec![0usize; self.shape.len()];
324        let mut rem = cell;
325        for d in (0..self.shape.len()).rev() {
326            let n = self.shape[d].max(1);
327            out[d] = rem % n;
328            rem /= n;
329        }
330        out
331    }
332
333    /// Scatter one flat state slab into `out`, a row-major array of
334    /// [`Self::shape`]. The Rust twin of Julia's `scatter_grid!`.
335    ///
336    /// # Errors
337    /// [`OutputError::BufferLength`] when `out` is not exactly `n_cells()`
338    /// long, or `flat_u` does not reach one of the variable's slots.
339    pub fn scatter(&self, flat_u: &[f64], out: &mut [f64]) -> Result<(), OutputError> {
340        self.scatter_record(flat_u, out, 0)
341    }
342
343    /// Scatter one flat state slab into record `record` of a
344    /// `[time, …spatial]` row-major buffer — the whole-trajectory form, where
345    /// `out` holds `n_records * n_cells()` values and record `r` occupies
346    /// `out[r * n_cells() .. (r + 1) * n_cells()]`.
347    ///
348    /// # Errors
349    /// [`OutputError::BufferLength`] when the record does not fit in `out`, or
350    /// `flat_u` does not reach one of the variable's slots.
351    pub fn scatter_record(
352        &self,
353        flat_u: &[f64],
354        out: &mut [f64],
355        record: usize,
356    ) -> Result<(), OutputError> {
357        let n = self.n_cells();
358        let start = record * n;
359        let have = out.len();
360        let slot = out
361            .get_mut(start..start + n)
362            .ok_or_else(|| OutputError::BufferLength {
363                base: self.base.clone(),
364                what: "gridded output",
365                got: have,
366                want: start + n,
367            })?;
368        for (o, &fi) in self.flat_indices.iter().enumerate() {
369            slot[o] = *flat_u.get(fi).ok_or_else(|| self.short_state(flat_u, fi))?;
370        }
371        Ok(())
372    }
373
374    /// The inverse of [`Self::scatter`] — read a gridded slab back into the
375    /// flat state (the checkpoint-restart direction, RFC §10). The Rust twin of
376    /// Julia's `gather_flat!`.
377    ///
378    /// # Errors
379    /// [`OutputError::BufferLength`] when `grid` is not `n_cells()` long or
380    /// `flat_u` does not reach one of the variable's slots.
381    pub fn gather(&self, grid: &[f64], flat_u: &mut [f64]) -> Result<(), OutputError> {
382        if grid.len() != self.n_cells() {
383            return Err(OutputError::BufferLength {
384                base: self.base.clone(),
385                what: "gridded input",
386                got: grid.len(),
387                want: self.n_cells(),
388            });
389        }
390        for (o, &fi) in self.flat_indices.iter().enumerate() {
391            let have = flat_u.len();
392            let cell = flat_u.get_mut(fi).ok_or(OutputError::BufferLength {
393                base: self.base.clone(),
394                what: "flat state",
395                got: have,
396                want: fi + 1,
397            })?;
398            *cell = grid[o];
399        }
400        Ok(())
401    }
402
403    fn short_state(&self, flat_u: &[f64], fi: usize) -> OutputError {
404        OutputError::BufferLength {
405            base: self.base.clone(),
406            what: "flat state",
407            got: flat_u.len(),
408            want: fi + 1,
409        }
410    }
411}
412
413/// Scatter a flat state slab into a row-major gridded buffer. Free-function
414/// form of [`VarGridding::scatter`], mirroring Julia's `scatter_grid!`.
415///
416/// # Errors
417/// See [`VarGridding::scatter`].
418pub fn scatter(g: &VarGridding, flat_u: &[f64], out: &mut [f64]) -> Result<(), OutputError> {
419    g.scatter(flat_u, out)
420}
421
422/// Read a row-major gridded buffer back into the flat state. Free-function
423/// form of [`VarGridding::gather`], mirroring Julia's `gather_flat!`.
424///
425/// # Errors
426/// See [`VarGridding::gather`].
427pub fn gather(g: &VarGridding, grid: &[f64], flat_u: &mut [f64]) -> Result<(), OutputError> {
428    g.gather(grid, flat_u)
429}
430
431/// Invert the flat state-element names into one [`VarGridding`] per output
432/// base-variable (RFC §7), the flat index of a name being its position in
433/// `slot_names` — the shape `Solution::state_variable_names` already has.
434///
435/// Base-variable order is sorted by base name, matching Julia, so a derived
436/// plan is reproducible and cross-language comparable.
437///
438/// # Errors
439/// See [`OutputError`]: a ragged rank, a duplicated scalar key, a 0 index, or
440/// a grid the cells do not densely cover.
441pub fn derive_output_gridding(slot_names: &[String]) -> Result<Vec<VarGridding>, OutputError> {
442    derive_output_gridding_from_map(slot_names.iter().enumerate().map(|(i, n)| (n.as_str(), i)))
443}
444
445/// [`derive_output_gridding`] over an explicit `(name, flat index)` mapping —
446/// the direct analog of Julia's `derive_output_gridding(var_map)`, for callers
447/// whose flat index is not the position in a slice.
448///
449/// # Errors
450/// See [`derive_output_gridding`].
451pub fn derive_output_gridding_from_map<'a, I>(entries: I) -> Result<Vec<VarGridding>, OutputError>
452where
453    I: IntoIterator<Item = (&'a str, usize)>,
454{
455    // base => [(flat index, 1-based cell indices)]
456    let mut groups: BTreeMap<String, Vec<(usize, Vec<usize>)>> = BTreeMap::new();
457    for (key, idx) in entries {
458        let (base, inds) = match parse_cell_key(key) {
459            Some((b, i)) => (b.to_string(), i),
460            None => (key.to_string(), Vec::new()),
461        };
462        groups.entry(base).or_default().push((idx, inds));
463    }
464
465    // BTreeMap iteration is sorted by base name — Julia's `sort!(collect(keys))`.
466    groups
467        .into_iter()
468        .map(|(base, entries)| grid_one_variable(base, entries))
469        .collect()
470}
471
472fn grid_one_variable(
473    base: String,
474    entries: Vec<(usize, Vec<usize>)>,
475) -> Result<VarGridding, OutputError> {
476    let ndim = entries.iter().map(|(_, i)| i.len()).max().unwrap_or(0);
477
478    if ndim == 0 {
479        if entries.len() != 1 {
480            return Err(OutputError::DuplicateScalar {
481                base,
482                count: entries.len(),
483            });
484        }
485        let flat = entries[0].0;
486        return Ok(VarGridding {
487            base,
488            shape: Vec::new(),
489            dim_names: Vec::new(),
490            flat_indices: vec![flat],
491        });
492    }
493
494    let mut shape = vec![0usize; ndim];
495    for (_, inds) in &entries {
496        if inds.len() != ndim {
497            return Err(OutputError::RaggedRank {
498                base,
499                got: inds.len(),
500                expected: ndim,
501            });
502        }
503        for (d, &i) in inds.iter().enumerate() {
504            if i == 0 {
505                return Err(OutputError::ZeroIndex { base, axis: d });
506            }
507            shape[d] = shape[d].max(i);
508        }
509    }
510
511    let total: usize = shape.iter().product();
512    if entries.len() != total {
513        return Err(OutputError::SparseGrid {
514            base,
515            shape,
516            got: entries.len(),
517            want: total,
518        });
519    }
520
521    // The inversion itself: place each flat slot at its ROW-MAJOR offset, so
522    // the resulting vector is already in the C order a Zarr writer wants while
523    // the flat state it reads from stays column-major.
524    let mut flat_indices = vec![usize::MAX; total];
525    for (flat, inds) in &entries {
526        let mut off = 0usize;
527        for (d, &i) in inds.iter().enumerate() {
528            off = off * shape[d] + (i - 1);
529        }
530        if flat_indices[off] != usize::MAX {
531            return Err(OutputError::DuplicateCell {
532                base,
533                cell: inds.clone(),
534            });
535        }
536        flat_indices[off] = *flat;
537    }
538
539    let dim_names = (0..ndim).map(|d| format!("{base}_d{d}")).collect();
540    Ok(VarGridding {
541        base,
542        shape,
543        dim_names,
544        flat_indices,
545    })
546}
547
548// --------------------------------------------------------------------------
549// §7/§8 — document-derived output metadata
550// --------------------------------------------------------------------------
551
552/// The document slice a streaming writer needs so it can name axes and emit CF
553/// coordinates (RFC §7–§8) without importing the document model. Distilled from
554/// an [`EsmFile`] by [`derive_output_meta`]; the Rust mirror of Julia's
555/// `OutputMeta`.
556///
557/// The `var_*` maps are keyed by BOTH the bare variable name and its
558/// `Model.`-qualified form, because a flat state's element names are namespaced
559/// on the coupled/flattened path and bare on the single-model path. A bare key
560/// that two models would make ambiguous is dropped, so an ambiguous lookup
561/// falls back to positional axis names rather than guessing.
562#[derive(Debug, Clone, Default, PartialEq)]
563pub struct OutputMeta {
564    /// Model keys present in the document, sorted.
565    pub model_names: Vec<String>,
566    /// Index-set name → axis length. `0` marks a length only the build knows
567    /// (`derived` / `ragged` sets), which callers treat as "unconstrained".
568    pub index_sets: BTreeMap<String, usize>,
569    /// Variable name → its declared `shape` (ordered index-set names). Absent
570    /// for scalars.
571    pub var_dims: BTreeMap<String, Vec<String>>,
572    /// Variable name → retained CF attributes (`units`, `description`).
573    pub var_attrs: BTreeMap<String, JsonMap<String, JsonValue>>,
574    /// Variable name → DECLARED kind (`unknown` / `parameter`, esm-spec §6.3).
575    pub var_types: BTreeMap<String, VariableType>,
576    /// The names that are OBSERVED unknowns, keyed exactly as `var_types` is,
577    /// so observed fields can be gated on the caller's request list (RFC
578    /// decision 8).
579    ///
580    /// Derived through [`crate::classification::observed_unknowns`], not read
581    /// off a declared type: from esm 1.0.0 "observed" is a property of the
582    /// equation that defines the unknown, and the declaration says only
583    /// `unknown`.
584    pub observed_unknowns: std::collections::BTreeSet<String>,
585    /// The additive document-scoped `coordinates` registry (§8.3), verbatim.
586    pub coordinates: BTreeMap<String, Coordinate>,
587    /// Record-axis name: `domain.independent_variable`, else `"time"`.
588    pub time_dim: String,
589}
590
591impl OutputMeta {
592    /// Look a flat base name up in one of the `var_*` tables: exact first, then
593    /// the last dotted segment (a nested namespace the exact keys do not
594    /// cover).
595    fn lookup<'m, T>(&self, table: &'m BTreeMap<String, T>, base: &str) -> Option<&'m T> {
596        if let Some(v) = table.get(base) {
597            return Some(v);
598        }
599        let bare = base.rsplit('.').next()?;
600        if bare == base {
601            return None;
602        }
603        table.get(bare)
604    }
605
606    /// The declared dimension names of `base`, if the document has them.
607    #[must_use]
608    pub fn dims_of(&self, base: &str) -> Option<&Vec<String>> {
609        self.lookup(&self.var_dims, base)
610    }
611
612    /// The declared kind of `base`, if the document has it.
613    #[must_use]
614    pub fn type_of(&self, base: &str) -> Option<&VariableType> {
615        self.lookup(&self.var_types, base)
616    }
617
618    /// Whether `base` is an OBSERVED unknown (esm-spec §6.3.1), by the same
619    /// exact-then-last-segment lookup the other tables use.
620    #[must_use]
621    pub fn is_observed(&self, base: &str) -> bool {
622        if self.observed_unknowns.contains(base) {
623            return true;
624        }
625        match base.rsplit('.').next() {
626            Some(bare) if bare != base => self.observed_unknowns.contains(bare),
627            _ => false,
628        }
629    }
630
631    /// The retained CF attributes of `base`.
632    #[must_use]
633    pub fn attrs_of(&self, base: &str) -> Option<&JsonMap<String, JsonValue>> {
634        self.lookup(&self.var_attrs, base)
635    }
636}
637
638/// Static axis length of an index set: `size` for `interval`, member count for
639/// `categorical`, and `size` (or 0 = "known only after the build") otherwise.
640fn index_set_axis_len(is: &IndexSet) -> usize {
641    match is.kind.as_str() {
642        "categorical" => is.members.as_ref().map_or(0, Vec::len),
643        // `interval` and anything else fall back to a declared `size`.
644        _ => usize::try_from(is.size.unwrap_or(0)).unwrap_or(0),
645    }
646}
647
648/// Distill a run document into [`OutputMeta`] — the Rust mirror of Julia's
649/// `derive_output_meta`. Reads only the document (no build artifacts), so it
650/// composes with [`derive_output_gridding`].
651#[must_use]
652pub fn derive_output_meta(doc: &EsmFile) -> OutputMeta {
653    let mut meta = OutputMeta {
654        time_dim: doc
655            .domain
656            .as_ref()
657            .and_then(|d| d.independent_variable.clone())
658            .unwrap_or_else(|| DEFAULT_TIME_DIM.to_string()),
659        ..OutputMeta::default()
660    };
661
662    if let Some(sets) = &doc.index_sets {
663        for (name, is) in sets {
664            meta.index_sets.insert(name.clone(), index_set_axis_len(is));
665        }
666    }
667
668    if let Some(coords) = &doc.coordinates {
669        for (name, c) in coords {
670            meta.coordinates.insert(name.clone(), c.clone());
671        }
672    }
673
674    let Some(models) = &doc.models else {
675        return meta;
676    };
677    meta.model_names = models.keys().cloned().collect();
678    meta.model_names.sort();
679
680    // Bare names claimed by more than one model are ambiguous; only the
681    // qualified key survives for those.
682    let mut bare_counts: BTreeMap<&str, usize> = BTreeMap::new();
683    for model in models.values() {
684        for vn in model.variables.keys() {
685            *bare_counts.entry(vn.as_str()).or_insert(0) += 1;
686        }
687    }
688
689    for mname in &meta.model_names {
690        let model = &models[mname];
691        // Which unknowns are OBSERVED is derived from this model's equations
692        // (esm-spec §6.3.1), once per model.
693        let class = crate::classification::Classification::of(model);
694        let mut vnames: Vec<&String> = model.variables.keys().collect();
695        vnames.sort();
696        for vn in vnames {
697            let v = &model.variables[vn];
698            let qualified = format!("{mname}.{vn}");
699            let ambiguous = bare_counts.get(vn.as_str()).copied().unwrap_or(0) > 1;
700
701            let mut keys: Vec<String> = vec![qualified];
702            if !ambiguous {
703                keys.push(vn.clone());
704            }
705
706            let mut attrs = JsonMap::new();
707            if let Some(u) = &v.units {
708                attrs.insert("units".to_string(), JsonValue::from(u.clone()));
709            }
710            if let Some(d) = &v.description {
711                attrs.insert("description".to_string(), JsonValue::from(d.clone()));
712            }
713
714            for key in keys {
715                if let Some(shape) = &v.shape {
716                    meta.var_dims.insert(key.clone(), shape.clone());
717                }
718                if !attrs.is_empty() {
719                    meta.var_attrs.insert(key.clone(), attrs.clone());
720                }
721                if class.is_observed(vn) {
722                    meta.observed_unknowns.insert(key.clone());
723                }
724                meta.var_types.insert(key, v.var_type);
725            }
726        }
727    }
728
729    meta
730}
731
732/// Rename one variable's axes from the positional `"<base>_d0"` placeholders to
733/// its REAL declared dim names (RFC §7 → §8) — the Rust mirror of Julia's
734/// `_name_gridding`. A variable the document does not describe keeps its
735/// positional names.
736///
737/// # Errors
738/// [`OutputError::DimCountMismatch`] / [`OutputError::DimLengthMismatch`] when
739/// the document and the built state disagree about a variable's shape. A
740/// mismatch must fail loudly rather than mislabel an axis.
741pub fn apply_output_meta(g: VarGridding, meta: &OutputMeta) -> Result<VarGridding, OutputError> {
742    let Some(dims) = meta.dims_of(&g.base) else {
743        return Ok(g); // scalar / undeclared → positional
744    };
745    if dims.len() != g.shape.len() {
746        let actual = g.shape.len();
747        return Err(OutputError::DimCountMismatch {
748            base: g.base,
749            dims: dims.clone(),
750            declared: dims.len(),
751            shape: g.shape,
752            actual,
753        });
754    }
755    for (d, dim) in dims.iter().enumerate() {
756        if let Some(&size) = meta.index_sets.get(dim)
757            && size != 0
758            && size != g.shape[d]
759        {
760            return Err(OutputError::DimLengthMismatch {
761                base: g.base,
762                axis: d,
763                dim: dim.clone(),
764                size,
765                len: g.shape[d],
766            });
767        }
768    }
769    Ok(VarGridding {
770        dim_names: dims.clone(),
771        ..g
772    })
773}
774
775/// [`derive_output_gridding`] followed by [`apply_output_meta`] on every
776/// variable — the doc-aware form, mirroring Julia's two-argument
777/// `derive_output_gridding(var_map, meta)`.
778///
779/// # Errors
780/// See [`derive_output_gridding`] and [`apply_output_meta`].
781pub fn derive_output_gridding_with_meta(
782    slot_names: &[String],
783    meta: &OutputMeta,
784) -> Result<Vec<VarGridding>, OutputError> {
785    derive_output_gridding(slot_names)?
786        .into_iter()
787        .map(|g| apply_output_meta(g, meta))
788        .collect()
789}
790
791// --------------------------------------------------------------------------
792// §9 — grids are emergent
793// --------------------------------------------------------------------------
794
795/// Partition output variables into GRIDS by their spatial-dim signature
796/// (RFC §9): variables over the same set of axes form one grid, so an
797/// atmosphere over `[lev, lat, lon]` and an ocean over `[depth, lat, lon]`
798/// split by construction. The signature is the SORTED SET of a variable's
799/// `dim_names`, so axis order does not fragment a grid, and every scalar
800/// (empty signature) lands in one shared 0-D grid. Groups come back in
801/// first-seen signature order.
802#[must_use]
803pub fn group_gridding_by_grid(gridding: Vec<VarGridding>) -> Vec<Vec<VarGridding>> {
804    let mut order: Vec<Vec<String>> = Vec::new();
805    let mut groups: BTreeMap<Vec<String>, Vec<VarGridding>> = BTreeMap::new();
806    for g in gridding {
807        let sig: Vec<String> = g
808            .dim_names
809            .iter()
810            .cloned()
811            .collect::<BTreeSet<_>>()
812            .into_iter()
813            .collect();
814        if !groups.contains_key(&sig) {
815            order.push(sig.clone());
816        }
817        groups.entry(sig).or_default().push(g);
818    }
819    order
820        .into_iter()
821        .filter_map(|sig| groups.remove(&sig))
822        .collect()
823}
824
825// --------------------------------------------------------------------------
826// §8 — CF dimension coordinates
827// --------------------------------------------------------------------------
828
829/// A CF **dimension coordinate** (§8.3): a 1-D coordinate variable named
830/// exactly like its dimension, its values, and its CF attributes. Field-for-
831/// field the shape of `earthsciio::format::WriteCoord`.
832#[derive(Debug, Clone, PartialEq)]
833pub struct CoordPlan {
834    /// Coordinate (and dimension) name.
835    pub name: String,
836    /// The coordinate values, one per grid index along its dimension.
837    pub values: Vec<f64>,
838    /// CF attributes: `standard_name` / `units` / `axis`, when declared.
839    pub attrs: JsonMap<String, JsonValue>,
840}
841
842/// Resolve the `coordinates` registry (§8.3) into the CF **dimension
843/// coordinates** for a set of [`VarGridding`]s — the Rust mirror of Julia's
844/// `plan_dimension_coordinates`.
845///
846/// For each distinct spatial dim, in first-seen order, a registry entry KEYED
847/// BY THAT DIM NAME carrying inline `values` produces a [`CoordPlan`] with the
848/// entry's `standard_name` / `units` / `axis`. Entries whose key is not a dim
849/// name (CF *auxiliary* coordinates) and `source`-backed entries are skipped:
850/// they resolve against build artifacts, not the document, and are the
851/// documented next drop-in on the same registry. A dim with no entry emits no
852/// coordinate — a bare integer axis.
853///
854/// # Errors
855/// [`OutputError::CoordLengthMismatch`] when an entry's `values` length does
856/// not match the axis it names.
857pub fn plan_dimension_coordinates(
858    gridding: &[VarGridding],
859    meta: &OutputMeta,
860) -> Result<Vec<CoordPlan>, OutputError> {
861    let mut order: Vec<&str> = Vec::new();
862    let mut dimlen: BTreeMap<&str, usize> = BTreeMap::new();
863    for g in gridding {
864        for (d, name) in g.dim_names.iter().enumerate() {
865            if !dimlen.contains_key(name.as_str()) {
866                order.push(name.as_str());
867            }
868            dimlen.insert(name.as_str(), g.shape[d]);
869        }
870    }
871
872    let mut out = Vec::new();
873    for name in order {
874        let Some(entry) = meta.coordinates.get(name) else {
875            continue;
876        };
877        // v1 handles the inline-`values` dimension-coordinate path only.
878        let Some(values) = &entry.values else {
879            continue;
880        };
881        let want = dimlen[name];
882        if values.len() != want {
883            return Err(OutputError::CoordLengthMismatch {
884                name: name.to_string(),
885                got: values.len(),
886                want,
887            });
888        }
889        let mut attrs = JsonMap::new();
890        if let Some(v) = &entry.standard_name {
891            attrs.insert("standard_name".to_string(), JsonValue::from(v.clone()));
892        }
893        if let Some(v) = &entry.units {
894            attrs.insert("units".to_string(), JsonValue::from(v.clone()));
895        }
896        if let Some(v) = &entry.axis {
897            attrs.insert("axis".to_string(), JsonValue::from(v.clone()));
898        }
899        out.push(CoordPlan {
900            name: name.to_string(),
901            values: values.clone(),
902            attrs,
903        });
904    }
905    Ok(out)
906}
907
908// --------------------------------------------------------------------------
909// The plan
910// --------------------------------------------------------------------------
911
912/// One output variable: what a writer declares, plus the inversion that fills
913/// it. Field-for-field the shape of `earthsciio::format::WriteVar` once
914/// [`Self::gridding`] has scattered the trajectory into `data`.
915#[derive(Debug, Clone, PartialEq)]
916pub struct VarPlan {
917    /// On-disk variable name (the flat base name).
918    pub name: String,
919    /// Ordered on-disk dimension names: the record axis first, then the
920    /// variable's spatial axes. A scalar's dims are exactly `[time_dim]`.
921    pub dims: Vec<String>,
922    /// CF attributes retained from the document (`units`, `description`).
923    pub attrs: JsonMap<String, JsonValue>,
924    /// Element type; [`DTYPE_FLOAT64`] throughout (the flat state is `f64`).
925    pub dtype: &'static str,
926    /// The flat→gridded inversion for this variable.
927    pub gridding: VarGridding,
928}
929
930impl VarPlan {
931    /// Number of values one output record contributes (1 for a scalar).
932    #[must_use]
933    pub fn values_per_record(&self) -> usize {
934        self.gridding.n_cells()
935    }
936}
937
938/// One grid: a set of variables sharing a spatial-dim signature, with the
939/// coordinates and dimension lengths that describe it (RFC §9). The RFC's
940/// recommended decomposition is one Sink — one store or group — per grid.
941///
942/// Converting to `earthsciio::format::OutputSchema` is mechanical:
943/// `OutputSchema::dims` is `[(time_dim, n_records)]` followed by
944/// [`Self::dims`]; `coords` is [`Self::coords`] plus a `WriteCoord` for the
945/// time axis built from the output times; `vars` is one `WriteVar` per
946/// [`VarPlan`] whose `data` is [`VarPlan::gridding`] scattered across every
947/// record. The record count is deliberately NOT part of the plan: a plan is
948/// derived once and stays valid however many records get written.
949#[derive(Debug, Clone, PartialEq)]
950pub struct GridPlan {
951    /// Ordered SPATIAL dims of this grid, `name → length`. Empty for the
952    /// scalar grid. The record axis is not included; see [`Self::time_dim`].
953    pub dims: Vec<(String, usize)>,
954    /// The growable record-axis name (`domain.independent_variable`).
955    pub time_dim: String,
956    /// CF dimension coordinates for this grid's axes (possibly empty).
957    pub coords: Vec<CoordPlan>,
958    /// The grid's output variables, ordered by name.
959    pub vars: Vec<VarPlan>,
960}
961
962impl GridPlan {
963    /// Length of `dim` on this grid.
964    #[must_use]
965    pub fn dim_len(&self, dim: &str) -> Option<usize> {
966        self.dims
967            .iter()
968            .find(|(n, _)| n == dim)
969            .map(|&(_, len)| len)
970    }
971}
972
973/// The full derived output plan: every grid the run produces.
974#[derive(Debug, Clone, PartialEq)]
975pub struct OutputPlan {
976    /// One entry per emergent grid, in first-seen signature order.
977    pub grids: Vec<GridPlan>,
978}
979
980impl OutputPlan {
981    /// Total number of output variables across every grid.
982    #[must_use]
983    pub fn n_vars(&self) -> usize {
984        self.grids.iter().map(|g| g.vars.len()).sum()
985    }
986
987    /// Find a variable by name across the grids.
988    #[must_use]
989    pub fn var(&self, name: &str) -> Option<&VarPlan> {
990        self.grids
991            .iter()
992            .find_map(|g| g.vars.iter().find(|v| v.name == name))
993    }
994}
995
996/// Derive the whole output plan for a run.
997///
998/// * `doc` — the (flattened) run document: axis names, axis lengths, CF
999///   attributes, and the `coordinates` registry.
1000/// * `slot_names` — the flat state-element names, index = flat index. This is
1001///   `Solution::state_variable_names` / `ArrayCompiled::state_variable_names`,
1002///   including any observed rows the runner appended.
1003/// * `observed` — the caller-named observed/derived fields to write alongside
1004///   the state (RFC decision 8). Output is state PLUS these, not every observed
1005///   field. A name selects the variable it names exactly; failing that, the
1006///   ONE variable whose last dotted segment equals the name's (so `flux`
1007///   selects `Box.flux`). A name an `operator_compose` merge deleted is NOT
1008///   resolved here: the caller resolves it through `merged_variable_renames`
1009///   first (CONFORMANCE_SPEC §5.17.4).
1010///
1011/// # Errors
1012/// [`OutputError::UnknownObserved`] when a requested observed field has no slot
1013/// in `slot_names`; [`OutputError::AmbiguousRequest`] when a request names no
1014/// variable exactly and its last segment is shared; otherwise see [`derive_output_gridding`],
1015/// [`apply_output_meta`] and [`plan_dimension_coordinates`].
1016pub fn derive_output_plan(
1017    doc: &EsmFile,
1018    slot_names: &[String],
1019    observed: &[String],
1020) -> Result<OutputPlan, OutputError> {
1021    let meta = derive_output_meta(doc);
1022    let gridding = derive_output_gridding_with_meta(slot_names, &meta)?;
1023
1024    // RFC decision 8: state is always written; an observed field only when the
1025    // caller named it. A requested name that designates no single variable is an
1026    // error — silently dropping, or silently choosing, a requested output is the
1027    // failure mode this refuses.
1028    let bases: Vec<&str> = gridding.iter().map(|g| g.base.as_str()).collect();
1029    let mut requested: HashSet<String> = HashSet::new();
1030    for name in observed {
1031        match match_output_request(name, &bases) {
1032            RequestMatch::Named(base) => {
1033                requested.insert(base.to_string());
1034            }
1035            RequestMatch::Ambiguous(candidates) => {
1036                return Err(OutputError::AmbiguousRequest {
1037                    name: name.clone(),
1038                    candidates: candidates.into_iter().map(str::to_string).collect(),
1039                });
1040            }
1041            RequestMatch::Missing => {
1042                return Err(OutputError::UnknownObserved { name: name.clone() });
1043            }
1044        }
1045    }
1046    let kept: Vec<VarGridding> = gridding
1047        .into_iter()
1048        .filter(|g| !meta.is_observed(&g.base) || requested.contains(&g.base))
1049        .collect();
1050
1051    let mut grids = Vec::new();
1052    for group in group_gridding_by_grid(kept) {
1053        grids.push(build_grid_plan(group, &meta)?);
1054    }
1055    Ok(OutputPlan { grids })
1056}
1057
1058/// What an output request designates among `candidates`.
1059pub(crate) enum RequestMatch<'a> {
1060    /// The one candidate the request names.
1061    Named(&'a str),
1062    /// No candidate is named exactly, and these (sorted) share the request's
1063    /// last dotted segment.
1064    Ambiguous(Vec<&'a str>),
1065    /// Nothing matches.
1066    Missing,
1067}
1068
1069/// The output-request rule of CONFORMANCE_SPEC §5.17.4: the candidate the
1070/// request names exactly; failing that, the ONE candidate whose last dotted
1071/// segment equals the request's. Mirrors Julia's `_resolve_output_requests`.
1072pub(crate) fn match_output_request<'a>(request: &str, candidates: &[&'a str]) -> RequestMatch<'a> {
1073    if let Some(exact) = candidates.iter().find(|c| **c == request) {
1074        return RequestMatch::Named(exact);
1075    }
1076    let last_segment = |n: &str| n.rsplit('.').next().unwrap_or(n).to_string();
1077    let tail = last_segment(request);
1078    let mut hits: Vec<&'a str> = candidates
1079        .iter()
1080        .copied()
1081        .filter(|c| last_segment(c) == tail)
1082        .collect();
1083    hits.sort_unstable();
1084    hits.dedup();
1085    match hits.len() {
1086        0 => RequestMatch::Missing,
1087        1 => RequestMatch::Named(hits[0]),
1088        _ => RequestMatch::Ambiguous(hits),
1089    }
1090}
1091
1092fn build_grid_plan(group: Vec<VarGridding>, meta: &OutputMeta) -> Result<GridPlan, OutputError> {
1093    // Dimensions first: a length conflict between two variables on one grid is
1094    // the more fundamental error, so it must win over a coordinate that merely
1095    // disagrees with one of the two.
1096    let mut dims: Vec<(String, usize)> = Vec::new();
1097    let mut owner: BTreeMap<String, String> = BTreeMap::new();
1098    for g in &group {
1099        for (d, name) in g.dim_names.iter().enumerate() {
1100            if name == &meta.time_dim {
1101                return Err(OutputError::TimeDimCollision {
1102                    base: g.base.clone(),
1103                    dim: name.clone(),
1104                });
1105            }
1106            match dims.iter().find(|(n, _)| n == name) {
1107                Some(&(_, len)) if len != g.shape[d] => {
1108                    return Err(OutputError::DimLengthConflict {
1109                        dim: name.clone(),
1110                        first: owner[name].clone(),
1111                        a: len,
1112                        second: g.base.clone(),
1113                        b: g.shape[d],
1114                    });
1115                }
1116                Some(_) => {}
1117                None => {
1118                    dims.push((name.clone(), g.shape[d]));
1119                    owner.insert(name.clone(), g.base.clone());
1120                }
1121            }
1122        }
1123    }
1124
1125    let coords = plan_dimension_coordinates(&group, meta)?;
1126
1127    let vars = group
1128        .into_iter()
1129        .map(|g| {
1130            let mut var_dims = Vec::with_capacity(g.dim_names.len() + 1);
1131            var_dims.push(meta.time_dim.clone());
1132            var_dims.extend(g.dim_names.iter().cloned());
1133            VarPlan {
1134                name: g.base.clone(),
1135                dims: var_dims,
1136                attrs: meta.attrs_of(&g.base).cloned().unwrap_or_default(),
1137                dtype: DTYPE_FLOAT64,
1138                gridding: g,
1139            }
1140        })
1141        .collect();
1142
1143    Ok(GridPlan {
1144        dims,
1145        time_dim: meta.time_dim.clone(),
1146        coords,
1147        vars,
1148    })
1149}
1150
1151// --------------------------------------------------------------------------
1152// Tests
1153// --------------------------------------------------------------------------
1154
1155#[cfg(test)]
1156mod tests {
1157    use super::*;
1158
1159    fn names(v: &[&str]) -> Vec<String> {
1160        v.iter().map(|s| (*s).to_string()).collect()
1161    }
1162
1163    /// Column-major slot names for `shape`, exactly as
1164    /// `simulate_array::compile::build_slot_tables` emits them (dim 0 fastest).
1165    fn col_major_slots(base: &str, shape: &[usize]) -> Vec<String> {
1166        let total: usize = shape.iter().product();
1167        (0..total)
1168            .map(|flat| {
1169                let mut rem = flat;
1170                let mut idx = Vec::with_capacity(shape.len());
1171                for &n in shape {
1172                    idx.push((rem % n) + 1);
1173                    rem /= n;
1174                }
1175                let joined: Vec<String> = idx.iter().map(usize::to_string).collect();
1176                format!("{base}[{}]", joined.join(","))
1177            })
1178            .collect()
1179    }
1180
1181    #[test]
1182    fn parse_cell_key_round_trips_and_rejects() {
1183        assert_eq!(parse_cell_key("u[3]"), Some(("u", vec![3])));
1184        assert_eq!(parse_cell_key("u[2,3]"), Some(("u", vec![2, 3])));
1185        assert_eq!(parse_cell_key("M.u[1,2,3]"), Some(("M.u", vec![1, 2, 3])));
1186        // Not cell keys.
1187        assert_eq!(parse_cell_key("u"), None);
1188        assert_eq!(parse_cell_key("u[]"), None);
1189        assert_eq!(parse_cell_key("[1]"), None);
1190        assert_eq!(parse_cell_key("u[a]"), None);
1191        assert_eq!(parse_cell_key("u[1,]"), None);
1192        assert_eq!(parse_cell_key("u[1"), None);
1193        // The Julia regex forbids `[` in the base name, so the FIRST bracket
1194        // opens the index list and a doubled suffix is not a cell key.
1195        assert_eq!(parse_cell_key("u[1][2]"), None);
1196    }
1197
1198    /// The load-bearing test: a NON-SQUARE 2-D grid, where a row/column-major
1199    /// mix-up is actually visible (a square grid passes a transposed
1200    /// implementation). `c` lives on a 3x2 grid; the flat state is column-major
1201    /// (dim 0 fastest) and the output must be row-major (dim 1 fastest).
1202    #[test]
1203    fn inversion_transposes_a_non_square_2d_grid() {
1204        let slots = col_major_slots("c", &[3, 2]);
1205        assert_eq!(
1206            slots,
1207            names(&["c[1,1]", "c[2,1]", "c[3,1]", "c[1,2]", "c[2,2]", "c[3,2]"])
1208        );
1209
1210        let g = &derive_output_gridding(&slots).unwrap()[0];
1211        assert_eq!(g.shape, vec![3, 2]);
1212        assert_eq!(g.dim_names, names(&["c_d0", "c_d1"]));
1213        // Row-major offset o holds the COLUMN-major flat index of that cell.
1214        // A transposed implementation would produce [0, 1, 2, 3, 4, 5].
1215        assert_eq!(g.flat_indices, vec![0, 3, 1, 4, 2, 5]);
1216
1217        // Encode the cell in the value: u at (i, j) = 10 * i + j (1-based).
1218        let mut u = vec![0.0; 6];
1219        for (flat, name) in slots.iter().enumerate() {
1220            let (_, idx) = parse_cell_key(name).unwrap();
1221            u[flat] = (10 * idx[0] + idx[1]) as f64;
1222        }
1223        let mut out = vec![0.0; 6];
1224        g.scatter(&u, &mut out).unwrap();
1225        assert_eq!(out, vec![11.0, 12.0, 21.0, 22.0, 31.0, 32.0]);
1226
1227        // ... and the C-order indexing arithmetic agrees cell by cell.
1228        for i in 0..3 {
1229            for j in 0..2 {
1230                assert_eq!(out[i * 2 + j], (10 * (i + 1) + (j + 1)) as f64);
1231            }
1232        }
1233    }
1234
1235    /// Rank 3 with three DIFFERENT axis lengths: every axis permutation and
1236    /// every stride mistake changes the answer.
1237    #[test]
1238    fn inversion_handles_a_rank_3_grid_with_distinct_axis_lengths() {
1239        let shape = [2usize, 3, 4];
1240        let slots = col_major_slots("f", &shape);
1241        assert_eq!(slots.len(), 24);
1242        assert_eq!(slots[0], "f[1,1,1]");
1243        assert_eq!(slots[1], "f[2,1,1]"); // dim 0 fastest in the flat state
1244        assert_eq!(slots[2], "f[1,2,1]");
1245        assert_eq!(slots[23], "f[2,3,4]");
1246
1247        let g = &derive_output_gridding(&slots).unwrap()[0];
1248        assert_eq!(g.shape, vec![2, 3, 4]);
1249        assert_eq!(g.dim_names, names(&["f_d0", "f_d1", "f_d2"]));
1250
1251        let u: Vec<f64> = (0..24).map(|i| i as f64).collect();
1252        let mut out = vec![0.0; 24];
1253        g.scatter(&u, &mut out).unwrap();
1254
1255        // out is C-order over [2,3,4]; u is column-major over the same shape.
1256        for i in 0..2 {
1257            for j in 0..3 {
1258                for k in 0..4 {
1259                    let row = (i * 3 + j) * 4 + k;
1260                    let col = i + 2 * (j + 3 * k);
1261                    assert_eq!(out[row], col as f64, "cell ({i},{j},{k})");
1262                    assert_eq!(g.cart(row), vec![i, j, k]);
1263                }
1264            }
1265        }
1266
1267        // gather is the exact inverse (the restart-read direction).
1268        let mut back = vec![0.0; 24];
1269        g.gather(&out, &mut back).unwrap();
1270        assert_eq!(back, u);
1271    }
1272
1273    #[test]
1274    fn scalars_are_zero_dimensional_and_share_one_grid() {
1275        let slots = names(&["a", "b"]);
1276        let gs = derive_output_gridding(&slots).unwrap();
1277        assert_eq!(gs.len(), 2);
1278        for g in &gs {
1279            assert!(g.is_scalar());
1280            assert!(g.shape.is_empty());
1281            assert!(g.dim_names.is_empty());
1282            assert_eq!(g.n_cells(), 1);
1283        }
1284        assert_eq!(gs[0].flat_indices, vec![0]);
1285        assert_eq!(gs[1].flat_indices, vec![1]);
1286
1287        let groups = group_gridding_by_grid(gs);
1288        assert_eq!(groups.len(), 1, "every scalar shares the 0-D grid");
1289        assert_eq!(groups[0].len(), 2);
1290    }
1291
1292    #[test]
1293    fn variables_are_sorted_and_grouped_by_spatial_signature() {
1294        let mut slots = Vec::new();
1295        slots.extend(col_major_slots("ocn.t", &[2, 2])); // [ocn.t_d0, ocn.t_d1]
1296        slots.extend(col_major_slots("atm.q", &[3, 2]));
1297        slots.push("scalar".to_string());
1298
1299        let gs = derive_output_gridding(&slots).unwrap();
1300        let bases: Vec<&str> = gs.iter().map(|g| g.base.as_str()).collect();
1301        assert_eq!(bases, vec!["atm.q", "ocn.t", "scalar"]); // sorted by base
1302
1303        // Positional dim names are per-variable, so the two fields are two
1304        // grids and the scalar is a third.
1305        let groups = group_gridding_by_grid(gs);
1306        assert_eq!(groups.len(), 3);
1307        assert_eq!(groups[0][0].base, "atm.q");
1308        assert_eq!(groups[1][0].base, "ocn.t");
1309        assert_eq!(groups[2][0].base, "scalar");
1310    }
1311
1312    #[test]
1313    fn shared_axis_names_collapse_two_variables_into_one_grid() {
1314        // Same axes, different order: the signature is the sorted SET, so axis
1315        // order must not fragment the grid (RFC §9).
1316        let a = VarGridding {
1317            base: "a".into(),
1318            shape: vec![2, 3],
1319            dim_names: names(&["lat", "lon"]),
1320            flat_indices: (0..6).collect(),
1321        };
1322        let b = VarGridding {
1323            base: "b".into(),
1324            shape: vec![3, 2],
1325            dim_names: names(&["lon", "lat"]),
1326            flat_indices: (6..12).collect(),
1327        };
1328        let groups = group_gridding_by_grid(vec![a, b]);
1329        assert_eq!(groups.len(), 1);
1330        assert_eq!(groups[0].len(), 2);
1331    }
1332
1333    #[test]
1334    fn malformed_griddings_are_refused() {
1335        // A scalar key appearing twice.
1336        let err = derive_output_gridding_from_map([("a", 0usize), ("a", 1usize)]).unwrap_err();
1337        assert!(matches!(err, OutputError::DuplicateScalar { .. }), "{err}");
1338
1339        // Cells of differing rank.
1340        let err = derive_output_gridding(&names(&["u[1]", "u[1,2]"])).unwrap_err();
1341        assert!(matches!(err, OutputError::RaggedRank { .. }), "{err}");
1342
1343        // A hole in the grid (5 cells declared over a 3x2 extent).
1344        let err =
1345            derive_output_gridding(&names(&["u[1,1]", "u[2,1]", "u[3,1]", "u[1,2]", "u[2,2]"]))
1346                .unwrap_err();
1347        assert!(matches!(err, OutputError::SparseGrid { .. }), "{err}");
1348
1349        // A 0 index (cell keys are 1-based).
1350        let err = derive_output_gridding(&names(&["u[0]"])).unwrap_err();
1351        assert!(matches!(err, OutputError::ZeroIndex { .. }), "{err}");
1352    }
1353
1354    #[test]
1355    fn scatter_refuses_a_wrong_sized_buffer() {
1356        let slots = col_major_slots("c", &[3, 2]);
1357        let g = &derive_output_gridding(&slots).unwrap()[0];
1358        let u = vec![1.0; 6];
1359        let mut too_small = vec![0.0; 5];
1360        assert!(matches!(
1361            g.scatter(&u, &mut too_small).unwrap_err(),
1362            OutputError::BufferLength { .. }
1363        ));
1364        let mut trajectory = vec![0.0; 12];
1365        g.scatter_record(&u, &mut trajectory, 1).unwrap();
1366        assert!(matches!(
1367            g.scatter_record(&u, &mut trajectory, 2).unwrap_err(),
1368            OutputError::BufferLength { .. }
1369        ));
1370    }
1371}