use std::borrow::Cow;
use std::cell::OnceCell;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::problem::{EsmProblem, ProblemOptions, esm_problem, solve};
use crate::simulate::{Solution, SolveOptions};
#[cfg(not(target_arch = "wasm32"))]
pub type BuildProviderFactory<'a> =
dyn Fn() -> Result<Vec<(String, Box<dyn crate::prepare::PrepareProvider>)>, String> + 'a;
#[cfg(target_arch = "wasm32")]
pub type BuildProviderFactory<'a> = dyn Fn() -> Result<(), String> + 'a;
fn scope_to_component(
overrides: Option<&HashMap<String, InlineValue>>,
model_name: &str,
file: &EsmFile,
) -> HashMap<String, InlineValue> {
let Some(overrides) = overrides.filter(|m| !m.is_empty()) else {
return HashMap::new();
};
let Ok(flat) = crate::flatten::flatten(file) else {
return overrides.clone(); };
overrides
.iter()
.map(|(k, v)| {
let qualified = format!("{model_name}.{k}");
let known = flat.parameters.contains_key(&qualified)
|| flat.state_variables.contains_key(&qualified);
if known {
return (qualified, v.clone());
}
match flat.metadata.merged_variable_renames.get(&qualified) {
Some(survivor) => (survivor.clone(), v.clone()),
None => (k.clone(), v.clone()),
}
})
.collect()
}
#[derive(Clone, Debug, Default)]
struct InlineTestSeeds {
parameter_overrides: HashMap<String, InlineValue>,
initial_conditions: HashMap<String, InlineValue>,
compiler: Option<crate::problem::Compiler>,
}
impl InlineTestSeeds {
fn lay_under(
&self,
seed: &HashMap<String, InlineValue>,
authored: Option<&HashMap<String, InlineValue>>,
) -> Option<HashMap<String, InlineValue>> {
if seed.is_empty() {
return authored.cloned();
}
let mut out = seed.clone();
if let Some(authored) = authored {
for (k, v) in authored {
out.insert(k.clone(), v.clone());
}
}
Some(out)
}
}
use crate::simulate_array::{BuildInspection, Value, eval_buildtime_field};
use crate::types::{
AssertionReference, EsmFile, Expr, FromFileReference, IndexSet, InlineValue, Model, Tolerance,
};
pub const DEFAULT_REL_TOL: f64 = 1e-6;
#[derive(Debug, Clone, Serialize)]
pub struct AssertionResult {
pub model: String,
pub test_id: String,
pub assertion_idx: usize,
pub variable: String,
pub time: f64,
pub reduce: Option<String>,
pub expected: f64,
pub actual: Option<f64>,
pub rtol: f64,
pub atol: f64,
pub passed: bool,
pub message: String,
pub file: String,
}
pub fn evaluate_cellwise(
expr: &Expr,
cells: &[Vec<i64>],
index_sets: &HashMap<String, IndexSet>,
params: &HashMap<String, f64>,
) -> Result<Vec<f64>, String> {
let value = eval_buildtime_field(expr, index_sets, params).map_err(|e| e.to_string())?;
match value {
Value::Scalar(s) => Ok(vec![s; cells.len()]),
Value::Array(arr) => {
let mut out = Vec::with_capacity(cells.len());
for cell in cells {
if cell.len() != arr.ndim() {
return Err(format!(
"evaluate_cellwise: cell {cell:?} has {} indices but the field has ndim={}",
cell.len(),
arr.ndim()
));
}
let mut idx = Vec::with_capacity(cell.len());
for (d, &c) in cell.iter().enumerate() {
if c < 1 || (c as usize) > arr.shape()[d] {
return Err(format!(
"evaluate_cellwise: cell {cell:?} is outside the field's shape {:?}",
arr.shape()
));
}
idx.push((c - 1) as usize);
}
out.push(arr[ndarray::IxDyn(&idx)]);
}
Ok(out)
}
}
}
fn mentions_free(expr: &Expr, name: &str) -> bool {
match expr {
Expr::Variable(v) => v == name,
Expr::Operator(node) => {
let binds = node
.output_idx
.as_ref()
.is_some_and(|ix| ix.iter().any(|s| s == name))
|| node.ranges.as_ref().is_some_and(|r| r.contains_key(name))
|| node.int_var.as_deref() == Some(name);
!binds && node.any_child(&mut |c| mentions_free(c, name))
}
Expr::Number(_) | Expr::Integer(_) => false,
}
}
const TIME_VARIABLE: &str = "t";
pub(crate) const REFERENCE_MENTIONS_TIME: &str = "inline `reference` mentions `t`, which esm-spec §6.6.5 does not admit: a reference's free variables are the field's dimension names, and its other names are the model's parameters. A reference is evaluated at build time, where the independent variable has no value, so `t` would silently read 0 rather than the asserted time.";
pub fn bind_dimension_names(
expr: &Expr,
dims: &[String],
scope: &HashMap<String, f64>,
arrays: &HashSet<String>,
) -> Result<Expr, String> {
if !dims.iter().any(|d| d == TIME_VARIABLE) && mentions_free(expr, TIME_VARIABLE) {
return Err(REFERENCE_MENTIONS_TIME.to_string());
}
let mentioned: Vec<&String> = dims.iter().filter(|d| mentions_free(expr, d)).collect();
if mentioned.is_empty() {
return Ok(expr.clone());
}
if let Some(clash) = mentioned
.iter()
.find(|d| scope.contains_key(d.as_str()) || arrays.contains(d.as_str()))
{
let kind = if scope.contains_key(clash.as_str()) {
"a parameter"
} else {
"a build-time array"
};
return Err(format!(
"inline `reference` mentions '{clash}', which is both a dimension of the \
asserted field and a name the build-time scope already binds ({kind}). \
esm-spec §6.6.5 binds a free dimension name to the cell's 1-based \
position, which would shadow it. Rename one of them, or gather \
explicitly with `aggregate(i from {clash}; …)`."
));
}
let ranges: serde_json::Map<String, serde_json::Value> = dims
.iter()
.map(|d| (d.clone(), serde_json::json!({ "from": d })))
.collect();
let wrapped = serde_json::json!({
"op": "faq",
"args": [],
"output_idx": dims,
"ranges": ranges,
"expr": serde_json::to_value(expr)
.map_err(|e| format!("inline `reference` could not be serialized: {e}"))?,
});
serde_json::from_value(wrapped).map_err(|e| {
format!("inline `reference` could not be wrapped in a dimension-name gather: {e}")
})
}
pub fn field_reduce(kind: &str, actual: &[f64], reference: Option<&[f64]>) -> Result<f64, String> {
match kind {
"L2_error" | "Linf_error" => {
let r = reference
.ok_or_else(|| format!("field_reduce: `{kind}` requires a reference field"))?;
if r.len() != actual.len() {
return Err(format!(
"field_reduce: actual has {} cells but reference has {}",
actual.len(),
r.len()
));
}
if kind == "L2_error" {
let refnorm = r.iter().map(|v| v * v).sum::<f64>().sqrt();
if refnorm == 0.0 {
return Err("field_reduce: L2_error reference has zero norm".to_string());
}
let diffnorm = actual
.iter()
.zip(r.iter())
.map(|(a, b)| (a - b) * (a - b))
.sum::<f64>()
.sqrt();
Ok(diffnorm / refnorm)
} else {
Ok(actual
.iter()
.zip(r.iter())
.map(|(a, b)| (a - b).abs())
.fold(0.0f64, f64::max))
}
}
"mean" | "integral" => {
if actual.is_empty() {
return Err("field_reduce: empty field".to_string());
}
Ok(actual.iter().sum::<f64>() / actual.len() as f64)
}
"max" => {
if actual.is_empty() {
return Err("field_reduce: empty field".to_string());
}
Ok(actual.iter().copied().fold(f64::NEG_INFINITY, f64::max))
}
"min" => {
if actual.is_empty() {
return Err("field_reduce: empty field".to_string());
}
Ok(actual.iter().copied().fold(f64::INFINITY, f64::min))
}
other => Err(format!("field_reduce: unsupported reduce kind '{other}'")),
}
}
fn parse_cell_name(name: &str) -> Option<(&str, Vec<i64>)> {
let open = name.rfind('[')?;
let inner = name.strip_suffix(']')?.get(open + 1..)?;
if inner.is_empty() {
return None;
}
let mut cell = Vec::new();
for part in inner.split(',') {
if part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
cell.push(part.parse::<i64>().ok()?);
}
Some((&name[..open], cell))
}
pub fn state_cells(
element_names: &[String],
variable: &str,
model: &str,
) -> Vec<(Vec<i64>, usize)> {
let qualified = format!("{model}.{variable}");
let mut exact: Vec<(Vec<i64>, usize)> = Vec::new();
let mut fallback: Vec<(Vec<i64>, usize)> = Vec::new();
for (row, name) in element_names.iter().enumerate() {
let Some((stem, cell)) = parse_cell_name(name) else {
continue;
};
if stem == qualified || stem == variable {
exact.push((cell, row));
} else if stem.split_once('.').map(|(_, b)| b).unwrap_or(stem) == variable {
fallback.push((cell, row));
}
}
let mut out = if exact.is_empty() { fallback } else { exact };
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
fn scalar_slot(
element_names: &[String],
variable: &str,
model: &str,
renames: &HashMap<String, String>,
) -> Option<usize> {
let qualified = format!("{model}.{variable}");
for (row, name) in element_names.iter().enumerate() {
if name == &qualified || name == variable {
return Some(row);
}
}
for (row, name) in element_names.iter().enumerate() {
let bare = name.split_once('.').map(|(_, b)| b).unwrap_or(name);
if bare == variable {
return Some(row);
}
}
let survivor = renames.get(&qualified).or_else(|| renames.get(variable))?;
element_names.iter().position(|name| name == survivor)
}
pub fn resolve_tolerance(
model_tol: Option<&Tolerance>,
test_tol: Option<&Tolerance>,
assertion_tol: Option<&Tolerance>,
) -> (f64, f64) {
let levels = [assertion_tol, test_tol, model_tol];
let first =
|pick: fn(&Tolerance) -> Option<f64>| levels.iter().copied().flatten().find_map(pick);
let rel = first(|t| t.rel);
let atol = first(|t| t.abs);
(rel.unwrap_or(DEFAULT_REL_TOL), atol.unwrap_or(0.0))
}
pub fn check_assertion(actual: f64, expected: f64, rtol: f64, atol: f64) -> bool {
if actual == expected {
return true;
}
if !actual.is_finite() || !expected.is_finite() {
return false;
}
if rtol == 0.0 && atol == 0.0 {
return false;
}
(actual - expected).abs() <= f64::max(atol, rtol * f64::max(actual.abs(), expected.abs()))
}
fn time_index(times: &[f64], t: f64) -> Result<usize, String> {
let mut best: Option<(usize, f64)> = None;
for (i, &tv) in times.iter().enumerate() {
let d = (tv - t).abs();
if best.map(|(_, bd)| d < bd).unwrap_or(true) {
best = Some((i, d));
}
}
let (i, d) = best.ok_or_else(|| format!("no saved state at t={t} (empty trajectory)"))?;
if d <= 1e-9 * f64::max(1.0, t.abs()) {
Ok(i)
} else {
Err(format!("no saved state at t={t} (nearest {})", times[i]))
}
}
fn param_scope_with_aliases(params: &HashMap<String, f64>, owner: &str) -> HashMap<String, f64> {
use crate::simulate::override_keys::dotted_suffixes;
let mut out: HashMap<String, f64> = params.clone();
let prefix = format!("{owner}.");
for (k, v) in params {
if let Some(rest) = k.strip_prefix(prefix.as_str()) {
out.entry(rest.to_string()).or_insert(*v);
}
}
let mut counts: HashMap<&str, usize> = HashMap::new();
for k in params.keys() {
for suffix in dotted_suffixes(k) {
*counts.entry(suffix).or_insert(0) += 1;
}
}
for (k, v) in params {
for suffix in dotted_suffixes(k) {
if counts.get(suffix) == Some(&1) && !out.contains_key(suffix) {
out.insert(suffix.to_string(), *v);
}
}
}
out
}
pub(crate) fn array_scope_names<'a>(
registries: impl IntoIterator<Item = &'a str>,
) -> HashSet<String> {
let names: HashSet<String> = registries.into_iter().map(|s| s.to_string()).collect();
let mut counts: HashMap<&str, usize> = HashMap::new();
for n in &names {
let bare = n.rsplit('.').next().unwrap_or(n.as_str());
if bare != n.as_str() {
*counts.entry(bare).or_insert(0) += 1;
}
}
let mut out = names.clone();
for n in &names {
let bare = n.rsplit('.').next().unwrap_or(n.as_str());
if bare != n.as_str() && counts.get(bare) == Some(&1) {
out.insert(bare.to_string());
}
}
out
}
fn observed_field(
file: &EsmFile,
model_name: &str,
variable: &str,
insp: &BuildInspection,
index_sets: &HashMap<String, IndexSet>,
) -> Option<(Vec<f64>, Vec<Vec<i64>>)> {
let model = model_at(file, model_name)?;
let v = model.variables.get(variable)?;
if !crate::classification::Classification::of(&model).is_observed(variable) {
return None;
}
let shape = v.shape.as_ref()?;
if shape.is_empty() {
return None;
}
let mut exts: Vec<usize> = Vec::with_capacity(shape.len());
for s in shape {
let iset = index_sets.get(s)?;
if iset.kind != "interval" {
return None;
}
exts.push(usize::try_from(iset.size?).ok()?);
}
let arr = build_field(insp, file, model_name, variable)?;
if arr.ndim() != exts.len()
|| arr
.shape()
.iter()
.zip(&exts)
.any(|(have, want)| have < want)
{
return None;
}
let total: usize = exts.iter().product();
let mut cells: Vec<Vec<i64>> = Vec::with_capacity(total);
let mut cur = vec![1i64; exts.len()];
for _ in 0..total {
cells.push(cur.clone());
for d in (0..exts.len()).rev() {
if (cur[d] as usize) < exts[d] {
cur[d] += 1;
break;
}
cur[d] = 1;
}
}
let field: Vec<f64> = cells
.iter()
.map(|c| {
let idx: Vec<usize> = c.iter().map(|&i| (i - 1) as usize).collect();
arr[ndarray::IxDyn(&idx)]
})
.collect();
Some((field, cells))
}
fn owner_spellings(file: &EsmFile, owner: &str) -> Vec<String> {
let mut out = vec![owner.to_string()];
let tops = file.models.as_ref().map_or(0, |m| m.len())
+ file.reaction_systems.as_ref().map_or(0, |r| r.len());
if tops == 1
&& let Some((_, below_root)) = owner.split_once('.')
{
out.push(below_root.to_string());
}
out
}
fn build_field<'a>(
insp: &'a BuildInspection,
file: &EsmFile,
model_name: &str,
variable: &str,
) -> Option<&'a ndarray::ArrayD<f64>> {
for m in owner_spellings(file, model_name) {
if let Some(arr) = insp.setup_arrays.get(&format!("{m}.{variable}")) {
return Some(arr);
}
}
if model_name.contains('.') {
return None;
}
insp.setup_arrays.get(variable)
}
fn scoped_scalar_slot(
element_names: &[String],
spellings: &[String],
variable: &str,
renames: &HashMap<String, String>,
) -> Option<usize> {
for m in spellings {
let qualified = format!("{m}.{variable}");
if let Some(row) = element_names.iter().position(|n| *n == qualified) {
return Some(row);
}
}
let survivor = renames.get(&format!("{}.{variable}", spellings[0]))?;
element_names.iter().position(|n| n == survivor)
}
fn scoped_state_cells(
element_names: &[String],
spellings: &[String],
variable: &str,
) -> Vec<(Vec<i64>, usize)> {
for m in spellings {
let qualified = format!("{m}.{variable}");
let mut out: Vec<(Vec<i64>, usize)> = element_names
.iter()
.enumerate()
.filter_map(|(row, name)| {
let (stem, cell) = parse_cell_name(name)?;
(stem == qualified).then_some((cell, row))
})
.collect();
if !out.is_empty() {
out.sort_by(|a, b| a.0.cmp(&b.0));
return out;
}
}
Vec::new()
}
fn scalar_observed(
file: &EsmFile,
model_name: &str,
variable: &str,
insp: &BuildInspection,
) -> Option<f64> {
let model = model_at(file, model_name)?;
let v = model.variables.get(variable)?;
if v.shape.as_ref().is_some_and(|s| !s.is_empty()) {
return None;
}
if !crate::classification::Classification::of(&model).is_observed(variable) {
return None;
}
let arr = build_field(insp, file, model_name, variable)?;
if arr.len() != 1 {
return None;
}
arr.iter().next().copied()
}
fn variable_shape(file: &EsmFile, model_name: &str, variable: &str) -> Result<Vec<String>, String> {
let model =
model_at(file, model_name).ok_or_else(|| format!("model '{model_name}' not found"))?;
let v = model
.variables
.get(variable)
.ok_or_else(|| format!("variable '{variable}' is not declared in model '{model_name}'"))?;
match &v.shape {
Some(shape) if !shape.is_empty() => Ok(shape.clone()),
_ => Err(format!(
"`coords` requires a spatially-shaped variable; '{variable}' is scalar"
)),
}
}
fn coords_cell(
coords: &HashMap<String, f64>,
shape: &[String],
index_sets: &HashMap<String, IndexSet>,
) -> Result<Vec<i64>, String> {
for k in coords.keys() {
if !shape.iter().any(|s| s == k) {
return Err(format!(
"`coords` names unknown dimension '{k}' (field dimensions: {})",
shape.join(", ")
));
}
}
let mut cell = Vec::with_capacity(shape.len());
for s in shape {
let size = index_sets
.get(s)
.filter(|iset| iset.kind == "interval")
.and_then(|iset| iset.size)
.ok_or_else(|| {
format!(
"`coords` sampling requires interval index sets with a \
declared size; '{s}' is not one"
)
})?;
match coords.get(s) {
Some(&c) => {
let idx = (c - 0.5).ceil() as i64; if idx < 1 || idx > size {
return Err(format!(
"`coords` position {c} along '{s}' resolves to index \
{idx}, outside 1..{size}"
));
}
cell.push(idx);
}
None => {
if size != 1 {
return Err(format!(
"`coords` leaves dimension '{s}' unpinned with {size} \
samples; a strict subset pins only when every \
remaining dimension is singleton"
));
}
cell.push(1);
}
}
}
Ok(cell)
}
fn nested_at(data: &serde_json::Value, cell: &[i64], exts: &[usize]) -> Result<f64, String> {
let mut node = data;
for (d, &i) in cell.iter().enumerate() {
let arr = node.as_array().ok_or_else(|| {
format!(
"from_file reference shape mismatch along dimension {}: \
expected a nested array of length {}",
d + 1,
exts[d]
)
})?;
if arr.len() != exts[d] {
return Err(format!(
"from_file reference shape mismatch along dimension {}: \
expected length {}, found {}",
d + 1,
exts[d],
arr.len()
));
}
node = &arr[(i - 1) as usize];
}
node.as_f64().ok_or_else(|| {
format!(
"from_file reference shape mismatch at cell [{}]: expected a number",
cell.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(",")
)
})
}
fn from_file_reference(
ff: &FromFileReference,
base_dir: Option<&Path>,
cell_tuples: &[Vec<i64>],
) -> Result<Vec<f64>, String> {
let fmt = ff
.format
.as_deref()
.map(str::to_lowercase)
.unwrap_or_else(|| "json".to_string());
if fmt != "json" {
return Err(format!(
"from_file reference format '{fmt}' is not supported (v1 supports \"json\" only)"
));
}
let p = Path::new(&ff.path);
let resolved = if p.is_absolute() {
p.to_path_buf()
} else {
base_dir.unwrap_or_else(|| Path::new(".")).join(p)
};
if !resolved.is_file() {
return Err(format!(
"from_file reference file not found: {}",
resolved.display()
));
}
let text = std::fs::read_to_string(&resolved)
.map_err(|e| format!("from_file reference read failed: {e}"))?;
let data: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| format!("from_file reference is not valid JSON: {e}"))?;
if cell_tuples.is_empty() {
return Err("from_file reference: field has no cells".to_string());
}
let nd = cell_tuples[0].len();
let exts: Vec<usize> = (0..nd)
.map(|d| {
cell_tuples
.iter()
.map(|c| c[d].max(0) as usize)
.max()
.unwrap_or(0)
})
.collect();
cell_tuples
.iter()
.map(|c| nested_at(&data, c, &exts))
.collect()
}
enum ComponentAt<'a> {
Model(Box<Cow<'a, Model>>),
ReactionSystem(&'a crate::types::ReactionSystem),
}
impl ComponentAt<'_> {
fn has_subsystem(&self, name: &str) -> bool {
let subs = match self {
Self::Model(m) => m.subsystems.as_ref(),
Self::ReactionSystem(r) => r.subsystems.as_ref(),
};
subs.is_some_and(|s| s.contains_key(name))
}
}
fn component_at<'a>(file: &'a EsmFile, path: &str) -> Option<ComponentAt<'a>> {
let mut segs = path.split('.');
let head = segs.next()?;
let mut cur = match file.models.as_ref().and_then(|ms| ms.get(head)) {
Some(m) => ComponentAt::Model(Box::new(Cow::Borrowed(m))),
None => ComponentAt::ReactionSystem(file.reaction_systems.as_ref()?.get(head)?),
};
for seg in segs {
let subs = match &cur {
ComponentAt::Model(m) => m.subsystems.as_ref(),
ComponentAt::ReactionSystem(r) => r.subsystems.as_ref(),
}?;
let (model, _) = crate::simulate_array::parse_subsystem_model(seg, subs.get(seg)?).ok()?;
cur = ComponentAt::Model(Box::new(Cow::Owned(model)));
}
Some(cur)
}
fn model_at<'a>(file: &'a EsmFile, path: &str) -> Option<Cow<'a, Model>> {
match component_at(file, path)? {
ComponentAt::Model(m) => Some(*m),
ComponentAt::ReactionSystem(_) => None,
}
}
fn resolve_asserted_name<'v>(
file: &EsmFile,
model_name: &str,
variable: &'v str,
) -> (String, &'v str) {
let Some((prefix, local)) = variable.rsplit_once('.') else {
return (model_name.to_string(), variable);
};
let head = prefix.split('.').next().unwrap_or(prefix);
let owner = if component_at(file, model_name).is_some_and(|c| c.has_subsystem(head)) {
format!("{model_name}.{prefix}")
} else {
prefix.to_string()
};
if component_at(file, &owner).is_none() {
return (model_name.to_string(), variable);
}
(owner, local)
}
fn eval_assertion(
sol: &Solution,
assertion: &crate::types::ModelTestAssertion,
model_name: &str,
index_sets: &HashMap<String, IndexSet>,
file: &EsmFile,
insp: &BuildInspection,
base_dir: Option<&Path>,
) -> Result<f64, String> {
let ti = time_index(&sol.time, assertion.time)?;
let (owner, variable) = resolve_asserted_name(file, model_name, &assertion.variable);
let scoped = owner != model_name;
let spellings = owner_spellings(file, &owner);
let model_name = owner.as_str();
if assertion.coords.is_some() && assertion.reduce.is_some() {
return Err("`coords` and `reduce` are mutually exclusive".to_string());
}
if assertion.coords.is_none() && assertion.reduce.is_none() {
let slot = if scoped {
scoped_scalar_slot(
&sol.state_variable_names,
&spellings,
variable,
&sol.metadata.merged_variable_renames,
)
} else {
scalar_slot(
&sol.state_variable_names,
variable,
model_name,
&sol.metadata.merged_variable_renames,
)
};
if let Some(slot) = slot {
return Ok(sol.state[slot][ti]);
}
return scalar_observed(file, model_name, variable, insp).ok_or_else(|| {
format!(
"scalar state or state-free scalar observed '{}' not found \
(the build materialized neither)",
assertion.variable
)
});
}
let coords_target = match &assertion.coords {
Some(coords) => {
let shape = variable_shape(file, model_name, variable)?;
Some(coords_cell(coords, &shape, index_sets)?)
}
None => None,
};
let cells = if scoped {
scoped_state_cells(&sol.state_variable_names, &spellings, variable)
} else {
state_cells(&sol.state_variable_names, variable, model_name)
};
let (field, cell_tuples): (Vec<f64>, Vec<Vec<i64>>) = if !cells.is_empty() {
(
cells.iter().map(|(_, row)| sol.state[*row][ti]).collect(),
cells.iter().map(|(c, _)| c.clone()).collect(),
)
} else {
observed_field(file, model_name, variable, insp, index_sets).ok_or_else(|| {
format!(
"array state '{}' has no cells in var_map",
assertion.variable
)
})?
};
if let Some(target) = coords_target {
let pos = cell_tuples
.iter()
.position(|c| *c == target)
.ok_or_else(|| {
format!(
"no grid sample at cell [{}] of '{}'",
target
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(","),
assertion.variable
)
})?;
return Ok(field[pos]);
}
let reduce = assertion
.reduce
.as_ref()
.expect("reduce is set on the non-coords array path");
let reference = match &assertion.reference {
None => None,
Some(AssertionReference::Expression(expr)) => {
let scope = param_scope_with_aliases(&insp.params, model_name);
let arrays = array_scope_names(insp.setup_arrays.keys().map(String::as_str));
let dims = variable_shape(file, model_name, variable).unwrap_or_default();
let bound = bind_dimension_names(expr, &dims, &scope, &arrays)?;
Some(evaluate_cellwise(&bound, &cell_tuples, index_sets, &scope)?)
}
Some(AssertionReference::FromFile(ff)) => {
Some(from_file_reference(ff, base_dir, &cell_tuples)?)
}
};
field_reduce(reduce, &field, reference.as_deref())
}
pub fn ephemeral_injected_file(
file: &EsmFile,
source_path: Option<&Path>,
model_name: &str,
imports: &[serde_json::Value],
base_dir: &Path,
) -> Result<EsmFile, crate::EsmError> {
let mut raw: serde_json::Value = match source_path {
Some(p) => {
let s = std::fs::read_to_string(p).map_err(|e| {
crate::EsmError::SchemaValidation(format!("failed to read {}: {e}", p.display()))
})?;
serde_json::from_str(&s).map_err(crate::EsmError::JsonParse)?
}
None => serde_json::to_value(file).map_err(crate::EsmError::JsonParse)?,
};
let mut injected = false;
if let Some(root) = raw.as_object_mut() {
for kind in ["models", "reaction_systems"] {
let Some(comps) = root.get_mut(kind).and_then(|v| v.as_object_mut()) else {
continue;
};
let Some(comp) = comps.get_mut(model_name).and_then(|v| v.as_object_mut()) else {
continue;
};
let mut base: Vec<serde_json::Value> = comp
.get("expression_template_imports")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
base.extend(imports.iter().cloned());
comp.insert(
"expression_template_imports".to_string(),
serde_json::Value::Array(base),
);
injected = true;
break;
}
}
if !injected {
return Err(crate::EsmError::SchemaValidation(format!(
"component '{model_name}' not found for per-test injection (esm-spec §9.7.10)"
)));
}
let text = serde_json::to_string(&raw).map_err(crate::EsmError::JsonParse)?;
let options = crate::parse::LoadOptions {
base_path: Some(base_dir.to_path_buf()),
metaparameters: std::collections::BTreeMap::new(),
};
crate::parse::load_string_with_options(&text, &options)
}
fn assertion_observed_requests(
file: &EsmFile,
model_name: &str,
t: &crate::types::ModelTest,
) -> Vec<String> {
let own = model_at(file, model_name);
let own_class = own
.as_deref()
.map(crate::classification::Classification::of);
let mut out: Vec<String> = Vec::new();
for a in &t.assertions {
let (owner, local) = resolve_asserted_name(file, model_name, &a.variable);
let scoped = if owner == model_name {
None
} else {
model_at(file, &owner)
};
let scoped_class = scoped
.as_deref()
.map(crate::classification::Classification::of);
let (model, class) = if owner == model_name {
(own.as_deref(), own_class.as_ref())
} else {
(scoped.as_deref(), scoped_class.as_ref())
};
let wants_array = a.coords.is_some() || a.reduce.is_some();
if let Some(v) = model.and_then(|m| m.variables.get(local)) {
let is_array = v.shape.as_ref().is_some_and(|s| !s.is_empty());
if is_array != wants_array {
continue;
}
if !class.is_some_and(|c| c.is_observed(local)) {
continue;
}
} else if wants_array {
continue;
}
let spellings: Vec<String> = if owner == model_name {
vec![a.variable.clone(), format!("{model_name}.{}", a.variable)]
} else {
owner_spellings(file, &owner)
.into_iter()
.map(|m| format!("{m}.{local}"))
.collect()
};
for spelling in spellings {
if !out.contains(&spelling) {
out.push(spelling);
}
}
}
out
}
fn push_test_error(
results: &mut Vec<AssertionResult>,
source: &str,
model_name: &str,
t: &crate::types::ModelTest,
tolerance: Option<&Tolerance>,
message: &str,
) {
for (i, a) in t.assertions.iter().enumerate() {
let (rtol, atol) = resolve_tolerance(tolerance, t.tolerance.as_ref(), a.tolerance.as_ref());
results.push(AssertionResult {
model: model_name.to_string(),
test_id: t.id.clone(),
assertion_idx: i + 1,
variable: a.variable.clone(),
time: a.time,
reduce: a.reduce.clone(),
expected: a.expected,
actual: None,
rtol,
atol,
passed: false,
message: message.to_string(),
file: source.to_string(),
});
}
}
fn build_only_solution(times: Vec<f64>) -> Solution {
Solution {
time: times,
state: Vec::new(),
state_variable_names: Vec::new(),
retcode: crate::simulate::ReturnCode::Success,
metadata: crate::simulate::SolutionMetadata {
alg: "build".to_string(),
..Default::default()
},
}
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn time_dependent_observeds(model: &Model) -> std::collections::BTreeSet<String> {
let bodies: Vec<(String, &Expr)> = model
.equations
.iter()
.filter_map(|eq| match crate::classification::lhs_form(&eq.lhs) {
crate::classification::LhsForm::Bare(name) => Some((name, &eq.rhs)),
_ => None,
})
.collect();
let mut dependent: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
loop {
let before = dependent.len();
for (name, body) in &bodies {
if dependent.contains(name) {
continue;
}
if mentions_free(body, TIME_VARIABLE)
|| dependent.iter().any(|d| mentions_free(body, d))
{
dependent.insert(name.clone());
}
}
if dependent.len() == before {
return dependent;
}
}
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
fn unevaluable_time_dependent_assertion(
file: &EsmFile,
model_name: &str,
t: &crate::types::ModelTest,
) -> Option<String> {
let t0 = t.time_span.start;
let mut per_owner: HashMap<String, std::collections::BTreeSet<String>> = HashMap::new();
for a in &t.assertions {
if (a.time - t0).abs() <= 1e-9 * f64::max(1.0, t0.abs()) {
continue;
}
let (owner, variable) = resolve_asserted_name(file, model_name, &a.variable);
let dependent = per_owner.entry(owner.clone()).or_insert_with(|| {
model_at(file, &owner)
.map(|m| time_dependent_observeds(&m))
.unwrap_or_default()
});
if dependent.contains(variable) {
return Some(format!(
"'{}' is a function of `t`, and this document has nothing to integrate: \
its fields were materialized once, at t = {t0}, so this test's assertion \
on it at t = {} cannot be answered. Assert it at t = {t0}, or give the \
model a differential equation so the observed is carried along a \
trajectory (esm-spec §6.6.3; issue #406)",
a.variable, a.time
));
}
}
None
}
fn static_evaluation_times(saveat: &[f64], start: f64, end: f64) -> Vec<f64> {
let (lo, hi) = if start <= end {
(start, end)
} else {
(end, start)
};
let mut out: Vec<f64> = saveat
.iter()
.copied()
.filter(|t| *t >= lo && *t <= hi)
.collect();
out.push(lo);
out.push(hi);
out.sort_by(f64::total_cmp);
out.dedup();
out
}
#[cfg(not(target_arch = "wasm32"))]
fn static_trajectory(prob: &EsmProblem, times: &[f64]) -> Option<Result<Solution, String>> {
let graph = crate::problem::static_observed_graph(prob)?;
if times.is_empty() {
return None;
}
let rows = match crate::problem::evaluate_static_observeds_over(prob, &graph, times) {
Ok(rows) => rows,
Err(e) => return Some(Err(format!("simulate failed: {e}"))),
};
let (state_variable_names, state): (Vec<String>, Vec<Vec<f64>>) = rows.into_iter().unzip();
Some(Ok(Solution {
time: times.to_vec(),
state,
state_variable_names,
retcode: crate::simulate::ReturnCode::Success,
metadata: crate::simulate::SolutionMetadata {
alg: "static".to_string(),
..Default::default()
},
}))
}
#[cfg(target_arch = "wasm32")]
fn static_trajectory(_prob: &EsmProblem, _times: &[f64]) -> Option<Result<Solution, String>> {
None
}
#[derive(PartialEq)]
struct BuildKey {
imports: Vec<serde_json::Value>,
tspan: (u64, u64),
p: Vec<(String, String)>,
u0: Vec<(String, String)>,
}
impl BuildKey {
fn of(t: &crate::types::ModelTest) -> Self {
fn bindings(m: Option<&HashMap<String, InlineValue>>) -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = m
.map(|m| {
m.iter()
.map(|(k, x)| (k.clone(), serde_json::to_string(x).unwrap_or_default()))
.collect()
})
.unwrap_or_default();
v.sort();
v
}
BuildKey {
imports: t.expression_template_imports.clone(),
tspan: (t.time_span.start.to_bits(), t.time_span.end.to_bits()),
p: bindings(t.parameter_overrides.as_ref()),
u0: bindings(t.initial_conditions.as_ref()),
}
}
}
enum Built {
Problem(Box<EsmProblem>),
TestError(String),
BuildFailed(String),
}
type RetryBindings = (
HashMap<String, f64>,
HashMap<String, f64>,
Option<crate::problem::Compiler>,
);
type BuiltFields = Vec<(String, ndarray::ArrayD<f64>)>;
struct BuiltModel {
key: BuildKey,
ephemeral: Option<EsmFile>,
index_sets: Option<HashMap<String, IndexSet>>,
built: Built,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
retry_bindings: Option<RetryBindings>,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
retry_fields: OnceCell<Option<BuiltFields>>,
}
impl BuiltModel {
#[cfg(not(target_arch = "wasm32"))]
fn retry_fields(&self, run_file: &EsmFile, tspan: (f64, f64)) -> Option<&BuiltFields> {
self.retry_fields
.get_or_init(|| build_pipeline_fields(run_file, tspan, self.retry_bindings.as_ref()))
.as_ref()
}
}
fn split_inline_arrays(
overrides: HashMap<String, InlineValue>,
shaped: impl Fn(&str) -> bool,
) -> (HashMap<String, f64>, HashMap<String, InlineValue>) {
let mut scalars = HashMap::new();
let mut arrays = HashMap::new();
for (k, v) in overrides {
match v.as_scalar() {
Some(x) if !shaped(&k) => {
scalars.insert(k, x);
}
_ => {
arrays.insert(k, v);
}
}
}
(scalars, arrays)
}
fn resolve_override_target(file: &EsmFile, key: &str) -> Option<(String, String)> {
let models = file.models.as_ref()?;
let mut split: Option<(String, String)> = None;
let mut best = 0usize;
for (mname, model) in models.iter() {
if let Some(rest) = key.strip_prefix(&format!("{mname}."))
&& mname.len() > best
&& model.variables.contains_key(rest)
{
best = mname.len();
split = Some((mname.clone(), rest.to_string()));
}
}
split.or_else(|| {
models
.iter()
.find(|(_, m)| m.variables.contains_key(key))
.map(|(n, _)| (n.clone(), key.to_string()))
})
}
fn locate_override_target<'a>(file: &'a mut EsmFile, key: &str) -> Option<(&'a mut Model, String)> {
let target = resolve_override_target(file, key)?;
let model = file.models.as_mut()?.get_mut(&target.0)?;
Some((model, target.1))
}
fn names_shaped_parameter(file: &EsmFile, key: &str) -> bool {
let Some((mname, local)) = resolve_override_target(file, key) else {
return false;
};
file.models
.as_ref()
.and_then(|ms| ms.get(&mname))
.and_then(|m| m.variables.get(&local))
.is_some_and(|v| {
v.var_type == crate::types::VariableType::Parameter
&& v.shape.as_ref().is_some_and(|s| !s.is_empty())
})
}
fn bind_array_parameters(
file: &mut EsmFile,
arrays: &HashMap<String, InlineValue>,
) -> Result<(), String> {
let mut keys: Vec<&String> = arrays.keys().collect();
keys.sort();
for key in keys {
let value = &arrays[key];
let Some((model, local)) = locate_override_target(file, key) else {
return Err(format!(
"parameter_overrides: unknown parameter '{key}' — this document declares no such \
variable. esm-spec §6.6.2 keys parameter_overrides by LOCAL parameter name."
));
};
let var = model
.variables
.get_mut(&local)
.expect("locate_override_target returned a declared name");
if var.var_type != crate::types::VariableType::Parameter {
return Err(format!(
"parameter_overrides: '{key}' is an unknown, not a parameter (esm-spec §6.6.2)"
));
}
var.default = Some(value.clone());
}
Ok(())
}
fn expand_array_initial_conditions(
file: &EsmFile,
arrays: &HashMap<String, InlineValue>,
u0: &mut HashMap<String, f64>,
) -> Result<(), String> {
let index_sets: HashMap<String, IndexSet> = file
.index_sets
.clone()
.unwrap_or_default()
.into_iter()
.collect();
let mut keys: Vec<&String> = arrays.keys().collect();
keys.sort();
for key in keys {
let (shape, values) = arrays[key]
.to_dense()
.map_err(|e| format!("initial_conditions[{key}]: {e} (esm-spec §6.3)"))?;
let declared = declared_extents(file, key, &index_sets);
if let Some(want) = declared.as_ref()
&& *want != shape
{
return Err(format!(
"initial_conditions[{key}]: inline array data has shape {shape:?}, which does not \
match the declared shape {want:?} (esm-spec §6.6.2 — the array MUST match the \
variable's declared shape after metaparameter folding)"
));
}
if shape.is_empty() {
return Err(format!(
"initial_conditions[{key}]: inline array data was supplied for a 0-D state; only a \
SHAPED unknown takes a nested array (esm-spec §6.6.2)"
));
}
for (flat, value) in values.iter().enumerate() {
let mut rest = flat;
let mut multi = vec![0usize; shape.len()];
for d in (0..shape.len()).rev() {
multi[d] = rest % shape[d];
rest /= shape[d];
}
let idx = multi
.iter()
.map(|i| (i + 1).to_string())
.collect::<Vec<_>>()
.join(",");
u0.entry(format!("{key}[{idx}]")).or_insert(*value);
}
}
Ok(())
}
fn declared_extents(
file: &EsmFile,
key: &str,
index_sets: &HashMap<String, IndexSet>,
) -> Option<Vec<usize>> {
let models = file.models.as_ref()?;
let local = key.rsplit('.').next().unwrap_or(key);
let var = models
.iter()
.find_map(|(_, m)| m.variables.get(key).or_else(|| m.variables.get(local)))?;
let shape = var.shape.as_ref()?;
let mut out = Vec::with_capacity(shape.len());
for axis in shape {
let set = index_sets.get(axis)?;
let size = match set.kind.as_str() {
"interval" => usize::try_from(set.size?).ok()?,
"categorical" => set.members.as_ref()?.len(),
_ => return None,
};
out.push(size);
}
Some(out)
}
fn build_for_test(
key: BuildKey,
file: &EsmFile,
model_name: &str,
t: &crate::types::ModelTest,
base_dir: Option<&Path>,
build_providers: Option<&BuildProviderFactory<'_>>,
seeds: &InlineTestSeeds,
) -> BuiltModel {
let (ephemeral, index_sets) = if t.expression_template_imports.is_empty() {
(None, None)
} else {
match ephemeral_injected_file(
file,
None,
model_name,
&t.expression_template_imports,
base_dir.unwrap_or_else(|| Path::new(".")),
) {
Ok(f) => {
let is: HashMap<String, IndexSet> = f
.index_sets
.clone()
.unwrap_or_default()
.into_iter()
.collect();
(Some(f), Some(is))
}
Err(e) => {
return BuiltModel {
key,
ephemeral: None,
index_sets: None,
built: Built::TestError(format!(
"per-test discretization injection failed: {e}"
)),
retry_bindings: None,
retry_fields: OnceCell::new(),
};
}
}
};
let mut ephemeral = ephemeral;
let run_file: &EsmFile = ephemeral.as_ref().unwrap_or(file);
let seeded_params = seeds.lay_under(&seeds.parameter_overrides, t.parameter_overrides.as_ref());
let seeded_ics = seeds.lay_under(&seeds.initial_conditions, t.initial_conditions.as_ref());
let params = scope_to_component(seeded_params.as_ref(), model_name, run_file);
let ics = scope_to_component(seeded_ics.as_ref(), model_name, run_file);
let (scalar_params, array_params) =
split_inline_arrays(params, |k| names_shaped_parameter(run_file, k));
let (scalar_ics, array_ics) = split_inline_arrays(ics, |_| false);
if !array_params.is_empty() {
let mut owned = ephemeral.take().unwrap_or_else(|| file.clone());
if let Err(e) = bind_array_parameters(&mut owned, &array_params) {
return BuiltModel {
key,
ephemeral: None,
index_sets: None,
built: Built::BuildFailed(format!("simulate failed: {e}")),
retry_bindings: None,
retry_fields: OnceCell::new(),
};
}
ephemeral = Some(owned);
}
let run_file: &EsmFile = ephemeral.as_ref().unwrap_or(file);
let mut u0 = scalar_ics;
if !array_ics.is_empty()
&& let Err(e) = expand_array_initial_conditions(run_file, &array_ics, &mut u0)
{
return BuiltModel {
key,
ephemeral,
index_sets,
built: Built::BuildFailed(format!("simulate failed: {e}")),
retry_bindings: None,
retry_fields: OnceCell::new(),
};
}
let retry_bindings = build_providers
.is_none()
.then(|| (scalar_params.clone(), u0.clone(), seeds.compiler));
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut popts = ProblemOptions {
p: scalar_params,
u0,
inspect: true,
rhs: crate::problem::Rhs::Always,
compiler: seeds.compiler,
..Default::default()
};
#[cfg(target_arch = "wasm32")]
if build_providers.is_some() {
return BuiltModel {
key,
ephemeral,
index_sets,
built: Built::TestError(
"data-source providers need the build pipeline, which is native-only".to_string(),
),
retry_bindings,
retry_fields: OnceCell::new(),
};
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(make) = build_providers {
match make() {
Ok(provs) => {
popts.build_providers = provs;
popts.build_pipeline = true;
popts.rhs = crate::problem::Rhs::Auto;
}
Err(e) => {
return BuiltModel {
key,
ephemeral,
index_sets,
built: Built::TestError(e),
retry_bindings: None,
retry_fields: OnceCell::new(),
};
}
}
}
let tspan = (t.time_span.start, t.time_span.end);
let built = match esm_problem(run_file, tspan, popts) {
Ok(p) => Built::Problem(Box::new(p)),
Err(e) => Built::BuildFailed(format!("simulate failed: {e}")),
};
BuiltModel {
key,
ephemeral,
index_sets,
built,
retry_bindings,
retry_fields: OnceCell::new(),
}
}
#[cfg(test)]
thread_local! {
static BUILD_PIPELINE_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(not(target_arch = "wasm32"))]
fn build_pipeline_fields(
run_file: &EsmFile,
tspan: (f64, f64),
bindings: Option<&RetryBindings>,
) -> Option<BuiltFields> {
let (p, u0, compiler) = bindings?;
let popts = ProblemOptions {
p: p.clone(),
u0: u0.clone(),
inspect: true,
rhs: crate::problem::Rhs::Always,
build_pipeline: true,
compiler: *compiler,
..Default::default()
};
#[cfg(test)]
BUILD_PIPELINE_BUILDS.with(|n| n.set(n.get() + 1));
let prob = esm_problem(run_file, tspan, popts).ok()?;
let fields: BuiltFields = prob
.observed_fields()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
(!fields.is_empty()).then_some(fields)
}
#[allow(clippy::too_many_arguments)]
fn run_component_tests(
file: &EsmFile,
source: &str,
model_name: &str,
tests: &[crate::types::ModelTest],
tolerance: Option<&Tolerance>,
index_sets: &HashMap<String, IndexSet>,
opts: &SolveOptions,
base_dir: Option<&Path>,
build_providers: Option<&BuildProviderFactory<'_>>,
test_filter: Option<&str>,
seeds: &InlineTestSeeds,
results: &mut Vec<AssertionResult>,
) {
let mut memo: Option<BuiltModel> = None;
for t in tests {
if test_filter.is_some_and(|needle| !t.id.contains(needle)) {
continue;
}
let key = BuildKey::of(t);
if !memo.as_ref().is_some_and(|m| m.key == key) {
drop(memo.take());
memo = Some(build_for_test(
key,
file,
model_name,
t,
base_dir,
build_providers,
seeds,
));
}
let cached = memo.as_ref().expect("just built");
let run_file: &EsmFile = cached.ephemeral.as_ref().unwrap_or(file);
let run_index_sets: &HashMap<String, IndexSet> =
cached.index_sets.as_ref().unwrap_or(index_sets);
if let Built::TestError(msg) = &cached.built {
push_test_error(results, source, model_name, t, tolerance, msg);
continue;
}
let mut times: Vec<f64> = t.assertions.iter().map(|a| a.time).collect();
times.sort_by(f64::total_cmp);
times.dedup();
let mut run_opts = opts.clone();
run_opts.saveat = Some(times);
for name in assertion_observed_requests(run_file, model_name, t) {
if !run_opts.output_observed.contains(&name) {
run_opts.output_observed.push(name);
}
}
let mut insp = BuildInspection::default();
let sim = match &cached.built {
Built::TestError(msg) => Err(msg.clone()),
Built::BuildFailed(msg) => Err(msg.clone()),
Built::Problem(prob) => {
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut fields: BuiltFields = prob
.observed_fields()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
prob.reset_inspection();
let saveat = run_opts.saveat.clone().unwrap_or_default();
let static_times =
static_evaluation_times(&saveat, t.time_span.start, t.time_span.end);
match static_trajectory(prob, &static_times) {
Some(result) => result,
None => match solve(prob, &run_opts) {
Ok(sol) => Ok(sol),
Err(crate::simulate::SimulateError::NotDynamic { .. }) => {
Ok(build_only_solution(saveat.clone()))
}
#[cfg(not(target_arch = "wasm32"))]
Err(e) if crate::problem::has_nothing_to_integrate(prob) => {
prob.reset_inspection();
if fields.is_empty()
&& seeds.compiler.unwrap_or_default().is_strict()
&& let Some(f) = crate::problem::stateless_fields(prob)
{
fields = f;
}
if fields.is_empty()
&& let Some(f) = cached
.retry_fields(run_file, (t.time_span.start, t.time_span.end))
{
fields = f.clone();
}
if fields.is_empty() {
Err(format!("simulate failed: {e}"))
} else {
match unevaluable_time_dependent_assertion(run_file, model_name, t)
{
Some(message) => Err(message),
None => Ok(build_only_solution(saveat)),
}
}
}
Err(e) => Err(format!("simulate failed: {e}")),
},
}
.inspect(|_sol| {
insp = prob.take_inspection();
for (k, v) in fields {
insp.setup_arrays.entry(k).or_insert(v);
}
})
}
}
.and_then(|sol| {
if sol.retcode.is_success() {
Ok(sol)
} else {
Err(format!("solver retcode {}", sol.retcode))
}
});
for (i, a) in t.assertions.iter().enumerate() {
let (rtol, atol) =
resolve_tolerance(tolerance, t.tolerance.as_ref(), a.tolerance.as_ref());
let outcome = match &sim {
Err(msg) => Err(msg.clone()),
Ok(sol) => eval_assertion(
sol,
a,
model_name,
run_index_sets,
run_file,
&insp,
base_dir,
)
.map_err(|msg| format!("assertion evaluation failed: {msg}")),
};
let (actual, passed, message) = match outcome {
Err(msg) => (None, false, msg),
Ok(actual) => {
let ok = check_assertion(actual, a.expected, rtol, atol);
let msg = if ok {
String::new()
} else {
format!(
"actual={actual} expected={} (rtol={rtol}, atol={atol})",
a.expected
)
};
(Some(actual), ok, msg)
}
};
results.push(AssertionResult {
model: model_name.to_string(),
test_id: t.id.clone(),
assertion_idx: i + 1,
variable: a.variable.clone(),
time: a.time,
reduce: a.reduce.clone(),
expected: a.expected,
actual,
rtol,
atol,
passed,
message,
file: source.to_string(),
});
}
}
}
pub fn run_inline_tests(
file: &EsmFile,
model_name: Option<&str>,
opts: &SolveOptions,
) -> Vec<AssertionResult> {
run_inline_tests_with_base_dir(file, model_name, opts, None)
}
pub fn run_inline_tests_with_base_dir(
file: &EsmFile,
model_name: Option<&str>,
opts: &SolveOptions,
base_dir: Option<&Path>,
) -> Vec<AssertionResult> {
run_inline_tests_with_providers(file, model_name, opts, base_dir, None)
}
pub fn run_inline_tests_with_providers(
file: &EsmFile,
model_name: Option<&str>,
opts: &SolveOptions,
base_dir: Option<&Path>,
build_providers: Option<&BuildProviderFactory<'_>>,
) -> Vec<AssertionResult> {
run_inline_tests_filtered(file, model_name, opts, base_dir, build_providers, None)
}
pub fn run_inline_tests_filtered(
file: &EsmFile,
model_name: Option<&str>,
opts: &SolveOptions,
base_dir: Option<&Path>,
build_providers: Option<&BuildProviderFactory<'_>>,
test_filter: Option<&str>,
) -> Vec<AssertionResult> {
run_inline_tests_seeded(
file,
None,
model_name,
opts,
base_dir,
build_providers,
test_filter,
&InlineTestSeeds::default(),
)
}
pub fn run_inline_tests_with_options(
file: &EsmFile,
opts: &InlineTestOptions,
build_providers: Option<&BuildProviderFactory<'_>>,
) -> Vec<AssertionResult> {
run_inline_tests_seeded(
file,
None,
opts.model_name.as_deref(),
&opts.solve,
opts.base_dir.as_deref(),
build_providers,
opts.test_filter.as_deref(),
&InlineTestSeeds {
parameter_overrides: opts.parameter_overrides.clone(),
initial_conditions: opts.initial_conditions.clone(),
compiler: opts.compiler,
},
)
}
#[allow(clippy::too_many_arguments)]
fn run_inline_tests_seeded(
file: &EsmFile,
source: Option<&Path>,
model_name: Option<&str>,
opts: &SolveOptions,
base_dir: Option<&Path>,
build_providers: Option<&BuildProviderFactory<'_>>,
test_filter: Option<&str>,
seeds: &InlineTestSeeds,
) -> Vec<AssertionResult> {
let mut results = Vec::new();
let source: String = source.map(|p| p.display().to_string()).unwrap_or_default();
let lowered = crate::lower_table_lookup::lowered_copy(file);
let file: &EsmFile = lowered.as_ref().unwrap_or(file);
let index_sets: HashMap<String, IndexSet> = file
.index_sets
.clone()
.unwrap_or_default()
.into_iter()
.collect();
let mut components: Vec<(&String, &Vec<crate::types::ModelTest>, Option<&Tolerance>)> =
Vec::new();
if let Some(models) = &file.models {
let mut names: Vec<&String> = models.keys().collect();
names.sort();
for name in names {
let m = &models[name];
if let Some(tests) = m.tests.as_ref() {
components.push((name, tests, m.tolerance.as_ref()));
}
}
}
if let Some(systems) = &file.reaction_systems {
let mut names: Vec<&String> = systems.keys().collect();
names.sort();
for name in names {
let rs = &systems[name];
if let Some(tests) = rs.tests.as_ref() {
components.push((name, tests, rs.tolerance.as_ref()));
}
}
}
for (name, tests, tolerance) in components {
if let Some(selected) = model_name
&& selected != name.as_str()
{
continue;
}
run_component_tests(
file,
&source,
name,
tests,
tolerance,
&index_sets,
opts,
base_dir,
build_providers,
test_filter,
seeds,
&mut results,
);
}
results
}
#[derive(Clone, Default)]
pub struct InlineTestOptions {
pub model_name: Option<String>,
pub solve: SolveOptions,
pub base_dir: Option<PathBuf>,
pub test_filter: Option<String>,
pub initial_conditions: HashMap<String, InlineValue>,
pub parameter_overrides: HashMap<String, InlineValue>,
pub compiler: Option<crate::problem::Compiler>,
}
impl std::fmt::Debug for InlineTestOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InlineTestOptions")
.field("model_name", &self.model_name)
.field("solve", &self.solve)
.field("base_dir", &self.base_dir)
.field("test_filter", &self.test_filter)
.field("initial_conditions", &self.initial_conditions.len())
.field("parameter_overrides", &self.parameter_overrides.len())
.field("compiler", &self.compiler)
.finish()
}
}
fn esm_files_under(dir: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&d) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "esm") {
out.push(path);
}
}
}
out.sort();
out
}
fn load_failure_result(path: &Path, message: String) -> AssertionResult {
AssertionResult {
model: path.display().to_string(),
test_id: "<load>".to_string(),
assertion_idx: 0,
variable: String::new(),
time: f64::NAN,
reduce: None,
expected: f64::NAN,
actual: None,
rtol: 0.0,
atol: 0.0,
passed: false,
message: format!("load failed: {message}"),
file: path.display().to_string(),
}
}
pub fn run_inline_tests_paths(
paths: &[impl AsRef<Path>],
options_for: &dyn Fn(&Path) -> InlineTestOptions,
) -> Vec<AssertionResult> {
let mut documents: Vec<PathBuf> = Vec::new();
for p in paths {
let p = p.as_ref();
if p.is_dir() {
documents.extend(esm_files_under(p));
} else {
documents.push(p.to_path_buf());
}
}
let mut results = Vec::new();
for path in documents {
let opts = options_for(&path);
let file = match crate::parse::load_path(&path) {
Ok(f) => f,
Err(e) => {
results.push(load_failure_result(&path, e.to_string()));
continue;
}
};
let base_dir = opts
.base_dir
.clone()
.or_else(|| path.parent().map(Path::to_path_buf));
let seeds = InlineTestSeeds {
parameter_overrides: opts.parameter_overrides.clone(),
initial_conditions: opts.initial_conditions.clone(),
compiler: opts.compiler,
};
results.extend(run_inline_tests_seeded(
&file,
Some(&path),
opts.model_name.as_deref(),
&opts.solve,
base_dir.as_deref(),
None,
opts.test_filter.as_deref(),
&seeds,
));
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parse::load_string;
use crate::simulate::Alg;
use crate::types::ExpressionNode;
use serde_json::json;
const N: i64 = 8;
fn x_coord_aggregate() -> serde_json::Value {
json!({
"op": "faq", "args": [], "output_idx": ["i"],
"ranges": {"i": {"from": "x"}},
"expr": {"op": "*",
"args": [{"op": "-", "args": ["i", 0.5]},
{"op": "/", "args": [1, N]}]},
})
}
fn cos_pi_x() -> serde_json::Value {
json!({"op": "cos",
"args": [{"op": "*",
"args": [std::f64::consts::PI, x_coord_aggregate()]}]})
}
fn decay_doc() -> serde_json::Value {
let idx = json!({"op": "index", "args": ["u", "i"]});
json!({
"esm": "1.1.0",
"metadata": {"name": "pde_inline_decay"},
"index_sets": {"x": {"kind": "interval", "size": N}},
"models": {"M": {
"variables": {
"u": {"type": "unknown", "units": "1", "shape": ["x"]},
},
"equations": [
{"lhs": {"op": "ic", "args": ["u"]}, "rhs": cos_pi_x()},
{"lhs": {"op": "faq", "args": [], "output_idx": ["i"],
"ranges": {"i": [1, N]},
"expr": {"op": "D", "args": [idx], "wrt": "t"}},
"rhs": {"op": "faq", "args": [], "output_idx": ["i"],
"ranges": {"i": [1, N]},
"expr": {"op": "*", "args": [-1, idx]}}},
],
"tests": [{
"id": "decay",
"time_span": {"start": 0.0, "end": 1.0},
"assertions": [
{"variable": "u", "time": 0.0, "expected": 0.0,
"tolerance": {"abs": 1e-12}, "reduce": "L2_error",
"reference": cos_pi_x()},
{"variable": "u", "time": 1.0, "expected": 0.0,
"tolerance": {"abs": 1e-8}, "reduce": "L2_error",
"reference": {"op": "*",
"args": [{"op": "exp", "args": [-1]},
cos_pi_x()]}},
{"variable": "u", "time": 1.0, "expected": 0.0,
"tolerance": {"abs": 1e-9}, "reduce": "mean"},
],
}],
}},
})
}
fn tight_opts() -> SolveOptions {
SolveOptions {
alg: Alg::Erk,
reltol: Some(1e-12),
abstol: Some(1e-14),
..Default::default()
}
}
#[test]
fn assertion_reduce_reference_parse_and_roundtrip() {
let file = load_string(&decay_doc().to_string()).expect("decay doc loads");
let models = file.models.as_ref().unwrap();
let a = &models["M"].tests.as_ref().unwrap()[0].assertions[0];
assert_eq!(a.reduce.as_deref(), Some("L2_error"));
assert!(a.coords.is_none());
match a.reference.as_ref().expect("reference parsed") {
AssertionReference::Expression(expr) => match expr.as_ref() {
Expr::Operator(node) => assert_eq!(node.op, "cos"),
other => panic!("expected operator reference, got {other:?}"),
},
other => panic!("expected inline-expression reference, got {other:?}"),
}
let ser = serde_json::to_value(&file).expect("serializes");
let asserts = &ser["models"]["M"]["tests"][0]["assertions"];
assert_eq!(asserts[0]["reduce"], "L2_error");
assert_eq!(asserts[0]["reference"]["op"], "cos");
assert_eq!(asserts[2]["reduce"], "mean");
assert!(asserts[2].get("reference").is_none());
}
#[test]
fn assertion_from_file_reference_roundtrips_verbatim() {
let mut doc = decay_doc();
doc["models"]["M"]["tests"][0]["assertions"][0]["reference"] =
json!({"type": "from_file", "path": "ref.nc", "format": "netcdf"});
let file = load_string(&doc.to_string()).expect("from_file reference parses");
let models = file.models.as_ref().unwrap();
let a = &models["M"].tests.as_ref().unwrap()[0].assertions[0];
match a.reference.as_ref().expect("reference parsed") {
AssertionReference::FromFile(ff) => {
assert_eq!(ff.ref_type, "from_file");
assert_eq!(ff.path, "ref.nc");
assert_eq!(ff.format.as_deref(), Some("netcdf"));
}
other => panic!("expected from_file reference, got {other:?}"),
}
let ser = serde_json::to_value(&file).expect("serializes");
assert_eq!(
ser["models"]["M"]["tests"][0]["assertions"][0]["reference"],
json!({"type": "from_file", "path": "ref.nc", "format": "netcdf"})
);
}
#[test]
fn evaluate_cellwise_grid_geometry() {
let file = load_string(&decay_doc().to_string()).expect("decay doc loads");
let models = file.models.as_ref().unwrap();
let a = &models["M"].tests.as_ref().unwrap()[0].assertions[0];
let Some(AssertionReference::Expression(expr)) = &a.reference else {
panic!("expected inline reference");
};
let index_sets: HashMap<String, IndexSet> =
file.index_sets.clone().unwrap().into_iter().collect();
let cells: Vec<Vec<i64>> = (1..=N).map(|i| vec![i]).collect();
let no_params: HashMap<String, f64> = HashMap::new();
let vals = evaluate_cellwise(expr, &cells, &index_sets, &no_params).expect("evaluates");
for (i, v) in (1..=N).zip(&vals) {
let want = (std::f64::consts::PI * (i as f64 - 0.5) / N as f64).cos();
assert!((v - want).abs() < 1e-15, "cell {i}: {v} vs {want}");
}
let two = Expr::operator(ExpressionNode {
op: "+".to_string(),
args: vec![Expr::Integer(1), Expr::Integer(1)],
..Default::default()
});
assert_eq!(
evaluate_cellwise(&two, &cells, &index_sets, &no_params).unwrap(),
vec![2.0; N as usize]
);
}
#[test]
fn field_reduce_semantics() {
let actual = [1.0, 2.0, 3.0];
let reference = [1.0, 2.0, 5.0];
let l2 = field_reduce("L2_error", &actual, Some(&reference)).unwrap();
assert!((l2 - 2.0 / 30.0f64.sqrt()).abs() < 1e-15);
assert_eq!(
field_reduce("Linf_error", &actual, Some(&reference)).unwrap(),
2.0
);
assert_eq!(field_reduce("mean", &actual, None).unwrap(), 2.0);
assert_eq!(field_reduce("max", &actual, None).unwrap(), 3.0);
assert_eq!(field_reduce("min", &actual, None).unwrap(), 1.0);
assert!(field_reduce("L2_error", &actual, None).is_err()); assert!(field_reduce("L2_error", &actual, Some(&[0.0, 0.0, 0.0])).is_err()); assert!(field_reduce("wat", &actual, None).is_err()); }
#[test]
fn param_scope_aliases_every_unambiguous_dotted_suffix() {
let params: HashMap<String, f64> =
[("P.sub.g".to_string(), 2.0), ("P.k".to_string(), 1.0)].into();
let scope = param_scope_with_aliases(¶ms, "P");
for (name, want) in [
("P.sub.g", 2.0),
("sub.g", 2.0),
("g", 2.0),
("P.k", 1.0),
("k", 1.0),
] {
assert_eq!(scope.get(name), Some(&want), "{name} must be in scope");
}
assert_eq!(scope.len(), 5, "no name beyond those five: {scope:?}");
let ambiguous: HashMap<String, f64> =
[("A.sub.g".to_string(), 1.0), ("B.sub.g".to_string(), 2.0)].into();
let scope = param_scope_with_aliases(&ambiguous, "C");
assert_eq!(scope.len(), 2, "only the flattened names: {scope:?}");
assert!(!scope.contains_key("sub.g"));
assert!(!scope.contains_key("g"));
let owned = param_scope_with_aliases(&ambiguous, "A");
assert_eq!(owned.get("sub.g"), Some(&1.0));
assert_eq!(
param_scope_with_aliases(&ambiguous, "B").get("sub.g"),
Some(&2.0)
);
let shadowing: HashMap<String, f64> =
[("A.sub.g".to_string(), 1.0), ("sub.g".to_string(), 7.0)].into();
assert_eq!(
param_scope_with_aliases(&shadowing, "A").get("sub.g"),
Some(&7.0)
);
assert!(!param_scope_with_aliases(¶ms, "P").contains_key("sub.gg"));
let tails: HashMap<String, f64> =
[("P.x".to_string(), 1.0), ("R.x".to_string(), 2.0)].into();
assert_eq!(param_scope_with_aliases(&tails, "P").get("x"), Some(&1.0));
assert_eq!(param_scope_with_aliases(&tails, "R").get("x"), Some(&2.0));
assert!(!param_scope_with_aliases(&tails, "Q").contains_key("x"));
}
#[test]
fn field_reduce_integral_is_unit_measure_mean() {
let f = [1.0, 2.0, 3.0];
assert_eq!(field_reduce("integral", &f, None).unwrap(), 2.0);
assert_eq!(
field_reduce("integral", &f, None).unwrap(),
field_reduce("mean", &f, None).unwrap()
);
let g: Vec<f64> = (1..=8).map(|i| (i as f64 - 0.5) / 8.0).collect();
assert_eq!(field_reduce("integral", &g, None).unwrap(), 0.5);
assert!(field_reduce("integral", &[], None).is_err());
}
#[test]
fn state_cells_matching_and_order() {
let names: Vec<String> = ["M.v[1]", "w", "wat", "M.u[1]", "M.u[2]", "x[oops]"]
.iter()
.map(|s| s.to_string())
.collect();
let names2: Vec<String> = ["M.u[2]", "M.u[1]", "M.u[10]", "M.v[1]", "w"]
.iter()
.map(|s| s.to_string())
.collect();
let cells = state_cells(&names2, "u", "M");
assert_eq!(
cells,
vec![(vec![1], 1), (vec![2], 0), (vec![10], 2)] );
assert_eq!(state_cells(&names2, "u", "Other"), cells); assert_eq!(state_cells(&names, "w", "M"), vec![]); assert_eq!(state_cells(&names, "x", "M"), vec![]); assert_eq!(scalar_slot(&names, "w", "M", &HashMap::new()), Some(1));
}
#[test]
fn tolerance_precedence_and_isapprox_semantics() {
let model_tol = Tolerance {
rel: Some(1e-2),
abs: None,
};
let test_tol = Tolerance {
rel: None,
abs: Some(1e-3),
};
let assertion_tol = Tolerance {
rel: Some(1e-6),
abs: Some(1e-9),
};
assert_eq!(
resolve_tolerance(Some(&model_tol), Some(&test_tol), Some(&assertion_tol)),
(1e-6, 1e-9)
);
assert_eq!(
resolve_tolerance(Some(&model_tol), Some(&test_tol), None),
(1e-2, 1e-3)
);
assert_eq!(resolve_tolerance(Some(&model_tol), None, None), (1e-2, 0.0));
assert_eq!(resolve_tolerance(None, None, None), (DEFAULT_REL_TOL, 0.0));
let abs_only = Tolerance {
abs: Some(1e-4),
rel: None,
};
assert_eq!(
resolve_tolerance(None, None, Some(&abs_only)),
(DEFAULT_REL_TOL, 1e-4)
);
assert_eq!(
resolve_tolerance(Some(&abs_only), None, None),
(DEFAULT_REL_TOL, 1e-4)
);
let abs_only_exact = Tolerance {
abs: Some(1e-4),
rel: Some(0.0),
};
assert_eq!(
resolve_tolerance(None, None, Some(&abs_only_exact)),
(0.0, 1e-4)
);
let outer_rel = Tolerance {
abs: None,
rel: Some(1e-3),
};
assert_eq!(
resolve_tolerance(Some(&outer_rel), None, Some(&abs_only)),
(1e-3, 1e-4)
);
assert!(check_assertion(1.0000009, 1.0, 1e-6, 0.0));
assert!(!check_assertion(1.000002, 1.0, 1e-6, 0.0));
assert!(check_assertion(0.0, 1e-10, 0.0, 1e-9));
assert!(check_assertion(2.0, 2.0, 0.0, 0.0)); assert!(!check_assertion(2.0, 2.0000001, 0.0, 0.0));
}
#[test]
fn relative_bound_is_symmetric_in_actual_and_expected() {
assert!(check_assertion(1.6, 1.0, 0.5, 0.0));
assert!(!check_assertion(3.0, 1.0, 0.5, 0.0));
for (a, e) in [(1.6, 1.0), (1.0, 1.6), (3.0, 1.0), (1.0, 3.0), (-2.0, -1.2)] {
assert_eq!(
check_assertion(a, e, 0.5, 0.0),
check_assertion(e, a, 0.5, 0.0),
"verdict must not depend on argument order: ({a}, {e})"
);
}
assert!(!check_assertion(1.0, 0.0, 0.5, 0.0));
assert!(check_assertion(1.0, 0.0, 1.0, 0.0));
assert!(check_assertion(1.0, 0.0, 0.0, 1.0)); assert!(check_assertion(0.0, 0.0, 0.0, 0.0));
assert!(!check_assertion(0.0, 1.0, 0.5, 0.0));
assert!(check_assertion(0.0, 1.0, 1.0, 0.0));
assert!(!check_assertion(1.0, -1.0, 1.0, 0.0));
assert!(check_assertion(-1.0, 1.0, 2.0, 0.0));
assert!(!check_assertion(f64::INFINITY, 1.0, 0.5, 0.0));
assert!(!check_assertion(f64::NEG_INFINITY, 1.0, 0.5, 0.0));
assert!(!check_assertion(f64::NAN, 1.0, 0.5, 0.0));
assert!(check_assertion(f64::INFINITY, f64::INFINITY, 0.5, 0.0));
assert!(!check_assertion(f64::INFINITY, f64::NEG_INFINITY, 0.5, 0.0));
assert!(check_assertion(1.6, 1.0, 0.5, 1e-12));
assert!(check_assertion(3.0, 1.0, 0.5, 2.5));
}
#[test]
fn tolerance_resolution_conformance_manifest() {
use serde_json::Value;
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/conformance/tolerance_resolution/manifest.json");
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
let m: Value = serde_json::from_str(&text).expect("parse manifest");
assert_eq!(m["category"], "tolerance_resolution");
assert!(m["integrators"].is_null());
let cases = m["cases"].as_array().expect("cases array");
assert!(cases.len() >= 15, "thin tier: {} cases", cases.len());
fn tolerance(block: &Value) -> Option<Tolerance> {
if block.is_null() {
return None;
}
let field = |name: &str| block.get(name).and_then(Value::as_f64);
Some(Tolerance {
abs: field("abs"),
rel: field("rel"),
})
}
fn wholesale(
model: Option<&Tolerance>,
test: Option<&Tolerance>,
assertion: Option<&Tolerance>,
) -> (f64, f64) {
match [assertion, test, model].into_iter().flatten().next() {
Some(c) => (c.rel.unwrap_or(0.0), c.abs.unwrap_or(0.0)),
None => (DEFAULT_REL_TOL, 0.0),
}
}
let mut changed = 0usize;
for case in cases {
let id = case["id"].as_str().expect("case id");
let levels = &case["levels"];
let model = tolerance(&levels["model"]);
let test = tolerance(&levels["test"]);
let assertion = tolerance(&levels["assertion"]);
let got = resolve_tolerance(model.as_ref(), test.as_ref(), assertion.as_ref());
let want = (
case["resolved"]["rel"].as_f64().expect("resolved.rel"),
case["resolved"]["abs"].as_f64().expect("resolved.abs"),
);
assert_eq!(got, want, "{id}: {}", case["note"]);
let old = wholesale(model.as_ref(), test.as_ref(), assertion.as_ref());
assert!(got.0 >= old.0 && got.1 >= old.1, "{id} tightened");
let flag = case["changed_by_228"].as_bool().expect("changed_by_228");
assert_eq!(old != got, flag, "{id}: changed_by_228 is stale");
changed += usize::from(flag);
}
assert!(changed >= 5, "tier does not exercise the change: {changed}");
}
#[test]
fn check_assertion_judges_finiteness_before_tolerance() {
let inf = f64::INFINITY;
let nan = f64::NAN;
for (rtol, atol) in [(1e-9, 0.0), (0.0, 1e300), (1e-9, 1e300), (0.0, 0.0)] {
assert!(!check_assertion(inf, 42.0, rtol, atol));
assert!(!check_assertion(inf, -5.0, rtol, atol));
assert!(!check_assertion(inf, 0.0, rtol, atol));
assert!(!check_assertion(inf, f64::MAX, rtol, atol));
assert!(!check_assertion(-inf, -42.0, rtol, atol));
assert!(!check_assertion(nan, 0.0, rtol, atol));
assert!(!check_assertion(nan, nan, rtol, atol));
assert!(!check_assertion(1e300, inf, rtol, atol));
assert!(check_assertion(inf, inf, rtol, atol));
assert!(check_assertion(-inf, -inf, rtol, atol));
assert!(!check_assertion(inf, -inf, rtol, atol));
assert!(!check_assertion(-inf, inf, rtol, atol));
}
assert!(check_assertion(-0.0, 0.0, 0.0, 0.0));
assert!(check_assertion(-0.0, 0.0, 1e-9, 0.0));
}
#[test]
fn scalar_only_component_is_assertable() {
let doc = json!({
"esm": "1.1.0",
"metadata": {"name": "scalar_only"},
"models": {"ScalarOnly": {
"variables": {
"input": {"type": "parameter", "units": "1", "default": 21.0},
"answer": {"type": "unknown", "units": "1"},
},
"equations": [
{"lhs": "answer", "rhs": {"op": "*", "args": [2.0, "input"]}},
],
"tests": [{
"id": "algebraic_scalar",
"time_span": {"start": 0.0, "end": 0.0},
"assertions": [
{"variable": "answer", "time": 0.0, "expected": 42.0,
"tolerance": {"rel": 1e-12}},
],
}],
}},
});
let file = load_string(&doc.to_string()).expect("scalar-only doc loads");
let results = run_inline_tests(&file, Some("ScalarOnly"), &tight_opts());
assert_eq!(results.len(), 1);
assert!(results[0].passed, "{}", results[0].message);
assert_eq!(results[0].actual, Some(42.0));
let mut wrong = doc.clone();
wrong["models"]["ScalarOnly"]["tests"][0]["assertions"][0]["expected"] = json!(41.0);
let file = load_string(&wrong.to_string()).expect("doc loads");
let results = run_inline_tests(&file, Some("ScalarOnly"), &tight_opts());
assert!(!results[0].passed);
assert_eq!(results[0].actual, Some(42.0));
assert!(
results[0].message.contains("actual=42"),
"a wrong expectation must be a FAIL naming the value, got: {}",
results[0].message
);
}
#[test]
fn scalar_observed_of_an_ode_component_is_assertable() {
let doc = json!({
"esm": "1.1.0",
"metadata": {"name": "scalar_ode_observed"},
"models": {"M": {
"variables": {
"u": {"type": "unknown", "units": "1", "default": 1.0},
"k": {"type": "parameter", "units": "1/s", "default": 0.5},
"twice": {"type": "unknown", "units": "1"},
},
"equations": [
{"lhs": {"op": "D", "args": ["u"], "wrt": "t"},
"rhs": {"op": "*", "args": [-1.0, {"op": "*", "args": ["k", "u"]}]}},
{"lhs": "twice", "rhs": {"op": "*", "args": [2.0, "u"]}},
],
"tests": [{
"id": "observed_along_the_trajectory",
"time_span": {"start": 0.0, "end": 1.0},
"assertions": [
{"variable": "u", "time": 0.0, "expected": 1.0,
"tolerance": {"rel": 1e-9}},
{"variable": "twice", "time": 0.0, "expected": 2.0,
"tolerance": {"rel": 1e-9}},
{"variable": "twice", "time": 1.0,
"expected": 1.2130613194252668,
"tolerance": {"rel": 1e-8}},
],
}],
}},
});
let file = load_string(&doc.to_string()).expect("ode doc loads");
let results = run_inline_tests(&file, Some("M"), &tight_opts());
assert_eq!(results.len(), 3);
for r in &results {
assert!(
r.passed,
"{}#{}: {}",
r.variable, r.assertion_idx, r.message
);
}
let late = results[2].actual.expect("actual");
assert!(
(late - 2.0 * (-0.5f64).exp()).abs() <= 1e-8,
"observed at t=1 is {late}"
);
}
#[test]
fn a_zero_length_time_span_answers_from_the_initial_state() {
let model = json!({
"variables": {
"u": {"type": "unknown", "units": "1", "default": 1.0},
"k": {"type": "parameter", "units": "1/s", "default": 0.5},
"twice": {"type": "unknown", "units": "1"},
},
"equations": [
{"lhs": {"op": "D", "args": ["u"], "wrt": "t"},
"rhs": {"op": "*", "args": [-1.0, {"op": "*", "args": ["k", "u"]}]}},
{"lhs": "twice", "rhs": {"op": "*", "args": [2.0, "u"]}},
],
"tests": [{
"id": "at_the_initial_state",
"time_span": {"start": 0.0, "end": 0.0},
"assertions": [
{"variable": "u", "time": 0.0, "expected": 1.0,
"tolerance": {"rel": 1e-12}},
{"variable": "twice", "time": 0.0, "expected": 2.0,
"tolerance": {"rel": 1e-12}},
],
}],
});
let doc = json!({
"esm": "1.1.0",
"metadata": {"name": "zero_length_span"},
"models": {"M": model},
});
let file = load_string(&doc.to_string()).expect("doc loads");
let results = run_inline_tests(&file, Some("M"), &tight_opts());
assert_eq!(results.len(), 2);
for r in &results {
assert!(
r.passed,
"{}#{}: {}",
r.variable, r.assertion_idx, r.message
);
}
assert_eq!(results[0].actual, Some(1.0));
assert_eq!(results[1].actual, Some(2.0));
let mut shifted = doc.clone();
shifted["models"]["M"]["tests"][0]["time_span"] = json!({"start": 2.0, "end": 2.0});
shifted["models"]["M"]["tests"][0]["assertions"][0]["time"] = json!(2.0);
shifted["models"]["M"]["tests"][0]["assertions"][1]["time"] = json!(2.0);
let file = load_string(&shifted.to_string()).expect("shifted doc loads");
let results = run_inline_tests(&file, Some("M"), &tight_opts());
assert_eq!(results.len(), 2);
for r in &results {
assert!(
r.passed,
"{}#{}: {}",
r.variable, r.assertion_idx, r.message
);
}
assert_eq!(results[1].actual, Some(2.0));
let mut moving = doc.clone();
moving["models"]["M"]["tests"][0]["time_span"] = json!({"start": 0.0, "end": 1.0});
moving["models"]["M"]["tests"][0]["assertions"][1]["time"] = json!(1.0);
moving["models"]["M"]["tests"][0]["assertions"][1]["expected"] = json!(1.2130613194252668);
moving["models"]["M"]["tests"][0]["assertions"][1]["tolerance"] = json!({"rel": 1e-8});
let file = load_string(&moving.to_string()).expect("moving doc loads");
let results = run_inline_tests(&file, Some("M"), &tight_opts());
assert_eq!(results.len(), 2);
for r in &results {
assert!(
r.passed,
"{}#{}: {}",
r.variable, r.assertion_idx, r.message
);
}
}
#[test]
fn an_unmaterialized_scalar_assertion_is_an_error_not_a_zero() {
let doc = json!({
"esm": "1.1.0",
"metadata": {"name": "scalar_missing"},
"models": {"ScalarOnly": {
"variables": {
"input": {"type": "parameter", "units": "1", "default": 21.0},
"answer": {"type": "unknown", "units": "1"},
},
"equations": [
{"lhs": "answer", "rhs": {"op": "*", "args": [2.0, "input"]}},
],
"tests": [{
"id": "no_such_variable",
"time_span": {"start": 0.0, "end": 0.0},
"assertions": [
{"variable": "ghost", "time": 0.0, "expected": 0.0},
],
}],
}},
});
let file = load_string(&doc.to_string()).expect("doc loads");
let results = run_inline_tests(&file, Some("ScalarOnly"), &tight_opts());
assert_eq!(results.len(), 1);
assert!(!results[0].passed);
assert_eq!(
results[0].actual, None,
"a missing scalar must not report a value"
);
assert!(
results[0].message.contains("ghost"),
"the diagnostic must name the variable, got: {}",
results[0].message
);
}
#[test]
fn run_inline_tests_decay_field() {
let file = load_string(&decay_doc().to_string()).expect("decay doc loads");
let results = run_inline_tests(&file, Some("M"), &tight_opts());
assert_eq!(
results.iter().map(|r| r.assertion_idx).collect::<Vec<_>>(),
vec![1, 2, 3]
);
assert!(results[0].passed, "t=0: {}", results[0].message);
assert!(results[0].actual.unwrap() < 1e-14);
assert!(results[1].passed, "t=1: {}", results[1].message);
assert!(results[1].actual.unwrap() < 1e-8);
assert!(results[2].passed, "mean: {}", results[2].message);
assert!(results[2].actual.unwrap().abs() < 1e-9);
assert!(
results
.iter()
.all(|r| matches!(r.reduce.as_deref(), Some("L2_error") | Some("mean")))
);
assert!(
results
.iter()
.all(|r| r.model == "M" && r.test_id == "decay")
);
}
#[test]
fn reference_binds_the_field_dimension_names() {
let free_x = json!({"op": "cos", "args": [{"op": "*", "args": [
std::f64::consts::PI,
{"op": "/", "args": [{"op": "-", "args": ["x", 0.5]}, N]}]}]});
let table: Vec<f64> = (1..=N)
.map(|i| (std::f64::consts::PI * (i as f64 - 0.5) / N as f64).cos())
.collect();
let mut doc = decay_doc();
doc["models"]["M"]["tests"][0]["assertions"] = json!([
{"variable": "u", "time": 0.0, "expected": 0.0,
"tolerance": {"abs": 1e-12}, "reduce": "L2_error", "reference": free_x},
{"variable": "u", "time": 0.0, "expected": 0.0,
"tolerance": {"abs": 1e-12}, "reduce": "Linf_error",
"reference": {"op": "index", "args": [
{"op": "const", "args": [], "value": table}, "x"]}},
{"variable": "u", "time": 0.0, "expected": 0.0,
"tolerance": {"abs": 1e-12}, "reduce": "L2_error",
"reference": {"op": "faq", "args": [], "output_idx": ["x"],
"ranges": {"x": {"from": "x"}}, "expr": free_x}},
{"variable": "u", "time": 1.0, "expected": 0.0,
"tolerance": {"abs": 1e-8}, "reduce": "L2_error",
"reference": {"op": "*", "args": [{"op": "exp", "args": [-1]}, free_x]}},
]);
let file = load_string(&doc.to_string()).expect("doc loads");
let results = run_inline_tests(&file, Some("M"), &tight_opts());
assert_eq!(results.len(), 4);
for r in &results {
assert!(r.passed, "assertion #{}: {}", r.assertion_idx, r.message);
}
}
#[test]
fn bind_dimension_names_rejects_a_free_time_variable() {
let parse = |v: serde_json::Value| -> Expr { serde_json::from_value(v).unwrap() };
let no_params: HashMap<String, f64> = HashMap::new();
let no_arrays: HashSet<String> = HashSet::new();
let free_t = parse(json!({"op": "*", "args": [1.0, "t"]}));
let dims = vec!["x".to_string()];
let err = bind_dimension_names(&free_t, &dims, &no_params, &no_arrays)
.expect_err("`t` is not admissible in a reference");
assert_eq!(err, REFERENCE_MENTIONS_TIME);
assert!(err.contains("mentions `t`"), "{err}");
assert!(err.contains("§6.6.5"), "{err}");
assert_eq!(
bind_dimension_names(&free_t, &[], &no_params, &no_arrays)
.expect_err("still not admissible"),
REFERENCE_MENTIONS_TIME
);
let t_dims = vec!["t".to_string()];
let Expr::Operator(node) = bind_dimension_names(&free_t, &t_dims, &no_params, &no_arrays)
.expect("`t` is this field's dimension name")
else {
panic!("expected a faq wrapper");
};
assert_eq!(node.op, "faq");
let bound = parse(json!({"op": "faq", "args": [], "output_idx": ["t"],
"ranges": {"t": {"from": "x"}},
"expr": {"op": "*", "args": [1.0, "t"]}}));
assert_eq!(
bind_dimension_names(&bound, &dims, &no_params, &no_arrays).expect("rebound"),
bound
);
}
#[test]
fn bind_dimension_names_wraps_only_a_free_mention() {
let dims = vec!["x".to_string()];
let scope: HashMap<String, f64> = HashMap::from([("k".to_string(), 2.0)]);
let parse = |v: serde_json::Value| -> Expr { serde_json::from_value(v).unwrap() };
let no_arrays: HashSet<String> = HashSet::new();
let bind = |e: &Expr, d: &[String]| {
bind_dimension_names(e, d, &scope, &no_arrays).expect("no clash")
};
let lit = parse(json!({"op": "*", "args": [2.0, "k"]}));
assert_eq!(bind(&lit, &dims), lit);
let free = parse(json!({"op": "+", "args": ["x", 1]}));
let Expr::Operator(node) = bind(&free, &dims) else {
panic!("expected an aggregate wrapper");
};
assert_eq!(node.op, "faq");
assert_eq!(node.output_idx.as_deref(), Some(&["x".to_string()][..]));
assert_eq!(node.expr.as_deref(), Some(&free));
let bound = parse(json!({"op": "faq", "args": [], "output_idx": ["x"],
"ranges": {"x": {"from": "x"}},
"expr": {"op": "+", "args": ["x", 1]}}));
assert_eq!(bind(&bound, &dims), bound);
let integ = parse(
json!({"op": "integral", "args": [{"op": "*", "args": [2, "x"]}],
"var": "x", "lower": 0, "upper": 1}),
);
assert_eq!(bind(&integ, &dims), integ);
let deriv = parse(json!({"op": "D", "args": ["u"], "wrt": "x"}));
assert_eq!(bind(&deriv, &dims), deriv);
assert_eq!(bind(&free, &[]), free);
}
#[test]
fn bind_dimension_names_rejects_a_dimension_that_shadows_a_parameter() {
let dims = vec!["x".to_string()];
let parse = |v: serde_json::Value| -> Expr { serde_json::from_value(v).unwrap() };
let scope: HashMap<String, f64> = HashMap::from([("x".to_string(), 3.0)]);
let free = parse(json!({"op": "+", "args": ["x", 1]}));
let no_arrays: HashSet<String> = HashSet::new();
let err =
bind_dimension_names(&free, &dims, &scope, &no_arrays).expect_err("clash is a fault");
assert!(err.contains("'x'"), "{err}");
assert!(err.contains("a parameter"), "{err}");
let lit = parse(json!({"op": "*", "args": [2.0, "k"]}));
assert_eq!(
bind_dimension_names(&lit, &dims, &scope, &no_arrays).expect("no mention"),
lit
);
let bound = parse(json!({"op": "faq", "args": [], "output_idx": ["x"],
"ranges": {"x": {"from": "x"}},
"expr": {"op": "+", "args": ["x", 1]}}));
assert_eq!(
bind_dimension_names(&bound, &dims, &scope, &no_arrays).expect("rebound"),
bound
);
}
#[test]
fn bind_dimension_names_rejects_a_dimension_a_build_array_binds() {
let dims = vec!["lev".to_string()];
let parse = |v: serde_json::Value| -> Expr { serde_json::from_value(v).unwrap() };
let no_params: HashMap<String, f64> = HashMap::new();
let free = parse(json!({"op": "index", "args": ["table", "lev"]}));
for names in [vec!["lev"], vec!["M.lev"]] {
let arrays = array_scope_names(names.iter().copied());
let err = bind_dimension_names(&free, &dims, &no_params, &arrays)
.expect_err("clash is a fault");
assert!(err.contains("'lev'"), "{err}");
assert!(err.contains("a build-time array"), "{err}");
}
let ambiguous = array_scope_names(["A.lev", "B.lev"]);
assert!(!ambiguous.contains("lev"));
let wrapped = bind_dimension_names(&free, &dims, &no_params, &ambiguous)
.expect("no unambiguous alias, no clash");
let Expr::Operator(node) = wrapped else {
panic!("expected an aggregate wrapper");
};
assert_eq!(node.op, "faq");
let arrays = array_scope_names(["lev"]);
let lit = parse(json!({"op": "*", "args": [2.0, "k"]}));
assert_eq!(
bind_dimension_names(&lit, &dims, &no_params, &arrays).expect("no mention"),
lit
);
let bound = parse(json!({"op": "faq", "args": [], "output_idx": ["lev"],
"ranges": {"lev": {"from": "lev"}},
"expr": {"op": "index", "args": ["table", "lev"]}}));
assert_eq!(
bind_dimension_names(&bound, &dims, &no_params, &arrays).expect("rebound"),
bound
);
}
#[test]
fn self_qualified_subsystem_reference_and_override_spellings() {
let mut doc = decay_doc();
doc["models"]["P"] = doc["models"]["M"].take();
doc["models"].as_object_mut().unwrap().remove("M");
doc["models"]["P"]["subsystems"] = json!({"sub": {"variables": {
"g": {"type": "parameter", "units": "1", "default": 9.81}}, "equations": []}});
doc["models"]["P"]["variables"]["gg"] = json!({"type": "unknown", "units": "1"});
doc["models"]["P"]["equations"]
.as_array_mut()
.unwrap()
.push(json!({"lhs": "gg", "rhs": "P.sub.g"}));
let assert_gg = |want: f64| json!([{"variable": "gg", "time": 0.0, "expected": want, "tolerance": {"rel": 1e-12}}]);
doc["models"]["P"]["tests"] = json!([
{"id": "default", "time_span": {"start": 0.0, "end": 1.0},
"assertions": assert_gg(9.81)},
{"id": "qualified", "time_span": {"start": 0.0, "end": 1.0},
"parameter_overrides": {"P.sub.g": 1.5}, "assertions": assert_gg(1.5)},
{"id": "relative", "time_span": {"start": 0.0, "end": 1.0},
"parameter_overrides": {"sub.g": 2.5}, "assertions": assert_gg(2.5)},
{"id": "bare", "time_span": {"start": 0.0, "end": 1.0},
"parameter_overrides": {"g": 3.5}, "assertions": assert_gg(3.5)},
]);
let file = load_string(&doc.to_string()).expect("doc loads");
let results = run_inline_tests(&file, Some("P"), &tight_opts());
assert_eq!(results.len(), 4);
for r in &results {
assert!(r.passed, "test {}: {}", r.test_id, r.message);
}
doc["models"]["P"]["tests"] = json!([
{"id": "typo", "time_span": {"start": 0.0, "end": 1.0},
"parameter_overrides": {"Q.sub.g": 1.5}, "assertions": assert_gg(1.5)},
]);
let file = load_string(&doc.to_string()).expect("doc loads");
let results = run_inline_tests(&file, Some("P"), &tight_opts());
assert_eq!(results.len(), 1);
assert!(!results[0].passed, "a typo'd qualifier must not resolve");
}
#[test]
fn run_inline_tests_reports_failing_assertion_with_actual() {
let mut doc = decay_doc();
doc["models"]["M"]["tests"][0]["assertions"] = json!([
{"variable": "u", "time": 1.0, "expected": 0.0,
"tolerance": {"abs": 1e-12}, "reduce": "L2_error",
"reference": cos_pi_x()},
]);
let file = load_string(&doc.to_string()).expect("doc loads");
let results = run_inline_tests(&file, Some("M"), &tight_opts());
assert_eq!(results.len(), 1);
let r = &results[0];
assert!(!r.passed);
let want = 1.0 - (-1.0f64).exp();
let actual = r.actual.expect("actual recorded on failure");
assert!(
(actual - want).abs() <= 1e-6 * want,
"actual {actual} vs {want}"
);
assert!(r.message.contains("actual="));
}
#[test]
fn coordinate_expression_ic_seeds_grid() {
let file = load_string(&decay_doc().to_string()).expect("decay doc loads");
let mut opts = tight_opts();
opts.saveat = Some(vec![0.0]);
let sol = crate::problem::esm_problem(
&file,
(0.0, 1.0),
crate::problem::ProblemOptions {
p: HashMap::new().clone(),
u0: HashMap::new().clone(),
rhs: crate::problem::Rhs::Always,
..Default::default()
},
)
.and_then(|prob| crate::problem::solve(&prob, &opts))
.expect("simulates");
let cells = state_cells(&sol.state_variable_names, "u", "M");
assert_eq!(cells.len(), N as usize);
for (cell, row) in &cells {
let want = (std::f64::consts::PI * (cell[0] as f64 - 0.5) / N as f64).cos();
let got = sol.state[*row][0];
assert!((got - want).abs() < 1e-15, "cell {cell:?}: {got} vs {want}");
}
}
fn observed_assert_doc() -> serde_json::Value {
let g = json!({"op": "faq", "args": [], "output_idx": ["i"],
"ranges": {"i": {"from": "x"}},
"expr": {"op": "*", "args": ["i", "i"]}});
let h = json!({"op": "faq", "args": ["u"], "output_idx": ["i"],
"ranges": {"i": {"from": "x"}},
"expr": {"op": "+",
"args": [{"op": "index", "args": ["u", "i"]}, 1]}});
json!({
"esm": "1.1.0",
"metadata": {"name": "observed_assertions"},
"index_sets": {"x": {"kind": "interval", "size": 3}},
"models": {"M": {
"variables": {
"u": {"type": "unknown", "units": "1", "shape": ["x"]},
"g": {"type": "unknown", "units": "1", "shape": ["x"]},
"h": {"type": "unknown", "units": "1", "shape": ["x"]},
},
"equations": [
{"lhs": "g", "rhs": g},
{"lhs": "h", "rhs": h},
{"lhs": {"op": "ic", "args": ["u"]}, "rhs": 0.0},
{"lhs": {"op": "D", "args": ["u"], "wrt": "t"}, "rhs": 0.0},
],
"tests": [{
"id": "obs",
"time_span": {"start": 0.0, "end": 1.0},
"assertions": [
{"variable": "g", "time": 1.0, "expected": 9.0,
"tolerance": {"abs": 1e-12}, "reduce": "max"},
{"variable": "g", "time": 1.0, "expected": 1.0,
"tolerance": {"abs": 1e-12}, "reduce": "min"},
{"variable": "h", "time": 1.0, "expected": 1.0,
"tolerance": {"abs": 1e-12}, "reduce": "max"},
{"variable": "h", "time": 1.0, "expected": 1.0,
"tolerance": {"abs": 1e-12}, "coords": {"x": 2}},
{"variable": "nope", "time": 1.0, "expected": 0.0,
"tolerance": {"abs": 1e-12}, "reduce": "max"},
],
}],
}},
})
}
#[test]
fn array_observed_assertions_evaluate_directly() {
let file = load_string(&observed_assert_doc().to_string()).expect("doc loads");
let results = run_inline_tests(&file, Some("M"), &tight_opts());
assert_eq!(results.len(), 5);
assert!(results[0].passed, "g max: {}", results[0].message);
assert_eq!(results[0].actual, Some(9.0));
assert!(results[1].passed, "g min: {}", results[1].message);
assert_eq!(results[1].actual, Some(1.0));
assert!(results[2].passed, "h max: {}", results[2].message);
assert_eq!(results[2].actual, Some(1.0));
assert!(results[3].passed, "h at x=2: {}", results[3].message);
assert_eq!(results[3].actual, Some(1.0));
assert!(!results[4].passed);
assert!(
results[4].message.contains("has no cells in var_map"),
"unexpected message: {}",
results[4].message
);
assert_eq!(results[4].actual, None);
}
fn coords_assert(coords: serde_json::Value, time: f64, expected: f64) -> serde_json::Value {
json!({"variable": "u", "time": time, "expected": expected,
"tolerance": {"abs": 1e-9}, "coords": coords})
}
fn decay_doc_with(assertions: serde_json::Value) -> serde_json::Value {
let mut doc = decay_doc();
doc["models"]["M"]["tests"][0]["assertions"] = assertions;
doc
}
#[test]
fn run_inline_tests_coords_sampling_nearest_ties_down() {
let u = |i: f64| (std::f64::consts::PI * (i - 0.5) / N as f64).cos();
let mut doc = decay_doc_with(json!([
coords_assert(json!({"x": 3}), 0.0, u(3.0)),
coords_assert(json!({"x": 3.5}), 0.0, u(3.0)), coords_assert(json!({"x": 2.5}), 0.0, u(2.0)), coords_assert(json!({"x": 5.6}), 0.0, u(6.0)), coords_assert(json!({"x": 8.5}), 0.0, u(8.0)), ]));
doc["models"]["M"]["tests"][0]["assertions"]
.as_array_mut()
.unwrap()
.push({
let mut a = coords_assert(json!({"x": 3}), 1.0, (-1.0f64).exp() * u(3.0));
a["tolerance"] = json!({"abs": 1e-8});
a
});
let file = load_string(&doc.to_string()).expect("doc loads");
let results = run_inline_tests(&file, Some("M"), &tight_opts());
assert_eq!(results.len(), 6);
for r in &results {
assert!(r.passed, "assertion {}: {}", r.assertion_idx, r.message);
assert!(r.reduce.is_none());
}
assert_eq!(results[0].actual, results[1].actual);
}
#[test]
fn run_inline_tests_coords_validation_rejections() {
let file = load_string(
&decay_doc_with(json!([
coords_assert(json!({"y": 1.0}), 0.0, 0.0),
coords_assert(json!({"x": 0.4}), 0.0, 0.0), coords_assert(json!({"x": 8.6}), 0.0, 0.0), ]))
.to_string(),
)
.expect("doc loads");
let results = run_inline_tests(&file, Some("M"), &tight_opts());
assert_eq!(results.len(), 3);
for r in &results {
assert!(!r.passed);
assert_eq!(r.actual, None);
}
assert!(results[0].message.contains("names unknown dimension 'y'"));
assert!(results[1].message.contains("outside 1..8"));
assert!(results[1].message.contains("resolves to index 0"));
assert!(results[2].message.contains("resolves to index 9"));
}
#[test]
fn run_inline_tests_coords_on_scalar_variable_rejected() {
let doc = json!({
"esm": "1.1.0",
"metadata": {"name": "scalar_coords"},
"models": {"M": {
"variables": {"z": {"type": "unknown", "units": "1", "default": 1.0}},
"equations": [
{"lhs": {"op": "D", "args": ["z"], "wrt": "t"}, "rhs": 0.0}],
"tests": [{
"id": "scalar",
"time_span": {"start": 0.0, "end": 1.0},
"assertions": [{"variable": "z", "time": 1.0, "expected": 1.0,
"tolerance": {"abs": 1e-9},
"coords": {"x": 1.0}}],
}],
}},
});
let file = load_string(&doc.to_string()).expect("doc loads");
let results = run_inline_tests(&file, Some("M"), &tight_opts());
assert_eq!(results.len(), 1);
assert!(!results[0].passed);
assert!(
results[0]
.message
.contains("requires a spatially-shaped variable"),
"unexpected message: {}",
results[0].message
);
}
#[test]
fn coords_and_reduce_are_mutually_exclusive_at_load() {
let doc = decay_doc_with(json!([
{"variable": "u", "time": 0.0, "expected": 0.0,
"coords": {"x": 1}, "reduce": "mean"},
]));
assert!(load_string(&doc.to_string()).is_err(), "schema must reject");
}
fn doc_2d(ny: i64) -> serde_json::Value {
let idx = json!({"op": "index", "args": ["u", "i", "j"]});
let ranges = json!({"i": [1, 4], "j": [1, ny]});
json!({
"esm": "1.1.0",
"metadata": {"name": "pde_inline_2d"},
"index_sets": {"x": {"kind": "interval", "size": 4},
"y": {"kind": "interval", "size": ny}},
"models": {"M": {
"variables": {"u": {"type": "unknown", "units": "1",
"shape": ["x", "y"]}},
"equations": [
{"lhs": {"op": "ic", "args": ["u"]}, "rhs": 0.0},
{"lhs": {"op": "faq", "args": [],
"output_idx": ["i", "j"], "ranges": ranges,
"expr": {"op": "D", "args": [idx], "wrt": "t"}},
"rhs": {"op": "faq", "args": [],
"output_idx": ["i", "j"], "ranges": ranges,
"expr": 1.0}},
],
"tests": [{
"id": "subset",
"time_span": {"start": 0.0, "end": 1.0},
"assertions": [{"variable": "u", "time": 1.0,
"expected": 1.0,
"tolerance": {"abs": 1e-8},
"coords": {"x": 2}}],
}],
}},
})
}
#[test]
fn coords_strict_subset_requires_singleton_remainder() {
let ok_file = load_string(&doc_2d(1).to_string()).expect("doc loads");
let ok = run_inline_tests(&ok_file, Some("M"), &tight_opts());
assert_eq!(ok.len(), 1);
assert!(ok[0].passed, "{}", ok[0].message);
assert!((ok[0].actual.unwrap() - 1.0).abs() < 1e-8);
let bad_file = load_string(&doc_2d(3).to_string()).expect("doc loads");
let bad = run_inline_tests(&bad_file, Some("M"), &tight_opts());
assert_eq!(bad.len(), 1);
assert!(!bad[0].passed);
assert!(
bad[0]
.message
.contains("leaves dimension 'y' unpinned with 3 samples"),
"unexpected message: {}",
bad[0].message
);
}
fn from_file_assert(reference: serde_json::Value, reduce: &str) -> serde_json::Value {
json!({"variable": "u", "time": 0.0, "expected": 0.0,
"tolerance": {"abs": 1e-12}, "reduce": reduce,
"reference": reference})
}
fn ic_cos_values() -> Vec<f64> {
(1..=N)
.map(|i| (std::f64::consts::PI * (i as f64 - 0.5) / N as f64).cos())
.collect()
}
#[test]
fn from_file_reference_happy_path() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("ref.json"),
serde_json::to_string(&ic_cos_values()).unwrap(),
)
.unwrap();
let doc = decay_doc_with(json!([
from_file_assert(json!({"type": "from_file", "path": "ref.json"}), "L2_error"),
from_file_assert(
json!({"type": "from_file", "path": "ref.json", "format": "json"}),
"Linf_error"
),
]));
let file = load_string(&doc.to_string()).expect("doc loads");
let results =
run_inline_tests_with_base_dir(&file, Some("M"), &tight_opts(), Some(dir.path()));
assert_eq!(results.len(), 2);
for r in &results {
assert!(r.passed, "assertion {}: {}", r.assertion_idx, r.message);
assert_eq!(r.actual, Some(0.0));
}
}
#[test]
fn from_file_reference_shape_mismatch() {
let dir = tempfile::tempdir().expect("tempdir");
let vals = ic_cos_values();
std::fs::write(
dir.path().join("short.json"),
serde_json::to_string(&vals[..7]).unwrap(),
)
.unwrap();
let file = load_string(
&decay_doc_with(json!([from_file_assert(
json!({"type": "from_file", "path": "short.json"}),
"L2_error"
)]))
.to_string(),
)
.expect("doc loads");
let r =
&run_inline_tests_with_base_dir(&file, Some("M"), &tight_opts(), Some(dir.path()))[0];
assert!(!r.passed);
assert!(
r.message
.contains("shape mismatch along dimension 1: expected length 8, found 7"),
"unexpected message: {}",
r.message
);
let nested: Vec<Vec<f64>> = vals.iter().map(|&v| vec![v]).collect();
std::fs::write(
dir.path().join("deep.json"),
serde_json::to_string(&nested).unwrap(),
)
.unwrap();
let file = load_string(
&decay_doc_with(json!([from_file_assert(
json!({"type": "from_file", "path": "deep.json"}),
"L2_error"
)]))
.to_string(),
)
.expect("doc loads");
let r =
&run_inline_tests_with_base_dir(&file, Some("M"), &tight_opts(), Some(dir.path()))[0];
assert!(!r.passed);
assert!(
r.message.contains("expected a number"),
"unexpected message: {}",
r.message
);
}
#[test]
fn from_file_reference_missing_file_and_format() {
let dir = tempfile::tempdir().expect("tempdir");
let file = load_string(
&decay_doc_with(json!([from_file_assert(
json!({"type": "from_file", "path": "nope.json"}),
"L2_error"
)]))
.to_string(),
)
.expect("doc loads");
let r =
&run_inline_tests_with_base_dir(&file, Some("M"), &tight_opts(), Some(dir.path()))[0];
assert!(!r.passed);
assert!(
r.message.contains("file not found"),
"unexpected message: {}",
r.message
);
std::fs::write(dir.path().join("ref.json"), "[1,2,3,4,5,6,7,8]").unwrap();
let file = load_string(
&decay_doc_with(json!([from_file_assert(
json!({"type": "from_file", "path": "ref.json", "format": "netcdf"}),
"L2_error"
)]))
.to_string(),
)
.expect("doc loads");
let r =
&run_inline_tests_with_base_dir(&file, Some("M"), &tight_opts(), Some(dir.path()))[0];
assert!(!r.passed);
assert!(
r.message.contains("format 'netcdf' is not supported"),
"unexpected message: {}",
r.message
);
}
#[test]
fn shared_fixture_pde_inline_assertions_exec() {
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/spatial/pde_inline_assertions_exec.esm");
assert!(fixture.is_file(), "missing shared fixture {fixture:?}");
let text = std::fs::read_to_string(&fixture).expect("fixture reads");
let file = load_string(&text).expect("fixture loads");
let results =
run_inline_tests_with_base_dir(&file, Some("M"), &tight_opts(), fixture.parent());
assert_eq!(results.len(), 7);
for r in &results {
assert!(r.passed, "assertion {}: {}", r.assertion_idx, r.message);
}
assert_eq!(results[0].actual, results[1].actual);
assert!(results[4].actual.unwrap().abs() < 1e-12);
assert!(results[5].actual.unwrap() < 1e-12);
assert!(results[6].actual.unwrap() < 1e-12);
}
fn scalar_observed_coupled_doc() -> serde_json::Value {
let comp = |a: f64, tests: serde_json::Value| -> serde_json::Value {
let idx = json!({"op": "index", "args": ["x", "i"]});
json!({
"variables": {
"T": {"type": "parameter", "units": "K", "default": 10.0},
"a": {"type": "parameter", "units": "1", "default": a},
"x": {"type": "unknown", "units": "1", "shape": ["s"]},
"k": {"type": "unknown", "units": "1"},
},
"equations": [
{"lhs": "k", "rhs": {"op": "*", "args": ["a", "T"]}},
{"lhs": {"op": "ic", "args": ["x"]}, "rhs": 0.0},
{"lhs": {"op": "faq", "args": [], "output_idx": ["i"],
"ranges": {"i": [1, 1]},
"expr": {"op": "D", "args": [idx], "wrt": "t"}},
"rhs": {"op": "faq", "args": [], "output_idx": ["i"],
"ranges": {"i": [1, 1]}, "expr": 0.0}},
],
"tests": tests,
})
};
json!({
"esm": "1.1.0",
"metadata": {"name": "scalar_observed_param_override"},
"index_sets": {"s": {"kind": "interval", "size": 1}},
"models": {
"M1": comp(2.0, json!([])),
"M2": comp(5.0, json!([
{"id": "t_lo", "time_span": {"start": 0.0, "end": 1.0},
"parameter_overrides": {"M2.T": 10.0},
"assertions": [{"variable": "k", "time": 1.0,
"expected": 50.0,
"tolerance": {"rel": 1e-9}}]},
{"id": "t_hi", "time_span": {"start": 0.0, "end": 1.0},
"parameter_overrides": {"M2.T": 20.0},
"assertions": [{"variable": "k", "time": 1.0,
"expected": 100.0,
"tolerance": {"rel": 1e-9}}]},
])),
},
})
}
#[test]
fn run_inline_tests_scalar_observed_tracks_parameter_overrides() {
let file = load_string(&scalar_observed_coupled_doc().to_string()).expect("doc loads");
let results = run_inline_tests(&file, Some("M2"), &tight_opts());
let by_id: HashMap<&str, &AssertionResult> =
results.iter().map(|r| (r.test_id.as_str(), r)).collect();
assert_eq!(by_id.len(), 2, "expected the two M2 tests");
let lo = by_id["t_lo"].actual.expect("t_lo actual recorded");
let hi = by_id["t_hi"].actual.expect("t_hi actual recorded");
assert!(
(lo - 50.0).abs() <= 1e-9 * 50.0,
"t_lo actual {lo}, want 50"
);
assert!(
(hi - 100.0).abs() <= 1e-9 * 100.0,
"t_hi actual {hi}, want 100"
);
assert_ne!(lo, hi);
assert!(
results.iter().all(|r| r.passed),
"{:?}",
results
.iter()
.map(|r| (r.test_id.clone(), r.message.clone()))
.collect::<Vec<_>>()
);
}
fn reaction_decay_doc() -> serde_json::Value {
json!({
"esm": "1.1.0",
"metadata": {"name": "inline_test_reaction_system"},
"reaction_systems": {"Decay": {
"species": {
"A": {"units": "mol/mol", "default": 1.0},
"B": {"units": "mol/mol", "default": 0.0},
},
"parameters": {"k": {"units": "1/s", "default": 1.0}},
"reactions": [{
"id": "R1",
"substrates": [{"species": "A", "stoichiometry": 1}],
"products": [{"species": "B", "stoichiometry": 1}],
"rate": "k",
}],
"tests": [{
"id": "decays",
"time_span": {"start": 0.0, "end": 1.0},
"assertions": [
{"variable": "A", "time": 1.0,
"expected": std::f64::consts::E.recip(),
"tolerance": {"rel": 1e-6}},
{"variable": "B", "time": 1.0,
"expected": 1.0 - std::f64::consts::E.recip(),
"tolerance": {"rel": 1e-6}},
],
}],
}},
})
}
#[test]
fn run_inline_tests_covers_reaction_systems() {
let file = load_string(&reaction_decay_doc().to_string()).expect("doc loads");
let results = run_inline_tests(&file, None, &tight_opts());
assert_eq!(results.len(), 2, "{results:?}");
assert!(results.iter().all(|r| r.model == "Decay"));
assert_eq!(
results
.iter()
.map(|r| r.variable.as_str())
.collect::<Vec<_>>(),
vec!["A", "B"]
);
assert!(
results.iter().all(|r| r.passed),
"{:?}",
results
.iter()
.map(|r| (r.variable.clone(), r.message.clone()))
.collect::<Vec<_>>()
);
}
fn ramp_doc(expected: f64, test_overrides: Option<serde_json::Value>) -> serde_json::Value {
let mut test = json!({
"id": "ramp",
"time_span": {"start": 0.0, "end": 1.0},
"assertions": [{"variable": "y", "time": 1.0,
"expected": expected, "tolerance": {"rel": 1e-9}}],
});
if let Some(ov) = test_overrides {
test["parameter_overrides"] = ov;
}
json!({
"esm": "1.1.0",
"metadata": {"name": "ramp"},
"models": {"M": {
"variables": {
"T": {"type": "parameter", "units": "1", "default": 1.0},
"y": {"type": "unknown", "units": "1", "default": 0.0},
},
"equations": [{"lhs": {"op": "D", "args": ["y"], "wrt": "t"}, "rhs": "T"}],
"tests": [test],
}},
})
}
fn write_doc(dir: &Path, name: &str, doc: &serde_json::Value) {
std::fs::write(dir.join(name), doc.to_string()).expect("fixture written");
}
#[test]
fn run_inline_tests_paths_consults_options_for_per_document() {
let dir = tempfile::tempdir().expect("tempdir");
write_doc(dir.path(), "a.esm", &ramp_doc(3.0, None));
write_doc(dir.path(), "b.esm", &ramp_doc(7.0, None));
let options_for = |path: &Path| {
let seed = match path.file_name().and_then(|n| n.to_str()) {
Some("a.esm") => 3.0,
_ => 7.0,
};
InlineTestOptions {
solve: tight_opts(),
parameter_overrides: HashMap::from([("T".to_string(), InlineValue::Scalar(seed))]),
..Default::default()
}
};
let results = run_inline_tests_paths(&[dir.path()], &options_for);
assert_eq!(results.len(), 2, "{results:?}");
assert!((results[0].actual.expect("a actual") - 3.0).abs() <= 1e-9 * 3.0);
assert!((results[1].actual.expect("b actual") - 7.0).abs() <= 1e-9 * 7.0);
assert!(results.iter().all(|r| r.passed), "{results:?}");
let bare = run_inline_tests_paths(&[dir.path()], &|_p| InlineTestOptions {
solve: tight_opts(),
..Default::default()
});
assert!(bare.iter().all(|r| !r.passed), "{bare:?}");
}
#[test]
fn run_inline_tests_paths_seed_yields_to_the_tests_own_override() {
let dir = tempfile::tempdir().expect("tempdir");
write_doc(
dir.path(),
"seeded.esm",
&ramp_doc(5.0, Some(json!({"T": 5.0}))),
);
let results = run_inline_tests_paths(&[dir.path()], &|_p| InlineTestOptions {
solve: tight_opts(),
parameter_overrides: HashMap::from([("T".to_string(), InlineValue::Scalar(99.0))]),
..Default::default()
});
assert_eq!(results.len(), 1, "{results:?}");
let actual = results[0].actual.expect("actual recorded");
assert!(
(actual - 5.0).abs() <= 1e-9 * 5.0,
"actual {actual}, want 5"
);
assert!(results[0].passed, "{}", results[0].message);
}
#[test]
fn run_inline_tests_paths_records_an_unreadable_document_as_a_row() {
let dir = tempfile::tempdir().expect("tempdir");
write_doc(dir.path(), "good.esm", &ramp_doc(1.0, None));
std::fs::write(dir.path().join("bad.esm"), "{ not json").expect("bad fixture");
let results = run_inline_tests_paths(&[dir.path()], &|_p| InlineTestOptions {
solve: tight_opts(),
..Default::default()
});
assert_eq!(results.len(), 2, "{results:?}");
let load_row = results
.iter()
.find(|r| r.test_id == "<load>")
.expect("a <load> row for the unreadable document");
assert!(load_row.model.ends_with("bad.esm"), "{}", load_row.model);
assert!(!load_row.passed);
assert!(
load_row.message.contains("load failed"),
"{}",
load_row.message
);
let good = results
.iter()
.find(|r| r.test_id == "ramp")
.expect("the good document still ran");
assert!(good.passed, "{}", good.message);
}
#[test]
fn run_inline_tests_paths_rows_name_the_document_they_came_from() {
let dir = tempfile::tempdir().expect("tempdir");
write_doc(dir.path(), "a.esm", &ramp_doc(3.0, Some(json!({"T": 3.0}))));
write_doc(dir.path(), "b.esm", &ramp_doc(7.0, Some(json!({"T": 7.0}))));
let opts = |_p: &Path| InlineTestOptions {
solve: tight_opts(),
..Default::default()
};
let results = run_inline_tests_paths(&[dir.path()], &opts);
assert_eq!(results.len(), 2, "{results:?}");
assert!(results.iter().all(|r| r.passed), "{results:?}");
assert_eq!(results[0].model, results[1].model);
assert_eq!(results[0].test_id, results[1].test_id);
let a = &results[0];
let b = &results[1];
assert_eq!(Path::new(&a.file), dir.path().join("a.esm"));
assert_eq!(Path::new(&b.file), dir.path().join("b.esm"));
assert!((a.actual.expect("a actual") - 3.0).abs() <= 1e-9 * 3.0);
assert!((b.actual.expect("b actual") - 7.0).abs() <= 1e-9 * 7.0);
std::fs::write(dir.path().join("broken.esm"), "{ not json").expect("bad fixture");
let mixed = run_inline_tests_paths(&[dir.path()], &opts);
let load_row = mixed
.iter()
.find(|r| r.test_id == "<load>")
.expect("a <load> row for the unreadable document");
assert_eq!(Path::new(&load_row.file), dir.path().join("broken.esm"));
assert_eq!(load_row.model, load_row.file);
assert!(!load_row.passed);
assert!(
mixed
.iter()
.any(|r| Path::new(&r.file) == dir.path().join("a.esm") && r.passed),
"{mixed:?}"
);
}
#[test]
fn an_already_loaded_document_leaves_the_row_file_empty() {
let file = load_string(&decay_doc().to_string()).expect("decay doc loads");
let results = run_inline_tests(&file, None, &tight_opts());
assert!(!results.is_empty());
assert!(results.iter().all(|r| r.file.is_empty()), "{results:?}");
}
#[test]
fn the_last_resort_retry_is_built_once_per_build_key() {
let test = |id: &str, end: f64| {
json!({
"id": id,
"time_span": {"start": 0.0, "end": end},
"tolerance": {"rel": 1e-9, "abs": 1e-11},
"assertions": [
{"variable": "g", "time": 0.0, "coords": {"x": 2},
"expected": 4.0},
{"variable": "g", "time": end / 2.0, "coords": {"x": 2},
"expected": 4.0},
],
})
};
let doc = json!({
"esm": "1.1.0",
"metadata": {"name": "ShapedStatic", "license": "MIT",
"description": "state-free, shaped, time-invariant"},
"index_sets": {"x": {"kind": "interval", "size": 3}},
"models": {"ShapedStatic": {
"variables": {
"a": {"type": "parameter", "units": "1", "default": 2.0},
"g": {"type": "unknown", "units": "1", "shape": ["x"]},
},
"equations": [
{"lhs": "g",
"rhs": {"op": "faq", "args": [], "output_idx": ["i"],
"ranges": {"i": {"from": "x"}},
"expr": {"op": "*", "args": ["a", "i"]}}},
],
"tests": [
test("one", 10.0),
test("two", 10.0),
test("three", 10.0),
test("another_span", 20.0),
],
}},
});
let file = load_string(&doc.to_string()).expect("document loads");
let before = BUILD_PIPELINE_BUILDS.with(std::cell::Cell::get);
let results = run_inline_tests_with_options(
&file,
&InlineTestOptions {
compiler: Some(crate::problem::Compiler::Interpreter),
..Default::default()
},
None,
);
let builds = BUILD_PIPELINE_BUILDS.with(std::cell::Cell::get) - before;
assert_eq!(results.len(), 8, "{results:?}");
for r in &results {
assert!(
r.passed,
"{}: {} (actual {:?})",
r.test_id, r.message, r.actual
);
}
assert_eq!(
builds, 2,
"four tests over two `BuildKey`s must pay two whole-document \
builds: one per key, reused by the tests sharing it"
);
}
fn shaped_static_with_cumulative() -> serde_json::Value {
json!({
"esm": "1.1.0",
"metadata": {"name": "ShapedStaticCumulative", "license": "MIT",
"description": "state-free, shaped, with a running sum"},
"index_sets": {"x": {"kind": "interval", "size": 3}},
"models": {"ShapedStaticCumulative": {
"variables": {
"a": {"type": "parameter", "units": "1", "default": 2.0},
"g": {"type": "unknown", "units": "1", "shape": ["x"]},
"cum": {"type": "unknown", "units": "1", "shape": ["x"]},
},
"equations": [
{"lhs": "g",
"rhs": {"op": "faq", "args": [], "output_idx": ["i"],
"ranges": {"i": {"from": "x"}},
"expr": {"op": "*", "args": ["a", "i"]}}},
{"lhs": "cum",
"rhs": {"op": "faq", "args": [], "output_idx": ["i"],
"ranges": {"i": {"from": "x"}, "j": {"from": "x"}},
"filter": {"op": "<=", "args": ["j", "i"]},
"expr": {"op": "index", "args": ["g", "j"]}}},
],
"tests": [{
"id": "values",
"time_span": {"start": 0.0, "end": 10.0},
"tolerance": {"rel": 1e-9, "abs": 1e-11},
"assertions": [
{"variable": "g", "time": 5.0, "coords": {"x": 2},
"expected": 4.0},
{"variable": "cum", "time": 5.0, "coords": {"x": 3},
"expected": 12.0},
],
}],
}},
})
}
#[test]
fn a_strict_compiler_answers_a_shaped_static_document_from_its_own_model() {
let file = load_string(&shaped_static_with_cumulative().to_string()).expect("loads");
let before = BUILD_PIPELINE_BUILDS.with(std::cell::Cell::get);
let results = run_inline_tests_with_options(
&file,
&InlineTestOptions {
compiler: Some(crate::problem::Compiler::Native),
..Default::default()
},
None,
);
let builds = BUILD_PIPELINE_BUILDS.with(std::cell::Cell::get) - before;
assert_eq!(results.len(), 2, "{results:?}");
for r in &results {
assert!(
r.passed,
"{}: {} (actual {:?})",
r.variable, r.message, r.actual
);
}
assert_eq!(builds, 0, "native must not fall back to a pipeline rebuild");
}
#[test]
fn the_pipeline_retry_is_built_on_the_named_compiler() {
let file = load_string(&shaped_static_with_cumulative().to_string()).expect("loads");
let bindings = |c| Some((HashMap::new(), HashMap::new(), Some(c)));
let interp = build_pipeline_fields(
&file,
(0.0, 10.0),
bindings(crate::problem::Compiler::Interpreter).as_ref(),
);
assert!(
interp
.as_ref()
.is_some_and(|f| f.iter().any(|(k, _)| k == "cum")),
"{interp:?}"
);
let native = build_pipeline_fields(
&file,
(0.0, 10.0),
bindings(crate::problem::Compiler::Native).as_ref(),
);
let bits = |f: &Option<Vec<(String, ndarray::ArrayD<f64>)>>| -> Vec<u64> {
f.as_ref()
.and_then(|f| f.iter().find(|(k, _)| k == "cum"))
.map(|(_, a)| a.iter().map(|x| x.to_bits()).collect())
.unwrap_or_default()
};
assert!(!bits(&native).is_empty(), "{native:?}");
assert_eq!(bits(&native), bits(&interp));
}
}