use crate::types::{Expr, ExpressionNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Arity {
Exact(usize),
Between(usize, usize),
AtLeast(usize),
Any,
}
impl Arity {
#[must_use]
pub fn admits(self, n: usize) -> bool {
match self {
Self::Exact(k) => n == k,
Self::Between(lo, hi) => n >= lo && n <= hi,
Self::AtLeast(k) => n >= k,
Self::Any => true,
}
}
#[must_use]
pub fn describe(self) -> String {
match self {
Self::Exact(k) => format!("exactly {k}"),
Self::Between(lo, hi) => format!("{lo} or {hi}"),
Self::AtLeast(k) => format!("at least {k}"),
Self::Any => "any number of".to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpError {
Arity {
op: String,
got: usize,
expected: String,
},
Unlowered {
op: String,
},
MakearrayRegion {
reason: String,
},
BroadcastFn {
fn_name: Option<String>,
reason: String,
},
}
impl std::fmt::Display for OpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Arity { op, got, expected } => {
write!(f, "operator '{op}' takes {expected} argument(s), got {got}")
}
Self::Unlowered { op } => write!(
f,
"operator '{op}' has no evaluator and was not lowered by any rewrite rule"
),
Self::MakearrayRegion { reason } | Self::BroadcastFn { reason, .. } => {
write!(f, "{reason}")
}
}
}
}
pub fn check_makearray_regions(node: &ExpressionNode) -> Result<(), OpError> {
let Some(regions) = node.regions.as_deref() else {
return Ok(());
};
let bad = |reason: String| Err(OpError::MakearrayRegion { reason });
if let Some(values) = node.values.as_deref()
&& values.len() != regions.len()
{
return bad(format!(
"makearray has {} region(s) but {} value(s) — they must correspond one-to-one",
regions.len(),
values.len()
));
}
let Some(first) = regions.first() else {
return Ok(());
};
let ndim = first.len();
for (i, region) in regions.iter().enumerate() {
if region.len() != ndim {
return bad(format!(
"makearray region {i} has rank {} but region 0 has rank {ndim} — every region \
must address the same number of dimensions",
region.len()
));
}
for (d, pair) in region.iter().enumerate() {
let Some([start, stop]) = crate::types::region_bounds(pair) else {
continue;
};
if stop < start - 1 {
return bad(format!(
"makearray region {i} dimension {d} has inverted bounds [{start}, {stop}]: \
stop < start - 1 (the empty spelling [{start}, {}] is legal, anything \
further inverted is not)",
start - 1
));
}
}
}
Ok(())
}
#[must_use]
pub fn arity_of(op: &str) -> Option<Arity> {
let a = match op {
"+" | "*" => Arity::AtLeast(1),
"-" => Arity::Between(1, 2),
"/" | "^" | "pow" => Arity::Exact(2),
"neg" => Arity::Exact(1),
crate::precision_infer::MARKER_OP => Arity::Exact(1),
"exp" | "log" | "log10" | "sqrt" | "abs" | "sign" | "floor" | "ceil" | "sin" | "cos"
| "tan" | "asin" | "acos" | "atan" | "sinh" | "cosh" | "tanh" | "asinh" | "acosh"
| "atanh" => Arity::Exact(1),
"atan2" => Arity::Exact(2),
"min" | "max" => Arity::AtLeast(2),
"ifelse" => Arity::Exact(3),
"==" | "!=" | "<" | "<=" | ">" | ">=" => Arity::Exact(2),
"and" | "or" => Arity::AtLeast(2),
"not" => Arity::Exact(1),
"D" | "ic" => Arity::Exact(1),
"Pre" => Arity::Exact(1),
"const" => Arity::Exact(0),
"true" | "false" => Arity::Exact(0),
"fn" => Arity::Any,
"enum" => Arity::Exact(2),
"table_lookup" => Arity::Exact(0),
"apply_expression_template" => Arity::Any,
"faq" => Arity::Any,
"makearray" => Arity::Any,
"index" => Arity::AtLeast(1),
"broadcast" => Arity::AtLeast(1),
"reshape" | "transpose" => Arity::Exact(1),
"concat" => Arity::AtLeast(1),
"skolem" | "rank" | "distinct" => Arity::Any,
"argmin" | "argmax" => Arity::Any,
"intersect_polygon" | "polygon_intersection_area" => Arity::Exact(2),
_ => return None,
};
Some(a)
}
#[must_use]
pub fn is_core_op(op: &str) -> bool {
arity_of(op).is_some()
}
#[must_use]
pub fn is_builtin_function_name(name: &str) -> bool {
matches!(
name,
"exp"
| "log"
| "log10"
| "sqrt"
| "abs"
| "sign"
| "sin"
| "cos"
| "tan"
| "asin"
| "acos"
| "atan"
| "atan2"
| "sinh"
| "cosh"
| "tanh"
| "asinh"
| "acosh"
| "atanh"
| "min"
| "max"
| "floor"
| "ceil"
| "ifelse"
| "Pre"
)
}
#[must_use]
pub fn is_scalar_operator(op: &str) -> bool {
matches!(
op,
"+" | "-" | "*" | "/" | "^" | "neg"
| "exp" | "log" | "log10" | "sqrt" | "abs" | "sign" | "floor" | "ceil"
| "sin" | "cos" | "tan" | "asin" | "acos" | "atan"
| "sinh" | "cosh" | "tanh" | "asinh" | "acosh" | "atanh"
| "atan2" | "min" | "max"
| "==" | "!=" | "<" | "<=" | ">" | ">=" | "and" | "or" | "not" | "ifelse"
)
}
#[must_use]
pub fn is_elementwise_node(node: &ExpressionNode) -> bool {
if node.op == "broadcast" {
return is_scalar_operator(node.broadcast_fn.as_deref().unwrap_or("+"));
}
is_scalar_operator(&node.op)
}
pub fn check_broadcast_fn(node: &ExpressionNode) -> Result<(), OpError> {
let Some(fn_name) = node.broadcast_fn.as_deref() else {
return Err(OpError::BroadcastFn {
fn_name: None,
reason: "'broadcast' is missing its 'fn': the field names the scalar operator to \
apply element-wise and has no default (esm-spec §4.3.4)"
.to_string(),
});
};
let Some(arity) = arity_of(fn_name).filter(|_| is_scalar_operator(fn_name)) else {
return Err(OpError::BroadcastFn {
fn_name: Some(fn_name.to_string()),
reason: format!(
"'broadcast' fn '{fn_name}' does not name a scalar operator (esm-spec §4.3.4)"
),
});
};
if !arity.admits(node.args.len()) {
return Err(OpError::Arity {
op: fn_name.to_string(),
got: node.args.len(),
expected: arity.describe(),
});
}
Ok(())
}
pub const STRUCTURAL_DERIVATIVE_WRT: &str = "t";
#[must_use]
pub fn derivative_wrt(node: &ExpressionNode) -> &str {
node.wrt.as_deref().unwrap_or(STRUCTURAL_DERIVATIVE_WRT)
}
#[must_use]
pub fn is_rewrite_target_derivative(node: &ExpressionNode) -> bool {
node.op == "D" && derivative_wrt(node) != STRUCTURAL_DERIVATIVE_WRT
}
pub fn check_node(node: &ExpressionNode) -> Result<(), OpError> {
let op = node.op.as_str();
if is_rewrite_target_derivative(node) {
return Err(OpError::Unlowered { op: op.to_string() });
}
let Some(arity) = arity_of(op) else {
return Err(OpError::Unlowered { op: op.to_string() });
};
if !arity.admits(node.args.len()) {
return Err(OpError::Arity {
op: op.to_string(),
got: node.args.len(),
expected: arity.describe(),
});
}
if op == "makearray" {
check_makearray_regions(node)?;
}
if op == "broadcast" {
check_broadcast_fn(node)?;
}
Ok(())
}
pub fn check_expr(expr: &Expr) -> Result<(), OpError> {
match expr {
Expr::Number(_) | Expr::Integer(_) | Expr::Variable(_) => Ok(()),
Expr::Operator(node) => {
check_node(node)?;
let mut first_err: Option<OpError> = None;
node.for_each_child(&mut |child| {
if first_err.is_none()
&& let Err(e) = check_expr(child)
{
first_err = Some(e);
}
});
match first_err {
Some(e) => Err(e),
None => Ok(()),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn node(json: serde_json::Value) -> Expr {
serde_json::from_value(json).expect("fixture parses")
}
#[test]
fn spec_arities_are_pinned() {
assert_eq!(arity_of("atan2"), Some(Arity::Exact(2)));
assert_eq!(arity_of("min"), Some(Arity::AtLeast(2)));
assert_eq!(arity_of("max"), Some(Arity::AtLeast(2)));
assert_eq!(arity_of("/"), Some(Arity::Exact(2)));
assert_eq!(arity_of("^"), Some(Arity::Exact(2)));
assert_eq!(arity_of("-"), Some(Arity::Between(1, 2)));
assert_eq!(arity_of("+"), Some(Arity::AtLeast(1)));
assert_eq!(arity_of("ifelse"), Some(Arity::Exact(3)));
assert_eq!(arity_of("const"), Some(Arity::Exact(0)));
assert_eq!(arity_of("expp"), None, "a typo is not a core op");
}
#[test]
fn underapplied_atan2_is_an_arity_error() {
let e = check_expr(&node(serde_json::json!({"op": "atan2", "args": [1.0]})))
.expect_err("atan2/1 must be rejected");
assert!(matches!(e, OpError::Arity { ref op, got: 1, .. } if op == "atan2"));
let e = check_expr(&node(serde_json::json!({"op": "atan2", "args": []})))
.expect_err("atan2/0 must be rejected");
assert!(matches!(e, OpError::Arity { got: 0, .. }));
}
#[test]
fn empty_neg_is_an_arity_error() {
assert!(matches!(
check_expr(&node(serde_json::json!({"op": "neg", "args": []}))),
Err(OpError::Arity { got: 0, .. })
));
}
#[test]
fn the_divergent_arities_are_all_rejected() {
for j in [
serde_json::json!({"op": "-", "args": [3.0, 1.0, 1.0]}),
serde_json::json!({"op": "/", "args": [8.0, 2.0, 2.0]}),
serde_json::json!({"op": "^", "args": [2.0, 3.0, 4.0]}),
serde_json::json!({"op": "min", "args": [5.0]}),
serde_json::json!({"op": "max", "args": [5.0]}),
serde_json::json!({"op": "and", "args": [5.0]}),
] {
let e = check_expr(&node(j.clone()));
assert!(
matches!(e, Err(OpError::Arity { .. })),
"{j} must be an arity error, got {e:?}"
);
}
}
#[test]
fn legal_arities_pass() {
for j in [
serde_json::json!({"op": "+", "args": ["a", "b", "c"]}),
serde_json::json!({"op": "-", "args": ["a"]}),
serde_json::json!({"op": "-", "args": ["a", "b"]}),
serde_json::json!({"op": "min", "args": ["a", "b", "c"]}),
serde_json::json!({"op": "atan2", "args": ["a", "b"]}),
serde_json::json!({"op": "ifelse", "args": ["c", "a", "b"]}),
serde_json::json!({"op": "D", "args": ["u"], "wrt": "t"}),
] {
check_expr(&node(j.clone())).unwrap_or_else(|e| panic!("{j} should be legal: {e}"));
}
}
#[test]
fn a_typo_is_an_unlowered_operator() {
let e = check_expr(&node(serde_json::json!({"op": "expp", "args": ["x"]})))
.expect_err("a typo must be rejected");
assert!(matches!(e, OpError::Unlowered { ref op } if op == "expp"));
}
#[test]
fn sugar_and_spatial_d_are_unlowered() {
for j in [
serde_json::json!({"op": "grad", "args": ["u"], "dim": "x"}),
serde_json::json!({"op": "laplacian", "args": ["u"]}),
serde_json::json!({"op": "D", "args": ["u"], "wrt": "x"}),
] {
assert!(
matches!(check_expr(&node(j.clone())), Err(OpError::Unlowered { .. })),
"{j} must be unlowered"
);
}
}
#[test]
fn ragged_and_inverted_makearray_regions_are_rejected() {
let ragged = node(serde_json::json!({
"op": "makearray",
"regions": [[[1, 3]], [[1, 3], [1, 2]]],
"values": [0.0, 1.0],
"args": []
}));
assert!(
matches!(check_expr(&ragged), Err(OpError::MakearrayRegion { .. })),
"a ragged regions list must be rejected, not panic"
);
let inverted = node(serde_json::json!({
"op": "makearray",
"regions": [[[5, 2]]],
"values": [0.0],
"args": []
}));
assert!(
matches!(check_expr(&inverted), Err(OpError::MakearrayRegion { .. })),
"an inverted region must be rejected, not overflow ArrayD::zeros"
);
}
#[test]
fn the_empty_region_spelling_is_legal() {
let empty = node(serde_json::json!({
"op": "makearray",
"regions": [[[2, 1]], [[1, 1]]],
"values": [0.0, 1.0],
"args": []
}));
check_expr(&empty).expect("[2, 1] is the canonical empty region and is legal");
}
#[test]
fn the_walk_descends_into_sidecar_fields() {
let agg = node(serde_json::json!({
"op": "faq",
"output_idx": ["i"],
"expr": {"op": "atan2", "args": [1.0]},
"args": ["u"]
}));
assert!(
matches!(check_expr(&agg), Err(OpError::Arity { .. })),
"an arity error inside `aggregate.expr` must be found"
);
}
const SCALAR: &[&str] = &[
"+", "-", "*", "/", "^", "neg", "exp", "log", "log10", "sqrt", "abs", "sign", "floor",
"ceil", "sin", "cos", "tan", "asin", "acos", "atan", "sinh", "cosh", "tanh", "asinh",
"acosh", "atanh", "atan2", "min", "max", "ifelse", "==", "!=", "<", "<=", ">", ">=", "and",
"or", "not",
];
const NOT_SCALAR: &[&str] = &[
"D",
"ic",
"Pre",
"const",
"true",
"false",
"fn",
"enum",
"table_lookup",
"apply_expression_template",
"faq",
"makearray",
"index",
"broadcast",
"reshape",
"transpose",
"concat",
"skolem",
"rank",
"distinct",
"argmin",
"argmax",
"intersect_polygon",
"polygon_intersection_area",
];
#[test]
fn the_scalar_operator_set_partitions_the_core() {
for op in SCALAR {
assert!(is_core_op(op), "`{op}` is scalar but not a core op");
assert!(is_scalar_operator(op), "`{op}` should be a scalar operator");
}
for op in NOT_SCALAR {
assert!(is_core_op(op), "`{op}` is listed but not a core op");
assert!(
!is_scalar_operator(op),
"`{op}` should not be a scalar operator"
);
}
}
#[test]
fn every_scalar_operator_has_an_arity() {
for op in SCALAR {
assert!(is_scalar_operator(op), "`{op}` must be a scalar operator");
assert!(
arity_of(op).is_some(),
"scalar operator `{op}` has no arity entry"
);
}
}
#[test]
fn builtin_function_names_are_core_and_nothing_widened() {
const BUILTIN: &[&str] = &[
"exp", "log", "log10", "sqrt", "abs", "sign", "sin", "cos", "tan", "asin", "acos",
"atan", "atan2", "sinh", "cosh", "tanh", "asinh", "acosh", "atanh", "min", "max",
"floor", "ceil", "ifelse", "Pre",
];
for name in BUILTIN {
assert!(
is_builtin_function_name(name),
"`{name}` must be an excused built-in function head"
);
assert!(is_core_op(name), "`{name}` is excused but not a core op");
}
for name in [
"neg", "ln", "and", "or", "not", "D", "ic", "const", "true", "fn", "faq", "index",
"expp", "t",
] {
assert!(
!is_builtin_function_name(name),
"`{name}` must not be excused as a built-in function head"
);
}
}
#[test]
fn pow_and_false_are_core_and_the_other_spellings_are_not() {
assert_eq!(arity_of("pow"), Some(Arity::Exact(2)));
assert_eq!(arity_of("false"), Some(Arity::Exact(0)));
for op in ["pow", "false"] {
assert!(
!is_scalar_operator(op),
"`{op}` must not be a scalar operator"
);
}
for op in ["**", "power", "="] {
assert!(!is_core_op(op), "`{op}` must not be a registry op");
assert!(
!is_scalar_operator(op),
"`{op}` must not be a scalar operator"
);
assert!(
matches!(
check_node(&ExpressionNode {
op: op.to_string(),
args: vec![Expr::Variable("z".into()), Expr::Number(2.0)],
..Default::default()
}),
Err(OpError::Unlowered { .. })
),
"`{op}` must be reported as an open-tier / unlowered operator"
);
}
}
#[test]
fn non_pointwise_ops_are_not_scalar_operators() {
for op in NOT_SCALAR.iter().copied().chain(["grad", "not_a_real_op"]) {
assert!(
!is_scalar_operator(op),
"`{op}` must NOT be usable as a `broadcast` fn"
);
}
}
#[test]
fn unregistered_broadcast_fn_is_rejected() {
let e = check_expr(&node(serde_json::json!({
"op": "broadcast", "fn": "not_a_real_op", "args": ["x"]
})))
.expect_err("an unregistered `fn` must be rejected");
assert!(
matches!(e, OpError::BroadcastFn { fn_name: Some(ref f), .. } if f == "not_a_real_op"),
"got {e:?}"
);
}
#[test]
fn missing_broadcast_fn_is_rejected() {
let e = check_expr(&node(
serde_json::json!({"op": "broadcast", "args": ["x", "y"]}),
))
.expect_err("a `broadcast` with no `fn` must be rejected");
assert!(
matches!(e, OpError::BroadcastFn { fn_name: None, .. }),
"got {e:?}"
);
}
#[test]
fn non_scalar_broadcast_fn_is_rejected() {
for f in ["faq", "index", "makearray", "broadcast", "fn", "D"] {
let e = check_expr(&node(serde_json::json!({
"op": "broadcast", "fn": f, "args": ["x"]
})))
.expect_err("a non-scalar `fn` must be rejected");
assert!(
matches!(e, OpError::BroadcastFn { fn_name: Some(ref g), .. } if g == f),
"fn `{f}`: got {e:?}"
);
}
}
#[test]
fn broadcast_fn_arity_mirrors_the_bare_node() {
let legal = |f: &str, n: usize| {
let args: Vec<serde_json::Value> =
(0..n).map(|i| serde_json::json!(format!("x{i}"))).collect();
check_expr(&node(
serde_json::json!({"op": "broadcast", "fn": f, "args": args}),
))
};
legal("+", 1).expect("`+` is n-ary from 1");
legal("*", 3).expect("`*` is n-ary");
legal("-", 1).expect("unary `-` is negation");
legal("-", 2).expect("binary `-` is subtraction");
assert!(matches!(legal("-", 3), Err(OpError::Arity { got: 3, .. })));
assert!(matches!(legal("/", 1), Err(OpError::Arity { ref op, got: 1, .. }) if op == "/"));
assert!(matches!(
legal("atan2", 3),
Err(OpError::Arity { got: 3, .. })
));
assert!(
matches!(legal("min", 1), Err(OpError::Arity { ref op, got: 1, .. }) if op == "min")
);
legal("min", 2).expect("`min` of two is fine");
legal("sin", 1).expect("`sin` is unary");
assert!(
matches!(legal("sin", 2), Err(OpError::Arity { ref op, got: 2, .. }) if op == "sin")
);
legal("neg", 1).expect("`neg` is unary");
assert!(matches!(
legal("neg", 2),
Err(OpError::Arity { got: 2, .. })
));
legal("ifelse", 3).expect("`ifelse` is ternary");
assert!(matches!(
legal("ifelse", 2),
Err(OpError::Arity { got: 2, .. })
));
}
#[test]
fn one_operand_unary_broadcast_stays_legal() {
check_expr(&node(serde_json::json!({
"op": "broadcast", "fn": "sin", "args": ["u"]
})))
.expect("`broadcast(fn=\"sin\", [u])` is the authored unary spelling");
check_expr(&node(serde_json::json!({
"op": "broadcast", "fn": "exp", "args": ["A"]
})))
.expect("`broadcast(fn=\"exp\", [A])` is pinned by tests/display/structural_ops.json");
}
#[test]
fn is_elementwise_node_sees_through_broadcast() {
let bcast = |f: Option<&str>| ExpressionNode {
op: "broadcast".to_string(),
broadcast_fn: f.map(str::to_string),
args: vec![Expr::Variable("a".into()), Expr::Variable("b".into())],
..Default::default()
};
assert!(is_elementwise_node(&bcast(Some("*"))));
assert!(is_elementwise_node(&bcast(Some("atan2"))));
assert!(is_elementwise_node(&bcast(None)));
assert!(!is_elementwise_node(&bcast(Some("pow"))));
assert!(!is_elementwise_node(&bcast(Some("faq"))));
assert!(!is_scalar_operator("broadcast"));
let named = |op: &str| ExpressionNode {
op: op.to_string(),
args: vec![Expr::Variable("a".into())],
..Default::default()
};
assert!(is_elementwise_node(&named("*")));
assert!(!is_elementwise_node(&named("index")));
assert!(!is_elementwise_node(&named("intersect_polygon")));
}
}