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}