Skip to main content

earthsci_ast/
problem.rs

1//! The EsmProblem / `solve` surface — one noun and one verb
2//! (`esm-libraries-spec.md` §2.5, `API_SPEC.md` §5.8).
3//!
4//! ```text
5//! let prob = esm_problem(&file, (0.0, 10.0), ProblemOptions::default())?;  // build once
6//! let sol  = solve(&prob, &SolveOptions::default())?;                      // run per knob-set
7//! ```
8//!
9//! [`ProblemOptions`] is taken **by value**: it owns the boxed data providers
10//! the build hands to the runtime, and there is nothing left in it worth
11//! reusing afterwards. [`SolveOptions`] is taken by reference, because a caller
12//! sweeping knobs reuses it.
13//!
14//! ## What replaced what
15//!
16//! `simulate` is **gone**, in all of its forms — the one-shot free function,
17//! `simulate_with_inspection`, `simulate_with_providers_inspect`, and
18//! `Prepared`/`prepare`. It conflated two operations whose costs differ by
19//! orders of magnitude and whose inputs differ in kind, which is exactly why
20//! this crate had grown a second, `prepare`-shaped entry point beside it.
21//!
22//! [`esm_problem`] absorbs the whole deterministic-per-document pipeline: the
23//! projection-pushdown rewrite, value invention, the gated fetch of provider
24//! data, CONST-provider materialization, and the compile of the right-hand
25//! side. [`solve`] varies only per-run knobs — `alg`, `abstol`, `reltol`,
26//! `saveat`, `callback`, `maxiters`.
27//!
28//! ## The solver is optional
29//!
30//! Per §2.5.9, **constructing a EsmProblem does not require the solver.**
31//! [`esm_problem`], [`remake`], [`callbacks`] and [`observed_field`] are all
32//! available with the `solve` Cargo feature switched off; only [`solve`],
33//! [`init`], [`step`] and [`solve_to_completion`] need it. `diffsol` is behind
34//! that feature and is not an unconditional dependency.
35
36use std::collections::{BTreeMap, HashMap};
37use std::path::{Path, PathBuf};
38use std::rc::Rc;
39
40use ndarray::ArrayD;
41use serde_json::Value as JsonValue;
42
43use crate::classification::DeclaredRank;
44use crate::flatten::FlattenedSystem;
45use crate::precision::{self, Precision};
46#[cfg_attr(not(feature = "solve"), allow(unused_imports))]
47use crate::simulate::Solution;
48use crate::simulate::{Flow, Progress, ProgressFn, SimulateError, SolveOptions};
49use crate::types::EsmFile;
50
51use crate::simulate_array::{ArrayCompiled, BuildInspection};
52
53// =============================================================================
54// Callbacks
55// =============================================================================
56
57/// One callback: an observer invoked once before the first step and then after
58/// every accepted step, with the interval covered so far AND the integrator's
59/// current state vector ([`Progress::u`]).
60///
61/// Returning [`Flow::Cancel`] stops the integration; the solution comes back
62/// with [`crate::ReturnCode::Terminated`] and the trajectory computed so far
63/// rather than as an error, because a caller who stops a run deliberately still
64/// wants what it produced.
65#[cfg(target_arch = "wasm32")]
66pub type CallbackFn = std::rc::Rc<dyn for<'p> Fn(&Progress<'p>) -> Flow>;
67/// One callback. See the `wasm32` twin for the documentation.
68#[cfg(not(target_arch = "wasm32"))]
69pub type CallbackFn = std::sync::Arc<dyn for<'p> Fn(&Progress<'p>) -> Flow + Send + Sync>;
70
71/// The callbacks declared on a [`EsmProblem`].
72///
73/// Callbacks live on the EsmProblem, not on a run: a callback that refreshes
74/// provider buffers or writes an output stream belongs to the *document*, not
75/// to a particular run's tolerances (§2.5.4).
76///
77/// Entries are named so that [`CallbackSet::names`] can tell a caller what a
78/// EsmProblem already carries before they decide whether to replace it.
79#[derive(Clone, Default)]
80pub struct CallbackSet {
81    entries: Vec<(String, CallbackFn)>,
82}
83
84impl std::fmt::Debug for CallbackSet {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("CallbackSet")
87            .field("names", &self.names())
88            .finish()
89    }
90}
91
92impl CallbackSet {
93    /// An empty set.
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// A one-entry set.
99    pub fn of(name: impl Into<String>, f: CallbackFn) -> Self {
100        let mut s = Self::new();
101        s.push(name, f);
102        s
103    }
104
105    /// Append one named callback.
106    pub fn push(&mut self, name: impl Into<String>, f: CallbackFn) -> &mut Self {
107        self.entries.push((name.into(), f));
108        self
109    }
110
111    /// How many callbacks the set holds.
112    pub fn len(&self) -> usize {
113        self.entries.len()
114    }
115
116    /// Whether the set is empty.
117    pub fn is_empty(&self) -> bool {
118        self.entries.is_empty()
119    }
120
121    /// The names, in invocation order.
122    pub fn names(&self) -> Vec<&str> {
123        self.entries.iter().map(|(n, _)| n.as_str()).collect()
124    }
125
126    /// Invoke every callback in order. The first [`Flow::Cancel`] wins, and the
127    /// remaining callbacks are still called — an output callback must not be
128    /// skipped for the final step just because an earlier watchdog asked to
129    /// stop on it.
130    pub fn invoke(&self, p: &Progress<'_>) -> Flow {
131        let mut flow = Flow::Continue;
132        for (_, f) in &self.entries {
133            if matches!(f(p), Flow::Cancel) {
134                flow = Flow::Cancel;
135            }
136        }
137        flow
138    }
139}
140
141/// Concatenate two callback sets into a new one.
142///
143/// This is the explicit composition §2.5.4 requires: a `callback` argument to
144/// [`solve`] REPLACES the EsmProblem's set entirely — it does not append, merge or
145/// wrap — so a caller who wants to *extend* rather than replace reads the
146/// existing set back with [`callbacks`] and composes:
147///
148/// ```text
149/// let cb = compose(callbacks(&prob), &my_extra);
150/// solve(&prob, &SolveOptions { callback: Some(cb), ..Default::default() })
151/// ```
152pub fn compose(a: &CallbackSet, b: &CallbackSet) -> CallbackSet {
153    let mut out = a.clone();
154    out.entries.extend(b.entries.iter().cloned());
155    out
156}
157
158// =============================================================================
159// Construction inputs and options
160// =============================================================================
161
162/// What [`esm_problem`] can be built from: a path on disk, a raw JSON
163/// document, a typed document, or an already-flattened system.
164pub enum ProblemInput<'a> {
165    /// A path to an `.esm` file.
166    Path(&'a Path),
167    /// A raw (un-typed) JSON document — the form the build pipeline rewrites.
168    Json(&'a JsonValue),
169    /// A typed document.
170    File(&'a EsmFile),
171    /// An already-flattened system (scalar ODE path only).
172    Flattened(&'a FlattenedSystem),
173}
174
175impl<'a> From<&'a Path> for ProblemInput<'a> {
176    fn from(p: &'a Path) -> Self {
177        ProblemInput::Path(p)
178    }
179}
180impl<'a> From<&'a JsonValue> for ProblemInput<'a> {
181    fn from(v: &'a JsonValue) -> Self {
182        ProblemInput::Json(v)
183    }
184}
185impl<'a> From<&'a EsmFile> for ProblemInput<'a> {
186    fn from(f: &'a EsmFile) -> Self {
187        ProblemInput::File(f)
188    }
189}
190impl<'a> From<&'a FlattenedSystem> for ProblemInput<'a> {
191    fn from(f: &'a FlattenedSystem) -> Self {
192        ProblemInput::Flattened(f)
193    }
194}
195
196/// Whether [`esm_problem`] compiles a right-hand side.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub enum Rhs {
199    /// Compile when the selected document declares differential equations,
200    /// skip when it does not. A document with no ODEs — a dispatched static
201    /// evaluation — still gets a EsmProblem, whose build-time products
202    /// [`observed_field`] reads; [`solve`] on it raises
203    /// [`SimulateError::NotDynamic`].
204    #[default]
205    Auto,
206    /// Always compile; a document with no differential equations is an error.
207    Always,
208    /// Never compile. Build-time products only.
209    Never,
210}
211
212/// The former spelling of [`Rhs`], kept for one minor (API_SPEC §8 item 22).
213///
214/// `Compile` and [`Compiler`] differ by one letter and answer different
215/// questions — `Rhs` decides WHETHER a right-hand side is built at all, and
216/// `Compiler` decides WHICH strategy builds it — so the two sat on the same
217/// `ProblemOptions` as `compile` and `compiler`. The type is `extension` tier,
218/// so the rename is a minor.
219#[deprecated(
220    since = "0.2.0",
221    note = "renamed to `Rhs`; the field is now `ProblemOptions::rhs`"
222)]
223pub type Compile = Rhs;
224
225/// Which strategy builds a Problem's right-hand side (API_SPEC §5.8,
226/// esm-libraries-spec §2.5.10).
227///
228/// The vocabulary is CLOSED and the same in every binding. A value this
229/// binding does not provide is REFUSED — never answered by quietly selecting
230/// another one, because the point of naming a compiler is that the caller
231/// knows which one ran.
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
233pub enum Compiler {
234    /// The universally fast option with no heavy external dependency, and the
235    /// default for that reason: the array runtime's TAPE, for every document
236    /// whatever its shape — never a routing decision taken on document
237    /// content.
238    ///
239    /// **Strict.** A rule the tape cannot lower is a construction error naming
240    /// the rule and the deepest decline reason
241    /// ([`crate::compile_error::CompileError::CompilerRefusedRule`]), not a
242    /// quiet demotion to the per-cell oracle.
243    ///
244    /// It covers the whole evaluable core. The only documents it may refuse
245    /// are those carrying a construct outside its scope, each refused by name:
246    /// events, implicit equations and SDE noise (`unsupported_construct`) and
247    /// a spatial operator with no discretization rule (`unlowered_operator`,
248    /// refused under every compiler). Every compiler-driven conformance tier
249    /// requires `native` for every fixture, so any other refusal is a defect
250    /// in this crate.
251    #[default]
252    Native,
253    /// The reference, and only the reference: the array runtime with the tape
254    /// AND the whole-array overlay off, so every rule is evaluated by the
255    /// per-cell oracle. Complete over the evaluable core, and carrying no
256    /// performance promise of any kind. A caller selects it to CHECK another
257    /// compiler, not to run a model.
258    Interpreter,
259    /// StableHLO through XLA: the specialty compiler that needs a heavy
260    /// external dependency to exist at all.
261    ///
262    /// The tape is lowered to an `rhs(u, p, t) -> du` XLA computation
263    /// (`crate::simulate_array::tape::xla_emit`) and run through PJRT
264    /// (`crate::xla_runtime`), so the Problem's right-hand side — and the
265    /// finite-difference Jacobian an implicit solve differences out of it —
266    /// ARE the compiled executable. The passes that are not the right-hand
267    /// side (the build-time materialization of constants and static
268    /// observeds, the per-segment seed, the inspection snapshot, and the
269    /// observeds reported at output times) are served from the SAME tape the
270    /// emitter was built from, because the emitted program's only output is
271    /// `du`. That is one evaluator, not two.
272    ///
273    /// **Strict twice over, and never a fallback.** A rule the tape cannot
274    /// lower and an instruction the emitter cannot lower are both
275    /// [`crate::compile_error::CompileError::CompilerRefusedRule`] at
276    /// construction, naming the rule and the reason; neither demotes to the
277    /// tape interpreter or to the per-cell oracle.
278    ///
279    /// It needs the `xla` Cargo feature AND a usable XLA runtime in this
280    /// process (an unpacked `xla_extension` release — see
281    /// `scripts/fetch-xla-extension.sh`). Without either, `esm_problem`
282    /// answers [`crate::SimulateError::CompilerUnavailable`], never a quiet
283    /// substitution.
284    Xla,
285    /// A ModelingToolkit system. Julia only; refused here with
286    /// `compiler_unavailable`.
287    Mtk,
288    /// A lambdified SymPy scalar right-hand side. Python only; refused here
289    /// with `compiler_unavailable`.
290    Sympy,
291}
292
293impl Compiler {
294    /// The closed vocabulary's spelling of this value — the one a diagnostic,
295    /// a CLI argument and a conformance manifest all use.
296    pub fn as_str(self) -> &'static str {
297        match self {
298            Compiler::Native => "native",
299            Compiler::Interpreter => "interpreter",
300            Compiler::Xla => "xla",
301            Compiler::Mtk => "mtk",
302            Compiler::Sympy => "sympy",
303        }
304    }
305
306    /// Parse a vocabulary member. `None` for anything outside it — the caller
307    /// raises `compiler_unknown`, which is a different failure from
308    /// `compiler_unavailable`.
309    pub fn parse(name: &str) -> Option<Compiler> {
310        match name {
311            "native" => Some(Compiler::Native),
312            "interpreter" => Some(Compiler::Interpreter),
313            "xla" => Some(Compiler::Xla),
314            "mtk" => Some(Compiler::Mtk),
315            "sympy" => Some(Compiler::Sympy),
316            _ => None,
317        }
318    }
319
320    /// [`Self::parse`] with the `compiler_unknown` diagnostic (esm-spec
321    /// §9.6.6) already written, for the entry points that take the compiler as
322    /// TEXT — the CLI flag, the wasm handle, a conformance adapter.
323    ///
324    /// One wording and one place the closed vocabulary is listed: a value
325    /// outside it is a different failure from one inside it this binding
326    /// cannot provide, and a caller who conflates them cannot tell a typo from
327    /// a missing runtime.
328    pub fn parse_named(name: &str) -> Result<Compiler, String> {
329        Compiler::parse(name).ok_or_else(|| {
330            format!(
331                "{}: '{name}' is not a compiler. The vocabulary is closed: {}",
332                crate::diagnostic::codes::COMPILER_UNKNOWN,
333                Compiler::vocabulary()
334                    .iter()
335                    .map(|c| c.as_str())
336                    .collect::<Vec<_>>()
337                    .join(", ")
338            )
339        })
340    }
341
342    /// Whether this compiler is STRICT: a rule it cannot express is a
343    /// construction error naming the rule, never a quiet demotion to a slower
344    /// evaluator (API_SPEC §5.8, "The default is `native`, and it is strict").
345    ///
346    /// Both of the values this binding implements over the tape are strict —
347    /// `native`, which is the tape, and `xla`, which is the emitter over it —
348    /// and they are strict for the same reason: a compiler either runs the
349    /// whole document or names what it refused. `interpreter` is not strict
350    /// because it declines nothing it can evaluate; it IS the fallback the
351    /// other two refuse to take.
352    pub fn is_strict(self) -> bool {
353        matches!(self, Compiler::Native | Compiler::Xla)
354    }
355
356    /// Every member, in the order API_SPEC §5.8 tabulates them.
357    pub fn vocabulary() -> &'static [Compiler] {
358        &[
359            Compiler::Native,
360            Compiler::Interpreter,
361            Compiler::Xla,
362            Compiler::Mtk,
363            Compiler::Sympy,
364        ]
365    }
366}
367
368impl std::fmt::Display for Compiler {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        f.write_str(self.as_str())
371    }
372}
373
374/// Where ONE rule of a Problem landed, and at what cadence (API_SPEC §5.8,
375/// "Every Problem reports what ran").
376#[derive(Clone, Debug)]
377pub struct CompilerRuleReport {
378    /// The rule — an equation or an observed — component-qualified, the same
379    /// spelling [`observed_field`] resolves and a refusal names.
380    pub rule: String,
381    /// `"observed"` or `"state derivative"` for a rule of the compiled model.
382    /// The evaluations construction performs OUTSIDE that rule set are
383    /// reported too, under their own kinds: `"build-time observed"` and
384    /// `"value invention"` (the build pipeline), and `"initial condition"` and
385    /// `"initial-condition scope"` (a field `ic` and the state-free observeds
386    /// it reads).
387    pub kind: &'static str,
388    /// The cadence tier the rule runs at: `"const"` (once per solve, at
389    /// setup), `"discrete"` (once per refresh segment) or `"continuous"`
390    /// (every right-hand-side call). The tier is the cost story: a decline at
391    /// `const` costs once, the same decline at `continuous` costs per step.
392    pub cadence: &'static str,
393    /// Where the rule landed: `"taped"`, `"xla"` (lowered from the tape into
394    /// the compiled XLA program, under [`Compiler::Xla`]), `"fallback"` (the
395    /// per-cell oracle beneath the tape, reachable only outside a strict
396    /// compiler), or `"oracle"` (every rule under [`Compiler::Interpreter`],
397    /// by design). An evaluation outside the compiled rule set is
398    /// `"vectorized"` when the whole-array overlay served it and `"oracle"`
399    /// when it was walked per cell, which a strict compiler refuses. A
400    /// `"value invention"` row is `"relational"`: the producer's member set,
401    /// computed once at setup by the relational engine from build-time factors
402    /// (CONFORMANCE_SPEC §5.7.6 keeps that engine off the hot path), under
403    /// every compiler alike. It is neither the oracle nor a fallback, so a
404    /// build that succeeds under a strict compiler still has
405    /// [`CompilerReport::n_oracle`] `== 0`.
406    pub tier: &'static str,
407    /// For a `"fallback"`, the DEEPEST decline reason reached while trying to
408    /// lower the rule; for an `"oracle"` evaluation outside the compiled rule
409    /// set, why it was walked per cell. `None` otherwise.
410    pub reason: Option<String>,
411}
412
413/// What built a Problem's right-hand side, per rule (API_SPEC §5.8).
414///
415/// The names and their meanings are stable surface; the record's exact shape
416/// is per-binding and is deliberately not pinned across the five.
417///
418/// **Fusion is reported per PROGRAM, not per rule.** The tape's Step-4 fusion
419/// pass merges instructions across rule boundaries and the fused program keeps
420/// no rule attribution, so a `tier` of `"taped"` covers both a rule that runs
421/// as discrete instructions and one absorbed into a fused group;
422/// [`Self::fused_groups`] and [`Self::fused_instructions`] report the fusion
423/// at the level the tape actually tracks it.
424#[derive(Clone, Debug)]
425pub struct CompilerReport {
426    compiler: Compiler,
427    rules: Vec<CompilerRuleReport>,
428    fused_groups: usize,
429    fused_instructions: usize,
430}
431
432impl CompilerReport {
433    /// The report of a Problem with no right-hand side: no rules, because no
434    /// compiler was asked to build anything.
435    fn empty(compiler: Compiler) -> CompilerReport {
436        CompilerReport {
437            compiler,
438            rules: Vec::new(),
439            fused_groups: 0,
440            fused_instructions: 0,
441        }
442    }
443
444    /// The compiler that built the Problem.
445    pub fn compiler(&self) -> Compiler {
446        self.compiler
447    }
448
449    /// Every rule, in program order: the evaluations construction performs
450    /// outside the compiled model (the build pipeline's, then a state-free
451    /// document's observeds, then the field initial conditions), then the
452    /// compiled model's observed rules in dependency order, then its state
453    /// derivatives. A state-free document's observeds are here too: it has no
454    /// right-hand side, but its observed graph is still evaluated by the
455    /// compiler the Problem names.
456    pub fn rules(&self) -> &[CompilerRuleReport] {
457        &self.rules
458    }
459
460    /// How many rules landed on the tape.
461    pub fn n_taped(&self) -> usize {
462        self.rules.iter().filter(|r| r.tier == "taped").count()
463    }
464
465    /// How many rules were lowered out of the tape into the compiled XLA
466    /// program. Non-zero only under [`Compiler::Xla`], where it is every rule
467    /// — the build refuses the document otherwise.
468    pub fn n_xla(&self) -> usize {
469        self.rules.iter().filter(|r| r.tier == "xla").count()
470    }
471
472    /// How many rules landed on the per-cell oracle — as a fallback beneath
473    /// the tape, or, under [`Compiler::Interpreter`], by design.
474    ///
475    /// Counts the two oracle tiers by NAME rather than everything that is not
476    /// `"taped"`: an `"xla"` rule is compiled, and counting it here would
477    /// report the fastest tier in the crate as the slowest.
478    pub fn n_oracle(&self) -> usize {
479        self.rules
480            .iter()
481            .filter(|r| matches!(r.tier, "oracle" | "fallback"))
482            .count()
483    }
484
485    /// How many evaluations outside the compiled rule set the whole-array
486    /// overlay served (tier `"vectorized"`).
487    pub fn n_vectorized(&self) -> usize {
488        self.rules.iter().filter(|r| r.tier == "vectorized").count()
489    }
490
491    /// Fused instruction groups in the compiled program. `0` under
492    /// [`Compiler::Interpreter`], which compiles no program.
493    pub fn fused_groups(&self) -> usize {
494        self.fused_groups
495    }
496
497    /// Instructions absorbed into those groups.
498    pub fn fused_instructions(&self) -> usize {
499        self.fused_instructions
500    }
501}
502
503impl std::fmt::Display for CompilerReport {
504    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
505        write!(
506            f,
507            "compiler {}: {} rules, {} taped, {} on the per-cell oracle",
508            self.compiler,
509            self.rules.len(),
510            self.n_taped(),
511            self.n_oracle()
512        )?;
513        if self.n_xla() > 0 {
514            write!(f, ", {} lowered into the XLA program", self.n_xla())?;
515        }
516        if self.n_vectorized() > 0 {
517            write!(f, ", {} on the whole-array overlay", self.n_vectorized())?;
518        }
519        let n_relational = self.rules.iter().filter(|r| r.tier == "relational").count();
520        if n_relational > 0 {
521            write!(f, ", {n_relational} by the relational engine at setup")?;
522        }
523        if self.fused_groups > 0 {
524            write!(
525                f,
526                "; {} fused groups absorbing {} instructions",
527                self.fused_groups, self.fused_instructions
528            )?;
529        }
530        Ok(())
531    }
532}
533
534/// Build-time bindings for [`esm_problem`] — everything that fixes a
535/// *document* rather than a *run*.
536///
537/// The per-run knobs (`alg`, `abstol`, `reltol`, `saveat`, `callback`,
538/// `maxiters`) live on [`SolveOptions`] instead. That split is the whole point
539/// of §2.5: construction is deterministic per document and expensive, `solve`
540/// is cheap and varies.
541#[derive(Default)]
542pub struct ProblemOptions {
543    /// Parameter bindings (canonical SciML `p`). Exact or bare names.
544    pub p: HashMap<String, f64>,
545    /// Initial state bindings (canonical SciML `u0`). Exact or bare names.
546    pub u0: HashMap<String, f64>,
547    /// Select one model when the document holds several.
548    pub model_name: Option<String>,
549    /// Metaparameter bindings closed at load (esm-spec §9.7.6 site 4).
550    pub metaparameters: BTreeMap<String, i64>,
551    /// Base path anchoring relative `{ref}`s.
552    pub base_path: Option<PathBuf>,
553    /// Callbacks declared on the EsmProblem (§2.5.4).
554    pub callbacks: CallbackSet,
555    /// Whether to compile a right-hand side. See [`Rhs`].
556    pub rhs: Rhs,
557    /// WHICH strategy builds the right-hand side (§2.5.10). `None` is
558    /// [`Compiler::Native`], and `native` is STRICT: a rule it cannot express
559    /// is a construction error, not a quiet demotion.
560    ///
561    /// `Option` rather than a bare `Compiler` so that "the caller said
562    /// nothing" and "the caller asked for native" stay distinguishable in a
563    /// `ProblemOptions` a test prints; they build identically.
564    pub compiler: Option<Compiler>,
565
566    /// Opt in to the automatic projection-pushdown desugar. Turning this on
567    /// also turns the build pipeline on.
568    #[cfg(not(target_arch = "wasm32"))]
569    pub pushdown_rewrite: bool,
570    /// Run-time data providers, keyed by the forcing VARIABLE name they feed.
571    ///
572    /// CONST providers are materialized ONCE, here, at construction — the
573    /// "gated fetch" §2.5.2 puts in the build half. DISCRETE providers
574    /// contribute the refresh anchors [`solve`] segments the integration on.
575    #[cfg(not(target_arch = "wasm32"))]
576    pub providers: HashMap<String, Box<dyn crate::provider::CadenceProvider>>,
577    /// Build-time array providers consumed by the pushdown / gated-fetch
578    /// pipeline, keyed by provider key. Supplying any turns the build pipeline
579    /// on.
580    #[cfg(not(target_arch = "wasm32"))]
581    pub build_providers: Vec<(String, Box<dyn crate::prepare::PrepareProvider>)>,
582    /// Constant arrays injected into the build-time evaluation scope.
583    #[cfg(not(target_arch = "wasm32"))]
584    pub const_arrays: HashMap<String, ArrayD<f64>>,
585    /// Force the deterministic build pipeline on (it is on automatically when
586    /// `pushdown_rewrite`, `build_providers` or `const_arrays` is set).
587    #[cfg(not(target_arch = "wasm32"))]
588    pub build_pipeline: bool,
589    /// Split a gated provider's pre-sliced fetch into requests of at most this
590    /// many native indices along the gated axis.
591    #[cfg(not(target_arch = "wasm32"))]
592    pub gated_fetch_batch: Option<usize>,
593    /// Print per-phase build progress lines on stdout.
594    #[cfg(not(target_arch = "wasm32"))]
595    pub verbose: bool,
596    /// Build-time progress observer. **Extension seam, not stable API** — a
597    /// binding MAY expose its own build observability (§2.5.2).
598    #[cfg(not(target_arch = "wasm32"))]
599    pub progress: Option<crate::prepare::PrepareProgressFn>,
600
601    /// Collect the array runtime's named build-time products so
602    /// [`observed_field`] can read them back.
603    ///
604    /// This is the **construction-time build-observability seam** that replaced
605    /// the old `simulate_with_inspection` / `observed_field(prep, inspection,
606    /// name)` threading: the caller asks for observability when it builds,
607    /// and reads it back off the EsmProblem, instead of carrying a
608    /// `BuildInspection` through the run.
609    pub inspect: bool,
610
611    /// Time at which CONST providers are sampled. Defaults to `tspan.0`.
612    pub sample_time: Option<f64>,
613
614    /// Leave every shaped parameter that has a value and is not fed from
615    /// outside the model (no `update`) as a run-time input of
616    /// [`crate::rhs_program`]: an [`crate::SlotKind::ArrayParameter`] forcing
617    /// slot, seeded with that value, instead of build-time data (API_SPEC
618    /// §5.8). The value is the document's (inline array data, or one scalar
619    /// broadcast over its grid) or the caller's [`Self::const_arrays`] entry,
620    /// which takes precedence. The Problem itself
621    /// reads it from its forcing buffer, where construction serves that
622    /// value, so `solve` integrates the same numbers either way. Off by
623    /// default.
624    pub runtime_array_parameters: bool,
625}
626
627impl std::fmt::Debug for ProblemOptions {
628    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629        let mut d = f.debug_struct("ProblemOptions");
630        d.field("p", &self.p)
631            .field("u0", &self.u0)
632            .field("model_name", &self.model_name)
633            .field("metaparameters", &self.metaparameters)
634            .field("base_path", &self.base_path)
635            .field("callbacks", &self.callbacks)
636            .field("rhs", &self.rhs)
637            .field("compiler", &self.compiler)
638            .field("inspect", &self.inspect)
639            .field("sample_time", &self.sample_time)
640            .field("runtime_array_parameters", &self.runtime_array_parameters);
641        #[cfg(not(target_arch = "wasm32"))]
642        d.field("pushdown_rewrite", &self.pushdown_rewrite)
643            .field("providers", &self.providers.keys().collect::<Vec<_>>())
644            .field(
645                "build_providers",
646                &self
647                    .build_providers
648                    .iter()
649                    .map(|(k, _)| k)
650                    .collect::<Vec<_>>(),
651            )
652            .field(
653                "const_arrays",
654                &self.const_arrays.keys().collect::<Vec<_>>(),
655            )
656            .field("build_pipeline", &self.build_pipeline)
657            .field("gated_fetch_batch", &self.gated_fetch_batch)
658            .field("verbose", &self.verbose)
659            // Hand-written because the observer is a trait object: it cannot
660            // derive `Debug`, and a `ProblemOptions` that no longer prints
661            // would be a regression for every `{:?}` on a build error path.
662            .field(
663                "progress",
664                &self
665                    .progress
666                    .as_ref()
667                    .map(|_| "<observer>")
668                    .unwrap_or("None"),
669            );
670        d.finish()
671    }
672}
673
674// =============================================================================
675// The EsmProblem
676// =============================================================================
677
678/// The compiled right-hand side a [`EsmProblem`] integrates, if any.
679pub(crate) enum Backend {
680    /// The array / spatial runtime — the tape under `native` / `xla`, its
681    /// per-cell oracle under `interpreter` (API_SPEC §5.8), for every document.
682    Array(Rc<ArrayCompiled>),
683    /// No right-hand side: the document declares no differential equations, or
684    /// the caller asked for [`Rhs::Never`]. Carries the reason.
685    Static(String),
686}
687
688/// The build-time products construction materialized.
689#[derive(Default)]
690pub(crate) struct BuildProducts {
691    /// Observed name → build-time field (dependency-complete).
692    pub fields: HashMap<String, ArrayD<f64>>,
693    /// Value-invention producer id (`from_faq`) → sorted-distinct member ids.
694    pub members: HashMap<String, Vec<i64>>,
695    /// Value-invention producer id → derived index-set extent.
696    pub extents: HashMap<String, i64>,
697    /// Provider keys that were deferred + fetched pre-sliced (sorted).
698    pub gated_provider_keys: Vec<String>,
699    /// Parameters baked into the build. Substituting one of these needs a
700    /// rebuild, so [`remake`] refuses rather than lying.
701    pub baked_parameters: Vec<String>,
702    /// Why a state-free document's observed graph could not be evaluated at
703    /// construction — a fail-closed evaluation fault such as
704    /// `table_lookup_out_of_bounds` — so [`observed_field`] can name the cause
705    /// instead of only the name it cannot answer.
706    pub static_eval_error: Option<String>,
707}
708
709/// The raw document a Problem carries. A document read from a file and built
710/// without rewriting keeps the file's text and parses it on the first
711/// [`EsmProblem::document`] call, so the build hands its one parsed copy to
712/// the loader instead of cloning it.
713pub(crate) struct RawDocument {
714    text: Option<String>,
715    value: std::cell::OnceCell<JsonValue>,
716}
717
718impl RawDocument {
719    fn parsed(value: JsonValue) -> Self {
720        RawDocument {
721            text: None,
722            value: std::cell::OnceCell::from(value),
723        }
724    }
725
726    fn from_text(text: String) -> Self {
727        RawDocument {
728            text: Some(text),
729            value: std::cell::OnceCell::new(),
730        }
731    }
732
733    fn get(&self) -> &JsonValue {
734        self.value.get_or_init(|| {
735            // The text parsed once already, when the Problem was built.
736            self.text
737                .as_deref()
738                .and_then(|t| serde_json::from_str(t).ok())
739                .unwrap_or(JsonValue::Null)
740        })
741    }
742}
743
744/// A simulation problem: a document, an interval, and the bindings that fix
745/// the document rather than the run.
746///
747/// Built once by [`esm_problem`]; run by [`solve`] (or stepped by [`init`]).
748/// Re-parameterized without rebuilding by [`remake`].
749pub struct EsmProblem {
750    /// The (possibly rewritten) raw document.
751    pub(crate) doc: Rc<RawDocument>,
752    /// The name of the model this EsmProblem was built from, when one was
753    /// selected.
754    pub(crate) model_name: Option<String>,
755    /// The working precision the document declared (`domain.element_type`,
756    /// esm-spec §11.3), captured at construction. Every run entry re-arms it
757    /// (`precision::enter`) so a problem carries its own precision rather than
758    /// depending on what the calling thread last evaluated.
759    pub(crate) precision: precision::Env,
760    /// The integration interval.
761    pub(crate) tspan: (f64, f64),
762    /// Parameter bindings.
763    pub(crate) p: HashMap<String, f64>,
764    /// Initial-state bindings.
765    pub(crate) u0: HashMap<String, f64>,
766    /// The compiled right-hand side. `Rc` so [`remake`] shares it rather than
767    /// recompiling.
768    pub(crate) backend: Rc<Backend>,
769    /// Build-time products, shared with every [`remake`] descendant.
770    pub(crate) build: Rc<BuildProducts>,
771    /// Build observability, filled at construction when
772    /// [`ProblemOptions::inspect`] is set and topped up at the first solve.
773    pub(crate) inspection: std::cell::RefCell<BuildInspection>,
774    /// Whether the caller asked for build observability.
775    pub(crate) inspect: bool,
776    /// The compiler that built this Problem's right-hand side (§2.5.10).
777    pub(crate) compiler: Compiler,
778    /// Where every rule landed, computed at CONSTRUCTION — which is also where
779    /// a strict `native` refuses, so the record and the refusal are read off
780    /// the same tape build.
781    pub(crate) compiler_report: CompilerReport,
782    /// Callbacks declared on the EsmProblem (§2.5.4).
783    pub(crate) callbacks: CallbackSet,
784    /// The document's §2.2 `solver` block, captured at construction.
785    ///
786    /// Its OWN field rather than a read-back out of `doc`, because `doc` is
787    /// `JsonValue::Null` whenever the problem was built from a typed
788    /// [`EsmFile`] and the build pipeline was not requested — which made the
789    /// §2.2.2 chain dead on exactly the paths a library caller uses. `None` when
790    /// the document declares no block, or when the problem was built from a bare
791    /// [`FlattenedSystem`], which carries no document at all.
792    pub(crate) solver: Option<crate::types::Solver>,
793    /// Bound run-time providers, already CONST-materialized.
794    #[cfg(not(target_arch = "wasm32"))]
795    pub(crate) refresh: Option<std::cell::RefCell<crate::provider::RefreshExecutor>>,
796    /// Forcing variables fed by a DISCRETE provider.
797    #[cfg(not(target_arch = "wasm32"))]
798    pub(crate) discrete_forcing: std::collections::HashSet<String>,
799    /// Refresh anchors strictly inside `tspan`.
800    #[cfg(not(target_arch = "wasm32"))]
801    pub(crate) refresh_boundaries: Vec<f64>,
802    /// The typed input the right-hand side was compiled from, kept so
803    /// [`crate::range_rhs::rhs_program`] can flatten it once and select terms
804    /// by provenance (API_SPEC §5.8). `None` for a problem with no array
805    /// backend.
806    pub(crate) program_source: Option<Rc<ProgramSource>>,
807    /// [`ProblemOptions::runtime_array_parameters`], for `rhs_program`.
808    pub(crate) runtime_array_parameters: bool,
809}
810
811/// What [`EsmProblem::program_source`] holds: the prepared typed document, or
812/// the caller's flattened system.
813pub(crate) enum ProgramSource {
814    File(Box<EsmFile>),
815    Flattened(Box<FlattenedSystem>),
816}
817
818impl std::fmt::Debug for EsmProblem {
819    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
820        f.debug_struct("EsmProblem")
821            .field("model_name", &self.model_name)
822            .field("tspan", &self.tspan)
823            .field("p", &self.p.len())
824            .field("u0", &self.u0.len())
825            .field("backend", &self.backend_kind())
826            .field("callbacks", &self.callbacks.names())
827            .finish()
828    }
829}
830
831impl EsmProblem {
832    /// The document's §2.2 `solver` block, or `None` when it declares none.
833    ///
834    /// The middle level of the esm-spec §2.2.2 tolerance chain, captured at
835    /// construction so it holds however the problem was built.
836    pub fn solver(&self) -> Option<&crate::types::Solver> {
837        self.solver.as_ref()
838    }
839
840    /// The integration interval.
841    pub fn tspan(&self) -> (f64, f64) {
842        self.tspan
843    }
844
845    /// The parameter bindings this EsmProblem carries.
846    pub fn p(&self) -> &HashMap<String, f64> {
847        &self.p
848    }
849
850    /// The initial-state bindings this EsmProblem carries.
851    pub fn u0(&self) -> &HashMap<String, f64> {
852        &self.u0
853    }
854
855    /// The model this EsmProblem was built from, when one was selected.
856    pub fn model_name(&self) -> Option<&str> {
857        self.model_name.as_deref()
858    }
859
860    /// The (possibly rewritten) raw document.
861    pub fn document(&self) -> &JsonValue {
862        self.doc.get()
863    }
864
865    /// The compiler that built this Problem's right-hand side (API_SPEC §5.8).
866    ///
867    /// The vocabulary value the build ACTUALLY used — which, because a refused
868    /// compiler is never answered by substituting another, is always the one
869    /// the caller asked for.
870    pub fn compiler(&self) -> Compiler {
871        self.compiler
872    }
873
874    /// Where every rule of this Problem landed (API_SPEC §5.8).
875    ///
876    /// Informative even under a strict [`Compiler::Native`], where every rule
877    /// is taped by construction: the interesting question there is the cadence
878    /// tier each rule runs at, which is what makes a cost readable.
879    pub fn compiler_report(&self) -> &CompilerReport {
880        &self.compiler_report
881    }
882
883    /// `"array"` or `"static"`.
884    ///
885    /// Kept for compatibility beside [`Self::compiler`], which is the stable
886    /// answer to "what built this". Under both `native` and `interpreter` a
887    /// dynamic Problem is `"array"`, so this does not distinguish the two;
888    /// `compiler()` does.
889    pub fn backend_kind(&self) -> &'static str {
890        match &*self.backend {
891            Backend::Array(_) => "array",
892            Backend::Static(_) => "static",
893        }
894    }
895
896    /// The working precision this problem evaluates in — `domain.element_type`
897    /// (esm-spec §11.3), [`Precision::Float64`] unless the document said
898    /// `"Float32"`.
899    pub fn precision(&self) -> Precision {
900        self.precision.document
901    }
902
903    /// The compiled array model behind this Problem's right-hand side, or
904    /// `None` for a static Problem.
905    ///
906    /// Read-only access for measurement tooling (the scaling conformance
907    /// tier's adapter and the wasm suite), which drive the same compiled
908    /// artifact through the `debug_*` entry points of [`ArrayCompiled`]
909    /// rather than through a solve.
910    #[doc(hidden)]
911    pub fn debug_array_compiled(&self) -> Option<Rc<ArrayCompiled>> {
912        match &*self.backend {
913            Backend::Array(c) => Some(Rc::clone(c)),
914            Backend::Static(_) => None,
915        }
916    }
917
918    /// Whether this EsmProblem has a right-hand side to integrate.
919    pub fn is_dynamic(&self) -> bool {
920        !matches!(&*self.backend, Backend::Static(_))
921    }
922
923    /// The state-variable names of the compiled right-hand side, in the
924    /// flattened state-vector order. Empty for a static EsmProblem.
925    pub fn state_variable_names(&self) -> Vec<String> {
926        match &*self.backend {
927            Backend::Array(c) => c.qualified_state_names().to_vec(),
928            Backend::Static(_) => Vec::new(),
929        }
930    }
931
932    /// The parameter names of the compiled right-hand side. Empty for a static
933    /// EsmProblem.
934    pub fn parameter_names(&self) -> Vec<String> {
935        match &*self.backend {
936            Backend::Array(c) => qualify_array_names(c, c.parameter_names()),
937            Backend::Static(_) => Vec::new(),
938        }
939    }
940
941    /// The value-invention members this EsmProblem's build materialized.
942    pub fn members(&self) -> &HashMap<String, Vec<i64>> {
943        &self.build.members
944    }
945
946    /// The derived index-set extents this EsmProblem's build materialized.
947    pub fn extents(&self) -> &HashMap<String, i64> {
948        &self.build.extents
949    }
950
951    /// Provider keys that were deferred and fetched pre-sliced.
952    pub fn gated_provider_keys(&self) -> &[String] {
953        &self.build.gated_provider_keys
954    }
955
956    /// Take the build-observability record this EsmProblem collected.
957    ///
958    /// Only populated when [`ProblemOptions::inspect`] was set. This is the
959    /// construction-time seam that replaced threading a `&mut BuildInspection`
960    /// through the run.
961    pub fn take_inspection(&self) -> BuildInspection {
962        std::mem::take(&mut self.inspection.borrow_mut())
963    }
964
965    /// Re-arm the build-observability record to the state CONSTRUCTION leaves
966    /// it in (empty).
967    ///
968    /// [`esm_problem`] initialises `inspection` empty and only [`solve`] fills
969    /// it — and only on the array backend, which OVERWRITES rather than merges;
970    /// [`Self::take_inspection`] DRAINS it. So a caller that solves one problem
971    /// more than once (the inline-test runner, which memoises the build across
972    /// consecutive tests that share it) must re-arm between solves, or the
973    /// second read would see what the first left behind instead of what a
974    /// rebuild would have produced. Idempotent, and a no-op on a problem that
975    /// has never been read.
976    pub(crate) fn reset_inspection(&self) {
977        *self.inspection.borrow_mut() = BuildInspection::default();
978    }
979
980    /// Every build-time field this EsmProblem's construction materialized, keyed
981    /// by observed name.
982    pub fn observed_fields(&self) -> &HashMap<String, ArrayD<f64>> {
983        &self.build.fields
984    }
985
986    /// The names of the OBSERVED VARIABLES [`observed_trajectory`] can report a
987    /// trajectory for.
988    ///
989    /// Disjoint from [`Self::observed_field_names`] and answering a different
990    /// question: those are constants the BUILD materialized, these vary along a
991    /// solution. Empty on the static backend, which has no run to vary along.
992    pub fn observed_variable_names(&self) -> Vec<String> {
993        match &*self.backend {
994            Backend::Array(c) => qualify_array_names(c, &c.observed_variable_names()),
995            Backend::Static(_) => Vec::new(),
996        }
997    }
998
999    /// The names of every build-time field [`observed_field`] can return,
1000    /// component-qualified — the spelling that resolves whatever the problem's
1001    /// component count, and the one to report to an author whose bare name was
1002    /// refused.
1003    pub fn observed_field_names(&self) -> Vec<String> {
1004        let model = self.model_name.as_deref().unwrap_or("");
1005        let mut names: Vec<String> = self
1006            .build
1007            .fields
1008            .keys()
1009            .chain(self.inspection.borrow().setup_arrays.keys())
1010            .map(|k| qualify(model, k))
1011            .collect();
1012        names.sort();
1013        names.dedup();
1014        names
1015    }
1016}
1017
1018/// The callbacks declared on `prob` (§2.5.4).
1019///
1020/// Stable API in every simulation-capable binding, and for a specific reason:
1021/// a `callback` argument to [`solve`] REPLACES this set entirely, so without a
1022/// way to read it back, a EsmProblem-level callback would be impossible to extend.
1023/// See [`compose`].
1024pub fn callbacks(prob: &EsmProblem) -> &CallbackSet {
1025    &prob.callbacks
1026}
1027
1028/// `key` qualified by the component path `model`, idempotently.
1029///
1030/// The two field maps an [`EsmProblem`] carries are keyed differently. The
1031/// build pipeline keys a field by the SELECTED MODEL's own variable name
1032/// (`E_PM25`, or `North.u` for a mounted subsystem), because it evaluates one
1033/// model's observed graph rather than the flattened document's; the state-free
1034/// static evaluation keys it by the FLATTENED name (`ISRM.E_PM25`), because
1035/// it evaluates the flattened system. Qualifying the first spelling and leaving
1036/// the second alone puts both into the one namespace Julia and Python key
1037/// their build-time fields by, so `observed_field` answers the same spellings
1038/// in all three bindings.
1039///
1040/// A key EQUAL to `model` is a variable named like its model, not a component
1041/// path, so it is qualified too: `fuel_mce` in model `fuel_mce` is
1042/// `fuel_mce.fuel_mce`, the spelling the flattened name has (API_SPEC §5.8)
1043/// and the one Julia and Python report.
1044pub(crate) fn qualify(model: &str, key: &str) -> String {
1045    let already = key
1046        .strip_prefix(model)
1047        .is_some_and(|rest| rest.starts_with('.'));
1048    if model.is_empty() || already {
1049        key.to_string()
1050    } else {
1051        format!("{model}.{key}")
1052    }
1053}
1054
1055/// `names` under the array runtime's single-model namespace, when that build
1056/// left them bare.
1057///
1058/// Presentation only: the compiled artifact keeps its own spelling, and the
1059/// override-key canonicalization (`canonicalize_override_keys`) already
1060/// resolves a `<namespace>.`-prefixed key against a bare slot, so a caller may
1061/// hand back exactly the names it was given.
1062///
1063/// This exists because the array runtime names its slots differently on its two
1064/// build routes: the single-model build leaves them bare, the flattened one
1065/// qualifies them. Without it the SAME document would report `M.y` or `y`
1066/// depending on a routing decision the caller cannot see, which is the failure
1067/// §2.5.10 exists to prevent.
1068fn qualify_array_names(
1069    compiled: &crate::simulate_array::ArrayCompiled,
1070    names: &[String],
1071) -> Vec<String> {
1072    let Some(ns) = compiled.namespace() else {
1073        return names.to_vec();
1074    };
1075    names.iter().map(|n| qualify(ns, n)).collect()
1076}
1077
1078/// The component that owns a qualified name — everything before the final
1079/// segment, or `""` when the name is unqualified.
1080fn component_of(qualified: &str) -> &str {
1081    qualified.rsplit_once('.').map_or("", |(head, _)| head)
1082}
1083
1084/// The distinct components owning `prob`'s build-time fields (API_SPEC §5.8).
1085///
1086/// A bare name is only resolvable when this holds exactly one: with two
1087/// mounted components a bare `u` designates `North.u` and `South.u` equally,
1088/// and answering with either is worse than refusing.
1089fn field_components(prob: &EsmProblem) -> std::collections::BTreeSet<String> {
1090    let model = prob.model_name.as_deref().unwrap_or("");
1091    let mut out = std::collections::BTreeSet::new();
1092    for k in prob.build.fields.keys() {
1093        out.insert(component_of(&qualify(model, k)).to_string());
1094    }
1095    for k in prob.inspection.borrow().setup_arrays.keys() {
1096        out.insert(component_of(&qualify(model, k)).to_string());
1097    }
1098    out
1099}
1100
1101/// Outcome of applying §5.8's precedence to one namespace of stored keys.
1102enum NameResolution {
1103    /// The stored key `name` designates.
1104    Key(String),
1105    /// `name` is a bare name whose tail matches these keys (sorted), but the
1106    /// gate refused to bind one: a multi-component problem, or a second tail
1107    /// match within ONE component — where two keys cannot share a tail, so a
1108    /// second match means the component set was miscounted; refuse rather
1109    /// than pick.
1110    Ambiguous(Vec<String>),
1111    /// No rule matched.
1112    Miss,
1113}
1114
1115/// The §5.8 name-resolution rule, shared by [`observed_field`] and the
1116/// observed-trajectory resolver, in precedence order:
1117///
1118/// 1. **Exact hit** — `name` is a stored key.
1119/// 2. **Component-qualified hit** — `name` is a stored key's [`qualify`]d
1120///    spelling (`ISRM.E_PM25` for the pipeline's `E_PM25`). Done as a strip
1121///    rather than a scan: `name` is the qualified spelling of key `k` exactly
1122///    when it is `model` + `.` + `k`. With an empty `model` the inner strip
1123///    fails and this is a no-op.
1124/// 3. **Bare name** — `name` carries no `.` and the problem has exactly ONE
1125///    component; it then resolves to the unique key with that tail.
1126///
1127/// A bare name whose tail matches but which rule 3's gate refuses comes back
1128/// as [`NameResolution::Ambiguous`] carrying every candidate, so a caller can
1129/// put the remedy in its diagnostic — the author has to qualify the name, and
1130/// cannot know which spellings exist without being told.
1131fn resolve_observed_key<'a>(
1132    keys: impl Iterator<Item = &'a String> + Clone,
1133    model: &str,
1134    single_component: bool,
1135    name: &str,
1136) -> NameResolution {
1137    if keys.clone().any(|k| k == name) {
1138        return NameResolution::Key(name.to_string()); // rule 1
1139    }
1140    if let Some(rest) = name.strip_prefix(model).and_then(|r| r.strip_prefix('.'))
1141        && keys.clone().any(|k| k == rest)
1142    {
1143        return NameResolution::Key(rest.to_string()); // rule 2
1144    }
1145    if name.contains('.') {
1146        return NameResolution::Miss; // rule 3 is for bare names only
1147    }
1148    let mut matches: Vec<&String> = keys
1149        .filter(|k| k.rsplit('.').next() == Some(name))
1150        .collect();
1151    matches.sort();
1152    if single_component && matches.len() == 1 {
1153        return NameResolution::Key(matches[0].clone());
1154    }
1155    if matches.is_empty() {
1156        NameResolution::Miss
1157    } else {
1158        NameResolution::Ambiguous(matches.into_iter().cloned().collect())
1159    }
1160}
1161
1162/// The build-time field named `name`, from `prob`'s construction.
1163///
1164/// Resolution is the cross-binding rule of API_SPEC §5.8
1165/// ([`resolve_observed_key`]), in precedence order:
1166///
1167/// 1. **Exact hit** — `name` is a stored key.
1168/// 2. **Component-qualified hit** — `name` is a stored key's [`qualify`]d
1169///    spelling (`ISRM.E_PM25` for the pipeline's `E_PM25`).
1170/// 3. **Bare name** — `name` carries no `.`, and the problem has exactly ONE
1171///    component; it then resolves to the unique field with that tail.
1172///
1173/// A bare name against a MULTI-component problem is refused rather than bound
1174/// to an arbitrary candidate. Reads the build pipeline's fields, the state-free
1175/// static evaluation's fields, and — when [`ProblemOptions::inspect`] was set —
1176/// the array runtime's materialized setup arrays. One arity, `(prob, name)`,
1177/// in every binding.
1178pub fn observed_field(prob: &EsmProblem, name: &str) -> Result<ArrayD<f64>, SimulateError> {
1179    // Re-arm the document's working precision for the duration of this call
1180    // (`domain.element_type`, esm-spec §11.3); a no-op for a Float64 document.
1181    let _precision_guard = prob.precision.enter();
1182    // A name an `operator_compose` renaming match DELETED addresses a field that
1183    // MOVED (issue #230). The merge only ever removes a spelling, so a hit here
1184    // can never shadow a live field.
1185    let name = problem_merged_renames(prob)
1186        .get(name)
1187        .map(String::as_str)
1188        .unwrap_or(name);
1189    let model = prob.model_name.as_deref().unwrap_or("");
1190    let components = field_components(prob);
1191    let single = components.len() == 1;
1192
1193    if let NameResolution::Key(k) =
1194        resolve_observed_key(prob.build.fields.keys(), model, single, name)
1195    {
1196        return Ok(prob.build.fields[&k].clone());
1197    }
1198    let inspection = prob.inspection.borrow();
1199    if let NameResolution::Key(k) =
1200        resolve_observed_key(inspection.setup_arrays.keys(), model, single, name)
1201    {
1202        return Ok(inspection.setup_arrays[&k].clone());
1203    }
1204    // A bare name that WOULD have resolved but for the component gate gets the
1205    // remedy in the diagnostic: the author has to qualify it, and cannot know
1206    // which spellings exist without being told. (`Ambiguous` under a SINGLE
1207    // component — the miscounted-set refusal — keeps the generic error below,
1208    // as it always has.)
1209    if !single
1210        && let NameResolution::Ambiguous(matches) = resolve_observed_key(
1211            prob.build
1212                .fields
1213                .keys()
1214                .chain(inspection.setup_arrays.keys()),
1215            model,
1216            single,
1217            name,
1218        )
1219    {
1220        let mut cands: Vec<String> = matches.iter().map(|k| qualify(model, k)).collect();
1221        cands.sort();
1222        cands.dedup();
1223        return Err(SimulateError::Compile(
1224            crate::compile_error::CompileError::build_err(format!(
1225                "observed_field: '{name}' is a bare name and this EsmProblem has {} \
1226                     components ({}); qualify it as one of: {}",
1227                components.len(),
1228                components
1229                    .iter()
1230                    .filter(|c| !c.is_empty())
1231                    .cloned()
1232                    .collect::<Vec<_>>()
1233                    .join(", "),
1234                cands.join(", ")
1235            )),
1236        ));
1237    }
1238    let cause = prob
1239        .build
1240        .static_eval_error
1241        .as_deref()
1242        .map(|e| format!("; evaluating the state-free observed graph failed: {e}"))
1243        .unwrap_or_default();
1244    Err(SimulateError::Compile(
1245        crate::compile_error::CompileError::build_err(format!(
1246            "observed_field: '{name}' is not a build-time-evaluable observed of this \
1247             EsmProblem{cause}"
1248        )),
1249    ))
1250}
1251
1252/// The trajectory of the observed variable `name` over `sol`'s output grid.
1253///
1254/// The solution-aware half of [`observed_field`], and the answer to a question
1255/// neither argument can answer alone. An observed is a pure function of
1256/// `(state, params, t)`: `prob` holds the function — the topo-sorted observed
1257/// graph and the parameter bindings — and `sol` holds the arguments. That is
1258/// why this takes both, and why [`observed_field`] can only ever report fields
1259/// the BUILD materialized. A [`Solution`] carries state rows only, so an
1260/// observed of a model that does integrate has no other way out.
1261///
1262/// Named `observed_trajectory` rather than being a second arity of
1263/// `observed_field` because the return RANK differs: a field is the shape the
1264/// document declares, a trajectory is that shape with a time axis added. Two
1265/// ranks under one name is a contract a caller cannot read off the call. (A
1266/// binding that can overload — Julia, Python — may still spell it as an extra
1267/// arity of `observed_field`; API_SPEC §5.8 records the transliteration.)
1268///
1269/// Name resolution is §5.8's rule, the same one [`observed_field`] applies:
1270/// exact hit, then the component-qualified spelling, then a bare name only when
1271/// the problem has exactly one component.
1272///
1273/// A static problem has no trajectory at all and wants [`observed_field`].
1274#[cfg(feature = "solve")]
1275pub fn observed_trajectory(
1276    prob: &EsmProblem,
1277    sol: &Solution,
1278    name: &str,
1279) -> Result<Vec<f64>, SimulateError> {
1280    // Re-arm the document's working precision for the duration of this call
1281    // (`domain.element_type`, esm-spec §11.3); a no-op for a Float64 document.
1282    let _precision_guard = prob.precision.enter();
1283    let one = [name.to_string()];
1284    // The bulk form omits what it cannot resolve; the singular one must not,
1285    // so an empty result becomes the diagnostic the resolver would have given.
1286    match observed_trajectories(prob, sol, &one)?.pop() {
1287        Some((_, values)) => Ok(values),
1288        None => {
1289            let declared: Vec<String> = match &*prob.backend {
1290                // The name resolved to nothing, and the diagnostic has to be
1291                // built against the runtime's own declared names.
1292                Backend::Array(c) => c.observed_variable_names(),
1293                Backend::Static(_) => Vec::new(),
1294            };
1295            let model = prob.model_name.as_deref().unwrap_or("");
1296            Err(
1297                resolve_observed_name(&declared, model, components_of(&declared, model) == 1, name)
1298                    .expect_err("resolution failed, or the bulk form would have answered"),
1299            )
1300        }
1301    }
1302}
1303
1304/// [`observed_trajectory`] for several names, in ONE pass over the output grid.
1305///
1306/// The graph is walked once per output time however many names are asked for,
1307/// so a caller wanting five observeds should ask once rather than five times.
1308///
1309/// **Tolerant where the singular form is strict**, and returns `(name, values)`
1310/// pairs so a caller can tell which is which. A name that is not an observed
1311/// variable — most often a STATE, which the caller already has in `sol` — is
1312/// omitted rather than failing the whole call. That is what a host asking "give
1313/// me whichever of these are observed" needs: a test harness reading a model's
1314/// authored assertions knows the variable names but not which kind each is, and
1315/// one state in the list must not cost it the other four answers.
1316///
1317/// The returned names are the spellings that were ASKED FOR, not the resolved
1318/// ones, so a caller can key its own lookup by them.
1319#[cfg(feature = "solve")]
1320pub fn observed_trajectories(
1321    prob: &EsmProblem,
1322    sol: &Solution,
1323    names: &[String],
1324) -> Result<Vec<(String, Vec<f64>)>, SimulateError> {
1325    // Re-arm the document's working precision for the duration of this call
1326    // (`domain.element_type`, esm-spec §11.3); a no-op for a Float64 document.
1327    let _precision_guard = prob.precision.enter();
1328    match &*prob.backend {
1329        // The array runtime has ALREADY materialized its observeds at every
1330        // saved time — `append_observed_trajectories` appends each 0-D one to
1331        // the solution as a row beside the states, in the same cell-key
1332        // spelling — so the answer is a lookup in `sol`, not a second
1333        // evaluation.
1334        Backend::Array(c) => array_observed_trajectories(prob, c, sol, names),
1335        Backend::Static(reason) => Err(SimulateError::NotDynamic {
1336            details: format!("{reason}; a static document's results are read with observed_field"),
1337        }),
1338    }
1339}
1340
1341/// [`observed_trajectories`] on the array runtime: read the rows that runtime
1342/// already appended to `sol`.
1343///
1344/// **Not a second evaluation, on purpose.** The array runtime materializes its
1345/// observeds at every output node during the solve (under `native`, from the
1346/// tape) and appends each 0-D one to the solution. Re-deriving them here would
1347/// be exactly the off-tape output-time evaluation `esm-libraries-spec.md`
1348/// §2.5.10 puts under the refusal — and it would also be a second answer to a
1349/// question that already has one.
1350///
1351/// **Rank.** An ARRAY-valued observed has no scalar trajectory, so it is not
1352/// answered here. It is reachable — `SolveOptions::output_observed` appends it
1353/// as one row per cell, named `base[i,j]` — and the diagnostic says so, because
1354/// a caller who asked for a name that exists deserves the remedy rather than
1355/// "not an observed variable".
1356///
1357/// Tolerant: a name that resolves to no observed is omitted rather than failing
1358/// the call.
1359#[cfg(feature = "solve")]
1360fn array_observed_trajectories(
1361    prob: &EsmProblem,
1362    compiled: &crate::simulate_array::ArrayCompiled,
1363    sol: &Solution,
1364    names: &[String],
1365) -> Result<Vec<(String, Vec<f64>)>, SimulateError> {
1366    let declared = compiled.observed_variable_names();
1367    // The COMPILED model's namespace, not `prob.model_name`: a problem built
1368    // from a typed single-model document carries no `model_name` (the caller
1369    // named none), while the artifact knows the `models` key it consumed. With
1370    // the empty string here, §5.8's rule-2 strip never fires and a caller's
1371    // component-qualified spelling resolves to nothing.
1372    let model = compiled
1373        .namespace()
1374        .or(prob.model_name.as_deref())
1375        .unwrap_or("");
1376    let single = components_of(&declared, model) == 1;
1377    let mut out = Vec::new();
1378    for name in names {
1379        let Ok(resolved) = resolve_observed_name(&declared, model, single, name) else {
1380            continue;
1381        };
1382        // The solution's rows carry the qualified spelling (see
1383        // `qualify_array_names`); the compiled model's rule names are bare.
1384        let row_name = match compiled.namespace() {
1385            Some(ns) => qualify(ns, &resolved),
1386            None => resolved.clone(),
1387        };
1388        match sol.state_variable_names.iter().position(|r| *r == row_name) {
1389            Some(i) => out.push((name.clone(), sol.state[i].clone())),
1390            None => {
1391                return Err(SimulateError::Compile(
1392                    crate::compile_error::CompileError::build_err(format!(
1393                        "observed_trajectory: '{name}' is an observed of this EsmProblem but \
1394                         carries no scalar trajectory — it is array-valued, and the array \
1395                         runtime appends one row per cell only for an observed the solve was \
1396                         asked for. Name it in `SolveOptions::output_observed` and read the \
1397                         `{resolved}[i,j]` rows of the solution."
1398                    )),
1399                ));
1400            }
1401        }
1402    }
1403    Ok(out)
1404}
1405
1406/// §5.8's precedence ([`resolve_observed_key`]), against a flat list of
1407/// declared observed names.
1408#[cfg(feature = "solve")]
1409fn resolve_observed_name(
1410    declared: &[String],
1411    model: &str,
1412    single_component: bool,
1413    name: &str,
1414) -> Result<String, SimulateError> {
1415    match resolve_observed_key(declared.iter(), model, single_component, name) {
1416        NameResolution::Key(k) => Ok(k),
1417        // A bare name that WOULD have resolved but for the component count gets
1418        // the remedy in the diagnostic — the author cannot qualify it without
1419        // being told which spellings exist.
1420        NameResolution::Ambiguous(matches) => Err(SimulateError::Compile(
1421            crate::compile_error::CompileError::build_err(format!(
1422                "observed_trajectory: '{name}' is a bare name and this EsmProblem has {}                          components; qualify it as one of: {}",
1423                components_of(declared, model),
1424                matches
1425                    .iter()
1426                    .map(|k| qualify(model, k))
1427                    .collect::<Vec<_>>()
1428                    .join(", ")
1429            )),
1430        )),
1431        NameResolution::Miss => Err(SimulateError::Compile(
1432            crate::compile_error::CompileError::build_err(format!(
1433                "observed_trajectory: '{name}' is not an observed variable of this EsmProblem"
1434            )),
1435        )),
1436    }
1437}
1438
1439#[cfg(feature = "solve")]
1440fn components_of(declared: &[String], model: &str) -> usize {
1441    declared
1442        .iter()
1443        .map(|k| component_of(&qualify(model, k)).to_string())
1444        .collect::<std::collections::BTreeSet<_>>()
1445        .len()
1446}
1447
1448// =============================================================================
1449// remake
1450// =============================================================================
1451
1452/// The substitutions [`remake`] applies. Every field is optional; an omitted
1453/// field is inherited unchanged.
1454#[derive(Debug, Clone, Default)]
1455pub struct Remake {
1456    /// Replacement parameter bindings, merged over the EsmProblem's.
1457    pub p: HashMap<String, f64>,
1458    /// Replacement initial-state bindings, merged over the EsmProblem's.
1459    pub u0: HashMap<String, f64>,
1460    /// A different integration interval.
1461    pub tspan: Option<(f64, f64)>,
1462    /// Replacement callbacks. `None` inherits the EsmProblem's set.
1463    pub callbacks: Option<CallbackSet>,
1464}
1465
1466/// A NEW EsmProblem with `changes` applied and everything else shared (§2.5.5).
1467///
1468/// Does not mutate `prob`, and does not redo the parts of construction the
1469/// substitution cannot have invalidated: the compiled right-hand side, the
1470/// build-time fields and the materialized provider data are shared by `Rc`, not
1471/// rebuilt. A changed parameter value does not re-fetch provider data or
1472/// recompile.
1473///
1474/// **Refusal is deliberate.** A substitution the EsmProblem cannot honour without
1475/// a rebuild raises [`SimulateError::UnsubstitutableBinding`], naming the
1476/// binding and the class that makes it un-substitutable, rather than silently
1477/// rebuilding or silently ignoring it. Two classes refuse: a parameter that was
1478/// baked into the build (it is a load-time constant of the compiled RHS, not a
1479/// solver input), and a name that is not a parameter of the compiled system at
1480/// all.
1481pub fn remake(prob: &EsmProblem, changes: &Remake) -> Result<EsmProblem, SimulateError> {
1482    // Re-arm the document's working precision for the duration of this call
1483    // (`domain.element_type`, esm-spec §11.3); a no-op for a Float64 document.
1484    let _precision_guard = prob.precision.enter();
1485    let known: std::collections::HashSet<String> = prob.parameter_names().into_iter().collect();
1486    let known_bare: HashMap<String, usize> = {
1487        let mut counts: HashMap<String, usize> = HashMap::new();
1488        for n in &known {
1489            *counts
1490                .entry(n.rsplit('.').next().unwrap_or(n).to_string())
1491                .or_insert(0) += 1;
1492        }
1493        counts
1494    };
1495
1496    for name in changes.p.keys() {
1497        if prob.build.baked_parameters.iter().any(|b| b == name) {
1498            return Err(SimulateError::UnsubstitutableBinding {
1499                name: name.clone(),
1500                class: "baked into the build as a load-time constant — build a new EsmProblem"
1501                    .to_string(),
1502            });
1503        }
1504        if prob.is_dynamic()
1505            && !known.contains(name)
1506            && known_bare.get(name).copied().unwrap_or(0) == 0
1507        {
1508            return Err(SimulateError::UnsubstitutableBinding {
1509                name: name.clone(),
1510                class: "not a parameter of the compiled system".to_string(),
1511            });
1512        }
1513    }
1514
1515    let mut p = prob.p.clone();
1516    p.extend(changes.p.iter().map(|(k, v)| (k.clone(), *v)));
1517    let mut u0 = prob.u0.clone();
1518    u0.extend(changes.u0.iter().map(|(k, v)| (k.clone(), *v)));
1519    let tspan = changes.tspan.unwrap_or(prob.tspan);
1520
1521    #[cfg(not(target_arch = "wasm32"))]
1522    let boundaries: Vec<f64> = prob
1523        .refresh_boundaries
1524        .iter()
1525        .copied()
1526        .filter(|&b| b > tspan.0 && b < tspan.1)
1527        .collect();
1528
1529    Ok(EsmProblem {
1530        doc: Rc::clone(&prob.doc),
1531        solver: prob.solver.clone(),
1532        model_name: prob.model_name.clone(),
1533        precision: prob.precision.clone(),
1534        tspan,
1535        p,
1536        u0,
1537        backend: Rc::clone(&prob.backend),
1538        build: Rc::clone(&prob.build),
1539        inspection: std::cell::RefCell::new(prob.inspection.borrow().clone()),
1540        inspect: prob.inspect,
1541        // The backend is SHARED (`Rc::clone` above), so the compiler and its
1542        // per-rule record carry over verbatim: a remake re-binds values, it
1543        // never rebuilds a right-hand side, and reporting a compiler the
1544        // derivative did not run would be a lie about which one produced its
1545        // numbers.
1546        compiler: prob.compiler,
1547        compiler_report: prob.compiler_report.clone(),
1548        callbacks: changes
1549            .callbacks
1550            .clone()
1551            .unwrap_or_else(|| prob.callbacks.clone()),
1552        // The materialized provider data is shared by moving the executor's
1553        // ownership question out of `remake`'s way: a remade EsmProblem reads the
1554        // SAME forcing buffer, because the provider fetch is exactly the work
1555        // §2.5.5 forbids redoing. A EsmProblem with providers therefore cannot be
1556        // remade into two live copies; the derivative borrows nothing and
1557        // carries no executor of its own.
1558        #[cfg(not(target_arch = "wasm32"))]
1559        refresh: None,
1560        #[cfg(not(target_arch = "wasm32"))]
1561        discrete_forcing: prob.discrete_forcing.clone(),
1562        #[cfg(not(target_arch = "wasm32"))]
1563        refresh_boundaries: boundaries,
1564        program_source: prob.program_source.clone(),
1565        runtime_array_parameters: prob.runtime_array_parameters,
1566    })
1567}
1568
1569// =============================================================================
1570// Ensembles
1571// =============================================================================
1572
1573/// A EsmProblem plus a per-trajectory rewrite, and the family it stands for
1574/// (§2.5.8) — the canonical form for parameter sweeps, Monte Carlo over
1575/// declared distributions, and perturbed initial conditions.
1576pub struct EnsembleProblem<'a> {
1577    prob: &'a EsmProblem,
1578    trajectories: usize,
1579    #[allow(clippy::type_complexity)]
1580    rewrite: Box<dyn Fn(&EsmProblem, usize) -> Result<Remake, SimulateError> + 'a>,
1581}
1582
1583impl std::fmt::Debug for EnsembleProblem<'_> {
1584    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1585        f.debug_struct("EnsembleProblem")
1586            .field("trajectories", &self.trajectories)
1587            .field("prob", self.prob)
1588            .finish()
1589    }
1590}
1591
1592impl<'a> EnsembleProblem<'a> {
1593    /// Wrap `prob` with a rewrite applied once per trajectory index.
1594    ///
1595    /// The rewrite returns a [`Remake`], not a EsmProblem, so every trajectory
1596    /// goes through the same refusal rules and shares the same compiled
1597    /// right-hand side.
1598    pub fn new(
1599        prob: &'a EsmProblem,
1600        trajectories: usize,
1601        rewrite: impl Fn(&EsmProblem, usize) -> Result<Remake, SimulateError> + 'a,
1602    ) -> Self {
1603        Self {
1604            prob,
1605            trajectories,
1606            rewrite: Box::new(rewrite),
1607        }
1608    }
1609
1610    /// The base EsmProblem.
1611    pub fn problem(&self) -> &EsmProblem {
1612        self.prob
1613    }
1614
1615    /// How many trajectories the family holds.
1616    pub fn trajectories(&self) -> usize {
1617        self.trajectories
1618    }
1619
1620    /// The EsmProblem for trajectory `i`.
1621    pub fn trajectory(&self, i: usize) -> Result<EsmProblem, SimulateError> {
1622        let changes = (self.rewrite)(self.prob, i)?;
1623        remake(self.prob, &changes)
1624    }
1625}
1626
1627/// Solve every trajectory of `ens`, in index order (§2.5.8).
1628#[cfg(feature = "solve")]
1629pub fn solve_ensemble(
1630    ens: &EnsembleProblem<'_>,
1631    opts: &SolveOptions,
1632) -> Result<Vec<Solution>, SimulateError> {
1633    let mut out = Vec::with_capacity(ens.trajectories());
1634    for i in 0..ens.trajectories() {
1635        let prob = ens.trajectory(i)?;
1636        out.push(solve(&prob, opts)?);
1637    }
1638    Ok(out)
1639}
1640
1641// =============================================================================
1642// Construction
1643// =============================================================================
1644
1645/// Build a [`EsmProblem`] (§2.5.2).
1646///
1647/// Absorbs the whole deterministic-per-document pipeline — the pushdown
1648/// rewrite, value invention, the gated fetch of provider data, CONST-provider
1649/// materialization, and the compile of the right-hand side — and takes the
1650/// bindings that fix a *document*: parameters, initial state, data providers,
1651/// metaparameters, and the model to build when the file holds several.
1652///
1653/// **Does not require the solver.** With the `solve` Cargo feature off this
1654/// still builds; only [`solve`] and the stepping entry points need `diffsol`.
1655pub fn esm_problem<'a>(
1656    input: impl Into<ProblemInput<'a>>,
1657    tspan: (f64, f64),
1658    opts: ProblemOptions,
1659) -> Result<EsmProblem, SimulateError> {
1660    let input = input.into();
1661    #[allow(unused_mut)]
1662    let mut opts = opts;
1663
1664    // ---- (1) Resolve the input to a raw document and/or a typed one. -------
1665    let mut owned_json: Option<JsonValue> = None;
1666    let mut owned_file: Option<EsmFile> = None;
1667    let mut flat_only: Option<&FlattenedSystem> = None;
1668    // `owned_json` is exactly what the parser made of the file's text, so it
1669    // nests within the parser's recursion limit (see stage (3)).
1670    let mut json_from_text = false;
1671    // Whether the build pipeline rewrote the document. Only a rewritten one
1672    // may fail the typed parse and still build (see stage (3)).
1673    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
1674    let mut rewritten_by_pipeline = false;
1675
1676    // The file's text, while `owned_json` is still exactly its parse.
1677    let mut source_text: Option<String> = None;
1678    match input {
1679        ProblemInput::Path(path) => {
1680            let text = std::fs::read_to_string(path).map_err(|e| {
1681                SimulateError::Compile(crate::compile_error::CompileError::build_err(format!(
1682                    "reading {}: {e}",
1683                    path.display()
1684                )))
1685            })?;
1686            let raw: JsonValue = serde_json::from_str(&text).map_err(|e| {
1687                SimulateError::Compile(crate::compile_error::CompileError::build_err(format!(
1688                    "parsing {}: {e}",
1689                    path.display()
1690                )))
1691            })?;
1692            owned_json = Some(raw);
1693            source_text = Some(text);
1694            json_from_text = true;
1695            // A relative `{ref}` or template import resolves against the
1696            // referencing file's directory (esm-spec §4.7), not the process's
1697            // working directory; a caller's explicit `base_path` still wins.
1698            if opts.base_path.is_none() {
1699                opts.base_path = Some(match path.parent() {
1700                    Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
1701                    _ => PathBuf::from("."),
1702                });
1703            }
1704        }
1705        ProblemInput::Json(v) => owned_json = Some(v.clone()),
1706        ProblemInput::File(f) => owned_file = Some(f.clone()),
1707        ProblemInput::Flattened(f) => flat_only = Some(f),
1708    }
1709
1710    // ---- (1b) The working precision, and the guard that arms it. ----------
1711    // `domain.element_type` (esm-spec §11.3). This has to be read from the RAW
1712    // input and armed BEFORE the build pipeline, not after the typed parse:
1713    // value invention, the pushdown rewrite and the build-time field
1714    // materialization all evaluate expressions, and a constant folded in
1715    // binary64 at build time would disagree with the same subexpression
1716    // evaluated in binary32 at run time.
1717    let prec = element_type_of(owned_json.as_ref(), owned_file.as_ref(), flat_only)?;
1718    // Per-variable overrides (`ModelVariable.element_type`, esm-spec §11.3.1)
1719    // are read from the same raw input and armed alongside the document's
1720    // precision, because the ingress sites below — and the provider fetches in
1721    // `prepare` — fill NAMED variables and must round each to ITS declared
1722    // precision, not to the document's. Empty for every document that declares
1723    // none, which is the inert path.
1724    let var_precisions = variable_element_types_of(
1725        owned_json.as_ref(),
1726        owned_file.as_ref(),
1727        flat_only,
1728        prec,
1729        opts.model_name.as_deref(),
1730    )?;
1731    let _precision_guard = precision::enter_env(prec, std::sync::Arc::new(var_precisions));
1732
1733    // Host-supplied numbers are the one class of value the evaluator does not
1734    // itself produce, so they are rounded ONCE here rather than on each of the
1735    // O(N) reads of them. Everything computed downstream is binary32 already,
1736    // every operation having rounded. Each is rounded to the precision of the
1737    // variable it binds: an override that spared a key column on the provider
1738    // path but not on the host-supplied one would be no exemption at all.
1739    if prec.is_f32() || precision::has_variable_overrides() {
1740        for (k, v) in opts.p.iter_mut() {
1741            *v = precision::of_variable(k).round(*v);
1742        }
1743        for (k, v) in opts.u0.iter_mut() {
1744            *v = precision::of_variable(k).round(*v);
1745        }
1746        // `const_arrays` is a host-side build-pipeline input and does not
1747        // exist on wasm32, where `ProblemOptions` carries no build half.
1748        #[cfg(not(target_arch = "wasm32"))]
1749        for (k, a) in opts.const_arrays.iter_mut() {
1750            let kp = precision::of_variable(k);
1751            if kp.is_f32() {
1752                a.mapv_inplace(|x| kp.round(x));
1753            }
1754        }
1755    }
1756
1757    // ---- (1c) Static precision inference, on the TYPED document. ---------
1758    // Propagate every `ModelVariable.element_type` over the equations and mark
1759    // the precision boundaries (esm-spec §11.3.1, `crate::precision_infer`).
1760    //
1761    // It runs on the TYPED form and BEFORE the build pipeline, which is the
1762    // only point that satisfies both constraints. Typed, because that is what
1763    // has `expression_templates` expanded and `$ref` imports resolved, so no
1764    // template body escapes the pass. Before the pipeline, because the pipeline
1765    // MATERIALIZES a relational document's whole observed graph — for a
1766    // state-free document that is the entire evaluation — and a marker
1767    // inserted after it would arrive too late to decide anything.
1768    //
1769    // Skipped entirely, not merely a no-op walk, for a document that declares
1770    // no per-variable element type.
1771    let mut inferred = false;
1772    if precision::has_variable_overrides()
1773        && let Some(f) = owned_file.as_mut()
1774        && let Some(models) = f.models.as_mut()
1775    {
1776        crate::precision_infer::annotate_models(models, prec).map_err(SimulateError::Compile)?;
1777        inferred = true;
1778    }
1779
1780    // ---- (1d) Lower `table_lookup` (esm-spec §9.5.3). ---------------------
1781    // `table_lookup` is sugar over `interp.linear` / `interp.bilinear` /
1782    // `index`, and NOTHING downstream evaluates it — the array runtime's
1783    // stage-(0) gate refuses it as `unevaluable_operator`. Lowering happens
1784    // here rather than in `parse::load_value` because §9.5.4 requires the
1785    // AUTHORED form to round-trip and this binding serializes the typed
1786    // document it loaded; a load-time rewrite would emit the lowered `fn`
1787    // tree (issue #188).
1788    //
1789    // Done for a TYPED input before the block below re-serializes it, so the
1790    // build pipeline's own observed-graph evaluation sees the lowered form
1791    // too; a raw-JSON input gets the same treatment at stage (3c), after its
1792    // typed parse. The pass is idempotent, so a document that goes through
1793    // both is lowered once.
1794    if let Some(f) = owned_file.as_mut() {
1795        crate::lower_table_lookup::lower_table_lookups(f).map_err(SimulateError::Compile)?;
1796    }
1797    // A caller-flattened system has no document, but `flatten` carries
1798    // `function_tables` so that this carrier can be lowered too. The lowered
1799    // system is a copy the build owns; the caller's keeps the authored form.
1800    let lowered_flat;
1801    if let Some(flat) = flat_only
1802        && let Some(lowered) = crate::lower_table_lookup::lowered_flattened_copy(flat)
1803            .map_err(SimulateError::Compile)?
1804    {
1805        lowered_flat = lowered;
1806        flat_only = Some(&lowered_flat);
1807    }
1808
1809    // ---- (2) The deterministic build pipeline. ----------------------------
1810    // `mut` on wasm32 only in the sense that the pipeline that writes these is
1811    // native-only; the bindings themselves exist on both targets.
1812    let mut build = BuildProducts::default();
1813    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
1814    let mut model_name = opts.model_name.clone();
1815    let compiler = opts.compiler.unwrap_or_default();
1816    // A compiler this build or process cannot provide is refused before
1817    // anything is evaluated for it: that failure says nothing about the
1818    // document, so no document-level refusal may pre-empt it.
1819    if let Some(details) = unavailable_reason(compiler) {
1820        return Err(SimulateError::CompilerUnavailable {
1821            compiler: compiler.as_str(),
1822            details,
1823        });
1824    }
1825    // What construction evaluates OUTSIDE the compiled model's rule set — the
1826    // build pipeline, a state-free document's observed graph — reported ahead
1827    // of that rule set (API_SPEC §5.8, "Every Problem reports what ran").
1828    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
1829    let mut route_rows: Vec<CompilerRuleReport> = Vec::new();
1830
1831    // What the caller supplied for the document's shaped parameters, captured
1832    // before the build pipeline consumes `const_arrays` and `build_providers`:
1833    // the compiled model binds the arrays too (stage 3d), and the missing-data
1834    // gate asks which names any channel serves.
1835    let supplied = SuppliedData::capture(&mut opts, owned_json.as_ref(), owned_file.as_ref());
1836
1837    // A TYPED document is a legitimate input to the build pipeline, and it used
1838    // to be the one input shape that silently was not: the pipeline reads raw
1839    // JSON, so `ProblemInput::File` skipped it entirely — `build_providers`,
1840    // `const_arrays` and `pushdown_rewrite` were accepted and then DROPPED,
1841    // with no error, and the caller got a document evaluated against every
1842    // data-fed parameter's `default`. Re-serializing here routes it through the
1843    // same path `Json` takes; the typed form is dropped so step (3) re-parses
1844    // the PREPARED document rather than the authored one, exactly as the JSON
1845    // input does.
1846    //
1847    // `ProblemInput::Flattened` still cannot: a flattened system has no
1848    // document to rewrite. It is reachable only from inside this crate.
1849    #[cfg(not(target_arch = "wasm32"))]
1850    if owned_json.is_none()
1851        && let Some(file) = owned_file.as_ref().filter(|_| wants_build_pipeline(&opts))
1852    {
1853        owned_json = Some(serde_json::to_value(file).map_err(|e| {
1854            SimulateError::Compile(crate::compile_error::CompileError::build_err(format!(
1855                "re-serializing the typed document for the build pipeline: {e}"
1856            )))
1857        })?);
1858        owned_file = None;
1859    }
1860
1861    #[cfg(not(target_arch = "wasm32"))]
1862    if let Some(raw) = owned_json.as_mut().filter(|_| wants_build_pipeline(&opts)) {
1863        {
1864            let prepared = crate::prepare::run_build_pipeline(raw, &mut opts)?;
1865            // The pipeline evaluates the observed graph through the reference
1866            // evaluator whatever the compiler, and under a strict one it
1867            // stopped at the first observed it had to walk per cell.
1868            if let Some(r) = &prepared.refused {
1869                // The walk stopped at its first cell (`StopAtFirstCell`).
1870                let reason = match &r.route {
1871                    crate::prepare::Route::PerCell(why) => {
1872                        format!("{why}; {}", crate::simulate_array::ONE_CELL_NOTE)
1873                    }
1874                    _ => String::new(),
1875                };
1876                return Err(SimulateError::Compile(
1877                    crate::compile_error::CompileError::CompilerRefusedRule {
1878                        compiler: compiler.as_str(),
1879                        kind: r.kind,
1880                        rule: qualify(&prepared.model_name, &r.name),
1881                        tier: "const",
1882                        reason,
1883                    },
1884                ));
1885            }
1886            // The pipeline keeps the whole-array overlay under every compiler,
1887            // `interpreter` included, so its rows say which observeds the
1888            // overlay served.
1889            route_rows.extend(prepared.rules.iter().map(|r| {
1890                let rule = qualify(&prepared.model_name, &r.name);
1891                match &r.route {
1892                    crate::prepare::Route::Relational => CompilerRuleReport {
1893                        rule,
1894                        kind: r.kind,
1895                        cadence: "const",
1896                        tier: "relational",
1897                        reason: None,
1898                    },
1899                    crate::prepare::Route::Overlay => {
1900                        outside_rule_row(compiler, rule, r.kind, None, false)
1901                    }
1902                    crate::prepare::Route::Tape => CompilerRuleReport {
1903                        rule,
1904                        kind: r.kind,
1905                        cadence: "const",
1906                        tier: "taped",
1907                        reason: None,
1908                    },
1909                    crate::prepare::Route::PerCell(why) => {
1910                        outside_rule_row(compiler, rule, r.kind, Some(why.clone()), false)
1911                    }
1912                }
1913            }));
1914            *raw = prepared.doc;
1915            json_from_text = false;
1916            source_text = None;
1917            rewritten_by_pipeline = true;
1918            model_name = Some(prepared.model_name);
1919            build.fields = prepared.fields;
1920            build.members = prepared.members;
1921            build.extents = prepared.extents;
1922            build.gated_provider_keys = prepared.gated_provider_keys;
1923            build.baked_parameters = opts.p.keys().cloned().collect();
1924            build.baked_parameters.sort();
1925        }
1926    }
1927
1928    // ---- (3) Typed parse. -------------------------------------------------
1929    // The document's own solver block, read here when the raw document is
1930    // handed to the loader below rather than kept.
1931    let mut doc_solver_of_text: Option<Option<crate::Solver>> = None;
1932    if owned_file.is_none() && owned_json.is_some() {
1933        {
1934            // The prepared document goes to the loader as the value it already
1935            // is. Writing it out and parsing the text back gives the same
1936            // document -- this crate parses with `float_roundtrip`, so every
1937            // number reads back as the value written -- except that the parser
1938            // refuses nesting past its recursion limit, so only a document
1939            // nested that deep still takes the round trip, to get the same
1940            // refusal. A document parsed from a file's text and not rewritten
1941            // since is within that limit already.
1942            // The loader-API metaparameters close the AUTHORED document
1943            // (esm-spec §9.7.6 site 4); a pipeline-rewritten one has had them
1944            // closed already, and may no longer declare the names.
1945            let load_opts = crate::parse::LoadOptions {
1946                base_path: opts.base_path.clone(),
1947                metaparameters: if rewritten_by_pipeline {
1948                    BTreeMap::new()
1949                } else {
1950                    opts.metaparameters.clone()
1951                },
1952            };
1953            let loaded = if json_from_text && source_text.is_some() {
1954                // The Problem keeps the file's text, so the loader may consume
1955                // the parse rather than a copy of it.
1956                let raw = owned_json.take().expect("checked above");
1957                doc_solver_of_text = Some(document_solver(&raw));
1958                crate::parse::load_document_owned(raw, &load_opts)
1959            } else if let Some(raw) = owned_json.as_ref()
1960                && (json_from_text || nesting_within(raw, NESTING_WITHOUT_ROUND_TRIP))
1961            {
1962                crate::parse::load_document_with_options(raw, &load_opts)
1963            } else {
1964                let raw = owned_json.as_ref().expect("checked above");
1965                let text = serde_json::to_string(raw).map_err(|e| {
1966                    SimulateError::Compile(crate::compile_error::CompileError::build_err(format!(
1967                        "re-serializing the prepared document: {e}"
1968                    )))
1969                })?;
1970                crate::parse::load_string_with_options(&text, &load_opts)
1971            };
1972            match loaded {
1973                Ok(f) => owned_file = Some(f),
1974                Err(e) => {
1975                    // The AUTHORED document failing to load — a schema
1976                    // violation, an unresolved import, a removed op, a version
1977                    // the library does not read — is fatal under every `Rhs`
1978                    // mode: esm-libraries-spec §2.1a says a library MUST NOT
1979                    // silently accept an invalid file, and a Problem built
1980                    // from one would answer for a document that does not load.
1981                    if !rewritten_by_pipeline {
1982                        return Err(SimulateError::Compile(
1983                            crate::compile_error::CompileError::build_err(format!(
1984                                "loading the document: {e}"
1985                            )),
1986                        ));
1987                    }
1988                    // A document the build pipeline rewrote may no longer be a
1989                    // *typed* ESM document (the pushdown desugar emits engine
1990                    // constructs). Its build-time products are still valid, so
1991                    // this is only fatal when the caller wanted a solve.
1992                    if opts.rhs == Rhs::Always {
1993                        return Err(SimulateError::Compile(
1994                            crate::compile_error::CompileError::build_err(format!(
1995                                "typed parse of the prepared document: {e}"
1996                            )),
1997                        ));
1998                    }
1999                }
2000            }
2001        }
2002    }
2003
2004    // ---- (3b) Static precision inference, for a RAW-JSON input. -----------
2005    // Stage (1c) already ran when the caller handed in a typed document. A raw
2006    // JSON input has no typed form until stage (3) — this is its first chance,
2007    // and the pass has to happen before stage (4) lowers the equations.
2008    if !inferred
2009        && precision::has_variable_overrides()
2010        && let Some(f) = owned_file.as_mut()
2011        && let Some(models) = f.models.as_mut()
2012    {
2013        crate::precision_infer::annotate_models(models, prec).map_err(SimulateError::Compile)?;
2014    }
2015
2016    // ---- (3c) Lower `table_lookup`, for a RAW-JSON input. ------------------
2017    // The counterpart of stage (1d) for a document that had no typed form
2018    // until stage (3). Idempotent, so a typed input already lowered above
2019    // walks nothing here.
2020    if let Some(f) = owned_file.as_mut() {
2021        crate::lower_table_lookup::lower_table_lookups(f).map_err(SimulateError::Compile)?;
2022    }
2023
2024    // ---- (3d) Caller arrays for shaped parameters. ------------------------
2025    // CONFORMANCE_SPEC §5.32.5: a caller's `const_arrays` entry that gives a
2026    // shaped parameter its value is the same binding as an inline-array
2027    // override, so it lands where that override does — on the parameter's
2028    // `default`, which the array compile lowers. The build pipeline above only
2029    // evaluates the observed graph; without this the compiled right-hand side
2030    // never saw the array and ran on the declared default instead.
2031    #[cfg(not(target_arch = "wasm32"))]
2032    if let Some(f) = owned_file.as_mut() {
2033        supplied.bind_arrays(f, &opts.p, model_name.as_deref());
2034    }
2035    // A scalar parameter value invention reads (a skolem bin width) is
2036    // build-time data: the invented set's size depends on it, so the caller's
2037    // `p` entry lands on its `default`, which the compile's value invention
2038    // reads, and the parameter is baked into the build.
2039    if let Some(f) = owned_file.as_mut() {
2040        for name in bind_value_invention_params(f, &opts.p) {
2041            if !build.baked_parameters.contains(&name) {
2042                build.baked_parameters.push(name);
2043            }
2044        }
2045        // A scalar parameter a join key column reads decides the join's match
2046        // set, which the build constructs once (CONFORMANCE_SPEC §5.5.8): it
2047        // is baked the same way, under every compiler.
2048        for name in bind_join_key_params(f, &opts.p) {
2049            if !build.baked_parameters.contains(&name) {
2050                build.baked_parameters.push(name);
2051            }
2052        }
2053        build.baked_parameters.sort();
2054    }
2055
2056    // ---- (3d) A `p` pin BINDS a data-fed parameter (esm-spec §9.6.6). -----
2057    // The caller passed a value for a parameter the document says is read from
2058    // a file, so for THIS build it is not read from a file — that is the
2059    // documented escape hatch for running a data-fed document offline, and for
2060    // its own inline tests (`Test.parameter_overrides`, esm-spec §6.6, is this
2061    // argument by another name). Stripping the `update` here, before the
2062    // backend is built, is what makes the pin REACH the right-hand side: a
2063    // parameter that still carries a `data` update is routed to the external
2064    // forcing channel, where a `p` binding never lands and which the tape
2065    // cannot lower at all, so the pin used to change nothing and `native`
2066    // refused the document anyway.
2067    #[cfg(not(target_arch = "wasm32"))]
2068    if !opts.p.is_empty()
2069        && let Some(file) = owned_file.as_mut()
2070    {
2071        crate::data_fed::pin_data_fed_parameters(file, opts.p.keys());
2072    }
2073
2074    // ---- (4) Compile the right-hand side. ---------------------------------
2075    let backend = compile_backend(
2076        owned_file.as_ref(),
2077        flat_only,
2078        model_name.as_deref(),
2079        opts.rhs,
2080        compiler,
2081        opts.runtime_array_parameters,
2082    )?;
2083
2084    // ---- (4a) The missing-data gate (esm-spec §10.10). ---------------------
2085    // A shaped parameter with neither a default nor a supplied value is a
2086    // construction error, raised here naming the parameter rather than as an
2087    // unbound read at the first evaluation. An array model is checked on its
2088    // compiled tables (after step 5 has bound the providers); a static one on
2089    // its flattened parameters, before its observed graph is evaluated.
2090    if let Backend::Static(_) = &backend
2091        && let Some(f) = owned_file.as_ref()
2092    {
2093        supplied.refuse_missing_static(f, &opts.p, model_name.as_deref())?;
2094    }
2095
2096    // ---- (4b) State-free static evaluation. -------------------------------
2097    // A document that declares no differential equations has nothing to
2098    // integrate — `solve` refuses it with `NotDynamic` — but its whole content
2099    // is its observed graph, which is exactly what `observed_field` reads. This
2100    // evaluates that graph so the read works with NO options set, which is what
2101    // a stable-API function has to do (API_SPEC §5.8): before this, reaching a
2102    // state-free document's own results through the Rust binding meant knowing
2103    // to pass `build_pipeline: true` AND to hand in raw JSON rather than a
2104    // typed document, neither of which the surface contract mentions.
2105    //
2106    // Skipped when the build pipeline already produced fields, so the ISRM /
2107    // pushdown path keeps its own (model-local) keys untouched.
2108    //
2109    // Evaluated by the compiler the Problem names, and gated by it: the tape
2110    // under `native` / `xla`, the per-cell oracle under `interpreter`.
2111    if build.fields.is_empty()
2112        && let Backend::Static(_) = &backend
2113    {
2114        let t0 = opts.sample_time.unwrap_or(tspan.0);
2115        let (fields, rows, eval_error) = static_observed_fields(
2116            owned_file.as_ref(),
2117            flat_only,
2118            &opts.p,
2119            t0,
2120            model_name.as_deref(),
2121            compiler,
2122        )?;
2123        build.fields = fields;
2124        build.static_eval_error = eval_error;
2125        route_rows.extend(rows);
2126    }
2127
2128    // ---- (5) Bind and CONST-materialize the run-time providers. -----------
2129    #[cfg(not(target_arch = "wasm32"))]
2130    let (refresh, discrete_forcing, refresh_boundaries) =
2131        bind_providers(&backend, &mut opts, tspan)?;
2132    if let Backend::Array(c) = &backend {
2133        supplied.refuse_missing_array(c, owned_file.as_ref(), &opts.p)?;
2134    }
2135
2136    // ---- (5c) Refuse a data-fed parameter nothing bound (§9.6.6). ---------
2137    // After the providers are bound and BEFORE the compiler's gate below builds
2138    // the tape, so the refusal is the same under `native` and `interpreter`:
2139    // this asks whether the DOCUMENT's inputs are bound, not what a compiler
2140    // can lower. Without it `native` reported the same document as
2141    // `compiler_refused_rule` ("wholesale: unresolved symbol"), naming the
2142    // tape's limits in place of the defect, while `interpreter` built and then
2143    // failed inside the run with an uncoded treewalk fault.
2144    #[cfg(not(target_arch = "wasm32"))]
2145    if let Backend::Array(compiled) = &backend {
2146        crate::data_fed::refuse_unbound(
2147            compiled,
2148            &opts.const_arrays,
2149            &discrete_forcing,
2150            opts.p.keys(),
2151        )?;
2152    }
2153
2154    // esm-spec §2.2: the document's own solver hints. Read from whichever
2155    // carrier survived to here — the raw JSON when there is one, else the typed
2156    // file — so the §2.2.2 chain holds however the problem was built.
2157    let doc_solver = match (doc_solver_of_text, owned_json.as_ref(), owned_file.as_ref()) {
2158        (Some(solver), _, _) => solver,
2159        (None, Some(raw), _) => document_solver(raw),
2160        (None, None, Some(file)) => crate::solver::normalize_empty(file.solver.clone()),
2161        (None, None, None) => None,
2162    };
2163
2164    // ---- (5b) The compiler's own gate, and its report. --------------------
2165    // §2.5.10 puts the refusal at CONSTRUCTION, and puts every evaluation the
2166    // compiler performs for the Problem under it — not the right-hand side
2167    // alone. Both need the tape, so the tape is built HERE rather than inside
2168    // `solve`, and with the DISCRETE forcing set step (5) just resolved, which
2169    // is what decides a rule's cadence tier.
2170    let mut compiler_report = build_compiler_report(
2171        &backend,
2172        model_name.as_deref(),
2173        compiler,
2174        #[cfg(not(target_arch = "wasm32"))]
2175        &discrete_forcing,
2176        #[cfg(target_arch = "wasm32")]
2177        &std::collections::HashSet::new(),
2178        &opts.p,
2179    )?;
2180    compiler_report.rules.splice(0..0, route_rows);
2181
2182    // ---- (5b') Every state slot has a starting value (esm-spec §11.4). ------
2183    // After (5b), which resolved the field `ic`s this reads.
2184    #[cfg(feature = "solve")]
2185    if let Backend::Array(c) = &backend {
2186        refuse_missing_initial_values(c, &opts.u0)?;
2187    }
2188
2189    // ---- (5c) `xla`: emit and compile, here, once. ------------------------
2190    // AFTER the gate above, so a rule that never reached the tape is refused
2191    // by name with its cadence tier and component, rather than by the
2192    // emitter's own unqualified refusal of the declined tape. At construction rather than at the first
2193    // step, because §2.5.10 puts the refusal at construction and because XLA
2194    // compilation is the expensive step: a Problem pays it once, and every
2195    // segment and every Jacobian call reuses the one executable.
2196    install_xla_program(&backend, model_name.as_deref(), compiler)?;
2197
2198    let program_source = match (&backend, owned_file, flat_only) {
2199        (Backend::Array(_), Some(file), _) => Some(Rc::new(ProgramSource::File(Box::new(file)))),
2200        (Backend::Array(_), None, Some(flat)) => {
2201            Some(Rc::new(ProgramSource::Flattened(Box::new(flat.clone()))))
2202        }
2203        _ => None,
2204    };
2205    let prob = EsmProblem {
2206        doc: Rc::new(match (owned_json, source_text) {
2207            (None, Some(text)) => RawDocument::from_text(text),
2208            (raw, _) => RawDocument::parsed(raw.unwrap_or(JsonValue::Null)),
2209        }),
2210        solver: doc_solver,
2211        model_name,
2212        precision: precision::Env::capture(),
2213        tspan,
2214        p: std::mem::take(&mut opts.p),
2215        u0: std::mem::take(&mut opts.u0),
2216        backend: Rc::new(backend),
2217        build: Rc::new(build),
2218        inspection: std::cell::RefCell::new(BuildInspection::default()),
2219        inspect: opts.inspect,
2220        compiler,
2221        compiler_report,
2222        callbacks: std::mem::take(&mut opts.callbacks),
2223        #[cfg(not(target_arch = "wasm32"))]
2224        refresh,
2225        #[cfg(not(target_arch = "wasm32"))]
2226        discrete_forcing,
2227        #[cfg(not(target_arch = "wasm32"))]
2228        refresh_boundaries,
2229        program_source,
2230        runtime_array_parameters: opts.runtime_array_parameters,
2231    };
2232    Ok(prob)
2233}
2234
2235/// Read `domain.element_type` off whichever form of the input the caller gave
2236/// (esm-spec §11.3).
2237///
2238/// The raw-JSON branch reads the field textually because it runs *before* the
2239/// typed parse — the build pipeline needs the precision armed already.
2240///
2241/// # Errors
2242///
2243/// [`SimulateError::Compile`] wrapping
2244/// [`crate::compile_error::CompileError::UnsupportedElementType`] for a
2245/// spelling that is neither `"Float64"` nor `"Float32"`. An unrecognised
2246/// element type is not silently binary64.
2247fn element_type_of(
2248    raw: Option<&JsonValue>,
2249    file: Option<&EsmFile>,
2250    flat: Option<&FlattenedSystem>,
2251) -> Result<Precision, SimulateError> {
2252    let named = raw
2253        .and_then(|v| v.get("domain"))
2254        .and_then(|d| d.get("element_type"))
2255        .and_then(JsonValue::as_str)
2256        .map(str::to_string)
2257        .or_else(|| {
2258            file.and_then(|f| f.domain.as_ref())
2259                .and_then(|d| d.element_type.clone())
2260        })
2261        .or_else(|| {
2262            flat.and_then(|f| f.domain.as_ref())
2263                .and_then(|d| d.element_type.clone())
2264        });
2265    Precision::from_element_type(named.as_deref()).map_err(SimulateError::Compile)
2266}
2267
2268/// Collect every `ModelVariable.element_type` the document declares
2269/// (esm-spec §11.3.1), keyed by both the bare and the `Model.name` spelling.
2270///
2271/// Read off whichever form of the input the caller gave, and — like
2272/// [`element_type_of`] — off the RAW JSON textually where there is one,
2273/// because it has to be armed before the build pipeline runs.
2274///
2275/// Returns an EMPTY table for the overwhelmingly common document that declares
2276/// none, which is what keeps the whole per-variable path inert.
2277///
2278/// # Errors
2279///
2280/// [`SimulateError::Compile`] wrapping
2281/// [`crate::compile_error::CompileError::UnsupportedElementType`] for a
2282/// spelling that is neither `"Float64"` nor `"Float32"`.
2283fn variable_element_types_of(
2284    raw: Option<&JsonValue>,
2285    file: Option<&EsmFile>,
2286    flat: Option<&FlattenedSystem>,
2287    document: Precision,
2288    selected: Option<&str>,
2289) -> Result<precision::VarPrecisions, SimulateError> {
2290    if let Some(models) = raw
2291        .and_then(|v| v.get("models"))
2292        .and_then(JsonValue::as_object)
2293    {
2294        let decls: Vec<precision::ModelDeclarations<'_>> = models
2295            .iter()
2296            .map(|(mname, model)| precision::ModelDeclarations {
2297                model: mname,
2298                variables: model
2299                    .get("variables")
2300                    .and_then(JsonValue::as_object)
2301                    .map(|vars| {
2302                        vars.iter()
2303                            .map(|(v, d)| {
2304                                (
2305                                    v.as_str(),
2306                                    d.get("element_type").and_then(JsonValue::as_str),
2307                                )
2308                            })
2309                            .collect()
2310                    })
2311                    .unwrap_or_default(),
2312                subsystem_variables: precision::subsystem_declarations(model.get("subsystems")),
2313            })
2314            .collect();
2315        return precision::VarPrecisions::from_declarations(&decls, document, selected)
2316            .map_err(SimulateError::Compile);
2317    }
2318    if let Some(models) = file.and_then(|f| f.models.as_ref()) {
2319        let decls = crate::precision_infer::model_declarations(models);
2320        return precision::VarPrecisions::from_declarations(&decls, document, selected)
2321            .map_err(SimulateError::Compile);
2322    }
2323    // A flattened system carries namespaced names and no per-variable element
2324    // types of its own; nothing to collect. `document` stays the answer for
2325    // every name.
2326    let _ = (flat, document);
2327    Ok(precision::VarPrecisions::default())
2328}
2329
2330/// Reject integrating a Float32 document.
2331///
2332/// The ODE/DAE solver (diffsol) is instantiated over `f64` — its step-size
2333/// control, error norms and Newton iteration are binary64 and have no binary32
2334/// form here. Integrating a Float32 document would therefore produce an answer
2335/// whose right-hand side was binary32 and whose time-stepping was binary64,
2336/// with nothing in the result to say so. Per esm-spec §11.3 that is an error
2337/// naming the construct, not a partial honouring of the declaration.
2338///
2339/// Algebraic, observed and relational evaluation — everything `observed_field`
2340/// and the inline `tests` blocks read — is unaffected and runs in binary32.
2341fn reject_f32_integration(prob: &EsmProblem) -> Result<(), SimulateError> {
2342    // Only a system that actually INTEGRATES is affected. A model whose every
2343    // equation is algebraic — the relational / lookup-table shape this mode
2344    // exists for — is evaluated entirely by the (binary32) expression
2345    // evaluator, and `Rhs::Always` giving it a non-static backend does not
2346    // make it dynamic.
2347    let integrates = match &*prob.backend {
2348        Backend::Static(_) => false,
2349        Backend::Array(c) => c.has_differential_equations(),
2350    };
2351    if prob.precision.is_f32() && integrates {
2352        return Err(SimulateError::Compile(
2353            crate::compile_error::CompileError::Float32Unsupported {
2354                construct: "time integration of a dynamic model".to_string(),
2355                reason: "the ODE/DAE solver is instantiated over binary64 (step-size control,                          error norms and the Newton solve), so the trajectory would be                          binary64 even though the right-hand side is binary32"
2356                    .to_string(),
2357            },
2358        ));
2359    }
2360    Ok(())
2361}
2362
2363/// Arrays and objects nested this deep or less parse from text well inside
2364/// `serde_json`'s recursion limit (128).
2365const NESTING_WITHOUT_ROUND_TRIP: usize = 64;
2366
2367/// Whether no array or object in `v` sits more than `limit` containers deep
2368/// (the root container is depth 1).
2369fn nesting_within(v: &JsonValue, limit: usize) -> bool {
2370    match v {
2371        JsonValue::Array(items) => limit > 0 && items.iter().all(|x| nesting_within(x, limit - 1)),
2372        JsonValue::Object(map) => limit > 0 && map.values().all(|x| nesting_within(x, limit - 1)),
2373        _ => true,
2374    }
2375}
2376
2377#[cfg(not(target_arch = "wasm32"))]
2378fn wants_build_pipeline(opts: &ProblemOptions) -> bool {
2379    opts.build_pipeline
2380        || opts.pushdown_rewrite
2381        || !opts.build_providers.is_empty()
2382        || !opts.const_arrays.is_empty()
2383}
2384
2385/// The compiled model of a problem with **nothing to integrate** whose
2386/// observeds are a scalar function of the parameters and `t` alone — the
2387/// graph [`evaluate_static_observeds_over`] evaluates.
2388///
2389/// `esm simulate` evaluates such a document ONCE, at `tspan.0`, because a
2390/// single evaluation is all that command promises. The §6.6 inline-test runner
2391/// cannot stop there: esm-spec §6.6.3 defines an assertion's `time` as
2392/// "Simulation time at which to evaluate the assertion", and a document with
2393/// no differential equations is still a FUNCTION OF `t` — a solar-geometry
2394/// component's declination, hour angle and zenith cosine are exactly that. So
2395/// the runner evaluates this once per asserted time (issue #406).
2396///
2397/// `None` when there is something to integrate — the trajectory, not this, is
2398/// the answer — and `None` for a SHAPED state-free document (an array,
2399/// tensor or geometry observed, [`ArrayCompiled::observeds_are_scalar`]), which
2400/// is answered from the fields its BUILD materialized instead: see the
2401/// `has_nothing_to_integrate` arm of the inline-test runner, which is why that
2402/// runner asks the build pipeline for them. `None` again when a DISCRETE
2403/// provider feeds the document, whose forcing moves with `t` only through the
2404/// segmented refresh a solve drives.
2405#[cfg(all(not(target_arch = "wasm32"), feature = "solve"))]
2406pub(crate) fn static_observed_graph(prob: &EsmProblem) -> Option<Rc<ArrayCompiled>> {
2407    if !prob.discrete_forcing.is_empty() {
2408        return None;
2409    }
2410    match &*prob.backend {
2411        Backend::Array(c)
2412            if !c.has_differential_equations()
2413                && c.state_variable_names().is_empty()
2414                && c.observeds_are_scalar() =>
2415        {
2416            Some(Rc::clone(c))
2417        }
2418        _ => None,
2419    }
2420}
2421
2422/// Every 0-D observed of [`static_observed_graph`]'s result at each of
2423/// `times`, against the problem's resolved parameters — `(name, values)` in
2424/// the document's own observed order, one value per time, named as a
2425/// [`solve`] of the same problem names its rows.
2426///
2427/// Evaluated by the array runtime on the compiler the problem was built with,
2428/// so under `native` the values come off the tape and under `interpreter` off
2429/// the per-cell oracle, exactly as a solve's reported observeds would.
2430#[cfg(all(not(target_arch = "wasm32"), feature = "solve"))]
2431pub(crate) fn evaluate_static_observeds_over(
2432    prob: &EsmProblem,
2433    graph: &ArrayCompiled,
2434    times: &[f64],
2435) -> Result<Vec<(String, Vec<f64>)>, SimulateError> {
2436    let _precision_guard = prob.precision.enter();
2437    let rows = graph.evaluate_stateless_observeds(&prob.p, times)?;
2438    let names: Vec<String> = rows.iter().map(|(name, _)| name.clone()).collect();
2439    Ok(qualify_array_names(graph, &names)
2440        .into_iter()
2441        .zip(rows.into_iter().map(|(_, values)| values))
2442        .collect())
2443}
2444
2445/// Every observed field of a problem with **nothing to integrate** whose
2446/// compiled model is state-free, evaluated at `tspan.0` on the compiler the
2447/// problem was built with — `(name, field)` in dependency order, named as the
2448/// model's rules are.
2449///
2450/// The inline-test runner's answer for a SHAPED state-free document, which
2451/// [`static_observed_graph`] does not serve: the same array runtime the
2452/// problem already built, so under `native` the fields come off the tape
2453/// rather than from a second, pipeline build through the reference evaluator.
2454/// `None` when there is state or something to integrate, when a DISCRETE
2455/// provider feeds the document (its forcing moves only through a solve's
2456/// refresh), or when the evaluation fails or produces nothing.
2457#[cfg(all(not(target_arch = "wasm32"), feature = "solve"))]
2458pub(crate) fn stateless_fields(prob: &EsmProblem) -> Option<Vec<(String, ArrayD<f64>)>> {
2459    if !prob.discrete_forcing.is_empty() {
2460        return None;
2461    }
2462    let Backend::Array(c) = &*prob.backend else {
2463        return None;
2464    };
2465    if c.has_differential_equations() || !c.state_variable_names().is_empty() {
2466        return None;
2467    }
2468    let _precision_guard = prob.precision.enter();
2469    c.evaluate_stateless_fields(&prob.p, prob.tspan.0)
2470        .ok()
2471        .filter(|f| !f.is_empty())
2472}
2473
2474/// Whether this problem has **nothing to integrate**: no backend at all, or a
2475/// backend that declares no differential equation (esm-spec §6.3.1 derives
2476/// `system_kind: "nonlinear"` for such a document).
2477///
2478/// This is the derivation `Rhs::Auto` consults to pick [`Backend::Static`],
2479/// asked of a problem the inline-test runner deliberately built with
2480/// `Rhs::Always` — which still compiles, and therefore still refuses, a
2481/// construct no evaluator supports, but which also hands a right-hand side over
2482/// an empty state vector to the solver. The runner asks this BEFORE solving, so
2483/// a document with nothing to integrate is answered from what was evaluated or
2484/// built rather than reported as a nonlinear-solver failure (issue #406).
2485///
2486/// [`static_observed_graph`] serves the scalar-observed documents; this
2487/// predicate is what lets the runner tell "nothing to integrate, and no scalar
2488/// graph to evaluate here" (a shaped document) apart from "something to
2489/// integrate".
2490#[cfg(all(not(target_arch = "wasm32"), feature = "solve"))]
2491pub(crate) fn has_nothing_to_integrate(prob: &EsmProblem) -> bool {
2492    match &*prob.backend {
2493        Backend::Static(_) => true,
2494        Backend::Array(c) => !c.has_differential_equations(),
2495    }
2496}
2497
2498/// Evaluate a state-free system's observed graph at `t0`, on the compiler the
2499/// Problem names, and report every rule it evaluated.
2500///
2501/// The build-time half of [`observed_field`] for a document with no
2502/// differential equations: one stateless evaluation of the array runtime
2503/// ([`ArrayCompiled::evaluate_stateless_observeds`]) — no solver, no build
2504/// pipeline, and no dependence on how the caller spelled its input.
2505///
2506/// Keys are FLATTENED names (`Sites.North.u`), matching Julia's
2507/// `BuildInspection.observed_exprs` and Python's `static_observed_values`.
2508///
2509/// **The rank comes from the DECLARATION**, through the same
2510/// [`crate::classification::DeclaredRank`] the build pipeline uses (API_SPEC
2511/// `observed_trajectories`: "A field is the shape the document declares").
2512/// This path and the pipeline serve the SAME documents — this one runs exactly
2513/// when the pipeline produced no fields — so deciding the rank differently
2514/// here would give one document two spellings, `Rel.total` or `Rel.total[1]`,
2515/// picked by a backend choice the author cannot see.
2516///
2517/// **Under the compiler's refusal.** esm-libraries-spec §2.5.10 puts "the
2518/// materialization of constants and static observeds at construction" under
2519/// the refusal, and this IS that materialization for a document with nothing
2520/// to integrate: it has no right-hand side, but its observed graph is its
2521/// whole answer and something evaluates it. So the runtime is built in the
2522/// named compiler's mode and the SAME per-rule gate a dynamic document meets
2523/// ([`build_compiler_report`]) runs over it: under `native` / `xla` every rule
2524/// must lower to the tape, which then evaluates them, and a rule that does not
2525/// is a `compiler_refused_rule` naming it; under `interpreter` every rule is
2526/// the per-cell oracle's. `xla` emits no program here — there is no
2527/// right-hand side to emit — so its rules are reported as `"taped"`, which is
2528/// what evaluates them.
2529///
2530/// **Every observed, scalar or shaped.** A shaped state-free document — a
2531/// relational row set, a grid-geometry table — is evaluated here as a scalar
2532/// one is: its observeds are rules of the same array runtime, so the compiler
2533/// that answers a scalar observed answers a shaped one, rather than leaving it
2534/// to the build pipeline's reference evaluator. A 0-D or one-cell observed takes its rank from the declaration; a
2535/// shaped one is the field the runtime materialized, reported only when the
2536/// document declares it an observed (a shaped parameter the runtime lowered to
2537/// a `const` rule is not one).
2538///
2539/// **Tolerant of what no compiler can build.** A system the runtime cannot
2540/// build or evaluate — a `v1`-unsupported feature, a parameter with no default
2541/// — yields NO fields rather than failing the build, under every compiler
2542/// alike. Construction was not asked to compile a right-hand side
2543/// (`Rhs::Auto` chose `Backend::Static`), so a failure to evaluate must surface
2544/// as `observed_field` reporting the name it cannot answer, not as a document
2545/// that will not build. Python makes the same call for the same reason
2546/// (`problem.py`, the scalar no-state branch). Only the compiler's own refusal is
2547/// raised: that one says the document evaluates under `interpreter` and not
2548/// under the compiler the caller named. An EVALUATION failure of a runtime
2549/// that did build (a fail-closed fault) comes back as the third element, for
2550/// [`observed_field`] to report.
2551#[allow(clippy::type_complexity)]
2552fn static_observed_fields(
2553    file: Option<&EsmFile>,
2554    flat_only: Option<&FlattenedSystem>,
2555    p: &HashMap<String, f64>,
2556    t0: f64,
2557    model_name: Option<&str>,
2558    compiler: Compiler,
2559) -> Result<
2560    (
2561        HashMap<String, ArrayD<f64>>,
2562        Vec<CompilerRuleReport>,
2563        Option<String>,
2564    ),
2565    SimulateError,
2566> {
2567    let nothing = || Ok((HashMap::new(), Vec::new(), None));
2568    let owned_flat;
2569    let flat = match (flat_only, file) {
2570        (Some(f), _) => f,
2571        // A document that does not flatten — an unresolved coupling endpoint,
2572        // a coupling-import error — is a build failure, raised as one
2573        // (esm-libraries-spec §2.5.2) rather than answered with no fields.
2574        (None, Some(file)) => {
2575            owned_flat = crate::flatten::flatten(file).map_err(|e| {
2576                SimulateError::Compile(crate::compile_error::CompileError::Flatten(e))
2577            })?;
2578            &owned_flat
2579        }
2580        (None, None) => return nothing(),
2581    };
2582    // The evaluable-core gate runs first (esm-spec §9.6.3 constraint 6): it
2583    // walks every equation, so an unknown no equation defines — which makes
2584    // the system not state-free below — does not let a rewrite-target operator
2585    // through unreported.
2586    if let Some(op) = crate::flatten::first_unlowered_operator(flat) {
2587        return Err(SimulateError::Compile(
2588            crate::compile_error::CompileError::UnloweredOperatorError { op },
2589        ));
2590    }
2591    // A system WITH state is not state-free evaluable: an observed may read
2592    // state, and there is none to read. Reachable when `model_name` selects an
2593    // ODE-free model out of a document that has ODEs elsewhere, since
2594    // `flatten` is document-wide.
2595    // An unknown defined by an element-wise equation (`index(a, i) = …`) is
2596    // listed as an observed as well as a state; only a state no equation
2597    // defines has anything to integrate.
2598    if flat
2599        .state_variables
2600        .keys()
2601        .any(|k| !flat.observed_variables.contains_key(k))
2602    {
2603        return nothing();
2604    }
2605    // An ill-formed document is not a compiler's to refuse: a name declared
2606    // nowhere fails the same free-variable gate the array build and the build
2607    // pipeline run (CONFORMANCE_SPEC §5.23), and is raised as that build
2608    // failure rather than surfacing as a strict compiler "declining" a rule no
2609    // compiler could evaluate — or as a Problem with no fields at all.
2610    #[cfg(not(target_arch = "wasm32"))]
2611    if let Some(file) = file
2612        && crate::simulate::whole_document_is_one_model(file)
2613        && let Some((_, model)) = file.models.as_ref().and_then(|m| m.iter().next())
2614    {
2615        let index_sets: HashMap<String, crate::types::IndexSet> = file
2616            .index_sets
2617            .clone()
2618            .unwrap_or_default()
2619            .into_iter()
2620            .collect();
2621        crate::simulate_array::check_free_variables(model, &index_sets, &[])
2622            .map_err(SimulateError::Compile)?;
2623    }
2624    // The same gates the array route runs — the evaluable-core operator gate
2625    // (esm-spec §9.6.3 constraint 6), events, operator arity — fire here too:
2626    // a state-free document is evaluated, so it is built.
2627    let mut compiled = ArrayCompiled::from_flattened(flat).map_err(SimulateError::Compile)?;
2628    compiled.runtime_mode = match compiler {
2629        Compiler::Interpreter => crate::simulate_array::RuntimeMode::Interpreter,
2630        _ => crate::simulate_array::RuntimeMode::Native,
2631    };
2632    let compiled = Rc::new(compiled);
2633    let report = rule_report(
2634        &compiled,
2635        model_name,
2636        compiler,
2637        &std::collections::HashSet::new(),
2638        false,
2639    )?;
2640    let values = match compiled.evaluate_stateless_fields(p, t0) {
2641        Ok(values) => values,
2642        // A value the spec makes invalid (a degenerate polygon operand,
2643        // esm-spec §8.6.1) is refused, not handed back as a Problem with no
2644        // fields; a value that is only not yet available (a parameter with no
2645        // default, a field a provider fills later) still is.
2646        Err(e)
2647            if e.to_string()
2648                .contains(crate::simulate_array::GEOMETRY_CLIP_CODE) =>
2649        {
2650            return Err(e);
2651        }
2652        Err(e) => return Ok((HashMap::new(), report.rules, Some(e.to_string()))),
2653    };
2654    let fields = values
2655        .into_iter()
2656        .filter_map(|(name, arr)| {
2657            let declared = flat.observed_variables.get(&name);
2658            if arr.len() == 1 {
2659                let shape = DeclaredRank::of_declaration(declared).scalar_shape();
2660                let v = arr.iter().copied().next().expect("one element");
2661                let arr = ArrayD::from_shape_vec(ndarray::IxDyn(shape), vec![v])
2662                    .expect("`scalar_shape` is `[]` or `[1]`, and both hold exactly one element");
2663                return Some((name, arr));
2664            }
2665            declared.map(|_| (name, arr))
2666        })
2667        .collect();
2668    Ok((fields, report.rules, None))
2669}
2670
2671/// Build the per-rule record, and — under a STRICT compiler ([`Compiler::Native`]
2672/// or [`Compiler::Xla`]) — REFUSE the document when any rule failed to lower
2673/// (esm-libraries-spec §2.5.10).
2674///
2675/// This is the whole of that strictness, and it lives at construction for the
2676/// reason §2.5.2 gives: a document that never became a Problem has no run to
2677/// describe, so a build failure must be raised where the build happens.
2678///
2679/// **It gates more than the right-hand side.** §2.5.10 puts "the
2680/// materialization of constants and static observeds at construction, the
2681/// per-segment seed, the right-hand side, and the observeds reported at output
2682/// times" all under the refusal. In this binding every one of those is an
2683/// evaluation of the SAME rule set, so one gate over the rule set covers them
2684/// all — and the driver then SERVES those passes from the tape under both
2685/// strict compilers (`simulate_array::driver`) instead of from the whole-array
2686/// overlay, which is what makes the gate honest rather than merely necessary.
2687///
2688/// `xla` is gated HERE as well as by its emitter, and the order matters: this
2689/// gate names the rule, its component and its cadence tier, while an emitter
2690/// refusal names only the rule as the compiled model spells it, or the
2691/// instruction it choked on. A rule that never reached the tape is therefore
2692/// reported here, in full.
2693fn build_compiler_report(
2694    backend: &Backend,
2695    model_name: Option<&str>,
2696    compiler: Compiler,
2697    discrete_forcing: &std::collections::HashSet<String>,
2698    #[cfg_attr(not(feature = "solve"), allow(unused_variables))] p: &HashMap<String, f64>,
2699) -> Result<CompilerReport, SimulateError> {
2700    let compiled = match backend {
2701        // No compiled model. A state-free document's observed graph was
2702        // evaluated — and gated — by `static_observed_fields`, whose rows the
2703        // caller adds.
2704        Backend::Static(_) => return Ok(CompilerReport::empty(compiler)),
2705        Backend::Array(c) => c,
2706    };
2707    #[cfg_attr(not(feature = "solve"), allow(unused_mut))]
2708    let mut report = rule_report(compiled, model_name, compiler, discrete_forcing, true)?;
2709    // The field initial conditions are evaluated by the reference evaluator
2710    // under every compiler — no tier has a form for initial-state assembly —
2711    // so they are exercised HERE, where a strict compiler refuses one it would
2712    // have to walk per cell, and reported ahead of the model's rules.
2713    #[cfg(feature = "solve")]
2714    {
2715        let model = model_name.unwrap_or("");
2716        let mut ic_rows = Vec::new();
2717        for r in compiled.field_ic_records(p, compiler.is_strict()) {
2718            let reason = r.per_cell.then(|| {
2719                "evaluated by the reference evaluator at setup, and a `faq` in it is not one \
2720                 the whole-array overlay takes, so it was walked once per cell"
2721                    .to_string()
2722            });
2723            if compiler.is_strict()
2724                && let Some(reason) = reason
2725            {
2726                // The walk stopped at its first cell (`field_ic_records`).
2727                return Err(SimulateError::Compile(
2728                    crate::compile_error::CompileError::CompilerRefusedRule {
2729                        compiler: compiler.as_str(),
2730                        kind: r.kind,
2731                        rule: qualify(model, &r.name),
2732                        tier: "const",
2733                        reason: format!("{reason}; {}", crate::simulate_array::ONE_CELL_NOTE),
2734                    },
2735                ));
2736            }
2737            ic_rows.push(outside_rule_row(
2738                compiler,
2739                qualify(model, &r.name),
2740                r.kind,
2741                reason,
2742                compiler == Compiler::Interpreter,
2743            ));
2744        }
2745        report.rules.splice(0..0, ic_rows);
2746    }
2747    Ok(report)
2748}
2749
2750/// The report row of one evaluation construction performs OUTSIDE the
2751/// compiled model's rule set, at setup (`"const"`). `per_cell` is why it was
2752/// walked per cell, if it was; `overlay_off` says the evaluation ran with the
2753/// whole-array overlay off, as a field `ic` does under
2754/// [`Compiler::Interpreter`].
2755///
2756/// The tier says what happened: the per-cell oracle, or the whole-array
2757/// overlay. Under the interpreter a per-cell walk is that compiler's design
2758/// rather than a decline, so its rows carry no reason, exactly as its rule
2759/// rows do.
2760fn outside_rule_row(
2761    compiler: Compiler,
2762    rule: String,
2763    kind: &'static str,
2764    per_cell: Option<String>,
2765    overlay_off: bool,
2766) -> CompilerRuleReport {
2767    let tier = if overlay_off || per_cell.is_some() {
2768        "oracle"
2769    } else {
2770        "vectorized"
2771    };
2772    let reason = match compiler {
2773        Compiler::Interpreter => None,
2774        _ => per_cell,
2775    };
2776    CompilerRuleReport {
2777        rule,
2778        kind,
2779        cadence: "const",
2780        tier,
2781        reason,
2782    }
2783}
2784
2785/// The per-rule record of one compiled model, refusing under a strict compiler
2786/// the first rule that did not lower ([`build_compiler_report`]).
2787///
2788/// `xla_emitted` says whether the model's right-hand side is the emitted XLA
2789/// program. A state-free model has none, so under [`Compiler::Xla`] its rules
2790/// are reported as the tape's, which is what evaluates them.
2791fn rule_report(
2792    compiled: &ArrayCompiled,
2793    model_name: Option<&str>,
2794    compiler: Compiler,
2795    discrete_forcing: &std::collections::HashSet<String>,
2796    xla_emitted: bool,
2797) -> Result<CompilerReport, SimulateError> {
2798    let (records, tape_report) = compiled.tape_rule_records(discrete_forcing);
2799    let model = model_name.unwrap_or("");
2800    let mut rules = Vec::with_capacity(records.len());
2801    for r in records {
2802        let qualified = qualify(model, &r.name);
2803        if compiler.is_strict()
2804            && let Some(reason) = &r.fallback_reason
2805        {
2806            return Err(SimulateError::Compile(
2807                crate::compile_error::CompileError::CompilerRefusedRule {
2808                    compiler: compiler.as_str(),
2809                    kind: r.kind,
2810                    rule: qualified,
2811                    tier: r.cadence,
2812                    reason: reason.clone(),
2813                },
2814            ));
2815        }
2816        let tier = match (compiler, &r.fallback_reason) {
2817            // Under `interpreter` the tape is never installed, so every rule
2818            // runs on the per-cell oracle by design — not as a decline. The
2819            // tape build above ran only to classify each rule's cadence and is
2820            // discarded; it evaluates nothing.
2821            (Compiler::Interpreter, _) => "oracle",
2822            // Under `xla` the gate above has already refused anything that did
2823            // not lower, so every rule reaching here is in the emitted
2824            // program. It is reported as `"xla"` rather than as `"taped"`
2825            // because the tape is where it was lowered FROM, not what
2826            // evaluates it.
2827            (Compiler::Xla, _) if xla_emitted => "xla",
2828            (_, None) => "taped",
2829            (_, Some(_)) => "fallback",
2830        };
2831        rules.push(CompilerRuleReport {
2832            rule: qualified,
2833            kind: r.kind,
2834            cadence: r.cadence,
2835            tier,
2836            // Under `interpreter` there is no decline to report: the tape was
2837            // never going to run, so what it would have said about a rule is
2838            // not this compiler's story.
2839            reason: match compiler {
2840                Compiler::Interpreter => None,
2841                _ => r.fallback_reason,
2842            },
2843        });
2844    }
2845    let (fused_groups, fused_instructions) = if compiler == Compiler::Interpreter {
2846        (0, 0)
2847    } else {
2848        (tape_report.fuse.n_groups, tape_report.fuse.n_member_instrs)
2849    };
2850    Ok(CompilerReport {
2851        compiler,
2852        rules,
2853        fused_groups,
2854        fused_instructions,
2855    })
2856}
2857
2858/// Why a compiler in the vocabulary cannot be provided here, and what would
2859/// provide it (esm-libraries-spec §2.5.10: the message names what to load or
2860/// build). `None` for the two this binding implements.
2861///
2862/// A refusal, never a substitution — answering `xla` by building `native`
2863/// would make the compiler's name describe nothing, which is the failure
2864/// §2.5.10 exists to prevent.
2865fn unavailable_reason(compiler: Compiler) -> Option<String> {
2866    match compiler {
2867        Compiler::Native | Compiler::Interpreter => None,
2868        Compiler::Xla => xla_unavailable_reason(),
2869        Compiler::Mtk => Some(
2870            "a ModelingToolkit system needs Julia and ModelingToolkit.jl, and this binding has \
2871             no ModelingToolkit runtime to load; `mtk` is provided by the Julia binding"
2872                .to_string(),
2873        ),
2874        Compiler::Sympy => Some(
2875            "a lambdified SymPy right-hand side needs Python and SymPy, and this binding has \
2876             neither; `sympy` is provided by the Python binding"
2877                .to_string(),
2878        ),
2879    }
2880}
2881
2882/// Whether `xla` can be provided by THIS build and THIS process, and what is
2883/// missing when it cannot.
2884///
2885/// `xla` is the specialty compiler that needs a heavy external dependency, and
2886/// the dependency can be absent in two different places: the crate can be
2887/// built without the `xla` feature, or it can have the feature and still find
2888/// no usable XLA runtime to start (no unpacked `xla_extension`, no device for
2889/// the requested platform). Both are `compiler_unavailable` and neither says
2890/// anything about what the emitter can lower, so both are answered here —
2891/// BEFORE any document is looked at, so the answer is a property of the build
2892/// and not of the input.
2893///
2894/// The client is a process-wide singleton and caches its own failure, so
2895/// probing it per Problem costs one atomic read after the first.
2896#[cfg(feature = "xla")]
2897fn xla_unavailable_reason() -> Option<String> {
2898    crate::xla_runtime::client().err().map(|e| {
2899        format!(
2900            "this build has the `xla` feature, but no usable XLA runtime in this process: {e}. \
2901             Point XLA_EXTENSION_DIR at an unpacked xla_extension release \
2902             (scripts/fetch-xla-extension.sh) and rebuild against it"
2903        )
2904    })
2905}
2906
2907#[cfg(not(feature = "xla"))]
2908fn xla_unavailable_reason() -> Option<String> {
2909    Some(
2910        "this binding's StableHLO emitter over the tape is behind the `xla` Cargo feature, \
2911         which this build does not have: rebuild with `--features xla` and XLA_EXTENSION_DIR \
2912         pointing at an unpacked xla_extension release (scripts/fetch-xla-extension.sh)"
2913            .to_string(),
2914    )
2915}
2916
2917/// Emit and compile the right-hand side for [`Compiler::Xla`], once, on the
2918/// backend the build just produced.
2919///
2920/// A no-op for every other compiler, and for a backend with no right-hand side
2921/// to emit: a state-free document is not integrated, so no compiler is chosen
2922/// for it and there is nothing to lower.
2923///
2924/// The two failures are kept apart, because they say different things:
2925///
2926///   * the emitter REFUSED this model — a named
2927///     [`CompileError::CompilerRefusedRule`], the same failure a rule the tape
2928///     could not lower raises, so a caller (and the compiler-agreement tier)
2929///     reads one shape whichever half declined;
2930///   * the XLA runtime broke — [`SimulateError::CompilerUnavailable`], which
2931///     is a fact about this process and says nothing about the document.
2932fn install_xla_program(
2933    backend: &Backend,
2934    model_name: Option<&str>,
2935    compiler: Compiler,
2936) -> Result<(), SimulateError> {
2937    // `model_name` is read only on the `xla` build; without the feature the
2938    // whole body is the early return, and `compiler` is already known to be
2939    // something this build provides.
2940    let _ = (backend, model_name);
2941    if compiler != Compiler::Xla {
2942        return Ok(());
2943    }
2944    #[cfg(feature = "xla")]
2945    {
2946        use crate::xla_runtime::CompileRhsError;
2947        let Backend::Array(compiled) = backend else {
2948            return Ok(());
2949        };
2950        if !compiled.has_differential_equations() {
2951            return Ok(());
2952        }
2953        match compiled.install_xla_rhs() {
2954            Ok(()) => Ok(()),
2955            Err(CompileRhsError::Refused(e)) => {
2956                Err(SimulateError::Compile(
2957                    crate::compile_error::CompileError::CompilerRefusedRule {
2958                        compiler: Compiler::Xla.as_str(),
2959                        // The emitter refuses per INSTRUCTION, and an instruction
2960                        // belongs to a rule whose kind it does not carry, so the
2961                        // kind is the neutral one rather than a guess between
2962                        // "observed" and "state derivative".
2963                        kind: "rule",
2964                        rule: qualify(model_name.unwrap_or(""), &e.rule),
2965                        // The emitted program computes every cadence section on
2966                        // every call (`xla_emit`'s module docs), so a refusal
2967                        // anywhere in it costs on the continuous schedule.
2968                        tier: "continuous",
2969                        reason: e.reason,
2970                    },
2971                ))
2972            }
2973            Err(CompileRhsError::Runtime(details)) => Err(SimulateError::CompilerUnavailable {
2974                compiler: Compiler::Xla.as_str(),
2975                details,
2976            }),
2977        }
2978    }
2979    #[cfg(not(feature = "xla"))]
2980    {
2981        // Unreachable: `compile_backend` answered `compiler_unavailable` for
2982        // `xla` before any backend existed.
2983        Ok(())
2984    }
2985}
2986
2987fn compile_backend(
2988    file: Option<&EsmFile>,
2989    flat: Option<&FlattenedSystem>,
2990    model_name: Option<&str>,
2991    mode: Rhs,
2992    compiler: Compiler,
2993    runtime_array_parameters: bool,
2994) -> Result<Backend, SimulateError> {
2995    if let Some(details) = unavailable_reason(compiler) {
2996        return Err(SimulateError::CompilerUnavailable {
2997            compiler: compiler.as_str(),
2998            details,
2999        });
3000    }
3001    // An equation that defines a parameter (esm-spec §6.3.1) is refused under
3002    // every compiler and every `Rhs` mode: the build used to drop it, answering
3003    // with the parameter's default.
3004    refuse_parameter_definitions(file, flat)?;
3005    if mode == Rhs::Never {
3006        return Ok(Backend::Static(
3007            "the caller asked for Rhs::Never".to_string(),
3008        ));
3009    }
3010    // The array runtime's own mode, carried on the compiled artifact so the
3011    // driver reads WHICH compiler it is serving rather than re-deciding per
3012    // call (`crate::simulate_array::RuntimeMode`).
3013    let runtime_mode = match compiler {
3014        Compiler::Interpreter => crate::simulate_array::RuntimeMode::Interpreter,
3015        Compiler::Xla => crate::simulate_array::RuntimeMode::Xla,
3016        _ => crate::simulate_array::RuntimeMode::Native,
3017    };
3018    if let Some(flat) = flat {
3019        // Same rule the typed-document branch applies below, on the input that
3020        // states it most directly: a flattened system with no state variables
3021        // has nothing to integrate, so `solve` reports `NotDynamic` rather than
3022        // failing inside the solver, and `observed_field` sees the static
3023        // fields.
3024        if mode == Rhs::Auto && flat.state_variables.is_empty() {
3025            return Ok(Backend::Static(
3026                "the flattened system declares no state variables".to_string(),
3027            ));
3028        }
3029        let mut compiled = crate::simulate_array::ArrayCompiled::from_flattened_with(
3030            flat,
3031            runtime_array_parameters,
3032        )?;
3033        compiled.runtime_mode = runtime_mode;
3034        return Ok(Backend::Array(Rc::new(compiled)));
3035    }
3036    let Some(file) = file else {
3037        if mode == Rhs::Always {
3038            return Err(SimulateError::Compile(
3039                crate::compile_error::CompileError::build_err("no typed document to compile"),
3040            ));
3041        }
3042        return Ok(Backend::Static(
3043            "the prepared document has no typed form to compile".to_string(),
3044        ));
3045    };
3046
3047    // A document with no component at all — a template or coupling library, a
3048    // `data_sources` registry — has nothing to build. Julia's and Python's
3049    // `flatten` refuse it the same way, rather than hand back an empty Problem.
3050    let n_components = file.models.as_ref().map_or(0, |m| m.len())
3051        + file.reaction_systems.as_ref().map_or(0, |r| r.len());
3052    if n_components == 0 {
3053        return Err(SimulateError::Compile(
3054            crate::compile_error::CompileError::build_err(format!(
3055                "nothing to flatten: '{}' declares no `models` and no `reaction_systems`. A \
3056                 file carrying only `expression_templates` is a template LIBRARY (esm-spec \
3057                 §9.7); import it from a document that declares components rather than \
3058                 building it directly",
3059                file.metadata.name.as_deref().unwrap_or("")
3060            )),
3061        ));
3062    }
3063
3064    refuse_structurally_unreachable(file)?;
3065    refuse_unregistered_callback_reads(file)?;
3066    refuse_reference_integrity_errors(file)?;
3067
3068    if mode == Rhs::Auto && !has_differential_equations(file, model_name) {
3069        // Nothing to integrate is not nothing to run: an event, an implicit
3070        // equation or a noise term still changes what the document means, and
3071        // the static evaluation below honours none of them. The array route
3072        // refuses each by name, and so does this one.
3073        refuse_static_unsupported_construct(file, model_name)?;
3074        return Ok(Backend::Static(
3075            "the document declares no differential equations".to_string(),
3076        ));
3077    }
3078
3079    // §2.5.10: "A binding MUST NOT switch strategy inside `native` on document
3080    // content: a scalar document and a gridded one are built by the same
3081    // machinery, so that what ran is a property of the name and not of the
3082    // input." So every document, scalar or gridded, builds the array runtime.
3083    let mut compiled = crate::simulate::build_array_compiled(file, runtime_array_parameters)?;
3084    compiled.runtime_mode = runtime_mode;
3085    Ok(Backend::Array(Rc::new(compiled)))
3086}
3087
3088/// Refuse an equation whose left-hand side names a parameter
3089/// (`equation_defines_parameter`, esm-spec §6.3.1), in the typed document or,
3090/// for a flattened input, in the flattened equations. `validate` reports the
3091/// same finding; Julia and Python refuse it at their front doors too.
3092fn refuse_parameter_definitions(
3093    file: Option<&EsmFile>,
3094    flat: Option<&FlattenedSystem>,
3095) -> Result<(), SimulateError> {
3096    let refuse = |path: &str, message: &str| {
3097        SimulateError::Compile(crate::compile_error::CompileError::build_err(format!(
3098            "[{}] {path}: {message} (esm-spec §6.3.1)",
3099            crate::diagnostic::codes::EQUATION_DEFINES_PARAMETER
3100        )))
3101    };
3102    if let Some(file) = file
3103        && let Some(e) = crate::structural::parameter_definition_errors(file).first()
3104    {
3105        return Err(refuse(&e.path, &e.message));
3106    }
3107    if let Some(flat) = flat {
3108        for (k, eq) in flat.equations.iter().enumerate() {
3109            let mut lhs = &eq.lhs;
3110            let name = loop {
3111                match lhs {
3112                    crate::types::Expr::Variable(v) => break Some(v.as_str()),
3113                    crate::types::Expr::Operator(n) if n.op == "faq" || n.op == "aggregate" => {
3114                        match n.expr.as_deref() {
3115                            Some(x) => lhs = x,
3116                            None => break None,
3117                        }
3118                    }
3119                    crate::types::Expr::Operator(n)
3120                        if n.op == "index"
3121                            || (n.op == "D" && matches!(n.wrt.as_deref(), None | Some("t"))) =>
3122                    {
3123                        match n.args.first() {
3124                            Some(x) => lhs = x,
3125                            None => break None,
3126                        }
3127                    }
3128                    _ => break None,
3129                }
3130            };
3131            if let Some(name) = name
3132                && flat.parameters.contains_key(name)
3133            {
3134                return Err(refuse(
3135                    &format!("/equations/{k}/lhs"),
3136                    &format!(
3137                        "Equation {k} defines '{name}', which is a parameter; an equation \
3138                         defines unknowns only"
3139                    ),
3140                ));
3141            }
3142        }
3143    }
3144    Ok(())
3145}
3146
3147/// Refuse two declarations the document can hold but no build may run, each a
3148/// structural error `validate` also reports, which built anyway would be
3149/// finite, plausible and wrong:
3150///
3151/// * a declaration spelled with the independent variable or `_var`
3152///   (`reserved_variable_name`, esm-spec §4.9.1.1): every reader of the name
3153///   gets the implicit symbol, never the declared quantity;
3154/// * an `ic` equation among a reaction system's `constraint_equations`
3155///   (`ic_in_reaction_system`, esm-spec §11.4.1), which no evaluator applies.
3156///
3157/// Julia and Python refuse both at their front doors too.
3158fn refuse_structurally_unreachable(file: &EsmFile) -> Result<(), SimulateError> {
3159    let refuse = |code: &str, path: &str, message: &str, section: &str| {
3160        SimulateError::Compile(crate::compile_error::CompileError::build_err(format!(
3161            "[{code}] {path}: {message} (esm-spec {section})"
3162        )))
3163    };
3164    if let Some(e) = crate::structural::reserved_declaration_errors(file).first() {
3165        return Err(refuse(&e.code.to_string(), &e.path, &e.message, "§4.9.1.1"));
3166    }
3167    if let Some(systems) = &file.reaction_systems {
3168        for (rs_name, rs) in systems {
3169            for (i, eq) in rs.constraint_equations.iter().flatten().enumerate() {
3170                if matches!(&eq.lhs, crate::types::Expr::Operator(n) if n.op == "ic") {
3171                    return Err(refuse(
3172                        crate::diagnostic::codes::IC_IN_REACTION_SYSTEM,
3173                        &format!("/reaction_systems/{rs_name}/constraint_equations/{i}"),
3174                        "ic equation not allowed in a reaction system; a reaction system \
3175                         hosts no ic equations (a species' initial value is its \
3176                         `species.default`, or a scoped-reference ic equation in a model)",
3177                        "§11.4.1",
3178                    ));
3179                }
3180            }
3181        }
3182    }
3183    Ok(())
3184}
3185
3186/// The structural-validation codes a build refuses on (esm-libraries-spec
3187/// §2.5.2). They are the reference-integrity findings: a name, reference or data
3188/// source the document uses and does not declare. A build that went ahead would
3189/// read a value the document does not describe. Equation-count and unit findings
3190/// are not here: they stay `validate`'s to report.
3191const BUILD_REFUSED_VALIDATION_CODES: &[&str] = &[
3192    "undefined_variable",
3193    "undefined_parameter",
3194    "undefined_species",
3195    "undefined_system",
3196    "undefined_index_set",
3197    "unresolved_scoped_ref",
3198    "event_var_undeclared",
3199    "data_source_undefined",
3200    "missing_required_field",
3201];
3202
3203/// Refuse the first [`BUILD_REFUSED_VALIDATION_CODES`] finding `validate`
3204/// reports for `file`, with the validator's code, pointer and message.
3205fn refuse_reference_integrity_errors(file: &EsmFile) -> Result<(), SimulateError> {
3206    let result =
3207        crate::validate::validate_scoped(file, crate::validate::ValidationScope::References);
3208    if let Some(e) = result.structural_errors.iter().find(|e| {
3209        // An inline test's references are the test runner's to report
3210        // (esm-spec §6.6); the build does not evaluate them.
3211        BUILD_REFUSED_VALIDATION_CODES.contains(&e.code.to_string().as_str())
3212            && !e.path.contains("/tests/")
3213    }) {
3214        let named = declared_name_at(&e.path)
3215            .map(|n| format!(" (variable '{n}')"))
3216            .unwrap_or_default();
3217        return Err(SimulateError::Compile(
3218            crate::compile_error::CompileError::build_err(format!(
3219                "[{}] {}: {}{named} (esm-libraries-spec §2.5.2)",
3220                e.code, e.path, e.message
3221            )),
3222        ));
3223    }
3224    Ok(())
3225}
3226
3227/// The full name of the variable a validator pointer sits under —
3228/// `/models/A/subsystems/B/variables/v/…` is `A.B.v` — so a refusal names the
3229/// variable the way a caller addresses it (esm-spec §6.6.2).
3230fn declared_name_at(pointer: &str) -> Option<String> {
3231    let segs: Vec<&str> = pointer.trim_start_matches('/').split('/').collect();
3232    let mut parts = Vec::new();
3233    let mut i = 0;
3234    while i + 1 < segs.len() {
3235        match segs[i] {
3236            "models" | "reaction_systems" | "subsystems" => parts.push(segs[i + 1]),
3237            "variables" | "species" | "parameters" => {
3238                parts.push(segs[i + 1]);
3239                return Some(parts.join("."));
3240            }
3241            _ => return None,
3242        }
3243        i += 2;
3244    }
3245    None
3246}
3247
3248/// Refuse, with `callback_unregistered` (esm-spec §9.6.6), an equation that
3249/// reads a variable a `callback` coupling injects
3250/// (`config.callback_variables[].name`). Only a registered callback supplies
3251/// that value, and nothing registers one at construction, so a build would read
3252/// a placeholder the document does not describe. The interpreter would read
3253/// an unset forcing slot at its first call instead.
3254fn refuse_unregistered_callback_reads(file: &EsmFile) -> Result<(), SimulateError> {
3255    let mut injected: BTreeMap<String, String> = BTreeMap::new();
3256    for entry in file.coupling.iter().flatten() {
3257        let crate::CouplingEntry::Callback {
3258            callback_id,
3259            config,
3260            ..
3261        } = entry
3262        else {
3263            continue;
3264        };
3265        let names = config
3266            .as_ref()
3267            .and_then(|c| c.get("callback_variables"))
3268            .and_then(|v| v.as_array())
3269            .into_iter()
3270            .flatten()
3271            .filter_map(|cv| cv.get("name").and_then(|n| n.as_str()));
3272        for name in names {
3273            injected.insert(name.to_string(), callback_id.clone());
3274        }
3275    }
3276    if injected.is_empty() {
3277        return Ok(());
3278    }
3279    let Some(models) = file.models.as_ref() else {
3280        return Ok(());
3281    };
3282    let mut names: Vec<&String> = models.keys().collect();
3283    names.sort();
3284    for name in names {
3285        callback_reads_in(&models[name], name, &injected)?;
3286    }
3287    Ok(())
3288}
3289
3290/// [`refuse_unregistered_callback_reads`] over one model and its inline
3291/// subsystems. A name the model declares itself is not the injected one.
3292fn callback_reads_in(
3293    model: &crate::types::Model,
3294    path: &str,
3295    injected: &BTreeMap<String, String>,
3296) -> Result<(), SimulateError> {
3297    for eq in &model.equations {
3298        for side in [&eq.lhs, &eq.rhs] {
3299            let mut vars: Vec<String> = crate::free_variables(side).into_iter().collect();
3300            vars.sort();
3301            for v in vars {
3302                if model.variables.contains_key(&v) {
3303                    continue;
3304                }
3305                if let Some(id) = injected.get(&v) {
3306                    return Err(SimulateError::Compile(
3307                        crate::compile_error::CompileError::build_err(format!(
3308                            "[{}] '{path}' reads '{v}', which the `callback` coupling '{id}' \
3309                             supplies, but no callback is registered to supply it at \
3310                             construction; refusing the build rather than reading a value \
3311                             the document does not give (esm-spec §9.6.6)",
3312                            crate::diagnostic::codes::CALLBACK_UNREGISTERED
3313                        )),
3314                    ));
3315                }
3316            }
3317        }
3318    }
3319    if let Some(subs) = model.subsystems.as_ref() {
3320        let mut names: Vec<&String> = subs.keys().collect();
3321        names.sort();
3322        for name in names {
3323            if let Ok((sub, _)) = crate::simulate_array::parse_subsystem_model(name, &subs[name]) {
3324                callback_reads_in(&sub, &format!("{path}.{name}"), injected)?;
3325            }
3326        }
3327    }
3328    Ok(())
3329}
3330
3331/// Refuse, with `unsupported_construct` (esm-spec §9.6.6), a document with
3332/// nothing to integrate that still carries an event, an implicit equation or a
3333/// Wiener-noise parameter — in the named model, or in any model when none is
3334/// named, subsystems included. The array route refuses the same three while
3335/// compiling; a state-free document never reaches it.
3336fn refuse_static_unsupported_construct(
3337    file: &EsmFile,
3338    model_name: Option<&str>,
3339) -> Result<(), SimulateError> {
3340    if model_name.is_none()
3341        && let Some((construct, event)) = crate::compile_error::first_event_in_file(file)
3342    {
3343        return Err(crate::compile_error::event_refusal(
3344            construct,
3345            crate::compile_error::ARRAY_EVALUATOR,
3346            event.as_deref(),
3347        )
3348        .into());
3349    }
3350    let Some(models) = file.models.as_ref() else {
3351        return Ok(());
3352    };
3353    for (_, model) in models
3354        .iter()
3355        .filter(|(name, _)| model_name.is_none_or(|want| want == name.as_str()))
3356    {
3357        if let Some(refusal) = static_unsupported_construct(model) {
3358            return Err(refusal.into());
3359        }
3360    }
3361    Ok(())
3362}
3363
3364/// The first construct of `model` or its inline subsystems that
3365/// [`refuse_static_unsupported_construct`] refuses, as its refusal.
3366fn static_unsupported_construct(
3367    model: &crate::types::Model,
3368) -> Option<crate::compile_error::CompileError> {
3369    use crate::compile_error::ARRAY_EVALUATOR;
3370    if let Some((construct, event)) = crate::compile_error::first_event(model) {
3371        return Some(crate::compile_error::event_refusal(
3372            construct,
3373            ARRAY_EVALUATOR,
3374            event.as_deref(),
3375        ));
3376    }
3377    if let Some(eq) = crate::compile_error::first_implicit_equation(&model.equations) {
3378        return Some(crate::compile_error::implicit_equation_refusal(
3379            ARRAY_EVALUATOR,
3380            eq,
3381        ));
3382    }
3383    let class = crate::classification::Classification::of(model);
3384    if let Some(name) = class.brownian_parameters.first() {
3385        return Some(crate::compile_error::CompileError::UnsupportedConstruct {
3386            construct: crate::compile_error::WIENER_NOISE,
3387            evaluator: ARRAY_EVALUATOR,
3388            detail: format!("parameter '{name}'"),
3389        });
3390    }
3391    let subs = model.subsystems.as_ref()?;
3392    let mut names: Vec<&String> = subs.keys().collect();
3393    names.sort();
3394    names.into_iter().find_map(|name| {
3395        crate::simulate_array::parse_subsystem_model(name, &subs[name])
3396            .ok()
3397            .and_then(|(sub, _)| static_unsupported_construct(&sub))
3398    })
3399}
3400
3401/// Whether the document (or the named model within it) declares at least one
3402/// differential equation, i.e. whether there is anything to integrate.
3403///
3404/// A document with none is a *dispatched static evaluation*: it still gets a
3405/// EsmProblem, and [`observed_field`] still reads its build-time products, but
3406/// [`solve`] on it raises [`SimulateError::NotDynamic`] rather than handing
3407/// back an empty trajectory.
3408pub(crate) fn has_differential_equations(file: &EsmFile, model_name: Option<&str>) -> bool {
3409    // A REACTION SYSTEM is differential, and reading only `models` said it was
3410    // not. Reactions lower to `D(species, t) = …` during flattening, so a
3411    // document whose whole content is a `reaction_systems` block has no models
3412    // AT ALL until that happens — and this function runs before it. Every pure
3413    // chemistry document in the wild is that shape (`pollu`, `superfast`,
3414    // `geoschem_fullchem`), and `Rhs::Auto` was calling each of them static
3415    // and then refusing to solve twenty-five differential equations with
3416    // `NotDynamic { "the document declares no differential equations" }`.
3417    //
3418    // A system with no reactions is genuinely not differential — it lowers to
3419    // nothing — so the check is for reactions rather than for the block.
3420    if let Some(systems) = file.reaction_systems.as_ref()
3421        && systems
3422            .iter()
3423            .filter(|(name, _)| model_name.is_none_or(|want| want == name.as_str()))
3424            .any(|(_, rs)| !rs.reactions.is_empty())
3425    {
3426        return true;
3427    }
3428    let Some(models) = file.models.as_ref() else {
3429        return false;
3430    };
3431    models
3432        .iter()
3433        .filter(|(name, _)| model_name.is_none_or(|want| want == name.as_str()))
3434        .any(|(_, m)| crate::simulate_array::model_tree_any(m, &model_has_derivative))
3435}
3436
3437/// Whether any equation of `model` has a TIME derivative in its LHS — the
3438/// esm-spec §6.3.1 derivation of `system_kind`, which is what "declares no
3439/// differential equations" means (API_SPEC §5.8).
3440///
3441/// A bare top-level `D` is not the only spelling. The arrayed form
3442/// `faq{expr: D(index(u, i))}` is how every array-op PDE is written and what
3443/// the pointwise lift produces, and `index(D(u), i)` also names a derivative.
3444/// Each of them makes the document dynamic (issue #476): the array runtime
3445/// then either compiles the equation or refuses it by name, where
3446/// [`Backend::Static`] would have `solve` answer `NotDynamic` for a document
3447/// that plainly integrates.
3448fn model_has_derivative(model: &crate::types::Model) -> bool {
3449    model
3450        .equations
3451        .iter()
3452        .any(|eq| crate::classification::has_time_derivative(&eq.lhs))
3453}
3454
3455/// Whether `var` is a parameter with a non-empty declared `shape`.
3456fn is_shaped_parameter(var: &crate::types::ModelVariable) -> bool {
3457    var.var_type == crate::types::VariableType::Parameter
3458        && var.shape.as_ref().is_some_and(|s| !s.is_empty())
3459}
3460
3461/// Whether every `update` rule of `var` takes its value from outside the model
3462/// — a data source (`from`) or a registered handler — which is what makes the
3463/// array runtime serve it from the forcing buffer.
3464fn is_externally_fed(var: &crate::types::ModelVariable) -> bool {
3465    var.update.as_ref().is_some_and(|spec| {
3466        spec.rules().iter().all(|rule| {
3467            rule.value()
3468                .is_some_and(|v| v.from.is_some() || v.handler.is_some())
3469        })
3470    })
3471}
3472
3473/// Whether `name` — a compiled or flattened name — or its model-local tail is a
3474/// key of `keys`.
3475fn names_key<V>(keys: &HashMap<String, V>, name: &str) -> bool {
3476    keys.contains_key(name)
3477        || name
3478            .split_once('.')
3479            .is_some_and(|(_, tail)| keys.contains_key(tail))
3480}
3481
3482/// The declared variable a compiled or flattened `name` refers to, for a
3483/// diagnostic: `Model.var` in a top-level model, or a bare name one model
3484/// declares.
3485fn declared_variable<'f>(
3486    file: Option<&'f EsmFile>,
3487    name: &str,
3488) -> Option<&'f crate::types::ModelVariable> {
3489    let models = file?.models.as_ref()?;
3490    if let Some((model, var)) = name.split_once('.')
3491        && let Some(v) = models.get(model).and_then(|m| m.variables.get(var))
3492    {
3493        return Some(v);
3494    }
3495    models.values().find_map(|m| m.variables.get(name))
3496}
3497
3498/// The `E_TREEWALK_MISSING_DATA` refusal for shaped parameter `name`, naming
3499/// what the document says feeds it and how a caller supplies it.
3500fn missing_data_error(name: &str, var: Option<&crate::types::ModelVariable>) -> SimulateError {
3501    let shaped = var.is_some_and(is_shaped_parameter);
3502    let shape = var
3503        .and_then(|v| v.shape.as_ref())
3504        .filter(|s| !s.is_empty())
3505        .map(|s| format!(" (shape [{}])", s.join(", ")))
3506        .unwrap_or_default();
3507    let mut feeds: Vec<String> = Vec::new();
3508    for rule in var
3509        .and_then(|v| v.update.as_ref())
3510        .map(|u| u.rules())
3511        .unwrap_or_default()
3512    {
3513        let Some(value) = rule.value() else { continue };
3514        if let Some(from) = &value.from {
3515            feeds.push(format!(
3516                "the data source '{}' (file_variable '{}')",
3517                rule.data_source().unwrap_or("?"),
3518                from.file_variable
3519            ));
3520        } else if let Some(h) = &value.handler {
3521            feeds.push(format!(
3522                "the registered handler '{}' (update kind '{}'), which writes it only when it fires",
3523                h.handler_id,
3524                rule.kind()
3525            ));
3526        }
3527    }
3528    let (fed, how) = if feeds.is_empty() {
3529        (
3530            "Nothing in the document gives it a value.".to_string(),
3531            if shaped {
3532                format!(
3533                    "pass its array in `ProblemOptions::const_arrays` under \"{name}\", or declare \
3534                     a `default` on it"
3535                )
3536            } else {
3537                format!(
3538                    "pass it in `ProblemOptions::p` under \"{name}\", or declare a `default` on it"
3539                )
3540            },
3541        )
3542    } else {
3543        (
3544            format!(
3545                "The document feeds it from {}, and no data for it was supplied at construction.",
3546                feeds.join(" and ")
3547            ),
3548            format!(
3549                "pass a provider for it in `ProblemOptions::providers` under \"{name}\", or its \
3550                 array in `ProblemOptions::const_arrays` (a constant snapshot), or declare a \
3551                 `default` on it"
3552            ),
3553        )
3554    };
3555    SimulateError::Compile(crate::compile_error::CompileError::MissingData {
3556        parameter: name.to_string(),
3557        detail: format!(
3558            "no data supplied for the {kind}parameter '{name}'{shape}, which declares no \
3559             `default`. {fed} A parameter with neither a default nor a supplied value is an error \
3560             when a problem is built (esm-spec §10.10). To supply it, {how}.",
3561            kind = if shaped { "shaped " } else { "" }
3562        ),
3563    })
3564}
3565
3566/// The `E_TREEWALK_MISSING_INITIAL_VALUE` refusal: a state slot no default,
3567/// initial condition, `ic` equation or caller `u0` gives a starting value.
3568#[cfg(feature = "solve")]
3569fn refuse_missing_initial_values(
3570    c: &ArrayCompiled,
3571    u0: &HashMap<String, f64>,
3572) -> Result<(), SimulateError> {
3573    let unset = c.unset_initial_slots(u0);
3574    let Some(first) = unset.first() else {
3575        return Ok(());
3576    };
3577    let mut shown: Vec<String> = unset.iter().take(5).map(|n| format!("'{n}'")).collect();
3578    if unset.len() > 5 {
3579        shown.push("…".to_string());
3580    }
3581    Err(SimulateError::Compile(
3582        crate::compile_error::CompileError::MissingInitialValue {
3583            slot: first.clone(),
3584            detail: format!(
3585                "no starting value for {} unknown(s) ({}): the unknown declares no `default`, \
3586                 and no initial condition, `ic` equation or caller `u0` sets it. An unknown with \
3587                 no starting value is an error when a problem is built (esm-spec §11.4). To \
3588                 supply it, pass it in `ProblemOptions::u0` under \"{first}\", or declare a \
3589                 `default` on the unknown.",
3590                unset.len(),
3591                shown.join(", ")
3592            ),
3593        },
3594    ))
3595}
3596
3597/// The nested [`crate::types::InlineValue`] an array denotes, row-major — the
3598/// spelling a shaped parameter's inline `default` takes (esm-spec §6.3).
3599#[cfg(not(target_arch = "wasm32"))]
3600fn inline_value_of(a: &ArrayD<f64>) -> crate::types::InlineValue {
3601    use crate::types::InlineValue;
3602    fn nest(shape: &[usize], values: &[f64]) -> InlineValue {
3603        let Some((&axis, rest)) = shape.split_first() else {
3604            return InlineValue::Scalar(values[0]);
3605        };
3606        let stride = rest.iter().product::<usize>();
3607        InlineValue::Array(
3608            (0..axis)
3609                .map(|i| nest(rest, &values[i * stride..(i + 1) * stride]))
3610                .collect(),
3611        )
3612    }
3613    let values: Vec<f64> = a.iter().copied().collect();
3614    if a.ndim() == 0 {
3615        return InlineValue::Scalar(values[0]);
3616    }
3617    nest(a.shape(), &values)
3618}
3619
3620/// The data a caller supplied for a document's shaped parameters, captured at
3621/// the top of [`esm_problem`] because the build pipeline consumes
3622/// `const_arrays` and `build_providers`.
3623#[derive(Default)]
3624struct SuppliedData {
3625    /// Each `const_arrays` entry whose key names a shaped parameter of the
3626    /// document, as `Model.param` or as the bare `param`.
3627    #[cfg(not(target_arch = "wasm32"))]
3628    arrays: HashMap<String, ArrayD<f64>>,
3629    /// The keys of the run-time providers and of the build-time providers.
3630    provider_keys: HashMap<String, ()>,
3631    /// The captured arrays for parameters the document feeds from outside
3632    /// (a data source or a handler) and no provider serves, keyed
3633    /// `Model.param`: [`Self::bind_arrays`] routes them to the forcing buffer,
3634    /// the channel such a parameter is read through, as a constant snapshot.
3635    #[cfg(not(target_arch = "wasm32"))]
3636    forcing_arrays: std::cell::RefCell<HashMap<String, ArrayD<f64>>>,
3637}
3638
3639impl SuppliedData {
3640    /// Capture what `opts` supplies. The shaped-parameter arrays are MOVED out
3641    /// of `opts.const_arrays` when they are the only reason the build pipeline
3642    /// would run on a document of several models with none selected, which the
3643    /// pipeline refuses: they need nothing from it there (stage 3d binds each
3644    /// onto its parameter). Otherwise the pipeline keeps them — it evaluates a
3645    /// state-free document's observed graph with them — and they are copied
3646    /// here.
3647    fn capture(opts: &mut ProblemOptions, raw: Option<&JsonValue>, file: Option<&EsmFile>) -> Self {
3648        #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
3649        let mut out = SuppliedData::default();
3650        #[cfg(not(target_arch = "wasm32"))]
3651        {
3652            out.provider_keys
3653                .extend(opts.providers.keys().map(|k| (k.clone(), ())));
3654            out.provider_keys
3655                .extend(opts.build_providers.iter().map(|(k, _)| (k.clone(), ())));
3656            if !opts.const_arrays.is_empty() {
3657                let shaped = shaped_parameter_keys(raw, file);
3658                let several_models =
3659                    opts.model_name.is_none() && model_count(raw, file).is_some_and(|n| n > 1);
3660                let only_trigger = (several_models || raw.is_some_and(declares_derivative))
3661                    && !opts.build_pipeline
3662                    && !opts.pushdown_rewrite
3663                    && opts.build_providers.is_empty()
3664                    && opts.const_arrays.keys().all(|k| shaped.contains(k));
3665                if only_trigger {
3666                    out.arrays = std::mem::take(&mut opts.const_arrays);
3667                } else {
3668                    for (k, a) in &opts.const_arrays {
3669                        if shaped.contains(k) {
3670                            out.arrays.insert(k.clone(), a.clone());
3671                        }
3672                    }
3673                }
3674            }
3675        }
3676        #[cfg(target_arch = "wasm32")]
3677        let _ = (opts, raw, file);
3678        out
3679    }
3680
3681    /// Bind each captured array to the shaped parameter it names — keyed
3682    /// `Model.param`, or by a bare name only one model's shaped parameter
3683    /// carries. A scalar override in `p` outranks it, and so does a provider
3684    /// for a parameter the document feeds from outside. Otherwise it becomes
3685    /// the parameter's inline `default`, or, for a parameter fed from outside,
3686    /// a constant snapshot in the forcing buffer (served by
3687    /// [`Self::refuse_missing_array`]).
3688    #[cfg(not(target_arch = "wasm32"))]
3689    fn bind_arrays(&self, file: &mut EsmFile, p: &HashMap<String, f64>, selected: Option<&str>) {
3690        if self.arrays.is_empty() {
3691            return;
3692        }
3693        let Some(models) = file.models.as_mut() else {
3694            return;
3695        };
3696        // With one model selected, a bare key names that model's parameter;
3697        // the other models are not built, so they cannot make it ambiguous.
3698        let in_scope = |mname: &str| selected.is_none_or(|s| s == mname);
3699        let mut owners: HashMap<String, usize> = HashMap::new();
3700        for (_, m) in models.iter().filter(|(n, _)| in_scope(n)) {
3701            for (v, var) in &m.variables {
3702                if is_shaped_parameter(var) {
3703                    *owners.entry(v.clone()).or_default() += 1;
3704                }
3705            }
3706        }
3707        for (mname, m) in models.iter_mut() {
3708            if !in_scope(mname) {
3709                continue;
3710            }
3711            for (v, var) in m.variables.iter_mut() {
3712                if !is_shaped_parameter(var) {
3713                    continue;
3714                }
3715                let qualified = format!("{mname}.{v}");
3716                let arr = self.arrays.get(&qualified).or_else(|| {
3717                    if owners.get(v) == Some(&1) {
3718                        self.arrays.get(v)
3719                    } else {
3720                        None
3721                    }
3722                });
3723                let Some(arr) = arr else { continue };
3724                if p.contains_key(&qualified) || p.contains_key(v) {
3725                    continue;
3726                }
3727                if is_externally_fed(var) {
3728                    if !names_key(&self.provider_keys, &qualified) {
3729                        self.forcing_arrays
3730                            .borrow_mut()
3731                            .insert(qualified, arr.clone());
3732                    }
3733                    continue;
3734                }
3735                var.default = Some(inline_value_of(arr));
3736            }
3737        }
3738    }
3739
3740    /// The missing-data gate for an array model: serve a forcing name no
3741    /// provider supplies from the caller's array, else from its declared
3742    /// `default`, and refuse one with neither, or a shaped parameter the build
3743    /// left with no value at all.
3744    fn refuse_missing_array(
3745        &self,
3746        c: &ArrayCompiled,
3747        file: Option<&EsmFile>,
3748        p: &HashMap<String, f64>,
3749    ) -> Result<(), SimulateError> {
3750        // The parameter as the caller addresses it: by full name (esm-spec
3751        // §6.6.2), which a single-model build compiles bare under its namespace.
3752        let full = |name: &str| match c.namespace() {
3753            Some(ns) if !name.starts_with(&format!("{ns}.")) => format!("{ns}.{name}"),
3754            _ => name.to_string(),
3755        };
3756        let forcing = c.forcing_buffer();
3757        for name in c.forcing_names() {
3758            if forcing.borrow().contains_key(name) || names_key(&self.provider_keys, name) {
3759                continue;
3760            }
3761            #[cfg(not(target_arch = "wasm32"))]
3762            if let Some(arr) = self.forcing_array(name) {
3763                forcing.borrow_mut().insert(name.clone(), arr);
3764                continue;
3765            }
3766            // No data is supplied: a declared default is the value (esm-spec
3767            // §6.3), and with none the parameter is missing data (§10.10).
3768            let mut value = match c.forcing_default(name) {
3769                None => {
3770                    return Err(missing_data_error(
3771                        &full(name),
3772                        declared_variable(file, name),
3773                    ));
3774                }
3775                // A default over a shape that does not resolve yet (an
3776                // unmaterialized derived set) has no field to fill here.
3777                Some(None) => continue,
3778                Some(Some(value)) => value,
3779            };
3780            let prec = precision::of_variable(name);
3781            if prec.is_f32() {
3782                value.mapv_inplace(|x| prec.round(x));
3783            }
3784            forcing.borrow_mut().insert(name.clone(), value);
3785        }
3786        for name in c.unvalued_shaped_params() {
3787            if !names_key(p, name) {
3788                return Err(missing_data_error(
3789                    &full(name),
3790                    declared_variable(file, name),
3791                ));
3792            }
3793        }
3794        // A scalar parameter no default, caller `p` or `distribution` gives a
3795        // value (esm-spec §10.10).
3796        for name in c.unsupplied_params(p) {
3797            let var = declared_variable(file, &name);
3798            if c.unvalued_shaped_params().contains(&name)
3799                || var.is_some_and(|v| v.distribution.is_some())
3800            {
3801                continue;
3802            }
3803            return Err(missing_data_error(&full(&name), var));
3804        }
3805        Ok(())
3806    }
3807
3808    /// The captured snapshot for forcing name `name` — a compiled name, which
3809    /// is `Model.param` on a coupled build and the bare `param` on a
3810    /// single-model one.
3811    #[cfg(not(target_arch = "wasm32"))]
3812    fn forcing_array(&self, name: &str) -> Option<ArrayD<f64>> {
3813        let arrays = self.forcing_arrays.borrow();
3814        if let Some(a) = arrays.get(name) {
3815            return Some(a.clone());
3816        }
3817        let mut tails = arrays
3818            .iter()
3819            .filter(|(k, _)| k.split_once('.').is_some_and(|(_, tail)| tail == name));
3820        match (tails.next(), tails.next()) {
3821            (Some((_, a)), None) => Some(a.clone()),
3822            _ => None,
3823        }
3824    }
3825
3826    /// The missing-data gate for a document with nothing to integrate, read
3827    /// off its flattened parameters (coupling may bind a declared parameter to
3828    /// another component's variable, and then it needs no value of its own).
3829    /// Flattens only when some parameter declares no value.
3830    fn refuse_missing_static(
3831        &self,
3832        file: &EsmFile,
3833        p: &HashMap<String, f64>,
3834        selected: Option<&str>,
3835    ) -> Result<(), SimulateError> {
3836        let unvalued = |v: &crate::types::ModelVariable| {
3837            v.var_type == crate::types::VariableType::Parameter
3838                && v.default.is_none()
3839                && v.distribution.is_none()
3840        };
3841        let any = file
3842            .models
3843            .as_ref()
3844            .is_some_and(|ms| ms.values().any(|m| m.variables.values().any(unvalued)));
3845        if !any {
3846            return Ok(());
3847        }
3848        let Ok(flat) = crate::flatten::flatten(file) else {
3849            return Ok(());
3850        };
3851        for (name, var) in &flat.parameters {
3852            if !unvalued(var) || names_key(p, name) {
3853                continue;
3854            }
3855            // With one model selected, only its parameters are built.
3856            if selected.is_some_and(|m| !name.starts_with(&format!("{m}."))) {
3857                continue;
3858            }
3859            if is_externally_fed(var) && names_key(&self.provider_keys, name) {
3860                continue;
3861            }
3862            return Err(missing_data_error(name, Some(var)));
3863        }
3864        Ok(())
3865    }
3866}
3867
3868/// Set each 0-D parameter a value-invention node reads (inside a `skolem`,
3869/// `rank` or arg-witness node, or a `distinct` / keyed `faq`) to the caller's
3870/// `p` entry for it — keyed `Model.param` or bare — and return the `p` keys
3871/// used.
3872fn bind_value_invention_params(file: &mut EsmFile, p: &HashMap<String, f64>) -> Vec<String> {
3873    use crate::types::Expr;
3874    use std::collections::HashSet;
3875    fn collect(e: &Expr, inside: bool, out: &mut HashSet<String>) {
3876        match e {
3877            Expr::Variable(v) if inside => {
3878                out.insert(v.clone());
3879            }
3880            Expr::Operator(n) => {
3881                let vi = inside
3882                    || matches!(n.op.as_str(), "skolem" | "rank" | "argmin" | "argmax")
3883                    || n.distinct == Some(true)
3884                    || n.key.is_some();
3885                n.for_each_child(&mut |c| collect(c, vi, out));
3886            }
3887            _ => {}
3888        }
3889    }
3890    let mut used = Vec::new();
3891    if p.is_empty() {
3892        return used;
3893    }
3894    let Some(models) = file.models.as_mut() else {
3895        return used;
3896    };
3897    for (mname, m) in models.iter_mut() {
3898        let mut read: HashSet<String> = HashSet::new();
3899        for eq in &m.equations {
3900            collect(&eq.rhs, false, &mut read);
3901        }
3902        for (v, var) in m.variables.iter_mut() {
3903            if var.var_type != crate::types::VariableType::Parameter
3904                || var.shape.as_ref().is_some_and(|s| !s.is_empty())
3905                || !read.contains(v)
3906            {
3907                continue;
3908            }
3909            let qualified = format!("{mname}.{v}");
3910            let key = if p.contains_key(&qualified) {
3911                qualified
3912            } else if p.contains_key(v) {
3913                v.clone()
3914            } else {
3915                continue;
3916            };
3917            var.default = Some(crate::types::InlineValue::Scalar(p[&key]));
3918            used.push(key);
3919        }
3920    }
3921    used
3922}
3923
3924/// Bake every scalar parameter a join key column reads
3925/// ([`crate::join::join_key_parameters`]): a caller's `p` entry lands on its
3926/// `default`, which the build folds into the match set, and the parameter is
3927/// recorded — under both its model-qualified and its bare name, whichever a
3928/// later [`remake`] spells — so a substitution of it is refused. Baked whether
3929/// or not the caller passed a value: the declared default is a build-time
3930/// value just the same.
3931fn bind_join_key_params(file: &mut EsmFile, p: &HashMap<String, f64>) -> Vec<String> {
3932    let mut baked = Vec::new();
3933    let Some(models) = file.models.as_mut() else {
3934        return baked;
3935    };
3936    for (mname, m) in models.iter_mut() {
3937        for v in crate::join::join_key_parameters(m) {
3938            let qualified = format!("{mname}.{v}");
3939            if let Some(&x) = p.get(&qualified).or_else(|| p.get(&v))
3940                && let Some(var) = m.variables.get_mut(&v)
3941            {
3942                var.default = Some(crate::types::InlineValue::Scalar(x));
3943            }
3944            baked.push(qualified);
3945            baked.push(v);
3946        }
3947    }
3948    baked
3949}
3950
3951/// Whether any equation of the raw document has a time derivative on its
3952/// left-hand side, or the document holds a reaction system: a document with a
3953/// right-hand side to compile, whose caller arrays the compile binds itself.
3954#[cfg(not(target_arch = "wasm32"))]
3955fn declares_derivative(raw: &JsonValue) -> bool {
3956    fn has_d(v: &JsonValue) -> bool {
3957        match v {
3958            JsonValue::Object(o) => {
3959                o.get("op").and_then(JsonValue::as_str) == Some("D") || o.values().any(has_d)
3960            }
3961            JsonValue::Array(a) => a.iter().any(has_d),
3962            _ => false,
3963        }
3964    }
3965    if raw
3966        .get("reaction_systems")
3967        .and_then(JsonValue::as_object)
3968        .is_some_and(|r| !r.is_empty())
3969    {
3970        return true;
3971    }
3972    raw.get("models")
3973        .and_then(JsonValue::as_object)
3974        .is_some_and(|ms| {
3975            ms.values().any(|m| {
3976                m.get("equations")
3977                    .and_then(JsonValue::as_array)
3978                    .is_some_and(|eqs| eqs.iter().any(|e| e.get("lhs").is_some_and(has_d)))
3979            })
3980        })
3981}
3982
3983/// How many `models` the document declares, off the raw JSON when there is one.
3984#[cfg(not(target_arch = "wasm32"))]
3985fn model_count(raw: Option<&JsonValue>, file: Option<&EsmFile>) -> Option<usize> {
3986    match raw {
3987        Some(r) => r
3988            .get("models")
3989            .and_then(JsonValue::as_object)
3990            .map(|m| m.len()),
3991        None => file.and_then(|f| f.models.as_ref()).map(|m| m.len()),
3992    }
3993}
3994
3995/// Every name a caller's `const_arrays` key may use for a shaped parameter of
3996/// the document: `Model.param` and the bare `param`, read off the raw JSON when
3997/// there is one and the typed document otherwise.
3998#[cfg(not(target_arch = "wasm32"))]
3999fn shaped_parameter_keys(
4000    raw: Option<&JsonValue>,
4001    file: Option<&EsmFile>,
4002) -> std::collections::HashSet<String> {
4003    let mut out = std::collections::HashSet::new();
4004    if let Some(models) = raw
4005        .and_then(|v| v.get("models"))
4006        .and_then(JsonValue::as_object)
4007    {
4008        for (m, model) in models {
4009            let Some(vars) = model.get("variables").and_then(JsonValue::as_object) else {
4010                continue;
4011            };
4012            for (v, var) in vars {
4013                let shaped = var
4014                    .get("shape")
4015                    .and_then(JsonValue::as_array)
4016                    .is_some_and(|s| !s.is_empty());
4017                if shaped && var.get("type").and_then(JsonValue::as_str) == Some("parameter") {
4018                    out.insert(format!("{m}.{v}"));
4019                    out.insert(v.clone());
4020                }
4021            }
4022        }
4023    } else if let Some(models) = file.and_then(|f| f.models.as_ref()) {
4024        for (m, model) in models {
4025            for (v, var) in &model.variables {
4026                if is_shaped_parameter(var) {
4027                    out.insert(format!("{m}.{v}"));
4028                    out.insert(v.clone());
4029                }
4030            }
4031        }
4032    }
4033    out
4034}
4035
4036#[cfg(not(target_arch = "wasm32"))]
4037#[allow(clippy::type_complexity)]
4038fn bind_providers(
4039    backend: &Backend,
4040    opts: &mut ProblemOptions,
4041    tspan: (f64, f64),
4042) -> Result<
4043    (
4044        Option<std::cell::RefCell<crate::provider::RefreshExecutor>>,
4045        std::collections::HashSet<String>,
4046        Vec<f64>,
4047    ),
4048    SimulateError,
4049> {
4050    if opts.providers.is_empty() {
4051        return Ok((None, Default::default(), Vec::new()));
4052    }
4053    let Backend::Array(compiled) = backend else {
4054        // Providers feed loader fields, which only exist on the array/spatial
4055        // runtime; a pure-scalar or static document has nowhere to put them.
4056        let name = opts.providers.keys().next().cloned().unwrap_or_default();
4057        return Err(SimulateError::ProviderError {
4058            name,
4059            details: "providers require an array/spatial model (this document has none)"
4060                .to_string(),
4061        });
4062    };
4063
4064    // One RefreshExecutor, classifying each provider by its `refresh_times()`.
4065    // CONST forcings are materialized ONCE, HERE — at construction, which is
4066    // where §2.5.2 puts the gated fetch — and never again.
4067    let providers = std::mem::take(&mut opts.providers);
4068    let mut exec = crate::provider::RefreshExecutor::from_providers(providers);
4069    let forcing = compiled.forcing_buffer();
4070    exec.materialize_const(&forcing)
4071        .map_err(|e| SimulateError::ProviderError {
4072            name: "<const-loader>".into(),
4073            details: e.to_string(),
4074        })?;
4075
4076    let discrete: std::collections::HashSet<String> = exec
4077        .bindings()
4078        .filter(|b| b.cadence == crate::cadence::Cadence::Discrete)
4079        .flat_map(|b| b.variables.iter().cloned())
4080        .collect();
4081
4082    let sample_time = opts.sample_time.unwrap_or(tspan.0);
4083    let mut boundaries: Vec<f64> = exec.refresh_times();
4084    boundaries.retain(|&b| b > sample_time && b < tspan.1);
4085
4086    Ok((Some(std::cell::RefCell::new(exec)), discrete, boundaries))
4087}
4088
4089// =============================================================================
4090// solve
4091// =============================================================================
4092
4093/// Run `prob` to completion (§2.5.3).
4094///
4095/// `opts` carries only per-run knobs: `alg`, `abstol`, `reltol`, `saveat`,
4096/// `callback`, `maxiters`. The result carries a
4097/// [`retcode`](crate::ReturnCode) — a caller distinguishes "ran to `tspan.1`"
4098/// from "stopped early, here is why" without parsing prose.
4099///
4100/// **`opts.callback` REPLACES the EsmProblem's callback set entirely.** It does
4101/// not append, merge or wrap. To extend rather than replace, read the set back
4102/// with [`callbacks`] and [`compose`] explicitly. See §2.5.4 for why
4103/// replacement is the safe default.
4104///
4105/// **`opts.output_observed` adds rows.** A [`Solution`] carries state rows by
4106/// default; a name listed there is appended as extra rows in the flat cell-key
4107/// spelling the state uses, which is what [`crate::derive_output_plan`] needs
4108/// to write that field alongside the state (RFC decision 8).
4109#[cfg(feature = "solve")]
4110pub fn solve(prob: &EsmProblem, opts: &SolveOptions) -> Result<Solution, SimulateError> {
4111    // Re-arm the document's working precision for the duration of this call
4112    // (`domain.element_type`, esm-spec §11.3); a no-op for a Float64 document.
4113    let _precision_guard = prob.precision.enter();
4114    reject_f32_integration(prob)?;
4115    let effective = effective_options(prob, opts);
4116    match &*prob.backend {
4117        Backend::Static(reason) => Err(SimulateError::NotDynamic {
4118            details: reason.clone(),
4119        }),
4120        Backend::Array(compiled) => {
4121            let mut insp = BuildInspection::default();
4122            let sink = prob.inspect.then_some(&mut insp);
4123
4124            #[cfg(not(target_arch = "wasm32"))]
4125            let sol = {
4126                match (&prob.refresh, prob.discrete_forcing.is_empty()) {
4127                    (Some(exec), false) => {
4128                        let forcing = compiled.forcing_buffer();
4129                        let mut exec = exec.borrow_mut();
4130                        let refresh_fn = |t: f64| -> Result<(), SimulateError> {
4131                            exec.refresh_at(t, &forcing).map(|_| ()).map_err(|e| {
4132                                SimulateError::ProviderError {
4133                                    name: "<discrete-loader>".into(),
4134                                    details: e.to_string(),
4135                                }
4136                            })
4137                        };
4138                        compiled.solve_with_refresh_inspect(
4139                            prob.tspan,
4140                            &prob.p,
4141                            &prob.u0,
4142                            &effective,
4143                            sink,
4144                            &prob.discrete_forcing,
4145                            &prob.refresh_boundaries,
4146                            refresh_fn,
4147                        )?
4148                    }
4149                    _ => compiled.solve_inspect(prob.tspan, &prob.p, &prob.u0, &effective, sink)?,
4150                }
4151            };
4152            #[cfg(target_arch = "wasm32")]
4153            let sol = compiled.solve_inspect(prob.tspan, &prob.p, &prob.u0, &effective, sink)?;
4154
4155            if prob.inspect {
4156                *prob.inspection.borrow_mut() = insp;
4157            }
4158            // One spelling per document, whichever route it took: the
4159            // single-model array build names its rows bare, the flattened one
4160            // qualifies them. See `qualify_array_names`.
4161            let mut sol = sol;
4162            if compiled.namespace().is_some() {
4163                // The state rows' names are the model's (kept, already
4164                // qualified); only the observed rows after them are spelled here.
4165                let states = compiled.qualified_state_names();
4166                let observed = sol
4167                    .state_variable_names
4168                    .split_off(states.len().min(sol.state_variable_names.len()));
4169                let mut names = states.to_vec();
4170                names.extend(qualify_array_names(compiled, &observed));
4171                sol.state_variable_names = names;
4172            }
4173            // Every row now carries the namespace, so none is reached through it.
4174            sol.metadata.namespace = None;
4175            Ok(sol)
4176        }
4177    }
4178}
4179
4180/// Run the esm-spec §2.2.2 tolerance chain against `prob`'s `solver` block and
4181/// return `opts` with both tolerances made CONCRETE.
4182///
4183/// Caller > the document's `solver` block > binding default, per field. The
4184/// chain belongs to "a document is being integrated", not to one entry point,
4185/// so it is applied at BOTH doors a document can be integrated through: `solve`
4186/// (via [`effective_options`]) and `init` (§2.5.6's stepping lifecycle). A
4187/// stepping caller who names no tolerance must get the document's, exactly as a
4188/// `solve` caller does.
4189///
4190/// **Idempotent**, which is what makes applying it at both doors safe: the
4191/// result has `Some` in both slots, so a second pass resolves at level 1 to the
4192/// same values. `init` relies on this — its `step` runs each segment through
4193/// `solve`, which resolves again.
4194///
4195/// Deliberately does NOT touch callbacks. Callback folding is `solve`-only for
4196/// the same reason: [`effective_options`] is *not* idempotent in that field
4197/// (a second pass would wrap the already-wrapped observer and invoke every
4198/// callback twice per step), so `init` must resolve tolerances without it.
4199fn with_resolved_tolerances(prob: &EsmProblem, opts: &SolveOptions) -> SolveOptions {
4200    let (abstol, reltol) =
4201        crate::resolve_tolerances(prob.solver.as_ref(), opts.abstol, opts.reltol);
4202    SolveOptions {
4203        abstol: Some(abstol),
4204        reltol: Some(reltol),
4205        ..opts.clone()
4206    }
4207}
4208
4209/// Fold the EsmProblem's callbacks (or the run's REPLACEMENT set) and the
4210/// extension-seam progress observer into the one per-step hook `run_solver`
4211/// already drives, on top of the §2.2.2 tolerance chain.
4212fn effective_options(prob: &EsmProblem, opts: &SolveOptions) -> SolveOptions {
4213    // An OUTPUT REQUEST is keyed by name, so it addresses a state an
4214    // `operator_compose` renaming match may have DELETED (issue #230). Resolve
4215    // the request list onto the surviving spellings here, where the compiled
4216    // artifact's map is in hand — `derive_output_plan` sees only the request and
4217    // the slot names, and would report the dead spelling as unknown.
4218    let observed = resolve_output_request(prob, &opts.output_observed);
4219
4220    // esm-spec §2.2.2, resolved once here — the single point where a `solve`
4221    // run's options and the document meet — so every backend below sees
4222    // concrete tolerances and none of them has to know about the chain.
4223    let resolved = with_resolved_tolerances(prob, opts);
4224
4225    // §2.5.4: the run's `callback` REPLACES the EsmProblem's set. It does not
4226    // append, merge, or wrap.
4227    let set = opts
4228        .callback
4229        .clone()
4230        .unwrap_or_else(|| prob.callbacks.clone());
4231    if set.is_empty() {
4232        return match observed {
4233            Some(output_observed) => SolveOptions {
4234                output_observed,
4235                ..resolved
4236            },
4237            None => resolved,
4238        };
4239    }
4240    let user = opts.progress.clone();
4241    let observer: ProgressFn = wrap_observer(set, user);
4242    SolveOptions {
4243        progress: Some(observer),
4244        output_observed: observed.unwrap_or_else(|| opts.output_observed.clone()),
4245        ..resolved
4246    }
4247}
4248
4249/// The problem's flatten-time merge map (issue #230), or an empty one for a
4250/// backend that carries none.
4251fn problem_merged_renames(prob: &EsmProblem) -> &HashMap<String, String> {
4252    static EMPTY: std::sync::OnceLock<HashMap<String, String>> = std::sync::OnceLock::new();
4253    match &*prob.backend {
4254        Backend::Array(c) => c.merged_renames(),
4255        Backend::Static(_) => EMPTY.get_or_init(HashMap::new),
4256    }
4257}
4258
4259/// Rewrite an output request off names an `operator_compose` renaming match
4260/// DELETED. `None` when nothing changes, so the common path clones nothing.
4261///
4262/// Not gated on `solve`: its one caller, `effective_options`, is not either.
4263fn resolve_output_request(prob: &EsmProblem, requested: &[String]) -> Option<Vec<String>> {
4264    if requested.is_empty() {
4265        return None;
4266    }
4267    let renames = problem_merged_renames(prob);
4268    if renames.is_empty() || !requested.iter().any(|n| renames.contains_key(n.as_str())) {
4269        return None;
4270    }
4271    Some(
4272        requested
4273            .iter()
4274            .map(|n| {
4275                renames
4276                    .get(n.as_str())
4277                    .cloned()
4278                    .unwrap_or_else(|| n.clone())
4279            })
4280            .collect(),
4281    )
4282}
4283
4284/// The document's §2.2 `solver` block, read out of a raw document.
4285///
4286/// Used at CONSTRUCTION, for the input carriers that reach `esm_problem` as raw
4287/// JSON (a path, a `Json` value, or a typed file the build pipeline
4288/// re-serialized). The typed carriers read `EsmFile::solver` directly; either
4289/// way the block lands on [`EsmProblem::solver`], which is what the §2.2.2
4290/// chain consults at solve time.
4291fn document_solver(doc: &JsonValue) -> Option<crate::Solver> {
4292    let block = doc.get("solver")?;
4293    // §2.2: an EMPTY block means what absence means, so it normalizes away
4294    // rather than becoming a `Solver` with nothing set.
4295    crate::solver::normalize_empty(serde_json::from_value(block.clone()).ok()?)
4296}
4297
4298#[cfg(not(target_arch = "wasm32"))]
4299fn wrap_observer(set: CallbackSet, user: Option<ProgressFn>) -> ProgressFn {
4300    std::sync::Arc::new(move |p: &Progress<'_>| {
4301        let a = set.invoke(p);
4302        let b = user.as_ref().map(|f| f(p)).unwrap_or(Flow::Continue);
4303        if matches!(a, Flow::Cancel) || matches!(b, Flow::Cancel) {
4304            Flow::Cancel
4305        } else {
4306            Flow::Continue
4307        }
4308    })
4309}
4310
4311#[cfg(target_arch = "wasm32")]
4312fn wrap_observer(set: CallbackSet, user: Option<ProgressFn>) -> ProgressFn {
4313    std::sync::Arc::new(move |p: &Progress<'_>| {
4314        let a = set.invoke(p);
4315        let b = user.as_ref().map(|f| f(p)).unwrap_or(Flow::Continue);
4316        if matches!(a, Flow::Cancel) || matches!(b, Flow::Cancel) {
4317            Flow::Cancel
4318        } else {
4319            Flow::Continue
4320        }
4321    })
4322}
4323
4324// =============================================================================
4325// Stepping
4326// =============================================================================
4327
4328/// A stepping integrator (§2.5.6) — the same lifecycle [`solve`] performs
4329/// internally, exposed for callers that need to interleave their own work with
4330/// the integration: the coupling driver in a host model, an interactive
4331/// session, a progress UI.
4332///
4333/// # What a "step" is here
4334///
4335/// [`Integrator::step`] advances to the next entry of the **output grid**, not
4336/// to the next internal solver step. Each advance runs the configured
4337/// algorithm over `[t_now, t_next]` from the current state, exactly the way the
4338/// array runtime's segmented provider-refresh driver already advances across a
4339/// refresh boundary. That means the adaptive controller restarts at every
4340/// interleave point, so a run stepped in N pieces is not step-for-step
4341/// identical to the same run solved in one call — it is the same trajectory to
4342/// tolerance, at some extra cost per boundary. Choose the grid accordingly:
4343/// coarse when you only need to interleave, fine when you need control.
4344///
4345/// (`diffsol`'s solver borrows its `OdeSolverProblem`, so a EsmProblem-owning
4346/// integrator that also owns a live solver would be self-referential. Restarting
4347/// per grid interval is the safe-Rust way to expose the lifecycle, and it is
4348/// the mechanism already in production here for segmented refresh.)
4349#[cfg(feature = "solve")]
4350pub struct Integrator<'a> {
4351    prob: &'a EsmProblem,
4352    opts: SolveOptions,
4353    grid: Vec<f64>,
4354    next: usize,
4355    t: f64,
4356    u: HashMap<String, f64>,
4357    time: Vec<f64>,
4358    state: Vec<Vec<f64>>,
4359    names: Vec<String>,
4360    retcode: crate::simulate::ReturnCode,
4361    metadata: crate::simulate::SolutionMetadata,
4362}
4363
4364#[cfg(feature = "solve")]
4365impl std::fmt::Debug for Integrator<'_> {
4366    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4367        f.debug_struct("Integrator")
4368            .field("t", &self.t)
4369            .field("remaining", &(self.grid.len().saturating_sub(self.next)))
4370            .field("retcode", &self.retcode)
4371            .finish()
4372    }
4373}
4374
4375/// Whether an [`Integrator`] has more to do.
4376#[cfg(feature = "solve")]
4377#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4378pub enum StepStatus {
4379    /// The integrator advanced and has further grid points to cover.
4380    Advanced,
4381    /// The integrator reached `tspan.1`, or stopped early — read
4382    /// [`Integrator::retcode`].
4383    Done,
4384}
4385
4386/// Build a stepping integrator for `prob` (§2.5.6).
4387///
4388/// The step grid is `opts.saveat` when the caller supplied one, else 100 evenly
4389/// spaced points across `tspan`.
4390///
4391/// The esm-spec §2.2.2 tolerance chain runs HERE, not only at `solve`: the
4392/// integrator this returns carries the document's `solver.abstol` / `reltol`
4393/// whenever the caller named none, so stepping a document honours its declared
4394/// numerics exactly as solving it does.
4395#[cfg(feature = "solve")]
4396pub fn init<'a>(
4397    prob: &'a EsmProblem,
4398    opts: &SolveOptions,
4399) -> Result<Integrator<'a>, SimulateError> {
4400    // Re-arm the document's working precision for the duration of this call
4401    // (`domain.element_type`, esm-spec §11.3); a no-op for a Float64 document.
4402    let _precision_guard = prob.precision.enter();
4403    reject_f32_integration(prob)?;
4404    if let Backend::Static(reason) = &*prob.backend {
4405        return Err(SimulateError::NotDynamic {
4406            details: reason.clone(),
4407        });
4408    }
4409    let (t0, t_end) = prob.tspan;
4410    let grid = match &opts.saveat {
4411        Some(g) if g.len() >= 2 => g.clone(),
4412        _ => {
4413            let n = 100usize;
4414            (0..=n)
4415                .map(|i| t0 + (t_end - t0) * (i as f64) / (n as f64))
4416                .collect()
4417        }
4418    };
4419    Ok(Integrator {
4420        prob,
4421        // esm-spec §2.2.2: caller > the document's `solver` block > binding
4422        // default. Resolved once, at construction, so the integrator HOLDS the
4423        // effective tolerances rather than leaving them to be rediscovered —
4424        // and so `step`'s per-segment `solve` sees them as explicit call-site
4425        // values (level 1) that resolve to themselves.
4426        opts: with_resolved_tolerances(prob, opts),
4427        grid,
4428        next: 0,
4429        t: t0,
4430        u: prob.u0.clone(),
4431        time: Vec::new(),
4432        state: Vec::new(),
4433        names: prob.state_variable_names(),
4434        retcode: crate::simulate::ReturnCode::Success,
4435        metadata: crate::simulate::SolutionMetadata::default(),
4436    })
4437}
4438
4439#[cfg(feature = "solve")]
4440impl Integrator<'_> {
4441    /// The independent-variable value reached so far.
4442    pub fn t(&self) -> f64 {
4443        self.t
4444    }
4445
4446    /// Why the integration stopped, once it has.
4447    pub fn retcode(&self) -> crate::simulate::ReturnCode {
4448        self.retcode
4449    }
4450
4451    /// The current state, keyed by variable name.
4452    pub fn u(&self) -> &HashMap<String, f64> {
4453        &self.u
4454    }
4455
4456    /// Advance to the next grid point. Returns [`StepStatus::Done`] at the end
4457    /// of `tspan` or when the run stopped early.
4458    pub fn step(&mut self) -> Result<StepStatus, SimulateError> {
4459        // Re-arm the document's working precision (esm-spec §11.3); a stepping
4460        // caller reaches the RHS without passing back through `solve`.
4461        let _precision_guard = self.prob.precision.enter();
4462        if !self.retcode.is_success() {
4463            return Ok(StepStatus::Done);
4464        }
4465        // Skip grid points at or behind where we already are.
4466        while self.next < self.grid.len() && self.grid[self.next] <= self.t {
4467            self.next += 1;
4468        }
4469        if self.next >= self.grid.len() {
4470            return Ok(StepStatus::Done);
4471        }
4472        let t_next = self.grid[self.next];
4473
4474        let seg = remake(
4475            self.prob,
4476            &Remake {
4477                u0: self.u.clone(),
4478                tspan: Some((self.t, t_next)),
4479                ..Default::default()
4480            },
4481        )?;
4482        let seg_opts = SolveOptions {
4483            saveat: Some(vec![t_next]),
4484            ..self.opts.clone()
4485        };
4486        let sol = solve(&seg, &seg_opts)?;
4487
4488        self.retcode = sol.retcode;
4489        self.metadata.alg = sol.metadata.alg.clone();
4490        self.metadata.n_rhs_calls += sol.metadata.n_rhs_calls;
4491        self.metadata.n_jacobian_calls += sol.metadata.n_jacobian_calls;
4492        self.metadata.n_accepted_steps += sol.metadata.n_accepted_steps;
4493        self.metadata.n_rejected_steps += sol.metadata.n_rejected_steps;
4494        self.metadata.tape_fallbacks = sol.metadata.tape_fallbacks.clone();
4495
4496        if self.state.is_empty() {
4497            self.names = sol.state_variable_names.clone();
4498            self.state = vec![Vec::new(); self.names.len()];
4499        }
4500        if let Some(k) = sol.time.len().checked_sub(1) {
4501            self.time.push(sol.time[k]);
4502            for (r, row) in sol.state.iter().enumerate() {
4503                if let Some(v) = row.get(k) {
4504                    if r < self.state.len() {
4505                        self.state[r].push(*v);
4506                    }
4507                    if let Some(name) = self.names.get(r) {
4508                        self.u.insert(name.clone(), *v);
4509                    }
4510                }
4511            }
4512            self.t = sol.time[k];
4513        }
4514        self.next += 1;
4515        if self.next >= self.grid.len() || !self.retcode.is_success() {
4516            Ok(StepStatus::Done)
4517        } else {
4518            Ok(StepStatus::Advanced)
4519        }
4520    }
4521
4522    /// Run to completion and take the accumulated [`Solution`]. This is the
4523    /// `solve!` of the SciML lifecycle; it is spelled out because `!` is not a
4524    /// legal suffix in Rust.
4525    pub fn solve_to_completion(&mut self) -> Result<Solution, SimulateError> {
4526        while matches!(self.step()?, StepStatus::Advanced) {}
4527        Ok(Solution {
4528            time: std::mem::take(&mut self.time),
4529            state: std::mem::take(&mut self.state),
4530            state_variable_names: self.names.clone(),
4531            retcode: self.retcode,
4532            metadata: self.metadata.clone(),
4533        })
4534    }
4535}
4536
4537/// Advance `integrator` by one grid point (§2.5.6's `step!`).
4538#[cfg(feature = "solve")]
4539pub fn step(integrator: &mut Integrator<'_>) -> Result<StepStatus, SimulateError> {
4540    integrator.step()
4541}
4542
4543/// Run `integrator` to completion (§2.5.6's `solve!`).
4544#[cfg(feature = "solve")]
4545pub fn solve_to_completion(integrator: &mut Integrator<'_>) -> Result<Solution, SimulateError> {
4546    integrator.solve_to_completion()
4547}
4548
4549// ===========================================================================
4550// Unit tests
4551// ===========================================================================
4552
4553/// The esm-spec §2.2.2 chain at the STEPPING door.
4554///
4555/// These live in-crate rather than in `tests/solver_block.rs` because what has
4556/// to be pinned is that the resolved tolerances land ON the integrator — a
4557/// private field. An observational test (does a looser tolerance change the
4558/// trajectory?) would pass either way for a well-conditioned decay and is not
4559/// the property; this is.
4560#[cfg(all(test, feature = "solve", not(target_arch = "wasm32")))]
4561mod stepping_tolerance_tests {
4562    use super::*;
4563    use crate::simulate::{DEFAULT_ABSTOL, DEFAULT_RELTOL};
4564
4565    fn doc(solver: &str) -> serde_json::Value {
4566        serde_json::from_str(&format!(
4567            r#"{{"esm":"1.1.0","metadata":{{"name":"T"}},{solver}"models":{{"M":{{
4568                 "variables":{{"y":{{"type":"unknown","units":"m","default":1.0}}}},
4569                 "equations":[{{"lhs":{{"op":"D","args":["y"],"wrt":"t"}},
4570                                "rhs":{{"op":"neg","args":["y"]}}}}]}}}}}}"#
4571        ))
4572        .expect("fixture parses")
4573    }
4574
4575    fn problem(solver: &str) -> EsmProblem {
4576        let json = doc(solver);
4577        esm_problem(
4578            ProblemInput::Json(&json),
4579            (0.0, 1.0),
4580            ProblemOptions::default(),
4581        )
4582        .expect("builds")
4583    }
4584
4585    /// A stepping caller who names no tolerance gets the DOCUMENT's, not the
4586    /// binding default.
4587    ///
4588    /// `init` used to store the caller's `SolveOptions` verbatim. The effect was
4589    /// MASKED in this binding — `step` remakes each segment and re-enters
4590    /// `solve`, and `remake` carries `solver` across, so the chain still ran, one
4591    /// layer down and once per segment. That is a coincidence of the current
4592    /// segment-per-step implementation, not the contract: the integrator did not
4593    /// hold the tolerances it was running at, and a step path that does not
4594    /// re-enter `solve` would silently drop the document. §2.2.2 puts the
4595    /// resolution at the DOOR, so this pins it at the door.
4596    #[test]
4597    fn init_resolves_the_documents_tolerances() {
4598        let prob = problem(r#""solver":{"abstol":1e-8,"reltol":1e-6},"#);
4599        let integ = init(&prob, &SolveOptions::default()).expect("init");
4600        assert_eq!(integ.opts.abstol, Some(1e-8));
4601        assert_eq!(integ.opts.reltol, Some(1e-6));
4602    }
4603
4604    /// Level 1 still wins: an explicitly-passed option beats the document.
4605    #[test]
4606    fn an_explicit_call_site_option_still_beats_the_document() {
4607        let prob = problem(r#""solver":{"abstol":1e-8,"reltol":1e-6},"#);
4608        let opts = SolveOptions {
4609            abstol: Some(1e-12),
4610            reltol: Some(1e-11),
4611            ..SolveOptions::default()
4612        };
4613        let integ = init(&prob, &opts).expect("init");
4614        assert_eq!(integ.opts.abstol, Some(1e-12));
4615        assert_eq!(integ.opts.reltol, Some(1e-11));
4616    }
4617
4618    /// Per FIELD, and level 3 for whatever the document leaves unsaid.
4619    #[test]
4620    fn each_tolerance_falls_through_on_its_own() {
4621        let only_reltol = problem(r#""solver":{"reltol":1e-9},"#);
4622        let integ = init(&only_reltol, &SolveOptions::default()).expect("init");
4623        assert_eq!(integ.opts.reltol, Some(1e-9));
4624        assert_eq!(integ.opts.abstol, Some(DEFAULT_ABSTOL));
4625
4626        let no_block = problem("");
4627        let integ = init(&no_block, &SolveOptions::default()).expect("init");
4628        assert_eq!(integ.opts.abstol, Some(DEFAULT_ABSTOL));
4629        assert_eq!(integ.opts.reltol, Some(DEFAULT_RELTOL));
4630    }
4631
4632    /// The chain is idempotent, which is what lets `init` and the per-segment
4633    /// `solve` inside `step` both run it without the second pass changing the
4634    /// answer or displacing the document.
4635    #[test]
4636    fn resolving_twice_is_resolving_once() {
4637        let prob = problem(r#""solver":{"abstol":1e-8,"reltol":1e-6},"#);
4638        let once = with_resolved_tolerances(&prob, &SolveOptions::default());
4639        let twice = with_resolved_tolerances(&prob, &once);
4640        assert_eq!((twice.abstol, twice.reltol), (once.abstol, once.reltol));
4641        assert_eq!((twice.abstol, twice.reltol), (Some(1e-8), Some(1e-6)));
4642    }
4643}