Skip to main content

earthsci_ast/
compile_error.rs

1//! Build-time error type shared by the simulate pipeline and the non-gated
2//! `faq` / `join` resolution passes.
3//!
4//! [`CompileError`] lives in its own module rather than inside `simulate`
5//! because `simulate` (and `simulate_array`) are `#[cfg(not(target_arch =
6//! "wasm32"))]` — they pull in the native ODE backend. The `faq` and
7//! `join` modules surface build-time resolution failures as `CompileError`
8//! but are compiled on every target, including `wasm32-unknown-unknown`.
9//! Keeping the type here lets those modules compile for WASM while `simulate`
10//! re-exports it unchanged for the native API (`crate::simulate::CompileError`).
11
12use crate::flatten::FlattenError;
13use thiserror::Error;
14
15/// Errors raised when building a compiled model from a flattened system.
16#[derive(Error, Debug)]
17pub enum CompileError {
18    /// A model construct this evaluator cannot run: a continuous or discrete
19    /// event, an implicit equation (an equation whose LHS is an expression
20    /// rather than an unknown, `D(unknown)` or `ic(unknown)`), or a Wiener-noise
21    /// parameter. esm-spec §9.6.6
22    /// `unsupported_construct`.
23    ///
24    /// Refused at build rather than skipped: a model run without its event, or
25    /// without its residual solved, reports a wrong answer, and nothing in that
26    /// answer says a construct was dropped.
27    #[error(
28        "{code}: {construct} {detail} is not supported by the {evaluator}; \
29         refusing the build rather than running the model without it (esm-spec §9.6.6)",
30        code = crate::diagnostic::codes::UNSUPPORTED_CONSTRUCT
31    )]
32    UnsupportedConstruct {
33        /// Which construct: [`CONTINUOUS_EVENT`], [`DISCRETE_EVENT`],
34        /// [`IMPLICIT_EQUATION`] or [`WIENER_NOISE`].
35        construct: &'static str,
36        /// Which evaluator refused it, e.g. `"Rust array evaluator"`.
37        evaluator: &'static str,
38        /// The offending instance: an event's name, or an equation's LHS.
39        detail: String,
40    },
41
42    /// One unknown carries BOTH a derivative equation and a bare-LHS one
43    /// (esm-spec §4.9.4 `equation_count_mismatch`).
44    ///
45    /// §4.9.4 counts an equation against the unknowns "whichever form its LHS
46    /// takes", so `D(x) ~ f` beside `x ~ g` is two equations binding one
47    /// unknown: the document is unbalanced, and `validate` reports exactly
48    /// that. This crate used to tie-break in favour of the derivative, which
49    /// integrates a system free of a constraint the file declares and reports
50    /// the trajectory as the answer. The build is the other place that same
51    /// document arrives, so it refuses under the same code.
52    #[error(
53        "{code}: unknown '{name}' is defined twice — by `{differential}` and by \
54         `{algebraic}`. esm-spec §4.9.4 counts an equation whichever form its LHS \
55         takes, so this system has one more equation than it has unknowns to bind; \
56         keeping the derivative and dropping the constraint would run a model the \
57         document does not describe. Remove one of the two definitions.",
58        code = crate::diagnostic::codes::EQUATION_COUNT_MISMATCH
59    )]
60    DoublyDefinedUnknown {
61        /// The unknown both equations define, spelled as the system spells it.
62        name: String,
63        /// The derivative equation, rendered `lhs ~ rhs`.
64        differential: String,
65        /// The competing bare-LHS equation, rendered `lhs ~ rhs`.
66        algebraic: String,
67    },
68
69    /// The chosen compiler cannot run one of this document's rules
70    /// (esm-spec §9.6.6 `compiler_refused_rule`, esm-libraries-spec §2.5.10).
71    ///
72    /// Raised at CONSTRUCTION, naming the compiler, the rule — an equation or
73    /// an observed, component-qualified — the cadence tier the rule would have
74    /// run at, and the DEEPEST decline reason reached while trying to lower
75    /// it. A refusal, never a fallback: the compiler does not run that rule on
76    /// a slower path, because a caller who asked for a compiled program and
77    /// got a tree walk has no way to find that out.
78    #[error(
79        "{code}: compiler '{compiler}' cannot run the {tier}-cadence {kind} '{rule}': {reason}. \
80         `native` is strict (esm-libraries-spec §2.5.10) — it refuses rather than demoting the \
81         rule to the per-cell oracle; build with `compiler: Some(Compiler::Interpreter)` to run \
82         this document on the reference evaluator",
83        code = crate::diagnostic::codes::COMPILER_REFUSED_RULE
84    )]
85    CompilerRefusedRule {
86        /// The vocabulary spelling of the compiler that refused.
87        compiler: &'static str,
88        /// `"observed"` or `"state derivative"`.
89        kind: &'static str,
90        /// The rule, component-qualified.
91        rule: String,
92        /// The cadence tier the rule would have run at: `"const"`,
93        /// `"discrete"` or `"continuous"`.
94        tier: &'static str,
95        /// The deepest decline reason reached while trying to lower it.
96        reason: String,
97    },
98
99    /// The flattened system contains a feature the v1 simulator does not support
100    /// (e.g. a join over data-derived columns).
101    #[error("Unsupported feature '{feature}': {message}")]
102    UnsupportedFeatureError {
103        /// Feature name (e.g. `"value-equality join over data-derived columns"`).
104        feature: String,
105        /// Why this is rejected and what to do about it.
106        message: String,
107    },
108
109    /// The flattened system still carries a spatial independent variable — a
110    /// spatial operator that was never discretized into a `faq` stencil.
111    /// Discretized PDEs fold their spatial axis into array dimensions, leaving
112    /// `independent_variables == ["t"]`, and simulate fine.
113    #[error(
114        "Unsupported dimensionality {independent_variables:?}: the simulator integrates systems whose only independent variable is time (independent_variables == [\"t\"]). A remaining spatial independent variable means a spatial operator was not discretized — apply the discretization template (an `expression_templates` `match` rewrite) that lowers it to a `faq` stencil, then simulate. Discretized PDEs run natively in this backend."
115    )]
116    UnsupportedDimensionalityError {
117        /// The actual independent variables found.
118        independent_variables: Vec<String>,
119    },
120
121    /// The interpreter could not build a callable representation of the
122    /// flattened equations.
123    #[error("Interpreter build failed: {details}")]
124    InterpreterBuildError {
125        /// Human-readable failure description.
126        details: String,
127    },
128
129    /// A rewrite-target operator (a spatial / right-hand-side `D`, or the
130    /// optional sugar ops `grad` / `div` / `laplacian` / ...) survived the
131    /// lowering fixpoint into an evaluation position (esm-spec §4.2 / §9.6.8).
132    /// Such ops carry NO evaluator: a discretization `match` rewrite rule MUST
133    /// lower them to a `faq` / `makearray` stencil before evaluation.
134    /// The gate fires here — before evaluation, not at load — with the uniform
135    /// `unlowered_operator` code that supersedes the former per-binding
136    /// `UnreachableSpatialOperatorError` / `UnsupportedDimensionality` errors.
137    /// Silently producing zeros (the previous behavior) would mask a broken
138    /// pipeline. This format ships no discretization rules — they live in
139    /// EarthSciDiscretizations.
140    #[error(
141        "unlowered_operator: rewrite-target operator '{op}' reached evaluation without being \
142         lowered to a stencil by a rewrite rule (esm-spec §4.2 / §9.6.8). Discretization rules \
143         live in EarthSciDiscretizations, not this format."
144    )]
145    UnloweredOperatorError {
146        /// The offending operator name (e.g. `"grad"` or `"D"`).
147        op: String,
148    },
149
150    /// An evaluable-core operator (esm-spec §4.2) applied to the wrong number
151    /// of arguments — e.g. `atan2` with one argument, or `min` with fewer than
152    /// two. The schema cannot express per-operator arity (`args` is
153    /// `minItems: 0`), so this is the gate that keeps a malformed-but-
154    /// schema-valid node out of the evaluators, where it would otherwise either
155    /// panic on an out-of-bounds index or — worse — be silently assigned two
156    /// different values by the per-cell oracle and the vectorized overlay.
157    /// See [`crate::op_registry`].
158    #[error(
159        "invalid_operator_arity: operator '{op}' takes {expected} argument(s), got {got} \
160         (esm-spec §4.2)"
161    )]
162    InvalidOperatorArity {
163        /// The offending operator name (e.g. `"atan2"`).
164        op: String,
165        /// How many arguments the node actually carried.
166        got: usize,
167        /// What the spec says the operator takes (e.g. `"exactly 2"`).
168        expected: String,
169    },
170
171    /// A `makearray` node whose `regions` are malformed: either the regions
172    /// disagree on rank (a ragged `regions` list), or a bound pair is inverted
173    /// beyond the legal empty spelling. Per esm-spec §4.3.2 a pair with
174    /// `stop == start - 1` is the canonical **empty** region and is legal;
175    /// anything further inverted (`stop < start - 1`) is rejected with
176    /// `makearray_region_inverted`.
177    #[error("makearray_region_inverted: {reason} (esm-spec §4.3.2)")]
178    MakearrayRegionInvalid {
179        /// What is wrong with the regions.
180        reason: String,
181    },
182
183    /// A `broadcast` node whose `fn` field is absent or does not name a scalar
184    /// operator (esm-spec §4.3.4). `broadcast` is the one op whose OPERATOR is
185    /// data — the node says `broadcast`, the arithmetic is named by a sibling
186    /// string — and nothing used to check that string, so a typo'd or missing
187    /// `fn` was silently evaluated as `+` (issue #101).
188    #[error("invalid_broadcast_fn: {reason}")]
189    InvalidBroadcastFn {
190        /// Why the `fn` is rejected.
191        reason: String,
192    },
193
194    /// An evaluable-core operator (esm-spec §4.2) that this interpreter has no
195    /// evaluation rule for reached the evaluator.
196    ///
197    /// These ops are legal in an AST but are *eliminated before* the per-cell
198    /// evaluator ever runs: the build-time query ops (`skolem`, `rank`,
199    /// `distinct`, `argmin`, `argmax`) are resolved by
200    /// [`crate::value_invention`], and the form/lowering ops (`ic`, `true`,
201    /// `enum`, `table_lookup`, `apply_expression_template`) are consumed by
202    /// their respective lowering passes. One arriving at the evaluator means the
203    /// pipeline is broken.
204    ///
205    /// It is reported rather than evaluated to a NaN sentinel: a silent NaN is
206    /// indistinguishable from a legitimate numerical result and would propagate
207    /// into the solution, whereas the pipeline stage that should have eliminated
208    /// the op is the actual defect.
209    ///
210    /// Raised by the array runtime's build gate and by the single-expression
211    /// [`crate::expression::evaluate`], whose scalar bindings additionally give
212    /// the array / tensor and geometry ops no value. The invariant is the same
213    /// either way: an unevaluable op is a diagnostic, never a number.
214    #[error(
215        "unevaluable_operator: operator '{op}' is an evaluable-core op with no evaluation rule \
216         in the interpreter this model was built for — it must be eliminated by an earlier \
217         pipeline stage (value invention, or a lowering pass), or the document built for a \
218         runtime that evaluates it, before evaluation (esm-spec §4.2)"
219    )]
220    UnevaluableOperatorError {
221        /// The offending operator name (e.g. `"skolem"`).
222        op: String,
223    },
224
225    /// An expression ranges over a `kind: "derived"` index set whose
226    /// value-invention producer could not be materialized at build (esm-spec
227    /// §4.2 / §9.6.6). Refused rather than contracted as an empty range, which
228    /// folds to the additive identity and reads as a plausible 0.
229    #[error("{code}: {reason}")]
230    ValueInventionRefused {
231        /// `derived_index_set_unmaterialized`, or `relational_node_in_continuous`
232        /// when the producer reads live state.
233        code: &'static str,
234        /// The index set, its producer, and why the producer could not run.
235        reason: String,
236    },
237
238    /// A `table_lookup` node that cannot be lowered to its esm-spec §9.5.3
239    /// `interp.linear` / `interp.bilinear` / `index` form — an unknown table,
240    /// an axis-key set that does not match the table's declared axes, an
241    /// out-of-range `output`, or a table whose `interpolation` and axis count
242    /// disagree.
243    ///
244    /// Raised by [`crate::lower_table_lookup`] during the build rather than by
245    /// the evaluator: `unevaluable_operator` would name `table_lookup` without
246    /// saying which of the §9.5.5 conditions the document actually trips.
247    #[error("{code}: {reason}")]
248    TableLookupLowering {
249        /// The esm-spec §9.5.5 diagnostic code.
250        code: &'static str,
251        /// What is wrong with this `table_lookup` (or the table it names).
252        reason: String,
253    },
254
255    /// `domain.element_type` names a precision this evaluator does not have.
256    ///
257    /// Reported rather than defaulted to binary64: a document that asks for a
258    /// precision and silently gets a different one is the same class of defect
259    /// as an `element_type` that is accepted and then ignored.
260    #[error(
261        "unsupported_element_type: domain.element_type '{element_type}' is not a precision this \
262         evaluator implements — use \"Float64\" (the default) or \"Float32\" (esm-spec §11.3)"
263    )]
264    UnsupportedElementType {
265        /// The offending `element_type` spelling.
266        element_type: String,
267    },
268
269    /// A construct that cannot be evaluated in binary32 appears in a document
270    /// declaring `domain.element_type: "Float32"`.
271    ///
272    /// The alternative — evaluating just that construct in binary64 — is a
273    /// silent fallback: the document asked for one precision and part of the
274    /// answer would be computed in another, with nothing in the result to say
275    /// which part. So it is an error naming the construct.
276    #[error(
277        "float32_unsupported: {construct} cannot be evaluated under \
278         `domain.element_type: \"Float32\"` — {reason}. Remove the construct, or evaluate the \
279         document in Float64 (esm-spec §11.3)"
280    )]
281    Float32Unsupported {
282        /// The offending construct, named (e.g. ``operator `intersect_polygon` ``).
283        construct: String,
284        /// Why binary32 cannot carry it.
285        reason: String,
286    },
287
288    /// A declared working precision reaches a construct this evaluator cannot
289    /// carry it through (esm-spec §11.3.1, §4.7 "Working precision crosses a
290    /// mount").
291    ///
292    /// Refused rather than evaluated in another precision: the declaration
293    /// would otherwise be accepted and ignored.
294    #[error("element_type_uncarried: {construct} — {reason} (esm-spec §11.3.1)")]
295    ElementTypeUncarried {
296        /// The construct whose declared precision cannot be carried.
297        construct: String,
298        /// Why this evaluator cannot carry it.
299        reason: String,
300    },
301
302    /// One operator's operands carry DIFFERENT declared element types
303    /// (`ModelVariable.element_type`, esm-spec §11.3.1).
304    ///
305    /// Refused rather than resolved by a usual-arithmetic-conversions rule,
306    /// because every resolution is a silent answer to a question only the
307    /// author can settle. Widening to the wider operand makes a quantity
308    /// declared binary32 come out binary64 — the declaration ignored, with
309    /// nothing in the result to say so. Narrowing to the narrower destroys the
310    /// binary64 operand, which is the very corruption per-variable element
311    /// types exist to prevent (a ten-digit key rounded to binary32 collides
312    /// with its neighbours). So the mix is an error naming both variables, and
313    /// the author states the intent by computing the mixed step into a variable
314    /// whose own `element_type` says which precision it is in.
315    #[error(
316        "mixed_element_type: operator '{op}' mixes operands of different element types — \
317         {lhs_name} is {lhs_type} and {rhs_name} is {rhs_type}. One operator cannot be both; \
318         neither widening nor narrowing is silently applied. Compute one side into a variable \
319         whose own `element_type` states which precision that step lands in, then combine the \
320         declared variables (esm-spec §11.3.1)"
321    )]
322    MixedElementType {
323        /// The operator whose operands disagree (or `"equation"` for an
324        /// equation whose right-hand side disagrees with its left-hand side).
325        op: String,
326        /// A variable supplying the first element type.
327        lhs_name: String,
328        /// That variable's element type.
329        lhs_type: String,
330        /// A variable supplying the conflicting element type.
331        rhs_name: String,
332        /// That variable's element type.
333        rhs_type: String,
334    },
335
336    /// A BARE array-level expression whose operand is declared over an index
337    /// set the result does not have (esm-spec §4.3.4).
338    ///
339    /// Operands of an array-level expression align by index-set NAME: an
340    /// operand declared over a SUBSET of the result's index sets broadcasts
341    /// along the ones it is missing. An operand carrying an index set the
342    /// result does not carry has no axis to align to and no defensible value to
343    /// take, so it is rejected rather than reinterpreted positionally — the
344    /// positional flatten this replaces produced plausible, non-`NaN`,
345    /// zero-padded garbage (issue #100).
346    ///
347    /// The shapes are fully known statically, so `validate()` reports the same
348    /// defect as an `array_shape_mismatch` structural error before any build is
349    /// attempted; this is the build-time backstop for a model compiled without
350    /// being validated first.
351    #[error(
352        "array_shape_mismatch: operand '{operand}' of the array-level expression for '{result}' \
353         is declared over index set '{axis}', which '{result}' (shape {result_axes:?}) does not \
354         have — an operand's index sets must be a subset of the result's (esm-spec §4.3.4)"
355    )]
356    UnalignableArrayShape {
357        /// The offending operand's variable name.
358        operand: String,
359        /// The index set the operand carries and the result does not.
360        axis: String,
361        /// The name of the array-level expression's result.
362        result: String,
363        /// The result's declared index sets, in declaration order.
364        result_axes: Vec<String>,
365    },
366
367    /// A dependency cycle among the model's OBSERVED unknowns (esm-spec
368    /// §4.9.6): each observed on the cycle is defined by an equation whose RHS
369    /// names the next, so no evaluation order satisfies every definition.
370    ///
371    /// The equations decide this on their own, so `validate()` reports the same
372    /// defect as an `observed_cycle` structural error before any build is
373    /// attempted; this is the build-time backstop for a model compiled without
374    /// being validated first — and it is the reason this variant exists at all.
375    /// The ordering sweep used to *tolerate* an unorderable rule set, appending
376    /// the stuck rules in declaration order so "the build still proceeds"; what
377    /// actually proceeded was a materialization pass that read an observed with
378    /// no value yet and reported `E_TREEWALK_UNBOUND_NAME` against whichever
379    /// name it reached first — routinely an observed that is declared, defined
380    /// and referenced perfectly well (issue #181).
381    #[error(
382        "observed_cycle: dependency cycle among observed variables: {}. Each is defined in \
383         terms of the next, so no evaluation order satisfies every definition (esm-spec §4.9.6). \
384         `esm validate` reports this cycle before any build.",
385        cycle.join(" -> ")
386    )]
387    ObservedCycle {
388        /// The observeds on the cycle, in traversal order, with the entry node
389        /// repeated to close it (`["a", "b", "a"]`).
390        cycle: Vec<String>,
391    },
392
393    /// A shaped parameter reached construction with no value: no `default`, no
394    /// caller array, and — for one the document refreshes from a data source
395    /// or a registered handler — no provider data either. esm-spec §10.10 makes
396    /// "a parameter with neither a default nor a supplied value" an error when
397    /// a problem is built. `detail` names what the document says feeds the
398    /// parameter and how a caller supplies it.
399    #[error("E_TREEWALK_MISSING_DATA: {detail}")]
400    MissingData {
401        /// The parameter, as the compiled model names it.
402        parameter: String,
403        /// The whole diagnostic after the code.
404        detail: String,
405    },
406
407    /// An unknown that needs a starting value reached construction with none:
408    /// no `default`, no initial condition or `ic` equation, and no caller `u0`
409    /// (esm-spec §11.4).
410    #[error("E_TREEWALK_MISSING_INITIAL_VALUE: {detail}")]
411    MissingInitialValue {
412        /// The first state slot with no starting value.
413        slot: String,
414        /// The whole diagnostic after the code.
415        detail: String,
416    },
417
418    /// The convenience constructors flattened the input first; that step
419    /// failed.
420    #[error("Flatten failed: {0}")]
421    Flatten(#[from] FlattenError),
422}
423
424impl CompileError {
425    /// Shorthand constructor for [`CompileError::InterpreterBuildError`], the
426    /// catch-all build failure, whose `{ details: format!(…) }` spelling is
427    /// otherwise repeated at every raise site.
428    pub fn build_err(details: impl Into<String>) -> Self {
429        CompileError::InterpreterBuildError {
430            details: details.into(),
431        }
432    }
433}
434
435/// [`CompileError::UnsupportedConstruct`]'s `construct` for a
436/// `continuous_events` entry.
437pub const CONTINUOUS_EVENT: &str = "continuous event";
438/// [`CompileError::UnsupportedConstruct`]'s `construct` for a `discrete_events`
439/// entry.
440pub const DISCRETE_EVENT: &str = "discrete event";
441/// [`CompileError::UnsupportedConstruct`]'s `construct` for an equation whose
442/// LHS is an expression ([`crate::classification::LhsForm::Expression`]).
443pub const IMPLICIT_EQUATION: &str = "implicit equation";
444/// [`CompileError::UnsupportedConstruct`]'s `construct` for a parameter whose
445/// `update.kind` is `"wiener"`: it makes the document an SDE, which no Rust
446/// evaluator integrates.
447pub const WIENER_NOISE: &str = "Wiener noise";
448/// The array runtime (`crate::simulate_array`), as a refusal names it.
449pub const ARRAY_EVALUATOR: &str = "Rust array evaluator";
450
451/// The refusal of an event by `evaluator`: `construct` is [`CONTINUOUS_EVENT`]
452/// or [`DISCRETE_EVENT`], and the message names the event.
453pub fn event_refusal(
454    construct: &'static str,
455    evaluator: &'static str,
456    name: Option<&str>,
457) -> CompileError {
458    CompileError::UnsupportedConstruct {
459        construct,
460        evaluator,
461        detail: name.map_or_else(|| "(unnamed)".to_string(), |n| format!("'{n}'")),
462    }
463}
464
465/// The refusal of a continuous event by `evaluator`, naming the event.
466pub fn continuous_event_refusal(evaluator: &'static str, name: Option<&str>) -> CompileError {
467    event_refusal(CONTINUOUS_EVENT, evaluator, name)
468}
469
470/// The refusal of a discrete event by `evaluator`, naming the event.
471pub fn discrete_event_refusal(evaluator: &'static str, name: Option<&str>) -> CompileError {
472    event_refusal(DISCRETE_EVENT, evaluator, name)
473}
474
475/// The first event `model` or any of its inline subsystems declares, a
476/// continuous one before a discrete one: its construct ([`CONTINUOUS_EVENT`] or
477/// [`DISCRETE_EVENT`]) and its name (`None` for an unnamed one). The subsystems
478/// are searched in their raw JSON: mounting keeps only their variables and
479/// equations, and `flatten` does not lift a subsystem's events into the
480/// flattened system, so no check on the flattened event lists can see one.
481pub(crate) fn first_event(model: &crate::types::Model) -> Option<(&'static str, Option<String>)> {
482    if let Some(event) = model.continuous_events.as_ref().and_then(|e| e.first()) {
483        return Some((CONTINUOUS_EVENT, event.name.clone()));
484    }
485    if let Some(event) = model.discrete_events.as_ref().and_then(|e| e.first()) {
486        return Some((DISCRETE_EVENT, event.name.clone()));
487    }
488    model
489        .subsystems
490        .as_ref()
491        .and_then(|subs| subs.values().find_map(first_event_in_json))
492}
493
494/// [`first_event`] over every model and reaction system in `file`, subsystems
495/// included.
496pub(crate) fn first_event_in_file(
497    file: &crate::types::EsmFile,
498) -> Option<(&'static str, Option<String>)> {
499    let in_models = file
500        .models
501        .as_ref()
502        .and_then(|models| models.values().find_map(first_event));
503    in_models.or_else(|| {
504        file.reaction_systems.as_ref().and_then(|systems| {
505            systems.values().find_map(|rs| {
506                if let Some(event) = rs.continuous_events.as_ref().and_then(|e| e.first()) {
507                    return Some((CONTINUOUS_EVENT, event.name.clone()));
508                }
509                if let Some(event) = rs.discrete_events.as_ref().and_then(|e| e.first()) {
510                    return Some((DISCRETE_EVENT, event.name.clone()));
511                }
512                rs.subsystems
513                    .as_ref()
514                    .and_then(|subs| subs.values().find_map(first_event_in_json))
515            })
516        })
517    })
518}
519
520fn first_event_in_json(value: &serde_json::Value) -> Option<(&'static str, Option<String>)> {
521    for (key, construct) in [
522        ("continuous_events", CONTINUOUS_EVENT),
523        ("discrete_events", DISCRETE_EVENT),
524    ] {
525        if let Some(event) = value
526            .get(key)
527            .and_then(|v| v.as_array())
528            .and_then(|events| events.first())
529        {
530            let name = event.get("name").and_then(|n| n.as_str());
531            return Some((construct, name.map(str::to_string)));
532        }
533    }
534    value
535        .get("subsystems")
536        .and_then(|s| s.as_object())
537        .and_then(|subs| subs.values().find_map(first_event_in_json))
538}
539
540/// The first equation that constrains its operands only implicitly, including a
541/// time derivative of an expression (`D(a + b) ~ 3`), which credits no state. An
542/// `ic` LHS is an initial condition, and a spatial-derivative LHS is a rewrite
543/// target the `unlowered_operator` gate reports; neither is counted.
544pub fn first_implicit_equation(
545    equations: &[crate::types::Equation],
546) -> Option<&crate::types::Equation> {
547    use crate::classification::{LhsFormRef, lhs_form_ref};
548    use crate::types::Expr;
549    equations.iter().find(|eq| {
550        let structural = matches!(
551            &eq.lhs,
552            Expr::Operator(n) if matches!(n.op.as_str(), "ic" | "grad" | "div" | "laplacian")
553                || (n.op == "D" && n.wrt.as_deref().is_some_and(|w| w != "t"))
554        );
555        !structural && matches!(lhs_form_ref(&eq.lhs), LhsFormRef::Expression)
556    })
557}
558
559/// The first unknown carrying BOTH a derivative equation and a bare-LHS one,
560/// with the two competing equations (esm-spec §4.9.4).
561///
562/// The search is over EQUATION SHAPES, not over any state/observed split: the
563/// derivative side accepts every spelling [`crate::classification::lhs_form`]
564/// unwraps to a [`LhsForm::Derivative`](crate::classification::LhsForm), while
565/// the competing definition must be a BARE variable LHS. An indexed LHS
566/// (`u[i] ~ …`) is deliberately not counted here — it writes one cell of `u`
567/// rather than redefining the whole unknown — which keeps this to the shape
568/// Python's `_assert_no_doubly_defined_state` refuses, so the two bindings
569/// refuse the same documents.
570///
571/// Returns the FIRST such unknown in equation order, so the message is stable
572/// for a document with several.
573pub fn first_doubly_defined_unknown(
574    equations: &[crate::types::Equation],
575) -> Option<(String, &crate::types::Equation, &crate::types::Equation)> {
576    use crate::classification::{LhsFormRef, lhs_form_ref};
577    use crate::types::Expr;
578    use std::collections::HashMap;
579
580    let mut bare: HashMap<&str, &crate::types::Equation> = HashMap::new();
581    for eq in equations {
582        if let Expr::Variable(name) = &eq.lhs {
583            bare.entry(name.as_str()).or_insert(eq);
584        }
585    }
586    if bare.is_empty() {
587        return None;
588    }
589    let mut differential: HashMap<&str, &crate::types::Equation> = HashMap::new();
590    for eq in equations {
591        if let LhsFormRef::Derivative(name) = lhs_form_ref(&eq.lhs) {
592            differential.entry(name).or_insert(eq);
593        }
594    }
595    equations.iter().find_map(|eq| {
596        let LhsFormRef::Derivative(name) = lhs_form_ref(&eq.lhs) else {
597            return None;
598        };
599        let diff = *differential.get(name)?;
600        let alg = *bare.get(name)?;
601        Some((name.to_string(), diff, alg))
602    })
603}
604
605/// The refusal of the doubly-defined unknown [`first_doubly_defined_unknown`]
606/// found, naming the unknown and both equations.
607pub fn doubly_defined_unknown_refusal(
608    name: &str,
609    differential: &crate::types::Equation,
610    algebraic: &crate::types::Equation,
611) -> CompileError {
612    let render = |eq: &crate::types::Equation| {
613        format!(
614            "{} ~ {}",
615            crate::to_ascii(&eq.lhs),
616            crate::to_ascii(&eq.rhs)
617        )
618    };
619    CompileError::DoublyDefinedUnknown {
620        name: name.to_string(),
621        differential: render(differential),
622        algebraic: render(algebraic),
623    }
624}
625
626/// The refusal of an implicit equation by `evaluator`, naming the equation.
627pub fn implicit_equation_refusal(
628    evaluator: &'static str,
629    equation: &crate::types::Equation,
630) -> CompileError {
631    CompileError::UnsupportedConstruct {
632        construct: IMPLICIT_EQUATION,
633        evaluator,
634        detail: format!(
635            "`{} ~ {}`",
636            crate::to_ascii(&equation.lhs),
637            crate::to_ascii(&equation.rhs)
638        ),
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    #[test]
647    fn unsupported_construct_carries_its_registered_code() {
648        let msg = discrete_event_refusal(ARRAY_EVALUATOR, Some("double")).to_string();
649        assert!(
650            msg.starts_with(&format!(
651                "{}:",
652                crate::diagnostic::codes::UNSUPPORTED_CONSTRUCT
653            )),
654            "{msg}"
655        );
656        assert!(msg.contains("discrete event 'double'"), "{msg}");
657        assert!(msg.contains(ARRAY_EVALUATOR), "{msg}");
658    }
659}