Skip to main content

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}