earthsci_ast/classification.rs
1//! Derived variable classification (esm-spec §6.3.1).
2//!
3//! esm 1.0.0 declares **two** variable types, `unknown` and `parameter`.
4//! Everything finer that a solver needs — which unknowns are ODE states, which
5//! parameters are Brownian — is DERIVED from the equations and from each
6//! parameter's `distribution` / `update`. This module is the single derivation
7//! the whole binding shares: **no site may branch on a declared type to answer
8//! a derived question**, which is precisely what 1.0.0 removes.
9//!
10//! Two partitions, pinned by `tests/conformance/classification/`:
11//!
12//! | Unknowns | |
13//! |---|---|
14//! | [`ode_states`] | appear under `D(·, t)` on some equation LHS |
15//! | [`observed_unknowns`] | defined by a bare-variable LHS (`y ~ f(…)`) |
16//! | [`algebraic_unknowns`] | constrained only implicitly (`H*H*SO4 ~ Ksp`) |
17//!
18//! | Parameters | |
19//! |---|---|
20//! | [`brownian_parameters`] | `update.kind == "wiener"` |
21//! | [`discrete_parameters`] | any OTHER update |
22//! | [`sampled_parameters`] | a `distribution` and no update |
23//! | [`constant_parameters`] | neither |
24//!
25//! Both are partitions of the model's declared variables, asserted by
26//! [`Classification::assert_partitions`] and by the conformance suite.
27//!
28//! Every list this module returns is sorted lexicographically by UTF-8 code
29//! point, so the answer never depends on `HashMap` iteration order (the
30//! conformance goldens compare order-independently for exactly that reason).
31
32use crate::types::{Equation, Expr, Model, ModelVariable, VariableType};
33use indexmap::IndexMap;
34use std::collections::{BTreeMap, BTreeSet};
35
36/// The `wrt` value naming the independent (time) variable.
37const TIME: &str = "t";
38
39/// Sugar ops that ARE a spatial derivative regardless of any `wrt`/`dim`
40/// (esm-spec §4.2 calculus ops; the §6.3.1 `pde` test).
41const SPATIAL_SUGAR_OPS: [&str; 3] = ["grad", "div", "laplacian"];
42
43/// The derived system kind (esm-spec §6.3.1).
44#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
45pub enum SystemKind {
46 /// Any Brownian parameter.
47 Sde,
48 /// Any equation contains a spatial derivative.
49 Pde,
50 /// No time-derivative equation at all.
51 Nonlinear,
52 /// Everything else.
53 #[default]
54 Ode,
55}
56
57impl SystemKind {
58 /// The wire spelling of the kind, as the `system_kind` field carries it.
59 pub fn as_str(self) -> &'static str {
60 match self {
61 SystemKind::Sde => "sde",
62 SystemKind::Pde => "pde",
63 SystemKind::Nonlinear => "nonlinear",
64 SystemKind::Ode => "ode",
65 }
66 }
67}
68
69impl std::fmt::Display for SystemKind {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 f.write_str(self.as_str())
72 }
73}
74
75/// One model's complete §6.3.1 classification, computed once. Every name list
76/// is sorted.
77///
78/// The free functions below ([`ode_states`], [`brownian_parameters`], …) are
79/// thin wrappers over this; build it directly when a caller needs several sets,
80/// so the equation walk happens once.
81#[derive(Debug, Clone, Default)]
82pub struct Classification {
83 /// Unknowns appearing under `D(·, t)` on some equation LHS.
84 pub ode_states: Vec<String>,
85 /// Unknowns defined by a bare-variable OR indexed-variable LHS
86 /// (esm-spec §6.3.1) — the semantic "defined by an equation" set.
87 pub observed_unknowns: Vec<String>,
88 /// The NARROWER set alongside [`Self::observed_unknowns`]: the observed
89 /// unknowns whose defining LHS is a BARE variable, so they are eliminable
90 /// **by inlining** (esm-spec §6.3.1, "Note that *eliminable* and
91 /// *inlineable* are not the same thing"). An ARRAYED definition
92 /// (`y[i] ~ f(i)`) is observed too, but it materializes into a buffer its
93 /// consumers index rather than being substituted away, so it is NOT here.
94 /// Spelled `inlined_unknowns` in the Python oracle; it does not narrow the
95 /// §6.3.1 partition, it sits beside it.
96 pub inlined_unknowns: Vec<String>,
97 /// Unknowns constrained only implicitly.
98 pub algebraic_unknowns: Vec<String>,
99 /// Parameters whose update is `wiener`.
100 pub brownian_parameters: Vec<String>,
101 /// Parameters carrying any OTHER update.
102 pub discrete_parameters: Vec<String>,
103 /// Parameters with a distribution and no update.
104 pub sampled_parameters: Vec<String>,
105 /// Parameters with neither.
106 pub constant_parameters: Vec<String>,
107 /// The DERIVED system kind.
108 pub system_kind: SystemKind,
109 /// Each observed unknown's defining RHS, keyed by the unknown's name — the
110 /// 1.0.0 home of what used to sit in `variables[v].expression`.
111 pub observed_definitions: BTreeMap<String, Expr>,
112}
113
114impl Classification {
115 /// Classify one model node. Classification is per MODEL NODE: a subsystem's
116 /// equations classify the subsystem's variables, never the parent's.
117 pub fn of(model: &Model) -> Classification {
118 Self::from_parts(&model.variables, &model.equations)
119 }
120
121 /// Classify from the raw parts, for callers holding a variables map and an
122 /// equation list rather than a whole [`Model`] (the JSON-level cadence
123 /// pass, the flatten/compile pipelines).
124 pub fn from_parts(
125 variables: &IndexMap<String, ModelVariable>,
126 equations: &[Equation],
127 ) -> Classification {
128 let UnknownPartition {
129 unknowns,
130 ode_states,
131 observed,
132 inlined,
133 observed_definitions,
134 } = UnknownPartition::of(variables, equations);
135
136 // The three sets partition the unknowns, so `algebraic` is exactly what
137 // neither of the first two claimed. That also gives an unknown named by
138 // NO equation a home — such a model is unbalanced and
139 // `equation_count_mismatch` reports it, but it must not fall out of the
140 // partition here.
141 let algebraic: Vec<String> = unknowns
142 .iter()
143 .filter(|n| ode_states.binary_search(n).is_err() && !observed.contains(*n))
144 .map(|n| (*n).to_string())
145 .collect();
146
147 let ParameterPartition {
148 brownian,
149 discrete,
150 sampled,
151 constant,
152 } = ParameterPartition::of(variables);
153 let owned =
154 |names: Vec<&str>| -> Vec<String> { names.into_iter().map(str::to_string).collect() };
155 let (brownian, discrete, sampled, constant) = (
156 owned(brownian),
157 owned(discrete),
158 owned(sampled),
159 owned(constant),
160 );
161
162 let system_kind = derive_system_kind(&brownian, equations, &ode_states);
163
164 Classification {
165 ode_states: ode_states.into_iter().map(str::to_string).collect(),
166 observed_unknowns: observed.into_iter().map(str::to_string).collect(),
167 inlined_unknowns: inlined.into_iter().map(str::to_string).collect(),
168 algebraic_unknowns: algebraic,
169 brownian_parameters: brownian,
170 discrete_parameters: discrete,
171 sampled_parameters: sampled,
172 constant_parameters: constant,
173 system_kind,
174 observed_definitions: owned_definitions(observed_definitions),
175 }
176 }
177
178 /// Classify a model held as raw JSON — the form the cadence-partition pass
179 /// and the CLI work in.
180 ///
181 /// Reads only `variables` and `equations`, and IGNORES every other key, so
182 /// a model carrying the document registries merged down onto it (as
183 /// [`crate::cadence::model_with_loaders`] does) classifies the same as the
184 /// bare one.
185 ///
186 /// A VARIABLE that does not deserialize is an error: mis-reading the
187 /// declared type set is exactly the failure this module exists to prevent,
188 /// and it would silently classify as "no variables". An individual
189 /// EQUATION that does not deserialize is SKIPPED, because the passes that
190 /// call this run over partially-lowered intermediate JSON whose
191 /// well-formedness is the schema layer's business, not classification's.
192 pub fn from_json(model: &serde_json::Value) -> Result<Classification, serde_json::Error> {
193 let variables: IndexMap<String, ModelVariable> = match model.get("variables") {
194 Some(v) => serde_json::from_value(v.clone())?,
195 None => IndexMap::new(),
196 };
197 let equations: Vec<Equation> = model
198 .get("equations")
199 .and_then(|v| v.as_array())
200 .map(|eqs| {
201 eqs.iter()
202 .filter_map(|eq| serde_json::from_value::<Equation>(eq.clone()).ok())
203 .collect()
204 })
205 .unwrap_or_default();
206 Ok(Classification::from_parts(&variables, &equations))
207 }
208
209 /// Membership test for [`Classification::ode_states`].
210 pub fn is_ode_state(&self, name: &str) -> bool {
211 sorted_contains(&self.ode_states, name)
212 }
213
214 /// Membership test for [`Classification::observed_unknowns`].
215 pub fn is_observed(&self, name: &str) -> bool {
216 sorted_contains(&self.observed_unknowns, name)
217 }
218
219 /// Membership test for [`Classification::inlined_unknowns`].
220 pub fn is_inlined(&self, name: &str) -> bool {
221 sorted_contains(&self.inlined_unknowns, name)
222 }
223
224 /// Membership test for [`Classification::brownian_parameters`].
225 pub fn is_brownian(&self, name: &str) -> bool {
226 sorted_contains(&self.brownian_parameters, name)
227 }
228
229 /// Membership test for [`Classification::discrete_parameters`].
230 pub fn is_discrete_parameter(&self, name: &str) -> bool {
231 sorted_contains(&self.discrete_parameters, name)
232 }
233
234 /// Every unknown of the model, sorted — the union of the three unknown
235 /// sets.
236 pub fn unknowns(&self) -> Vec<String> {
237 let mut all: Vec<String> = self
238 .ode_states
239 .iter()
240 .chain(&self.observed_unknowns)
241 .chain(&self.algebraic_unknowns)
242 .cloned()
243 .collect();
244 all.sort();
245 all
246 }
247
248 /// Every parameter of the model, sorted — the union of the four parameter
249 /// sets.
250 pub fn parameters(&self) -> Vec<String> {
251 let mut all: Vec<String> = self
252 .brownian_parameters
253 .iter()
254 .chain(&self.discrete_parameters)
255 .chain(&self.sampled_parameters)
256 .chain(&self.constant_parameters)
257 .cloned()
258 .collect();
259 all.sort();
260 all
261 }
262
263 /// Assert the two §6.3.1 partition laws against the model this was derived
264 /// from: the three unknown sets partition the unknowns, and the four
265 /// parameter sets partition the parameters — both disjointly and totally.
266 ///
267 /// Returns the first violation as an `Err`, so a caller can surface it as a
268 /// diagnostic rather than panicking.
269 pub fn assert_partitions(&self, model: &Model) -> Result<(), String> {
270 let declared_unknowns: BTreeSet<&str> = model
271 .variables
272 .iter()
273 .filter(|(_, v)| v.var_type == VariableType::Unknown)
274 .map(|(k, _)| k.as_str())
275 .collect();
276 let declared_parameters: BTreeSet<&str> = model
277 .variables
278 .iter()
279 .filter(|(_, v)| v.var_type == VariableType::Parameter)
280 .map(|(k, _)| k.as_str())
281 .collect();
282
283 let unknown_total: usize =
284 self.ode_states.len() + self.observed_unknowns.len() + self.algebraic_unknowns.len();
285 let unknown_union: BTreeSet<&str> = self
286 .ode_states
287 .iter()
288 .chain(&self.observed_unknowns)
289 .chain(&self.algebraic_unknowns)
290 .map(String::as_str)
291 .collect();
292 if unknown_union.len() != unknown_total {
293 return Err("unknown sets overlap: ode_states / observed_unknowns / \
294 algebraic_unknowns must be disjoint"
295 .to_string());
296 }
297 if unknown_union != declared_unknowns {
298 return Err(format!(
299 "unknown sets do not cover the declared unknowns: got {unknown_union:?}, \
300 declared {declared_unknowns:?}"
301 ));
302 }
303
304 let param_total: usize = self.brownian_parameters.len()
305 + self.discrete_parameters.len()
306 + self.sampled_parameters.len()
307 + self.constant_parameters.len();
308 let param_union: BTreeSet<&str> = self
309 .brownian_parameters
310 .iter()
311 .chain(&self.discrete_parameters)
312 .chain(&self.sampled_parameters)
313 .chain(&self.constant_parameters)
314 .map(String::as_str)
315 .collect();
316 if param_union.len() != param_total {
317 return Err(
318 "parameter sets overlap: brownian / discrete / sampled / constant \
319 must be disjoint"
320 .to_string(),
321 );
322 }
323 if param_union != declared_parameters {
324 return Err(format!(
325 "parameter sets do not cover the declared parameters: got {param_union:?}, \
326 declared {declared_parameters:?}"
327 ));
328 }
329 Ok(())
330 }
331}
332
333// === The §6.3.1 API =======================================================
334
335/// Unknowns appearing under `D(·, t)` on some equation LHS.
336///
337/// Names are sorted lexicographically, not in declaration order (API_SPEC.md §5.3).
338pub fn ode_states(model: &Model) -> Vec<String> {
339 Classification::of(model).ode_states
340}
341
342/// Unknowns defined by a bare-variable LHS (`y ~ f(…)`) — eliminable,
343/// materializable.
344///
345/// Names are sorted lexicographically, not in declaration order (API_SPEC.md §5.3).
346pub fn observed_unknowns(model: &Model) -> Vec<String> {
347 Classification::of(model).observed_unknowns
348}
349
350/// The observed unknowns whose defining LHS is a BARE variable — the strict
351/// `y ~ f(…)` form that is eliminable by INLINING (esm-spec §6.3.1). An arrayed
352/// definition is observed but materializes into a buffer, so it is excluded.
353pub fn inlined_unknowns(model: &Model) -> Vec<String> {
354 Classification::of(model).inlined_unknowns
355}
356
357/// Unknowns constrained only implicitly (`H*H*SO4 ~ Ksp`).
358///
359/// Names are sorted lexicographically, not in declaration order (API_SPEC.md §5.3).
360pub fn algebraic_unknowns(model: &Model) -> Vec<String> {
361 Classification::of(model).algebraic_unknowns
362}
363
364/// Membership test for [`ode_states`].
365pub fn is_ode_state(model: &Model, name: &str) -> bool {
366 Classification::of(model).is_ode_state(name)
367}
368
369/// Parameters whose `update.kind` is `wiener` — the SDE noise sources.
370///
371/// Names are sorted lexicographically, not in declaration order (API_SPEC.md §5.3).
372pub fn brownian_parameters(model: &Model) -> Vec<String> {
373 Classification::of(model).brownian_parameters
374}
375
376/// Parameters carrying any OTHER update — piecewise-constant between
377/// refreshes.
378///
379/// Names are sorted lexicographically, not in declaration order (API_SPEC.md §5.3).
380pub fn discrete_parameters(model: &Model) -> Vec<String> {
381 Classification::of(model).discrete_parameters
382}
383
384/// Parameters with a `distribution` and no update — drawn once at setup.
385///
386/// Names are sorted lexicographically, not in declaration order (API_SPEC.md §5.3).
387pub fn sampled_parameters(model: &Model) -> Vec<String> {
388 Classification::of(model).sampled_parameters
389}
390
391/// Parameters with neither a distribution nor an update — plain constants.
392///
393/// Names are sorted lexicographically, not in declaration order (API_SPEC.md §5.3).
394pub fn constant_parameters(model: &Model) -> Vec<String> {
395 Classification::of(model).constant_parameters
396}
397
398/// The DERIVED system kind (esm-spec §6.3.1), which a binding uses when the
399/// `system_kind` field is absent and checks against when it is present.
400pub fn system_kind(model: &Model) -> SystemKind {
401 Classification::of(model).system_kind
402}
403
404/// Each observed unknown's defining RHS, keyed by name.
405///
406/// An observed's definition lives in the model's `equations` (esm-spec
407/// §6.3.1): the equation whose left-hand side is the bare variable.
408pub fn observed_definitions(model: &Model) -> BTreeMap<String, Expr> {
409 observed_definitions_of_parts(&model.variables, &model.equations)
410}
411
412/// The first cycle a deterministic DFS closes over an observed dependency
413/// graph, as the path in traversal order with the entry node repeated to close
414/// it (`["a", "b", "a"]`), or `None` when the graph is acyclic.
415///
416/// Shared by all three sites that need this answer — the `observed_cycle`
417/// structural check (esm-spec §4.9.6), `prepare`'s observed ordering, and the
418/// array runtime's `dependency_order_observed` — so that a document reaching
419/// any of them is told about the SAME cycle, in the same order, in the same
420/// shape. They read different inputs (a model's equations, a definition map,
421/// a set of lowered rules); agreeing on the walk is what makes their answers
422/// comparable.
423///
424/// `BTreeMap`/`BTreeSet` on both axes is load-bearing, not tidiness: the cycle
425/// a diagnostic NAMES is chosen by the traversal order, and hashed iteration
426/// would hand a different member of the same cycle to two runs of one binary.
427///
428/// ITERATIVE. The depth of this walk is the length of the longest observed
429/// CHAIN — a property of the document, and unbounded — rather than the
430/// expression nesting the schema caps, so a recursive walk would put a
431/// stack-overflow abort between a long acyclic mechanism and its clean
432/// verdict. Each frame carries the node's own successor cursor, so resuming a
433/// parent after a child finishes picks up exactly where it left off.
434pub(crate) fn first_observed_cycle(
435 deps: &BTreeMap<String, BTreeSet<String>>,
436) -> Option<Vec<String>> {
437 const ON_CHAIN: u8 = 1;
438 const FINISHED: u8 = 2;
439 let mut state: BTreeMap<&str, u8> = BTreeMap::new();
440
441 for root in deps.keys() {
442 if state.contains_key(root.as_str()) {
443 continue;
444 }
445 state.insert(root.as_str(), ON_CHAIN);
446 let mut chain: Vec<&str> = vec![root.as_str()];
447 // One cursor per chain entry: an iterator over that node's successors.
448 let mut cursors: Vec<std::collections::btree_set::Iter<'_, String>> =
449 vec![empty_successors(deps, root.as_str())];
450 while let Some(&name) = chain.last() {
451 match cursors.last_mut().and_then(Iterator::next) {
452 None => {
453 state.insert(name, FINISHED);
454 chain.pop();
455 cursors.pop();
456 }
457 Some(successor) => match state.get(successor.as_str()).copied() {
458 Some(ON_CHAIN) => {
459 // Close the cycle at its ENTRY node: the suffix of the
460 // chain from where this name first appeared, repeated
461 // to close it. A PATH, so it is ordered semantically
462 // rather than by the §7.1.0 lexicographic rule.
463 let start = chain
464 .iter()
465 .position(|c| *c == successor.as_str())
466 .unwrap_or(0);
467 let mut cycle: Vec<String> =
468 chain[start..].iter().map(|s| (*s).to_string()).collect();
469 cycle.push(successor.clone());
470 return Some(cycle);
471 }
472 Some(_) => {}
473 None => {
474 state.insert(successor.as_str(), ON_CHAIN);
475 chain.push(successor.as_str());
476 cursors.push(empty_successors(deps, successor.as_str()));
477 }
478 },
479 }
480 }
481 }
482 None
483}
484
485/// `name`'s successor iterator, or an empty one when the graph has no entry for
486/// it (a dependency on an observed with no defining equation is a leaf).
487fn empty_successors<'a>(
488 deps: &'a BTreeMap<String, BTreeSet<String>>,
489 name: &str,
490) -> std::collections::btree_set::Iter<'a, String> {
491 static EMPTY: std::sync::OnceLock<BTreeSet<String>> = std::sync::OnceLock::new();
492 deps.get(name)
493 .unwrap_or_else(|| EMPTY.get_or_init(BTreeSet::new))
494 .iter()
495}
496
497/// An observed unknown's defining RHS, read from a model held as RAW JSON.
498///
499/// The JSON-view counterpart of [`observed_definitions`], for the passes and
500/// tools that work on `serde_json::Value` (the cadence partition, the
501/// projection-pushdown desugar, the CLI).
502pub fn observed_definition_json<'a>(
503 model: &'a serde_json::Value,
504 name: &str,
505) -> Option<&'a serde_json::Value> {
506 model
507 .get("equations")?
508 .as_array()?
509 .iter()
510 .find(|eq| eq.get("lhs").and_then(serde_json::Value::as_str) == Some(name))?
511 .get("rhs")
512}
513
514/// Membership in one of a [`Classification`]'s name lists, which are sorted.
515fn sorted_contains(names: &[String], name: &str) -> bool {
516 names.binary_search_by(|s| s.as_str().cmp(name)).is_ok()
517}
518
519// === LHS forms ============================================================
520
521/// What an equation's LHS says about the unknown it names (esm-spec §6.3.1).
522#[derive(Debug, Clone, PartialEq, Eq)]
523pub enum LhsForm {
524 /// A time derivative of the named base variable — an ODE state.
525 Derivative(String),
526 /// A bare variable — an observed (eliminable) definition.
527 Bare(String),
528 /// Anything else: an implicit algebraic constraint.
529 Expression,
530}
531
532/// Classify an equation LHS.
533///
534/// A derivative LHS may be WRAPPED and still credits its base variable:
535/// `D(u)`, `D(u[i])` (an `index` under the `D`), and a `faq` whose
536/// `expr` is a `D(…)` — the arrayed spelling every discretized fixture uses.
537/// The same unwrapping applies to a bare LHS, so `faq{expr: y[i]}` still
538/// reads as a definition of `y`.
539pub fn lhs_form(lhs: &Expr) -> LhsForm {
540 match lhs_form_ref(lhs) {
541 LhsFormRef::Derivative(name) => LhsForm::Derivative(name.to_string()),
542 LhsFormRef::Bare(name) => LhsForm::Bare(name.to_string()),
543 LhsFormRef::Expression => LhsForm::Expression,
544 }
545}
546
547/// [`LhsForm`], borrowing the name from the left-hand side.
548pub(crate) enum LhsFormRef<'a> {
549 Derivative(&'a str),
550 Bare(&'a str),
551 Expression,
552}
553
554pub(crate) fn lhs_form_ref(lhs: &Expr) -> LhsFormRef<'_> {
555 match lhs {
556 Expr::Variable(name) => LhsFormRef::Bare(name),
557 Expr::Operator(node) => match node.op.as_str() {
558 // `D(x)` / `D(x[i])`, time only: a `wrt` naming a SPATIAL
559 // dimension is a spatial derivative and defines no ODE state.
560 "D" if node.wrt.as_deref().unwrap_or(TIME) == TIME => node
561 .args
562 .first()
563 .and_then(base_variable)
564 .map(LhsFormRef::Derivative)
565 .unwrap_or(LhsFormRef::Expression),
566 // A `faq` LHS is a shell around the real form.
567 "faq" => node
568 .expr
569 .as_deref()
570 .map(lhs_form_ref)
571 .unwrap_or(LhsFormRef::Expression),
572 // `u[i] ~ …` defines `u`.
573 "index" => node
574 .args
575 .first()
576 .and_then(base_variable)
577 .map(LhsFormRef::Bare)
578 .unwrap_or(LhsFormRef::Expression),
579 _ => LhsFormRef::Expression,
580 },
581 Expr::Integer(_) | Expr::Number(_) => LhsFormRef::Expression,
582 }
583}
584
585/// The base variable of an LHS operand, peeling the wrappers that do not
586/// change WHICH quantity is being written: `index`, `faq`
587/// shells, and `broadcast`.
588fn base_variable(expr: &Expr) -> Option<&str> {
589 match expr {
590 Expr::Variable(name) => Some(name),
591 Expr::Operator(node) => match node.op.as_str() {
592 "index" | "broadcast" => node.args.first().and_then(base_variable),
593 "faq" => node
594 .expr
595 .as_deref()
596 .and_then(base_variable)
597 .or_else(|| node.args.first().and_then(base_variable)),
598 _ => None,
599 },
600 _ => None,
601 }
602}
603
604// === the left-hand-side partition ========================================
605
606/// The part of a [`Classification`] read off the equations' left-hand sides:
607/// which unknowns are ODE states and which are observed, and each observed's
608/// definition. It walks no right-hand side, so a caller that wants only the
609/// definitions ([`observed_definitions_of_parts`]) pays for nothing else.
610struct UnknownPartition<'a> {
611 /// Sorted.
612 unknowns: Vec<&'a str>,
613 /// Sorted, without repeats.
614 ode_states: Vec<&'a str>,
615 observed: BTreeSet<&'a str>,
616 inlined: BTreeSet<&'a str>,
617 observed_definitions: BTreeMap<&'a str, &'a Expr>,
618}
619
620impl<'a> UnknownPartition<'a> {
621 fn of(variables: &'a IndexMap<String, ModelVariable>, equations: &'a [Equation]) -> Self {
622 let mut ode_states = Vec::new();
623 let mut observed = BTreeSet::new();
624 let mut inlined = BTreeSet::new();
625 let mut observed_definitions = BTreeMap::new();
626
627 let mut unknowns: Vec<&str> = variables
628 .iter()
629 .filter(|(_, v)| v.var_type == VariableType::Unknown)
630 .map(|(k, _)| k.as_str())
631 .collect();
632 unknowns.sort_unstable();
633 let is_unknown = |name: &str| unknowns.binary_search(&name).is_ok();
634
635 for eq in equations {
636 match lhs_form_ref(&eq.lhs) {
637 // `D(x)/dt ~ …`, including the wrapped spellings `D(x[i])` and
638 // a `faq` whose `expr` is the derivative.
639 LhsFormRef::Derivative(name) => {
640 if is_unknown(name) {
641 ode_states.push(name);
642 }
643 }
644 // `y ~ f(…)` / `y[i] ~ f(…)` — the LHS DEFINES y.
645 LhsFormRef::Bare(name) => {
646 if is_unknown(name) {
647 if !observed.contains(name) {
648 observed_definitions.insert(name, &eq.rhs);
649 }
650 if matches!(eq.lhs, Expr::Variable(_)) {
651 inlined.insert(name);
652 }
653 observed.insert(name);
654 }
655 }
656 // An expression LHS constrains its unknowns only implicitly.
657 LhsFormRef::Expression => {}
658 }
659 }
660
661 ode_states.sort_unstable();
662 ode_states.dedup();
663
664 // An unknown that is BOTH differentiated somewhere and bare-LHS
665 // elsewhere is an ODE state: the derivative is what the solver
666 // integrates, and the partition must stay disjoint.
667 for name in &ode_states {
668 observed.remove(name);
669 inlined.remove(name);
670 observed_definitions.remove(name);
671 }
672
673 UnknownPartition {
674 unknowns,
675 ode_states,
676 observed,
677 inlined,
678 observed_definitions,
679 }
680 }
681}
682
683/// The parameter half of a [`Classification`], each list sorted.
684struct ParameterPartition<'a> {
685 brownian: Vec<&'a str>,
686 discrete: Vec<&'a str>,
687 sampled: Vec<&'a str>,
688 constant: Vec<&'a str>,
689}
690
691impl<'a> ParameterPartition<'a> {
692 fn of(variables: &'a IndexMap<String, ModelVariable>) -> Self {
693 let mut p = ParameterPartition {
694 brownian: Vec::new(),
695 discrete: Vec::new(),
696 sampled: Vec::new(),
697 constant: Vec::new(),
698 };
699 for (name, var) in variables {
700 if var.var_type != VariableType::Parameter {
701 continue;
702 }
703 let set = match (&var.update, &var.distribution) {
704 (Some(update), _) if update.is_wiener() => &mut p.brownian,
705 (Some(_), _) => &mut p.discrete,
706 (None, Some(_)) => &mut p.sampled,
707 (None, None) => &mut p.constant,
708 };
709 set.push(name.as_str());
710 }
711 for set in [
712 &mut p.brownian,
713 &mut p.discrete,
714 &mut p.sampled,
715 &mut p.constant,
716 ] {
717 set.sort_unstable();
718 }
719 p
720 }
721}
722
723/// The role of each variable as a [`Classification`] decides it (an observed
724/// unknown, a Brownian or a discrete parameter), without the system kind's
725/// walk over every equation and without copying names.
726pub(crate) struct VariableRoles<'a> {
727 observed: BTreeSet<&'a str>,
728 parameters: ParameterPartition<'a>,
729}
730
731impl<'a> VariableRoles<'a> {
732 pub(crate) fn of(model: &'a Model) -> Self {
733 let observed = if defines_by_name(&model.equations) {
734 UnknownPartition::of(&model.variables, &model.equations).observed
735 } else {
736 BTreeSet::new()
737 };
738 VariableRoles {
739 observed,
740 parameters: ParameterPartition::of(&model.variables),
741 }
742 }
743
744 /// [`Classification::is_observed`].
745 pub(crate) fn is_observed(&self, name: &str) -> bool {
746 self.observed.contains(name)
747 }
748
749 /// [`Classification::is_brownian`].
750 pub(crate) fn is_brownian(&self, name: &str) -> bool {
751 self.parameters.brownian.binary_search(&name).is_ok()
752 }
753
754 /// [`Classification::is_discrete_parameter`].
755 pub(crate) fn is_discrete_parameter(&self, name: &str) -> bool {
756 self.parameters.discrete.binary_search(&name).is_ok()
757 }
758}
759
760/// [`Classification::observed_definitions`] from the raw parts, without the
761/// rest of the classification (the system kind walks every equation).
762pub(crate) fn observed_definitions_of_parts(
763 variables: &IndexMap<String, ModelVariable>,
764 equations: &[Equation],
765) -> BTreeMap<String, Expr> {
766 if !defines_by_name(equations) {
767 return BTreeMap::new();
768 }
769 owned_definitions(UnknownPartition::of(variables, equations).observed_definitions)
770}
771
772/// Whether any equation's left-hand side is a bare (or indexed) variable, the
773/// only form that defines an observed unknown.
774fn defines_by_name(equations: &[Equation]) -> bool {
775 equations
776 .iter()
777 .any(|eq| matches!(lhs_form_ref(&eq.lhs), LhsFormRef::Bare(_)))
778}
779
780fn owned_definitions(defs: BTreeMap<&str, &Expr>) -> BTreeMap<String, Expr> {
781 defs.into_iter()
782 .map(|(name, rhs)| (name.to_string(), rhs.clone()))
783 .collect()
784}
785
786// === system_kind ==========================================================
787
788/// Derive the system kind, testing the four §6.3.1 conditions IN ORDER and
789/// taking the first that holds. The order is normative: `pde` is tested before
790/// `nonlinear` (a steady-state PDE has no time derivative but is still a PDE),
791/// and `sde` before `pde` (there is no `SPDESystem` constructor to select).
792fn derive_system_kind(
793 brownian: &[String],
794 equations: &[Equation],
795 ode_states: &[&str],
796) -> SystemKind {
797 if !brownian.is_empty() {
798 return SystemKind::Sde;
799 }
800 if equations
801 .iter()
802 .any(|eq| has_spatial_derivative(&eq.lhs) || has_spatial_derivative(&eq.rhs))
803 {
804 return SystemKind::Pde;
805 }
806 if ode_states.is_empty() && !equations.iter().any(|eq| has_time_derivative(&eq.lhs)) {
807 return SystemKind::Nonlinear;
808 }
809 SystemKind::Ode
810}
811
812/// True when the expression contains a SPATIAL derivative anywhere: a `D` whose
813/// `wrt` is present and is not `"t"`, or a `grad` / `div` / `laplacian` sugar
814/// op. The walk descends EVERY expression child, not just `args` (§4.9.5).
815pub fn has_spatial_derivative(expr: &Expr) -> bool {
816 match expr {
817 Expr::Operator(node) => {
818 let here = (node.op == "D" && node.wrt.as_deref().is_some_and(|w| w != TIME))
819 || SPATIAL_SUGAR_OPS.contains(&node.op.as_str());
820 here || node.any_child(&mut has_spatial_derivative)
821 }
822 _ => false,
823 }
824}
825
826/// True when the expression contains a TIME derivative anywhere.
827///
828/// Asked of an equation's LHS, this is the §6.3.1 test for whether a document
829/// declares a differential equation at all, and it has to see through every
830/// wrapper an LHS can carry: `D(u)`, `faq{expr: D(u[i])}` (the arrayed
831/// spelling, and what the pointwise lift writes), and `index(D(u), i)`.
832pub(crate) fn has_time_derivative(expr: &Expr) -> bool {
833 match expr {
834 Expr::Operator(node) => {
835 let here = node.op == "D" && node.wrt.as_deref().unwrap_or(TIME) == TIME;
836 here || node.any_child(&mut has_time_derivative)
837 }
838 _ => false,
839 }
840}
841
842/// The rank a build-time field takes when its body evaluates to a plain
843/// number, decided by the DECLARATION rather than by the value (issue #431).
844///
845/// An unshaped `unknown`/`observed` is a 0-D quantity, and materializing its
846/// value is the only place its rank is chosen. Materializing it as `[1]` —
847/// which is what every scalar body did — handed a rank-1 array to every later
848/// reader of the name. Those readers are rank-sensitive: `simulate_array`'s
849/// `lookup_variable` returns a `Value::Scalar` for a 0-D entry and a
850/// `Value::Array` for anything else, so a bare `base` inside a `faq` body
851/// resolved to an ARRAY where the body wanted a number. `base + k` then
852/// broadcast to a one-element ARRAY, which is not what a `faq` writes into an
853/// output cell: the cell takes `eval(body).as_scalar().unwrap_or(NAN)`, and
854/// `as_scalar` answers only for rank 0. So the cell got the evaluator's NaN
855/// sentinel — silently, since a rank-1 field of one element still reports the
856/// right number when it is OBSERVED directly.
857///
858/// [`DeclaredRank::Unknown`] keeps `[1]`. The case it actually decides is a
859/// variable declared WITH a shape whose body still evaluates to a plain
860/// number; that ought to broadcast to the declared extents (issue #219's
861/// family) and is deliberately left where it was. Its other arm — a name the
862/// declaration table does not hold at all — is unreachable from every call
863/// site. The two here walk `self.order`, which is the key set of
864/// `observed_defs`, and `classification::observed_definitions` admits only
865/// names the model declares as `unknown` variables;
866/// `problem::static_observed_fields` looks names up in the very map
867/// (`FlattenedSystem::observed_variables`) whose keys those names came from.
868/// That arm is a conservative default, not a decision this makes.
869#[derive(Debug, Clone, Copy, PartialEq, Eq)]
870pub(crate) enum DeclaredRank {
871 /// The model declares this name with no `shape`: it is 0-D.
872 Scalar,
873 /// Declared WITH a `shape` — or, unreachably from either caller, a name
874 /// no declaration table holds.
875 Unknown,
876}
877
878impl DeclaredRank {
879 /// The shape a scalar body materializes into under this rank.
880 pub(crate) fn scalar_shape(self) -> &'static [usize] {
881 match self {
882 DeclaredRank::Scalar => &[],
883 DeclaredRank::Unknown => &[1],
884 }
885 }
886
887 /// Read the rank off a DECLARATION: present, and with no (or an empty)
888 /// `shape`, is the 0-D case.
889 ///
890 /// Takes the declaration rather than a model so the rule has ONE
891 /// implementation across the two places that materialize a scalar body:
892 /// this pipeline, which looks the name up in [`Model::variables`], and
893 /// `problem::static_observed_fields`, which has only a flattened system
894 /// and looks it up in `FlattenedSystem::observed_variables`. Two copies
895 /// would let the same document come back rank-0 down one path and rank-1
896 /// down the other.
897 pub(crate) fn of_declaration(decl: Option<&crate::types::ModelVariable>) -> Self {
898 match decl {
899 Some(v) if v.shape.as_ref().is_none_or(|s| s.is_empty()) => DeclaredRank::Scalar,
900 _ => DeclaredRank::Unknown,
901 }
902 }
903
904 /// Read the rank off the model.
905 pub(crate) fn of(model: &Model, name: &str) -> Self {
906 Self::of_declaration(model.variables.get(name))
907 }
908}
909
910#[cfg(test)]
911mod tests {
912 use super::*;
913
914 fn model(json: serde_json::Value) -> Model {
915 serde_json::from_value(json).expect("model deserializes")
916 }
917
918 /// The esm-spec §6.3.1 worked example, verbatim.
919 #[test]
920 fn worked_example() {
921 let m = model(serde_json::json!({
922 "variables": {
923 "c": { "type": "unknown", "units": "kg" },
924 "v_dep": { "type": "unknown", "units": "m/s" },
925 "SO4": { "type": "unknown", "units": "mol" },
926 "k": { "type": "parameter", "units": "1/s", "default": 0.1 },
927 "eps": { "type": "parameter", "units": "1/s^0.5",
928 "distribution": { "kind": "normal", "mean": 0.0, "std": 1.0 },
929 "update": { "kind": "wiener" } }
930 },
931 "equations": [
932 { "lhs": { "op": "D", "args": ["c"], "wrt": "t" },
933 "rhs": { "op": "*", "args": ["k", "c", "eps"] } },
934 { "lhs": "v_dep", "rhs": { "op": "/", "args": [1, "k"] } },
935 { "lhs": { "op": "*", "args": ["SO4", "SO4"] }, "rhs": "k" }
936 ]
937 }));
938 let c = Classification::of(&m);
939 assert_eq!(c.ode_states, ["c"]);
940 assert_eq!(c.observed_unknowns, ["v_dep"]);
941 assert_eq!(c.algebraic_unknowns, ["SO4"]);
942 assert_eq!(c.brownian_parameters, ["eps"]);
943 assert_eq!(c.constant_parameters, ["k"]);
944 assert!(c.sampled_parameters.is_empty());
945 assert!(c.discrete_parameters.is_empty());
946 assert_eq!(c.system_kind, SystemKind::Sde);
947 c.assert_partitions(&m).expect("partitions hold");
948 }
949
950 /// A distribution WITHOUT an update is sampled, not Brownian; an update
951 /// that is not `wiener` is discrete, whatever its kind.
952 #[test]
953 fn parameter_partition_discriminates_distribution_from_update() {
954 let m = model(serde_json::json!({
955 "variables": {
956 "u": { "type": "unknown", "units": "1" },
957 "p": { "type": "parameter", "units": "1", "default": 1.0 },
958 "samp": { "type": "parameter", "units": "1",
959 "distribution": { "kind": "uniform", "low": 0.0, "high": 1.0 } },
960 "cond": { "type": "parameter", "units": "1", "default": 1.0,
961 "update": { "kind": "condition",
962 "when": { "op": ">", "args": ["u", 1.0] },
963 "expression": 0.5 } },
964 "w": { "type": "parameter", "units": "1",
965 "distribution": { "kind": "normal", "mean": 0.0, "std": 1.0 },
966 "update": { "kind": "wiener" } }
967 },
968 "equations": [
969 { "lhs": { "op": "D", "args": ["u"], "wrt": "t" }, "rhs": "p" }
970 ]
971 }));
972 let c = Classification::of(&m);
973 assert_eq!(c.brownian_parameters, ["w"]);
974 assert_eq!(c.discrete_parameters, ["cond"]);
975 assert_eq!(c.sampled_parameters, ["samp"]);
976 assert_eq!(c.constant_parameters, ["p"]);
977 c.assert_partitions(&m).expect("partitions hold");
978 }
979
980 /// An update ARRAY is discrete, never Brownian (the schema forbids
981 /// `wiener` inside one, so the array form can only ever be discrete).
982 #[test]
983 fn update_array_is_discrete() {
984 let m = model(serde_json::json!({
985 "variables": {
986 "u": { "type": "unknown", "units": "1" },
987 "season": { "type": "parameter", "units": "1", "default": 1.0,
988 "update": [
989 { "kind": "condition", "when": { "op": "==", "args": ["u", 1] },
990 "expression": 1.0 },
991 { "kind": "condition", "when": { "op": "==", "args": ["u", 2] },
992 "expression": 2.0 }
993 ] }
994 },
995 "equations": [
996 { "lhs": { "op": "D", "args": ["u"], "wrt": "t" }, "rhs": "season" }
997 ]
998 }));
999 let c = Classification::of(&m);
1000 assert_eq!(c.discrete_parameters, ["season"]);
1001 assert!(c.brownian_parameters.is_empty());
1002 }
1003
1004 /// A derivative LHS may be wrapped: `D(u[i])` and a `faq` whose
1005 /// `expr` is the derivative both credit `u`.
1006 #[test]
1007 fn wrapped_derivative_lhs_credits_the_base_variable() {
1008 let m = model(serde_json::json!({
1009 "variables": {
1010 "u": { "type": "unknown", "units": "1", "shape": ["cells"] },
1011 "k": { "type": "parameter", "units": "1/s", "default": 1.0 }
1012 },
1013 "equations": [
1014 { "lhs": { "op": "faq", "output_idx": ["i"],
1015 "ranges": { "i": { "from": "cells" } },
1016 "args": ["u"],
1017 "expr": { "op": "D",
1018 "args": [{ "op": "index", "args": ["u", "i"] }],
1019 "wrt": "t" } },
1020 "rhs": "k" }
1021 ]
1022 }));
1023 assert_eq!(Classification::of(&m).ode_states, ["u"]);
1024 }
1025
1026 /// system_kind's four conditions, in the normative order.
1027 #[test]
1028 fn system_kind_order() {
1029 // A steady-state PDE has no time derivative but is still a PDE.
1030 let steady = model(serde_json::json!({
1031 "variables": { "phi": { "type": "unknown" }, "f": { "type": "parameter" } },
1032 "equations": [ { "lhs": { "op": "laplacian", "args": ["phi"] }, "rhs": "f" } ]
1033 }));
1034 assert_eq!(system_kind(&steady), SystemKind::Pde);
1035
1036 // Both signals: sde wins, because there is no SPDESystem to select.
1037 let both = model(serde_json::json!({
1038 "variables": {
1039 "v": { "type": "unknown" },
1040 "xi": { "type": "parameter",
1041 "distribution": { "kind": "normal", "mean": 0.0, "std": 1.0 },
1042 "update": { "kind": "wiener" } }
1043 },
1044 "equations": [ { "lhs": { "op": "D", "args": ["v"], "wrt": "t" },
1045 "rhs": { "op": "*", "args": [{ "op": "grad", "args": ["v"], "dim": "x" }, "xi"] } } ]
1046 }));
1047 assert_eq!(system_kind(&both), SystemKind::Sde);
1048
1049 // No derivative anywhere: nonlinear.
1050 let nl = model(serde_json::json!({
1051 "variables": { "H": { "type": "unknown" }, "SO4": { "type": "unknown" },
1052 "Ksp": { "type": "parameter" } },
1053 "equations": [
1054 { "lhs": "H", "rhs": { "op": "*", "args": ["SO4", 2.0] } },
1055 { "lhs": { "op": "*", "args": ["H", "H", "SO4"] }, "rhs": "Ksp" }
1056 ]
1057 }));
1058 assert_eq!(system_kind(&nl), SystemKind::Nonlinear);
1059
1060 // A plain time derivative: ode.
1061 let ode = model(serde_json::json!({
1062 "variables": { "u": { "type": "unknown" }, "r": { "type": "parameter" } },
1063 "equations": [ { "lhs": { "op": "D", "args": ["u"], "wrt": "t" }, "rhs": "r" } ]
1064 }));
1065 assert_eq!(system_kind(&ode), SystemKind::Ode);
1066 }
1067
1068 /// Classification is a property of the equation SET, not of traversal
1069 /// order: definitions may appear out of dependency order.
1070 #[test]
1071 fn observed_chain_out_of_order() {
1072 let m = model(serde_json::json!({
1073 "variables": {
1074 "z": { "type": "unknown" }, "y": { "type": "unknown" },
1075 "x": { "type": "unknown" }, "a": { "type": "parameter" }
1076 },
1077 "equations": [
1078 { "lhs": "z", "rhs": { "op": "*", "args": ["y", 2.0] } },
1079 { "lhs": { "op": "D", "args": ["x"], "wrt": "t" }, "rhs": "a" },
1080 { "lhs": "y", "rhs": { "op": "+", "args": ["x", 1.0] } }
1081 ]
1082 }));
1083 let c = Classification::of(&m);
1084 assert_eq!(c.ode_states, ["x"]);
1085 assert_eq!(c.observed_unknowns, ["y", "z"]);
1086 assert!(c.observed_definitions.contains_key("y"));
1087 assert!(c.observed_definitions.contains_key("z"));
1088 }
1089
1090 /// [`first_observed_cycle`]'s walk descends the longest observed CHAIN,
1091 /// which is a property of the DOCUMENT and unbounded — not the expression
1092 /// nesting the schema caps. A recursive walk therefore puts a stack
1093 /// overflow between a long acyclic mechanism and its clean verdict, and a
1094 /// stack overflow in Rust is an abort, not a diagnosis.
1095 #[test]
1096 fn a_chain_far_deeper_than_a_call_stack_is_walked_without_recursing() {
1097 const N: usize = 200_000;
1098 // x_i -> x_{i+1}, with x_{N-1} the base case: the DFS entered at the
1099 // sorted-first root descends the whole thing in one go.
1100 let mut deps: BTreeMap<String, BTreeSet<String>> = (0..N - 1)
1101 .map(|i| (format!("x{i}"), BTreeSet::from([format!("x{}", i + 1)])))
1102 .collect();
1103 deps.insert(format!("x{}", N - 1), BTreeSet::new());
1104 assert_eq!(first_observed_cycle(&deps), None);
1105
1106 // Closing it into a ring at the same depth must still name the path.
1107 deps.insert(format!("x{}", N - 1), BTreeSet::from(["x0".to_string()]));
1108 let cycle = first_observed_cycle(&deps).expect("the ring is a cycle");
1109 assert_eq!(cycle.len(), N + 1);
1110 assert_eq!(cycle.first().map(String::as_str), Some("x0"));
1111 assert_eq!(cycle.last().map(String::as_str), Some("x0"));
1112 }
1113
1114 /// A DIAMOND is not a cycle: `a` reaches `d` by two routes, and `d` being
1115 /// already FINISHED on the second is a shared tail, not a closed loop.
1116 #[test]
1117 fn a_diamond_is_not_a_cycle() {
1118 let deps: BTreeMap<String, BTreeSet<String>> = BTreeMap::from([
1119 (
1120 "a".to_string(),
1121 BTreeSet::from(["b".to_string(), "c".to_string()]),
1122 ),
1123 ("b".to_string(), BTreeSet::from(["d".to_string()])),
1124 ("c".to_string(), BTreeSet::from(["d".to_string()])),
1125 ("d".to_string(), BTreeSet::new()),
1126 ]);
1127 assert_eq!(first_observed_cycle(&deps), None);
1128 }
1129}