Version: dev

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).
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.
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).
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. 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).

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.