The plan and the planner
Status: M2 draft, 2026-10-25. Normative for both planners (D1, D8); the M0 skeleton of 2026-10-23 fixed the field set, this draft specifies how every field is computed. Its gate is conformance tier 1 (§9): for each fixture, the Julia and Rust planners write byte-identical plan.json and derived/*.esm, equal to the checked-in goldens. The checkpoint action vocabulary is specified at M4. Background: architecture §2, §3, D8, D14, D34, D64–D84. Stage 1’s findings (Julia PR #21, Rust PR #20) were resolved on 2026-10-26 (D81–D84). Data-fed parameters (§4.1, §5.5.1, §6.3) were added on 2026-11-02 (D87).
The planner reads a model and a run config and writes a plan: plan.json plus a directory derived/ of .esm documents. The runtime executes only the plan. The plan is where the Julia and Rust implementations are checked against each other (D8): the same inputs and spec version must give byte-identical files in both (architecture §3.3).
Every choice in this chapter that can change a byte of the output is stated exactly. Where this chapter says “EarthSciAST” or “EarthSciASTDiff” it means the bindings of the pinned versions (§0.2) in the planner’s own language, never a call into the other language.
0. Scope at M2
0.1 What M2 plans
| Construct | M2 | Refusal code (spec/planner-errors.json) |
|---|---|---|
Ordinary differential equations D(x, t), scalar and arrayed, with faq/makearray right-hand sides, pointwise lifts, observeds, ic equations | planned | — |
operator_compose, couple, variable_map couplings (EarthSciAST flattens them) | planned | — |
Term operators (select), default operators, bind_dt | planned | — |
The schedule grammar of spec/run-config.md §4 except map, solve, coupled with algebraic operators and split_explicit | planned | — |
Derived documents rhs and jacobian | emitted | — |
time, step (dt, {"max": …} limits, hit, hit_multiples, resolution), partition, precision | resolved | — |
Algebraic equations (an algebraic_variables entry in the flattened model) | refused | unsupported_algebraic_equation |
| Continuous or discrete events | refused | unsupported_event |
Data-fed parameters (§4.1): parameters a loader_fields entry names, whose only update rule, if any, is one data rule with from (D87) | planned, and supplied by the runtime from the model’s data sources (spec/operator-interface.md §8.4) | — |
A used data-fed parameter (§4.1) without a nonzero default at every element | refused | forcing_without_scale |
Parameter update rules of any other kind, schedule included, and a data rule beside another rule (D75, amended by D87) | refused | unsupported_parameter_update |
A loader_fields entry that names no parameter of the flattened model (D87) | refused | unsupported_forcing |
map operators and map nodes | refused | unsupported_map_operator |
Observed step limits in step.limits or subcycle.limits | refused | unsupported_step_limit |
solve nodes | refused | unsupported_solve_node |
coupled with algebraic operators, split_explicit, crossing updates (spec/run-config.md §8 rule 16) | refused | unsupported_coupled_dae, unsupported_split_explicit, unsupported_crossing_update |
Derived kinds other than rhs and jacobian (frozen_jacobian, vjp, second_order, param_jacobian, forcing_jacobian need U7–U9, M4; residual, map, limit, condition need U6, M4b) | not emitted | — |
column_block structure (U9) | not emitted | — |
A run config whose adjoint key is present is planned; its checkpoint schedule is a placeholder (§7.6) and no adjoint artifact is derived.
0.2 Upstream versions
M2 is specified against EarthSciAST 0.10.0 (esm format 1.3.0: solver.external, solver.time_span and resolve_time_span, per-term provenance equation_contributions, and Rust’s equation_lifted, all since 0.4.0; 0.5.0 adds the public reentrant right-hand side rhs_program in Julia and Rust, U4; 0.6.0 lowers reaction systems to one form in every binding, esm-libraries-spec §4.6.2, and its Julia binding accepts SciMLBase 3; 0.7.0 makes the unit checks stricter and checks every function table at load, makes a loop index shadow a same-named variable when flattening (EarthSciAST#610), fixes the Julia native compiler on lifted equations (#609, §10 item 5), builds the Rust crate without its solve feature (#608, §10 item 8), adds the public clock accessors time_origin and timestamp_to_seconds in Julia, Python and Rust (#587, #600, §10 item 11) and Rust’s initial_state (#602, §10 item 15); 0.8.0 makes every binding’s flatten record a pointwise-lifted state’s index sets as its shape and range both lifted faq nodes over them, and refuses two initial conditions for one state (#623, §10 item 1); 0.9.0 adds records_per_sample and the data-source changes of §10 items 16 and 18–20 and changes nothing a plan holds; 0.10.0 writes a const node’s value in the canonical number form in every binding (#670, §10 item 23), keeps an observed’s declared shape through Rust’s flatten (#672, §10 item 24) and places an operator_compose merge in Julia as the other bindings do (#669, §10 item 25)) and EarthSciASTDiff 0.5.0 (on EarthSciAST 0.6 through 0.10 in Julia and >=0.5, <0.11 in Rust; 0.1.0, its first registered release, brought it to EarthSciAST 0.6, §10 item 12; 0.2.0 removes its evaluators, so a Jacobian is a document that EarthSciAST evaluates, and adds forcing and array-parameter columns (U7), second-order bands (U8), filtered and non-+ reductions (U9), and in Rust sysview_flat, jacobian_bands_view, and public plan_factorization and serialize_plan, §10 items 13 and 14; 0.3.0 lays a flattened system’s evaluation document out as its component tree and carries its source’s domain (EarthSciASTDiff#49, §10 item 2), and takes a lifted state’s index sets from flatten (#50); 0.4.0 adds the structure-only mode pass 2 reads (§5.2.2, EarthSciASTDiff#59), clips each group of summands of a sum on its own axes (#60), which merges a dimension-split stencil’s bands into fewer, and keeps data-fed parameters as inputs of its derived documents (#55); 0.5.0 lets Julia’s evaluation_document take bands already computed (#64), makes subtree sharing one pass with a numbering rule every binding follows (#66, esm-jacobian-spec §4.1; D90), and writes a const node’s value in the canonical number form in stable_json (§2.2)). Conformance tier 0 runs on EarthSciAST’s Python binding, 0.9.0, until its 0.10.0 is published on PyPI; no fixture .esm changed between the two. A planner reports the versions it was built against in plan.report.json, never in plan.json. Data-fed parameters (D87) additionally need EarthSciAST#632, #634, #635 and #636, EarthSciASTDiff#55 and EarthSciIO#27–#30 (§10 items 16–20): EarthSciAST 0.9.0 and EarthSciASTDiff 0.4.0 carry the first five, and EarthSciAST from 0.9.0 requires EarthSciIO 0.3.0 for its data bridge.
1. Files
<plan dir>/
plan.json canonical JSON (§2.1), golden-gated
derived/*.esm one document per derived artifact (§6), golden-gated
plan.report.json implementation-specific report, not golden-gated
derived/documents are named<operator>.<kind>.esm, wherekindis one of thederivedkinds in the schema:rhs,jacobian,frozen_jacobian,vjp,second_order,param_jacobian,forcing_jacobian,residual,map,limit,condition(architecture §3.2 pass 6). At M2 every operator has exactly anrhsand ajacobiandocument.frozen_jacobianis emitted for an operator used by asolvenode withpicard.freeze, the only place freezing is written (D51). The forward iteration uses the frozen Jacobian; the adjoint needs the full residual Jacobian at the converged solution, emitted asjacobian(spec/adjoint.md §4.6). Not at M2.plan.report.jsonholds everything that legitimately differs between implementations: the planner’s name and version, the upstream versions it was built with, timings, and the measured errors behind each validation check. Its format is not specified.- Band-value files (conformance only, §8.2):
validation/<operator>.bandvalues.jsonbeside a golden’splan.json, written by the Julia planner when asked, read by the conformance harness’s Rust comparison. They are not part of the plan and not golden-gated byte for byte.
2. Canonical form
2.1 plan.json
plan.json is serialized as RFC 8785 JSON Canonicalization Scheme (JCS): UTF-8, object keys sorted by UTF-16 code units, no insignificant whitespace, no trailing newline, and numbers in ECMAScript’s Number.prototype.toString form. Both implementations write JCS directly; neither post-processes the other’s output. Check values for the number form: 300 → 300, 0.1 → 0.1, 1e-6 → 0.000001, 1e-7 → 1e-7, 1.5e21 → 1.5e+21, −0 → 0.
Beyond JCS, the content is canonical:
- Name lists (
writes,reads,hit) are sorted by Unicode code point and duplicate-free. termsfollow flattened-equation order, then contribution order, then summand order (§5.1).structure.bandsfollow EarthSciASTDiff’s band order (§5.4).validationis sorted by(operator, check), the operator-lessrhs_sumentry first (§8).- Every default is made explicit (§7); nothing is omitted because it equals a default. Options that do not apply, and options whose default is computed at run time, are absent (D73).
- Paths are relative to the plan directory or, for
inputs, as §7.7 gives them. No absolute paths, hostnames, timestamps or implementation names appear. sha256is the lowercase hex SHA-256 of a file’s bytes.
2.2 Derived documents
A derived document is a JSON object built as §6 describes and written as EarthSciASTDiff’s stable_json writes it (Julia EarthSciASTDiff.stable_json, Rust earthsci_astdiff::stable_json): compact, object keys sorted by code point at every level, arrays in order, no trailing newline. Expressions inside it are EarthSciAST’s serialized expression objects (Julia EarthSciAST.serialize_expression), whose numbers are in the esm canonical number form (EarthSciAST CONFORMANCE_SPEC §5.5.3.1): a number of the model whose value is integral and fits a 64-bit signed integer is written as an integer however the model spells it (2, and 2.0, 1e3 and 3.0e7 are written 2, 1000 and 30000000), and any other in the shortest round-trip form with Julia’s layout (0.5, 1.0e-9, 1.5e21). Every number of a const node’s value is written the same way, nested arrays included: [100.0, 0.5] is written [100,0.5]. This holds for every const node of both derived documents wherever it came from: the model, a table_lookup lowering, or EarthSciASTDiff (whose 0.5 writes none of its own; the slopes and knots of an interp.linear derivative are literal numbers, already in this form in both bindings). Neither planner rewrites a value itself: EarthSciAST writes every const node’s value in this form in every binding from 0.10.0 (EarthSciAST#670, §10 item 23), and EarthSciASTDiff’s stable_json applies the rule to every const node’s value it writes from 0.5.0, so a value a binding still holds as floats in memory (Julia’s lowered table_lookup) is written alike. The numbers the planner writes itself rather than copying an expression are floats in stable_json’s float form: the zero right-hand sides of §6.1.2 (0.0) and the defaults of §6.1.1 and §6.2. This is the writer whose output EarthSciASTDiff’s evaluation-document goldens already hold byte-identical between Julia and Rust. Fixture authors: a literal whose float type matters should be non-integral (1.5), since an integral one is written as an integer whatever its spelling.
3. Contents
| Key | Contents |
|---|---|
esm_integrator | The spec version, equal to the run config’s. |
inputs | The model and run config, by path and hash (§7.7). A plan is stale when either hash changes. |
time | The run interval in seconds (§7.2, D60, D74). Which source gave it is not recorded. A plan is also stale when the interval resolved for a run differs from this one. |
operators | Per operator: kind (rhs at M2); locality with its per-axis radius for stencils (§5.2); sorted writes and reads (§5.3), and forcing, the data-fed parameters among reads, when there are any (§5.3); terms (§5.1); structure (§5.4); stiffness with the source of the classification (§5.5). |
forcing | Present only when the model has used data-fed parameters (§4.1, D87): for each used one, by flattened name, its shape (index-set names, [] for a scalar), the source and file_variable its loader_fields entry names, its cadence (const or discrete) and its records_per_sample (1 or 2, §4.1). It records what feeds each parameter and how, never where the data is or which data was read: no URL, path, record time or file hash appears (a run’s report records the data it read, D87). A data-fed parameter nothing reads is not listed, so the runtime builds no provider for it (spec/operator-interface.md §8.4.2). |
schedule | The resolved schedule (§7.1): the run-config grammar with no "auto" anywhere, every leaf’s options explicit, every default operator named, and an id on every node. |
step | The macro-step control, as resolved (§7.3). |
partition | Rank and tile grids, halo per horizontal axis, and shard counts per leaf (§7.4). |
precision | Every precision role for every leaf, by node id (§7.5). |
checkpoint_schedule | Present exactly when the run config has adjoint; a placeholder until M4 (§7.6, D78). |
derived | Every derived document, by path, with its kind, operator and hash. |
validation | The checks of pass 8 that ran (§8). A plan is written only when every check passes, so each entry records passed: true; measured errors go to plan.report.json. |
4. Reading the inputs
The planner runs these steps in order and stops at the first refusal. A refusal names its code (spec/planner-errors.json), the offending run-config key as a JSON pointer (or the model location), and a message. Only the code and pointer are conformance-tested. For a refusal whose cause has no run-config key of its own, spec/planner-errors.json gives the pointer in the code’s description (D79).
- The run config. Parse it; validate it against
schemas/run-config.schema.json(run_config_invalid, at the empty pointer"", since a failingoneOfhas no single location); refuse anesm_integratorversion the planner does not implement (version_unsupported). - The model path.
modelis resolved relative to the run config’s directory.toy:<problem>is the conformance harness’s and is refused by the planner (toy_model_not_plannable). - Load. Load the document with EarthSciAST (Julia
EarthSciAST.load_path) and validate it (EarthSciAST.validate, Rustearthsci_ast::validate, whose findingsload_pathdoes not refuse in Julia or Rust). A load or validation failure, including EarthSciAST’s ownsolver_external_*load-time refusals, ismodel_load_failedwith EarthSciAST’s code in the message. Asolver.time_spanthat EarthSciAST rejects, here or later inresolve_time_span(§7.2), is refused with the rule-14 code of its cause, at/model: an end not after the start istime_interval_empty, a malformed timestamp or a day its month does not havetime_invalid_date, an unreadable time origintime_origin_invalid; any other cause ismodel_load_failed. solver.external(spec/run-config.md §1, rule 1). When the model carriessolver.external, its resolved location (EarthSciAST’s accessor, esm-spec §2.2.6, with the model’s directory as base) must equal the run config’s own location written the same way (file://+ the absolute path with dot segments removed lexically and percent-encoded as esm-spec §2.2.6 encodes), elsesolver_external_mismatch. The comparison is of the two strings. A URLexternalnever matches a file. The planner never writessolver.externalinto a derived document.- Flatten. Flatten with EarthSciAST (
flatten), which applies the couplings and recordsequation_contributions(esm-libraries-spec §4.7.5). Then expand surviving expression-template references (JuliaEarthSciAST.expand_flattened_refs), lowertable_lookuptofnandenumto integers (EarthSciAST’s own lowering), each inside every contribution, as esm-libraries-spec §4.7.5 requires of passes over flattened equations. A flatten failure ismodel_flatten_failed. - M2 scope. Refuse, in this order, a flattened model with algebraic variables, events, parameter
updaterules other than those of data-fed parameters (§4.1), aloader_fieldsentry that names no parameter of the flattened model (unsupported_forcing), a data-fed parameter whose records per sample its cadence or shape cannot honour (forcing_records_invalid, §4.1), or an index set that is not anintervalwith an integer size (unsupported_index_set), with the codes of §0.1, each at/model. The order holds whatever the parameter rules are: a model with events and a parameterupdaterule isunsupported_event. At the parameter-update place, the code isunsupported_crossing_updatewhen any parameter has acrossingupdate rule, whatever the order of the parameters, andunsupported_parameter_updateotherwise: the more specific code wins over the general one only. - Reserved names. Refuse a model with a top-level component named
JacobianEval(reserved_component_name, §6.1.4). - Axes (§5.2.1) and time (§7.2) are resolved next, since passes 2 and 5 use them. The default axes read every state’s shape, a lifted state’s being the one
flattenrecords (§6.1.3). The axes (axes_unresolved) come first, then the time.
Order of refusals (D81). After these steps the planner runs, in order:
- pass 1’s terms and owners (§5.1.1–§5.1.2;
term_origin_unknown); - each term’s locality (§5.2.2–§5.2.4 applied to the term;
band_model_failed), since selection reads it; - selection (§5.1.3), with its refusals in the order §5.1.3 gives;
- each operator’s class and radius (§5.2.4), pass 3 (§5.3), the scale of the used data-fed parameters (§4.1;
forcing_without_scale) and pass 4 (§5.4;band_model_failed), each pass taking the operators in code-point order of their names; - the schedule (§7.1), the step (§7.3), the partition (§7.4), the precision (§7.5) and the adjoint (§7.6);
- the strategies (§5.5), then the precision check of §7.5 that waits for a resolved method (
precision_low_with_classical_rosenbrockon a leaf whosealgis"auto"); - the derived documents (§6) and validation (§8).
The Jacobian documents (§6.2) are built after item 5 and before item 6’s strategies, since stiffness rule 4’s Gershgorin bound (§5.5.2) evaluates them. Each is built from the band list item 4 already computed, so every EarthSciASTDiff refusal, band_model_failed included, has already happened in item 2 or item 4. Rust passes that list to evaluation_document. Julia’s public evaluation_document takes no band list: it calls jacobian_bands again on the same flattened right-hand-side document, which gives the same list, and the Julia planner checks the band fields it writes against item 4’s list (one field J_k per band, and each field’s row region equal to its band’s rows). Building one refuses nothing: a failure of EarthSciASTDiff’s evaluation-document builder on bands that item 4 accepted, Julia’s second jacobian_bands call included, and a mismatch found by that check are internal planner errors, not refusal codes. So where it is built does not change the order of refusals. Item 7 then writes them with the right-hand-side documents.
So band_model_failed for a term’s structure precedes every selection refusal; one for a term’s derivative (§5.2.2) comes in item 4, after selection. The first refusal ends planning. Within §7.1, unsupported constructs are refused first, in tree order, then spec/run-config.md §8’s rules in rule order (§7.1).
The flattened model below means the result of step 5. Its state variables are the keys of state_variables that some equation’s left-hand side differentiates; its canonical state order is those names sorted by code point, each array’s cells in column-major order (EarthSciASTDiff’s canonical order, esm-jacobian-spec §8). DOF $n$ (0-based) is position $n$ in that order. The initial state $u_0$ is EarthSciAST’s, from building the flattened model (defaults and field_ics) with every used data-fed parameter (§4.1) bound to its default and every unused one to 1, permuted into canonical order. They are bound so that the build needs no data and an ic equation that reads a data-fed parameter has a value that both planners compute alike (no evaluation reads an unused one); a model without data-fed parameters builds exactly as before. This $u_0$ is the planner’s: it scales the sampled states (§5.5.1) and is what derived documents build (§6.3). A run’s initial state evaluates such an ic equation with the data at $t_0$ instead (spec/operator-interface.md §8.4.5).
4.1 Data-fed parameters
A data-fed parameter is a parameter of the flattened model that a loader_fields entry names (esm-libraries-spec §4.7.5; esm-spec §8.5). Its update rule, when the flattened model still carries one, must be a single data rule with from; any other rule on it is unsupported_parameter_update (§4 step 6). The planner identifies data-fed parameters from loader_fields, not from update, which every binding records with the effective records_per_sample (§10 item 18); before EarthSciAST#634 a param_to_var coupling onto a shaped target could also leave the merged parameter without its update (§10 item 16). Its shape is its declared shape in the flattened model (index-set names; [] for a scalar); its source and file variable are the loader_fields entry’s data source name and var. Its cadence is discrete when its source’s temporal has a start and a frequency or file_period (the reading EarthSciIO and EarthSciAST’s providers_from_document give a temporal block), and const otherwise. Its records per sample are the effective value EarthSciAST reports for it in loader_fields: the parameter’s update.from.records_per_sample (1 or 2) when given, which overrides its source’s data_sources.<s>.temporal.records_per_sample, else that, else 1 (esm-spec §8.5, EarthSciAST#636). 1 is the record at or before $t$; 2 is that record and its successor, stacked along the parameter’s first dimension. EarthSciAST validates the value at load (records_per_sample_invalid, which the planner reports as model_load_failed with that code in the message, §4 step 3): it refuses 2 on a parameter whose source declares no temporal block, and on one whose record shape is empty or whose first index set does not have exactly two members (esm-spec §8.5.1). The planner checks only what EarthSciAST does not: 2 on a parameter whose source has a temporal block that is not discrete by the reading above (no start, or neither frequency nor file_period), which has no successor record to deliver, is forcing_records_invalid at /model, checked with §4 step 6. (A temporal block with no start at all is already refused by EarthSciAST’s validate, model_load_failed at §4 step 3, so in practice this code is reached by a block with a start but neither frequency nor file_period.)
A data-fed parameter is used when some operator’s reads (§5.3) lists it, or an ic equation of the flattened model references it, directly or through observed definitions. A used data-fed parameter needs a default that is nonzero at every element (a scalar default broadcast over its shape counts at every element): the planner’s checks evaluate it around that value (§5.5.1), and with no scale to sample around there is no defensible value to check at. Otherwise it is refused, forcing_without_scale at /model, naming the first such parameter in code-point order. The check runs after pass 3, at the end of item 4 of the order of refusals below, since reads is known only then. Unused data-fed parameters are not checked, so a mounted data component may declare fields no equation reads (reseact’s GEOS-FP and NEI components declare 111, of which 20 are read).
The planner never reads data. A data-fed parameter is, to the planner, a parameter whose value it does not know: it is not in the scalar parameter vector $p$ (spec/operator-interface.md §8.2), it is not a state, and its values at the sampled states are the synthetic ones of §5.5.1. The runtime supplies its values during a run (spec/operator-interface.md §8.4). The plan’s forcing key lists the used data-fed parameters (§3), and an operator’s forcing those among its reads (§5.3). An unused data-fed parameter appears nowhere in the plan or its derived documents (§6.3); only the planner’s own evaluations of the flattened model bind it (to 1, never read; §4, §5.5.1).
Where the data is (the data source’s url_template, mirrors, records, cadence) stays in the model, which inputs.model names. EarthSciAST resolves a relative url_template to an absolute file:// URL at load (esm-spec §8.2.1), so nothing from a data_sources entry other than its name and kind reaches the plan or a derived document (§2.1: no absolute paths).
5. Passes
5.1 Pass 1: terms and operators
5.1.1 Terms
For each equation of the flattened model, in order, whose left-hand side differentiates a state (D(x, t), or the pointwise lift’s faq over D(index(x, …), t)): for each of its contributions $c = 0, 1, \ldots$ (the equation’s equation_contributions entry, in order), decompose the contribution’s expression into summands $s = 0, 1, \ldots$ by EarthSciASTSplitter’s additive_terms (D14, D64):
+with any number of arguments: the concatenation of the decompositions of its arguments, in order;-with two arguments $a, b$: the decomposition of $a$, then each summand $t$ of $b$ as{"op": "-", "args": [t]};-with one argument $a$: each summand $t$ of $a$ as{"op": "-", "args": [t]};- anything else, including
*,faq,makearrayandindex: itself, as one summand.
A summand that is a numeric literal equal to zero is dropped (esm-libraries-spec §4.7.5 lets a binding drop a literal-zero contribution’s operand). Only a literal is dropped: in a - 0 the second summand is {"op": "-", "args": [0]}, an operation and not a numeric literal, so it is kept as a term. Each remaining summand is a term, identified by (variable, contribution, summand): the dependent variable’s flattened name and the two 0-based indices. For a lifted equation the contribution expressions are the per-cell bodies EarthSciAST records (the faq wrapper is around their sum), so terms are per-cell expressions; Rust identifies lifted equations by equation_lifted, Julia by the lift’s shape (esm-libraries-spec §4.7.5). Equations defining observeds and ic equations are not split into terms.
A term’s provenance label is built from its contribution’s origins: {"component": P} gives P; {"coupling": k} gives coupling:k; several origins (a collapsed contribution) are joined by + in their recorded order, e.g. SuperFast+coupling:4. An empty origins list is refused (term_origin_unknown), since only a caller’s edit of a flattened system produces one.
5.1.2 Owner
A term’s owner is the component a components selector attributes it to (D65):
- If its contribution has exactly one origin and it is a component
P:P. - Otherwise, let $R$ be the set of components owning the names the term references (states, parameters, observeds; a name’s component is the name without its last dot-separated segment), excluding the dependent variable’s own component,
tand index names. If $R$ is empty, the owner is the dependent variable’s component; if $R$ has one element, that one; if two or more, the term has no owner.
A component $C$ in a selector covers a component $P$ when $P = C$ or $P$ begins with $C$ followed by a dot.
5.1.3 Selection
Selection runs after every term’s locality is known (§5.2, §4 “Order of refusals”; D81). Every term operator of the run config (spec/run-config.md §3.1) is tested against every term. A selector matches a term when every key present matches:
components: some listed component covers the term’s owner (a term with no owner never matches);variables: the term’s dependent variable is listed;locality: the term’s locality class (§5.2) is listed;terms: the term’s provenance label is listed exactly.
Then, for each term in term order:
- matched by one operator: it belongs to that operator;
- matched by two or more:
term_selected_twice, naming the first two operator names in code-point order; - matched by none, with no owner, while any operator’s selector has a
componentskey:ambiguous_term_owner, naming the term and the components it reads; - matched by none otherwise: it belongs to the default operator of its locality,
default_pointwise,default_column,default_stencilordefault_global(spec/run-config.md §3.3).
An operator of the run config that no term matches is selector_matches_nothing; component paths in a selector are not otherwise checked against the model, while variables entries must name states (name_unresolved, name_wrong_kind). A map operator is unsupported_map_operator.
Selection’s refusals come in this order, the first one found ending planning:
unsupported_map_operator, for themapoperators in code-point order of operator name;variablesentries (name_unresolved,name_wrong_kind), operators in code-point order, each operator’s entries in written order;- per term, in term order,
term_selected_twiceorambiguous_term_owneras above; selector_matches_nothing, for the first such operator in code-point order.
Architecture §3.2’s inlining of an assembly component’s observeds, for EqWeFiC’s summed tendencies, is not done at M2: such a term belongs to the assembly component by rule 1 (D65).
5.1.4 Operators
An operator is the set of its terms in term order, with the run config’s bind_dt if it has one. At M2 every operator’s kind is rhs. Default operators exist only when they have terms. The plan lists each term as an object:
{"variable": "Chem.O3", "contribution": 1, "summand": 0, "provenance": "Transport", "locality": "stencil"}
5.2 Pass 2: locality
5.2.1 Axes
The run config’s axes (spec/run-config.md §6.1, D67) gives horizontal, a list of one or two index-set names, and vertical, one name. Defaults: no vertical axis; horizontal is every index set that is a dimension of some state’s shape (§6.1.3 for lifted states, whose shapes §4 step 8 resolves first), other than the vertical one, in the flattened model’s index_sets order. A named index set that does not exist, a name in both roles, or more than two horizontal axes is axes_unresolved. An index set a state uses that is neither horizontal nor vertical is an other axis (possible only when the run config gives axes). Offsets on an other axis do not affect a term’s class, while a reach that covers one makes the term global (§5.2.3–§5.2.4, D82).
Pass 2 runs in two parts (D81): each term’s class (§5.2.2–§5.2.4) before selection (§5.1.3), which reads it, and each operator’s class and radius (the end of §5.2.4) after.
5.2.2 A term’s bands
The term’s band list is EarthSciASTDiff’s structure-only state Jacobian (esm-jacobian-spec §2.1; Julia jacobian_bands(...; wrt = :states, coefficients = false), Rust jacobian_structure_view with Wrt::States) of the term’s own right-hand-side document: §6.1’s document for a hypothetical operator whose only term is this one, loaded and flattened by EarthSciAST and viewed with sysview. Locality and radius (§5.2.3–§5.2.4) read this pattern, never the full mode’s. It is computed as the full band list is but takes no derivative and carries no coefficient, and its points include the full list’s (P(full) ⊆ P(structure-only)), with equality unless some (region, site) coefficient of the full mode simplifies to the literal 0; such a pair is a coupling of the term’s expression that cancels, and pass 2 counts it. Pass 4 (§5.4) and the derived documents (§6) read the full mode. An EarthSciASTDiff failure in structure-only mode (a filtered or non-+ faq, any other refusal) is band_model_failed at /model, carrying its message. The structure-only mode refuses what the full mode refuses, an operator with no derivative rule (underivable_operator) included (esm-jacobian-spec §2.1, EarthSciASTDiff 0.4.0), so such a term is refused here. A failure only the full mode finds, should one remain, is found in item 4 of the order of refusals (§4), when the operator’s own document fails. Pass 4 takes the operators in code-point order of their names, so the operator reported is the first in that order whose own document fails, whatever the order of the terms of other operators. The refusal is then band_model_failed at /model against the first of that operator’s terms, in term order, whose own document fails in the full mode, with that document’s message, and against the operator, with its own document’s message, when none does or when finding it meets a failure that is not EarthSciASTDiff’s (an EarthSciAST failure on a term’s own document). How the planner finds that term is not prescribed; both planners search as pass 2 does (below), in the full mode, over the operator’s terms: the same batches, each probed whole and, when it fails, split in halves, first half first, down to single terms’ own documents, about $2\log_2 n$ documents for an operator of $n$ terms rather than one per term. Since the full mode’s points are a subset of the structure-only mode’s, the class and radius an operator’s full band list (§5.4) implies are never wider than its locality (§5.2.4); both planners check this after pass 4’s band list, and a wider one is an internal planner error, not a refusal. A narrower one is allowed.
This defines the band list; it does not prescribe one document per term, which would mean one EarthSciAST load and flatten per term, a cost that grows with the model. Pass 2 uses the bands only for the locality and radius they imply (§5.2.3), so a planner may compute them any way that gives the same offsets. Whatever the method, a failure it finds is reported against the term it concerns.
Both planners compute the band lists from probe documents (D85), one for each batch of terms. The batches are consecutive runs of the term list, in term order, whose terms have at most 10,000 operator nodes in all, unless the run is a single term. A term’s operator nodes are those of its summand (§5.1.1; for a lifted term, the per-cell body), counting every occurrence, in args and in every other Expression-valued field, so a subexpression shared by several parents counts once for each. A batch ends just before a term that would take its total over 10,000: the first batch runs from the first term to just before the first term that would, the next from there, and so on, and a list whose terms have at most 10,000 operator nodes in all is one batch. A large term, one with more than 10,000 operator nodes, is therefore a batch by itself. A batch’s probe document is §6.1’s document of the batch’s terms, except that every state of the model has the zero right-hand side (§6.1.2) and the $k$-th term of the batch (1-based, term order) is instead the whole right-hand side of a new state JacobianEval.term_k. That state is declared as the term’s equation’s state is: its shape (the same index-set names, so §5.2.3 pairs its dimensions as it would the state’s; a lifted state’s being the one flatten records) and its default. Its equation is the one §6.1.2 writes for that state holding the term alone (a lifted term inside the same faq wrapper), and it has that state’s ic equations, if any (§6.1.1). A planner may write the subexpressions the batch’s terms share once each, as zero-parameter templates shared_k of the component JacobianEval (esm-spec §9.6), with each place one occurs, in the terms and in other templates’ bodies, written as an apply_expression_template reference to it. The references expand to exactly the terms, so the templates change no band list, and a probe with any such templates, or with none, is a probe document of the batch. The Julia planner chooses them as follows; the choice is not prescribed. Subexpressions are compared structurally and chosen outermost first. A subexpression becomes a template when it has at least 8 operator nodes, counting every occurrence, and occurs at least twice in what is written: in the terms, and in the templates’ bodies, each body counting once however many references it has. A subexpression that occurs only inside one template’s body therefore stays in that body. A subexpression with 32 templates above it, on some path from a term down to it, is written in place, so no chain of templates referencing templates is longer than EarthSciAST’s MAX_TEMPLATE_EXPANSION_DEPTH (esm-spec §9.7.3). A term that the flattened model holds as a graph whose nodes are shared (template expansion makes one)
then takes about as much space in the probe as in the model, rather than its size as a tree, as long as its shared subexpressions are nested fewer than 32 deep. Below that depth they are written in place, so a term whose sharing is nested much deeper can still be exponentially larger in the probe than in the model. Each probe is loaded and flattened once and its structure-only entries are computed once;
its bands with row JacobianEval.term_k are the band list of the batch’s $k$-th term, row renamed to the term’s variable.
Structure-only entries carry no coefficient (§5.2.3 reads each band’s row and column variables, rows, row_idx, col_idx and contracted). A planner need keep nothing else of a probe once its bands are read, and both planners drop it before the next batch, so they hold one batch’s probe at a time: of one large term, or of terms with at most 10,000 operator nodes in all.
Batching changes no band list, since each is its term’s own document’s whichever batch the term is in. No name of the model can clash with the new states, since §4 step 7 refuses a top-level component JacobianEval (§6.1.4). A probe that succeeds gives every band list of its batch, and no term is refused here (D86). The probe holds everything a term’s own document holds, with the term’s state replaced by JacobianEval.term_k, declared, initialized and written as that state, so a term whose own document fails is expected to fail its batch’s probe too. Each operator’s own document, which holds its terms with their own states, is differentiated again in item 4 (§5.2.4), where a failure the probe did not show is band_model_failed for the operator. An EarthSciAST or EarthSciASTDiff failure on a probe names no term. On any such failure the planner refuses at the first term, in term order, whose own document fails, with the message that document gives. The batches are probed in order and every earlier batch succeeded, so that term is in the failing batch; how the planner finds it is not prescribed (one way: probe the first and second halves of the batch in turn, splitting a half that fails, down to single terms, each differentiated from its own document).
5.2.3 Offsets
For each band entry, with row variable $x$ and column variable $y$:
- Scalar row and scalar column: no reach.
- One scalar, one array: the term is global.
- Both arrays: dimensions are paired by index-set name: the $j$-th dimension of $y$ on index set $X$ (counting only $y$’s dimensions on $X$, from the left) pairs with the $j$-th dimension of $x$ on $X$. For each dimension $d$ of $y$ with index set $X$:
- if $x$ has no paired dimension on $X$ (no dimension on $X$, or fewer than $j$): the reach covers $X$ (global if $X$ is horizontal or other, column if $X$ is vertical);
- if
col_idx[d]references acontractedname of the entry: likewise, the reach covers $X$; - otherwise, with
row_idxentry $r$ of $x$’s paired dimension andcol_idx[d]= $c$, the raw offsets are:- $c$ equal to $r$: ${0}$;
- $c$ of the form $r + \delta$, $r - \delta$ or $\delta + r$, $\delta$ an integer literal: ${\pm\delta}$;
- $c$ an integer constant $k$, and $r$ an integer $\rho$: ${k - \rho}$. An integer constant is, recursively, an integer literal, a
-of one or two integer constants, or a+or*of one or more integer constants. Its value is evaluated exactly in 64-bit signed integers; an expression whose value, or the value of any of its sub-expressions, falls outside that range is not an integer constant (the “anything else” case below). An integer literal is a number the flattened model holds as an integer (§2.2: the literal type is kept); a float literal such as1.0is not one, even when its value is integral, so+(i, 1.0)is “anything else”; - $c$ an integer constant $k$, and $r$ a name over the band’s row range $[lo, hi]$ on that dimension: ${k - hi, \ldots, k - lo}$;
- one of the three cases above whose offset $-\delta$, $k - \rho$, $k - hi$ or $k - lo$ falls outside 64-bit signed integers: as “anything else” below, the reach covers $X$, even when $k$ itself is a valid integer constant;
- anything else (another index name, a table gather, a nonlinear index): the reach covers $X$.
On a horizontal axis of size $N$, each raw offset $o$ is reduced: $o’ = ((o + \lfloor N/2 \rfloor) \bmod N) - \lfloor N/2 \rfloor$, with $\bmod$ giving a value in $[0, N)$. A periodic seam, cell 1 reading cell $N$, has $o = N - 1$ and $o’ = -1$ (D66). Vertical offsets are not reduced. Offsets on an other axis are computed by the same rules but not used: they do not affect the class (§5.2.4, D82).
5.2.4 Class and radius
- The term is global if any band makes it global, or the reach covers a horizontal or other axis. (No size check on the radius is needed: reduction keeps every horizontal offset in $[-\lfloor N/2 \rfloor, \lceil N/2 \rceil - 1]$, so
lower+upper+ 1 never exceeds the axis size $N$.) - Otherwise stencil if some reduced horizontal offset is nonzero. Its radius on a horizontal axis is
[lower, upper]:lowerthe largest $-o’$ over negative offsets on that axis (cells toward lower index),upperthe largest $o’$ over positive ones, each 0 if there are none. - Otherwise column if some vertical offset is nonzero or the reach covers the vertical axis.
- Otherwise pointwise. A term with no bands is pointwise.
A nonzero offset on an other axis therefore leaves the class as the horizontal and vertical offsets give it: such a coupling stays inside one cell’s block, since an other axis is never divided among shards, tiles or ranks (D82).
An operator’s class is the coarsest of its terms’ in the order pointwise < column < stencil < global. A stencil operator’s radius lists every horizontal axis, each direction the maximum over its stencil terms (0 where none reaches). Only stencil operators have radius (the schema checks this).
5.3 Pass 3: DOF sets
writes: the dependent variables of the operator’s terms.reads: every state, parameter and observed name the operator’s terms reference, together with, transitively, every name referenced by the definitions of the observeds among them.tand index names are not names.forcing: the data-fed parameters (§4.1) amongreads. Absent when there are none, so a plan of a model without data is unchanged.
Both are sorted by code point (§2.1). A data-fed parameter is a parameter like any other here: it is in reads when a term or a live observed references it.
5.4 Pass 4: structure
The operator’s band list is EarthSciASTDiff’s jacobian_bands (states) of its right-hand-side document (§6.1), in EarthSciASTDiff’s order (equation order, then sorted column variable, then canonical site key; the order its .bands.json goldens hold). Band $k$ (1-based) is band field J_k of the Jacobian document (§6.2). An EarthSciASTDiff refusal is band_model_failed at /model.
structure holds:
| Key | Present | Contents |
|---|---|---|
class | always | EarthSciASTDiff’s detect_structure on the band list: block_diagonal, banded, general or empty. A classification only: a periodic stencil is general (structure.jl marks every literal column index general), which is why the footprint below is read from the bands directly. |
linear_in_u | always | true when no band coefficient references a state, or an observed whose definition references a state, transitively. The coefficients are read as EarthSciASTDiff returns them, after it substitutes observeds: an observed it has inlined is not live in the Jacobian document and is not followed again. |
depends_on_t | always | true when the operator’s right-hand-side document references t in a state equation or in the definition of an observed it keeps. |
bands | always | One object per band, in field order: row, col, rows, row_idx, col_idx and, when non-empty, contracted, exactly as EarthSciASTDiff’s serialize_band writes them, without coef. |
block | pointwise operators whose class is block_diagonal | EarthSciASTDiff’s plan_factorization (BlockDiagonalPlan, markowitz_ordering, lu_fill) serialized by serialize_plan without its type key: vars (its block_vars), ordering, pattern, lu_pattern (1-based pairs). |
column | column operators | axis (the vertical axis), kl, ku: the largest sub- and super-diagonal vertical offsets over all bands of the operator’s terms’ band lists (§5.2.2, the pattern locality reads, not the band list above), $\max(0, -\min o)$ and $\max(0, \max o)$; a band whose reach covers the vertical axis of size $N_v$ makes both $N_v - 1$. |
stencil | stencil operators | footprint: the distinct vectors of reduced horizontal offsets over all bands of the operator’s terms’ band lists (§5.2.2: the pattern that locality and radius read, not the band list above, so the footprint and the radius agree even when a coupling cancels in the full mode), each vector one integer per horizontal axis, in axes.horizontal order, an axis without an offset in that band contributing 0, sorted lexicographically. A band with several offsets on one axis (an integer-constant column against a named row range, §5.2.3) contributes every combination: the Cartesian product of its per-axis offset sets. The product is exact, not an over-approximation: a band’s rows is a box, one range per dimension, and each dimension’s column index depends only on that dimension’s row index, so every combination of per-axis offsets is reached by some row of the band. Offsets of different bands are never combined: bands reading $(i+1, j+1)$ and $(i-1, j-1)$ contribute those two vectors and not $(1, -1)$ or $(-1, 1)$. |
The Gershgorin bound of an operator at $(u, p, t)$ is $\rho = \max_r \sum_c |J_{rc}|$ over the rows $r$ of its writes DOFs, where $J$ is assembled from the band fields of its Jacobian document at $(u, p, t)$, adding every band’s contribution to an entry before taking the absolute value (esm-jacobian-spec §4 additivity). At M2 it is used by pass 5 only; architecture §3.5’s step limit from it needs limit operators (U6) and is not emitted (D71).
5.5 Pass 5: stiffness and strategy
5.5.1 Sampled states
Passes 5 and 8 evaluate at three sampled states $s = 1, 2, 3$:
- time $t_s = t_0 + (t_f - t_0),q_s$, with $q_s$ = 0.25, 0.5, 0.75 and the interval of §7.2;
- parameters at their defaults;
- data-fed parameters (§4.1) at sampled forcing values (D87): $F_s[m] = b^F_m \cdot (0.5 + r_{s+3,m})$ for every element $m$ (0-based) of the canonical forcing order, the used data-fed parameters’ (§4.1) names sorted by code point, each array’s elements column-major (a scalar is one element). $b^F_m$ is the parameter’s
defaultat that element, nonzero by §4.1. Unused data-fed parameters are bound to 1 and never read. $r_{s+3,m}$ is the function below at sample index $s + 3$, so the forcing stream (4, 5, 6) never meets the state stream (1, 2, 3). Every element of a record axis is sampled on its own, so the two records of a bracket differ and a model’s time blend between them is exercised; - state $u_s[n] = b_n \cdot (0.5 + r_{s,n})$ for every DOF $n$ in canonical order, where $b_n = u_0[n]$ if it is nonzero and $1$ otherwise, and $r_{s,n} \in [0, 1)$ is SplitMix64 of the 64-bit unsigned integer $z = 2^{32} s + n$:
z = z + 0x9E3779B97F4A7C15
z = (z XOR (z >> 30)) * 0xBF58476D1CE4E5B9
z = (z XOR (z >> 27)) * 0x94D049BB133111EB
z = z XOR (z >> 31)
r = (z >> 11) * 2^-53
with wrapping 64-bit arithmetic and >> a logical shift. Check values: $r_{1,0}$ = 0.766301757339086, $r_{1,1}$ = 0.12603102042098713, $r_{2,0}$ = 0.905065227878185, $r_{3,2}$ = 0.03750424614895098. Every operation is exact or correctly rounded binary64, so both languages produce the same bits given the same $u_0$. Forcing check values: $r_{4,0}$ = 0.5689035232967019, $r_{4,1}$ = 0.4280676132950757, $r_{5,0}$ = 0.7116989708490432, $r_{6,2}$ = 0.7714896152704298.
The sampled forcing values check identities (rhs_sum, band_model) that hold at any finite input; they are not physical values and say nothing about a run’s regime. That is why stiffness rule 4 does not use them (§5.5.2). The planner reads no data file, so a plan, its goldens and its checks are the same whatever data exists, and both planners compute the same values.
5.5.2 Stiffness
Each operator gets stiffness: {class, source} (D70), by the first rule that applies:
run_config: the leaf that advances the operator givesalg(not"auto").stiffif the method is a Rosenbrock, SDIRK,IMEXEulerorThetamethod (spec/methods.md §2),nonstiffif explicit.reaction_systems: some term of the operator has a single component origin that is a reaction system of the model.stiff.solver_hint: the operator is pointwise and the model’ssolver.stiffnessis"high".stiff. ("low"and"moderate"assert nothing; D70.)gershgorin:stiffif $\max_s \rho(u_s, p, t_s) \cdot \Delta \ge 10$, elsenonstiff. $\Delta$ is the reference step of the leaf that advances the operator (§5.5.4). An operator whose Jacobian document reads a data-fed parameter (a band coefficient references one, directly or through an observed) is not classified by this rule: its bound would be evaluated at the synthetic forcing values of §5.5.1, whose magnitudes are arbitrary, so the classification would be a guess. It is refused,auto_stiffness_reads_forcing, at the pointer of the leaf that advances it (/scheduleunder an"auto"schedule), and the run config names the leaf’salginstead (D87; representative values in the run config were considered and not adopted, docs/decisions.md §7.2).
5.5.3 Methods where alg is "auto"
A leaf’s class is the coarsest locality of its operators, and it is stiff if any of its operators is. Then (D71):
| Leaf | alg | jacobian | linsolve | dt_scope |
|---|---|---|---|---|
| pointwise, stiff | Rosenbrock23 if the leaf’s resolved reltol ≥ 1e-3, else Rodas4 | symbolic | {"block_lu": {"pivot": true, "refine": 0}} if the block has at most 16 variables, else {"sparse_block_lu": {"refine": 0}} | cell |
| pointwise, non-stiff | Tsit5 | — | — | cell |
| column, stiff | TRBDF2 | symbolic | {"banded": {}} | global |
| column, non-stiff | Tsit5 | — | — | global |
| stencil | SSPRK43 | — | — | global |
stencil with a stiff column operator, leaf dt given | IMEXEuler, imex implicit = the stiff column operators, explicit = the rest, implicit_solve converged | symbolic | {"banded": {}} | global |
stencil with a stiff column operator, no dt; global, stiff | refused: auto_strategy_unavailable | |||
| global, non-stiff | SSPRK43 | — | — | global |
For a stencil leaf, only the stiffness of its column operators enters the choice: the stencil rows depend on whether a column operator is stiff, never on the stiffness of its stencil, pointwise or global operators. The block size is the number of vars in the union of the leaf’s operators’ structure.block variables. The pointwise stiff row applies only when the leaf’s Jacobian storage is block_diagonal, whether or not the run config gives linsolve (spec/operator-interface.md §8.3: every operator has structure.block, and the leaf’s state variables, the states its operators write, are all arrays of one shape or all scalars), since block_lu and sparse_block_lu need it; otherwise the leaf is refused, auto_strategy_unavailable. A linsolve, jacobian or dt_scope the run config gives explicitly is kept; only "auto" or an omitted key is resolved, and an omitted linsolve or jacobian on an explicitly named implicit method resolves as this table’s row for the leaf’s class would (pointwise block_lu/sparse_block_lu, column banded, otherwise auto_strategy_unavailable). controller "auto" is PI. Once a leaf’s "auto" method is resolved here, its precision_low_with_classical_rosenbrock check (§7.5) runs.
5.5.4 Reference step $\Delta$
Computed top down over the resolved schedule. The root receives $\Delta_{\text{root}}$: step.dt when fixed; else the smallest {"max": d} in step.limits; else $t_f - t_0$. A sequence passes its $\Delta$ to every child; a strang passes $\Delta/2$ to every child but the last, which gets $\Delta$; a subcycle with n passes $\Delta/n$, and with limits passes the smaller of $\Delta$ and its smallest {"max": d}; an every passes its period in seconds. A leaf’s $\Delta$ is its numeric dt if it has one, else what it receives. Operators advanced by no leaf are refused earlier (§7.1).
5.5.5 The "auto" schedule
"schedule": "auto" (D21, D72): group every operator, named and default, by its locality class; one leaf per non-empty class, holding that class’s operators sorted by name, in the order global, stencil, column, pointwise; every leaf option "auto". One leaf is itself the root. Two or more form a sequence, or a strang when the model’s solver.splitting is "strang".
5.6 Pass 6: derived documents
§6.
5.7 Pass 7: partition
§7.4.
5.8 Pass 8: validation
§8.
6. Derived documents
6.1 The right-hand-side document <operator>.rhs.esm
A JSON object with exactly these keys:
"esm": "1.3.0"."metadata": {"name": "<operator>"}."index_sets": every index set of the flattened model,{"kind": "interval", "size": N}(§4 step 6 refused any other kind)."domain": when the flattened model has adomain, the fields of it that bear on evaluation, as EarthSciASTDiff writes them in its evaluation document (esm-jacobian-spec §8.1):temporalwith those ofstart,endandreference_timethe source gives (absent when it gives none), thenelement_typeandarray_typewhen given, each value as EarthSciAST serializes it.independent_variableand every other field are left out. So an operator’s right-hand-side and Jacobian documents carry the samedomain. Absent when the flattened model has none, or when none of these fields is given."models": the component tree (§6.1.1)."data_sources": present only when the operator reads data-fed parameters (itsforcing, §5.3), in §6.3’s stub form.
No other key appears: no solver, coupling, reaction_systems, function_tables, enums, expression_templates or metaparameters, all of which were consumed by load and flatten; data_sources appears only in §6.3’s stub form.
6.1.1 The component tree
The document declares the flattened model’s variables in models named after their components (D68). A variable’s flattened name $P.v$ places $v$ in the component at path $P$: the top-level segment of $P$ is a key of models, and each further segment a key of the parent’s subsystems. A reaction system’s species and parameters are declared in a model of the reaction system’s name. Every model and subsystem has "variables" and "equations" (possibly empty) and, when it has children, "subsystems". The tree holds only components that declare a variable, and their ancestors: a component with no state, no parameter and no live observed does not appear, unless a descendant declares one.
Declared, in each model:
- every state of the flattened model;
- every parameter of the flattened model;
- every observed that is live: referenced by a term of this operator, or by the definition of a live observed.
A variable’s object is {"type": T} plus "shape" and "default" when present: T is "parameter" for parameters and "unknown" otherwise; shape is the declared shape, or for a lifted state the shape of §6.1.3, written only when non-empty (a variable declared with "shape": [] has none, as in EarthSciASTDiff’s evaluation document). An observed’s declared shape is the one its own declaration in the model gives, or none: an observed declared without a shape whose definition is an array (esm-spec §4.3.4: elementwise down to an ifelse over an array, as reseact’s WetDeposition.i_rain = ifelse(qrain > 0, 1, 0)) is written without one, as both bindings’ flatten keep it (EarthSciAST#672; the Julia binding infers the shape only when it builds a problem), and loading the document infers it again (§10 item 24); default is the flattened variable’s default, as EarthSciAST holds it. The schema allows a default to be only a number or a nested array of numbers, and every such number is a binary64 value (EarthSciAST’s Rust binding holds it as f64 from load; a binding that keeps an integer is converted to binary64 by the planner), so a default is written as that binary64 value in stable_json’s float form, whatever the literal’s type in the source: 10000 is written 10000.0 and 30000000 is written 3.0e7. An integer the source writes beyond $2^{53}$ is therefore rounded to the nearest binary64, and the planner writes the rounded value (9007199254740993 is written 9.007199254740992e15); a planner does not try to recover the source digits. An array default keeps its nesting, each element written the same way. (Expressions and defaults differ: §2.2 writes a number of an expression, a const node’s value included, as an integer whenever it is integral and fits a 64-bit signed integer, whatever its type in the model, while a default is always written as a binary64 float.)
6.1.2 Equations
In each model, equations lists, in the flattened model’s equation order (esm-libraries-spec §4.7.5 step 4, which every binding of EarthSciAST must produce alike; §10 item 25), the equations whose subject belongs to that model’s component (for a state equation the dependent variable, for an observed definition the observed), followed by the ic equations of its states in field_ics order. Every reference is written as its flattened (fully scoped) name, on both sides; both EarthSciAST bindings (0.4 and 0.5) resolve such references from any model (D68).
- State equation, not lifted.
{"lhs": {"op": "D", "args": ["<x>"], "wrt": "t"}, "rhs": R}, where R is the left fold((t_1 + t_2) + t_3) + …of this operator’s terms of that equation in term order, written as nested binary{"op": "+", "args": [a, b]}(one term: the term itself). With no term:0.0for a scalar state, and{"op": "makearray", "args": [], "regions": [[[1, n_1], …, [1, n_k]]], "values": [0.0]}for an array of extents $n_1 \ldots n_k$. - State equation, lifted. The same left-hand side,
D(x)on the whole array, and R ={"op": "faq", "args": [], "output_idx": O, "ranges": G, "expr": F}withOcopied from the flattened equation’s right-hand-sidefaqnode,Gmapping the $d$-th name ofOto{"from": X_d}, $X_d$ the index set of dimension $d$ of the state’s shape (§6.1.3), andFthe left fold of the per-cell terms; with no term, themakearrayzero above.Gis built, not copied, because the bindings write the flattened ranges differently: Julia’sflattenwrites{"from": "cell"}, the form above, while Rust’s (and Python’s) write the interval[1, 4]. - Observed definition (a live observed): copied, left-hand side and right-hand side serialized as they stand in the flattened model.
- Initial condition:
{"lhs": {"op": "ic", "args": ["<x>"]}, "rhs": E}for everyfield_icsentry of every state, whether or not the operator writes it, so every document builds the same $u_0$.
6.1.3 Shapes of lifted states
A lifted state (a key of EarthSciAST’s lifted_shapes) has no shape in the model as written. Its shape is the one flatten records on it in state_variables: the lift’s index sets, each named by an operand declared on it and read at the lift’s loops, else by the only declared index set of the dimension’s size, else a synthetic interval _liftdim<d>_<extent> that flatten adds to the flattened index_sets (esm-libraries-spec §4.7.5, “Pointwise lift: index sets”; EarthSciAST 0.8.0, D68). Every binding records it, so the planner neither searches for it nor refuses a lift it cannot name. A synthetic index set is an index set of the flattened model like any other, so it is in every derived document’s index_sets (§6.1).
6.1.4 Name collisions
A model with a top-level component named JacobianEval is refused at §4 step 7 (reserved_component_name), since §6.2 uses that name, as do pass 2’s probe documents (§5.2.2).
6.2 The Jacobian document <operator>.jacobian.esm
EarthSciASTDiff’s evaluation document (esm-jacobian-spec.md §8.1; evaluation_document in Julia and Rust) of the operator’s right-hand-side document (§6.1), loaded and flattened, for the operator’s bands of pass 4 (§5.4), which the planner passes to it (Julia evaluation_document(flat, entries; cse, cse_min_nodes, data_sources), Rust evaluation_document(sv, entries, cse, cse_min_nodes)) so that they are not computed again, with subtree sharing on (cse = true, cse_min_nodes = 12, esm-jacobian-spec §4.1 and §8.1; D90), as EarthSciASTDiff writes it except that its ic equations are dropped (D69). So:
esmandmetadata.nameare EarthSciASTDiff’s ("1.1.0", or"1.2.0"when an expression node carriesunits;"JacobianEval").- The right-hand-side document’s variables are declared in its component tree (§6.1.1), every reference fully scoped. Every state equation’s right-hand side is the zero of §6.1.2 (
0.0, or themakearrayzero over the state’s extents), whether or not the operator writes the state; observeds are declared and defined when a band coefficient references them, directly or transitively; thedomainis carried as EarthSciASTDiff carries it. - A model
JacobianEvalholds, for each band $k$ = 1, 2, … in band order (the order ofstructure.bands, §5.4), the band fieldJ_k: the variable{"type": "unknown", "default": 0.0}plus, for an array row or a band withcontracteddimensions,"shape"(the row variable’s index sets followed by one index set per contracted dimension, added toindex_setsunder EarthSciASTDiff’s names), and the equationD(J_k) =the band’s coefficient over its region, zero elsewhere. Once flattened, the field is the stateJacobianEval.J_k. The same model holds the observeds subtree sharing hoists: each subtree of at least 12 nodes that the coefficients repeat becomes an observedJacobianEval.Jcse<n>, scalar or shaped over index setsjcax<m>that EarthSciASTDiff adds toindex_sets, and each use of it in a coefficient a reference to it, numbered and laid out by esm-jacobian-spec §4.1’s deterministic rule, so both planners write the same bytes. A coefficient with no repeated subtree that large is written whole, as it was before D90. - No
icequation. A state with nodefault(one only anicequation orfield_icsentry starts) would therefore have no starting value in the document, which EarthSciAST refuses when it builds a problem (E_TREEWALK_MISSING_INITIAL_VALUE, esm-spec §11.4). So the document’s own initial state, its construction values, is fixed as the planner drops theicequations: every state (every variable aDequation of the document defines) keeps thedefaultof the flattened model, written as §6.1.1 writes defaults; a state without one gets thedefault1.0, and so does a state whosedefaultisnullin the document EarthSciASTDiff builds, which counts as none (a model’s owndefaultis nevernull: the schema refuses it at load); the band fields keep EarthSciASTDiff’s0.0. Adefaultis a number or, on a shaped variable, inline array data; it is never an expression (esm-spec §6.3: the schema allows only these two forms). A number on an array state is broadcast to every element, as EarthSciAST builds it, and inline array data gives each element its own value, so an element’s construction value is its owndefault, and1.0written on an array state without one is1.0at every element. The planner never evaluates anicequation orfield_icsentry to supply a missingdefault, and never writes the plan’s $u_0$ into the document, which therefore depends only on the right-hand-side document’s declarations, not on its initial state. One rather than zero, so that no band coefficient divides by a zero the planner itself wrote (reseact’s transport Jacobian document divides byTransport3D.m, which has nodefault, in nearly every band field). The rule does not guarantee a finite document at its construction values: the document is finite there whenever its coefficients are finite at the model’s owndefaults (a missing one counting as1.0), but a state whosedefaultis0.0keeps it, so a coefficient that divides by such a state is not finite there. No check or runtime call evaluates the document at its construction values, since every evaluation is at a state its caller passes, and the planner and both runtimes build the document as written, with no calleru0(D89). - Data-fed parameters declared as §6.3 declares them, and the same
data_sourcesstub. The planner passes the right-hand-side document’sdata_sources(its stubs) to EarthSciASTDiff’s builder, Juliaevaluation_document(flat, entries; data_sources = …), Rustsysview_flat(&flat)?.with_data_sources(…), which keeps each data-fed parameter’sdatarule and the sources it names (EarthSciASTDiff#55, §10 item 17). Until the release with #55 is pinned (§0.2), the planner adds the declarations and the stub to the built document itself, as it drops theicequations; the bytes are the same either way. Without them a data-fed parameter would be a baked constant of the Jacobian document, not a forcing input.
Evaluating the document’s right-hand side at $(u, p, t)$ gives every band coefficient in the J_k slots and zero in every state’s slot. A runtime scatters JacobianEval.J_k into its matrix storage with structure.bands[k-1] (spec/operator-interface.md §8). For an operator that reads data, the document is evaluated with the same forcing as the right-hand side, so its coefficients are those of $J(u, p, t; F)$.
6.3 Data-fed parameters in derived documents
§6.1.1 declares every parameter of the flattened model, with this exception for data-fed parameters (§4.1), so that an operator’s programs have a forcing slot for exactly the data it reads (spec/operator-interface.md §1):
- an unused data-fed parameter is not declared: no term, live observed or
icequation of any derived document references it, and it may have nodefault; - a used data-fed parameter that this operator does not read (it is not in the operator’s
forcing, §5.3; anicequation or another operator reads it) is declared as an ordinary parameter,{"type": "parameter"}withshapeanddefaultas §6.1.1 writes them, and noupdate; itsdefaultexists and is nonzero (§4.1), and onlyicequations read it; - a data-fed parameter in the operator’s
forcingis declared, in its component as usual, as
{"type": "parameter", "shape": [...], "default": ..., "update": {"kind": "data", "source": "<source>", "from": {"file_variable": "<file variable>"}}}
with shape and default written as §6.1.1 writes them (default only when the flattened model has one; the planner invents none), and source and file_variable those of §4.1. Nothing else of the original update or from is copied: unit_conversion, codes and select describe how a provider produces values, which is the runtime’s concern (spec/operator-interface.md §8.4), not the document’s.
Both of an operator’s documents, right-hand side and Jacobian, declare the same set this way, so their programs have the same forcing slots. The document’s data_sources holds one stub entry per source named by a data-fed parameter of the operator’s forcing, keys sorted by code point, and is absent when the operator has no forcing:
{"<source>": {"kind": "<the source's kind>", "source": {"url_template": "{esm_integrator_forcing}"}}}
The url_template begins with {, so EarthSciAST uses it unchanged at load (esm-spec §8.2.1, “Substitution-led”): it resolves to no path, the document’s bytes do not depend on where the plan or the data lives, and nothing can read it, because the substitution is never expanded. The stub exists only so the document validates (update.source must name a data_sources key, data_source_undefined) and so that both bindings treat the parameter as externally fed: EarthSciAST’s rhs_program makes every parameter whose update has from a forcing slot (API_SPEC §5.8, “Forcing sets”), a 0-d slot for a scalar, and leaves it out of the scalar parameter vector. The original source’s temporal, url_template, mirrors and the rest stay in the model.
A document built from these declarations has no data bound to its data-fed parameters. A planner or runtime that builds it binds each one at construction to its default (EarthSciAST refuses a data-fed parameter with neither a default nor a binding, E_TREEWALK_MISSING_DATA; a used one always has a default, §4.1), and evaluates the right-hand side only through rhs_program with an explicit forcing set, never with the build’s own default forcing. Which construction-time mechanism a language uses (a provider, a caller array) is the implementation’s, but the value is the default, because the document’s ic equations read it: so every derived document builds the planner’s $u_0$ (§4, §6.1.2), whichever data-fed parameters its operator reads. No right-hand-side evaluation reads the construction-time values, since every one passes its forcing set. A run’s initial state is not taken from a derived document when an ic equation reads data (spec/operator-interface.md §8.4.5).
7. Resolved settings
7.1 Schedule
The planner refuses the unsupported constructs of §0.1 in the schedule (map, solve, split_explicit and coupled with algebraic operators, and an observed in a subcycle’s limits) in tree order, then an observed in step.limits, entries in written order, then checks the run config against spec/run-config.md §8 rules 2, 3, 5–9, 12, 13 and 15 in rule order, with the codes of spec/planner-errors.json, and then writes the resolved tree. Tree order is depth first, a node before its children, children in written order. A name in a subcycle’s or step’s limits is first resolved (name_unresolved; name_wrong_kind when it is not an observed) and then refused, unsupported_step_limit, at its pointer (/step/limits/<i> for step’s).
A node’s pointer is its JSON pointer in the run config: /schedule for the root, and below a node at pointer $P$, P/sequence/<i> or P/strang/<i> for the child at position $i$ (0-based), P/subcycle and P/every for the child of a subcycle or every. A refusal “at the node” or “at the leaf’s pointer” uses it. Every node of an "auto" schedule (§5.5.5) is reported at /schedule, with no key appended, since the run config has no node there to point at.
Rule 2 here covers the names §5.1.3 has not already checked, in this order:
- each operator’s
bind_dtkeys (model parameters), operators in code-point order, keys in code-point order; - the parameter-name durations outside the schedule:
step.dt, eachstep.limitsentry’smaxin written order,step.hit_multiples,step.resolution, thenoutput.saveat, then eachoutput.sinksentry’ssaveat, sinks in written order; - per schedule node in tree order: its parameter-name durations, in the order
dt,control.dt0,dtmin,dtmax,period,phase, then eachlimits[i].maxin order, then eachfrozen[i]’speriodandphasein order; then itsbind_dtkeys, its per-variablecontrol.abstolkeys and itsdomain.nonnegativeentries (states), object keys in code-point order and list entries in written order; - the
precisionkeys, in code-point order: each must bedefault, an operator name (named or an existing default operator) or a node id, elsename_unresolvedat/precision/<key>.
Rule 3 (operator_undefined) covers every operator name the schedule names: per node in tree order, its leaf or coupled list, then its frozen entries, then its imex entries, explicit before implicit, each in written order (a locality name in imex is not an operator name). The pointer is the entry’s: P/leaf/<i>, P/coupled/<i>, P/frozen/<i> (or P/frozen/<i>/every), and P/imex/<part>/<i>, or P/leaf and P/imex/<part> for a single string. Rule 6 therefore checks only partitions whose operators are defined.
Rule 5 (D84): an operator is advanced at a place when a leaf or coupled node names it or a frozen entry of such a node names it. An operator advanced nowhere is operator_not_advanced, at /operators/<op>, or at /schedule for a default operator, which has no run-config key. An operator named by two leaf or coupled nodes is operator_advanced_twice, at the second such node’s operator entry in tree order (P/leaf/<i> or P/coupled/<i>, P/leaf when the leaf names one operator as a string); a strang that runs a child twice is still one place, and a frozen entry does not count toward “twice”.
Rule 15’s pointer is the binding’s: the leaf’s /…/bind_dt when the leaf gives the "leaf" binding, and /operators/<op>/bind_dt when it comes from an operator’s bind_dt.
Rule 6 (imex_partition_invalid) is checked here, in rule order with the others, not when the leaf’s settings are resolved, and only on leaves whose written alg is IMEXEuler. A bad partition is refused at the leaf’s P/imex. An IMEXEuler leaf without imex is refused with the same code at the leaf’s pointer: the schema does not require the key, and there is no default partition. A leaf whose alg is "auto" and resolves to IMEXEuler gets its partition from §5.5.3 and is not checked here. An imex key on a leaf whose written alg is not IMEXEuler ("auto" included) is ignored: it is not checked by rule 6 and does not appear in the resolved leaf.
Rule 8 (hold_tendency_child_not_leaf) is reported at the every node’s P/every.
For every node:
id: the given id, else the node’s path:rootfor the root; a child of asequenceorstrangat position $i$ (0-based) is<parent path>.<i>; the child of asubcycleis<parent path>.subcycle, of anevery<parent path>.every. Paths are built from positions alone, whatever ids the ancestors have, so the unnamed first child of a nodetransportatroot.0isroot.0.0. A run config may write a path-form id only on the node whose path it is (node_id_reservedotherwise), so given and assigned ids never collide; the schema accepts both forms.leaf,coupled: always a list of operator names, in the order given (accumulation order, spec/operator-interface.md §3).- Durations (
dtwhen numeric,dt0,dtmin,dtmax,period,phase,{"max": …}) are written in seconds as numbers; a parameter-name duration is the parameter’s default (spec/run-config.md §2.2). A"<number> <unit>"string is the number parsed as the nearest binary64, times the unit’s seconds (an exact integer: 1, 60, 3600, 86400), one rounding in all; so"1.1 h"is the binary64 product of 1.1 and 3600,3960.0000000000005, not 3960.phase0 is written0. A parameter-name duration whose default is not a positive finite number isrun_config_invalid, at the pointer of the duration’s key (spec/run-config.md §2.2: a duration is positive, which the schema can check only for literals).
A leaf or coupled node is written with exactly these keys:
| Key | When | Value |
|---|---|---|
id, leaf/coupled, alg | always | resolved (§5.5.3) |
dt | when given | seconds, "step" or "accumulated" |
control | always | object below |
jacobian | methods that form $J$ (Rosenbrock, SDIRK, IMEXEuler, Theta) | given, or symbolic |
linsolve | methods that form $J$ | object form with every option this spec defaults: {"block_lu": {"pivot": P, "refine": R}} (defaults true, 0), {"sparse_block_lu": {"refine": R}} (0), {"dense_lu": {}}, {"banded": {}}, {"block_tridiagonal": {}}, {"matrix_free": {}}; Krylov options as given |
domain | always | {"on_violation": V, "check_stages": B} (defaults reject, false) plus nonnegative when given |
imex, implicit_solve | IMEXEuler | imex as lists of operator names in leaf order, a locality name expanded to the leaf’s operators of that class; implicit_solve given or converged |
theta | Theta | as given |
frozen, bind_dt | when given | as given, durations in seconds |
control holds reltol (default 1e-3) and abstol (default 1e-6; a per-variable object as given) always; dt_scope always (§5.5.3; an explicit method’s leaf takes the table’s dt_scope for its class); and, for adaptive leaves only (no dt), controller (default PI) and maxiters (default 1000000), plus dt0, dtmin and dtmax when given (dtmin’s default, 0, is not a duration the schema admits, and means no minimum). A leaf with a dt (numeric, "step" or "accumulated") has no controller, dt0, dtmin or dtmax in the resolved leaf, even when the run config gives them. A fixed-dt leaf’s maxiters is written only when given (spec/methods.md §12: unlimited otherwise). A leaf’s partition key does not appear in the resolved leaf; it lands in partition.shards (§7.4).
Other nodes keep their keys, resolved recursively: sequence, strang (children), subcycle (n or limits with {"max"} in seconds), every (period, phase (default 0), hold (default none)).
7.2 Time
The interval comes from the caller, the run config’s time, or the model’s solver.time_span, the first that gives one giving all of it (D60). Numbers are seconds. A timestamp, from any source, is converted exactly as esm-spec §2.2.7 converts a time_span timestamp: seconds from the model’s time origin (domain.temporal.reference_time, else domain.temporal.start, else the POSIX epoch) as $(W - W_0) + (f - f_0)$ (D74). The model’s solver.time_span is read with EarthSciAST’s resolve_time_span. duration is added to the start in binary64. Refusals: no interval (time_interval_missing), end not after start (time_interval_empty), a timestamp that is malformed or names no calendar day (time_invalid_date), a timestamp when the model’s origin field is unreadable (time_origin_invalid, at the key of the timestamp that needs the origin, such as /time/start; "" for the caller’s, /model for the model’s solver.time_span) (spec/run-config.md §8 rule 14). A refusal about an interval the caller gave has no run-config key and is reported at the empty pointer "". A model solver.time_span that EarthSciAST rejects is refused at /model with the code of its cause (§4 step 3), and that includes a malformed timestamp, which EarthSciAST’s schema rejects at load: it is time_invalid_date at /model. No schema checks the caller’s interval, so a malformed timestamp there is time_invalid_date at "". A run-config timestamp the run-config schema rejects stays run_config_invalid at "" (§4 step 1). A planner converts with EarthSciAST’s timestamp_to_seconds (on the raw domain before the model loads) and maps its codes: timestamp_malformed and timestamp_no_calendar_day to time_invalid_date, time_origin_invalid to time_origin_invalid. The plan’s time is {"start": t_0, "end": t_f}.
7.3 Step
step is written with dt (seconds) or limits (each {"max": seconds}; an observed limit was refused in §7.1, unsupported_step_limit) when given, hit always (sorted; [] by default), and hit_multiples and resolution (seconds) when given. With neither dt nor limits the root must be a single adaptive leaf or coupled node (step_control_missing, rule 12). forcing_refresh in hit is kept as written: its times are the providers’ record times, which the runtime knows and the planner does not (it reads no data, §4.1), so rule 9 is checked for them by the runtime when it builds the run (spec/operator-interface.md §8.4), not here. A model in which some operator reads a data-fed parameter of discrete cadence (it is in an operator’s forcing, §5.3) needs forcing_refresh in step.hit, since a step must not cross a forcing epoch (architecture §7.5); without it the plan is refused, forcing_refresh_not_hit at /step/hit (/step when step has no hit), checked right after rule 12 and before rule 13 (§7.1 checks the rules in rule order, so a run config that also breaks rule 9 is refused for that first). Rule 9 (tstop_off_fixed_step) is checked for the tstops M2 can know: hit_multiples (its multiples $k,m$ from model time zero in $(t_0, t_f]$, each the binary64 product) and the adjoint’s observation times. A tstop $\tau$ is on the fixed step when it falls on a macro-step boundary of spec/run-config.md §5, tested exactly as follows in binary64, with $\lambda(a, b)$ meaning $|a - b| \le 100,\epsilon(\max(|a|, |b|))$ (spec/methods.md §10’s landing tolerance, $\epsilon(x)$ the spacing of binary64 at $x$): $\lambda(\tau, t_f)$; or, with $r$ the integer nearest $(\tau - t_0)/\mathtt{dt}$ (either on a tie), some $k \in {r - 1, r, r + 1}$ with $k \ge 1$ has $b = t_0 + k \cdot \mathtt{dt}$ (product, then sum) with $\lambda(\tau, b)$, and the macro step from $t_0 + (k - 1) \cdot \mathtt{dt}$ (just $t_0$ for $k = 1$) is not a stop step by spec/methods.md §10 with $h = \mathtt{dt}$ and $t_{\text{stop}} = t_f$. Otherwise it is refused, at the key that gave it: /step/hit_multiples, or /adjoint/functional/over for an adjoint observation time. The observation times are $t_f$ for over: "final", none for over: "time", and for over: "saveat" the output times of spec/run-config.md §7.3: with output.saveat a duration $d$, the multiples $k,d$ ($k$ an integer, counted from model time zero, the time origin of D74, each the binary64 product) taken as follows: a multiple with $\lambda(k,d, t_f)$ is the output time $t_f$, written as $t_f$; a multiple with $\lambda(k,d, t_0)$ is excluded, like $t_0$ itself; any other multiple is kept when it lies in $(t_0, t_f]$; $t_f$ is not added when no multiple lands on it. Without saveat, $t_f$ alone (D83). So $d = 0.1$ on $[0, 0.3]$ gives 0.1, 0.2 and 0.3: $3 \times 0.1$ is 0.30000000000000004, which lands on $t_f$ and is written 0.3.
7.4 Partition
partition (D77):
ranks,tiles: as given, default[1, 1]. Entry 0 dividesaxes.horizontal[0], entry 1axes.horizontal[1]. With fewer horizontal axes, the entries for missing axes must be 1 (partition_axis_missing, at/partition);ranks[a] · tiles[a]above the axis size ispartition_too_fine.halo: for each horizontal axis, the largestlowerorupperradius over all stencil operators, 0 when none; absent when there is no horizontal axis.shards: for every pointwise or column leaf, by node id: the leaf’s ownpartition.shards, else itsshard_sizeturned into $\lceil \text{cells} / \text{shard_size} \rceil$, else the run config’spartition.shardsentry for the leaf’s node id, else for one of its operators (all of a leaf’s operator entries must agree,partition_conflict, reported at the first operator entry, in the leaf’s operator order, that disagrees with the leaf’s first entry,/partition/shards/<op>), else 1."auto"is 1 at M2. With no pointwise or column leaf,shardsis{}. Cells is the largest, over the states the leaf’s operators write, of the product of the sizes of the state’s dimensions on horizontal axes and, for a pointwise leaf only, on the vertical axis (a column leaf’s shards hold whole columns); a dimension on an other axis is not counted, since it stays inside a cell (D82), and a state with no such dimension counts 1. So a column leaf writingA[lon, lat, lev]andB[lon, lat, species]has $|\text{lon}| \cdot |\text{lat}|$ cells. “Leaf” here means aleafnode only: acouplednode gets noshardsentry, unlike §7.5, which namescouplednodes explicitly. The schema admits nopartitionkey on acouplednode, and apartition.shardsentry whose key is acouplednode’s id, or an operator that acouplednode names, isrun_config_invalidat/partition/shards/<key>, since it could shard nothing; these entries are checked in code-point order of the key, before the rest ofshards.output_shard: absent at M2.
7.5 Precision
For every leaf and coupled node, by id, all five roles (spec/run-config.md §7.1): the node-id entry overrides the leaf’s operators’ entries, which override default, role by role; operator entries of one leaf that disagree on a role are precision_conflict, reported at the first operator entry, in the leaf’s operator order, that disagrees on that role with the leaf’s first entry setting it, /precision/<op> (the reading of partition_conflict, §7.4). Unset roles: state is the declared element_type that governs the leaf’s states, the states its operators write (esm-spec §11.3.1), else f64; rhs is state; jacobian and factors are rhs; tables is f64 (no forcing tables at M2). Refusals: rule 10 (precision_contradicts_declaration, precision_rhs_below_state), and a jacobian or factors role below rhs on a Rosenbrock method other than Rosenbrock23 (architecture §8; precision_low_with_classical_rosenbrock). A leaf whose states declare different element_types is precision_contradicts_declaration, whatever the roles, since no state role satisfies them all. A precision key that names no operator, node or default is name_unresolved, checked with rule 2 in §7.1, not ignored.
A precision refusal is reported at the precision entry that set the offending role, /precision/<key> (for a role left unset, the entry that set the role it defaults from; for mixed declarations with no entry setting state, the leaf’s pointer, §7.1). For a leaf whose alg is given, the classical-Rosenbrock check runs here; for a leaf whose alg is "auto", it runs after §5.5.3 resolves the method (§4 “Order of refusals”).
7.6 Checkpoint schedule
Present exactly when the run config has adjoint: {"kind": "all", "macro_steps": 0}, a placeholder that M4 replaces (D34, D78). The adjoint rules 11 (wrt_matches_nothing, wrt_bound_parameter) are checked. At M2 the wrt prefixes range over: params: the scalar parameters other than data-fed ones (params:numeric: those of them with a numeric default), which are $p$ (spec/operator-interface.md §8.2); fields: the array parameters other than data-fed ones; forcing: the used data-fed parameters, those of the plan’s forcing (§3, §4.1), so each parameter matches at most one prefix and a data-fed parameter, whatever its shape or default, matches only forcing:, whose gradients the placeholder schedule does not yet carry (forcing bands are U7, M4), so a forcing: entry that matches none is wrt_matches_nothing; u0 the state.
7.7 Inputs and derived
inputs.model.pathis the run config’smodelstring as written;inputs.run_config.pathis the run config’s file name without directories. Hashes are of the files’ bytes.derivedlistsderived/<operator>.rhs.esmandderived/<operator>.jacobian.esmfor every operator, each withkind,operatorand the hash of the bytes written.
8. Pass 8: validation
The checks run after the derived documents are written to memory and before anything is written to disk. Every program the planner builds, here, for the initial state of §4 and §5.5.1 and for the Gershgorin bound of §5.5.2, is built as spec/operator-interface.md §8.1 builds it: with EarthSciAST’s native compiler. A native refusal (compiler_refused_rule) is an error of the planner; the planner never falls back to EarthSciAST’s interpreter (D89). Each program is built from its document as written, with no caller u0: a Jacobian document starts at the construction values of §6.2 item 4, its states’ defaults with 1.0 where a state has none, the same in the planner and both runtimes, and no check evaluates there. Every evaluation below is at a sampled state $(u_s, p, t_s)$ with the sampled forcing values $F_s$ of §5.5.1, passed as the forcing set of the document’s rhs_program (spec/operator-interface.md §8.4) to every document alike, each program receiving the buffers its slots name: an operator’s documents have slots for its forcing only (§6.3), and the flattened model has one for every data-fed parameter, an unused one’s buffer filled with 1 (§5.5.1), which nothing reads; a model without data-fed parameters is evaluated exactly as before. A failing check is a refusal (validation_rhs_sum_failed, validation_band_model_failed, validation_empty_operator) carrying the measured quantity.
8.1 Checks
rhs_sum(one entry, no operator): at each sampled state, every operator’s right-hand-side document and the flattened model are built with EarthSciAST and evaluated; for every DOF, $|\sum_k f_k - f| \le 10^{-12} \max(|f|, \sum_k |f_k|)$, an exact zero passing (D76).band_model(per operator): at each sampled state, the band Jacobian $J^B$ assembled from the Jacobian document’s fields andstructure.bandsis compared with an independent Jacobian of the operator’s right-hand-side document, rows ofwritesonly: ForwardDiff in Julia, central finite differences in Rust (§8.2). The check also confirms the band pattern is a structural superset of the true dependence.non_empty(per operator): the operator has at least one term, and its band list is non-empty when any of its terms references a state.
validation lists rhs_sum first, then band_model and non_empty for each operator in code-point order of the operator name.
The checks run in a different order, which decides the refusal when several would fail: rhs_sum at all three sampled states, then, for each operator in code-point order, non_empty and then band_model. non_empty goes first because it needs no numbers, and because an operator that reads a state but has no band would otherwise always fail band_model (its nonzero dependence falls outside an empty pattern), so validation_empty_operator could not be reached. This is the order within §4’s item 7, after every earlier refusal (§4, “Order of refusals”; D76, D81); the order of the validation listing above is for reading only and decides nothing.
8.2 The independent Jacobian
Each planner validates on its own, in production and in conformance runs alike, on the full grid (D8, D76; §8.3 records how this could change at scale). $M_r$ below is the largest magnitude in row $r$ of either Jacobian.
Julia: ForwardDiff. $J^I$ is ForwardDiff’s Jacobian of EarthSciAST’s f! built from the operator’s right-hand-side document. For every entry, $|J^B_{rc} - J^I_{rc}| \le 10^{-10} M_r$, and every $(r, c)$ with $J^I_{rc} \ne 0$ is in the band pattern. This is the oracle architecture §3.2 pass 8 names; EarthSciASTDiff’s own oracle lives only in its tests.
Rust: central finite differences. Rust has no automatic differentiation, so it differences the operator’s right-hand side, evaluated in f64 through EarthSciAST’s public reentrant Rust right-hand side (RhsProgram, U4, EarthSciAST 0.5.0) built from the right-hand-side document:
- Step. For state component $n$ at sampled state $s$, $h_n = \varepsilon^{1/3} \max(|u_s[n]|, |b_n|)$, with $\varepsilon = 2^{-52}$ ($\varepsilon^{1/3} \approx 6.06 \times 10^{-6}$) and $b_n$ the sampling base of §5.5.1, so the step scales with the component’s own magnitude and a species near $10^{-20}$ gets a step near $10^{-25}$, not $10^{-6}$. The step actually taken is $\tilde h_n = (u_s[n] + h_n) - u_s[n]$ in binary64, and the backward point is $u_s[n] - \tilde h_n$, so the difference quotient divides by the exact distance.
- Grouping. Columns are differenced together when no row has band-pattern entries in two of them: the planner colours the columns greedily in canonical order, each taking the lowest colour none of whose columns shares a pattern row with it (Curtis, Powell and Reid). For each colour, every column $c$ in it is moved by $+\tilde h_c$, then by $-\tilde h_c$; for a row $r$ whose pattern has column $c$ in that colour, $J^I_{rc} = (f_r^+ - f_r^-)/(2\tilde h_c)$. A block-diagonal operator needs one colour per species, a stencil one per species per footprint offset, so the cost is two right-hand sides per colour, not per DOF.
- Tolerance. For every pattern entry, $|J^B_{rc} - J^I_{rc}| \le \tau_{rc}$ with $\tau_{rc} = 10^{-6} M_r + 10^{3},\varepsilon, F_r / \tilde h_c$, where $F_r = \max(|f_r^+|, |f_r^-|)$ for $c$’s colour. The central difference’s truncation error is about $h^2 |f’’’|/6$ and its rounding error about $\varepsilon |f| / h$; with $h \propto \varepsilon^{1/3}$ both are near $\varepsilon^{2/3} \approx 4 \times 10^{-11}$ relative to a row whose terms scale with the state, so $10^{-6}$ leaves five orders of magnitude for curvature and for cancellation inside a right-hand side (chemistry production and loss terms that nearly balance). The second term is the floor for a row whose entries are all near zero, where only rounding in $f_r^\pm$ remains; $10^3 \varepsilon$ allows for the operation count of a long right-hand side.
- Pattern. For a row $r$ with no pattern column in a colour, the difference $(f_r^+ - f_r^-)/2$ must stay within the floor $10^{3} \varepsilon F_r$ (no step to divide by); larger means the operator depends on a column the bands omit, and the check fails. A dependence outside the pattern in a row that does have a pattern column in that colour corrupts that entry’s $J^I$ and fails its tolerance instead. Either way an incomplete pattern is caught, as the ForwardDiff check catches it in Julia.
Forcing is held fixed. Both independent Jacobians are with respect to the state only: ForwardDiff seeds the state, finite differences perturb it, and the forcing set is the same at every point. A data-fed parameter is therefore a constant of the state Jacobian, as every parameter is, and its gathers (a field read at another cell, a record index) are not band columns and do not affect locality (§5.2.3 reads state columns only). Derivatives with respect to forcing are the forcing axis of EarthSciASTDiff (esm-jacobian-spec §4.3, U7), at M4.
Rust in the conformance suite. In tier 1 the Rust planner additionally compares its band Jacobian with the Julia planner’s values, so the cross-language check stays exact: the golden directory carries validation/<operator>.bandvalues.json for every operator (§9), {"samples": [{"t": t_s, "u": {"<name>[i,…]": value, …}, "entries": [["<row element>", "<column element>", value], …]}, …]} in the style of EarthSciASTDiff’s .jvals.json (element names as EarthSciAST writes them, entries sorted by row then column name), written by the Julia planner from its ForwardDiff values. entries lists every distinct (row, column) of the operator’s band pattern, the structural key set, with ForwardDiff’s value there, explicit zeros included: a band coefficient that vanishes at a sampled state is still listed. Rows, and columns within a row, are sorted by the element-name string, so x[10] sorts before x[2]. u holds every DOF; there is no params key, parameters being at their defaults (§5.5.1). When the model has data-fed parameters, each sample also has "forcing": {"<name>[i,…]": value, …}, the sampled forcing values $F_s$ of §5.5.1 at every element of the canonical forcing order, which holds the used data-fed parameters only (a scalar written as its bare name; an unused data-fed parameter is never listed), and Rust evaluates at those values, giving each of the operator’s programs the slots it declares (§6.3); the key is absent otherwise, so existing band-value files are unchanged. Rust evaluates its band Jacobian at the file’s u and t, requires the same structural key set, and applies the ForwardDiff tolerance $10^{-10} M_r$. This comparison belongs to the conformance harness, not to the planner: a missing file is a harness error, never a planner refusal, and production plans need no file.
8.3 Cost at scale
Pass 8 is on by default and stays on: no run config turns it off, and it is never removed (D76, amended). It evaluates every DOF of the full grid, which is cheap for the fixtures. Its cost on full-size reseact is measured at M3. If it is then too expensive, the planner moves in this order, each step taken only once the one before is shown to be too slow:
- Sampled cells. The numerical checks (
rhs_sumandband_model’s values) evaluate a deterministic sample of cells instead of every cell: every cell on a domain edge or corner; the cells on both sides of every periodic seam; the top and bottom cell of every column; the cells on each side of everymakearrayregion boundary; and a seeded interior sample. Only the state columns the sampled rows read are perturbed, and the right-hand side is evaluated over the sampled cells and their halos with EarthSciAST’s range-restricted right-hand side (U3). The structural check, that the band pattern covers the true dependence, still runs on every cell, since it needs no evaluation at sampled states. - Conformance only. The full-grid numerical checks run in the conformance suite, and in production when the run config asks for them (a key added with this step, below); production plans otherwise run step 1’s sample.
Neither step is in force at M2: until one is adopted, every check runs on the full grid in production and conformance alike (§8.2). The sample (which interior cells, drawn how, and from what seed; it is not the sampled-state stream of §5.5.1) and the halos are specified, with goldens, before step 1 is adopted, so both planners sample the same cells. Step 2 needs a run-config key for asking for the full-grid checks; spec/run-config.md and its schema have none today, and the key is added to them, with its name, when step 2 is adopted. Adopting either step amends D8’s “validates on its own, in production and in conformance runs alike” as §8.2 states it.
9. Conformance
Tier 1 (architecture §12): for each fixture case under tests/conformance/plans/fixtures/ (layout in tests/conformance/README.md), both planners write plan.json and derived/*.esm, and both are compared byte for byte with the checked-in golden, and the harness compares the Rust planner’s band values with the golden’s validation/*.bandvalues.json (§8.2). Goldens are written by the Julia planner and reviewed by hand against the case’s expected.md before they are checked in; the Rust planner must then match them. Refusal fixtures under tests/conformance/plans/refusals/ must be refused with the listed code at the listed pointer.
Data inputs (D87). The planner fixture forced_column covers §4.1, §5.5.1’s forcing samples and §6.3 with a synthetic data input whose files the planner never opens: a data_sources entry with a relative {date:%Y%m%d} url_template, a temporal block and a metadata.esio_format; a data-fed array parameter with a leading length-2 record axis (update.from.records_per_sample 2), read through a model-side time blend $w = \mathrm{frac}((t - t_\text{ref})/\Delta t_\text{rec})$ as reseact’s GEOS-FP fields are; a const data-fed scalar from a source without temporal; one of them reached through a param_to_var coupling onto a shaped target (§10 item 16); every used one with a nonzero default; an unused data-fed parameter with none (not refused, and absent from plan.json, the derived documents and the band-value files); a pointwise operator and a column operator that read them, the column operator’s alg named. Its golden case uses a named schedule with forcing_refresh in step.hit, and its band-value files carry forcing (§8.2). The M2 end-to-end data case (spec/toy-problems.md §4.4) has a smaller model of its own, with data files that its runtime reads; it is planned and run like the other M2 cases, without a golden.
Refusal fixtures, each a small variant of that model: forcing-without-scale (a used data-fed parameter with no default, and a second one whose array default has a zero element is left to the planners’ unit tests; forcing_without_scale at /model); auto-stiffness-reads-forcing (the "auto" schedule, whose column operator’s stiffness would need rule 4; at /schedule); forcing-refresh-not-hit (a discrete data-fed parameter, step.hit without forcing_refresh; at /step/hit); forcing-records-invalid (update.from.records_per_sample 2 on a data-fed parameter with a leading length-2 record axis whose source has a temporal block with a start and a time_variable but neither frequency nor file_period: EarthSciAST accepts it at load, since the block exists and the record shape is right, but its cadence is const, §4.1; at /model. A block without start is refused by EarthSciAST’s validate in Julia and Rust and at load in Python, model_load_failed by §4 step 3, so the fixture gives one); data-update-with-schedule (a data rule beside a schedule rule; unsupported_parameter_update at /model). unsupported_forcing (a loader_fields entry naming no parameter) gets a fixture only if some binding’s flatten can produce one. Every existing golden stays byte for byte the same, since a model without data-fed parameters writes no forcing key, no data_sources and no forcing samples.
Serialization cases found on reseact (2026-11-07). The planner fixture emission_compose covers three places where the two planners’ derived documents of reseact differed (§10 items 23–25): §2.2’s integer form for the integral numbers of a const node’s value, §6.1.1’s declared shape for an observed declared without one and defined by an ifelse over an array, and §6.1.2’s equation order where an operator_compose operator, declared before the component whose state it writes, authors that state’s equation by its scoped name. The planner fixture interp_slope covers the numbers of the Jacobian document that do not come from the model as written (§2.2): the slopes and knots EarthSciASTDiff computes for the derivative of an interp.linear of a state, all integral, and a const table a band coefficient keeps.
Tier 0 (spec/README.md) checks the fixtures’ structure: every .esm loads, validates and flattens with EarthSciAST (the Python binding, 0.9.0; §0.2), every run config validates, every refusal code is listed in spec/planner-errors.json, and every case has an expected.md entry. Until M2’s goldens exist, it also checks that the schema accepts tests/conformance/plans/valid/ and rejects tests/conformance/plans/invalid/.
10. Upstream gaps found while specifying M2
Items 1–6 are recorded here, not filed (the RFC process files them); items 7, 8, 11 and 12 are filed as issues. Each item’s status was rechecked on 2026-10-26 against EarthSciAST 0.5.0 and EarthSciASTDiff main (after EarthSciASTDiff#13); items 4, 7, 8, 11 and 12 again on 2026-10-30, against EarthSciAST main and EarthSciASTDiff 0.1.0; items 1, 2, 3, 5, 6, 7, 8 and 11 again on 2026-11-01, against EarthSciAST 0.7.0 and EarthSciASTDiff 0.2.0:
- Lifted states have no index-set names. EarthSciAST’s
lifted_shapesrecords a lifted state’s extents but not which index sets they are; §6.1.3’s search for a matching declared shape works around it and fails for a lift onto a grid no variable declares. Asking: record the lift’s index sets. Still open in 0.5.0: esm-libraries-spec §4.7.5 still defineslifted_shapesas post-lift extents only. The bindings also differ beyond it: Julia’sflattenwrites index-set names as ashapeon the lifted state (["cell"], even where no variable declares that shape) and writes the liftedfaq’s ranges as{"from": "cell"}; Rust’s (checked in the 0.5.0 crate source,apply_pointwise_lift) and Python’s write noshapeand write the ranges as[1, 4]. §6.1.3 ignores the recorded shape and §6.1.2 builds the ranges, so neither difference reaches a plan. Still open in 0.7.0: its notes list no change here, and esm-libraries-spec §4.7.5 still describeslifted_shapesas “post-lift grid shapes” only. Resolved in 0.8.0 (EarthSciAST#623): every binding’sflattenrecords a lifted state’sshapeas the lift’s index sets, named by the operand gathered at the lift’s loops, else by the only declared index set of that size, else a synthetic_liftdim<d>_<extent>added toindex_sets, and ranges both liftedfaqnodes over them ({"from": X}). §6.1.3 reads that shape; the search and its refusallift_shape_unresolvedare gone. - EarthSciASTDiff’s evaluation document for a flattened system does not load. It declares dotted names (
SimpleOzone.O3) in one model, which EarthSciAST 0.4.0 reads as references to an undefined system (checked in Python and Julia). §6.2 builds its own tree for this reason. The Julia builder is also internal (_evaluation_document). Still open: the trimmed evaluation document (EarthSciASTDiff#8) still declares every name in one modelJacobianEval; for the flattenedcoupled_chem_advectionfixture, EarthSciAST 0.5.0’s Python binding refuses it (reference 'SimpleOzone.O3' to undefined system 'SimpleOzone'), and Julia loads and flattens it but refuses to build its right-hand side (E_TREEWALK_UNKNOWN_STATE: SimpleOzone.O3). Still open in EarthSciASTDiff 0.2.0 and EarthSciAST 0.7.0: the builder is now public (evaluation_document), but for the flattenedcoupled_chem_advectionfixture it still declares every name in the one modelJacobianEval, and EarthSciAST.jl 0.7.0 loads and flattens the document and refuses to build it with the sameE_TREEWALK_UNKNOWN_STATE: SimpleOzone.O3(checked in Julia). Resolved in EarthSciASTDiff 0.3.0 (EarthSciASTDiff#49, with the source’sdomain, and #50, the lifted index sets of item 1): the evaluation document of a flattened system declares its variables in the system’s component tree, every reference fully scoped, and loads and builds in every binding. §6.2 is now that document,icequations dropped, and the planner builds none of its own. - Julia’s
build_evaluator(::EsmFile)refuses a model’s scoped references to its own variables (E_TREEWALK_UNBOUND_VARIABLEforDecay.kinside modelDecay) where flattening and Python accept them. Not blocking: still open in 0.5.0, butbuild_evaluatoris deprecated since 0.4.0 and runtimes do not use it. They build derived documents withrhs_program(esm_problem(...))(spec/operator-interface.md §8.1), andesm_problemaccepts such references in 0.4.0 and 0.5.0. Nothing to ask upstream. Unchanged in 0.7.0:build_evaluatorstill refusesDecay.k(E_TREEWALK_UNBOUND_VARIABLE), with a deprecation warning that it will be removed in the next minor version, andesm_problemaccepts it. - No specified time derivative from EarthSciASTDiff. Its
maincomputes bands with respect tot(wrt = :time) in Julia, Rust and Python, butesm-jacobian-spec.mdstill lists time gradients as out of scope and no golden gates them across bindings. So M2 emits no time-derivative document, planned operators cannot provideft, and Rosenbrock leaves on time-dependent planned operators wait (D80). Promoted to upstream ask U17 (docs/rfcs/U17-time-derivative-bands.md), needed by M3. Resolved upstream on EarthSciASTDiffmainby EarthSciASTDiff#13 (esm-jacobian-spec.md§4.2 and §8,tgrad_photolysisgoldens in Julia, Rust and Python), released in EarthSciASTDiff 0.1.0 (commit 743d140). M2’s scope is unchanged: it still emits no time-derivative document (D80). M3 adds it, with the planner’s numerical check of time bands (D76 covers state Jacobians only). - Julia’s native compiler declines a contracting
faqinside a lifted equation (compiler_refused_rule; the interpreter evaluates it). Derived documents write a lifted equation as a whole-arrayfaqwhose body is the per-cell terms (§6.1.2, D68), so a lifted equation whose terms contract keeps the contractingfaqinside it, which Julia’s native compiler declines. The column fixture states its equation explicitly (amakearrayover afaq, not lifted), which the native compiler accepts; no fixture yet contracts inside a lifted equation. Asking: compile a contractingfaqinside a lifted equation natively. Not reproduced: 0.5.0’s notes list no change here, and a small hand-written whole-arrayfaqholding a contractingfaqbuilds natively in both 0.4.0 and 0.5.0, so the refusal needs the original lifted case to recheck. Reproduced in 0.6.0, then resolved in 0.7.0 (EarthSciAST#609): thereseact_shapedfixture has the case. In 0.6.0 the native compiler refused its contractingfaq(compiler_refused_rule) in the flattened model and in the column operator’s right-hand-side document, and it evaluated the transport terms, each a liftedindex(makearray(…), i, j, k), to zero with no error, because it evaluated amakearrayregion other than the one each cell selects. In 0.7.0 the native compiler selects the right region and compiles a contractingfaqinside it. The Julia planner builds every document natively, as runtimes do (spec/operator-interface.md §8.1), and on every fixture case, at the three sampled states and the initial state, the native compiler’s right-hand sides and ForwardDiff Jacobians equal the interpreter’s bit for bit. - A
makearrayregion with singleton axes must give a value whose rank is the number of non-singleton axes in Julia, while Python accepts a value over all axes; fixtures avoid size-1 axes. Not reproduced: 0.5.0’s notes list no change here, and a smallmakearrayover a size-1 axis builds in Julia 0.4.0 and 0.5.0 with a value of either rank, so the failure needs the original case to recheck. Still not reproduced in 0.7.0: amakearrayover an axis of size 1 builds with a value of either rank, natively and in the interpreter, with the same values.
Found while implementing M2 stage 1 (Julia PR #21, Rust PR #20), rechecked against EarthSciAST 0.6.0 on 2026-10-27:
- Rust EarthSciAST has no public
table_lookuplowering. §4 step 5 lowerstable_lookuptofnwith EarthSciAST’s own lowering, which Julia exports and Rust does not. The Rust planner stops with an internal not-implemented error on a model that keeps atable_lookup(not a refusal code). No M2 fixture has one. Interpolation tables are slated for deprecation in EarthSciAST, so nothing in the milestones before M7 needs them, and the item does not block M2 or M3 (2026-11-01). Asking: a public lowering in Rust. Still open in 0.6.0: the crate’slower_table_lookupmodule is crate-private. Filed as EarthSciAST#580; still open on 2026-11-01. Still open in 0.7.0: its notes list no change here. - EarthSciAST (Rust) does not compile without its
solvefeature: its compiler-agreement adapter module is not gated on it. The Rust planner turnssolveon although it uses no ODE solver. Asking: gate the module. Still open in 0.6.0: 0.6.0 adds aconformance-adaptersfeature that turnssolveon, butcompiler_agreement_adapteris still declared without a feature gate, so a build withdefault-features = falseand nosolvefails in that module (unresolved importsproblem::solveandproblem::observed_trajectories). Filed as EarthSciAST#581. Resolved in 0.7.0 (EarthSciAST#608, closing #581): the Rust library builds without thesolvefeature. The Rust planner still turnssolveon, becauseinitial_state(item 15) needs it. - EarthSciAST.jl’s weak-dependency compat
SciMLBase = "2"held SciMLBase, and with it OrdinaryDiffEqSDIRK, at old versions whenever both were in an environment, which changed an M1 result. So EarthSciIntegrator.jl loaded EarthSciAST as a package extension, not a dependency. Resolved in 0.6.0: EarthSciAST.jl accepts SciMLBase 2 and 3. EarthSciIntegrator.jl now depends on EarthSciAST directly, and its test environment, with EarthSciAST in it, resolves the same SciMLBase (3.58) and OrdinaryDiffEqSDIRK (2.9.8) as without it. The test environment’s compatOrdinaryDiffEqSDIRK = "2"andSciMLBase = "3"keeps it so: a package that holds SciMLBase at 2 makes the environment fail to resolve rather than quietly change the M1 oracle. Rechecked on 2026-11-01 with EarthSciAST 0.7.0 and EarthSciASTDiff 0.2.0: the test environment still resolves SciMLBase 3.58.0 and OrdinaryDiffEqSDIRK 2.9.8. - Reaction-system summands differ between the bindings. For a reaction whose net stoichiometry for a species is $-1$, Julia’s
flattenwrites the summand as a one-argument-,{"op": "-", "args": [{"op": "*", "args": ["Chem.j", NO2[i]]}]}; Rust’s (lower_reactions_to_equationsin the 0.5.0 crate source) writes{"op": "*", "args": [-1.0, …]}with a float literal, and Python’s{"op": "*", "args": [-1, …]}. The term count and classes agree, but derived documents copy terms (§6.1.2), so for a model with a reaction system the two planners’derived/*.esmcannot be byte-identical until the bindings agree. Asking: one form in every binding. Resolved in 0.6.0: esm-libraries-spec §4.6.2 fixes the form and every binding writes it. A species’ term is the bare rate law for a net coefficient of 1 and otherwise the two-operand product{"op": "*", "args": [c, rate]},can integer literal when integral, so a loss of 1 is-1 * rate; a species’ terms are one n-ary+in reaction order. Checked by flatteningpointwise_chemistryandreseact_shapedwith the Python binding and the Julia binding, and in the Rust crate source (lower_reactions_to_equations). Both fixtures’expected.mdshow this form. - No public accessor for timestamp conversion in Julia. §7.2 converts a caller’s or run config’s timestamp exactly as
resolve_time_spanconverts asolver.time_spanone, from the model’s time origin. EarthSciAST.jl exposes no public function for either half, so the Julia planner calls its internals_datetime_partsand_time_origin, which may change in any release. Asking: a public accessor for a model’s time origin and for converting a timestamp to model seconds. Still open in 0.6.0: the two functions and the pattern_TIME_SPAN_TIMESTAMP_REare unchanged from 0.5.0 and still internal. Filed as EarthSciAST#582. Partly resolved on EarthSciASTmain, not yet released (EarthSciAST#587, after 0.6.0):time_origin(file)andtimestamp_to_seconds(file, timestamp)are public in every binding. They do not yet replace the internals: both raise the one codesolver_time_span_invalidfor a timestamp that names no calendar day and for an unreadable origin, where §7.2 refuses the first withtime_invalid_dateand the second withtime_origin_invalid, and both take a loadedEsmFile, where the planner also checks a model’ssolver.time_spanin the raw document before it loads (rule 14). Asking: a way to tell the two causes apart, and the same conversion for a rawdomain. Resolved in 0.7.0 (EarthSciAST#587 and #600, closing #582):time_originandtimestamp_to_secondstake a document or itsdomain, typed or raw JSON, in Julia, Python and Rust, and give each cause its own code:timestamp_malformed,timestamp_no_calendar_dayandtime_origin_invalid. The Julia planner callstimestamp_to_secondsin place of the internals, with the rawdomainin the pre-load check, and maps the first two codes totime_invalid_dateand the third totime_origin_invalid(§7.2).
Found while moving to EarthSciAST 0.6.0, on 2026-10-27:
- EarthSciASTDiff.jl does not accept EarthSciAST 0.6. Its
mainhas compatEarthSciAST = "0.4, 0.5"and, for its SciMLBase extension,SciMLBase = "2", so it cannot share an environment with EarthSciIntegrator.jl, which needs EarthSciAST 0.6, and wherever it is loaded with SciMLBase it holds SciMLBase at 2, item 9’s problem. Its Rust crate is onearthsci-ast = "0.6". Stage 1 does not use EarthSciASTDiff, so nothing fails yet; the Julia planner’s stage 2 backend needs it. Asking: compat EarthSciAST 0.6 and SciMLBase 2 and 3. Filed as EarthSciASTDiff#17. Resolved by EarthSciASTDiff#19 (compatEarthSciAST = "0.4, 0.5, 0.6",SciMLBase = "2, 3"), released in EarthSciASTDiff 0.1.0.
Found while implementing M2 stage 2 (Julia PR #34, Rust PR #33), resolved in EarthSciAST 0.7.0 and EarthSciASTDiff 0.2.0, on 2026-11-01:
- Rust EarthSciASTDiff could not view a flattened system. EarthSciASTDiff 0.1.0’s Rust
sysviewtakes a document, so the Rust planner wrapped each flattened right-hand-side document in a one-model document to differentiate it. Resolved in EarthSciASTDiff 0.2.0 (EarthSciASTDiff#34):sysview_flatviews a flattened system andjacobian_bands_viewdifferentiates it. The Rust planner uses them; its bands, derived documents and plans are unchanged. - Rust EarthSciASTDiff did not export the factorization plan. §5.4’s
blockisplan_factorizationserialized byserialize_plan, which EarthSciASTDiff 0.1.0’s Rust crate did not export, so the Rust planner assembled the same content by hand fromblock_diagonal_lu. Resolved in EarthSciASTDiff 0.2.0: both are exported, and the Rust planner calls them as §5.4 says, with the same output. - Rust EarthSciAST had no initial-state accessor. §5.5.1’s sampled states start from the flattened model’s initial state, which the Rust planner read from a solve that did not advance. Resolved in 0.7.0 (EarthSciAST#602):
initial_state(prob)returns the state a problem integrates from, matched to the canonical DOFs bystate_variable_names(). The values are unchanged.
Found while specifying data inputs (D87), on 2026-11-02:
- A
param_to_varcoupling onto a shaped target dropped the data rule in Python. With a data-fed scalarMet.Kmapped onto a shaped parameterBox.F_k[rec, x], EarthSciAST 0.8.0’s PythonflattenkeptMet.Kwith the target’s shape, but withupdateNone and absent fromdiscrete_parameters, whileloader_fieldsstill listed it. This is reseact’s pattern (GEOSFP.PBLH→Transport3D.F_PBLH). Resolved by EarthSciAST#634 (2026-11-02): in every binding the surviving parameter is the source’s own declaration, itsdefault,unitsandupdate, taking the target’s shape when the source declares none. So a data-fed parameter’sdefault, which §4.1’sforcing_without_scalereads, is the source’s, and the target’s default is ignored. §4.1 still identifies data-fed parameters fromloader_fields, which is correct either way. - EarthSciASTDiff’s evaluation document dropped
updateanddata_sources. esm-jacobian-spec §8.1 declared each variable with itsshapeanddefaultonly, so a data-fed parameter of the source would be a baked constant of the Jacobian document. Resolved by EarthSciASTDiff#55 (2026-11-02):evaluation_document(flat; data_sources = …)in Julia andsysview_flat(&flat)?.with_data_sources(…)in Rust keep data-fed parameters’datarules and the sources they name, and the planner passes the right-hand-side document’s stubs (§6.2 item 5). The planner’s own patch is dropped once the release with #55 is pinned (§0.2).
Found while specifying the M2 runtime’s data layer (D87), on 2026-11-02. The runtimes write no data layer of their own: they call EarthSciAST and EarthSciIO for everything an esm data source can declare (spec/operator-interface.md §8.4.2).
- The esm format could not ask for the two records bracketing $t$, and did not define the reader format. Resolved by EarthSciAST#636:
update.from.records_per_sample(1 or 2) on a data update, overriding the source-leveldata_sources.<s>.temporal.records_per_sample, with the effective value reported inloader_fieldsand an invalid one refused at load (records_per_sample_invalid); andmetadata.esio_format, the EarthSciIO reader name, defined in esm-spec §8.1.1. - EarthSciIO did not expand esm templates or build providers from a document. Resolved by EarthSciIO#27, #28, #29 and #30 and EarthSciAST#635: esm URL templates with
{date:<strftime>}and custom substitutions, andmirrors; exact calendar cadences (P1M,P1Y); a provider built from an esmdata_sourcesentry; a CSV reader in Rust; and EarthSciAST’sproviders_from_documentbuilt on that constructor in Julia, Rust and Python. Follow-up: #635 does not yet pass a parameter’s effectiverecords_per_sample(item 18) through to the provider it builds; until it does, a two-record parameter cannot be served byproviders_from_document. - No public sample-to-buffer conversion. Resolved by EarthSciAST#632, released in 0.9.0 as
forcing_from_sample(the nameforcing_bufferdid not survive into the release):forcing_from_sample(sample, slot)in Julia andearthsci_ast::forcing_from_sample(&sample, &slot)in Rust turn a provider sample into a forcing slot’s column-major buffer, the dimensions mapped by position asesm_problemmaps them. Python has none, since itsrhs_programis unavailable. It does not applyunit_conversion: the provider built byproviders_from_documentalready applies it, so conversion happens once, in the provider (spec/operator-interface.md §8.4.2 step 3). - What EarthSciIO still lacks, after item 19 (the list handed to EarthSciIO; each is a feature an esm data source can declare):
- no
jsonreader in any language (spec/registries.md listsjsonas active for points sources); - records located from the file’s own time axis (
records_per_file: "auto", atime_variablewithout a static count) only in Python; Julia and Rust need a static count; - a source’s
determinism(esm-spec §8.1: byte order, float format, integer width) is not checked against the file in any language; - no
time_originshift: providers count model time as Unix seconds, so a runtime converts with the model’s time origin (D74), UTC seconds = origin + $t$, on every call into a provider.
- no
- Rust EarthSciAST bugs found while specifying this (not yet fixed; each blocks the Rust runtime on data-reading leaves):
rhs_programdoes not make a data-fed parameter with an arraydefaultinto a forcing slot, which every §6.3 declaration with an array default hits;- with providers keyed by qualified name,
rhs_programmay return the parameter’s default instead of the provider’s data, depending on map iteration order; - discrete providers are first sampled only when a solve starts, so a problem built for its
rhs_programalone has no data in its discrete slots until the runtime passes a forcing set, which the runtime always does (§8.4.1), but the default forcing set is not the data.
Found comparing the two planners’ derived documents of reseact on a reduced grid, on 2026-11-07 (both planners on EarthSciAST’s main of that day, which carries EarthSciAST#667). Each is exercised by the planner fixture emission_compose (§9):
- Rust EarthSciAST wrote a
constnode’svalueas the source spelled it. EarthSciAST’s Julia binding holds every integral number of the model as an integer from load,constvalues included, so[100.0, 200.0]is written[100,200]; its Rust binding writes a literalExpr::Numberin that form but serialized aconstnode’svalue, a raw JSON value, as parsed ([100.0,200.0]). CONFORMANCE_SPEC §5.5.3.1 rule 1 puts every number of a canonical form in the integer form, so Julia’s is right (§2.2). Fixed by EarthSciAST#670, released in 0.10.0, which also writes Julia’s loweredtable_lookupin that form; EarthSciASTDiff 0.5.0’sstable_jsonapplies the same rule. Both planners wrote thevalueof everyconstnode in that form themselves until those releases, and now write it as their bindings serialize it (§2.2). The planner fixtureinterp_slopeholds aconsttable that reaches the Jacobian document. - Rust EarthSciAST’s
flattenrecorded the shape it infers for an observed declared without one. From EarthSciAST#667, a variable declared without ashapewhose definition is elementwise down to anifelseover an array (esm-spec §4.3.4) got the inferred shape in the RustFlattenedSystem; the Julia binding infers it only when it builds a problem, and esm-libraries-spec §4.7.5 says the one fieldflattenchanges is a lifted state’sshape. §6.1.1 writes the declared shape, so the Rust planner read an observed’s shape from its declaration in the model as loaded and gave the Jacobian document the right-hand-side document’s shapes (reseact’sDryDepositionGas.Ra,i_unstable,ra_neutral_stableandWetDeposition.i_rain). Fixed by EarthSciAST#672, released in 0.10.0: Rust’sflattenkeeps the declared shape and infers the shape once, when it builds a problem, so the Rust planner writes the flattened model’s shape and EarthSciASTDiff’s declarations as it writes them; the fixtureemission_compose(Chem.wet) holds it. - Julia EarthSciAST left an
operator_composemerge at the operator’s position. With"systems": ["SuperFastEmissionSource", "SuperFast"], the emission source authoringD(SuperFast.NO, t)and declared beforeSuperFast, the merged equation belongs toSuperFast(esm-libraries-spec §4.7.1 step 4). The Python reference and the Rust binding append it toSuperFast’s equations; the Julia binding left it where the emission source’s equation stood, ahead ofSuperFast’s own, so the flattened equation order, and with it the order of a derived document’s equations and ofplan.json’stermsand bands, differed. The Python reference’s order is the conforming one. Fixed by EarthSciAST#669, released in 0.10.0, which M2 requires; the fixture’s goldens hold the conforming order.