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}