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}