use clap::{Parser, Subcommand, ValueEnum};
use earthsci_ast::extension::analysis::{
collect_unit_types, collect_variables, contains_common_subexpressions,
contains_expensive_operations, contains_redundant_operations, count_expression_nodes,
count_numerical_values, count_operations, expression_depth, expressions_numerically_equal,
find_longest_dependency_chain, find_strongly_connected_components,
};
use earthsci_ast::{
EquationDimensionVerdict, ExpressionGraphOptions, StructuralErrorCode, Unit, UnitError,
component_exists, component_graph, equation_dimension_verdict, expression_graph,
expression_graph_with_options, model_unit_env, parse_unit, stoichiometric_matrix, to_json,
to_json_compact, validate, validate_text,
};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "esm")]
#[command(about = "A CLI tool for EarthSciML Abstract Syntax Tree Format")]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Validate {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long)]
verbose: bool,
},
#[command(visible_alias = "display")]
Pretty {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(short, long, default_value = "unicode")]
format: DisplayFormat,
},
Extract {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long)]
component: String,
#[arg(short, long)]
output: Option<PathBuf>,
},
Diff {
#[arg(value_name = "FILE1")]
file1: PathBuf,
#[arg(value_name = "FILE2")]
file2: PathBuf,
},
Stoich {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long)]
system: String,
},
Graph {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long, default_value = "component")]
level: GraphLevel,
#[arg(short, long, default_value = "dot")]
format: GraphFormat,
#[arg(long)]
system: Option<String>,
},
Convert {
#[arg(value_name = "INPUT")]
input: PathBuf,
#[arg(short, long, value_name = "OUTPUT")]
output: Option<PathBuf>,
#[arg(long, default_value = "json")]
to: ConvertFormat,
},
Analyze {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long, default_value = "all")]
analysis_type: AnalysisType,
},
Simulate {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long, default_value = "10.0")]
time: f64,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long = "observed", value_name = "NAME")]
observed: Vec<String>,
#[arg(long = "model", value_name = "NAME")]
model: Option<String>,
#[arg(long, default_value = "flat")]
format: SimulateFormat,
#[arg(long, value_name = "NAME")]
compiler: Option<String>,
},
Info {
#[arg(value_name = "FILE")]
file: PathBuf,
},
Units {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long)]
check: bool,
},
CouplingAnalysis {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long, default_value = "shallow")]
depth: CouplingDepth,
},
PerformanceProfile {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long, default_value = "both")]
profile_type: ProfileType,
},
Compare {
#[arg(value_name = "FILE1")]
file1: PathBuf,
#[arg(value_name = "FILE2")]
file2: PathBuf,
#[arg(long, default_value = "semantic")]
comparison_type: ComparisonType,
},
Optimize {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long, default_value = "expression")]
opt_type: OptimizeTarget,
},
Init {
#[arg(value_name = "NAME")]
name: String,
#[arg(long, default_value = "minimal")]
template: InitTemplate,
},
ValidateFixtures {
#[arg(value_name = "DIRECTORY")]
directory: PathBuf,
#[arg(short, long)]
recursive: bool,
},
Benchmark {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long, default_value = "all")]
bench_type: BenchType,
#[arg(long, default_value = "100")]
iterations: usize,
},
SchemaCheck {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long)]
schema_version: Option<String>,
},
RoundTrip {
#[arg(value_name = "FILE")]
file: PathBuf,
#[arg(long, default_value = "1")]
rounds: usize,
},
#[command(visible_alias = "run-tests")]
Test {
#[arg(value_name = "PATH", required = true)]
paths: Vec<PathBuf>,
#[arg(long, value_name = "NAME")]
model: Option<String>,
#[arg(long, value_name = "SUBSTRING")]
filter: Option<String>,
#[arg(long, default_value = "bdf")]
solver: SolverAlg,
#[arg(long)]
reltol: Option<f64>,
#[arg(long)]
abstol: Option<f64>,
#[arg(short, long)]
verbose: bool,
#[arg(long, value_name = "NAME")]
compiler: Option<String>,
},
ConformanceTest {
#[arg(value_name = "OUT_DIR")]
out_dir: PathBuf,
#[arg(value_name = "MANIFEST")]
manifest: Option<PathBuf>,
},
}
#[derive(Clone, Copy, ValueEnum)]
enum GraphLevel {
Component,
Expression,
}
#[derive(Clone, Copy, ValueEnum)]
enum GraphFormat {
Dot,
Mermaid,
Json,
}
#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
enum SimulateFormat {
Flat,
Grid,
Csv,
}
#[derive(Clone, Copy, ValueEnum)]
enum ConvertFormat {
Json,
CompactJson,
}
#[derive(Clone, Copy, ValueEnum)]
enum AnalysisType {
All,
Structure,
Complexity,
Coupling,
}
#[derive(Clone, Copy, ValueEnum)]
enum CouplingDepth {
Shallow,
Deep,
}
#[derive(Clone, Copy, ValueEnum)]
enum ProfileType {
Memory,
Time,
Both,
}
#[derive(Clone, Copy, ValueEnum)]
enum ComparisonType {
Semantic,
Structural,
Numerical,
}
#[derive(Clone, Copy, ValueEnum)]
enum OptimizeTarget {
Expression,
Structure,
Performance,
}
#[derive(Clone, Copy, ValueEnum)]
enum InitTemplate {
Minimal,
Atmospheric,
Ecosystem,
Coupling,
}
#[derive(Clone, Copy, ValueEnum)]
enum BenchType {
All,
Parse,
Validate,
Simulate,
}
#[derive(Clone, Copy, ValueEnum)]
enum SolverAlg {
Bdf,
Sdirk,
Erk,
}
impl From<SolverAlg> for earthsci_ast::Alg {
fn from(value: SolverAlg) -> Self {
match value {
SolverAlg::Bdf => earthsci_ast::Alg::Bdf,
SolverAlg::Sdirk => earthsci_ast::Alg::Sdirk,
SolverAlg::Erk => earthsci_ast::Alg::Erk,
}
}
}
fn arg_name<T: ValueEnum>(value: &T) -> String {
value
.to_possible_value()
.expect("no skipped variants")
.get_name()
.to_string()
}
#[derive(Debug)]
struct CliFailure {
message: Option<String>,
}
impl std::fmt::Display for CliFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.message.as_deref().unwrap_or("command failed"))
}
}
impl std::error::Error for CliFailure {}
fn fail(message: impl Into<String>) -> Box<dyn std::error::Error> {
Box::new(CliFailure {
message: Some(message.into()),
})
}
fn fail_silent() -> Box<dyn std::error::Error> {
Box::new(CliFailure { message: None })
}
fn analyze_model_units(esm_file: &earthsci_ast::EsmFile) {
if let Some(ref models) = esm_file.models {
for (model_id, model) in models {
println!("Model: {model_id}");
let mut unit_count = 0;
for (var_name, var) in &model.variables {
if let Some(ref units) = var.units {
println!(" Variable {var_name}: {var_name} [{units}]");
unit_count += 1;
} else {
println!(" Variable {var_name}: {var_name} [no units specified]");
}
}
println!(
" {} out of {} variables have units specified",
unit_count,
model.variables.len()
);
for (i, equation, verdict) in model_equation_verdicts(esm_file, model) {
let label = match equation_lhs_label(&equation.lhs) {
Some(lhs) => format!("Equation {} ({lhs})", i + 1),
None => format!("Equation {}", i + 1),
};
match verdict {
EquationDimensionVerdict::Consistent(dim) => {
println!(" {label}: consistent [{dim}]");
}
EquationDimensionVerdict::Mismatch(messages) => {
println!(" {label}: MISMATCH — {}", messages.join("; "));
}
EquationDimensionVerdict::NotChecked(reason) => {
println!(" {label}: NOT CHECKED — {reason}");
}
}
}
}
}
}
fn model_equation_verdicts<'m>(
esm_file: &earthsci_ast::EsmFile,
model: &'m earthsci_ast::Model,
) -> Vec<(usize, &'m earthsci_ast::Equation, EquationDimensionVerdict)> {
let (env, _) = model_unit_env(esm_file, model);
model
.equations
.iter()
.enumerate()
.map(|(i, eq)| (i, eq, equation_dimension_verdict(eq, &env)))
.collect()
}
fn equation_lhs_label(lhs: &earthsci_ast::Expr) -> Option<String> {
match lhs {
earthsci_ast::Expr::Variable(name) => Some(name.clone()),
earthsci_ast::Expr::Operator(op) if op.op == "D" => match op.args.first() {
Some(earthsci_ast::Expr::Variable(name)) => Some(format!("D({name})")),
_ => None,
},
_ => None,
}
}
fn analyze_reaction_system_units(
esm_file: &earthsci_ast::EsmFile,
validation: &earthsci_ast::ValidationResult,
) {
if let Some(ref reaction_systems) = esm_file.reaction_systems {
for (rs_id, rs) in reaction_systems {
println!("Reaction System: {rs_id}");
let mut species_with_units = 0;
for (species_name, species) in &rs.species {
if let Some(ref units) = species.units {
println!(" Species {species_name}: [{units}]");
species_with_units += 1;
} else {
println!(" Species {species_name}: [no units]");
}
}
println!(
" {} out of {} species have units specified",
species_with_units,
rs.species.len()
);
for (i, verdict) in reaction_rate_verdicts(rs_id, rs, validation) {
match verdict {
RateVerdict::Consistent => println!(
" Reaction {} rate: no stoichiometric mismatch found",
i + 1
),
RateVerdict::Mismatch(message) => {
println!(" Reaction {} rate: MISMATCH — {message}", i + 1);
}
RateVerdict::NotChecked(reason) => {
println!(" Reaction {} rate: NOT CHECKED — {reason}", i + 1);
}
}
}
}
}
}
enum RateVerdict {
Consistent,
Mismatch(String),
NotChecked(String),
}
fn reaction_rate_verdicts(
rs_id: &str,
rs: &earthsci_ast::ReactionSystem,
validation: &earthsci_ast::ValidationResult,
) -> Vec<(usize, RateVerdict)> {
let env: HashMap<String, Unit> = rs
.species
.iter()
.map(|(name, s)| (name, &s.units))
.chain(rs.parameters.iter().map(|(name, p)| (name, &p.units)))
.filter_map(|(name, units)| {
let unit = parse_unit(units.as_deref()?).ok()?;
Some((name.clone(), unit))
})
.collect();
rs.reactions
.iter()
.enumerate()
.map(|(i, reaction)| {
let path = format!("/reaction_systems/{rs_id}/reactions/{i}");
let mismatches: Vec<&str> = validation
.structural_errors
.iter()
.filter(|e| {
e.path == path && matches!(e.code, StructuralErrorCode::UnitInconsistency)
})
.map(|e| e.message.as_str())
.collect();
if !mismatches.is_empty() {
return (i, RateVerdict::Mismatch(mismatches.join("; ")));
}
match Unit::propagate(&reaction.rate, &env) {
Ok(_) => (i, RateVerdict::Consistent),
Err(UnitError::DimensionMismatch(message)) => (i, RateVerdict::Mismatch(message)),
Err(e) => (i, RateVerdict::NotChecked(e.to_string())),
}
})
.collect()
}
fn analyze_expression_optimization(esm_file: &earthsci_ast::EsmFile) {
println!("\n=== EXPRESSION OPTIMIZATION ANALYSIS ===");
let mut suggestions: Vec<String> = Vec::new();
let mut complex_expressions = 0;
let mut redundant_operations = 0;
if let Some(ref models) = esm_file.models {
for (model_id, model) in models {
println!("Model: {model_id}");
for (i, equation) in model.equations.iter().enumerate() {
let lhs_depth = expression_depth(&equation.lhs);
let rhs_depth = expression_depth(&equation.rhs);
let max_depth = lhs_depth.max(rhs_depth);
if max_depth > 5 {
complex_expressions += 1;
suggestions.push(format!(
" Eq {}: High complexity (depth {}), consider factoring",
i + 1,
max_depth
));
}
if contains_redundant_operations(&equation.rhs) {
redundant_operations += 1;
suggestions.push(format!(" Eq {}: Contains redundant operations", i + 1));
}
if contains_common_subexpressions(&equation.lhs, &equation.rhs) {
suggestions.push(format!(" Eq {}: Common subexpressions found", i + 1));
}
}
}
}
if let Some(ref reaction_systems) = esm_file.reaction_systems {
for (rs_id, rs) in reaction_systems {
println!("Reaction System: {rs_id}");
for (i, reaction) in rs.reactions.iter().enumerate() {
let rate_depth = expression_depth(&reaction.rate);
if rate_depth > 4 {
complex_expressions += 1;
suggestions.push(format!(
" Reaction {}: Complex rate expression (depth {})",
i + 1,
rate_depth
));
}
if contains_redundant_operations(&reaction.rate) {
redundant_operations += 1;
suggestions.push(format!(
" Reaction {}: Rate contains redundant operations",
i + 1
));
}
}
}
}
println!("\n=== OPTIMIZATION SUGGESTIONS ===");
if suggestions.is_empty() {
println!("✓ No obvious expression optimizations needed");
} else {
println!(
"Found {complex_expressions} complex expressions, {redundant_operations} redundant operations"
);
for suggestion in suggestions {
println!("{suggestion}");
}
}
println!("\n=== GENERAL RECOMMENDATIONS ===");
println!("- Factor out common subexpressions into intermediate variables");
println!("- Use pre-computed constants where possible");
println!("- Consider using operator precedence to reduce parentheses");
}
fn analyze_structure_optimization(esm_file: &earthsci_ast::EsmFile) {
println!("\n=== STRUCTURE OPTIMIZATION ANALYSIS ===");
let model_count = esm_file.models.as_ref().map(|m| m.len()).unwrap_or(0);
let rs_count = esm_file
.reaction_systems
.as_ref()
.map(|rs| rs.len())
.unwrap_or(0);
let coupling_count = esm_file.coupling.as_ref().map(|c| c.len()).unwrap_or(0);
println!(
"Structure: {model_count} models, {rs_count} reaction systems, {coupling_count} coupling rules"
);
let mut suggestions: Vec<String> = Vec::new();
if coupling_count > 10 {
suggestions
.push("High coupling complexity - consider grouping related couplings".to_string());
}
if let Some(ref models) = esm_file.models {
for (model_id, model) in models {
let var_count = model.variables.len();
let eq_count = model.equations.len();
if var_count > 50 {
suggestions.push(format!(
"Model '{model_id}' has {var_count} variables - consider splitting into smaller components"
));
}
if eq_count > 30 {
suggestions.push(format!(
"Model '{model_id}' has {eq_count} equations - consider modular decomposition"
));
}
let mut used_vars = std::collections::HashSet::new();
for equation in &model.equations {
collect_variables(&equation.lhs, &mut used_vars);
collect_variables(&equation.rhs, &mut used_vars);
}
let unused_count = model
.variables
.keys()
.filter(|&v| !used_vars.contains(v))
.count();
if unused_count > 0 {
suggestions.push(format!(
"Model '{model_id}' has {unused_count} potentially unused variables"
));
}
}
}
if let Some(ref reaction_systems) = esm_file.reaction_systems {
for (rs_id, rs) in reaction_systems {
if rs.species.len() > 100 {
suggestions.push(format!(
"Reaction system '{}' has {} species - consider chemical family grouping",
rs_id,
rs.species.len()
));
}
if rs.reactions.len() > 200 {
suggestions.push(format!(
"Reaction system '{}' has {} reactions - consider mechanism reduction",
rs_id,
rs.reactions.len()
));
}
}
}
if suggestions.is_empty() {
println!("✓ Structure appears well-organized");
} else {
println!("\n=== OPTIMIZATION SUGGESTIONS ===");
for suggestion in suggestions {
println!(" - {suggestion}");
}
}
println!("\n=== GENERAL RECOMMENDATIONS ===");
println!("- Keep models focused on single domains/processes");
println!("- Use hierarchical naming for related variables");
println!("- Group related coupling operations together");
}
fn analyze_performance_optimization(esm_file: &earthsci_ast::EsmFile) {
println!("\n=== PERFORMANCE OPTIMIZATION ANALYSIS ===");
let mut total_ops = 0;
let mut expensive_ops = 0;
if let Some(ref models) = esm_file.models {
for (model_id, model) in models {
println!("Model: {model_id}");
let mut model_ops = 0;
for equation in &model.equations {
let lhs_ops = count_operations(&equation.lhs);
let rhs_ops = count_operations(&equation.rhs);
model_ops += lhs_ops + rhs_ops;
if contains_expensive_operations(&equation.rhs) {
expensive_ops += 1;
}
}
total_ops += model_ops;
println!(" Operations per timestep: ~{model_ops}");
}
}
if let Some(ref reaction_systems) = esm_file.reaction_systems {
for (rs_id, rs) in reaction_systems {
println!("Reaction System: {rs_id}");
let mut rs_ops = 0;
for reaction in &rs.reactions {
let rate_ops = count_operations(&reaction.rate);
rs_ops += rate_ops;
if contains_expensive_operations(&reaction.rate) {
expensive_ops += 1;
}
}
rs_ops += rs.reactions.len() * rs.species.len();
total_ops += rs_ops;
println!(" Operations per timestep: ~{rs_ops}");
}
}
println!("\nTotal operations per timestep: ~{total_ops}");
println!("Expensive operations found: {expensive_ops}");
println!("\n=== OPTIMIZATION SUGGESTIONS ===");
if total_ops > 10000 {
println!(" - High computational load - consider numerical method optimization");
}
if expensive_ops > 5 {
println!(
" - {expensive_ops} expensive operations found - consider lookup tables or approximations"
);
}
let var_count = esm_file
.models
.as_ref()
.map(|models| models.values().map(|m| m.variables.len()).sum::<usize>())
.unwrap_or(0);
let species_count = esm_file
.reaction_systems
.as_ref()
.map(|rs_map| rs_map.values().map(|rs| rs.species.len()).sum::<usize>())
.unwrap_or(0);
let total_state_vars = var_count + species_count;
let estimated_bytes = total_state_vars * 8; println!(
"State variables: {total_state_vars} (estimated memory: {estimated_bytes} bytes / {:.1} KiB)",
estimated_bytes as f64 / 1024.0
);
println!("\n=== GENERAL RECOMMENDATIONS ===");
println!("- Use fast numerical integrators for stiff systems");
println!("- Cache frequently computed expressions");
println!("- Consider sparse matrix representations for large systems");
println!("- Profile actual runtime to identify bottlenecks");
}
fn perform_structural_comparison(
esm_file1: &earthsci_ast::EsmFile,
esm_file2: &earthsci_ast::EsmFile,
) {
println!("\n=== STRUCTURAL COMPARISON ===");
let mut differences = Vec::new();
if esm_file1.esm != esm_file2.esm {
differences.push(format!(
"ESM version: {} vs {}",
esm_file1.esm, esm_file2.esm
));
}
let models1_count = esm_file1.models.as_ref().map(|m| m.len()).unwrap_or(0);
let models2_count = esm_file2.models.as_ref().map(|m| m.len()).unwrap_or(0);
if models1_count != models2_count {
differences.push(format!("Model count: {models1_count} vs {models2_count}"));
}
let rs1_count = esm_file1
.reaction_systems
.as_ref()
.map(|rs| rs.len())
.unwrap_or(0);
let rs2_count = esm_file2
.reaction_systems
.as_ref()
.map(|rs| rs.len())
.unwrap_or(0);
if rs1_count != rs2_count {
differences.push(format!("Reaction system count: {rs1_count} vs {rs2_count}"));
}
let coupling1_count = esm_file1.coupling.as_ref().map(|c| c.len()).unwrap_or(0);
let coupling2_count = esm_file2.coupling.as_ref().map(|c| c.len()).unwrap_or(0);
if coupling1_count != coupling2_count {
differences.push(format!(
"Coupling rule count: {coupling1_count} vs {coupling2_count}"
));
}
match (&esm_file1.models, &esm_file2.models) {
(Some(models1), Some(models2)) => {
let keys1: std::collections::HashSet<_> = models1.keys().collect();
let keys2: std::collections::HashSet<_> = models2.keys().collect();
let only_in_1: Vec<_> = keys1.difference(&keys2).collect();
let only_in_2: Vec<_> = keys2.difference(&keys1).collect();
if !only_in_1.is_empty() {
differences.push(format!("Models only in file1: {only_in_1:?}"));
}
if !only_in_2.is_empty() {
differences.push(format!("Models only in file2: {only_in_2:?}"));
}
for model_id in keys1.intersection(&keys2) {
let model1 = &models1[*model_id];
let model2 = &models2[*model_id];
if model1.variables.len() != model2.variables.len() {
differences.push(format!(
"Model {}: variable count {} vs {}",
model_id,
model1.variables.len(),
model2.variables.len()
));
}
if model1.equations.len() != model2.equations.len() {
differences.push(format!(
"Model {}: equation count {} vs {}",
model_id,
model1.equations.len(),
model2.equations.len()
));
}
let vars1: std::collections::HashSet<_> = model1.variables.keys().collect();
let vars2: std::collections::HashSet<_> = model2.variables.keys().collect();
if vars1 != vars2 {
differences.push(format!("Model {model_id}: different variable sets"));
}
}
}
(Some(_), None) => differences.push("File1 has models, file2 does not".to_string()),
(None, Some(_)) => differences.push("File2 has models, file1 does not".to_string()),
(None, None) => {}
}
match (&esm_file1.reaction_systems, &esm_file2.reaction_systems) {
(Some(rs1), Some(rs2)) => {
let keys1: std::collections::HashSet<_> = rs1.keys().collect();
let keys2: std::collections::HashSet<_> = rs2.keys().collect();
let only_in_1: Vec<_> = keys1.difference(&keys2).collect();
let only_in_2: Vec<_> = keys2.difference(&keys1).collect();
if !only_in_1.is_empty() {
differences.push(format!("Reaction systems only in file1: {only_in_1:?}"));
}
if !only_in_2.is_empty() {
differences.push(format!("Reaction systems only in file2: {only_in_2:?}"));
}
for rs_id in keys1.intersection(&keys2) {
let system1 = &rs1[*rs_id];
let system2 = &rs2[*rs_id];
if system1.species.len() != system2.species.len() {
differences.push(format!(
"Reaction system {}: species count {} vs {}",
rs_id,
system1.species.len(),
system2.species.len()
));
}
if system1.reactions.len() != system2.reactions.len() {
differences.push(format!(
"Reaction system {}: reaction count {} vs {}",
rs_id,
system1.reactions.len(),
system2.reactions.len()
));
}
}
}
(Some(_), None) => {
differences.push("File1 has reaction systems, file2 does not".to_string())
}
(None, Some(_)) => {
differences.push("File2 has reaction systems, file1 does not".to_string())
}
(None, None) => {}
}
if differences.is_empty() {
println!("✓ Files have identical structure");
} else {
println!("✗ Structural differences found:");
for diff in differences {
println!(" - {diff}");
}
}
}
fn inline_value_difference(
a: &earthsci_ast::InlineValue,
b: &earthsci_ast::InlineValue,
tolerance: f64,
) -> Option<String> {
match (a.as_scalar(), b.as_scalar()) {
(Some(x), Some(y)) => ((x - y).abs() > tolerance).then(|| format!("{x} vs {y}")),
(None, None) => {
let (sa, va) = match a.to_dense() {
Ok(v) => v,
Err(e) => return Some(format!("unreadable array data ({e})")),
};
let (sb, vb) = match b.to_dense() {
Ok(v) => v,
Err(e) => return Some(format!("unreadable array data ({e})")),
};
if sa != sb {
return Some(format!("array shape {sa:?} vs {sb:?}"));
}
va.iter()
.zip(vb.iter())
.position(|(x, y)| (x - y).abs() > tolerance)
.map(|i| format!("array element {i}: {} vs {}", va[i], vb[i]))
}
_ => Some("scalar vs array data".to_string()),
}
}
fn perform_numerical_comparison(
esm_file1: &earthsci_ast::EsmFile,
esm_file2: &earthsci_ast::EsmFile,
) {
println!("\n=== NUMERICAL COMPARISON ===");
let tolerance = 1e-10;
let mut differences = Vec::new();
if let (Some(models1), Some(models2)) = (&esm_file1.models, &esm_file2.models) {
for model_id in models1.keys() {
if let Some(model2) = models2.get(model_id) {
let model1 = &models1[model_id];
for (var_name, var1) in &model1.variables {
if let Some(var2) = model2.variables.get(var_name)
&& let (Some(default1), Some(default2)) =
(var1.default.as_ref(), var2.default.as_ref())
&& let Some(detail) = inline_value_difference(default1, default2, tolerance)
{
differences.push(format!(
"Model {model_id}, variable {var_name}: default {detail}"
));
}
}
if model1.equations.len() == model2.equations.len() {
for (i, (eq1, eq2)) in model1
.equations
.iter()
.zip(model2.equations.iter())
.enumerate()
{
if !expressions_numerically_equal(&eq1.lhs, &eq2.lhs, tolerance) {
differences.push(format!(
"Model {}, equation {} LHS: numerical difference",
model_id,
i + 1
));
}
if !expressions_numerically_equal(&eq1.rhs, &eq2.rhs, tolerance) {
differences.push(format!(
"Model {}, equation {} RHS: numerical difference",
model_id,
i + 1
));
}
}
}
}
}
}
if let (Some(rs1), Some(rs2)) = (&esm_file1.reaction_systems, &esm_file2.reaction_systems) {
for rs_id in rs1.keys() {
if let Some(system2) = rs2.get(rs_id) {
let system1 = &rs1[rs_id];
if system1.reactions.len() == system2.reactions.len() {
for (i, (reaction1, reaction2)) in system1
.reactions
.iter()
.zip(system2.reactions.iter())
.enumerate()
{
let subs1: &[_] = reaction1.substrates.as_deref().unwrap_or(&[]);
let subs2: &[_] = reaction2.substrates.as_deref().unwrap_or(&[]);
if subs1.len() == subs2.len() {
for (sub1, sub2) in subs1.iter().zip(subs2.iter()) {
if sub1.coefficient != sub2.coefficient {
differences.push(format!("Reaction system {}, reaction {}: substrate coefficient {} vs {}",
rs_id, i + 1, sub1.coefficient, sub2.coefficient));
}
}
}
let prods1: &[_] = reaction1.products.as_deref().unwrap_or(&[]);
let prods2: &[_] = reaction2.products.as_deref().unwrap_or(&[]);
if prods1.len() == prods2.len() {
for (prod1, prod2) in prods1.iter().zip(prods2.iter()) {
if prod1.coefficient != prod2.coefficient {
differences.push(format!("Reaction system {}, reaction {}: product coefficient {} vs {}",
rs_id, i + 1, prod1.coefficient, prod2.coefficient));
}
}
}
if !expressions_numerically_equal(
&reaction1.rate,
&reaction2.rate,
tolerance,
) {
differences.push(format!("Reaction system {}, reaction {}: rate expression numerical difference",
rs_id, i + 1));
}
}
}
}
}
}
if differences.is_empty() {
println!("✓ Files are numerically equivalent (tolerance: {tolerance})");
} else {
println!("✗ Numerical differences found (tolerance: {tolerance}):");
for diff in differences {
println!(" - {diff}");
}
}
let mut total_numbers1 = 0;
let mut total_numbers2 = 0;
count_numerical_values(esm_file1, &mut total_numbers1);
count_numerical_values(esm_file2, &mut total_numbers2);
println!("\nNumerical values compared: {total_numbers1} vs {total_numbers2}");
}
fn perform_deep_coupling_analysis(esm_file: &earthsci_ast::EsmFile) {
println!("\n=== DEEP COUPLING DEPENDENCY ANALYSIS ===");
let graph = component_graph(esm_file);
println!(
"Analyzing {} components with {} coupling relationships",
graph.nodes.len(),
graph.edges.len()
);
let mut dependencies = std::collections::HashMap::new();
let mut dependents = std::collections::HashMap::new();
for edge in &graph.edges {
dependencies
.entry(edge.to.clone())
.or_insert_with(Vec::new)
.push(edge.from.clone());
dependents
.entry(edge.from.clone())
.or_insert_with(Vec::new)
.push(edge.to.clone());
}
analyze_coupling_patterns(&graph);
let sccs = find_strongly_connected_components(&graph);
let has_self_edge = |id: &str| graph.edges.iter().any(|e| e.from == id && e.to == id);
let cycles: Vec<&Vec<String>> = sccs
.iter()
.filter(|c| c.len() > 1 || (c.len() == 1 && has_self_edge(&c[0])))
.collect();
if cycles.is_empty() {
println!("\n✓ No circular dependencies found");
} else {
println!("\n=== CIRCULAR DEPENDENCIES FOUND ===");
for (i, component) in cycles.iter().enumerate() {
println!("Circular dependency group {}: {:?}", i + 1, component);
}
}
analyze_coupling_strength(&graph, esm_file);
detect_coupling_antipatterns(&graph, esm_file);
println!("\n=== COUPLING RECOMMENDATIONS ===");
if graph.edges.len() > graph.nodes.len() * 2 {
println!(
"- High coupling density ({} edges, {} nodes) - consider reducing interdependencies",
graph.edges.len(),
graph.nodes.len()
);
}
let mut connection_counts = std::collections::HashMap::new();
for edge in &graph.edges {
*connection_counts.entry(&edge.from).or_insert(0) += 1;
*connection_counts.entry(&edge.to).or_insert(0) += 1;
}
let max_connections = connection_counts.values().max().unwrap_or(&0);
if *max_connections > 5 {
println!(
"- Hub components detected (max {max_connections} connections) - consider architectural refactoring"
);
for (component, count) in &connection_counts {
if *count > 5 {
println!(" High connectivity: {component} ({count} connections)");
}
}
}
let mut coupling_types = std::collections::HashMap::new();
for edge in &graph.edges {
*coupling_types.entry(&edge.coupling_type).or_insert(0) += 1;
}
println!("\n=== COUPLING TYPE ANALYSIS ===");
for (coupling_type, count) in coupling_types {
println!(" {coupling_type}: {count} occurrences");
}
let n_nodes = graph.nodes.len();
if n_nodes < 2 {
println!("\nCoupling density: N/A (fewer than two components)");
} else {
let coupling_density = graph.edges.len() as f64 / (n_nodes as f64 * (n_nodes - 1) as f64);
println!("\nCoupling density: {coupling_density:.3} (0=no coupling, 1=fully coupled)");
if coupling_density > 0.5 {
println!("⚠ Very high coupling density - system may be difficult to maintain");
} else if coupling_density > 0.2 {
println!("- Moderate coupling density - monitor for growth");
} else {
println!("✓ Low coupling density - good architectural separation");
}
}
}
fn analyze_coupling_patterns(graph: &earthsci_ast::ComponentGraph) {
println!("\n=== COUPLING PATTERNS ===");
let mut out_degree = std::collections::HashMap::new();
let mut in_degree = std::collections::HashMap::new();
for edge in &graph.edges {
*out_degree.entry(&edge.from).or_insert(0) += 1;
*in_degree.entry(&edge.to).or_insert(0) += 1;
}
let mut fan_out_components = Vec::new();
let mut fan_in_components = Vec::new();
for (component, degree) in &out_degree {
if *degree > 3 {
fan_out_components.push((component, degree));
}
}
for (component, degree) in &in_degree {
if *degree > 3 {
fan_in_components.push((component, degree));
}
}
if !fan_out_components.is_empty() {
println!("Fan-out patterns (components affecting many others):");
for (component, degree) in fan_out_components {
println!(" {component} -> {degree} components");
}
}
if !fan_in_components.is_empty() {
println!("Fan-in patterns (components affected by many others):");
for (component, degree) in fan_in_components {
println!(" {component} <- {degree} components");
}
}
let chain_length = find_longest_dependency_chain(graph);
if chain_length > 4 {
println!("Long dependency chain detected: {chain_length} components");
println!(" Consider reducing chain length for better parallelization");
}
}
fn analyze_coupling_strength(
_graph: &earthsci_ast::ComponentGraph,
esm_file: &earthsci_ast::EsmFile,
) {
println!("\n=== COUPLING STRENGTH ANALYSIS ===");
if let Some(ref coupling) = esm_file.coupling {
let mut strong_couplings = 0;
let mut weak_couplings = 0;
for rule in coupling {
match rule {
earthsci_ast::CouplingEntry::Couple { systems, .. } => {
strong_couplings += 1;
if systems.len() >= 2 {
println!(
" Strong coupling: {} <-> {} (bidirectional)",
systems[0], systems[1]
);
}
}
earthsci_ast::CouplingEntry::OperatorCompose { systems, .. } => {
if systems.len() >= 2 {
println!(
" Medium coupling: {} -> {} (composition)",
systems[0], systems[1]
);
}
}
earthsci_ast::CouplingEntry::VariableMap { from, to, .. } => {
weak_couplings += 1;
println!(" Weak coupling: {from} -> {to} (variable mapping)");
}
earthsci_ast::CouplingEntry::OperatorApply { operator, .. } => {
println!(" Medium coupling: {operator} (operator application)");
}
earthsci_ast::CouplingEntry::Callback { callback_id, .. } => {
println!(" Dynamic coupling: {callback_id} (callback)");
}
earthsci_ast::CouplingEntry::Event { name, affects, .. } => {
let event_name = name.as_deref().unwrap_or("unnamed");
let affected_count = affects.as_ref().map(|a| a.len()).unwrap_or(0);
println!(" Event coupling: {event_name} affects {affected_count} components");
}
earthsci_ast::CouplingEntry::CouplingImport { reference, .. } => {
println!(" Library import: {reference} (coupling_import)");
}
}
}
println!("\nCoupling strength distribution:");
println!(" Strong (bidirectional): {strong_couplings}");
println!(
" Medium (composition/operators): {}",
coupling.len() - strong_couplings - weak_couplings
);
println!(" Weak (variable mapping): {weak_couplings}");
if strong_couplings > weak_couplings {
println!("⚠ Many strong couplings - consider loosening dependencies");
}
}
}
fn detect_coupling_antipatterns(
graph: &earthsci_ast::ComponentGraph,
_esm_file: &earthsci_ast::EsmFile,
) {
println!("\n=== COUPLING ANTI-PATTERNS ===");
let mut antipatterns_found = Vec::new();
let mut connection_counts = std::collections::HashMap::new();
for edge in &graph.edges {
*connection_counts.entry(&edge.from).or_insert(0) += 1;
*connection_counts.entry(&edge.to).or_insert(0) += 1;
}
for (component, count) in &connection_counts {
if *count > graph.nodes.len() / 2 {
antipatterns_found.push(format!(
"God component: {} connected to {}/{} components",
component,
count,
graph.nodes.len()
));
}
}
let mut component_pairs = std::collections::HashMap::new();
for edge in &graph.edges {
let key = if edge.from < edge.to {
format!("{}->{}", edge.from, edge.to)
} else {
format!("{}->{}", edge.to, edge.from)
};
*component_pairs.entry(key).or_insert(0) += 1;
}
for (pair, count) in &component_pairs {
if *count > 2 {
antipatterns_found.push(format!(
"Inappropriate intimacy: {pair} ({count} coupling types)"
));
}
}
if antipatterns_found.is_empty() {
println!("✓ No major coupling anti-patterns detected");
} else {
for antipattern in antipatterns_found {
println!(" ⚠ {antipattern}");
}
}
}
const TEMPLATE_MINIMAL: &str = r#"{
"esm": "1.0.0",
"metadata": {
"name": "{name}",
"description": "A minimal ESM model",
"authors": ["Generated by earthsci-ast CLI"]
},
"models": {
"{name}": {
"variables": {},
"equations": []
}
}
}"#;
const TEMPLATE_ATMOSPHERIC: &str = r#"{
"esm": "1.0.0",
"metadata": {
"name": "{name}",
"description": "An atmospheric chemistry model",
"authors": ["Generated by earthsci-ast CLI"]
},
"reaction_systems": {
"atmospheric_chemistry": {
"species": {
"O2": {"units": "molec/cm^3", "default": 1.0e18},
"O3": {"units": "molec/cm^3", "default": 1.0e12}
},
"parameters": {
"k": {"units": "1/s", "default": 1.0e-6, "description": "Rate constant"}
},
"reactions": [
{
"id": "o2_to_o3",
"name": "O2_to_O3",
"substrates": [{"species": "O2", "stoichiometry": 1}],
"products": [{"species": "O3", "stoichiometry": 1}],
"rate": "k"
}
]
}
}
}"#;
const TEMPLATE_ECOSYSTEM: &str = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "{name}",
"description": "An ecosystem model",
"authors": [
"Generated by earthsci-ast CLI"
]
},
"models": {
"ecosystem": {
"variables": {
"biomass": {
"type": "unknown",
"units": "kg/m^2",
"default": 1.0
},
"r": {
"type": "parameter",
"units": "1/s",
"default": 0.1
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"biomass"
],
"wrt": "t"
},
"rhs": {
"op": "*",
"args": [
"r",
"biomass"
]
}
}
]
}
}
}
"#;
const TEMPLATE_COUPLING: &str = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "{name}",
"description": "A coupled system model",
"authors": [
"Generated by earthsci-ast CLI"
]
},
"models": {
"atmosphere": {
"variables": {
"temperature": {
"type": "unknown",
"units": "K",
"default": 288.0
},
"relaxation": {
"type": "parameter",
"units": "1/s",
"default": 0.01
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"temperature"
],
"wrt": "t"
},
"rhs": {
"op": "*",
"args": [
{
"op": "-",
"args": [
"relaxation"
]
},
"temperature"
]
}
}
]
},
"ocean": {
"variables": {
"sea_temp": {
"type": "unknown",
"units": "K",
"default": 285.0
},
"heat_flux": {
"type": "parameter",
"units": "K",
"default": 0.0
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"sea_temp"
],
"wrt": "t"
},
"rhs": {
"op": "*",
"args": [
0.5,
"heat_flux"
]
}
}
]
}
},
"coupling": [
{
"type": "variable_map",
"from": "atmosphere.temperature",
"to": "ocean.heat_flux",
"transform": {
"op": "*",
"args": [
"atmosphere.temperature",
0.1
]
},
"description": "Ocean heat flux driven by atmospheric temperature"
}
]
}
"#;
fn semantic_compare(
esm_file1: &earthsci_ast::EsmFile,
esm_file2: &earthsci_ast::EsmFile,
) -> Result<bool, Box<dyn std::error::Error>> {
let json1 = serde_json::to_value(esm_file1)?;
let json2 = serde_json::to_value(esm_file2)?;
if json1 == json2 {
println!("✓ Files are semantically identical");
return Ok(true);
}
println!("✗ Files differ semantically");
if esm_file1.esm != esm_file2.esm {
println!(" ESM version: {} vs {}", esm_file1.esm, esm_file2.esm);
}
let models1_count = esm_file1.models.as_ref().map(|m| m.len()).unwrap_or(0);
let models2_count = esm_file2.models.as_ref().map(|m| m.len()).unwrap_or(0);
if models1_count != models2_count {
println!(" Model count: {models1_count} vs {models2_count}");
}
let rs1_count = esm_file1
.reaction_systems
.as_ref()
.map(|rs| rs.len())
.unwrap_or(0);
let rs2_count = esm_file2
.reaction_systems
.as_ref()
.map(|rs| rs.len())
.unwrap_or(0);
if rs1_count != rs2_count {
println!(" Reaction system count: {rs1_count} vs {rs2_count}");
}
Ok(false)
}
fn read_input(path: &std::path::Path) -> Result<String, Box<dyn std::error::Error>> {
fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()).into())
}
fn base_of(path: &std::path::Path) -> earthsci_ast::LoadOptions {
earthsci_ast::LoadOptions {
base_path: Some(
path.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."))
.to_path_buf(),
),
..Default::default()
}
}
fn load_at(
path: &std::path::Path,
content: &str,
) -> Result<earthsci_ast::EsmFile, earthsci_ast::EsmError> {
earthsci_ast::load_string_with_options(content, &base_of(path))
}
fn collect_esm_files(
dir: &std::path::Path,
recursive: bool,
out: &mut Vec<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let mut entries: Vec<_> = fs::read_dir(dir)
.map_err(|e| format!("cannot read directory {}: {e}", dir.display()))?
.collect::<Result<_, _>>()?;
entries.sort_by_key(|e| e.path());
for entry in entries {
let path = entry.path();
if path.is_dir() {
if recursive {
collect_esm_files(&path, recursive, out)?;
}
} else if path.extension().and_then(|e| e.to_str()) == Some("esm") {
out.push(path);
}
}
Ok(())
}
fn time_parse(
path: &std::path::Path,
content: &str,
iterations: usize,
) -> Result<std::time::Duration, Box<dyn std::error::Error>> {
let start = std::time::Instant::now();
for _ in 0..iterations {
let _ = load_at(path, content)?;
}
Ok(start.elapsed())
}
fn time_validate(esm_file: &earthsci_ast::EsmFile, iterations: usize) -> std::time::Duration {
let start = std::time::Instant::now();
for _ in 0..iterations {
let _ = validate(esm_file);
}
start.elapsed()
}
fn bench_parse(
path: &std::path::Path,
content: &str,
iterations: usize,
) -> Result<(), Box<dyn std::error::Error>> {
let duration = time_parse(path, content, iterations)?;
println!(
"Parse: {} iterations in {:?} ({:?}/iter)",
iterations,
duration,
duration / (iterations as u32)
);
Ok(())
}
fn bench_validate(
path: &std::path::Path,
content: &str,
iterations: usize,
) -> Result<(), Box<dyn std::error::Error>> {
let esm_file = load_at(path, content)?;
let duration = time_validate(&esm_file, iterations);
println!(
"Validate: {} iterations in {:?} ({:?}/iter)",
iterations,
duration,
duration / (iterations as u32)
);
Ok(())
}
fn bench_simulate(
path: &std::path::Path,
content: &str,
iterations: usize,
) -> Result<(), Box<dyn std::error::Error>> {
let esm_file = load_at(path, content)?;
let opts = earthsci_ast::SolveOptions::default();
let prob = earthsci_ast::esm_problem(
&esm_file,
(0.0, 1.0),
earthsci_ast::ProblemOptions {
rhs: earthsci_ast::Rhs::Always,
..Default::default()
},
)
.map_err(|e| format!("problem build failed: {e}"))?;
let start = std::time::Instant::now();
for _ in 0..iterations {
earthsci_ast::solve(&prob, &opts).map_err(|e| format!("solve failed: {e}"))?;
}
let duration = start.elapsed();
println!(
"Simulate: {} iterations in {:?} ({:?}/iter)",
iterations,
duration,
duration / (iterations as u32)
);
Ok(())
}
fn print_complexity_analysis(esm_file: &earthsci_ast::EsmFile) {
use std::collections::BTreeMap;
fn walk(
expr: &earthsci_ast::Expr,
depth: usize,
nodes: &mut usize,
max_depth: &mut usize,
ops: &mut BTreeMap<String, usize>,
) {
*nodes += 1;
*max_depth = (*max_depth).max(depth);
if let earthsci_ast::Expr::Operator(node) = expr {
*ops.entry(node.op.clone()).or_insert(0) += 1;
node.for_each_child(&mut |child| walk(child, depth + 1, nodes, max_depth, ops));
}
}
println!("\n=== COMPLEXITY ANALYSIS ===");
let mut total_nodes = 0usize;
let mut total_max_depth = 0usize;
let mut ops: BTreeMap<String, usize> = BTreeMap::new();
if let Some(ref models) = esm_file.models {
let mut ids: Vec<&String> = models.keys().collect();
ids.sort();
for model_id in ids {
let model = &models[model_id];
let mut nodes = 0usize;
let mut max_depth = 0usize;
for eq in &model.equations {
walk(&eq.lhs, 1, &mut nodes, &mut max_depth, &mut ops);
walk(&eq.rhs, 1, &mut nodes, &mut max_depth, &mut ops);
}
let mut var_names: Vec<&String> = model.variables.keys().collect();
var_names.sort();
for name in var_names {
model.variables[name].for_each_expression(&mut |expr| {
walk(expr, 1, &mut nodes, &mut max_depth, &mut ops);
});
}
println!(" Model {model_id}: {nodes} expression nodes, max depth {max_depth}");
total_nodes += nodes;
total_max_depth = total_max_depth.max(max_depth);
}
}
if let Some(ref reaction_systems) = esm_file.reaction_systems {
let mut ids: Vec<&String> = reaction_systems.keys().collect();
ids.sort();
for rs_id in ids {
let rs = &reaction_systems[rs_id];
let mut nodes = 0usize;
let mut max_depth = 0usize;
for reaction in &rs.reactions {
walk(&reaction.rate, 1, &mut nodes, &mut max_depth, &mut ops);
}
println!(
" Reaction System {rs_id}: {nodes} rate expression nodes, max depth {max_depth}"
);
total_nodes += nodes;
total_max_depth = total_max_depth.max(max_depth);
}
}
println!("Total: {total_nodes} expression nodes, max depth {total_max_depth}");
if !ops.is_empty() {
let mut by_count: Vec<(&String, &usize)> = ops.iter().collect();
by_count.sort_by(|a, b| b.1.cmp(a.1).then(a.0.cmp(b.0)));
println!("Operator frequency:");
for (op, count) in by_count.iter().take(10) {
println!(" {op}: {count}");
}
}
}
#[derive(Clone, Copy, ValueEnum)]
enum DisplayFormat {
Unicode,
Latex,
Ascii,
}
fn run_validate(file: PathBuf, verbose: bool) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
let base_dir = file.parent();
let validation_result = validate_text(&content, base_dir);
if validation_result.is_valid {
println!("✓ Validation passed");
if verbose && !validation_result.unit_warnings.is_empty() {
println!("Warnings:");
for warning in &validation_result.unit_warnings {
println!(" ⚠ {}", warning.message);
}
}
} else {
println!("✗ Validation failed");
if !validation_result.schema_errors.is_empty() {
println!("Schema errors:");
for error in &validation_result.schema_errors {
println!(" ✗ {}: {}", error.path, error.message);
}
}
if !validation_result.structural_errors.is_empty() {
println!("Structural errors:");
for error in &validation_result.structural_errors {
if verbose {
println!(" ✗ {} ({}): {}", error.path, error.code, error.message);
if !error.details.is_null() {
println!(" Details: {}", error.details);
}
} else {
println!(" ✗ {}: {}", error.path, error.message);
}
}
}
return Err(fail_silent());
}
Ok(())
}
fn run_pretty(file: PathBuf, format: DisplayFormat) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
let esm_file = load_at(&file, &content)?;
let render = |expr: &earthsci_ast::Expr| match format {
DisplayFormat::Unicode => earthsci_ast::to_unicode(expr),
DisplayFormat::Latex => earthsci_ast::to_latex(expr),
DisplayFormat::Ascii => earthsci_ast::to_ascii(expr),
};
println!("Displaying expressions in {}:", file.display());
if let Some(ref models) = esm_file.models {
for (model_id, model) in models {
println!(
"\nModel: {} ({})",
model_id,
model.name.as_deref().unwrap_or("unnamed")
);
for (i, equation) in model.equations.iter().enumerate() {
let lhs = render(&equation.lhs);
let rhs = render(&equation.rhs);
println!(" Eq {}: {} = {}", i + 1, lhs, rhs);
}
}
}
if let Some(ref reaction_systems) = esm_file.reaction_systems {
for (rs_id, rs) in reaction_systems {
println!("\nReaction System: {rs_id}");
for (i, reaction) in rs.reactions.iter().enumerate() {
let rate = render(&reaction.rate);
println!(" Reaction {}: rate = {}", i + 1, rate);
}
}
}
Ok(())
}
fn run_extract(
file: PathBuf,
component: String,
output: Option<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
let esm_file = load_at(&file, &content)?;
if !component_exists(&esm_file, &component) {
return Err(fail(format!(
"Component '{component}' not found in the ESM file"
)));
}
let mut extracted_esm = earthsci_ast::EsmFile {
esm: esm_file.esm.clone(),
metadata: esm_file.metadata.clone(),
index_sets: esm_file.index_sets.clone(),
domain: esm_file.domain.clone(),
function_tables: esm_file.function_tables.clone(),
..Default::default()
};
if let Some(ref models) = esm_file.models
&& let Some(model) = models.get(&component)
{
let mut extracted_models = indexmap::IndexMap::new();
extracted_models.insert(component.clone(), model.clone());
extracted_esm.models = Some(extracted_models);
}
if let Some(ref reaction_systems) = esm_file.reaction_systems
&& let Some(rs) = reaction_systems.get(&component)
{
let mut extracted_rs = indexmap::IndexMap::new();
extracted_rs.insert(component.clone(), rs.clone());
extracted_esm.reaction_systems = Some(extracted_rs);
}
if let Some(ref data_sources) = esm_file.data_sources
&& let Some(dl) = data_sources.get(&component)
{
let mut extracted_dl = indexmap::IndexMap::new();
extracted_dl.insert(component.clone(), dl.clone());
extracted_esm.data_sources = Some(extracted_dl);
}
if let Some(ref operators) = esm_file.operators
&& let Some(op) = operators.get(&component)
{
let mut extracted_op = indexmap::IndexMap::new();
extracted_op.insert(component.clone(), op.clone());
extracted_esm.operators = Some(extracted_op);
}
let output_content = to_json(&extracted_esm)?;
if let Some(output_path) = output {
fs::write(&output_path, output_content)?;
println!(
"Extracted component '{}' to {}",
component,
output_path.display()
);
} else {
print!("{output_content}");
}
Ok(())
}
fn run_diff(file1: PathBuf, file2: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let content1 = read_input(&file1)?;
let content2 = read_input(&file2)?;
let esm_file1 = load_at(&file1, &content1)?;
let esm_file2 = load_at(&file2, &content2)?;
println!("Comparing {} and {}:", file1.display(), file2.display());
if !semantic_compare(&esm_file1, &esm_file2)? {
return Err(fail_silent());
}
Ok(())
}
fn run_stoich(file: PathBuf, system: String) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
let esm_file = load_at(&file, &content)?;
if let Some(ref reaction_systems) = esm_file.reaction_systems {
if let Some(rs) = reaction_systems.get(&system) {
println!("Stoichiometric matrix for reaction system '{system}':");
let matrix = stoichiometric_matrix(rs);
let species: Vec<&String> = rs.species.keys().collect();
print!("{:>15}", "");
for name in &species {
print!("{name:>10}");
}
println!();
for reaction_idx in 0..rs.reactions.len() {
print!("Reaction {:>3}:", reaction_idx + 1);
for row in &matrix {
print!("{:>10.1}", row[reaction_idx]);
}
println!();
}
} else {
return Err(fail(format!("Reaction system '{system}' not found")));
}
} else {
return Err(fail("No reaction systems found in the file"));
}
Ok(())
}
fn render_graph<G: earthsci_ast::Graph>(graph: &G, format: GraphFormat) {
match format {
GraphFormat::Dot => println!("{}", earthsci_ast::to_dot(graph)),
GraphFormat::Mermaid => println!("{}", earthsci_ast::to_mermaid(graph)),
GraphFormat::Json => println!("{}", earthsci_ast::to_json_graph(graph)),
}
}
fn run_graph(
file: PathBuf,
level: GraphLevel,
format: GraphFormat,
system: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
let esm_file = load_at(&file, &content)?;
match level {
GraphLevel::Component => render_graph(&component_graph(&esm_file), format),
GraphLevel::Expression => {
match system {
Some(name) => {
if let Some(model) = esm_file.models.as_ref().and_then(|m| m.get(&name)) {
render_graph(&expression_graph(model), format)
} else if let Some(rs) = esm_file
.reaction_systems
.as_ref()
.and_then(|r| r.get(&name))
{
render_graph(&expression_graph(rs), format)
} else {
return Err(fail(format!(
"Unsupported system: {name}. Name a model or reaction system \
declared in this file."
)));
}
}
None => render_graph(
&expression_graph_with_options(
&esm_file,
&ExpressionGraphOptions {
merge_coupled: true,
},
),
format,
),
}
}
}
Ok(())
}
fn run_convert(
input: PathBuf,
output: Option<PathBuf>,
to: ConvertFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&input)?;
let esm_file = load_at(&input, &content)?;
let output_content = match to {
ConvertFormat::Json => to_json(&esm_file)?,
ConvertFormat::CompactJson => to_json_compact(&esm_file)?,
};
if let Some(output_path) = output {
fs::write(&output_path, output_content)?;
println!("Converted {} to {}", input.display(), output_path.display());
} else {
print!("{output_content}");
}
Ok(())
}
fn run_analyze(
file: PathBuf,
analysis_type: AnalysisType,
) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
let esm_file = load_at(&file, &content)?;
println!("System Analysis for: {}", file.display());
println!("Analysis Type: {}", arg_name(&analysis_type));
if matches!(analysis_type, AnalysisType::All | AnalysisType::Structure) {
{
println!("\n=== STRUCTURAL ANALYSIS ===");
let model_count = esm_file.models.as_ref().map(|m| m.len()).unwrap_or(0);
let rs_count = esm_file
.reaction_systems
.as_ref()
.map(|rs| rs.len())
.unwrap_or(0);
let dl_count = esm_file
.data_sources
.as_ref()
.map(|dl| dl.len())
.unwrap_or(0);
let op_count = esm_file.operators.as_ref().map(|op| op.len()).unwrap_or(0);
println!(
"Components: {model_count} models, {rs_count} reaction systems, {dl_count} data loaders, {op_count} operators"
);
let mut total_vars = 0;
let mut total_eqs = 0;
if let Some(ref models) = esm_file.models {
for (model_id, model) in models {
total_vars += model.variables.len();
total_eqs += model.equations.len();
println!(
" Model {}: {} variables, {} equations",
model_id,
model.variables.len(),
model.equations.len()
);
}
}
let mut total_species = 0;
let mut total_reactions = 0;
if let Some(ref reaction_systems) = esm_file.reaction_systems {
for (rs_id, rs) in reaction_systems {
total_species += rs.species.len();
total_reactions += rs.reactions.len();
println!(
" Reaction System {}: {} species, {} reactions",
rs_id,
rs.species.len(),
rs.reactions.len()
);
}
}
println!(
"Total: {total_vars} variables, {total_eqs} equations, {total_species} species, {total_reactions} reactions"
);
}
}
if matches!(analysis_type, AnalysisType::All | AnalysisType::Complexity) {
print_complexity_analysis(&esm_file);
}
if matches!(analysis_type, AnalysisType::All | AnalysisType::Coupling) {
{
println!("\n=== COUPLING ANALYSIS ===");
if let Some(ref coupling) = esm_file.coupling {
println!("Coupling rules: {}", coupling.len());
for (i, rule) in coupling.iter().enumerate() {
match rule {
earthsci_ast::CouplingEntry::OperatorCompose { systems, .. } => {
if systems.len() >= 2 {
println!(
" Rule {}: {} -> {} (OperatorCompose)",
i + 1,
systems[0],
systems[1]
);
}
}
earthsci_ast::CouplingEntry::Couple { systems, .. } => {
if systems.len() >= 2 {
println!(
" Rule {}: {} <-> {} (Couple)",
i + 1,
systems[0],
systems[1]
);
}
}
earthsci_ast::CouplingEntry::VariableMap { from, to, .. } => {
println!(" Rule {}: {} -> {} (VariableMap)", i + 1, from, to);
}
earthsci_ast::CouplingEntry::OperatorApply { operator, .. } => {
println!(" Rule {}: {} (OperatorApply)", i + 1, operator);
}
earthsci_ast::CouplingEntry::Callback { callback_id, .. } => {
println!(" Rule {}: {} (Callback)", i + 1, callback_id);
}
earthsci_ast::CouplingEntry::Event { name, affects, .. } => {
let event_name = name.as_deref().unwrap_or("unnamed_event");
let systems: Vec<String> = affects
.as_ref()
.map(|affects| {
affects
.iter()
.filter_map(|affect| {
if affect.lhs.contains('.') {
Some(
affect
.lhs
.split('.')
.next()
.unwrap_or("")
.to_string(),
)
} else {
None
}
})
.collect()
})
.unwrap_or_default();
println!(" Rule {}: {} -> {:?} (Event)", i + 1, event_name, systems);
}
earthsci_ast::CouplingEntry::CouplingImport { reference, .. } => {
println!(" Rule {}: {} (CouplingImport)", i + 1, reference);
}
}
}
} else {
println!("No coupling rules defined");
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_simulate(
file: PathBuf,
time: f64,
output: Option<PathBuf>,
observed: Vec<String>,
model: Option<String>,
format: SimulateFormat,
compiler: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let compiler = match compiler.as_deref() {
None => None,
Some(name) => Some(earthsci_ast::Compiler::parse_named(name).map_err(fail)?),
};
let content = read_input(&file)?;
let esm_file = load_at(&file, &content)?;
println!("Running simulation for: {}", file.display());
println!("Simulation time: 0 → {time}");
let opts = earthsci_ast::SolveOptions {
output_observed: observed.clone(),
..Default::default()
};
let (esm_file, providers) = close_declared_extents(esm_file, |mp| {
earthsci_ast::load_string_with_options(
&content,
&earthsci_ast::LoadOptions {
base_path: file.parent().map(std::path::Path::to_path_buf),
metaparameters: mp.clone(),
},
)
.map_err(|e| format!("re-loading with the discovered source extents: {e}"))
})
.map_err(fail)?;
let ingesting = providers.is_some();
let build = |pipeline: bool| -> Result<earthsci_ast::EsmProblem, Box<dyn std::error::Error>> {
let mut popts = earthsci_ast::ProblemOptions {
rhs: earthsci_ast::Rhs::Auto,
compiler,
model_name: model.clone(),
build_pipeline: pipeline,
..Default::default()
};
if let Some(make) = &providers {
popts.build_providers = make()?;
popts.build_pipeline = true;
}
earthsci_ast::esm_problem(&esm_file, (0.0, time), popts)
.map_err(|e| fail(format!("problem build failed: {e}")))
};
let prob = build(false)?;
let mut evaluated: Vec<(String, ndarray::ArrayD<f64>)> = Vec::new();
let sol = if prob.is_dynamic() {
println!("{}", prob.compiler_report());
let sol = earthsci_ast::solve(&prob, &opts).map_err(|e| format!("solve failed: {e}"))?;
println!(
"✓ Simulation complete: {} output points, alg {}, retcode {}",
sol.time.len(),
sol.metadata.alg,
sol.retcode
);
sol
} else {
let prob = if ingesting || !prob.observed_fields().is_empty() {
prob
} else {
build(true)?
};
println!("{}", prob.compiler_report());
evaluated = static_fields(&prob, &observed)?;
println!(
"✓ Static evaluation complete: {} field(s). The document declares no \
differential equations, so it was evaluated once rather than integrated.",
evaluated.len()
);
static_solution(&evaluated)
};
if !sol.retcode.is_success() {
println!(
"⚠ The run did NOT reach t = {time}: it stopped at t = {} with retcode {}",
sol.time.last().copied().unwrap_or(0.0),
sol.retcode
);
}
if let Some(&t_final) = sol.time.last() {
println!("Final state at t = {t_final}:");
for (name, series) in sol.state_variable_names.iter().zip(sol.state.iter()) {
if let Some(v) = series.last() {
println!(" {name} = {v}");
}
}
}
if let Some(output_path) = output {
let out = match format {
SimulateFormat::Flat => serde_json::json!({
"time": sol.time,
"state": sol.state,
"state_variable_names": sol.state_variable_names,
"alg": sol.metadata.alg,
"retcode": sol.retcode.name(),
}),
SimulateFormat::Grid => gridded_results(&esm_file, &sol, &observed)?,
SimulateFormat::Csv => {
if evaluated.is_empty() {
return Err(fail(
"--format csv writes ONE row set, and this document integrates: \
its result is a trajectory. Use --format flat or --format grid.",
));
}
write_csv_rows(&evaluated, &output_path)?;
println!("Results written to: {}", output_path.display());
return Ok(());
}
};
let mut w = std::io::BufWriter::new(fs::File::create(&output_path)?);
serde_json::to_writer_pretty(&mut w, &out)?;
std::io::Write::write_all(&mut w, b"\n")?;
std::io::Write::flush(&mut w)?;
println!("Results written to: {}", output_path.display());
}
Ok(())
}
type StaticFields = Vec<(String, ndarray::ArrayD<f64>)>;
fn static_fields(
prob: &earthsci_ast::EsmProblem,
observed: &[String],
) -> Result<StaticFields, Box<dyn std::error::Error>> {
let names: Vec<String> = if observed.is_empty() {
prob.observed_field_names()
} else {
observed.to_vec()
};
if names.is_empty() {
return Err(fail(
"the document declares no differential equations and its build \
materialized no fields, so there is nothing to write. Check that the \
values you want are OBSERVED variables (an equation whose LHS is the \
bare name), and that any `data_sources` they read were ingested.",
));
}
let mut out = Vec::with_capacity(names.len());
for name in names {
let arr = earthsci_ast::observed_field(prob, &name)
.map_err(|e| fail(format!("evaluating '{name}': {e}")))?;
out.push((name, arr));
}
Ok(out)
}
fn static_solution(fields: &[(String, ndarray::ArrayD<f64>)]) -> earthsci_ast::Solution {
let mut state_variable_names = Vec::new();
let mut state = Vec::new();
for (name, arr) in fields {
let shape = arr.shape();
if shape.is_empty() {
state_variable_names.push(name.clone());
state.push(vec![arr[ndarray::IxDyn(&[])]]);
continue;
}
for flat in 0..arr.len() {
let mut rem = flat;
let mut idx = vec![0usize; shape.len()];
for (d, dim) in shape.iter().enumerate() {
idx[d] = rem % dim;
rem /= dim;
}
let label = idx
.iter()
.map(|i| (i + 1).to_string())
.collect::<Vec<_>>()
.join(",");
state_variable_names.push(format!("{name}[{label}]"));
state.push(vec![arr[ndarray::IxDyn(&idx)]]);
}
}
earthsci_ast::Solution {
time: vec![0.0],
state,
state_variable_names,
retcode: earthsci_ast::ReturnCode::Success,
metadata: earthsci_ast::SolutionMetadata {
alg: "static".to_string(),
..Default::default()
},
}
}
fn write_csv_rows(
fields: &[(String, ndarray::ArrayD<f64>)],
path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
use std::io::Write;
let shape = fields[0].1.shape().to_vec();
if shape.is_empty() {
return Err(fail(format!(
"--format csv writes a ROW SET, and '{}' is a single value, not a column. \
Name the row-shaped fields with --observed.",
fields[0].0
)));
}
if let Some((name, arr)) = fields.iter().find(|(_, a)| a.shape() != shape.as_slice()) {
return Err(fail(format!(
"--format csv writes ONE row set, so every written field must share a \
shape: '{}' is {:?} but '{}' is {:?}. Name a consistent set with \
--observed.",
fields[0].0,
shape,
name,
arr.shape()
)));
}
let mut w = std::io::BufWriter::new(fs::File::create(path)?);
let mut header: Vec<String> = (1..=shape.len()).map(|d| format!("i{d}")).collect();
header.extend(fields.iter().map(|(n, _)| n.clone()));
writeln!(w, "{}", header.join(","))?;
let total: usize = shape.iter().product();
let mut idx = vec![0usize; shape.len()];
for _ in 0..total {
let mut row: Vec<String> = idx.iter().map(|i| (i + 1).to_string()).collect();
row.extend(
fields
.iter()
.map(|(_, a)| format_csv_float(a[ndarray::IxDyn(&idx)])),
);
writeln!(w, "{}", row.join(","))?;
for d in (0..shape.len()).rev() {
idx[d] += 1;
if idx[d] < shape[d] {
break;
}
idx[d] = 0;
}
}
w.flush()?;
Ok(())
}
fn format_csv_float(v: f64) -> String {
format!("{v}")
}
fn gridded_results(
esm_file: &earthsci_ast::EsmFile,
sol: &earthsci_ast::Solution,
observed: &[String],
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let renames = &sol.metadata.merged_variable_renames;
let resolved: Vec<String> = observed
.iter()
.map(|n| {
renames
.get(n.as_str())
.cloned()
.unwrap_or_else(|| n.clone())
})
.collect();
let plan = earthsci_ast::derive_output_plan(esm_file, &sol.state_variable_names, &resolved)
.map_err(|e| fail(format!("deriving the output plan: {e}")))?;
let n_records = sol.time.len();
let n_slots = sol.state.len();
let mut grids = Vec::with_capacity(plan.grids.len());
for grid in &plan.grids {
let mut data: Vec<Vec<f64>> = grid
.vars
.iter()
.map(|v| vec![0.0f64; n_records * v.values_per_record()])
.collect();
let mut flat = vec![0.0f64; n_slots];
for r in 0..n_records {
for (i, slot) in flat.iter_mut().enumerate() {
*slot = sol.state[i][r];
}
for (v, buf) in grid.vars.iter().zip(data.iter_mut()) {
v.gridding
.scatter_record(&flat, buf, r)
.map_err(|e| fail(format!("gridding '{}': {e}", v.name)))?;
}
}
let mut dims = vec![serde_json::json!([grid.time_dim, n_records])];
dims.extend(grid.dims.iter().map(|(n, l)| serde_json::json!([n, l])));
let mut coords = vec![serde_json::json!({
"name": grid.time_dim,
"values": sol.time,
"attrs": {},
})];
coords.extend(
grid.coords.iter().map(
|c| serde_json::json!({ "name": c.name, "values": c.values, "attrs": c.attrs }),
),
);
let vars: Vec<serde_json::Value> = grid
.vars
.iter()
.zip(data)
.map(|(v, buf)| {
serde_json::json!({
"name": v.name,
"dims": v.dims,
"attrs": v.attrs,
"dtype": v.dtype,
"data": buf,
})
})
.collect();
grids.push(serde_json::json!({
"dims": dims,
"time_dim": grid.time_dim,
"coords": coords,
"vars": vars,
}));
}
println!(
"Output plan: {} grid(s), {} variable(s)",
plan.grids.len(),
plan.n_vars()
);
for grid in &plan.grids {
let axes: Vec<String> = grid.dims.iter().map(|(n, l)| format!("{n}={l}")).collect();
println!(
" [{}] {}",
if axes.is_empty() {
"scalar".to_string()
} else {
axes.join(", ")
},
grid.vars
.iter()
.map(|v| v.name.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
Ok(serde_json::json!({
"alg": sol.metadata.alg,
"retcode": sol.retcode.name(),
"grids": grids,
}))
}
fn run_info(file: PathBuf) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
let esm_file = load_at(&file, &content)?;
println!("ESM File Information for: {}", file.display());
println!("ESM Version: {}", esm_file.esm);
if let Some(ref name) = esm_file.metadata.name {
println!("Model Name: {name}");
}
if let Some(ref description) = esm_file.metadata.description {
println!("Description: {description}");
}
if let Some(ref authors) = esm_file.metadata.authors {
println!("Authors: {}", authors.join(", "));
}
if let Some(ref models) = esm_file.models {
println!("Models: {} component(s)", models.len());
for (id, model) in models {
println!(
" - {} ({} vars, {} eqs)",
id,
model.variables.len(),
model.equations.len()
);
}
}
if let Some(ref reaction_systems) = esm_file.reaction_systems {
println!("Reaction Systems: {} component(s)", reaction_systems.len());
for (id, rs) in reaction_systems {
println!(
" - {} ({} species, {} reactions)",
id,
rs.species.len(),
rs.reactions.len()
);
}
}
if let Some(ref data_sources) = esm_file.data_sources {
println!("Data Loaders: {} component(s)", data_sources.len());
}
if let Some(ref operators) = esm_file.operators {
println!("Operators: {} component(s)", operators.len());
}
if let Some(ref coupling) = esm_file.coupling {
println!("Coupling Rules: {} rule(s)", coupling.len());
}
Ok(())
}
fn run_units(file: PathBuf, check: bool) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
let esm_file = load_at(&file, &content)?;
println!("Units Analysis for: {}", file.display());
if check {
println!("\n=== DIMENSIONAL CONSISTENCY CHECK ===");
let validation_result = validate(&esm_file);
let unit_errors: Vec<_> = validation_result
.structural_errors
.iter()
.filter(|e| {
matches!(
e.code,
StructuralErrorCode::UnitInconsistency | StructuralErrorCode::UnitParseError
)
})
.collect();
let mut error_lines: Vec<String> = unit_errors
.iter()
.map(|e| format!("{}: {}", e.path, e.message))
.collect();
let (mut consistent, mut mismatched, mut not_checked) = (0, 0, 0);
for model in esm_file.models.iter().flat_map(|m| m.values()) {
for (_, _, verdict) in model_equation_verdicts(&esm_file, model) {
match verdict {
EquationDimensionVerdict::Consistent(_) => consistent += 1,
EquationDimensionVerdict::Mismatch(_) => mismatched += 1,
EquationDimensionVerdict::NotChecked(_) => not_checked += 1,
}
}
}
let (mut rates_ok, mut rates_bad, mut rates_unchecked) = (0, 0, 0);
for (rs_id, rs) in esm_file.reaction_systems.iter().flat_map(|m| m.iter()) {
for (i, verdict) in reaction_rate_verdicts(rs_id, rs, &validation_result) {
match verdict {
RateVerdict::Consistent => rates_ok += 1,
RateVerdict::Mismatch(message) => {
rates_bad += 1;
let line = format!("/reaction_systems/{rs_id}/reactions/{i}: {message}");
if !error_lines.contains(&line) {
error_lines.push(line);
}
}
RateVerdict::NotChecked(_) => rates_unchecked += 1,
}
}
}
if !error_lines.is_empty() {
println!("Unit errors:");
for line in &error_lines {
println!(" ✗ {line}");
}
}
if !validation_result.unit_warnings.is_empty() {
println!("Unit warnings:");
for warning in &validation_result.unit_warnings {
println!(" ⚠ {}", warning.message);
}
}
println!(
"Equations: {consistent} consistent, {mismatched} mismatched, {not_checked} not checked"
);
if rates_ok + rates_bad + rates_unchecked > 0 {
println!(
"Reaction rates: {rates_ok} consistent, {rates_bad} mismatched, {rates_unchecked} not checked"
);
}
if !error_lines.is_empty() || mismatched > 0 || rates_bad > 0 {
return Err(fail_silent());
}
if not_checked == 0 && rates_unchecked == 0 {
println!("✓ All units are dimensionally consistent");
} else {
println!(
"✓ No dimensional mismatch found; run without --check to see why equations were not checked"
);
}
} else {
println!("\n=== COMPREHENSIVE UNIT ANALYSIS ===");
let validation_result = validate(&esm_file);
analyze_model_units(&esm_file);
analyze_reaction_system_units(&esm_file, &validation_result);
println!("\n=== UNIT SYSTEM SUMMARY ===");
let unit_summary = collect_unit_types(&esm_file);
if unit_summary.is_empty() {
println!("No units specified in the model");
} else {
println!("Unit types found:");
for unit_type in &unit_summary {
println!(" - {unit_type}");
}
println!("\n=== RECOMMENDATIONS ===");
if unit_summary.contains(&"m".to_string()) && unit_summary.contains(&"cm".to_string()) {
println!("⚠ Mixed length units detected (m, cm) - consider standardizing");
}
if unit_summary.contains(&"s".to_string()) && unit_summary.contains(&"min".to_string())
{
println!("⚠ Mixed time units detected (s, min) - consider standardizing");
}
let si_base_units = ["m", "kg", "s", "A", "K", "mol", "cd"];
let using_si = unit_summary
.iter()
.any(|u| si_base_units.contains(&u.as_str()));
if using_si {
println!("✓ SI base units detected - good for scientific consistency");
} else {
println!("ℹ Consider using SI base units for scientific consistency");
}
}
}
Ok(())
}
fn run_coupling_analysis(
file: PathBuf,
depth: CouplingDepth,
) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
let esm_file = load_at(&file, &content)?;
println!("Coupling Dependency Analysis for: {}", file.display());
println!("Depth: {}", arg_name(&depth));
let graph = component_graph(&esm_file);
println!(
"Found {} components with {} coupling relationships",
graph.nodes.len(),
graph.edges.len()
);
for edge in &graph.edges {
println!(" {} --[{}]--> {}", edge.from, edge.coupling_type, edge.to);
}
match depth {
CouplingDepth::Shallow => {
println!("Shallow analysis complete");
}
CouplingDepth::Deep => {
perform_deep_coupling_analysis(&esm_file);
}
}
Ok(())
}
fn run_performance_profile(
file: PathBuf,
profile_type: ProfileType,
) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
let esm_file = load_at(&file, &content)?;
println!("Performance Profile for: {}", file.display());
println!("Profile type: {}", arg_name(&profile_type));
match profile_type {
ProfileType::Memory => {
println!("\n=== MEMORY ANALYSIS ===");
let file_size = content.len();
println!(
"File size: {} bytes ({:.2} KB)",
file_size,
file_size as f64 / 1024.0
);
let _model_count = esm_file.models.as_ref().map(|m| m.len()).unwrap_or(0);
let _rs_count = esm_file
.reaction_systems
.as_ref()
.map(|rs| rs.len())
.unwrap_or(0);
let _dl_count = esm_file
.data_sources
.as_ref()
.map(|dl| dl.len())
.unwrap_or(0);
let _op_count = esm_file.operators.as_ref().map(|op| op.len()).unwrap_or(0);
let mut estimated_memory = file_size;
if let Some(ref models) = esm_file.models {
for (model_id, model) in models {
let vars_mem = model.variables.len() * 64; let eqs_mem = model.equations.len() * 128; estimated_memory += vars_mem + eqs_mem;
println!(
" Model {}: ~{} bytes ({} vars, {} eqs)",
model_id,
vars_mem + eqs_mem,
model.variables.len(),
model.equations.len()
);
}
}
if let Some(ref reaction_systems) = esm_file.reaction_systems {
for (rs_id, rs) in reaction_systems {
let species_mem = rs.species.len() * 32; let reactions_mem = rs.reactions.len() * 256; estimated_memory += species_mem + reactions_mem;
println!(
" Reaction System {}: ~{} bytes ({} species, {} reactions)",
rs_id,
species_mem + reactions_mem,
rs.species.len(),
rs.reactions.len()
);
}
}
println!(
"Total estimated memory: {} bytes ({:.2} KB)",
estimated_memory,
estimated_memory as f64 / 1024.0
);
if file_size > 10000 {
println!("\n=== MEMORY OPTIMIZATION SUGGESTIONS ===");
println!("- Consider using compact JSON format for smaller file sizes");
println!("- Use references instead of duplicating common expressions");
}
}
ProfileType::Time => {
println!("\n=== TIME ANALYSIS ===");
let iterations = 100;
let parse_duration = time_parse(&file, &content, iterations)?;
println!(
"Parse time: {} iterations in {:?} ({:?}/iter)",
iterations,
parse_duration,
parse_duration / iterations as u32
);
let validate_duration = time_validate(&esm_file, iterations);
println!(
"Validate time: {} iterations in {:?} ({:?}/iter)",
iterations,
validate_duration,
validate_duration / iterations as u32
);
if let Some(ref models) = esm_file.models {
println!("\n=== EXPRESSION COMPLEXITY ===");
for (model_id, model) in models {
let mut max_depth = 0;
let mut total_nodes = 0;
for equation in &model.equations {
let lhs_depth = expression_depth(&equation.lhs);
let rhs_depth = expression_depth(&equation.rhs);
max_depth = max_depth.max(lhs_depth).max(rhs_depth);
total_nodes += count_expression_nodes(&equation.lhs);
total_nodes += count_expression_nodes(&equation.rhs);
}
println!(
" Model {model_id}: max depth = {max_depth}, total nodes = {total_nodes}"
);
}
}
println!("\n=== PERFORMANCE SUGGESTIONS ===");
if parse_duration.as_millis() > 100 {
println!("- Parse time is high; consider SIMD JSON parsing for large files");
}
if validate_duration.as_millis() > 50 {
println!("- Validation time is high; consider caching validation results");
}
}
ProfileType::Both => {
println!("=== COMBINED PERFORMANCE ANALYSIS ===");
let file_size = content.len();
println!(
"File size: {} bytes ({:.2} KB)",
file_size,
file_size as f64 / 1024.0
);
let iterations = 50;
let parse_duration = time_parse(&file, &content, iterations)?;
println!("Parse time: {:?}/iter", parse_duration / iterations as u32);
println!("\n=== OVERALL ASSESSMENT ===");
if file_size < 1000 {
println!("✓ Small file size - good performance expected");
} else if file_size < 10000 {
println!("- Medium file size - acceptable performance");
} else {
println!("⚠ Large file size - consider optimization");
}
}
}
Ok(())
}
fn run_compare(
file1: PathBuf,
file2: PathBuf,
comparison_type: ComparisonType,
) -> Result<(), Box<dyn std::error::Error>> {
let content1 = read_input(&file1)?;
let content2 = read_input(&file2)?;
let esm_file1 = load_at(&file1, &content1)?;
let esm_file2 = load_at(&file2, &content2)?;
println!("Comparing {} and {}:", file1.display(), file2.display());
println!("Comparison type: {}", arg_name(&comparison_type));
match comparison_type {
ComparisonType::Semantic => {
let _ = semantic_compare(&esm_file1, &esm_file2)?;
}
ComparisonType::Structural => {
perform_structural_comparison(&esm_file1, &esm_file2);
}
ComparisonType::Numerical => {
perform_numerical_comparison(&esm_file1, &esm_file2);
}
}
Ok(())
}
fn run_optimize(file: PathBuf, opt_type: OptimizeTarget) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
let esm_file = load_at(&file, &content)?;
println!("Optimization Analysis for: {}", file.display());
println!("Optimization type: {}", arg_name(&opt_type));
match opt_type {
OptimizeTarget::Expression => {
analyze_expression_optimization(&esm_file);
}
OptimizeTarget::Structure => {
analyze_structure_optimization(&esm_file);
}
OptimizeTarget::Performance => {
analyze_performance_optimization(&esm_file);
}
}
Ok(())
}
fn run_init(name: String, template: InitTemplate) -> Result<(), Box<dyn std::error::Error>> {
println!("Creating new ESM project: {name}");
println!("Template: {}", arg_name(&template));
let project_dir = PathBuf::from(&name);
if project_dir.exists() {
return Err(fail(format!("Directory '{name}' already exists")));
}
fs::create_dir(&project_dir)?;
let template_content = match template {
InitTemplate::Minimal => TEMPLATE_MINIMAL,
InitTemplate::Atmospheric => TEMPLATE_ATMOSPHERIC,
InitTemplate::Ecosystem => TEMPLATE_ECOSYSTEM,
InitTemplate::Coupling => TEMPLATE_COUPLING,
};
let final_content = template_content.replace("{name}", &name);
let esm_file = project_dir.join(format!("{name}.esm"));
if let Err(e) = load_at(&esm_file, &final_content) {
return Err(fail(format!(
"internal error: generated template does not load: {e}"
)));
}
fs::write(&esm_file, final_content)?;
println!(
"Created project '{name}' with template '{}'",
arg_name(&template)
);
println!("Main file: {}", esm_file.display());
Ok(())
}
fn run_validate_fixtures(
directory: PathBuf,
recursive: bool,
) -> Result<(), Box<dyn std::error::Error>> {
println!("Validating fixtures in: {}", directory.display());
println!("Recursive: {recursive}");
if !directory.is_dir() {
return Err(fail(format!(
"Directory does not exist: {}",
directory.display()
)));
}
let mut files = Vec::new();
collect_esm_files(&directory, recursive, &mut files)?;
files.sort();
if files.is_empty() {
println!("No .esm files found");
}
let mut passed = 0usize;
let mut failed = 0usize;
for path in &files {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) => {
println!("✗ {} (cannot read: {e})", path.display());
failed += 1;
continue;
}
};
let result = validate_text(&content, path.parent());
if result.is_valid {
println!("✓ {}", path.display());
passed += 1;
} else {
let n = result.schema_errors.len() + result.structural_errors.len();
println!("✗ {} ({n} error(s))", path.display());
failed += 1;
}
}
println!("\n{passed} passed, {failed} failed ({} total)", files.len());
if failed > 0 {
return Err(fail_silent());
}
Ok(())
}
fn run_benchmark(
file: PathBuf,
bench_type: BenchType,
iterations: usize,
) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
println!("Benchmarking: {}", file.display());
println!("Type: {}, Iterations: {iterations}", arg_name(&bench_type));
match bench_type {
BenchType::Parse => bench_parse(&file, &content, iterations)?,
BenchType::Validate => bench_validate(&file, &content, iterations)?,
BenchType::Simulate => bench_simulate(&file, &content, iterations)?,
BenchType::All => {
bench_parse(&file, &content, iterations)?;
bench_validate(&file, &content, iterations)?;
if let Err(e) = bench_simulate(&file, &content, iterations) {
println!("Simulate: skipped ({e})");
}
}
}
Ok(())
}
fn run_schema_check(
file: PathBuf,
schema_version: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let content = read_input(&file)?;
println!("Schema check for: {}", file.display());
if let Some(ref version) = schema_version {
println!("Target schema version: {version}");
}
match load_at(&file, &content) {
Ok(_) => println!("✓ Schema validation passed"),
Err(e) => {
println!("✗ Schema validation failed: {e}");
return Err(fail_silent());
}
}
Ok(())
}
fn run_round_trip(file: PathBuf, rounds: usize) -> Result<(), Box<dyn std::error::Error>> {
println!(
"Round-trip test for: {} ({} rounds)",
file.display(),
rounds
);
let original_content = read_input(&file)?;
let mut content = original_content.clone();
let opts = earthsci_ast::LoadOptions {
base_path: Some(
file.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."))
.to_path_buf(),
),
..Default::default()
};
for round in 1..=rounds {
let esm_file = earthsci_ast::load_string_with_options(&content, &opts)
.map_err(|e| fail(format!("Round {round}: Load failed: {e}")))?;
content =
to_json(&esm_file).map_err(|e| fail(format!("Round {round}: Save failed: {e}")))?;
println!("Round {round}: OK");
}
let original_esm = earthsci_ast::load_string_with_options(&original_content, &opts)?;
let final_esm = earthsci_ast::load_string_with_options(&content, &opts)?;
let original_json = serde_json::to_value(&original_esm)?;
let final_json = serde_json::to_value(&final_esm)?;
if original_json == final_json {
println!("✓ Round-trip fidelity maintained");
} else {
println!("✗ Round-trip fidelity lost");
return Err(fail_silent());
}
Ok(())
}
const TEST_RELTOL: f64 = 1e-10;
const TEST_ABSTOL: f64 = 1e-14;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Verdict {
Pass,
Fail,
Error,
}
impl Verdict {
fn of(passed: bool, actual: Option<f64>) -> Self {
match (passed, actual) {
(true, _) => Verdict::Pass,
(false, None) => Verdict::Error,
(false, Some(_)) => Verdict::Fail,
}
}
fn label(self) -> &'static str {
match self {
Verdict::Pass => "PASS",
Verdict::Fail => "FAIL",
Verdict::Error => "ERROR",
}
}
fn column(self) -> usize {
match self {
Verdict::Pass => 0,
Verdict::Fail => 1,
Verdict::Error => 2,
}
}
}
fn consumed_data_sources(doc: &serde_json::Value) -> BTreeMap<String, Vec<String>> {
fn visit(node: &serde_json::Value, prefix: &str, out: &mut BTreeMap<String, Vec<String>>) {
if let Some(vars) = node.get("variables").and_then(|v| v.as_object()) {
for (vname, var) in vars {
let Some(update) = var.get("update") else {
continue;
};
if update.get("kind").and_then(|k| k.as_str()) != Some("data") {
continue;
}
let Some(src) = update.get("source").and_then(|s| s.as_str()) else {
continue;
};
out.entry(src.to_string())
.or_default()
.push(format!("{prefix}.{vname}"));
}
}
if let Some(subs) = node.get("subsystems").and_then(|v| v.as_object()) {
for (sname, sub) in subs {
visit(sub, &format!("{prefix}.{sname}"), out);
}
}
}
let declared: Vec<&String> = match doc.get("data_sources").and_then(|v| v.as_object()) {
Some(m) => m.keys().collect(),
None => return BTreeMap::new(),
};
let mut out = BTreeMap::new();
if let Some(models) = doc.get("models").and_then(|v| v.as_object()) {
for (mname, model) in models {
visit(model, mname, &mut out);
}
}
out.retain(|src, _| declared.contains(&src));
out
}
#[cfg(feature = "esio")]
fn cache_root() -> PathBuf {
match std::env::var_os("ESM_CACHE_DIR") {
Some(dir) => PathBuf::from(dir),
None => std::env::temp_dir().join("earthsci-esm-cache"),
}
}
fn consumed_summary(consumed: &BTreeMap<String, Vec<String>>) -> String {
consumed
.iter()
.map(|(src, readers)| format!("'{src}' (read by {})", readers.join(", ")))
.collect::<Vec<_>>()
.join("; ")
}
#[cfg(not(feature = "esio"))]
fn no_reader_diagnostic(consumed: &BTreeMap<String, Vec<String>>) -> String {
format!(
"this `esm` binary cannot ingest data_sources: it was built WITHOUT the \
`esio` feature, so it links no data reader, but the document declares \
{} that {} read: {}. Rebuild the binary with \
`cargo build --release --features esio` (see pkg/earthsci-ast-rs/Cargo.toml). \
Running anyway would evaluate every data-fed parameter at its `default` \
and report the result as the document's answer.",
if consumed.len() == 1 {
"a source"
} else {
"sources"
},
if consumed.len() == 1 {
"it"
} else {
"its parameters"
},
consumed_summary(consumed)
)
}
fn close_declared_extents(
esm_file: earthsci_ast::EsmFile,
reload: impl FnOnce(&BTreeMap<String, i64>) -> Result<earthsci_ast::EsmFile, String>,
) -> Result<
(
earthsci_ast::EsmFile,
Option<Box<earthsci_ast::BuildProviderFactory<'static>>>,
),
String,
> {
let factory = |file: &earthsci_ast::EsmFile| {
serde_json::to_value(file)
.ok()
.and_then(|doc| data_source_providers(&doc))
};
let Some(make) = factory(&esm_file) else {
return Ok((esm_file, None));
};
let Ok(provs) = make() else {
return Ok((esm_file, Some(make)));
};
let mut extents: BTreeMap<String, i64> = BTreeMap::new();
for (key, mut prov) in provs {
let Some(mp) = earthsci_ast::PrepareProvider::extent_metaparameter(&*prov) else {
continue;
};
let Ok(arr) = prov.sample() else {
return Ok((esm_file, Some(make)));
};
let n = arr.shape().first().copied().unwrap_or(0) as i64;
if let Some(prev) = extents.insert(mp.clone(), n)
&& prev != n
{
return Err(format!(
"the data sources binding metaparameter '{mp}' disagree on its extent: \
one measures {prev} records and '{key}' measures {n}. Every source \
declaring the same `extent` metaparameter must measure the same number \
of records — that agreement is what keeps a table's columns aligned."
));
}
}
if extents.is_empty() {
return Ok((esm_file, Some(make)));
}
let reloaded = reload(&extents)?;
let make = factory(&reloaded);
Ok((reloaded, make))
}
#[cfg(feature = "esio")]
fn data_source_providers(
doc: &serde_json::Value,
) -> Option<Box<earthsci_ast::BuildProviderFactory<'static>>> {
let consumed = consumed_data_sources(doc);
if consumed.is_empty() {
return None;
}
let doc = doc.clone();
let cache_root = cache_root();
let summary = consumed_summary(&consumed);
let names: Vec<String> = consumed.into_keys().collect();
Some(Box::new(move || {
let selected: Vec<&str> = names.iter().map(String::as_str).collect();
earthsci_ast::esio_provider::providers_from_document(
&doc,
&cache_root,
Some(&selected),
&HashMap::new(),
)
.map(|provs| {
provs
.into_iter()
.map(|(k, p)| (k, Box::new(p) as Box<dyn earthsci_ast::PrepareProvider>))
.collect()
})
.map_err(|e| format!("data_sources ingest failed for {summary}: {}", e.0))
}))
}
#[cfg(not(feature = "esio"))]
fn data_source_providers(
doc: &serde_json::Value,
) -> Option<Box<earthsci_ast::BuildProviderFactory<'static>>> {
let consumed = consumed_data_sources(doc);
if consumed.is_empty() {
return None;
}
let msg = no_reader_diagnostic(&consumed);
Some(Box::new(move || Err(msg.clone())))
}
struct TestRow {
file: PathBuf,
container: String,
test_id: String,
assertion_idx: usize,
variable: String,
time: f64,
verdict: Verdict,
message: String,
}
fn julia_float(v: f64) -> String {
let rendered = format!("{v}");
if v.is_finite() && !rendered.contains(['.', 'e', 'E']) {
format!("{rendered}.0")
} else {
rendered
}
}
fn discover_test_inputs(paths: &[PathBuf]) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
let mut out = Vec::new();
for path in paths {
if path.is_dir() {
collect_esm_files(path, true, &mut out)?;
} else {
out.push(path.clone());
}
}
out.sort();
out.dedup();
Ok(out)
}
fn relative_to_cwd(path: &std::path::Path) -> String {
std::env::current_dir()
.ok()
.and_then(|cwd| {
path.strip_prefix(&cwd)
.ok()
.map(std::path::Path::to_path_buf)
})
.unwrap_or_else(|| path.to_path_buf())
.display()
.to_string()
}
fn mounted_components(path: &std::path::Path) -> Vec<String> {
let Ok(text) = fs::read_to_string(path) else {
return Vec::new();
};
let Ok(raw) = serde_json::from_str::<serde_json::Value>(&text) else {
return Vec::new();
};
let Some(models) = raw.get("models").and_then(|v| v.as_object()) else {
return Vec::new();
};
models
.iter()
.filter(|(_, m)| m.get("ref").is_some() && m.get("variables").is_none())
.map(|(k, m)| {
let target = m.get("ref").and_then(|v| v.as_str()).unwrap_or("<ref>");
format!("{k} ← {target}")
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn run_test(
paths: Vec<PathBuf>,
model: Option<String>,
filter: Option<String>,
solver: SolverAlg,
reltol: Option<f64>,
abstol: Option<f64>,
verbose: bool,
compiler: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let compiler = match compiler.as_deref() {
None => None,
Some(name) => Some(earthsci_ast::Compiler::parse_named(name).map_err(fail)?),
};
let files = discover_test_inputs(&paths)?;
if files.is_empty() {
let named: Vec<String> = paths.iter().map(|p| p.display().to_string()).collect();
println!("⚠ No .esm files discovered under: {}", named.join(", "));
return Ok(());
}
let opts = earthsci_ast::SolveOptions {
alg: solver.into(),
..Default::default()
};
let mut rows: Vec<TestRow> = Vec::new();
for path in &files {
let first_row = rows.len();
match earthsci_ast::load_path(path) {
Err(e) => rows.push(TestRow {
file: path.clone(),
container: "<parse>".to_string(),
test_id: "<load>".to_string(),
assertion_idx: 0,
variable: String::new(),
time: f64::NAN,
verdict: Verdict::Error,
message: format!("Parse failed: {e}"),
}),
Ok(esm_file) => {
let ready = close_declared_extents(esm_file, |mp| {
earthsci_ast::load_path_with_options(path, mp)
.map_err(|e| format!("re-loading with the discovered source extents: {e}"))
});
let (esm_file, providers) = match ready {
Ok(pair) => pair,
Err(message) => {
rows.push(TestRow {
file: path.clone(),
container: "<data_sources>".to_string(),
test_id: "<extent>".to_string(),
assertion_idx: 0,
variable: String::new(),
time: f64::NAN,
verdict: Verdict::Error,
message,
});
continue;
}
};
let file_opts = earthsci_ast::SolveOptions {
reltol: Some(
reltol
.or(esm_file.solver.as_ref().and_then(|s| s.reltol))
.unwrap_or(TEST_RELTOL),
),
abstol: Some(
abstol
.or(esm_file.solver.as_ref().and_then(|s| s.abstol))
.unwrap_or(TEST_ABSTOL),
),
..opts.clone()
};
let results = earthsci_ast::run_inline_tests_with_options(
&esm_file,
&earthsci_ast::InlineTestOptions {
model_name: model.clone(),
solve: file_opts,
base_dir: path.parent().map(std::path::Path::to_path_buf),
test_filter: filter.clone(),
compiler,
..Default::default()
},
providers.as_deref(),
);
for r in results {
rows.push(TestRow {
file: path.clone(),
container: r.model,
test_id: r.test_id,
assertion_idx: r.assertion_idx,
variable: r.variable,
time: r.time,
verdict: Verdict::of(r.passed, r.actual),
message: r.message,
});
}
}
}
if verbose {
println!("{}", relative_to_cwd(path));
for row in &rows[first_row..] {
println!(
" {:<5} {}/{}[{}] ({}@t={})",
row.verdict.label(),
row.container,
row.test_id,
row.assertion_idx,
row.variable,
julia_float(row.time),
);
if !row.message.is_empty() {
println!(" {}", row.message);
}
}
}
}
print_test_summary(&files, &rows);
if rows.iter().any(|r| r.verdict != Verdict::Pass) {
return Err(fail_silent());
}
Ok(())
}
fn print_test_summary(files: &[PathBuf], rows: &[TestRow]) {
println!();
println!("================ ESM inline-test summary ================");
println!("Files discovered: {}", files.len());
println!("Assertions: {}", rows.len());
let mounts: Vec<(String, String)> = files
.iter()
.flat_map(|path| {
let file = relative_to_cwd(path);
mounted_components(path)
.into_iter()
.map(move |edge| (file.clone(), edge))
})
.collect();
if !mounts.is_empty() {
println!(
"Mounted: {} (esm-spec §6.6 — a mounted component's inline tests are not \
run here; they run when its own file is a test target)",
mounts.len()
);
for (file, edge) in &mounts {
println!(" - {file} :: {edge}");
}
}
if rows.is_empty() {
println!("(no inline tests found)");
println!("=========================================================");
return;
}
let mut by_file: BTreeMap<String, [usize; 3]> = BTreeMap::new();
let mut totals = [0usize; 3];
for row in rows {
by_file.entry(relative_to_cwd(&row.file)).or_default()[row.verdict.column()] += 1;
totals[row.verdict.column()] += 1;
}
let pad = by_file
.keys()
.map(|name| name.chars().count())
.max()
.unwrap_or(20)
.max(20);
let rule = "-".repeat(pad + 25);
println!(
" {:<pad$} {:>5} {:>5} {:>5}",
"file", "pass", "fail", "err"
);
println!(" {rule}");
for (name, counts) in &by_file {
println!(
" {:<pad$} {:>5} {:>5} {:>5}",
name, counts[0], counts[1], counts[2]
);
}
println!(" {rule}");
println!(
" {:<pad$} {:>5} {:>5} {:>5}",
"TOTAL", totals[0], totals[1], totals[2]
);
if totals[1] + totals[2] > 0 {
println!();
println!("Failures:");
for row in rows.iter().filter(|r| r.verdict != Verdict::Pass) {
println!(
" - {} :: {}/{}[{}] ({}@t={}) — {}",
relative_to_cwd(&row.file),
row.container,
row.test_id,
row.assertion_idx,
row.variable,
julia_float(row.time),
row.verdict.label(),
);
if !row.message.is_empty() {
println!(" {}", row.message);
}
}
}
println!("=========================================================");
}
pub fn main() -> std::process::ExitCode {
let cli = Cli::parse();
let result = match cli.command {
Commands::Validate { file, verbose } => run_validate(file, verbose),
Commands::Pretty { file, format } => run_pretty(file, format),
Commands::Extract {
file,
component,
output,
} => run_extract(file, component, output),
Commands::Diff { file1, file2 } => run_diff(file1, file2),
Commands::Stoich { file, system } => run_stoich(file, system),
Commands::Graph {
file,
level,
format,
system,
} => run_graph(file, level, format, system),
Commands::Convert { input, output, to } => run_convert(input, output, to),
Commands::Analyze {
file,
analysis_type,
} => run_analyze(file, analysis_type),
Commands::Simulate {
file,
time,
output,
observed,
model,
format,
compiler,
} => run_simulate(file, time, output, observed, model, format, compiler),
Commands::Info { file } => run_info(file),
Commands::Units { file, check } => run_units(file, check),
Commands::CouplingAnalysis { file, depth } => run_coupling_analysis(file, depth),
Commands::PerformanceProfile { file, profile_type } => {
run_performance_profile(file, profile_type)
}
Commands::Compare {
file1,
file2,
comparison_type,
} => run_compare(file1, file2, comparison_type),
Commands::Optimize { file, opt_type } => run_optimize(file, opt_type),
Commands::Init { name, template } => run_init(name, template),
Commands::ValidateFixtures {
directory,
recursive,
} => run_validate_fixtures(directory, recursive),
Commands::Benchmark {
file,
bench_type,
iterations,
} => run_benchmark(file, bench_type, iterations),
Commands::SchemaCheck {
file,
schema_version,
} => run_schema_check(file, schema_version),
Commands::RoundTrip { file, rounds } => run_round_trip(file, rounds),
Commands::Test {
paths,
model,
filter,
solver,
reltol,
abstol,
verbose,
compiler,
} => run_test(
paths, model, filter, solver, reltol, abstol, verbose, compiler,
),
Commands::ConformanceTest { out_dir, manifest } => {
run_conformance_test(&out_dir, manifest.as_deref())
}
};
match result {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(e) => {
match e.downcast::<CliFailure>() {
Ok(failure) => {
if let Some(message) = failure.message {
eprintln!("{message}");
}
}
Err(other) => eprintln!("Error: {other:?}"),
}
std::process::ExitCode::FAILURE
}
}
}
#[derive(serde::Deserialize)]
struct ConformanceManifest {
validation_files: Vec<ManifestValidationEntry>,
display_cases: Vec<ManifestDisplayCase>,
substitution_cases: Vec<ManifestSubstitutionCase>,
}
#[derive(serde::Deserialize)]
struct ManifestValidationEntry {
id: String,
path: String,
}
#[derive(serde::Deserialize)]
struct ManifestDisplayCase {
id: String,
input: serde_json::Value,
}
#[derive(serde::Deserialize)]
struct ManifestSubstitutionCase {
id: String,
input: serde_json::Value,
bindings: std::collections::BTreeMap<String, serde_json::Value>,
}
fn find_repo_root() -> Option<std::path::PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
if dir.join("tests").join("valid").is_dir() {
return Some(dir);
}
if !dir.pop() {
return None;
}
}
}
fn locate_manifest(
out_dir: &std::path::Path,
explicit: Option<&std::path::Path>,
) -> Result<std::path::PathBuf, Box<dyn std::error::Error>> {
let candidate = match explicit {
Some(p) => p.to_path_buf(),
None => match std::env::var("ESM_CONFORMANCE_MANIFEST") {
Ok(v) if !v.is_empty() => std::path::PathBuf::from(v),
_ => out_dir
.parent()
.unwrap_or(std::path::Path::new("."))
.join("corpus_manifest.json"),
},
};
if !candidate.is_file() {
return Err(format!(
"Corpus manifest not found: {}\nGenerate it with: python3 scripts/conformance_corpus.py --output <path>",
candidate.display()
)
.into());
}
Ok(candidate)
}
fn schema_error_json(e: &earthsci_ast::SchemaError) -> serde_json::Value {
serde_json::json!({
"path": e.path,
"message": e.message,
"keyword": e.keyword,
"code": e.keyword,
"details": {},
})
}
fn structural_error_json(e: &earthsci_ast::StructuralError) -> serde_json::Value {
let code = e.code.to_string();
serde_json::json!({
"path": e.path,
"message": e.message,
"code": code,
"keyword": code,
"details": e.details,
})
}
fn collect_subsystem_ref_errors(
content: &str,
file_path: &std::path::Path,
) -> Vec<earthsci_ast::StructuralError> {
use earthsci_ast::{StructuralError, StructuralErrorCode};
let mut out = Vec::new();
let Ok(json) = serde_json::from_str::<serde_json::Value>(content) else {
return out;
};
let base = file_path
.parent()
.unwrap_or_else(|| std::path::Path::new("."));
if let Some(rs) = json.get("reaction_systems").and_then(|v| v.as_object()) {
for (name, entry) in rs {
if let Some(ref_str) = entry.get("ref").and_then(|v| v.as_str())
&& entry.get("species").is_none()
{
out.push(StructuralError {
path: format!("/reaction_systems/{name}"),
code: StructuralErrorCode::MountFormUnsupported,
message: format!(
"a top-level `reaction_systems.<k>` `{{ref}}` mount (ref '{ref_str}') is \
not supported by this binding"
),
details: serde_json::json!({ "ref": ref_str }),
});
}
}
}
for container in ["models", "reaction_systems"] {
let Some(systems) = json.get(container).and_then(|v| v.as_object()) else {
continue;
};
for (sys_name, sys_val) in systems {
let Some(subs) = sys_val.get("subsystems").and_then(|v| v.as_object()) else {
continue;
};
for (sub_name, sub_val) in subs {
let Some(ref_str) = sub_val.get("ref").and_then(|v| v.as_str()) else {
continue;
};
let path = format!("/{container}/{sys_name}/subsystems/{sub_name}");
let details = serde_json::json!({
"ref": ref_str,
"subsystem": sub_name,
"parent_model": sys_name,
});
if ref_str.starts_with("http://") || ref_str.starts_with("https://") {
out.push(StructuralError {
path,
code: StructuralErrorCode::UnresolvedSubsystemRef,
message: format!(
"Subsystem reference '{ref_str}' is a remote URL and could not be resolved"
),
details,
});
continue;
}
let Ok(canonical) = base.join(ref_str).canonicalize() else {
out.push(StructuralError {
path,
code: StructuralErrorCode::UnresolvedSubsystemRef,
message: format!(
"Subsystem reference '{ref_str}' could not be resolved — file does not exist"
),
details,
});
continue;
};
let doc = fs::read_to_string(&canonical)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok());
let Some(doc) = doc else {
out.push(StructuralError {
path,
code: StructuralErrorCode::UnresolvedSubsystemRef,
message: format!(
"Subsystem reference '{ref_str}' could not be read or parsed"
),
details,
});
continue;
};
let count = ["models", "reaction_systems", "data_sources"]
.iter()
.filter_map(|k| doc.get(*k).and_then(|v| v.as_object()))
.map(|m| m.len())
.sum::<usize>();
if count != 1 {
out.push(StructuralError {
path,
code: StructuralErrorCode::AmbiguousSubsystemRef,
message: format!(
"Subsystem reference '{ref_str}' resolves to a file containing \
multiple top-level systems; exactly one is required"
),
details,
});
}
}
}
}
out
}
fn run_conformance_test(
out_dir: &std::path::Path,
manifest_path: Option<&std::path::Path>,
) -> Result<(), Box<dyn std::error::Error>> {
use serde_json::json;
fs::create_dir_all(out_dir)?;
let manifest_file = locate_manifest(out_dir, manifest_path)?;
let manifest: ConformanceManifest = serde_json::from_str(&fs::read_to_string(&manifest_file)?)?;
let repo_root = find_repo_root()
.ok_or("Could not locate repo root by walking up from the current directory")?;
let validation = conformance_validation_sweep(&manifest, &repo_root);
let display = conformance_display_results(&manifest);
let substitution = conformance_substitution_results(&manifest)?;
let results = json!({
"language": "rust",
"validation_results": validation,
"display_results": display,
"substitution_results": substitution,
"errors": Vec::<String>::new(),
});
let results_file = out_dir.join("results.json");
fs::write(&results_file, serde_json::to_string_pretty(&results)?)?;
println!(
"Rust conformance results written to: {}",
results_file.display()
);
println!(
"✓ Validation sweep completed ({} files); display {} cases; substitution {} cases",
manifest.validation_files.len(),
manifest.display_cases.len(),
manifest.substitution_cases.len()
);
Ok(())
}
fn conformance_validation_sweep(
manifest: &ConformanceManifest,
repo_root: &std::path::Path,
) -> std::collections::BTreeMap<String, serde_json::Value> {
let mut validation: std::collections::BTreeMap<String, serde_json::Value> =
std::collections::BTreeMap::new();
for entry in &manifest.validation_files {
let path = repo_root.join(&entry.path);
validation.insert(
entry.id.clone(),
serde_json::Value::Object(conformance_validation_record(&path)),
);
}
validation
}
fn conformance_validation_record(
path: &std::path::Path,
) -> serde_json::Map<String, serde_json::Value> {
use serde_json::json;
let mut record = serde_json::Map::new();
record.insert("schema_errors".into(), json!([]));
record.insert("structural_errors".into(), json!([]));
let content = fs::read_to_string(path).unwrap_or_default();
let loaded = earthsci_ast::load_path(path);
let resolve_ok = loaded.is_ok();
record.insert("resolve_ok".into(), json!(resolve_ok));
match &loaded {
Ok(esm_file) => {
let schema = validate_text(&content, path.parent());
let structural = validate(esm_file);
record.insert(
"schema_errors".into(),
json!(
schema
.schema_errors
.iter()
.map(schema_error_json)
.collect::<Vec<_>>()
),
);
record.insert(
"structural_errors".into(),
json!(
structural
.structural_errors
.iter()
.map(structural_error_json)
.collect::<Vec<_>>()
),
);
record.insert(
"is_valid".into(),
json!(schema.schema_errors.is_empty() && structural.structural_errors.is_empty()),
);
record.insert("phase".into(), json!("validate"));
}
Err(e) => {
record.insert("error".into(), json!(e.to_string()));
record.insert("error_type".into(), json!("LoadError"));
record.insert("phase".into(), json!("load"));
record.insert("is_valid".into(), json!(false));
let raw = validate_text(&content, path.parent());
let mut structural_errors: Vec<earthsci_ast::StructuralError> =
raw.structural_errors.clone();
structural_errors.extend(collect_subsystem_ref_errors(&content, path));
record.insert(
"schema_errors".into(),
json!(
raw.schema_errors
.iter()
.map(schema_error_json)
.collect::<Vec<_>>()
),
);
record.insert(
"structural_errors".into(),
json!(
structural_errors
.iter()
.map(structural_error_json)
.collect::<Vec<_>>()
),
);
}
}
let is_valid = record
.get("is_valid")
.and_then(|v| v.as_bool())
.unwrap_or(false)
&& resolve_ok;
record.insert("is_valid".into(), json!(is_valid));
record.insert(
"outcome".into(),
json!(if is_valid { "valid" } else { "invalid" }),
);
record
}
fn conformance_display_results(
manifest: &ConformanceManifest,
) -> std::collections::BTreeMap<String, serde_json::Value> {
use serde_json::{Value, json};
let mut display: std::collections::BTreeMap<String, Value> = std::collections::BTreeMap::new();
for case in &manifest.display_cases {
let parsed: Result<earthsci_ast::Expr, _> = serde_json::from_value(case.input.clone());
match parsed {
Ok(expr) => {
display.insert(
case.id.clone(),
json!({
"unicode": earthsci_ast::to_unicode(&expr),
"latex": earthsci_ast::to_latex(&expr),
"ascii": earthsci_ast::to_ascii(&expr),
}),
);
}
Err(e) => {
display.insert(
case.id.clone(),
json!({
"unicode": Value::Null,
"latex": Value::Null,
"ascii": Value::Null,
"errors": { "parse": e.to_string() },
}),
);
}
}
}
display
}
fn conformance_substitution_results(
manifest: &ConformanceManifest,
) -> Result<std::collections::BTreeMap<String, serde_json::Value>, Box<dyn std::error::Error>> {
use serde_json::{Value, json};
let mut substitution: std::collections::BTreeMap<String, Value> =
std::collections::BTreeMap::new();
for case in &manifest.substitution_cases {
let expr: Result<earthsci_ast::Expr, _> = serde_json::from_value(case.input.clone());
let mut bindings: HashMap<String, earthsci_ast::Expr> = HashMap::new();
let mut binding_err: Option<String> = None;
for (name, value) in &case.bindings {
match serde_json::from_value::<earthsci_ast::Expr>(value.clone()) {
Ok(e) => {
bindings.insert(name.clone(), e);
}
Err(e) => binding_err = Some(e.to_string()),
}
}
match (expr, binding_err) {
(Ok(e), None) => {
let result = earthsci_ast::substitute(&e, &bindings);
substitution.insert(
case.id.clone(),
json!({ "result": serde_json::to_value(&result)? }),
);
}
(Err(e), _) => {
substitution.insert(
case.id.clone(),
json!({ "result": Value::Null, "error": e.to_string() }),
);
}
(_, Some(e)) => {
substitution.insert(
case.id.clone(),
json!({ "result": Value::Null, "error": e }),
);
}
}
}
Ok(substitution)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn init_templates_are_loadable() {
for (name, template) in [
("minimal", TEMPLATE_MINIMAL),
("atmospheric", TEMPLATE_ATMOSPHERIC),
("ecosystem", TEMPLATE_ECOSYSTEM),
("coupling", TEMPLATE_COUPLING),
] {
let content = template.replace("{name}", "demo_project");
let esm_file = earthsci_ast::load_string(&content)
.unwrap_or_else(|e| panic!("template '{name}' does not load: {e}"));
let result = validate(&esm_file);
assert!(
result.is_valid,
"template '{name}' fails validation: schema={:?} structural={:?}",
result.schema_errors, result.structural_errors
);
}
}
}