use crate::relational::{Key, canonical_index_set_json, rank, skolem};
use serde_json::{Map, Value};
use std::collections::HashMap;
pub type CadenceError = crate::error::MessageError;
fn err(msg: impl Into<String>) -> CadenceError {
crate::error::MessageError(msg.into())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Cadence {
Const,
Discrete,
Continuous,
}
impl Cadence {
pub fn as_str(self) -> &'static str {
match self {
Cadence::Const => "const",
Cadence::Discrete => "discrete",
Cadence::Continuous => "continuous",
}
}
}
const RELATIONAL_OPS: [&str; 6] = ["distinct", "join", "skolem", "rank", "argmin", "argmax"];
struct Ctx<'a> {
model: &'a Value,
class: crate::classification::Classification,
observed_rhs: HashMap<String, &'a Value>,
memo: HashMap<String, Cadence>,
resolving: Vec<String>,
}
impl<'a> Ctx<'a> {
fn new(model: &'a Value) -> Result<Ctx<'a>, CadenceError> {
let class = crate::classification::Classification::from_json(model).map_err(|e| {
err(format!(
"model does not deserialize for classification: {e}"
))
})?;
let mut observed_rhs: HashMap<String, &'a Value> = HashMap::new();
if let Some(equations) = model.get("equations").and_then(|v| v.as_array()) {
for eq in equations {
let (Some(lhs), Some(rhs)) = (eq.get("lhs"), eq.get("rhs")) else {
continue;
};
let Ok(lhs_expr) = serde_json::from_value::<crate::types::Expr>(lhs.clone()) else {
continue;
};
if let crate::classification::LhsForm::Bare(name) =
crate::classification::lhs_form(&lhs_expr)
&& class.is_observed(&name)
{
observed_rhs.entry(name).or_insert(rhs);
}
}
}
Ok(Ctx {
model,
class,
observed_rhs,
memo: HashMap::new(),
resolving: Vec::new(),
})
}
fn seed_leaf(&mut self, leaf: &Value) -> Result<Cadence, CadenceError> {
match leaf {
Value::Number(_) => Ok(Cadence::Const),
Value::String(s) => {
if s == "t" {
return Ok(Cadence::Continuous);
}
if self.class.is_observed(s) {
return self.seed_observed(s);
}
if self.class.is_ode_state(s)
|| self.class.algebraic_unknowns.iter().any(|n| n == s)
|| self.class.is_brownian(s)
{
return Ok(Cadence::Continuous);
}
if self.class.is_discrete_parameter(s) {
return Ok(self.discrete_parameter_seed(s));
}
Ok(Cadence::Const)
}
other => Err(err(format!("unexpected leaf {other}"))),
}
}
fn discrete_parameter_seed(&self, name: &str) -> Cadence {
let Some(var) = self
.model
.get("variables")
.and_then(|v| v.get(name))
.and_then(|v| serde_json::from_value::<crate::types::ModelVariable>(v.clone()).ok())
else {
return Cadence::Discrete;
};
let sources = var.update_sources();
if sources.is_empty() {
return Cadence::Discrete;
}
let mut seed = Cadence::Const;
for source in sources {
let entry = self
.model
.get("data_sources")
.and_then(|registry| registry.get(source));
let refined = match entry {
Some(e) if e.is_object() && e.get("temporal").is_none() => Cadence::Const,
_ => Cadence::Discrete,
};
seed = seed.max(refined);
}
seed
}
fn seed_observed(&mut self, name: &str) -> Result<Cadence, CadenceError> {
if let Some(c) = self.memo.get(name) {
return Ok(*c);
}
if self.resolving.iter().any(|n| n == name) {
let mut cycle = self.resolving.clone();
cycle.push(name.to_string());
return Err(err(format!(
"observed-definition cycle: {} โ an observed unknown's cadence seed is its \
defining equation's RHS, so a cycle is an implicit solve (ยง5.7.2)",
cycle.join(" -> ")
)));
}
let Some(rhs) = self.observed_rhs.get(name).copied() else {
return Ok(Cadence::Const);
};
self.resolving.push(name.to_string());
let result = self.classify(rhs);
self.resolving.pop();
let class = result?;
self.memo.insert(name.to_string(), class);
Ok(class)
}
fn classify(&mut self, node: &Value) -> Result<Cadence, CadenceError> {
let Value::Object(map) = node else {
return self.seed_leaf(node);
};
let mut acc = Cadence::Const;
for c in child_exprs(map) {
acc = acc.max(self.classify(c)?);
}
Ok(acc)
}
}
pub fn seed_leaf(leaf: &Value, model: &Value) -> Result<Cadence, CadenceError> {
Ctx::new(model)?.seed_leaf(leaf)
}
fn child_exprs(node: &Map<String, Value>) -> Vec<&Value> {
let mut out = Vec::new();
if let Some(Value::Array(args)) = node.get("args") {
out.extend(args.iter());
}
for field in ["expr", "key", "filter", "lower", "upper"] {
if let Some(v) = node.get(field) {
out.push(v);
}
}
out
}
pub fn classify(node: &Value, model: &Value) -> Result<Cadence, CadenceError> {
Ctx::new(model)?.classify(node)
}
pub fn check_expect_cadence(
node: &Value,
model: &Value,
problems: &mut Vec<String>,
) -> Result<(), CadenceError> {
Ctx::new(model)?.check_expect_cadence(node, problems)
}
impl Ctx<'_> {
fn check_expect_cadence(
&mut self,
node: &Value,
problems: &mut Vec<String>,
) -> Result<(), CadenceError> {
let Value::Object(map) = node else {
return Ok(());
};
if let Some(want) = map.get("expect_cadence").and_then(|v| v.as_str()) {
let derived = self.classify(node)?;
if derived.as_str() != want {
problems.push(format!(
"expect_cadence mismatch on op={:?}: declared {:?} but derived {:?}",
map.get("op").and_then(|v| v.as_str()),
want,
derived.as_str()
));
}
}
for c in child_exprs(map) {
self.check_expect_cadence(c, problems)?;
}
Ok(())
}
}
pub fn tally_classes(
node: &Value,
model: &Value,
counts: &mut ClassSummary,
) -> Result<(), CadenceError> {
Ctx::new(model)?.tally_classes(node, counts)
}
impl Ctx<'_> {
fn tally_classes(
&mut self,
node: &Value,
counts: &mut ClassSummary,
) -> Result<(), CadenceError> {
let Value::Object(map) = node else {
return Ok(());
};
if map.contains_key("expect_cadence") {
counts.bump(self.classify(node)?);
}
for c in child_exprs(map) {
self.tally_classes(c, counts)?;
}
Ok(())
}
}
pub fn has_continuous(node: &Value, model: &Value) -> Result<bool, CadenceError> {
Ctx::new(model)?.has_continuous(node)
}
impl Ctx<'_> {
fn has_continuous(&mut self, node: &Value) -> Result<bool, CadenceError> {
if let Value::Object(map) = node {
if self.classify(node)? == Cadence::Continuous {
return Ok(true);
}
for c in child_exprs(map) {
if self.has_continuous(c)? {
return Ok(true);
}
}
Ok(false)
} else {
Ok(self.seed_leaf(node)? == Cadence::Continuous)
}
}
}
#[derive(Debug, Clone)]
pub struct ExprEdge {
pub threshold: String,
pub op: Option<String>,
}
pub fn materialization_frontier(
node: &Value,
model: &Value,
out: &mut Vec<ExprEdge>,
) -> Result<(), CadenceError> {
Ctx::new(model)?.materialization_frontier(node, out)
}
impl Ctx<'_> {
fn materialization_frontier(
&mut self,
node: &Value,
out: &mut Vec<ExprEdge>,
) -> Result<(), CadenceError> {
let Value::Object(map) = node else {
return Ok(());
};
let parent = self.classify(node)?;
for c in child_exprs(map) {
let Value::Object(child_map) = c else {
continue;
};
let cc = self.classify(c)?;
if cc < parent {
out.push(ExprEdge {
threshold: format!("{}->{}", cc.as_str(), parent.as_str()),
op: child_map
.get("op")
.and_then(|v| v.as_str())
.map(str::to_string),
});
} else {
self.materialization_frontier(c, out)?;
}
}
Ok(())
}
}
pub fn assert_no_continuous_relational(node: &Value, model: &Value) -> Result<(), CadenceError> {
Ctx::new(model)?.assert_no_continuous_relational(node)
}
impl Ctx<'_> {
fn assert_no_continuous_relational(&mut self, node: &Value) -> Result<(), CadenceError> {
let Value::Object(map) = node else {
return Ok(());
};
let op = map.get("op").and_then(|v| v.as_str());
let is_relational = op.is_some_and(|o| RELATIONAL_OPS.contains(&o))
|| (op == Some("faq")
&& map
.get("distinct")
.and_then(|v| v.as_bool())
.unwrap_or(false));
if is_relational && self.classify(node)? == Cadence::Continuous {
return Err(err(format!(
"relational/value-invention node op={op:?} classifies CONTINUOUS โ it may \
not run on the hot path (ยง5.7 guard 2). A state-dependent \
distinct/join/skolem/rank is out of scope for v1."
)));
}
for c in child_exprs(map) {
self.assert_no_continuous_relational(c)?;
}
Ok(())
}
}
pub fn assert_acyclic_index_sets(model: &Value) -> Result<(), CadenceError> {
use std::collections::{BTreeMap, BTreeSet};
let mut node_reads: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
fn collect(node: &Value, node_reads: &mut BTreeMap<String, BTreeSet<String>>) {
let Value::Object(map) = node else {
return;
};
if let Some(nid) = map.get("id").and_then(|v| v.as_str()) {
let reads = node_reads.entry(nid.to_string()).or_default();
if let Some(Value::Object(ranges)) = map.get("ranges") {
for r in ranges.values() {
if let Some(from) = r.get("from").and_then(|v| v.as_str()) {
reads.insert(from.to_string());
}
}
}
}
for c in child_exprs(map) {
collect(c, node_reads);
}
}
if let Some(eqs) = model.get("equations").and_then(|v| v.as_array()) {
for eq in eqs {
if let Some(lhs) = eq.get("lhs") {
collect(lhs, &mut node_reads);
}
if let Some(rhs) = eq.get("rhs") {
collect(rhs, &mut node_reads);
}
}
}
let mut set_to_node: BTreeMap<String, String> = BTreeMap::new();
if let Some(Value::Object(sets)) = model.get("index_sets") {
for (name, s) in sets {
if s.get("kind").and_then(|v| v.as_str()) == Some("derived")
&& let Some(node_id) = s.get("from_faq").and_then(|v| v.as_str())
{
set_to_node.insert(name.clone(), node_id.to_string());
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Color {
Gray,
Black,
}
let mut color: BTreeMap<String, Color> = BTreeMap::new();
fn visit(
name: &str,
stack: &mut Vec<String>,
color: &mut std::collections::BTreeMap<String, Color>,
set_to_node: &std::collections::BTreeMap<String, String>,
node_reads: &std::collections::BTreeMap<String, std::collections::BTreeSet<String>>,
) -> Result<(), CadenceError> {
color.insert(name.to_string(), Color::Gray);
stack.push(name.to_string());
if let Some(node_id) = set_to_node.get(name)
&& let Some(reads) = node_reads.get(node_id)
{
for nxt in reads {
if !set_to_node.contains_key(nxt) {
continue; }
match color.get(nxt) {
Some(Color::Gray) => {
let from = stack.iter().position(|n| n == nxt).unwrap_or(0);
let mut cyc: Vec<String> = stack[from..].to_vec();
cyc.push(nxt.clone());
return Err(err(format!(
"cycle in the โคDISCRETE index-set dependency graph \
(implicit solve, out of scope โ ยง5.7 guard 1): {}",
cyc.join(" -> ")
)));
}
Some(Color::Black) => {}
None => visit(nxt, stack, color, set_to_node, node_reads)?,
}
}
}
stack.pop();
color.insert(name.to_string(), Color::Black);
Ok(())
}
for name in set_to_node.keys() {
if !color.contains_key(name) {
visit(name, &mut Vec::new(), &mut color, &set_to_node, &node_reads)?;
}
}
Ok(())
}
pub fn canonical_serialize(value: &Value) -> String {
serde_json::to_string(value).expect("serialising a JSON value never fails")
}
fn fold_to_zero_based(arr: &Value) -> Result<Value, CadenceError> {
let rows = arr
.as_array()
.ok_or_else(|| err("to_zero_based: expected a 2-D array"))?;
let mut out = Vec::with_capacity(rows.len());
for row in rows {
let cells = row
.as_array()
.ok_or_else(|| err("to_zero_based: expected an array of rows"))?;
let mut orow = Vec::with_capacity(cells.len());
for x in cells {
let n = x
.as_i64()
.ok_or_else(|| err(format!("to_zero_based: non-integer component {x}")))?;
orow.push(Value::from(n - 1));
}
out.push(Value::Array(orow));
}
Ok(Value::Array(out))
}
fn edge_keys(face_lo: &Value, face_hi: &Value, mode: &str) -> Result<Vec<Key>, CadenceError> {
let symmetric = mode == "undirected";
let los = face_lo
.as_array()
.ok_or_else(|| err("edge_enumeration: face_lo must be a 2-D array"))?;
let his = face_hi
.as_array()
.ok_or_else(|| err("edge_enumeration: face_hi must be a 2-D array"))?;
let mut keys = Vec::new();
for (f_lo, f_hi) in los.iter().zip(his.iter()) {
let lo_cells = f_lo
.as_array()
.ok_or_else(|| err("edge_enumeration: face_lo row must be an array"))?;
let hi_cells = f_hi
.as_array()
.ok_or_else(|| err("edge_enumeration: face_hi row must be an array"))?;
for (lo, hi) in lo_cells.iter().zip(hi_cells.iter()) {
let klo = Key::try_from_json(lo).map_err(|e| {
err(format!(
"float component forbidden in a topology key (ยง5.5 rule 1): {e}"
))
})?;
let khi = Key::try_from_json(hi).map_err(|e| {
err(format!(
"float component forbidden in a topology key (ยง5.5 rule 1): {e}"
))
})?;
keys.push(skolem(vec![klo, khi], symmetric));
}
}
Ok(keys)
}
pub fn compute_fold(label: &str, spec: &Value, inputs: &Value) -> Result<String, CadenceError> {
let kind = spec
.get("fold")
.and_then(|v| v.as_str())
.ok_or_else(|| err(format!("buffer {label:?}: missing fold kind")))?;
let input = |name: &str| {
inputs
.get(name)
.ok_or_else(|| err(format!("buffer {label:?}: missing fold input {name:?}")))
};
let array_name = spec.get("array").and_then(|v| v.as_str()).unwrap_or(label);
let mode = inputs
.get("skolem")
.and_then(|v| v.as_str())
.unwrap_or("undirected");
match kind {
"to_zero_based" => Ok(canonical_serialize(&fold_to_zero_based(input(
array_name,
)?)?)),
"identity" => Ok(canonical_serialize(input(array_name)?)),
"edge_enumeration" => {
let keys = edge_keys(input("face_lo")?, input("face_hi")?, mode)?;
Ok(canonical_index_set_json(&keys))
}
"rank" => {
let keys = edge_keys(input("face_lo")?, input("face_hi")?, mode)?;
let ids: Vec<Value> = rank(&keys)
.canonical_dense_ids()
.into_iter()
.map(Value::from)
.collect();
Ok(canonical_serialize(&Value::Array(ids)))
}
other => Err(err(format!(
"buffer {label:?}: unknown fold kind {other:?}"
))),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ClassSummary {
pub const_: u64,
pub discrete: u64,
pub continuous: u64,
}
impl ClassSummary {
fn bump(&mut self, c: Cadence) {
match c {
Cadence::Const => self.const_ += 1,
Cadence::Discrete => self.discrete += 1,
Cadence::Continuous => self.continuous += 1,
}
}
pub fn to_json(&self) -> Value {
serde_json::json!({
"const": self.const_,
"discrete": self.discrete,
"continuous": self.continuous,
})
}
}
#[derive(Debug, Clone)]
pub struct MaterializationPoint {
pub label: Option<String>,
pub kind: &'static str,
pub threshold: String,
}
#[derive(Debug, Clone)]
pub struct Partition {
pub class_summary: ClassSummary,
pub materialization_points: Vec<MaterializationPoint>,
pub hot_tree_empty: bool,
pub event_handler_empty: bool,
}
fn output_label(eq: &Value, rhs: &Map<String, Value>) -> Option<String> {
if let Some(id) = rhs.get("id").and_then(|v| v.as_str()) {
return Some(id.to_string());
}
let lhs = eq.get("lhs")?;
let args = lhs.get("args").and_then(|v| v.as_array())?;
args.first().and_then(|v| v.as_str()).map(str::to_string)
}
fn lhs_target_is_arrayed(eq: &Value, model: &Value) -> bool {
let Some(lhs) = eq.get("lhs") else {
return false;
};
let target = match lhs {
Value::String(name) => Some(name.as_str()),
_ => lhs
.get("args")
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_str()),
};
let Some(target) = target else {
return true;
};
model
.get("variables")
.and_then(|v| v.get(target))
.and_then(|v| v.get("shape"))
.and_then(|v| v.as_array())
.is_some_and(|s| !s.is_empty())
}
pub fn model_with_loaders(model: &Value, doc: &Value) -> Value {
let mut model = model.clone();
if let Value::Object(map) = &mut model {
if let Some(loaders) = doc.get("data_sources") {
map.entry("data_sources".to_string())
.or_insert_with(|| loaders.clone());
}
if let Some(index_sets) = doc.get("index_sets") {
map.entry("index_sets".to_string())
.or_insert_with(|| index_sets.clone());
}
}
model
}
pub fn partition_model(model: &Value) -> Result<Partition, CadenceError> {
let empty = Vec::new();
let equations = model
.get("equations")
.and_then(|v| v.as_array())
.unwrap_or(&empty);
let mut ctx = Ctx::new(model)?;
let mut problems = Vec::new();
for eq in equations {
if let Some(rhs) = eq.get("rhs") {
ctx.check_expect_cadence(rhs, &mut problems)?;
}
}
if !problems.is_empty() {
return Err(err(problems.join("; ")));
}
for eq in equations {
if let Some(rhs) = eq.get("rhs") {
ctx.assert_no_continuous_relational(rhs)?;
}
}
assert_acyclic_index_sets(model)?;
let mut class_summary = ClassSummary::default();
let mut materialization_points = Vec::new();
let mut hot_tree_empty = true;
for eq in equations {
let Some(rhs) = eq.get("rhs") else { continue };
let Value::Object(rhs_map) = rhs else {
continue;
};
ctx.tally_classes(rhs, &mut class_summary)?;
if ctx.has_continuous(rhs)? {
hot_tree_empty = false;
}
let mut edges = Vec::new();
ctx.materialization_frontier(rhs, &mut edges)?;
for e in edges {
materialization_points.push(MaterializationPoint {
label: e.op.clone(),
kind: "expr_edge",
threshold: e.threshold,
});
}
if lhs_target_is_arrayed(eq, model) && ctx.classify(rhs)? == Cadence::Const {
materialization_points.push(MaterializationPoint {
label: output_label(eq, rhs_map),
kind: "output_buffer",
threshold: "const->artifact".to_string(),
});
}
}
let event_handler_empty = !materialization_points
.iter()
.any(|m| m.threshold.starts_with("discrete"));
Ok(Partition {
class_summary,
materialization_points,
hot_tree_empty,
event_handler_empty,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const MIXED: &str = include_str!("../../../tests/valid/cadence/mixed_stencil.esm");
const TOPOLOGY: &str = include_str!("../../../tests/valid/cadence/pure_topology.esm");
const POINTWISE: &str = include_str!("../../../tests/valid/cadence/pure_pointwise.esm");
fn model(fixture: &str, name: &str) -> Value {
let doc: Value = serde_json::from_str(fixture).expect("fixture parses");
doc["models"][name].clone()
}
fn thresholds(p: &Partition) -> Vec<String> {
let mut t: Vec<String> = p
.materialization_points
.iter()
.map(|m| m.threshold.clone())
.collect();
t.sort();
t
}
#[test]
fn mixed_stencil_classes_and_frontier() {
let m = model(MIXED, "MixedStencilDiffusion");
let p = partition_model(&m).expect("partition");
assert_eq!(
p.class_summary,
ClassSummary {
const_: 2,
discrete: 1,
continuous: 6
}
);
assert_eq!(
thresholds(&p),
vec![
"const->continuous",
"const->continuous",
"discrete->continuous"
]
);
assert!(!p.hot_tree_empty);
assert!(!p.event_handler_empty);
}
#[test]
fn pure_topology_all_const_empty_hot_tree() {
let m = model(TOPOLOGY, "PureTopologyEdges");
let p = partition_model(&m).expect("partition");
assert_eq!(
p.class_summary,
ClassSummary {
const_: 6,
discrete: 0,
continuous: 0
}
);
assert_eq!(thresholds(&p), vec!["const->artifact", "const->artifact"]);
assert!(p.hot_tree_empty);
assert!(p.event_handler_empty);
}
#[test]
fn pure_pointwise_all_continuous_no_materialization() {
let m = model(POINTWISE, "PurePointwiseForcing");
let p = partition_model(&m).expect("partition");
assert_eq!(
p.class_summary,
ClassSummary {
const_: 0,
discrete: 0,
continuous: 6
}
);
assert!(p.materialization_points.is_empty());
assert!(!p.hot_tree_empty);
assert!(p.event_handler_empty);
}
#[test]
fn gather_split_is_independent_of_the_array() {
let m = model(MIXED, "MixedStencilDiffusion");
let outer = json!({
"op": "index",
"args": ["u", {"op": "index", "args": ["nbr", "i", "k"]}]
});
let inner = json!({"op": "index", "args": ["nbr", "i", "k"]});
assert_eq!(classify(&outer, &m).unwrap(), Cadence::Continuous);
assert_eq!(classify(&inner, &m).unwrap(), Cadence::Const);
}
#[test]
fn loader_temporal_seeds_discrete_no_temporal_seeds_const() {
let variables = json!({
"c": {"type": "unknown", "shape": ["cells"]},
"bc": {"type": "parameter", "shape": ["cells"],
"update": {"kind": "data", "source": "bc_loader", "from": {"file_variable": "F"}}}
});
let bc = json!({"op": "index", "args": ["bc", "i"]});
let with_temporal = json!({
"variables": variables.clone(),
"data_sources": {"bc_loader": {"kind": "grid", "temporal": {"frequency": "PT6H"}}}
});
assert_eq!(classify(&bc, &with_temporal).unwrap(), Cadence::Discrete);
let no_temporal = json!({
"variables": variables.clone(),
"data_sources": {"bc_loader": {"kind": "static"}}
});
assert_eq!(classify(&bc, &no_temporal).unwrap(), Cadence::Const);
let bare = json!({ "variables": variables });
assert_eq!(classify(&bc, &bare).unwrap(), Cadence::Discrete);
}
#[test]
fn model_with_loaders_attaches_top_level_loaders() {
let doc = json!({
"models": {"M": {"variables": {}, "equations": []}},
"data_sources": {"bc_loader": {"kind": "static"}}
});
let model = model_with_loaders(&doc["models"]["M"], &doc);
assert!(
model
.get("data_sources")
.and_then(|l| l.get("bc_loader"))
.is_some()
);
let doc2 = json!({ "models": {"M": {"variables": {}}} });
let model2 = model_with_loaders(&doc2["models"]["M"], &doc2);
assert!(model2.get("data_sources").is_none());
}
#[test]
fn const_folds_are_byte_identical_to_golden() {
let nbr = json!([[2, 4, 1], [3, 1, 2], [4, 2, 3], [1, 3, 4]]);
let coeff = json!([[1, 1, 0], [1, 1, 0], [1, 1, 0], [1, 1, 0]]);
let inputs = json!({ "nbr": nbr, "coeff": coeff });
assert_eq!(
compute_fold(
"nbr_idx",
&json!({"fold": "to_zero_based", "array": "nbr"}),
&inputs
)
.unwrap(),
"[[1,3,0],[2,0,1],[3,1,2],[0,2,3]]"
);
assert_eq!(
compute_fold("coeff", &json!({"fold": "identity"}), &inputs).unwrap(),
"[[1,1,0],[1,1,0],[1,1,0],[1,1,0]]"
);
let topo = json!({
"face_lo": [[1, 2, 3], [3, 2, 4]],
"face_hi": [[2, 3, 1], [2, 4, 3]],
"skolem": "undirected"
});
assert_eq!(
compute_fold("edges", &json!({"fold": "edge_enumeration"}), &topo).unwrap(),
"[[1,2],[1,3],[2,3],[2,4],[3,4]]"
);
assert_eq!(
compute_fold("edge_dense_id", &json!({"fold": "rank"}), &topo).unwrap(),
"[0,1,2,3,4]"
);
}
#[test]
fn guards_pass_on_the_good_fixtures() {
for (fx, name) in [
(MIXED, "MixedStencilDiffusion"),
(TOPOLOGY, "PureTopologyEdges"),
(POINTWISE, "PurePointwiseForcing"),
] {
let m = model(fx, name);
partition_model(&m).expect("guards must not reject a valid fixture");
}
}
#[test]
fn neg_wrong_expect_cadence_is_flagged() {
let mut m = model(MIXED, "MixedStencilDiffusion");
let coeff_node = &mut m["equations"][0]["rhs"]["expr"]["args"][1]["args"][0];
assert_eq!(coeff_node["expect_cadence"], json!("const"));
coeff_node["expect_cadence"] = json!("continuous");
let mut problems = Vec::new();
check_expect_cadence(&m["equations"][0]["rhs"], &m, &mut problems).unwrap();
assert!(
!problems.is_empty(),
"a wrong expect_cadence must be flagged"
);
}
#[test]
fn neg_continuous_relational_is_rejected() {
let bad = json!({
"variables": {"u": {"type": "unknown"}, "lo": {"type": "parameter"}},
"index_sets": {"faces": {"kind": "interval", "size": 4}},
"equations": [{
"lhs": {"op": "index", "args": ["edge_exists", "e"]},
"rhs": {
"op": "faq", "distinct": true, "semiring": "bool_and_or",
"output_idx": ["e"], "ranges": {"f": {"from": "faces"}},
"key": {"op": "skolem", "label": "edge", "args": [{"op": "index", "args": ["u", "f"]}]},
"expr": {"op": "true", "args": []}
}
}]
});
let rhs = &bad["equations"][0]["rhs"];
assert!(
assert_no_continuous_relational(rhs, &bad).is_err(),
"guard 2 must reject a state-dependent distinct"
);
}
#[test]
fn neg_from_faq_cycle_is_rejected() {
let cyclic = json!({
"variables": {},
"index_sets": {
"setA": {"kind": "derived", "from_faq": "nodeA"},
"setB": {"kind": "derived", "from_faq": "nodeB"}
},
"equations": [
{"lhs": {"op": "index", "args": ["a", "x"]},
"rhs": {"op": "faq", "id": "nodeA", "distinct": true,
"semiring": "bool_and_or", "output_idx": ["x"],
"ranges": {"y": {"from": "setB"}},
"expr": {"op": "true", "args": []}}},
{"lhs": {"op": "index", "args": ["b", "x"]},
"rhs": {"op": "faq", "id": "nodeB", "distinct": true,
"semiring": "bool_and_or", "output_idx": ["x"],
"ranges": {"y": {"from": "setA"}},
"expr": {"op": "true", "args": []}}}
]
});
assert!(
assert_acyclic_index_sets(&cyclic).is_err(),
"guard 1 must reject a from_faq cycle"
);
}
#[test]
fn neg_float_topology_key_is_rejected() {
let topo = json!({"face_lo": [[1.5]], "face_hi": [[2]], "skolem": "undirected"});
assert!(
compute_fold("edges", &json!({"fold": "edge_enumeration"}), &topo).is_err(),
"a float topology key must be rejected (ยง5.5 rule 1)"
);
}
}