use crate::EsmFile;
use crate::op_registry::is_builtin_function_name;
use crate::units::{
ComponentUnitEnv, Unit, UnitEnv, build_unit_env, equation_findings, expression_findings,
parse_unit,
};
use crate::validate::{StructuralError, StructuralErrorCode, SystemInfo, UnitWarning};
use std::collections::{HashMap, HashSet};
#[allow(clippy::too_many_arguments)]
pub(crate) fn validate_model(
esm_file: &EsmFile,
model_name: &str,
model: &crate::Model,
system_refs: &HashMap<String, SystemInfo>,
document_units: &HashMap<String, Unit>,
scope: crate::validate::ValidationScope,
errors: &mut Vec<StructuralError>,
warnings: &mut Vec<UnitWarning>,
) {
let ctx = ModelCtx::new(esm_file, model_name, model, system_refs, document_units);
if scope == crate::validate::ValidationScope::References {
validate_model_references(&ctx, errors);
return;
}
check_reserved_declaration_names(
esm_file,
model.variables.keys(),
&format!("/models/{model_name}/variables"),
&format!("Model '{model_name}'"),
"variable",
errors,
);
if let Some(subsystems) = &model.subsystems {
check_reserved_subsystem_names(
esm_file,
subsystems.iter(),
&format!("/models/{model_name}/subsystems"),
errors,
);
}
check_array_defaults_have_shape(
model,
&format!("/models/{model_name}"),
&format!("Model '{model_name}'"),
errors,
);
errors.extend(lhs_name_errors(esm_file, model_name, model));
ctx.check_equation_balance(errors);
let unit_env = ctx.check_unit_declarations(errors);
ctx.check_initialization_equation_refs(errors);
ctx.check_guess_refs(errors);
ctx.check_test_reference_refs(errors);
ctx.check_equations(&unit_env, errors, warnings);
ctx.check_aggregate_nodes(errors);
validate_array_broadcast_shapes(model_name, model, errors);
check_observed_dependency_cycle(model_name, model, ctx.class(), errors);
ctx.check_default_units_identity(errors);
ctx.check_observed_definitions(&unit_env, errors, warnings);
ctx.check_update_expression_refs(errors);
ctx.check_discrete_events(errors);
check_physical_constant_units(model_name, model, errors);
ctx.check_continuous_events(errors);
}
fn validate_model_references(ctx: &ModelCtx<'_>, errors: &mut Vec<StructuralError>) {
ctx.check_initialization_equation_refs(errors);
ctx.check_guess_refs(errors);
ctx.check_test_reference_refs(errors);
ctx.check_equation_refs(errors);
ctx.check_aggregate_nodes(errors);
ctx.check_update_expression_refs(errors);
ctx.check_discrete_events(errors);
ctx.check_continuous_events(errors);
}
trait DeclaredLookup {
fn declares(&self, name: &str) -> bool;
}
impl<S: std::hash::BuildHasher> DeclaredLookup for HashSet<String, S> {
fn declares(&self, name: &str) -> bool {
self.contains(name)
}
}
struct DeclaredNames<'a> {
vars: HashSet<&'a str, rustc_hash::FxBuildHasher>,
other: HashSet<String, rustc_hash::FxBuildHasher>,
}
impl DeclaredLookup for DeclaredNames<'_> {
fn declares(&self, name: &str) -> bool {
self.vars.contains(name) || self.other.contains(name)
}
}
struct ModelCtx<'a> {
esm_file: &'a EsmFile,
model_name: &'a str,
model: &'a crate::Model,
system_refs: &'a HashMap<String, SystemInfo>,
document_units: &'a HashMap<String, Unit>,
model_path: String,
class: std::cell::OnceCell<crate::classification::Classification>,
defined_vars: DeclaredNames<'a>,
faq_equations: std::cell::RefCell<Option<Vec<bool>>>,
local_scoped: HashSet<String>,
is_coupled: bool,
}
impl<'a> ModelCtx<'a> {
fn new(
esm_file: &'a EsmFile,
model_name: &'a str,
model: &'a crate::Model,
system_refs: &'a HashMap<String, SystemInfo>,
document_units: &'a HashMap<String, Unit>,
) -> Self {
let model_path = format!("/models/{model_name}");
let mut defined_vars = DeclaredNames {
vars: model.variables.keys().map(String::as_str).collect(),
other: HashSet::default(),
};
defined_vars
.other
.extend(implicitly_declared_symbols(esm_file));
let local_scoped = subsystem_scoped_refs(model);
let is_coupled = coupled_system_names(esm_file).contains(model_name);
if is_coupled {
defined_vars.other.extend(document_declared_names(esm_file));
}
ModelCtx {
esm_file,
model_name,
model,
system_refs,
document_units,
model_path,
class: std::cell::OnceCell::new(),
defined_vars,
faq_equations: std::cell::RefCell::new(None),
local_scoped,
is_coupled,
}
}
fn class(&self) -> &crate::classification::Classification {
self.class
.get_or_init(|| crate::classification::Classification::of(self.model))
}
fn check_refs(
&self,
expr: &crate::Expr,
path: &str,
idx: usize,
errs: &mut Vec<StructuralError>,
) {
self.check_refs_bound(expr, path, idx, &HashSet::new(), errs);
}
fn check_refs_bound(
&self,
expr: &crate::Expr,
path: &str,
idx: usize,
bound: &HashSet<String>,
errs: &mut Vec<StructuralError>,
) {
let mut binders: HashSet<String> = bound.clone();
collect_bound_symbols(expr, &mut binders);
self.check_refs_with(expr, path, idx, &binders, errs);
}
fn check_refs_with(
&self,
expr: &crate::Expr,
path: &str,
idx: usize,
binders: &HashSet<String>,
errs: &mut Vec<StructuralError>,
) {
self.check_refs_seen(expr, path, idx, binders, errs, &mut RefWalkSeen::default());
}
fn check_refs_seen(
&self,
expr: &crate::Expr,
path: &str,
idx: usize,
binders: &HashSet<String>,
errs: &mut Vec<StructuralError>,
seen: &mut RefWalkSeen,
) {
let is_declared = |name: &str| self.defined_vars.declares(name);
let declared = NameScope::Set(&is_declared);
let scope = if binders.is_empty() {
declared
} else {
NameScope::WithSet(&declared, binders)
};
references_in_scope(
expr,
&scope,
self.system_refs,
&self.local_scoped,
path,
idx,
errs,
seen,
);
}
fn check_equation_balance(&self, errors: &mut Vec<StructuralError>) {
if self.is_coupled {
return;
}
let unknown_vars: Vec<String> = self.class().unknowns();
let defining_equations = count_defining_equations(&self.model.equations);
if defining_equations != unknown_vars.len() {
let (extra_equations_for, missing_equations_for) =
analyze_equation_mismatch(&self.model.equations, &unknown_vars);
let mut details = serde_json::json!({
"unknowns": unknown_vars,
"equations": defining_equations,
});
if !missing_equations_for.is_empty() {
details["missing_equations_for"] = serde_json::json!(missing_equations_for);
}
if !extra_equations_for.is_empty() {
details["extra_equations_for"] = serde_json::json!(extra_equations_for);
}
errors.push(StructuralError {
path: self.model_path.clone(),
code: StructuralErrorCode::EquationCountMismatch,
message: format!(
"Number of equations ({}) does not match number of unknowns ({})",
defining_equations,
unknown_vars.len()
),
details,
});
}
}
fn check_unit_declarations(&self, errors: &mut Vec<StructuralError>) -> ComponentUnitEnv<'a> {
let (own, unit_parse_failures) = build_unit_env(&self.model.variables);
let unit_env =
ComponentUnitEnv::new(own, self.model.subsystems.as_ref(), self.document_units);
for failure in unit_parse_failures {
errors.push(StructuralError {
path: format!("{}/variables/{}", self.model_path, failure.name),
code: StructuralErrorCode::UnitParseError,
message: format!("Unit string '{}' is not a recognised unit", failure.units),
details: serde_json::json!({
"variable": failure.name,
"units": failure.units,
}),
});
}
unit_env
}
fn check_initialization_equation_refs(&self, errors: &mut Vec<StructuralError>) {
for (eq_idx, equation) in self
.model
.initialization_equations
.iter()
.flatten()
.enumerate()
{
let eq_path = format!("{}/initialization_equations/{eq_idx}", self.model_path);
let mut eq_bound = HashSet::new();
collect_bound_symbols(&equation.lhs, &mut eq_bound);
collect_bound_symbols(&equation.rhs, &mut eq_bound);
for (field, expr) in [("lhs", &equation.lhs), ("rhs", &equation.rhs)] {
self.check_refs_bound(
expr,
&format!("{eq_path}/{field}"),
eq_idx,
&eq_bound,
errors,
);
}
}
}
fn check_guess_refs(&self, errors: &mut Vec<StructuralError>) {
for (var_name, guess) in self.model.guesses.iter().flatten() {
let Ok(expr) = serde_json::from_value::<crate::Expr>(guess.clone()) else {
continue; };
self.check_refs(
&expr,
&format!("{}/guesses/{var_name}", self.model_path),
0,
errors,
);
}
}
fn check_test_reference_refs(&self, errors: &mut Vec<StructuralError>) {
for (t_idx, test) in self.model.tests.iter().flatten().enumerate() {
for (a_idx, assertion) in test.assertions.iter().enumerate() {
let Some(crate::types::AssertionReference::Expression(reference)) =
&assertion.reference
else {
continue;
};
self.check_refs(
reference,
&format!(
"{}/tests/{t_idx}/assertions/{a_idx}/reference",
self.model_path
),
0,
errors,
);
}
}
}
fn check_equations(
&self,
unit_env: &ComponentUnitEnv<'_>,
errors: &mut Vec<StructuralError>,
warnings: &mut Vec<UnitWarning>,
) {
let mut faq = Vec::with_capacity(self.model.equations.len());
for (eq_idx, equation) in self.model.equations.iter().enumerate() {
let eq_path = format!("{}/equations/{eq_idx}", self.model_path);
faq.push(self.check_one_equation_refs(eq_idx, equation, &eq_path, errors));
for (field, expr) in [("lhs", &equation.lhs), ("rhs", &equation.rhs)] {
for units in crate::units::unresolvable_const_units(expr) {
errors.push(StructuralError {
path: format!("{eq_path}/{field}"),
code: StructuralErrorCode::UnitParseError,
message: format!("Unit string '{units}' is not a recognised unit"),
details: serde_json::json!({ "units": units }),
});
}
}
record_unit_findings(
equation_findings(equation, unit_env),
&eq_path,
&format!("Equation {eq_idx}"),
errors,
warnings,
);
}
*self.faq_equations.borrow_mut() = Some(faq);
}
fn check_equation_refs(&self, errors: &mut Vec<StructuralError>) {
let mut faq = Vec::with_capacity(self.model.equations.len());
for (eq_idx, equation) in self.model.equations.iter().enumerate() {
let eq_path = format!("{}/equations/{eq_idx}", self.model_path);
faq.push(self.check_one_equation_refs(eq_idx, equation, &eq_path, errors));
}
*self.faq_equations.borrow_mut() = Some(faq);
}
fn check_one_equation_refs(
&self,
eq_idx: usize,
equation: &crate::types::Equation,
eq_path: &str,
errors: &mut Vec<StructuralError>,
) -> bool {
let no_binders = HashSet::new();
let mut found = Vec::new();
let mut seen = RefWalkSeen::default();
for (field, expr) in [("lhs", &equation.lhs), ("rhs", &equation.rhs)] {
let path = format!("{eq_path}/{field}");
self.check_refs_seen(expr, &path, eq_idx, &no_binders, &mut found, &mut seen);
}
if !seen.binder && !seen.enum_node {
errors.append(&mut found);
return seen.faq;
}
let mut eq_bound = HashSet::new();
let mut faq = false;
collect_bound_symbols_noting_faq(&equation.lhs, &mut eq_bound, &mut faq);
collect_bound_symbols_noting_faq(&equation.rhs, &mut eq_bound, &mut faq);
for (field, expr) in [("lhs", &equation.lhs), ("rhs", &equation.rhs)] {
self.check_refs_with(
expr,
&format!("{eq_path}/{field}"),
eq_idx,
&eq_bound,
errors,
);
}
faq
}
fn check_aggregate_nodes(&self, errors: &mut Vec<StructuralError>) {
let state_var_set: std::cell::OnceCell<HashSet<&str>> = std::cell::OnceCell::new();
let is_state = |name: &str| {
state_var_set
.get_or_init(|| {
let class = self.class();
class
.ode_states
.iter()
.chain(&class.algebraic_unknowns)
.chain(&class.brownian_parameters)
.map(String::as_str)
.collect()
})
.contains(name)
};
validate_aggregate_constraints(
self.esm_file,
self.model_name,
self.model,
&is_state,
self.faq_equations.borrow().as_deref(),
errors,
);
}
fn check_default_units_identity(&self, errors: &mut Vec<StructuralError>) {
for (var_name, variable) in &self.model.variables {
let (Some(declared), Some(default_units)) =
(variable.units.as_deref(), variable.default_units.as_deref())
else {
continue;
};
if declared.trim() == default_units.trim() {
continue;
}
errors.push(StructuralError {
path: format!("{}/variables/{var_name}", self.model_path),
code: StructuralErrorCode::UnitInconsistency,
message: "Parameter default value units do not match declared units".to_string(),
details: serde_json::json!({
"variable": var_name,
"declared_units": declared,
"default_value": variable.default,
"inferred_default_units": default_units,
}),
});
}
}
fn check_observed_definitions(
&self,
unit_env: &ComponentUnitEnv<'_>,
errors: &mut Vec<StructuralError>,
warnings: &mut Vec<UnitWarning>,
) {
for (var_name, rhs) in &self.class().observed_definitions {
let Some(variable) = self.model.variables.get(var_name) else {
continue;
};
let declared = variable.units.as_deref().and_then(|u| parse_unit(u).ok());
record_unit_findings(
expression_findings(rhs, declared.as_ref(), unit_env),
&format!("{}/variables/{var_name}", self.model_path),
&format!("Observed variable \"{var_name}\""),
errors,
warnings,
);
if let Some(declared) = &declared {
check_linear_conversion_factor(
rhs,
declared,
self.model,
&format!("{}/variables/{var_name}", self.model_path),
var_name,
errors,
);
}
}
}
fn check_update_expression_refs(&self, errors: &mut Vec<StructuralError>) {
let mut var_names: Vec<&String> = self
.model
.variables
.iter()
.filter(|(_, v)| v.update.is_some())
.map(|(n, _)| n)
.collect();
var_names.sort();
for var_name in var_names {
let variable = &self.model.variables[var_name];
let base = format!("{}/variables/{var_name}/update", self.model_path);
variable.for_each_expression_at(&mut |expr, site| {
self.check_refs(expr, &format!("{base}{site}"), 0, errors)
});
}
}
fn check_discrete_events(&self, errors: &mut Vec<StructuralError>) {
if let Some(ref discrete_events) = self.model.discrete_events {
for (event_idx, event) in discrete_events.iter().enumerate() {
validate_discrete_event(
event,
event_idx,
&self.model_path,
&self.defined_vars,
&self.model.variables,
errors,
);
}
}
}
fn check_continuous_events(&self, errors: &mut Vec<StructuralError>) {
if let Some(ref continuous_events) = self.model.continuous_events {
for (event_idx, event) in continuous_events.iter().enumerate() {
validate_continuous_event(
event,
event_idx,
&self.model_path,
&self.defined_vars,
&self.model.variables,
errors,
);
}
}
}
}
fn check_linear_conversion_factor(
expr: &crate::Expr,
declared: &crate::units::Unit,
model: &crate::Model,
path: &str,
var_name: &str,
errors: &mut Vec<StructuralError>,
) {
let crate::Expr::Operator(node) = expr else {
return;
};
if node.op != "*" || node.args.len() != 2 {
return;
}
let (factor, src_name) = match (&node.args[0], &node.args[1]) {
(crate::Expr::Number(f), crate::Expr::Variable(v)) => (*f, v),
(crate::Expr::Variable(v), crate::Expr::Number(f)) => (*f, v),
(crate::Expr::Integer(i), crate::Expr::Variable(v)) => (*i as f64, v),
(crate::Expr::Variable(v), crate::Expr::Integer(i)) => (*i as f64, v),
_ => return,
};
let Some(src_units) = model
.variables
.get(src_name)
.and_then(|v| v.units.as_deref())
else {
return;
};
let Ok(src) = parse_unit(src_units) else {
return;
};
if !src.same_dimensions(declared) {
return;
}
let (src_scale, dst_scale) = (src.scale(), declared.scale());
if !src_scale.is_finite() || !dst_scale.is_finite() || dst_scale == 0.0 {
return;
}
if src.exact_scale() == declared.exact_scale() {
return;
}
let expected = src.exact_scale().divide(declared.exact_scale()).to_f64();
if (factor - expected).abs() <= 1e-6 * expected.abs() {
return;
}
errors.push(StructuralError {
path: path.to_string(),
code: StructuralErrorCode::UnitInconsistency,
message: "Unit conversion factor is incorrect for specified unit transformation"
.to_string(),
details: serde_json::json!({
"variable": var_name,
"declared_units": model.variables[var_name].units,
"source_units": src_units,
"declared_factor": factor,
"expected_factor": expected,
}),
});
}
pub(crate) fn coupled_system_names(esm_file: &EsmFile) -> HashSet<String> {
let mut coupled = HashSet::new();
let mut add = |name: &str| {
if name.is_empty() {
return;
}
coupled.insert(name.to_string());
if let Some((root, _)) = name.split_once('.') {
coupled.insert(root.to_string());
}
};
for entry in esm_file.coupling.iter().flatten() {
match entry {
crate::CouplingEntry::OperatorCompose { systems, .. }
| crate::CouplingEntry::Couple { systems, .. } => {
for s in systems {
add(s);
}
}
crate::CouplingEntry::VariableMap { from, to, .. } => {
add(from);
add(to);
}
_ => {}
}
}
coupled
}
pub(crate) fn validate_data_source_references(
esm_file: &EsmFile,
errors: &mut Vec<StructuralError>,
) {
let declared: Vec<String> = {
let mut names: Vec<String> = esm_file
.data_sources
.iter()
.flatten()
.map(|(k, _)| k.clone())
.collect();
names.sort();
names
};
for (model_name, model) in esm_file.models.iter().flatten() {
let mut var_names: Vec<&String> = model
.variables
.iter()
.filter(|(_, v)| v.update.is_some())
.map(|(n, _)| n)
.collect();
var_names.sort();
for var_name in var_names {
let var = &model.variables[var_name];
for source in var.update_sources() {
if declared.iter().any(|d| d == source) {
continue;
}
errors.push(StructuralError {
path: format!("/models/{model_name}/variables/{var_name}/update"),
code: StructuralErrorCode::DataSourceUndefined,
message: format!(
"Parameter update names data source '{source}', which the document does not declare"
),
details: serde_json::json!({
"variable": var_name,
"source": source,
"available_sources": declared,
}),
});
}
}
}
}
fn document_declared_names(esm_file: &EsmFile) -> HashSet<String> {
let mut names = HashSet::new();
for model in esm_file.models.iter().flatten().map(|(_, m)| m) {
names.extend(model.variables.keys().cloned());
}
for rs in esm_file.reaction_systems.iter().flatten().map(|(_, r)| r) {
names.extend(rs.species.keys().cloned());
names.extend(rs.parameters.keys().cloned());
}
names
}
fn implicitly_declared_symbols(esm_file: &EsmFile) -> HashSet<String> {
let mut symbols = HashSet::new();
symbols.insert(independent_variable(esm_file));
symbols.insert("_var".to_string());
if let Some(index_sets) = &esm_file.index_sets {
symbols.extend(index_sets.keys().cloned());
}
for entry in esm_file.coupling.iter().flatten() {
let crate::CouplingEntry::Callback { config, .. } = entry else {
continue;
};
let names = config
.as_ref()
.and_then(|c| c.get("callback_variables"))
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|cv| cv.get("name").and_then(|n| n.as_str()))
.map(str::to_string);
symbols.extend(names);
}
if let Some(models) = &esm_file.models {
for model in models.values() {
for eq in &model.equations {
if is_ic_equation(&eq.lhs) {
collect_free_symbols(&eq.rhs, &mut symbols);
}
collect_coordinate_symbols(&eq.lhs, &mut symbols);
collect_coordinate_symbols(&eq.rhs, &mut symbols);
}
for var in model.variables.values() {
var.for_each_expression(&mut |expr| collect_coordinate_symbols(expr, &mut symbols));
}
}
}
symbols
}
fn independent_variable(esm_file: &EsmFile) -> String {
esm_file
.domain
.as_ref()
.and_then(|d| d.independent_variable.clone())
.unwrap_or_else(|| "t".to_string())
}
fn reserved_declaration_reason(
esm_file: &EsmFile,
name: &str,
) -> Option<(&'static str, &'static str)> {
if name == independent_variable(esm_file) {
Some((
"independent_variable",
"the document's independent variable",
))
} else if name == crate::flatten::VAR_PLACEHOLDER {
Some(("operator_placeholder", "the operator-model placeholder"))
} else {
None
}
}
fn check_reserved_declaration_names<'a, I: IntoIterator<Item = &'a String>>(
esm_file: &EsmFile,
declarations: I,
container_path: &str,
owner: &str,
kind: &str,
errors: &mut Vec<StructuralError>,
) {
let mut found: Vec<(&String, _)> = declarations
.into_iter()
.filter_map(|name| Some((name, reserved_declaration_reason(esm_file, name)?)))
.collect();
found.sort_by(|a, b| a.0.cmp(b.0));
for (name, (reason, role)) in found {
errors.push(StructuralError {
path: format!("{container_path}/{name}"),
code: StructuralErrorCode::ReservedVariableName,
message: format!("{owner} declares a {kind} named '{name}', which is {role}"),
details: serde_json::json!({ "name": name, "reserved_as": reason }),
});
}
}
pub(crate) fn reserved_declaration_errors(esm_file: &EsmFile) -> Vec<StructuralError> {
let mut errors = Vec::new();
if let Some(models) = &esm_file.models {
let mut names: Vec<&String> = models.keys().collect();
names.sort();
for model_name in names {
let model = &models[model_name];
check_reserved_declaration_names(
esm_file,
model.variables.keys(),
&format!("/models/{model_name}/variables"),
&format!("Model '{model_name}'"),
"variable",
&mut errors,
);
if let Some(subsystems) = &model.subsystems {
check_reserved_subsystem_names(
esm_file,
subsystems.iter(),
&format!("/models/{model_name}/subsystems"),
&mut errors,
);
}
}
}
if let Some(systems) = &esm_file.reaction_systems {
let mut names: Vec<&String> = systems.keys().collect();
names.sort();
for rs_name in names {
let rs = &systems[rs_name];
let owner = format!("Reaction system '{rs_name}'");
for (map, kind, keys) in [
("species", "species", rs.species.keys().collect::<Vec<_>>()),
("parameters", "parameter", rs.parameters.keys().collect()),
] {
check_reserved_declaration_names(
esm_file,
keys,
&format!("/reaction_systems/{rs_name}/{map}"),
&owner,
kind,
&mut errors,
);
}
}
}
errors
}
fn check_reserved_subsystem_names<'a, I>(
esm_file: &EsmFile,
subsystems: I,
base_path: &str,
errors: &mut Vec<StructuralError>,
) where
I: IntoIterator<Item = (&'a String, &'a serde_json::Value)>,
{
let mut entries: Vec<(&String, &serde_json::Value)> = subsystems.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
for (name, value) in entries {
let sub_path = format!("{base_path}/{name}");
if let Some(vars) = value.get("variables").and_then(|v| v.as_object()) {
check_reserved_declaration_names(
esm_file,
vars.keys(),
&format!("{sub_path}/variables"),
&format!("Model '{name}'"),
"variable",
errors,
);
}
if let Some(nested) = value.get("subsystems").and_then(|v| v.as_object()) {
check_reserved_subsystem_names(
esm_file,
nested.iter(),
&format!("{sub_path}/subsystems"),
errors,
);
}
}
}
fn array_default_without_shape(
model_path: &str,
owner: &str,
name: &str,
var_type: &str,
) -> StructuralError {
StructuralError {
path: format!("{model_path}/variables/{name}/default"),
code: StructuralErrorCode::ArrayDefaultWithoutShape,
message: format!(
"{owner} variable '{name}' has inline array data as its default but declares no \
shape; inline array data is a shaped variable's value (esm-spec §6.3)"
),
details: serde_json::json!({ "variable": name, "variable_type": var_type }),
}
}
fn check_array_defaults_have_shape(
model: &crate::Model,
model_path: &str,
owner: &str,
errors: &mut Vec<StructuralError>,
) {
let mut unshaped: Vec<(&String, &crate::ModelVariable)> = model
.variables
.iter()
.filter(|(_, var)| {
let is_array = matches!(var.default, Some(crate::types::InlineValue::Array(_)));
let shaped = var.shape.as_ref().is_some_and(|s| !s.is_empty());
is_array && !shaped
})
.collect();
unshaped.sort_by(|a, b| a.0.cmp(b.0));
for (name, var) in unshaped {
let var_type = serde_json::to_value(var.var_type)
.ok()
.and_then(|v| v.as_str().map(str::to_string))
.unwrap_or_default();
errors.push(array_default_without_shape(
model_path, owner, name, &var_type,
));
}
if let Some(subsystems) = &model.subsystems {
check_subsystem_array_defaults(
subsystems.iter(),
&format!("{model_path}/subsystems"),
errors,
);
}
}
fn check_subsystem_array_defaults<'a, I>(
subsystems: I,
base_path: &str,
errors: &mut Vec<StructuralError>,
) where
I: IntoIterator<Item = (&'a String, &'a serde_json::Value)>,
{
let mut entries: Vec<(&String, &serde_json::Value)> = subsystems.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
for (name, value) in entries {
let sub_path = format!("{base_path}/{name}");
if let Some(vars) = value.get("variables").and_then(|v| v.as_object()) {
let mut keys: Vec<&String> = vars.keys().collect();
keys.sort();
for key in keys {
let var = &vars[key.as_str()];
let is_array = var.get("default").is_some_and(|d| d.is_array());
let shaped = var
.get("shape")
.and_then(|s| s.as_array())
.is_some_and(|s| !s.is_empty());
if is_array && !shaped {
let var_type = var.get("type").and_then(|t| t.as_str()).unwrap_or("");
errors.push(array_default_without_shape(
&sub_path,
&format!("Model '{name}'"),
key,
var_type,
));
}
}
}
if let Some(nested) = value.get("subsystems").and_then(|v| v.as_object()) {
check_subsystem_array_defaults(
nested.iter(),
&format!("{sub_path}/subsystems"),
errors,
);
}
}
}
pub(crate) fn lhs_name_errors(
esm_file: &EsmFile,
model_name: &str,
model: &crate::Model,
) -> Vec<StructuralError> {
let mut out = Vec::new();
let mut scope: HashSet<String> = esm_file
.metaparameters
.as_ref()
.and_then(|m| m.as_object())
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
scope.insert(independent_variable(esm_file));
lhs_name_errors_in(
esm_file,
&format!("/models/{model_name}"),
LhsModel::Typed(model),
&scope,
&mut out,
);
out
}
#[derive(Clone, Copy)]
enum LhsModel<'a> {
Typed(&'a crate::Model),
Json(&'a serde_json::Value),
}
impl<'a> LhsModel<'a> {
fn declares(self, name: &str) -> bool {
match self {
LhsModel::Typed(m) => m.variables.contains_key(name),
LhsModel::Json(m) => m.get("variables").and_then(|v| v.get(name)).is_some(),
}
}
fn subsystem(self, name: &str) -> Option<&'a serde_json::Value> {
match self {
LhsModel::Typed(m) => m.subsystems.as_ref()?.get(name),
LhsModel::Json(m) => m.get("subsystems")?.get(name),
}
}
fn subsystem_names(self) -> Vec<&'a String> {
match self {
LhsModel::Typed(m) => m.subsystems.iter().flat_map(|s| s.keys()).collect(),
LhsModel::Json(m) => m
.get("subsystems")
.and_then(|v| v.as_object())
.map(|subs| subs.keys().collect())
.unwrap_or_default(),
}
}
fn is_param(self, rest: &[&str]) -> Option<bool> {
let (last, subs) = rest.split_last()?;
let mut cur = self;
for p in subs {
cur = LhsModel::Json(cur.subsystem(p)?);
}
match cur {
LhsModel::Typed(m) => {
Some(m.variables.get(*last)?.var_type == crate::VariableType::Parameter)
}
LhsModel::Json(m) => {
let v = m.get("variables")?.get(*last)?;
Some(v.get("type").and_then(|t| t.as_str()) == Some("parameter"))
}
}
}
fn for_each_equation(self, mut f: impl FnMut(usize, LhsSide<'_>, Option<Vec<String>>)) {
match self {
LhsModel::Typed(m) => {
for (k, eq) in m.equations.iter().enumerate() {
let faq_idx = match &eq.rhs {
crate::Expr::Operator(n) if n.op == "faq" || n.op == "aggregate" => Some(
n.output_idx
.iter()
.flatten()
.filter(|s| !is_integer_literal(s))
.cloned()
.collect(),
),
_ => None,
};
f(k, LhsSide::Typed(&eq.lhs), faq_idx);
}
}
LhsModel::Json(m) => {
for (k, eq) in m
.get("equations")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.enumerate()
{
let Some(lhs) = eq.get("lhs") else { continue };
let faq_idx = eq
.get("rhs")
.filter(|rhs| {
matches!(
rhs.get("op").and_then(|o| o.as_str()),
Some("faq") | Some("aggregate")
)
})
.map(json_output_idx);
f(k, LhsSide::Json(lhs), faq_idx);
}
}
}
}
}
#[derive(Clone, Copy)]
enum LhsSide<'a> {
Typed(&'a crate::Expr),
Json(&'a serde_json::Value),
}
impl<'a> LhsSide<'a> {
fn is_ic(self) -> bool {
match self {
LhsSide::Typed(e) => is_ic_equation(e),
LhsSide::Json(v) => v.get("op").and_then(|o| o.as_str()) == Some("ic"),
}
}
fn defined_name(self) -> Option<&'a str> {
match self {
LhsSide::Typed(lhs) => {
let mut e = lhs;
loop {
let crate::Expr::Operator(n) = e else {
return match e {
crate::Expr::Variable(s) => Some(s),
_ => None,
};
};
e = match n.op.as_str() {
"faq" | "aggregate" => n.expr.as_deref()?,
"index" => n.args.first()?,
"D" if matches!(n.wrt.as_deref(), None | Some("t")) => n.args.first()?,
_ => return None,
};
}
}
LhsSide::Json(v) => lhs_defined_name(v),
}
}
fn subject_name(self) -> Option<&'a str> {
match self {
LhsSide::Typed(lhs) => {
let mut e = lhs;
loop {
let crate::Expr::Operator(n) = e else {
return match e {
crate::Expr::Variable(s) => Some(s),
_ => None,
};
};
e = match n.op.as_str() {
"faq" | "aggregate" => n.expr.as_deref()?,
"index" | "D" => n.args.first()?,
_ => return None,
};
}
}
LhsSide::Json(v) => lhs_subject_name(v),
}
}
fn free_subscripts(
self,
bound: &HashSet<String>,
names: &dyn Fn(&str) -> bool,
out: &mut Vec<String>,
) {
match self {
LhsSide::Typed(e) => collect_free_lhs_subscripts_typed(e, bound, names, out),
LhsSide::Json(v) => collect_free_lhs_subscripts(v, bound, names, out),
}
}
}
pub(crate) fn parameter_definition_errors(esm_file: &EsmFile) -> Vec<StructuralError> {
let Some(models) = &esm_file.models else {
return Vec::new();
};
let mut names: Vec<&String> = models.keys().collect();
names.sort();
names
.into_iter()
.flat_map(|n| lhs_name_errors(esm_file, n, &models[n]))
.filter(|e| matches!(e.code, StructuralErrorCode::EquationDefinesParameter))
.collect()
}
fn lhs_name_errors_in(
esm_file: &EsmFile,
path: &str,
model: LhsModel<'_>,
doc_scope: &HashSet<String>,
out: &mut Vec<StructuralError>,
) {
let names = |sym: &str| doc_scope.contains(sym) || model.declares(sym);
model.for_each_equation(|k, lhs, faq_idx| {
let lhs_path = || format!("{path}/equations/{k}/lhs");
if lhs.is_ic() {
return;
}
if let Some(name) = lhs.defined_name()
&& lhs_names_parameter(esm_file, model, name)
{
out.push(StructuralError {
path: lhs_path(),
code: StructuralErrorCode::EquationDefinesParameter,
message: format!(
"Equation {k} defines '{name}', which is a parameter; an equation \
defines unknowns only"
),
details: serde_json::json!({ "variable": name }),
});
}
let bound: HashSet<String> = faq_idx.into_iter().flatten().collect();
let mut free: Vec<String> = Vec::new();
lhs.free_subscripts(&bound, &names, &mut free);
let subject = lhs.subject_name();
for symbol in free {
let defining = subject.map_or(String::new(), |v| format!(" (defining '{v}')"));
out.push(StructuralError {
path: lhs_path(),
code: StructuralErrorCode::UnboundIndexSymbol,
message: format!(
"Equation {k}{defining} subscripts its left-hand side with \
'{symbol}', which no faq binds"
),
details: serde_json::json!({ "symbol": symbol, "variable": subject }),
});
}
});
let mut sub_names = model.subsystem_names();
sub_names.sort();
for s in sub_names {
let Some(sub) = model.subsystem(s) else {
continue;
};
if sub.get("ref").is_some() {
continue; }
lhs_name_errors_in(
esm_file,
&format!("{path}/subsystems/{s}"),
LhsModel::Json(sub),
doc_scope,
out,
);
}
}
fn lhs_defined_name(lhs: &serde_json::Value) -> Option<&str> {
let mut e = lhs;
loop {
if let Some(s) = e.as_str() {
return Some(s);
}
match e.get("op").and_then(|o| o.as_str())? {
"faq" | "aggregate" => e = e.get("expr")?,
"index" => e = e.get("args")?.as_array()?.first()?,
"D" if matches!(e.get("wrt").and_then(|w| w.as_str()), None | Some("t")) => {
e = e.get("args")?.as_array()?.first()?
}
_ => return None,
}
}
}
fn lhs_names_parameter(esm_file: &EsmFile, model: LhsModel<'_>, name: &str) -> bool {
let parts: Vec<&str> = name.split('.').collect();
if let Some(found) = model.is_param(&parts) {
return found;
}
if parts.len() > 1
&& let Some(models) = &esm_file.models
&& let Some(top) = models.get(parts[0])
&& let Some(found) = LhsModel::Typed(top).is_param(&parts[1..])
{
return found;
}
false
}
fn lhs_subject_name(lhs: &serde_json::Value) -> Option<&str> {
let mut e = lhs;
loop {
if let Some(s) = e.as_str() {
return Some(s);
}
match e.get("op").and_then(|o| o.as_str())? {
"faq" | "aggregate" => e = e.get("expr")?,
"index" | "D" => e = e.get("args")?.as_array()?.first()?,
_ => return None,
}
}
}
fn is_integer_literal(s: &str) -> bool {
matches!(s.parse::<i64>(), Ok(v) if v.to_string() == s)
}
fn json_output_idx(node: &serde_json::Value) -> Vec<String> {
node.get("output_idx")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|x| x.as_str().map(str::to_string))
.collect()
}
fn collect_free_lhs_subscripts(
e: &serde_json::Value,
bound: &HashSet<String>,
names: &dyn Fn(&str) -> bool,
out: &mut Vec<String>,
) {
let Some(obj) = e.as_object() else { return };
let op = obj.get("op").and_then(|o| o.as_str()).unwrap_or("");
let args: &[serde_json::Value] = obj
.get("args")
.and_then(|a| a.as_array())
.map(Vec::as_slice)
.unwrap_or(&[]);
match op {
"faq" | "aggregate" => {
let mut inner = bound.clone();
inner.extend(json_output_idx(e));
if let Some(r) = obj.get("ranges").and_then(|r| r.as_object()) {
inner.extend(r.keys().cloned());
}
if let Some(x) = obj.get("expr") {
collect_free_lhs_subscripts(x, &inner, names, out);
}
for a in args {
collect_free_lhs_subscripts(a, &inner, names, out);
}
}
"index" => {
if let Some(base) = args.first() {
collect_free_lhs_subscripts(base, bound, names, out);
}
for s in args.iter().skip(1) {
match s.as_str() {
Some(sym) => {
if !bound.contains(sym) && !names(sym) && !out.iter().any(|o| o == sym) {
out.push(sym.to_string());
}
}
None => collect_free_lhs_subscripts(s, bound, names, out),
}
}
}
_ => {
for a in args {
collect_free_lhs_subscripts(a, bound, names, out);
}
}
}
}
fn collect_free_lhs_subscripts_typed(
e: &crate::Expr,
bound: &HashSet<String>,
names: &dyn Fn(&str) -> bool,
out: &mut Vec<String>,
) {
let crate::Expr::Operator(node) = e else {
return;
};
match node.op.as_str() {
"faq" | "aggregate" => {
let mut inner = bound.clone();
inner.extend(
node.output_idx
.iter()
.flatten()
.filter(|s| !is_integer_literal(s))
.cloned(),
);
if let Some(r) = &node.ranges {
inner.extend(r.keys().cloned());
}
if let Some(x) = node.expr.as_deref() {
collect_free_lhs_subscripts_typed(x, &inner, names, out);
}
for a in &node.args {
collect_free_lhs_subscripts_typed(a, &inner, names, out);
}
}
"index" => {
if let Some(base) = node.args.first() {
collect_free_lhs_subscripts_typed(base, bound, names, out);
}
for s in node.args.iter().skip(1) {
match s {
crate::Expr::Variable(sym) => {
if !bound.contains(sym) && !names(sym) && !out.iter().any(|o| o == sym) {
out.push(sym.clone());
}
}
_ => collect_free_lhs_subscripts_typed(s, bound, names, out),
}
}
}
_ => {
for a in &node.args {
collect_free_lhs_subscripts_typed(a, bound, names, out);
}
}
}
}
fn is_ic_equation(lhs: &crate::Expr) -> bool {
matches!(lhs, crate::Expr::Operator(op) if op.op == "ic")
}
fn collect_coordinate_symbols(expr: &crate::Expr, out: &mut HashSet<String>) {
if let crate::Expr::Operator(op) = expr {
if let Some(dim) = &op.dim {
out.insert(dim.clone());
}
op.for_each_child(&mut |child| collect_coordinate_symbols(child, out));
}
}
fn collect_free_symbols(expr: &crate::Expr, out: &mut HashSet<String>) {
match expr {
crate::Expr::Variable(name) => {
if !name.contains('.') && !is_builtin_function_name(name) {
out.insert(name.clone());
}
}
crate::Expr::Operator(op) => {
op.for_each_child(&mut |child| collect_free_symbols(child, out));
}
crate::Expr::Number(_) | crate::Expr::Integer(_) => {}
}
}
fn validate_aggregate_constraints(
esm_file: &EsmFile,
model_name: &str,
model: &crate::Model,
is_state: &dyn Fn(&str) -> bool,
faq_equations: Option<&[bool]>,
errors: &mut Vec<StructuralError>,
) {
let model_path = format!("/models/{model_name}");
let var_shapes = crate::join::declared_var_shapes(model);
let array_shaped: HashSet<&str> = model
.variables
.iter()
.filter(|(_, v)| v.shape.as_ref().is_some_and(|s| !s.is_empty()))
.map(|(n, _)| n.as_str())
.collect();
for (eq_idx, equation) in model.equations.iter().enumerate() {
if faq_equations.is_none_or(|faq| faq.get(eq_idx).copied().unwrap_or(true)) {
for (field, expr) in [("lhs", &equation.lhs), ("rhs", &equation.rhs)] {
let field_path = || format!("{model_path}/equations/{eq_idx}/{field}");
check_aggregates_in_expr(
expr,
&field_path,
esm_file,
is_state,
&var_shapes,
errors,
);
}
}
if recurrence_lhs_target(&equation.lhs).is_some_and(|(var, _)| array_shaped.contains(var)) {
check_recurrence_equation(
equation,
&format!("{model_path}/equations/{eq_idx}/rhs"),
esm_file,
&array_shaped,
errors,
);
}
}
}
fn validator_symbol_bounds(
spec: &crate::types::RangeSpec,
esm_file: &EsmFile,
) -> Option<(i64, i64)> {
match spec {
crate::types::RangeSpec::Interval([lo, hi]) => Some((*lo, *hi)),
crate::types::RangeSpec::Strided([lo, _, hi]) => Some((*lo, *hi)),
crate::types::RangeSpec::IndexSetRef { from, of: None } => {
let set = esm_file.index_sets.as_ref()?.get(from)?;
match set.kind.as_str() {
"interval" => Some((1, set.size?)),
"categorical" => Some((1, set.members.as_ref()?.len() as i64)),
_ => None,
}
}
_ => None,
}
}
struct StructuralAffine {
coef: i64,
konst: Option<(i64, i64)>,
}
pub(crate) fn data_lag_guidance(var: &str, sym: &str) -> String {
format!(
"If the offset is read from data (`{sym} - index(lag, {sym})`), it has no direct \
spelling; contract it instead: either order the axis so the predecessor is the \
preceding position and the lag is the constant 1, or range a contracted index `a` \
over the lag's bounds and select the matching term with \
`ifelse(index(lag, {sym}) == a, <term reading index({var}, {sym} - a)>, 0)`."
)
}
fn structural_affine_in_sym(
e: &crate::Expr,
sym: &str,
env: &HashMap<String, (i64, i64)>,
) -> Option<StructuralAffine> {
let konst = |lo: i64, hi: i64| {
Some(StructuralAffine {
coef: 0,
konst: Some((lo, hi)),
})
};
match e {
crate::Expr::Integer(n) => konst(*n, *n),
crate::Expr::Number(f) if f.fract() == 0.0 && f.is_finite() => {
let n = *f as i64;
konst(n, n)
}
crate::Expr::Variable(v) if v == sym => Some(StructuralAffine {
coef: 1,
konst: Some((0, 0)),
}),
crate::Expr::Variable(v) => Some(StructuralAffine {
coef: 0,
konst: env.get(v).copied(),
}),
crate::Expr::Operator(node) if node.args.len() == 2 => {
let a = structural_affine_in_sym(&node.args[0], sym, env)?;
let b = structural_affine_in_sym(&node.args[1], sym, env)?;
let both = a.konst.zip(b.konst);
match node.op.as_str() {
"+" => Some(StructuralAffine {
coef: a.coef + b.coef,
konst: both.map(|((la, ha), (lb, hb))| (la + lb, ha + hb)),
}),
"-" => Some(StructuralAffine {
coef: a.coef - b.coef,
konst: both.map(|((la, ha), (lb, hb))| (la - hb, ha - lb)),
}),
"*" => {
let (k, other) = match (a.coef, a.konst, b.coef, b.konst) {
(0, Some((lo, hi)), _, _) if lo == hi => (lo, &b),
(_, _, 0, Some((lo, hi))) if lo == hi => (lo, &a),
_ => return None,
};
Some(StructuralAffine {
coef: other.coef * k,
konst: other.konst.map(|(lo, hi)| {
let (p, q) = (lo * k, hi * k);
(p.min(q), p.max(q))
}),
})
}
_ => None,
}
}
_ => None,
}
}
struct StructuralSelfRead<'a> {
args: &'a [crate::Expr],
env: HashMap<String, (i64, i64)>,
unsequenceable: bool,
}
fn op_blocks_cell_restriction(op: &str) -> bool {
matches!(op, "reshape" | "transpose" | "concat" | "broadcast")
}
fn collect_structural_self_reads<'a>(
e: &'a crate::Expr,
var: &str,
esm_file: &EsmFile,
env: &mut Vec<(String, (i64, i64))>,
blocked: bool,
out: &mut Vec<StructuralSelfRead<'a>>,
bare: &mut bool,
) {
let crate::Expr::Operator(node) = e else {
if let crate::Expr::Variable(v) = e
&& v == var
{
*bare = true;
}
return;
};
let pushed = if node.op == "faq" {
let add: Vec<(String, (i64, i64))> = node
.ranges
.as_ref()
.map(|m| {
m.iter()
.filter_map(|(k, spec)| {
validator_symbol_bounds(spec, esm_file).map(|b| (k.clone(), b))
})
.collect()
})
.unwrap_or_default();
let n = add.len();
env.extend(add);
n
} else {
0
};
let is_self_index = node.op == "index"
&& matches!(node.args.first(), Some(crate::Expr::Variable(v)) if v == var);
if is_self_index {
let mut snapshot: HashMap<String, (i64, i64)> = HashMap::new();
for (k, v) in env.iter() {
snapshot.insert(k.clone(), *v);
}
out.push(StructuralSelfRead {
args: &node.args[1..],
env: snapshot,
unsequenceable: blocked,
});
}
let blocked_children = blocked || op_blocks_cell_restriction(&node.op);
let skip = usize::from(is_self_index);
for a in node.args.iter().skip(skip) {
collect_structural_self_reads(a, var, esm_file, env, blocked_children, out, bare);
}
for side in [
node.expr.as_deref(),
node.filter.as_deref(),
node.key.as_deref(),
node.lower.as_deref(),
node.upper.as_deref(),
]
.into_iter()
.flatten()
{
collect_structural_self_reads(side, var, esm_file, env, blocked_children, out, bare);
}
if let Some(vs) = node.values.as_ref() {
for v in vs {
collect_structural_self_reads(v, var, esm_file, env, true, out, bare);
}
}
env.truncate(env.len() - pushed);
}
fn recurrence_lhs_target(lhs: &crate::Expr) -> Option<(&str, Option<&Vec<String>>)> {
match lhs {
crate::Expr::Variable(v) => Some((v.as_str(), None)),
crate::Expr::Operator(node) if node.op == "faq" => {
let crate::Expr::Operator(inner) = node.expr.as_deref()? else {
return None;
};
if inner.op != "index" {
return None;
}
let crate::Expr::Variable(v) = inner.args.first()? else {
return None;
};
Some((v.as_str(), node.output_idx.as_ref()))
}
_ => None,
}
}
fn check_recurrence_equation(
equation: &crate::types::Equation,
field_path: &str,
esm_file: &EsmFile,
array_shaped: &HashSet<&str>,
errors: &mut Vec<StructuralError>,
) {
let Some((var, lhs_idx)) = recurrence_lhs_target(&equation.lhs) else {
return;
};
if !array_shaped.contains(var) {
return;
}
let mut env: Vec<(String, (i64, i64))> = Vec::new();
let mut reads: Vec<StructuralSelfRead> = Vec::new();
let mut bare = false;
collect_structural_self_reads(
&equation.rhs,
var,
esm_file,
&mut env,
false,
&mut reads,
&mut bare,
);
if reads.is_empty() {
return;
}
let push = |errors: &mut Vec<StructuralError>,
code: StructuralErrorCode,
message: String,
axis: Option<&str>| {
errors.push(StructuralError {
path: field_path.to_string(),
code,
message,
details: serde_json::json!({
"variable": var,
"recurrence_axis": axis,
}),
});
};
if bare {
push(
errors,
StructuralErrorCode::RecurrenceNotWellfounded,
format!(
"'{var}' is read bare inside its own defining equation as well as through \
`index`. A bare read names the whole array, which does not exist while the \
recurrence sweeps it (esm-spec §4.3.1.1)."
),
None,
);
return;
}
if let Some(read) = reads.iter().find(|r| r.unsequenceable) {
let _ = read;
push(
errors,
StructuralErrorCode::RecurrenceUnsupportedForm,
format!(
"a causal self-read of '{var}' is reached only through a construct that \
evaluates its operand whole — a `makearray` region value, or a \
`reshape`/`transpose`/`concat`/`broadcast` operand — so no cell-by-cell sweep \
can supply it. A `makearray`'s region order fixes which write WINS, not the \
order cells are EVALUATED in (esm-spec §4.3.1.1, §4.3.2); write the recurrence \
as one `faq` with the base case as an `ifelse` guard in the body."
),
None,
);
return;
}
let rhs_idx = match &equation.rhs {
crate::Expr::Operator(node) if node.op == "faq" => node.output_idx.as_ref(),
_ => None,
};
let Some(idx_names) = lhs_idx.or(rhs_idx) else {
push(
errors,
StructuralErrorCode::RecurrenceUnsupportedForm,
format!(
"the definition of '{var}' reads '{var}' at another position, but the equation \
declares no cell frame to sweep: its RHS is not a `faq` over the \
variable's axes and its LHS is not the indexed-`faq` form \
`aggregate{{expr: index({var}, k…)}}` (esm-spec §4.3.1.1)."
),
None,
);
return;
};
if idx_names.is_empty() || idx_names.iter().any(|n| n.parse::<i64>().is_ok()) {
push(
errors,
StructuralErrorCode::RecurrenceUnsupportedForm,
format!(
"the recurrence definition of '{var}' has no symbolic output index to fold \
along ({idx_names:?}); a literal singleton dimension cannot be a recurrence \
axis (esm-spec §4.3.1.1)."
),
None,
);
return;
}
let frame_env: HashMap<String, (i64, i64)> = match &equation.rhs {
crate::Expr::Operator(node) if node.op == "faq" => node
.ranges
.as_ref()
.map(|m| {
m.iter()
.filter_map(|(k, spec)| {
validator_symbol_bounds(spec, esm_file).map(|b| (k.clone(), b))
})
.collect()
})
.unwrap_or_default(),
_ => HashMap::new(),
};
let mut axis: Option<usize> = None;
for read in &reads {
if read.args.len() != idx_names.len() {
push(
errors,
StructuralErrorCode::RecurrenceNotWellfounded,
format!(
"a causal self-read of '{var}' supplies {} indices but its frame has {} \
axes; every self-read indexes every axis (esm-spec §4.3.1.1).",
read.args.len(),
idx_names.len()
),
None,
);
return;
}
let mut env = frame_env.clone();
for (k, v) in &read.env {
env.insert(k.clone(), *v);
}
let mut lagged: Option<usize> = None;
for (d, arg) in read.args.iter().enumerate() {
let sym = &idx_names[d];
let Some(StructuralAffine { coef, konst }) = structural_affine_in_sym(arg, sym, &env)
else {
push(
errors,
StructuralErrorCode::RecurrenceNotWellfounded,
format!(
"index {d} of a causal self-read of '{var}' is not affine in its frame \
symbol '{sym}'. A self-read names a position RELATIVE to the cell being \
written (`{sym} - 1`, `{sym} - a`, `{sym} - a - 2`), which is what makes \
the recurrence axis and its direction decidable (esm-spec §4.3.1.1). \
{}",
data_lag_guidance(var, sym)
),
None,
);
return;
};
if coef != 1 {
push(
errors,
StructuralErrorCode::RecurrenceNotWellfounded,
format!(
"index {d} of a causal self-read of '{var}' carries its frame symbol \
'{sym}' with coefficient {coef}, not 1, so it does not name a position \
relative to the cell being written (esm-spec §4.3.1.1)."
),
None,
);
return;
}
let Some((clo, chi)) = konst else {
if lagged.is_some() {
push(
errors,
StructuralErrorCode::RecurrenceNotWellfounded,
format!(
"a causal self-read of '{var}' is offset on more than one axis. A \
recurrence folds along exactly ONE axis; every other index must be \
the bare frame symbol (esm-spec §4.3.1.1)."
),
Some(sym),
);
return;
}
lagged = Some(d);
continue;
};
let (lag_lo, lag_hi) = (-chi, -clo);
if lag_lo == 0 && lag_hi == 0 {
continue;
}
if lag_hi <= 0 {
push(
errors,
StructuralErrorCode::RecurrenceNotWellfounded,
format!(
"index {d} of a causal self-read of '{var}' names the cell being \
written, or a later one, on axis '{sym}'. A causal self-reference reads \
strictly EARLIER positions; no sweep order can satisfy a same-cell or \
forward read (esm-spec §4.3.1.1)."
),
Some(sym),
);
return;
}
if lagged.is_some() {
push(
errors,
StructuralErrorCode::RecurrenceNotWellfounded,
format!(
"a causal self-read of '{var}' is offset on more than one axis. A \
recurrence folds along exactly ONE axis; every other index must be the \
bare frame symbol (esm-spec §4.3.1.1)."
),
Some(sym),
);
return;
}
lagged = Some(d);
}
let Some(d) = lagged else {
push(
errors,
StructuralErrorCode::RecurrenceNotWellfounded,
format!(
"a causal self-read of '{var}' is at the same cell on every axis, so it \
defines '{var}' in terms of itself rather than of an earlier position \
(esm-spec §4.3.1.1)."
),
None,
);
return;
};
match axis {
None => axis = Some(d),
Some(prev) if prev == d => {}
Some(prev) => {
push(
errors,
StructuralErrorCode::RecurrenceNotWellfounded,
format!(
"the causal self-reads of '{var}' disagree on the recurrence axis: one \
folds along '{}' and another along '{}'. A definition folds along \
exactly one axis (esm-spec §4.3.1.1).",
idx_names[prev], idx_names[d]
),
Some(&idx_names[d]),
);
return;
}
}
}
}
fn recurrence_candidate_vars(model: &crate::Model) -> HashSet<String> {
let array_shaped: HashSet<&str> = model
.variables
.iter()
.filter(|(_, v)| v.shape.as_ref().is_some_and(|s| !s.is_empty()))
.map(|(n, _)| n.as_str())
.collect();
let mut out = HashSet::new();
for equation in &model.equations {
let Some((var, _)) = recurrence_lhs_target(&equation.lhs) else {
continue;
};
if array_shaped.contains(var) && has_index_self_read(&equation.rhs, var) {
out.insert(var.to_string());
}
}
out
}
fn has_index_self_read(expr: &crate::Expr, var: &str) -> bool {
let crate::Expr::Operator(node) = expr else {
return false;
};
if node.op == "index" && matches!(node.args.first(), Some(crate::Expr::Variable(v)) if v == var)
{
return true;
}
node.any_child(&mut |child| has_index_self_read(child, var))
}
fn observed_dependency_graph(
class: &crate::classification::Classification,
candidates: &HashSet<String>,
) -> std::collections::BTreeMap<String, std::collections::BTreeSet<String>> {
let observed: HashSet<&str> = class.observed_unknowns.iter().map(String::as_str).collect();
class
.observed_definitions
.iter()
.map(|(name, rhs)| {
let mut free = HashSet::new();
collect_free_symbols(rhs, &mut free);
let mut bound = HashSet::new();
collect_bound_symbols(rhs, &mut bound);
let mut deps: std::collections::BTreeSet<String> = free
.into_iter()
.filter(|s| !bound.contains(s) && observed.contains(s.as_str()))
.collect();
if candidates.contains(name) {
deps.remove(name);
}
(name.clone(), deps)
})
.collect()
}
fn check_observed_dependency_cycle(
model_name: &str,
model: &crate::Model,
class: &crate::classification::Classification,
errors: &mut Vec<StructuralError>,
) {
if class.observed_definitions.is_empty() {
return;
}
let candidates = recurrence_candidate_vars(model);
let deps = observed_dependency_graph(class, &candidates);
if let Some(cycle) = crate::classification::first_observed_cycle(&deps) {
errors.push(StructuralError {
path: format!("/models/{model_name}"),
code: StructuralErrorCode::ObservedCycle,
message: format!(
"Observed dependency cycle: {}. Each of these observed variables is defined \
in terms of the next, so no evaluation order satisfies every definition \
(esm-spec §4.9.6). Break the cycle by splitting one observed into a \
pre-value and a post-value.",
cycle.join(" -> ")
),
details: serde_json::json!({
"cycle": cycle,
"dependency_type": "observed_definitions",
}),
});
}
}
fn validate_array_broadcast_shapes(
model_name: &str,
model: &crate::Model,
errors: &mut Vec<StructuralError>,
) {
let model_path = format!("/models/{model_name}");
let declared_axes: HashMap<&str, &Vec<String>> = model
.variables
.iter()
.filter_map(|(name, var)| {
var.shape
.as_ref()
.filter(|s| !s.is_empty())
.map(|s| (name.as_str(), s))
})
.collect();
if declared_axes.is_empty() {
return;
}
for (eq_idx, equation) in model.equations.iter().enumerate() {
let Some(target) = whole_array_lhs_target(&equation.lhs) else {
continue;
};
check_operand_axes(
&equation.rhs,
target,
&declared_axes,
&format!("{model_path}/equations/{eq_idx}/rhs"),
errors,
);
}
}
fn whole_array_lhs_target(lhs: &crate::Expr) -> Option<&str> {
match lhs {
crate::Expr::Variable(name) => Some(name.as_str()),
crate::Expr::Operator(node) if node.op == "D" => match node.args.first() {
Some(crate::Expr::Variable(name)) => Some(name.as_str()),
_ => None,
},
_ => None,
}
}
fn check_operand_axes(
expr: &crate::Expr,
target: &str,
declared_axes: &HashMap<&str, &Vec<String>>,
path: &str,
errors: &mut Vec<StructuralError>,
) {
let Some(target_axes) = declared_axes.get(target) else {
return;
};
if (1..target_axes.len()).any(|d| target_axes[..d].contains(&target_axes[d])) {
return;
}
let mut operands: Vec<&str> = Vec::new();
collect_bare_array_operands(expr, declared_axes, &mut operands);
for operand in operands {
let Some(operand_axes) = declared_axes.get(operand) else {
continue;
};
for axis in operand_axes.iter() {
if target_axes.contains(axis) {
continue;
}
errors.push(StructuralError {
path: path.to_string(),
code: StructuralErrorCode::ArrayShapeMismatch,
message: format!(
"Operand '{operand}' of the array-level expression for '{target}' is \
declared over index set '{axis}', which '{target}' is not shaped over"
),
details: serde_json::json!({
"variable": target,
"operand": operand,
"operand_shape": operand_axes,
"result_shape": target_axes,
"missing_index_set": axis,
}),
});
break;
}
}
}
fn collect_bare_array_operands<'a>(
expr: &'a crate::Expr,
declared_axes: &HashMap<&str, &Vec<String>>,
out: &mut Vec<&'a str>,
) {
match expr {
crate::Expr::Variable(name) if declared_axes.contains_key(name.as_str()) => {
out.push(name.as_str());
}
crate::Expr::Operator(node) => {
if !crate::op_registry::is_elementwise_node(node) {
return;
}
node.for_each_child(&mut |child| {
collect_bare_array_operands(child, declared_axes, out)
});
}
_ => {}
}
}
fn check_aggregates_in_expr(
expr: &crate::Expr,
field_path: &dyn Fn() -> String,
esm_file: &EsmFile,
is_state: &dyn Fn(&str) -> bool,
var_shapes: &HashMap<String, Vec<String>>,
errors: &mut Vec<StructuralError>,
) {
let crate::Expr::Operator(node) = expr else {
return;
};
if node.op == "faq" {
let path = field_path();
check_aggregate_node(node, &path, esm_file, is_state, var_shapes, errors);
}
node.for_each_child(&mut |child| {
check_aggregates_in_expr(child, field_path, esm_file, is_state, var_shapes, errors);
});
}
fn check_join_sides(
node: &crate::types::ExpressionNode,
field_path: &str,
var_shapes: &HashMap<String, Vec<String>>,
errors: &mut Vec<StructuralError>,
) {
let (Some(joins), Some(ranges)) = (node.join.as_ref(), node.ranges.as_ref()) else {
return;
};
let mut set_to_syms: HashMap<&str, Vec<&str>> = HashMap::new();
for (sym, spec) in ranges {
if let crate::types::RangeSpec::IndexSetRef { from, .. } = spec {
set_to_syms.entry(from.as_str()).or_default().push(sym);
}
}
for syms in set_to_syms.values_mut() {
syms.sort_unstable();
}
for clause in joins {
if let Some([sl, sr]) = clause.syms.as_ref() {
for s in [sl, sr] {
if !ranges.contains_key(s) {
let mut declared: Vec<&str> = ranges.keys().map(String::as_str).collect();
declared.sort_unstable();
errors.push(StructuralError {
path: field_path.to_string(),
code: StructuralErrorCode::JoinSymsUnknownSymbol,
message: format!(
"Aggregate join `syms` names '{s}', which is not a range symbol of this aggregate; both entries must name one of its `ranges`"
),
details: serde_json::json!({
"join_syms_entry": s,
"declared_ranges": declared,
}),
});
return; }
}
continue;
}
for pair in &clause.on {
for (side, key) in [("left", pair[0].as_str()), ("right", pair[1].as_str())] {
if ranges.contains_key(key) {
continue;
}
let (set_name, via_column) = match set_to_syms.get(key) {
Some(_) => (key, false),
None => match var_shapes.get(key) {
Some(shape) if shape.len() == 1 => (shape[0].as_str(), true),
_ => continue, },
};
let Some(cands) = set_to_syms.get(set_name) else {
continue;
};
if cands.len() < 2 || (cands.len() == 2 && via_column) {
continue;
}
errors.push(StructuralError {
path: field_path.to_string(),
code: StructuralErrorCode::JoinSideAmbiguous,
message: if via_column {
format!(
"Aggregate join key column '{key}' lives on index set '{set_name}', which {} range symbols of this aggregate draw, so the document does not determine which one the key is read at; name the clause's two sides with `syms`",
cands.len()
)
} else {
format!(
"Aggregate join key '{key}' names an index set that {} range symbols of this aggregate draw; reference the range symbol directly, or name the clause's two sides with `syms`",
cands.len()
)
},
details: serde_json::json!({
"join_key": key,
"side": side,
"index_set": set_name,
"candidate_range_symbols": cands,
}),
});
return; }
}
}
}
const VALUE_INVENTION_BODY_OPS: [&str; 5] = ["skolem", "rank", "distinct", "argmin", "argmax"];
fn is_value_invention_faq(node: &crate::types::ExpressionNode) -> bool {
if node.distinct == Some(true) {
return true;
}
if matches!(node.key.as_deref(), Some(crate::Expr::Operator(key)) if key.op == "skolem") {
return true;
}
matches!(
node.expr.as_deref(),
Some(crate::Expr::Operator(body)) if VALUE_INVENTION_BODY_OPS.contains(&body.op.as_str())
)
}
fn contains_template_reference(expr: &crate::Expr) -> bool {
let crate::Expr::Operator(node) = expr else {
return false;
};
if node.op == "apply_expression_template" {
return true;
}
let mut found = false;
node.for_each_child(&mut |child| found = found || contains_template_reference(child));
found
}
fn collect_variable_refs(expr: &crate::Expr, out: &mut HashSet<String>) {
match expr {
crate::Expr::Variable(name) => {
out.insert(name.clone());
}
crate::Expr::Operator(node) => {
node.for_each_child(&mut |child| collect_variable_refs(child, out));
}
crate::Expr::Number(_) | crate::Expr::Integer(_) => {}
}
}
fn check_aggregate_node(
node: &crate::types::ExpressionNode,
field_path: &str,
esm_file: &EsmFile,
is_state: &dyn Fn(&str) -> bool,
var_shapes: &HashMap<String, Vec<String>>,
errors: &mut Vec<StructuralError>,
) {
let index_sets = esm_file.index_sets.as_ref();
check_join_sides(node, field_path, var_shapes, errors);
let registry_declared = index_sets.is_some_and(|m| !m.is_empty());
if let Some(ranges) = &node.ranges
&& registry_declared
{
let mut undeclared: Vec<String> = ranges
.values()
.filter_map(|r| match r {
crate::types::RangeSpec::IndexSetRef { from, .. } => Some(from.clone()),
_ => None,
})
.filter(|name| index_sets.is_none_or(|m| !m.contains_key(name)))
.collect();
undeclared.sort();
undeclared.dedup();
if let Some(first) = undeclared.first() {
errors.push(StructuralError {
path: field_path.to_string(),
code: StructuralErrorCode::UndefinedIndexSet,
message: format!("Aggregate range references undeclared index set '{first}'"),
details: serde_json::json!({ "undeclared_index_sets": undeclared }),
});
}
}
if let Some(joins) = &node.join
&& !joins.is_empty()
{
let cols: Vec<&str> = joins
.iter()
.flat_map(|jc| jc.on.iter())
.flat_map(|pair| [pair[0].as_str(), pair[1].as_str()])
.collect();
let ranges = node.ranges.as_ref();
'cols: for col in cols {
let Some(crate::types::RangeSpec::IndexSetRef { from, .. }) =
ranges.and_then(|r| r.get(col))
else {
continue;
};
let Some(iset) = index_sets.and_then(|m| m.get(from)) else {
continue;
};
if iset.kind != "categorical" {
continue;
}
let Some(members) = &iset.members else {
continue;
};
if members
.iter()
.any(|m| crate::join::JoinKey::from_json(m).is_err())
{
errors.push(StructuralError {
path: field_path.to_string(),
code: StructuralErrorCode::JoinKeyInvalidType,
message: format!(
"Aggregate join key column '{col}' ranges over categorical index set '{from}' whose members are not portable equality keys (a float or null member)"
),
details: serde_json::json!({
"join_key_column": col,
"index_set": from,
}),
});
break 'cols;
}
}
}
if let (Some(ranges), Some(sets)) = (&node.ranges, index_sets)
&& !is_value_invention_faq(node)
{
let body: Vec<&crate::Expr> = [node.expr.as_deref(), node.filter.as_deref()]
.into_iter()
.flatten()
.collect();
if !body.iter().any(|part| contains_template_reference(part)) {
let mut refs = HashSet::new();
for part in &body {
collect_variable_refs(part, &mut refs);
}
let mut syms: Vec<&String> = ranges.keys().collect();
syms.sort();
for sym in syms {
let crate::types::RangeSpec::IndexSetRef { from, of } = &ranges[sym] else {
continue;
};
let Some(iset) = sets.get(from) else {
continue;
};
if iset.kind != "ragged" {
continue;
}
let Some(values) = iset.values.as_deref() else {
continue;
};
let scoped = format!(".{values}");
if refs.iter().any(|r| r == values || r.ends_with(&scoped)) {
continue;
}
let parent = of
.as_ref()
.filter(|p| !p.is_empty())
.map(|p| p.join(", "))
.unwrap_or_else(|| "<parent>".to_string());
let offsets = iset.offsets.as_deref().unwrap_or("offsets");
errors.push(StructuralError {
path: field_path.to_string(),
code: StructuralErrorCode::RaggedValuesNotGathered,
message: format!(
"faq range '{sym}' iterates ragged index set '{from}', so it binds the POSITION k in 1..{offsets}[{parent}], not a member; the body never reads the set's `values` array '{values}'. Gather the member explicitly: index({values}, {parent}, {sym})"
),
details: serde_json::json!({
"range": sym,
"index_set": from,
"values": values,
}),
});
}
}
}
if node.distinct == Some(true) {
let mut free = HashSet::new();
if let Some(key) = node.key.as_deref() {
collect_free_symbols(key, &mut free);
}
if let Some(body) = node.expr.as_deref() {
collect_free_symbols(body, &mut free);
}
let mut states_read: Vec<String> = free.into_iter().filter(|name| is_state(name)).collect();
if !states_read.is_empty() {
states_read.sort();
errors.push(StructuralError {
path: field_path.to_string(),
code: StructuralErrorCode::RelationalNodeInContinuous,
message: "Value-invention aggregate (distinct) reads a state variable, so it classes continuous; relational work is not permitted on the per-step hot path".to_string(),
details: serde_json::json!({ "state_variables_read": states_read }),
});
}
}
}
fn record_unit_findings(
findings: Vec<crate::units::UnitFinding>,
path: &str,
subject: &str,
errors: &mut Vec<StructuralError>,
warnings: &mut Vec<UnitWarning>,
) {
for finding in findings {
if finding.is_error() {
errors.push(StructuralError {
path: path.to_string(),
code: StructuralErrorCode::UnitInconsistency,
message: finding.message.clone(),
details: serde_json::json!({
"subject": subject,
"detail": finding.message,
}),
});
} else {
warnings.push(UnitWarning {
path: path.to_string(),
code: finding.code().to_string(),
message: format!("{subject}: {} (in {path})", finding.message),
lhs_units: String::new(),
rhs_units: String::new(),
});
}
}
}
fn known_physical_constants() -> &'static [(&'static str, &'static str, &'static str)] {
&[
("R", "J/(mol*K)", "ideal gas constant"),
("k_B", "J/K", "Boltzmann constant"),
("N_A", "1/mol", "Avogadro constant"),
]
}
const PHYSICAL_CONSTANT_UNIT_MESSAGE: &str =
"Physical constant used with incorrect dimensional analysis";
fn check_physical_constant_units(
model_name: &str,
model: &crate::Model,
errors: &mut Vec<StructuralError>,
) {
for (constant_name, canonical, description) in known_physical_constants() {
let Some(var) = model.variables.get(*constant_name) else {
continue;
};
if var.var_type != crate::VariableType::Parameter {
continue;
}
let Some(declared) = var.units.as_deref() else {
continue;
};
if declared.is_empty() {
continue;
}
let Ok(declared_unit) = parse_unit(declared) else {
continue;
};
let Ok(canonical_unit) = parse_unit(canonical) else {
continue;
};
if declared_unit.is_compatible(&canonical_unit) {
continue;
}
let observed = crate::classification::Classification::of(model).observed_definitions;
let usage_site: Option<&str> = observed
.iter()
.find(|(_, rhs)| expr_references_name(rhs, constant_name))
.and_then(|(name, _)| {
model
.variables
.get_key_value(name.as_str())
.map(|(k, _)| k.as_str())
});
let target = usage_site.unwrap_or(constant_name);
let target_path = format!("/models/{model_name}/variables/{target}");
errors.retain(|e| {
!(matches!(e.code, StructuralErrorCode::UnitInconsistency)
&& e.path == target_path
&& e.message != PHYSICAL_CONSTANT_UNIT_MESSAGE)
});
errors.push(StructuralError {
path: target_path,
code: StructuralErrorCode::UnitInconsistency,
message: PHYSICAL_CONSTANT_UNIT_MESSAGE.to_string(),
details: serde_json::json!({
"constant_name": constant_name,
"constant_description": description,
"declared_units": declared,
"canonical_units": canonical,
}),
});
}
}
fn expr_references_name(expr: &crate::Expr, name: &str) -> bool {
match expr {
crate::Expr::Variable(v) => v == name,
crate::Expr::Operator(node) => node.any_child(&mut |a| expr_references_name(a, name)),
crate::Expr::Number(_) | crate::Expr::Integer(_) => false,
}
}
fn count_defining_equations(equations: &[crate::Equation]) -> usize {
equations
.iter()
.filter(|eq| !is_ic_equation(&eq.lhs))
.count()
}
fn analyze_equation_mismatch(
equations: &[crate::Equation],
state_vars: &[String],
) -> (Vec<String>, Vec<String>) {
let state_vars_set: HashSet<_> = state_vars.iter().cloned().collect();
let mut lhs_vars = HashSet::new();
for equation in equations {
if is_ic_equation(&equation.lhs) {
continue; }
match &equation.lhs {
crate::Expr::Operator(op) if op.op == "D" => {
if let Some(crate::Expr::Variable(var_name)) = op.args.first() {
lhs_vars.insert(var_name.clone());
}
}
crate::Expr::Variable(var_name) => {
lhs_vars.insert(var_name.clone());
}
crate::Expr::Operator(_) => {
let mut free = HashSet::new();
collect_free_symbols(&equation.lhs, &mut free);
lhs_vars.extend(free.intersection(&state_vars_set).cloned());
}
crate::Expr::Number(_) | crate::Expr::Integer(_) => {}
}
}
let extra_equations_for: Vec<_> = lhs_vars.difference(&state_vars_set).cloned().collect();
let missing_equations_for: Vec<_> = state_vars_set.difference(&lhs_vars).cloned().collect();
(extra_equations_for, missing_equations_for)
}
pub(crate) fn validate_reaction_system(
esm_file: &EsmFile,
rs_name: &str,
rs: &crate::ReactionSystem,
system_refs: &HashMap<String, SystemInfo>,
document_units: &HashMap<String, Unit>,
errors: &mut Vec<StructuralError>,
) {
let rs_path = format!("/reaction_systems/{rs_name}");
let defined_species: HashSet<String> = rs.species.keys().cloned().collect();
let defined_parameters: HashSet<String> = rs.parameters.keys().cloned().collect();
let owner = format!("Reaction system '{rs_name}'");
check_reserved_declaration_names(
esm_file,
rs.species.keys(),
&format!("{rs_path}/species"),
&owner,
"species",
errors,
);
check_reserved_declaration_names(
esm_file,
rs.parameters.keys(),
&format!("{rs_path}/parameters"),
&owner,
"parameter",
errors,
);
let rate_scope: HashSet<String> = defined_parameters
.union(&defined_species)
.cloned()
.chain(implicitly_declared_symbols(esm_file))
.collect();
for (rxn_idx, reaction) in rs.reactions.iter().enumerate() {
let rxn_path = format!("{rs_path}/reactions/{rxn_idx}");
let reaction_label = reaction
.id
.as_deref()
.or(reaction.name.as_deref())
.unwrap_or("unnamed");
let substrates_empty = reaction.substrates.as_ref().is_none_or(|v| v.is_empty());
let products_empty = reaction.products.as_ref().is_none_or(|v| v.is_empty());
if substrates_empty && products_empty {
errors.push(StructuralError {
path: rxn_path.clone(),
code: StructuralErrorCode::NullReaction,
message: "Reaction has both substrates: null and products: null".to_string(),
details: serde_json::json!({
"reaction_id": reaction_label
}),
});
}
for (sub_idx, substrate) in reaction.substrates.iter().flatten().enumerate() {
if !defined_species.contains(&substrate.species) {
errors.push(StructuralError {
path: format!("{rxn_path}/substrates/{sub_idx}/species"),
code: StructuralErrorCode::UndefinedSpecies,
message: format!(
"Species '{}' referenced in reaction substrates is not declared",
substrate.species
),
details: serde_json::json!({
"species": substrate.species,
"reaction_id": reaction_label,
"location": "substrates",
"expected_in": "species"
}),
});
}
}
for (prod_idx, product) in reaction.products.iter().flatten().enumerate() {
if !defined_species.contains(&product.species) {
errors.push(StructuralError {
path: format!("{rxn_path}/products/{prod_idx}/species"),
code: StructuralErrorCode::UndefinedSpecies,
message: format!(
"Species '{}' referenced in reaction products is not declared",
product.species
),
details: serde_json::json!({
"species": product.species,
"reaction_id": reaction_label,
"location": "products",
"expected_in": "species"
}),
});
}
}
validate_rate_expression(
&reaction.rate,
&rate_scope,
system_refs,
&format!("{rxn_path}/rate"),
reaction_label,
errors,
);
}
let mut rs_scope: HashSet<String> = defined_species
.union(&defined_parameters)
.cloned()
.collect();
rs_scope.extend(implicitly_declared_symbols(esm_file));
if coupled_system_names(esm_file).contains(rs_name) {
rs_scope.extend(document_declared_names(esm_file));
}
if let Some(constraint_eqs) = &rs.constraint_equations {
for (ce_idx, eq) in constraint_eqs.iter().enumerate() {
if let crate::Expr::Operator(node) = &eq.lhs
&& node.op == "ic"
{
let species = match node.args.first() {
Some(crate::Expr::Variable(s)) => s.clone(),
_ => String::new(),
};
errors.push(StructuralError {
path: format!("{rs_path}/constraint_equations/{ce_idx}"),
code: StructuralErrorCode::IcInReactionSystem,
message: "ic equation not allowed in a reaction system; a reaction system has no equations field and hosts no ic equations (ICs are model-hosted: species.default, or a scoped-reference ic equation in a model, spec §11.4.1)".to_string(),
details: serde_json::json!({
"system": rs_name,
"species": species,
"constraint_equation_index": ce_idx,
}),
});
}
let ce_path = format!("{rs_path}/constraint_equations/{ce_idx}");
for (side, expr) in [("lhs", &eq.lhs), ("rhs", &eq.rhs)] {
let mut ref_scope = rs_scope.clone();
collect_bound_symbols(expr, &mut ref_scope);
validate_expression_references_with_systems(
expr,
&ref_scope,
system_refs,
&HashSet::new(),
&format!("{ce_path}/{side}"),
ce_idx,
errors,
);
}
}
}
let no_variables = indexmap::IndexMap::new();
for (event_idx, event) in rs.discrete_events.iter().flatten().enumerate() {
validate_discrete_event(event, event_idx, &rs_path, &rs_scope, &no_variables, errors);
}
for (event_idx, event) in rs.continuous_events.iter().flatten().enumerate() {
validate_continuous_event(event, event_idx, &rs_path, &rs_scope, &no_variables, errors);
}
for (t_idx, test) in rs.tests.iter().flatten().enumerate() {
for (a_idx, assertion) in test.assertions.iter().enumerate() {
let Some(crate::types::AssertionReference::Expression(reference)) =
&assertion.reference
else {
continue;
};
let mut ref_scope = rs_scope.clone();
collect_bound_symbols(reference, &mut ref_scope);
validate_expression_references_with_systems(
reference,
&ref_scope,
system_refs,
&HashSet::new(),
&format!("{rs_path}/tests/{t_idx}/assertions/{a_idx}/reference"),
0,
errors,
);
}
}
validate_reaction_rate_units(rs_name, rs, document_units, errors);
}
fn validate_reaction_rate_units(
rs_name: &str,
rs: &crate::ReactionSystem,
document_units: &HashMap<String, Unit>,
errors: &mut Vec<StructuralError>,
) {
let mut env: HashMap<String, Unit> = HashMap::new();
let mut parse_failures: Vec<(String, String, String)> = Vec::new();
for (name, species) in &rs.species {
match &species.units {
Some(s) => match parse_unit(s) {
Ok(u) => {
env.insert(name.clone(), u);
}
Err(_) => parse_failures.push(("species".into(), name.clone(), s.clone())),
},
None => continue,
}
}
for (name, param) in &rs.parameters {
match ¶m.units {
Some(s) => match parse_unit(s) {
Ok(u) => {
env.insert(name.clone(), u);
}
Err(_) => parse_failures.push(("parameters".into(), name.clone(), s.clone())),
},
None => continue,
}
}
parse_failures.sort();
for (kind, name, units) in parse_failures {
errors.push(StructuralError {
path: format!("/reaction_systems/{rs_name}/{kind}/{name}"),
code: StructuralErrorCode::UnitParseError,
message: format!("Unit string '{units}' is not a recognised unit"),
details: serde_json::json!({
"name": name,
"units": units,
}),
});
}
let env = ComponentUnitEnv::new(env, rs.subsystems.as_ref(), document_units);
let time = Unit::base(crate::units::Dimension::Time, 1, 1.0);
for (rxn_idx, reaction) in rs.reactions.iter().enumerate() {
let rxn_path = format!("/reaction_systems/{rs_name}/reactions/{rxn_idx}");
let reaction_label = reaction
.id
.as_deref()
.or(reaction.name.as_deref())
.unwrap_or("unnamed");
let rate_unit = match Unit::propagate_in(&reaction.rate, &env) {
Ok(u) => u,
Err(_) => continue,
};
let substrates = match reaction.substrates.as_ref() {
Some(s) if !s.is_empty() => s,
_ => continue,
};
let first_sp_name = &substrates[0].species;
let conc_unit = match env.unit_of(first_sp_name) {
Some(u) => u.clone(),
None => continue,
};
if conc_unit.is_dimensionless() {
continue;
}
let mut total_order: u32 = 0;
let mut resolvable = true;
let mut fractional_substrate = false;
for entry in substrates {
if !env.contains(&entry.species) {
resolvable = false;
break;
}
if entry.coefficient.fract() != 0.0 || !entry.coefficient.is_finite() {
fractional_substrate = true;
break;
}
total_order += entry.coefficient as u32;
}
if !resolvable || fractional_substrate {
continue;
}
let expected_rate_constant = conc_unit.power(1 - total_order as i32).divide(&time);
let expected_velocity = conc_unit.divide(&time);
if !rate_unit.is_compatible(&expected_rate_constant)
&& !rate_unit.is_compatible(&expected_velocity)
{
let rate_units_str = reaction_rate_units_str(&reaction.rate, rs);
let first_sp_units = rs
.species
.get(first_sp_name)
.and_then(|s| s.units.clone())
.unwrap_or_default();
errors.push(StructuralError {
path: rxn_path,
code: StructuralErrorCode::UnitInconsistency,
message:
"Reaction rate expression has incompatible units for reaction stoichiometry"
.to_string(),
details: serde_json::json!({
"reaction_id": reaction_label,
"rate_units": rate_units_str,
"expected_rate_units": format_expected_rate_units(&first_sp_units, total_order),
"reaction_order": total_order,
}),
});
}
}
}
fn format_expected_rate_units(species_units: &str, total_order: u32) -> String {
let exp: i32 = 1 - total_order as i32;
if exp == 0 {
return "1/s".to_string();
}
let (mut num, mut den) = split_unit_num_den(species_units);
let mut exp_abs = exp;
if exp < 0 {
std::mem::swap(&mut num, &mut den);
exp_abs = -exp;
}
let num_str = power_factor(&num, exp_abs);
let mut den_factors: Vec<String> = Vec::new();
let df = power_factor(&den, exp_abs);
if !df.is_empty() {
den_factors.push(df);
}
den_factors.push("s".to_string());
let num_out = if num_str.is_empty() {
"1".to_string()
} else {
num_str
};
if den_factors.len() == 1 {
format!("{}/{}", num_out, den_factors[0])
} else {
format!("{}/({})", num_out, den_factors.join("*"))
}
}
fn split_unit_num_den(s: &str) -> (String, String) {
let s = s.trim();
if s.is_empty() {
return (String::new(), String::new());
}
let mut depth = 0i32;
for (i, c) in s.char_indices() {
match c {
'(' => depth += 1,
')' => depth -= 1,
'/' if depth == 0 => {
let num = s[..i].trim().to_string();
let den_raw = s[i + 1..].trim();
let den = den_raw
.strip_prefix('(')
.and_then(|t| t.strip_suffix(')'))
.unwrap_or(den_raw)
.to_string();
return (num, den);
}
_ => {}
}
}
(s.to_string(), String::new())
}
fn power_factor(s: &str, n: i32) -> String {
let s = s.trim();
if s.is_empty() {
return String::new();
}
if n == 1 {
return s.to_string();
}
if s.contains('*') || s.contains('/') {
format!("({s})^{n}")
} else {
format!("{s}^{n}")
}
}
fn reaction_rate_units_str(rate: &crate::Expr, rs: &crate::ReactionSystem) -> String {
if let crate::Expr::Variable(name) = rate {
if let Some(p) = rs.parameters.get(name)
&& let Some(u) = &p.units
{
return u.clone();
}
if let Some(s) = rs.species.get(name)
&& let Some(u) = &s.units
{
return u.clone();
}
}
String::new()
}
fn bound_index_symbols(node: &crate::types::ExpressionNode) -> Vec<String> {
let mut syms = Vec::new();
if let Some(idx) = &node.output_idx {
syms.extend(idx.iter().cloned());
}
if let Some(ranges) = &node.ranges {
syms.extend(ranges.keys().cloned());
}
if let Some(v) = &node.int_var {
syms.push(v.clone());
}
if let Some(a) = &node.arg {
syms.push(a.clone());
}
if node.op == "index" {
for arg in node.args.iter().skip(1) {
if let crate::Expr::Variable(name) = arg {
syms.push(name.clone());
}
}
}
if let Some(bindings) = &node.bindings {
syms.extend(bindings.keys().cloned());
}
syms
}
fn collect_bound_symbols_noting_faq(expr: &crate::Expr, out: &mut HashSet<String>, faq: &mut bool) {
if let crate::Expr::Operator(node) = expr {
*faq |= node.op == "faq";
for sym in bound_index_symbols(node) {
out.insert(sym);
}
node.for_each_child(&mut |child| collect_bound_symbols_noting_faq(child, out, faq));
}
}
fn collect_bound_symbols(expr: &crate::Expr, out: &mut HashSet<String>) {
if let crate::Expr::Operator(node) = expr {
for sym in bound_index_symbols(node) {
out.insert(sym);
}
node.for_each_child(&mut |child| collect_bound_symbols(child, out));
}
}
pub(crate) enum MissingScopedComponent<'a> {
System(&'a str),
Component(&'a str),
}
pub(crate) fn resolve_scoped_ref<'a>(
name: &'a str,
system_refs: &HashMap<String, SystemInfo>,
) -> Result<(), MissingScopedComponent<'a>> {
let Some((system_name, component)) = name.rsplit_once('.') else {
return Ok(()); };
match system_refs.get(system_name) {
Some(system) => {
if system.variables.contains(component)
|| system.species.contains(component)
|| system.parameters.contains(component)
{
Ok(())
} else {
Err(MissingScopedComponent::Component(component))
}
}
None => Err(MissingScopedComponent::System(system_name)),
}
}
fn validate_rate_expression(
rate: &crate::Expr,
defined_parameters: &HashSet<String>,
system_refs: &HashMap<String, SystemInfo>,
reaction_path: &str,
reaction_id: &str,
errors: &mut Vec<StructuralError>,
) {
match rate {
crate::Expr::Variable(var_name) => {
if var_name.contains('.') {
if resolve_scoped_ref(var_name, system_refs).is_err() {
errors.push(StructuralError {
path: reaction_path.to_string(),
code: StructuralErrorCode::UnresolvedScopedRef,
message: format!("Scoped reference '{var_name}' cannot be resolved"),
details: serde_json::json!({
"reference": var_name,
"reaction_id": reaction_id,
}),
});
}
return;
}
if !defined_parameters.contains(var_name) {
errors.push(StructuralError {
path: reaction_path.to_string(),
code: StructuralErrorCode::UndefinedParameter,
message: format!(
"Parameter '{var_name}' referenced in rate expression is not declared"
),
details: serde_json::json!({
"parameter": var_name,
"reaction_id": reaction_id,
"expected_in": "parameters"
}),
});
}
}
crate::Expr::Operator(op_node) => {
let bound = bound_index_symbols(op_node);
if bound.is_empty() {
op_node.for_each_child(&mut |arg| {
validate_rate_expression(
arg,
defined_parameters,
system_refs,
reaction_path,
reaction_id,
errors,
)
});
} else {
let mut scope = defined_parameters.clone();
scope.extend(bound);
op_node.for_each_child(&mut |arg| {
validate_rate_expression(
arg,
&scope,
system_refs,
reaction_path,
reaction_id,
errors,
)
});
}
}
crate::Expr::Number(_) | crate::Expr::Integer(_) => {
}
}
}
fn subsystem_scoped_refs(model: &crate::Model) -> HashSet<String> {
let mut refs = HashSet::new();
if let Some(subs) = &model.subsystems {
collect_subsystem_scoped_refs("", subs.iter(), &mut refs);
}
refs
}
fn collect_subsystem_scoped_refs<'a>(
prefix: &str,
subs: impl Iterator<Item = (&'a String, &'a serde_json::Value)>,
refs: &mut HashSet<String>,
) {
for (sub_name, value) in subs {
let path = if prefix.is_empty() {
sub_name.clone()
} else {
format!("{prefix}.{sub_name}")
};
let component = value
.get("models")
.or_else(|| value.get("reaction_systems"))
.and_then(|m| m.as_object())
.filter(|m| m.len() == 1)
.and_then(|m| m.values().next())
.unwrap_or(value);
for field in ["variables", "species"] {
let Some(members) = component.get(field).and_then(|v| v.as_object()) else {
continue;
};
for var in members.keys() {
refs.insert(format!("{path}.{var}"));
}
}
if let Some(nested) = component.get("subsystems").and_then(|v| v.as_object()) {
collect_subsystem_scoped_refs(&path, nested.iter(), refs);
}
}
}
pub(crate) fn toplevel_reference_units(esm_file: &EsmFile) -> HashMap<String, String> {
let mut out = HashMap::new();
let mut insert = |name: String, units: Option<&str>| {
if let Some(units) = units {
out.insert(name, units.to_string());
}
};
for (name, model) in esm_file.models.iter().flatten() {
for (var, decl) in &model.variables {
insert(format!("{name}.{var}"), decl.units.as_deref());
}
if let Some(subs) = &model.subsystems {
collect_subsystem_units(name, subs.iter(), &mut insert);
}
}
for (name, rs) in esm_file.reaction_systems.iter().flatten() {
for (member, units) in rs
.species
.iter()
.map(|(k, v)| (k, &v.units))
.chain(rs.parameters.iter().map(|(k, v)| (k, &v.units)))
{
insert(format!("{name}.{member}"), units.as_deref());
}
if let Some(subs) = &rs.subsystems {
collect_subsystem_units(name, subs.iter(), &mut insert);
}
}
out
}
pub(crate) fn subsystem_reference_units(
subsystems: &indexmap::IndexMap<String, serde_json::Value>,
) -> HashMap<String, Option<String>> {
let mut out = HashMap::new();
collect_subsystem_units("", subsystems.iter(), &mut |name, units| {
out.insert(name, units.map(str::to_string));
});
out
}
fn collect_subsystem_units<'a>(
prefix: &str,
subs: impl Iterator<Item = (&'a String, &'a serde_json::Value)>,
visit: &mut impl FnMut(String, Option<&str>),
) {
for (sub_name, value) in subs {
let path = if prefix.is_empty() {
sub_name.clone()
} else {
format!("{prefix}.{sub_name}")
};
let component = value
.get("models")
.or_else(|| value.get("reaction_systems"))
.and_then(|m| m.as_object())
.filter(|m| m.len() == 1)
.and_then(|m| m.values().next())
.unwrap_or(value);
for field in ["variables", "species", "parameters"] {
let Some(members) = component.get(field).and_then(|v| v.as_object()) else {
continue;
};
for (member, decl) in members {
let units = decl.get("units").and_then(|u| u.as_str());
visit(format!("{path}.{member}"), units);
}
}
if let Some(nested) = component.get("subsystems").and_then(|v| v.as_object()) {
collect_subsystem_units(&path, nested.iter(), visit);
}
}
}
pub(crate) fn validate_expression_references_with_systems<S: std::hash::BuildHasher>(
expr: &crate::Expr,
defined_vars: &HashSet<String, S>,
system_refs: &HashMap<String, SystemInfo>,
local_scoped: &HashSet<String>,
base_path: &str,
equation_index: usize,
errors: &mut Vec<StructuralError>,
) {
references_in_scope(
expr,
&NameScope::Set(&|name: &str| defined_vars.contains(name)),
system_refs,
local_scoped,
base_path,
equation_index,
errors,
&mut RefWalkSeen::default(),
);
}
#[derive(Clone, Copy)]
enum NameScope<'a> {
Set(&'a dyn Fn(&str) -> bool),
With(&'a NameScope<'a>, &'a [String]),
WithSet(&'a NameScope<'a>, &'a HashSet<String>),
}
impl NameScope<'_> {
fn contains(&self, name: &str) -> bool {
match self {
NameScope::Set(declared) => declared(name),
NameScope::With(outer, names) => {
names.iter().any(|n| n == name) || outer.contains(name)
}
NameScope::WithSet(outer, names) => names.contains(name) || outer.contains(name),
}
}
}
#[derive(Default)]
struct RefWalkSeen {
binder: bool,
faq: bool,
enum_node: bool,
}
#[allow(clippy::too_many_arguments)]
fn references_in_scope(
expr: &crate::Expr,
defined_vars: &NameScope<'_>,
system_refs: &HashMap<String, SystemInfo>,
local_scoped: &HashSet<String>,
base_path: &str,
equation_index: usize,
errors: &mut Vec<StructuralError>,
seen: &mut RefWalkSeen,
) {
match expr {
crate::Expr::Variable(var_name) => {
if var_name.starts_with("d(") || is_builtin_function_name(var_name) {
return; }
if local_scoped.contains(var_name) {
return;
}
if var_name.contains('.') {
if let Err(missing) = resolve_scoped_ref(var_name, system_refs) {
let missing_component = match missing {
MissingScopedComponent::System(s) => s,
MissingScopedComponent::Component(c) => c,
};
errors.push(StructuralError {
path: base_path.to_string(),
code: StructuralErrorCode::UnresolvedScopedRef,
message: format!("Scoped reference '{var_name}' cannot be resolved"),
details: serde_json::json!({
"reference": var_name,
"equation_index": equation_index,
"missing_component": missing_component
}),
});
}
} else {
if !defined_vars.contains(var_name) {
errors.push(StructuralError {
path: base_path.to_string(),
code: StructuralErrorCode::UndefinedVariable,
message: format!(
"Variable '{var_name}' referenced in equation is not declared"
),
details: serde_json::json!({
"variable": var_name,
"equation_index": equation_index,
"expected_in": "variables"
}),
});
}
}
}
crate::Expr::Operator(op_node) => {
if op_node.op == "broadcast" {
check_broadcast_fn_node(op_node, base_path, equation_index, errors);
}
if op_node.op == "enum" {
seen.enum_node = true;
return;
}
seen.faq |= op_node.op == "faq";
let bound = bound_index_symbols(op_node);
seen.binder |= !bound.is_empty();
let scope = if bound.is_empty() {
*defined_vars
} else {
NameScope::With(defined_vars, &bound)
};
op_node.for_each_child(&mut |child| {
references_in_scope(
child,
&scope,
system_refs,
local_scoped,
base_path,
equation_index,
errors,
seen,
)
});
}
crate::Expr::Number(_) | crate::Expr::Integer(_) => {
}
}
}
fn check_broadcast_fn_node(
node: &crate::types::ExpressionNode,
base_path: &str,
equation_index: usize,
errors: &mut Vec<StructuralError>,
) {
use crate::op_registry::OpError;
let Err(err) = crate::op_registry::check_broadcast_fn(node) else {
return;
};
let (message, details) = match err {
OpError::BroadcastFn { fn_name, reason } => (
reason,
serde_json::json!({
"broadcast_fn": fn_name,
"equation_index": equation_index,
}),
),
OpError::Arity { op, got, expected } => (
format!(
"'broadcast' fn '{op}' takes {expected} argument(s), got {got} (esm-spec §4.3.4)"
),
serde_json::json!({
"broadcast_fn": op,
"got": got,
"expected": expected,
"equation_index": equation_index,
}),
),
other => (other.to_string(), serde_json::json!({})),
};
errors.push(StructuralError {
path: base_path.to_string(),
code: StructuralErrorCode::InvalidBroadcastFn,
message,
details,
});
}
fn validate_discrete_event<D: DeclaredLookup + ?Sized>(
event: &crate::DiscreteEvent,
event_idx: usize,
parent_path: &str,
defined_vars: &D,
variables: &indexmap::IndexMap<String, crate::types::ModelVariable>,
errors: &mut Vec<StructuralError>,
) {
let event_path = format!("{parent_path}/discrete_events/{event_idx}");
let event_name = event.name.as_deref().unwrap_or("unnamed");
if let crate::DiscreteEventTrigger::Condition { expression } = &event.trigger {
validate_event_ref_expression(
expression,
defined_vars,
&format!("{event_path}/trigger/expression"),
errors,
);
}
if let Some(ref affects) = event.affects {
validate_event_affects(
affects,
&EventAffectsCtx {
defined_vars,
variables,
trigger: Some(&event.trigger),
event_path: &event_path,
location: "affects",
event_name,
event_type: "discrete",
},
errors,
);
}
}
fn validate_continuous_event<D: DeclaredLookup + ?Sized>(
event: &crate::ContinuousEvent,
event_idx: usize,
parent_path: &str,
defined_vars: &D,
variables: &indexmap::IndexMap<String, crate::types::ModelVariable>,
errors: &mut Vec<StructuralError>,
) {
let event_path = format!("{parent_path}/continuous_events/{event_idx}");
let event_name = event.name.as_deref().unwrap_or("unnamed");
for (cond_idx, condition) in event.conditions.iter().enumerate() {
validate_event_ref_expression(
condition,
defined_vars,
&format!("{event_path}/conditions/{cond_idx}"),
errors,
);
}
validate_event_affects(
&event.affects,
&EventAffectsCtx {
defined_vars,
variables,
trigger: None,
event_path: &event_path,
location: "affects",
event_name,
event_type: "continuous",
},
errors,
);
if let Some(ref affect_neg) = event.affect_neg {
validate_event_affects(
affect_neg,
&EventAffectsCtx {
defined_vars,
variables,
trigger: None,
event_path: &event_path,
location: "affect_neg",
event_name,
event_type: "continuous",
},
errors,
);
}
}
struct EventAffectsCtx<'a, D: ?Sized> {
defined_vars: &'a D,
variables: &'a indexmap::IndexMap<String, crate::types::ModelVariable>,
trigger: Option<&'a crate::DiscreteEventTrigger>,
event_path: &'a str,
location: &'a str,
event_name: &'a str,
event_type: &'a str,
}
fn validate_event_affects<D: DeclaredLookup + ?Sized>(
affects: &[crate::AffectEquation],
ctx: &EventAffectsCtx<'_, D>,
errors: &mut Vec<StructuralError>,
) {
let &EventAffectsCtx {
defined_vars,
variables,
trigger,
event_path,
location,
event_name,
event_type,
} = ctx;
for (affect_idx, affect) in affects.iter().enumerate() {
if variables.get(&affect.lhs).map(|v| v.var_type) == Some(crate::VariableType::Parameter) {
let mut details = serde_json::json!({
"variable": affect.lhs,
"variable_type": "parameter",
"event_name": event_name,
"event_type": event_type,
"remedy": remedy_for(&affect.lhs, trigger),
});
if let Some(kind) = trigger.map(trigger_kind) {
details["trigger_type"] = serde_json::json!(kind);
}
errors.push(StructuralError {
path: format!("{event_path}/{location}/{affect_idx}"),
code: StructuralErrorCode::EventAffectsParameter,
message: format!(
"Event '{event_name}' affects '{}', which is a parameter; an event may affect unknowns only",
affect.lhs
),
details,
});
}
if !defined_vars.declares(&affect.lhs) {
errors.push(StructuralError {
path: format!("{event_path}/{location}/{affect_idx}/lhs"),
code: StructuralErrorCode::EventVarUndeclared,
message: format!(
"Variable '{}' in event {location} is not declared",
affect.lhs
),
details: serde_json::json!({
"variable": affect.lhs,
"event_name": event_name,
"event_type": event_type,
"location": location,
"expected_in": "variables"
}),
});
}
validate_event_ref_expression(
&affect.rhs,
defined_vars,
&format!("{event_path}/{location}/{affect_idx}/rhs"),
errors,
);
}
}
fn trigger_kind(trigger: &crate::DiscreteEventTrigger) -> &'static str {
match trigger {
crate::DiscreteEventTrigger::Condition { .. } => "condition",
crate::DiscreteEventTrigger::Periodic { .. } => "periodic",
crate::DiscreteEventTrigger::PresetTimes { .. } => "preset_times",
}
}
fn remedy_for(variable: &str, trigger: Option<&crate::DiscreteEventTrigger>) -> String {
match trigger {
Some(crate::DiscreteEventTrigger::Periodic { interval, .. }) => format!(
"declare the change as update: {{kind: \"schedule\", interval: {interval:?}}} on '{variable}' (esm-spec 5.4)"
),
Some(crate::DiscreteEventTrigger::PresetTimes { times }) => format!(
"declare the change as update: {{kind: \"schedule\", times: {times:?}}} on '{variable}' (esm-spec 5.4)"
),
_ => "declare the change as the parameter's own update (esm-spec 5.4)".to_string(),
}
}
fn validate_event_ref_expression<D: DeclaredLookup + ?Sized>(
expr: &crate::Expr,
defined_vars: &D,
path: &str,
errors: &mut Vec<StructuralError>,
) {
match expr {
crate::Expr::Variable(var_name) => {
if !is_builtin_function_name(var_name) && !defined_vars.declares(var_name) {
errors.push(StructuralError {
path: path.to_string(),
code: StructuralErrorCode::UndefinedVariable,
message: format!(
"Variable \"{var_name}\" referenced in event expression is not declared"
),
details: serde_json::json!({ "variable": var_name }),
});
}
}
crate::Expr::Operator(op_node) => {
op_node.for_each_child(&mut |arg| {
validate_event_ref_expression(arg, defined_vars, path, errors)
});
}
crate::Expr::Number(_) | crate::Expr::Integer(_) => {
}
}
}
pub(crate) fn check_circular_dependencies_in_models(
models: &indexmap::IndexMap<String, crate::Model>,
errors: &mut Vec<StructuralError>,
) {
let mut dependencies: HashMap<String, HashSet<String>> = HashMap::new();
for (model_name, model) in models {
let mut model_deps = HashSet::new();
for equation in &model.equations {
extract_model_dependencies(&equation.rhs, &mut model_deps, model_name, models);
extract_model_dependencies(&equation.lhs, &mut model_deps, model_name, models);
}
for variable in model.variables.values() {
variable.for_each_expression(&mut |expr| {
extract_model_dependencies(expr, &mut model_deps, model_name, models)
});
}
dependencies.insert(model_name.clone(), model_deps);
}
let mut visited = HashSet::new();
let mut rec_stack = HashSet::new();
for model_name in models.keys() {
if !visited.contains(model_name)
&& has_cycle_dfs(model_name, &dependencies, &mut visited, &mut rec_stack)
{
let cycle = find_cycle(&dependencies, model_name);
errors.push(StructuralError {
path: "/models".to_string(),
code: StructuralErrorCode::CircularDependency,
message: format!(
"Circular dependency detected in model dependencies: {}",
cycle.join(" -> ")
),
details: serde_json::json!({
"cycle": cycle,
"dependency_type": "model_references"
}),
});
break; }
}
}
fn extract_model_dependencies(
expr: &crate::Expr,
deps: &mut HashSet<String>,
self_name: &str,
models: &indexmap::IndexMap<String, crate::Model>,
) {
match expr {
crate::Expr::Variable(var_name) => {
if let Some(dot_pos) = var_name.find('.') {
let model_name = &var_name[..dot_pos];
if model_name == self_name {
return;
}
if models.contains_key(model_name) {
deps.insert(model_name.to_string());
}
}
}
crate::Expr::Operator(op_node) => {
op_node.for_each_child(&mut |arg| {
extract_model_dependencies(arg, deps, self_name, models)
});
}
crate::Expr::Number(_) | crate::Expr::Integer(_) => {
}
}
}
pub(crate) fn has_cycle_dfs(
node: &str,
graph: &HashMap<String, HashSet<String>>,
visited: &mut HashSet<String>,
rec_stack: &mut HashSet<String>,
) -> bool {
visited.insert(node.to_string());
rec_stack.insert(node.to_string());
if let Some(neighbors) = graph.get(node) {
for neighbor in neighbors {
if !visited.contains(neighbor) {
if has_cycle_dfs(neighbor, graph, visited, rec_stack) {
return true;
}
} else if rec_stack.contains(neighbor) {
return true;
}
}
}
rec_stack.remove(node);
false
}
pub(crate) fn find_cycle(graph: &HashMap<String, HashSet<String>>, start: &str) -> Vec<String> {
let mut path = vec![];
let mut visited = HashSet::new();
if find_cycle_path(start, graph, &mut path, &mut visited) {
path
} else {
vec![start.to_string()] }
}
fn find_cycle_path(
current: &str,
graph: &HashMap<String, HashSet<String>>,
path: &mut Vec<String>,
visited: &mut HashSet<String>,
) -> bool {
if let Some(start) = path.iter().position(|n| n.as_str() == current) {
path.drain(..start);
path.push(current.to_string());
return true;
}
if visited.contains(current) {
return false;
}
visited.insert(current.to_string());
path.push(current.to_string());
if let Some(neighbors) = graph.get(current) {
for neighbor in neighbors {
if find_cycle_path(neighbor, graph, path, visited) {
return true;
}
}
}
path.pop();
false
}