use crate::types::{Expr, ExpressionNode};
#[derive(Debug, Clone, PartialEq)]
pub enum CanonicalizeError {
NonFinite,
DivByZero,
UnsupportedField(String),
}
impl std::fmt::Display for CanonicalizeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CanonicalizeError::NonFinite => write!(f, "E_CANONICAL_NONFINITE"),
CanonicalizeError::DivByZero => write!(f, "E_CANONICAL_DIVBY_ZERO"),
CanonicalizeError::UnsupportedField(_) => write!(f, "E_CANONICAL_UNSUPPORTED_FIELD"),
}
}
}
impl std::error::Error for CanonicalizeError {}
pub fn canonicalize(expr: &Expr) -> Result<Expr, CanonicalizeError> {
match expr {
Expr::Integer(i) => Ok(Expr::Integer(*i)),
Expr::Number(f) => {
if !f.is_finite() {
return Err(CanonicalizeError::NonFinite);
}
Ok(Expr::Number(*f))
}
Expr::Variable(s) => Ok(Expr::Variable(s.clone())),
Expr::Operator(node) => canon_op(node),
}
}
pub fn canonical_json(expr: &Expr) -> Result<String, CanonicalizeError> {
let c = canonicalize(expr)?;
validate_emissible(&c)?;
Ok(emit_canonical_json(&c))
}
fn validate_emissible(e: &Expr) -> Result<(), CanonicalizeError> {
if let Expr::Operator(n) = e {
if let Some(field) = first_non_emissible_field(n) {
return Err(CanonicalizeError::UnsupportedField(field.into()));
}
for a in &n.args {
validate_emissible(a)?;
}
}
Ok(())
}
fn first_non_emissible_field(n: &ExpressionNode) -> Option<&'static str> {
let ExpressionNode {
op: _,
args: _,
wrt: _,
dim: _,
broadcast_fn: _, name: _,
value: _,
units: _,
arg: _,
bindings: _,
int_var,
lower,
upper,
expr,
output_idx,
ranges,
reduce,
semiring,
join,
filter,
regions,
values,
shape,
perm,
axis,
label,
table,
axes,
output,
id,
manifold,
distinct,
key,
expect_cadence,
attrs,
} = n;
if int_var.is_some() {
return Some("var");
}
if lower.is_some() {
return Some("lower");
}
if upper.is_some() {
return Some("upper");
}
if expr.is_some() {
return Some("expr");
}
if output_idx.is_some() {
return Some("output_idx");
}
if ranges.is_some() {
return Some("ranges");
}
if reduce.is_some() {
return Some("reduce");
}
if semiring.is_some() {
return Some("semiring");
}
if join.is_some() {
return Some("join");
}
if filter.is_some() {
return Some("filter");
}
if regions.is_some() {
return Some("regions");
}
if values.is_some() {
return Some("values");
}
if shape.is_some() {
return Some("shape");
}
if perm.is_some() {
return Some("perm");
}
if axis.is_some() {
return Some("axis");
}
if label.is_some() {
return Some("label");
}
if table.is_some() {
return Some("table");
}
if axes.is_some() {
return Some("axes");
}
if output.is_some() {
return Some("output");
}
if id.is_some() {
return Some("id");
}
if manifold.is_some() {
return Some("manifold");
}
if distinct.is_some() {
return Some("distinct");
}
if key.is_some() {
return Some("key");
}
if expect_cadence.is_some() {
return Some("expect_cadence");
}
if attrs.is_some() {
return Some("attrs");
}
None
}
fn canon_op(node: &ExpressionNode) -> Result<Expr, CanonicalizeError> {
let mut work = node.clone();
let mut err: Option<CanonicalizeError> = None;
work.for_each_child_mut(&mut |child| {
if err.is_some() {
return;
}
match canonicalize(&*child) {
Ok(c) => *child = c,
Err(e) => err = Some(e),
}
});
if let Some(e) = err {
return Err(e);
}
match work.op.as_str() {
"+" => canon_add(&mut work),
"*" => canon_mul(&mut work),
"-" => canon_sub(&mut work),
"/" => canon_div(&mut work),
"neg" => canon_neg(&mut work),
_ => Ok(Expr::operator(work)),
}
}
fn canon_add(node: &mut ExpressionNode) -> Result<Expr, CanonicalizeError> {
let flat = flatten_same_op(std::mem::take(&mut node.args), "+");
let (mut others, _had_int_zero, had_float_zero) = partition_identity(flat, 0);
if had_float_zero && !all_float_literals(&others) {
others.push(Expr::Number(0.0));
}
if others.is_empty() {
return Ok(if had_float_zero {
Expr::Number(0.0)
} else {
Expr::Integer(0)
});
}
if others.len() == 1 {
return Ok(others.pop().unwrap());
}
sort_args(&mut others);
Ok(Expr::operator(ExpressionNode {
op: "+".into(),
args: others,
..ExpressionNode::default()
}))
}
fn canon_mul(node: &mut ExpressionNode) -> Result<Expr, CanonicalizeError> {
let flat = flatten_same_op(std::mem::take(&mut node.args), "*");
for a in &flat {
if let Expr::Integer(0) = a {
return Ok(Expr::Integer(0));
}
if let Expr::Number(f) = a
&& *f == 0.0
{
return Ok(Expr::Number(*f * 0.0_f64));
}
}
let (mut others, _had_int_one, had_float_one) = partition_identity(flat, 1);
if had_float_one && !all_float_literals(&others) {
others.push(Expr::Number(1.0));
}
if others.is_empty() {
return Ok(if had_float_one {
Expr::Number(1.0)
} else {
Expr::Integer(1)
});
}
if others.len() == 1 {
return Ok(others.pop().unwrap());
}
sort_args(&mut others);
Ok(Expr::operator(ExpressionNode {
op: "*".into(),
args: others,
..ExpressionNode::default()
}))
}
fn canon_sub(node: &mut ExpressionNode) -> Result<Expr, CanonicalizeError> {
if node.args.len() == 1 {
let arg = node.args.pop().unwrap();
return canon_neg_value(arg);
}
if node.args.len() == 2 {
let b = node.args.pop().unwrap();
let a = node.args.pop().unwrap();
if is_zero_any(&a) {
return canon_neg_value(b);
}
if is_zero_any(&b) {
if matches!(b, Expr::Number(_))
&& let Expr::Integer(i) = a
{
return Ok(Expr::Number(i as f64));
}
return Ok(a);
}
return Ok(Expr::operator(ExpressionNode {
op: "-".into(),
args: vec![a, b],
..ExpressionNode::default()
}));
}
Ok(Expr::operator(std::mem::take(node)))
}
fn canon_div(node: &mut ExpressionNode) -> Result<Expr, CanonicalizeError> {
if node.args.len() != 2 {
return Ok(Expr::operator(std::mem::take(node)));
}
let b = node.args.pop().unwrap();
let a = node.args.pop().unwrap();
if is_zero_any(&a) && is_zero_any(&b) {
return Err(CanonicalizeError::DivByZero);
}
if is_one_any(&b) {
if matches!(b, Expr::Number(_))
&& let Expr::Integer(i) = a
{
return Ok(Expr::Number(i as f64));
}
return Ok(a);
}
if is_zero_any(&a) {
return Ok(if matches!(a, Expr::Number(_)) {
Expr::Number(0.0)
} else {
Expr::Integer(0)
});
}
Ok(Expr::operator(ExpressionNode {
op: "/".into(),
args: vec![a, b],
..ExpressionNode::default()
}))
}
fn canon_neg(node: &mut ExpressionNode) -> Result<Expr, CanonicalizeError> {
if node.args.len() != 1 {
return Ok(Expr::operator(std::mem::take(node)));
}
let arg = node.args.pop().unwrap();
canon_neg_value(arg)
}
fn canon_neg_value(arg: Expr) -> Result<Expr, CanonicalizeError> {
match arg {
Expr::Integer(i) => Ok(Expr::Integer(-i)),
Expr::Number(f) => Ok(Expr::Number(-f)),
Expr::Operator(n) if n.op == "neg" && n.args.len() == 1 => Ok(n.args[0].clone()),
other => Ok(Expr::operator(ExpressionNode {
op: "neg".into(),
args: vec![other],
..ExpressionNode::default()
})),
}
}
fn flatten_same_op(args: Vec<Expr>, op: &str) -> Vec<Expr> {
let mut out = Vec::with_capacity(args.len());
for a in args {
match a {
Expr::Operator(node) if node.op == op => out.extend(node.args.iter().cloned()),
other => out.push(other),
}
}
out
}
fn partition_identity(args: Vec<Expr>, identity: i64) -> (Vec<Expr>, bool, bool) {
let mut others = Vec::with_capacity(args.len());
let (mut had_int, mut had_float) = (false, false);
for a in args {
match &a {
Expr::Integer(i) if *i == identity => {
had_int = true;
continue;
}
Expr::Number(f) if *f == identity as f64 => {
had_float = true;
continue;
}
_ => {}
}
others.push(a);
}
(others, had_int, had_float)
}
fn all_float_literals(args: &[Expr]) -> bool {
!args.is_empty() && args.iter().all(|a| matches!(a, Expr::Number(_)))
}
fn is_zero_any(e: &Expr) -> bool {
matches!(e, Expr::Integer(0)) || matches!(e, Expr::Number(f) if *f == 0.0)
}
fn is_one_any(e: &Expr) -> bool {
matches!(e, Expr::Integer(1)) || matches!(e, Expr::Number(f) if *f == 1.0)
}
fn sort_args(args: &mut [Expr]) {
let mut cache: std::collections::HashMap<usize, String> = std::collections::HashMap::new();
let mut indices: Vec<usize> = (0..args.len()).collect();
indices.sort_by(|&i, &j| compare_exprs(&args[i], &args[j], i, j, &mut cache));
let cloned: Vec<Expr> = indices.iter().map(|&i| args[i].clone()).collect();
for (slot, e) in args.iter_mut().zip(cloned) {
*slot = e;
}
}
fn arg_tier(e: &Expr) -> u8 {
match e {
Expr::Integer(_) | Expr::Number(_) => 0,
Expr::Variable(_) => 1,
Expr::Operator(_) => 2,
}
}
fn numeric_key(e: &Expr) -> f64 {
match e {
Expr::Integer(i) => *i as f64,
Expr::Number(f) => *f,
_ => 0.0,
}
}
fn compare_exprs(
a: &Expr,
b: &Expr,
ia: usize,
ib: usize,
cache: &mut std::collections::HashMap<usize, String>,
) -> std::cmp::Ordering {
use std::cmp::Ordering;
let (ta, tb) = (arg_tier(a), arg_tier(b));
if ta != tb {
return ta.cmp(&tb);
}
match ta {
0 => {
let av = numeric_key(a);
let bv = numeric_key(b);
match av.partial_cmp(&bv).unwrap_or(Ordering::Equal) {
Ordering::Equal => {
let af = matches!(a, Expr::Number(_));
let bf = matches!(b, Expr::Number(_));
af.cmp(&bf)
}
ord => ord,
}
}
1 => match (a, b) {
(Expr::Variable(x), Expr::Variable(y)) => x.cmp(y),
_ => Ordering::Equal,
},
_ => {
let aj = cache
.entry(ia)
.or_insert_with(|| emit_canonical_json(a))
.clone();
let bj = cache
.entry(ib)
.or_insert_with(|| emit_canonical_json(b))
.clone();
aj.cmp(&bj)
}
}
}
fn emit_canonical_json(e: &Expr) -> String {
match e {
Expr::Integer(i) => i.to_string(),
Expr::Number(f) => format_canonical_float(*f),
Expr::Variable(s) => json_string(s),
Expr::Operator(n) => emit_node_json(n),
}
}
fn emit_node_json(n: &ExpressionNode) -> String {
let mut entries: Vec<(String, String)> = Vec::new();
entries.push(("op".into(), json_string(&n.op)));
let arg_parts: Vec<String> = n.args.iter().map(emit_canonical_json).collect();
entries.push(("args".into(), format!("[{}]", arg_parts.join(","))));
if let Some(ref s) = n.wrt {
entries.push(("wrt".into(), json_string(s)));
}
if let Some(ref s) = n.dim {
entries.push(("dim".into(), json_string(s)));
}
if let Some(ref s) = n.name {
entries.push(("name".into(), json_string(s)));
}
if let Some(ref v) = n.value {
entries.push(("value".into(), emit_canonical_json_value(v)));
}
if let Some(ref u) = n.units {
entries.push(("units".into(), json_string(u)));
}
if let Some(ref s) = n.broadcast_fn {
entries.push(("fn".into(), json_string(s)));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
let body: Vec<String> = entries
.into_iter()
.map(|(k, v)| format!("{}:{}", json_string(&k), v))
.collect();
format!("{{{}}}", body.join(","))
}
fn json_string(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
}
fn emit_canonical_json_value(v: &serde_json::Value) -> String {
match v {
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
i.to_string()
} else if let Some(u) = n.as_u64() {
u.to_string()
} else if let Some(f) = n.as_f64() {
format_canonical_float(f)
} else {
n.to_string()
}
}
serde_json::Value::Array(arr) => {
let parts: Vec<String> = arr.iter().map(emit_canonical_json_value).collect();
format!("[{}]", parts.join(","))
}
_ => v.to_string(),
}
}
pub fn format_canonical_float(f: f64) -> String {
if !f.is_finite() {
return if f.is_nan() {
"NaN".into()
} else if f > 0.0 {
"Infinity".into()
} else {
"-Infinity".into()
};
}
if f == 0.0 {
return if f.is_sign_negative() {
"-0.0".into()
} else {
"0.0".into()
};
}
let abs = f.abs();
let use_exp = !(1e-6..1e21).contains(&abs);
if use_exp {
let s = format!("{f:e}");
normalize_exponent(&s)
} else {
let s = format!("{f}");
if !s.contains('.') {
format!("{s}.0")
} else {
s
}
}
}
fn normalize_exponent(s: &str) -> String {
if let Some(idx) = s.find('e') {
let (mant, exp) = s.split_at(idx);
let exp = &exp[1..]; let exp = exp.strip_prefix('+').unwrap_or(exp);
let (sign, digits) = if let Some(rest) = exp.strip_prefix('-') {
("-", rest)
} else {
("", exp)
};
let digits = digits.trim_start_matches('0');
let digits = if digits.is_empty() { "0" } else { digits };
format!("{mant}e{sign}{digits}")
} else {
s.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn op(name: &str, args: Vec<Expr>) -> Expr {
Expr::operator(ExpressionNode {
op: name.into(),
args,
..ExpressionNode::default()
})
}
#[test]
fn float_format_table() {
let cases = [
(1.0_f64, "1.0"),
(-3.0, "-3.0"),
(0.0, "0.0"),
(-0.0_f64, "-0.0"),
(2.5, "2.5"),
(1e25, "1e25"),
(5e-324, "5e-324"),
(1e-7, "1e-7"),
];
for (v, want) in cases {
assert_eq!(format_canonical_float(v), want, "for {v}");
}
let mixed = 0.1_f64 + 0.2_f64;
assert_eq!(format_canonical_float(mixed), "0.30000000000000004");
}
#[test]
fn integer_emission() {
for (v, want) in [(1_i64, "1"), (-42, "-42"), (0, "0")] {
assert_eq!(canonical_json(&Expr::Integer(v)).unwrap(), want);
}
}
#[test]
fn nonfinite_errors() {
for f in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(
canonicalize(&Expr::Number(f)).unwrap_err(),
CanonicalizeError::NonFinite
);
}
}
#[test]
fn worked_example() {
let e = op(
"+",
vec![
op("*", vec![Expr::Variable("a".into()), Expr::Integer(0)]),
Expr::Variable("b".into()),
op("+", vec![Expr::Variable("a".into()), Expr::Integer(1)]),
],
);
let got = canonical_json(&e).unwrap();
assert_eq!(got, r#"{"args":[1,"a","b"],"op":"+"}"#);
}
#[test]
fn flatten_basic() {
let e = op(
"+",
vec![
op(
"+",
vec![Expr::Variable("a".into()), Expr::Variable("b".into())],
),
Expr::Variable("c".into()),
],
);
assert_eq!(
canonical_json(&e).unwrap(),
r#"{"args":["a","b","c"],"op":"+"}"#
);
}
#[test]
fn type_preserving_identity() {
let e1 = op("*", vec![Expr::Integer(1), Expr::Variable("x".into())]);
assert_eq!(canonical_json(&e1).unwrap(), r#""x""#);
let e2 = op("*", vec![Expr::Number(1.0), Expr::Variable("x".into())]);
assert_eq!(
canonical_json(&e2).unwrap(),
r#"{"args":[1.0,"x"],"op":"*"}"#
);
}
#[test]
fn zero_annihilation_type_preserve() {
let e1 = op("*", vec![Expr::Integer(0), Expr::Variable("x".into())]);
assert_eq!(canonical_json(&e1).unwrap(), "0");
let e2 = op("*", vec![Expr::Number(0.0), Expr::Variable("x".into())]);
assert_eq!(canonical_json(&e2).unwrap(), "0.0");
let e3 = op("*", vec![Expr::Number(-0.0), Expr::Variable("x".into())]);
assert_eq!(canonical_json(&e3).unwrap(), "-0.0");
}
#[test]
fn int_float_disambiguation() {
let a = op("+", vec![Expr::Number(1.0), Expr::Number(2.5)]);
let b = op("+", vec![Expr::Integer(1), Expr::Number(2.5)]);
let ja = canonical_json(&a).unwrap();
let jb = canonical_json(&b).unwrap();
assert_ne!(ja, jb, "int/float distinction lost: {ja} == {jb}");
assert!(ja.contains("1.0"), "float 1.0 not emitted as 1.0: {ja}");
}
#[test]
fn neg_canonical() {
let inner = op("neg", vec![Expr::Variable("x".into())]);
let outer = op("neg", vec![inner]);
assert_eq!(canonical_json(&outer).unwrap(), r#""x""#);
let lit = op("neg", vec![Expr::Integer(5)]);
assert_eq!(canonical_json(&lit).unwrap(), "-5");
let sub = op("-", vec![Expr::Integer(0), Expr::Variable("x".into())]);
assert_eq!(
canonical_json(&sub).unwrap(),
r#"{"args":["x"],"op":"neg"}"#
);
}
#[test]
fn div_zero_by_zero() {
let e = op("/", vec![Expr::Integer(0), Expr::Integer(0)]);
assert_eq!(canonicalize(&e).unwrap_err(), CanonicalizeError::DivByZero);
}
#[test]
fn non_emissible_field_fails_closed() {
let filtered = Expr::operator(ExpressionNode {
op: "faq".into(),
args: vec![Expr::Variable("x".into())],
filter: Some(Box::new(Expr::Variable("p".into()))),
..ExpressionNode::default()
});
let bodied = Expr::operator(ExpressionNode {
op: "faq".into(),
args: vec![Expr::Variable("A".into())],
expr: Some(Box::new(Expr::Variable("A".into()))),
..ExpressionNode::default()
});
let tabled = Expr::operator(ExpressionNode {
op: "table_lookup".into(),
args: vec![],
table: Some("t".into()),
..ExpressionNode::default()
});
let ided = Expr::operator(ExpressionNode {
op: "intersect_polygon".into(),
args: vec![Expr::Variable("poly".into())],
id: Some("g0".into()),
..ExpressionNode::default()
});
for e in [&filtered, &bodied, &tabled, &ided] {
let err = canonical_json(e).unwrap_err();
assert!(
matches!(err, CanonicalizeError::UnsupportedField(_)),
"expected UnsupportedField, got {err:?}"
);
assert_eq!(err.to_string(), "E_CANONICAL_UNSUPPORTED_FIELD");
}
}
#[test]
fn skolem_label_fails_closed() {
let edge = Expr::operator(ExpressionNode {
op: "skolem".into(),
args: vec![Expr::Variable("u".into()), Expr::Variable("v".into())],
label: Some("edge".into()),
..ExpressionNode::default()
});
let err = canonical_json(&edge).unwrap_err();
assert_eq!(err, CanonicalizeError::UnsupportedField("label".into()));
assert_eq!(err.to_string(), "E_CANONICAL_UNSUPPORTED_FIELD");
let bare = Expr::operator(ExpressionNode {
op: "skolem".into(),
args: vec![Expr::Variable("u".into()), Expr::Variable("v".into())],
..ExpressionNode::default()
});
assert!(canonical_json(&bare).is_ok());
}
#[test]
fn non_emissible_field_in_args_fails_closed() {
let e = op(
"+",
vec![
Expr::Variable("x".into()),
Expr::operator(ExpressionNode {
op: "table_lookup".into(),
args: vec![],
table: Some("t".into()),
..ExpressionNode::default()
}),
],
);
assert_eq!(
canonical_json(&e).unwrap_err().to_string(),
"E_CANONICAL_UNSUPPORTED_FIELD"
);
}
#[test]
fn emissible_and_tolerated_fields_round_trip() {
let bc = Expr::operator(ExpressionNode {
op: "broadcast".into(),
args: vec![Expr::Variable("u".into())],
broadcast_fn: Some("sin".into()),
..ExpressionNode::default()
});
let got = canonical_json(&bc).unwrap();
assert!(got.contains(r#""fn":"sin""#), "fn not emitted: {got}");
let named = Expr::operator(ExpressionNode {
op: "fn".into(),
args: vec![Expr::Variable("x".into())],
name: Some("m.f".into()),
..ExpressionNode::default()
});
assert!(
canonical_json(&named).unwrap().contains(r#""name":"m.f""#),
"name not emitted"
);
let mut bindings = std::collections::HashMap::new();
bindings.insert("t".to_string(), Expr::Variable("z".into()));
let tolerated = Expr::operator(ExpressionNode {
op: "argmax".into(),
args: vec![Expr::Variable("x".into())],
arg: Some("i".into()),
bindings: Some(bindings),
..ExpressionNode::default()
});
let got = canonical_json(&tolerated).expect("tolerated fields must not error");
assert!(!got.contains("\"arg\""), "arg leaked into emission: {got}");
assert!(
!got.contains("bindings"),
"bindings leaked into emission: {got}"
);
}
#[test]
fn canonicalize_alone_preserves_fields() {
let e = Expr::operator(ExpressionNode {
op: "table_lookup".into(),
args: vec![],
table: Some("mytable".into()),
output: Some(serde_json::json!(0)),
..ExpressionNode::default()
});
let c = canonicalize(&e).expect("canonicalize must not fail-close");
match c {
Expr::Operator(n) => {
assert_eq!(n.table.as_deref(), Some("mytable"));
assert_eq!(n.output, Some(serde_json::json!(0)));
}
other => panic!("expected preserved operator, got {other:?}"),
}
assert_eq!(
canonical_json(&e).unwrap_err().to_string(),
"E_CANONICAL_UNSUPPORTED_FIELD"
);
}
#[test]
fn cross_binding_conformance_fixtures() {
use std::path::PathBuf;
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let repo_root: PathBuf = PathBuf::from(manifest_dir)
.parent()
.unwrap()
.parent()
.unwrap()
.to_path_buf();
let dir = repo_root
.join("tests")
.join("conformance")
.join("canonical");
let manifest_bytes = std::fs::read(dir.join("manifest.json")).expect("read manifest");
let manifest: serde_json::Value =
serde_json::from_slice(&manifest_bytes).expect("parse manifest");
let fixtures = manifest["fixtures"].as_array().expect("fixtures array");
assert!(!fixtures.is_empty(), "manifest has no fixtures");
for f in fixtures {
let id = f["id"].as_str().unwrap();
let path = dir.join(f["path"].as_str().unwrap());
let raw = std::fs::read(&path).expect("read fixture");
let fixture: serde_json::Value = serde_json::from_slice(&raw).expect("parse fixture");
let input_json = fixture["input"].clone();
let expr: Expr = serde_json::from_value(input_json).expect("decode input as Expr");
if let Some(code) = fixture.get("expect_error").and_then(|v| v.as_str()) {
let err = canonical_json(&expr)
.expect_err(&format!("fixture {id}: expected error {code}, got Ok"));
assert_eq!(
err.to_string(),
code,
"fixture {id}: error code mismatch (got {err}, want {code})"
);
} else {
let got = canonical_json(&expr).expect("canonicalize");
let want = fixture["expected"].as_str().unwrap();
assert_eq!(got, want, "fixture {id}: got {got}, want {want}");
}
}
}
}