earthsci-ast 0.6.0

Rust implementation of EarthSciML Abstract Syntax Tree Format
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
//! Build-time error type shared by the simulate pipeline and the non-gated
//! `faq` / `join` resolution passes.
//!
//! [`CompileError`] lives in its own module rather than inside `simulate`
//! because `simulate` (and `simulate_array`) are `#[cfg(not(target_arch =
//! "wasm32"))]` — they pull in the native ODE backend. The `faq` and
//! `join` modules surface build-time resolution failures as `CompileError`
//! but are compiled on every target, including `wasm32-unknown-unknown`.
//! Keeping the type here lets those modules compile for WASM while `simulate`
//! re-exports it unchanged for the native API (`crate::simulate::CompileError`).

use crate::flatten::FlattenError;
use thiserror::Error;

/// Errors raised when building a compiled model from a flattened system.
#[derive(Error, Debug)]
pub enum CompileError {
    /// A model construct this evaluator cannot run: a continuous or discrete
    /// event, an implicit equation (an equation whose LHS is an expression
    /// rather than an unknown, `D(unknown)` or `ic(unknown)`), or a Wiener-noise
    /// parameter. esm-spec §9.6.6
    /// `unsupported_construct`.
    ///
    /// Refused at build rather than skipped: a model run without its event, or
    /// without its residual solved, reports a wrong answer, and nothing in that
    /// answer says a construct was dropped.
    #[error(
        "{code}: {construct} {detail} is not supported by the {evaluator}; \
         refusing the build rather than running the model without it (esm-spec §9.6.6)",
        code = crate::diagnostic::codes::UNSUPPORTED_CONSTRUCT
    )]
    UnsupportedConstruct {
        /// Which construct: [`CONTINUOUS_EVENT`], [`DISCRETE_EVENT`],
        /// [`IMPLICIT_EQUATION`] or [`WIENER_NOISE`].
        construct: &'static str,
        /// Which evaluator refused it, e.g. `"Rust array evaluator"`.
        evaluator: &'static str,
        /// The offending instance: an event's name, or an equation's LHS.
        detail: String,
    },

    /// One unknown carries BOTH a derivative equation and a bare-LHS one
    /// (esm-spec §4.9.4 `equation_count_mismatch`).
    ///
    /// §4.9.4 counts an equation against the unknowns "whichever form its LHS
    /// takes", so `D(x) ~ f` beside `x ~ g` is two equations binding one
    /// unknown: the document is unbalanced, and `validate` reports exactly
    /// that. This crate used to tie-break in favour of the derivative, which
    /// integrates a system free of a constraint the file declares and reports
    /// the trajectory as the answer. The build is the other place that same
    /// document arrives, so it refuses under the same code.
    #[error(
        "{code}: unknown '{name}' is defined twice — by `{differential}` and by \
         `{algebraic}`. esm-spec §4.9.4 counts an equation whichever form its LHS \
         takes, so this system has one more equation than it has unknowns to bind; \
         keeping the derivative and dropping the constraint would run a model the \
         document does not describe. Remove one of the two definitions.",
        code = crate::diagnostic::codes::EQUATION_COUNT_MISMATCH
    )]
    DoublyDefinedUnknown {
        /// The unknown both equations define, spelled as the system spells it.
        name: String,
        /// The derivative equation, rendered `lhs ~ rhs`.
        differential: String,
        /// The competing bare-LHS equation, rendered `lhs ~ rhs`.
        algebraic: String,
    },

    /// The chosen compiler cannot run one of this document's rules
    /// (esm-spec §9.6.6 `compiler_refused_rule`, esm-libraries-spec §2.5.10).
    ///
    /// Raised at CONSTRUCTION, naming the compiler, the rule — an equation or
    /// an observed, component-qualified — the cadence tier the rule would have
    /// run at, and the DEEPEST decline reason reached while trying to lower
    /// it. A refusal, never a fallback: the compiler does not run that rule on
    /// a slower path, because a caller who asked for a compiled program and
    /// got a tree walk has no way to find that out.
    #[error(
        "{code}: compiler '{compiler}' cannot run the {tier}-cadence {kind} '{rule}': {reason}. \
         `native` is strict (esm-libraries-spec §2.5.10) — it refuses rather than demoting the \
         rule to the per-cell oracle; build with `compiler: Some(Compiler::Interpreter)` to run \
         this document on the reference evaluator",
        code = crate::diagnostic::codes::COMPILER_REFUSED_RULE
    )]
    CompilerRefusedRule {
        /// The vocabulary spelling of the compiler that refused.
        compiler: &'static str,
        /// `"observed"` or `"state derivative"`.
        kind: &'static str,
        /// The rule, component-qualified.
        rule: String,
        /// The cadence tier the rule would have run at: `"const"`,
        /// `"discrete"` or `"continuous"`.
        tier: &'static str,
        /// The deepest decline reason reached while trying to lower it.
        reason: String,
    },

    /// The flattened system contains a feature the v1 simulator does not support
    /// (e.g. a join over data-derived columns).
    #[error("Unsupported feature '{feature}': {message}")]
    UnsupportedFeatureError {
        /// Feature name (e.g. `"value-equality join over data-derived columns"`).
        feature: String,
        /// Why this is rejected and what to do about it.
        message: String,
    },

    /// The flattened system still carries a spatial independent variable — a
    /// spatial operator that was never discretized into a `faq` stencil.
    /// Discretized PDEs fold their spatial axis into array dimensions, leaving
    /// `independent_variables == ["t"]`, and simulate fine.
    #[error(
        "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."
    )]
    UnsupportedDimensionalityError {
        /// The actual independent variables found.
        independent_variables: Vec<String>,
    },

    /// The interpreter could not build a callable representation of the
    /// flattened equations.
    #[error("Interpreter build failed: {details}")]
    InterpreterBuildError {
        /// Human-readable failure description.
        details: String,
    },

    /// A rewrite-target operator (a spatial / right-hand-side `D`, or the
    /// optional sugar ops `grad` / `div` / `laplacian` / ...) survived the
    /// lowering fixpoint into an evaluation position (esm-spec §4.2 / §9.6.8).
    /// Such ops carry NO evaluator: a discretization `match` rewrite rule MUST
    /// lower them to a `faq` / `makearray` stencil before evaluation.
    /// The gate fires here — before evaluation, not at load — with the uniform
    /// `unlowered_operator` code that supersedes the former per-binding
    /// `UnreachableSpatialOperatorError` / `UnsupportedDimensionality` errors.
    /// Silently producing zeros (the previous behavior) would mask a broken
    /// pipeline. This format ships no discretization rules — they live in
    /// EarthSciDiscretizations.
    #[error(
        "unlowered_operator: rewrite-target operator '{op}' reached evaluation without being \
         lowered to a stencil by a rewrite rule (esm-spec §4.2 / §9.6.8). Discretization rules \
         live in EarthSciDiscretizations, not this format."
    )]
    UnloweredOperatorError {
        /// The offending operator name (e.g. `"grad"` or `"D"`).
        op: String,
    },

    /// An evaluable-core operator (esm-spec §4.2) applied to the wrong number
    /// of arguments — e.g. `atan2` with one argument, or `min` with fewer than
    /// two. The schema cannot express per-operator arity (`args` is
    /// `minItems: 0`), so this is the gate that keeps a malformed-but-
    /// schema-valid node out of the evaluators, where it would otherwise either
    /// panic on an out-of-bounds index or — worse — be silently assigned two
    /// different values by the per-cell oracle and the vectorized overlay.
    /// See [`crate::op_registry`].
    #[error(
        "invalid_operator_arity: operator '{op}' takes {expected} argument(s), got {got} \
         (esm-spec §4.2)"
    )]
    InvalidOperatorArity {
        /// The offending operator name (e.g. `"atan2"`).
        op: String,
        /// How many arguments the node actually carried.
        got: usize,
        /// What the spec says the operator takes (e.g. `"exactly 2"`).
        expected: String,
    },

    /// A `makearray` node whose `regions` are malformed: either the regions
    /// disagree on rank (a ragged `regions` list), or a bound pair is inverted
    /// beyond the legal empty spelling. Per esm-spec §4.3.2 a pair with
    /// `stop == start - 1` is the canonical **empty** region and is legal;
    /// anything further inverted (`stop < start - 1`) is rejected with
    /// `makearray_region_inverted`.
    #[error("makearray_region_inverted: {reason} (esm-spec §4.3.2)")]
    MakearrayRegionInvalid {
        /// What is wrong with the regions.
        reason: String,
    },

    /// A `broadcast` node whose `fn` field is absent or does not name a scalar
    /// operator (esm-spec §4.3.4). `broadcast` is the one op whose OPERATOR is
    /// data — the node says `broadcast`, the arithmetic is named by a sibling
    /// string — and nothing used to check that string, so a typo'd or missing
    /// `fn` was silently evaluated as `+` (issue #101).
    #[error("invalid_broadcast_fn: {reason}")]
    InvalidBroadcastFn {
        /// Why the `fn` is rejected.
        reason: String,
    },

    /// An evaluable-core operator (esm-spec §4.2) that this interpreter has no
    /// evaluation rule for reached the evaluator.
    ///
    /// These ops are legal in an AST but are *eliminated before* the per-cell
    /// evaluator ever runs: the build-time query ops (`skolem`, `rank`,
    /// `distinct`, `argmin`, `argmax`) are resolved by
    /// [`crate::value_invention`], and the form/lowering ops (`ic`, `true`,
    /// `enum`, `table_lookup`, `apply_expression_template`) are consumed by
    /// their respective lowering passes. One arriving at the evaluator means the
    /// pipeline is broken.
    ///
    /// It is reported rather than evaluated to a NaN sentinel: a silent NaN is
    /// indistinguishable from a legitimate numerical result and would propagate
    /// into the solution, whereas the pipeline stage that should have eliminated
    /// the op is the actual defect.
    ///
    /// Raised by the array runtime's build gate and by the single-expression
    /// [`crate::expression::evaluate`], whose scalar bindings additionally give
    /// the array / tensor and geometry ops no value. The invariant is the same
    /// either way: an unevaluable op is a diagnostic, never a number.
    #[error(
        "unevaluable_operator: operator '{op}' is an evaluable-core op with no evaluation rule \
         in the interpreter this model was built for — it must be eliminated by an earlier \
         pipeline stage (value invention, or a lowering pass), or the document built for a \
         runtime that evaluates it, before evaluation (esm-spec §4.2)"
    )]
    UnevaluableOperatorError {
        /// The offending operator name (e.g. `"skolem"`).
        op: String,
    },

    /// An expression ranges over a `kind: "derived"` index set whose
    /// value-invention producer could not be materialized at build (esm-spec
    /// §4.2 / §9.6.6). Refused rather than contracted as an empty range, which
    /// folds to the additive identity and reads as a plausible 0.
    #[error("{code}: {reason}")]
    ValueInventionRefused {
        /// `derived_index_set_unmaterialized`, or `relational_node_in_continuous`
        /// when the producer reads live state.
        code: &'static str,
        /// The index set, its producer, and why the producer could not run.
        reason: String,
    },

    /// A `table_lookup` node that cannot be lowered to its esm-spec §9.5.3
    /// `interp.linear` / `interp.bilinear` / `index` form — an unknown table,
    /// an axis-key set that does not match the table's declared axes, an
    /// out-of-range `output`, or a table whose `interpolation` and axis count
    /// disagree.
    ///
    /// Raised by [`crate::lower_table_lookup`] during the build rather than by
    /// the evaluator: `unevaluable_operator` would name `table_lookup` without
    /// saying which of the §9.5.5 conditions the document actually trips.
    #[error("{code}: {reason}")]
    TableLookupLowering {
        /// The esm-spec §9.5.5 diagnostic code.
        code: &'static str,
        /// What is wrong with this `table_lookup` (or the table it names).
        reason: String,
    },

    /// `domain.element_type` names a precision this evaluator does not have.
    ///
    /// Reported rather than defaulted to binary64: a document that asks for a
    /// precision and silently gets a different one is the same class of defect
    /// as an `element_type` that is accepted and then ignored.
    #[error(
        "unsupported_element_type: domain.element_type '{element_type}' is not a precision this \
         evaluator implements — use \"Float64\" (the default) or \"Float32\" (esm-spec §11.3)"
    )]
    UnsupportedElementType {
        /// The offending `element_type` spelling.
        element_type: String,
    },

    /// A construct that cannot be evaluated in binary32 appears in a document
    /// declaring `domain.element_type: "Float32"`.
    ///
    /// The alternative — evaluating just that construct in binary64 — is a
    /// silent fallback: the document asked for one precision and part of the
    /// answer would be computed in another, with nothing in the result to say
    /// which part. So it is an error naming the construct.
    #[error(
        "float32_unsupported: {construct} cannot be evaluated under \
         `domain.element_type: \"Float32\"` — {reason}. Remove the construct, or evaluate the \
         document in Float64 (esm-spec §11.3)"
    )]
    Float32Unsupported {
        /// The offending construct, named (e.g. ``operator `intersect_polygon` ``).
        construct: String,
        /// Why binary32 cannot carry it.
        reason: String,
    },

    /// A declared working precision reaches a construct this evaluator cannot
    /// carry it through (esm-spec §11.3.1, §4.7 "Working precision crosses a
    /// mount").
    ///
    /// Refused rather than evaluated in another precision: the declaration
    /// would otherwise be accepted and ignored.
    #[error("element_type_uncarried: {construct} — {reason} (esm-spec §11.3.1)")]
    ElementTypeUncarried {
        /// The construct whose declared precision cannot be carried.
        construct: String,
        /// Why this evaluator cannot carry it.
        reason: String,
    },

    /// One operator's operands carry DIFFERENT declared element types
    /// (`ModelVariable.element_type`, esm-spec §11.3.1).
    ///
    /// Refused rather than resolved by a usual-arithmetic-conversions rule,
    /// because every resolution is a silent answer to a question only the
    /// author can settle. Widening to the wider operand makes a quantity
    /// declared binary32 come out binary64 — the declaration ignored, with
    /// nothing in the result to say so. Narrowing to the narrower destroys the
    /// binary64 operand, which is the very corruption per-variable element
    /// types exist to prevent (a ten-digit key rounded to binary32 collides
    /// with its neighbours). So the mix is an error naming both variables, and
    /// the author states the intent by computing the mixed step into a variable
    /// whose own `element_type` says which precision it is in.
    #[error(
        "mixed_element_type: operator '{op}' mixes operands of different element types — \
         {lhs_name} is {lhs_type} and {rhs_name} is {rhs_type}. One operator cannot be both; \
         neither widening nor narrowing is silently applied. Compute one side into a variable \
         whose own `element_type` states which precision that step lands in, then combine the \
         declared variables (esm-spec §11.3.1)"
    )]
    MixedElementType {
        /// The operator whose operands disagree (or `"equation"` for an
        /// equation whose right-hand side disagrees with its left-hand side).
        op: String,
        /// A variable supplying the first element type.
        lhs_name: String,
        /// That variable's element type.
        lhs_type: String,
        /// A variable supplying the conflicting element type.
        rhs_name: String,
        /// That variable's element type.
        rhs_type: String,
    },

    /// A BARE array-level expression whose operand is declared over an index
    /// set the result does not have (esm-spec §4.3.4).
    ///
    /// Operands of an array-level expression align by index-set NAME: an
    /// operand declared over a SUBSET of the result's index sets broadcasts
    /// along the ones it is missing. An operand carrying an index set the
    /// result does not carry has no axis to align to and no defensible value to
    /// take, so it is rejected rather than reinterpreted positionally — the
    /// positional flatten this replaces produced plausible, non-`NaN`,
    /// zero-padded garbage (issue #100).
    ///
    /// The shapes are fully known statically, so `validate()` reports the same
    /// defect as an `array_shape_mismatch` structural error before any build is
    /// attempted; this is the build-time backstop for a model compiled without
    /// being validated first.
    #[error(
        "array_shape_mismatch: operand '{operand}' of the array-level expression for '{result}' \
         is declared over index set '{axis}', which '{result}' (shape {result_axes:?}) does not \
         have — an operand's index sets must be a subset of the result's (esm-spec §4.3.4)"
    )]
    UnalignableArrayShape {
        /// The offending operand's variable name.
        operand: String,
        /// The index set the operand carries and the result does not.
        axis: String,
        /// The name of the array-level expression's result.
        result: String,
        /// The result's declared index sets, in declaration order.
        result_axes: Vec<String>,
    },

    /// A dependency cycle among the model's OBSERVED unknowns (esm-spec
    /// §4.9.6): each observed on the cycle is defined by an equation whose RHS
    /// names the next, so no evaluation order satisfies every definition.
    ///
    /// The equations decide this on their own, so `validate()` reports the same
    /// defect as an `observed_cycle` structural error before any build is
    /// attempted; this is the build-time backstop for a model compiled without
    /// being validated first — and it is the reason this variant exists at all.
    /// The ordering sweep used to *tolerate* an unorderable rule set, appending
    /// the stuck rules in declaration order so "the build still proceeds"; what
    /// actually proceeded was a materialization pass that read an observed with
    /// no value yet and reported `E_TREEWALK_UNBOUND_NAME` against whichever
    /// name it reached first — routinely an observed that is declared, defined
    /// and referenced perfectly well (issue #181).
    #[error(
        "observed_cycle: dependency cycle among observed variables: {}. Each is defined in \
         terms of the next, so no evaluation order satisfies every definition (esm-spec §4.9.6). \
         `esm validate` reports this cycle before any build.",
        cycle.join(" -> ")
    )]
    ObservedCycle {
        /// The observeds on the cycle, in traversal order, with the entry node
        /// repeated to close it (`["a", "b", "a"]`).
        cycle: Vec<String>,
    },

    /// A shaped parameter reached construction with no value: no `default`, no
    /// caller array, and — for one the document refreshes from a data source
    /// or a registered handler — no provider data either. esm-spec §10.10 makes
    /// "a parameter with neither a default nor a supplied value" an error when
    /// a problem is built. `detail` names what the document says feeds the
    /// parameter and how a caller supplies it.
    #[error("E_TREEWALK_MISSING_DATA: {detail}")]
    MissingData {
        /// The parameter, as the compiled model names it.
        parameter: String,
        /// The whole diagnostic after the code.
        detail: String,
    },

    /// An unknown that needs a starting value reached construction with none:
    /// no `default`, no initial condition or `ic` equation, and no caller `u0`
    /// (esm-spec §11.4).
    #[error("E_TREEWALK_MISSING_INITIAL_VALUE: {detail}")]
    MissingInitialValue {
        /// The first state slot with no starting value.
        slot: String,
        /// The whole diagnostic after the code.
        detail: String,
    },

    /// The convenience constructors flattened the input first; that step
    /// failed.
    #[error("Flatten failed: {0}")]
    Flatten(#[from] FlattenError),
}

