Skip to main content

earthsci_ast/
validate.rs

1//! Top-level validation surface for ESM files.
2//!
3//! This module owns the public [`ValidationResult`] / [`SchemaError`] /
4//! [`StructuralError`] types and the orchestrator entry points
5//! ([`validate`], [`validate_complete`], [`validate_with_schema`]). The
6//! actual checks are delegated to:
7//!
8//! - [`crate::structural`] — equation balance, model references, reactions,
9//!   discrete events, and inter-model dependency cycles.
10//! - [`crate::coupling`] — coupling-entry well-formedness and scoped
11//!   references between systems.
12//!
13//! The [`SystemInfo`] map produced by [`build_system_reference_map`] is the
14//! shared input both submodules consume.
15
16use crate::EsmFile;
17use crate::parse::{LoadOptions, load_string, load_string_with_options};
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use std::collections::{HashMap, HashSet};
21
22/// Result of structural validation
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ValidationResult {
25    /// Schema validation errors
26    pub schema_errors: Vec<SchemaError>,
27    /// Structural validation errors
28    pub structural_errors: Vec<StructuralError>,
29    /// Dimensional-analysis findings that did not invalidate the document
30    /// (see [`UnitWarning`]).
31    pub unit_warnings: Vec<UnitWarning>,
32    /// Whether validation passed (no schema or structural errors)
33    pub is_valid: bool,
34}
35
36impl ValidationResult {
37    /// Check if there are any errors (schema or structural)
38    pub fn has_errors(&self) -> bool {
39        !self.schema_errors.is_empty() || !self.structural_errors.is_empty()
40    }
41
42    /// Structural errors, cloned (legacy shim: prefer reading
43    /// `structural_errors` — and `schema_errors`, which this does NOT
44    /// include — directly).
45    #[deprecated(note = "read the structural_errors / schema_errors fields directly")]
46    pub fn errors(&self) -> Vec<StructuralError> {
47        self.structural_errors.clone()
48    }
49}
50
51/// A dimensional-analysis finding surfaced by validation.
52///
53/// Despite the name — kept for wire compatibility, it is the `unit_warnings`
54/// field of the spec's `ValidationResult` (CONFORMANCE_SPEC §3.1) — a
55/// `UnitWarning` is not necessarily advisory. [`code`](Self::code) says whether
56/// the finding states a defect in the FILE or a limit of the ANALYSIS:
57///
58/// * `dimensional_mismatch` — a PROVABLE inconsistency (metres plus kilograms,
59///   an equation whose sides cannot agree).
60/// * `unparseable_unit` — a declared unit string that denotes no real unit.
61/// * `analysis` — the checker could not DETERMINE a dimension (an unknown
62///   variable, a symbolic exponent, an op with no dimensional rule). This
63///   reports what the checker could not conclude, not a defect in the file.
64///
65/// The classification is decided AT THE POINT the finding is raised (never
66/// recovered later from the prose, so rewording a message can never silently
67/// change its severity) — see [`crate::units::UnitSeverity`]. In this binding
68/// the defect-bearing kinds are promoted at that same decision point:
69/// `dimensional_mismatch` becomes a `unit_inconsistency`
70/// [`StructuralError`] and `unparseable_unit` a `unit_parse_error` one, so what
71/// remains here is the non-blocking `analysis` residue. Mirrors Go's
72/// `UnitWarning` (`pkg/earthsci-ast-go/pkg/esm/validate.go`) field for field, so
73/// the two serialize identically.
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct UnitWarning {
76    /// RFC 6901 JSON Pointer to the equation/expression (see
77    /// [`StructuralError::path`]). `""` when the raise site has no pointer.
78    pub path: String,
79    /// Finding kind, and the whole of the severity policy:
80    /// `dimensional_mismatch` | `unparseable_unit` | `analysis`. See the
81    /// `UNIT_FINDING_*` constants in [`crate::units`].
82    pub code: String,
83    /// Human-readable description of the finding.
84    pub message: String,
85    /// Inferred units of the LHS; `""` when the checker did not determine them
86    /// (which is the norm for an `analysis` finding — not determining a
87    /// dimension is what makes it one).
88    pub lhs_units: String,
89    /// Inferred units of the RHS; `""` when not determined.
90    pub rhs_units: String,
91}
92
93/// A schema validation error
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct SchemaError {
96    /// Path to the problematic element
97    pub path: String,
98    /// Error message
99    pub message: String,
100    /// Keyword that failed (e.g., "required", "type", "enum")
101    pub keyword: String,
102}
103
104/// A structural validation error
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct StructuralError {
107    /// Path to the problematic element
108    pub path: String,
109    /// Error code (matching spec codes)
110    pub code: StructuralErrorCode,
111    /// Error message
112    pub message: String,
113    /// Additional error details
114    pub details: serde_json::Value,
115}
116
117/// Error codes for structural validation
118///
119/// Serialized in `snake_case` so the wasm-boundary JSON matches this type's
120/// [`std::fmt::Display`] output and the cross-binding contract in
121/// `tests/invalid/expected_errors.json` (e.g. `undefined_variable`, not the
122/// default PascalCase `UndefinedVariable`).
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum StructuralErrorCode {
126    /// Undefined variable reference
127    UndefinedVariable,
128    /// Number of equations doesn't match state variables
129    EquationCountMismatch,
130    /// Undefined species in reactions
131    UndefinedSpecies,
132    /// Undefined parameter in expressions
133    UndefinedParameter,
134    /// Reaction with both substrates and products null
135    NullReaction,
136    /// An equation whose left-hand side names a parameter (esm-spec §6.3.1)
137    EquationDefinesParameter,
138    /// A left-hand-side subscript bound by no `faq` (esm-spec §6.3.1)
139    UnboundIndexSymbol,
140    /// An event `affects` LHS names a PARAMETER (esm-spec §5.4/§5.5): esm
141    /// 1.0.0 events may affect UNKNOWNS only, and a parameter that changes
142    /// during a run declares its own `update` block instead.
143    EventAffectsParameter,
144    /// Scoped reference cannot be resolved
145    UnresolvedScopedRef,
146    /// Variable in event is not declared
147    EventVarUndeclared,
148    /// Operator referenced but not declared
149    UndefinedOperator,
150    /// A parameter `update` of kind `data` names a `data_sources` entry the
151    /// document does not declare (esm-spec §8.5).
152    DataSourceUndefined,
153    /// System referenced but not declared
154    UndefinedSystem,
155
156    /// Operator variable not available
157    OperatorVariableMissing,
158    /// Circular dependency detected
159    CircularDependency,
160    /// Reaction rate expression has incompatible units for reaction stoichiometry
161    UnitInconsistency,
162    /// A declared `units` string denotes no real unit (esm-spec §4.8.4)
163    UnitParseError,
164    /// An `ic`-op equation placed inside a reaction system's `constraint_equations`
165    IcInReactionSystem,
166    /// A `variable_map` expression transform carries a `factor` (esm-spec
167    /// §10.4: the expression spells its own arithmetic — fold scaling into it)
168    FactorWithExpressionTransform,
169    /// A subsystem `ref` (esm-spec §4.7) that could not be resolved — a missing
170    /// file, a remote URL, a cycle, or an otherwise unreadable document.
171    UnresolvedSubsystemRef,
172    /// A subsystem `ref` that resolved to a file NOT containing exactly one
173    /// top-level system (zero or several), so which system to mount is ambiguous.
174    AmbiguousSubsystemRef,
175    /// A §4.7 `{ref}` mount at a form this binding does not implement — a
176    /// top-level `reaction_systems.<k>` `{ref}`.
177    MountFormUnsupported,
178    /// A `variable_map` `identity` coupling whose `from`/`to` variables carry
179    /// declared, non-empty, and DIFFERING units (esm-spec §4.7.6). Static mirror
180    /// of the flatten-time [`crate::flatten::FlattenError::DomainUnitMismatch`].
181    DomainUnitMismatch,
182    /// A `faq` value-equality `join` whose key column ranges over a
183    /// categorical index set carrying a FLOAT or NULL member (RFC
184    /// semiring-faq-unified-ir §5.3 / §5.7 rule 1): floats are not portably
185    /// equality-comparable and a null key is unmatchable.
186    JoinKeyInvalidType,
187    /// A `join.on` key whose range symbol the document does not determine
188    /// (CONFORMANCE_SPEC §5.5.8).
189    JoinSideAmbiguous,
190    /// A `join.syms` entry that is not a range symbol of the node.
191    JoinSymsUnknownSymbol,
192    /// A `faq` whose range iterates a `kind: "ragged"` index set but whose body
193    /// never reads that set's `values` array (esm-spec §4.3.1 "Ragged ranges").
194    RaggedValuesNotGathered,
195    /// A value-invention `faq` (`distinct: true`) whose `key`/`expr` reads
196    /// a model STATE variable, so the cadence partition classes it CONTINUOUS —
197    /// relational work is forbidden on the per-step hot path (RFC
198    /// semiring-faq-unified-ir §6.1; CONFORMANCE_SPEC.md §5.7.6 guard 2).
199    RelationalNodeInContinuous,
200    /// A causal self-read (esm-spec §4.3.1.1) that is not a well-founded
201    /// strictly-earlier read along exactly one axis: a provably same-cell or
202    /// forward read, an index that is not affine in its frame symbol with
203    /// coefficient 1, self-reads disagreeing on the axis, or a bare read of the
204    /// variable inside its own defining RHS.
205    RecurrenceNotWellfounded,
206    /// A causal self-read (esm-spec §4.3.1.1) the runtime cannot restrict to a
207    /// single cell: reached through a `makearray` region value or a
208    /// `reshape`/`transpose`/`concat` operand, or in an equation whose RHS is
209    /// not a `faq` over the variable's own axes. The READ is causal; the
210    /// CARRIER cannot sequence it.
211    RecurrenceUnsupportedForm,
212    /// A `faq` `ranges` entry `{ "from": NAME }` whose NAME is not a key
213    /// of the document `index_sets` registry (RFC semiring-faq-unified-ir §5.2):
214    /// no implicit interval is inferred for an undeclared name.
215    UndefinedIndexSet,
216    /// A `broadcast` node whose `fn` field is absent, does not name a scalar
217    /// operator in the registry, or is applied to an argument count that
218    /// operator does not accept (esm-spec §4.3.4: "the `fn` value MUST name a
219    /// scalar operator … loading MUST fail").
220    ///
221    /// `broadcast` is the one op whose OPERATOR is data — the node says
222    /// `broadcast` and the arithmetic is named by a sibling string field — so
223    /// no amount of `op`-keyed checking reaches it. Until this code existed,
224    /// `{"op":"broadcast","fn":"not_a_real_op","args":[x]}` validated clean and
225    /// then evaluated to `x` (issue #101).
226    InvalidBroadcastFn,
227    /// A BARE array-level expression whose operand is declared over an index set
228    /// the result does not have (esm-spec §4.3.4). Operands of an array-level
229    /// expression align by index-set NAME, so an operand declared over a SUBSET
230    /// of the result's index sets broadcasts along the missing ones; one
231    /// carrying an index set the result does not carry has no axis to align to.
232    /// Both shapes are declared, so this is decidable here — and it must be
233    /// decided here, because the alternative is a positional flatten that
234    /// produces plausible, non-`NaN`, zero-padded garbage.
235    ArrayShapeMismatch,
236    /// A dependency cycle among a model's OBSERVED unknowns (esm-spec §4.9.6):
237    /// each observed on the cycle is defined in terms of the next, so no
238    /// evaluation order satisfies every definition and the algebraic block is
239    /// not a substitutable one. Decidable from the equations alone, so it is
240    /// decided HERE — before a build reaches the cycle and reports whichever
241    /// name its walk happened to try next (issue #181).
242    ///
243    /// The self-edge of a §4.3.1.1 recurrence CANDIDATE is an ordering *within*
244    /// one variable, not a dependency between two, and is dropped
245    /// (CONFORMANCE_SPEC §5.19.5). Every other self-reference is a cycle of
246    /// length one and is reported here.
247    ObservedCycle,
248    /// A DECLARATION spelled with a globally-scoped name — the document's
249    /// independent variable (`domain.independent_variable`, default `"t"`) or
250    /// the §6.4 `_var` placeholder (esm-spec §4.9.1.1).
251    ///
252    /// Both names are implicitly declared in every component's expression scope
253    /// (§4.9.1) and are resolved BY NAME ahead of the declaration maps —
254    /// `ModelCtx::new` extends `defined_vars` with them, and every evaluator in
255    /// this crate resolves them first — so such a declaration is unreachable:
256    /// the implicit symbol shadows it, not the other way round. That is why it
257    /// is a hard error rather than a lint (issue #200). The reported document
258    /// VALIDATED, a bare build reported the observed as having no defining
259    /// expression, and a build with a subsystem mounted handed every reader of
260    /// `t` the simulation clock, so `log(t)` was `-inf` at `t = 0` and every
261    /// number downstream was finite, plausible and wrong.
262    ///
263    /// Reserved set defined by §4.9.1.1, shared with the sibling
264    /// `reserved_index_symbol` binder rule, and it follows the
265    /// document: renaming the independent variable moves the rejection onto the
266    /// new name and frees `t`.
267    ReservedVariableName,
268    /// An inline test's `initial_conditions` / `parameter_overrides` key that
269    /// matches no declared name under the esm-spec §6.6.2 override-key rules.
270    UnknownOverrideKey,
271    /// An assertion whose form does not match the declared rank of the variable
272    /// it names (esm-spec §6.6.5).
273    AssertionRankMismatch,
274    /// Inline ARRAY data as the `default` of a variable that declares no
275    /// `shape` — omitted, null or empty (esm-spec §6.3).
276    ///
277    /// Inline array data is a SHAPED variable's value: its nesting is matched
278    /// against the declared shape, so with no shape there is nothing for it to
279    /// fill and no scalar reading of it. Rejected at the declaration so the
280    /// document fails to load instead of reaching a runtime that would have to
281    /// drop the variable from scope or fabricate a value for it.
282    ArrayDefaultWithoutShape,
283}
284
285use crate::diagnostic::codes;
286
287impl std::fmt::Display for StructuralErrorCode {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        let s = match self {
290            Self::UndefinedVariable => codes::UNDEFINED_VARIABLE,
291            Self::EquationCountMismatch => codes::EQUATION_COUNT_MISMATCH,
292            Self::UndefinedSpecies => codes::UNDEFINED_SPECIES,
293            Self::UndefinedParameter => codes::UNDEFINED_PARAMETER,
294            Self::NullReaction => codes::NULL_REACTION,
295            Self::EquationDefinesParameter => codes::EQUATION_DEFINES_PARAMETER,
296            Self::UnboundIndexSymbol => codes::UNBOUND_INDEX_SYMBOL,
297            Self::EventAffectsParameter => codes::EVENT_AFFECTS_PARAMETER,
298            Self::UnresolvedScopedRef => codes::UNRESOLVED_SCOPED_REF,
299            Self::EventVarUndeclared => codes::EVENT_VAR_UNDECLARED,
300            Self::UndefinedOperator => codes::UNDEFINED_OPERATOR,
301            Self::DataSourceUndefined => codes::DATA_SOURCE_UNDEFINED,
302            Self::UndefinedSystem => codes::UNDEFINED_SYSTEM,
303            Self::OperatorVariableMissing => codes::OPERATOR_VARIABLE_MISSING,
304            Self::CircularDependency => codes::CIRCULAR_DEPENDENCY,
305            Self::UnitInconsistency => codes::UNIT_INCONSISTENCY,
306            Self::UnitParseError => codes::UNIT_PARSE_ERROR,
307            Self::IcInReactionSystem => codes::IC_IN_REACTION_SYSTEM,
308            Self::FactorWithExpressionTransform => codes::FACTOR_WITH_EXPRESSION_TRANSFORM,
309            Self::UnresolvedSubsystemRef => codes::UNRESOLVED_SUBSYSTEM_REF,
310            Self::AmbiguousSubsystemRef => codes::AMBIGUOUS_SUBSYSTEM_REF,
311            Self::MountFormUnsupported => codes::MOUNT_FORM_UNSUPPORTED,
312            Self::DomainUnitMismatch => codes::DOMAIN_UNIT_MISMATCH,
313            Self::JoinKeyInvalidType => codes::JOIN_KEY_INVALID_TYPE,
314            Self::JoinSideAmbiguous => codes::JOIN_SIDE_AMBIGUOUS,
315            Self::JoinSymsUnknownSymbol => codes::JOIN_SYMS_UNKNOWN_SYMBOL,
316            Self::RelationalNodeInContinuous => codes::RELATIONAL_NODE_IN_CONTINUOUS,
317            Self::RaggedValuesNotGathered => codes::RAGGED_VALUES_NOT_GATHERED,
318            Self::RecurrenceNotWellfounded => codes::RECURRENCE_NOT_WELLFOUNDED,
319            Self::RecurrenceUnsupportedForm => codes::RECURRENCE_UNSUPPORTED_FORM,
320            Self::UndefinedIndexSet => codes::UNDEFINED_INDEX_SET,
321            Self::InvalidBroadcastFn => codes::INVALID_BROADCAST_FN,
322            Self::ArrayShapeMismatch => codes::ARRAY_SHAPE_MISMATCH,
323            Self::ObservedCycle => codes::OBSERVED_CYCLE,
324            Self::ReservedVariableName => codes::RESERVED_VARIABLE_NAME,
325            Self::UnknownOverrideKey => codes::UNKNOWN_OVERRIDE_KEY,
326            Self::AssertionRankMismatch => codes::ASSERTION_RANK_MISMATCH,
327            Self::ArrayDefaultWithoutShape => codes::ARRAY_DEFAULT_WITHOUT_SHAPE,
328        };
329        write!(f, "{s}")
330    }
331}
332
333/// Perform structural validation on an ESM file
334///
335/// **Note**: This function performs ONLY structural validation, not schema validation.
336/// For comprehensive validation (both schema and structural), use `validate_text()` instead.
337///
338/// This function runs the structural checks (delegating to
339/// [`crate::structural`] and [`crate::coupling`]):
340/// - All variable references are defined (including scoped refs resolved via
341///   the subsystem hierarchy)
342/// - Equation-unknown balance (ODE count vs. state variables)
343/// - Observed variables carry expressions
344/// - Discrete/continuous event references
345/// - Reaction species/stoichiometry consistency
346/// - Coupling-entry well-formedness
347/// - Dimensional consistency of equations — reported as `unit_warnings`,
348///   never as errors
349///
350/// # Arguments
351///
352/// * `esm_file` - The ESM file to validate (already parsed and schema-validated)
353///
354/// # Returns
355///
356/// * `ValidationResult` - Structural validation results (schema_errors will always be empty)
357///
358/// # Examples
359///
360/// ```rust
361/// use earthsci_ast::{validate, load_string, EsmFile, Metadata};
362///
363/// let json_str = r#"
364/// {
365///   "esm": "1.0.0",
366///   "metadata": {"name": "test"},
367///   "models": {"simple": {"variables": {}, "equations": []}}
368/// }
369/// "#;
370///
371/// // First load and parse (includes schema validation)
372/// let esm_file = load_string(json_str).unwrap();
373///
374/// // Then do structural validation
375/// let result = validate(&esm_file);
376/// assert!(result.is_valid);
377/// assert!(result.schema_errors.is_empty()); // Always empty for this function
378/// ```
379pub fn validate(esm_file: &EsmFile) -> ValidationResult {
380    validate_scoped(esm_file, ValidationScope::Full)
381}
382
383/// Which findings [`validate_scoped`] looks for.
384#[derive(Clone, Copy, Debug, PartialEq, Eq)]
385pub(crate) enum ValidationScope {
386    /// Every finding: [`validate`].
387    Full,
388    /// The reference-integrity findings a build refuses on (an undeclared
389    /// variable, parameter, species, system, index set or data source, an
390    /// unresolved scoped reference, an undeclared event variable, a missing
391    /// required field), with the same paths and in the same order as
392    /// [`validate`] reports them; the checks that report only other codes are
393    /// skipped.
394    References,
395}
396
397/// [`validate`], limited to `scope`.
398pub(crate) fn validate_scoped(esm_file: &EsmFile, scope: ValidationScope) -> ValidationResult {
399    let schema_errors = Vec::new();
400    let mut structural_errors = Vec::new();
401    let mut unit_warnings = Vec::new();
402
403    // First validate schema if we have access to JSON
404    // Note: In practice, this would be called with the original JSON string
405    // For now, we focus on structural validation
406
407    // Build system reference map for scoped reference validation
408    let system_refs = build_system_reference_map(esm_file);
409    // The units of every top-level scoped reference, parsed once for all
410    // components (each layers its own names over it).
411    let document_units = crate::units::document_scoped_units(esm_file);
412
413    // Validate models
414    if let Some(ref models) = esm_file.models {
415        for (model_name, model) in models {
416            crate::structural::validate_model(
417                esm_file,
418                model_name,
419                model,
420                &system_refs,
421                &document_units,
422                scope,
423                &mut structural_errors,
424                &mut unit_warnings,
425            );
426        }
427
428        // Check for circular dependencies between models
429        if scope == ValidationScope::Full {
430            crate::structural::check_circular_dependencies_in_models(
431                models,
432                &mut structural_errors,
433            );
434        }
435    }
436
437    // Validate reaction systems
438    if let Some(ref reaction_systems) = esm_file.reaction_systems {
439        for (rs_name, rs) in reaction_systems {
440            crate::structural::validate_reaction_system(
441                esm_file,
442                rs_name,
443                rs,
444                &system_refs,
445                &document_units,
446                &mut structural_errors,
447            );
448        }
449    }
450
451    // An inline test's assertion targets, override keys and assertion ranks
452    // (esm-spec §6.6.2, §6.6.3, §6.6.5).
453    crate::inline_test_checks::validate_inline_tests(esm_file, &mut structural_errors);
454
455    // A parameter `update` of kind `data` names a `data_sources` entry, which
456    // MUST resolve (esm-spec §8.5, `data_source_undefined`). From esm 1.0.0 a
457    // source is not a component, so an `update.source` is the only place a
458    // source name can appear — and the only place it can be wrong.
459    crate::structural::validate_data_source_references(esm_file, &mut structural_errors);
460
461    // Validate coupling
462    if let Some(ref coupling) = esm_file.coupling {
463        crate::coupling::validate_coupling(
464            coupling,
465            &system_refs,
466            esm_file,
467            &mut structural_errors,
468        );
469    }
470    // esm-spec §10.10.3: a mis-bound `coupling_import` is a bad edge, and the
471    // coupled-system reference check MUST see the expanded edges.
472    crate::coupling::validate_imported_coupling(esm_file, &system_refs, &mut structural_errors);
473
474    let is_valid = schema_errors.is_empty() && structural_errors.is_empty();
475
476    ValidationResult {
477        schema_errors,
478        structural_errors,
479        unit_warnings,
480        is_valid,
481    }
482}
483
484/// Validate an ESM file completely (schema + structural validation)
485///
486/// This is the main validation function that performs both schema and structural validation.
487/// Most users should use this function instead of the lower-level `validate()`.
488///
489/// # Arguments
490///
491/// * `json_str` - The original JSON string to validate
492/// * `base_path` - Directory anchoring relative §4.7 subsystem refs and §9.7
493///   template-import refs (esm-spec §9.7.2). `None` anchors them at the process
494///   current directory; a caller that loaded the
495///   document from a known file MUST pass the file's own directory so relative
496///   refs resolve the same way `load_path` resolves them — otherwise a valid
497///   document with relative refs is wrongly rejected.
498///
499/// # Returns
500///
501/// * `ValidationResult` - Comprehensive validation results with both schema and structural errors
502pub fn validate_text(json_str: &str, base_path: Option<&std::path::Path>) -> ValidationResult {
503    // First try to parse the JSON and ESM file, anchoring relative refs at the
504    // caller-provided base directory (mirrors Python's `validate(base_path=…)`).
505    let loaded = match base_path {
506        Some(base) => load_string_with_options(
507            json_str,
508            &LoadOptions {
509                base_path: Some(base.to_path_buf()),
510                ..Default::default()
511            },
512        ),
513        None => load_string(json_str),
514    };
515    match loaded {
516        Ok(esm_file) => {
517            // If parsing/schema validation succeeded, do structural validation
518            validate_with_schema(json_str, &esm_file)
519        }
520        Err(e) => {
521            // Load failed — but a load failure is usually a SCHEMA violation, and
522            // the wire contract (CONFORMANCE_SPEC.md §7.1.2) wants ONE record per
523            // violation, not a single collapsed blob. Re-parse the raw JSON and
524            // enumerate the individual schema errors so each pinned `(keyword,
525            // path)` surfaces. Only if the JSON itself is unparseable, or the
526            // document is schema-valid but failed a LATER load stage (structural /
527            // ref resolution), do we fall back to a single diagnostic record.
528            let schema_errors = match serde_json::from_str::<Value>(json_str) {
529                Ok(json_value) => {
530                    let mut errs = crate::parse::collect_schema_errors(&json_value);
531                    if errs.is_empty() {
532                        errs.push(SchemaError {
533                            path: "".to_string(),
534                            message: format!("Failed to load ESM file: {e}"),
535                            keyword: "parse".to_string(),
536                        });
537                    }
538                    errs
539                }
540                Err(je) => vec![SchemaError {
541                    path: "".to_string(),
542                    message: format!("Invalid JSON: {je}"),
543                    keyword: "format".to_string(),
544                }],
545            };
546            // A load rejection must STILL surface its structured `(code, path)`
547            // structural findings (CONFORMANCE_SPEC §7.1.2) rather than collapsing
548            // to an EMPTY `structural_errors` — otherwise a document rejected at
549            // load records `is_valid:false` with no structural records and every
550            // pin on it silently misses. Some structural defects (an undeclared
551            // event target, an invalid discrete parameter, a coupling cycle, an
552            // unresolvable coupling scoped ref) reject the load BEFORE the typed
553            // `validate()` pass runs, yet the document is otherwise deserializable
554            // — so run the typed structural pass on a best-effort raw parse and
555            // populate `structural_errors` from it. This never flips the verdict:
556            // `is_valid` stays false; it only recovers the `(code, path)` records.
557            // (Mirrors the conformance runner's recovery in `bin/esm.rs`.)
558            let structural_errors = match serde_json::from_str::<EsmFile>(json_str) {
559                Ok(esm_file) => validate(&esm_file).structural_errors,
560                Err(_) => vec![],
561            };
562            ValidationResult {
563                schema_errors,
564                structural_errors,
565                unit_warnings: vec![],
566                is_valid: false,
567            }
568        }
569    }
570}
571
572/// Deprecated alias of [`validate_text`].
573///
574/// API_SPEC.md §8 item 13 makes `validate` take a typed document everywhere
575/// and names the text convenience `validate_text`. This alias is deprecated
576/// and will be removed at the next major (API_SPEC.md §10).
577#[deprecated(
578    since = "0.2.0",
579    note = "renamed to `validate_text` (API_SPEC.md §8 item 13)"
580)]
581pub fn validate_complete(json_str: &str, base_path: Option<&std::path::Path>) -> ValidationResult {
582    validate_text(json_str, base_path)
583}
584
585/// Validate an ESM file including schema validation
586///
587/// This function combines schema and structural validation.
588/// Note: Consider using `validate_text()` instead for a simpler API.
589pub fn validate_with_schema(json_str: &str, esm_file: &EsmFile) -> ValidationResult {
590    let mut schema_errors = Vec::new();
591    let mut structural_errors = Vec::new();
592    let mut unit_warnings = Vec::new();
593
594    // Schema validation
595    match serde_json::from_str::<Value>(json_str) {
596        Err(e) => {
597            schema_errors.push(SchemaError {
598                path: "".to_string(),
599                message: format!("Invalid JSON: {e}"),
600                keyword: "format".to_string(),
601            });
602        }
603        Ok(json_value) => {
604            // One record PER schema violation (RFC-6901 pointer + standard
605            // keyword), not a single collapsed blob (CONFORMANCE_SPEC.md §7.1.2).
606            schema_errors.extend(crate::parse::collect_schema_errors(&json_value));
607        }
608    }
609
610    // Continue with structural validation even if schema fails
611    let result = validate(esm_file);
612    structural_errors.extend(result.structural_errors);
613    unit_warnings.extend(result.unit_warnings);
614
615    let is_valid = schema_errors.is_empty() && structural_errors.is_empty();
616
617    ValidationResult {
618        schema_errors,
619        structural_errors,
620        unit_warnings,
621        is_valid,
622    }
623}
624
625/// Build a map of all system references for scoped reference resolution.
626///
627/// Shared between [`crate::structural`] and [`crate::coupling`]; not part of
628/// the public API.
629pub(crate) fn build_system_reference_map(esm_file: &EsmFile) -> HashMap<String, SystemInfo> {
630    let mut systems = HashMap::new();
631
632    // Add models
633    if let Some(ref models) = esm_file.models {
634        for (name, model) in models {
635            let variables: HashSet<String> = model.variables.keys().cloned().collect();
636            // Parameter-typed variables also resolve as scoped `Model.param`
637            // refs; expose them in `parameters` too so the reference map
638            // classifies them correctly (structural.rs / coupling.rs read
639            // `system.parameters`).
640            let parameters: HashSet<String> = model
641                .variables
642                .iter()
643                .filter(|(_, v)| v.var_type == crate::VariableType::Parameter)
644                .map(|(k, _)| k.clone())
645                .collect();
646            systems.insert(
647                name.clone(),
648                SystemInfo {
649                    variables,
650                    species: HashSet::new(),
651                    parameters,
652                },
653            );
654
655            // A scoped reference is a dot path of ARBITRARY DEPTH (esm-spec
656            // §4.9.2): `EarthSystem.Atmosphere.Chemistry.O3` walks the
657            // `subsystems` maps down and takes `O3` from the system it lands on.
658            // Registering every nested subsystem under its FULL DOTTED PATH
659            // turns that walk into a single prefix lookup for both the variable
660            // resolver (structural.rs) and the coupling system position
661            // (coupling.rs) — without it, any reference more than two segments
662            // deep is a spurious `unresolved_scoped_ref`/`undefined_system`.
663            register_subsystems(name, model.subsystems.as_ref(), &mut systems);
664        }
665    }
666
667    // Add reaction systems
668    if let Some(ref reaction_systems) = esm_file.reaction_systems {
669        for (name, rs) in reaction_systems {
670            let species: HashSet<String> = rs.species.keys().cloned().collect();
671            // Reaction-system parameters (rate constants, etc.) resolve as
672            // scoped `RS.k1` refs; structural.rs reads `system.parameters`.
673            let parameters: HashSet<String> = rs.parameters.keys().cloned().collect();
674            systems.insert(
675                name.clone(),
676                SystemInfo {
677                    variables: HashSet::new(),
678                    species,
679                    parameters,
680                },
681            );
682
683            // Reaction systems nest too (esm-spec §4.9.2).
684            register_subsystems(name, rs.subsystems.as_ref(), &mut systems);
685        }
686    }
687
688    // Data sources are DELIBERATELY not registered as systems. From esm 1.0.0
689    // (RFC unified-variable-model D2) a source is not a component: it cannot be
690    // a coupling endpoint, a subsystem, or the root of a scoped reference, and
691    // it exposes no variables at all — the consuming PARAMETER names the source
692    // in its `update` and owns the units. Registering one here would make
693    // `<source>.<something>` resolve as a scoped reference, which is exactly
694    // the shape 1.0.0 removes.
695
696    // Add operators
697    if let Some(ref operators) = esm_file.operators {
698        for name in operators.keys() {
699            systems.insert(
700                name.clone(),
701                SystemInfo {
702                    variables: HashSet::new(),
703                    species: HashSet::new(),
704                    parameters: HashSet::new(),
705                },
706            );
707        }
708    }
709
710    systems
711}
712
713/// Register a model's `subsystems` — recursively, at arbitrary depth — under
714/// their full dotted paths (`Parent.Child`, `Parent.Child.Grandchild`, …).
715///
716/// `Model::subsystems` is raw `serde_json::Value` (a subsystem may be an inline
717/// system object or an unresolved `{"ref": …}` edge), so this reads the nested
718/// shape structurally rather than through the typed `Model`. A `{"ref": …}` edge
719/// that has not been resolved contributes no variables — it is registered as an
720/// empty system so that the PATH resolves (the file may legitimately be inlined
721/// later) without claiming to know its contents.
722fn register_subsystems(
723    prefix: &str,
724    subsystems: Option<&indexmap::IndexMap<String, serde_json::Value>>,
725    systems: &mut HashMap<String, SystemInfo>,
726) {
727    let Some(subsystems) = subsystems else {
728        return;
729    };
730    for (child_name, child) in subsystems {
731        let path = format!("{prefix}.{child_name}");
732
733        let variables: HashSet<String> = child
734            .get("variables")
735            .and_then(|v| v.as_object())
736            .map(|m| m.keys().cloned().collect())
737            .unwrap_or_default();
738        let species: HashSet<String> = child
739            .get("species")
740            .and_then(|v| v.as_object())
741            .map(|m| m.keys().cloned().collect())
742            .unwrap_or_default();
743        // Parameter-typed variables also resolve as scoped `<path>.<param>`
744        // refs, mirroring the top-level model case above.
745        let mut parameters: HashSet<String> = child
746            .get("variables")
747            .and_then(|v| v.as_object())
748            .map(|m| {
749                m.iter()
750                    .filter(|(_, v)| v.get("type").and_then(|t| t.as_str()) == Some("parameter"))
751                    .map(|(k, _)| k.clone())
752                    .collect()
753            })
754            .unwrap_or_default();
755        if let Some(params) = child.get("parameters").and_then(|v| v.as_object()) {
756            parameters.extend(params.keys().cloned());
757        }
758
759        systems.insert(
760            path.clone(),
761            SystemInfo {
762                variables,
763                species,
764                parameters,
765            },
766        );
767
768        // Recurse into this subsystem's own `subsystems` map.
769        if let Some(nested) = child.get("subsystems").and_then(|v| v.as_object()) {
770            let nested: indexmap::IndexMap<String, serde_json::Value> =
771                nested.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
772            register_subsystems(&path, Some(&nested), systems);
773        }
774    }
775}
776
777#[derive(Debug, Clone)]
778pub(crate) struct SystemInfo {
779    pub(crate) variables: HashSet<String>,
780    pub(crate) species: HashSet<String>,
781    pub(crate) parameters: HashSet<String>,
782}
783
784#[cfg(test)]
785mod tests {
786    use super::*;
787    use crate::test_support::{ddt, test_file, var};
788    use crate::types::{Equation, ExpressionNode, Metadata, ModelVariable, VariableType};
789    use crate::{Expr, Model};
790    use indexmap::IndexMap;
791
792    #[test]
793    fn test_validate_empty_file() {
794        let esm_file = test_file();
795
796        let result = validate(&esm_file);
797        assert!(result.is_valid);
798        assert!(result.structural_errors.is_empty());
799        assert!(result.schema_errors.is_empty());
800    }
801
802    #[test]
803    fn test_validate_model_with_undefined_variable() {
804        let mut models = IndexMap::new();
805        let mut variables = IndexMap::new();
806        variables.insert("x".to_string(), var(VariableType::Unknown, None));
807
808        models.insert(
809            "test".to_string(),
810            Model {
811                name: Some("Test Model".to_string()),
812                variables,
813                // The undeclared RHS reference should cause an error.
814                equations: vec![ddt("x", Expr::Variable("undefined_var".to_string()))],
815                ..Default::default()
816            },
817        );
818
819        let esm_file = EsmFile {
820            models: Some(models),
821            ..test_file()
822        };
823
824        let result = validate(&esm_file);
825        assert!(!result.is_valid);
826        assert!(!result.structural_errors.is_empty());
827        assert!(
828            result.structural_errors[0]
829                .message
830                .contains("Variable 'undefined_var' referenced in equation is not declared")
831        );
832        assert!(matches!(
833            result.structural_errors[0].code,
834            StructuralErrorCode::UndefinedVariable
835        ));
836    }
837
838    #[test]
839    fn test_equation_count_mismatch() {
840        let mut models = IndexMap::new();
841        let mut variables = IndexMap::new();
842
843        // Define two state variables
844        variables.insert("x".to_string(), var(VariableType::Unknown, None));
845        variables.insert("y".to_string(), var(VariableType::Unknown, None));
846
847        models.insert(
848            "test".to_string(),
849            Model {
850                name: Some("Test Model".to_string()),
851                variables,
852                // Only one equation for two state variables
853                equations: vec![ddt("x", Expr::Variable("x".to_string()))],
854                ..Default::default()
855            },
856        );
857
858        let esm_file = EsmFile {
859            models: Some(models),
860            ..test_file()
861        };
862
863        let result = validate(&esm_file);
864        assert!(!result.is_valid);
865        assert!(!result.structural_errors.is_empty());
866
867        let error = &result.structural_errors[0];
868        assert!(matches!(
869            error.code,
870            StructuralErrorCode::EquationCountMismatch
871        ));
872        // esm-spec §4.9.4: the balance is UNKNOWNS vs EQUATIONS. This model
873        // declares two states and carries one equation, so it is genuinely
874        // under-determined.
875        assert!(
876            error
877                .message
878                .contains("Number of equations (1) does not match number of unknowns (2)"),
879            "unexpected message: {}",
880            error.message
881        );
882    }
883
884    #[test]
885    fn test_validation_result_structure() {
886        // Test that the new ValidationResult structure works as expected
887        let esm_file = test_file();
888
889        let result = validate(&esm_file);
890
891        // Check the new structure
892        assert!(result.is_valid);
893        assert!(result.schema_errors.is_empty());
894        assert!(result.structural_errors.is_empty());
895        assert!(result.unit_warnings.is_empty());
896    }
897
898    #[test]
899    /// esm 1.0.0 removed the variable `expression` field and with it the
900    /// `missing_observed_expr` diagnostic: an unknown with nothing defining it
901    /// is not a malformed declaration but an UNBALANCED SYSTEM, reported by
902    /// `equation_count_mismatch` (esm-spec §4.9.4).
903    fn test_unknown_without_equation() {
904        let mut models = IndexMap::new();
905        let mut variables = IndexMap::new();
906
907        // An unknown that NO equation defines - the defect.
908        variables.insert("total".to_string(), var(VariableType::Unknown, None));
909
910        models.insert(
911            "test".to_string(),
912            Model {
913                name: Some("Test Model".to_string()),
914                variables,
915                equations: vec![], // No equations needed for this test
916                ..Default::default()
917            },
918        );
919
920        let esm_file = EsmFile {
921            models: Some(models),
922            ..test_file()
923        };
924
925        let result = validate(&esm_file);
926        assert!(!result.is_valid);
927        assert_eq!(result.structural_errors.len(), 1);
928        let finding = &result.structural_errors[0];
929        assert!(matches!(
930            finding.code,
931            StructuralErrorCode::EquationCountMismatch
932        ));
933        assert_eq!(
934            finding.message,
935            "Number of equations (0) does not match number of unknowns (1)"
936        );
937        // `missing_equations_for` is what preserves the retired diagnostic's
938        // discriminating power: it names the very unknown that has nothing
939        // defining it.
940        assert_eq!(
941            finding.details["missing_equations_for"],
942            serde_json::json!(["total"])
943        );
944        assert_eq!(finding.details["unknowns"], serde_json::json!(["total"]));
945    }
946
947    #[test]
948    fn test_observed_variable_with_expression() {
949        let mut models = IndexMap::new();
950        let mut variables = IndexMap::new();
951
952        // State variable
953        variables.insert(
954            "x".to_string(),
955            ModelVariable {
956                default: Some(1.0.into()),
957                ..var(VariableType::Unknown, Some("m"))
958            },
959        );
960
961        // Parameter
962        variables.insert(
963            "k".to_string(),
964            ModelVariable {
965                default: Some(0.1.into()),
966                ..var(VariableType::Parameter, Some("1/s"))
967            },
968        );
969
970        // An OBSERVED unknown: declared `unknown`, and made observed by the
971        // bare-variable-LHS equation added below.
972        variables.insert(
973            "rate".to_string(),
974            ModelVariable {
975                description: Some("Rate of change".to_string()),
976                ..var(VariableType::Unknown, Some("m/s"))
977            },
978        );
979
980        models.insert(
981            "test".to_string(),
982            Model {
983                name: Some("Test Model".to_string()),
984                variables,
985                equations: vec![
986                    ddt("x", Expr::Variable("rate".to_string())),
987                    Equation {
988                        comment: None,
989                        lhs: Expr::Variable("rate".to_string()),
990                        rhs: Expr::operator(ExpressionNode {
991                            op: "*".to_string(),
992                            args: vec![
993                                Expr::Variable("k".to_string()),
994                                Expr::Variable("x".to_string()),
995                            ],
996                            ..Default::default()
997                        }),
998                    },
999                ],
1000                ..Default::default()
1001            },
1002        );
1003
1004        let esm_file = EsmFile {
1005            models: Some(models),
1006            ..test_file()
1007        };
1008
1009        let result = validate(&esm_file);
1010        // Should pass validation - observed variable has expression
1011        assert!(
1012            result.is_valid,
1013            "Validation failed: {:?}",
1014            result.structural_errors
1015        );
1016        assert!(result.structural_errors.is_empty());
1017    }
1018
1019    #[test]
1020    fn test_json_serialization_with_observed_expression() {
1021        // Test that we can serialize and deserialize observed variables with expressions
1022        let json_str = r#"{
1023            "esm": "1.0.0",
1024            "metadata": {
1025                "name": "TestModel",
1026                "description": "Test observed variables with expressions"
1027            },
1028            "models": {
1029                "TestModel": {
1030                    "variables": {
1031                        "x": { "type": "unknown", "units": "m", "default": 1.0 },
1032                        "k": { "type": "parameter", "units": "1/s", "default": 0.1 },
1033                        "rate": {
1034                            "type": "unknown",
1035                            "units": "m/s",
1036                            "description": "Rate of change"
1037                        }
1038                    },
1039                    "equations": [
1040                        {
1041                            "lhs": { "op": "D", "args": ["x"], "wrt": "t" },
1042                            "rhs": "rate"
1043                        },
1044                        {
1045                            "lhs": "rate",
1046                            "rhs": { "op": "*", "args": ["k", "x"] }
1047                        }
1048                    ]
1049                }
1050            }
1051        }"#;
1052
1053        // Parse JSON
1054        let esm_file: EsmFile = serde_json::from_str(json_str).expect("Failed to parse JSON");
1055
1056        // Validate the model
1057        let result = validate(&esm_file);
1058        assert!(
1059            result.is_valid,
1060            "Validation should pass: {:?}",
1061            result.structural_errors
1062        );
1063
1064        // `rate` is DECLARED an unknown and DERIVED observed, by the
1065        // bare-variable-LHS equation that defines it (esm-spec §6.3.1).
1066        let model = esm_file.models.as_ref().unwrap().get("TestModel").unwrap();
1067        let rate_var = model.variables.get("rate").unwrap();
1068        assert_eq!(rate_var.var_type, VariableType::Unknown);
1069        assert_eq!(crate::classification::observed_unknowns(model), ["rate"]);
1070        assert!(
1071            crate::classification::observed_definitions(model).contains_key("rate"),
1072            "the observed unknown's definition is its equation's RHS"
1073        );
1074
1075        // Test serialization back to JSON
1076        let serialized =
1077            serde_json::to_string_pretty(&esm_file).expect("Failed to serialize to JSON");
1078
1079        // Should be able to parse it again
1080        let _reparsed: EsmFile =
1081            serde_json::from_str(&serialized).expect("Failed to reparse serialized JSON");
1082    }
1083
1084    #[test]
1085    fn test_unit_validation() {
1086        let mut models = IndexMap::new();
1087        let mut variables = IndexMap::new();
1088
1089        // State variable with units
1090        variables.insert(
1091            "x".to_string(),
1092            ModelVariable {
1093                default: Some(1.0.into()),
1094                ..var(VariableType::Unknown, Some("m"))
1095            },
1096        );
1097
1098        // Parameter with units
1099        variables.insert(
1100            "k".to_string(),
1101            ModelVariable {
1102                default: Some(0.1.into()),
1103                ..var(VariableType::Parameter, Some("1/s"))
1104            },
1105        );
1106
1107        models.insert(
1108            "test".to_string(),
1109            Model {
1110                name: Some("Test Model".to_string()),
1111                variables,
1112                equations: vec![ddt(
1113                    "x",
1114                    Expr::operator(ExpressionNode {
1115                        op: "*".to_string(),
1116                        args: vec![
1117                            Expr::Variable("k".to_string()),
1118                            Expr::Variable("x".to_string()),
1119                        ],
1120                        ..Default::default()
1121                    }),
1122                )],
1123                ..Default::default()
1124            },
1125        );
1126
1127        let esm_file = EsmFile {
1128            models: Some(models),
1129            ..test_file()
1130        };
1131
1132        let result = validate(&esm_file);
1133        // Should pass validation - units are dimensionally consistent
1134        // LHS: d(m)/dt = m/s, RHS: (1/s) * m = m/s
1135        assert!(
1136            result.is_valid,
1137            "Validation should pass: {:?}",
1138            result.structural_errors
1139        );
1140        assert!(result.structural_errors.is_empty());
1141        // Unit warnings should be empty since dimensions are consistent
1142        assert!(
1143            result.unit_warnings.is_empty(),
1144            "Unit warnings: {:?}",
1145            result.unit_warnings
1146        );
1147    }
1148
1149    #[test]
1150    fn test_unit_validation_mismatch() {
1151        let mut models = IndexMap::new();
1152        let mut variables = IndexMap::new();
1153
1154        // State variable with units
1155        variables.insert(
1156            "x".to_string(),
1157            ModelVariable {
1158                default: Some(1.0.into()),
1159                ..var(VariableType::Unknown, Some("m"))
1160            },
1161        );
1162
1163        // Parameter with incompatible units
1164        variables.insert(
1165            "k".to_string(),
1166            ModelVariable {
1167                default_units: None,
1168                default: Some(0.1.into()),
1169                ..var(VariableType::Parameter, Some("kg")) // mass units (incompatible)
1170            },
1171        );
1172
1173        models.insert(
1174            "test".to_string(),
1175            Model {
1176                name: Some("Test Model".to_string()),
1177                variables,
1178                // Just k, not k*x
1179                equations: vec![ddt("x", Expr::Variable("k".to_string()))],
1180                ..Default::default()
1181            },
1182        );
1183
1184        let esm_file = EsmFile {
1185            models: Some(models),
1186            ..test_file()
1187        };
1188
1189        let result = validate(&esm_file);
1190        // D(x)/dt = k with x in metres and k in kilograms: no time unit can
1191        // reconcile the two sides, so this is a PROVABLE mismatch and therefore
1192        // a hard error rather than a warning.
1193        assert!(
1194            !result.is_valid,
1195            "An unreconcilable derivative equation must fail validation: {result:?}"
1196        );
1197        let unit_errors: Vec<_> = result
1198            .structural_errors
1199            .iter()
1200            .filter(|e| matches!(e.code, StructuralErrorCode::UnitInconsistency))
1201            .collect();
1202        assert_eq!(unit_errors.len(), 1, "{:?}", result.structural_errors);
1203        assert_eq!(unit_errors[0].path, "/models/test/equations/0");
1204    }
1205
1206    #[test]
1207    fn test_unit_validation_integration() {
1208        // Test that unit validation warnings are properly returned from the main validate function
1209        let mut models = IndexMap::new();
1210        let mut variables = IndexMap::new();
1211
1212        // State variable with position units
1213        variables.insert(
1214            "position".to_string(),
1215            ModelVariable {
1216                default: Some(0.0.into()),
1217                ..var(VariableType::Unknown, Some("m"))
1218            },
1219        );
1220
1221        // Parameter with velocity units - should be compatible
1222        variables.insert(
1223            "velocity".to_string(),
1224            ModelVariable {
1225                default: Some(1.0.into()),
1226                ..var(VariableType::Parameter, Some("m/s"))
1227            },
1228        );
1229
1230        models.insert(
1231            "test_model".to_string(),
1232            Model {
1233                name: Some("Test Model".to_string()),
1234                variables,
1235                equations: vec![ddt("position", Expr::Variable("velocity".to_string()))],
1236                ..Default::default()
1237            },
1238        );
1239
1240        let esm_file = EsmFile {
1241            metadata: Metadata {
1242                name: Some("Unit Test".to_string()),
1243                ..Default::default()
1244            },
1245            models: Some(models),
1246            ..Default::default()
1247        };
1248
1249        let result = validate(&esm_file);
1250        // Should pass validation - LHS: d(position)/dt = m/s, RHS: velocity = m/s
1251        assert!(
1252            result.is_valid,
1253            "Validation should pass: {:?}",
1254            result.structural_errors
1255        );
1256        assert!(result.structural_errors.is_empty());
1257        assert!(
1258            result.unit_warnings.is_empty(),
1259            "No unit warnings expected: {:?}",
1260            result.unit_warnings
1261        );
1262    }
1263
1264    #[test]
1265    fn test_transcendental_function_units() {
1266        let mut models = IndexMap::new();
1267        let mut variables = IndexMap::new();
1268
1269        // State variable with units (should cause warning when used in exp)
1270        variables.insert(
1271            "x".to_string(),
1272            ModelVariable {
1273                default: Some(1.0.into()),
1274                ..var(VariableType::Unknown, Some("m"))
1275            },
1276        );
1277
1278        models.insert(
1279            "test".to_string(),
1280            Model {
1281                name: Some("Test Model".to_string()),
1282                variables,
1283                // exp(x) where x has units - should warn
1284                equations: vec![ddt(
1285                    "x",
1286                    Expr::operator(ExpressionNode {
1287                        op: "exp".to_string(),
1288                        args: vec![Expr::Variable("x".to_string())],
1289                        ..Default::default()
1290                    }),
1291                )],
1292                ..Default::default()
1293            },
1294        );
1295
1296        let esm_file = EsmFile {
1297            models: Some(models),
1298            ..test_file()
1299        };
1300
1301        let result = validate(&esm_file);
1302        // `exp(x)` with x in metres is a PROVABLE dimensional inconsistency, so
1303        // it is a hard `unit_inconsistency` error, not a warning — the shared
1304        // corpus pins `units_*.esm` fixtures as `is_valid: false`.
1305        assert!(
1306            !result.is_valid,
1307            "A dimensional argument to exp must fail validation: {result:?}"
1308        );
1309        let unit_errors: Vec<_> = result
1310            .structural_errors
1311            .iter()
1312            .filter(|e| matches!(e.code, StructuralErrorCode::UnitInconsistency))
1313            .collect();
1314        // `D(x)/dt = exp(x)` has TWO independent provable defects: the
1315        // dimensional argument to `exp`, and the resulting equation (which no
1316        // time unit can reconcile). BOTH are reported — propagation no longer
1317        // abandons the equation at the first finding, which is what used to let
1318        // one defect hide another.
1319        assert_eq!(unit_errors.len(), 2, "{:?}", result.structural_errors);
1320        assert!(
1321            unit_errors.iter().any(|e| e
1322                .message
1323                .contains("Argument to 'exp' must be dimensionless")),
1324            "{unit_errors:?}"
1325        );
1326        assert!(
1327            unit_errors
1328                .iter()
1329                .any(|e| e.message.contains("No time unit can reconcile")),
1330            "{unit_errors:?}"
1331        );
1332        assert!(
1333            unit_errors
1334                .iter()
1335                .all(|e| e.path == "/models/test/equations/0")
1336        );
1337    }
1338
1339    #[test]
1340    fn test_validate_vs_validate_text() {
1341        // Test to demonstrate the difference between validate() and validate_text()
1342        // validate() only does structural validation, validate_text() does both
1343
1344        // Create a valid EsmFile structure
1345        let esm_file = test_file();
1346
1347        // JSON that should fail schema validation (has invalid variable type)
1348        let invalid_json = r#"
1349        {
1350            "esm": "1.0.0",
1351            "metadata": {
1352                "name": "test"
1353            },
1354            "models": {
1355                "test_model": {
1356                    "variables": {
1357                        "x": {
1358                            "type": "invalid_type_that_should_fail_schema"
1359                        }
1360                    },
1361                    "equations": []
1362                }
1363            }
1364        }
1365        "#;
1366
1367        // The validate() function - only does structural validation
1368        let result1 = validate(&esm_file);
1369
1370        // The validate_text() function - does both schema and structural validation
1371        let result2 = validate_text(invalid_json, None);
1372
1373        // Correct behavior: validate() should have empty schema_errors (it doesn't check schema)
1374        assert!(
1375            result1.schema_errors.is_empty(),
1376            "validate() should have empty schema_errors because it only does structural validation"
1377        );
1378        assert!(
1379            result1.is_valid,
1380            "validate() should pass structural validation on valid ESM structure"
1381        );
1382
1383        // validate_text() should find schema errors
1384        assert!(
1385            !result2.schema_errors.is_empty(),
1386            "validate_text() should find schema errors"
1387        );
1388        assert!(
1389            !result2.is_valid,
1390            "validate_text() should fail due to schema errors"
1391        );
1392
1393        println!(
1394            "CORRECT BEHAVIOR: validate() found {} schema errors, validate_text() found {} schema errors",
1395            result1.schema_errors.len(),
1396            result2.schema_errors.len()
1397        );
1398    }
1399
1400    #[test]
1401    fn test_validate_text_with_schema_errors() {
1402        // Test the new validate_text function that should detect schema errors
1403        let invalid_json = r#"
1404        {
1405            "esm": "1.0.0",
1406            "metadata": {
1407                "name": "test"
1408            },
1409            "models": {
1410                "test_model": {
1411                    "variables": {
1412                        "x": {
1413                            "type": "invalid_type_that_should_fail_schema"
1414                        }
1415                    },
1416                    "equations": []
1417                }
1418            }
1419        }
1420        "#;
1421
1422        let result = validate_text(invalid_json, None);
1423
1424        // Should detect schema errors
1425        assert!(
1426            !result.is_valid,
1427            "validate_text should detect schema validation failures"
1428        );
1429        assert!(
1430            !result.schema_errors.is_empty(),
1431            "validate_text should find schema errors"
1432        );
1433
1434        // Per CONFORMANCE_SPEC.md §7.1.2 the wire format is one record PER schema
1435        // violation — a standard JSON-Schema keyword plus the RFC-6901 pointer of
1436        // the offending node — not a single collapsed "Failed to load" blob. The
1437        // invalid `type` enum value must surface as an `enum` error at the
1438        // variable's `type` pointer.
1439        assert!(
1440            result
1441                .schema_errors
1442                .iter()
1443                .any(|e| e.keyword == "enum" && e.path == "/models/test_model/variables/x/type"),
1444            "expected a per-error `enum` schema violation at the variable's type pointer; got {:?}",
1445            result.schema_errors
1446        );
1447        // No record should be the old collapsed placeholder.
1448        assert!(
1449            result.schema_errors.iter().all(|e| e.keyword != "parse"),
1450            "schema errors must be enumerated per-violation, not a single `parse` blob: {:?}",
1451            result.schema_errors
1452        );
1453    }
1454
1455    #[test]
1456    fn test_validate_text_with_valid_json() {
1457        // Test validate_text with valid JSON
1458        let valid_json = r#"
1459            {
1460              "esm": "1.0.0",
1461              "metadata": {
1462                "name": "test"
1463              },
1464              "models": {
1465                "test_model": {
1466                  "variables": {
1467                    "x": {
1468                      "type": "unknown",
1469                      "units": "m",
1470                      "default": 1.0
1471                    }
1472                  },
1473                  "equations": [
1474                    {
1475                      "lhs": {
1476                        "op": "D",
1477                        "args": [
1478                          "x"
1479                        ],
1480                        "wrt": "t"
1481                      },
1482                      "rhs": {
1483                        "op": "*",
1484                        "args": [
1485                          0.1,
1486                          "x"
1487                        ]
1488                      }
1489                    }
1490                  ]
1491                }
1492              }
1493            }
1494            "#;
1495
1496        let result = validate_text(valid_json, None);
1497
1498        // Should pass validation
1499        assert!(
1500            result.is_valid,
1501            "validate_text should pass with valid JSON: {result:?}"
1502        );
1503        assert!(
1504            result.schema_errors.is_empty(),
1505            "Should have no schema errors"
1506        );
1507        assert!(
1508            result.structural_errors.is_empty(),
1509            "Should have no structural errors"
1510        );
1511    }
1512
1513    #[test]
1514    fn test_validate_text_recovers_structural_on_load_reject() {
1515        // A document that is schema-valid but rejected at LOAD by a structural
1516        // rule (here: an event `affects` targeting an undeclared variable) must
1517        // still surface its structured `(code, path)` structural findings from
1518        // `validate_text`, NOT collapse to an empty `structural_errors`
1519        // (CONFORMANCE_SPEC §7.1.2). Fixture + pinned shape:
1520        // tests/invalid/event_var_undeclared.esm + expected_errors.json.
1521        let fixture = include_str!("../../../tests/invalid/event_var_undeclared.esm");
1522        let result = validate_text(fixture, None);
1523
1524        assert!(!result.is_valid, "load-rejected fixture must be invalid");
1525        assert!(
1526            !result.structural_errors.is_empty(),
1527            "validate_text must recover typed structural errors on load-reject, got none"
1528        );
1529        // The pinned `(code, path)` record must be present. Per §7.1.2 the pin is
1530        // a REQUIRED SUBSET of what the binding emits, so a load-Err document may
1531        // additionally carry a `keyword:"parse"` load diagnostic in
1532        // `schema_errors` — that is existing behavior and is not asserted here;
1533        // what matters is that the structural pin is recovered rather than lost.
1534        assert!(
1535            result.structural_errors.iter().any(|e| matches!(
1536                e.code,
1537                StructuralErrorCode::EventVarUndeclared
1538            ) && e.path
1539                == "/models/TestModel/continuous_events/0/affects/0/lhs"),
1540            "expected pinned event_var_undeclared @ /models/TestModel/continuous_events/0/affects/0/lhs, got: {:?}",
1541            result.structural_errors
1542        );
1543    }
1544
1545    // -----------------------------------------------------------------------
1546    // Issue #101 — `broadcast.fn` must be validated, not silently discarded.
1547    // -----------------------------------------------------------------------
1548
1549    /// A one-model document whose observed `y` is defined by `expr`.
1550    fn doc_with_observed_expr(expr: &str) -> String {
1551        format!(
1552            r#"{{
1553              "esm": "1.0.0",
1554              "metadata": {{"name": "bcast"}},
1555              "models": {{"M": {{
1556                "variables": {{
1557                  "x": {{"type": "unknown", "units": "1", "default": 1.0}},
1558                  "y": {{"type": "unknown", "units": "1"}}
1559                }},
1560                "equations": [
1561                  {{"lhs": "y", "rhs": {expr}}},
1562                  {{"lhs": {{"op": "D", "args": ["x"], "wrt": "t"}}, "rhs": "y"}}
1563                ]
1564              }}}}
1565            }}"#
1566        )
1567    }
1568
1569    /// Deserialize DIRECTLY, bypassing `load_string()`'s schema pass, and return the
1570    /// `invalid_broadcast_fn` findings `validate()` reports.
1571    ///
1572    /// The bypass is deliberate. The schema already requires `fn` to be PRESENT
1573    /// on a `broadcast` node (`$defs/ExpressionNode/allOf`), so a missing-`fn`
1574    /// document cannot reach `validate()` through `load_string()` — but a document
1575    /// built programmatically, or parsed by a caller that skipped schema
1576    /// validation, can, and `validate()` must not wave it through. What the
1577    /// schema CANNOT express is the value constraint (§4.3.4: `fn` must name a
1578    /// scalar operator, applied at a legal arity); that is what these tests pin.
1579    fn broadcast_findings(expr: &str) -> Vec<StructuralError> {
1580        let file: EsmFile =
1581            serde_json::from_str(&doc_with_observed_expr(expr)).expect("fixture deserializes");
1582        let result = validate(&file);
1583        result
1584            .structural_errors
1585            .into_iter()
1586            .filter(|e| matches!(e.code, StructuralErrorCode::InvalidBroadcastFn))
1587            .collect()
1588    }
1589
1590    /// The headline case from the issue: `fn` names an operator that is not in
1591    /// the registry at all, and `validate()` returned `is_valid: true`.
1592    #[test]
1593    fn unregistered_broadcast_fn_is_a_structural_error() {
1594        let expr = r#"{"op": "broadcast", "fn": "not_a_real_op", "args": ["x"]}"#;
1595        // Through the real `load_string()` — the schema accepts any `fn` STRING, so
1596        // this is exactly the document the issue reported as `is_valid: true`.
1597        let file = crate::parse::load_string(&doc_with_observed_expr(expr)).expect("fixture loads");
1598        let result = validate(&file);
1599        assert!(
1600            !result.is_valid,
1601            "an unregistered `broadcast.fn` must fail validation"
1602        );
1603        let found = broadcast_findings(expr);
1604        assert_eq!(found.len(), 1, "expected one finding, got {found:?}");
1605        assert_eq!(found[0].path, "/models/M/equations/0/rhs");
1606        assert!(
1607            found[0].message.contains("not_a_real_op"),
1608            "the message must name the offending fn: {}",
1609            found[0].message
1610        );
1611    }
1612
1613    /// A MISSING `fn` silently became `+` in all three evaluators. It is
1614    /// rejected at BOTH layers: the schema requires the field to be present, and
1615    /// `validate()` reports it for a document that never went through the schema.
1616    #[test]
1617    fn missing_broadcast_fn_is_rejected_at_both_layers() {
1618        let expr = r#"{"op": "broadcast", "args": ["x", "x"]}"#;
1619        assert!(
1620            crate::parse::load_string(&doc_with_observed_expr(expr)).is_err(),
1621            "the schema requires `fn` on a `broadcast` node"
1622        );
1623        let found = broadcast_findings(expr);
1624        assert_eq!(found.len(), 1, "expected one finding, got {found:?}");
1625        assert_eq!(found[0].details["broadcast_fn"], serde_json::Value::Null);
1626    }
1627
1628    /// §4.3.4 requires a SCALAR operator; the array/tensor ops are not.
1629    #[test]
1630    fn non_scalar_broadcast_fn_is_a_structural_error() {
1631        for f in ["faq", "index", "broadcast", "makearray"] {
1632            let expr = format!(r#"{{"op": "broadcast", "fn": "{f}", "args": ["x"]}}"#);
1633            let found = broadcast_findings(&expr);
1634            assert_eq!(
1635                found.len(),
1636                1,
1637                "fn `{f}`: expected one finding, got {found:?}"
1638            );
1639        }
1640    }
1641
1642    /// An `fn`/`args` arity mismatch is reported too — `min` of one operand and
1643    /// `sin` of two are exactly as wrong as their bare spellings.
1644    #[test]
1645    fn broadcast_fn_arity_mismatch_is_a_structural_error() {
1646        for (f, args) in [
1647            ("min", r#"["x"]"#),
1648            ("sin", r#"["x", "x"]"#),
1649            ("/", r#"["x"]"#),
1650        ] {
1651            let expr = format!(r#"{{"op": "broadcast", "fn": "{f}", "args": {args}}}"#);
1652            let found = broadcast_findings(&expr);
1653            assert_eq!(
1654                found.len(),
1655                1,
1656                "fn `{f}`: expected one finding, got {found:?}"
1657            );
1658            assert_eq!(found[0].details["broadcast_fn"], f);
1659        }
1660    }
1661
1662    /// The legal spellings must stay legal — including the ONE-operand unary
1663    /// form, which is an authored in-tree idiom (`tests/display/
1664    /// structural_ops.json`, `canonicalize.rs`), and the one-operand n-ary
1665    /// form (`tests/property_corpus/expressions/expr_039.json`).
1666    #[test]
1667    fn well_formed_broadcasts_produce_no_finding() {
1668        for expr in [
1669            r#"{"op": "broadcast", "fn": "exp", "args": ["x"]}"#,
1670            r#"{"op": "broadcast", "fn": "-", "args": ["x"]}"#,
1671            r#"{"op": "broadcast", "fn": "neg", "args": ["x"]}"#,
1672            r#"{"op": "broadcast", "fn": "+", "args": ["x"]}"#,
1673            r#"{"op": "broadcast", "fn": "+", "args": ["x", "x"]}"#,
1674            r#"{"op": "broadcast", "fn": "min", "args": ["x", "x"]}"#,
1675            r#"{"op": "broadcast", "fn": "*", "args": ["x", "x", "x"]}"#,
1676            r#"{"op": "broadcast", "fn": "ifelse", "args": ["x", "x", "x"]}"#,
1677        ] {
1678            assert!(
1679                broadcast_findings(expr).is_empty(),
1680                "`{expr}` must remain valid"
1681            );
1682        }
1683    }
1684
1685    /// The check rides the shared expression walker, so it reaches every
1686    /// expression-bearing block — not just equation sides. A `broadcast` buried
1687    /// in an `aggregate.expr` sidecar is found, at the enclosing field's pointer.
1688    #[test]
1689    fn broadcast_fn_is_checked_inside_sidecar_fields() {
1690        let expr = r#"{"op": "faq", "output_idx": ["i"], "args": [],
1691                       "ranges": {"i": [1, 3]},
1692                       "expr": {"op": "broadcast", "fn": "nope", "args": ["x"]}}"#;
1693        let found = broadcast_findings(expr);
1694        assert_eq!(found.len(), 1, "expected one finding, got {found:?}");
1695        assert_eq!(found[0].path, "/models/M/equations/0/rhs");
1696    }
1697}