Version: v0.0.1

EarthSciIntegrator: decisions and tradeoff analyses

Facts are in survey.md and the design is in architecture.md . This file records each decision and the analysis behind it.

Decisions

#DateDecision
D12026-10-13A written spec plus a conformance suite, with independent native implementations in Julia and Rust. Python comes later as its own native implementation, not PyO3 bindings to Rust. This is the same pattern as EarthSciAST and EarthSciIO.
D22026-10-13The schedule lives in a separate run-config document. The schedule is the splitting tree, the per-operator algorithm, tolerances, subcycling and adjoint settings. The .esm solver block gains an external option that points at the run config, so the .esm itself stays integrator-agnostic. This needs an EarthSciAST spec change: today the solver-block RFC is advisory only and excludes algorithm names.
D32026-10-13Algebraic constraints get both paths: (a) a nested, quasi-static nonlinear solve inside a step, done PISM-style with the slow state frozen, and (b) monolithic semi-explicit index-1 DAE integration, M = diag(I, 0), with stiffly accurate Rosenbrock or SDIRK. Amended 2026-10-14 (D35): (a) is implemented in v1; (b) is specified in v1 and implemented after M6, unless eqice test F shows the nested path insufficient (survey §2 marks monolithic DAE ○ for eqice).
D42026-10-13Adjoints are method-level discrete adjoints in both languages (option A in §1). Every nonlinear and algebraic solve gets its adjoint from the implicit function theorem. Julia additionally offers Enzyme-on-step (option B) for leaves with no hand adjoint, and as the conformance oracle. Continuous adjoints are not supported.
D52026-10-13Gradients are taken with respect to scalar parameters, the initial state, gridded fields and forcing time series. Consequences: vjp_p must handle array-valued parameters. Forcing gradients accumulate per provider record, not per integrator epoch (U7 Decision 7; GEOS-FP’s collections are not co-phased): the in-model time blend chains to both records of the bracket, so the gradient has the size of the forcing dataset over the window. EarthSciASTDiff does not yet produce ∂f/∂(forcing) (“forcing-buffer columns” is unchecked in its README).
D62026-10-13The integrator owns partitioning by operator locality (§2.2), and MPI is in v1.
• pointwise shards (reseact-style sharding, using its executor contract), with per-cell dt on lane pools by default (D17; per-shard dt remains an option)
• column sets
• stencil tiles with halo exchange
• global solves
Hierarchy: rank → tile → shard. Executors: threads, processes and MPI ranks. Proposed: horizontal-only decomposition, so columns stay whole and column operators stay local, as in WRF, PISM and GEOS-Chem.
D72026-10-13Compiler contract: option 2 plus small asks (§3). The integrator infers structure from the flattened AST. Upstream asks:
• solver.external
• a public, thread-safe Rust RHS
• Float32 integration not refused
• per-term provenance carried through flatten
• a range-restricted RHS, needed in v1 because of D6/MPI; in Rust this means running the slab tape (simulate_array/tape) over sub-boxes with halo reads
D82026-10-13All code, including the planner (structure inference, splitting, derivative models, strategy choice), is implemented in both Julia and Rust against one shared spec. The planner’s output is a conformance artifact: a resolved run config plus derived documents, gated by goldens as EarthSciASTDiff does.
D92026-10-13First end-to-end milestone: reseact. Re-host the prototype and match its forward run and 48 h CONUS 4×5° gradient. (The prototype’s own sharded-vs-unsharded agreement was 1.3e-11; the gate for the new adjoint is D16’s ≤1e-9, since the arithmetic order differs from Enzyme-on-step.)
D102026-10-13Results are bitwise identical for a fixed decomposition (ranks, tiles, shards). All reductions use a fixed order. Different decompositions agree to tolerance, because per-shard dt depends on the shard.
D112026-10-13v1 runs on native CPU: Julia’s native in-place f! and Rust’s slab tape, with threads and MPI. Reactant/XLA and GPU come later, behind the same kernel interface.
D122026-10-13Distributed global solves use Krylov methods we control (GMRES, BiCGStab) with block-Jacobi ILU(0) or ASM preconditioning, in both languages. No PETSc dependency; a PETSc backend may come later. Amended 2026-10-14 (D32): “our own” means “not PETSc”, not “hand-written”. Julia uses Krylov.jl (gmres, fgmres, bicgstab, block_gmres; M=/N= preconditioners with ldiv; any AbstractVector, so GPU and distributed vectors work). Rust reuses faer’s bicgstab with its Precond traits and writes GMRES(m), ILU(0) and ASM, which no maintained crate provides. Cross-language agreement is at tolerance (conformance tier 3), so the two need not be the same code.
D132026-10-13Precision is generic over the scalar type T (f32/f64), and can be mixed per leaf. Time, dt, error norms and global reductions are always f64.
D142026-10-13EarthSciASTSplitter is folded into the planner. Its additive-term splitting and locality rules become planner passes 1–3: specified, with Julia starting from its 636 lines and Rust implementing to the spec. It is retired once reseact, its only user, moves to the integrator after M3.
D152026-10-13Julia has a GPU path in v1, through XLA (amends D11). The integrator’s kernel layer gets an XLA lowering: each leaf step (RHS, stages, linear solves, controller) is one XLA program, traced by Reactant. CPU stays native in both languages. Rust GPU comes after v1 (XlaBuilder). GPU is an optional conformance capability, so Rust declares it unsupported in v1. Analysis in §4.
D162026-10-13M4 gate: the new adjoint matches the prototype’s 48 h CONUS gradient to ≤1e-9 relative, plus exact dot-product and replay gates.
D172026-10-13Pointwise leaves default to per-cell dt on lane pools; stencil leaves to global dt. shard and global remain options.
D182026-10-13Precision roles per leaf, amending D13 (architecture §8): f16/bf16 for W, LU factors, preconditioners and stored tables, with equilibration and iterative refinement; f32/f64 for the state; f64 for time, norms, reductions and adjoint accumulators.
D192026-10-13A domain violation rejects the step by default. clip must be requested explicitly.
D202026-10-13Rank and tile decomposition is horizontal only, so columns stay whole. Chemistry shards may still mix levels.
D212026-10-13The auto schedule is a Lie sequence in locality order stencil → column → pointwise. The solver.splitting hint upgrades it to Strang.
D222026-10-13No continuous root-finding events in v1.
D232026-10-13No multi-domain nesting for now. It is not planned, and no design hooks are reserved for it.
D242026-10-13The run config is JSON with its own JSON Schema and an esm_integrator version, independent of esm.
D252026-10-13Picard is expressed as a frozen-observed Jacobian (freeze: [...] in the run config).
D262026-10-13Accumulated diagnostics are integrated states that the planner adds.
D272026-10-13Names: EarthSciIntegrator.jl and the earthsci-integrator crates (core, -planner, -solvers, -mpi).
D282026-10-13The GPU gate (M5g) runs two configurations: f64 state, and mixed (f32 transport state, 16-bit W, chemistry on CPU). Either kind of GPU then works when hardware is chosen.
—2026-10-13RFCs are drafted for all upstream asks U1–U15 in rfcs/ , for review before anything is filed upstream. (U14 and U15 were added on 2026-10-13/14 after the first thirteen.)
D292026-10-13Numerical precision is separate from semantic precision. A declared element_type (document or variable) stays semantic and binding (esm-spec §11.3.1). An omitted one means “unspecified”: f64 by default, but the run config’s precision roles may choose f32 state or RHS per leaf, with the error judged against tolerances, and the integrator reports what it chose. Mixed precision across leaves is legal because conversions happen at leaf boundaries. Spec change: U14.
D302026-10-14EqWeFiC’s WRF meteorology leaves element_type unspecified (D29), so WRF-Chem’s double-precision chemistry can read it without a widening operator. Bit-faithful real*4 rounding is given up; tests already compare f64 runs against real32 WRF at rel 1e-5, and 3-D agreement is statistical.
—2026-10-13GPU hardware target: unknown. Design for both FP64-strong (A100/H100) and FP64-weak GPUs. Precision roles (architecture §8) make that a run-config choice.
D312026-10-14The method families are written from scratch in both languages, and this is why. Not because nothing exists: OrdinaryDiffEq.jl has every family, reseact’s native reference already runs SSPRK43 + Rosenbrock23(linsolve=LU) on an EarthSciMLBase BlockDiagonal through OrdinaryDiffEqOperatorSplitting, and diffsol 0.11 (the in-crate Rust solver) has tableau-generic SDIRK/ESDIRK, NDF-BDF and ERK. The reasons are: (i) D4’s method-level discrete adjoint needs the forward step’s stage internals in the same code as its transpose, which neither library exposes as API; (ii) D17’s lane pools need one vectorized step with per-lane controllers, which no library does (EarthSciMLBase’s per-cell reinit! is the slow form of it); (iii) D8’s spec parity needs step semantics the spec owns. diffsol in particular is ruled out by facts, not taste: its adjoint is continuous (uniform checkpoints plus Hermite interpolation), it has no Rosenbrock, IMEX or PI controller, its ERK tableau check is FSAL-only (SSPRK tableaus are rejected), and its solver objects hold Rc/RefCell. What is reused: tableau coefficients and controller constants are taken verbatim, with citation, from OrdinaryDiffEqRosenbrockTableaus, OrdinaryDiffEqSSPRK, OrdinaryDiffEqSDIRK and OrdinaryDiffEqCore (reseact’s traced steps were already copies of OrdinaryDiffEq’s perform_step!); OrdinaryDiffEq and diffsol are the conformance tier-2 oracles for order and trajectory; and SciMLSensitivity.ReverseDiffAdjoint through OrdinaryDiffEq’s own methods is the oracle for each hand-written family adjoint on toy problems (§1.5).
D322026-10-14Julia’s linear and nonlinear solvers are library code where the structure allows it. Krylov: Krylov.jl (amends D12). Nonlinear solve nodes: NonlinearSolve.jl (NewtonRaphson, PseudoTransient, TrustRegion, Broyden/Klement; LineSearch.jl backtracking and strong-Wolfe; Anderson through the FixedPointAcceleration extension), driven through a LinearSolveFunction that wraps the structured W and Krylov.jl. The implicit-function-theorem adjoint (one transposed solve), the recover ladder, the Picard-as-frozen-observed Jacobian (D25) and the convergence-on-a-named-observed criterion stay integrator code; SciMLSensitivity.SteadyStateAdjoint is an optional implementation of the first. The batched per-cell solvers (block_lu, sparse_block_lu, banded) stay in-house in both languages: block_lu starts from reseact’s SoA blocksolve algorithm typed over the U10 LaneBlocks layout, with EarthSciMLBase’s BlockDiagonal LU (AoS, generic T, GPU-tested) as its oracle; sparse_block_lu is new everywhere (EarthSciASTDiff emits the Markowitz plan and fill pattern but never executes it). Rust writes all of these; argmin and diffsol’s Newton are tied to dense or Matrix-trait storage.
D332026-10-14The Julia implementation speaks SciMLBase. It implements SciMLBase.AbstractODEAlgorithm and the DEIntegrator interface (CommonSolve init/step!/solve!), so solve(esm_problem(...), Schedule(run_config)) works through EarthSciAST’s existing problem surface, and EarthSciAST’s composed data-refresh PresetTimeCallback, output sinks, checkpoint callback, remake, ensembles and name indexing keep working. Architecture §9’s discrete events are DiffEqCallbacks’ PresetTimeCallback, PeriodicCallback and DiscreteCallback, propagated into every leaf and substep. The OrdinaryDiffEqOperatorSplitting failure was dropping callbacks; the remedy is propagation, not a parallel callback vocabulary. Rust has no equivalent surface and defines its own.
D342026-10-14Checkpoint schedules are a plan artifact. Because the macro-step count is known in advance (fixed macro dt, tstops from the plan), the planner computes the revolve or multi-level schedule once, with Checkpointing.jl’s algorithms, and writes it into plan.json. Both runtimes execute the schedule; neither implements revolve. No Rust revolve crate exists.
D352026-10-14v1 scope cuts. BDF is out of v1 (no gate needs it; stiffly accurate Rodas and SDIRK cover the index-1 DAE path; the variable-order BDF adjoint was the subtlest item in risk 2). The ARK family ships only IMEXEuler in v1 (ARS222, KenCarp3/4 later; reseact measured KenCarp47 at 1.7× the split’s cost). D3b, monolithic DAE integration, is specified in v1 and implemented after M6 unless eqice test F requires it.
D362026-10-14Forward sensitivities, including dt tangents, are in v1 for Julia. The integrator is generic over the scalar type (D13), and EarthSciAST’s f! is ForwardDiff-transparent, so Dual numbers flow through the state, stages, error norm and controller, which gives the controller (dt) tangents reseact’s prototype carried. This is SciMLSensitivity.ForwardDiffSensitivity with convert_tspan=true, applied to our integrator. Survey §2 marks this ● for reseact; the architecture had omitted it. Rust forward sensitivities are post-v1.
D372026-10-14The first non-reseact model comes before MPI and GPU. A new milestone M4b runs eqice test F (31×31×61) on shared memory: solve nodes, maps, limits, IMEX columns. It needs no MPI, it is the first science capability that does not exist today, and it is the first test of the planner on a model it was not designed around. M5 (MPI), M5g (GPU) and M5b follow. M6 becomes eqice at ice-sheet scale under MPI, plus D3b if needed.
D382026-10-14Julia’s AD fallbacks go through DifferentiationInterface.jl, which is already in the depot: prepare_pullback/pullback! with AutoEnzyme(mode=Reverse) for vjp_u/vjp_p where a band model is unavailable, and AutoForwardDiff pushforwards for jvp. This replaces a dependency on SciMLSensitivity’s EnzymeVJP/ReverseDiffVJP wrappers, which pull in Zygote, ReverseDiff and Tracker.
D392026-10-23The adjoint functional’s time-integral term is integrated by the last leaf of the macro-step schedule, so ψ sees the state after every process. adjoint.functional.integrate_in names a different leaf (spec/adjoint.md §2.1).
D402026-10-23Forward sensitivities have two modes, amending D36. dt_tangents = false holds the step sequence fixed and is the exact linearization of the adjoint’s frozen-tape map; the M4 dot-product gate uses it. dt_tangents = true carries step-size tangents (reseact’s lane) and is checked only against finite differences of the adaptive map (spec/adjoint.md §2.3).
D412026-10-23A Dual number with f64 value type satisfies D13’s “error norms are always f64”. Its partials flow through the error norm into the controller when step-size tangents are on.
D422026-10-23SDIRK adjoints solve the exact transposed system at each converged stage. Per-cell and per-column solvers (block_lu, sparse_block_lu, banded) refactor W there; Krylov leaves iterate on the exact system with the forward factorization as a preconditioner. Reusing the forward factorization outright is exact only for Rosenbrock (spec/adjoint.md §4.3, architecture §6.1).
D432026-10-23IMEXEuler solves its implicit part to convergence by default. The leaf option implicit_solve: "linearized" takes one linearized step instead, whose adjoint is a one-stage Rosenbrock adjoint (spec/adjoint.md §4.4).
D442026-10-23Rust adjoints are gated by finite differences on the frozen tape plus agreement with Julia’s gradient, not by an exact dot-product identity. Rust has no forward sensitivities in v1 (D36) and writes no tangent-linear step per family; the exact dot-product part of the M4 gate is Julia’s (spec/adjoint.md §7.1).
D452026-10-23Years and months are not duration units in the run config, because they are not constant durations. Year-scale settings are written in days; calendar-aware periods, if needed, would be a separate construct.
D462026-10-23forcing_refresh is the only forcing hit source: the times any provider’s two-record bracket changes. forcing_records is dropped.
D472026-10-23frozen is an option of the leaf, coupled or split_explicit node whose stages it feeds, not a standalone schedule node.
D482026-10-23When every fires, its child advances by the time since its previous firing, ending at the end of the current step (WRF-Chem chemdt, PISM’s accumulated bed-deformation step).
D492026-10-23The macro step has no error control of its own. Leaves adapt inside it; a schedule whose root is a single adaptive leaf takes that leaf’s accepted steps as macro steps.
D502026-10-23Under a fixed macro step, every firing times are not tstops. The node fires on the macro step that contains the firing time, as WRF does for radt and chemdt, so periods need not be multiples of step.dt. Under adaptive macro steps, firing times stay tstops.
D512026-10-23Picard freezing is written only as method.picard.freeze on a solve node. The operator-level freeze is dropped, so the planner’s frozen_jacobian output has a single trigger.
D522026-10-23A leaf whose method has no embedded error estimate must give dt. Omitted dt always means adaptive, and the planner refuses it for ForwardEuler, SSPRK33, SSPRK104, WRF_RK3, IMEXEuler and Theta, rather than defaulting by method.
D532026-10-23M1’s toy problems are written by hand in both languages, behind the operator interface (spec/operator-interface.md), with hand-derived derivatives. .esm versions join the conformance suite once U4 gives Rust a public, reentrant right-hand side, and must give the same results. Amended 2026-10-26: U4 shipped in EarthSciAST 0.5.0 (rhs_program in Julia and Rust), so both Julia and Rust run the .esm versions from M2 (spec/toy-problems.md §4).
D542026-10-23Method coefficients and controller constants are shared data, spec/tableaus/*.json, generated once from the installed OrdinaryDiffEq packages by tools/tableaus/extract.jl with source citations. Both implementations read the same numbers, which implements D31’s “taken verbatim” and keeps Julia and Rust from drifting.
D552026-10-23Kvaerno3/4 use the correct stage-2 time (the row sum $2\gamma$), not OrdinaryDiffEqSDIRK’s $\gamma$, which makes them first order on time-dependent problems. Comparisons against OrdinaryDiffEq’s Kvaerno methods use autonomous problems only. Not reported upstream.
D562026-10-23The rule for differences from OrdinaryDiffEq: follow it unless that gives wrong or low-accuracy results. Applied to the smoothed SDIRK error estimate: OrdinaryDiffEq’s is scaled by $h\gamma$, which measured 13–6000 times the tolerance in end-of-run error, so the spec uses Hosea and Shampine’s formula (spec/methods.md §5.5). That fixes the non-stiff case and TRBDF2 on a stiff problem; for SDIRK2 and Kvaerno3/4 on stiff problems it does not, settled for v1 by D59. Not reported upstream.
D572026-10-23SDIRK forms its Jacobian on every step attempt in v1. OrdinaryDiffEq-style reuse across steps is revisited at M4b with eqice’s column-solve costs.
D582026-10-23KPP’s own step-size rule becomes a controller option at M7 (controller: "KPP"), for WRF-Chem parity. M1 runs KPP coefficients under the PI controller.
D592026-10-23v1 keeps Hosea and Shampine’s SDIRK error estimate for all SDIRK methods, and recommends TRBDF2 for stiff adaptive leaves. On a stiff test problem the estimate leaves SDIRK2 and Kvaerno3/4 far above the tolerance at tight tolerances, and no alternative measured so far is both accurate and efficient. They stay available, documented as unreliable there; the estimate is revisited at M4b with eqice’s column solves (spec/methods.md §5.5, §13 item 5).
D602026-10-23The run interval comes from the caller, the run config or the model, in that order of preference. The caller is an API argument or a command-line option; the run config’s key is time (start with end or duration, as seconds or RFC 3339 UTC timestamps); the model’s is the .esm solver.time_span, which needs an upstream addition (U16, EarthSciAST#550). The most preferred source that gives an interval gives all of it. The planner resolves it for every run and records it in seconds, but not its source, so the same interval gives the same plan; a timestamp makes model time POSIX time, and start and end are both seconds or both timestamps (spec/run-config.md §1.1).
D612026-10-23After an accepted step shortened to land on a stop, the next proposal comes from the shortened size if the leaf’s previous attempt was accepted, and from the unshortened size on the first attempt of the run or after a rejection, as OrdinaryDiffEq does (its loopheader! overwrites the saved unshortened size only after an accepted step). Under D56 the spec follows OrdinaryDiffEq, so adaptive runs match its step counts; the cost is that under splitting each invocation starts from its short landing step. One exception: a stop step shorter than machine epsilon leaves the proposal and controller unchanged, where OrdinaryDiffEq’s would shrink the next step to that size (spec/methods.md §8.6, §10). Found by the Rust M1 implementation.
D622026-10-24Rosenbrock23’s error estimate adds $h d f_t$ in its third stage, not OrdinaryDiffEq’s $h f_t$. OrdinaryDiffEq’s term makes the estimate second order on problems that depend on time explicitly, so it overstates the error and takes smaller steps than the tolerance needs: the common case in atmospheric chemistry, where photolysis varies through the day. The solution is unchanged, and fixed-step runs and the oracle still match OrdinaryDiffEq; adaptive step sequences on such problems do not. D56 applied to a low-order estimate, like D55 (spec/methods.md §4.3). Found by the Rust M1 implementation.
D632026-10-24The Newton rate $\theta_{\text{prev}}$ of an implicit leaf is 1 at the start of every step attempt and carries across that attempt’s stages only. OrdinaryDiffEq carries it across steps and resets it only in the attempts that follow a rejection, but the tape does not record where rejections fell, so replay could not reproduce such a step bitwise (D10); with the reset the step is a function of $(u_n, h)$ alone, as the Jacobian rule already makes it (D57). The cost is up to one more Newton iteration on the first implicit stage of a step, since with $\theta_1 = 1$ the first iteration converges only when its increment is below $10^{-5}$. $\theta_{\text{prev}}$ and $\eta_{\text{old}}$ (which OrdinaryDiffEq overwrites before use, so it has no effect) leave the controller state and checkpoints (spec/methods.md §5.2, §8.6; spec/tape.md §4). Found by the Julia and Rust M1 implementations.
D642026-10-25A term is one additive summand of one U2 contribution. The planner takes each state equation’s equation_contributions (EarthSciAST 0.4) and splits every contribution’s expression with EarthSciASTSplitter’s decomposition (n-ary +, binary and unary -, nothing else); each summand inherits its contribution’s origins. Splitting below the contribution is what lets one component’s mixed pointwise and stencil terms reach different default operators (architecture §3.2 pass 1); splitting no further than + and - keeps the decomposition a few lines that two languages cannot disagree on. A term is named in the plan by (variable, contribution, summand) and carries a provenance label: the component path, coupling:<k>, or several joined by + for a collapsed contribution. The run config’s terms selector matches these labels (spec/plan.md §5.1).
D652026-10-25The components selector attributes a term by provenance first, then by what it reads. A term whose contribution has one component origin belongs to that component. Any other term (a couple term, or a collapsed contribution) belongs to the one component, other than its dependent variable’s own, whose variables it reads; if it reads none it belongs to its dependent variable’s component. A term that reads variables of two or more other components belongs to none: a components selector never matches it, and if no other selector takes it while any operator selects by component, the planner refuses with ambiguous_term_owner, asking for a terms or variables selector. The alternative, choosing one owner by a rule, is a silent decision about physics (is a deposition sink scaled by a met field “deposition” or “meteorology”?), which is the kind provenance exists to avoid. Architecture §3.2’s observed inlining for EqWeFiC-style assembly components is deferred to M7 (spec/plan.md §5.1.3).
D662026-10-25Locality is read from each term’s own state-Jacobian bands, not from the AST. The planner differentiates a one-term derived document with EarthSciASTDiff and classifies the band entries’ column indices against their row indices per axis (spec/plan.md §5.2). EarthSciASTDiff already lowers index(makearray…), inlines observeds and clips regions, so the radius per direction is the maximum over makearray regions with no second offset analyser to keep in step across two languages. Horizontal offsets are reduced modulo the axis size into $[-\lfloor N/2\rfloor, \lceil N/2\rceil - 1]$, so a periodic seam (cell 1 reads cell N) is a reach of 1, not a global read; a gathered column (contracted band dimensions) or a non-affine column index is global on a horizontal axis and column on the vertical one.
D672026-10-25The run config names the grid’s axes: axes: {"horizontal": [...], "vertical": ...}. Locality (column versus stencil), the halo, and the meaning of partition.ranks/tiles all need to know which index sets are horizontal and which one is vertical, and an .esm document does not say (coordinates carries an axis only for source coordinates, and reseact has no coordinates block). Defaults: no vertical axis, and every index set that shapes a state variable horizontal, in index_sets order; more than two horizontal axes then is a refusal (axes_unresolved).
D682026-10-25Derived documents are esm 1.3.0 documents in the flattened model’s own component tree, with every name written fully scoped. A single model holding dotted names such as Chem.O3 does not load (both bindings read the dot as a reference to a system Chem, which they then cannot find; checked with EarthSciAST 0.4.0 in Python and Julia), so a derived document declares one model per top-level component, nested subsystems for deeper paths, and writes every reference, left-hand sides included, as its flattened dotted name, which both bindings resolve from anywhere. Flattening a derived document therefore gives back the original flattened names. Every document carries every state (D(x) = 0 where the operator does not write x), every parameter, the ic equations, and the observeds its terms read, so all of a plan’s documents share one state and parameter layout. Pointwise-lifted equations are rewritten as explicitly shaped arrays, D(x) = faq{…}; a lifted state’s index sets are those of the first declared shape with the lift’s extents (states, then parameters, then observeds, each in document order), since EarthSciAST’s lifted_shapes records extents but not index-set names. The bytes are EarthSciASTDiff’s stable_json over that tree (spec/plan.md §6). Amended 2026-11-02: a lifted state’s index sets are the shape EarthSciAST 0.8.0’s flatten records on it (EarthSciAST#623), no longer a search of the declared shapes, so the refusal lift_shape_unresolved is gone (spec/plan.md §6.1.3). The Jacobian document (D69) carries no ic equations, and is not an esm 1.3.0 document: its esm version is the one EarthSciASTDiff writes (1.1.0, or 1.2.0 when an expression carries units). Only the right-hand-side document is esm 1.3.0. Both documents carry the same domain, reduced as EarthSciASTDiff reduces it: temporal’s start, end and reference_time, element_type and array_type (spec/plan.md §6.1).
D692026-10-25The Jacobian document is the operator’s right-hand-side document with its state derivatives zeroed, plus a model _jacobian holding one band field J_k per band, built as EarthSciASTDiff’s evaluation document builds its band fields but in D68’s component tree and without coefficient hoisting (eval_cse = false). Evaluating its right-hand side gives every band’s coefficients at once with the stock EarthSciAST compiler. The band positions a runtime needs to scatter those fields (row, col, rows, row_idx, col_idx, contracted) go into plan.json as operators.*.structure.bands, in field order, because the esm schema has no slot for EarthSciASTDiff’s draft "jacobians" block and a derived document must stay a valid .esm. Amended 2026-11-02: the Jacobian document is EarthSciASTDiff 0.3.0’s own evaluation document of the flattened right-hand-side document (evaluation_document, cse = false; EarthSciASTDiff#49 lays it out in D68’s component tree), with its ic equations dropped, its esm version and its model name JacobianEval as EarthSciASTDiff writes them; the band fields are JacobianEval.J_k and the reserved top-level component name is JacobianEval (spec/plan.md §6.2). Amended 2026-11-08 (D90): subtree sharing is on (cse = true, cse_min_nodes = 12), and the planner passes pass 4’s bands to evaluation_document instead of letting it compute them again.
D702026-10-25Stiffness, where a leaf’s method is not given, is classified in this order: any term authored by a reaction system; for pointwise operators, the model’s solver.stiffness: "high"; then the Gershgorin bound. The bound is $\rho = \max_r \sum_c
D712026-10-25"auto" methods resolve by a fixed table (spec/plan.md §5.5.3): pointwise stiff → Rosenbrock23 at reltol ≥ 1e-3, else Rodas4, with block_lu for blocks of at most 16 and sparse_block_lu above; pointwise non-stiff → Tsit5; column stiff → TRBDF2 with banded (D59); column non-stiff → Tsit5; stencil and global → SSPRK43, whatever the stiffness, since adaptive error control bounds their step; a stiff global operator, a stiff pointwise leaf whose Jacobian is not block_diagonal (mixed shapes, or an operator without structure.block), or a stencil leaf with a stiff column operator and no dt, is refused (auto_strategy_unavailable). dt_scope resolves to cell for pointwise leaves (D17) and global otherwise. Architecture §3.5’s Gershgorin step limit for stencil leaves is not emitted at M2: a step limit is a limit operator, which needs U6 (M4b).
D722026-10-25"schedule": "auto" puts one leaf per locality class in the order global → stencil → column → pointwise, each holding every operator of that class sorted by name; a single leaf is the root itself, not a one-child sequence. D21 named stencil → column → pointwise; global, which D21 did not place, goes first because a global operator (a whole-domain solve or reduction) produces what the local ones consume. Without the unwrapping, a model with one locality could not be planned with an adaptive root and no step (D49). solver.splitting: "strang" turns a sequence of two or more leaves into strang.
D732026-10-25The resolved schedule is canonical: planner-assigned node ids are dotted paths (root, root.0, root.1.every), the form spec/tape.md already records, and a run config may write a path-form id only on the node whose path it is, so given and assigned ids never collide; a leaf’s operators are always a list; durations are seconds; options with a spec default are written, options that do not apply to the leaf’s method (jacobian and linsolve on an explicit method) are absent rather than defaulted, and options whose default is computed at run time or means “none” (dt0, dtmax, dtmin) appear only when given (spec/plan.md §7.1).
D742026-10-25Timestamps from the caller and the run config count from the model’s time origin, exactly as EarthSciAST 0.4’s resolve_time_span converts a solver.time_span timestamp (esm-spec §2.2.7: domain.temporal.reference_time, else domain.temporal.start, else the POSIX epoch, by exact integer arithmetic). Amends spec/run-config.md §1.1, which fixed run-config timestamps at the POSIX epoch: with a model that declares reference_time, the two sources would otherwise put the same written instant at different model times.
D752026-10-25M2 plans right-hand-side operators only. Algebraic equations, events, parameter update rules of every kind (schedule and data included), map operators, observed step limits, solve nodes and the constructs of spec/run-config.md §8 rule 16 are refused with a code that names the construct (spec/planner-errors.json). schedule and data updates are out because they need forcing epochs and tstops from providers, which the runtimes gain at M3; the rest need U6 (M4b). Amended 2026-11-02 (D87): data rules are planned and run at M2, both halves: the planner needs no epochs to plan them, and the runtimes build forcing epochs and forcing_refresh tstops from EarthSciIO providers at M2 instead of M3.
D762026-10-25Pass 8’s checks and tolerances. rhs_sum: at the three sampled states, the operators’ right-hand sides sum to the flattened model’s within $10^{-12}$ of $\max(
D772026-10-25Partition defaults at M2: ranks and tiles [1, 1]; one shard per pointwise or column leaf, and "auto" shards resolve to 1 until M3’s cost model; halo per horizontal axis is the largest radius in either direction over all stencil operators (every rank runs every operator in v1); output_shard is absent until output sinks are planned (U12).
D782026-10-25Until M4, checkpoint_schedule is the fixed placeholder {"kind": "all", "macro_steps": 0}, present exactly when the run config has an adjoint key, so plans that will carry a schedule already have the key and none carries a number M2 cannot compute consistently (D34).
D792026-10-25Planner refusals carry stable snake_case codes listed in spec/planner-errors.json , EarthSciAST’s style (solver_time_span_invalid), one per rule of spec/run-config.md §8 and per new M2 refusal. Conformance refusal fixtures assert the code and the run-config pointer, never the message text. Amended 2026-10-26 (M2 stage 1): a refusal whose cause has no run-config key of its own, or whose key is not obvious, gets a fixed pointer, given in its code’s description in spec/planner-errors.json, the one list of them (spec/run-config.md §8 points there and names only examples): "" for a schema failure and for refusals about the caller’s interval, /model for refusals about the model alone, /schedule for a default operator no node advances, the precision entry that set the role for precision refusals, and so on.
D802026-10-25M2’s end-to-end runtime gate replays the M1 cases on .esm versions of the toy problems and compares each language’s planned run with its own hand-written M1 run by the cross_language rule of spec/toy-problems.md §2.1. Only cases whose leaves need nothing beyond rhs, jac, jvp_u and vjp_u take part: M2 derives no time-derivative document (EarthSciASTDiff computes wrt = :time bands but neither specifies nor golden-gates them; U17, needed by M3), no parameter Jacobian (U7) and no second-order bands (U8), so Rosenbrock leaves on time-dependent problems, adjoint checks and the map problem wait for M4/M4b (spec/toy-problems.md §4).
D812026-10-26Passes 1 and 2 are interleaved, and the model-only refusals come at load. The planner builds the terms and their owners (spec/plan.md §5.1.1–§5.1.2), computes every term’s locality (§5.2), selects operators (§5.1.3), then computes the operators’ classes and radii (§5.2.4). Selection reads a term’s locality (a locality selector, the default operators), so it cannot run before it; as a result band_model_failed precedes every selection refusal. Selection’s own refusals come in a fixed order (map operators, then variables entries, then per term, then selector_matches_nothing). reserved_component_name is checked at load, right after the M2 scope (§4 step 7), and lifted-state shapes are resolved with the axes (§4 step 8, lift_shape_unresolved), even when axes is given, because the default axes need them; neither waits for bands. Amended 2026-11-02 (D68): lifted-state shapes are read from flatten, so lift_shape_unresolved no longer exists. The classical-Rosenbrock precision check of a leaf whose alg is "auto" runs once §5.5.3 has resolved its method. Found by both stage-1 planners (Julia PR #21, Rust PR #20).
D822026-10-26Offsets on an other axis do not affect a term’s class; a reach that covers an other axis still makes it global. An other axis is an index set a state uses that the run config’s axes names neither horizontal nor vertical. It is never divided among shards, tiles or ranks, so a fixed offset along it couples entries inside one cell’s block, which every locality class already holds; a reach over the whole of it is a dependence on every entry, which spec/plan.md §5.2.4 has always called global. The rule “lower + upper + 1 exceeds the axis size” is deleted from §5.2.4: reduction keeps every offset in $[-\lfloor N/2 \rfloor, \lceil N/2 \rceil - 1]$, so it could never fire.
D832026-10-26output.saveat is a duration $d$; the output times are its multiples $k,d$ counted from model time zero (the time origin of D74) that lie in $(t_0, t_f]$, and $t_f$ is not added. Amends spec/run-config.md §7.3, which spoke of “listed” output times although the schema has always made saveat a single duration. Counting from time zero, as hit_multiples does, puts hourly output on the hour with timestamps. Without saveat the output time is $t_f$ alone. The adjoint’s over: "saveat" observation times, and rule 9’s check of them under a fixed step.dt, use these times. Both ends use the landing tolerance of spec/run-config.md §5 (“Fixed boundaries”, $
D842026-10-26Rule 5’s “advanced twice” means named by two leaf or coupled nodes of the tree; a frozen entry counts as advancing for “never advanced” but not for “twice”. A strang that runs a child twice runs it at one place in the tree, which is what the rule’s “outside strang” exempts. A frozen entry makes the operator’s tendency enter a node’s stages, so an operator that appears only there is advanced; it does not count toward “twice”, so one node may freeze an operator that another node names. Rust’s stage-1 reading; Julia counted frozen entries toward both. A default operator advanced by no node is reported at /schedule, since it has no run-config key (spec/plan.md §7.1).
D852026-11-02Pass 2 computes every term’s bands from one probe document, whose new states live in the reserved component JacobianEval, and, when the probe fails, finds the failing term by probing halves of the term list. One document per term costs one EarthSciAST load and flatten each, and each document holds the whole model, so their load and flatten cost grows with the number of terms times the model’s size. The probe document gives each term its own new state JacobianEval.term_k, shaped on the term’s equation’s state, so one jacobian_bands call yields every term’s bands (spec/plan.md §5.2.2). Reusing JacobianEval, already refused as a top-level component name (§6.1.4), needs no new reserved name or refusal. A failure on the probe document does not say which term caused it. Falling back to one document per term would pay the whole per-term cost on top of the probe’s, in exactly the case that refuses, so the planner instead probes the first and second halves of the term list in turn, splitting any half that fails, down to single terms, whose own documents it differentiates: about 2 log₂ n probe documents. It reports band_model_failed against the same term, with the same message, as before. The spec fixes only that result, not the search, so the two planners may search differently. On every plan fixture case, the probe’s band lists equal the per-term ones band for band. The probe is taken in batches of consecutive terms with at most 10,000 operator nodes in all, a larger term being a batch by itself, since EarthSciASTDiff’s entries for one of reseact’s advection terms hold about 2.6 GiB of coefficients and a probe of all of them did not fit in memory. The bound is on a batch’s total, not on how many large terms it holds, so that many terms just under the bound cannot make a batch as large as the whole list. Pass 2 keeps no coefficient, so a planner drops each batch’s before probing the next. A failure on a batch’s probe is searched within that batch, which, the batches being probed in order, finds the same term. Each probe also writes the subexpressions its terms share once, as zero-parameter templates of JacobianEval: reseact’s advection term, a graph of about 9,000 distinct nodes in the flattened model, was a 5.6 MB tree in the probe and reloaded as about 169,000 nodes. Templates are part of the format both EarthSciAST loaders read, and EarthSciASTDiff expands them, so this needs no new interface; handing EarthSciASTDiff an in-memory system instead would have needed a way to build one that neither EarthSciAST offers in both languages, and would have skipped the load and flatten that define the band list.
D862026-11-02A probe document that succeeds gives every term’s bands; no term’s own document is checked as well. §5.2.2 defines a term’s band list by its own document, and that document can in principle fail where the probe does not, since the probe moves the term onto the new state JacobianEval.term_k, giving the term’s own state the zero right-hand side. Checking each term’s own document after a successful probe would bring back the one-document-per-term cost D85 removes. Instead the probe state is declared, initialized and written as the term’s state: the same shape and default, the same ic equations and the same equation, so the probe holds everything a term’s own document holds. Both planners’ tests check, on every plan fixture case and every refusal fixture that reaches pass 2, that the probe fails exactly when some term’s own document fails, and gives the same bands otherwise. A failure the probe still did not show surfaces in item 4 of the order of refusals (spec/plan.md §4), where each operator’s own document, which holds its terms with their own states, is differentiated: it is band_model_failed for that operator, later in the order and against the operator instead of the term. Pass 2 now reads EarthSciASTDiff’s structure-only entries (EarthSciASTDiff#59), which take no derivative, so a term whose derivative EarthSciASTDiff cannot take (underivable_operator) passes pass 2 and fails its operator’s document in item 4. The planner then differentiates the operator’s terms’ own documents in term order and reports the first that fails, with its message, so the refusal still names the term; only its place in the order moves, after selection. Structure-only entries cost no derivative and hold no coefficient, and their pattern includes the full mode’s (equal unless a coefficient simplifies to zero, which is a cancelling coupling that pass 2 then counts). Amended 2026-11-04: EarthSciASTDiff 0.4.0, the first release with the structure-only mode, refuses in that mode what the full mode refuses, an operator with no derivative rule included (esm-jacobian-spec §2.1), so an underivable_operator term now fails pass 2’s probe and is named by pass 2’s search, with the same message; pass 4’s search remains for a failure only the full mode meets.
D872026-11-02Data-fed parameters are planned and supplied at M2: the planner never reads data, and both runtimes fetch it and run leaves that read it. Proposed 2026-11-02; the user’s answers of 2026-11-02 to the proposal’s six questions are folded in. A data-fed parameter is one that a loader_fields entry of the flattened model names (spec/plan.md §4.1). (1) In derived documents it stays a parameter whose update is one data rule naming its source and file variable, and the document carries a stub data_sources entry per source, {"kind": K, "source": {"url_template": "{esm_integrator_forcing}"}}: a substitution-led template that EarthSciAST leaves unchanged at load, so no path enters a golden, and that makes both bindings’ rhs_program take the parameter as a forcing slot (§6.3). The planner writes these declarations itself until two upstream fixes are released (EarthSciAST keeping update through param_to_var, EarthSciASTDiff’s evaluation document carrying update and data_sources; §10 items 16, 17). plan.json gains forcing (shape, source, file variable, cadence, records per sample) and operators.*.forcing, both absent for a model without data, so every existing golden is unchanged. (2) The planner’s checks evaluate a used data-fed parameter at $b (0.5 + r)$, $r$ from D70’s SplitMix64 at sample index $s + 3$ and $b$ its default, which must be nonzero at every element (forcing_without_scale otherwise, checked after pass 3; a parameter no operator and no ic equation reads is not checked). The values check identities, read no file, and are the same in both languages; band-value files list them under forcing. Stiffness rule 4 (D70) does not use them: an operator whose Jacobian reads a data-fed parameter and would be classified by the Gershgorin bound is refused (auto_stiffness_reads_forcing), and the run config names its alg. (3) At run time every operator call takes the forcing set as its last argument (spec/operator-interface.md §8.4). The runtime owns the data but writes no data layer of its own: it loads the plan’s model, builds one provider per data-fed parameter with EarthSciAST’s providers_from_document on EarthSciIO, converts each sample to a buffer with EarthSciAST’s public conversion (the one esm_problem uses), and swaps one immutable set per forcing epoch; forcing_refresh must be in step.hit (forcing_refresh_not_hit). Everything an esm data source can declare is supported at M2 (mirrors, select, codes, record filters, extents, unit conversions, points sources, calendar cadences, custom substitutions bound by the model), so reseact can run end to end. The model locates the data and declares which records a parameter receives (update.from.records_per_sample); cache directory, offline mode and URL overrides are run-time options, outside the run config and the plan; data identity goes in the run’s report, not plan.json. Time interpolation stays the model’s. This rests on upstream work that arrived on 2026-11-02 (EarthSciAST#632, #634, #635, #636; EarthSciIO#27–#30; EarthSciASTDiff#55): update.from.records_per_sample and metadata.esio_format in the esm spec, EarthSciIO template expansion, calendar cadences and document-built providers in every language, and EarthSciAST’s public forcing_buffer. What remains is listed in spec/plan.md §10 items 19 (a records_per_sample follow-up), 21 (EarthSciIO) and 22 (Rust EarthSciAST bugs). (4) Band and locality analysis treat a data-fed parameter as a constant of the state Jacobian, like any parameter; its derivatives are the forcing axis (U7, M4). The gate is three end-to-end cases on checked-in data with exact solutions: data_decay (two-record bracket across files), data_monthly (calendar cadence, custom substitution, mirrors, select, expression unit conversion) and data_points (points source, codes, record filter, extent) (spec/toy-problems.md §4.4–§4.6). Analysis: §7.
D882026-11-04At a forcing epoch boundary each data-fed parameter takes its limit from the left, and a step never reuses its first-same-as-last stage across one. A stage at exactly the right end of a step that ends at an epoch start $B_{k+1}$ takes epoch $k+1$’s buffer for a parameter with records_per_sample 2 (time-interpolated: the model’s blend weight wraps to 0 at a record time, so the new bracket gives the continuous value) and epoch $k$’s for one with records_per_sample 1 (step-like, such as a monthly inventory); a step that starts at $B_{k+1}$ takes epoch $k+1$’s for both, so its first stage is evaluated anew rather than reused (spec/operator-interface.md §8.4.1). Replaces D87’s rule that every buffer of the right-end stage is the new epoch’s, under which data_monthly’s Tsit5 run erred by 2.3e-8 against its 1e-10 reference tolerance (the right-end stage carried next month’s rate with weight about 2.3), and the old epoch alone still erred by 2.4e-3 through the reused stage. Found by the Rust runtime (PR #58); decided by the user on 2026-11-04.
D892026-11-06Programs are built with EarthSciAST’s native compiler only, outside tests; a Jacobian document gives every state without a default the default 1.0 (zero until the amendment of 2026-11-07). The Rust planner built each program natively and, when the native compiler refused a rule, built it again with the interpreter. That made a refusal silent and the planner’s numbers come from a different evaluator than the runtime’s, which builds natively (spec/operator-interface.md §8.1). Now a native refusal is an error in the planner and in both runtimes, and only tests ask for the interpreter, by name. Planning reseact with the Rust planner also found that a Jacobian document cannot be built when a state has no default: D69 drops its ic equations, so a state only an ic equation starts (reseact’s Transport3D.m) has no starting value, which EarthSciAST refuses at construction. The starting value is never read, so the planner writes it into the document: as it drops the ic equations it gives every state without a default the default 0.0. A first version had every builder (the planner and both runtimes) pass every state element at 0.0 as the build’s caller u0; review found that this left the document unbuildable on its own, made every builder of it, third parties included, remember the special u0, and cost one formatted element name per grid cell at every build (millions on reseact’s grid). The document changes only for a state with no default, so no golden changes. Zero rather than the plan’s $u_0$ keeps the document independent of its right-hand-side document’s initial state (spec/plan.md §6.2 item 4, §8). Amended 2026-11-07 (user decision): the value is 1.0, not 0.0, and a state with a default keeps the model’s: a Jacobian document’s construction values are its states’ defaults, with 1.0 where a state has none. Reseact’s transport Jacobian document has one state without a default, Transport3D.m, and 1,656 of its 1,668 band fields divide by it, so the document evaluated at its own initial state gave 1.83 million non-finite values in 16.6 million slots. No check reads them, but a document that is not finite where it starts misleads anyone who evaluates it there, a third-party builder included. With one, no coefficient divides by a zero the planner wrote, and one is as deterministic as zero; the plan’s $u_0$ is still not used, for the reason above. This does not guarantee a finite document at its construction values: it is finite there whenever it is finite at the model’s own defaults (with 1.0 for each missing one), and a state whose model default is 0.0 keeps it, so a coefficient dividing by that state is still not finite there. A "default": null in the built document counts as no default and gets 1.0 (a model’s own default cannot be null: the schema refuses it). A default is never an expression (the schema allows a number, broadcast over an array state, or inline array data, one value per element), so each element’s construction value is its own default. The Julia planner’s second build of a right-hand-side document without its ic equations, when they read data, gives such a state a placeholder 1.0 that nothing reads (its initial state comes from the build that keeps the ic equations). No existing golden changes, since every state of those fixtures has a default; the fixture inverse_decay (a state with no default that a band coefficient divides by) has its Jacobian document’s 1.0 in its golden (spec/plan.md §6.2 item 4, §8; spec/operator-interface.md §8.1).
D902026-11-08The Jacobian document is written with subtree sharing on (cse = true, cse_min_nodes = 12), from the bands pass 4 already computed. D69 wrote the document without EarthSciASTDiff’s coefficient hoisting. EarthSciASTDiff#66 (released in 0.5.0, with #64 and #65) makes subtree sharing a single pass over the distinct subtrees with a deterministic numbering rule that both bindings follow (esm-jacobian-spec §4.1), and #65 makes the band computation faster, so both planners can be held to the same bytes with it on. Without it every band coefficient carries its whole expression: on reseact the transport operator’s 1,668 band fields repeat the same advection and limiter subtrees, so its Jacobian document was the largest artifact of the plan, writing and loading it in the Julia planner took about 450 s and 700 s, and building it took about 50 GB in Julia and 35 GB in Rust. With sharing each repeated subtree of at least 12 nodes is one observed JacobianEval.Jcse<n> that the coefficients reference, which EarthSciAST’s builders evaluate once per cell per evaluation. On reseact the transport Jacobian document is 9.0 MB instead of 333 MB; the Julia planner writes it in 16.6 s and loads it in 7.7 s; and EarthSciAST 0.10.0’s Rust binding builds it natively (esm_problem then rhs_program) in 8 s at a 1.6 GB peak, where the document without sharing took 3.5 min and 35 GB (the Julia build was not measured again). The document’s values are unchanged (the band-value goldens do not move); its bytes, and so the goldens of the Jacobian documents and their sha256 in plan.json, change wherever a coefficient repeats such a subtree. JacobianEval therefore holds Jcse<n> observeds beside the band fields; nothing reads the model’s variables other than J_k by name. EarthSciASTDiff#64 lets the Julia planner pass its pass 4 bands to evaluation_document(flat, entries; ...) as the Rust planner already did, so the bands are no longer computed twice and §6.2’s check that EarthSciASTDiff’s own bands agree with pass 4’s is gone with the second computation. Alternatives: keep sharing off (simplest, but leaves the largest documents and builds as they were), or share with a smaller cse_min_nodes (more observeds, each saving less; 12 is EarthSciASTDiff’s default) (spec/plan.md §6.2).

1. Adjoint strategy: tradeoffs

Goal: a full-simulation adjoint, as in reseact. That means the gradient of a scalar functional of the trajectory with respect to parameters, initial state and possibly gridded fields. It must be exact for the trajectory that was actually computed: reseact checks to 1e-11 against a reference, with dot-product and FD gates.

1.1 Parts every option needs

The bulk of reseact’s tools/adjoint_gradient.jl is this machinery, and it is needed whichever way the per-step derivative is produced:

  • A step tape. Record the accepted (t, dt) sequence, the method per leaf, discrete decisions (event times, which maps fired, clamp masks) and forcing epochs. The backward sweep replays this tape with the controller off.
  • Checkpointing. A per-macro-step state checkpoint costs about 680 kB per step at CONUS 4×5°. At 0.25° a 5-day run would need about 188 GB, so we need multi-level checkpointing (memory plus disk) and revolve-style schedules for long or high-resolution runs.
  • Adjoint of composition. The adjoint of Lie(A, B) is B* then A*, and Strang, Sequence, Subcycle and Every compose the same way. Splitting is therefore not a problem for discrete adjoints.
  • Classification and functionals. Parameters are classified as active or inactive, by where each value is consumed. Cost functionals and observation operators are defined over outputs.
  • Validation. Dot-product identity per step, FD of the frozen-dt map, and replay-versus-checkpoint consistency.
  • Controller derivative. The derivative of the controller (dt as a function of state) is ignored, which is standard. reseact’s forward-sensitivity lane does carry dt tangents.

1.2 The choice: what does automatic or symbolic differentiation see?

A. Method-level discrete adjoint (AD sees one RHS / Jacobian evaluation)B. AD through the step (AD sees one whole step; this is reseact)C. Continuous adjoint (solve the adjoint ODE backward)
Who writes the step adjointThe integrator, once per method family: ERK/SSP, Rosenbrock, SDIRK/ESDIRK, BDF, ARK. It is generic over tableaus.Enzyme generates it for every method automatically.Nobody; it is method-agnostic.
Model primitives neededf, J·v, vjp_u, vjp_p. Rosenbrock also needs (∂(J·k)/∂u)ᵀλ, a second derivative. Implicit methods also need a structured W and its transpose solve.Only f. AD differentiates everything else, including the LU and the controller-free step.vjp_u, vjp_p.
Exact for the computed trajectory?Yes for explicit and Rosenbrock methods. For SDIRK, BDF and nested solves it is exact up to the nonlinear-solve tolerance, via the implicit function theorem (IFT).Yes, though differentiating Newton iterations is costly and needs custom IFT rules anyway.No. Consistent only to tolerance, which fails reseact’s gates and is ill-posed with limiters, maps and splitting.
Linear algebra in reverseReuses the forward factorization: a Wᵀ solve, batched block LU transposed, Krylov on Jᵀ for the SSA.AD differentiates the elimination itself. reseact’s unrolled blocksolve is reversed elementwise.Jᵀ solves from a fresh factorization.
Nested SSA and DAE constraintsIFT: one transposed sparse solve with g_zᵀ per step. This is the natural fit.Needs a hand-written IFT rule, so it falls back to A for this piece.IFT.
Rust parityFull. Primitives come from the compiler and the rest is plain integrator code.Weak. Rust Enzyme (std::autodiff) is nightly-only and experimental, as far as I know, and the Julia path depends on Enzyme+Reactant.Full.
Toolchain riskLow. AD or symbolic differentiation only ever sees straight-line RHS code.High, and documented in reseact: reverse-over-forward segfault, silently wrong Reactant checkpointing, dJ/dp silently vanishing through closures, stablehlo.case with no reverse rule.Low.
Cost evidence—reseact: transport VJP is about 19.6× one RHS (XLA recomputes face chains); ssp_vjp compile takes 195 s.Extra adaptive backward solve plus interpolation of the forward trajectory.
Maps, clamps, eventsHand rules: the VJP of a step map, which the compiler can emit since maps are .esm expressions; clamp → mask; event times frozen from the tape.Automatic.Event-time jump conditions; hard.
Engineering costHighest per method family. BDF needs an adjoint of a variable-step linear multistep method, but with the step sizes and orders fixed by the tape it is a known-coefficient recursion.Lowest per method. Orchestration (§1.1) is still hand-written.Low, but it misses the goal.

1.3 Where the model primitives come from (for A)

  1. EarthSciAST emits them by source-to-source transformation (recommended): f, jvp, vjp_u, vjp_p, hvp and the structured Jacobian (EarthSciASTDiff bands) from the same expression tree.
    • This works the same way for Julia, Rust and later Python, and can be conformance-tested.
    • Recurrences (faq sweeps such as Thomas solves or RRTM) need reverse-mode code generation for loops. The compiler has the structure to do that.
  2. Julia: Enzyme on the compiled RHS only. This is much smaller and more robust than Enzyme on a step.
  3. Rust: std::autodiff on the RHS, if it stabilizes.

1.4 Recommendation

  • A is the spec’d contract in both languages. IFT is used for every nonlinear and algebraic solve, and EarthSciAST emits the model primitives.
  • Julia additionally offers B (Enzyme on a step) for leaves that have no hand adjoint. B also serves as the conformance oracle that checks A’s hand adjoints.
  • C is not supported.
  • Rosenbrock’s second-derivative requirement is the main extra primitive A needs. For per-cell chemistry it is a small block-local HVP. Where it is unavailable, running SDIRK in adjoint mode avoids it, since SDIRK needs only first derivatives plus transposed solves.

1.5 SciMLSensitivity.jl (formerly DiffEqSensitivity.jl), considered 2026-10-14

Its catalogue is exactly the B and C columns of §1.2, so it cannot supply A:

Its algorithmsColumnWhy not the contract
BacksolveAdjoint, InterpolatingAdjoint, QuadratureAdjoint, GaussAdjointCContinuous: they integrate the adjoint ODE backward over a checkpointed or dense forward solution.
ReverseDiffAdjoint, TrackerAdjoint, ZygoteAdjoint, EnzymeAdjoint, MooncakeAdjointBAD through the solver’s steps; they require “the pure Julia solvers written generically”, i.e. OrdinaryDiffEq. The Enzyme and Mooncake variants are documented as experimental, explicit-only, and currently failing on most solvers.

No method-level discrete adjoint exists in it or elsewhere in SciML. What is reused: ReverseDiffAdjoint through OrdinaryDiffEq as the per-family adjoint oracle (D31); SteadyStateAdjoint, the IFT adjoint of a nonlinear solve, as an optional implementation for solve nodes (D32); the ForwardDiffSensitivity(convert_tspan=true) pattern for forward sensitivities with dt tangents (D36). Its callback handling (freeze firing times, differentiate the affect map by AD) matches architecture §9’s rule but is not lifted, since our maps are compiled .esm kernels with band models (U6, U7). Its VJP wrappers are replaced by DifferentiationInterface (D38).

2. Partitioning and parallelism: tradeoffs

2.1 What “every stage is global” loses

If the RHS is one whole-domain function and parallelism lives only inside it, every stage of every leaf touches the whole state and ends in a barrier. The costs:

  1. One dt per leaf for the whole domain. One stiff cell, such as the pre-dawn NO minimum, rejects the step for every cell. reseact measured its global chemistry dt at about 6× the per-cell ideal in cell-steps.
  2. Memory and locality. An implicit leaf holds stage vectors and Jacobians for all cells at once. On EqAtmChem d05 that is 6.7M cells × 64² × 8 B ≈ 220 GB of CBM-Z block Jacobians (est.); run per shard, it is O(shard). Each explicit stage also streams the full state through memory, so tiles cannot stay in cache across stages.
  3. Parallel efficiency is whatever the kernel achieves. In reseact, transport runs on about one core and XLA:CPU parallel efficiency is about 9%. There is a barrier per stage. Load imbalance cannot be rebalanced: chemistry costs 9.6 s/step pre-dawn against 35 s/step at midday.
  4. No distributed memory. State, checkpoints and I/O share one address space. That rules out WRF d05 with chemistry, and the roughly 188 GB of 0.25° adjoint checkpoints.
  5. No heterogeneous executors (processes, devices, ranks). reseact needed separate processes because XLA:CPU executions in one process do not overlap.
  6. Compile cost grows with the domain on tracing backends. reseact’s lesson was that compile and memory must be per tile.

Gained by staying global:

  • simplicity;
  • boundary conditions and halos are handled once, by the compiler;
  • global operators are natural: SSA, iceberg connected components, global norms;
  • a single GPU likes large kernels.

2.2 The partition follows each operator’s locality

LocalityPartitionHaloOwn dt?Examples
pointwiseshards: any set of cells, bucketed by expected costnoneper shard or per cellchemistry, deposition, microphysics rates
columnsets of columnsnone horizontallyper shardPBL, enthalpy/age, radiation, HEVI vertical
stencilgeographic tilesstencil radius, exchanged per stageper tile (multirate, later)advection, level set, SIA, hydrology
globalwhole domain or a distributed solver——SSA, connected components, global reductions

reseact-style stiffness sharding is the pointwise row. We adopt its executor contract from tools/shard_exec.jl:

  • species-major layout at the boundary;
  • per-shard partial error norms reduced in a fixed order, for determinism;
  • per-epoch lane refresh;
  • per-shard dJ/dp;
  • executors read no driver state.

Executors are threads by default, then processes, devices and MPI ranks. reseact used processes only because of XLA:CPU.

With per-shard dt, the adjoint tape is per shard, so tape memory scales with the number of shards, not cells.

The hierarchy is rank → tile → shard. In shared memory, tiles are index ranges over one global array and the halo “exchange” is a barrier. MPI later replaces this with real exchange, with no change to the leaf algorithms.

What the compiler must provide:

  • Pointwise and column partitions: nothing new. reseact’s capacity builds re-instantiate the operator on a lane grid with gathered inputs, at a build cost that depends only on the lane count.
  • Exact stencil tiles: a range-restricted RHS that evaluates cells in a range and reads halo cells from a neighbour buffer. Today’s flat f cannot do this. A tile re-instantiated as a small model would apply boundary stencils at its edges, so it would not match the global run.

3. Compiler contract: inferring strategy from the AST, integrator side

“Option 2” means: EarthSciAST changes only by solver.external. The integrator takes the flattened AST, infers the structure itself, and compiles each piece with the stock compiler. That is how reseact already works:

  1. flatten;
  2. EarthSciASTSplitter classifies terms by locality;
  3. each part is compiled separately;
  4. EarthSciASTDiff’s band model gives the Jacobian;
  5. capacity builds give the shards.

3.1 What option 2 gives

  • No waiting on spec RFCs.
  • Strategy inference becomes a feature, like DifferentialEquations’ default algorithm choice but structure-aware:
    • block-diagonal → batched Rosenbrock;
    • banded column → block tridiagonal;
    • stencil → SSP-RK with a Gershgorin bound on the band Jacobian as the step limit;
    • algebraic → Newton–Krylov.
  • It fits the .esm philosophy: solver-relevant classes are derived, not declared.

3.2 What it gives up or makes harder

  1. Authorial intent that structure cannot show.
    • Locality separates pointwise, column and stencil terms. It cannot tell radiation from PBL (both column), acoustic from advective (both stencil), microphysics-after-RK, the radt cadence, or PISM’s 12-step order.
    • The run config must name these. After flattening, a term’s component can only be recovered from the namespaced variables it references. That usually works, but it fails when a term references two components’ variables.
  2. Derivatives come from separately compiled “derivative models”. This is the band-model trick, and it works (reseact).
    • It is fragile across versions. reseact’s band model went silently empty after an upstream node rename (aggregate → faq), which is why validate_plan (about 338 s) exists. Note what that check is: it compares the gather plan against EarthSciASTDiff’s own index maps at one point and refuses an empty Jacobian (rx_sym_block_jac.jl:288-337). It is not a band-versus-derivative value check; no reusable tool for that exists in any sibling repo (EarthSciASTDiff’s ForwardDiff oracle lives only in its test suite), so architecture §3.2 pass 8 is new integrator work.
    • EarthSciASTDiff fails loudly on filtered faq nodes and non-+ semirings. It has no ∂f/∂(forcing) (D5), and does not list recurrence (column-sweep) support.
    • Rust has no AD fallback, so the Rust adjoint depends entirely on EarthSciASTDiff coverage.
  3. No exact stencil tiling and no MPI (§2.2). Pointwise and column sharding still work.
  4. Events, algebraic equations and step maps are refused by every fast compiler. The integrator must strip them from the AST and compile the conditions, affects and residuals as separate observed models, in each language.
  5. Duplication. AST analysis and surgery would be implemented in Julia and in Rust, and later Python, unless it is done once (§3.3).
  6. Split parts are full-length RHSs over the shared u. The Splitter produces no DOF subsets, so work is wasted on zeros. This is fixable on the integrator side.

Unavoidable even under option 2:

  • solver.external;
  • a public, thread-safe RHS entry point in Rust: today debug_eval_rhs is #[doc(hidden)] and EsmProblem is built on Rc/RefCell;
  • Float32 integration not being refused (WRF).

Cheap additions that recover most of the loss:

  • a per-term provenance tag carried through flatten (fixes 1);
  • a range-restricted RHS (fixes 3).

3.3 Proposal: a planner that writes artifacts

Structure inference runs once, in a planner, which writes portable artifacts:

  • a resolved run config: an explicit schedule, inspectable and editable, and the file solver.external can point to;
  • derived .esm documents: split parts, Jacobian and VJP band models, residual models for algebraic constraints, and event-condition models.

Both runtimes consume these artifacts. Inference is implemented once, and the runtimes stay thin and conformance-testable.

Superseded by D8: the planner is implemented in both languages. The artifact boundary stays, as the conformance seam: both planners must produce equivalent plans for the same document.

4. GPU via XLA: tradeoffs

Feasibility. Writing the integrator’s kernels in XLA works, and has been done:

  • reseact’s tools/reactant_handoff/rx_traced_integrator.jl already runs SSPRK43, Rosenbrock23 (with the batched 13×13 block solve), the PI controller and adaptive while loops as one XLA program per macro step.
  • Both EarthSciAST bindings already emit the RHS as XLA. Julia emits StableHLO through Reactant. Rust uses XlaBuilder via the opt-in xla 0.4 crate (simulate_array/tape/xla_emit.rs, 2,121 lines), which links a 144 MB prebuilt xla_extension.

So a whole leaf step, RHS plus stages plus linear solves plus controller, can be one XLA program on CPU or GPU. No XLA↔CUDA buffer interop is needed.

What fits XLA’s static-shape, data-parallel model:

  • explicit RK stages;
  • Rosenbrock and SDIRK stages with per-cell LU, unrolled over the n×n entries and vectorized across lanes (reseact’s blocksolve);
  • column Thomas sweeps (a loop over levels);
  • error norms, PI control and fixed-tree reductions;
  • Newton and Krylov while loops. These run forward only, since D4 never reverse-differentiates through them.

What is awkward:

  • Lane pools need a fixed lane capacity and refill in masked chunks; finished lanes idle until the chunk ends. reseact’s masked device loop wasted 20–28%.
  • Dynamic re-sharding works if the composition is a runtime gather-index input rather than a shape, so nothing recompiles.
  • MPI halos and distributed Krylov. XLA has no MPI. The options are:
    • split programs per stage and exchange halos on the host — reseact warned these round trips “would DOMINATE on an A100/H100”;
    • XLA collectives (collective-permute, all-reduce) — fine across one node’s GPUs; multi-node needs PJRT’s distributed runtime.
  • Determinism (D10) needs XLA’s deterministic-ops mode on GPU. reseact also hit a data race on XLA:CPU (one NaN in about 2,000 calls).

Measured costs (reseact):

  • On CPU, XLA was 2.8× slower than native Julia, had about 9% parallel efficiency, and a roughly 3 ms per-call floor.
  • Compiles took minutes per program. Pathological ones ran for hours or exhausted memory (HloCSE, LLVM section memory). Compiling per tile bounds this.
  • Upstream bugs: miscompiles, and @compile donating input buffers.

Effort by language:

  • Julia: trace generic kernel code with Reactant; reseact’s integrator is a working template. One source that is both fast native code and traceable is possible but may be suboptimal for one of the two.
  • Rust: build programs op by op with XlaBuilder. This is more verbose, but EarthSciAST’s tape→XLA emitter is precedent.

Recommendation.

  • Keep native kernels for CPU (D11).
  • Give the integrator’s kernel-primitive layer an XLA lowering for GPU: each leaf step becomes one XLA program, and the host is touched only at macro-step and epoch boundaries and for MPI.
  • Accept these rules on the XLA path: fixed lane capacity, composition as gather indices, per-tile programs, deterministic-ops mode, and XLA collectives within a node.

5. Open questions

  • The GPU hardware target (D28 keeps both kinds viable).

6. Alternatives recorded but not adopted (2026-10-14 review)

  • Sequence the Rust planner after the Rust runtime. The plan artifact is the seam, so the Rust runtime could consume Julia-produced plan.json and derived documents until a Rust planner lands (after M5), with D8 as the end state. Not adopted; D8 and M2 stand as written. Recorded in architecture §15 as the fallback if planner effort threatens M3.
  • A separate, later Rust M4 gate. Rust’s Rosenbrock adjoint depends on U10 W0 → U7, U8, U9 (filtered faq) → U12, six upstream RFCs, while Julia’s M4 can use the Enzyme-on-RHS fallback. Not adopted as a milestone change; recorded in architecture §15 risk 3 so that a Rust slip does not hold the Julia gate.
  • Wrapping OrdinaryDiffEq per leaf for the forward pass of stencil, column and global leaves on Julia’s native path. Rejected for the same reasons as D31 (the adjoint would have to read OrdinaryDiffEq cache internals), but it remains the fastest route to a forward-only M3 transport leaf if M1 slips.

7. Data inputs: tradeoffs (D87, 2026-11-02)

The problem. Real models read gridded data. reseact.esm reads GEOS-FP meteorology (GEOSFP.PBLH, GEOSFP.CLDTOT, winds, temperature and more) as parameters with a data update (esm-spec §5.4, §8.5), mapped by param_to_var onto shaped parameters with a leading two-record axis (Transport3D.F_PBLH[gf_t, gf_lat, gf_lon]) that the model blends in time with a weight it computes from t. Until D87, M2 refused every such model (unsupported_parameter_update, D75), and spec/operator-interface.md §8.1 said derived documents carry no data providers. So pass 8’s checks and §5.5.2’s stiffness bound had no values for these parameters, and a runtime building an operator from derived documents had no way to give its right-hand side time-varying fields. No fixture or toy problem had a data input, so none of this was exercised, and it stops the reseact.esm planner smoke test.

What EarthSciAST already gives. rhs_program takes forcing as an argument: every parameter whose update has from (or a handler) is a forcing slot, a 0-d slot for a scalar, out of the scalar parameter vector; a forcing set is immutable and carries an epoch stamp; refresh is a swap (API_SPEC §5.8). Data providers are always the caller’s: no binding fetches from data_sources on its own (Julia providers, Rust ProblemOptions::providers, the EarthSciIO adapters opt-in). EarthSciASTDiff’s forcing axis takes the forcing set as a list of names (esm-jacobian-spec §4.3) and already uses the leading record axis convention. EarthSciIO’s PrefetchGroup gives the epoch boundaries. The planner half uses these as they are, and needed two upstream fixes to stop patching documents (spec/plan.md §10 items 16, 17, resolved by EarthSciAST#634 and EarthSciASTDiff#55). The runtime half rests on upstream work that arrived on 2026-11-02 and on what is still missing (§10 items 18–22, §7.3 below).

7.1 How a data-fed parameter appears in a derived document

OptionForAgainst
A. A data-fed parameter with a stub data source (update with source and file_variable; one data_sources entry per source whose url_template is the substitution-led {esm_integrator_forcing})Both bindings already make it a forcing slot; nothing about locations or records enters the plan; byte-stable, since the template is left unchanged at load; the plan stays valid against a mirror or a local copy of the dataThe planner writes the declaration itself (two upstream gaps), and a reader may mistake the stub for a real sourceadopted
B. Copy the model’s data_sources and the full updateSelf-contained documentsEarthSciAST resolves a relative url_template to an absolute file:// URL at load, which then reaches the goldens (spec/plan.md §2.1 forbids absolute paths); a relative one would also resolve against derived/, the wrong directory; two copies of where the data is, which can driftrejected
C. A plain parameter, built with runtime_array_parametersNo update or stubThat option makes every shaped parameter a run-time slot, so genuinely constant arrays are no longer folded at build (reseact relies on folding its NEI regrid); a data-fed scalar would land in $p$, not in the forcing setrejected
D. A constant state of the leaf (spec/operator-interface.md §1’s “state an operator reads but no operator writes”)Reuses an existing mechanismPuts the meteorology into the state vector, the tape and the canonical DOF order; EarthSciASTDiff would differentiate with respect to it as a state; the two-record buffers are not statesrejected
E. Keep refusing data until M3Nothing to specify nowThe M2 reseact.esm smoke test cannot runrejected

7.2 What the planner’s checks evaluate data at

OptionForAgainst
A. Synthetic values from D70’s SplitMix64 stream at sample index $s + 3$, around the parameter’s nonzero default (a used parameter without one is refused)Deterministic and equal in both languages with no RNG; no file read, so tier 1 goldens need no data; the checks (rhs_sum, band_model) are identities that hold at any finite input; records of a bracket differ, so the time blend is exercisedThe values are not physical: a model whose right-hand side is undefined at magnitudes near 1 (a logarithm of a ratio below zero, say) would fail a check that real data would pass; the Gershgorin stiffness bound at these values means nothingadopted, with stiffness rule 4 refused rather than guessed; falling back to a base of 1 when there is no default was proposed and rejected in review (2026-11-02): a value of order 1 for a pressure or a boundary-layer height is not a scale, so the model must state one
B. The real data at the run’s startRealistic magnitudes, so the Gershgorin bound would mean somethingThe plan then depends on the data files and their decoding in two languages; goldens would need data files; a plan is no longer a function of the model, the run config and the intervalrejected
C. The parameters’ defaultsSimplereseact’s are 0.0 or absent: the checks would pass vacuously on zero coefficients, and a parameter without a default cannot be evaluated at allrejected
D. Representative values in the run config (a new key, say forcing_samples: {"GEOSFP.PBLH": 1000}) as the sampling basePhysical magnitudes when the author gives them; would let rule 4 classifyA new run-config key and rule; the author must know what the bound needsnot adopted (2026-11-02); compatible with A if wanted later

7.3 How the runtime supplies data

The forcing set becomes the last argument of every operator call at M2 (spec/operator-interface.md §8.4). The runtime owns the data: it builds providers from the model’s own data_sources (not from the derived documents), turns a sample into a buffer exactly as esm_problem does, and swaps one immutable set per forcing epoch; steps never cross an epoch, and time interpolation stays the model’s (D5’s per-record gradients rely on the model’s blend being explicit). Rejected: interpolating in time in the runtime (it would put a second, hidden blend beside the model’s own, and break D5’s accumulation per record); giving each operator its own provider (several decodes of one field per epoch); passing data through $p$ (only scalars, and it would change the parameter vector’s length with the data).

Locating the data. The model locates it: url_template resolves relative to the model’s file (esm-spec §8.2.1), so a plan runs wherever the model and its data sit together. Rejected: naming data files in the run config (a second declaration of what the model already declares, which can disagree with it) or in the plan (the plan would carry paths, which §2.1 forbids, and go stale when data moves). Cache directory, offline mode and URL overrides are run-time options of the runtime’s API: they change where bytes come from, never what is computed.

What the runtimes call (spec/operator-interface.md §8.4.2): EarthSciAST’s providers_from_document (Julia, from its EarthSciIO extension; Rust, feature esio), built on EarthSciIO’s constructor of a provider from a document’s data_sources entry; provider_sample / CadenceProvider::refresh at each epoch; EarthSciAST’s public sample-to-buffer conversion; EarthSciIO’s refresh times for the epochs. The proposal had the runtimes write a small data layer of their own over a subset of esm-spec §8; review (2026-11-02) chose the full feature set instead, through EarthSciAST and EarthSciIO, so that a planned run and EarthSciAST’s solve read the same numbers by construction and reseact runs end to end at M2. The cost is that M2’s runtime half waits for the upstream work below. Rejected as well: building the original model’s whole esm_problem with providers and copying its forcing_buffers at each epoch, which compiles the whole model’s right-hand side only to read its buffers, and whose refresh is a solver callback, not a call a runtime can make at an epoch boundary.

Upstream work (spec/plan.md §10 items 16–22, as of 2026-11-02):

  • Arrived: EarthSciAST#636 (update.from.records_per_sample, overriding the source’s temporal.records_per_sample, reported in loader_fields; metadata.esio_format defined in esm-spec §8.1.1), #632 (forcing_buffer in Julia and Rust, which does not apply unit_conversion because providers already do), #634 (after param_to_var the source’s declaration survives, so defaults go on sources), #635 (providers_from_document on EarthSciIO’s document constructor in all three languages); EarthSciIO#27–#30 (esm templates with substitutions and mirrors, exact calendar cadences, document-built providers, a Rust CSV reader); EarthSciASTDiff#55 (the evaluation document keeps data rules and their sources).
  • Still missing: #635 does not pass records_per_sample through yet; no json reader in any language; the file’s own time axis is used only in Python; determinism is not checked; providers have no time_origin shift, so the runtimes convert; and three Rust EarthSciAST bugs (rhs_program misses array-default data-fed parameters as forcing slots, may return defaults instead of data with qualified provider keys depending on map order, and samples discrete providers only when a solve starts).

7.4 Milestone

Both halves are M2 (decided 2026-11-02, amending D75; the proposal had the runtime half at M3), with the full data layer: the planner half for the reseact.esm smoke test, the runtime half so that a planned run reads data end to end, reseact included, gated by data_decay, data_monthly and data_points (spec/toy-problems.md §4.4–§4.6). M3 keeps reseact’s forward run at scale, shards and lanes, which take forcing slices per lane (architecture §7.3). M4 adds forcing derivatives (U7, U8); M5 rank windows, forcing halos and asynchronous prefetch (U11).

7.5 Questions settled in review (2026-11-02)

First round: the stub data source is accepted; Gershgorin stiffness on operators that read data is refused; a used data-fed parameter without a nonzero default is refused (forcing_without_scale), not sampled around 1; band-value files list forcing values; the two derived-document gaps (§10 items 16, 17) are fixed upstream, with the planner’s patches as the fallback; both halves are M2. Second round: the two-record request and esio_format become esm fields (the run-config forcing.records stand-in is dropped, not kept as a fallback, since the planner and the runtimes read the model’s field); the full data layer is M2’s, through EarthSciAST and EarthSciIO; a file variable’s dimensions map onto a parameter’s by position, as esm_problem does; data identity is recorded in the run’s report, never in plan.json.

Third round, a review of the pull request (2026-11-03), fixed inconsistencies without changing the design: (1) only used data-fed parameters reach the plan: the plan’s forcing lists them, an operator’s derived documents declare as forcing inputs only the parameters in its forcing (another used one, which only ic equations read there, is a plain parameter at its default; an unused one is not declared), so a program’s forcing slots are the data it reads, the runtime builds no provider for data nothing reads, and band-value files list only the canonical forcing order; (2) the set a call takes is chosen by the call’s $t$, so a stage at exactly an epoch’s end reads the next epoch (architecture §7.5), and $F$ is constant on each step’s half-open interval rather than over the closed step, which a two-record blend with a $\mathrm{frac}$ weight needs to be continuous; (3) a refresh time at $t_f$ starts a last epoch that the stages at $t_f$ read, so its records must exist; (4) epochs and forcing_refresh_not_hit follow the parameters operators read, and a parameter only an ic equation reads is sampled once, at $t_0$; (5) a run’s initial state evaluates such an ic equation with the data at $t_0$, from EarthSciAST’s esm_problem with the providers, as EarthSciAST’s own solve does, while the planner’s $u_0$ keeps defaults (spec/operator-interface.md §8.4.5); (6) the wrt prefixes params: and fields: exclude data-fed parameters, which match forcing: only; (7) every call’s forcing argument is written into spec/operator-interface.md §3 and §8.3; (8) forcing_records_invalid covers only a temporal block that is not time-varying, since EarthSciAST already refuses a missing block and a record axis not of size 2 at load, and its fixture was changed to match; (9) run-config’s rule-16 paragraph no longer lists the data refusals.