impl CompileError {
    /// Shorthand constructor for [`CompileError::InterpreterBuildError`], the
    /// catch-all build failure, whose `{ details: format!(…) }` spelling is
    /// otherwise repeated at every raise site.
    pub fn build_err(details: impl Into<String>) -> Self {
        CompileError::InterpreterBuildError {
            details: details.into(),
        }
    }
}

/// [`CompileError::UnsupportedConstruct`]'s `construct` for a
/// `continuous_events` entry.
pub const CONTINUOUS_EVENT: &str = "continuous event";
/// [`CompileError::UnsupportedConstruct`]'s `construct` for a `discrete_events`
/// entry.
pub const DISCRETE_EVENT: &str = "discrete event";
/// [`CompileError::UnsupportedConstruct`]'s `construct` for an equation whose
/// LHS is an expression ([`crate::classification::LhsForm::Expression`]).
pub const IMPLICIT_EQUATION: &str = "implicit equation";
/// [`CompileError::UnsupportedConstruct`]'s `construct` for a parameter whose
/// `update.kind` is `"wiener"`: it makes the document an SDE, which no Rust
/// evaluator integrates.
pub const WIENER_NOISE: &str = "Wiener noise";
/// The array runtime (`crate::simulate_array`), as a refusal names it.
pub const ARRAY_EVALUATOR: &str = "Rust array evaluator";

