use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
use crate::load_string;
use crate::simulate_array::ArrayCompiled;
pub const COMPILED_UNAVAILABLE_REASON: &str = concat!(
"built without the `xla` feature: rebuild with ",
"--features conformance-adapters,xla and XLA_EXTENSION_DIR pointing at an ",
"unpacked xla_extension release (scripts/fetch-xla-extension.sh)"
);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Engine {
Interpreter,
Compiled,
}
impl Engine {
pub fn as_str(self) -> &'static str {
match self {
Engine::Interpreter => "interpreter",
Engine::Compiled => "compiled",
}
}
fn parse(s: &str) -> Result<Self, String> {
match s {
"interpreter" => Ok(Engine::Interpreter),
"compiled" => Ok(Engine::Compiled),
other => Err(format!(
"unknown --engine {other:?} (want interpreter|compiled)"
)),
}
}
}
pub struct Args {
pub manifest: PathBuf,
pub output: PathBuf,
pub engine: Engine,
}
pub fn parse_args<I: IntoIterator<Item = String>>(args: I) -> Result<Args, String> {
let mut manifest: Option<PathBuf> = None;
let mut output: Option<PathBuf> = None;
let mut engine = Engine::Interpreter;
let mut it = args.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--manifest" => manifest = it.next().map(PathBuf::from),
"--output" => output = it.next().map(PathBuf::from),
"--engine" => {
let v = it.next().ok_or("--engine needs a value")?;
engine = Engine::parse(&v)?;
}
other => return Err(format!("unexpected argument {other:?}")),
}
}
match (manifest, output) {
(Some(manifest), Some(output)) => Ok(Args {
manifest,
output,
engine,
}),
_ => Err("--manifest and --output are required".to_string()),
}
}
pub struct Report {
pub payload: Value,
pub failed: Vec<String>,
}
fn bare(name: &str) -> &str {
name.split_once('.').map(|x| x.1).unwrap_or(name)
}
fn num(v: f64) -> Value {
if v.is_finite() {
json!(v)
} else if v.is_nan() {
json!("NaN")
} else if v > 0.0 {
json!("Infinity")
} else {
json!("-Infinity")
}
}
pub fn resolve_fixture_path(manifest_dir: &Path, rel: &str) -> Result<PathBuf, String> {
let mut tried: Vec<PathBuf> = Vec::new();
tried.push(manifest_dir.join(rel));
for ancestor in manifest_dir.ancestors() {
if ancestor.file_name().map(|n| n == "tests").unwrap_or(false) {
tried.push(ancestor.join(rel));
}
tried.push(ancestor.join("tests").join(rel));
}
tried.iter().find(|p| p.is_file()).cloned().ok_or_else(|| {
format!(
"fixture path {rel:?} not found; tried {}",
tried
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)
})
}
fn canonical_parameters(
compiled: &ArrayCompiled,
model: &str,
parameters: &Map<String, Value>,
) -> Result<HashMap<String, f64>, String> {
let known: Vec<&str> = compiled
.parameter_names()
.iter()
.map(String::as_str)
.collect();
let mut out = HashMap::new();
for (key, value) in parameters {
let v = value
.as_f64()
.ok_or_else(|| format!("parameter override {key:?} is not a number"))?;
let stripped = key.strip_prefix(&format!("{model}.")).unwrap_or(key);
let resolved = if known.contains(&key.as_str()) {
key.as_str()
} else if known.contains(&stripped) {
stripped
} else {
return Err(format!(
"parameter override {key:?} does not name a parameter of model {model:?} \
(known: {})",
known.join(", ")
));
};
out.insert(resolved.to_string(), v);
}
Ok(out)
}
fn state_vec(names: &[String], state: &Map<String, Value>) -> Result<Vec<f64>, String> {
names
.iter()
.map(|n| {
state
.get(n)
.or_else(|| state.get(bare(n)))
.and_then(Value::as_f64)
.ok_or_else(|| format!("probe state is missing element {:?}", bare(n)))
})
.collect()
}
#[cfg(feature = "xla")]
fn compiled_unavailable_reason() -> Option<String> {
crate::xla_runtime::client()
.err()
.map(|e| format!("XLA runtime is not usable on this machine: {e}"))
}
#[cfg(not(feature = "xla"))]
fn compiled_unavailable_reason() -> Option<String> {
Some(COMPILED_UNAVAILABLE_REASON.to_string())
}
#[cfg(feature = "xla")]
fn eval_compiled(
compiled: &ArrayCompiled,
fx: &Value,
names: &[String],
params: &HashMap<String, f64>,
) -> Result<Value, String> {
use crate::xla_runtime::{CompileRhsError, CompiledRhs};
let program = match CompiledRhs::compile(compiled) {
Ok(p) => p,
Err(CompileRhsError::Refused(e)) => {
return Ok(json!({
"status": "refused",
"rule": e.rule,
"reason": e.reason,
}));
}
Err(CompileRhsError::Runtime(m)) => return Err(format!("xla: {m}")),
};
let param_vec = compiled.debug_resolve_params(params);
let mut rhs = Map::new();
for probe in fx["rhs_probes"].as_array().ok_or("rhs_probes not array")? {
let pid = probe["id"].as_str().ok_or("probe.id missing")?;
let state_obj = probe["state"].as_object().ok_or("probe.state missing")?;
let t = probe["t"].as_f64().unwrap_or(0.0);
let sv = state_vec(names, state_obj).map_err(|e| format!("probe {pid}: {e}"))?;
let du = program
.eval(&sv, ¶m_vec, t)
.map_err(|e| format!("probe {pid}: {e}"))?;
let mut m = Map::new();
for (i, n) in names.iter().enumerate() {
m.insert(bare(n).to_string(), num(du[i]));
}
rhs.insert(pid.to_string(), Value::Object(m));
}
Ok(json!({ "rhs": Value::Object(rhs) }))
}
#[cfg(not(feature = "xla"))]
fn eval_compiled(
_compiled: &ArrayCompiled,
_fx: &Value,
_names: &[String],
_params: &HashMap<String, f64>,
) -> Result<Value, String> {
Err(COMPILED_UNAVAILABLE_REASON.to_string())
}
fn run_fixture(fx: &Value, manifest_dir: &Path, engine: Engine) -> Result<Value, String> {
let rel = fx["path"].as_str().ok_or("fixture.path missing")?;
let path = resolve_fixture_path(manifest_dir, rel)?;
let json_str = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
let file = load_string(&json_str).map_err(|e| format!("load: {e:?}"))?;
if let Some(models) = &file.models
&& let Some(want) = fx["model"].as_str()
&& !models.contains_key(want)
{
return Err(format!(
"manifest model {want:?} is not in {}; document declares {}",
path.display(),
models.keys().cloned().collect::<Vec<_>>().join(", ")
));
}
let compiled = ArrayCompiled::from_file(&file).map_err(|e| format!("compile: {e:?}"))?;
let params = match fx.get("parameters").and_then(Value::as_object) {
Some(p) if !p.is_empty() => {
canonical_parameters(&compiled, fx["model"].as_str().unwrap_or_default(), p)?
}
_ => HashMap::new(),
};
let names: Vec<String> = compiled.state_variable_names().to_vec();
if engine == Engine::Compiled {
return eval_compiled(&compiled, fx, &names, ¶ms);
}
let mut rhs = Map::new();
for probe in fx["rhs_probes"].as_array().ok_or("rhs_probes not array")? {
let pid = probe["id"].as_str().ok_or("probe.id missing")?;
let state_obj = probe["state"].as_object().ok_or("probe.state missing")?;
let t = probe["t"].as_f64().unwrap_or(0.0);
let sv = state_vec(&names, state_obj).map_err(|e| format!("probe {pid}: {e}"))?;
let (dy, _stats) = compiled.debug_eval_rhs(&sv, t, ¶ms, false);
let mut m = Map::new();
for (i, n) in names.iter().enumerate() {
m.insert(bare(n).to_string(), num(dy[i]));
}
rhs.insert(pid.to_string(), Value::Object(m));
}
Ok(json!({ "rhs": Value::Object(rhs) }))
}
pub fn run_manifest(manifest_path: &Path, engine: Engine) -> Result<Report, String> {
if engine == Engine::Compiled
&& let Some(reason) = compiled_unavailable_reason()
{
return Ok(Report {
payload: json!({
"binding": "rust",
"engine": engine.as_str(),
"status": "unavailable",
"reason": reason,
}),
failed: Vec::new(),
});
}
let text = fs::read_to_string(manifest_path)
.map_err(|e| format!("failed to read manifest {}: {e}", manifest_path.display()))?;
let manifest: Value =
serde_json::from_str(&text).map_err(|e| format!("failed to parse manifest: {e}"))?;
let abs = std::path::absolute(manifest_path).unwrap_or_else(|_| manifest_path.to_path_buf());
let manifest_dir = abs.parent().unwrap_or(Path::new(".")).to_path_buf();
let empty: Vec<Value> = Vec::new();
let mut fixtures = Map::new();
let mut failed = Vec::new();
for fx in manifest["fixtures"].as_array().unwrap_or(&empty) {
let id = fx["id"].as_str().unwrap_or("<unknown>").to_string();
let entry = match run_fixture(fx, &manifest_dir, engine) {
Ok(v) => v,
Err(e) => {
eprintln!("fixture {id}: {e}");
failed.push(id.clone());
json!({ "error": e })
}
};
fixtures.insert(id, entry);
}
Ok(Report {
payload: json!({
"binding": "rust",
"engine": engine.as_str(),
"fixtures": Value::Object(fixtures),
}),
failed,
})
}