From 887cda88b930b6240e6b60dcaf81b054bfe0ff62 Mon Sep 17 00:00:00 2001 From: Keavon Chambers Date: Mon, 21 Sep 2026 04:17:46 -0700 Subject: [PATCH] =?UTF-8?q?Add=20the=20Math=20f(x)=20and=20Math=20f(?= =?UTF-8?q?=E2=80=A6)=20nodes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- libraries/math-parser/src/ast.rs | 10 ++ libraries/math-parser/src/constants.rs | 206 +++++++++++++----------- libraries/math-parser/src/executer.rs | 4 +- libraries/math-parser/src/lib.rs | 1 + libraries/math-parser/src/reducer.rs | 208 +++++++++++++++++++++++++ libraries/math-parser/src/value.rs | 15 +- node-graph/nodes/math/src/lib.rs | 173 ++++++++++++++++++++ 7 files changed, 517 insertions(+), 100 deletions(-) create mode 100644 libraries/math-parser/src/reducer.rs diff --git a/libraries/math-parser/src/ast.rs b/libraries/math-parser/src/ast.rs index a63c60493a..036154f04b 100644 --- a/libraries/math-parser/src/ast.rs +++ b/libraries/math-parser/src/ast.rs @@ -32,6 +32,16 @@ pub enum BinaryOp { } impl BinaryOp { + /// The operand that leaves the other unchanged, like 0 for `+`, which is also what a fold of no items yields. + pub fn identity_element(self) -> Option { + use BinaryOp as Op; + match self { + Op::Add | Op::Or => Some(0.), + Op::Mul | Op::And => Some(1.), + Op::Sub | Op::Div | Op::Pow | Op::Leq | Op::Lt | Op::Geq | Op::Gt | Op::Neq | Op::Eq => None, + } + } + /// Whether a chain of comparisons reads in one direction: `<`/`<=`/`==` ascending, `>`/`>=`/`==` descending, or `!=` alone. pub fn chain_in_one_direction(ops: &[BinaryOp]) -> bool { let ascending = ops.iter().all(|op| matches!(op, BinaryOp::Lt | BinaryOp::Leq | BinaryOp::Eq)); diff --git a/libraries/math-parser/src/constants.rs b/libraries/math-parser/src/constants.rs index ca058ac435..0d32570676 100644 --- a/libraries/math-parser/src/constants.rs +++ b/libraries/math-parser/src/constants.rs @@ -142,50 +142,68 @@ pub fn suffixed_function(name: &str) -> Option<(BuiltinFunction, f64)> { } let base = suffix.parse::().ok().filter(|base| base.is_finite())?; - Some((builtin_function(function)?, base)) + Some((builtin_function(function)?.function, base)) } -/// Looks up a built-in math function by name, returning a plain function pointer so dispatch avoids hashing and dynamic allocation. -pub fn builtin_function(name: &str) -> Option { +/// A built-in math function and whether it's variadic. +#[derive(Clone, Copy)] +pub struct Builtin { + pub function: BuiltinFunction, + /// Takes any count of arguments, like `min(a, b, c)`, which makes its name usable as a lone reducer token. + pub variadic: bool, +} + +/// Defines a built-in function taking a particular count of arguments, or a few like `log(x)` and `log(x, base)`. +fn fixed_arity(function: BuiltinFunction) -> Builtin { + Builtin { function, variadic: false } +} + +/// Defines a built-in function taking any count of arguments. +fn variadic(function: BuiltinFunction) -> Builtin { + Builtin { function, variadic: true } +} + +/// Looks up a built-in math function by name, holding a plain function pointer so dispatch avoids hashing and dynamic allocation. +pub fn builtin_function(name: &str) -> Option { Some(match name { // Trigonometric functions, with the inverses climbing into the complex plane outside the real domain (`asin(2)`) - "sin" => |values| climbing(values, f64::sin, Complex::sin), - "cos" => |values| climbing(values, f64::cos, Complex::cos), - "tan" => |values| climbing(values, f64::tan, Complex::tan), - "csc" => |values| climbing(values, |x| x.sin().recip(), |z| z.sin().recip()), - "sec" => |values| climbing(values, |x| x.cos().recip(), |z| z.cos().recip()), - "cot" => |values| climbing(values, |x| x.tan().recip(), |z| z.tan().recip()), + "sin" => fixed_arity(|values| climbing(values, f64::sin, Complex::sin)), + "cos" => fixed_arity(|values| climbing(values, f64::cos, Complex::cos)), + "tan" => fixed_arity(|values| climbing(values, f64::tan, Complex::tan)), + "csc" => fixed_arity(|values| climbing(values, |x| x.sin().recip(), |z| z.sin().recip())), + "sec" => fixed_arity(|values| climbing(values, |x| x.cos().recip(), |z| z.cos().recip())), + "cot" => fixed_arity(|values| climbing(values, |x| x.tan().recip(), |z| z.tan().recip())), // TODO: Offer the `arc-`/`ar-` spellings (`arcsin`, `artanh`) and the legacy `inv-` names as autocomplete aliases in the expression widget, resolving to these canonical names - "asin" => |values| climbing(values, f64::asin, Complex::asin), - "acos" => |values| climbing(values, f64::acos, Complex::acos), - "atan" => |values| climbing(values, f64::atan, Complex::atan), - "acsc" => |values| climbing(values, |x| x.recip().asin(), |z| z.recip().asin()), - "asec" => |values| climbing(values, |x| x.recip().acos(), |z| z.recip().acos()), - "acot" => |values| climbing(values, |x| x.recip().atan(), |z| z.recip().atan()), + "asin" => fixed_arity(|values| climbing(values, f64::asin, Complex::asin)), + "acos" => fixed_arity(|values| climbing(values, f64::acos, Complex::acos)), + "atan" => fixed_arity(|values| climbing(values, f64::atan, Complex::atan)), + "acsc" => fixed_arity(|values| climbing(values, |x| x.recip().asin(), |z| z.recip().asin())), + "asec" => fixed_arity(|values| climbing(values, |x| x.recip().acos(), |z| z.recip().acos())), + "acot" => fixed_arity(|values| climbing(values, |x| x.recip().atan(), |z| z.recip().atan())), // Hyperbolic functions, with the inverses likewise climbing outside the real domain (`acosh(0.5)`, `atanh(2)`) - "sinh" => |values| climbing(values, f64::sinh, Complex::sinh), - "cosh" => |values| climbing(values, f64::cosh, Complex::cosh), - "tanh" => |values| climbing(values, f64::tanh, Complex::tanh), - "csch" => |values| climbing(values, |x| x.sinh().recip(), |z| z.sinh().recip()), - "sech" => |values| climbing(values, |x| x.cosh().recip(), |z| z.cosh().recip()), - "coth" => |values| climbing(values, |x| x.tanh().recip(), |z| z.tanh().recip()), - "asinh" => |values| climbing(values, f64::asinh, Complex::asinh), - "acosh" => |values| climbing(values, f64::acosh, Complex::acosh), - "atanh" => |values| climbing(values, f64::atanh, Complex::atanh), - "acsch" => |values| climbing(values, |x| x.recip().asinh(), |z| z.recip().asinh()), - "asech" => |values| climbing(values, |x| x.recip().acosh(), |z| z.recip().acosh()), - "acoth" => |values| climbing(values, |x| x.recip().atanh(), |z| z.recip().atanh()), + "sinh" => fixed_arity(|values| climbing(values, f64::sinh, Complex::sinh)), + "cosh" => fixed_arity(|values| climbing(values, f64::cosh, Complex::cosh)), + "tanh" => fixed_arity(|values| climbing(values, f64::tanh, Complex::tanh)), + "csch" => fixed_arity(|values| climbing(values, |x| x.sinh().recip(), |z| z.sinh().recip())), + "sech" => fixed_arity(|values| climbing(values, |x| x.cosh().recip(), |z| z.cosh().recip())), + "coth" => fixed_arity(|values| climbing(values, |x| x.tanh().recip(), |z| z.tanh().recip())), + "asinh" => fixed_arity(|values| climbing(values, f64::asinh, Complex::asinh)), + "acosh" => fixed_arity(|values| climbing(values, f64::acosh, Complex::acosh)), + "atanh" => fixed_arity(|values| climbing(values, f64::atanh, Complex::atanh)), + "acsch" => fixed_arity(|values| climbing(values, |x| x.recip().asinh(), |z| z.recip().asinh())), + "asech" => fixed_arity(|values| climbing(values, |x| x.recip().acosh(), |z| z.recip().acosh())), + "acoth" => fixed_arity(|values| climbing(values, |x| x.recip().atanh(), |z| z.recip().atanh())), // Logarithms, exponentials, and roots, climbing outside the real domain (`ln(-1)`, `sqrt(-4)`) - "ln" => |values| climbing(values, f64::ln, Complex::ln), - "exp" => |values| climbing(values, f64::exp, Complex::exp), - "sqrt" => |values| climbing(values, f64::sqrt, Complex::sqrt), - "cbrt" => |values| climbing(values, f64::cbrt, |z| z.powf(1. / 3.)), - "log2" => |values| climbing(values, f64::log2, |z| z.ln() / LN_2), + "ln" => fixed_arity(|values| climbing(values, f64::ln, Complex::ln)), + "exp" => fixed_arity(|values| climbing(values, f64::exp, Complex::exp)), + "sqrt" => fixed_arity(|values| climbing(values, f64::sqrt, Complex::sqrt)), + "cbrt" => fixed_arity(|values| climbing(values, f64::cbrt, |z| z.powf(1. / 3.))), + "log2" => fixed_arity(|values| climbing(values, f64::log2, |z| z.ln() / LN_2)), - "log" => |values| match values { + "log" => fixed_arity(|values| match values { [value] => climbing(std::slice::from_ref(value), f64::log10, |z| z.log10()), // Change of base, staying real when it can and climbing into the complex plane when it cannot [Value::Number(Number::Real(x)), Value::Number(Number::Real(base))] => { @@ -198,9 +216,9 @@ pub fn builtin_function(name: &str) -> Option { } [Value::Number(x), Value::Number(base)] => Some(Value::from(x.as_complex().ln() / base.as_complex().ln())), _ => None, - }, + }), - "root" => |values| match values { + "root" => fixed_arity(|values| match values { [Value::Number(Number::Real(x)), Value::Number(Number::Real(n))] => { // An odd root of a negative real is real, where `powf` alone would climb to the principal complex root if *x < 0. && n.rem_euclid(2.) == 1. { @@ -211,47 +229,47 @@ pub fn builtin_function(name: &str) -> Option { } [Value::Number(Number::Complex(x)), Value::Number(Number::Real(n))] => Some(Value::from(x.powf(1. / *n))), _ => None, - }, + }), // Geometry Functions // Folding pairwise hypotenuses gives the root of the sum of squares without ever squaring, avoiding overflow - "hypot" => |values| Some(Value::from_f64(real_operands(values)?.into_iter().fold(0., f64::hypot))), + "hypot" => variadic(|values| Some(Value::from_f64(real_operands(values)?.into_iter().fold(0., f64::hypot)))), - "atan2" => |values| match values { + "atan2" => fixed_arity(|values| match values { [Value::Number(Number::Real(y)), Value::Number(Number::Real(x))] => Some(Value::Number(Number::Real(y.atan2(*x)))), _ => None, - }, + }), // Mapping Functions // Each part's absolute value, where `|x|` is instead the one magnitude of the whole value - "abs" => |values| match values { + "abs" => fixed_arity(|values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.abs()))), [Value::Number(Number::Complex(complex))] => Some(Value::from(Complex::new(complex.re.abs(), complex.im.abs()))), _ => None, - }, + }), - "floor" => |values| match values { + "floor" => fixed_arity(|values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.floor()))), _ => None, - }, + }), - "ceil" => |values| match values { + "ceil" => fixed_arity(|values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.ceil()))), _ => None, - }, + }), - "round" => |values| match values { + "round" => fixed_arity(|values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.round()))), _ => None, - }, + }), - "clamp" => |values| match values { + "clamp" => fixed_arity(|values| match values { [Value::Number(Number::Real(x)), Value::Number(Number::Real(min)), Value::Number(Number::Real(max))] => Some(Value::Number(Number::Real(x.clamp(*min, *max)))), _ => None, - }, + }), // Variadic across one or more real arguments - "min" => |values| { + "min" => variadic(|values| { let [Value::Number(Number::Real(first)), rest @ ..] = values else { return None }; let mut min = *first; for value in rest { @@ -259,9 +277,9 @@ pub fn builtin_function(name: &str) -> Option { min = min.min(*real); } Some(Value::Number(Number::Real(min))) - }, + }), - "max" => |values| { + "max" => variadic(|values| { let [Value::Number(Number::Real(first)), rest @ ..] = values else { return None }; let mut max = *first; for value in rest { @@ -269,17 +287,17 @@ pub fn builtin_function(name: &str) -> Option { max = max.max(*real); } Some(Value::Number(Number::Real(max))) - }, + }), // Statistics across one or more real arguments // TODO: Offer `avg` and `average` as autocomplete aliases in the expression widget, resolving to `mean` - "mean" => |values| { + "mean" => variadic(|values| { let reals = real_operands(values)?; let scale = power_of_two_scale(&reals); Some(Value::from_f64(reals.iter().map(|real| real / scale).sum::() / reals.len() as f64 * scale)) - }, + }), - "median" => |values| { + "median" => variadic(|values| { let mut reals = real_operands(values)?; reals.sort_by(f64::total_cmp); let middle = reals.len() / 2; @@ -287,24 +305,24 @@ pub fn builtin_function(name: &str) -> Option { let median = if reals.len() % 2 == 0 { reals[middle - 1].midpoint(reals[middle]) } else { reals[middle] }; Some(Value::from_f64(median)) - }, + }), // The bare names are the sample forms and the `pop` suffix marks the population forms - "variance" => |values| scaled_variance(values, 1).map(|(variance, scale)| Value::from_f64(variance * scale * scale)), - "variancepop" => |values| scaled_variance(values, 0).map(|(variance, scale)| Value::from_f64(variance * scale * scale)), - "stdev" => |values| scaled_variance(values, 1).map(|(variance, scale)| Value::from_f64(variance.sqrt() * scale)), - "stdevpop" => |values| scaled_variance(values, 0).map(|(variance, scale)| Value::from_f64(variance.sqrt() * scale)), + "variance" => variadic(|values| scaled_variance(values, 1).map(|(variance, scale)| Value::from_f64(variance * scale * scale))), + "variancepop" => variadic(|values| scaled_variance(values, 0).map(|(variance, scale)| Value::from_f64(variance * scale * scale))), + "stdev" => variadic(|values| scaled_variance(values, 1).map(|(variance, scale)| Value::from_f64(variance.sqrt() * scale))), + "stdevpop" => variadic(|values| scaled_variance(values, 0).map(|(variance, scale)| Value::from_f64(variance.sqrt() * scale))), - "geomean" => |values| { + "geomean" => variadic(|values| { let reals = real_operands(values)?; // A negative operand has no real geometric mean, and averaging the logarithms keeps the product from overflowing if reals.iter().any(|real| *real < 0.) { return None; } Some(Value::from_f64((reals.iter().map(|real| real.ln()).sum::() / reals.len() as f64).exp())) - }, + }), - "harmmean" => |values| { + "harmmean" => variadic(|values| { let reals = real_operands(values)?; // Like the geometric mean, a negative operand has no meaningful harmonic mean, while a zero one makes it zero @@ -319,16 +337,16 @@ pub fn builtin_function(name: &str) -> Option { // Dividing the smallest operand by each keeps every reciprocal term within 1, so their sum can't overflow let scaled_reciprocal_sum = reals.iter().map(|real| smallest / real).sum::(); Some(Value::from_f64(reals.len() as f64 / scaled_reciprocal_sum * smallest)) - }, + }), - "rms" => |values| { + "rms" => variadic(|values| { let reals = real_operands(values)?; let scale = power_of_two_scale(&reals); let mean_square = reals.iter().map(|real| (real / scale).powi(2)).sum::() / reals.len() as f64; Some(Value::from_f64(mean_square.sqrt() * scale)) - }, + }), - "mode" => |values| { + "mode" => variadic(|values| { let mut reals = real_operands(values)?; reals.sort_by(f64::total_cmp); @@ -342,12 +360,12 @@ pub fn builtin_function(name: &str) -> Option { } } mode.map(Value::from_f64) - }, + }), - "count" => |values| Some(Value::from_f64(values.len() as f64)), + "count" => variadic(|values| Some(Value::from_f64(values.len() as f64))), // Variadic parity across logical operands, which must each be exactly 0 or 1 - "xor" => |values| { + "xor" => variadic(|values| { let mut parity = false; for value in values { let Value::Number(Number::Real(real)) = value else { return None }; @@ -358,14 +376,14 @@ pub fn builtin_function(name: &str) -> Option { } } Some(Value::from_f64(parity as u8 as f64)) - }, + }), - "lerp" => |values| match values { + "lerp" => fixed_arity(|values| match values { [Value::Number(Number::Real(a)), Value::Number(Number::Real(b)), Value::Number(Number::Real(t))] => Some(Value::from_f64(lerp(*a, *b, *t))), _ => None, - }, + }), - "remap" => |values| match values { + "remap" => fixed_arity(|values| match values { [ Value::Number(Number::Real(value)), Value::Number(Number::Real(in_a)), @@ -374,19 +392,19 @@ pub fn builtin_function(name: &str) -> Option { Value::Number(Number::Real(out_b)), ] => Some(Value::from_f64(lerp(*out_a, *out_b, inverse_lerp(*value, *in_a, *in_b)))), _ => None, - }, + }), - "trunc" => |values| match values { + "trunc" => fixed_arity(|values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.trunc()))), _ => None, - }, + }), - "fract" => |values| match values { + "fract" => fixed_arity(|values| match values { [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(real.fract()))), _ => None, - }, + }), - "sign" => |values| match values { + "sign" => fixed_arity(|values| match values { [Value::Number(Number::Real(real))] => { let s = if *real > 0. { 1. @@ -398,33 +416,33 @@ pub fn builtin_function(name: &str) -> Option { Some(Value::Number(Number::Real(s))) } _ => None, - }, + }), - "mod" => |values| match values { + "mod" => fixed_arity(|values| match values { [Value::Number(Number::Real(x)), Value::Number(Number::Real(modulus))] => { // Floored, so a truncated remainder with the opposite sign from the modulus moves over by one modulus let remainder = x % modulus; Some(Value::from_f64(if remainder != 0. && (remainder < 0.) != (*modulus < 0.) { remainder + modulus } else { remainder })) } _ => None, - }, + }), - "gcd" => |values| { + "gcd" => variadic(|values| { let reduced = real_operands(values)? .into_iter() .try_fold(0_u128, |accumulated, real| Some(gcd(accumulated, integer_operand(real)?)))?; Some(Value::from_f64(reduced as f64)) - }, + }), - "lcm" => |values| { + "lcm" => variadic(|values| { let reduced = real_operands(values)? .into_iter() .try_fold(1_u128, |accumulated, real| checked_lcm(accumulated, integer_operand(real)?))?; Some(Value::from_f64(reduced as f64)) - }, + }), // Combinatorics over whole numbers: `choose(n, r)` is the binomial coefficient and `pick(n, r)` the falling factorial - "choose" => |values| match values { + "choose" => fixed_arity(|values| match values { [Value::Number(Number::Real(n)), Value::Number(Number::Real(r))] => { let (n, r) = (whole_operand(*n)?, whole_operand(*r)?); if r > n { @@ -437,9 +455,9 @@ pub fn builtin_function(name: &str) -> Option { Some(Value::from_f64(binomial)) } _ => None, - }, + }), - "pick" => |values| match values { + "pick" => fixed_arity(|values| match values { [Value::Number(Number::Real(n)), Value::Number(Number::Real(r))] => { let (n, r) = (whole_operand(*n)?, whole_operand(*r)?); if r > n { @@ -450,14 +468,14 @@ pub fn builtin_function(name: &str) -> Option { Some(Value::from_f64(falling_factorial)) } _ => None, - }, + }), // The conjugate negates the imaginary part - "conj" => |values| match values { + "conj" => fixed_arity(|values| match values { [Value::Number(Number::Complex(complex))] => Some(Value::Number(Number::Complex(complex.conj()))), [Value::Number(Number::Real(real))] => Some(Value::Number(Number::Real(*real))), _ => None, - }, + }), _ => return None, }) diff --git a/libraries/math-parser/src/executer.rs b/libraries/math-parser/src/executer.rs index fbd17ae3b3..a61a448d4a 100644 --- a/libraries/math-parser/src/executer.rs +++ b/libraries/math-parser/src/executer.rs @@ -1,5 +1,5 @@ use crate::ast::{BinaryOp, Literal, Node, UnaryOp}; -use crate::constants::{builtin_function, suffixed_function}; +use crate::constants::{Builtin, builtin_function, suffixed_function}; use crate::context::{EvalContext, FunctionProvider, ValueProvider}; use crate::lexer::Constant; use crate::value::{Number, Value}; @@ -141,7 +141,7 @@ impl Node { if !prefixed && let Some(value) = context.run_function(bare_name, values) { settle(canonical_host_value(bare_name, value)?) - } else if let Some(function) = builtin_function(bare_name) { + } else if let Some(Builtin { function, .. }) = builtin_function(bare_name) { settle(function(values).ok_or(EvalError::TypeError)?) } else if let Some((function, base)) = suffixed_function(bare_name) { // A base-suffixed call like `log10(x)` runs the two-argument form with the suffix baked in as its second argument diff --git a/libraries/math-parser/src/lib.rs b/libraries/math-parser/src/lib.rs index edcaf15878..42e0a6419a 100644 --- a/libraries/math-parser/src/lib.rs +++ b/libraries/math-parser/src/lib.rs @@ -4,6 +4,7 @@ pub mod context; pub mod executer; pub mod lexer; pub mod parser; +pub mod reducer; pub mod value; use context::EvalContext; diff --git a/libraries/math-parser/src/reducer.rs b/libraries/math-parser/src/reducer.rs new file mode 100644 index 0000000000..f86d857d56 --- /dev/null +++ b/libraries/math-parser/src/reducer.rs @@ -0,0 +1,208 @@ +use crate::ast::BinaryOp; +use crate::constants::{BuiltinFunction, builtin_function}; +use crate::context::ValueProvider; +use crate::lexer::{Lexer, Token}; +use crate::value::{Number, Value}; +use std::collections::HashSet; + +/// How a lone reducer token combines the items it is applied across. +#[derive(Clone, Copy)] +pub enum Reducer { + /// Left-associative pairwise accumulation, like `((a + b) + c)`. + FoldLeft(BinaryOp), + /// Right-associative pairwise accumulation, like `a ^ (b ^ c)`. + FoldRight(BinaryOp), + /// A single n-ary predicate asserting the relation between every adjacent pair, like `a < b < c`. + ChainAdjacent(BinaryOp), + /// A single n-ary predicate asserting that every pair of items is distinct, like `a != b != c`. + ChainDistinct, + /// A single call of a variadic function over all items, like `min(a, b, c)`. + Function(BuiltinFunction), +} + +/// Classifies an input string as a lone reducer token, or `None` when it should instead be parsed as a full expression. +/// A lone token expands to the expression written out over a whole item list: operators interleave and functions wrap. +pub fn classify_reducer(source: &str, bindings: impl ValueProvider) -> Option { + let mut lexer = Lexer::new(source); + let token = lexer.next_token()?; + if lexer.next_token().is_some() { + return None; + } + + Some(match token { + Token::Plus => Reducer::FoldLeft(BinaryOp::Add), + Token::Minus => Reducer::FoldLeft(BinaryOp::Sub), + Token::Star => Reducer::FoldLeft(BinaryOp::Mul), + Token::Slash => Reducer::FoldLeft(BinaryOp::Div), + Token::AndAnd => Reducer::FoldLeft(BinaryOp::And), + Token::OrOr => Reducer::FoldLeft(BinaryOp::Or), + Token::Caret => Reducer::FoldRight(BinaryOp::Pow), + Token::Lt => Reducer::ChainAdjacent(BinaryOp::Lt), + Token::Le => Reducer::ChainAdjacent(BinaryOp::Leq), + Token::Gt => Reducer::ChainAdjacent(BinaryOp::Gt), + Token::Ge => Reducer::ChainAdjacent(BinaryOp::Geq), + Token::EqEq => Reducer::ChainAdjacent(BinaryOp::Eq), + Token::Neq => Reducer::ChainDistinct, + Token::Ident(name) => { + // A binding shadows the function of exactly its spelling, which the `\` prefix still reaches + let bare_name = match name.strip_prefix('\\') { + Some(bare_name) => bare_name, + None if bindings.get_value(name).is_some() => return None, + None => name, + }; + Reducer::Function(builtin_function(bare_name).filter(|builtin| builtin.variadic)?.function) + } + _ => return None, + }) +} + +impl Reducer { + /// Evaluates this reducer across the given items, or `None` for an ill-formed application, like a NaN item, a fold of an + /// empty list under an operator with no identity element, or an indeterminate result, since no operation produces NaN. + pub fn evaluate(&self, items: &[f64]) -> Option { + if items.iter().any(|item| item.is_nan()) { + return None; + } + self.evaluate_unsettled(items).filter(|result| !result.is_nan()) + } + + fn evaluate_unsettled(&self, items: &[f64]) -> Option { + match self { + Reducer::FoldLeft(op) => { + let mut iter = items.iter(); + let Some(&first) = iter.next() else { return op.identity_element() }; + let folded = iter.try_fold(Number::Real(first), |accumulated, &item| accumulated.binary_op(*op, Number::Real(item))); + folded?.as_real() + } + + Reducer::FoldRight(op) => { + let mut iter = items.iter().rev(); + let &first = iter.next()?; + let folded = iter.try_fold(Number::Real(first), |accumulated, &item| Number::Real(item).binary_op(*op, accumulated)); + folded?.as_real() + } + + // A chain over zero or one items is true, since no pair exists to fail the relation + Reducer::ChainAdjacent(op) => { + let satisfied = items.windows(2).all(|pair| Number::Real(pair[0]).binary_op(*op, Number::Real(pair[1])) == Some(Number::Real(1.))); + Some(if satisfied { 1. } else { 0. }) + } + + // Unifying -0 with 0 (NaN is already rejected) gives equal items equal bits, so hashing finds a repeat in O(n) + Reducer::ChainDistinct => { + let mut seen = HashSet::new(); + let distinct = items.iter().all(|&item| seen.insert(if item == 0. { 0 } else { item.to_bits() })); + Some(if distinct { 1. } else { 0. }) + } + + Reducer::Function(function) => { + let values: Vec = items.iter().map(|&item| Value::from_f64(item)).collect(); + function(&values)?.as_real() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ast; + use crate::context::{EvalContext, NothingMap, ValueMap}; + use std::collections::HashMap; + + fn run(source: &str, items: &[f64]) -> Option { + classify_reducer(source, NothingMap).and_then(|reducer| reducer.evaluate(items)) + } + + #[test] + fn lone_tokens_classify_and_expressions_do_not() { + for source in ["+", " * ", "^", "<", "!=", "min", "\\min", "xor"] { + assert!(classify_reducer(source, NothingMap).is_some(), "expected `{source}` to classify as a reducer"); + } + + // Anything beyond one token, and any fixed-arity function name, is an expression instead + for source in ["a + b", "*5", "min(a, b)", "log", "atan2", "sqrt", "\\sqrt", "mod", "%", "", " ", "x"] { + assert!(classify_reducer(source, NothingMap).is_none(), "expected `{source}` to classify as an expression"); + } + } + + #[test] + fn bindings_shadow_reducer_function_names() { + // A lone token reduces across the items if it classifies, and otherwise evaluates as an expression over the bindings + let evaluate = |source: &str, items: &[f64], bindings: &ValueMap| match classify_reducer(source, bindings) { + Some(reducer) => reducer.evaluate(items), + None => ast::Node::try_parse_from_str(source).ok()?.eval(&EvalContext::new(bindings, NothingMap)).ok()?.as_real(), + }; + + let items = [5., 2., 8.]; + let unbound = ValueMap::default(); + let min_bound = ValueMap(HashMap::from([("min".to_string(), Value::from_f64(42.))])); + + assert_eq!(evaluate("min", &items, &unbound), Some(2.)); + assert_eq!(evaluate("\\min", &items, &unbound), Some(2.)); + assert_eq!(evaluate("min", &items, &min_bound), Some(42.)); + assert_eq!(evaluate("\\min", &items, &min_bound), Some(2.)); + } + + #[test] + fn folds_accumulate_pairwise() { + assert_eq!(run("+", &[1., 2., 3.]), Some(6.)); + assert_eq!(run("-", &[10., 3., 2.]), Some(5.)); + assert_eq!(run("*", &[2., 3., 4.]), Some(24.)); + assert_eq!(run("^", &[2., 2., 3.]), Some(256.)); + assert_eq!(run("-", &[7.]), Some(7.)); + } + + #[test] + fn empty_lists_use_identity_elements() { + assert_eq!(run("+", &[]), Some(0.)); + assert_eq!(run("*", &[]), Some(1.)); + assert_eq!(run("&&", &[]), Some(1.)); + assert_eq!(run("||", &[]), Some(0.)); + assert_eq!(run("-", &[]), None); + assert_eq!(run("^", &[]), None); + } + + #[test] + fn chains_are_single_predicates() { + assert_eq!(run("<", &[1., 2., 3.]), Some(1.)); + assert_eq!(run("<", &[3., 5., 2.]), Some(0.)); + assert_eq!(run("<=", &[1., 1., 2.]), Some(1.)); + assert_eq!(run("==", &[2., 2., 2.]), Some(1.)); + assert_eq!(run("!=", &[1., 2., 1.]), Some(0.)); + assert_eq!(run("!=", &[1., 2., 3.]), Some(1.)); + assert_eq!(run("!=", &[0., -0.]), Some(0.)); + assert_eq!(run("<", &[5.]), Some(1.)); + assert_eq!(run("!=", &[]), Some(1.)); + } + + #[test] + fn distinctness_scales_to_large_lists() { + let mut items: Vec = (0..1_000_000).map(f64::from).collect(); + assert_eq!(run("!=", &items), Some(1.)); + + items.push(0.); + assert_eq!(run("!=", &items), Some(0.)); + } + + #[test] + fn function_tokens_apply_variadically() { + assert_eq!(run("min", &[5., 2., 8.]), Some(2.)); + assert_eq!(run("\\min", &[5., 2., 8.]), Some(2.)); + assert_eq!(run("mean", &[1., 2., 3., 6.]), Some(3.)); + assert_eq!(run("count", &[1., 2., 3.]), Some(3.)); + assert_eq!(run("rms", &[3., 4.]), Some(12.5_f64.sqrt())); + assert_eq!(run("mode", &[1., 2., 2.]), Some(2.)); + assert_eq!(run("xor", &[1., 1., 1.]), Some(1.)); + assert_eq!(run("min", &[]), None); + assert_eq!(run("count", &[]), Some(0.)); + } + + #[test] + fn nan_items_are_rejected() { + // `min` and `count` would otherwise drop or ignore the NaN and return a number + for source in ["min", "count", "+", "<"] { + assert_eq!(run(source, &[f64::NAN, 1.]), None, "`{source}`"); + } + } +} diff --git a/libraries/math-parser/src/value.rs b/libraries/math-parser/src/value.rs index b600faa555..1f9ed8f1b9 100644 --- a/libraries/math-parser/src/value.rs +++ b/libraries/math-parser/src/value.rs @@ -27,10 +27,8 @@ impl Value { } pub fn as_real(&self) -> Option { - match self { - Self::Number(Number::Real(val)) => Some(*val), - _ => None, - } + let Self::Number(number) = self; + number.as_real() } /// Reads the value as a single-precision float, or `None` if it isn't a real number. @@ -94,6 +92,15 @@ impl std::fmt::Display for Number { } impl Number { + /// Reads the number as a real, or `None` if it has an imaginary part. + pub fn as_real(self) -> Option { + match self { + Number::Real(real) => Some(real), + // Canonical form stores a zero imaginary part as a real, so a canonical complex number is never real + Number::Complex(_) => None, + } + } + /// Widens the number into the complex plane, since every real number is a complex number without an imaginary part. pub fn as_complex(self) -> Complex { match self { diff --git a/node-graph/nodes/math/src/lib.rs b/node-graph/nodes/math/src/lib.rs index d82907a5d4..34a1b770ba 100644 --- a/node-graph/nodes/math/src/lib.rs +++ b/node-graph/nodes/math/src/lib.rs @@ -10,6 +10,8 @@ use graphic_types::{Artboard, Graphic, Vector}; use log::warn; use math_parser::ast; use math_parser::context::{EvalContext, NothingMap, ValueProvider}; +use math_parser::lexer::Constant; +use math_parser::reducer::classify_reducer; use math_parser::value::{Number, Value}; use rand::{Rng, SeedableRng}; use std::ops::{Add, Mul, Rem, Sub}; @@ -84,6 +86,162 @@ fn math( Item::from_parts(result, attributes) } +/// Parses and evaluates a math expression with the given variable bindings, logging and returning `None` on failure. +fn evaluate_expression(expression: &str, provider: impl ValueProvider) -> Option { + let node = match ast::Node::try_parse_from_str(expression) { + Ok(node) => node, + Err(error) => { + warn!("Invalid expression: `{expression}`\n{error}"); + return None; + } + }; + + match node.eval(&EvalContext::new(provider, NothingMap)) { + Ok(value) => Some(value), + Err(error) => { + warn!("Expression evaluation error: {error:?}"); + None + } + } +} + +/// Converts a node item type to and from the values the expression evaluator runs in. +trait ExpressionValue: Copy + Default { + fn into_f64(self) -> f64; + /// Reads an evaluated result as this type, or `None` when it does not fit, like a complex number read as a Number. + fn from_value(value: &Value) -> Option; +} + +impl ExpressionValue for f64 { + fn into_f64(self) -> f64 { + self + } + fn from_value(value: &Value) -> Option { + value.as_real() + } +} + +/// Reads an expression's result into the node's output type, warning and falling back to the type's default when it does not fit. +fn output(result: Option) -> T { + result + .and_then(|value| { + let output = T::from_value(&value); + if output.is_none() { + warn!("The expression's result {value} does not fit the output type"); + } + output + }) + .unwrap_or_default() +} + +impl ExpressionValue for f32 { + fn into_f64(self) -> f64 { + self as f64 + } + fn from_value(value: &Value) -> Option { + value.as_f32() + } +} + +impl ExpressionValue for bool { + fn into_f64(self) -> f64 { + self as u8 as f64 + } + + // A truth value is exactly 0 or 1 in the expression language, so any other result does not fit + fn from_value(value: &Value) -> Option { + value.as_bool() + } +} + +/// Supplies the value of `x` for the "Math f(x)" node's expression. +struct SingleVariableMathContext { + x: f64, +} + +impl ValueProvider for SingleVariableMathContext { + fn get_value(&self, name: &str) -> Option { + // Bound by exact spelling, per the language's rule that a binding shadows the builtin of exactly its spelling + (name == "x").then(|| Value::from_f64(self.x)) + } +} + +/// Evaluates a math expression written in terms of the single variable `x`, which carries the input value. +/// +/// A boolean input reads as 0 or 1, and a boolean output requires the expression to produce exactly 0 or 1, since any other number is not a truth value. +#[node_macro::node(name("Math f(x)"), category("Math: Arithmetic"))] +fn math_fx( + _: impl Ctx, + /// The value passed into the expression as `x`. + #[implementations(f64, f32, bool)] + value: Item, + /// The expression evaluated for the input value, in terms of `x`, such as `4sin(x/2)`. + #[name("f(x) =")] + #[default("x")] + fx: Item, +) -> Item { + let (value, attributes) = value.into_parts(); + + let x = value.into_f64(); + let result = output(evaluate_expression(fx.element(), SingleVariableMathContext { x })); + + Item::from_parts(result, attributes) +} + +/// Binds the items of the "Math f(…)" node's list to the positional variables `a`, `b`, `c`, and so on. +struct PositionalMathContext { + items: Vec, +} + +impl ValueProvider for PositionalMathContext { + fn get_value(&self, name: &str) -> Option { + let mut characters = name.chars(); + let letter = characters.next()?; + if characters.next().is_some() || !letter.is_ascii_lowercase() { + return None; + } + + // A wired item shadows the constant spelled by its letter (`e` as the fifth item, `i` as the ninth), which stay reachable + // as `\e` and `\i`; an unwired letter reads as its default of 0, except that a constant's letter stays the constant + let index = (letter as u8 - b'a') as usize; + match self.items.get(index) { + Some(item) => Some(Value::from_f64(*item)), + None if Constant::from_name(name).is_some() => None, + None => Some(Value::from_f64(0.)), + } + } +} + +/// Evaluates a math expression across all of the input items at once. A full expression reads the items as `a`, `b`, `c`, …, while a math operator or N-argument function name (like `*` or `min`) applies across every item. +/// +/// Boolean items read as 0 or 1, and a boolean output requires the expression to produce exactly 0 or 1, since any other number is not a truth value. +#[node_macro::node(name("Math f(…)"), category("Math: Arithmetic"))] +fn math_f( + _: impl Ctx, + /// The items the expression reads. + #[implementations(List, List, List)] + values: List, + /// The expression evaluated over the items, such as `a * b + c`, or a lone operator or function applied across all of them. + #[name("f(…) =")] + f: Item, +) -> Item { + let expression = f.element(); + let items: Vec = values.iter_element_values().map(|&value| value.into_f64()).collect(); + let bindings = PositionalMathContext { items }; + + // A lone operator or variadic function name applies across all items rather than parsing as an expression + if let Some(reducer) = classify_reducer(expression, &bindings) { + let Some(result) = reducer.evaluate(&bindings.items) else { + warn!("The `{expression}` reducer cannot be applied to {} items", bindings.items.len()); + return Item::new_from_element(T::default()); + }; + return Item::new_from_element(output(Some(Value::from_f64(result)))); + } + + let result = output(evaluate_expression(expression, bindings)); + Item::new_from_element(result) +} + /// The addition operation (`+`) calculates the sum of two scalar numbers or vec2s. #[node_macro::node(category("Math: Arithmetic"))] fn add, B>( @@ -1846,6 +2004,21 @@ mod test { assert_eq!(result.into_element(), 0.); } + #[test] + fn test_boolean_items() { + // Booleans read as exactly 0 and 1, and logical results convert back + assert!(!math_fx((), Item::new_from_element(true), Item::new_from_element("!x".to_string())).into_element()); + assert!(math_fx((), Item::new_from_element(false), Item::new_from_element("x == 0".to_string())).into_element()); + + // A result that is not exactly 0 or 1 cannot be a truth value, so it reads as false + assert!(!math_fx((), Item::new_from_element(true), Item::new_from_element("x + 1".to_string())).into_element()); + + let bools = || [true, true, false].into_iter().map(Item::new_from_element).collect::>(); + assert!(!math_f((), bools(), Item::new_from_element("&&".to_string())).into_element()); + assert!(math_f((), bools(), Item::new_from_element("||".to_string())).into_element()); + assert!(!math_f((), bools(), Item::new_from_element("xor".to_string())).into_element()); + } + #[test] fn test_is_nonzero() { assert!(!is_nonzero((), Item::new_from_element(0.)).into_element());