/// The refusal of an event by `evaluator`: `construct` is [`CONTINUOUS_EVENT`]
/// or [`DISCRETE_EVENT`], and the message names the event.
pub fn event_refusal(
    construct: &'static str,
    evaluator: &'static str,
    name: Option<&str>,
) -> CompileError {
    CompileError::UnsupportedConstruct {
        construct,
        evaluator,
        detail: name.map_or_else(|| "(unnamed)".to_string(), |n| format!("'{n}'")),
    }
}

/// The refusal of a continuous event by `evaluator`, naming the event.
pub fn continuous_event_refusal(evaluator: &'static str, name: Option<&str>) -> CompileError {
    event_refusal(CONTINUOUS_EVENT, evaluator, name)
}

/// The refusal of a discrete event by `evaluator`, naming the event.
pub fn discrete_event_refusal(evaluator: &'static str, name: Option<&str>) -> CompileError {
    event_refusal(DISCRETE_EVENT, evaluator, name)
}

/// The first event `model` or any of its inline subsystems declares, a
/// continuous one before a discrete one: its construct ([`CONTINUOUS_EVENT`] or
/// [`DISCRETE_EVENT`]) and its name (`None` for an unnamed one). The subsystems
/// are searched in their raw JSON: mounting keeps only their variables and
/// equations, and `flatten` does not lift a subsystem's events into the
/// flattened system, so no check on the flattened event lists can see one.
pub(crate) fn first_event(model: &crate::types::Model) -> Option<(&'static str, Option<String>)> {
    if let Some(event) = model.continuous_events.as_ref().and_then(|e| e.first()) {
        return Some((CONTINUOUS_EVENT, event.name.clone()));
    }
    if let Some(event) = model.discrete_events.as_ref().and_then(|e| e.first()) {
        return Some((DISCRETE_EVENT, event.name.clone()));
    }
    model
        .subsystems
        .as_ref()
        .and_then(|subs| subs.values().find_map(first_event_in_json))
}

/// [`first_event`] over every model and reaction system in `file`, subsystems
/// included.
pub(crate) fn first_event_in_file(
    file: &crate::types::EsmFile,
) -> Option<(&'static str, Option<String>)> {
    let in_models = file
        .models
        .as_ref()
        .and_then(|models| models.values().find_map(first_event));
    in_models.or_else(|| {
        file.reaction_systems.as_ref().and_then(|systems| {
            systems.values().find_map(|rs| {
                if let Some(event) = rs.continuous_events.as_ref().and_then(|e| e.first()) {
                    return Some((CONTINUOUS_EVENT, event.name.clone()));
                }
                if let Some(event) = rs.discrete_events.as_ref().and_then(|e| e.first()) {
                    return Some((DISCRETE_EVENT, event.name.clone()));
                }
                rs.subsystems
                    .as_ref()
                    .and_then(|subs| subs.values().find_map(first_event_in_json))
            })
        })
    })
}

fn first_event_in_json(value: &serde_json::Value) -> Option<(&'static str, Option<String>)> {
    for (key, construct) in [
        ("continuous_events", CONTINUOUS_EVENT),
        ("discrete_events", DISCRETE_EVENT),
    ] {
        if let Some(event) = value
            .get(key)
            .and_then(|v| v.as_array())
            .and_then(|events| events.first())
        {
            let name = event.get("name").and_then(|n| n.as_str());
            return Some((construct, name.map(str::to_string)));
        }
    }
    value
        .get("subsystems")
        .and_then(|s| s.as_object())
        .and_then(|subs| subs.values().find_map(first_event_in_json))
}

/// The first equation that constrains its operands only implicitly, including a
/// time derivative of an expression (`D(a + b) ~ 3`), which credits no state. An
/// `ic` LHS is an initial condition, and a spatial-derivative LHS is a rewrite
/// target the `unlowered_operator` gate reports; neither is counted.
pub fn first_implicit_equation(
    equations: &[crate::types::Equation],
) -> Option<&crate::types::Equation> {
    use crate::classification::{LhsFormRef, lhs_form_ref};
    use crate::types::Expr;
    equations.iter().find(|eq| {
        let structural = matches!(
            &eq.lhs,
            Expr::Operator(n) if matches!(n.op.as_str(), "ic" | "grad" | "div" | "laplacian")
                || (n.op == "D" && n.wrt.as_deref().is_some_and(|w| w != "t"))
        );
        !structural && matches!(lhs_form_ref(&eq.lhs), LhsFormRef::Expression)
    })
}

/// The first unknown carrying BOTH a derivative equation and a bare-LHS one,
/// with the two competing equations (esm-spec §4.9.4).
///
/// The search is over EQUATION SHAPES, not over any state/observed split: the
/// derivative side accepts every spelling [`crate::classification::lhs_form`]
/// unwraps to a [`LhsForm::Derivative`](crate::classification::LhsForm), while
/// the competing definition must be a BARE variable LHS. An indexed LHS
/// (`u[i] ~ …`) is deliberately not counted here — it writes one cell of `u`
/// rather than redefining the whole unknown — which keeps this to the shape
/// Python's `_assert_no_doubly_defined_state` refuses, so the two bindings
/// refuse the same documents.
///
/// Returns the FIRST such unknown in equation order, so the message is stable
/// for a document with several.
pub fn first_doubly_defined_unknown(
    equations: &[crate::types::Equation],
) -> Option<(String, &crate::types::Equation, &crate::types::Equation)> {
    use crate::classification::{LhsFormRef, lhs_form_ref};
    use crate::types::Expr;
    use std::collections::HashMap;

    let mut bare: HashMap<&str, &crate::types::Equation> = HashMap::new();
    for eq in equations {
        if let Expr::Variable(name) = &eq.lhs {
            bare.entry(name.as_str()).or_insert(eq);
        }
    }
    if bare.is_empty() {
        return None;
    }
    let mut differential: HashMap<&str, &crate::types::Equation> = HashMap::new();
    for eq in equations {
        if let LhsFormRef::Derivative(name) = lhs_form_ref(&eq.lhs) {
            differential.entry(name).or_insert(eq);
        }
    }
    equations.iter().find_map(|eq| {
        let LhsFormRef::Derivative(name) = lhs_form_ref(&eq.lhs) else {
            return None;
        };
        let diff = *differential.get(name)?;
        let alg = *bare.get(name)?;
        Some((name.to_string(), diff, alg))
    })
}

/// The refusal of the doubly-defined unknown [`first_doubly_defined_unknown`]
/// found, naming the unknown and both equations.
pub fn doubly_defined_unknown_refusal(
    name: &str,
    differential: &crate::types::Equation,
    algebraic: &crate::types::Equation,
) -> CompileError {
    let render = |eq: &crate::types::Equation| {
        format!(
            "{} ~ {}",
            crate::to_ascii(&eq.lhs),
            crate::to_ascii(&eq.rhs)
        )
    };
    CompileError::DoublyDefinedUnknown {
        name: name.to_string(),
        differential: render(differential),
        algebraic: render(algebraic),
    }
}

/// The refusal of an implicit equation by `evaluator`, naming the equation.
pub fn implicit_equation_refusal(
    evaluator: &'static str,
    equation: &crate::types::Equation,
) -> CompileError {
    CompileError::UnsupportedConstruct {
        construct: IMPLICIT_EQUATION,
        evaluator,
        detail: format!(
            "`{} ~ {}`",
            crate::to_ascii(&equation.lhs),
            crate::to_ascii(&equation.rhs)
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn unsupported_construct_carries_its_registered_code() {
        let msg = discrete_event_refusal(ARRAY_EVALUATOR, Some("double")).to_string();
        assert!(
            msg.starts_with(&format!(
                "{}:",
                crate::diagnostic::codes::UNSUPPORTED_CONSTRUCT
            )),
            "{msg}"
        );
        assert!(msg.contains("discrete event 'double'"), "{msg}");
        assert!(msg.contains(ARRAY_EVALUATOR), "{msg}");
    }
}