From e761d39205faaf3b81f432f5a585863578444eff Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 9 Aug 2026 00:56:15 +0800 Subject: [PATCH 1/9] refactor: introduce shared exact expression core --- Cargo.toml | 9 +- problemreductions-cli/src/bin/pred_sym.rs | 19 +- problemreductions-cli/src/commands/graph.rs | 2 +- problemreductions-cli/tests/pred_sym_tests.rs | 11 +- problemreductions-expr/Cargo.toml | 17 + problemreductions-expr/src/lib.rs | 636 ++++++++++++++++++ problemreductions-macros/Cargo.toml | 2 + problemreductions-macros/src/expr_codegen.rs | 180 +++++ problemreductions-macros/src/lib.rs | 13 +- problemreductions-macros/src/parser.rs | 489 -------------- src/expr.rs | 589 +++------------- src/growth.rs | 217 +++--- src/lib.rs | 4 +- src/rules/graph.rs | 43 +- src/rules/pareto.rs | 45 +- src/rules/registry.rs | 15 +- src/rules/subsetsum_integerknapsack.rs | 4 +- src/unit_tests/big_o.rs | 17 +- src/unit_tests/expr.rs | 245 +++---- src/unit_tests/growth.rs | 116 ++-- src/unit_tests/reduction_graph.rs | 29 +- src/unit_tests/rules/analysis.rs | 86 ++- src/unit_tests/rules/graph.rs | 12 +- src/unit_tests/rules/pareto.rs | 273 ++++---- src/unit_tests/rules/registry.rs | 10 +- 25 files changed, 1586 insertions(+), 1497 deletions(-) create mode 100644 problemreductions-expr/Cargo.toml create mode 100644 problemreductions-expr/src/lib.rs create mode 100644 problemreductions-macros/src/expr_codegen.rs delete mode 100644 problemreductions-macros/src/parser.rs diff --git a/Cargo.toml b/Cargo.toml index 3b0066232..087452239 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,10 @@ [workspace] -members = [".", "problemreductions-macros", "problemreductions-cli"] +members = [ + ".", + "problemreductions-expr", + "problemreductions-macros", + "problemreductions-cli", +] [package] name = "problemreductions" @@ -27,12 +32,14 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2.0" num-bigint = "0.4" +num-rational = "0.4" num-traits = "0.2" good_lp = { version = "=1.14.2", default-features = false, optional = true } inventory = "0.3" ordered-float = "5.0" rand = "0.10" problemreductions-macros = { version = "0.6.0", path = "problemreductions-macros" } +problemreductions-expr = { version = "0.6.0", path = "problemreductions-expr" } [dev-dependencies] proptest = "1.0" diff --git a/problemreductions-cli/src/bin/pred_sym.rs b/problemreductions-cli/src/bin/pred_sym.rs index c20c2b97b..23fc71287 100644 --- a/problemreductions-cli/src/bin/pred_sym.rs +++ b/problemreductions-cli/src/bin/pred_sym.rs @@ -1,5 +1,5 @@ use clap::{Parser, Subcommand}; -use problemreductions::{big_o_normal_form, Expr, ProblemSize}; +use problemreductions::{big_o_normal_form, evaluate_approximate, Expr, ProblemSize}; #[derive(Parser)] #[command( @@ -112,22 +112,20 @@ fn main() { } Commands::Eval { expr, vars } => { let parsed = parse_expr_or_exit(&expr); - let bindings: Vec<(&str, usize)> = vars + let bindings: Vec<(String, usize)> = vars .split(',') .filter_map(|pair| { let mut parts = pair.splitn(2, '='); let name = parts.next()?.trim(); let value: usize = parts.next()?.trim().parse().ok()?; - // Leak the name for &'static str compatibility - let leaked: &'static str = Box::leak(name.to_string().into_boxed_str()); - Some((leaked, value)) + Some((name.to_string(), value)) }) .collect(); // Check for unbound variables let expr_vars = parsed.variables(); let bound_vars: std::collections::HashSet<&str> = - bindings.iter().map(|(k, _)| *k).collect(); + bindings.iter().map(|(name, _)| name.as_str()).collect(); let mut unbound: Vec<&str> = expr_vars .iter() .filter(|v| !bound_vars.contains(*v)) @@ -143,8 +141,13 @@ fn main() { std::process::exit(1); } - let size = ProblemSize::new(bindings); - let result = parsed.eval(&size); + let size = ProblemSize { + components: bindings, + }; + let result = evaluate_approximate(&parsed, &size).unwrap_or_else(|error| { + eprintln!("Error: {error}"); + std::process::exit(1); + }); // Format as integer if it's a whole number if (result - result.round()).abs() < 1e-10 { diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 9c20200f9..74f586ad9 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -568,7 +568,7 @@ pub(crate) fn format_front_json( let big_o: BTreeMap<&str, String> = label .fields() .iter() - .map(|(f, g)| (*f, g.to_big_o())) + .map(|(field, growth)| (field.as_str(), growth.to_big_o())) .collect(); let route = format_path_json(graph, reduction_path); serde_json::json!({ diff --git a/problemreductions-cli/tests/pred_sym_tests.rs b/problemreductions-cli/tests/pred_sym_tests.rs index 9bf644973..845e3256a 100644 --- a/problemreductions-cli/tests/pred_sym_tests.rs +++ b/problemreductions-cli/tests/pred_sym_tests.rs @@ -51,18 +51,11 @@ fn test_pred_sym_big_o_signed_polynomial() { } #[test] -fn test_pred_sym_big_o_sqrt_display() { - // A fractional polynomial degree renders with sqrt notation. - // (`2^sqrt(n)` — a nonlinear exponent — is now unsupported, so use an - // in-domain sqrt input instead.) +fn test_pred_sym_big_o_preserves_fractional_degrees() { let output = pred_sym().args(["big-o", "sqrt(n * m)"]).output().unwrap(); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - stdout.contains("sqrt"), - "expected sqrt notation, got: {}", - stdout.trim() - ); + assert_eq!(stdout.trim(), "O(m^0.5 * n^0.5)"); } #[test] diff --git a/problemreductions-expr/Cargo.toml b/problemreductions-expr/Cargo.toml new file mode 100644 index 000000000..d19a1b770 --- /dev/null +++ b/problemreductions-expr/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "problemreductions-expr" +version = "0.6.0" +edition = "2021" +description = "Lossless symbolic expressions for problemreductions" +license = "MIT" +repository = "https://github.com/CodingThrust/problem-reductions" + +[dependencies] +num-bigint = { version = "0.4", features = ["serde"] } +num-rational = { version = "0.4", features = ["serde"] } +num-traits = "0.2" +serde = { version = "1.0", features = ["derive"] } +thiserror = "2.0" + +[dev-dependencies] +serde_json = "1.0" diff --git a/problemreductions-expr/src/lib.rs b/problemreductions-expr/src/lib.rs new file mode 100644 index 000000000..853b3e985 --- /dev/null +++ b/problemreductions-expr/src/lib.rs @@ -0,0 +1,636 @@ +//! Lossless symbolic expressions shared by the runtime library and proc macros. + +use num_bigint::BigInt; +use num_rational::BigRational; +use num_traits::{One, Signed, Zero}; +use std::collections::{BTreeSet, HashMap}; +use std::fmt; +use std::str::FromStr; + +/// A symbolic expression over named problem-size variables. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum Expr { + Const(BigRational), + Var(Box), + Add(Box, Box), + Sub(Box, Box), + Mul(Box, Box), + Div(Box, Box), + Pow(Box, Box), + Neg(Box), + Exp(Box), + Log(Box), + Sqrt(Box), + Factorial(Box), +} + +impl Expr { + pub fn integer(value: impl Into) -> Self { + Self::Const(BigRational::from_integer(value.into())) + } + + pub fn rational(numerator: impl Into, denominator: impl Into) -> Self { + Self::Const(BigRational::new(numerator.into(), denominator.into())) + } + + pub fn variable(name: impl Into>) -> Self { + Self::Var(name.into()) + } + + pub fn pow(base: Expr, exponent: Expr) -> Self { + Self::Pow(Box::new(base), Box::new(exponent)) + } + + pub fn parse(input: &str) -> Self { + Self::try_parse(input) + .unwrap_or_else(|error| panic!("failed to parse expression {input:?}: {error}")) + } + + pub fn try_parse(input: &str) -> Result { + Parser::new(tokenize(input)?).parse() + } + + pub fn variables(&self) -> BTreeSet<&str> { + let mut variables = BTreeSet::new(); + self.collect_variables(&mut variables); + variables + } + + fn collect_variables<'a>(&'a self, variables: &mut BTreeSet<&'a str>) { + match self { + Self::Const(_) => {} + Self::Var(name) => { + variables.insert(name); + } + Self::Add(left, right) + | Self::Sub(left, right) + | Self::Mul(left, right) + | Self::Div(left, right) + | Self::Pow(left, right) => { + left.collect_variables(variables); + right.collect_variables(variables); + } + Self::Neg(value) + | Self::Exp(value) + | Self::Log(value) + | Self::Sqrt(value) + | Self::Factorial(value) => value.collect_variables(variables), + } + } + + pub fn substitute(&self, replacements: &HashMap<&str, &Expr>) -> Expr { + match self { + Self::Const(value) => Self::Const(value.clone()), + Self::Var(name) => replacements + .get(name.as_ref()) + .map_or_else(|| self.clone(), |replacement| (*replacement).clone()), + Self::Add(left, right) => { + left.substitute(replacements) + right.substitute(replacements) + } + Self::Sub(left, right) => { + left.substitute(replacements) - right.substitute(replacements) + } + Self::Mul(left, right) => { + left.substitute(replacements) * right.substitute(replacements) + } + Self::Div(left, right) => { + left.substitute(replacements) / right.substitute(replacements) + } + Self::Pow(base, exponent) => Self::pow( + base.substitute(replacements), + exponent.substitute(replacements), + ), + Self::Neg(value) => -value.substitute(replacements), + Self::Exp(value) => Self::Exp(Box::new(value.substitute(replacements))), + Self::Log(value) => Self::Log(Box::new(value.substitute(replacements))), + Self::Sqrt(value) => Self::Sqrt(Box::new(value.substitute(replacements))), + Self::Factorial(value) => Self::Factorial(Box::new(value.substitute(replacements))), + } + } + + pub fn is_constant(&self) -> bool { + match self { + Self::Const(_) => true, + Self::Var(_) => false, + Self::Add(left, right) + | Self::Sub(left, right) + | Self::Mul(left, right) + | Self::Div(left, right) + | Self::Pow(left, right) => left.is_constant() && right.is_constant(), + Self::Neg(value) + | Self::Exp(value) + | Self::Log(value) + | Self::Sqrt(value) + | Self::Factorial(value) => value.is_constant(), + } + } + + pub fn is_polynomial(&self) -> bool { + match self { + Self::Const(_) | Self::Var(_) => true, + Self::Add(left, right) | Self::Sub(left, right) | Self::Mul(left, right) => { + left.is_polynomial() && right.is_polynomial() + } + Self::Pow(base, exponent) => { + base.is_polynomial() + && matches!(exponent.as_ref(), Self::Const(value) if value.is_integer() && !value.is_negative()) + } + Self::Div(_, _) + | Self::Neg(_) + | Self::Exp(_) + | Self::Log(_) + | Self::Sqrt(_) + | Self::Factorial(_) => false, + } + } + + pub fn is_valid_complexity_notation(&self) -> bool { + match self { + Self::Const(value) => value.is_one(), + Self::Var(_) => true, + Self::Add(left, right) | Self::Mul(left, right) => { + !left.is_constant() + && !right.is_constant() + && left.is_valid_complexity_notation() + && right.is_valid_complexity_notation() + } + Self::Pow(base, exponent) => { + let base_valid = if let Self::Const(value) = base.as_ref() { + value.is_positive() + } else { + base.is_valid_complexity_notation() + }; + base_valid && (exponent.is_constant() || exponent.is_valid_complexity_notation()) + } + Self::Exp(value) | Self::Log(value) | Self::Sqrt(value) | Self::Factorial(value) => { + value.is_valid_complexity_notation() + } + Self::Sub(_, _) | Self::Div(_, _) | Self::Neg(_) => false, + } + } +} + +impl std::ops::Add for Expr { + type Output = Self; + fn add(self, rhs: Self) -> Self::Output { + Self::Add(Box::new(self), Box::new(rhs)) + } +} + +impl std::ops::Sub for Expr { + type Output = Self; + fn sub(self, rhs: Self) -> Self::Output { + Self::Sub(Box::new(self), Box::new(rhs)) + } +} + +impl std::ops::Mul for Expr { + type Output = Self; + fn mul(self, rhs: Self) -> Self::Output { + Self::Mul(Box::new(self), Box::new(rhs)) + } +} + +impl std::ops::Div for Expr { + type Output = Self; + fn div(self, rhs: Self) -> Self::Output { + Self::Div(Box::new(self), Box::new(rhs)) + } +} + +impl std::ops::Neg for Expr { + type Output = Self; + fn neg(self) -> Self::Output { + Self::Neg(Box::new(self)) + } +} + +impl fmt::Display for Expr { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.fmt_with_precedence(formatter, 0, false) + } +} + +impl Expr { + fn precedence(&self) -> u8 { + match self { + Self::Add(_, _) | Self::Sub(_, _) => 1, + Self::Mul(_, _) | Self::Div(_, _) => 2, + Self::Neg(_) => 3, + Self::Pow(_, _) => 4, + _ => 5, + } + } + + fn fmt_with_precedence( + &self, + formatter: &mut fmt::Formatter<'_>, + parent_precedence: u8, + right_child: bool, + ) -> fmt::Result { + let precedence = self.precedence(); + let needs_parentheses = precedence < parent_precedence + || (right_child + && precedence == parent_precedence + && matches!( + self, + Self::Add(_, _) | Self::Sub(_, _) | Self::Mul(_, _) | Self::Div(_, _) + )) + || (!right_child && precedence == parent_precedence && matches!(self, Self::Pow(_, _))); + if needs_parentheses { + write!(formatter, "(")?; + } + match self { + Self::Const(value) => fmt_rational(value, formatter)?, + Self::Var(name) => write!(formatter, "{name}")?, + Self::Add(left, right) => { + left.fmt_with_precedence(formatter, precedence, false)?; + write!(formatter, " + ")?; + right.fmt_with_precedence(formatter, precedence, true)?; + } + Self::Sub(left, right) => { + left.fmt_with_precedence(formatter, precedence, false)?; + write!(formatter, " - ")?; + right.fmt_with_precedence(formatter, precedence, true)?; + } + Self::Mul(left, right) => { + left.fmt_with_precedence(formatter, precedence, false)?; + write!(formatter, " * ")?; + right.fmt_with_precedence(formatter, precedence, true)?; + } + Self::Div(left, right) => { + left.fmt_with_precedence(formatter, precedence, false)?; + write!(formatter, " / ")?; + right.fmt_with_precedence(formatter, precedence, true)?; + } + Self::Pow(base, exponent) => { + base.fmt_with_precedence(formatter, precedence, false)?; + write!(formatter, "^")?; + exponent.fmt_with_precedence(formatter, precedence, true)?; + } + Self::Neg(value) => { + write!(formatter, "-")?; + value.fmt_with_precedence(formatter, precedence, true)?; + } + Self::Exp(value) => write!(formatter, "exp({value})")?, + Self::Log(value) => write!(formatter, "log({value})")?, + Self::Sqrt(value) => write!(formatter, "sqrt({value})")?, + Self::Factorial(value) => write!(formatter, "factorial({value})")?, + } + if needs_parentheses { + write!(formatter, ")")?; + } + Ok(()) + } +} + +fn fmt_rational(value: &BigRational, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if value.is_integer() { + return write!(formatter, "{}", value.to_integer()); + } + let negative = value.is_negative(); + let numerator = value.numer().abs(); + let mut denominator = value.denom().clone(); + let mut twos = 0usize; + let mut fives = 0usize; + while (&denominator % 2u8).is_zero() { + denominator /= 2u8; + twos += 1; + } + while (&denominator % 5u8).is_zero() { + denominator /= 5u8; + fives += 1; + } + if !denominator.is_one() { + return write!(formatter, "{}/{}", value.numer(), value.denom()); + } + let scale = twos.max(fives); + let scaled = numerator + * BigInt::from(2u8).pow((scale - twos) as u32) + * BigInt::from(5u8).pow((scale - fives) as u32); + let digits = scaled.to_string(); + let sign = if negative { "-" } else { "" }; + if digits.len() <= scale { + write!(formatter, "{sign}0.{:0>width$}", digits, width = scale) + } else { + let split = digits.len() - scale; + write!(formatter, "{sign}{}.{}", &digits[..split], &digits[split..]) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("{message} at byte {position}")] +pub struct ParseError { + position: usize, + message: String, +} + +impl ParseError { + fn new(position: usize, message: impl Into) -> Self { + Self { + position, + message: message.into(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Token { + position: usize, + kind: TokenKind, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum TokenKind { + Number(BigRational), + Ident(Box), + Plus, + Minus, + Star, + Slash, + Caret, + LeftParen, + RightParen, +} + +fn tokenize(input: &str) -> Result, ParseError> { + let bytes = input.as_bytes(); + let mut tokens = Vec::new(); + let mut position = 0; + while position < bytes.len() { + match bytes[position] { + b' ' | b'\t' | b'\n' | b'\r' => position += 1, + b'+' => push_token(&mut tokens, &mut position, TokenKind::Plus), + b'-' => push_token(&mut tokens, &mut position, TokenKind::Minus), + b'*' => push_token(&mut tokens, &mut position, TokenKind::Star), + b'/' => push_token(&mut tokens, &mut position, TokenKind::Slash), + b'^' => push_token(&mut tokens, &mut position, TokenKind::Caret), + b'(' => push_token(&mut tokens, &mut position, TokenKind::LeftParen), + b')' => push_token(&mut tokens, &mut position, TokenKind::RightParen), + byte if byte.is_ascii_digit() || byte == b'.' => { + let start = position; + while position < bytes.len() + && (bytes[position].is_ascii_digit() || bytes[position] == b'.') + { + position += 1; + } + let spelling = &input[start..position]; + let value = parse_decimal(spelling).ok_or_else(|| { + ParseError::new(start, format!("invalid number {spelling:?}")) + })?; + tokens.push(Token { + position: start, + kind: TokenKind::Number(value), + }); + } + byte if byte.is_ascii_alphabetic() || byte == b'_' => { + let start = position; + while position < bytes.len() + && (bytes[position].is_ascii_alphanumeric() || bytes[position] == b'_') + { + position += 1; + } + tokens.push(Token { + position: start, + kind: TokenKind::Ident(input[start..position].into()), + }); + } + _ => { + let character = input[position..].chars().next().unwrap(); + return Err(ParseError::new( + position, + format!("unexpected character {character:?}"), + )); + } + } + } + Ok(tokens) +} + +fn push_token(tokens: &mut Vec, position: &mut usize, kind: TokenKind) { + tokens.push(Token { + position: *position, + kind, + }); + *position += 1; +} + +fn parse_decimal(spelling: &str) -> Option { + let mut parts = spelling.split('.'); + let integer = parts.next()?; + let fractional = parts.next(); + if parts.next().is_some() || (integer.is_empty() && fractional.is_none()) { + return None; + } + match fractional { + None => BigInt::from_str(integer) + .ok() + .map(BigRational::from_integer), + Some(fractional) if !integer.is_empty() || !fractional.is_empty() => { + let combined = format!("{integer}{fractional}"); + let numerator = BigInt::from_str(&combined).ok()?; + let denominator = BigInt::from(10u8).pow(fractional.len() as u32); + Some(BigRational::new(numerator, denominator)) + } + Some(_) => None, + } +} + +struct Parser { + tokens: Vec, + position: usize, +} + +impl Parser { + fn new(tokens: Vec) -> Self { + Self { + tokens, + position: 0, + } + } + + fn parse(mut self) -> Result { + if self.tokens.is_empty() { + return Err(ParseError::new(0, "expected expression")); + } + let expression = self.parse_additive()?; + if let Some(token) = self.peek() { + return Err(ParseError::new(token.position, "unexpected trailing token")); + } + Ok(expression) + } + + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.position) + } + + fn advance(&mut self) -> Option { + let token = self.tokens.get(self.position).cloned(); + self.position += usize::from(token.is_some()); + token + } + + fn consume(&mut self, kind: &TokenKind) -> bool { + if self.peek().is_some_and(|token| &token.kind == kind) { + self.position += 1; + true + } else { + false + } + } + + fn parse_additive(&mut self) -> Result { + let mut expression = self.parse_multiplicative()?; + loop { + if self.consume(&TokenKind::Plus) { + expression = expression + self.parse_multiplicative()?; + } else if self.consume(&TokenKind::Minus) { + expression = expression - self.parse_multiplicative()?; + } else { + return Ok(expression); + } + } + } + + fn parse_multiplicative(&mut self) -> Result { + let mut expression = self.parse_unary()?; + loop { + if self.consume(&TokenKind::Star) { + expression = expression * self.parse_unary()?; + } else if self.consume(&TokenKind::Slash) { + expression = expression / self.parse_unary()?; + } else { + return Ok(expression); + } + } + } + + fn parse_unary(&mut self) -> Result { + if self.consume(&TokenKind::Minus) { + Ok(-self.parse_unary()?) + } else { + self.parse_power() + } + } + + fn parse_power(&mut self) -> Result { + let base = self.parse_primary()?; + if self.consume(&TokenKind::Caret) { + Ok(Expr::pow(base, self.parse_unary()?)) + } else { + Ok(base) + } + } + + fn parse_primary(&mut self) -> Result { + let token = self + .advance() + .ok_or_else(|| ParseError::new(self.end_position(), "expected expression"))?; + match token.kind { + TokenKind::Number(value) => Ok(Expr::Const(value)), + TokenKind::Ident(name) => { + if !self.consume(&TokenKind::LeftParen) { + return Ok(Expr::Var(name)); + } + let argument = self.parse_additive()?; + self.expect_right_paren()?; + match name.as_ref() { + "exp" => Ok(Expr::Exp(Box::new(argument))), + "log" => Ok(Expr::Log(Box::new(argument))), + "sqrt" => Ok(Expr::Sqrt(Box::new(argument))), + "factorial" => Ok(Expr::Factorial(Box::new(argument))), + _ => Err(ParseError::new( + token.position, + format!("unknown function {name:?}"), + )), + } + } + TokenKind::LeftParen => { + let expression = self.parse_additive()?; + self.expect_right_paren()?; + Ok(expression) + } + _ => Err(ParseError::new(token.position, "expected expression")), + } + } + + fn expect_right_paren(&mut self) -> Result<(), ParseError> { + if self.consume(&TokenKind::RightParen) { + Ok(()) + } else { + Err(ParseError::new( + self.end_position(), + "expected closing parenthesis", + )) + } + } + + fn end_position(&self) -> usize { + self.peek().map_or_else( + || self.tokens.last().map_or(0, |token| token.position + 1), + |token| token.position, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decimal_literals_are_exact() { + assert_eq!(Expr::parse("2.372"), Expr::rational(593, 250)); + } + + #[test] + fn parser_preserves_source_operators() { + let expression = Expr::parse("n * (n - 1) / 2 - m"); + assert!(matches!(expression, Expr::Sub(_, _))); + let Expr::Sub(left, _) = expression else { + unreachable!() + }; + assert!(matches!(left.as_ref(), Expr::Div(_, _))); + } + + #[test] + fn variables_are_owned() { + let name = String::from("dynamic_size"); + let expression = Expr::parse(&name); + drop(name); + assert_eq!(expression.variables(), BTreeSet::from(["dynamic_size"])); + } + + #[test] + fn exponentiation_precedes_unary_minus() { + assert_eq!( + Expr::parse("-n^2"), + -Expr::pow(Expr::variable("n"), Expr::integer(2)) + ); + assert_eq!( + Expr::parse("2^-3"), + Expr::pow(Expr::integer(2), -Expr::integer(3)) + ); + } + + #[test] + fn display_preserves_grouping() { + let expression = Expr::parse("n * (n - 1) / 2 - m"); + assert_eq!(expression.to_string(), "n * (n - 1) / 2 - m"); + assert_eq!(Expr::parse(&expression.to_string()), expression); + } + + #[test] + fn serialization_preserves_every_operator() { + let expression = Expr::parse("-factorial(n - 1) + exp(m) / log(sqrt(k))^2"); + let encoded = serde_json::to_string(&expression).unwrap(); + let decoded: Expr = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, expression); + } + + #[test] + fn display_does_not_normalize_half_power_to_sqrt() { + let power = Expr::pow(Expr::variable("n"), Expr::rational(1, 2)); + assert_eq!(power.to_string(), "n^0.5"); + assert_eq!(Expr::parse(&power.to_string()), power); + } +} diff --git a/problemreductions-macros/Cargo.toml b/problemreductions-macros/Cargo.toml index 9db71743c..16b94ead5 100644 --- a/problemreductions-macros/Cargo.toml +++ b/problemreductions-macros/Cargo.toml @@ -13,3 +13,5 @@ proc-macro = true syn = { version = "2.0", features = ["full", "parsing"] } quote = "1.0" proc-macro2 = "1.0" +problemreductions-expr = { version = "0.6.0", path = "../problemreductions-expr" } +num-traits = "0.2" diff --git a/problemreductions-macros/src/expr_codegen.rs b/problemreductions-macros/src/expr_codegen.rs new file mode 100644 index 000000000..50ce89933 --- /dev/null +++ b/problemreductions-macros/src/expr_codegen.rs @@ -0,0 +1,180 @@ +use num_traits::ToPrimitive; +use problemreductions_expr::Expr; +use proc_macro2::TokenStream; +use quote::quote; + +pub(crate) trait ExprCodegen { + fn to_expr_tokens(&self) -> TokenStream; + fn to_eval_tokens(&self, source: &syn::Ident) -> TokenStream; +} + +impl ExprCodegen for Expr { + fn to_expr_tokens(&self) -> TokenStream { + match self { + Expr::Const(value) => { + let numerator = value.numer().to_string(); + let denominator = value.denom().to_string(); + quote! { + crate::expr::Expr::rational( + #numerator.parse::().expect("macro-generated numerator must be valid"), + #denominator.parse::().expect("macro-generated denominator must be valid"), + ) + } + } + Expr::Var(name) => quote! { crate::expr::Expr::variable(#name) }, + Expr::Add(left, right) => { + binary_tokens(left, right, |left, right| quote! { (#left) + (#right) }) + } + Expr::Sub(left, right) => { + binary_tokens(left, right, |left, right| quote! { (#left) - (#right) }) + } + Expr::Mul(left, right) => { + binary_tokens(left, right, |left, right| quote! { (#left) * (#right) }) + } + Expr::Div(left, right) => { + binary_tokens(left, right, |left, right| quote! { (#left) / (#right) }) + } + Expr::Pow(base, exponent) => { + let base = base.to_expr_tokens(); + let exponent = exponent.to_expr_tokens(); + quote! { crate::expr::Expr::pow(#base, #exponent) } + } + Expr::Neg(value) => { + let value = value.to_expr_tokens(); + quote! { -(#value) } + } + Expr::Exp(value) => unary_tokens( + value, + |value| quote! { crate::expr::Expr::Exp(Box::new(#value)) }, + ), + Expr::Log(value) => unary_tokens( + value, + |value| quote! { crate::expr::Expr::Log(Box::new(#value)) }, + ), + Expr::Sqrt(value) => unary_tokens( + value, + |value| quote! { crate::expr::Expr::Sqrt(Box::new(#value)) }, + ), + Expr::Factorial(value) => unary_tokens( + value, + |value| quote! { crate::expr::Expr::Factorial(Box::new(#value)) }, + ), + } + } + + fn to_eval_tokens(&self, source: &syn::Ident) -> TokenStream { + match self { + Expr::Const(value) => { + let value = value + .to_f64() + .expect("expression constant must fit the temporary f64 evaluator"); + quote! { #value } + } + Expr::Var(name) => { + let getter = syn::Ident::new(name, proc_macro2::Span::call_site()); + quote! { (#source.#getter() as f64) } + } + Expr::Add(left, right) => eval_binary_tokens( + left, + right, + source, + |left, right| quote! { (#left + #right) }, + ), + Expr::Sub(left, right) => eval_binary_tokens( + left, + right, + source, + |left, right| quote! { (#left - #right) }, + ), + Expr::Mul(left, right) => eval_binary_tokens( + left, + right, + source, + |left, right| quote! { ::std::ops::Mul::mul(#left, #right) }, + ), + Expr::Div(left, right) => eval_binary_tokens( + left, + right, + source, + |left, right| quote! { (#left / #right) }, + ), + Expr::Pow(base, exponent) => eval_binary_tokens( + base, + exponent, + source, + |base, exponent| quote! { f64::powf(#base, #exponent) }, + ), + Expr::Neg(value) => { + let value = value.to_eval_tokens(source); + quote! { -(#value) } + } + Expr::Exp(value) => { + eval_unary_tokens(value, source, |value| quote! { f64::exp(#value) }) + } + Expr::Log(value) => { + eval_unary_tokens(value, source, |value| quote! { f64::ln(#value) }) + } + Expr::Sqrt(value) => { + eval_unary_tokens(value, source, |value| quote! { f64::sqrt(#value) }) + } + Expr::Factorial(value) => { + let value = value.to_eval_tokens(source); + quote! {{ + let __n = #value; + let __rounded = __n.round(); + if (__n - __rounded).abs() < 1e-10 && __rounded >= 0.0 { + (2..=(__rounded as u64)).fold(1.0f64, |product, factor| product * factor as f64) + } else { + (2.0 * ::std::f64::consts::PI * __n).sqrt() + * (__n / ::std::f64::consts::E).powf(__n) + } + }} + } + } + } +} + +fn binary_tokens( + left: &Expr, + right: &Expr, + build: impl FnOnce(TokenStream, TokenStream) -> TokenStream, +) -> TokenStream { + build(left.to_expr_tokens(), right.to_expr_tokens()) +} + +fn unary_tokens(value: &Expr, build: impl FnOnce(TokenStream) -> TokenStream) -> TokenStream { + build(value.to_expr_tokens()) +} + +fn eval_binary_tokens( + left: &Expr, + right: &Expr, + source: &syn::Ident, + build: impl FnOnce(TokenStream, TokenStream) -> TokenStream, +) -> TokenStream { + build(left.to_eval_tokens(source), right.to_eval_tokens(source)) +} + +fn eval_unary_tokens( + value: &Expr, + source: &syn::Ident, + build: impl FnOnce(TokenStream) -> TokenStream, +) -> TokenStream { + build(value.to_eval_tokens(source)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_parser_drives_codegen() { + let expression = Expr::parse("n * (n - 1) / 2 - m"); + assert!(matches!(expression, Expr::Sub(_, _))); + assert_eq!( + expression.variables().into_iter().collect::>(), + vec!["m", "n"] + ); + assert!(!expression.to_expr_tokens().is_empty()); + } +} diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index fc8be8213..757729960 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -5,8 +5,9 @@ //! and the `declare_variants!` proc macro for compile-time validated variant //! registration. -pub(crate) mod parser; +mod expr_codegen; +use expr_codegen::ExprCodegen; use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::quote; @@ -230,7 +231,7 @@ fn generate_parsed_overhead(fields: &[(String, String)]) -> syn::Result syn::Result = entry.aliases.iter().map(|s| s.value()).collect(); // Parse the complexity expression to validate syntax - let parsed = parser::parse_expr(&complexity_str).map_err(|e| { + let parsed = problemreductions_expr::Expr::try_parse(&complexity_str).map_err(|e| { syn::Error::new( entry.complexity.span(), format!("invalid complexity expression \"{complexity_str}\": {e}"), @@ -713,7 +714,7 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result syn::Result { let src_ident = syn::Ident::new("__src", proc_macro2::Span::call_site()); diff --git a/problemreductions-macros/src/parser.rs b/problemreductions-macros/src/parser.rs deleted file mode 100644 index 36e9505cd..000000000 --- a/problemreductions-macros/src/parser.rs +++ /dev/null @@ -1,489 +0,0 @@ -//! Pratt parser for overhead expression strings. -//! -//! Parses expressions like: -//! - `"num_vertices"` -//! - `"num_vertices^2"` -//! - `"num_edges + num_vertices^2"` -//! - `"3 * num_vertices"` -//! - `"exp(num_vertices^2)"` -//! - `"sqrt(num_edges)"` -//! -//! Grammar: -//! expr = term (('+' | '-') term)* -//! term = factor (('*' | '/') factor)* -//! factor = unary ('^' factor)? // right-associative -//! unary = '-' unary | primary -//! primary = NUMBER | IDENT | func_call | '(' expr ')' -//! func_call = ('exp' | 'log' | 'sqrt' | 'factorial') '(' expr ')' - -use proc_macro2::TokenStream; -use quote::quote; - -/// Parsed expression node (intermediate representation before codegen). -#[derive(Debug, Clone, PartialEq)] -pub enum ParsedExpr { - Const(f64), - Var(String), - Add(Box, Box), - Sub(Box, Box), - Mul(Box, Box), - Div(Box, Box), - Pow(Box, Box), - Neg(Box), - Exp(Box), - Log(Box), - Sqrt(Box), - Factorial(Box), -} - -#[derive(Debug, Clone, PartialEq)] -enum Token { - Number(f64), - Ident(String), - Plus, - Minus, - Star, - Slash, - Caret, - LParen, - RParen, -} - -fn tokenize(input: &str) -> Result, String> { - let mut tokens = Vec::new(); - let mut chars = input.chars().peekable(); - while let Some(&ch) = chars.peek() { - match ch { - ' ' | '\t' | '\n' => { - chars.next(); - } - '+' => { - chars.next(); - tokens.push(Token::Plus); - } - '-' => { - chars.next(); - tokens.push(Token::Minus); - } - '*' => { - chars.next(); - tokens.push(Token::Star); - } - '/' => { - chars.next(); - tokens.push(Token::Slash); - } - '^' => { - chars.next(); - tokens.push(Token::Caret); - } - '(' => { - chars.next(); - tokens.push(Token::LParen); - } - ')' => { - chars.next(); - tokens.push(Token::RParen); - } - c if c.is_ascii_digit() || c == '.' => { - let mut num = String::new(); - while let Some(&c) = chars.peek() { - if c.is_ascii_digit() || c == '.' { - num.push(c); - chars.next(); - } else { - break; - } - } - let val: f64 = num.parse().map_err(|_| format!("invalid number: {num}"))?; - tokens.push(Token::Number(val)); - } - c if c.is_ascii_alphabetic() || c == '_' => { - let mut ident = String::new(); - while let Some(&c) = chars.peek() { - if c.is_ascii_alphanumeric() || c == '_' { - ident.push(c); - chars.next(); - } else { - break; - } - } - tokens.push(Token::Ident(ident)); - } - _ => return Err(format!("unexpected character: '{ch}'")), - } - } - Ok(tokens) -} - -struct Parser { - tokens: Vec, - pos: usize, -} - -impl Parser { - fn new(tokens: Vec) -> Self { - Self { tokens, pos: 0 } - } - - fn peek(&self) -> Option<&Token> { - self.tokens.get(self.pos) - } - - fn advance(&mut self) -> Option { - let tok = self.tokens.get(self.pos).cloned(); - self.pos += 1; - tok - } - - fn expect(&mut self, expected: &Token) -> Result<(), String> { - match self.advance() { - Some(ref tok) if tok == expected => Ok(()), - Some(tok) => Err(format!("expected {expected:?}, got {tok:?}")), - None => Err(format!("expected {expected:?}, got end of input")), - } - } - - fn parse_expr(&mut self) -> Result { - let mut left = self.parse_term()?; - while matches!(self.peek(), Some(Token::Plus) | Some(Token::Minus)) { - let op = self.advance().unwrap(); - let right = self.parse_term()?; - left = match op { - Token::Plus => ParsedExpr::Add(Box::new(left), Box::new(right)), - Token::Minus => ParsedExpr::Sub(Box::new(left), Box::new(right)), - _ => unreachable!(), - }; - } - Ok(left) - } - - fn parse_term(&mut self) -> Result { - let mut left = self.parse_factor()?; - while matches!(self.peek(), Some(Token::Star) | Some(Token::Slash)) { - let op = self.advance().unwrap(); - let right = self.parse_factor()?; - left = match op { - Token::Star => ParsedExpr::Mul(Box::new(left), Box::new(right)), - Token::Slash => ParsedExpr::Div(Box::new(left), Box::new(right)), - _ => unreachable!(), - }; - } - Ok(left) - } - - fn parse_factor(&mut self) -> Result { - let base = self.parse_unary()?; - if matches!(self.peek(), Some(Token::Caret)) { - self.advance(); - let exp = self.parse_factor()?; // right-associative - Ok(ParsedExpr::Pow(Box::new(base), Box::new(exp))) - } else { - Ok(base) - } - } - - fn parse_unary(&mut self) -> Result { - if matches!(self.peek(), Some(Token::Minus)) { - self.advance(); - let expr = self.parse_unary()?; - Ok(ParsedExpr::Neg(Box::new(expr))) - } else { - self.parse_primary() - } - } - - fn parse_primary(&mut self) -> Result { - match self.advance() { - Some(Token::Number(n)) => Ok(ParsedExpr::Const(n)), - Some(Token::Ident(name)) => { - // Check for function call: exp(...), log(...), sqrt(...) - if matches!(self.peek(), Some(Token::LParen)) { - self.advance(); // consume '(' - let arg = self.parse_expr()?; - self.expect(&Token::RParen)?; - match name.as_str() { - "exp" => Ok(ParsedExpr::Exp(Box::new(arg))), - "log" => Ok(ParsedExpr::Log(Box::new(arg))), - "sqrt" => Ok(ParsedExpr::Sqrt(Box::new(arg))), - "factorial" => Ok(ParsedExpr::Factorial(Box::new(arg))), - _ => Err(format!("unknown function: {name}")), - } - } else { - Ok(ParsedExpr::Var(name)) - } - } - Some(Token::LParen) => { - let expr = self.parse_expr()?; - self.expect(&Token::RParen)?; - Ok(expr) - } - Some(tok) => Err(format!("unexpected token: {tok:?}")), - None => Err("unexpected end of input".to_string()), - } - } -} - -/// Parse an expression string into a ParsedExpr. -pub fn parse_expr(input: &str) -> Result { - let tokens = tokenize(input)?; - let mut parser = Parser::new(tokens); - let expr = parser.parse_expr()?; - if parser.pos != parser.tokens.len() { - return Err(format!( - "unexpected trailing tokens at position {}", - parser.pos - )); - } - Ok(expr) -} - -impl ParsedExpr { - /// Generate TokenStream that constructs an `Expr` value. - pub fn to_expr_tokens(&self) -> TokenStream { - match self { - ParsedExpr::Const(c) => quote! { crate::expr::Expr::Const(#c) }, - ParsedExpr::Var(name) => quote! { crate::expr::Expr::Var(#name) }, - ParsedExpr::Add(a, b) => { - let a = a.to_expr_tokens(); - let b = b.to_expr_tokens(); - quote! { (#a) + (#b) } - } - ParsedExpr::Sub(a, b) => { - let a = a.to_expr_tokens(); - let b = b.to_expr_tokens(); - quote! { (#a) - (#b) } - } - ParsedExpr::Mul(a, b) => { - let a = a.to_expr_tokens(); - let b = b.to_expr_tokens(); - quote! { (#a) * (#b) } - } - ParsedExpr::Div(a, b) => { - let a = a.to_expr_tokens(); - let b = b.to_expr_tokens(); - quote! { (#a) / (#b) } - } - ParsedExpr::Pow(base, exp) => { - let base = base.to_expr_tokens(); - let exp = exp.to_expr_tokens(); - quote! { crate::expr::Expr::pow(#base, #exp) } - } - ParsedExpr::Neg(a) => { - let a = a.to_expr_tokens(); - quote! { -(#a) } - } - ParsedExpr::Exp(a) => { - let a = a.to_expr_tokens(); - quote! { crate::expr::Expr::Exp(Box::new(#a)) } - } - ParsedExpr::Log(a) => { - let a = a.to_expr_tokens(); - quote! { crate::expr::Expr::Log(Box::new(#a)) } - } - ParsedExpr::Sqrt(a) => { - let a = a.to_expr_tokens(); - quote! { crate::expr::Expr::Sqrt(Box::new(#a)) } - } - ParsedExpr::Factorial(a) => { - let a = a.to_expr_tokens(); - quote! { crate::expr::Expr::Factorial(Box::new(#a)) } - } - } - } - - /// Generate TokenStream that evaluates the expression by calling getter methods - /// on a source variable `src`. - pub fn to_eval_tokens(&self, src_ident: &syn::Ident) -> TokenStream { - match self { - ParsedExpr::Const(c) => quote! { (#c as f64) }, - ParsedExpr::Var(name) => { - let getter = syn::Ident::new(name, proc_macro2::Span::call_site()); - quote! { (#src_ident.#getter() as f64) } - } - ParsedExpr::Add(a, b) => { - let a = a.to_eval_tokens(src_ident); - let b = b.to_eval_tokens(src_ident); - quote! { (#a + #b) } - } - ParsedExpr::Sub(a, b) => { - let a = a.to_eval_tokens(src_ident); - let b = b.to_eval_tokens(src_ident); - quote! { (#a - #b) } - } - ParsedExpr::Mul(a, b) => { - let a = a.to_eval_tokens(src_ident); - let b = b.to_eval_tokens(src_ident); - quote! { (#a * #b) } - } - ParsedExpr::Div(a, b) => { - let a = a.to_eval_tokens(src_ident); - let b = b.to_eval_tokens(src_ident); - quote! { (#a / #b) } - } - ParsedExpr::Pow(base, exp) => { - let base = base.to_eval_tokens(src_ident); - let exp = exp.to_eval_tokens(src_ident); - quote! { f64::powf(#base, #exp) } - } - ParsedExpr::Neg(a) => { - let a = a.to_eval_tokens(src_ident); - quote! { (-(#a)) } - } - ParsedExpr::Exp(a) => { - let a = a.to_eval_tokens(src_ident); - quote! { f64::exp(#a) } - } - ParsedExpr::Log(a) => { - let a = a.to_eval_tokens(src_ident); - quote! { f64::ln(#a) } - } - ParsedExpr::Sqrt(a) => { - let a = a.to_eval_tokens(src_ident); - quote! { f64::sqrt(#a) } - } - ParsedExpr::Factorial(a) => { - let a = a.to_eval_tokens(src_ident); - quote! { { - let __n = #a; - let __r = __n.round(); - if (__n - __r).abs() < 1e-10 && __r >= 0.0 { - let mut __f = 1u64; - let __k = __r as u64; - let mut __i = 2u64; - while __i <= __k { __f = __f.saturating_mul(__i); __i += 1; } - __f as f64 - } else { - (2.0 * ::std::f64::consts::PI * __n).sqrt() * (__n / ::std::f64::consts::E).powf(__n) - } - } } - } - } - } - - /// Collect all variable names in the expression. - pub fn variables(&self) -> Vec { - let mut vars = Vec::new(); - self.collect_vars(&mut vars); - vars.sort(); - vars.dedup(); - vars - } - - fn collect_vars(&self, vars: &mut Vec) { - match self { - ParsedExpr::Const(_) => {} - ParsedExpr::Var(name) => vars.push(name.clone()), - ParsedExpr::Add(a, b) - | ParsedExpr::Sub(a, b) - | ParsedExpr::Mul(a, b) - | ParsedExpr::Div(a, b) - | ParsedExpr::Pow(a, b) => { - a.collect_vars(vars); - b.collect_vars(vars); - } - ParsedExpr::Neg(a) - | ParsedExpr::Exp(a) - | ParsedExpr::Log(a) - | ParsedExpr::Sqrt(a) - | ParsedExpr::Factorial(a) => { - a.collect_vars(vars); - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_var() { - assert_eq!( - parse_expr("num_vertices").unwrap(), - ParsedExpr::Var("num_vertices".into()) - ); - } - - #[test] - fn test_parse_const() { - assert_eq!(parse_expr("42").unwrap(), ParsedExpr::Const(42.0)); - } - - #[test] - fn test_parse_pow() { - let e = parse_expr("n^2").unwrap(); - assert_eq!( - e, - ParsedExpr::Pow( - Box::new(ParsedExpr::Var("n".into())), - Box::new(ParsedExpr::Const(2.0)), - ) - ); - } - - #[test] - fn test_parse_add_mul() { - // n + 3 * m → n + (3*m) - let e = parse_expr("n + 3 * m").unwrap(); - assert_eq!( - e, - ParsedExpr::Add( - Box::new(ParsedExpr::Var("n".into())), - Box::new(ParsedExpr::Mul( - Box::new(ParsedExpr::Const(3.0)), - Box::new(ParsedExpr::Var("m".into())), - )), - ) - ); - } - - #[test] - fn test_parse_exp() { - let e = parse_expr("exp(n^2)").unwrap(); - assert_eq!( - e, - ParsedExpr::Exp(Box::new(ParsedExpr::Pow( - Box::new(ParsedExpr::Var("n".into())), - Box::new(ParsedExpr::Const(2.0)), - ))) - ); - } - - #[test] - fn test_parse_complex() { - // 3 * n^2 + exp(m) — should parse correctly - let e = parse_expr("3 * n^2 + exp(m)").unwrap(); - assert!(matches!(e, ParsedExpr::Add(_, _))); - } - - #[test] - fn test_parse_parens() { - let e = parse_expr("(n + m)^2").unwrap(); - assert!(matches!(e, ParsedExpr::Pow(_, _))); - } - - #[test] - fn test_variables() { - let e = parse_expr("n^2 + 3 * m + exp(k)").unwrap(); - assert_eq!(e.variables(), vec!["k", "m", "n"]); - } - - #[test] - fn test_parse_neg() { - let e = parse_expr("-n").unwrap(); - assert_eq!(e, ParsedExpr::Neg(Box::new(ParsedExpr::Var("n".into())))); - } - - #[test] - fn test_parse_sub() { - let e = parse_expr("n - m").unwrap(); - assert_eq!( - e, - ParsedExpr::Sub( - Box::new(ParsedExpr::Var("n".into())), - Box::new(ParsedExpr::Var("m".into())), - ) - ); - } -} diff --git a/src/expr.rs b/src/expr.rs index fccbab06c..822adc46f 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -1,299 +1,107 @@ -//! General symbolic expression AST for reduction overhead. +//! Symbolic expression integration for the problem-reduction domain. -use crate::types::ProblemSize; -use std::collections::{HashMap, HashSet}; +pub use num_bigint::BigInt; +use num_rational::BigRational; +use num_traits::{FromPrimitive, ToPrimitive}; +pub use problemreductions_expr::{Expr, ParseError}; use std::fmt; -/// A symbolic math expression over problem size variables. -#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] -pub enum Expr { - /// Numeric constant. - Const(f64), - /// Named variable (e.g., "num_vertices"). - Var(&'static str), - /// Addition: a + b. - Add(Box, Box), - /// Multiplication: a * b. - Mul(Box, Box), - /// Exponentiation: base ^ exponent. - Pow(Box, Box), - /// Exponential function: exp(a). - Exp(Box), - /// Natural logarithm: log(a). - Log(Box), - /// Square root: sqrt(a). - Sqrt(Box), - /// Factorial: factorial(a). - Factorial(Box), -} - -impl Expr { - /// Convenience constructor for exponentiation. - pub fn pow(base: Expr, exp: Expr) -> Self { - Expr::Pow(Box::new(base), Box::new(exp)) - } - - /// Multiply expression by a scalar constant. - pub fn scale(self, c: f64) -> Self { - Expr::Const(c) * self - } +use crate::types::ProblemSize; - /// Evaluate the expression given concrete variable values. - pub fn eval(&self, vars: &ProblemSize) -> f64 { - match self { - Expr::Const(c) => *c, - Expr::Var(name) => vars.get(name).unwrap_or(0) as f64, - Expr::Add(a, b) => a.eval(vars) + b.eval(vars), - Expr::Mul(a, b) => a.eval(vars) * b.eval(vars), - Expr::Pow(base, exp) => base.eval(vars).powf(exp.eval(vars)), - Expr::Exp(a) => a.eval(vars).exp(), - Expr::Log(a) => a.eval(vars).ln(), - Expr::Sqrt(a) => a.eval(vars).sqrt(), - Expr::Factorial(a) => gamma_factorial(a.eval(vars)), +/// Evaluate an expression numerically at an explicitly approximate boundary. +pub fn evaluate_approximate( + expression: &Expr, + variables: &ProblemSize, +) -> Result { + match expression { + Expr::Const(value) => rational_to_f64(value), + Expr::Var(name) => variables + .get(name) + .map(|value| value as f64) + .ok_or_else(|| ApproximationError::MissingVariable(name.to_string())), + Expr::Add(left, right) => { + Ok(evaluate_approximate(left, variables)? + evaluate_approximate(right, variables)?) } - } - - /// Collect all variable names referenced in this expression. - pub fn variables(&self) -> HashSet<&'static str> { - let mut vars = HashSet::new(); - self.collect_variables(&mut vars); - vars - } - - fn collect_variables(&self, vars: &mut HashSet<&'static str>) { - match self { - Expr::Const(_) => {} - Expr::Var(name) => { - vars.insert(name); - } - Expr::Add(a, b) | Expr::Mul(a, b) | Expr::Pow(a, b) => { - a.collect_variables(vars); - b.collect_variables(vars); - } - Expr::Exp(a) | Expr::Log(a) | Expr::Sqrt(a) | Expr::Factorial(a) => { - a.collect_variables(vars); - } + Expr::Sub(left, right) => { + Ok(evaluate_approximate(left, variables)? - evaluate_approximate(right, variables)?) } - } - - /// Substitute variables with other expressions. - pub fn substitute(&self, mapping: &HashMap<&str, &Expr>) -> Expr { - match self { - Expr::Const(c) => Expr::Const(*c), - Expr::Var(name) => { - if let Some(replacement) = mapping.get(name) { - (*replacement).clone() - } else { - Expr::Var(name) - } - } - Expr::Add(a, b) => a.substitute(mapping) + b.substitute(mapping), - Expr::Mul(a, b) => a.substitute(mapping) * b.substitute(mapping), - Expr::Pow(a, b) => Expr::pow(a.substitute(mapping), b.substitute(mapping)), - Expr::Exp(a) => Expr::Exp(Box::new(a.substitute(mapping))), - Expr::Log(a) => Expr::Log(Box::new(a.substitute(mapping))), - Expr::Sqrt(a) => Expr::Sqrt(Box::new(a.substitute(mapping))), - Expr::Factorial(a) => Expr::Factorial(Box::new(a.substitute(mapping))), + Expr::Mul(left, right) => { + Ok(evaluate_approximate(left, variables)? * evaluate_approximate(right, variables)?) } - } - - /// Parse an expression string into an `Expr` at runtime. - /// - /// **Memory note:** Variable names are leaked to `&'static str` via `Box::leak` - /// since `Expr::Var` requires static lifetimes. Each unique variable name leaks - /// a small allocation that is never freed. This is acceptable for testing and - /// one-time cross-check evaluation, but should not be used in hot loops with - /// dynamic input. - /// - /// # Panics - /// Panics if the expression string has invalid syntax. - pub fn parse(input: &str) -> Expr { - Self::try_parse(input) - .unwrap_or_else(|e| panic!("failed to parse expression \"{input}\": {e}")) - } - - /// Parse an expression string into an `Expr`, returning a normal error on failure. - pub fn try_parse(input: &str) -> Result { - parse_to_expr(input) - } - - /// Check if this expression is a polynomial (no exp/log/sqrt, integer exponents only). - pub fn is_polynomial(&self) -> bool { - match self { - Expr::Const(_) | Expr::Var(_) => true, - Expr::Add(a, b) | Expr::Mul(a, b) => a.is_polynomial() && b.is_polynomial(), - Expr::Pow(base, exp) => { - base.is_polynomial() - && matches!(exp.as_ref(), Expr::Const(c) if *c >= 0.0 && (*c - c.round()).abs() < 1e-10) - } - Expr::Exp(_) | Expr::Log(_) | Expr::Sqrt(_) | Expr::Factorial(_) => false, + Expr::Div(left, right) => { + Ok(evaluate_approximate(left, variables)? / evaluate_approximate(right, variables)?) } - } - - /// Check whether this expression is suitable for asymptotic complexity notation. - /// - /// This is intentionally conservative for symbolic size formulas: - /// - rejects explicit multiplicative constant factors like `3 * n` - /// - rejects additive constant terms like `n + 1` - /// - allows constants used as exponents (e.g. `n^(1/3)`) - /// - allows constants used as exponential bases (e.g. `2^n`) - /// - /// The goal is to accept expressions that already look like reduced - /// asymptotic notation, rather than exact-count formulas. - pub fn is_valid_complexity_notation(&self) -> bool { - self.is_valid_complexity_notation_inner() - } - - fn is_valid_complexity_notation_inner(&self) -> bool { - match self { - Expr::Const(c) => (*c - 1.0).abs() < 1e-10, - Expr::Var(_) => true, - Expr::Add(a, b) => { - a.constant_value().is_none() - && b.constant_value().is_none() - && a.is_valid_complexity_notation_inner() - && b.is_valid_complexity_notation_inner() - } - Expr::Mul(a, b) => { - a.constant_value().is_none() - && b.constant_value().is_none() - && a.is_valid_complexity_notation_inner() - && b.is_valid_complexity_notation_inner() - } - Expr::Pow(base, exp) => { - let base_is_constant = base.constant_value().is_some(); - let exp_is_constant = exp.constant_value().is_some(); - - let base_ok = if base_is_constant { - base.is_valid_exponential_base() - } else { - base.is_valid_complexity_notation_inner() - }; - - let exp_ok = if exp_is_constant { - true - } else { - exp.is_valid_complexity_notation_inner() - }; - - base_ok && exp_ok - } - Expr::Exp(a) | Expr::Log(a) | Expr::Sqrt(a) | Expr::Factorial(a) => { - a.is_valid_complexity_notation_inner() - } - } - } - - fn is_valid_exponential_base(&self) -> bool { - self.constant_value().is_some_and(|c| c > 0.0) - } - - pub(crate) fn constant_value(&self) -> Option { - match self { - Expr::Const(c) => Some(*c), - Expr::Var(_) => None, - Expr::Add(a, b) => Some(a.constant_value()? + b.constant_value()?), - Expr::Mul(a, b) => Some(a.constant_value()? * b.constant_value()?), - Expr::Pow(base, exp) => Some(base.constant_value()?.powf(exp.constant_value()?)), - Expr::Exp(a) => Some(a.constant_value()?.exp()), - Expr::Log(a) => Some(a.constant_value()?.ln()), - Expr::Sqrt(a) => Some(a.constant_value()?.sqrt()), - Expr::Factorial(a) => Some(gamma_factorial(a.constant_value()?)), + Expr::Pow(base, exponent) => { + Ok(evaluate_approximate(base, variables)? + .powf(evaluate_approximate(exponent, variables)?)) } + Expr::Neg(value) => Ok(-evaluate_approximate(value, variables)?), + Expr::Exp(value) => Ok(evaluate_approximate(value, variables)?.exp()), + Expr::Log(value) => Ok(evaluate_approximate(value, variables)?.ln()), + Expr::Sqrt(value) => Ok(evaluate_approximate(value, variables)?.sqrt()), + Expr::Factorial(value) => Ok(approximate_factorial(evaluate_approximate( + value, variables, + )?)), } } -impl fmt::Display for Expr { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Expr::Const(c) => { - let ci = c.round() as i64; - if (*c - ci as f64).abs() < 1e-10 { - write!(f, "{ci}") - } else { - write!(f, "{c}") - } - } - Expr::Var(name) => write!(f, "{name}"), - Expr::Add(a, b) => write!(f, "{a} + {b}"), - Expr::Mul(a, b) => { - let left = if matches!(a.as_ref(), Expr::Add(_, _)) { - format!("({a})") - } else { - format!("{a}") - }; - let right = if matches!(b.as_ref(), Expr::Add(_, _)) { - format!("({b})") - } else { - format!("{b}") - }; - write!(f, "{left} * {right}") - } - Expr::Pow(base, exp) => { - // Special case: x^0.5 → sqrt(x) - if let Expr::Const(e) = exp.as_ref() { - if (*e - 0.5).abs() < 1e-15 { - return write!(f, "sqrt({base})"); - } - } - let base_str = if matches!(base.as_ref(), Expr::Add(_, _) | Expr::Mul(_, _)) { - format!("({base})") - } else { - format!("{base}") - }; - let exp_str = if matches!(exp.as_ref(), Expr::Add(_, _) | Expr::Mul(_, _)) { - format!("({exp})") - } else { - format!("{exp}") - }; - write!(f, "{base_str}^{exp_str}") - } - Expr::Exp(a) => write!(f, "exp({a})"), - Expr::Log(a) => write!(f, "log({a})"), - Expr::Sqrt(a) => write!(f, "sqrt({a})"), - Expr::Factorial(a) => write!(f, "factorial({a})"), +/// Approximate a wholly constant expression; return `None` for expressions with variables. +pub(crate) fn constant_approximation(expression: &Expr) -> Option { + match expression { + Expr::Const(value) => rational_to_f64(value).ok(), + Expr::Var(_) => None, + Expr::Add(left, right) => { + Some(constant_approximation(left)? + constant_approximation(right)?) } + Expr::Sub(left, right) => { + Some(constant_approximation(left)? - constant_approximation(right)?) + } + Expr::Mul(left, right) => { + Some(constant_approximation(left)? * constant_approximation(right)?) + } + Expr::Div(left, right) => { + Some(constant_approximation(left)? / constant_approximation(right)?) + } + Expr::Pow(base, exponent) => { + Some(constant_approximation(base)?.powf(constant_approximation(exponent)?)) + } + Expr::Neg(value) => Some(-constant_approximation(value)?), + Expr::Exp(value) => Some(constant_approximation(value)?.exp()), + Expr::Log(value) => Some(constant_approximation(value)?.ln()), + Expr::Sqrt(value) => Some(constant_approximation(value)?.sqrt()), + Expr::Factorial(value) => Some(approximate_factorial(constant_approximation(value)?)), } } -impl std::ops::Add for Expr { - type Output = Self; - - fn add(self, other: Self) -> Self { - Expr::Add(Box::new(self), Box::new(other)) - } -} - -impl std::ops::Mul for Expr { - type Output = Self; - - fn mul(self, other: Self) -> Self { - Expr::Mul(Box::new(self), Box::new(other)) - } +/// Convert an approximation produced by the growth domain back to an exact AST constant. +pub(crate) fn expression_from_approximation(value: f64) -> Expr { + Expr::Const( + BigRational::from_f64(value) + .expect("growth-domain expression constants must be finite numbers"), + ) } -impl std::ops::Sub for Expr { - type Output = Self; - - fn sub(self, other: Self) -> Self { - self + Expr::Const(-1.0) * other - } +fn rational_to_f64(value: &BigRational) -> Result { + value + .to_f64() + .ok_or_else(|| ApproximationError::OutOfRange(value.to_string())) } -impl std::ops::Div for Expr { - type Output = Self; - - fn div(self, other: Self) -> Self { - self * Expr::pow(other, Expr::Const(-1.0)) +fn approximate_factorial(value: f64) -> f64 { + let rounded = value.round(); + if value >= 0.0 && value == rounded { + (2..=rounded as u64).fold(1.0, |product, factor| product * factor as f64) + } else { + (2.0 * std::f64::consts::PI * value).sqrt() * (value / std::f64::consts::E).powf(value) } } -impl std::ops::Neg for Expr { - type Output = Self; - - fn neg(self) -> Self { - Expr::Const(-1.0) * self - } +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ApproximationError { + #[error("missing expression variable {0}")] + MissingVariable(String), + #[error("exact constant {0} is outside the f64 approximation domain")] + OutOfRange(String), } /// Error returned when analyzing asymptotic behavior. @@ -303,243 +111,16 @@ pub enum AsymptoticAnalysisError { } impl fmt::Display for AsymptoticAnalysisError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Unsupported(expr) => write!(f, "unsupported asymptotic expression: {expr}"), - } - } -} - -impl std::error::Error for AsymptoticAnalysisError {} - -/// Compute factorial for non-negative values. -/// -/// For non-negative integers, returns the exact integer factorial. -/// For non-integer values, uses Stirling's approximation of the gamma function: -/// n! = Γ(n+1) ≈ √(2πn) · (n/e)^n. -fn gamma_factorial(n: f64) -> f64 { - if n < 0.0 { - return f64::NAN; - } - let rounded = n.round(); - if (n - rounded).abs() < 1e-10 && rounded >= 0.0 { - let k = rounded as u64; - let mut result = 1u64; - for i in 2..=k { - result = result.saturating_mul(i); - } - result as f64 - } else { - // Stirling's approximation: Γ(n+1) ≈ √(2πn) · (n/e)^n - (2.0 * std::f64::consts::PI * n).sqrt() * (n / std::f64::consts::E).powf(n) - } -} - -// --- Runtime expression parser --- - -/// Parse an expression string into an `Expr`. -/// -/// Uses the same grammar as the proc macro parser. Variable names are leaked -/// to `&'static str` for compatibility with `Expr::Var`. -fn parse_to_expr(input: &str) -> Result { - let tokens = tokenize_expr(input)?; - let mut parser = ExprParser::new(tokens); - let expr = parser.parse_additive()?; - if parser.pos != parser.tokens.len() { - return Err(format!("trailing tokens at position {}", parser.pos)); - } - Ok(expr) -} - -#[derive(Debug, Clone, PartialEq)] -enum ExprToken { - Number(f64), - Ident(String), - Plus, - Minus, - Star, - Slash, - Caret, - LParen, - RParen, -} - -fn tokenize_expr(input: &str) -> Result, String> { - let mut tokens = Vec::new(); - let mut chars = input.chars().peekable(); - while let Some(&ch) = chars.peek() { - match ch { - ' ' | '\t' | '\n' => { - chars.next(); - } - '+' => { - chars.next(); - tokens.push(ExprToken::Plus); - } - '-' => { - chars.next(); - tokens.push(ExprToken::Minus); - } - '*' => { - chars.next(); - tokens.push(ExprToken::Star); - } - '/' => { - chars.next(); - tokens.push(ExprToken::Slash); - } - '^' => { - chars.next(); - tokens.push(ExprToken::Caret); - } - '(' => { - chars.next(); - tokens.push(ExprToken::LParen); - } - ')' => { - chars.next(); - tokens.push(ExprToken::RParen); - } - c if c.is_ascii_digit() || c == '.' => { - let mut num = String::new(); - while let Some(&c) = chars.peek() { - if c.is_ascii_digit() || c == '.' { - num.push(c); - chars.next(); - } else { - break; - } - } - tokens.push(ExprToken::Number( - num.parse().map_err(|_| format!("invalid number: {num}"))?, - )); + Self::Unsupported(expression) => { + write!(formatter, "unsupported asymptotic expression: {expression}") } - c if c.is_ascii_alphabetic() || c == '_' => { - let mut ident = String::new(); - while let Some(&c) = chars.peek() { - if c.is_ascii_alphanumeric() || c == '_' { - ident.push(c); - chars.next(); - } else { - break; - } - } - tokens.push(ExprToken::Ident(ident)); - } - _ => return Err(format!("unexpected character: '{ch}'")), } } - Ok(tokens) -} - -struct ExprParser { - tokens: Vec, - pos: usize, } -impl ExprParser { - fn new(tokens: Vec) -> Self { - Self { tokens, pos: 0 } - } - - fn peek(&self) -> Option<&ExprToken> { - self.tokens.get(self.pos) - } - - fn advance(&mut self) -> Option { - let tok = self.tokens.get(self.pos).cloned(); - self.pos += 1; - tok - } - - fn expect(&mut self, expected: &ExprToken) -> Result<(), String> { - match self.advance() { - Some(ref tok) if tok == expected => Ok(()), - Some(tok) => Err(format!("expected {expected:?}, got {tok:?}")), - None => Err(format!("expected {expected:?}, got end of input")), - } - } - - fn parse_additive(&mut self) -> Result { - let mut left = self.parse_multiplicative()?; - while matches!(self.peek(), Some(ExprToken::Plus) | Some(ExprToken::Minus)) { - let op = self.advance().unwrap(); - let right = self.parse_multiplicative()?; - left = match op { - ExprToken::Plus => left + right, - ExprToken::Minus => left - right, - _ => unreachable!(), - }; - } - Ok(left) - } - - fn parse_multiplicative(&mut self) -> Result { - let mut left = self.parse_unary()?; - while matches!(self.peek(), Some(ExprToken::Star) | Some(ExprToken::Slash)) { - let op = self.advance().unwrap(); - let right = self.parse_unary()?; - left = match op { - ExprToken::Star => left * right, - ExprToken::Slash => left / right, - _ => unreachable!(), - }; - } - Ok(left) - } - - fn parse_power(&mut self) -> Result { - let base = self.parse_primary()?; - if matches!(self.peek(), Some(ExprToken::Caret)) { - self.advance(); - let exp = self.parse_unary()?; // right-associative, allows unary minus in exponent - Ok(Expr::pow(base, exp)) - } else { - Ok(base) - } - } - - fn parse_unary(&mut self) -> Result { - if matches!(self.peek(), Some(ExprToken::Minus)) { - self.advance(); - let expr = self.parse_unary()?; - Ok(-expr) - } else { - self.parse_power() - } - } - - fn parse_primary(&mut self) -> Result { - match self.advance() { - Some(ExprToken::Number(n)) => Ok(Expr::Const(n)), - Some(ExprToken::Ident(name)) => { - if matches!(self.peek(), Some(ExprToken::LParen)) { - self.advance(); - let arg = self.parse_additive()?; - self.expect(&ExprToken::RParen)?; - match name.as_str() { - "exp" => Ok(Expr::Exp(Box::new(arg))), - "log" => Ok(Expr::Log(Box::new(arg))), - "sqrt" => Ok(Expr::Sqrt(Box::new(arg))), - "factorial" => Ok(Expr::Factorial(Box::new(arg))), - _ => Err(format!("unknown function: {name}")), - } - } else { - // Leak the string to get &'static str for Expr::Var - let leaked: &'static str = Box::leak(name.into_boxed_str()); - Ok(Expr::Var(leaked)) - } - } - Some(ExprToken::LParen) => { - let expr = self.parse_additive()?; - self.expect(&ExprToken::RParen)?; - Ok(expr) - } - Some(tok) => Err(format!("unexpected token: {tok:?}")), - None => Err("unexpected end of input".to_string()), - } - } -} +impl std::error::Error for AsymptoticAnalysisError {} #[cfg(test)] #[path = "unit_tests/expr.rs"] diff --git a/src/growth.rs b/src/growth.rs index 65c310271..152cf90ab 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -28,9 +28,9 @@ //! `add = antichain union + prune`. All bounds produced are **upper** bounds. //! //! Widening (always toward a valid upper bound): -//! - Subtraction `a − b ⇝ a + b`: `a - b` is stored as `Add(a, Mul(-1, b))`; -//! the constant `-1` is dropped by [`Growth::from_expr`], so `from_expr` of a -//! subtraction is exactly the union of the two operands. This also covers the +//! - Subtraction `a − b ⇝ a + b`: [`Expr::Sub`] remains explicit in the source +//! tree, and [`Growth::from_expr`] widens it to the union of both operands. +//! This also covers the //! `sqrt((a − b)^2)` absolute-value idiom (`|a − b| ≤ a + b`). //! - Constants and constant multipliers/divisors are dropped on entry. //! - Exponentials with a **linear** exponent (`c^x`, `c^(r·x)`, `exp(x)`) are @@ -39,7 +39,8 @@ //! and never reconstructed by rounding. Nonlinear exponents (`2^(n·k)`, //! `2^sqrt(n)`), `factorial(·)`, and negative polynomial exponents widen to //! [`Growth::Unknown`], which absorbs through every operation. -//! - [`Expr::Log`] evaluates numerically as the natural logarithm, but all fixed +//! - The explicit approximation boundary treats [`Expr::Log`] as the natural +//! logarithm, but all fixed //! logarithm bases greater than one have the same asymptotic class and are //! intentionally represented by the single `log(v)` factor. //! @@ -51,7 +52,7 @@ //! binomial cross term is introduced — and it is what makes the widening chain //! `sqrt((n − m)^2) ≍ n + m` hold exactly. -use crate::expr::Expr; +use crate::expr::{constant_approximation, expression_from_approximation, Expr}; use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; @@ -69,43 +70,6 @@ enum ExpBase { Natural, } -#[derive(serde::Deserialize)] -enum OwnedExpr { - Const(f64), - Var(String), - Add(Box, Box), - Mul(Box, Box), - Pow(Box, Box), - Exp(Box), - Log(Box), - Sqrt(Box), - Factorial(Box), -} - -impl OwnedExpr { - fn into_constant_expr(self) -> Option { - match self { - OwnedExpr::Const(value) => Some(Expr::Const(value)), - OwnedExpr::Var(name) => { - drop(name); - None - } - OwnedExpr::Add(a, b) => Some(a.into_constant_expr()? + b.into_constant_expr()?), - OwnedExpr::Mul(a, b) => Some(a.into_constant_expr()? * b.into_constant_expr()?), - OwnedExpr::Pow(base, exponent) => Some(Expr::pow( - base.into_constant_expr()?, - exponent.into_constant_expr()?, - )), - OwnedExpr::Exp(value) => Some(Expr::Exp(Box::new(value.into_constant_expr()?))), - OwnedExpr::Log(value) => Some(Expr::Log(Box::new(value.into_constant_expr()?))), - OwnedExpr::Sqrt(value) => Some(Expr::Sqrt(Box::new(value.into_constant_expr()?))), - OwnedExpr::Factorial(value) => { - Some(Expr::Factorial(Box::new(value.into_constant_expr()?))) - } - } - } -} - impl<'de> serde::Deserialize<'de> for ExpBase { fn deserialize(deserializer: D) -> Result where @@ -113,17 +77,14 @@ impl<'de> serde::Deserialize<'de> for ExpBase { { #[derive(serde::Deserialize)] enum Repr { - Constant(OwnedExpr), + Constant(Expr), Natural, } match Repr::deserialize(deserializer)? { Repr::Natural => Ok(ExpBase::Natural), Repr::Constant(base) => { - let base = base.into_constant_expr(); - if let Some(base) = - base.filter(|base| base.constant_value().is_some_and(|value| value.is_finite())) - { + if constant_approximation(&base).is_some_and(f64::is_finite) { Ok(ExpBase::Constant(base)) } else { Err(serde::de::Error::custom( @@ -147,7 +108,9 @@ impl ExpBase { /// `Expr::Exp`; arbitrary constant subtrees remain structural-only. fn directly_comparable_value(&self) -> Option { match self { - ExpBase::Constant(Expr::Const(value)) => Some(*value), + ExpBase::Constant(Expr::Const(value)) => { + constant_approximation(&Expr::Const(value.clone())) + } ExpBase::Natural => Some(std::f64::consts::E), ExpBase::Constant(_) => None, } @@ -155,9 +118,9 @@ impl ExpBase { fn value(&self) -> f64 { match self { - ExpBase::Constant(base) => base - .constant_value() - .expect("ExpBase::Constant must remain constant"), + ExpBase::Constant(base) => { + constant_approximation(base).expect("ExpBase::Constant must remain constant") + } ExpBase::Natural => std::f64::consts::E, } } @@ -381,11 +344,11 @@ impl ExpProduct { #[derive(Clone, Debug, PartialEq, serde::Serialize)] pub struct GrowthTerm { /// Variable → canonical product of symbolic exponential factors. - exp: BTreeMap<&'static str, ExpProduct>, + exp: BTreeMap, ExpProduct>, /// variable → polynomial degree (`0.5` covers `sqrt`). - poly: BTreeMap<&'static str, f64>, + poly: BTreeMap, f64>, /// variable → log power. - logs: BTreeMap<&'static str, u32>, + logs: BTreeMap, u32>, } /// The asymptotic growth class of an [`Expr`]. @@ -447,14 +410,14 @@ impl GrowthTerm { for (v, product) in &self.exp { let product = product.powf(k); if !product.is_empty() { - r.exp.insert(v, product); + r.exp.insert(v.clone(), product); } } for (v, deg) in &self.poly { - r.poly.insert(v, deg * k); + r.poly.insert(v.clone(), deg * k); } for (v, p) in &self.logs { - r.logs.insert(v, ((*p as f64) * k).ceil() as u32); + r.logs.insert(v.clone(), ((*p as f64) * k).ceil() as u32); } r } @@ -470,14 +433,14 @@ impl GrowthTerm { if combined.is_empty() { t.exp.remove(k); } else { - t.exp.insert(k, combined); + t.exp.insert(k.clone(), combined); } } for (k, v) in &other.poly { - *t.poly.entry(k).or_insert(0.0) += *v; + *t.poly.entry(k.clone()).or_insert(0.0) += *v; } for (k, v) in &other.logs { - *t.logs.entry(k).or_insert(0) += *v; + *t.logs.entry(k.clone()).or_insert(0) += *v; } t } @@ -488,21 +451,21 @@ impl GrowthTerm { /// polynomial degree and log power then break proven exponential ties. /// Returns `None` for incomparable or unproved terms. fn cmp(&self, other: &GrowthTerm) -> Option { - let mut vars: BTreeSet<&'static str> = BTreeSet::new(); + let mut vars: BTreeSet<&str> = BTreeSet::new(); for m in [&self.exp, &other.exp] { - vars.extend(m.keys().copied()); + vars.extend(m.keys().map(Box::as_ref)); } for m in [&self.poly, &other.poly] { - vars.extend(m.keys().copied()); + vars.extend(m.keys().map(Box::as_ref)); } for m in [&self.logs, &other.logs] { - vars.extend(m.keys().copied()); + vars.extend(m.keys().map(Box::as_ref)); } let mut saw_gt = false; let mut saw_lt = false; let empty_exp = ExpProduct::empty(); - for v in &vars { + for v in vars { let exp_a = self.exp.get(v).unwrap_or(&empty_exp); let exp_b = other.exp.get(v).unwrap_or(&empty_exp); let exp_order = exp_a.cmp_proven(exp_b)?; @@ -568,7 +531,7 @@ impl Growth { // Any wholly constant subexpression is O(1). Handling it up front keeps // constant idioms (`n / 2` = `n * 2^(-1)`, `factorial(3)`, `2^3`) out of // the negative-exponent / factorial `Unknown` bails below. - if expr.constant_value().is_some() { + if constant_approximation(expr).is_some() { return Growth::Terms(vec![GrowthTerm::one()]); } match expr { @@ -576,12 +539,21 @@ impl Growth { Expr::Const(_) => Growth::Terms(vec![GrowthTerm::one()]), Expr::Var(v) => { let mut t = GrowthTerm::one(); - t.poly.insert(*v, 1.0); + t.poly.insert(v.clone(), 1.0); Growth::Terms(vec![t]) } Expr::Add(a, b) => add(Growth::from_expr(a), Growth::from_expr(b)), + Expr::Sub(a, b) => add(Growth::from_expr(a), Growth::from_expr(b)), Expr::Mul(a, b) => mul(Growth::from_expr(a), Growth::from_expr(b)), + Expr::Div(a, b) => { + if constant_approximation(b).is_some() { + Growth::from_expr(a) + } else { + Growth::Unknown + } + } Expr::Pow(base, exp) => pow_expr(base, exp), + Expr::Neg(value) => Growth::from_expr(value), Expr::Exp(a) => exponential(ExpBase::Natural, a), Expr::Log(a) => log_growth(Growth::from_expr(a)), Expr::Sqrt(a) => pow_const(Growth::from_expr(a), 0.5), @@ -631,7 +603,7 @@ impl Growth { Growth::Unknown => None, Growth::Terms(terms) => { if terms.is_empty() { - return Some(Expr::Const(1.0)); + return Some(Expr::integer(1)); } let mut it = terms.iter().map(term_to_expr); let mut acc = it.next().unwrap(); @@ -670,17 +642,17 @@ fn term_to_expr(t: &GrowthTerm) -> Expr { } let mut it = factors.into_iter(); match it.next() { - None => Expr::Const(1.0), + None => Expr::integer(1), Some(first) => it.fold(first, |acc, f| acc * f), } } /// Render a stored exponential factor without changing its base or coefficient. -fn exp_factor(v: &'static str, factor: &ExpFactor) -> Expr { +fn exp_factor(v: &str, factor: &ExpFactor) -> Expr { let exponent = if factor.coefficient == 1.0 { - Expr::Var(v) + Expr::variable(v) } else { - Expr::Const(factor.coefficient) * Expr::Var(v) + expression_from_approximation(factor.coefficient) * Expr::variable(v) }; match &factor.base { ExpBase::Constant(base) => Expr::pow(base.clone(), exponent), @@ -689,21 +661,21 @@ fn exp_factor(v: &'static str, factor: &ExpFactor) -> Expr { } /// Render `v^degree` (`Display` turns degree `0.5` into `sqrt(v)`). -fn poly_factor(v: &'static str, degree: f64) -> Expr { +fn poly_factor(v: &str, degree: f64) -> Expr { if degree == 1.0 { - Expr::Var(v) + Expr::variable(v) } else { - Expr::pow(Expr::Var(v), Expr::Const(degree)) + Expr::pow(Expr::variable(v), expression_from_approximation(degree)) } } /// Render `(log v)^power`. -fn log_factor(v: &'static str, power: u32) -> Expr { - let log = Expr::Log(Box::new(Expr::Var(v))); +fn log_factor(v: &str, power: u32) -> Expr { + let log = Expr::Log(Box::new(Expr::variable(v))); if power == 1 { log } else { - Expr::pow(log, Expr::Const(power as f64)) + Expr::pow(log, Expr::integer(power)) } } @@ -732,9 +704,9 @@ fn componentwise_max(terms: &[GrowthTerm]) -> Option { let mut m = GrowthTerm::one(); let mut vars = BTreeSet::new(); for term in terms { - vars.extend(term.exp.keys().copied()); - vars.extend(term.poly.keys().copied()); - vars.extend(term.logs.keys().copied()); + vars.extend(term.exp.keys().cloned()); + vars.extend(term.poly.keys().cloned()); + vars.extend(term.logs.keys().cloned()); } for var in vars { @@ -742,7 +714,7 @@ fn componentwise_max(terms: &[GrowthTerm]) -> Option { let mut maximum = &empty_exp; for product in terms .iter() - .map(|term| term.exp.get(var).unwrap_or(&empty_exp)) + .map(|term| term.exp.get(&var).unwrap_or(&empty_exp)) { if matches!(product.cmp_proven(maximum), Some(Ordering::Greater)) { maximum = product; @@ -750,28 +722,28 @@ fn componentwise_max(terms: &[GrowthTerm]) -> Option { } if !terms.iter().all(|term| { matches!( - maximum.cmp_proven(term.exp.get(var).unwrap_or(&empty_exp)), + maximum.cmp_proven(term.exp.get(&var).unwrap_or(&empty_exp)), Some(Ordering::Greater | Ordering::Equal) ) }) { return None; } if !maximum.is_empty() { - m.exp.insert(var, maximum.clone()); + m.exp.insert(var.clone(), maximum.clone()); } let mut max_poly = 0.0_f64; let mut max_logs = 0_u32; for term in terms { - let degree = term.poly.get(var).copied().unwrap_or(0.0); + let degree = term.poly.get(&var).copied().unwrap_or(0.0); if !degree.is_finite() { return None; } max_poly = max_poly.max(degree); - max_logs = max_logs.max(term.logs.get(var).copied().unwrap_or(0)); + max_logs = max_logs.max(term.logs.get(&var).copied().unwrap_or(0)); } if max_poly > 0.0 { - m.poly.insert(var, max_poly); + m.poly.insert(var.clone(), max_poly); } if max_logs > 0 { m.logs.insert(var, max_logs); @@ -847,7 +819,7 @@ fn pow_const(g: Growth, k: f64) -> Growth { /// Transfer function for `Pow(base, exp)`. fn pow_expr(base: &Expr, exp: &Expr) -> Growth { - if let Some(k) = exp.constant_value() { + if let Some(k) = constant_approximation(exp) { // Constant exponent → polynomial power. if k < 0.0 { return Growth::Unknown; // negative exponent @@ -856,7 +828,7 @@ fn pow_expr(base: &Expr, exp: &Expr) -> Growth { return Growth::Terms(vec![GrowthTerm::one()]); // x^0 = O(1) } pow_const(Growth::from_expr(base), k) - } else if let Some(c) = base.constant_value() { + } else if let Some(c) = constant_approximation(base) { // Constant base, variable exponent → exponential. if c.is_finite() { exponential(ExpBase::Constant(base.clone()), exp) @@ -902,14 +874,14 @@ fn exponential(base: ExpBase, exp: &Expr) -> Growth { /// Extract the linear coefficients of an expression (variable → coefficient), /// or `None` if the expression is not linear in its variables. The additive /// constant term is ignored (dropped). Pure constants map to the empty form. -fn linear_form(expr: &Expr) -> Option> { - if expr.constant_value().is_some() { +fn linear_form(expr: &Expr) -> Option, f64>> { + if constant_approximation(expr).is_some() { return Some(BTreeMap::new()); } match expr { Expr::Var(v) => { let mut m = BTreeMap::new(); - m.insert(*v, 1.0); + m.insert(v.clone(), 1.0); Some(m) } Expr::Add(a, b) => { @@ -919,17 +891,24 @@ fn linear_form(expr: &Expr) -> Option> { } Some(m) } + Expr::Sub(a, b) => { + let mut m = linear_form(a)?; + for (k, v) in linear_form(b)? { + *m.entry(k).or_insert(0.0) -= v; + } + Some(m) + } Expr::Mul(a, b) => { // A linear term times a variable is nonlinear, so one side must be // a constant scalar. - if let Some(c) = a.constant_value() { + if let Some(c) = constant_approximation(a) { Some( linear_form(b)? .into_iter() .map(|(k, v)| (k, v * c)) .collect(), ) - } else if let Some(c) = b.constant_value() { + } else if let Some(c) = constant_approximation(b) { Some( linear_form(a)? .into_iter() @@ -940,6 +919,21 @@ fn linear_form(expr: &Expr) -> Option> { None } } + Expr::Div(a, b) => { + let divisor = constant_approximation(b)?; + Some( + linear_form(a)? + .into_iter() + .map(|(variable, coefficient)| (variable, coefficient / divisor)) + .collect(), + ) + } + Expr::Neg(value) => Some( + linear_form(value)? + .into_iter() + .map(|(variable, coefficient)| (variable, -coefficient)) + .collect(), + ), // Pow / Exp / Log / Sqrt / Factorial of variables are nonlinear. _ => None, } @@ -973,20 +967,25 @@ fn log_growth(g: Growth) -> Growth { fn log_term(t: &GrowthTerm) -> Vec { let mut out = Vec::new(); // Every stored exponential product grows, so its logarithm is linear. - for v in t.exp.keys().copied() { + for v in t.exp.keys().cloned() { let mut g = GrowthTerm::one(); g.poly.insert(v, 1.0); out.push(g); } // log(v^a) ≍ log v: each positive-degree polynomial factor becomes a log. - for v in t.poly.iter().filter(|(_, d)| **d > 0.0).map(|(k, _)| *k) { + for v in t + .poly + .iter() + .filter(|(_, degree)| **degree > 0.0) + .map(|(variable, _)| variable.clone()) + { let mut g = GrowthTerm::one(); g.logs.insert(v, 1); out.push(g); } // log((log v)^s) = log log v, upper-bounded by log v (log log v ≤ log v for // v ≥ 2): each log factor stays a single log. - for v in t.logs.keys().copied() { + for v in t.logs.keys().cloned() { let mut g = GrowthTerm::one(); g.logs.insert(v, 1); out.push(g); @@ -1000,11 +999,8 @@ fn log_term(t: &GrowthTerm) -> Vec { // --- serde --- // -// `GrowthTerm` uses `&'static str` keys (to align with `Expr::Var`), which serde -// cannot deserialize directly. `Deserialize` reads owned `String` keys and leaks -// them to `&'static str`, matching the convention of `Expr`'s runtime parser. -// Each unique key leaks a small allocation that is never freed; acceptable for -// the CLI's one-shot serialization, not for hot loops with adversarial input. +// Deserialize through an unchecked representation, then enforce the growth +// domain's invariants before constructing a term. impl<'de> serde::Deserialize<'de> for GrowthTerm { fn deserialize(deserializer: D) -> Result @@ -1017,18 +1013,23 @@ impl<'de> serde::Deserialize<'de> for GrowthTerm { poly: BTreeMap, logs: BTreeMap, } - fn leak(s: String) -> &'static str { - Box::leak(s.into_boxed_str()) - } let r = Repr::deserialize(deserializer)?; let term = GrowthTerm { exp: r .exp .into_iter() - .map(|(k, product)| (leak(k), ExpProduct::new(product.factors))) + .map(|(key, product)| (key.into_boxed_str(), ExpProduct::new(product.factors))) + .collect(), + poly: r + .poly + .into_iter() + .map(|(key, value)| (key.into_boxed_str(), value)) + .collect(), + logs: r + .logs + .into_iter() + .map(|(key, value)| (key.into_boxed_str(), value)) .collect(), - poly: r.poly.into_iter().map(|(k, v)| (leak(k), v)).collect(), - logs: r.logs.into_iter().map(|(k, v)| (leak(k), v)).collect(), }; if growth_term_is_valid(&term) { Ok(term) diff --git a/src/lib.rs b/src/lib.rs index 4cad72e55..36e7ee395 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -114,7 +114,9 @@ pub mod prelude { // Re-export commonly used items at crate root pub use big_o::big_o_normal_form; pub use error::{ProblemError, Result}; -pub use expr::{AsymptoticAnalysisError, Expr}; +pub use expr::{ + evaluate_approximate, ApproximationError, AsymptoticAnalysisError, Expr, ParseError, +}; pub use growth::Growth; pub use registry::{ComplexityClass, ProblemInfo}; pub use solvers::{BruteForce, Solver}; diff --git a/src/rules/graph.rs b/src/rules/graph.rs index c18c0a88e..4000e1cf4 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -1407,42 +1407,67 @@ impl ReductionGraph { /// where this problem appears as source or target. When the problem is a /// source, its size fields are the input variables referenced in the overhead /// expressions. When it's a target, its size fields are the output field names. - pub fn size_field_names(&self, name: &str) -> Vec<&'static str> { - let mut fields: std::collections::HashSet<&'static str> = + pub fn size_field_names(&self, name: &str) -> Vec { + let mut fields: std::collections::HashSet = crate::registry::declared_size_fields(name) .into_iter() + .map(str::to_string) .collect(); for entry in inventory::iter:: { if entry.source_name == name { // Source's size fields are the input variables of the overhead. - fields.extend(entry.overhead().input_variable_names()); + fields.extend( + entry + .overhead() + .input_variable_names() + .into_iter() + .map(str::to_string), + ); } if entry.target_name == name { // Target's size fields are the output field names. let overhead = entry.overhead(); - fields.extend(overhead.output_size.iter().map(|(name, _)| *name)); + fields.extend( + overhead + .output_size + .iter() + .map(|(field, _)| (*field).to_string()), + ); } } - let mut result: Vec<&'static str> = fields.into_iter().collect(); + let mut result: Vec = fields.into_iter().collect(); result.sort_unstable(); result } fn validate_size_budget(&self, budget: &SizeBudget) -> Result<(), UnknownSizeField> { - let mut known: HashSet<&str> = self + let mut known: HashSet = self .name_to_nodes .keys() .flat_map(|name| crate::registry::declared_size_fields(name)) + .map(str::to_string) .collect(); for entry in inventory::iter:: { if self.name_to_nodes.contains_key(entry.source_name) { - known.extend(entry.overhead().input_variable_names()); + known.extend( + entry + .overhead() + .input_variable_names() + .into_iter() + .map(str::to_string), + ); } if self.name_to_nodes.contains_key(entry.target_name) { - known.extend(entry.overhead().output_size.iter().map(|(field, _)| *field)); + known.extend( + entry + .overhead() + .output_size + .iter() + .map(|(field, _)| (*field).to_string()), + ); } } - if let Some(field) = budget.fields().find(|field| !known.contains(field)) { + if let Some(field) = budget.fields().find(|field| !known.contains(*field)) { return Err(UnknownSizeField(field.to_string())); } Ok(()) diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index c2fd2bf20..a054f7c9d 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -67,7 +67,7 @@ pub struct AnalysisCoverage { /// Why a searched path could not participate in the symbolic front. #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct AnalysisFailure { - pub fields: Vec<&'static str>, + pub fields: Vec, pub reason: &'static str, } @@ -262,7 +262,7 @@ impl PathLabel for MeasuredLabel<'_> { #[derive(Clone, Debug, PartialEq)] pub struct GrowthLabel { /// Current node's size fields → growth in the source problem's variables. - fields: BTreeMap<&'static str, Growth>, + fields: BTreeMap, } impl GrowthLabel { @@ -270,21 +270,34 @@ impl GrowthLabel { /// /// `source_fields` is the source problem's list of size-field names (e.g. from /// [`ReductionGraph::size_field_names`](crate::rules::ReductionGraph::size_field_names)). - pub fn source(source_fields: &[&'static str]) -> Self { + pub fn source(source_fields: &[String]) -> Self { let fields = source_fields .iter() - .map(|&f| (f, Growth::from_expr(&Expr::Var(f)))) + .map(|field| { + ( + field.clone(), + Growth::from_expr(&Expr::variable(field.as_str())), + ) + }) .collect(); GrowthLabel { fields } } /// Construct directly from a field → growth map (test/introspection helper). - pub fn from_fields(fields: BTreeMap<&'static str, Growth>) -> Self { - GrowthLabel { fields } + pub fn from_fields(fields: BTreeMap) -> Self + where + K: Into + Ord, + { + GrowthLabel { + fields: fields + .into_iter() + .map(|(field, growth)| (field.into(), growth)) + .collect(), + } } /// The current node's size fields mapped to their growth in source variables. - pub fn fields(&self) -> &BTreeMap<&'static str, Growth> { + pub fn fields(&self) -> &BTreeMap { &self.fields } @@ -293,7 +306,8 @@ impl GrowthLabel { let fields: Vec<_> = self .fields .iter() - .filter_map(|(field, growth)| matches!(growth, Growth::Unknown).then_some(*field)) + .filter(|(_, growth)| matches!(growth, Growth::Unknown)) + .map(|(field, _)| field.clone()) .collect(); (!fields.is_empty()).then_some(AnalysisFailure { fields, @@ -307,8 +321,11 @@ impl PathLabel for GrowthLabel { // Render each current field's growth back to a display `Expr` in the source // variables. `Unknown` growth has no `Expr` (`None`) and taints any target // field that references it. - let rendered: BTreeMap<&'static str, Option> = - self.fields.iter().map(|(k, g)| (*k, g.to_expr())).collect(); + let rendered: BTreeMap<&str, Option> = self + .fields + .iter() + .map(|(field, growth)| (field.as_str(), growth.to_expr())) + .collect(); // Substitution map from current field name to its rendered growth `Expr` (in // source variables). Depends only on `rendered`, so build it once for all edges' @@ -319,10 +336,10 @@ impl PathLabel for GrowthLabel { // references it must be tainted (see below) rather than leaked verbatim. let mapping: HashMap<&str, &Expr> = rendered .iter() - .filter_map(|(k, opt)| opt.as_ref().map(|e| (*k, e))) + .filter_map(|(field, expression)| expression.as_ref().map(|value| (*field, value))) .collect(); - let mut new_fields: BTreeMap<&'static str, Growth> = BTreeMap::new(); + let mut new_fields: BTreeMap = BTreeMap::new(); for (target_field, expr) in &edge.overhead.output_size { // Taint the target field if this overhead references any variable we cannot // express in the source's variables: either a present-but-`Unknown` current @@ -331,13 +348,13 @@ impl PathLabel for GrowthLabel { // variable). Both cases are exactly "not in `mapping`". let taints = expr.variables().iter().any(|v| !mapping.contains_key(v)); if taints { - new_fields.insert(target_field, Growth::Unknown); + new_fields.insert((*target_field).to_string(), Growth::Unknown); continue; } // Substitute rendered growths into the overhead, then reduce in the growth // domain. let substituted = expr.substitute(&mapping); - new_fields.insert(target_field, Growth::from_expr(&substituted)); + new_fields.insert((*target_field).to_string(), Growth::from_expr(&substituted)); } // Asymptotic mode has no budget, so `extend` never prunes. Some(GrowthLabel { fields: new_fields }) diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 387062baf..2c8213a70 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -1,6 +1,6 @@ //! Automatic reduction registration via inventory. -use crate::expr::Expr; +use crate::expr::{evaluate_approximate, Expr}; use crate::rules::traits::{DynAggregateReductionResult, DynReductionResult}; use crate::types::ProblemSize; use std::any::Any; @@ -23,7 +23,10 @@ impl ReductionOverhead { /// Used by variant cast reductions where problem size doesn't change. pub fn identity(fields: &[&'static str]) -> Self { Self { - output_size: fields.iter().map(|&f| (f, Expr::Var(f))).collect(), + output_size: fields + .iter() + .map(|&field| (field, Expr::variable(field))) + .collect(), } } @@ -36,13 +39,17 @@ impl ReductionOverhead { let fields: Vec<_> = self .output_size .iter() - .map(|(name, expr)| (*name, expr.eval(input).round() as usize)) + .map(|(name, expr)| { + let value = evaluate_approximate(expr, input) + .expect("overhead approximation requires every expression variable"); + (*name, value.round() as usize) + }) .collect(); ProblemSize::new(fields) } /// Collect all input variable names referenced by the overhead expressions. - pub fn input_variable_names(&self) -> HashSet<&'static str> { + pub fn input_variable_names(&self) -> HashSet<&str> { self.output_size .iter() .flat_map(|(_, expr)| expr.variables()) diff --git a/src/rules/subsetsum_integerknapsack.rs b/src/rules/subsetsum_integerknapsack.rs index e0fb8bf1c..8ab41e908 100644 --- a/src/rules/subsetsum_integerknapsack.rs +++ b/src/rules/subsetsum_integerknapsack.rs @@ -57,8 +57,8 @@ inventory::submit! { source_variant_fn: ::variant, target_variant_fn: ::variant, overhead_fn: || ReductionOverhead::new(vec![ - ("num_items", Expr::Var("num_elements")), - ("capacity", Expr::Var("target")), + ("num_items", Expr::variable("num_elements")), + ("capacity", Expr::variable("target")), ]), module_path: module_path!(), reduce_fn: None, diff --git a/src/unit_tests/big_o.rs b/src/unit_tests/big_o.rs index 666989bf5..877c8508e 100644 --- a/src/unit_tests/big_o.rs +++ b/src/unit_tests/big_o.rs @@ -85,7 +85,8 @@ fn test_big_o_composed_overhead_duplicate() { #[test] fn test_big_o_exp_with_polynomial() { // exp(n) dominates n^10 - let e = Expr::Exp(Box::new(Expr::Var("n"))) + Expr::pow(Expr::Var("n"), Expr::Const(10.0)); + let e = Expr::Exp(Box::new(Expr::variable("n"))) + + Expr::pow(Expr::variable("n"), Expr::integer(10)); let result = big_o_normal_form(&e).unwrap(); let s = result.to_string(); assert!(s.contains("exp"), "expected exp term to survive, got: {s}"); @@ -97,14 +98,14 @@ fn test_big_o_exp_with_polynomial() { #[test] fn test_big_o_pure_constant_returns_one() { - let e = Expr::Const(42.0); + let e = Expr::integer(42); let result = big_o_normal_form(&e).unwrap(); assert_eq!(result.to_string(), "1"); } #[test] fn test_big_o_rejects_division() { - let e = Expr::Var("n") / Expr::Var("m"); + let e = Expr::variable("n") / Expr::variable("m"); assert!(big_o_normal_form(&e).is_err()); } @@ -112,21 +113,21 @@ fn test_big_o_rejects_division() { fn test_big_o_drops_negative_constant_factor() { // The growth domain drops constant multipliers, sign included, so `-1 * n` // widens to `n` (an upper bound on its magnitude) instead of being rejected. - let e = Expr::Const(-1.0) * Expr::Var("n"); + let e = Expr::integer(-1) * Expr::variable("n"); let result = big_o_normal_form(&e).unwrap(); assert_eq!(result.to_string(), "n"); } #[test] fn test_big_o_constant_base_one_becomes_constant() { - let e = Expr::pow(Expr::Const(1.0), Expr::Var("n")); + let e = Expr::pow(Expr::integer(1), Expr::variable("n")); let result = big_o_normal_form(&e).unwrap(); assert_eq!(result.to_string(), "1"); } #[test] fn test_big_o_rejects_nonpositive_constant_base_exponential() { - let e = Expr::pow(Expr::Const(-2.0), Expr::Var("n")); + let e = Expr::pow(Expr::integer(-2), Expr::variable("n")); assert!(big_o_normal_form(&e).is_err()); } @@ -226,8 +227,8 @@ fn test_big_o_pathological_nesting_returns_bound_instantly() { // A deeply nested power that the old expansion pipeline could not normalize. // The growth domain answers it bottom-up: `((a+b+c+d)^4)^4` raises each // variable term to degree 16, so it returns a real bound immediately. - let sum = Expr::Var("a") + Expr::Var("b") + Expr::Var("c") + Expr::Var("d"); - let e = Expr::pow(Expr::pow(sum, Expr::Const(4.0)), Expr::Const(4.0)); + let sum = Expr::variable("a") + Expr::variable("b") + Expr::variable("c") + Expr::variable("d"); + let e = Expr::pow(Expr::pow(sum, Expr::integer(4)), Expr::integer(4)); let start = std::time::Instant::now(); let result = big_o_normal_form(&e).unwrap(); assert!(start.elapsed().as_millis() < 50, "should be instant"); diff --git a/src/unit_tests/expr.rs b/src/unit_tests/expr.rs index fd1e217aa..c296c00bb 100644 --- a/src/unit_tests/expr.rs +++ b/src/unit_tests/expr.rs @@ -1,144 +1,151 @@ use super::*; use crate::types::ProblemSize; -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap}; + +fn eval(expression: &Expr, size: &ProblemSize) -> f64 { + evaluate_approximate(expression, size).unwrap() +} #[test] fn test_expr_const_eval() { - let e = Expr::Const(42.0); + let e = Expr::integer(42); let size = ProblemSize::new(vec![]); - assert_eq!(e.eval(&size), 42.0); + assert_eq!(eval(&e, &size), 42.0); } #[test] fn test_expr_var_eval() { - let e = Expr::Var("n"); + let e = Expr::variable("n"); let size = ProblemSize::new(vec![("n", 10)]); - assert_eq!(e.eval(&size), 10.0); + assert_eq!(eval(&e, &size), 10.0); } #[test] fn test_expr_add_eval() { // n + 3 - let e = Expr::Var("n") + Expr::Const(3.0); + let e = Expr::variable("n") + Expr::integer(3); let size = ProblemSize::new(vec![("n", 7)]); - assert_eq!(e.eval(&size), 10.0); + assert_eq!(eval(&e, &size), 10.0); } #[test] fn test_expr_mul_eval() { // 3 * n - let e = Expr::Const(3.0) * Expr::Var("n"); + let e = Expr::integer(3) * Expr::variable("n"); let size = ProblemSize::new(vec![("n", 5)]); - assert_eq!(e.eval(&size), 15.0); + assert_eq!(eval(&e, &size), 15.0); } #[test] fn test_expr_pow_eval() { // n^2 - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)); + let e = Expr::pow(Expr::variable("n"), Expr::integer(2)); let size = ProblemSize::new(vec![("n", 4)]); - assert_eq!(e.eval(&size), 16.0); + assert_eq!(eval(&e, &size), 16.0); } #[test] fn test_expr_exp_eval() { - let e = Expr::Exp(Box::new(Expr::Const(1.0))); + let e = Expr::Exp(Box::new(Expr::integer(1))); let size = ProblemSize::new(vec![]); - assert!((e.eval(&size) - std::f64::consts::E).abs() < 1e-10); + assert!((eval(&e, &size) - std::f64::consts::E).abs() < 1e-10); } #[test] fn test_expr_log_eval() { - let e = Expr::Log(Box::new(Expr::Const(std::f64::consts::E))); + let e = Expr::Log(Box::new(expression_from_approximation(std::f64::consts::E))); let size = ProblemSize::new(vec![]); - assert!((e.eval(&size) - 1.0).abs() < 1e-10); + assert!((eval(&e, &size) - 1.0).abs() < 1e-10); } #[test] fn test_expr_sqrt_eval() { - let e = Expr::Sqrt(Box::new(Expr::Const(9.0))); + let e = Expr::Sqrt(Box::new(Expr::integer(9))); let size = ProblemSize::new(vec![]); - assert_eq!(e.eval(&size), 3.0); + assert_eq!(eval(&e, &size), 3.0); } #[test] fn test_expr_complex() { // n^2 + 3*m - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)) + Expr::Const(3.0) * Expr::Var("m"); + let e = + Expr::pow(Expr::variable("n"), Expr::integer(2)) + Expr::integer(3) * Expr::variable("m"); let size = ProblemSize::new(vec![("n", 4), ("m", 2)]); - assert_eq!(e.eval(&size), 22.0); // 16 + 6 + assert_eq!(eval(&e, &size), 22.0); // 16 + 6 } #[test] fn test_expr_variables() { - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)) + Expr::Const(3.0) * Expr::Var("m"); + let e = + Expr::pow(Expr::variable("n"), Expr::integer(2)) + Expr::integer(3) * Expr::variable("m"); let vars = e.variables(); - assert_eq!(vars, HashSet::from(["n", "m"])); + assert_eq!(vars, BTreeSet::from(["n", "m"])); } #[test] fn test_expr_substitute() { // n^2, substitute n → (a + b) - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)); - let replacement = Expr::Var("a") + Expr::Var("b"); + let e = Expr::pow(Expr::variable("n"), Expr::integer(2)); + let replacement = Expr::variable("a") + Expr::variable("b"); let mut mapping = HashMap::new(); mapping.insert("n", &replacement); let result = e.substitute(&mapping); // Should be (a + b)^2 let size = ProblemSize::new(vec![("a", 3), ("b", 2)]); - assert_eq!(result.eval(&size), 25.0); // (3+2)^2 + assert_eq!(eval(&result, &size), 25.0); // (3+2)^2 } #[test] fn test_expr_display_simple() { - assert_eq!(format!("{}", Expr::Const(5.0)), "5"); - assert_eq!(format!("{}", Expr::Var("n")), "n"); + assert_eq!(format!("{}", Expr::integer(5)), "5"); + assert_eq!(format!("{}", Expr::variable("n")), "n"); } #[test] fn test_expr_display_add() { - let e = Expr::Var("n") + Expr::Const(3.0); + let e = Expr::variable("n") + Expr::integer(3); assert_eq!(format!("{e}"), "n + 3"); } #[test] fn test_expr_display_mul() { - let e = Expr::Const(3.0) * Expr::Var("n"); + let e = Expr::integer(3) * Expr::variable("n"); assert_eq!(format!("{e}"), "3 * n"); } #[test] fn test_expr_display_pow() { - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)); + let e = Expr::pow(Expr::variable("n"), Expr::integer(2)); assert_eq!(format!("{e}"), "n^2"); } #[test] fn test_expr_display_exp() { - let e = Expr::Exp(Box::new(Expr::Var("n"))); + let e = Expr::Exp(Box::new(Expr::variable("n"))); assert_eq!(format!("{e}"), "exp(n)"); } #[test] fn test_expr_display_nested() { // n^2 + 3 * m - let e = Expr::pow(Expr::Var("n"), Expr::Const(2.0)) + Expr::Const(3.0) * Expr::Var("m"); + let e = + Expr::pow(Expr::variable("n"), Expr::integer(2)) + Expr::integer(3) * Expr::variable("m"); assert_eq!(format!("{e}"), "n^2 + 3 * m"); } #[test] fn test_expr_is_polynomial() { - assert!(Expr::Var("n").is_polynomial()); - assert!(Expr::pow(Expr::Var("n"), Expr::Const(2.0)).is_polynomial()); - assert!(!Expr::Exp(Box::new(Expr::Var("n"))).is_polynomial()); - assert!(!Expr::Log(Box::new(Expr::Var("n"))).is_polynomial()); - assert!(!Expr::Sqrt(Box::new(Expr::Var("n"))).is_polynomial()); + assert!(Expr::variable("n").is_polynomial()); + assert!(Expr::pow(Expr::variable("n"), Expr::integer(2)).is_polynomial()); + assert!(!Expr::Exp(Box::new(Expr::variable("n"))).is_polynomial()); + assert!(!Expr::Log(Box::new(Expr::variable("n"))).is_polynomial()); + assert!(!Expr::Sqrt(Box::new(Expr::variable("n"))).is_polynomial()); } #[test] fn test_expr_is_valid_complexity_notation_simple() { - assert!(Expr::Var("n").is_valid_complexity_notation()); - assert!(Expr::pow(Expr::Var("n"), Expr::Const(2.0)).is_valid_complexity_notation()); + assert!(Expr::variable("n").is_valid_complexity_notation()); + assert!(Expr::pow(Expr::variable("n"), Expr::integer(2)).is_valid_complexity_notation()); assert!(Expr::parse("n + m").is_valid_complexity_notation()); assert!(Expr::parse("2^n").is_valid_complexity_notation()); assert!(Expr::parse("n^(1/3)").is_valid_complexity_notation()); @@ -158,138 +165,141 @@ fn test_expr_is_valid_complexity_notation_rejects_additive_constants() { assert!(!Expr::parse("n + 1").is_valid_complexity_notation()); assert!(!Expr::parse("log(n + 1)").is_valid_complexity_notation()); assert!(!Expr::parse("(n + 1)^2").is_valid_complexity_notation()); - assert!(!Expr::Const(5.0).is_valid_complexity_notation()); - assert!(Expr::Const(1.0).is_valid_complexity_notation()); + assert!(!Expr::integer(5).is_valid_complexity_notation()); + assert!(Expr::integer(1).is_valid_complexity_notation()); } #[test] fn test_expr_display_pow_with_complex_exponent() { - let expr = Expr::pow(Expr::Const(2.0), Expr::Var("m") + Expr::Var("n")); + let expr = Expr::pow(Expr::integer(2), Expr::variable("m") + Expr::variable("n")); assert_eq!(format!("{expr}"), "2^(m + n)"); } #[test] fn test_expr_display_fractional_constant() { - assert_eq!(format!("{}", Expr::Const(2.75)), "2.75"); - assert_eq!(format!("{}", Expr::Const(0.5)), "0.5"); + assert_eq!(format!("{}", Expr::rational(11, 4)), "2.75"); + assert_eq!(format!("{}", Expr::rational(1, 2)), "0.5"); } #[test] fn test_expr_display_log() { - let e = Expr::Log(Box::new(Expr::Var("n"))); + let e = Expr::Log(Box::new(Expr::variable("n"))); assert_eq!(format!("{e}"), "log(n)"); } #[test] fn test_expr_display_sqrt() { - let e = Expr::Sqrt(Box::new(Expr::Var("n"))); + let e = Expr::Sqrt(Box::new(Expr::variable("n"))); assert_eq!(format!("{e}"), "sqrt(n)"); } #[test] -fn test_expr_display_pow_half_as_sqrt() { - let e = Expr::pow(Expr::Var("n"), Expr::Const(0.5)); - assert_eq!(format!("{e}"), "sqrt(n)"); +fn test_expr_display_preserves_half_power() { + let e = Expr::pow(Expr::variable("n"), Expr::rational(1, 2)); + assert_eq!(format!("{e}"), "n^0.5"); } #[test] -fn test_expr_display_pow_half_complex_base() { - let e = Expr::pow(Expr::Var("n") * Expr::Var("m"), Expr::Const(0.5)); - assert_eq!(format!("{e}"), "sqrt(n * m)"); +fn test_expr_display_preserves_half_power_with_complex_base() { + let e = Expr::pow( + Expr::variable("n") * Expr::variable("m"), + Expr::rational(1, 2), + ); + assert_eq!(format!("{e}"), "(n * m)^0.5"); } #[test] -fn test_expr_display_pow_half_in_exponent() { - // 2^(n^0.5) should display as 2^sqrt(n), NOT 2^n^0.5 +fn test_expr_display_preserves_nested_half_power() { let e = Expr::pow( - Expr::Const(2.0), - Expr::pow(Expr::Var("n"), Expr::Const(0.5)), + Expr::integer(2), + Expr::pow(Expr::variable("n"), Expr::rational(1, 2)), ); - let s = format!("{e}"); - assert!(s.contains("sqrt"), "expected sqrt notation, got: {s}"); - assert!(!s.contains("0.5"), "should not contain raw 0.5, got: {s}"); + assert_eq!(format!("{e}"), "2^n^0.5"); } #[test] fn test_expr_display_mul_with_add_parenthesization() { // (a + b) * c should parenthesize the left side - let e = (Expr::Var("a") + Expr::Var("b")) * Expr::Var("c"); + let e = (Expr::variable("a") + Expr::variable("b")) * Expr::variable("c"); assert_eq!(format!("{e}"), "(a + b) * c"); // c * (a + b) should parenthesize the right side - let e = Expr::Var("c") * (Expr::Var("a") + Expr::Var("b")); + let e = Expr::variable("c") * (Expr::variable("a") + Expr::variable("b")); assert_eq!(format!("{e}"), "c * (a + b)"); // (a + b) * (c + d) should parenthesize both sides - let e = (Expr::Var("a") + Expr::Var("b")) * (Expr::Var("c") + Expr::Var("d")); + let e = + (Expr::variable("a") + Expr::variable("b")) * (Expr::variable("c") + Expr::variable("d")); assert_eq!(format!("{e}"), "(a + b) * (c + d)"); } #[test] fn test_expr_display_pow_with_complex_base() { // (a + b)^2 - let e = Expr::pow(Expr::Var("a") + Expr::Var("b"), Expr::Const(2.0)); + let e = Expr::pow(Expr::variable("a") + Expr::variable("b"), Expr::integer(2)); assert_eq!(format!("{e}"), "(a + b)^2"); // (a * b)^2 - let e = Expr::pow(Expr::Var("a") * Expr::Var("b"), Expr::Const(2.0)); + let e = Expr::pow(Expr::variable("a") * Expr::variable("b"), Expr::integer(2)); assert_eq!(format!("{e}"), "(a * b)^2"); } #[test] fn test_expr_eval_missing_variable() { - // Missing variable should default to 0 - let e = Expr::Var("missing"); + let e = Expr::variable("missing"); let size = ProblemSize::new(vec![("other", 5)]); - assert_eq!(e.eval(&size), 0.0); + assert_eq!( + evaluate_approximate(&e, &size), + Err(ApproximationError::MissingVariable("missing".to_string())) + ); } #[test] fn test_expr_scale() { - let e = Expr::Var("n").scale(3.0); + let e = Expr::integer(3) * Expr::variable("n"); let size = ProblemSize::new(vec![("n", 5)]); - assert_eq!(e.eval(&size), 15.0); + assert_eq!(eval(&e, &size), 15.0); } #[test] fn test_expr_ops_add_trait() { - let a = Expr::Var("a"); - let b = Expr::Var("b"); + let a = Expr::variable("a"); + let b = Expr::variable("b"); let e = a + b; // uses std::ops::Add let size = ProblemSize::new(vec![("a", 3), ("b", 4)]); - assert_eq!(e.eval(&size), 7.0); + assert_eq!(eval(&e, &size), 7.0); } #[test] fn test_expr_substitute_exp_log_sqrt() { - let replacement = Expr::Const(2.0); + let replacement = Expr::integer(2); let mut mapping = HashMap::new(); mapping.insert("n", &replacement); - let e = Expr::Exp(Box::new(Expr::Var("n"))); + let e = Expr::Exp(Box::new(Expr::variable("n"))); let result = e.substitute(&mapping); let size = ProblemSize::new(vec![]); - assert!((result.eval(&size) - 2.0_f64.exp()).abs() < 1e-10); + assert!((eval(&result, &size) - 2.0_f64.exp()).abs() < 1e-10); - let e = Expr::Log(Box::new(Expr::Var("n"))); + let e = Expr::Log(Box::new(Expr::variable("n"))); let result = e.substitute(&mapping); - assert!((result.eval(&size) - 2.0_f64.ln()).abs() < 1e-10); + assert!((eval(&result, &size) - 2.0_f64.ln()).abs() < 1e-10); - let e = Expr::Sqrt(Box::new(Expr::Var("n"))); + let e = Expr::Sqrt(Box::new(Expr::variable("n"))); let result = e.substitute(&mapping); - assert!((result.eval(&size) - 2.0_f64.sqrt()).abs() < 1e-10); + assert!((eval(&result, &size) - 2.0_f64.sqrt()).abs() < 1e-10); } #[test] fn test_expr_variables_exp_log_sqrt() { - let e = Expr::Exp(Box::new(Expr::Var("a"))); - assert_eq!(e.variables(), HashSet::from(["a"])); + let e = Expr::Exp(Box::new(Expr::variable("a"))); + assert_eq!(e.variables(), BTreeSet::from(["a"])); - let e = Expr::Log(Box::new(Expr::Var("b"))); - assert_eq!(e.variables(), HashSet::from(["b"])); + let e = Expr::Log(Box::new(Expr::variable("b"))); + assert_eq!(e.variables(), BTreeSet::from(["b"])); - let e = Expr::Sqrt(Box::new(Expr::Var("c"))); - assert_eq!(e.variables(), HashSet::from(["c"])); + let e = Expr::Sqrt(Box::new(Expr::variable("c"))); + assert_eq!(e.variables(), BTreeSet::from(["c"])); } // --- Runtime parser tests (Expr::parse / parse_to_expr) --- @@ -298,7 +308,7 @@ fn test_expr_variables_exp_log_sqrt() { fn parse_eval(input: &str, vars: &[(&str, usize)]) -> f64 { let expr = Expr::parse(input); let size = ProblemSize::new(vars.to_vec()); - expr.eval(&size) + eval(&expr, &size) } /// Like parse_eval but accepts f64 variable values for testing transcendental functions. @@ -307,11 +317,14 @@ fn parse_eval_f64(input: &str, vars: &[(&str, f64)]) -> f64 { // Build a ProblemSize-compatible evaluation by using substitute + eval // Since ProblemSize only stores usize, we substitute variables with Const nodes. let mut mapping = std::collections::HashMap::new(); - let exprs: Vec = vars.iter().map(|(_, v)| Expr::Const(*v)).collect(); + let exprs: Vec = vars + .iter() + .map(|(_, value)| expression_from_approximation(*value)) + .collect(); for ((name, _), expr) in vars.iter().zip(exprs.iter()) { mapping.insert(*name, expr); } - expr.substitute(&mapping).eval(&ProblemSize::new(vec![])) + eval(&expr.substitute(&mapping), &ProblemSize::new(vec![])) } // -- Tokenizer coverage -- @@ -344,12 +357,12 @@ fn test_parse_whitespace_handling() { #[test] fn test_parse_tokenize_invalid_char() { - assert!(parse_to_expr("n @ m").is_err()); + assert!(Expr::try_parse("n @ m").is_err()); } #[test] fn test_parse_tokenize_invalid_number() { - assert!(parse_to_expr("1.2.3").is_err()); + assert!(Expr::try_parse("1.2.3").is_err()); } // -- Additive: +, - -- @@ -463,9 +476,9 @@ fn test_parse_sqrt() { #[test] fn test_parse_unknown_function() { - assert!(parse_to_expr("foo(3)").is_err()); - let err = parse_to_expr("foo(3)").unwrap_err(); - assert!(err.contains("unknown function"), "got: {err}"); + assert!(Expr::try_parse("foo(3)").is_err()); + let err = Expr::try_parse("foo(3)").unwrap_err(); + assert!(err.to_string().contains("unknown function"), "got: {err}"); } #[test] @@ -519,32 +532,38 @@ fn test_parse_precedence_unary_pow() { #[test] fn test_parse_trailing_tokens_error() { - let err = parse_to_expr("n m").unwrap_err(); - assert!(err.contains("trailing"), "got: {err}"); + let err = Expr::try_parse("n m").unwrap_err(); + assert!(err.to_string().contains("trailing"), "got: {err}"); } #[test] fn test_parse_unexpected_token_error() { - let err = parse_to_expr(")").unwrap_err(); - assert!(err.contains("unexpected token"), "got: {err}"); + let err = Expr::try_parse(")").unwrap_err(); + assert!( + err.to_string().contains("expected expression"), + "got: {err}" + ); } #[test] fn test_parse_empty_input_error() { - let err = parse_to_expr("").unwrap_err(); - assert!(err.contains("end of input"), "got: {err}"); + let err = Expr::try_parse("").unwrap_err(); + assert!( + err.to_string().contains("expected expression"), + "got: {err}" + ); } #[test] fn test_parse_unclosed_paren_error() { - let err = parse_to_expr("(n + m").unwrap_err(); - assert!(err.contains("expected"), "got: {err}"); + let err = Expr::try_parse("(n + m").unwrap_err(); + assert!(err.to_string().contains("expected"), "got: {err}"); } #[test] fn test_parse_unclosed_function_error() { - let err = parse_to_expr("exp(n").unwrap_err(); - assert!(err.contains("expected"), "got: {err}"); + let err = Expr::try_parse("exp(n").unwrap_err(); + assert!(err.to_string().contains("expected"), "got: {err}"); } #[test] @@ -552,9 +571,9 @@ fn test_parse_expect_mismatch() { // "exp(n]" — expects RParen, gets unexpected token ']' // Actually ']' is an invalid char so tokenizer catches it first. // Use "exp(n +" to trigger expect mismatch (expects RParen, gets Plus). - let err = parse_to_expr("exp(n +").unwrap_err(); + let err = Expr::try_parse("exp(n +").unwrap_err(); assert!( - err.contains("expected") || err.contains("end of input"), + err.to_string().contains("expected") || err.to_string().contains("end of input"), "got: {err}" ); } @@ -581,37 +600,37 @@ fn test_parse_factorial_variable() { #[test] fn test_expr_factorial_eval() { - let e = Expr::Factorial(Box::new(Expr::Const(4.0))); + let e = Expr::Factorial(Box::new(Expr::integer(4))); let size = ProblemSize::new(vec![]); - assert_eq!(e.eval(&size), 24.0); + assert_eq!(eval(&e, &size), 24.0); } #[test] fn test_expr_factorial_display() { - let e = Expr::Factorial(Box::new(Expr::Var("n"))); + let e = Expr::Factorial(Box::new(Expr::variable("n"))); assert_eq!(format!("{e}"), "factorial(n)"); } #[test] fn test_expr_factorial_variables() { - let e = Expr::Factorial(Box::new(Expr::Var("n"))); - assert_eq!(e.variables(), HashSet::from(["n"])); + let e = Expr::Factorial(Box::new(Expr::variable("n"))); + assert_eq!(e.variables(), BTreeSet::from(["n"])); } #[test] fn test_expr_factorial_substitute() { - let replacement = Expr::Const(5.0); + let replacement = Expr::integer(5); let mut mapping = HashMap::new(); mapping.insert("n", &replacement); - let e = Expr::Factorial(Box::new(Expr::Var("n"))); + let e = Expr::Factorial(Box::new(Expr::variable("n"))); let result = e.substitute(&mapping); let size = ProblemSize::new(vec![]); - assert_eq!(result.eval(&size), 120.0); + assert_eq!(eval(&result, &size), 120.0); } #[test] fn test_expr_factorial_is_not_polynomial() { - assert!(!Expr::Factorial(Box::new(Expr::Var("n"))).is_polynomial()); + assert!(!Expr::Factorial(Box::new(Expr::variable("n"))).is_polynomial()); } #[test] diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index 9ff717ec6..d52338ccc 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -3,27 +3,31 @@ use super::{ add, componentwise_max, make_growth, mul, ExpBase, ExpFactor, ExpProduct, Growth, GrowthTerm, }; -use crate::expr::Expr; +use crate::expr::{ + constant_approximation, evaluate_approximate, expression_from_approximation, Expr, +}; use std::cmp::Ordering; /// Build a term from `(exp, poly, logs)` entry lists. -fn term( - exp: &[(&'static str, f64)], - poly: &[(&'static str, f64)], - logs: &[(&'static str, u32)], -) -> GrowthTerm { +fn term(exp: &[(&str, f64)], poly: &[(&str, f64)], logs: &[(&str, u32)]) -> GrowthTerm { GrowthTerm { exp: exp .iter() .map(|(variable, rate)| { ( - *variable, - ExpProduct::single(ExpBase::Constant(Expr::Const(2.0)), *rate), + (*variable).into(), + ExpProduct::single(ExpBase::Constant(Expr::integer(2)), *rate), ) }) .collect(), - poly: poly.iter().copied().collect(), - logs: logs.iter().copied().collect(), + poly: poly + .iter() + .map(|(variable, degree)| ((*variable).into(), *degree)) + .collect(), + logs: logs + .iter() + .map(|(variable, power)| ((*variable).into(), *power)) + .collect(), } } @@ -43,7 +47,7 @@ fn exp_product(factors: &[(f64, f64)]) -> ExpProduct { factors .iter() .map(|(base, coefficient)| ExpFactor { - base: ExpBase::Constant(Expr::Const(*base)), + base: ExpBase::Constant(expression_from_approximation(*base)), coefficient: *coefficient, }) .collect(), @@ -199,15 +203,15 @@ fn test_exponential_product_proof_rules() { fn test_exponential_product_canonicalization() { let combined = ExpProduct::new(vec![ ExpFactor { - base: ExpBase::Constant(Expr::Const(2.0)), + base: ExpBase::Constant(Expr::integer(2)), coefficient: 1.0, }, ExpFactor { - base: ExpBase::Constant(Expr::Const(2.0)), + base: ExpBase::Constant(Expr::integer(2)), coefficient: 2.0, }, ExpFactor { - base: ExpBase::Constant(Expr::Const(3.0)), + base: ExpBase::Constant(Expr::integer(3)), coefficient: 0.0, }, ]); @@ -215,11 +219,11 @@ fn test_exponential_product_canonicalization() { let cancelled = ExpProduct::new(vec![ ExpFactor { - base: ExpBase::Constant(Expr::Const(2.0)), + base: ExpBase::Constant(Expr::integer(2)), coefficient: 1.0, }, ExpFactor { - base: ExpBase::Constant(Expr::Const(2.0)), + base: ExpBase::Constant(Expr::integer(2)), coefficient: -1.0, }, ]); @@ -420,10 +424,11 @@ fn test_growth_unknown_dominance() { #[test] fn test_growth_antichain_cap_widens() { // 40 distinct single-variable terms are pairwise incomparable. - let vars: Vec<&'static str> = (0..40) - .map(|i| &*Box::leak(format!("v{i}").into_boxed_str())) + let vars: Vec = (0..40).map(|index| format!("v{index}")).collect(); + let many: Vec = vars + .iter() + .map(|variable| term(&[], &[(variable, 1.0)], &[])) .collect(); - let many: Vec = vars.iter().map(|v| term(&[], &[(*v, 1.0)], &[])).collect(); let widened = make_growth(many); let ts = terms_of(&widened); @@ -431,7 +436,7 @@ fn test_growth_antichain_cap_widens() { // The single term dominates every original (it carries all variables). for v in &vars { assert!( - ts[0].dominates(&term(&[], &[(*v, 1.0)], &[])) || ts[0] == term(&[], &[(*v, 1.0)], &[]) + ts[0].dominates(&term(&[], &[(v, 1.0)], &[])) || ts[0] == term(&[], &[(v, 1.0)], &[]) ); } } @@ -448,7 +453,7 @@ fn test_growth_componentwise_max_with_symbolic_exponentials() { let invalid = GrowthTerm { exp: BTreeMap::new(), - poly: [("n", f64::NAN)].into_iter().collect(), + poly: [("n".into(), f64::NAN)].into_iter().collect(), logs: BTreeMap::new(), }; assert_eq!(componentwise_max(&[invalid]), None); @@ -460,9 +465,12 @@ fn test_growth_componentwise_max_with_symbolic_exponentials() { fn test_growth_antichain_cap_with_unproved_exponentials_is_unknown() { let terms = (1..=33) .map(|i| GrowthTerm { - exp: [("n", exp_product(&[(2.0, i as f64), (3.0, 1.0 / i as f64)]))] - .into_iter() - .collect(), + exp: [( + "n".into(), + exp_product(&[(2.0, i as f64), (3.0, 1.0 / i as f64)]), + )] + .into_iter() + .collect(), poly: BTreeMap::new(), logs: BTreeMap::new(), }) @@ -471,7 +479,7 @@ fn test_growth_antichain_cap_with_unproved_exponentials_is_unknown() { assert_eq!(make_growth(terms), Growth::Unknown); } -/// Structured serde round-trips (with `&'static str` keys leaked on read), and +/// Structured serde round-trips with owned variable names, and /// `Unknown` round-trips. #[test] fn test_growth_serde_roundtrip() { @@ -511,7 +519,7 @@ fn test_growth_serde_roundtrip() { assert!(serde_json::from_str::(variable_base).is_err()); let invalid = Growth::Terms(vec![GrowthTerm { - exp: [("n", ExpProduct::empty())].into_iter().collect(), + exp: [("n".into(), ExpProduct::empty())].into_iter().collect(), poly: BTreeMap::new(), logs: BTreeMap::new(), }]); @@ -586,12 +594,11 @@ fn b(e: Expr) -> Box { Box::new(e) } -/// Variable pool — `&'static str` literals so they satisfy `Expr::Var` and match -/// the `ProblemSize` keys built by [`joint_size`]. +/// Variable pool used by generated expressions and [`joint_size`]. const VARS: [&str; 3] = ["n", "m", "k"]; fn gen_var(rng: &mut SplitMix64) -> Expr { - Expr::Var(VARS[rng.below(VARS.len() as u64) as usize]) + Expr::variable(VARS[rng.below(VARS.len() as u64) as usize]) } /// All variables set jointly to `s` (the contracts evaluate on the diagonal). @@ -611,7 +618,7 @@ const MAX_DEPTH: u32 = 5; fn gen_leaf(rng: &mut SplitMix64) -> Expr { // Bias toward variables; keep constants small and positive. if rng.below(4) == 0 { - Expr::Const((1 + rng.below(4)) as f64) + Expr::integer(1 + rng.below(4)) } else { gen_var(rng) } @@ -634,7 +641,7 @@ fn gen_lin_term(rng: &mut SplitMix64) -> Expr { if c == 1 { v } else { - Expr::Const(c as f64) * v + Expr::integer(c) * v } } @@ -653,7 +660,7 @@ const STABLE_EXPONENTIAL_BASES: &[f64] = &[2.0, E_BELOW, E_ABOVE, 3.0]; const ADVERSARIAL_EXPONENTIAL_BASES: &[f64] = &[1.0000000001, 2.0, E_BELOW, E_ABOVE, 3.0]; fn gen_exponential_base(rng: &mut SplitMix64, bases: &[f64]) -> Expr { - Expr::Const(bases[rng.below(bases.len() as u64) as usize]) + expression_from_approximation(bases[rng.below(bases.len() as u64) as usize]) } fn gen_expr(rng: &mut SplitMix64, depth: u32, exponential_bases: &[f64]) -> Expr { @@ -672,7 +679,7 @@ fn gen_expr(rng: &mut SplitMix64, depth: u32, exponential_bases: &[f64]) -> Expr ), 55..=69 => Expr::pow( gen_expr(rng, depth - 1, exponential_bases), - Expr::Const((1 + rng.below(3)) as f64), + Expr::integer(1 + rng.below(3)), ), 70..=79 => Expr::Sqrt(b(gen_expr(rng, depth - 1, exponential_bases))), 80..=89 => Expr::Log(b(gen_expr(rng, depth - 1, exponential_bases))), @@ -698,14 +705,14 @@ fn gen_factor(rng: &mut SplitMix64) -> Expr { let v = gen_var(rng); match rng.below(6) { 0 => v, - 1 => Expr::pow(v, Expr::Const((1 + rng.below(3)) as f64)), + 1 => Expr::pow(v, Expr::integer(1 + rng.below(3))), 2 => Expr::Sqrt(b(v)), 3 => Expr::Log(b(v)), // Keep the numeric dominance harness on one common base: different // fixed bases can have crossovers beyond its finite observation window. // Multi-base behavior is covered by symbolic proof tests above. - 4 => Expr::pow(Expr::Const(2.0), v), - _ => Expr::pow(Expr::Const(2.0), Expr::Const((1 + rng.below(3)) as f64) * v), + 4 => Expr::pow(Expr::integer(2), v), + _ => Expr::pow(Expr::integer(2), Expr::integer(1 + rng.below(3)) * v), } } @@ -762,8 +769,8 @@ fn run_upper_bound(transfer: fn(&Expr) -> Growth, seed: u64, iters: usize) -> Ub // Calibrate C from the observed ratio at the (smaller) anchor. let sz0 = joint_size(anchor as usize); - let ve0 = e.eval(&sz0); - let vg0 = gexpr.eval(&sz0); + let ve0 = evaluate_approximate(&e, &sz0).unwrap(); + let vg0 = evaluate_approximate(&gexpr, &sz0).unwrap(); // Nonnegativity is a domain precondition. A negative anchor value means // the generated expression is outside the domain's contract (e.g. deeply // nested `log`s that are negative at these sizes) — skip it, don't hold @@ -777,8 +784,8 @@ fn run_upper_bound(transfer: fn(&Expr) -> Growth, seed: u64, iters: usize) -> Ub let mut conclusive = false; for &s in &large { let sz = joint_size(s as usize); - let ve = e.eval(&sz); - let vg = gexpr.eval(&sz); + let ve = evaluate_approximate(&e, &sz).unwrap(); + let vg = evaluate_approximate(&gexpr, &sz).unwrap(); if ve.is_nan() || vg.is_nan() { continue; } @@ -830,21 +837,29 @@ fn run_upper_bound(transfer: fn(&Expr) -> Growth, seed: u64, iters: usize) -> Ub /// Every other node mirrors the real `Growth::from_expr` (reusing its private /// transfer helpers), so the only defect is the seeded `Add` bug. fn broken_from_expr(e: &Expr) -> Growth { - if e.constant_value().is_some() { + if constant_approximation(e).is_some() { return Growth::Terms(vec![GrowthTerm::one()]); } match e { Expr::Const(_) => Growth::Terms(vec![GrowthTerm::one()]), Expr::Var(v) => { let mut t = GrowthTerm::one(); - t.poly.insert(v, 1.0); + t.poly.insert(v.clone(), 1.0); Growth::Terms(vec![t]) } // The seeded bug: drop the second summand. Expr::Add(a, _b) => broken_from_expr(a), + Expr::Sub(a, b) => add(broken_from_expr(a), broken_from_expr(b)), Expr::Mul(a, b) => mul(broken_from_expr(a), broken_from_expr(b)), + Expr::Div(a, b) => { + if constant_approximation(b).is_some() { + broken_from_expr(a) + } else { + Growth::Unknown + } + } Expr::Pow(base, exp) => { - if let Some(k) = exp.constant_value() { + if let Some(k) = constant_approximation(exp) { if k < 0.0 { Growth::Unknown } else if k == 0.0 { @@ -852,13 +867,14 @@ fn broken_from_expr(e: &Expr) -> Growth { } else { pow_const(broken_from_expr(base), k) } - } else if base.constant_value().is_some() { + } else if constant_approximation(base).is_some() { exponential(ExpBase::Constant(base.as_ref().clone()), exp) } else { Growth::Unknown } } Expr::Exp(a) => exponential(ExpBase::Natural, a), + Expr::Neg(value) => broken_from_expr(value), Expr::Log(a) => log_growth(broken_from_expr(a)), Expr::Sqrt(a) => pow_const(broken_from_expr(a), 0.5), Expr::Factorial(_) => Growth::Unknown, @@ -911,7 +927,7 @@ fn test_growth_property_upper_bound_negative_control() { /// Exponential factors round-trip exactly. Polynomial degrees retain the /// pre-existing tolerance for unrelated floating-point power composition. -fn map_approx_eq(a: &BTreeMap<&'static str, f64>, b: &BTreeMap<&'static str, f64>) -> bool { +fn map_approx_eq(a: &BTreeMap, f64>, b: &BTreeMap, f64>) -> bool { a.len() == b.len() && a.iter() .all(|(k, v)| b.get(k).is_some_and(|w| (v - w).abs() < 1e-6)) @@ -1072,8 +1088,14 @@ fn test_growth_property_dominance_sound() { let a = Growth::Terms(vec![lo.clone()]).to_expr().unwrap(); let bx = Growth::Terms(vec![hi.clone()]).to_expr().unwrap(); let (z1, z2) = (joint_size(s1), joint_size(s2)); - let (a1, a2) = (a.eval(&z1), a.eval(&z2)); - let (b1, b2) = (bx.eval(&z1), bx.eval(&z2)); + let (a1, a2) = ( + evaluate_approximate(&a, &z1).unwrap(), + evaluate_approximate(&a, &z2).unwrap(), + ); + let (b1, b2) = ( + evaluate_approximate(&bx, &z1).unwrap(), + evaluate_approximate(&bx, &z2).unwrap(), + ); if [a1, a2, b1, b2].iter().any(|v| !v.is_finite() || *v <= 0.0) { skipped += 1; continue; diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index 45fba4eba..4eb0bc1d1 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -1,5 +1,6 @@ //! Tests for ReductionGraph: discovery, path finding, and typed API. +use crate::expr::evaluate_approximate; #[cfg(feature = "ilp-solver")] use crate::models::algebraic::ILP; use crate::models::decision::Decision; @@ -372,28 +373,26 @@ fn test_3sat_to_mis_triangular_overhead() { ("num_vertices", 10), ("num_edges", 15), ]); + let approximate = |expression| evaluate_approximate(expression, &test_size).unwrap(); // Edge 0: K3SAT → KN_SAT (variant cast, identity for num_vars + num_clauses) - assert_eq!(edges[0].get("num_vars").unwrap().eval(&test_size), 3.0); - assert_eq!(edges[0].get("num_clauses").unwrap().eval(&test_size), 2.0); + assert_eq!(approximate(edges[0].get("num_vars").unwrap()), 3.0); + assert_eq!(approximate(edges[0].get("num_clauses").unwrap()), 2.0); // Edge 1: KN_SAT → SAT (identity) - assert_eq!(edges[1].get("num_vars").unwrap().eval(&test_size), 3.0); - assert_eq!(edges[1].get("num_clauses").unwrap().eval(&test_size), 2.0); - assert_eq!(edges[1].get("num_literals").unwrap().eval(&test_size), 6.0); + assert_eq!(approximate(edges[1].get("num_vars").unwrap()), 3.0); + assert_eq!(approximate(edges[1].get("num_clauses").unwrap()), 2.0); + assert_eq!(approximate(edges[1].get("num_literals").unwrap()), 6.0); // Edge 2: SAT → MIS{SimpleGraph,One} // num_vertices = num_literals, num_edges = num_literals^2 - assert_eq!(edges[2].get("num_vertices").unwrap().eval(&test_size), 6.0); - assert_eq!(edges[2].get("num_edges").unwrap().eval(&test_size), 36.0); + assert_eq!(approximate(edges[2].get("num_vertices").unwrap()), 6.0); + assert_eq!(approximate(edges[2].get("num_edges").unwrap()), 36.0); // Edge 3: MIS{SimpleGraph,One} → MIS{TriangularSubgraph,i32} // num_vertices = num_vertices², num_edges = num_vertices² - assert_eq!( - edges[3].get("num_vertices").unwrap().eval(&test_size), - 100.0 - ); - assert_eq!(edges[3].get("num_edges").unwrap().eval(&test_size), 100.0); + assert_eq!(approximate(edges[3].get("num_vertices").unwrap()), 100.0); + assert_eq!(approximate(edges[3].get("num_edges").unwrap()), 100.0); // Compose overheads symbolically along the path. // The composed overhead maps 3-SAT input variables to final MIS{Triangular} output. @@ -406,8 +405,8 @@ fn test_3sat_to_mis_triangular_overhead() { // Composed: num_vertices = L², num_edges = L² let composed = graph.compose_path_overhead(&path); // Evaluate composed at input: L=6, so L²=36 - assert_eq!(composed.get("num_vertices").unwrap().eval(&test_size), 36.0); - assert_eq!(composed.get("num_edges").unwrap().eval(&test_size), 36.0); + assert_eq!(approximate(composed.get("num_vertices").unwrap()), 36.0); + assert_eq!(approximate(composed.get("num_edges").unwrap()), 36.0); } // ---- k-neighbor BFS ---- @@ -970,7 +969,7 @@ fn test_find_paths_bounded_returns_shortest_when_truncated() { } ReductionEdgeData { - overhead: ReductionOverhead::new(vec![("n", Expr::Var("n"))]), + overhead: ReductionOverhead::new(vec![("n", Expr::variable("n"))]), reduce_fn: Some(reduce), reduce_aggregate_fn: None, turing: false, diff --git a/src/unit_tests/rules/analysis.rs b/src/unit_tests/rules/analysis.rs index 6088d9d38..f4e62ab70 100644 --- a/src/unit_tests/rules/analysis.rs +++ b/src/unit_tests/rules/analysis.rs @@ -10,8 +10,8 @@ use crate::rules::registry::ReductionOverhead; #[test] fn test_compare_overhead_equal() { - let a = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - let b = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); + let a = ReductionOverhead::new(vec![("num_vars", Expr::variable("n"))]); + let b = ReductionOverhead::new(vec![("num_vars", Expr::variable("n"))]); assert_eq!(compare_overhead(&a, &b), ComparisonStatus::Dominated); } @@ -20,19 +20,19 @@ fn test_compare_overhead_composite_smaller_degree() { // primitive: num_vars = n^2, composite: num_vars = n → dominated let prim = ReductionOverhead::new(vec![( "num_vars", - Expr::pow(Expr::Var("n"), Expr::Const(2.0)), + Expr::pow(Expr::variable("n"), Expr::integer(2)), )]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); + let comp = ReductionOverhead::new(vec![("num_vars", Expr::variable("n"))]); assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } #[test] fn test_compare_overhead_composite_worse() { // primitive: num_vars = n, composite: num_vars = n^2 → not dominated - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); + let prim = ReductionOverhead::new(vec![("num_vars", Expr::variable("n"))]); let comp = ReductionOverhead::new(vec![( "num_vars", - Expr::pow(Expr::Var("n"), Expr::Const(2.0)), + Expr::pow(Expr::variable("n"), Expr::integer(2)), )]); assert_eq!( compare_overhead(&prim, &comp), @@ -44,15 +44,15 @@ fn test_compare_overhead_composite_worse() { fn test_compare_overhead_multi_field_mixed() { // One field better, one worse → not dominated let prim = ReductionOverhead::new(vec![ - ("num_vars", Expr::Var("n")), + ("num_vars", Expr::variable("n")), ( "num_constraints", - Expr::pow(Expr::Var("n"), Expr::Const(2.0)), + Expr::pow(Expr::variable("n"), Expr::integer(2)), ), ]); let comp = ReductionOverhead::new(vec![ - ("num_vars", Expr::pow(Expr::Var("n"), Expr::Const(2.0))), - ("num_constraints", Expr::Var("n")), + ("num_vars", Expr::pow(Expr::variable("n"), Expr::integer(2))), + ("num_constraints", Expr::variable("n")), ]); assert_eq!( compare_overhead(&prim, &comp), @@ -62,8 +62,8 @@ fn test_compare_overhead_multi_field_mixed() { #[test] fn test_compare_overhead_no_common_fields() { - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - let comp = ReductionOverhead::new(vec![("num_spins", Expr::Var("n"))]); + let prim = ReductionOverhead::new(vec![("num_vars", Expr::variable("n"))]); + let comp = ReductionOverhead::new(vec![("num_spins", Expr::variable("n"))]); assert_eq!( compare_overhead(&prim, &comp), ComparisonStatus::NotDominated @@ -75,8 +75,8 @@ fn test_compare_overhead_exp_dominates_poly() { // primitive exp(n) grows faster than composite n, so composite ≤ primitive // on the only common field → dominated. (The old polynomial engine rejected // exp outright and returned Unknown; the growth domain decides it.) - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Exp(Box::new(Expr::Var("n"))))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); + let prim = ReductionOverhead::new(vec![("num_vars", Expr::Exp(Box::new(Expr::variable("n"))))]); + let comp = ReductionOverhead::new(vec![("num_vars", Expr::variable("n"))]); assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } @@ -85,8 +85,8 @@ fn test_compare_overhead_poly_dominates_log() { // primitive n vs composite log(n): n grows faster than log(n), so the // composite is dominated. Previously Unknown (the polynomial engine could // not normalize `log`); now decided by the growth domain. - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Log(Box::new(Expr::Var("n"))))]); + let prim = ReductionOverhead::new(vec![("num_vars", Expr::variable("n"))]); + let comp = ReductionOverhead::new(vec![("num_vars", Expr::Log(Box::new(Expr::variable("n"))))]); assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } @@ -139,12 +139,18 @@ fn test_compare_overhead_negative_control_cubic_worse() { // the differing field, so this MUST be NotDominated — a direction inversion // or an ignored field would flip it to Dominated. let prim = ReductionOverhead::new(vec![ - ("num_vertices", Expr::pow(Expr::Var("n"), Expr::Const(2.0))), - ("num_edges", Expr::Var("n")), + ( + "num_vertices", + Expr::pow(Expr::variable("n"), Expr::integer(2)), + ), + ("num_edges", Expr::variable("n")), ]); let comp = ReductionOverhead::new(vec![ - ("num_vertices", Expr::pow(Expr::Var("n"), Expr::Const(3.0))), - ("num_edges", Expr::Var("n")), + ( + "num_vertices", + Expr::pow(Expr::variable("n"), Expr::integer(3)), + ), + ("num_edges", Expr::variable("n")), ]); assert_eq!( compare_overhead(&prim, &comp), @@ -164,8 +170,14 @@ fn test_compare_overhead_multivariate_product_vs_sum() { // primitive n + m ≍ {n, m} (two incomparable terms) vs composite n * m ≍ // {n·m}. The single composite term n·m is dominated by neither n nor m, so // the primitive does not dominate the composite → not dominated. - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n") + Expr::Var("m"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n") * Expr::Var("m"))]); + let prim = ReductionOverhead::new(vec![( + "num_vars", + Expr::variable("n") + Expr::variable("m"), + )]); + let comp = ReductionOverhead::new(vec![( + "num_vars", + Expr::variable("n") * Expr::variable("m"), + )]); assert_eq!( compare_overhead(&prim, &comp), ComparisonStatus::NotDominated @@ -179,9 +191,12 @@ fn test_compare_overhead_incomparable_field_not_dominated() { // other (n^2 wins on n, n·m wins on m) → not dominated. let prim = ReductionOverhead::new(vec![( "num_vars", - Expr::pow(Expr::Var("n"), Expr::Const(2.0)), + Expr::pow(Expr::variable("n"), Expr::integer(2)), + )]); + let comp = ReductionOverhead::new(vec![( + "num_vars", + Expr::variable("n") * Expr::variable("m"), )]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n") * Expr::Var("m"))]); assert_eq!( compare_overhead(&prim, &comp), ComparisonStatus::NotDominated @@ -191,16 +206,19 @@ fn test_compare_overhead_incomparable_field_not_dominated() { #[test] fn test_compare_overhead_sum_vs_single_var() { // composite: n, primitive: n + m → composite ≤ primitive (n dominated by n) - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n") + Expr::Var("m"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); + let prim = ReductionOverhead::new(vec![( + "num_vars", + Expr::variable("n") + Expr::variable("m"), + )]); + let comp = ReductionOverhead::new(vec![("num_vars", Expr::variable("n"))]); assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } #[test] fn test_compare_overhead_constant_factor() { // 3*n vs n → same asymptotic class → dominated (equal) - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Var("n"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Const(3.0) * Expr::Var("n"))]); + let prim = ReductionOverhead::new(vec![("num_vars", Expr::variable("n"))]); + let comp = ReductionOverhead::new(vec![("num_vars", Expr::integer(3) * Expr::variable("n"))]); assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } @@ -213,11 +231,11 @@ fn test_compare_overhead_polynomial_expansion() { // large. let prim = ReductionOverhead::new(vec![( "num_vars", - Expr::pow(Expr::Var("n"), Expr::Const(3.0)), + Expr::pow(Expr::variable("n"), Expr::integer(3)), )]); let comp = ReductionOverhead::new(vec![( "num_vars", - Expr::pow(Expr::Var("n") + Expr::Var("m"), Expr::Const(2.0)), + Expr::pow(Expr::variable("n") + Expr::variable("m"), Expr::integer(2)), )]); assert_eq!( compare_overhead(&prim, &comp), @@ -229,15 +247,15 @@ fn test_compare_overhead_polynomial_expansion() { fn test_compare_overhead_multi_field_all_smaller() { // Both fields: composite has smaller degree → dominated let prim = ReductionOverhead::new(vec![ - ("num_vars", Expr::pow(Expr::Var("n"), Expr::Const(2.0))), + ("num_vars", Expr::pow(Expr::variable("n"), Expr::integer(2))), ( "num_constraints", - Expr::pow(Expr::Var("n"), Expr::Const(3.0)), + Expr::pow(Expr::variable("n"), Expr::integer(3)), ), ]); let comp = ReductionOverhead::new(vec![ - ("num_vars", Expr::Var("n")), - ("num_constraints", Expr::Var("n")), + ("num_vars", Expr::variable("n")), + ("num_constraints", Expr::variable("n")), ]); assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index d373dc2ac..55354542b 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -1330,18 +1330,18 @@ fn test_size_field_names_returns_own_fields() { // not the target's fields from any reduction. let mis_fields = graph.size_field_names("MaximumIndependentSet"); assert!( - mis_fields.contains(&"num_vertices"), + mis_fields.iter().any(|field| field == "num_vertices"), "MIS should have num_vertices, got: {:?}", mis_fields ); assert!( - mis_fields.contains(&"num_edges"), + mis_fields.iter().any(|field| field == "num_edges"), "MIS should have num_edges, got: {:?}", mis_fields ); // Should NOT contain target fields like num_vars or num_constraints assert!( - !mis_fields.contains(&"num_constraints"), + !mis_fields.iter().any(|field| field == "num_constraints"), "MIS should not report ILP's num_constraints, got: {:?}", mis_fields ); @@ -1349,7 +1349,7 @@ fn test_size_field_names_returns_own_fields() { // QUBO should report num_vars let qubo_fields = graph.size_field_names("QUBO"); assert!( - qubo_fields.contains(&"num_vars"), + qubo_fields.iter().any(|field| field == "num_vars"), "QUBO should have num_vars, got: {:?}", qubo_fields ); @@ -1373,14 +1373,14 @@ fn test_overhead_variables_are_consistent() { continue; } - let source_fields: std::collections::HashSet<&str> = graph + let source_fields: std::collections::HashSet = graph .size_field_names(entry.source_name) .into_iter() .collect(); for var in &input_vars { assert!( - source_fields.contains(var), + source_fields.contains(*var), "Reduction {} -> {}: overhead references variable '{}' \ which is not a known size field of {}. Known fields: {:?}", entry.source_name, diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index a0342d2db..b9d8fff29 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -6,7 +6,7 @@ //! returns the path with the strictly-better final measured size. use super::*; -use crate::expr::Expr; +use crate::expr::{evaluate_approximate, expression_from_approximation, Expr}; use crate::growth::Growth; use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::formula::{CNFClause, Satisfiability}; @@ -164,7 +164,7 @@ fn measured_edge( ReductionEdgeData { overhead: ReductionOverhead::new(vec![( "predicted_total", - Expr::Const(asymptotic_prediction), + expression_from_approximation(asymptotic_prediction), )]), reduce_fn: Some(reduce_fn), reduce_aggregate_fn: None, @@ -598,11 +598,15 @@ impl DiamondLabel { impl PathLabel for DiamondLabel { fn extend(&self, edge: &ReductionEdge) -> Option { let ctx = self.ctx(); - let add_c = edge.overhead.get("c").map(|e| e.eval(&ctx)).unwrap_or(0.0); + let add_c = edge + .overhead + .get("c") + .map(|expression| evaluate_approximate(expression, &ctx).unwrap()) + .unwrap_or(0.0); let new_s = edge .overhead .get("s") - .map(|e| e.eval(&ctx)) + .map(|expression| evaluate_approximate(expression, &ctx).unwrap()) .unwrap_or(self.s); Some(DiamondLabel { c: self.c + add_c, @@ -617,7 +621,7 @@ impl PathLabel for DiamondLabel { fn diamond_edge(c: f64, s: Expr) -> ReductionEdgeData { ReductionEdgeData { - overhead: ReductionOverhead::new(vec![("c", Expr::Const(c)), ("s", s)]), + overhead: ReductionOverhead::new(vec![("c", expression_from_approximation(c)), ("s", s)]), reduce_fn: Some(measured_source_to_a), reduce_aggregate_fn: None, turing: false, @@ -632,13 +636,13 @@ fn test_negative_control_diamond_keeps_componentwise_front() { &["S", "M", "P", "T"], &[ // S -> M: cheap first edge (c=1), large intermediate size (s=100). - ("S", "M", diamond_edge(1.0, Expr::Const(100.0))), + ("S", "M", diamond_edge(1.0, Expr::integer(100))), // S -> P: pricier first edge (c=2), small size (s=5). - ("S", "P", diamond_edge(2.0, Expr::Const(5.0))), + ("S", "P", diamond_edge(2.0, Expr::integer(5))), // P -> M: small size (s=6). - ("P", "M", diamond_edge(1.0, Expr::Const(6.0))), + ("P", "M", diamond_edge(1.0, Expr::integer(6))), // M -> T: identity on size (final size = size at M). - ("M", "T", diamond_edge(1.0, Expr::Var("s"))), + ("M", "T", diamond_edge(1.0, Expr::variable("s"))), ], ); @@ -671,10 +675,10 @@ fn test_diamond_exact_multi_label_keeps_incomparable_routes() { let graph = ReductionGraph::from_test_edges( &["S", "M", "P", "T"], &[ - ("S", "M", diamond_edge(1.0, Expr::Const(100.0))), - ("S", "P", diamond_edge(2.0, Expr::Const(5.0))), - ("P", "M", diamond_edge(1.0, Expr::Const(6.0))), - ("M", "T", diamond_edge(1.0, Expr::Var("s"))), + ("S", "M", diamond_edge(1.0, Expr::integer(100))), + ("S", "P", diamond_edge(2.0, Expr::integer(5))), + ("P", "M", diamond_edge(1.0, Expr::integer(6))), + ("M", "T", diamond_edge(1.0, Expr::variable("s"))), ], ); let front = graph @@ -703,7 +707,7 @@ fn test_diamond_exact_multi_label_keeps_incomparable_routes() { /// A power `Var(v)^k`. fn powk(v: &'static str, k: f64) -> Expr { - Expr::pow(Expr::Var(v), Expr::Const(k)) + Expr::pow(Expr::variable(v), expression_from_approximation(k)) } /// A test edge carrying only a symbolic overhead (target field → Expr over the @@ -734,7 +738,7 @@ fn field_big_o(label: &GrowthLabel, field: &str) -> String { #[test] fn test_growth_label_extend_composes_overhead() { // Source S has fields n, m; edge maps a = n^2, b = m (in the source's variables). - let edge_data = growth_edge(vec![("a", powk("n", 2.0)), ("b", Expr::Var("m"))]); + let edge_data = growth_edge(vec![("a", powk("n", 2.0)), ("b", Expr::variable("m"))]); let target_variant = BTreeMap::new(); let redge = ReductionEdge { overhead: &edge_data.overhead, @@ -743,7 +747,7 @@ fn test_growth_label_extend_composes_overhead() { target_variant: &target_variant, }; - let initial = GrowthLabel::source(&["n", "m"]); + let initial = GrowthLabel::source(&["n".to_string(), "m".to_string()]); let next = initial .extend(&redge) .expect("asymptotic extend never prunes"); @@ -751,7 +755,7 @@ fn test_growth_label_extend_composes_overhead() { assert_eq!(field_big_o(&next, "b"), "m"); // A second hop composes: c = a * b substitutes a→n^2, b→m ⇒ n^2 * m. - let edge2 = growth_edge(vec![("c", Expr::Var("a") * Expr::Var("b"))]); + let edge2 = growth_edge(vec![("c", Expr::variable("a") * Expr::variable("b"))]); let redge2 = ReductionEdge { overhead: &edge2.overhead, reduce_fn: None, @@ -770,15 +774,15 @@ fn test_growth_label_propagates_unknown() { let mut fields = BTreeMap::new(); fields.insert( "x", - Growth::from_expr(&Expr::Factorial(Box::new(Expr::Var("n")))), + Growth::from_expr(&Expr::Factorial(Box::new(Expr::variable("n")))), ); - fields.insert("y", Growth::from_expr(&Expr::Var("n"))); + fields.insert("y", Growth::from_expr(&Expr::variable("n"))); let label = GrowthLabel::from_fields(fields); assert!(matches!(label.fields().get("x"), Some(Growth::Unknown))); // out1 uses x (Unknown) → Unknown; out2 uses only y → bounded. let edge = growth_edge(vec![ - ("out1", Expr::Var("x") * Expr::Var("y")), + ("out1", Expr::variable("x") * Expr::variable("y")), ("out2", powk("y", 2.0)), ]); let tv = BTreeMap::new(); @@ -799,14 +803,22 @@ fn test_symbolic_front_excludes_unknown_with_analysis_reason() { let graph = ReductionGraph::from_test_edges( &["S", "Known", "Unknown", "T"], &[ - ("S", "Known", growth_edge(vec![("x", Expr::Const(1.0))])), - ("Known", "T", growth_edge(vec![("out", Expr::Var("x"))])), + ("S", "Known", growth_edge(vec![("x", Expr::integer(1))])), + ( + "Known", + "T", + growth_edge(vec![("out", Expr::variable("x"))]), + ), ( "S", "Unknown", - growth_edge(vec![("x", Expr::Var("missing"))]), + growth_edge(vec![("x", Expr::variable("missing"))]), + ), + ( + "Unknown", + "T", + growth_edge(vec![("out", Expr::variable("x"))]), ), - ("Unknown", "T", growth_edge(vec![("out", Expr::Var("x"))])), ], ); let outcome = graph.asymptotic_front( @@ -836,15 +848,23 @@ fn test_symbolic_coverage_counts_dominated_analyzable_paths() { ( "MaximumIndependentSet", "Small", - growth_edge(vec![("x", Expr::Const(1.0))]), + growth_edge(vec![("x", Expr::integer(1))]), + ), + ( + "Small", + "T", + growth_edge(vec![("out", Expr::variable("x"))]), ), - ("Small", "T", growth_edge(vec![("out", Expr::Var("x"))])), ( "MaximumIndependentSet", "Large", - growth_edge(vec![("x", Expr::Var("num_vertices"))]), + growth_edge(vec![("x", Expr::variable("num_vertices"))]), + ), + ( + "Large", + "T", + growth_edge(vec![("out", Expr::variable("x"))]), ), - ("Large", "T", growth_edge(vec![("out", Expr::Var("x"))])), ], ); let result = graph @@ -868,7 +888,11 @@ fn test_symbolic_front_all_unknown_is_explicit_error() { let empty = BTreeMap::new(); let graph = ReductionGraph::from_test_edges( &["S", "T"], - &[("S", "T", growth_edge(vec![("out", Expr::Var("missing"))]))], + &[( + "S", + "T", + growth_edge(vec![("out", Expr::variable("missing"))]), + )], ); let error = graph .asymptotic_front( @@ -894,11 +918,15 @@ fn test_symbolic_all_discovered_unknown_can_still_be_search_incomplete() { let graph = ReductionGraph::from_test_edges( &["S", "A", "B", "C", "T"], &[ - ("S", "A", growth_edge(vec![("x", Expr::Var("missing"))])), - ("A", "T", growth_edge(vec![("out", Expr::Var("x"))])), - ("S", "B", growth_edge(vec![("x", Expr::Const(1.0))])), - ("B", "C", growth_edge(vec![("x", Expr::Var("x"))])), - ("C", "T", growth_edge(vec![("out", Expr::Var("x"))])), + ( + "S", + "A", + growth_edge(vec![("x", Expr::variable("missing"))]), + ), + ("A", "T", growth_edge(vec![("out", Expr::variable("x"))])), + ("S", "B", growth_edge(vec![("x", Expr::integer(1))])), + ("B", "C", growth_edge(vec![("x", Expr::variable("x"))])), + ("C", "T", growth_edge(vec![("out", Expr::variable("x"))])), ], ); let outcome = graph.asymptotic_front( @@ -925,7 +953,7 @@ fn test_growth_label_unknown_is_incomparable() { let known = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); m.insert("a", Growth::from_expr(&powk("n", 2.0))); - m.insert("b", Growth::from_expr(&Expr::Var("m"))); + m.insert("b", Growth::from_expr(&Expr::variable("m"))); m }); let with_unknown = GrowthLabel::from_fields({ @@ -944,14 +972,14 @@ fn test_growth_label_unknown_is_incomparable() { fn test_growth_label_terminal_dominance_partial_order() { let a = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); - m.insert("v", Growth::from_expr(&Expr::Var("n"))); // n - m.insert("e", Growth::from_expr(&Expr::Var("m"))); // m + m.insert("v", Growth::from_expr(&Expr::variable("n"))); // n + m.insert("e", Growth::from_expr(&Expr::variable("m"))); // m m }); let b = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); m.insert("v", Growth::from_expr(&powk("n", 2.0))); // n^2 - m.insert("e", Growth::from_expr(&Expr::Var("m"))); // m + m.insert("e", Growth::from_expr(&Expr::variable("m"))); // m m }); // a (n, m) grows slower in v, equal in e ⇒ a dominates b; b does not dominate a. @@ -963,12 +991,12 @@ fn test_growth_label_terminal_dominance_partial_order() { let c = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); m.insert("v", Growth::from_expr(&powk("n", 2.0))); // n^2 - m.insert("e", Growth::from_expr(&Expr::Var("m"))); // m + m.insert("e", Growth::from_expr(&Expr::variable("m"))); // m m }); let d = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); - m.insert("v", Growth::from_expr(&Expr::Var("n"))); // n + m.insert("v", Growth::from_expr(&Expr::variable("n"))); // n m.insert("e", Growth::from_expr(&powk("m", 2.0))); // m^2 m }); @@ -990,12 +1018,12 @@ fn test_growth_negative_control_incomparable_front() { ( "S", "A", - growth_edge(vec![("n", Expr::Var("n")), ("m", Expr::Var("m"))]), + growth_edge(vec![("n", Expr::variable("n")), ("m", Expr::variable("m"))]), ), ( "S", "B", - growth_edge(vec![("n", Expr::Var("n")), ("m", Expr::Var("m"))]), + growth_edge(vec![("n", Expr::variable("n")), ("m", Expr::variable("m"))]), ), // Path A: vertices = n^2, edges = m. ( @@ -1003,7 +1031,7 @@ fn test_growth_negative_control_incomparable_front() { "T", growth_edge(vec![ ("vertices", powk("n", 2.0)), - ("edges", Expr::Var("m")), + ("edges", Expr::variable("m")), ]), ), // Path B: vertices = n, edges = m^2. @@ -1011,14 +1039,14 @@ fn test_growth_negative_control_incomparable_front() { "B", "T", growth_edge(vec![ - ("vertices", Expr::Var("n")), + ("vertices", Expr::variable("n")), ("edges", powk("m", 2.0)), ]), ), ], ); - let initial = GrowthLabel::source(&["n", "m"]); + let initial = GrowthLabel::source(&["n".to_string(), "m".to_string()]); let front = graph .pareto_search_by_name( "S", @@ -1079,12 +1107,12 @@ fn test_growth_asymmetric_incomparable_front_complete() { ( "S", "A", - growth_edge(vec![("n", Expr::Var("n")), ("m", Expr::Var("m"))]), + growth_edge(vec![("n", Expr::variable("n")), ("m", Expr::variable("m"))]), ), ( "S", "B", - growth_edge(vec![("n", Expr::Var("n")), ("m", Expr::Var("m"))]), + growth_edge(vec![("n", Expr::variable("n")), ("m", Expr::variable("m"))]), ), // Path A: vertices = n^2, edges = m (magnitude 2 + 1 = 3). ( @@ -1092,7 +1120,7 @@ fn test_growth_asymmetric_incomparable_front_complete() { "T", growth_edge(vec![ ("vertices", powk("n", 2.0)), - ("edges", Expr::Var("m")), + ("edges", Expr::variable("m")), ]), ), // Path B: vertices = n, edges = m^3 (magnitude 1 + 3 = 4). @@ -1100,7 +1128,7 @@ fn test_growth_asymmetric_incomparable_front_complete() { "B", "T", growth_edge(vec![ - ("vertices", Expr::Var("n")), + ("vertices", Expr::variable("n")), ("edges", powk("m", 3.0)), ]), ), @@ -1114,7 +1142,7 @@ fn test_growth_asymmetric_incomparable_front_complete() { "T", &empty, ReductionMode::Witness, - GrowthLabel::source(&["n", "m"]), + GrowthLabel::source(&["n".to_string(), "m".to_string()]), crate::rules::SearchMode::Exact, ) .value; @@ -1149,7 +1177,7 @@ fn test_growth_asymmetric_incomparable_front_complete() { #[test] fn test_growth_label_monotone_overhead_preserves_order() { // A = (n, m) dominates B = (n^2, m^2) componentwise. - let a = GrowthLabel::source(&["n", "m"]); + let a = GrowthLabel::source(&["n".to_string(), "m".to_string()]); let b = GrowthLabel::from_fields({ let mut mm = BTreeMap::new(); mm.insert("n", Growth::from_expr(&powk("n", 2.0))); @@ -1161,8 +1189,8 @@ fn test_growth_label_monotone_overhead_preserves_order() { let tv = BTreeMap::new(); // A monotone overhead in both fields. for overhead in [ - growth_edge(vec![("x", Expr::Var("n") * Expr::Var("m"))]), - growth_edge(vec![("x", powk("n", 3.0)), ("y", Expr::Var("m"))]), + growth_edge(vec![("x", Expr::variable("n") * Expr::variable("m"))]), + growth_edge(vec![("x", powk("n", 3.0)), ("y", Expr::variable("m"))]), ] { let redge = ReductionEdge { overhead: &overhead.overhead, @@ -1388,10 +1416,7 @@ fn test_pareto_search_matches_independent_small_graph_oracle() { edges.push(( NAMES[source], *target_name, - growth_edge(vec![ - ("a", Expr::Const(a as f64)), - ("b", Expr::Const(b as f64)), - ]), + growth_edge(vec![("a", Expr::integer(a)), ("b", Expr::integer(b))]), )); } } @@ -1450,12 +1475,12 @@ impl PathLabel for ContractLabel { let downstream_cost = edge .overhead .get("downstream") - .map(|expr| expr.eval(&empty)) + .map(|expression| evaluate_approximate(expression, &empty).unwrap()) .unwrap_or(self.downstream_cost); let agenda_cost = edge .overhead .get("agenda") - .map(|expr| expr.eval(&empty)) + .map(|expression| evaluate_approximate(expression, &empty).unwrap()) .unwrap_or(self.agenda_cost); Some(Self { agenda_cost, @@ -1546,8 +1571,8 @@ fn test_search_mode_exact_and_approximate_contract() { "S", "M", growth_edge(vec![ - ("agenda", Expr::Const((i + 1) as f64)), - ("downstream", Expr::Const((33 - i) as f64)), + ("agenda", Expr::integer(i + 1)), + ("downstream", Expr::integer(33 - i)), ]), ) }) @@ -1626,12 +1651,12 @@ fn test_equal_labels_keep_incomparable_continuation_state() { let graph = ReductionGraph::from_test_edges( &["S", "X", "Y", "M", "T"], &[ - ("S", "X", diamond_edge(0.0, Expr::Const(1.0))), - ("X", "M", diamond_edge(0.0, Expr::Var("s"))), - ("S", "Y", diamond_edge(0.0, Expr::Const(1.0))), - ("Y", "M", diamond_edge(0.0, Expr::Var("s"))), - ("M", "X", diamond_edge(0.0, Expr::Const(0.0))), - ("X", "T", diamond_edge(0.0, Expr::Var("s"))), + ("S", "X", diamond_edge(0.0, Expr::integer(1))), + ("X", "M", diamond_edge(0.0, Expr::variable("s"))), + ("S", "Y", diamond_edge(0.0, Expr::integer(1))), + ("Y", "M", diamond_edge(0.0, Expr::variable("s"))), + ("M", "X", diamond_edge(0.0, Expr::integer(0))), + ("X", "T", diamond_edge(0.0, Expr::variable("s"))), ], ); @@ -1657,10 +1682,10 @@ fn test_equal_intermediate_labels_are_not_coalesced() { let graph = ReductionGraph::from_test_edges( &["S", "M", "X", "T"], &[ - ("S", "M", diamond_edge(0.0, Expr::Const(1.0))), - ("S", "X", diamond_edge(0.0, Expr::Const(1.0))), - ("X", "M", diamond_edge(0.0, Expr::Var("s"))), - ("M", "T", diamond_edge(0.0, Expr::Var("s"))), + ("S", "M", diamond_edge(0.0, Expr::integer(1))), + ("S", "X", diamond_edge(0.0, Expr::integer(1))), + ("X", "M", diamond_edge(0.0, Expr::variable("s"))), + ("M", "T", diamond_edge(0.0, Expr::variable("s"))), ], ); @@ -1731,7 +1756,11 @@ impl PathLabel for ShrinkLabel { fn extend(&self, edge: &ReductionEdge) -> Option { // The edge sets a new absolute value (`v`), which may be smaller than the current. let z = ProblemSize::new(vec![]); - let v = edge.overhead.get("v").map(|e| e.eval(&z)).unwrap_or(self.v); + let v = edge + .overhead + .get("v") + .map(|expression| evaluate_approximate(expression, &z).unwrap()) + .unwrap_or(self.v); Some(ShrinkLabel { v }) } @@ -1752,11 +1781,11 @@ fn test_kernel_keeps_shrink_late_route_without_intermediate_pruning() { &["S", "A", "T"], &[ // S -> T: completes early with final value 50. - ("S", "T", growth_edge(vec![("v", Expr::Const(50.0))])), + ("S", "T", growth_edge(vec![("v", Expr::integer(50))])), // S -> A: intermediate value 100 (would trip a B&B bound of 50). - ("S", "A", growth_edge(vec![("v", Expr::Const(100.0))])), + ("S", "A", growth_edge(vec![("v", Expr::integer(100))])), // A -> T: shrinks the value to 10. - ("A", "T", growth_edge(vec![("v", Expr::Const(10.0))])), + ("A", "T", growth_edge(vec![("v", Expr::integer(10))])), ], ); @@ -1802,9 +1831,9 @@ fn test_formula_vector_keeps_incomparable_routes() { "S", "M", growth_edge(vec![ - ("c", Expr::Const(1.0)), - ("wf", Expr::Const(0.0)), - ("w", Expr::Const(10.0) * Expr::Var("w")), + ("c", Expr::integer(1)), + ("wf", Expr::integer(0)), + ("w", Expr::integer(10) * Expr::variable("w")), ]), ), // S -> P: pricier prefix (c = 3) but shrinks the source size from 10 to 1. @@ -1812,9 +1841,9 @@ fn test_formula_vector_keeps_incomparable_routes() { "S", "P", growth_edge(vec![ - ("c", Expr::Const(3.0)), - ("wf", Expr::Const(0.0)), - ("w", Expr::Var("w") / Expr::Const(10.0)), + ("c", Expr::integer(3)), + ("wf", Expr::integer(0)), + ("w", Expr::variable("w") / Expr::integer(10)), ]), ), // P -> M: cheap (c = 1), keeps the small size w = 1. @@ -1822,9 +1851,9 @@ fn test_formula_vector_keeps_incomparable_routes() { "P", "M", growth_edge(vec![ - ("c", Expr::Const(1.0)), - ("wf", Expr::Const(0.0)), - ("w", Expr::Var("w")), + ("c", Expr::integer(1)), + ("wf", Expr::integer(0)), + ("w", Expr::variable("w")), ]), ), // M -> T: cost = current w (wf = 1, c = 0); identity on size. @@ -1832,9 +1861,9 @@ fn test_formula_vector_keeps_incomparable_routes() { "M", "T", growth_edge(vec![ - ("c", Expr::Const(0.0)), - ("wf", Expr::Const(1.0)), - ("w", Expr::Var("w")), + ("c", Expr::integer(0)), + ("wf", Expr::integer(1)), + ("w", Expr::variable("w")), ]), ), ], @@ -1875,36 +1904,36 @@ fn test_formula_vector_nonmonotone_overhead_does_not_prune() { "S", "A", growth_edge(vec![ - ("n", Expr::Var("n")), - ("m", Expr::Var("m") - Expr::Const(3.0)), - ("edge_cost", Expr::Const(0.0)), + ("n", Expr::variable("n")), + ("m", Expr::variable("m") - Expr::integer(3)), + ("edge_cost", Expr::integer(0)), ]), ), ( "A", "M", growth_edge(vec![ - ("n", Expr::Var("n")), - ("m", Expr::Var("m")), - ("edge_cost", Expr::Const(0.0)), + ("n", Expr::variable("n")), + ("m", Expr::variable("m")), + ("edge_cost", Expr::integer(0)), ]), ), ( "S", "B", growth_edge(vec![ - ("n", Expr::Var("n")), - ("m", Expr::Var("m") + Expr::Const(3.0)), - ("edge_cost", Expr::Const(1.0)), + ("n", Expr::variable("n")), + ("m", Expr::variable("m") + Expr::integer(3)), + ("edge_cost", Expr::integer(1)), ]), ), ( "B", "M", growth_edge(vec![ - ("n", Expr::Var("n")), - ("m", Expr::Var("m")), - ("edge_cost", Expr::Const(0.0)), + ("n", Expr::variable("n")), + ("m", Expr::variable("m")), + ("edge_cost", Expr::integer(0)), ]), ), ( @@ -1913,10 +1942,11 @@ fn test_formula_vector_nonmonotone_overhead_does_not_prune() { growth_edge(vec![ ( "m", - Expr::Var("n") * (Expr::Var("n") - Expr::Const(1.0)) / Expr::Const(2.0) - - Expr::Var("m"), + Expr::variable("n") * (Expr::variable("n") - Expr::integer(1)) + / Expr::integer(2) + - Expr::variable("m"), ), - ("terminal", Expr::Const(1.0)), + ("terminal", Expr::integer(1)), ]), ), ], @@ -1949,12 +1979,12 @@ fn test_formula_vector_nonmonotone_overhead_does_not_prune() { #[test] fn test_growth_label_taints_absent_variable() { // The label knows only the source field `n`. - let label = GrowthLabel::source(&["n"]); + let label = GrowthLabel::source(&["n".to_string()]); // Edge output: `bounded` depends only on `n`; `leaky` references `tseitin`, which is // absent from the label (an intermediate-only construction variable). let edge = growth_edge(vec![ - ("bounded", Expr::Var("n")), - ("leaky", Expr::Var("n") * Expr::Var("tseitin")), + ("bounded", Expr::variable("n")), + ("leaky", Expr::variable("n") * Expr::variable("tseitin")), ]); let tv = BTreeMap::new(); let redge = ReductionEdge { @@ -2028,11 +2058,28 @@ struct TokenLabel { _tok: Rc, } +impl TokenLabel { + fn ctx(&self) -> ProblemSize { + ProblemSize::new(vec![ + ("c", self.c.round().max(0.0) as usize), + ("s", self.s.round().max(0.0) as usize), + ]) + } +} + impl PathLabel for TokenLabel { fn extend(&self, edge: &ReductionEdge) -> Option { - let z = ProblemSize::new(vec![]); - let c = edge.overhead.get("c").map(|e| e.eval(&z)).unwrap_or(self.c); - let s = edge.overhead.get("s").map(|e| e.eval(&z)).unwrap_or(self.s); + let ctx = self.ctx(); + let c = edge + .overhead + .get("c") + .map(|expression| evaluate_approximate(expression, &ctx).unwrap()) + .unwrap_or(self.c); + let s = edge + .overhead + .get("s") + .map(|expression| evaluate_approximate(expression, &ctx).unwrap()) + .unwrap_or(self.s); Some(TokenLabel { c, s, @@ -2066,15 +2113,15 @@ fn test_arena_frees_evicted_labels_bounds_live_memory() { "S", "M", growth_edge(vec![ - ("c", Expr::Const((i + 1) as f64)), - ("s", Expr::Const((n - i) as f64)), + ("c", Expr::integer(i + 1)), + ("s", Expr::integer(n - i)), ]), )); } edges.push(( "M", "T", - growth_edge(vec![("c", Expr::Var("c")), ("s", Expr::Var("s"))]), + growth_edge(vec![("c", Expr::variable("c")), ("s", Expr::variable("s"))]), )); let graph = ReductionGraph::from_test_edges(&["S", "M", "T"], &edges); @@ -2144,13 +2191,13 @@ fn test_exact_dfs_releases_completed_prefixes() { edges.push(( "S", "M", - growth_edge(vec![("c", Expr::Const(1.0)), ("s", Expr::Const(1.0))]), + growth_edge(vec![("c", Expr::integer(1)), ("s", Expr::integer(1))]), )); } edges.push(( "M", "T", - growth_edge(vec![("c", Expr::Var("c")), ("s", Expr::Var("s"))]), + growth_edge(vec![("c", Expr::variable("c")), ("s", Expr::variable("s"))]), )); let graph = ReductionGraph::from_test_edges(&["S", "M", "T"], &edges); let empty = BTreeMap::new(); diff --git a/src/unit_tests/rules/registry.rs b/src/unit_tests/rules/registry.rs index 3512135a2..96fdb408d 100644 --- a/src/unit_tests/rules/registry.rs +++ b/src/unit_tests/rules/registry.rs @@ -1,5 +1,5 @@ use super::*; -use crate::expr::Expr; +use crate::expr::{evaluate_approximate, Expr}; use std::path::Path; /// Dummy reduce_fn for unit tests that don't exercise runtime reduction. @@ -24,8 +24,8 @@ fn dummy_source_size_fn(_: &dyn std::any::Any) -> ProblemSize { #[test] fn test_reduction_overhead_evaluate() { let overhead = ReductionOverhead::new(vec![ - ("n", Expr::Const(3.0) * Expr::Var("m")), - ("m", Expr::pow(Expr::Var("m"), Expr::Const(2.0))), + ("n", Expr::integer(3) * Expr::variable("m")), + ("m", Expr::pow(Expr::variable("m"), Expr::integer(2))), ]); let input = ProblemSize::new(vec![("m", 4)]); @@ -48,7 +48,7 @@ fn test_reduction_entry_overhead() { target_name: "TestTarget", source_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "One")], target_variant_fn: || vec![("graph", "SimpleGraph"), ("weight", "One")], - overhead_fn: || ReductionOverhead::new(vec![("n", Expr::Const(2.0) * Expr::Var("n"))]), + overhead_fn: || ReductionOverhead::new(vec![("n", Expr::integer(2) * Expr::variable("n"))]), module_path: "test::module", reduce_fn: Some(dummy_reduce_fn), reduce_aggregate_fn: None, @@ -242,7 +242,7 @@ fn cross_check_complexity( ) { let compiled = (entry.complexity_eval_fn)(src); let parsed = crate::expr::Expr::parse(entry.complexity); - let symbolic = parsed.eval(input); + let symbolic = evaluate_approximate(&parsed, input).unwrap(); let diff = (compiled - symbolic).abs(); let tol = 1e-6 * symbolic.abs().max(1.0); From de6a708fbd6b25ac832f13ebb40ea4faa9cde86c Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 9 Aug 2026 01:26:42 +0800 Subject: [PATCH 2/9] refactor: simplify symbolic expression pipeline --- problemreductions-expr/src/lib.rs | 103 ++++-- problemreductions-macros/src/expr_codegen.rs | 268 +++++++------- problemreductions-macros/src/lib.rs | 118 +++--- src/expr.rs | 35 +- src/growth.rs | 358 ++++++++++++------- src/rules/pareto.rs | 30 +- src/unit_tests/expr.rs | 6 + src/unit_tests/growth.rs | 9 +- src/unit_tests/rules/pareto.rs | 32 +- 9 files changed, 546 insertions(+), 413 deletions(-) diff --git a/problemreductions-expr/src/lib.rs b/problemreductions-expr/src/lib.rs index 853b3e985..9f60d656e 100644 --- a/problemreductions-expr/src/lib.rs +++ b/problemreductions-expr/src/lib.rs @@ -108,6 +108,49 @@ impl Expr { } } + /// Substitute every variable, returning `None` when any replacement is missing. + pub fn substitute_complete(&self, replacements: &HashMap<&str, &Expr>) -> Option { + match self { + Self::Const(value) => Some(Self::Const(value.clone())), + Self::Var(name) => replacements + .get(name.as_ref()) + .map(|value| (*value).clone()), + Self::Add(left, right) => Some( + left.substitute_complete(replacements)? + + right.substitute_complete(replacements)?, + ), + Self::Sub(left, right) => Some( + left.substitute_complete(replacements)? + - right.substitute_complete(replacements)?, + ), + Self::Mul(left, right) => Some( + left.substitute_complete(replacements)? + * right.substitute_complete(replacements)?, + ), + Self::Div(left, right) => Some( + left.substitute_complete(replacements)? + / right.substitute_complete(replacements)?, + ), + Self::Pow(base, exponent) => Some(Self::pow( + base.substitute_complete(replacements)?, + exponent.substitute_complete(replacements)?, + )), + Self::Neg(value) => Some(-value.substitute_complete(replacements)?), + Self::Exp(value) => Some(Self::Exp(Box::new( + value.substitute_complete(replacements)?, + ))), + Self::Log(value) => Some(Self::Log(Box::new( + value.substitute_complete(replacements)?, + ))), + Self::Sqrt(value) => Some(Self::Sqrt(Box::new( + value.substitute_complete(replacements)?, + ))), + Self::Factorial(value) => Some(Self::Factorial(Box::new( + value.substitute_complete(replacements)?, + ))), + } + } + pub fn is_constant(&self) -> bool { match self { Self::Const(_) => true, @@ -131,16 +174,16 @@ impl Expr { Self::Add(left, right) | Self::Sub(left, right) | Self::Mul(left, right) => { left.is_polynomial() && right.is_polynomial() } + Self::Div(numerator, denominator) => { + numerator.is_polynomial() + && matches!(denominator.as_ref(), Self::Const(value) if !value.is_zero()) + } Self::Pow(base, exponent) => { base.is_polynomial() && matches!(exponent.as_ref(), Self::Const(value) if value.is_integer() && !value.is_negative()) } - Self::Div(_, _) - | Self::Neg(_) - | Self::Exp(_) - | Self::Log(_) - | Self::Sqrt(_) - | Self::Factorial(_) => false, + Self::Neg(value) => value.is_polynomial(), + Self::Exp(_) | Self::Log(_) | Self::Sqrt(_) | Self::Factorial(_) => false, } } @@ -437,20 +480,21 @@ fn parse_decimal(spelling: &str) -> Option { } struct Parser { - tokens: Vec, - position: usize, + tokens: std::iter::Peekable>, + end_position: usize, } impl Parser { fn new(tokens: Vec) -> Self { + let end_position = tokens.last().map_or(0, |token| token.position + 1); Self { - tokens, - position: 0, + tokens: tokens.into_iter().peekable(), + end_position, } } fn parse(mut self) -> Result { - if self.tokens.is_empty() { + if self.tokens.peek().is_none() { return Err(ParseError::new(0, "expected expression")); } let expression = self.parse_additive()?; @@ -460,19 +504,17 @@ impl Parser { Ok(expression) } - fn peek(&self) -> Option<&Token> { - self.tokens.get(self.position) + fn peek(&mut self) -> Option<&Token> { + self.tokens.peek() } fn advance(&mut self) -> Option { - let token = self.tokens.get(self.position).cloned(); - self.position += usize::from(token.is_some()); - token + self.tokens.next() } fn consume(&mut self, kind: &TokenKind) -> bool { if self.peek().is_some_and(|token| &token.kind == kind) { - self.position += 1; + self.tokens.next(); true } else { false @@ -566,10 +608,7 @@ impl Parser { } fn end_position(&self) -> usize { - self.peek().map_or_else( - || self.tokens.last().map_or(0, |token| token.position + 1), - |token| token.position, - ) + self.end_position } } @@ -600,6 +639,28 @@ mod tests { assert_eq!(expression.variables(), BTreeSet::from(["dynamic_size"])); } + #[test] + fn complete_substitution_rejects_missing_variables() { + let expression = Expr::parse("n + m"); + let n = Expr::integer(3); + let replacements = HashMap::from([("n", &n)]); + assert_eq!(expression.substitute_complete(&replacements), None); + + let m = Expr::integer(4); + let replacements = HashMap::from([("n", &n), ("m", &m)]); + assert_eq!( + expression.substitute_complete(&replacements), + Some(Expr::integer(3) + Expr::integer(4)) + ); + } + + #[test] + fn polynomial_accepts_exact_rational_coefficients() { + assert!(Expr::parse("-n / 2").is_polynomial()); + assert!(!Expr::parse("n / 0").is_polynomial()); + assert!(!Expr::parse("n / m").is_polynomial()); + } + #[test] fn exponentiation_precedes_unary_minus() { assert_eq!( diff --git a/problemreductions-macros/src/expr_codegen.rs b/problemreductions-macros/src/expr_codegen.rs index 50ce89933..147bab949 100644 --- a/problemreductions-macros/src/expr_codegen.rs +++ b/problemreductions-macros/src/expr_codegen.rs @@ -3,164 +3,155 @@ use problemreductions_expr::Expr; use proc_macro2::TokenStream; use quote::quote; -pub(crate) trait ExprCodegen { - fn to_expr_tokens(&self) -> TokenStream; - fn to_eval_tokens(&self, source: &syn::Ident) -> TokenStream; -} - -impl ExprCodegen for Expr { - fn to_expr_tokens(&self) -> TokenStream { - match self { - Expr::Const(value) => { - let numerator = value.numer().to_string(); - let denominator = value.denom().to_string(); - quote! { - crate::expr::Expr::rational( - #numerator.parse::().expect("macro-generated numerator must be valid"), - #denominator.parse::().expect("macro-generated denominator must be valid"), - ) - } - } - Expr::Var(name) => quote! { crate::expr::Expr::variable(#name) }, - Expr::Add(left, right) => { - binary_tokens(left, right, |left, right| quote! { (#left) + (#right) }) - } - Expr::Sub(left, right) => { - binary_tokens(left, right, |left, right| quote! { (#left) - (#right) }) - } - Expr::Mul(left, right) => { - binary_tokens(left, right, |left, right| quote! { (#left) * (#right) }) - } - Expr::Div(left, right) => { - binary_tokens(left, right, |left, right| quote! { (#left) / (#right) }) - } - Expr::Pow(base, exponent) => { - let base = base.to_expr_tokens(); - let exponent = exponent.to_expr_tokens(); - quote! { crate::expr::Expr::pow(#base, #exponent) } - } - Expr::Neg(value) => { - let value = value.to_expr_tokens(); - quote! { -(#value) } +pub(crate) fn expr_tokens(expression: &Expr) -> TokenStream { + match expression { + Expr::Const(value) => { + let numerator = value.numer().to_string(); + let denominator = value.denom().to_string(); + quote! { + crate::expr::Expr::rational( + #numerator.parse::().expect("macro-generated numerator must be valid"), + #denominator.parse::().expect("macro-generated denominator must be valid"), + ) } - Expr::Exp(value) => unary_tokens( - value, - |value| quote! { crate::expr::Expr::Exp(Box::new(#value)) }, - ), - Expr::Log(value) => unary_tokens( - value, - |value| quote! { crate::expr::Expr::Log(Box::new(#value)) }, - ), - Expr::Sqrt(value) => unary_tokens( - value, - |value| quote! { crate::expr::Expr::Sqrt(Box::new(#value)) }, - ), - Expr::Factorial(value) => unary_tokens( - value, - |value| quote! { crate::expr::Expr::Factorial(Box::new(#value)) }, - ), } + Expr::Var(name) => quote! { crate::expr::Expr::variable(#name) }, + Expr::Add(left, right) => { + binary_expr_tokens(left, right, |left, right| quote! { (#left) + (#right) }) + } + Expr::Sub(left, right) => { + binary_expr_tokens(left, right, |left, right| quote! { (#left) - (#right) }) + } + Expr::Mul(left, right) => { + binary_expr_tokens(left, right, |left, right| quote! { (#left) * (#right) }) + } + Expr::Div(left, right) => { + binary_expr_tokens(left, right, |left, right| quote! { (#left) / (#right) }) + } + Expr::Pow(base, exponent) => { + let base = expr_tokens(base); + let exponent = expr_tokens(exponent); + quote! { crate::expr::Expr::pow(#base, #exponent) } + } + Expr::Neg(value) => { + let value = expr_tokens(value); + quote! { -(#value) } + } + Expr::Exp(value) => unary_expr_tokens( + value, + |value| quote! { crate::expr::Expr::Exp(Box::new(#value)) }, + ), + Expr::Log(value) => unary_expr_tokens( + value, + |value| quote! { crate::expr::Expr::Log(Box::new(#value)) }, + ), + Expr::Sqrt(value) => unary_expr_tokens( + value, + |value| quote! { crate::expr::Expr::Sqrt(Box::new(#value)) }, + ), + Expr::Factorial(value) => unary_expr_tokens( + value, + |value| quote! { crate::expr::Expr::Factorial(Box::new(#value)) }, + ), } +} - fn to_eval_tokens(&self, source: &syn::Ident) -> TokenStream { - match self { - Expr::Const(value) => { - let value = value - .to_f64() - .expect("expression constant must fit the temporary f64 evaluator"); - quote! { #value } - } - Expr::Var(name) => { - let getter = syn::Ident::new(name, proc_macro2::Span::call_site()); - quote! { (#source.#getter() as f64) } - } - Expr::Add(left, right) => eval_binary_tokens( - left, - right, - source, - |left, right| quote! { (#left + #right) }, - ), - Expr::Sub(left, right) => eval_binary_tokens( - left, - right, - source, - |left, right| quote! { (#left - #right) }, - ), - Expr::Mul(left, right) => eval_binary_tokens( - left, - right, - source, - |left, right| quote! { ::std::ops::Mul::mul(#left, #right) }, - ), - Expr::Div(left, right) => eval_binary_tokens( - left, - right, - source, - |left, right| quote! { (#left / #right) }, - ), - Expr::Pow(base, exponent) => eval_binary_tokens( - base, - exponent, - source, - |base, exponent| quote! { f64::powf(#base, #exponent) }, - ), - Expr::Neg(value) => { - let value = value.to_eval_tokens(source); - quote! { -(#value) } - } - Expr::Exp(value) => { - eval_unary_tokens(value, source, |value| quote! { f64::exp(#value) }) - } - Expr::Log(value) => { - eval_unary_tokens(value, source, |value| quote! { f64::ln(#value) }) - } - Expr::Sqrt(value) => { - eval_unary_tokens(value, source, |value| quote! { f64::sqrt(#value) }) - } - Expr::Factorial(value) => { - let value = value.to_eval_tokens(source); - quote! {{ - let __n = #value; - let __rounded = __n.round(); - if (__n - __rounded).abs() < 1e-10 && __rounded >= 0.0 { - (2..=(__rounded as u64)).fold(1.0f64, |product, factor| product * factor as f64) - } else { - (2.0 * ::std::f64::consts::PI * __n).sqrt() - * (__n / ::std::f64::consts::E).powf(__n) - } - }} - } +pub(crate) fn eval_tokens(expression: &Expr, source: &syn::Ident) -> syn::Result { + Ok(match expression { + Expr::Const(value) => { + let value = value.to_f64().ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("exact expression constant {value} is outside the f64 evaluator"), + ) + })?; + quote! { #value } } - } + Expr::Var(name) => { + let getter = syn::parse_str::(name).map_err(|_| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("expression variable {name:?} is not a valid Rust getter name"), + ) + })?; + quote! { (#source.#getter() as f64) } + } + Expr::Add(left, right) => binary_eval_tokens( + left, + right, + source, + |left, right| quote! { (#left + #right) }, + )?, + Expr::Sub(left, right) => binary_eval_tokens( + left, + right, + source, + |left, right| quote! { (#left - #right) }, + )?, + Expr::Mul(left, right) => binary_eval_tokens( + left, + right, + source, + |left, right| quote! { ::std::ops::Mul::mul(#left, #right) }, + )?, + Expr::Div(left, right) => binary_eval_tokens( + left, + right, + source, + |left, right| quote! { (#left / #right) }, + )?, + Expr::Pow(base, exponent) => binary_eval_tokens( + base, + exponent, + source, + |base, exponent| quote! { f64::powf(#base, #exponent) }, + )?, + Expr::Neg(value) => { + let value = eval_tokens(value, source)?; + quote! { -(#value) } + } + Expr::Exp(value) => unary_eval_tokens(value, source, |value| quote! { f64::exp(#value) })?, + Expr::Log(value) => unary_eval_tokens(value, source, |value| quote! { f64::ln(#value) })?, + Expr::Sqrt(value) => { + unary_eval_tokens(value, source, |value| quote! { f64::sqrt(#value) })? + } + Expr::Factorial(value) => { + let value = eval_tokens(value, source)?; + quote! { crate::expr::approximate_factorial(#value) } + } + }) } -fn binary_tokens( +fn binary_expr_tokens( left: &Expr, right: &Expr, build: impl FnOnce(TokenStream, TokenStream) -> TokenStream, ) -> TokenStream { - build(left.to_expr_tokens(), right.to_expr_tokens()) + build(expr_tokens(left), expr_tokens(right)) } -fn unary_tokens(value: &Expr, build: impl FnOnce(TokenStream) -> TokenStream) -> TokenStream { - build(value.to_expr_tokens()) +fn unary_expr_tokens(value: &Expr, build: impl FnOnce(TokenStream) -> TokenStream) -> TokenStream { + build(expr_tokens(value)) } -fn eval_binary_tokens( +fn binary_eval_tokens( left: &Expr, right: &Expr, source: &syn::Ident, build: impl FnOnce(TokenStream, TokenStream) -> TokenStream, -) -> TokenStream { - build(left.to_eval_tokens(source), right.to_eval_tokens(source)) +) -> syn::Result { + Ok(build( + eval_tokens(left, source)?, + eval_tokens(right, source)?, + )) } -fn eval_unary_tokens( +fn unary_eval_tokens( value: &Expr, source: &syn::Ident, build: impl FnOnce(TokenStream) -> TokenStream, -) -> TokenStream { - build(value.to_eval_tokens(source)) +) -> syn::Result { + Ok(build(eval_tokens(value, source)?)) } #[cfg(test)] @@ -175,6 +166,13 @@ mod tests { expression.variables().into_iter().collect::>(), vec!["m", "n"] ); - assert!(!expression.to_expr_tokens().is_empty()); + assert!(!expr_tokens(&expression).is_empty()); + } + + #[test] + fn invalid_getter_name_is_reported() { + let expression = Expr::variable("type"); + let source = syn::Ident::new("source", proc_macro2::Span::call_site()); + assert!(eval_tokens(&expression, &source).is_err()); } } diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index 757729960..7402b6e67 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -7,7 +7,7 @@ mod expr_codegen; -use expr_codegen::ExprCodegen; +use expr_codegen::{eval_tokens, expr_tokens}; use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::quote; @@ -60,6 +60,11 @@ enum OverheadSpec { Parsed(Vec<(String, String)>), } +struct ParsedOverheadField { + name: String, + expression: problemreductions_expr::Expr, +} + /// Parsed attributes from #[reduction(...)] struct ReductionAttrs { overhead: Option, @@ -227,25 +232,34 @@ fn make_variant_fn_body(ty: &Type, type_generics: &HashSet) -> syn::Resu /// Generate overhead code from the new parsed syntax. /// /// Produces a `ReductionOverhead` constructor that uses `Expr` AST values. -fn generate_parsed_overhead(fields: &[(String, String)]) -> syn::Result { - let mut field_tokens = Vec::new(); - - for (field_name, expr_str) in fields { - let parsed = problemreductions_expr::Expr::try_parse(expr_str).map_err(|e| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!("error parsing overhead expression \"{expr_str}\": {e}"), - ) - })?; +fn parse_overhead_fields(fields: &[(String, String)]) -> syn::Result> { + fields + .iter() + .map(|(name, source)| { + let expression = problemreductions_expr::Expr::try_parse(source).map_err(|error| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("error parsing overhead expression \"{source}\": {error}"), + ) + })?; + Ok(ParsedOverheadField { + name: name.clone(), + expression, + }) + }) + .collect() +} - let expr_ast = parsed.to_expr_tokens(); - let name_lit = field_name.as_str(); - field_tokens.push(quote! { (#name_lit, #expr_ast) }); - } +fn generate_parsed_overhead(fields: &[ParsedOverheadField]) -> TokenStream2 { + let field_tokens = fields.iter().map(|field| { + let expression = expr_tokens(&field.expression); + let name = field.name.as_str(); + quote! { (#name, #expression) } + }); - Ok(quote! { + quote! { crate::rules::registry::ReductionOverhead::new(vec![#(#field_tokens),*]) - }) + } } /// Generate a compiled overhead evaluation function from parsed overhead fields. @@ -253,24 +267,18 @@ fn generate_parsed_overhead(fields: &[(String, String)]) -> syn::Result syn::Result { let src_ident = syn::Ident::new("__src", proc_macro2::Span::call_site()); - - let mut field_eval_tokens = Vec::new(); - for (field_name, expr_str) in fields { - let parsed = problemreductions_expr::Expr::try_parse(expr_str).map_err(|e| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!("error parsing overhead expression \"{expr_str}\": {e}"), - ) - })?; - - let eval_tokens = parsed.to_eval_tokens(&src_ident); - let name_lit = field_name.as_str(); - field_eval_tokens.push(quote! { (#name_lit, (#eval_tokens).round() as usize) }); - } + let field_eval_tokens = fields + .iter() + .map(|field| { + let expression = eval_tokens(&field.expression, &src_ident)?; + let name = field.name.as_str(); + Ok(quote! { (#name, (#expression).round() as usize) }) + }) + .collect::>>()?; Ok(quote! { |__any_src: &dyn std::any::Any| -> crate::types::ProblemSize { @@ -285,33 +293,26 @@ fn generate_overhead_eval_fn( /// Collects all variable names referenced in the overhead expressions, generates /// getter calls for each, and returns a `ProblemSize`. fn generate_source_size_fn( - fields: &[(String, String)], + fields: &[ParsedOverheadField], source_type: &Type, ) -> syn::Result { let src_ident = syn::Ident::new("__src", proc_macro2::Span::call_site()); - - // Collect all unique variable names from overhead expressions - let mut var_names = std::collections::BTreeSet::new(); - for (_, expr_str) in fields { - let parsed = problemreductions_expr::Expr::try_parse(expr_str).map_err(|e| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!("error parsing overhead expression \"{expr_str}\": {e}"), - ) - })?; - for v in parsed.variables() { - var_names.insert(v.to_string()); - } - } - - let getter_tokens: Vec<_> = var_names + let var_names: std::collections::BTreeSet<_> = fields .iter() - .map(|var| { - let getter = syn::Ident::new(var, proc_macro2::Span::call_site()); - let name_lit = var.as_str(); - quote! { (#name_lit, #src_ident.#getter() as usize) } - }) + .flat_map(|field| field.expression.variables()) .collect(); + let getter_tokens = var_names + .into_iter() + .map(|name| { + let getter = syn::parse_str::(name).map_err(|_| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("expression variable {name:?} is not a valid Rust getter name"), + ) + })?; + Ok(quote! { (#name, #src_ident.#getter() as usize) }) + }) + .collect::>>()?; Ok(quote! { |__any_src: &dyn std::any::Any| -> crate::types::ProblemSize { @@ -385,9 +386,10 @@ fn generate_reduction_entry( (tokens.clone(), eval_fn, size_fn) } Some(OverheadSpec::Parsed(fields)) => { - let overhead_tokens = generate_parsed_overhead(fields)?; - let eval_fn = generate_overhead_eval_fn(fields, source_type)?; - let size_fn = generate_source_size_fn(fields, source_type)?; + let fields = parse_overhead_fields(fields)?; + let overhead_tokens = generate_parsed_overhead(&fields); + let eval_fn = generate_overhead_eval_fn(&fields, source_type)?; + let size_fn = generate_source_size_fn(&fields, source_type)?; (overhead_tokens, eval_fn, size_fn) } None => { @@ -718,7 +720,7 @@ fn generate_complexity_eval_fn( ty: &Type, ) -> syn::Result { let src_ident = syn::Ident::new("__src", proc_macro2::Span::call_site()); - let eval_tokens = parsed.to_eval_tokens(&src_ident); + let eval_tokens = eval_tokens(parsed, &src_ident)?; Ok(quote! { |__any_src: &dyn std::any::Any| -> f64 { diff --git a/src/expr.rs b/src/expr.rs index 822adc46f..ce70b3bc0 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -47,30 +47,7 @@ pub fn evaluate_approximate( /// Approximate a wholly constant expression; return `None` for expressions with variables. pub(crate) fn constant_approximation(expression: &Expr) -> Option { - match expression { - Expr::Const(value) => rational_to_f64(value).ok(), - Expr::Var(_) => None, - Expr::Add(left, right) => { - Some(constant_approximation(left)? + constant_approximation(right)?) - } - Expr::Sub(left, right) => { - Some(constant_approximation(left)? - constant_approximation(right)?) - } - Expr::Mul(left, right) => { - Some(constant_approximation(left)? * constant_approximation(right)?) - } - Expr::Div(left, right) => { - Some(constant_approximation(left)? / constant_approximation(right)?) - } - Expr::Pow(base, exponent) => { - Some(constant_approximation(base)?.powf(constant_approximation(exponent)?)) - } - Expr::Neg(value) => Some(-constant_approximation(value)?), - Expr::Exp(value) => Some(constant_approximation(value)?.exp()), - Expr::Log(value) => Some(constant_approximation(value)?.ln()), - Expr::Sqrt(value) => Some(constant_approximation(value)?.sqrt()), - Expr::Factorial(value) => Some(approximate_factorial(constant_approximation(value)?)), - } + evaluate_approximate(expression, &ProblemSize::default()).ok() } /// Convert an approximation produced by the growth domain back to an exact AST constant. @@ -81,16 +58,20 @@ pub(crate) fn expression_from_approximation(value: f64) -> Expr { ) } -fn rational_to_f64(value: &BigRational) -> Result { +pub(crate) fn rational_to_f64(value: &BigRational) -> Result { value .to_f64() .ok_or_else(|| ApproximationError::OutOfRange(value.to_string())) } -fn approximate_factorial(value: f64) -> f64 { +pub(crate) fn approximate_factorial(value: f64) -> f64 { let rounded = value.round(); if value >= 0.0 && value == rounded { - (2..=rounded as u64).fold(1.0, |product, factor| product * factor as f64) + if rounded > 170.0 { + f64::INFINITY + } else { + (2..=rounded as u64).fold(1.0, |product, factor| product * factor as f64) + } } else { (2.0 * std::f64::consts::PI * value).sqrt() * (value / std::f64::consts::E).powf(value) } diff --git a/src/growth.rs b/src/growth.rs index 152cf90ab..726798d22 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -52,7 +52,10 @@ //! binomial cross term is introduced — and it is what makes the widening chain //! `sqrt((n − m)^2) ≍ n + m` hold exactly. -use crate::expr::{constant_approximation, expression_from_approximation, Expr}; +use crate::expr::{ + approximate_factorial, constant_approximation, expression_from_approximation, rational_to_f64, + Expr, +}; use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; @@ -108,9 +111,7 @@ impl ExpBase { /// `Expr::Exp`; arbitrary constant subtrees remain structural-only. fn directly_comparable_value(&self) -> Option { match self { - ExpBase::Constant(Expr::Const(value)) => { - constant_approximation(&Expr::Const(value.clone())) - } + ExpBase::Constant(Expr::Const(value)) => rational_to_f64(value).ok(), ExpBase::Natural => Some(std::f64::consts::E), ExpBase::Constant(_) => None, } @@ -528,37 +529,7 @@ impl GrowthTerm { impl Growth { /// Compute the growth class of an expression in a single bottom-up pass. pub fn from_expr(expr: &Expr) -> Growth { - // Any wholly constant subexpression is O(1). Handling it up front keeps - // constant idioms (`n / 2` = `n * 2^(-1)`, `factorial(3)`, `2^3`) out of - // the negative-exponent / factorial `Unknown` bails below. - if constant_approximation(expr).is_some() { - return Growth::Terms(vec![GrowthTerm::one()]); - } - match expr { - // A pure constant is O(1) — the empty term (also caught above). - Expr::Const(_) => Growth::Terms(vec![GrowthTerm::one()]), - Expr::Var(v) => { - let mut t = GrowthTerm::one(); - t.poly.insert(v.clone(), 1.0); - Growth::Terms(vec![t]) - } - Expr::Add(a, b) => add(Growth::from_expr(a), Growth::from_expr(b)), - Expr::Sub(a, b) => add(Growth::from_expr(a), Growth::from_expr(b)), - Expr::Mul(a, b) => mul(Growth::from_expr(a), Growth::from_expr(b)), - Expr::Div(a, b) => { - if constant_approximation(b).is_some() { - Growth::from_expr(a) - } else { - Growth::Unknown - } - } - Expr::Pow(base, exp) => pow_expr(base, exp), - Expr::Neg(value) => Growth::from_expr(value), - Expr::Exp(a) => exponential(ExpBase::Natural, a), - Expr::Log(a) => log_growth(Growth::from_expr(a)), - Expr::Sqrt(a) => pow_const(Growth::from_expr(a), 0.5), - Expr::Factorial(_) => Growth::Unknown, - } + analyze_expr(expr).growth } /// Partial order: `true` iff `self` grows at least as fast as `other`. @@ -628,6 +599,227 @@ impl Growth { } } +struct ExprAnalysis { + growth: Growth, + constant: Option, + linear: Option, f64>>, +} + +fn analyze_expr(expression: &Expr) -> ExprAnalysis { + match expression { + Expr::Const(value) => { + let constant = rational_to_f64(value).ok(); + ExprAnalysis { + growth: constant_growth(), + linear: constant.map(|_| BTreeMap::new()), + constant, + } + } + Expr::Var(variable) => { + let mut term = GrowthTerm::one(); + term.poly.insert(variable.clone(), 1.0); + let mut linear = BTreeMap::new(); + linear.insert(variable.clone(), 1.0); + ExprAnalysis { + growth: Growth::Terms(vec![term]), + constant: None, + linear: Some(linear), + } + } + Expr::Add(left, right) => analyze_sum(left, right, 1.0), + Expr::Sub(left, right) => analyze_sum(left, right, -1.0), + Expr::Mul(left, right) => { + let left = analyze_expr(left); + let right = analyze_expr(right); + let constant = left + .constant + .zip(right.constant) + .map(|(left, right)| left * right); + let linear = if constant.is_some() { + Some(BTreeMap::new()) + } else if let Some(coefficient) = left.constant { + scale_linear(right.linear, coefficient) + } else if let Some(coefficient) = right.constant { + scale_linear(left.linear, coefficient) + } else { + None + }; + ExprAnalysis { + growth: if constant.is_some() { + constant_growth() + } else { + mul(left.growth, right.growth) + }, + constant, + linear, + } + } + Expr::Div(numerator, denominator) => { + let numerator = analyze_expr(numerator); + let denominator = analyze_expr(denominator); + let constant = numerator + .constant + .zip(denominator.constant) + .map(|(numerator, denominator)| numerator / denominator); + let linear = if constant.is_some() { + Some(BTreeMap::new()) + } else if let Some(divisor) = denominator.constant { + scale_linear(numerator.linear, 1.0 / divisor) + } else { + None + }; + ExprAnalysis { + growth: if constant.is_some() { + constant_growth() + } else if denominator.constant.is_some() { + numerator.growth + } else { + Growth::Unknown + }, + constant, + linear, + } + } + Expr::Pow(base, exponent) => { + let base_analysis = analyze_expr(base); + let exponent_analysis = analyze_expr(exponent); + let constant = base_analysis + .constant + .zip(exponent_analysis.constant) + .map(|(base, exponent)| base.powf(exponent)); + let growth = if constant.is_some() { + constant_growth() + } else if let Some(power) = exponent_analysis.constant { + if power < 0.0 { + Growth::Unknown + } else if power == 0.0 { + constant_growth() + } else { + pow_const(base_analysis.growth, power) + } + } else if base_analysis.constant.is_some_and(f64::is_finite) { + exponential( + ExpBase::Constant(base.as_ref().clone()), + exponent_analysis.linear, + ) + } else { + Growth::Unknown + }; + ExprAnalysis { + growth, + constant, + linear: constant.map(|_| BTreeMap::new()), + } + } + Expr::Neg(value) => { + let value = analyze_expr(value); + let constant = value.constant.map(|constant| -constant); + ExprAnalysis { + growth: if constant.is_some() { + constant_growth() + } else { + value.growth + }, + constant, + linear: scale_linear(value.linear, -1.0), + } + } + Expr::Exp(value) => { + let value = analyze_expr(value); + let constant = value.constant.map(f64::exp); + ExprAnalysis { + growth: if constant.is_some() { + constant_growth() + } else { + exponential(ExpBase::Natural, value.linear) + }, + constant, + linear: constant.map(|_| BTreeMap::new()), + } + } + Expr::Log(value) => analyze_unary(value, f64::ln, log_growth), + Expr::Sqrt(value) => analyze_unary(value, f64::sqrt, |growth| pow_const(growth, 0.5)), + Expr::Factorial(value) => { + let value = analyze_expr(value); + let constant = value.constant.map(approximate_factorial); + ExprAnalysis { + growth: if constant.is_some() { + constant_growth() + } else { + Growth::Unknown + }, + constant, + linear: constant.map(|_| BTreeMap::new()), + } + } + } +} + +fn analyze_sum(left: &Expr, right: &Expr, right_sign: f64) -> ExprAnalysis { + let left = analyze_expr(left); + let right = analyze_expr(right); + let constant = left + .constant + .zip(right.constant) + .map(|(left, right)| left + right_sign * right); + ExprAnalysis { + growth: if constant.is_some() { + constant_growth() + } else { + add(left.growth, right.growth) + }, + constant, + linear: combine_linear(left.linear, right.linear, right_sign), + } +} + +fn analyze_unary( + value: &Expr, + evaluate: impl FnOnce(f64) -> f64, + transform_growth: impl FnOnce(Growth) -> Growth, +) -> ExprAnalysis { + let value = analyze_expr(value); + let constant = value.constant.map(evaluate); + ExprAnalysis { + growth: if constant.is_some() { + constant_growth() + } else { + transform_growth(value.growth) + }, + constant, + linear: constant.map(|_| BTreeMap::new()), + } +} + +fn combine_linear( + left: Option, f64>>, + right: Option, f64>>, + right_sign: f64, +) -> Option, f64>> { + let mut left = left?; + for (variable, coefficient) in right? { + *left.entry(variable).or_insert(0.0) += right_sign * coefficient; + } + left.retain(|_, coefficient| *coefficient != 0.0); + Some(left) +} + +fn scale_linear( + linear: Option, f64>>, + coefficient: f64, +) -> Option, f64>> { + Some( + linear? + .into_iter() + .map(|(variable, value)| (variable, coefficient * value)) + .collect(), + ) +} + +fn constant_growth() -> Growth { + Growth::Terms(vec![GrowthTerm::one()]) +} + /// Render one monomial as a product of its factors (or `Const(1)` when empty). fn term_to_expr(t: &GrowthTerm) -> Expr { let mut factors: Vec = Vec::new(); @@ -817,33 +1009,9 @@ fn pow_const(g: Growth, k: f64) -> Growth { } } -/// Transfer function for `Pow(base, exp)`. -fn pow_expr(base: &Expr, exp: &Expr) -> Growth { - if let Some(k) = constant_approximation(exp) { - // Constant exponent → polynomial power. - if k < 0.0 { - return Growth::Unknown; // negative exponent - } - if k == 0.0 { - return Growth::Terms(vec![GrowthTerm::one()]); // x^0 = O(1) - } - pow_const(Growth::from_expr(base), k) - } else if let Some(c) = constant_approximation(base) { - // Constant base, variable exponent → exponential. - if c.is_finite() { - exponential(ExpBase::Constant(base.clone()), exp) - } else { - Growth::Unknown - } - } else { - // Variable base and variable exponent (e.g. n^m) → not representable. - Growth::Unknown - } -} - /// Transfer function for a symbolic fixed-base exponential. The base's numeric /// value is used only for domain and monotonic-direction checks. -fn exponential(base: ExpBase, exp: &Expr) -> Growth { +fn exponential(base: ExpBase, linear: Option, f64>>) -> Growth { let c = base.value(); if !c.is_finite() || c <= 0.0 { return Growth::Unknown; @@ -852,7 +1020,7 @@ fn exponential(base: ExpBase, exp: &Expr) -> Growth { // 1^x = 1 for every x: bounded by O(1). return Growth::Terms(vec![GrowthTerm::one()]); } - match linear_form(exp) { + match linear { None => Growth::Unknown, // nonlinear exponent Some(coeffs) => { let mut term = GrowthTerm::one(); @@ -871,74 +1039,6 @@ fn exponential(base: ExpBase, exp: &Expr) -> Growth { } } -/// Extract the linear coefficients of an expression (variable → coefficient), -/// or `None` if the expression is not linear in its variables. The additive -/// constant term is ignored (dropped). Pure constants map to the empty form. -fn linear_form(expr: &Expr) -> Option, f64>> { - if constant_approximation(expr).is_some() { - return Some(BTreeMap::new()); - } - match expr { - Expr::Var(v) => { - let mut m = BTreeMap::new(); - m.insert(v.clone(), 1.0); - Some(m) - } - Expr::Add(a, b) => { - let mut m = linear_form(a)?; - for (k, v) in linear_form(b)? { - *m.entry(k).or_insert(0.0) += v; - } - Some(m) - } - Expr::Sub(a, b) => { - let mut m = linear_form(a)?; - for (k, v) in linear_form(b)? { - *m.entry(k).or_insert(0.0) -= v; - } - Some(m) - } - Expr::Mul(a, b) => { - // A linear term times a variable is nonlinear, so one side must be - // a constant scalar. - if let Some(c) = constant_approximation(a) { - Some( - linear_form(b)? - .into_iter() - .map(|(k, v)| (k, v * c)) - .collect(), - ) - } else if let Some(c) = constant_approximation(b) { - Some( - linear_form(a)? - .into_iter() - .map(|(k, v)| (k, v * c)) - .collect(), - ) - } else { - None - } - } - Expr::Div(a, b) => { - let divisor = constant_approximation(b)?; - Some( - linear_form(a)? - .into_iter() - .map(|(variable, coefficient)| (variable, coefficient / divisor)) - .collect(), - ) - } - Expr::Neg(value) => Some( - linear_form(value)? - .into_iter() - .map(|(variable, coefficient)| (variable, -coefficient)) - .collect(), - ), - // Pow / Exp / Log / Sqrt / Factorial of variables are nonlinear. - _ => None, - } -} - /// Transfer function for `Log(a)`: `log` of an antichain is `log` of its /// dominant term(s), unioned. Uses `log(n^a · m^b) ≍ log n + log m` and /// `log(2^(r·n)) ≍ n`. diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index a054f7c9d..7c7b90ace 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -284,16 +284,8 @@ impl GrowthLabel { } /// Construct directly from a field → growth map (test/introspection helper). - pub fn from_fields(fields: BTreeMap) -> Self - where - K: Into + Ord, - { - GrowthLabel { - fields: fields - .into_iter() - .map(|(field, growth)| (field.into(), growth)) - .collect(), - } + pub fn from_fields(fields: BTreeMap) -> Self { + GrowthLabel { fields } } /// The current node's size fields mapped to their growth in source variables. @@ -341,20 +333,10 @@ impl PathLabel for GrowthLabel { let mut new_fields: BTreeMap = BTreeMap::new(); for (target_field, expr) in &edge.overhead.output_size { - // Taint the target field if this overhead references any variable we cannot - // express in the source's variables: either a present-but-`Unknown` current - // field, or a variable absent from the label entirely (an intermediate-only - // field that would otherwise leak through `substitute` as a fake source - // variable). Both cases are exactly "not in `mapping`". - let taints = expr.variables().iter().any(|v| !mapping.contains_key(v)); - if taints { - new_fields.insert((*target_field).to_string(), Growth::Unknown); - continue; - } - // Substitute rendered growths into the overhead, then reduce in the growth - // domain. - let substituted = expr.substitute(&mapping); - new_fields.insert((*target_field).to_string(), Growth::from_expr(&substituted)); + let growth = expr + .substitute_complete(&mapping) + .map_or(Growth::Unknown, |expression| Growth::from_expr(&expression)); + new_fields.insert((*target_field).to_string(), growth); } // Asymptotic mode has no budget, so `extend` never prunes. Some(GrowthLabel { fields: new_fields }) diff --git a/src/unit_tests/expr.rs b/src/unit_tests/expr.rs index c296c00bb..dad957116 100644 --- a/src/unit_tests/expr.rs +++ b/src/unit_tests/expr.rs @@ -605,6 +605,12 @@ fn test_expr_factorial_eval() { assert_eq!(eval(&e, &size), 24.0); } +#[test] +fn test_expr_factorial_above_f64_range_is_infinite() { + let expression = Expr::Factorial(Box::new(Expr::integer(171))); + assert_eq!(eval(&expression, &ProblemSize::default()), f64::INFINITY); +} + #[test] fn test_expr_factorial_display() { let e = Expr::Factorial(Box::new(Expr::variable("n"))); diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index d52338ccc..b538efab1 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -555,7 +555,7 @@ fn test_growth_serde_roundtrip() { // comparison (`GrowthTerm::cmp`) at the heart of the order, so the restriction // is well-aimed, not vacuous. -use super::{exponential, log_growth, pow_const}; +use super::{analyze_expr, exponential, log_growth, pow_const}; use crate::types::ProblemSize; use std::collections::BTreeMap; @@ -868,12 +868,15 @@ fn broken_from_expr(e: &Expr) -> Growth { pow_const(broken_from_expr(base), k) } } else if constant_approximation(base).is_some() { - exponential(ExpBase::Constant(base.as_ref().clone()), exp) + exponential( + ExpBase::Constant(base.as_ref().clone()), + analyze_expr(exp).linear, + ) } else { Growth::Unknown } } - Expr::Exp(a) => exponential(ExpBase::Natural, a), + Expr::Exp(a) => exponential(ExpBase::Natural, analyze_expr(a).linear), Expr::Neg(value) => broken_from_expr(value), Expr::Log(a) => log_growth(broken_from_expr(a)), Expr::Sqrt(a) => pow_const(broken_from_expr(a), 0.5), diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index b9d8fff29..6ee6eace7 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -773,10 +773,10 @@ fn test_growth_label_propagates_unknown() { // Build a label whose field `x` is Unknown (factorial growth). let mut fields = BTreeMap::new(); fields.insert( - "x", + "x".to_string(), Growth::from_expr(&Expr::Factorial(Box::new(Expr::variable("n")))), ); - fields.insert("y", Growth::from_expr(&Expr::variable("n"))); + fields.insert("y".to_string(), Growth::from_expr(&Expr::variable("n"))); let label = GrowthLabel::from_fields(fields); assert!(matches!(label.fields().get("x"), Some(Growth::Unknown))); @@ -952,14 +952,14 @@ fn test_symbolic_all_discovered_unknown_can_still_be_search_incomplete() { fn test_growth_label_unknown_is_incomparable() { let known = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); - m.insert("a", Growth::from_expr(&powk("n", 2.0))); - m.insert("b", Growth::from_expr(&Expr::variable("m"))); + m.insert("a".to_string(), Growth::from_expr(&powk("n", 2.0))); + m.insert("b".to_string(), Growth::from_expr(&Expr::variable("m"))); m }); let with_unknown = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); - m.insert("a", Growth::from_expr(&powk("n", 2.0))); - m.insert("b", Growth::Unknown); + m.insert("a".to_string(), Growth::from_expr(&powk("n", 2.0))); + m.insert("b".to_string(), Growth::Unknown); m }); assert!(!known.final_dominates(&with_unknown)); @@ -972,14 +972,14 @@ fn test_growth_label_unknown_is_incomparable() { fn test_growth_label_terminal_dominance_partial_order() { let a = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); - m.insert("v", Growth::from_expr(&Expr::variable("n"))); // n - m.insert("e", Growth::from_expr(&Expr::variable("m"))); // m + m.insert("v".to_string(), Growth::from_expr(&Expr::variable("n"))); // n + m.insert("e".to_string(), Growth::from_expr(&Expr::variable("m"))); // m m }); let b = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); - m.insert("v", Growth::from_expr(&powk("n", 2.0))); // n^2 - m.insert("e", Growth::from_expr(&Expr::variable("m"))); // m + m.insert("v".to_string(), Growth::from_expr(&powk("n", 2.0))); // n^2 + m.insert("e".to_string(), Growth::from_expr(&Expr::variable("m"))); // m m }); // a (n, m) grows slower in v, equal in e ⇒ a dominates b; b does not dominate a. @@ -990,14 +990,14 @@ fn test_growth_label_terminal_dominance_partial_order() { // Incomparable pair: one better in v, the other better in e. let c = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); - m.insert("v", Growth::from_expr(&powk("n", 2.0))); // n^2 - m.insert("e", Growth::from_expr(&Expr::variable("m"))); // m + m.insert("v".to_string(), Growth::from_expr(&powk("n", 2.0))); // n^2 + m.insert("e".to_string(), Growth::from_expr(&Expr::variable("m"))); // m m }); let d = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); - m.insert("v", Growth::from_expr(&Expr::variable("n"))); // n - m.insert("e", Growth::from_expr(&powk("m", 2.0))); // m^2 + m.insert("v".to_string(), Growth::from_expr(&Expr::variable("n"))); // n + m.insert("e".to_string(), Growth::from_expr(&powk("m", 2.0))); // m^2 m }); assert!(!c.final_dominates(&d)); @@ -1180,8 +1180,8 @@ fn test_growth_label_monotone_overhead_preserves_order() { let a = GrowthLabel::source(&["n".to_string(), "m".to_string()]); let b = GrowthLabel::from_fields({ let mut mm = BTreeMap::new(); - mm.insert("n", Growth::from_expr(&powk("n", 2.0))); - mm.insert("m", Growth::from_expr(&powk("m", 2.0))); + mm.insert("n".to_string(), Growth::from_expr(&powk("n", 2.0))); + mm.insert("m".to_string(), Growth::from_expr(&powk("m", 2.0))); mm }); assert!(a.final_dominates(&b)); From 3428865c7b1921a28db0c031938af2aba4cda475 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 9 Aug 2026 02:15:04 +0800 Subject: [PATCH 3/9] test: add SymPy expression conformance fixture --- .../tests/fixtures/sympy_oracle.json | 666 ++++++++++++++++++ problemreductions-expr/tests/sympy_fixture.rs | 243 +++++++ scripts/generate_symbolic_expr_fixture.py | 172 +++++ scripts/pyproject.toml | 1 + scripts/uv.lock | 25 +- 5 files changed, 1106 insertions(+), 1 deletion(-) create mode 100644 problemreductions-expr/tests/fixtures/sympy_oracle.json create mode 100644 problemreductions-expr/tests/sympy_fixture.rs create mode 100644 scripts/generate_symbolic_expr_fixture.py diff --git a/problemreductions-expr/tests/fixtures/sympy_oracle.json b/problemreductions-expr/tests/fixtures/sympy_oracle.json new file mode 100644 index 000000000..7830b9b9f --- /dev/null +++ b/problemreductions-expr/tests/fixtures/sympy_oracle.json @@ -0,0 +1,666 @@ +{ + "oracle": { + "engine": "SymPy", + "version": "1.14.0", + "parse_evaluate": false, + "decimal_mode": "rationalize base-10 spelling", + "documentation": { + "parser": "https://docs.sympy.org/latest/modules/parsing.html", + "expression_core": "https://docs.sympy.org/latest/modules/core.html" + } + }, + "cases": [ + { + "name": "zero", + "source": "0", + "variables": [], + "bindings": {}, + "exact_result": "0/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "integer", + "source": "42", + "variables": [], + "bindings": {}, + "exact_result": "42/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "exact_decimal", + "source": "2.372", + "variables": [], + "bindings": {}, + "exact_result": "593/250", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "leading_decimal_point", + "source": ".125", + "variables": [], + "bindings": {}, + "exact_result": "1/8", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "arbitrary_precision_integer", + "source": "100000000000000000000000000000000000000000000000001", + "variables": [], + "bindings": {}, + "exact_result": "100000000000000000000000000000000000000000000000001/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "variable", + "source": "n", + "variables": [ + "n" + ], + "bindings": { + "n": 7 + }, + "exact_result": "7/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "negation", + "source": "-n", + "variables": [ + "n" + ], + "bindings": { + "n": 7 + }, + "exact_result": "-7/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "addition", + "source": "n + m", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 3, + "m": 4 + }, + "exact_result": "7/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "subtraction", + "source": "n - m", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 3, + "m": 7 + }, + "exact_result": "-4/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "multiplication", + "source": "n * m", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 6, + "m": 7 + }, + "exact_result": "42/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "rational_coefficient", + "source": "n / 2", + "variables": [ + "n" + ], + "bindings": { + "n": 3 + }, + "exact_result": "3/2", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "variable_divisor", + "source": "n / m", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 12, + "m": 5 + }, + "exact_result": "12/5", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "nested_divisor", + "source": "n / (m + 1)", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 10, + "m": 4 + }, + "exact_result": "2/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "exact_size_formula", + "source": "n * (n - 1) / 2 - m", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 5, + "m": 4 + }, + "exact_result": "6/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "zero_power", + "source": "n^0", + "variables": [ + "n" + ], + "bindings": { + "n": 9 + }, + "exact_result": "1/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "integer_power", + "source": "n^3", + "variables": [ + "n" + ], + "bindings": { + "n": 4 + }, + "exact_result": "64/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "negative_power", + "source": "2^-3", + "variables": [], + "bindings": {}, + "exact_result": "1/8", + "compare_polynomial": false, + "is_polynomial": true + }, + { + "name": "symbolic_exponent", + "source": "2^n", + "variables": [ + "n" + ], + "bindings": { + "n": 10 + }, + "exact_result": "1024/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "unary_precedence", + "source": "-n^2", + "variables": [ + "n" + ], + "bindings": { + "n": 3 + }, + "exact_result": "-9/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "parenthesized_negative_base", + "source": "(-n)^2", + "variables": [ + "n" + ], + "bindings": { + "n": 3 + }, + "exact_result": "9/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "fractional_power", + "source": "n^0.5", + "variables": [ + "n" + ], + "bindings": { + "n": 81 + }, + "exact_result": "9/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "square_root", + "source": "sqrt(n)", + "variables": [ + "n" + ], + "bindings": { + "n": 81 + }, + "exact_result": "9/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "pythagorean_root", + "source": "sqrt(n^2 + m^2)", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 3, + "m": 4 + }, + "exact_result": "5/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "exponential_identity", + "source": "exp(n)", + "variables": [ + "n" + ], + "bindings": { + "n": 0 + }, + "exact_result": "1/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "logarithm_identity", + "source": "log(n)", + "variables": [ + "n" + ], + "bindings": { + "n": 1 + }, + "exact_result": "0/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "factorial", + "source": "factorial(n)", + "variables": [ + "n" + ], + "bindings": { + "n": 6 + }, + "exact_result": "720/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "factorial_subexpression", + "source": "factorial(n - 1)", + "variables": [ + "n" + ], + "bindings": { + "n": 6 + }, + "exact_result": "120/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "decimal_scaling", + "source": "2.372 * n", + "variables": [ + "n" + ], + "bindings": { + "n": 1000 + }, + "exact_result": "2372/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "difference_of_squares", + "source": "(n + m) * (n - m)", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 10, + "m": 3 + }, + "exact_result": "91/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "multivariate_polynomial", + "source": "n^2 + 2 * n * m + m^2", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 3, + "m": 4 + }, + "exact_result": "49/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "nested_rational", + "source": "n / (2 * m)", + "variables": [ + "m", + "n" + ], + "bindings": { + "n": 12, + "m": 3 + }, + "exact_result": "2/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "long_decimal", + "source": "1.0000000000000000000000000000000000000001", + "variables": [], + "bindings": {}, + "exact_result": "10000000000000000000000000000000000000001/10000000000000000000000000000000000000000", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "nested_subtraction", + "source": "n - (m - k)", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 10, + "m": 7, + "k": 2 + }, + "exact_result": "5/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "left_subtraction", + "source": "(n - m) - k", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 10, + "m": 7, + "k": 2 + }, + "exact_result": "1/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "nested_division", + "source": "n / (m / k)", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 12, + "m": 6, + "k": 3 + }, + "exact_result": "6/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "left_division", + "source": "(n / m) / k", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 12, + "m": 6, + "k": 2 + }, + "exact_result": "1/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "right_associative_power", + "source": "n^(m^k)", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 2, + "m": 3, + "k": 2 + }, + "exact_result": "512/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "parenthesized_power", + "source": "(n^m)^k", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 2, + "m": 3, + "k": 2 + }, + "exact_result": "64/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "double_negation", + "source": "--n", + "variables": [ + "n" + ], + "bindings": { + "n": 7 + }, + "exact_result": "7/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "zero_factorial", + "source": "factorial(0)", + "variables": [], + "bindings": {}, + "exact_result": "1/1", + "compare_polynomial": false, + "is_polynomial": true + }, + { + "name": "zero_square_root", + "source": "sqrt(0)", + "variables": [], + "bindings": {}, + "exact_result": "0/1", + "compare_polynomial": false, + "is_polynomial": true + }, + { + "name": "constant_functions", + "source": "exp(0) + log(1) + factorial(5)", + "variables": [], + "bindings": {}, + "exact_result": "121/1", + "compare_polynomial": false, + "is_polynomial": true + }, + { + "name": "zero_product", + "source": "n * 0 + 7", + "variables": [ + "n" + ], + "bindings": { + "n": 999 + }, + "exact_result": "7/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "self_division", + "source": "n / n", + "variables": [ + "n" + ], + "bindings": { + "n": 5 + }, + "exact_result": "1/1", + "compare_polynomial": true, + "is_polynomial": false + }, + { + "name": "identity_power", + "source": "n^1", + "variables": [ + "n" + ], + "bindings": { + "n": 13 + }, + "exact_result": "13/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "decimal_integer_power", + "source": "n^2.0", + "variables": [ + "n" + ], + "bindings": { + "n": 9 + }, + "exact_result": "81/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "decimal_sum", + "source": "0.1 + 0.2", + "variables": [], + "bindings": {}, + "exact_result": "3/10", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "large_mixed_decimal", + "source": "99999999999999999999.00000000000000000001", + "variables": [], + "bindings": {}, + "exact_result": "9999999999999999999900000000000000000001/100000000000000000000", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "identifier_shapes", + "source": "n_1 + size2", + "variables": [ + "n_1", + "size2" + ], + "bindings": { + "n_1": 8, + "size2": 9 + }, + "exact_result": "17/1", + "compare_polynomial": true, + "is_polynomial": true + }, + { + "name": "mixed_precedence", + "source": "n + m * k^2", + "variables": [ + "k", + "m", + "n" + ], + "bindings": { + "n": 1, + "m": 2, + "k": 3 + }, + "exact_result": "19/1", + "compare_polynomial": true, + "is_polynomial": true + } + ] +} diff --git a/problemreductions-expr/tests/sympy_fixture.rs b/problemreductions-expr/tests/sympy_fixture.rs new file mode 100644 index 000000000..ab2fdf19e --- /dev/null +++ b/problemreductions-expr/tests/sympy_fixture.rs @@ -0,0 +1,243 @@ +use num_bigint::BigInt; +use num_rational::BigRational; +use num_traits::{One, Signed, ToPrimitive, Zero}; +use problemreductions_expr::Expr; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::str::FromStr; + +#[derive(Deserialize)] +struct Fixture { + oracle: Oracle, + cases: Vec, +} + +#[derive(Deserialize)] +struct Oracle { + engine: String, + version: String, + parse_evaluate: bool, + decimal_mode: String, +} + +#[derive(Deserialize)] +struct Case { + name: String, + source: String, + variables: Vec, + bindings: BTreeMap, + exact_result: String, + compare_polynomial: bool, + is_polynomial: bool, +} + +#[test] +fn sympy_fixture_matches_expression_semantics() { + let fixture: Fixture = + serde_json::from_str(include_str!("fixtures/sympy_oracle.json")).unwrap(); + assert_eq!(fixture.oracle.engine, "SymPy"); + assert_eq!(fixture.oracle.version, "1.14.0"); + assert!(!fixture.oracle.parse_evaluate); + assert_eq!(fixture.oracle.decimal_mode, "rationalize base-10 spelling"); + assert_eq!(fixture.cases.len(), 50); + + let mut names = std::collections::BTreeSet::new(); + let mut operators = std::collections::BTreeSet::new(); + for case in fixture.cases { + assert!( + names.insert(case.name.clone()), + "duplicate case {}", + case.name + ); + let expression = Expr::try_parse(&case.source) + .unwrap_or_else(|error| panic!("{} failed to parse: {error}", case.name)); + collect_operators(&expression, &mut operators); + let variables: Vec<_> = expression + .variables() + .into_iter() + .map(str::to_string) + .collect(); + assert_eq!(variables, case.variables, "{} variable set", case.name); + + let bindings: BTreeMap<_, _> = case + .bindings + .iter() + .map(|(name, value)| { + ( + name.as_str(), + BigRational::from_integer(BigInt::from(*value)), + ) + }) + .collect(); + let actual = evaluate_exact(&expression, &bindings) + .unwrap_or_else(|| panic!("{} left the exact fixture domain", case.name)); + assert_eq!( + actual, + parse_rational(&case.exact_result), + "{} value", + case.name + ); + + if case.compare_polynomial { + assert_eq!( + expression.is_polynomial(), + case.is_polynomial, + "{} polynomial classification", + case.name + ); + } + } + assert_eq!( + operators, + std::collections::BTreeSet::from([ + "Add", + "Const", + "Div", + "Exp", + "Factorial", + "Log", + "Mul", + "Neg", + "Pow", + "Sqrt", + "Sub", + "Var", + ]) + ); +} + +fn collect_operators(expression: &Expr, operators: &mut std::collections::BTreeSet<&'static str>) { + let operator = match expression { + Expr::Const(_) => "Const", + Expr::Var(_) => "Var", + Expr::Add(_, _) => "Add", + Expr::Sub(_, _) => "Sub", + Expr::Mul(_, _) => "Mul", + Expr::Div(_, _) => "Div", + Expr::Pow(_, _) => "Pow", + Expr::Neg(_) => "Neg", + Expr::Exp(_) => "Exp", + Expr::Log(_) => "Log", + Expr::Sqrt(_) => "Sqrt", + Expr::Factorial(_) => "Factorial", + }; + operators.insert(operator); + match expression { + Expr::Add(left, right) + | Expr::Sub(left, right) + | Expr::Mul(left, right) + | Expr::Div(left, right) + | Expr::Pow(left, right) => { + collect_operators(left, operators); + collect_operators(right, operators); + } + Expr::Neg(value) + | Expr::Exp(value) + | Expr::Log(value) + | Expr::Sqrt(value) + | Expr::Factorial(value) => collect_operators(value, operators), + Expr::Const(_) | Expr::Var(_) => {} + } +} + +fn evaluate_exact( + expression: &Expr, + bindings: &BTreeMap<&str, BigRational>, +) -> Option { + match expression { + Expr::Const(value) => Some(value.clone()), + Expr::Var(name) => bindings.get(name.as_ref()).cloned(), + Expr::Add(left, right) => { + Some(evaluate_exact(left, bindings)? + evaluate_exact(right, bindings)?) + } + Expr::Sub(left, right) => { + Some(evaluate_exact(left, bindings)? - evaluate_exact(right, bindings)?) + } + Expr::Mul(left, right) => { + Some(evaluate_exact(left, bindings)? * evaluate_exact(right, bindings)?) + } + Expr::Div(left, right) => { + let denominator = evaluate_exact(right, bindings)?; + if denominator.is_zero() { + return None; + } + Some(evaluate_exact(left, bindings)? / denominator) + } + Expr::Pow(base, exponent) => { + let base = evaluate_exact(base, bindings)?; + let exponent = evaluate_exact(exponent, bindings)?; + if exponent == BigRational::new(BigInt::one(), BigInt::from(2)) { + exact_square_root(&base) + } else if exponent.is_integer() { + rational_power(base, exponent.to_integer().to_i32()?) + } else { + None + } + } + Expr::Neg(value) => Some(-evaluate_exact(value, bindings)?), + Expr::Exp(value) => evaluate_exact(value, bindings)? + .is_zero() + .then(BigRational::one), + Expr::Log(value) => { + (evaluate_exact(value, bindings)? == BigRational::one()).then(BigRational::zero) + } + Expr::Sqrt(value) => exact_square_root(&evaluate_exact(value, bindings)?), + Expr::Factorial(value) => { + let value = evaluate_exact(value, bindings)?; + if !value.is_integer() || value.is_negative() { + return None; + } + let value = value.to_integer().to_u32()?; + Some(BigRational::from_integer( + (2..=value).fold(BigInt::one(), |product, factor| product * factor), + )) + } + } +} + +fn rational_power(base: BigRational, exponent: i32) -> Option { + let reciprocal = exponent.is_negative(); + if reciprocal && base.is_zero() { + return None; + } + let mut remaining = exponent.unsigned_abs(); + let mut factor = base; + let mut result = BigRational::one(); + while remaining > 0 { + if remaining % 2 == 1 { + result *= &factor; + } + remaining /= 2; + if remaining > 0 { + factor = &factor * &factor; + } + } + if reciprocal { + Some(result.recip()) + } else { + Some(result) + } +} + +fn exact_square_root(value: &BigRational) -> Option { + if value.is_negative() { + return None; + } + Some(BigRational::new( + perfect_square_root(value.numer())?, + perfect_square_root(value.denom())?, + )) +} + +fn perfect_square_root(value: &BigInt) -> Option { + let root = value.sqrt(); + (&root * &root == *value).then_some(root) +} + +fn parse_rational(source: &str) -> BigRational { + let (numerator, denominator) = source.split_once('/').unwrap(); + BigRational::new( + BigInt::from_str(numerator).unwrap(), + BigInt::from_str(denominator).unwrap(), + ) +} diff --git a/scripts/generate_symbolic_expr_fixture.py b/scripts/generate_symbolic_expr_fixture.py new file mode 100644 index 000000000..b4061e00b --- /dev/null +++ b/scripts/generate_symbolic_expr_fixture.py @@ -0,0 +1,172 @@ +"""Generate the symbolic-expression conformance fixture with SymPy. + +The committed fixture lets Rust tests use SymPy as an independent semantic +oracle without adding Python to the Rust build or test environment. + +Usage: + uv run --project scripts python scripts/generate_symbolic_expr_fixture.py +""" + +import json +from pathlib import Path + +import sympy +from sympy.parsing.sympy_parser import ( + convert_xor, + parse_expr, + rationalize, + standard_transformations, +) + + +OUTPUT = ( + Path(__file__).resolve().parents[1] + / "problemreductions-expr" + / "tests" + / "fixtures" + / "sympy_oracle.json" +) +TRANSFORMATIONS = standard_transformations + (convert_xor, rationalize) + + +# The final boolean selects cases where SymPy's mathematical polynomial +# predicate and this crate's deliberately syntactic predicate have the same +# contract. Every case still participates in variable and exact-value checks. +CASES = [ + ("zero", "0", {}, True), + ("integer", "42", {}, True), + ("exact_decimal", "2.372", {}, True), + ("leading_decimal_point", ".125", {}, True), + ( + "arbitrary_precision_integer", + "100000000000000000000000000000000000000000000000001", + {}, + True, + ), + ("variable", "n", {"n": 7}, True), + ("negation", "-n", {"n": 7}, True), + ("addition", "n + m", {"n": 3, "m": 4}, True), + ("subtraction", "n - m", {"n": 3, "m": 7}, True), + ("multiplication", "n * m", {"n": 6, "m": 7}, True), + ("rational_coefficient", "n / 2", {"n": 3}, True), + ("variable_divisor", "n / m", {"n": 12, "m": 5}, True), + ("nested_divisor", "n / (m + 1)", {"n": 10, "m": 4}, True), + ("exact_size_formula", "n * (n - 1) / 2 - m", {"n": 5, "m": 4}, True), + ("zero_power", "n^0", {"n": 9}, True), + ("integer_power", "n^3", {"n": 4}, True), + ("negative_power", "2^-3", {}, False), + ("symbolic_exponent", "2^n", {"n": 10}, True), + ("unary_precedence", "-n^2", {"n": 3}, True), + ("parenthesized_negative_base", "(-n)^2", {"n": 3}, True), + ("fractional_power", "n^0.5", {"n": 81}, True), + ("square_root", "sqrt(n)", {"n": 81}, True), + ("pythagorean_root", "sqrt(n^2 + m^2)", {"n": 3, "m": 4}, True), + ("exponential_identity", "exp(n)", {"n": 0}, True), + ("logarithm_identity", "log(n)", {"n": 1}, True), + ("factorial", "factorial(n)", {"n": 6}, True), + ("factorial_subexpression", "factorial(n - 1)", {"n": 6}, True), + ("decimal_scaling", "2.372 * n", {"n": 1000}, True), + ("difference_of_squares", "(n + m) * (n - m)", {"n": 10, "m": 3}, True), + ( + "multivariate_polynomial", + "n^2 + 2 * n * m + m^2", + {"n": 3, "m": 4}, + True, + ), + ("nested_rational", "n / (2 * m)", {"n": 12, "m": 3}, True), + ( + "long_decimal", + "1.0000000000000000000000000000000000000001", + {}, + True, + ), + ("nested_subtraction", "n - (m - k)", {"n": 10, "m": 7, "k": 2}, True), + ("left_subtraction", "(n - m) - k", {"n": 10, "m": 7, "k": 2}, True), + ("nested_division", "n / (m / k)", {"n": 12, "m": 6, "k": 3}, True), + ("left_division", "(n / m) / k", {"n": 12, "m": 6, "k": 2}, True), + ("right_associative_power", "n^(m^k)", {"n": 2, "m": 3, "k": 2}, True), + ("parenthesized_power", "(n^m)^k", {"n": 2, "m": 3, "k": 2}, True), + ("double_negation", "--n", {"n": 7}, True), + ("zero_factorial", "factorial(0)", {}, False), + ("zero_square_root", "sqrt(0)", {}, False), + ( + "constant_functions", + "exp(0) + log(1) + factorial(5)", + {}, + False, + ), + ("zero_product", "n * 0 + 7", {"n": 999}, True), + ("self_division", "n / n", {"n": 5}, True), + ("identity_power", "n^1", {"n": 13}, True), + ("decimal_integer_power", "n^2.0", {"n": 9}, True), + ("decimal_sum", "0.1 + 0.2", {}, True), + ( + "large_mixed_decimal", + "99999999999999999999.00000000000000000001", + {}, + True, + ), + ("identifier_shapes", "n_1 + size2", {"n_1": 8, "size2": 9}, True), + ("mixed_precedence", "n + m * k^2", {"n": 1, "m": 2, "k": 3}, True), +] + + +def parse(source: str) -> sympy.Expr: + return parse_expr(source, transformations=TRANSFORMATIONS, evaluate=False) + + +def exact_fraction(value: sympy.Expr) -> str: + value = value.doit() + if value.is_Rational is not True: + raise ValueError(f"fixture result is not exact rational: {value!r}") + numerator, denominator = value.as_numer_denom() + return f"{numerator}/{denominator}" + + +def generate_case( + name: str, + source: str, + bindings: dict[str, int], + compare_polynomial: bool, +) -> dict: + expression = parse(source) + symbols = sorted(str(symbol) for symbol in expression.free_symbols) + substitutions = {sympy.Symbol(name): value for name, value in bindings.items()} + result = expression.subs(substitutions) + polynomial = expression.is_polynomial( + *(sympy.Symbol(name) for name in symbols) + ) + return { + "name": name, + "source": source, + "variables": symbols, + "bindings": bindings, + "exact_result": exact_fraction(result), + "compare_polynomial": compare_polynomial, + "is_polynomial": polynomial is True, + } + + +def main() -> None: + if sympy.__version__ != "1.14.0": + raise RuntimeError(f"expected SymPy 1.14.0, found {sympy.__version__}") + fixture = { + "oracle": { + "engine": "SymPy", + "version": sympy.__version__, + "parse_evaluate": False, + "decimal_mode": "rationalize base-10 spelling", + "documentation": { + "parser": "https://docs.sympy.org/latest/modules/parsing.html", + "expression_core": "https://docs.sympy.org/latest/modules/core.html", + }, + }, + "cases": [generate_case(*case) for case in CASES], + } + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + OUTPUT.write_text(json.dumps(fixture, indent=2) + "\n", encoding="utf-8") + print(f"wrote {len(fixture['cases'])} cases to {OUTPUT}") + + +if __name__ == "__main__": + main() diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml index a61d0e94f..d35725258 100644 --- a/scripts/pyproject.toml +++ b/scripts/pyproject.toml @@ -6,4 +6,5 @@ requires-python = ">=3.12" dependencies = [ "numpy>=1.26,<2", "qubogen>=0.1.1", + "sympy==1.14.0", ] diff --git a/scripts/uv.lock b/scripts/uv.lock index 58b3004f7..952679aca 100644 --- a/scripts/uv.lock +++ b/scripts/uv.lock @@ -1,7 +1,16 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + [[package]] name = "networkx" version = "3.6.1" @@ -46,10 +55,24 @@ source = { virtual = "." } dependencies = [ { name = "numpy" }, { name = "qubogen" }, + { name = "sympy" }, ] [package.metadata] requires-dist = [ { name = "numpy", specifier = ">=1.26,<2" }, { name = "qubogen", specifier = ">=0.1.1" }, + { name = "sympy", specifier = "==1.14.0" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] From dedbd11b547f760e4587ebf9e926f54bd62e7ca1 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 9 Aug 2026 02:26:18 +0800 Subject: [PATCH 4/9] test: validate approximate expressions with SymPy --- .../tests/fixtures/sympy_oracle.json | 81 +++++++++++++++++++ scripts/generate_symbolic_expr_fixture.py | 50 +++++++++++- src/unit_tests/expr.rs | 50 +++++++++++- 3 files changed, 179 insertions(+), 2 deletions(-) diff --git a/problemreductions-expr/tests/fixtures/sympy_oracle.json b/problemreductions-expr/tests/fixtures/sympy_oracle.json index 7830b9b9f..78d069735 100644 --- a/problemreductions-expr/tests/fixtures/sympy_oracle.json +++ b/problemreductions-expr/tests/fixtures/sympy_oracle.json @@ -662,5 +662,86 @@ "compare_polynomial": true, "is_polynomial": true } + ], + "approximate_cases": [ + { + "name": "exp_one", + "source": "exp(1)", + "bindings": {}, + "decimal_result": "2.7182818284590452353602874713526624977572470936999595749669676277240766303535476" + }, + { + "name": "exp_fraction", + "source": "exp(n / 3)", + "bindings": { + "n": 5 + }, + "decimal_result": "5.2944900504700293668273720041970084836945003393922853071798661344646724034457350" + }, + { + "name": "log_two", + "source": "log(2)", + "bindings": {}, + "decimal_result": "0.69314718055994530941723212145817656807550013436025525412068000949339362196969472" + }, + { + "name": "log_large", + "source": "log(1000000)", + "bindings": {}, + "decimal_result": "13.815510557964274104107948728106185245606608931772637856199967405805435658064115" + }, + { + "name": "sqrt_two", + "source": "sqrt(2)", + "bindings": {}, + "decimal_result": "1.4142135623730950488016887242096980785696718753769480731766797379907324784621070" + }, + { + "name": "sqrt_large", + "source": "sqrt(1234567)", + "bindings": {}, + "decimal_result": "1111.1107055554815416396515848965904897963992832303701857506256489602822366577437" + }, + { + "name": "fractional_power", + "source": "7^2.372", + "bindings": {}, + "decimal_result": "101.05843092384223958212718059829945761475621729782192940886273940327749873565595" + }, + { + "name": "mixed_transcendental", + "source": "exp(log(n)) + sqrt(m)", + "bindings": { + "n": 13, + "m": 2 + }, + "decimal_result": "14.414213562373095048801688724209698078569671875376948073176679737990732478462107" + }, + { + "name": "complexity_formula", + "source": "2^(2.372 * n / 3)", + "bindings": { + "n": 19 + }, + "decimal_result": "33286.894651335198492304106719929283764371367866374006692959786883373657361699408" + }, + { + "name": "factorial_ten", + "source": "factorial(10)", + "bindings": {}, + "decimal_result": "3628800.0000000000000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "factorial_f64_boundary", + "source": "factorial(170)", + "bindings": {}, + "decimal_result": "7.2574156153079989673967282111292631147169916812964513765435777989005618434017062e+306" + }, + { + "name": "factorial_f64_overflow", + "source": "factorial(171)", + "bindings": {}, + "decimal_result": "1.2410180702176678234248405241031039926166055775016931853889518036119960752216918e+309" + } ] } diff --git a/scripts/generate_symbolic_expr_fixture.py b/scripts/generate_symbolic_expr_fixture.py index b4061e00b..6bc5969cb 100644 --- a/scripts/generate_symbolic_expr_fixture.py +++ b/scripts/generate_symbolic_expr_fixture.py @@ -111,6 +111,25 @@ ] +# These cases exercise the production f64 boundary. Expected values are emitted +# at 80 decimal digits so the Rust test, rather than Python's float conversion, +# performs the final rounding to f64. +APPROXIMATE_CASES = [ + ("exp_one", "exp(1)", {}), + ("exp_fraction", "exp(n / 3)", {"n": 5}), + ("log_two", "log(2)", {}), + ("log_large", "log(1000000)", {}), + ("sqrt_two", "sqrt(2)", {}), + ("sqrt_large", "sqrt(1234567)", {}), + ("fractional_power", "7^2.372", {}), + ("mixed_transcendental", "exp(log(n)) + sqrt(m)", {"n": 13, "m": 2}), + ("complexity_formula", "2^(2.372 * n / 3)", {"n": 19}), + ("factorial_ten", "factorial(10)", {}), + ("factorial_f64_boundary", "factorial(170)", {}), + ("factorial_f64_overflow", "factorial(171)", {}), +] + + def parse(source: str) -> sympy.Expr: return parse_expr(source, transformations=TRANSFORMATIONS, evaluate=False) @@ -131,6 +150,8 @@ def generate_case( ) -> dict: expression = parse(source) symbols = sorted(str(symbol) for symbol in expression.free_symbols) + if set(symbols) != set(bindings): + raise ValueError(f"{name} bindings do not match free symbols") substitutions = {sympy.Symbol(name): value for name, value in bindings.items()} result = expression.subs(substitutions) polynomial = expression.is_polynomial( @@ -147,6 +168,27 @@ def generate_case( } +def generate_approximate_case( + name: str, + source: str, + bindings: dict[str, int], +) -> dict: + expression = parse(source) + symbols = sorted(str(symbol) for symbol in expression.free_symbols) + if set(symbols) != set(bindings): + raise ValueError(f"{name} bindings do not match free symbols") + substitutions = {sympy.Symbol(name): value for name, value in bindings.items()} + result = expression.subs(substitutions).doit() + if result.is_real is not True or result.is_finite is not True: + raise ValueError(f"{name} result is not a finite real number: {result!r}") + return { + "name": name, + "source": source, + "bindings": bindings, + "decimal_result": str(sympy.N(result, 80)), + } + + def main() -> None: if sympy.__version__ != "1.14.0": raise RuntimeError(f"expected SymPy 1.14.0, found {sympy.__version__}") @@ -162,10 +204,16 @@ def main() -> None: }, }, "cases": [generate_case(*case) for case in CASES], + "approximate_cases": [ + generate_approximate_case(*case) for case in APPROXIMATE_CASES + ], } OUTPUT.parent.mkdir(parents=True, exist_ok=True) OUTPUT.write_text(json.dumps(fixture, indent=2) + "\n", encoding="utf-8") - print(f"wrote {len(fixture['cases'])} cases to {OUTPUT}") + print( + f"wrote {len(fixture['cases'])} exact and " + f"{len(fixture['approximate_cases'])} approximate cases to {OUTPUT}" + ) if __name__ == "__main__": diff --git a/src/unit_tests/expr.rs b/src/unit_tests/expr.rs index dad957116..cfe91cdfb 100644 --- a/src/unit_tests/expr.rs +++ b/src/unit_tests/expr.rs @@ -1,11 +1,59 @@ use super::*; use crate::types::ProblemSize; -use std::collections::{BTreeSet, HashMap}; +use serde::Deserialize; +use std::collections::{BTreeMap, BTreeSet, HashMap}; fn eval(expression: &Expr, size: &ProblemSize) -> f64 { evaluate_approximate(expression, size).unwrap() } +#[derive(Deserialize)] +struct SympyApproximateFixture { + approximate_cases: Vec, +} + +#[derive(Deserialize)] +struct SympyApproximateCase { + name: String, + source: String, + bindings: BTreeMap, + decimal_result: String, +} + +#[test] +fn test_approximate_evaluation_against_sympy_fixture() { + let fixture: SympyApproximateFixture = serde_json::from_str(include_str!( + "../../problemreductions-expr/tests/fixtures/sympy_oracle.json" + )) + .unwrap(); + assert_eq!(fixture.approximate_cases.len(), 12); + + for case in fixture.approximate_cases { + let expression = Expr::try_parse(&case.source) + .unwrap_or_else(|error| panic!("{} failed to parse: {error}", case.name)); + let size = ProblemSize::new( + case.bindings + .iter() + .map(|(name, value)| (name.as_str(), *value)) + .collect(), + ); + let actual = evaluate_approximate(&expression, &size) + .unwrap_or_else(|error| panic!("{} failed to evaluate: {error}", case.name)); + let expected: f64 = case.decimal_result.parse().unwrap(); + + if expected.is_infinite() { + assert_eq!(actual, expected, "{} value", case.name); + } else { + let relative_error = (actual - expected).abs() / expected.abs().max(1.0); + assert!( + relative_error <= 1e-14, + "{} value: actual={actual}, expected={expected}, relative error={relative_error}", + case.name + ); + } + } +} + #[test] fn test_expr_const_eval() { let e = Expr::integer(42); From 4a397d3c73e7e46be7056673175db0e2fca5f354 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 9 Aug 2026 02:35:20 +0800 Subject: [PATCH 5/9] test: validate growth ordering with SymPy --- .../tests/fixtures/sympy_oracle.json | 100 ++++++++++++++++++ scripts/generate_symbolic_expr_fixture.py | 57 +++++++++- src/unit_tests/growth.rs | 48 +++++++++ 3 files changed, 204 insertions(+), 1 deletion(-) diff --git a/problemreductions-expr/tests/fixtures/sympy_oracle.json b/problemreductions-expr/tests/fixtures/sympy_oracle.json index 78d069735..cc569849b 100644 --- a/problemreductions-expr/tests/fixtures/sympy_oracle.json +++ b/problemreductions-expr/tests/fixtures/sympy_oracle.json @@ -743,5 +743,105 @@ "bindings": {}, "decimal_result": "1.2410180702176678234248405241031039926166055775016931853889518036119960752216918e+309" } + ], + "growth_cases": [ + { + "name": "constant_factor", + "left": "3 * n^2", + "right": "n^2", + "ratio_limit": "3", + "relation": "equivalent" + }, + { + "name": "lower_order_sum", + "left": "n^2 + n", + "right": "n^2", + "ratio_limit": "1", + "relation": "equivalent" + }, + { + "name": "shifted_power", + "left": "(n + 1)^2", + "right": "n^2", + "ratio_limit": "1", + "relation": "equivalent" + }, + { + "name": "log_constant_power", + "left": "log(n^3)", + "right": "log(n)", + "ratio_limit": "3", + "relation": "equivalent" + }, + { + "name": "higher_polynomial_degree", + "left": "n^3", + "right": "n^2", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "polynomial_over_log", + "left": "n", + "right": "log(n)^5", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "polylog_tie_break", + "left": "n^3 * log(n)", + "right": "n^3", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "small_base_exponential", + "left": "1.001^n", + "right": "n^100", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "exponential_base", + "left": "3^n", + "right": "2^n", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "exponential_rate", + "left": "2^(2 * n)", + "right": "2^n", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "natural_exponential", + "left": "exp(n)", + "right": "n^100", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "exponential_poly_tie_break", + "left": "2^n * n", + "right": "2^n", + "ratio_limit": "oo", + "relation": "left_dominates" + }, + { + "name": "reverse_polynomial_degree", + "left": "n", + "right": "n^2", + "ratio_limit": "0", + "relation": "right_dominates" + }, + { + "name": "reverse_exponential", + "left": "n^100", + "right": "exp(n)", + "ratio_limit": "0", + "relation": "right_dominates" + } ] } diff --git a/scripts/generate_symbolic_expr_fixture.py b/scripts/generate_symbolic_expr_fixture.py index 6bc5969cb..de5981f03 100644 --- a/scripts/generate_symbolic_expr_fixture.py +++ b/scripts/generate_symbolic_expr_fixture.py @@ -130,6 +130,26 @@ ] +# Univariate, eventually positive cases where asymptotic order is decided by +# the exact limit of left / right as n tends to positive infinity. +GROWTH_CASES = [ + ("constant_factor", "3 * n^2", "n^2"), + ("lower_order_sum", "n^2 + n", "n^2"), + ("shifted_power", "(n + 1)^2", "n^2"), + ("log_constant_power", "log(n^3)", "log(n)"), + ("higher_polynomial_degree", "n^3", "n^2"), + ("polynomial_over_log", "n", "log(n)^5"), + ("polylog_tie_break", "n^3 * log(n)", "n^3"), + ("small_base_exponential", "1.001^n", "n^100"), + ("exponential_base", "3^n", "2^n"), + ("exponential_rate", "2^(2 * n)", "2^n"), + ("natural_exponential", "exp(n)", "n^100"), + ("exponential_poly_tie_break", "2^n * n", "2^n"), + ("reverse_polynomial_degree", "n", "n^2"), + ("reverse_exponential", "n^100", "exp(n)"), +] + + def parse(source: str) -> sympy.Expr: return parse_expr(source, transformations=TRANSFORMATIONS, evaluate=False) @@ -189,6 +209,39 @@ def generate_approximate_case( } +def generate_growth_case(name: str, left: str, right: str) -> dict: + variable = sympy.Symbol("n", positive=True) + local_dict = {"n": variable} + left_expression = parse_expr( + left, + local_dict=local_dict, + transformations=TRANSFORMATIONS, + evaluate=False, + ) + right_expression = parse_expr( + right, + local_dict=local_dict, + transformations=TRANSFORMATIONS, + evaluate=False, + ) + ratio_limit = sympy.limit(left_expression / right_expression, variable, sympy.oo) + if ratio_limit == 0: + relation = "right_dominates" + elif ratio_limit == sympy.oo: + relation = "left_dominates" + elif ratio_limit.is_positive is True and ratio_limit.is_finite is True: + relation = "equivalent" + else: + raise ValueError(f"{name} has unsupported ratio limit {ratio_limit!r}") + return { + "name": name, + "left": left, + "right": right, + "ratio_limit": str(ratio_limit), + "relation": relation, + } + + def main() -> None: if sympy.__version__ != "1.14.0": raise RuntimeError(f"expected SymPy 1.14.0, found {sympy.__version__}") @@ -207,12 +260,14 @@ def main() -> None: "approximate_cases": [ generate_approximate_case(*case) for case in APPROXIMATE_CASES ], + "growth_cases": [generate_growth_case(*case) for case in GROWTH_CASES], } OUTPUT.parent.mkdir(parents=True, exist_ok=True) OUTPUT.write_text(json.dumps(fixture, indent=2) + "\n", encoding="utf-8") print( f"wrote {len(fixture['cases'])} exact and " - f"{len(fixture['approximate_cases'])} approximate cases to {OUTPUT}" + f"{len(fixture['approximate_cases'])} approximate and " + f"{len(fixture['growth_cases'])} growth cases to {OUTPUT}" ) diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index b538efab1..78c7aac74 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -6,6 +6,7 @@ use super::{ use crate::expr::{ constant_approximation, evaluate_approximate, expression_from_approximation, Expr, }; +use serde::Deserialize; use std::cmp::Ordering; /// Build a term from `(exp, poly, logs)` entry lists. @@ -42,6 +43,53 @@ fn g(s: &str) -> Growth { Growth::from_expr(&Expr::parse(s)) } +#[derive(Deserialize)] +struct SympyGrowthFixture { + growth_cases: Vec, +} + +#[derive(Deserialize)] +struct SympyGrowthCase { + name: String, + left: String, + right: String, + ratio_limit: String, + relation: SympyGrowthRelation, +} + +#[derive(Deserialize)] +#[serde(rename_all = "snake_case")] +enum SympyGrowthRelation { + Equivalent, + LeftDominates, + RightDominates, +} + +#[test] +fn test_growth_relations_against_sympy_limits() { + let fixture: SympyGrowthFixture = serde_json::from_str(include_str!( + "../../problemreductions-expr/tests/fixtures/sympy_oracle.json" + )) + .unwrap(); + assert_eq!(fixture.growth_cases.len(), 14); + + for case in fixture.growth_cases { + let left = g(&case.left); + let right = g(&case.right); + let actual = (left.dominates(&right), right.dominates(&left)); + let expected = match case.relation { + SympyGrowthRelation::Equivalent => (true, true), + SympyGrowthRelation::LeftDominates => (true, false), + SympyGrowthRelation::RightDominates => (false, true), + }; + assert_eq!( + actual, expected, + "{} with SymPy ratio limit {}", + case.name, case.ratio_limit + ); + } +} + fn exp_product(factors: &[(f64, f64)]) -> ExpProduct { ExpProduct::new( factors From 009d559f1b228c2c2f40841f2f3c129b76af8e41 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 9 Aug 2026 20:09:08 +0800 Subject: [PATCH 6/9] fix: enforce symbolic expression domains --- problemreductions-expr/src/lib.rs | 154 +++++++++++++++++- .../tests/fixtures/sympy_oracle.json | 42 +++++ problemreductions-macros/src/expr_codegen.rs | 19 ++- scripts/generate_symbolic_expr_fixture.py | 18 +- src/expr.rs | 26 +-- src/growth.rs | 8 +- src/unit_tests/expr.rs | 41 +++++ src/unit_tests/growth.rs | 4 +- 8 files changed, 281 insertions(+), 31 deletions(-) diff --git a/problemreductions-expr/src/lib.rs b/problemreductions-expr/src/lib.rs index 9f60d656e..d65a52774 100644 --- a/problemreductions-expr/src/lib.rs +++ b/problemreductions-expr/src/lib.rs @@ -7,11 +7,126 @@ use std::collections::{BTreeSet, HashMap}; use std::fmt; use std::str::FromStr; +/// A validated problem-size variable name. +#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize)] +#[serde(transparent)] +pub struct Symbol(Box); + +impl Symbol { + pub fn new(name: impl Into>) -> Result { + let name = name.into(); + if is_valid_symbol(&name) { + Ok(Self(name)) + } else { + Err(InvalidSymbol(name)) + } + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for Symbol { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for Symbol { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl<'de> serde::Deserialize<'de> for Symbol { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let name = Box::::deserialize(deserializer)?; + Self::new(name).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("invalid expression variable name {0:?}")] +pub struct InvalidSymbol(Box); + +fn is_valid_symbol(name: &str) -> bool { + let mut bytes = name.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + if !(first.is_ascii_alphabetic() || first == b'_') + || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + || name == "_" + { + return false; + } + !matches!( + name, + "abstract" + | "as" + | "async" + | "await" + | "become" + | "box" + | "break" + | "const" + | "continue" + | "crate" + | "do" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "final" + | "fn" + | "for" + | "gen" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "macro" + | "match" + | "mod" + | "move" + | "mut" + | "override" + | "priv" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "try" + | "type" + | "typeof" + | "union" + | "unsafe" + | "unsized" + | "use" + | "virtual" + | "where" + | "while" + | "yield" + ) +} + /// A symbolic expression over named problem-size variables. #[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum Expr { Const(BigRational), - Var(Box), + Var(Symbol), Add(Box, Box), Sub(Box, Box), Mul(Box, Box), @@ -34,7 +149,11 @@ impl Expr { } pub fn variable(name: impl Into>) -> Self { - Self::Var(name.into()) + Self::try_variable(name).unwrap_or_else(|error| panic!("{error}")) + } + + pub fn try_variable(name: impl Into>) -> Result { + Symbol::new(name).map(Self::Var) } pub fn pow(base: Expr, exponent: Expr) -> Self { @@ -60,7 +179,7 @@ impl Expr { match self { Self::Const(_) => {} Self::Var(name) => { - variables.insert(name); + variables.insert(name.as_str()); } Self::Add(left, right) | Self::Sub(left, right) @@ -572,7 +691,8 @@ impl Parser { TokenKind::Number(value) => Ok(Expr::Const(value)), TokenKind::Ident(name) => { if !self.consume(&TokenKind::LeftParen) { - return Ok(Expr::Var(name)); + return Expr::try_variable(name) + .map_err(|error| ParseError::new(token.position, error.to_string())); } let argument = self.parse_additive()?; self.expect_right_paren()?; @@ -639,6 +759,32 @@ mod tests { assert_eq!(expression.variables(), BTreeSet::from(["dynamic_size"])); } + #[test] + fn variables_enforce_one_identifier_grammar() { + for invalid in ["", "_", "1n", "n-m", "type"] { + assert!(Expr::try_variable(invalid).is_err(), "accepted {invalid:?}"); + } + for invalid_expression in ["", "_", "1n", "type"] { + assert!( + Expr::try_parse(invalid_expression).is_err(), + "parsed {invalid_expression:?}" + ); + } + assert!(matches!(Expr::parse("n-m"), Expr::Sub(_, _))); + for valid in ["n", "_n", "n_1", "num_vertices"] { + let expression = Expr::try_variable(valid).unwrap(); + assert_eq!( + Expr::try_parse(&expression.to_string()).unwrap(), + expression + ); + } + } + + #[test] + fn deserialization_rejects_invalid_variable_names() { + assert!(serde_json::from_str::(r#"{"Var":"n-m"}"#).is_err()); + } + #[test] fn complete_substitution_rejects_missing_variables() { let expression = Expr::parse("n + m"); diff --git a/problemreductions-expr/tests/fixtures/sympy_oracle.json b/problemreductions-expr/tests/fixtures/sympy_oracle.json index cc569849b..3ce1065c0 100644 --- a/problemreductions-expr/tests/fixtures/sympy_oracle.json +++ b/problemreductions-expr/tests/fixtures/sympy_oracle.json @@ -843,5 +843,47 @@ "ratio_limit": "0", "relation": "right_dominates" } + ], + "factorial_domain_cases": [ + { + "source": "0", + "exact_argument": "0", + "accepted": true + }, + { + "source": "1", + "exact_argument": "1", + "accepted": true + }, + { + "source": "10", + "exact_argument": "10", + "accepted": true + }, + { + "source": "170", + "exact_argument": "170", + "accepted": true + }, + { + "source": "171", + "exact_argument": "171", + "accepted": true + }, + { + "source": "-1", + "exact_argument": "-1", + "accepted": false + }, + { + "source": "3.5", + "exact_argument": "7/2", + "accepted": false + }, + { + "source": "1 / 2", + "exact_argument": "1/2", + "accepted": false + } ] } diff --git a/problemreductions-macros/src/expr_codegen.rs b/problemreductions-macros/src/expr_codegen.rs index 147bab949..4a706e220 100644 --- a/problemreductions-macros/src/expr_codegen.rs +++ b/problemreductions-macros/src/expr_codegen.rs @@ -15,7 +15,10 @@ pub(crate) fn expr_tokens(expression: &Expr) -> TokenStream { ) } } - Expr::Var(name) => quote! { crate::expr::Expr::variable(#name) }, + Expr::Var(name) => { + let name = name.as_str(); + quote! { crate::expr::Expr::variable(#name) } + } Expr::Add(left, right) => { binary_expr_tokens(left, right, |left, right| quote! { (#left) + (#right) }) } @@ -68,7 +71,7 @@ pub(crate) fn eval_tokens(expression: &Expr, source: &syn::Ident) -> syn::Result quote! { #value } } Expr::Var(name) => { - let getter = syn::parse_str::(name).map_err(|_| { + let getter = syn::parse_str::(name.as_str()).map_err(|_| { syn::Error::new( proc_macro2::Span::call_site(), format!("expression variable {name:?} is not a valid Rust getter name"), @@ -117,7 +120,10 @@ pub(crate) fn eval_tokens(expression: &Expr, source: &syn::Ident) -> syn::Result } Expr::Factorial(value) => { let value = eval_tokens(value, source)?; - quote! { crate::expr::approximate_factorial(#value) } + quote! { + crate::expr::approximate_factorial(#value) + .expect("factorial argument must evaluate to a non-negative integer") + } } }) } @@ -167,12 +173,7 @@ mod tests { vec!["m", "n"] ); assert!(!expr_tokens(&expression).is_empty()); - } - - #[test] - fn invalid_getter_name_is_reported() { - let expression = Expr::variable("type"); let source = syn::Ident::new("source", proc_macro2::Span::call_site()); - assert!(eval_tokens(&expression, &source).is_err()); + assert!(!eval_tokens(&expression, &source).unwrap().is_empty()); } } diff --git a/scripts/generate_symbolic_expr_fixture.py b/scripts/generate_symbolic_expr_fixture.py index de5981f03..6c023a00a 100644 --- a/scripts/generate_symbolic_expr_fixture.py +++ b/scripts/generate_symbolic_expr_fixture.py @@ -150,6 +150,9 @@ ] +FACTORIAL_ARGUMENTS = ["0", "1", "10", "170", "171", "-1", "3.5", "1 / 2"] + + def parse(source: str) -> sympy.Expr: return parse_expr(source, transformations=TRANSFORMATIONS, evaluate=False) @@ -242,6 +245,15 @@ def generate_growth_case(name: str, left: str, right: str) -> dict: } +def generate_factorial_domain_case(source: str) -> dict: + argument = parse(source).doit() + return { + "source": source, + "exact_argument": str(argument), + "accepted": argument.is_integer is True and argument.is_nonnegative is True, + } + + def main() -> None: if sympy.__version__ != "1.14.0": raise RuntimeError(f"expected SymPy 1.14.0, found {sympy.__version__}") @@ -261,13 +273,17 @@ def main() -> None: generate_approximate_case(*case) for case in APPROXIMATE_CASES ], "growth_cases": [generate_growth_case(*case) for case in GROWTH_CASES], + "factorial_domain_cases": [ + generate_factorial_domain_case(source) for source in FACTORIAL_ARGUMENTS + ], } OUTPUT.parent.mkdir(parents=True, exist_ok=True) OUTPUT.write_text(json.dumps(fixture, indent=2) + "\n", encoding="utf-8") print( f"wrote {len(fixture['cases'])} exact and " f"{len(fixture['approximate_cases'])} approximate and " - f"{len(fixture['growth_cases'])} growth cases to {OUTPUT}" + f"{len(fixture['growth_cases'])} growth cases plus " + f"{len(fixture['factorial_domain_cases'])} factorial domain cases to {OUTPUT}" ) diff --git a/src/expr.rs b/src/expr.rs index ce70b3bc0..53657f8aa 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -16,7 +16,7 @@ pub fn evaluate_approximate( match expression { Expr::Const(value) => rational_to_f64(value), Expr::Var(name) => variables - .get(name) + .get(name.as_str()) .map(|value| value as f64) .ok_or_else(|| ApproximationError::MissingVariable(name.to_string())), Expr::Add(left, right) => { @@ -39,9 +39,7 @@ pub fn evaluate_approximate( Expr::Exp(value) => Ok(evaluate_approximate(value, variables)?.exp()), Expr::Log(value) => Ok(evaluate_approximate(value, variables)?.ln()), Expr::Sqrt(value) => Ok(evaluate_approximate(value, variables)?.sqrt()), - Expr::Factorial(value) => Ok(approximate_factorial(evaluate_approximate( - value, variables, - )?)), + Expr::Factorial(value) => approximate_factorial(evaluate_approximate(value, variables)?), } } @@ -64,16 +62,16 @@ pub(crate) fn rational_to_f64(value: &BigRational) -> Result f64 { - let rounded = value.round(); - if value >= 0.0 && value == rounded { - if rounded > 170.0 { - f64::INFINITY - } else { - (2..=rounded as u64).fold(1.0, |product, factor| product * factor as f64) - } +pub(crate) fn approximate_factorial(value: f64) -> Result { + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 { + return Err(ApproximationError::InvalidFactorialArgument( + value.to_string(), + )); + } + if value > 170.0 { + Ok(f64::INFINITY) } else { - (2.0 * std::f64::consts::PI * value).sqrt() * (value / std::f64::consts::E).powf(value) + Ok((2..=value as u64).fold(1.0, |product, factor| product * factor as f64)) } } @@ -83,6 +81,8 @@ pub enum ApproximationError { MissingVariable(String), #[error("exact constant {0} is outside the f64 approximation domain")] OutOfRange(String), + #[error("factorial argument must be a non-negative integer, found {0}")] + InvalidFactorialArgument(String), } /// Error returned when analyzing asymptotic behavior. diff --git a/src/growth.rs b/src/growth.rs index 726798d22..7419abe41 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -617,9 +617,9 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { } Expr::Var(variable) => { let mut term = GrowthTerm::one(); - term.poly.insert(variable.clone(), 1.0); + term.poly.insert(variable.as_str().into(), 1.0); let mut linear = BTreeMap::new(); - linear.insert(variable.clone(), 1.0); + linear.insert(variable.as_str().into(), 1.0); ExprAnalysis { growth: Growth::Terms(vec![term]), constant: None, @@ -741,7 +741,9 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { Expr::Sqrt(value) => analyze_unary(value, f64::sqrt, |growth| pow_const(growth, 0.5)), Expr::Factorial(value) => { let value = analyze_expr(value); - let constant = value.constant.map(approximate_factorial); + let constant = value + .constant + .and_then(|constant| approximate_factorial(constant).ok()); ExprAnalysis { growth: if constant.is_some() { constant_growth() diff --git a/src/unit_tests/expr.rs b/src/unit_tests/expr.rs index cfe91cdfb..b2d193cf6 100644 --- a/src/unit_tests/expr.rs +++ b/src/unit_tests/expr.rs @@ -10,6 +10,7 @@ fn eval(expression: &Expr, size: &ProblemSize) -> f64 { #[derive(Deserialize)] struct SympyApproximateFixture { approximate_cases: Vec, + factorial_domain_cases: Vec, } #[derive(Deserialize)] @@ -20,6 +21,13 @@ struct SympyApproximateCase { decimal_result: String, } +#[derive(Deserialize)] +struct SympyFactorialDomainCase { + source: String, + exact_argument: String, + accepted: bool, +} + #[test] fn test_approximate_evaluation_against_sympy_fixture() { let fixture: SympyApproximateFixture = serde_json::from_str(include_str!( @@ -54,6 +62,27 @@ fn test_approximate_evaluation_against_sympy_fixture() { } } +#[test] +fn test_factorial_domain_against_sympy_fixture() { + let fixture: SympyApproximateFixture = serde_json::from_str(include_str!( + "../../problemreductions-expr/tests/fixtures/sympy_oracle.json" + )) + .unwrap(); + assert_eq!(fixture.factorial_domain_cases.len(), 8); + + for case in fixture.factorial_domain_cases { + let expression = Expr::try_parse(&format!("factorial({})", case.source)).unwrap(); + let result = evaluate_approximate(&expression, &ProblemSize::default()); + assert_eq!( + result.is_ok(), + case.accepted, + "factorial argument {} ({})", + case.source, + case.exact_argument + ); + } +} + #[test] fn test_expr_const_eval() { let e = Expr::integer(42); @@ -659,6 +688,18 @@ fn test_expr_factorial_above_f64_range_is_infinite() { assert_eq!(eval(&expression, &ProblemSize::default()), f64::INFINITY); } +#[test] +fn test_expr_factorial_rejects_non_integer_and_negative_arguments() { + for (source, argument) in [("factorial(3.5)", "3.5"), ("factorial(-1)", "-1")] { + assert_eq!( + evaluate_approximate(&Expr::parse(source), &ProblemSize::default()), + Err(ApproximationError::InvalidFactorialArgument( + argument.to_string() + )) + ); + } +} + #[test] fn test_expr_factorial_display() { let e = Expr::Factorial(Box::new(Expr::variable("n"))); diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index 78c7aac74..3ac8fd712 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -320,6 +320,8 @@ fn test_growth_determinism() { fn test_growth_unknown_negative_control() { assert_eq!(g("2^(n*k)"), Growth::Unknown); assert_eq!(g("factorial(n)"), Growth::Unknown); + assert_eq!(g("factorial(3.5)"), Growth::Unknown); + assert_eq!(g("factorial(-1)"), Growth::Unknown); // Absorption through the real `from_expr` add/mul paths. assert_eq!(g("factorial(n) + n^2"), Growth::Unknown); @@ -892,7 +894,7 @@ fn broken_from_expr(e: &Expr) -> Growth { Expr::Const(_) => Growth::Terms(vec![GrowthTerm::one()]), Expr::Var(v) => { let mut t = GrowthTerm::one(); - t.poly.insert(v.clone(), 1.0); + t.poly.insert(v.as_str().into(), 1.0); Growth::Terms(vec![t]) } // The seeded bug: drop the second summand. From 750591c201936c32652bc397a3aa7b17ee418e0b Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sun, 9 Aug 2026 21:08:36 +0800 Subject: [PATCH 7/9] fix: make symbolic analysis failures explicit --- problemreductions-cli/src/commands/graph.rs | 47 +-- problemreductions-cli/src/mcp/tools.rs | 9 +- src/big_o.rs | 18 +- src/expr.rs | 10 +- src/growth.rs | 427 ++++++++++++-------- src/rules/analysis.rs | 2 +- src/rules/pareto.rs | 60 ++- src/unit_tests/big_o.rs | 6 +- src/unit_tests/growth.rs | 171 ++++---- src/unit_tests/rules/pareto.rs | 26 +- 10 files changed, 464 insertions(+), 312 deletions(-) diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 74f586ad9..e71e59271 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -541,10 +541,9 @@ fn format_front_text( )); for excluded in &result.excluded { text.push_str(&format!( - " Excluded {}: {} ({})\n", + " Excluded {}: {}\n", path_arrow_summary(graph, &excluded.path), - excluded.failure.reason, - excluded.failure.fields.join(", ") + excluded.failure, )); } text @@ -643,10 +642,9 @@ fn path_front( .iter() .map(|item| { format!( - "{}: {} ({})", + "{}: {}", path_arrow_summary(graph, &item.path), - item.failure.reason, - item.failure.fields.join(", ") + item.failure, ) }) .collect::>() @@ -1012,7 +1010,7 @@ mod tests { } } -/// Regression and budget tests for bounded `pred path --all` overhead rendering. +/// Regression tests for `pred path --all` overhead rendering. /// All tests run **in-process** against the CLI's own private rendering helpers — /// no `pred` binary is spawned. /// @@ -1020,20 +1018,14 @@ mod tests { /// multivariate polynomial normal forms (an antichain of pairwise-incomparable /// monomials). These are the correct, tight Big-O answers, not raw fallbacks — a /// degree-8 trivariate form like `O(a^8 + a^6 b^2 + … + c^8)` legitimately runs -/// several hundred chars. The guarantee is *structural boundedness*: the -/// antichain is capped at `growth::ANTICHAIN_CAP = 32` terms and computed -/// bottom-up in linear time. +/// several hundred chars. Antichains are retained exactly; there is no hidden +/// term cap or componentwise widening. #[cfg(test)] mod path_overhead_rendering_tests { use super::big_o_of; use problemreductions::big_o_normal_form; use problemreductions::rules::{ReductionGraph, ReductionPath}; - /// Structural upper bound on a single rendered `O(...)` field: an antichain of - /// at most 32 terms (`ANTICHAIN_CAP`) over a handful of variables, each term a - /// short monomial and independent of path length. - const RENDER_LEN_BOUND: usize = 2000; - /// A deeply composed path as a node-name chain (KSat → QUBO through /// QuadraticAssignment/ILP). Used to reconstruct the path from the live graph /// by name so the tests track inventory changes rather than hard-coding the @@ -1091,13 +1083,6 @@ mod path_overhead_rendering_tests { !rendered.contains("O(?)"), "field {field} rendered as unbounded O(?): expr = {expr}" ); - // Structurally bounded — no raw-expression explosion. - assert!( - rendered.len() < RENDER_LEN_BOUND, - "field {field} rendered {} chars (>= {RENDER_LEN_BOUND}); \ - raw fallback may have returned: {rendered}", - rendered.len() - ); // The rendered normal form is never *longer* than the raw composed // expression: proof that normalization (not passthrough) happened. let raw_len = expr.to_string().len(); @@ -1121,12 +1106,11 @@ mod path_overhead_rendering_tests { } /// Whole-graph budget: rendering Big-O for **every** path of representative - /// hot pairs must finish well within the CI budget and never produce an - /// unbounded-length string. This is the "can't OOM/hang again" guard: it walks - /// the *complete* path set (`find_all_paths`), so no enumeration cap can hide a - /// runaway rendering. + /// hot pairs must finish within the CI budget and every result must either + /// normalize or expose a concrete analysis error. It walks the *complete* + /// path set (`find_all_paths`), so no enumeration cap can hide work. #[test] - fn all_path_overhead_rendering_stays_bounded() { + fn all_path_overhead_rendering_finishes() { let graph = ReductionGraph::new(); let start = std::time::Instant::now(); for (src, dst) in [("KSat", "QUBO"), ("MIS", "QUBO")] { @@ -1145,12 +1129,9 @@ mod path_overhead_rendering_tests { let overall = graph.compose_path_overhead(path); for oh in per_step.iter().chain(std::iter::once(&overall)) { for (field, expr) in &oh.output_size { - let rendered = big_o_of(expr); - assert!( - rendered.len() < RENDER_LEN_BOUND, - "{src}->{dst} field {field} rendered {} chars (>= {RENDER_LEN_BOUND})", - rendered.len() - ); + big_o_normal_form(expr).unwrap_or_else(|error| { + panic!("{src}->{dst} field {field} failed analysis: {error}") + }); } } } diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index ae2ca6646..0c2153db1 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -320,14 +320,7 @@ impl McpServer { let details = error .excluded .iter() - .map(|item| { - format!( - "{}: {} ({})", - item.path, - item.failure.reason, - item.failure.fields.join(", ") - ) - }) + .map(|item| format!("{}: {}", item.path, item.failure,)) .collect::>() .join("\n"); anyhow::bail!( diff --git a/src/big_o.rs b/src/big_o.rs index 7941ae026..d88959319 100644 --- a/src/big_o.rs +++ b/src/big_o.rs @@ -1,7 +1,7 @@ //! Big-O asymptotic normal form. //! //! Thin wrapper over the [growth domain](crate::growth): compute the growth -//! class of an expression bottom-up (linear cost, no monomial expansion) and +//! class of an expression bottom-up (without fully distributing the source AST) and //! render it back to a display [`Expr`]. Content the growth domain cannot bound //! symbolically ([`Growth::Unknown`] — nonlinear exponents, factorials, negative //! exponents) maps to the [`AsymptoticAnalysisError::Unsupported`] error. @@ -15,9 +15,19 @@ use crate::growth::Growth; /// [`AsymptoticAnalysisError::Unsupported`] when the growth domain widens the /// input to [`Growth::Unknown`]. pub fn big_o_normal_form(expr: &Expr) -> Result { - Growth::from_expr(expr) - .to_expr() - .ok_or_else(|| AsymptoticAnalysisError::Unsupported(expr.to_string())) + let growth = Growth::from_expr(expr); + match growth.to_expr() { + Some(expression) => Ok(expression), + None => Err(AsymptoticAnalysisError::Unsupported( + growth + .failures() + .expect("growth without an expression must contain failure reasons") + .iter() + .map(ToString::to_string) + .collect::>() + .join("; "), + )), + } } #[cfg(test)] diff --git a/src/expr.rs b/src/expr.rs index 53657f8aa..7f44716bf 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -43,9 +43,13 @@ pub fn evaluate_approximate( } } -/// Approximate a wholly constant expression; return `None` for expressions with variables. -pub(crate) fn constant_approximation(expression: &Expr) -> Option { - evaluate_approximate(expression, &ProblemSize::default()).ok() +/// Approximate a wholly constant expression without conflating variables with errors. +pub(crate) fn constant_approximation(expression: &Expr) -> Result, ApproximationError> { + if expression.is_constant() { + evaluate_approximate(expression, &ProblemSize::default()).map(Some) + } else { + Ok(None) + } } /// Convert an approximation produced by the growth domain back to an exact AST constant. diff --git a/src/growth.rs b/src/growth.rs index 7419abe41..72ee119f3 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -3,8 +3,10 @@ //! //! Where [`crate::canonical`] answers Big-O questions by fully expanding an //! [`Expr`] to monomial normal form, with exponential cost in nesting depth, the -//! growth domain computes an asymptotic upper bound *bottom-up* in a single pass, -//! linear in the tree size, without ever expanding nested sums. +//! growth domain computes an asymptotic upper bound bottom-up without rewriting +//! the source AST into a fully distributed polynomial. Work is output-sensitive: +//! exact antichains are never truncated, so genuinely large Pareto fronts remain +//! large and visible to the caller. //! //! # Representation //! @@ -16,8 +18,8 @@ //! ``` //! //! and a [`Growth`] is an *antichain* of pairwise-incomparable dominant terms -//! (each summand of an asymptotic sum), or the absorbing [`Growth::Unknown`] -//! sentinel for content we cannot bound symbolically. +//! (each summand of an asymptotic sum), or [`Growth::Unknown`] with explicit +//! reasons for content we cannot bound symbolically. //! //! # Semantic foundation (the trust contract) //! @@ -38,7 +40,7 @@ //! authoritative: it is never normalized through a floating-point logarithm //! and never reconstructed by rounding. Nonlinear exponents (`2^(n·k)`, //! `2^sqrt(n)`), `factorial(·)`, and negative polynomial exponents widen to -//! [`Growth::Unknown`], which absorbs through every operation. +//! [`Growth::Unknown`], which preserves its reasons through every operation. //! - The explicit approximation boundary treats [`Expr::Log`] as the natural //! logarithm, but all fixed //! logarithm bases greater than one have the same asymptotic class and are @@ -59,11 +61,6 @@ use crate::expr::{ use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet}; -/// Maximum number of terms kept in an antichain. On overflow the antichain is -/// widened to a proven componentwise upper bound when one is representable; -/// otherwise it becomes [`Growth::Unknown`]. It is never truncated by order. -const ANTICHAIN_CAP: usize = 32; - /// A base retained exactly as it appeared in the input expression. #[derive(Clone, Debug, PartialEq, serde::Serialize)] enum ExpBase { @@ -86,15 +83,13 @@ impl<'de> serde::Deserialize<'de> for ExpBase { match Repr::deserialize(deserializer)? { Repr::Natural => Ok(ExpBase::Natural), - Repr::Constant(base) => { - if constant_approximation(&base).is_some_and(f64::is_finite) { - Ok(ExpBase::Constant(base)) - } else { - Err(serde::de::Error::custom( - "symbolic exponential base must be a finite constant", - )) - } - } + Repr::Constant(base) => match constant_approximation(&base) { + Ok(Some(value)) if value.is_finite() => Ok(ExpBase::Constant(base)), + Ok(_) => Err(serde::de::Error::custom( + "symbolic exponential base must be a finite constant", + )), + Err(error) => Err(serde::de::Error::custom(error)), + }, } } } @@ -111,7 +106,10 @@ impl ExpBase { /// `Expr::Exp`; arbitrary constant subtrees remain structural-only. fn directly_comparable_value(&self) -> Option { match self { - ExpBase::Constant(Expr::Const(value)) => rational_to_f64(value).ok(), + ExpBase::Constant(Expr::Const(value)) => Some( + rational_to_f64(value) + .expect("direct exponential constants are validated when constructed"), + ), ExpBase::Natural => Some(std::f64::consts::E), ExpBase::Constant(_) => None, } @@ -119,9 +117,9 @@ impl ExpBase { fn value(&self) -> f64 { match self { - ExpBase::Constant(base) => { - constant_approximation(base).expect("ExpBase::Constant must remain constant") - } + ExpBase::Constant(base) => constant_approximation(base) + .expect("ExpBase::Constant must remain evaluable") + .expect("ExpBase::Constant must remain constant"), ExpBase::Natural => std::f64::consts::E, } } @@ -320,9 +318,8 @@ impl ExpProduct { } } - /// Approximate common-base rate used only to order search work. It is not - /// stored and never participates in equality, dominance, pruning, widening, - /// serialization, or rendering. + /// Test-only independent approximation of the retained exponential rate. + #[cfg(test)] fn log2_estimate(&self) -> f64 { self.factors .iter() @@ -358,9 +355,52 @@ pub enum Growth { /// Antichain of pairwise-incomparable dominant terms, sorted by a /// deterministic total order for platform-stable output/serialization. Terms(Vec), - /// Absorbing sentinel: exp/factorial/negative exponents, or cap overflow - /// that even widening cannot represent. Absorbs through all operations. - Unknown, + /// Content outside the represented growth domain, with every reason that + /// contributed to the result. + Unknown(Vec), +} + +/// A precise reason why an expression has no represented [`Growth`] value. +#[derive( + Clone, + Debug, + PartialEq, + Eq, + PartialOrd, + Ord, + serde::Serialize, + serde::Deserialize, + thiserror::Error, +)] +pub enum GrowthFailure { + #[error("cannot approximate constant {expression}: {error}")] + Approximation { expression: String, error: String }, + #[error("variable denominator is unsupported: {0}")] + VariableDenominator(String), + #[error("negative exponent is unsupported: {0}")] + NegativeExponent(String), + #[error("nonlinear exponent is unsupported: {0}")] + NonlinearExponent(String), + #[error("variable base and exponent are unsupported: {0}")] + VariableBaseAndExponent(String), + #[error("factorial of a nonconstant expression is unsupported: {0}")] + FactorialOfNonconstant(String), + #[error("invalid exponential base: {0}")] + InvalidExponentialBase(String), + #[error("non-finite linear coefficient for {0}")] + NonFiniteLinearCoefficient(String), + #[error( + "exponential factor {base}^({coefficient} * {variable}) decreases as {variable} grows" + )] + DecayingExponential { + base: String, + variable: String, + coefficient: String, + }, + #[error("growth construction produced an invalid term")] + InvalidGrowthTerm, + #[error("missing substitution for {0}")] + MissingSubstitution(String), } impl GrowthTerm { @@ -513,20 +553,20 @@ impl GrowthTerm { Some(Ordering::Greater) | Some(Ordering::Equal) ) } - - /// A monotone scalar summary of this monomial's growth rate. Exponential rate - /// dominates polynomial degree, which dominates log power. Bigger ⇒ grows - /// faster. Used only as a search-ordering / branch-and-bound heuristic, never - /// for asymptotic dominance decisions (those go through [`GrowthTerm::cmp`]). - fn magnitude(&self) -> f64 { - let e: f64 = self.exp.values().map(ExpProduct::log2_estimate).sum(); - let p: f64 = self.poly.values().sum(); - let l: f64 = self.logs.values().map(|&x| x as f64).sum(); - 1e6 * e + p + 1e-3 * l - } } impl Growth { + pub(crate) fn unknown(failure: GrowthFailure) -> Self { + Self::Unknown(vec![failure]) + } + + pub fn failures(&self) -> Option<&[GrowthFailure]> { + match self { + Self::Terms(_) => None, + Self::Unknown(failures) => Some(failures), + } + } + /// Compute the growth class of an expression in a single bottom-up pass. pub fn from_expr(expr: &Expr) -> Growth { analyze_expr(expr).growth @@ -541,28 +581,14 @@ impl Growth { /// some term of `self` — the standard antichain (Pareto) comparison. pub fn dominates(&self, other: &Growth) -> bool { match (self, other) { - (Growth::Unknown, _) => true, - (Growth::Terms(_), Growth::Unknown) => false, + (Growth::Unknown(_), _) => true, + (Growth::Terms(_), Growth::Unknown(_)) => false, (Growth::Terms(a), Growth::Terms(b)) => { b.iter().all(|tb| a.iter().any(|ta| ta.dominates_or_eq(tb))) } } } - /// A deterministic, monotone scalar summary of this growth class (the maximum - /// over its antichain terms). Exponential rate ≫ polynomial degree ≫ log - /// power; [`Growth::Unknown`] maps to a very large finite value so undecidable - /// growth sorts last. This is a *search-ordering* heuristic only (frontier - /// order, branch-and-bound bound); asymptotic dominance is decided exactly by - /// [`Growth::dominates`], never by this scalar. - pub fn magnitude(&self) -> f64 { - match self { - // Large but finite (and well below f64::MAX so sums stay finite). - Growth::Unknown => 1e18, - Growth::Terms(terms) => terms.iter().map(GrowthTerm::magnitude).fold(0.0, f64::max), - } - } - /// Render this growth class back to a display [`Expr`] (a sum of monomials), /// or `None` for [`Growth::Unknown`]. Terms are already in the deterministic /// sort order, so the rendered expression is platform-stable. @@ -571,7 +597,7 @@ impl Growth { /// symbolic bases and coefficients; no base reconstruction is performed. pub fn to_expr(&self) -> Option { match self { - Growth::Unknown => None, + Growth::Unknown(_) => None, Growth::Terms(terms) => { if terms.is_empty() { return Some(Expr::integer(1)); @@ -607,14 +633,14 @@ struct ExprAnalysis { fn analyze_expr(expression: &Expr) -> ExprAnalysis { match expression { - Expr::Const(value) => { - let constant = rational_to_f64(value).ok(); - ExprAnalysis { + Expr::Const(value) => match rational_to_f64(value) { + Ok(constant) => ExprAnalysis { growth: constant_growth(), - linear: constant.map(|_| BTreeMap::new()), - constant, - } - } + linear: Some(BTreeMap::new()), + constant: Some(constant), + }, + Err(error) => failed_analysis(expression, error.to_string()), + }, Expr::Var(variable) => { let mut term = GrowthTerm::one(); term.poly.insert(variable.as_str().into(), 1.0); @@ -657,24 +683,40 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { Expr::Div(numerator, denominator) => { let numerator = analyze_expr(numerator); let denominator = analyze_expr(denominator); - let constant = numerator - .constant - .zip(denominator.constant) - .map(|(numerator, denominator)| numerator / denominator); + let constant = match numerator.constant.zip(denominator.constant) { + Some((_, 0.0)) => None, + Some((numerator, denominator)) => Some(numerator / denominator), + None => None, + }; let linear = if constant.is_some() { Some(BTreeMap::new()) } else if let Some(divisor) = denominator.constant { - scale_linear(numerator.linear, 1.0 / divisor) + if divisor == 0.0 { + None + } else { + scale_linear(numerator.linear, 1.0 / divisor) + } } else { None }; ExprAnalysis { growth: if constant.is_some() { constant_growth() + } else if denominator.constant == Some(0.0) { + unknown(GrowthFailure::Approximation { + expression: expression.to_string(), + error: "division by zero".to_string(), + }) + } else if matches!(&numerator.growth, Growth::Unknown(_)) + || matches!(&denominator.growth, Growth::Unknown(_)) + { + merge_unknown(numerator.growth, denominator.growth) } else if denominator.constant.is_some() { numerator.growth } else { - Growth::Unknown + unknown(GrowthFailure::VariableDenominator(denominator_expression( + expression, + ))) }, constant, linear, @@ -686,12 +728,24 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { let constant = base_analysis .constant .zip(exponent_analysis.constant) - .map(|(base, exponent)| base.powf(exponent)); - let growth = if constant.is_some() { + .and_then(|(base, exponent)| { + let value = base.powf(exponent); + (!value.is_nan()).then_some(value) + }); + let growth = if matches!(&base_analysis.growth, Growth::Unknown(_)) + || matches!(&exponent_analysis.growth, Growth::Unknown(_)) + { + merge_unknown(base_analysis.growth, exponent_analysis.growth) + } else if constant.is_some() { constant_growth() + } else if base_analysis.constant.is_some() && exponent_analysis.constant.is_some() { + unknown(GrowthFailure::Approximation { + expression: expression.to_string(), + error: "power has no real value".to_string(), + }) } else if let Some(power) = exponent_analysis.constant { if power < 0.0 { - Growth::Unknown + unknown(GrowthFailure::NegativeExponent(exponent.to_string())) } else if power == 0.0 { constant_growth() } else { @@ -701,9 +755,12 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { exponential( ExpBase::Constant(base.as_ref().clone()), exponent_analysis.linear, + exponent, ) } else { - Growth::Unknown + unknown(GrowthFailure::VariableBaseAndExponent( + expression.to_string(), + )) }; ExprAnalysis { growth, @@ -730,28 +787,60 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { ExprAnalysis { growth: if constant.is_some() { constant_growth() + } else if matches!(&value.growth, Growth::Unknown(_)) { + value.growth } else { - exponential(ExpBase::Natural, value.linear) + exponential(ExpBase::Natural, value.linear, expression) }, constant, linear: constant.map(|_| BTreeMap::new()), } } - Expr::Log(value) => analyze_unary(value, f64::ln, log_growth), - Expr::Sqrt(value) => analyze_unary(value, f64::sqrt, |growth| pow_const(growth, 0.5)), + Expr::Log(value) => analyze_unary( + expression, + value, + |constant| { + (constant > 0.0) + .then(|| constant.ln()) + .ok_or("logarithm argument must be positive") + }, + log_growth, + ), + Expr::Sqrt(value) => analyze_unary( + expression, + value, + |constant| { + (constant >= 0.0) + .then(|| constant.sqrt()) + .ok_or("square-root argument must be non-negative") + }, + |growth| pow_const(growth, 0.5), + ), Expr::Factorial(value) => { let value = analyze_expr(value); - let constant = value - .constant - .and_then(|constant| approximate_factorial(constant).ok()); - ExprAnalysis { - growth: if constant.is_some() { - constant_growth() - } else { - Growth::Unknown + if matches!(&value.growth, Growth::Unknown(_)) { + return ExprAnalysis { + growth: value.growth, + constant: None, + linear: None, + }; + } + match value.constant { + Some(constant) => match approximate_factorial(constant) { + Ok(constant) => ExprAnalysis { + growth: constant_growth(), + constant: Some(constant), + linear: Some(BTreeMap::new()), + }, + Err(error) => failed_analysis(expression, error.to_string()), + }, + None => ExprAnalysis { + growth: unknown(GrowthFailure::FactorialOfNonconstant( + expression.to_string(), + )), + constant: None, + linear: None, }, - constant, - linear: constant.map(|_| BTreeMap::new()), } } } @@ -776,20 +865,51 @@ fn analyze_sum(left: &Expr, right: &Expr, right_sign: f64) -> ExprAnalysis { } fn analyze_unary( + expression: &Expr, value: &Expr, - evaluate: impl FnOnce(f64) -> f64, + evaluate: impl FnOnce(f64) -> Result, transform_growth: impl FnOnce(Growth) -> Growth, ) -> ExprAnalysis { let value = analyze_expr(value); - let constant = value.constant.map(evaluate); - ExprAnalysis { - growth: if constant.is_some() { - constant_growth() - } else { - transform_growth(value.growth) + if matches!(&value.growth, Growth::Unknown(_)) { + return ExprAnalysis { + growth: value.growth, + constant: None, + linear: None, + }; + } + match value.constant { + Some(constant) => match evaluate(constant) { + Ok(constant) => ExprAnalysis { + growth: constant_growth(), + constant: Some(constant), + linear: Some(BTreeMap::new()), + }, + Err(error) => failed_analysis(expression, error.to_string()), }, - constant, - linear: constant.map(|_| BTreeMap::new()), + None => ExprAnalysis { + growth: transform_growth(value.growth), + constant: None, + linear: None, + }, + } +} + +fn failed_analysis(expression: &Expr, error: String) -> ExprAnalysis { + ExprAnalysis { + growth: unknown(GrowthFailure::Approximation { + expression: expression.to_string(), + error, + }), + constant: None, + linear: None, + } +} + +fn denominator_expression(expression: &Expr) -> String { + match expression { + Expr::Div(_, denominator) => denominator.to_string(), + _ => unreachable!("denominator_expression requires division"), } } @@ -822,6 +942,23 @@ fn constant_growth() -> Growth { Growth::Terms(vec![GrowthTerm::one()]) } +fn unknown(failure: GrowthFailure) -> Growth { + Growth::unknown(failure) +} + +fn merge_unknown(left: Growth, right: Growth) -> Growth { + let mut failures = Vec::new(); + if let Growth::Unknown(left) = left { + failures.extend(left); + } + if let Growth::Unknown(right) = right { + failures.extend(right); + } + failures.sort(); + failures.dedup(); + Growth::Unknown(failures) +} + /// Render one monomial as a product of its factors (or `Const(1)` when empty). fn term_to_expr(t: &GrowthTerm) -> Expr { let mut factors: Vec = Vec::new(); @@ -892,60 +1029,6 @@ fn prune(mut terms: Vec) -> Vec { result } -/// Construct a componentwise upper bound when every exponential component has -/// a symbolically proven maximal product. -fn componentwise_max(terms: &[GrowthTerm]) -> Option { - let mut m = GrowthTerm::one(); - let mut vars = BTreeSet::new(); - for term in terms { - vars.extend(term.exp.keys().cloned()); - vars.extend(term.poly.keys().cloned()); - vars.extend(term.logs.keys().cloned()); - } - - for var in vars { - let empty_exp = ExpProduct::empty(); - let mut maximum = &empty_exp; - for product in terms - .iter() - .map(|term| term.exp.get(&var).unwrap_or(&empty_exp)) - { - if matches!(product.cmp_proven(maximum), Some(Ordering::Greater)) { - maximum = product; - } - } - if !terms.iter().all(|term| { - matches!( - maximum.cmp_proven(term.exp.get(&var).unwrap_or(&empty_exp)), - Some(Ordering::Greater | Ordering::Equal) - ) - }) { - return None; - } - if !maximum.is_empty() { - m.exp.insert(var.clone(), maximum.clone()); - } - - let mut max_poly = 0.0_f64; - let mut max_logs = 0_u32; - for term in terms { - let degree = term.poly.get(&var).copied().unwrap_or(0.0); - if !degree.is_finite() { - return None; - } - max_poly = max_poly.max(degree); - max_logs = max_logs.max(term.logs.get(&var).copied().unwrap_or(0)); - } - if max_poly > 0.0 { - m.poly.insert(var.clone(), max_poly); - } - if max_logs > 0 { - m.logs.insert(var, max_logs); - } - } - Some(m) -} - fn growth_term_is_valid(term: &GrowthTerm) -> bool { term.exp .values() @@ -956,22 +1039,12 @@ fn growth_term_is_valid(term: &GrowthTerm) -> bool { .all(|degree| degree.is_finite() && *degree >= 0.0) } -/// Prune, apply the antichain cap (widening upward on overflow), and sort into -/// the deterministic total order. +/// Prune to the exact maximal antichain and sort deterministically. fn make_growth(terms: Vec) -> Growth { if !terms.iter().all(growth_term_is_valid) { - return Growth::Unknown; - } - let mut pruned = prune(terms); - if pruned.len() > ANTICHAIN_CAP { - let Some(widened) = componentwise_max(&pruned) else { - return Growth::Unknown; - }; - if !pruned.iter().all(|term| widened.dominates_or_eq(term)) { - return Growth::Unknown; - } - pruned = vec![widened]; + return unknown(GrowthFailure::InvalidGrowthTerm); } + let pruned = prune(terms); debug_assert!(pruned.iter().all(growth_term_is_valid)); Growth::Terms(pruned) } @@ -979,7 +1052,9 @@ fn make_growth(terms: Vec) -> Growth { /// Antichain union (asymptotic `+ ≍ max`). fn add(a: Growth, b: Growth) -> Growth { match (a, b) { - (Growth::Unknown, _) | (_, Growth::Unknown) => Growth::Unknown, + (left @ Growth::Unknown(_), right) | (left, right @ Growth::Unknown(_)) => { + merge_unknown(left, right) + } (Growth::Terms(mut x), Growth::Terms(y)) => { x.extend(y); make_growth(x) @@ -990,7 +1065,9 @@ fn add(a: Growth, b: Growth) -> Growth { /// Pairwise product of two antichains. fn mul(a: Growth, b: Growth) -> Growth { match (a, b) { - (Growth::Unknown, _) | (_, Growth::Unknown) => Growth::Unknown, + (left @ Growth::Unknown(_), right) | (left, right @ Growth::Unknown(_)) => { + merge_unknown(left, right) + } (Growth::Terms(x), Growth::Terms(y)) => { let mut prod = Vec::with_capacity(x.len() * y.len()); for tx in &x { @@ -1006,34 +1083,38 @@ fn mul(a: Growth, b: Growth) -> Growth { /// Raise a whole antichain to a nonnegative real power `k` (raise each term). fn pow_const(g: Growth, k: f64) -> Growth { match g { - Growth::Unknown => Growth::Unknown, + Growth::Unknown(failures) => Growth::Unknown(failures), Growth::Terms(terms) => make_growth(terms.iter().map(|t| t.powf(k)).collect()), } } /// Transfer function for a symbolic fixed-base exponential. The base's numeric /// value is used only for domain and monotonic-direction checks. -fn exponential(base: ExpBase, linear: Option, f64>>) -> Growth { +fn exponential(base: ExpBase, linear: Option, f64>>, exponent: &Expr) -> Growth { let c = base.value(); if !c.is_finite() || c <= 0.0 { - return Growth::Unknown; + return unknown(GrowthFailure::InvalidExponentialBase(c.to_string())); } if c == 1.0 { // 1^x = 1 for every x: bounded by O(1). return Growth::Terms(vec![GrowthTerm::one()]); } match linear { - None => Growth::Unknown, // nonlinear exponent + None => unknown(GrowthFailure::NonlinearExponent(exponent.to_string())), Some(coeffs) => { let mut term = GrowthTerm::one(); for (v, coeff) in coeffs { if !coeff.is_finite() { - return Growth::Unknown; + return unknown(GrowthFailure::NonFiniteLinearCoefficient(v.to_string())); } - // Drop decaying directions as an upward widening. A fractional - // base grows only along negative exponent coefficients. if (c > 1.0 && coeff > 0.0) || (c < 1.0 && coeff < 0.0) { term.exp.insert(v, ExpProduct::single(base.clone(), coeff)); + } else if coeff != 0.0 { + return unknown(GrowthFailure::DecayingExponential { + base: c.to_string(), + variable: v.to_string(), + coefficient: coeff.to_string(), + }); } } make_growth(vec![term]) @@ -1046,7 +1127,7 @@ fn exponential(base: ExpBase, linear: Option, f64>>) -> Growth /// `log(2^(r·n)) ≍ n`. fn log_growth(g: Growth) -> Growth { match g { - Growth::Unknown => Growth::Unknown, + Growth::Unknown(failures) => Growth::Unknown(failures), Growth::Terms(terms) => { let mut out = Vec::new(); for t in &terms { diff --git a/src/rules/analysis.rs b/src/rules/analysis.rs index a54d2dc5f..c24bf8bce 100644 --- a/src/rules/analysis.rs +++ b/src/rules/analysis.rs @@ -134,7 +134,7 @@ pub fn compare_overhead( // A field whose growth we cannot bound symbolically makes the whole // comparison undecidable. - if matches!(pg, Growth::Unknown) || matches!(cg, Growth::Unknown) { + if matches!(pg, Growth::Unknown(_)) || matches!(cg, Growth::Unknown(_)) { return ComparisonStatus::Unknown; } diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index 7c7b90ace..74405a290 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -13,7 +13,7 @@ //! Asymptotic overhead formulas are not used as concrete budget bounds. use crate::expr::Expr; -use crate::growth::Growth; +use crate::growth::{Growth, GrowthFailure}; use crate::rules::registry::{ReduceFn, ReductionOverhead}; use crate::rules::traits::DynReductionResult; use crate::types::ProblemSize; @@ -68,7 +68,27 @@ pub struct AnalysisCoverage { #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct AnalysisFailure { pub fields: Vec, - pub reason: &'static str, + pub reasons: BTreeMap>, +} + +impl std::fmt::Display for AnalysisFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut first_field = true; + for (field, reasons) in &self.reasons { + if !first_field { + formatter.write_str("; ")?; + } + first_field = false; + write!(formatter, "{field}: ")?; + for (index, reason) in reasons.iter().enumerate() { + if index > 0 { + formatter.write_str(", ")?; + } + write!(formatter, "{reason}")?; + } + } + Ok(()) + } } /// A borrowed view of one reduction edge, handed to [`PathLabel::extend`]. @@ -295,15 +315,17 @@ impl GrowthLabel { /// Return the explicit failure boundary when any field is unanalyzable. pub fn analysis_failure(&self) -> Option { - let fields: Vec<_> = self + let reasons: BTreeMap<_, _> = self .fields .iter() - .filter(|(_, growth)| matches!(growth, Growth::Unknown)) - .map(|(field, _)| field.clone()) + .filter_map(|(field, growth)| match growth { + Growth::Terms(_) => None, + Growth::Unknown(reasons) => Some((field.clone(), reasons.clone())), + }) .collect(); - (!fields.is_empty()).then_some(AnalysisFailure { - fields, - reason: "symbolic growth analysis returned Unknown", + (!reasons.is_empty()).then(|| AnalysisFailure { + fields: reasons.keys().cloned().collect(), + reasons, }) } } @@ -333,9 +355,23 @@ impl PathLabel for GrowthLabel { let mut new_fields: BTreeMap = BTreeMap::new(); for (target_field, expr) in &edge.overhead.output_size { - let growth = expr - .substitute_complete(&mapping) - .map_or(Growth::Unknown, |expression| Growth::from_expr(&expression)); + let growth = match expr.substitute_complete(&mapping) { + Some(expression) => Growth::from_expr(&expression), + None => { + let mut failures: Vec<_> = expr + .variables() + .into_iter() + .filter(|variable| !mapping.contains_key(variable)) + .flat_map(|variable| match self.fields.get(variable) { + Some(Growth::Unknown(failures)) => failures.clone(), + _ => vec![GrowthFailure::MissingSubstitution(variable.to_string())], + }) + .collect(); + failures.sort(); + failures.dedup(); + Growth::Unknown(failures) + } + }; new_fields.insert((*target_field).to_string(), growth); } // Asymptotic mode has no budget, so `extend` never prunes. @@ -347,7 +383,7 @@ impl PathLabel for GrowthLabel { .fields .values() .chain(other.fields.values()) - .any(|growth| matches!(growth, Growth::Unknown)) + .any(|growth| matches!(growth, Growth::Unknown(_))) { return false; } diff --git a/src/unit_tests/big_o.rs b/src/unit_tests/big_o.rs index 877c8508e..c70e21e55 100644 --- a/src/unit_tests/big_o.rs +++ b/src/unit_tests/big_o.rs @@ -106,7 +106,11 @@ fn test_big_o_pure_constant_returns_one() { #[test] fn test_big_o_rejects_division() { let e = Expr::variable("n") / Expr::variable("m"); - assert!(big_o_normal_form(&e).is_err()); + let error = big_o_normal_form(&e).unwrap_err(); + assert_eq!( + error.to_string(), + "unsupported asymptotic expression: variable denominator is unsupported: m" + ); } #[test] diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index 3ac8fd712..7c920bc78 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -1,7 +1,7 @@ //! Unit tests for the symbolic growth domain (`src/growth.rs`). use super::{ - add, componentwise_max, make_growth, mul, ExpBase, ExpFactor, ExpProduct, Growth, GrowthTerm, + add, make_growth, mul, ExpBase, ExpFactor, ExpProduct, Growth, GrowthFailure, GrowthTerm, }; use crate::expr::{ constant_approximation, evaluate_approximate, expression_from_approximation, Expr, @@ -35,7 +35,7 @@ fn term(exp: &[(&str, f64)], poly: &[(&str, f64)], logs: &[(&str, u32)]) -> Grow fn terms_of(g: &Growth) -> &[GrowthTerm] { match g { Growth::Terms(t) => t, - Growth::Unknown => panic!("expected Terms, got Unknown"), + Growth::Unknown(failures) => panic!("expected Terms, got {failures:?}"), } } @@ -318,23 +318,55 @@ fn test_growth_determinism() { /// and mul — unsupported content can never silently produce a fake bound. #[test] fn test_growth_unknown_negative_control() { - assert_eq!(g("2^(n*k)"), Growth::Unknown); - assert_eq!(g("factorial(n)"), Growth::Unknown); - assert_eq!(g("factorial(3.5)"), Growth::Unknown); - assert_eq!(g("factorial(-1)"), Growth::Unknown); + assert_eq!( + g("2^(n*k)").failures(), + Some([GrowthFailure::NonlinearExponent("n * k".to_string())].as_slice()) + ); + assert!(matches!( + g("factorial(n)").failures(), + Some([GrowthFailure::FactorialOfNonconstant(_)]) + )); + assert!(matches!( + g("factorial(3.5)").failures(), + Some([GrowthFailure::Approximation { .. }]) + )); + assert!(matches!( + g("factorial(-1)").failures(), + Some([GrowthFailure::Approximation { .. }]) + )); + assert_eq!( + g("factorial(n) + 2^(n*k)").failures(), + Some( + [ + GrowthFailure::NonlinearExponent("n * k".to_string()), + GrowthFailure::FactorialOfNonconstant("factorial(n)".to_string()), + ] + .as_slice() + ) + ); // Absorption through the real `from_expr` add/mul paths. - assert_eq!(g("factorial(n) + n^2"), Growth::Unknown); - assert_eq!(g("n^2 + factorial(n)"), Growth::Unknown); - assert_eq!(g("factorial(n) * n^2"), Growth::Unknown); - assert_eq!(g("n^2 * factorial(n)"), Growth::Unknown); + let factorial_failure = g("factorial(n)"); + assert_eq!(g("factorial(n) + n^2"), factorial_failure); + assert_eq!(g("n^2 + factorial(n)"), factorial_failure); + assert_eq!(g("factorial(n) * n^2"), factorial_failure); + assert_eq!(g("n^2 * factorial(n)"), factorial_failure); // Absorption at the operation level too. let n2 = g("n^2"); - assert_eq!(add(Growth::Unknown, n2.clone()), Growth::Unknown); - assert_eq!(add(n2.clone(), Growth::Unknown), Growth::Unknown); - assert_eq!(mul(Growth::Unknown, n2.clone()), Growth::Unknown); - assert_eq!(mul(n2, Growth::Unknown), Growth::Unknown); + assert_eq!( + add(factorial_failure.clone(), n2.clone()), + factorial_failure + ); + assert_eq!( + add(n2.clone(), factorial_failure.clone()), + factorial_failure + ); + assert_eq!( + mul(factorial_failure.clone(), n2.clone()), + factorial_failure + ); + assert_eq!(mul(n2, factorial_failure.clone()), factorial_failure); } // --- Additional coverage --- @@ -358,9 +390,15 @@ fn test_growth_constants_are_o1() { #[test] fn test_growth_pow_special_cases() { assert_eq!(terms_of(&g("n^0")), [GrowthTerm::one()]); - assert_eq!(g("n^(-1)"), Growth::Unknown); + assert!(matches!( + g("n^(-1)").failures(), + Some([GrowthFailure::NegativeExponent(_)]) + )); // Variable base with variable exponent is not representable. - assert_eq!(g("n^m"), Growth::Unknown); + assert!(matches!( + g("n^m").failures(), + Some([GrowthFailure::VariableBaseAndExponent(_)]) + )); } /// Canonical Big-O rendering: bounded classes get `O()`, `Unknown` gets `O(?)`. @@ -370,7 +408,7 @@ fn test_growth_to_big_o() { assert_eq!(g("n^2 + n").to_big_o(), "O(n^2)"); assert_eq!(g("2^n").to_big_o(), "O(2^n)"); assert_eq!(g("5").to_big_o(), "O(1)"); - assert_eq!(Growth::Unknown.to_big_o(), "O(?)"); + assert_eq!(g("factorial(n)").to_big_o(), "O(?)"); // Renders exactly `O()` for bounded classes. let bounded = g("n * m"); assert_eq!( @@ -408,17 +446,27 @@ fn test_growth_exponential_roundtrip_is_exact() { } } -/// `exp(n)` uses base e; a decaying/unit base is bounded by O(1). +/// `exp(n)` uses base e; unit bases are constant, while decaying directions +/// remain explicit analysis failures rather than silently widening to O(1). #[test] fn test_growth_exponential_variants() { // exp(n) is represented directly as e^n: exponential, dominates any polynomial. let en = g("exp(n)"); assert!(en.dominates(&g("n^5"))); - // 2^(n-m) ≤ 2^n after dropping the negative rate. - assert_eq!(g("2^(n - m)"), g("2^n")); - // Unit base is O(1); a decaying base with a growing exponent is O(1) too. + assert!(matches!( + g("2^(n - m)").failures(), + Some([GrowthFailure::DecayingExponential { variable, .. }]) if variable == "m" + )); + // Unit base is exactly O(1). assert_eq!(g("1^n"), g("7")); - assert_eq!(g("0.5^n"), g("7")); + assert!(matches!( + g("0.5^n").failures(), + Some([GrowthFailure::DecayingExponential { + variable, + coefficient, + .. + }]) if variable == "n" && coefficient == "1" + )); // A fractional base with a negative exponent grows and retains that exact // symbolic base instead of being translated through a common logarithm. assert_eq!(g("0.5^(-n)").to_big_o(), "O(0.5^(-1 * n))"); @@ -464,15 +512,15 @@ fn test_growth_log_levels() { #[test] fn test_growth_unknown_dominance() { let n2 = g("n^2"); - assert!(Growth::Unknown.dominates(&n2)); - assert!(!n2.dominates(&Growth::Unknown)); - assert!(Growth::Unknown.dominates(&Growth::Unknown)); + let unknown = g("factorial(n)"); + assert!(unknown.dominates(&n2)); + assert!(!n2.dominates(&unknown)); + assert!(unknown.dominates(&unknown)); } -/// On antichain-cap overflow the domain widens up to the single componentwise -/// max term (a valid upper bound), never truncating by iteration order. +/// Large antichains remain exact; growth analysis has no hidden size cap. #[test] -fn test_growth_antichain_cap_widens() { +fn test_growth_preserves_large_antichain() { // 40 distinct single-variable terms are pairwise incomparable. let vars: Vec = (0..40).map(|index| format!("v{index}")).collect(); let many: Vec = vars @@ -480,39 +528,14 @@ fn test_growth_antichain_cap_widens() { .map(|variable| term(&[], &[(variable, 1.0)], &[])) .collect(); - let widened = make_growth(many); - let ts = terms_of(&widened); - assert_eq!(ts.len(), 1, "cap overflow should widen to one term"); - // The single term dominates every original (it carries all variables). - for v in &vars { - assert!( - ts[0].dominates(&term(&[], &[(v, 1.0)], &[])) || ts[0] == term(&[], &[(v, 1.0)], &[]) - ); - } + let growth = make_growth(many.clone()); + assert_eq!(terms_of(&growth).len(), many.len()); + assert!(many.iter().all(|term| terms_of(&growth).contains(term))); } +/// Unproved exponential comparisons also remain as a complete antichain. #[test] -fn test_growth_componentwise_max_with_symbolic_exponentials() { - let inputs = vec![ - terms_of(&g("2^n * n")).first().unwrap().clone(), - terms_of(&g("3^n * log(n)")).first().unwrap().clone(), - ]; - let upper = componentwise_max(&inputs).expect("3^n is a proven exponential maximum"); - assert!(inputs.iter().all(|term| upper.dominates_or_eq(term))); - assert_eq!(Growth::Terms(vec![upper]).to_big_o(), "O(3^n * n * log(n))"); - - let invalid = GrowthTerm { - exp: BTreeMap::new(), - poly: [("n".into(), f64::NAN)].into_iter().collect(), - logs: BTreeMap::new(), - }; - assert_eq!(componentwise_max(&[invalid]), None); -} - -/// If symbolic exponential products have no provable componentwise maximum, -/// cap overflow widens to Unknown instead of guessing an under-bound. -#[test] -fn test_growth_antichain_cap_with_unproved_exponentials_is_unknown() { +fn test_growth_preserves_large_unproved_exponential_antichain() { let terms = (1..=33) .map(|i| GrowthTerm { exp: [( @@ -524,9 +547,9 @@ fn test_growth_antichain_cap_with_unproved_exponentials_is_unknown() { poly: BTreeMap::new(), logs: BTreeMap::new(), }) - .collect(); + .collect::>(); - assert_eq!(make_growth(terms), Growth::Unknown); + assert_eq!(terms_of(&make_growth(terms.clone())).len(), terms.len()); } /// Structured serde round-trips with owned variable names, and @@ -538,10 +561,11 @@ fn test_growth_serde_roundtrip() { let back: Growth = serde_json::from_str(&json).unwrap(); assert_eq!(value, back); - let unknown_json = serde_json::to_string(&Growth::Unknown).unwrap(); + let unknown = g("factorial(n)"); + let unknown_json = serde_json::to_string(&unknown).unwrap(); assert_eq!( serde_json::from_str::(&unknown_json).unwrap(), - Growth::Unknown + unknown ); // Every constant Expr form admitted as a symbolic base remains lossless. @@ -887,7 +911,7 @@ fn run_upper_bound(transfer: fn(&Expr) -> Growth, seed: u64, iters: usize) -> Ub /// Every other node mirrors the real `Growth::from_expr` (reusing its private /// transfer helpers), so the only defect is the seeded `Add` bug. fn broken_from_expr(e: &Expr) -> Growth { - if constant_approximation(e).is_some() { + if constant_approximation(e).unwrap().is_some() { return Growth::Terms(vec![GrowthTerm::one()]); } match e { @@ -902,35 +926,38 @@ fn broken_from_expr(e: &Expr) -> Growth { Expr::Sub(a, b) => add(broken_from_expr(a), broken_from_expr(b)), Expr::Mul(a, b) => mul(broken_from_expr(a), broken_from_expr(b)), Expr::Div(a, b) => { - if constant_approximation(b).is_some() { + if constant_approximation(b).unwrap().is_some() { broken_from_expr(a) } else { - Growth::Unknown + Growth::unknown(GrowthFailure::VariableDenominator(b.to_string())) } } Expr::Pow(base, exp) => { - if let Some(k) = constant_approximation(exp) { + if let Some(k) = constant_approximation(exp).unwrap() { if k < 0.0 { - Growth::Unknown + Growth::unknown(GrowthFailure::NegativeExponent(exp.to_string())) } else if k == 0.0 { Growth::Terms(vec![GrowthTerm::one()]) } else { pow_const(broken_from_expr(base), k) } - } else if constant_approximation(base).is_some() { + } else if constant_approximation(base).unwrap().is_some() { exponential( ExpBase::Constant(base.as_ref().clone()), analyze_expr(exp).linear, + exp, ) } else { - Growth::Unknown + Growth::unknown(GrowthFailure::VariableBaseAndExponent(e.to_string())) } } - Expr::Exp(a) => exponential(ExpBase::Natural, analyze_expr(a).linear), + Expr::Exp(a) => exponential(ExpBase::Natural, analyze_expr(a).linear, a), Expr::Neg(value) => broken_from_expr(value), Expr::Log(a) => log_growth(broken_from_expr(a)), Expr::Sqrt(a) => pow_const(broken_from_expr(a), 0.5), - Expr::Factorial(_) => Growth::Unknown, + Expr::Factorial(value) => { + Growth::unknown(GrowthFailure::FactorialOfNonconstant(value.to_string())) + } } } @@ -992,7 +1019,7 @@ fn term_approx_eq(x: &GrowthTerm, y: &GrowthTerm) -> bool { fn growth_approx_eq(a: &Growth, b: &Growth) -> bool { match (a, b) { - (Growth::Unknown, Growth::Unknown) => true, + (Growth::Unknown(_), Growth::Unknown(_)) => true, (Growth::Terms(ta), Growth::Terms(tb)) => { ta.len() == tb.len() && ta.iter().all(|t| tb.iter().any(|u| term_approx_eq(t, u))) diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index 6ee6eace7..5efa61c64 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -7,7 +7,7 @@ use super::*; use crate::expr::{evaluate_approximate, expression_from_approximation, Expr}; -use crate::growth::Growth; +use crate::growth::{Growth, GrowthFailure}; use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::formula::{CNFClause, Satisfiability}; use crate::models::graph::HamiltonianCircuit; @@ -778,7 +778,7 @@ fn test_growth_label_propagates_unknown() { ); fields.insert("y".to_string(), Growth::from_expr(&Expr::variable("n"))); let label = GrowthLabel::from_fields(fields); - assert!(matches!(label.fields().get("x"), Some(Growth::Unknown))); + assert!(matches!(label.fields().get("x"), Some(Growth::Unknown(_)))); // out1 uses x (Unknown) → Unknown; out2 uses only y → bounded. let edge = growth_edge(vec![ @@ -795,6 +795,12 @@ fn test_growth_label_propagates_unknown() { let next = label.extend(&redge).expect("extend"); assert_eq!(field_big_o(&next, "out1"), "?"); assert_eq!(field_big_o(&next, "out2"), "n^2"); + assert!(matches!( + next.fields()["out1"] + .failures() + .expect("propagated reasons"), + [GrowthFailure::FactorialOfNonconstant(expression)] if expression == "factorial(n)" + )); } #[test] @@ -836,7 +842,10 @@ fn test_symbolic_front_excludes_unknown_with_analysis_reason() { assert_eq!(result.coverage.analyzed_paths, 1); assert_eq!(result.coverage.excluded_paths, 1); assert_eq!(result.excluded[0].failure.fields, ["out"]); - assert!(result.excluded[0].failure.reason.contains("Unknown")); + assert!(matches!( + result.excluded[0].failure.reasons["out"].as_slice(), + [GrowthFailure::MissingSubstitution(variable)] if variable == "missing" + )); } #[test] @@ -959,7 +968,10 @@ fn test_growth_label_unknown_is_incomparable() { let with_unknown = GrowthLabel::from_fields({ let mut m = BTreeMap::new(); m.insert("a".to_string(), Growth::from_expr(&powk("n", 2.0))); - m.insert("b".to_string(), Growth::Unknown); + m.insert( + "b".to_string(), + Growth::from_expr(&Expr::Factorial(Box::new(Expr::variable("n")))), + ); m }); assert!(!known.final_dominates(&with_unknown)); @@ -2000,10 +2012,14 @@ fn test_growth_label_taints_absent_variable() { // References an unmapped, intermediate-only variable ⇒ tainted to Unknown, never // leaked as `O(n * tseitin)`. assert!( - matches!(next.fields().get("leaky"), Some(Growth::Unknown)), + matches!(next.fields().get("leaky"), Some(Growth::Unknown(_))), "a target field referencing an absent variable must become Unknown, got {:?}", next.fields().get("leaky") ); + assert!(matches!( + next.fields()["leaky"].failures().expect("unknown reasons"), + [GrowthFailure::MissingSubstitution(variable)] if variable == "tseitin" + )); } // --------------------------------------------------------------------------- From 5c6d7a17ef0e9bc1090abe68364ebe6e07acc6b8 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 10 Aug 2026 02:34:02 +0800 Subject: [PATCH 8/9] refactor: preserve exact symbolic path semantics --- ...hained_reduction_factoring_to_spinglass.rs | 2 +- problemreductions-cli/src/commands/graph.rs | 46 +- problemreductions-cli/tests/pred_sym_tests.rs | 2 +- problemreductions-expr/src/lib.rs | 931 +++++++++++++----- .../tests/fixtures/sympy_oracle.json | 75 +- problemreductions-expr/tests/sympy_fixture.rs | 103 +- problemreductions-macros/src/expr_codegen.rs | 127 ++- problemreductions-macros/src/lib.rs | 100 +- scripts/generate_symbolic_expr_fixture.py | 15 +- src/expr.rs | 65 +- src/growth.rs | 268 ++--- src/rules/analysis.rs | 15 +- src/rules/graph.rs | 51 +- src/rules/ksatisfiability_casts.rs | 4 +- src/rules/mod.rs | 11 +- src/rules/pareto.rs | 152 +-- src/rules/registry.rs | 57 +- src/unit_tests/big_o.rs | 7 +- src/unit_tests/expr.rs | 160 ++- src/unit_tests/growth.rs | 315 ++---- src/unit_tests/reduction_graph.rs | 36 +- src/unit_tests/rules/analysis.rs | 22 +- src/unit_tests/rules/pareto.rs | 119 ++- src/unit_tests/rules/registry.rs | 27 + 24 files changed, 1644 insertions(+), 1066 deletions(-) diff --git a/examples/chained_reduction_factoring_to_spinglass.rs b/examples/chained_reduction_factoring_to_spinglass.rs index dd1860666..556ae6ef1 100644 --- a/examples/chained_reduction_factoring_to_spinglass.rs +++ b/examples/chained_reduction_factoring_to_spinglass.rs @@ -71,7 +71,7 @@ pub fn run() { } // Compose overheads symbolically along the full path - let composed = graph.compose_path_overhead(rpath); + let composed = graph.compose_path_overhead(rpath).unwrap(); println!("Composed (source → target):"); for (field, poly) in &composed.output_size { println!(" {} = {}", field, poly); diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index e71e59271..0afbdd041 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -450,10 +450,16 @@ fn format_path_text( // Show composed overall overhead for multi-step paths if reduction_path.len() > 1 { - let composed = overheads.iter().cloned().reduce(|acc, oh| acc.compose(&oh)); text.push_str(&format!("\n {}:\n", crate::output::fmt_section("Overall"))); - for (field, poly) in &composed.expect("multi-step path has overheads").output_size { - text.push_str(&format!(" {field} = {}\n", big_o_of(poly))); + match graph.compose_path_overhead(reduction_path) { + Ok(composed) => { + for (field, poly) in &composed.output_size { + text.push_str(&format!(" {field} = {}\n", big_o_of(poly))); + } + } + Err(error) => { + text.push_str(&format!(" unavailable: {error}\n")); + } } } @@ -480,15 +486,19 @@ pub(crate) fn format_path_json( }) .collect(); - let composed = overheads.into_iter().reduce(|acc, oh| acc.compose(&oh)); - let overall = composed - .as_ref() - .map_or_else(Vec::new, |overhead| overhead_to_json(&overhead.output_size)); + let (overall, overall_error) = match graph.compose_path_overhead(reduction_path) { + Ok(composed) => ( + Some(overhead_to_json(&composed.output_size)), + None::, + ), + Err(error) => (None, Some(error.to_string())), + }; serde_json::json!({ "steps": reduction_path.len(), "path": steps_json, "overall_overhead": overall, + "overall_overhead_error": overall_error, }) } @@ -574,6 +584,7 @@ pub(crate) fn format_front_json( "steps": route["steps"], "path": route["path"], "overall_overhead": route["overall_overhead"], + "overall_overhead_error": route["overall_overhead_error"], "growth": label.fields(), "big_o": big_o, }) @@ -1024,7 +1035,7 @@ mod tests { mod path_overhead_rendering_tests { use super::big_o_of; use problemreductions::big_o_normal_form; - use problemreductions::rules::{ReductionGraph, ReductionPath}; + use problemreductions::rules::{PathOverheadCompositionError, ReductionGraph, ReductionPath}; /// A deeply composed path as a node-name chain (KSat → QUBO through /// QuadraticAssignment/ILP). Used to reconstruct the path from the live graph @@ -1065,7 +1076,7 @@ mod path_overhead_rendering_tests { let graph = ReductionGraph::new(); let path = named_exploding_path(&graph); - let composed = graph.compose_path_overhead(&path); + let composed = graph.compose_path_overhead(&path).unwrap(); assert!( !composed.output_size.is_empty(), "composed overhead has no size fields" @@ -1126,14 +1137,27 @@ mod path_overhead_rendering_tests { for path in &paths { // Per-step overheads plus the composed overall overhead. let per_step = graph.path_overheads(path); - let overall = graph.compose_path_overhead(path); - for oh in per_step.iter().chain(std::iter::once(&overall)) { + for oh in &per_step { for (field, expr) in &oh.output_size { big_o_normal_form(expr).unwrap_or_else(|error| { panic!("{src}->{dst} field {field} failed analysis: {error}") }); } } + match graph.compose_path_overhead(path) { + Ok(overall) => { + for (field, expr) in &overall.output_size { + big_o_normal_form(expr).unwrap_or_else(|error| { + panic!("{src}->{dst} field {field} failed analysis: {error}") + }); + } + } + Err(PathOverheadCompositionError::Step { error, .. }) => assert!( + !error.field_errors().is_empty(), + "composition error must identify a failing output field" + ), + Err(error) => panic!("unexpected path composition error: {error}"), + } } } let elapsed = start.elapsed(); diff --git a/problemreductions-cli/tests/pred_sym_tests.rs b/problemreductions-cli/tests/pred_sym_tests.rs index 845e3256a..7b2a903ed 100644 --- a/problemreductions-cli/tests/pred_sym_tests.rs +++ b/problemreductions-cli/tests/pred_sym_tests.rs @@ -9,7 +9,7 @@ fn test_pred_sym_parse() { let output = pred_sym().args(["parse", "n + m"]).output().unwrap(); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); - assert_eq!(stdout.trim(), "n + m"); + assert_eq!(stdout.trim(), "m + n"); } #[test] diff --git a/problemreductions-expr/src/lib.rs b/problemreductions-expr/src/lib.rs index d65a52774..d57e2eec2 100644 --- a/problemreductions-expr/src/lib.rs +++ b/problemreductions-expr/src/lib.rs @@ -3,12 +3,13 @@ use num_bigint::BigInt; use num_rational::BigRational; use num_traits::{One, Signed, Zero}; -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::fmt; use std::str::FromStr; +use std::sync::Arc; /// A validated problem-size variable name. -#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)] #[serde(transparent)] pub struct Symbol(Box); @@ -122,30 +123,210 @@ fn is_valid_symbol(name: &str) -> bool { ) } -/// A symbolic expression over named problem-size variables. -#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -pub enum Expr { +/// One immutable node in a symbolic expression DAG. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ExprNode { Const(BigRational), Var(Symbol), - Add(Box, Box), - Sub(Box, Box), - Mul(Box, Box), - Div(Box, Box), - Pow(Box, Box), - Neg(Box), - Exp(Box), - Log(Box), - Sqrt(Box), - Factorial(Box), + Add(Box<[Expr]>), + Mul(Box<[Expr]>), + Pow(Expr, Expr), + Exp(Expr), + Log(Expr), + Factorial(Expr), +} + +/// A cheap, immutable handle to a shared symbolic expression node. +#[derive(Clone, Debug)] +pub struct Expr(Arc); + +impl PartialEq for Expr { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) || self.node() == other.node() + } +} + +impl Eq for Expr {} + +impl std::hash::Hash for Expr { + fn hash(&self, state: &mut H) { + std::hash::Hash::hash(self.node(), state); + } +} + +impl PartialOrd for Expr { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Expr { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + if Arc::ptr_eq(&self.0, &other.0) { + std::cmp::Ordering::Equal + } else { + self.node().cmp(other.node()) + } + } +} + +/// Opaque identity used to memoize one traversal of an expression DAG. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ExprNodeId(usize); + +impl serde::Serialize for Expr { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + ExprDocument::from_expression(self).serialize(serializer) + } +} + +impl<'de> serde::Deserialize<'de> for Expr { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + ExprDocument::deserialize(deserializer)? + .into_expression() + .map_err(serde::de::Error::custom) + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct ExprDocument { + nodes: Vec, + root: usize, +} + +#[derive(serde::Serialize, serde::Deserialize)] +enum SerializedNode { + Const(BigRational), + Var(Symbol), + Add(Vec), + Mul(Vec), + Pow(usize, usize), + Exp(usize), + Log(usize), + Factorial(usize), +} + +impl ExprDocument { + fn from_expression(root: &Expr) -> Self { + let mut ids = HashMap::new(); + let mut nodes = Vec::new(); + let mut pending = vec![(root, false)]; + while let Some((expression, expanded)) = pending.pop() { + if ids.contains_key(&expression.node_identity()) { + continue; + } + if !expanded { + pending.push((expression, true)); + match expression.node() { + ExprNode::Add(values) | ExprNode::Mul(values) => { + pending.extend(values.iter().rev().map(|value| (value, false))); + } + ExprNode::Pow(base, exponent) => { + pending.push((exponent, false)); + pending.push((base, false)); + } + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + pending.push((value, false)) + } + ExprNode::Const(_) | ExprNode::Var(_) => {} + } + continue; + } + + let child_id = |child: &Expr| ids[&child.node_identity()]; + let node = match expression.node() { + ExprNode::Const(value) => SerializedNode::Const(value.clone()), + ExprNode::Var(symbol) => SerializedNode::Var(symbol.clone()), + ExprNode::Add(values) => SerializedNode::Add(values.iter().map(child_id).collect()), + ExprNode::Mul(values) => SerializedNode::Mul(values.iter().map(child_id).collect()), + ExprNode::Pow(base, exponent) => { + SerializedNode::Pow(child_id(base), child_id(exponent)) + } + ExprNode::Exp(value) => SerializedNode::Exp(child_id(value)), + ExprNode::Log(value) => SerializedNode::Log(child_id(value)), + ExprNode::Factorial(value) => SerializedNode::Factorial(child_id(value)), + }; + let id = nodes.len(); + nodes.push(node); + ids.insert(expression.node_identity(), id); + } + Self { + nodes, + root: ids[&root.node_identity()], + } + } + + fn into_expression(self) -> Result { + let mut expressions = Vec::with_capacity(self.nodes.len()); + for (node_id, node) in self.nodes.into_iter().enumerate() { + let child = |id: usize| { + expressions + .get(id) + .cloned() + .ok_or(InvalidExpressionDocument::UnavailableChild { node_id, id }) + }; + let expression = match node { + SerializedNode::Const(value) => Expr::constant(value), + SerializedNode::Var(symbol) => Expr::from_node(ExprNode::Var(symbol)), + SerializedNode::Add(ids) => { + Expr::add_all(ids.into_iter().map(child).collect::>()?) + } + SerializedNode::Mul(ids) => { + Expr::mul_all(ids.into_iter().map(child).collect::>()?) + } + SerializedNode::Pow(base, exponent) => Expr::pow(child(base)?, child(exponent)?), + SerializedNode::Exp(value) => Expr::exp(child(value)?), + SerializedNode::Log(value) => Expr::log(child(value)?), + SerializedNode::Factorial(value) => Expr::factorial(child(value)?), + }; + expressions.push(expression); + } + expressions + .get(self.root) + .cloned() + .ok_or(InvalidExpressionDocument::UnavailableRoot(self.root)) + } +} + +#[derive(Debug, thiserror::Error)] +enum InvalidExpressionDocument { + #[error("expression node {node_id} references unavailable child node {id}")] + UnavailableChild { node_id: usize, id: usize }, + #[error("expression root references unavailable node {0}")] + UnavailableRoot(usize), } impl Expr { + fn from_node(node: ExprNode) -> Self { + Self(Arc::new(node)) + } + + pub fn node(&self) -> &ExprNode { + &self.0 + } + + /// Identity of this allocation for operation-local DAG memoization. + /// The value is process-local and remains valid while any clone of the node lives. + pub fn node_identity(&self) -> ExprNodeId { + ExprNodeId(Arc::as_ptr(&self.0) as usize) + } + pub fn integer(value: impl Into) -> Self { - Self::Const(BigRational::from_integer(value.into())) + Self::constant(BigRational::from_integer(value.into())) } pub fn rational(numerator: impl Into, denominator: impl Into) -> Self { - Self::Const(BigRational::new(numerator.into(), denominator.into())) + Self::constant(BigRational::new(numerator.into(), denominator.into())) + } + + pub fn constant(value: BigRational) -> Self { + Self::from_node(ExprNode::Const(value)) } pub fn variable(name: impl Into>) -> Self { @@ -153,11 +334,33 @@ impl Expr { } pub fn try_variable(name: impl Into>) -> Result { - Symbol::new(name).map(Self::Var) + Symbol::new(name).map(|symbol| Self::from_node(ExprNode::Var(symbol))) } pub fn pow(base: Expr, exponent: Expr) -> Self { - Self::Pow(Box::new(base), Box::new(exponent)) + if exponent.is_exact_integer(0) || base.is_exact_integer(1) { + return Self::integer(1); + } + if exponent.is_exact_integer(1) { + return base; + } + Self::from_node(ExprNode::Pow(base, exponent)) + } + + pub fn exp(value: Expr) -> Self { + Self::from_node(ExprNode::Exp(value)) + } + + pub fn log(value: Expr) -> Self { + Self::from_node(ExprNode::Log(value)) + } + + pub fn sqrt(value: Expr) -> Self { + Self::pow(value, Self::rational(1, 2)) + } + + pub fn factorial(value: Expr) -> Self { + Self::from_node(ExprNode::Factorial(value)) } pub fn parse(input: &str) -> Self { @@ -171,199 +374,372 @@ impl Expr { pub fn variables(&self) -> BTreeSet<&str> { let mut variables = BTreeSet::new(); - self.collect_variables(&mut variables); + let mut visited = HashSet::new(); + self.collect_variables(&mut variables, &mut visited); variables } - fn collect_variables<'a>(&'a self, variables: &mut BTreeSet<&'a str>) { - match self { - Self::Const(_) => {} - Self::Var(name) => { + fn collect_variables<'a>( + &'a self, + variables: &mut BTreeSet<&'a str>, + visited: &mut HashSet, + ) { + if !visited.insert(self.node_identity()) { + return; + } + match self.node() { + ExprNode::Const(_) => {} + ExprNode::Var(name) => { variables.insert(name.as_str()); } - Self::Add(left, right) - | Self::Sub(left, right) - | Self::Mul(left, right) - | Self::Div(left, right) - | Self::Pow(left, right) => { - left.collect_variables(variables); - right.collect_variables(variables); - } - Self::Neg(value) - | Self::Exp(value) - | Self::Log(value) - | Self::Sqrt(value) - | Self::Factorial(value) => value.collect_variables(variables), - } - } - - pub fn substitute(&self, replacements: &HashMap<&str, &Expr>) -> Expr { - match self { - Self::Const(value) => Self::Const(value.clone()), - Self::Var(name) => replacements - .get(name.as_ref()) - .map_or_else(|| self.clone(), |replacement| (*replacement).clone()), - Self::Add(left, right) => { - left.substitute(replacements) + right.substitute(replacements) - } - Self::Sub(left, right) => { - left.substitute(replacements) - right.substitute(replacements) - } - Self::Mul(left, right) => { - left.substitute(replacements) * right.substitute(replacements) - } - Self::Div(left, right) => { - left.substitute(replacements) / right.substitute(replacements) - } - Self::Pow(base, exponent) => Self::pow( - base.substitute(replacements), - exponent.substitute(replacements), - ), - Self::Neg(value) => -value.substitute(replacements), - Self::Exp(value) => Self::Exp(Box::new(value.substitute(replacements))), - Self::Log(value) => Self::Log(Box::new(value.substitute(replacements))), - Self::Sqrt(value) => Self::Sqrt(Box::new(value.substitute(replacements))), - Self::Factorial(value) => Self::Factorial(Box::new(value.substitute(replacements))), - } - } - - /// Substitute every variable, returning `None` when any replacement is missing. - pub fn substitute_complete(&self, replacements: &HashMap<&str, &Expr>) -> Option { - match self { - Self::Const(value) => Some(Self::Const(value.clone())), - Self::Var(name) => replacements - .get(name.as_ref()) - .map(|value| (*value).clone()), - Self::Add(left, right) => Some( - left.substitute_complete(replacements)? - + right.substitute_complete(replacements)?, - ), - Self::Sub(left, right) => Some( - left.substitute_complete(replacements)? - - right.substitute_complete(replacements)?, - ), - Self::Mul(left, right) => Some( - left.substitute_complete(replacements)? - * right.substitute_complete(replacements)?, - ), - Self::Div(left, right) => Some( - left.substitute_complete(replacements)? - / right.substitute_complete(replacements)?, - ), - Self::Pow(base, exponent) => Some(Self::pow( - base.substitute_complete(replacements)?, - exponent.substitute_complete(replacements)?, - )), - Self::Neg(value) => Some(-value.substitute_complete(replacements)?), - Self::Exp(value) => Some(Self::Exp(Box::new( - value.substitute_complete(replacements)?, - ))), - Self::Log(value) => Some(Self::Log(Box::new( - value.substitute_complete(replacements)?, - ))), - Self::Sqrt(value) => Some(Self::Sqrt(Box::new( - value.substitute_complete(replacements)?, - ))), - Self::Factorial(value) => Some(Self::Factorial(Box::new( - value.substitute_complete(replacements)?, - ))), + ExprNode::Add(values) | ExprNode::Mul(values) => { + for value in values { + value.collect_variables(variables, visited); + } + } + ExprNode::Pow(base, exponent) => { + base.collect_variables(variables, visited); + exponent.collect_variables(variables, visited); + } + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + value.collect_variables(variables, visited); + } } } - pub fn is_constant(&self) -> bool { - match self { - Self::Const(_) => true, - Self::Var(_) => false, - Self::Add(left, right) - | Self::Sub(left, right) - | Self::Mul(left, right) - | Self::Div(left, right) - | Self::Pow(left, right) => left.is_constant() && right.is_constant(), - Self::Neg(value) - | Self::Exp(value) - | Self::Log(value) - | Self::Sqrt(value) - | Self::Factorial(value) => value.is_constant(), + /// Replace every variable or report the complete set of missing replacements. + pub fn substitute_complete( + &self, + replacements: &HashMap<&str, &Expr>, + ) -> Result { + self.substitute_inner(replacements, &mut HashMap::new()) + .map_err(SubstitutionError::new) + } + + fn substitute_inner( + &self, + replacements: &HashMap<&str, &Expr>, + memo: &mut HashMap>>>, + ) -> Result>> { + let identity = self.node_identity(); + if let Some(result) = memo.get(&identity) { + return result.clone(); } + let result = match self.node() { + ExprNode::Const(_) => Ok(self.clone()), + ExprNode::Var(name) => match replacements.get(name.as_ref()) { + Some(replacement) => Ok((*replacement).clone()), + None => Err(BTreeSet::from([name.as_str().into()])), + }, + ExprNode::Add(values) => { + Self::substitute_values(values, replacements, memo).map(Self::add_all) + } + ExprNode::Mul(values) => { + Self::substitute_values(values, replacements, memo).map(Self::mul_all) + } + ExprNode::Pow(base, exponent) => { + let base = base.substitute_inner(replacements, memo); + let exponent = exponent.substitute_inner(replacements, memo); + match (base, exponent) { + (Ok(base), Ok(exponent)) => Ok(Self::pow(base, exponent)), + (Err(mut left), Err(right)) => { + left.extend(right); + Err(left) + } + (Err(missing), _) | (_, Err(missing)) => Err(missing), + } + } + ExprNode::Exp(value) => value.substitute_inner(replacements, memo).map(Self::exp), + ExprNode::Log(value) => value.substitute_inner(replacements, memo).map(Self::log), + ExprNode::Factorial(value) => value + .substitute_inner(replacements, memo) + .map(Self::factorial), + }; + memo.insert(identity, result.clone()); + result } - pub fn is_polynomial(&self) -> bool { - match self { - Self::Const(_) | Self::Var(_) => true, - Self::Add(left, right) | Self::Sub(left, right) | Self::Mul(left, right) => { - left.is_polynomial() && right.is_polynomial() + fn substitute_values( + values: &[Expr], + replacements: &HashMap<&str, &Expr>, + memo: &mut HashMap>>>, + ) -> Result, BTreeSet>> { + let mut substituted = Vec::with_capacity(values.len()); + let mut missing = BTreeSet::new(); + for value in values { + match value.substitute_inner(replacements, memo) { + Ok(value) => substituted.push(value), + Err(variables) => missing.extend(variables), } - Self::Div(numerator, denominator) => { - numerator.is_polynomial() - && matches!(denominator.as_ref(), Self::Const(value) if !value.is_zero()) + } + if missing.is_empty() { + Ok(substituted) + } else { + Err(missing) + } + } + + pub fn is_constant(&self) -> bool { + self.is_constant_inner(&mut HashMap::new()) + } + + fn is_constant_inner(&self, memo: &mut HashMap) -> bool { + if let Some(result) = memo.get(&self.node_identity()) { + return *result; + } + let result = match self.node() { + ExprNode::Const(_) => true, + ExprNode::Var(_) => false, + ExprNode::Add(values) | ExprNode::Mul(values) => { + values.iter().all(|value| value.is_constant_inner(memo)) } - Self::Pow(base, exponent) => { - base.is_polynomial() - && matches!(exponent.as_ref(), Self::Const(value) if value.is_integer() && !value.is_negative()) + ExprNode::Pow(base, exponent) => { + base.is_constant_inner(memo) && exponent.is_constant_inner(memo) } - Self::Neg(value) => value.is_polynomial(), - Self::Exp(_) | Self::Log(_) | Self::Sqrt(_) | Self::Factorial(_) => false, + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + value.is_constant_inner(memo) + } + }; + memo.insert(self.node_identity(), result); + result + } + + pub fn is_polynomial(&self) -> bool { + self.is_polynomial_inner(&mut HashMap::new()) + } + + fn is_polynomial_inner(&self, polynomial_memo: &mut HashMap) -> bool { + if let Some(result) = polynomial_memo.get(&self.node_identity()) { + return *result; } + let result = match self.node() { + ExprNode::Const(_) | ExprNode::Var(_) => true, + ExprNode::Add(values) | ExprNode::Mul(values) => values + .iter() + .all(|value| value.is_polynomial_inner(polynomial_memo)), + ExprNode::Pow(base, exponent) => { + (matches!((base.node(), exponent.node()), + (ExprNode::Const(base), ExprNode::Const(exponent)) + if exponent.is_integer() + && (!exponent.is_negative() || !base.is_zero()))) + || (base.is_polynomial_inner(polynomial_memo) + && matches!(exponent.node(), ExprNode::Const(value) if value.is_integer() && !value.is_negative())) + } + ExprNode::Exp(_) | ExprNode::Log(_) | ExprNode::Factorial(_) => false, + }; + polynomial_memo.insert(self.node_identity(), result); + result } pub fn is_valid_complexity_notation(&self) -> bool { - match self { - Self::Const(value) => value.is_one(), - Self::Var(_) => true, - Self::Add(left, right) | Self::Mul(left, right) => { - !left.is_constant() - && !right.is_constant() - && left.is_valid_complexity_notation() - && right.is_valid_complexity_notation() - } - Self::Pow(base, exponent) => { - let base_valid = if let Self::Const(value) = base.as_ref() { - value.is_positive() - } else { - base.is_valid_complexity_notation() + self.complexity_notation_analysis(&mut HashMap::new()).1 + } + + fn complexity_notation_analysis( + &self, + memo: &mut HashMap, + ) -> (bool, bool) { + if let Some(analysis) = memo.get(&self.node_identity()) { + return *analysis; + } + let analysis = match self.node() { + ExprNode::Const(value) => (true, value.is_one()), + ExprNode::Var(_) => (false, true), + ExprNode::Add(values) | ExprNode::Mul(values) => { + let mut all_constant = true; + let mut all_valid_nonconstant = true; + for value in values { + let (constant, valid) = value.complexity_notation_analysis(memo); + all_constant &= constant; + all_valid_nonconstant &= !constant && valid; + } + (all_constant, all_valid_nonconstant) + } + ExprNode::Pow(base, exponent) => { + let base_analysis = base.complexity_notation_analysis(memo); + let exponent_analysis = exponent.complexity_notation_analysis(memo); + let base_valid = match base.node() { + ExprNode::Const(value) => value.is_positive(), + _ => base_analysis.1, }; - base_valid && (exponent.is_constant() || exponent.is_valid_complexity_notation()) + ( + base_analysis.0 && exponent_analysis.0, + base_valid && (exponent_analysis.0 || exponent_analysis.1), + ) + } + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + value.complexity_notation_analysis(memo) + } + }; + memo.insert(self.node_identity(), analysis); + analysis + } + + pub fn unique_node_count(&self) -> usize { + let mut visited = HashSet::new(); + let mut pending = vec![self]; + while let Some(expression) = pending.pop() { + if !visited.insert(expression.node_identity()) { + continue; + } + match expression.node() { + ExprNode::Add(values) | ExprNode::Mul(values) => pending.extend(values), + ExprNode::Pow(base, exponent) => { + pending.push(base); + pending.push(exponent); + } + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + pending.push(value); + } + ExprNode::Const(_) | ExprNode::Var(_) => {} + } + } + visited.len() + } + + fn is_exact_integer(&self, expected: i64) -> bool { + matches!(self.node(), ExprNode::Const(value) if *value == BigRational::from_integer(expected.into())) + } + + fn add_all(values: Vec) -> Expr { + let mut constant = BigRational::zero(); + let mut coefficients: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + let mut pending = values; + while let Some(value) = pending.pop() { + match value.node() { + ExprNode::Add(nested) => pending.extend(nested.iter().cloned()), + ExprNode::Const(value) => constant += value, + ExprNode::Mul(factors) + if matches!(factors.first().map(Expr::node), Some(ExprNode::Const(_))) => + { + let ExprNode::Const(coefficient) = factors[0].node() else { + unreachable!() + }; + let base = Self::mul_all(factors[1..].to_vec()); + *coefficients.entry(base).or_insert_with(BigRational::zero) += coefficient; + } + _ => { + *coefficients.entry(value).or_insert_with(BigRational::zero) += + BigRational::one(); + } + } + } + let mut terms = Vec::with_capacity(coefficients.len() + usize::from(!constant.is_zero())); + for (base, coefficient) in coefficients { + if coefficient.is_zero() { + continue; + } + if coefficient.is_one() { + terms.push(base); + } else { + terms.push(Self::mul_all(vec![Self::constant(coefficient), base])); } - Self::Exp(value) | Self::Log(value) | Self::Sqrt(value) | Self::Factorial(value) => { - value.is_valid_complexity_notation() + } + if !constant.is_zero() { + terms.push(Self::constant(constant)); + } + terms.sort(); + match terms.len() { + 0 => Self::integer(0), + 1 => terms.pop().expect("single normalized sum term"), + _ => Self::from_node(ExprNode::Add(terms.into_boxed_slice())), + } + } + + fn mul_all(values: Vec) -> Expr { + let mut constant = BigRational::one(); + let mut powers: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + let mut pending = values; + while let Some(value) = pending.pop() { + match value.node() { + ExprNode::Mul(nested) => pending.extend(nested.iter().cloned()), + ExprNode::Const(value) => constant *= value, + ExprNode::Pow(base, exponent) => { + powers + .entry(base.clone()) + .or_default() + .push(exponent.clone()); + } + _ => powers.entry(value).or_default().push(Self::integer(1)), } - Self::Sub(_, _) | Self::Div(_, _) | Self::Neg(_) => false, } + let mut factors = Vec::with_capacity(powers.len() + usize::from(!constant.is_one())); + for (base, exponents) in powers { + factors.push(Self::pow(base, Self::add_all(exponents))); + } + if !constant.is_one() { + factors.push(Self::constant(constant)); + } + factors.sort(); + match factors.len() { + 0 => Self::integer(1), + 1 => factors.pop().expect("single normalized product factor"), + _ => Self::from_node(ExprNode::Mul(factors.into_boxed_slice())), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubstitutionError { + missing: BTreeSet>, +} + +impl SubstitutionError { + fn new(missing: BTreeSet>) -> Self { + Self { missing } + } + + pub fn missing_variables(&self) -> impl Iterator { + self.missing.iter().map(AsRef::as_ref) + } +} + +impl fmt::Display for SubstitutionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "missing substitutions for {}", + self.missing_variables().collect::>().join(", ") + ) } } +impl std::error::Error for SubstitutionError {} + impl std::ops::Add for Expr { type Output = Self; fn add(self, rhs: Self) -> Self::Output { - Self::Add(Box::new(self), Box::new(rhs)) + Self::add_all(vec![self, rhs]) } } impl std::ops::Sub for Expr { type Output = Self; fn sub(self, rhs: Self) -> Self::Output { - Self::Sub(Box::new(self), Box::new(rhs)) + Self::add_all(vec![self, -rhs]) } } impl std::ops::Mul for Expr { type Output = Self; fn mul(self, rhs: Self) -> Self::Output { - Self::Mul(Box::new(self), Box::new(rhs)) + Self::mul_all(vec![self, rhs]) } } impl std::ops::Div for Expr { type Output = Self; fn div(self, rhs: Self) -> Self::Output { - Self::Div(Box::new(self), Box::new(rhs)) + Self::mul_all(vec![self, Self::pow(rhs, Self::integer(-1))]) } } impl std::ops::Neg for Expr { type Output = Self; fn neg(self) -> Self::Output { - Self::Neg(Box::new(self)) + Self::mul_all(vec![Self::integer(-1), self]) } } @@ -375,11 +751,10 @@ impl fmt::Display for Expr { impl Expr { fn precedence(&self) -> u8 { - match self { - Self::Add(_, _) | Self::Sub(_, _) => 1, - Self::Mul(_, _) | Self::Div(_, _) => 2, - Self::Neg(_) => 3, - Self::Pow(_, _) => 4, + match self.node() { + ExprNode::Add(_) => 1, + ExprNode::Mul(_) => 2, + ExprNode::Pow(_, _) => 4, _ => 5, } } @@ -394,50 +769,40 @@ impl Expr { let needs_parentheses = precedence < parent_precedence || (right_child && precedence == parent_precedence - && matches!( - self, - Self::Add(_, _) | Self::Sub(_, _) | Self::Mul(_, _) | Self::Div(_, _) - )) - || (!right_child && precedence == parent_precedence && matches!(self, Self::Pow(_, _))); + && matches!(self.node(), ExprNode::Add(_) | ExprNode::Mul(_))) + || (!right_child + && precedence == parent_precedence + && matches!(self.node(), ExprNode::Pow(_, _))); if needs_parentheses { write!(formatter, "(")?; } - match self { - Self::Const(value) => fmt_rational(value, formatter)?, - Self::Var(name) => write!(formatter, "{name}")?, - Self::Add(left, right) => { - left.fmt_with_precedence(formatter, precedence, false)?; - write!(formatter, " + ")?; - right.fmt_with_precedence(formatter, precedence, true)?; - } - Self::Sub(left, right) => { - left.fmt_with_precedence(formatter, precedence, false)?; - write!(formatter, " - ")?; - right.fmt_with_precedence(formatter, precedence, true)?; - } - Self::Mul(left, right) => { - left.fmt_with_precedence(formatter, precedence, false)?; - write!(formatter, " * ")?; - right.fmt_with_precedence(formatter, precedence, true)?; - } - Self::Div(left, right) => { - left.fmt_with_precedence(formatter, precedence, false)?; - write!(formatter, " / ")?; - right.fmt_with_precedence(formatter, precedence, true)?; - } - Self::Pow(base, exponent) => { + match self.node() { + ExprNode::Const(value) => fmt_rational(value, formatter)?, + ExprNode::Var(name) => write!(formatter, "{name}")?, + ExprNode::Add(values) => { + for (index, value) in values.iter().enumerate() { + if index > 0 { + write!(formatter, " + ")?; + } + value.fmt_with_precedence(formatter, precedence, index > 0)?; + } + } + ExprNode::Mul(values) => { + for (index, value) in values.iter().enumerate() { + if index > 0 { + write!(formatter, " * ")?; + } + value.fmt_with_precedence(formatter, precedence, index > 0)?; + } + } + ExprNode::Pow(base, exponent) => { base.fmt_with_precedence(formatter, precedence, false)?; write!(formatter, "^")?; exponent.fmt_with_precedence(formatter, precedence, true)?; } - Self::Neg(value) => { - write!(formatter, "-")?; - value.fmt_with_precedence(formatter, precedence, true)?; - } - Self::Exp(value) => write!(formatter, "exp({value})")?, - Self::Log(value) => write!(formatter, "log({value})")?, - Self::Sqrt(value) => write!(formatter, "sqrt({value})")?, - Self::Factorial(value) => write!(formatter, "factorial({value})")?, + ExprNode::Exp(value) => write!(formatter, "exp({value})")?, + ExprNode::Log(value) => write!(formatter, "log({value})")?, + ExprNode::Factorial(value) => write!(formatter, "factorial({value})")?, } if needs_parentheses { write!(formatter, ")")?; @@ -658,8 +1023,16 @@ impl Parser { loop { if self.consume(&TokenKind::Star) { expression = expression * self.parse_unary()?; - } else if self.consume(&TokenKind::Slash) { - expression = expression / self.parse_unary()?; + } else if self + .peek() + .is_some_and(|token| token.kind == TokenKind::Slash) + { + let position = self.advance().expect("peeked division token").position; + let denominator = self.parse_unary()?; + if denominator.is_exact_integer(0) { + return Err(ParseError::new(position, "division by zero")); + } + expression = expression / denominator; } else { return Ok(expression); } @@ -676,8 +1049,22 @@ impl Parser { fn parse_power(&mut self) -> Result { let base = self.parse_primary()?; - if self.consume(&TokenKind::Caret) { - Ok(Expr::pow(base, self.parse_unary()?)) + if self + .peek() + .is_some_and(|token| token.kind == TokenKind::Caret) + { + let position = self.advance().expect("peeked power token").position; + let exponent = self.parse_unary()?; + if matches!((base.node(), exponent.node()), + (ExprNode::Const(base), ExprNode::Const(exponent)) + if base.is_zero() && exponent.is_negative()) + { + return Err(ParseError::new( + position, + "zero cannot have a negative power", + )); + } + Ok(Expr::pow(base, exponent)) } else { Ok(base) } @@ -688,7 +1075,7 @@ impl Parser { .advance() .ok_or_else(|| ParseError::new(self.end_position(), "expected expression"))?; match token.kind { - TokenKind::Number(value) => Ok(Expr::Const(value)), + TokenKind::Number(value) => Ok(Expr::constant(value)), TokenKind::Ident(name) => { if !self.consume(&TokenKind::LeftParen) { return Expr::try_variable(name) @@ -697,10 +1084,41 @@ impl Parser { let argument = self.parse_additive()?; self.expect_right_paren()?; match name.as_ref() { - "exp" => Ok(Expr::Exp(Box::new(argument))), - "log" => Ok(Expr::Log(Box::new(argument))), - "sqrt" => Ok(Expr::Sqrt(Box::new(argument))), - "factorial" => Ok(Expr::Factorial(Box::new(argument))), + "exp" => Ok(Expr::exp(argument)), + "log" => { + if matches!(argument.node(), ExprNode::Const(value) if !value.is_positive()) + { + Err(ParseError::new( + token.position, + "logarithm argument must be positive", + )) + } else { + Ok(Expr::log(argument)) + } + } + "sqrt" => { + if matches!(argument.node(), ExprNode::Const(value) if value.is_negative()) + { + Err(ParseError::new( + token.position, + "square-root argument must be non-negative", + )) + } else { + Ok(Expr::sqrt(argument)) + } + } + "factorial" => { + if matches!(argument.node(), ExprNode::Const(value) + if !value.is_integer() || value.is_negative()) + { + Err(ParseError::new( + token.position, + "factorial argument must be a non-negative integer", + )) + } else { + Ok(Expr::factorial(argument)) + } + } _ => Err(ParseError::new( token.position, format!("unknown function {name:?}"), @@ -742,13 +1160,25 @@ mod tests { } #[test] - fn parser_preserves_source_operators() { + fn parser_normalizes_source_operators() { let expression = Expr::parse("n * (n - 1) / 2 - m"); - assert!(matches!(expression, Expr::Sub(_, _))); - let Expr::Sub(left, _) = expression else { - unreachable!() - }; - assert!(matches!(left.as_ref(), Expr::Div(_, _))); + assert!(matches!(expression.node(), ExprNode::Add(_))); + assert_eq!(expression.variables(), BTreeSet::from(["m", "n"])); + } + + #[test] + fn parser_rejects_statically_undefined_expressions() { + for source in [ + "0 / 0", + "0^-1", + "log(0)", + "log(-1)", + "sqrt(-1)", + "factorial(-1)", + "factorial(3.5)", + ] { + assert!(Expr::try_parse(source).is_err(), "accepted {source}"); + } } #[test] @@ -770,7 +1200,7 @@ mod tests { "parsed {invalid_expression:?}" ); } - assert!(matches!(Expr::parse("n-m"), Expr::Sub(_, _))); + assert!(matches!(Expr::parse("n-m").node(), ExprNode::Add(_))); for valid in ["n", "_n", "n_1", "num_vertices"] { let expression = Expr::try_variable(valid).unwrap(); assert_eq!( @@ -782,7 +1212,27 @@ mod tests { #[test] fn deserialization_rejects_invalid_variable_names() { - assert!(serde_json::from_str::(r#"{"Var":"n-m"}"#).is_err()); + assert!(serde_json::from_str::(r#"{"nodes":[{"Var":"n-m"}],"root":0}"#).is_err()); + } + + #[test] + fn serialization_preserves_shared_nodes() { + let shared = Expr::variable("a") + Expr::variable("b"); + let expression = Expr::pow(shared.clone(), shared); + let encoded = serde_json::to_value(&expression).unwrap(); + assert_eq!(encoded["nodes"].as_array().unwrap().len(), 4); + + let decoded: Expr = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, expression); + assert_eq!(decoded.unique_node_count(), 4); + } + + #[test] + fn deserialization_rejects_forward_node_references() { + let error = + serde_json::from_str::(r#"{"nodes":[{"Pow":[1,1]},{"Var":"n"}],"root":0}"#) + .unwrap_err(); + assert!(error.to_string().contains("unavailable child node 1")); } #[test] @@ -790,20 +1240,23 @@ mod tests { let expression = Expr::parse("n + m"); let n = Expr::integer(3); let replacements = HashMap::from([("n", &n)]); - assert_eq!(expression.substitute_complete(&replacements), None); + let error = expression.substitute_complete(&replacements).unwrap_err(); + assert_eq!(error.missing_variables().collect::>(), ["m"]); let m = Expr::integer(4); let replacements = HashMap::from([("n", &n), ("m", &m)]); assert_eq!( expression.substitute_complete(&replacements), - Some(Expr::integer(3) + Expr::integer(4)) + Ok(Expr::integer(3) + Expr::integer(4)) ); } #[test] fn polynomial_accepts_exact_rational_coefficients() { assert!(Expr::parse("-n / 2").is_polynomial()); - assert!(!Expr::parse("n / 0").is_polynomial()); + assert!( + !(Expr::variable("n") * Expr::pow(Expr::integer(0), Expr::integer(-1))).is_polynomial() + ); assert!(!Expr::parse("n / m").is_polynomial()); } @@ -822,10 +1275,42 @@ mod tests { #[test] fn display_preserves_grouping() { let expression = Expr::parse("n * (n - 1) / 2 - m"); - assert_eq!(expression.to_string(), "n * (n - 1) / 2 - m"); + assert_eq!(expression.to_string(), "-1 * m + n * (-1 + n) * 2^-1"); assert_eq!(Expr::parse(&expression.to_string()), expression); } + #[test] + fn repeated_substitution_keeps_a_constant_number_of_nodes() { + let template = Expr::parse("x + x"); + let mut expression = Expr::variable("n"); + for _ in 0..100 { + let replacements = HashMap::from([("x", &expression)]); + expression = template + .substitute_complete(&replacements) + .expect("x has an exact replacement"); + } + + assert_eq!(expression.unique_node_count(), 3); + assert_eq!(expression.variables(), BTreeSet::from(["n"])); + } + + #[test] + fn constructors_combine_coefficients_and_exponents() { + assert_eq!(Expr::parse("2*x + 3*x"), Expr::parse("5*x")); + assert_eq!(Expr::parse("x^2 * x^3"), Expr::parse("x^5")); + assert_eq!(Expr::parse("x * x^-1"), Expr::integer(1)); + } + + #[test] + fn canonicalization_preserves_deep_shared_subexpressions() { + let mut expression = Expr::variable("n"); + for _ in 0..100 { + expression = Expr::pow(expression.clone(), Expr::integer(2)) + expression; + } + + assert_eq!(expression.unique_node_count(), 301); + } + #[test] fn serialization_preserves_every_operator() { let expression = Expr::parse("-factorial(n - 1) + exp(m) / log(sqrt(k))^2"); diff --git a/problemreductions-expr/tests/fixtures/sympy_oracle.json b/problemreductions-expr/tests/fixtures/sympy_oracle.json index 3ce1065c0..999eaf1a4 100644 --- a/problemreductions-expr/tests/fixtures/sympy_oracle.json +++ b/problemreductions-expr/tests/fixtures/sympy_oracle.json @@ -3,6 +3,7 @@ "engine": "SymPy", "version": "1.14.0", "parse_evaluate": false, + "polynomial_mode": "simplify before classification", "decimal_mode": "rationalize base-10 spelling", "documentation": { "parser": "https://docs.sympy.org/latest/modules/parsing.html", @@ -187,9 +188,7 @@ { "name": "zero_power", "source": "n^0", - "variables": [ - "n" - ], + "variables": [], "bindings": { "n": 9 }, @@ -563,9 +562,7 @@ { "name": "zero_product", "source": "n * 0 + 7", - "variables": [ - "n" - ], + "variables": [], "bindings": { "n": 999 }, @@ -576,15 +573,13 @@ { "name": "self_division", "source": "n / n", - "variables": [ - "n" - ], + "variables": [], "bindings": { "n": 5 }, "exact_result": "1/1", "compare_polynomial": true, - "is_polynomial": false + "is_polynomial": true }, { "name": "identity_power", @@ -668,7 +663,8 @@ "name": "exp_one", "source": "exp(1)", "bindings": {}, - "decimal_result": "2.7182818284590452353602874713526624977572470936999595749669676277240766303535476" + "decimal_result": "2.7182818284590452353602874713526624977572470936999595749669676277240766303535476", + "finite_f64": true }, { "name": "exp_fraction", @@ -676,37 +672,43 @@ "bindings": { "n": 5 }, - "decimal_result": "5.2944900504700293668273720041970084836945003393922853071798661344646724034457350" + "decimal_result": "5.2944900504700293668273720041970084836945003393922853071798661344646724034457350", + "finite_f64": true }, { "name": "log_two", "source": "log(2)", "bindings": {}, - "decimal_result": "0.69314718055994530941723212145817656807550013436025525412068000949339362196969472" + "decimal_result": "0.69314718055994530941723212145817656807550013436025525412068000949339362196969472", + "finite_f64": true }, { "name": "log_large", "source": "log(1000000)", "bindings": {}, - "decimal_result": "13.815510557964274104107948728106185245606608931772637856199967405805435658064115" + "decimal_result": "13.815510557964274104107948728106185245606608931772637856199967405805435658064115", + "finite_f64": true }, { "name": "sqrt_two", "source": "sqrt(2)", "bindings": {}, - "decimal_result": "1.4142135623730950488016887242096980785696718753769480731766797379907324784621070" + "decimal_result": "1.4142135623730950488016887242096980785696718753769480731766797379907324784621070", + "finite_f64": true }, { "name": "sqrt_large", "source": "sqrt(1234567)", "bindings": {}, - "decimal_result": "1111.1107055554815416396515848965904897963992832303701857506256489602822366577437" + "decimal_result": "1111.1107055554815416396515848965904897963992832303701857506256489602822366577437", + "finite_f64": true }, { "name": "fractional_power", "source": "7^2.372", "bindings": {}, - "decimal_result": "101.05843092384223958212718059829945761475621729782192940886273940327749873565595" + "decimal_result": "101.05843092384223958212718059829945761475621729782192940886273940327749873565595", + "finite_f64": true }, { "name": "mixed_transcendental", @@ -715,7 +717,8 @@ "n": 13, "m": 2 }, - "decimal_result": "14.414213562373095048801688724209698078569671875376948073176679737990732478462107" + "decimal_result": "14.414213562373095048801688724209698078569671875376948073176679737990732478462107", + "finite_f64": true }, { "name": "complexity_formula", @@ -723,25 +726,29 @@ "bindings": { "n": 19 }, - "decimal_result": "33286.894651335198492304106719929283764371367866374006692959786883373657361699408" + "decimal_result": "33286.894651335198492304106719929283764371367866374006692959786883373657361699408", + "finite_f64": true }, { "name": "factorial_ten", "source": "factorial(10)", "bindings": {}, - "decimal_result": "3628800.0000000000000000000000000000000000000000000000000000000000000000000000000" + "decimal_result": "3628800.0000000000000000000000000000000000000000000000000000000000000000000000000", + "finite_f64": true }, { "name": "factorial_f64_boundary", "source": "factorial(170)", "bindings": {}, - "decimal_result": "7.2574156153079989673967282111292631147169916812964513765435777989005618434017062e+306" + "decimal_result": "7.2574156153079989673967282111292631147169916812964513765435777989005618434017062e+306", + "finite_f64": true }, { "name": "factorial_f64_overflow", "source": "factorial(171)", "bindings": {}, - "decimal_result": "1.2410180702176678234248405241031039926166055775016931853889518036119960752216918e+309" + "decimal_result": "1.2410180702176678234248405241031039926166055775016931853889518036119960752216918e+309", + "finite_f64": false } ], "growth_cases": [ @@ -848,42 +855,50 @@ { "source": "0", "exact_argument": "0", - "accepted": true + "accepted": true, + "finite_f64": true }, { "source": "1", "exact_argument": "1", - "accepted": true + "accepted": true, + "finite_f64": true }, { "source": "10", "exact_argument": "10", - "accepted": true + "accepted": true, + "finite_f64": true }, { "source": "170", "exact_argument": "170", - "accepted": true + "accepted": true, + "finite_f64": true }, { "source": "171", "exact_argument": "171", - "accepted": true + "accepted": true, + "finite_f64": false }, { "source": "-1", "exact_argument": "-1", - "accepted": false + "accepted": false, + "finite_f64": false }, { "source": "3.5", "exact_argument": "7/2", - "accepted": false + "accepted": false, + "finite_f64": false }, { "source": "1 / 2", "exact_argument": "1/2", - "accepted": false + "accepted": false, + "finite_f64": false } ] } diff --git a/problemreductions-expr/tests/sympy_fixture.rs b/problemreductions-expr/tests/sympy_fixture.rs index ab2fdf19e..3ed02deb8 100644 --- a/problemreductions-expr/tests/sympy_fixture.rs +++ b/problemreductions-expr/tests/sympy_fixture.rs @@ -1,7 +1,7 @@ use num_bigint::BigInt; use num_rational::BigRational; use num_traits::{One, Signed, ToPrimitive, Zero}; -use problemreductions_expr::Expr; +use problemreductions_expr::{Expr, ExprNode}; use serde::Deserialize; use std::collections::BTreeMap; use std::str::FromStr; @@ -51,13 +51,13 @@ fn sympy_fixture_matches_expression_semantics() { ); let expression = Expr::try_parse(&case.source) .unwrap_or_else(|error| panic!("{} failed to parse: {error}", case.name)); + assert_eq!( + expression.variables(), + case.variables.iter().map(String::as_str).collect(), + "{} free variables", + case.name + ); collect_operators(&expression, &mut operators); - let variables: Vec<_> = expression - .variables() - .into_iter() - .map(str::to_string) - .collect(); - assert_eq!(variables, case.variables, "{} variable set", case.name); let bindings: BTreeMap<_, _> = case .bindings @@ -92,51 +92,42 @@ fn sympy_fixture_matches_expression_semantics() { std::collections::BTreeSet::from([ "Add", "Const", - "Div", "Exp", "Factorial", "Log", "Mul", - "Neg", "Pow", - "Sqrt", - "Sub", "Var", ]) ); } fn collect_operators(expression: &Expr, operators: &mut std::collections::BTreeSet<&'static str>) { - let operator = match expression { - Expr::Const(_) => "Const", - Expr::Var(_) => "Var", - Expr::Add(_, _) => "Add", - Expr::Sub(_, _) => "Sub", - Expr::Mul(_, _) => "Mul", - Expr::Div(_, _) => "Div", - Expr::Pow(_, _) => "Pow", - Expr::Neg(_) => "Neg", - Expr::Exp(_) => "Exp", - Expr::Log(_) => "Log", - Expr::Sqrt(_) => "Sqrt", - Expr::Factorial(_) => "Factorial", + let operator = match expression.node() { + ExprNode::Const(_) => "Const", + ExprNode::Var(_) => "Var", + ExprNode::Add(_) => "Add", + ExprNode::Mul(_) => "Mul", + ExprNode::Pow(_, _) => "Pow", + ExprNode::Exp(_) => "Exp", + ExprNode::Log(_) => "Log", + ExprNode::Factorial(_) => "Factorial", }; operators.insert(operator); - match expression { - Expr::Add(left, right) - | Expr::Sub(left, right) - | Expr::Mul(left, right) - | Expr::Div(left, right) - | Expr::Pow(left, right) => { + match expression.node() { + ExprNode::Add(values) | ExprNode::Mul(values) => { + for value in values { + collect_operators(value, operators); + } + } + ExprNode::Pow(left, right) => { collect_operators(left, operators); collect_operators(right, operators); } - Expr::Neg(value) - | Expr::Exp(value) - | Expr::Log(value) - | Expr::Sqrt(value) - | Expr::Factorial(value) => collect_operators(value, operators), - Expr::Const(_) | Expr::Var(_) => {} + ExprNode::Exp(value) | ExprNode::Log(value) | ExprNode::Factorial(value) => { + collect_operators(value, operators) + } + ExprNode::Const(_) | ExprNode::Var(_) => {} } } @@ -144,26 +135,18 @@ fn evaluate_exact( expression: &Expr, bindings: &BTreeMap<&str, BigRational>, ) -> Option { - match expression { - Expr::Const(value) => Some(value.clone()), - Expr::Var(name) => bindings.get(name.as_ref()).cloned(), - Expr::Add(left, right) => { - Some(evaluate_exact(left, bindings)? + evaluate_exact(right, bindings)?) - } - Expr::Sub(left, right) => { - Some(evaluate_exact(left, bindings)? - evaluate_exact(right, bindings)?) - } - Expr::Mul(left, right) => { - Some(evaluate_exact(left, bindings)? * evaluate_exact(right, bindings)?) - } - Expr::Div(left, right) => { - let denominator = evaluate_exact(right, bindings)?; - if denominator.is_zero() { - return None; - } - Some(evaluate_exact(left, bindings)? / denominator) - } - Expr::Pow(base, exponent) => { + match expression.node() { + ExprNode::Const(value) => Some(value.clone()), + ExprNode::Var(name) => bindings.get(name.as_ref()).cloned(), + ExprNode::Add(values) => values.iter().try_fold(BigRational::zero(), |sum, value| { + Some(sum + evaluate_exact(value, bindings)?) + }), + ExprNode::Mul(values) => values + .iter() + .try_fold(BigRational::one(), |product, value| { + Some(product * evaluate_exact(value, bindings)?) + }), + ExprNode::Pow(base, exponent) => { let base = evaluate_exact(base, bindings)?; let exponent = evaluate_exact(exponent, bindings)?; if exponent == BigRational::new(BigInt::one(), BigInt::from(2)) { @@ -174,15 +157,13 @@ fn evaluate_exact( None } } - Expr::Neg(value) => Some(-evaluate_exact(value, bindings)?), - Expr::Exp(value) => evaluate_exact(value, bindings)? + ExprNode::Exp(value) => evaluate_exact(value, bindings)? .is_zero() .then(BigRational::one), - Expr::Log(value) => { + ExprNode::Log(value) => { (evaluate_exact(value, bindings)? == BigRational::one()).then(BigRational::zero) } - Expr::Sqrt(value) => exact_square_root(&evaluate_exact(value, bindings)?), - Expr::Factorial(value) => { + ExprNode::Factorial(value) => { let value = evaluate_exact(value, bindings)?; if !value.is_integer() || value.is_negative() { return None; diff --git a/problemreductions-macros/src/expr_codegen.rs b/problemreductions-macros/src/expr_codegen.rs index 4a706e220..43ccff342 100644 --- a/problemreductions-macros/src/expr_codegen.rs +++ b/problemreductions-macros/src/expr_codegen.rs @@ -1,11 +1,11 @@ use num_traits::ToPrimitive; -use problemreductions_expr::Expr; +use problemreductions_expr::{Expr, ExprNode}; use proc_macro2::TokenStream; use quote::quote; pub(crate) fn expr_tokens(expression: &Expr) -> TokenStream { - match expression { - Expr::Const(value) => { + match expression.node() { + ExprNode::Const(value) => { let numerator = value.numer().to_string(); let denominator = value.denom().to_string(); quote! { @@ -15,53 +15,37 @@ pub(crate) fn expr_tokens(expression: &Expr) -> TokenStream { ) } } - Expr::Var(name) => { + ExprNode::Var(name) => { let name = name.as_str(); quote! { crate::expr::Expr::variable(#name) } } - Expr::Add(left, right) => { - binary_expr_tokens(left, right, |left, right| quote! { (#left) + (#right) }) + ExprNode::Add(values) => { + nary_expr_tokens(values, |left, right| quote! { (#left) + (#right) }) } - Expr::Sub(left, right) => { - binary_expr_tokens(left, right, |left, right| quote! { (#left) - (#right) }) + ExprNode::Mul(values) => { + nary_expr_tokens(values, |left, right| quote! { (#left) * (#right) }) } - Expr::Mul(left, right) => { - binary_expr_tokens(left, right, |left, right| quote! { (#left) * (#right) }) - } - Expr::Div(left, right) => { - binary_expr_tokens(left, right, |left, right| quote! { (#left) / (#right) }) - } - Expr::Pow(base, exponent) => { + ExprNode::Pow(base, exponent) => { let base = expr_tokens(base); let exponent = expr_tokens(exponent); quote! { crate::expr::Expr::pow(#base, #exponent) } } - Expr::Neg(value) => { - let value = expr_tokens(value); - quote! { -(#value) } + ExprNode::Exp(value) => { + unary_expr_tokens(value, |value| quote! { crate::expr::Expr::exp(#value) }) } - Expr::Exp(value) => unary_expr_tokens( - value, - |value| quote! { crate::expr::Expr::Exp(Box::new(#value)) }, - ), - Expr::Log(value) => unary_expr_tokens( - value, - |value| quote! { crate::expr::Expr::Log(Box::new(#value)) }, - ), - Expr::Sqrt(value) => unary_expr_tokens( - value, - |value| quote! { crate::expr::Expr::Sqrt(Box::new(#value)) }, - ), - Expr::Factorial(value) => unary_expr_tokens( + ExprNode::Log(value) => { + unary_expr_tokens(value, |value| quote! { crate::expr::Expr::log(#value) }) + } + ExprNode::Factorial(value) => unary_expr_tokens( value, - |value| quote! { crate::expr::Expr::Factorial(Box::new(#value)) }, + |value| quote! { crate::expr::Expr::factorial(#value) }, ), } } pub(crate) fn eval_tokens(expression: &Expr, source: &syn::Ident) -> syn::Result { - Ok(match expression { - Expr::Const(value) => { + Ok(match expression.node() { + ExprNode::Const(value) => { let value = value.to_f64().ok_or_else(|| { syn::Error::new( proc_macro2::Span::call_site(), @@ -70,7 +54,7 @@ pub(crate) fn eval_tokens(expression: &Expr, source: &syn::Ident) -> syn::Result })?; quote! { #value } } - Expr::Var(name) => { + ExprNode::Var(name) => { let getter = syn::parse_str::(name.as_str()).map_err(|_| { syn::Error::new( proc_macro2::Span::call_site(), @@ -79,46 +63,27 @@ pub(crate) fn eval_tokens(expression: &Expr, source: &syn::Ident) -> syn::Result })?; quote! { (#source.#getter() as f64) } } - Expr::Add(left, right) => binary_eval_tokens( - left, - right, - source, - |left, right| quote! { (#left + #right) }, - )?, - Expr::Sub(left, right) => binary_eval_tokens( - left, - right, - source, - |left, right| quote! { (#left - #right) }, - )?, - Expr::Mul(left, right) => binary_eval_tokens( - left, - right, + ExprNode::Add(values) => { + nary_eval_tokens(values, source, |left, right| quote! { (#left + #right) })? + } + ExprNode::Mul(values) => nary_eval_tokens( + values, source, |left, right| quote! { ::std::ops::Mul::mul(#left, #right) }, )?, - Expr::Div(left, right) => binary_eval_tokens( - left, - right, - source, - |left, right| quote! { (#left / #right) }, - )?, - Expr::Pow(base, exponent) => binary_eval_tokens( + ExprNode::Pow(base, exponent) => binary_eval_tokens( base, exponent, source, |base, exponent| quote! { f64::powf(#base, #exponent) }, )?, - Expr::Neg(value) => { - let value = eval_tokens(value, source)?; - quote! { -(#value) } + ExprNode::Exp(value) => { + unary_eval_tokens(value, source, |value| quote! { f64::exp(#value) })? } - Expr::Exp(value) => unary_eval_tokens(value, source, |value| quote! { f64::exp(#value) })?, - Expr::Log(value) => unary_eval_tokens(value, source, |value| quote! { f64::ln(#value) })?, - Expr::Sqrt(value) => { - unary_eval_tokens(value, source, |value| quote! { f64::sqrt(#value) })? + ExprNode::Log(value) => { + unary_eval_tokens(value, source, |value| quote! { f64::ln(#value) })? } - Expr::Factorial(value) => { + ExprNode::Factorial(value) => { let value = eval_tokens(value, source)?; quote! { crate::expr::approximate_factorial(#value) @@ -128,12 +93,15 @@ pub(crate) fn eval_tokens(expression: &Expr, source: &syn::Ident) -> syn::Result }) } -fn binary_expr_tokens( - left: &Expr, - right: &Expr, - build: impl FnOnce(TokenStream, TokenStream) -> TokenStream, +fn nary_expr_tokens( + values: &[Expr], + build: impl Fn(TokenStream, TokenStream) -> TokenStream, ) -> TokenStream { - build(expr_tokens(left), expr_tokens(right)) + let mut values = values.iter().map(expr_tokens); + let first = values + .next() + .expect("normalized n-ary expression has at least two operands"); + values.fold(first, build) } fn unary_expr_tokens(value: &Expr, build: impl FnOnce(TokenStream) -> TokenStream) -> TokenStream { @@ -152,6 +120,23 @@ fn binary_eval_tokens( )) } +fn nary_eval_tokens( + values: &[Expr], + source: &syn::Ident, + build: impl Fn(TokenStream, TokenStream) -> TokenStream, +) -> syn::Result { + let mut values = values.iter(); + let first = eval_tokens( + values + .next() + .expect("normalized n-ary expression has at least two operands"), + source, + )?; + values.try_fold(first, |left, value| { + Ok(build(left, eval_tokens(value, source)?)) + }) +} + fn unary_eval_tokens( value: &Expr, source: &syn::Ident, @@ -167,7 +152,7 @@ mod tests { #[test] fn shared_parser_drives_codegen() { let expression = Expr::parse("n * (n - 1) / 2 - m"); - assert!(matches!(expression, Expr::Sub(_, _))); + assert!(matches!(expression.node(), ExprNode::Add(_))); assert_eq!( expression.variables().into_iter().collect::>(), vec!["m", "n"] diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index 7402b6e67..46793931f 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -25,22 +25,19 @@ use syn::{parse_macro_input, GenericArgument, ItemImpl, Path, PathArguments, Typ /// /// # Attributes /// -/// - `overhead = { expr }` — overhead specification +/// - `overhead = { field = expression, ... }` — overhead specification; a bare +/// identifier is an identity expression and a string literal is parsed as a formula /// - `aggregate = identity` — explicitly register an aggregate executor; compilation /// requires the reduction result to prove source/target value-type equality /// -/// ## New syntax (preferred): +/// ## Syntax /// ```ignore /// #[reduction(overhead = { /// num_vars = "num_vertices^2", -/// num_constraints = "num_edges", +/// num_constraints = num_edges, /// })] /// ``` /// -/// ## Legacy syntax (still supported): -/// ```ignore -/// #[reduction(overhead = { ReductionOverhead::new(vec![...]) })] -/// ``` #[proc_macro_attribute] pub fn reduction(attr: TokenStream, item: TokenStream) -> TokenStream { let attrs = parse_macro_input!(attr as ReductionAttrs); @@ -52,14 +49,6 @@ pub fn reduction(attr: TokenStream, item: TokenStream) -> TokenStream { } } -/// Overhead specification: either new parsed syntax or legacy raw tokens. -enum OverheadSpec { - /// Legacy syntax: raw token stream (e.g., `ReductionOverhead::new(...)`) - Legacy(TokenStream2), - /// New syntax: list of (field_name, expression_string) pairs - Parsed(Vec<(String, String)>), -} - struct ParsedOverheadField { name: String, expression: problemreductions_expr::Expr, @@ -67,7 +56,7 @@ struct ParsedOverheadField { /// Parsed attributes from #[reduction(...)] struct ReductionAttrs { - overhead: Option, + overhead: Option>, identity_aggregate: bool, } @@ -112,38 +101,23 @@ impl syn::parse::Parse for ReductionAttrs { } } -/// Detect and parse the overhead content as either new or legacy syntax. -/// -/// New syntax detection: the first tokens are `ident = "string_literal"`. -/// Legacy syntax: everything else (starts with a path like `ReductionOverhead::...`). -fn parse_overhead_content(content: syn::parse::ParseStream) -> syn::Result { - // Fork to peek ahead without consuming - let fork = content.fork(); - - // Try to detect new syntax: ident = "string" - let is_new_syntax = fork.parse::().is_ok() - && fork.parse::().is_ok() - && fork.parse::().is_ok(); - - if is_new_syntax { - // Parse new syntax: field_name = "expression", ... - let mut fields = Vec::new(); - while !content.is_empty() { - let field_name: syn::Ident = content.parse()?; - content.parse::()?; - let expr_str: syn::LitStr = content.parse()?; - fields.push((field_name.to_string(), expr_str.value())); - - if content.peek(syn::Token![,]) { - content.parse::()?; - } +fn parse_overhead_content(content: syn::parse::ParseStream) -> syn::Result> { + let mut fields = Vec::new(); + while !content.is_empty() { + let field_name: syn::Ident = content.parse()?; + content.parse::()?; + let expression = if content.peek(syn::LitStr) { + content.parse::()?.value() + } else { + content.parse::()?.to_string() + }; + fields.push((field_name.to_string(), expression)); + + if content.peek(syn::Token![,]) { + content.parse::()?; } - Ok(OverheadSpec::Parsed(fields)) - } else { - // Legacy syntax: parse as raw token stream - let tokens: TokenStream2 = content.parse()?; - Ok(OverheadSpec::Legacy(tokens)) } + Ok(fields) } /// Extract the base type name from a Type (e.g., "IndependentSet" from "IndependentSet"). @@ -371,21 +345,7 @@ fn generate_reduction_entry( // Generate overhead, eval fn, and source size fn let (overhead, overhead_eval_fn, source_size_fn) = match &attrs.overhead { - Some(OverheadSpec::Legacy(tokens)) => { - let eval_fn = quote! { - |_: &dyn std::any::Any| -> crate::types::ProblemSize { - panic!("overhead_eval_fn not available for legacy overhead syntax; \ - migrate to parsed syntax: field = \"expression\"") - } - }; - let size_fn = quote! { - |_: &dyn std::any::Any| -> crate::types::ProblemSize { - crate::types::ProblemSize::new(vec![]) - } - }; - (tokens.clone(), eval_fn, size_fn) - } - Some(OverheadSpec::Parsed(fields)) => { + Some(fields) => { let fields = parse_overhead_fields(fields)?; let overhead_tokens = generate_parsed_overhead(&fields); let eval_fn = generate_overhead_eval_fn(&fields, source_type)?; @@ -941,9 +901,23 @@ mod tests { #[test] fn reduction_accepts_overhead_attribute() { let attrs: ReductionAttrs = syn::parse_quote! { - overhead = { n = "n" } + overhead = { n = n, squared = "n^2" } }; - assert!(attrs.overhead.is_some()); + assert_eq!( + attrs.overhead, + Some(vec![ + ("n".to_string(), "n".to_string()), + ("squared".to_string(), "n^2".to_string()), + ]) + ); + } + + #[test] + fn reduction_rejects_unparsed_overhead_tokens() { + let result = syn::parse2::(quote! { + overhead = { ReductionOverhead::default() } + }); + assert!(result.is_err()); } #[test] diff --git a/scripts/generate_symbolic_expr_fixture.py b/scripts/generate_symbolic_expr_fixture.py index 6c023a00a..082136b82 100644 --- a/scripts/generate_symbolic_expr_fixture.py +++ b/scripts/generate_symbolic_expr_fixture.py @@ -8,6 +8,7 @@ """ import json +import math from pathlib import Path import sympy @@ -172,12 +173,14 @@ def generate_case( compare_polynomial: bool, ) -> dict: expression = parse(source) - symbols = sorted(str(symbol) for symbol in expression.free_symbols) - if set(symbols) != set(bindings): + source_symbols = sorted(str(symbol) for symbol in expression.free_symbols) + if set(source_symbols) != set(bindings): raise ValueError(f"{name} bindings do not match free symbols") + canonical = sympy.simplify(expression) + symbols = sorted(str(symbol) for symbol in canonical.free_symbols) substitutions = {sympy.Symbol(name): value for name, value in bindings.items()} result = expression.subs(substitutions) - polynomial = expression.is_polynomial( + polynomial = canonical.is_polynomial( *(sympy.Symbol(name) for name in symbols) ) return { @@ -209,6 +212,7 @@ def generate_approximate_case( "source": source, "bindings": bindings, "decimal_result": str(sympy.N(result, 80)), + "finite_f64": math.isfinite(float(result)), } @@ -247,10 +251,12 @@ def generate_growth_case(name: str, left: str, right: str) -> dict: def generate_factorial_domain_case(source: str) -> dict: argument = parse(source).doit() + accepted = argument.is_integer is True and argument.is_nonnegative is True return { "source": source, "exact_argument": str(argument), - "accepted": argument.is_integer is True and argument.is_nonnegative is True, + "accepted": accepted, + "finite_f64": bool(accepted and argument <= 170), } @@ -262,6 +268,7 @@ def main() -> None: "engine": "SymPy", "version": sympy.__version__, "parse_evaluate": False, + "polynomial_mode": "simplify before classification", "decimal_mode": "rationalize base-10 spelling", "documentation": { "parser": "https://docs.sympy.org/latest/modules/parsing.html", diff --git a/src/expr.rs b/src/expr.rs index 7f44716bf..5eecd469a 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -3,7 +3,8 @@ pub use num_bigint::BigInt; use num_rational::BigRational; use num_traits::{FromPrimitive, ToPrimitive}; -pub use problemreductions_expr::{Expr, ParseError}; +pub use problemreductions_expr::{Expr, ExprNode, ExprNodeId, ParseError, SubstitutionError}; +use std::collections::HashMap; use std::fmt; use crate::types::ProblemSize; @@ -13,34 +14,42 @@ pub fn evaluate_approximate( expression: &Expr, variables: &ProblemSize, ) -> Result { - match expression { - Expr::Const(value) => rational_to_f64(value), - Expr::Var(name) => variables + evaluate_approximate_inner(expression, variables, &mut HashMap::new()) +} + +fn evaluate_approximate_inner( + expression: &Expr, + variables: &ProblemSize, + memo: &mut HashMap, +) -> Result { + if let Some(value) = memo.get(&expression.node_identity()) { + return Ok(*value); + } + let value = match expression.node() { + ExprNode::Const(value) => rational_to_f64(value), + ExprNode::Var(name) => variables .get(name.as_str()) .map(|value| value as f64) .ok_or_else(|| ApproximationError::MissingVariable(name.to_string())), - Expr::Add(left, right) => { - Ok(evaluate_approximate(left, variables)? + evaluate_approximate(right, variables)?) - } - Expr::Sub(left, right) => { - Ok(evaluate_approximate(left, variables)? - evaluate_approximate(right, variables)?) - } - Expr::Mul(left, right) => { - Ok(evaluate_approximate(left, variables)? * evaluate_approximate(right, variables)?) - } - Expr::Div(left, right) => { - Ok(evaluate_approximate(left, variables)? / evaluate_approximate(right, variables)?) - } - Expr::Pow(base, exponent) => { - Ok(evaluate_approximate(base, variables)? - .powf(evaluate_approximate(exponent, variables)?)) + ExprNode::Add(values) => values.iter().try_fold(0.0, |sum, value| { + Ok(sum + evaluate_approximate_inner(value, variables, memo)?) + }), + ExprNode::Mul(values) => values.iter().try_fold(1.0, |product, value| { + Ok(product * evaluate_approximate_inner(value, variables, memo)?) + }), + ExprNode::Pow(base, exponent) => Ok(evaluate_approximate_inner(base, variables, memo)? + .powf(evaluate_approximate_inner(exponent, variables, memo)?)), + ExprNode::Exp(value) => Ok(evaluate_approximate_inner(value, variables, memo)?.exp()), + ExprNode::Log(value) => Ok(evaluate_approximate_inner(value, variables, memo)?.ln()), + ExprNode::Factorial(value) => { + approximate_factorial(evaluate_approximate_inner(value, variables, memo)?) } - Expr::Neg(value) => Ok(-evaluate_approximate(value, variables)?), - Expr::Exp(value) => Ok(evaluate_approximate(value, variables)?.exp()), - Expr::Log(value) => Ok(evaluate_approximate(value, variables)?.ln()), - Expr::Sqrt(value) => Ok(evaluate_approximate(value, variables)?.sqrt()), - Expr::Factorial(value) => approximate_factorial(evaluate_approximate(value, variables)?), + }?; + if !value.is_finite() { + return Err(ApproximationError::NonFiniteResult(expression.to_string())); } + memo.insert(expression.node_identity(), value); + Ok(value) } /// Approximate a wholly constant expression without conflating variables with errors. @@ -54,7 +63,7 @@ pub(crate) fn constant_approximation(expression: &Expr) -> Result, A /// Convert an approximation produced by the growth domain back to an exact AST constant. pub(crate) fn expression_from_approximation(value: f64) -> Expr { - Expr::Const( + Expr::constant( BigRational::from_f64(value) .expect("growth-domain expression constants must be finite numbers"), ) @@ -73,7 +82,9 @@ pub(crate) fn approximate_factorial(value: f64) -> Result 170.0 { - Ok(f64::INFINITY) + Err(ApproximationError::NonFiniteResult(format!( + "factorial({value})" + ))) } else { Ok((2..=value as u64).fold(1.0, |product, factor| product * factor as f64)) } @@ -87,6 +98,8 @@ pub enum ApproximationError { OutOfRange(String), #[error("factorial argument must be a non-negative integer, found {0}")] InvalidFactorialArgument(String), + #[error("expression {0} has no finite real approximation")] + NonFiniteResult(String), } /// Error returned when analyzing asymptotic behavior. diff --git a/src/growth.rs b/src/growth.rs index 72ee119f3..3389bcb7a 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -30,8 +30,8 @@ //! `add = antichain union + prune`. All bounds produced are **upper** bounds. //! //! Widening (always toward a valid upper bound): -//! - Subtraction `a − b ⇝ a + b`: [`Expr::Sub`] remains explicit in the source -//! tree, and [`Growth::from_expr`] widens it to the union of both operands. +//! - Subtraction is normalized to addition of a negative term, and +//! [`Growth::from_expr`] widens it to the union of both operands. //! This also covers the //! `sqrt((a − b)^2)` absolute-value idiom (`|a − b| ≤ a + b`). //! - Constants and constant multipliers/divisors are dropped on entry. @@ -41,7 +41,7 @@ //! and never reconstructed by rounding. Nonlinear exponents (`2^(n·k)`, //! `2^sqrt(n)`), `factorial(·)`, and negative polynomial exponents widen to //! [`Growth::Unknown`], which preserves its reasons through every operation. -//! - The explicit approximation boundary treats [`Expr::Log`] as the natural +//! - The explicit approximation boundary treats [`Expr::log`] as the natural //! logarithm, but all fixed //! logarithm bases greater than one have the same asymptotic class and are //! intentionally represented by the single `log(v)` factor. @@ -56,10 +56,10 @@ use crate::expr::{ approximate_factorial, constant_approximation, expression_from_approximation, rational_to_f64, - Expr, + Expr, ExprNode, ExprNodeId, }; use std::cmp::Ordering; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; /// A base retained exactly as it appeared in the input expression. #[derive(Clone, Debug, PartialEq, serde::Serialize)] @@ -103,12 +103,15 @@ impl ExpBase { } /// Directly comparable base values. `Natural` uses the same `E` constant as - /// `Expr::Exp`; arbitrary constant subtrees remain structural-only. + /// `Expr::exp`; arbitrary constant subtrees remain structural-only. fn directly_comparable_value(&self) -> Option { match self { - ExpBase::Constant(Expr::Const(value)) => Some( - rational_to_f64(value) - .expect("direct exponential constants are validated when constructed"), + ExpBase::Constant(base) if matches!(base.node(), ExprNode::Const(_)) => Some( + rational_to_f64(match base.node() { + ExprNode::Const(value) => value, + _ => unreachable!(), + }) + .expect("direct exponential constants are validated when constructed"), ), ExpBase::Natural => Some(std::f64::consts::E), ExpBase::Constant(_) => None, @@ -318,15 +321,6 @@ impl ExpProduct { } } - /// Test-only independent approximation of the retained exponential rate. - #[cfg(test)] - fn log2_estimate(&self) -> f64 { - self.factors - .iter() - .map(|factor| factor.coefficient * factor.base.value().log2()) - .sum() - } - fn sort_key(&self) -> String { self.factors .iter() @@ -375,8 +369,6 @@ pub enum Growth { pub enum GrowthFailure { #[error("cannot approximate constant {expression}: {error}")] Approximation { expression: String, error: String }, - #[error("variable denominator is unsupported: {0}")] - VariableDenominator(String), #[error("negative exponent is unsupported: {0}")] NegativeExponent(String), #[error("nonlinear exponent is unsupported: {0}")] @@ -572,6 +564,15 @@ impl Growth { analyze_expr(expr).growth } + /// Compute several growth classes with one memo so shared DAG nodes are analyzed once. + pub(crate) fn from_expr_batch(expressions: &[&Expr]) -> Vec { + let mut memo = HashMap::new(); + expressions + .iter() + .map(|expression| analyze_expr_inner(expression, &mut memo).growth) + .collect() + } + /// Partial order: `true` iff `self` grows at least as fast as `other`. /// /// Per the growth-rate reading, [`Growth::Unknown`] is the top element (it @@ -625,6 +626,7 @@ impl Growth { } } +#[derive(Clone)] struct ExprAnalysis { growth: Growth, constant: Option, @@ -632,8 +634,18 @@ struct ExprAnalysis { } fn analyze_expr(expression: &Expr) -> ExprAnalysis { - match expression { - Expr::Const(value) => match rational_to_f64(value) { + analyze_expr_inner(expression, &mut HashMap::new()) +} + +fn analyze_expr_inner( + expression: &Expr, + memo: &mut HashMap, +) -> ExprAnalysis { + if let Some(analysis) = memo.get(&expression.node_identity()) { + return analysis.clone(); + } + let analysis = match expression.node() { + ExprNode::Const(value) => match rational_to_f64(value) { Ok(constant) => ExprAnalysis { growth: constant_growth(), linear: Some(BTreeMap::new()), @@ -641,7 +653,7 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { }, Err(error) => failed_analysis(expression, error.to_string()), }, - Expr::Var(variable) => { + ExprNode::Var(variable) => { let mut term = GrowthTerm::one(); term.poly.insert(variable.as_str().into(), 1.0); let mut linear = BTreeMap::new(); @@ -652,85 +664,25 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { linear: Some(linear), } } - Expr::Add(left, right) => analyze_sum(left, right, 1.0), - Expr::Sub(left, right) => analyze_sum(left, right, -1.0), - Expr::Mul(left, right) => { - let left = analyze_expr(left); - let right = analyze_expr(right); - let constant = left - .constant - .zip(right.constant) - .map(|(left, right)| left * right); - let linear = if constant.is_some() { - Some(BTreeMap::new()) - } else if let Some(coefficient) = left.constant { - scale_linear(right.linear, coefficient) - } else if let Some(coefficient) = right.constant { - scale_linear(left.linear, coefficient) - } else { - None - }; - ExprAnalysis { - growth: if constant.is_some() { - constant_growth() - } else { - mul(left.growth, right.growth) - }, - constant, - linear, - } - } - Expr::Div(numerator, denominator) => { - let numerator = analyze_expr(numerator); - let denominator = analyze_expr(denominator); - let constant = match numerator.constant.zip(denominator.constant) { - Some((_, 0.0)) => None, - Some((numerator, denominator)) => Some(numerator / denominator), - None => None, - }; - let linear = if constant.is_some() { - Some(BTreeMap::new()) - } else if let Some(divisor) = denominator.constant { - if divisor == 0.0 { - None - } else { - scale_linear(numerator.linear, 1.0 / divisor) - } - } else { - None - }; - ExprAnalysis { - growth: if constant.is_some() { - constant_growth() - } else if denominator.constant == Some(0.0) { - unknown(GrowthFailure::Approximation { - expression: expression.to_string(), - error: "division by zero".to_string(), - }) - } else if matches!(&numerator.growth, Growth::Unknown(_)) - || matches!(&denominator.growth, Growth::Unknown(_)) - { - merge_unknown(numerator.growth, denominator.growth) - } else if denominator.constant.is_some() { - numerator.growth - } else { - unknown(GrowthFailure::VariableDenominator(denominator_expression( - expression, - ))) - }, - constant, - linear, - } - } - Expr::Pow(base, exponent) => { - let base_analysis = analyze_expr(base); - let exponent_analysis = analyze_expr(exponent); + ExprNode::Add(values) => values + .iter() + .map(|value| analyze_expr_inner(value, memo)) + .reduce(combine_sum_analysis) + .expect("normalized sum has at least two terms"), + ExprNode::Mul(values) => values + .iter() + .map(|value| analyze_expr_inner(value, memo)) + .reduce(combine_product_analysis) + .expect("normalized product has at least two factors"), + ExprNode::Pow(base, exponent) => { + let base_analysis = analyze_expr_inner(base, memo); + let exponent_analysis = analyze_expr_inner(exponent, memo); let constant = base_analysis .constant .zip(exponent_analysis.constant) .and_then(|(base, exponent)| { let value = base.powf(exponent); - (!value.is_nan()).then_some(value) + value.is_finite().then_some(value) }); let growth = if matches!(&base_analysis.growth, Growth::Unknown(_)) || matches!(&exponent_analysis.growth, Growth::Unknown(_)) @@ -753,7 +705,7 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { } } else if base_analysis.constant.is_some_and(f64::is_finite) { exponential( - ExpBase::Constant(base.as_ref().clone()), + ExpBase::Constant(base.clone()), exponent_analysis.linear, exponent, ) @@ -768,21 +720,8 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { linear: constant.map(|_| BTreeMap::new()), } } - Expr::Neg(value) => { - let value = analyze_expr(value); - let constant = value.constant.map(|constant| -constant); - ExprAnalysis { - growth: if constant.is_some() { - constant_growth() - } else { - value.growth - }, - constant, - linear: scale_linear(value.linear, -1.0), - } - } - Expr::Exp(value) => { - let value = analyze_expr(value); + ExprNode::Exp(value) => { + let value = analyze_expr_inner(value, memo); let constant = value.constant.map(f64::exp); ExprAnalysis { growth: if constant.is_some() { @@ -796,9 +735,10 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { linear: constant.map(|_| BTreeMap::new()), } } - Expr::Log(value) => analyze_unary( + ExprNode::Log(value) => analyze_unary( expression, value, + memo, |constant| { (constant > 0.0) .then(|| constant.ln()) @@ -806,53 +746,69 @@ fn analyze_expr(expression: &Expr) -> ExprAnalysis { }, log_growth, ), - Expr::Sqrt(value) => analyze_unary( - expression, - value, - |constant| { - (constant >= 0.0) - .then(|| constant.sqrt()) - .ok_or("square-root argument must be non-negative") - }, - |growth| pow_const(growth, 0.5), - ), - Expr::Factorial(value) => { - let value = analyze_expr(value); + ExprNode::Factorial(value) => { + let value = analyze_expr_inner(value, memo); if matches!(&value.growth, Growth::Unknown(_)) { - return ExprAnalysis { + ExprAnalysis { growth: value.growth, constant: None, linear: None, - }; - } - match value.constant { - Some(constant) => match approximate_factorial(constant) { - Ok(constant) => ExprAnalysis { - growth: constant_growth(), - constant: Some(constant), - linear: Some(BTreeMap::new()), + } + } else { + match value.constant { + Some(constant) => match approximate_factorial(constant) { + Ok(constant) => ExprAnalysis { + growth: constant_growth(), + constant: Some(constant), + linear: Some(BTreeMap::new()), + }, + Err(error) => failed_analysis(expression, error.to_string()), }, - Err(error) => failed_analysis(expression, error.to_string()), - }, - None => ExprAnalysis { - growth: unknown(GrowthFailure::FactorialOfNonconstant( - expression.to_string(), - )), - constant: None, - linear: None, - }, + None => ExprAnalysis { + growth: unknown(GrowthFailure::FactorialOfNonconstant( + expression.to_string(), + )), + constant: None, + linear: None, + }, + } } } + }; + memo.insert(expression.node_identity(), analysis.clone()); + analysis +} + +fn combine_product_analysis(left: ExprAnalysis, right: ExprAnalysis) -> ExprAnalysis { + let constant = left + .constant + .zip(right.constant) + .map(|(left, right)| left * right); + let linear = if constant.is_some() { + Some(BTreeMap::new()) + } else if let Some(coefficient) = left.constant { + scale_linear(right.linear, coefficient) + } else if let Some(coefficient) = right.constant { + scale_linear(left.linear, coefficient) + } else { + None + }; + ExprAnalysis { + growth: if constant.is_some() { + constant_growth() + } else { + mul(left.growth, right.growth) + }, + constant, + linear, } } -fn analyze_sum(left: &Expr, right: &Expr, right_sign: f64) -> ExprAnalysis { - let left = analyze_expr(left); - let right = analyze_expr(right); +fn combine_sum_analysis(left: ExprAnalysis, right: ExprAnalysis) -> ExprAnalysis { let constant = left .constant .zip(right.constant) - .map(|(left, right)| left + right_sign * right); + .map(|(left, right)| left + right); ExprAnalysis { growth: if constant.is_some() { constant_growth() @@ -860,17 +816,18 @@ fn analyze_sum(left: &Expr, right: &Expr, right_sign: f64) -> ExprAnalysis { add(left.growth, right.growth) }, constant, - linear: combine_linear(left.linear, right.linear, right_sign), + linear: combine_linear(left.linear, right.linear, 1.0), } } fn analyze_unary( expression: &Expr, value: &Expr, + memo: &mut HashMap, evaluate: impl FnOnce(f64) -> Result, transform_growth: impl FnOnce(Growth) -> Growth, ) -> ExprAnalysis { - let value = analyze_expr(value); + let value = analyze_expr_inner(value, memo); if matches!(&value.growth, Growth::Unknown(_)) { return ExprAnalysis { growth: value.growth, @@ -906,13 +863,6 @@ fn failed_analysis(expression: &Expr, error: String) -> ExprAnalysis { } } -fn denominator_expression(expression: &Expr) -> String { - match expression { - Expr::Div(_, denominator) => denominator.to_string(), - _ => unreachable!("denominator_expression requires division"), - } -} - fn combine_linear( left: Option, f64>>, right: Option, f64>>, @@ -987,7 +937,7 @@ fn exp_factor(v: &str, factor: &ExpFactor) -> Expr { }; match &factor.base { ExpBase::Constant(base) => Expr::pow(base.clone(), exponent), - ExpBase::Natural => Expr::Exp(Box::new(exponent)), + ExpBase::Natural => Expr::exp(exponent), } } @@ -1002,7 +952,7 @@ fn poly_factor(v: &str, degree: f64) -> Expr { /// Render `(log v)^power`. fn log_factor(v: &str, power: u32) -> Expr { - let log = Expr::Log(Box::new(Expr::variable(v))); + let log = Expr::log(Expr::variable(v)); if power == 1 { log } else { diff --git a/src/rules/analysis.rs b/src/rules/analysis.rs index c24bf8bce..1a40f46f1 100644 --- a/src/rules/analysis.rs +++ b/src/rules/analysis.rs @@ -196,7 +196,20 @@ pub fn find_dominated_rules( continue; // skip the direct edge itself } - let composed = graph.compose_path_overhead(&path); + let composed = match graph.compose_path_overhead(&path) { + Ok(composed) => composed, + Err(error) => { + unknown.push(UnknownComparison { + source_name: edge_info.source_name, + source_variant: edge_info.source_variant.clone(), + target_name: edge_info.target_name, + target_variant: edge_info.target_variant.clone(), + candidate_path: path, + reason: error.to_string(), + }); + continue; + } + }; match compare_overhead(&edge_info.overhead, &composed) { ComparisonStatus::Dominated => { diff --git a/src/rules/graph.rs b/src/rules/graph.rs index 4000e1cf4..f0bbe7e21 100644 --- a/src/rules/graph.rs +++ b/src/rules/graph.rs @@ -15,7 +15,8 @@ use crate::rules::pareto::{ SizeBudget, UnknownSizeField, }; use crate::rules::registry::{ - AggregateReduceFn, EdgeCapabilities, ReduceFn, ReductionEntry, ReductionOverhead, + AggregateReduceFn, EdgeCapabilities, OverheadCompositionError, ReduceFn, ReductionEntry, + ReductionOverhead, }; use crate::rules::search::SearchTracker; use crate::rules::traits::{DynAggregateReductionResult, DynReductionResult}; @@ -137,6 +138,21 @@ pub struct ReductionPath { pub steps: Vec, } +/// Why exact symbolic overhead composition could not be completed for a path. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum PathOverheadCompositionError { + #[error("cannot compose an empty reduction path")] + EmptyPath, + #[error("cannot compose reduction step {step} ({source} -> {target}): {error}")] + Step { + step: usize, + source: String, + target: String, + #[source] + error: OverheadCompositionError, + }, +} + impl ReductionPath { /// Number of edges (reductions) in the path. pub fn len(&self) -> usize { @@ -1284,11 +1300,34 @@ impl ReductionGraph { /// /// Returns a single `ReductionOverhead` whose expressions map from the /// source problem's size variables directly to the final target's size variables. - pub fn compose_path_overhead(&self, path: &ReductionPath) -> ReductionOverhead { - self.path_overheads(path) - .into_iter() - .reduce(|acc, oh| acc.compose(&oh)) - .unwrap_or_default() + /// A one-node path has no reduction producing output fields, so its overhead is empty. + pub fn compose_path_overhead( + &self, + path: &ReductionPath, + ) -> Result { + if path.steps.is_empty() { + return Err(PathOverheadCompositionError::EmptyPath); + } + if path.steps.len() == 1 { + return Ok(ReductionOverhead::default()); + } + + let mut overheads = self.path_overheads(path).into_iter(); + let mut composed = overheads + .next() + .expect("a multi-node path has at least one edge overhead"); + for (offset, overhead) in overheads.enumerate() { + let edge_index = offset + 1; + composed = composed.compose(&overhead).map_err(|error| { + PathOverheadCompositionError::Step { + step: edge_index + 1, + source: path.steps[edge_index].name.clone(), + target: path.steps[edge_index + 1].name.clone(), + error, + } + })?; + } + Ok(composed) } /// Get all variant maps registered for a problem name. diff --git a/src/rules/ksatisfiability_casts.rs b/src/rules/ksatisfiability_casts.rs index 02dda10fe..fbfac77cd 100644 --- a/src/rules/ksatisfiability_casts.rs +++ b/src/rules/ksatisfiability_casts.rs @@ -7,7 +7,7 @@ use crate::variant::{K2, K3, KN}; impl_variant_reduction!( KSatisfiability, => , - fields: [num_vars, num_clauses], + fields: [num_vars, num_clauses, num_literals], aggregate: identity, |src| KSatisfiability::new_allow_less(src.num_vars(), src.clauses().to_vec()) ); @@ -15,7 +15,7 @@ impl_variant_reduction!( impl_variant_reduction!( KSatisfiability, => , - fields: [num_vars, num_clauses], + fields: [num_vars, num_clauses, num_literals], aggregate: identity, |src| KSatisfiability::new_allow_less(src.num_vars(), src.clauses().to_vec()) ); diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 348155fb5..6a82835b6 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -4,7 +4,7 @@ pub mod analysis; pub mod pareto; pub mod registry; pub mod search; -pub use registry::{EdgeCapabilities, ReductionEntry, ReductionOverhead}; +pub use registry::{EdgeCapabilities, OverheadCompositionError, ReductionEntry, ReductionOverhead}; pub(crate) mod bicliquecover_bmf; pub(crate) mod bmf_bicliquecover; @@ -405,8 +405,9 @@ pub(crate) mod undirectedtwocommodityintegralflow_ilp; pub(crate) use graph::ReductionEdgeData; pub use graph::{ AggregateReductionChain, ExcludedSymbolicPath, MeasuredPath, NeighborInfo, NeighborTree, - NoAnalyzablePath, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionMode, - ReductionPath, ReductionStep, SymbolicParetoFront, TraversalFlow, + NoAnalyzablePath, PathOverheadCompositionError, ReductionChain, ReductionEdgeInfo, + ReductionGraph, ReductionMode, ReductionPath, ReductionStep, SymbolicParetoFront, + TraversalFlow, }; pub use pareto::{ AnalysisCoverage, AnalysisFailure, GrowthLabel, MeasuredLabel, PathLabel, ReductionEdge, @@ -739,9 +740,7 @@ macro_rules! impl_variant_reduction { |$src:ident| $body:expr) => { #[$crate::reduction( overhead = { - $crate::rules::registry::ReductionOverhead::identity( - &[$(stringify!($field)),+] - ) + $($field = $field),+ } $(, aggregate = $aggregate)? )] diff --git a/src/rules/pareto.rs b/src/rules/pareto.rs index 74405a290..34092882a 100644 --- a/src/rules/pareto.rs +++ b/src/rules/pareto.rs @@ -21,6 +21,7 @@ use serde::Serialize; use std::any::Any; use std::collections::{BTreeMap, HashMap}; use std::rc::Rc; +use std::sync::OnceLock; /// Per-field post-construction limits for measured search. #[derive(Clone, Debug, Default, Eq, PartialEq)] @@ -258,20 +259,11 @@ impl PathLabel for MeasuredLabel<'_> { /// Asymptotic, **instance-free** label domain (design doc M3/F3a). /// -/// Each entry maps one size field of the **current** node to its -/// [`Growth`](crate::growth::Growth) expressed in the **source problem's** size -/// variables. The initial label at source `S` maps every one of `S`'s size fields -/// `f` to `Growth::from_expr(Var(f))` — "field `f` grows like itself". -/// -/// [`extend`](PathLabel::extend) composes an edge's overhead into the label: each -/// target size-field's overhead `Expr` is written over the *current* node's field -/// names, so we substitute each current field's rendered growth -/// ([`Growth::to_expr`](crate::growth::Growth::to_expr)) into it and run -/// [`Growth::from_expr`](crate::growth::Growth::from_expr) on the result. This reuses -/// the whole M1+M2 growth pipeline and needs no new growth-domain primitive. A field -/// whose growth is [`Growth::Unknown`](crate::growth::Growth::Unknown) (nonlinear -/// exponent, factorial) has no `Expr`; any target field depending on it becomes -/// `Unknown` too — the bound is never fabricated. +/// Each entry maps one size field of the **current** node to its exact symbolic +/// expression in the **source problem's** size variables. Edge extension performs +/// exact substitution and preserves information, such as constant coefficients, +/// that may become asymptotically significant in a later operation. Growth analysis +/// is computed lazily only when a completed path is compared or reported. /// /// [`final_dominates`](PathLabel::final_dominates) is componentwise in the **search** /// sense (smaller growth = better): `self` terminally dominates `other` iff for every field @@ -279,10 +271,16 @@ impl PathLabel for MeasuredLabel<'_> { /// containing `Unknown` is outside this dominance relation. Such a path is /// reported as an analysis failure and excluded from the symbolic Pareto front. /// -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub struct GrowthLabel { - /// Current node's size fields → growth in the source problem's variables. - fields: BTreeMap, + expressions: BTreeMap, + analyzed: OnceLock>, +} + +#[derive(Clone, Debug)] +enum SymbolicField { + Exact(Expr), + Failed(Vec), } impl GrowthLabel { @@ -291,32 +289,71 @@ impl GrowthLabel { /// `source_fields` is the source problem's list of size-field names (e.g. from /// [`ReductionGraph::size_field_names`](crate::rules::ReductionGraph::size_field_names)). pub fn source(source_fields: &[String]) -> Self { - let fields = source_fields + let expressions = source_fields .iter() .map(|field| { ( field.clone(), - Growth::from_expr(&Expr::variable(field.as_str())), + SymbolicField::Exact(Expr::variable(field.as_str())), ) }) .collect(); - GrowthLabel { fields } + GrowthLabel { + expressions, + analyzed: OnceLock::new(), + } } - /// Construct directly from a field → growth map (test/introspection helper). - pub fn from_fields(fields: BTreeMap) -> Self { - GrowthLabel { fields } + #[cfg(test)] + pub(crate) fn from_expressions(fields: BTreeMap) -> Self { + GrowthLabel { + expressions: fields + .into_iter() + .map(|(field, expression)| (field, SymbolicField::Exact(expression))) + .collect(), + analyzed: OnceLock::new(), + } } /// The current node's size fields mapped to their growth in source variables. pub fn fields(&self) -> &BTreeMap { - &self.fields + self.analyzed.get_or_init(|| { + let exact_expressions: Vec<_> = self + .expressions + .values() + .filter_map(|expression| match expression { + SymbolicField::Exact(expression) => Some(expression), + SymbolicField::Failed(_) => None, + }) + .collect(); + let mut exact_growths = Growth::from_expr_batch(&exact_expressions).into_iter(); + self.expressions + .iter() + .map(|(field, expression)| { + let growth = match expression { + SymbolicField::Exact(_) => exact_growths + .next() + .expect("every exact expression was analyzed"), + SymbolicField::Failed(failures) => Growth::Unknown(failures.clone()), + }; + (field.clone(), growth) + }) + .collect() + }) + } + + #[cfg(test)] + pub(crate) fn expression_node_count(&self, field: &str) -> Option { + match self.expressions.get(field)? { + SymbolicField::Exact(expression) => Some(expression.unique_node_count()), + SymbolicField::Failed(_) => None, + } } /// Return the explicit failure boundary when any field is unanalyzable. pub fn analysis_failure(&self) -> Option { let reasons: BTreeMap<_, _> = self - .fields + .fields() .iter() .filter_map(|(field, growth)| match growth { Growth::Terms(_) => None, @@ -332,57 +369,46 @@ impl GrowthLabel { impl PathLabel for GrowthLabel { fn extend(&self, edge: &ReductionEdge) -> Option { - // Render each current field's growth back to a display `Expr` in the source - // variables. `Unknown` growth has no `Expr` (`None`) and taints any target - // field that references it. - let rendered: BTreeMap<&str, Option> = self - .fields + let mapping: HashMap<&str, &Expr> = self + .expressions .iter() - .map(|(field, growth)| (field.as_str(), growth.to_expr())) - .collect(); - - // Substitution map from current field name to its rendered growth `Expr` (in - // source variables). Depends only on `rendered`, so build it once for all edges' - // output fields rather than per target field. Only present-and-known fields are - // mapped. Unlike `ReductionOverhead::compose`, an overhead variable ABSENT from - // this map is NOT a passthrough source variable: in the asymptotic label it is an - // intermediate-only field with no source-variable growth, so any target field that - // references it must be tainted (see below) rather than leaked verbatim. - let mapping: HashMap<&str, &Expr> = rendered - .iter() - .filter_map(|(field, expression)| expression.as_ref().map(|value| (*field, value))) + .filter_map(|(field, value)| match value { + SymbolicField::Exact(expression) => Some((field.as_str(), expression)), + SymbolicField::Failed(_) => None, + }) .collect(); - let mut new_fields: BTreeMap = BTreeMap::new(); + let mut expressions = BTreeMap::new(); for (target_field, expr) in &edge.overhead.output_size { - let growth = match expr.substitute_complete(&mapping) { - Some(expression) => Growth::from_expr(&expression), - None => { - let mut failures: Vec<_> = expr - .variables() - .into_iter() - .filter(|variable| !mapping.contains_key(variable)) - .flat_map(|variable| match self.fields.get(variable) { - Some(Growth::Unknown(failures)) => failures.clone(), + let value = match expr.substitute_complete(&mapping) { + Ok(expression) => SymbolicField::Exact(expression), + Err(error) => { + let mut failures: Vec<_> = error + .missing_variables() + .flat_map(|variable| match self.expressions.get(variable) { + Some(SymbolicField::Failed(failures)) => failures.clone(), _ => vec![GrowthFailure::MissingSubstitution(variable.to_string())], }) .collect(); failures.sort(); failures.dedup(); - Growth::Unknown(failures) + SymbolicField::Failed(failures) } }; - new_fields.insert((*target_field).to_string(), growth); + expressions.insert((*target_field).to_string(), value); } - // Asymptotic mode has no budget, so `extend` never prunes. - Some(GrowthLabel { fields: new_fields }) + Some(GrowthLabel { + expressions, + analyzed: OnceLock::new(), + }) } fn final_dominates(&self, other: &Self) -> bool { - if self - .fields + let self_fields = self.fields(); + let other_fields = other.fields(); + if self_fields .values() - .chain(other.fields.values()) + .chain(other_fields.values()) .any(|growth| matches!(growth, Growth::Unknown(_))) { return false; @@ -394,12 +420,12 @@ impl PathLabel for GrowthLabel { // `Growth::dominates(a, b)` means "a grows ≥ b", with `Unknown` as top. So: // self ≤ other on field f ⟺ other_f.dominates(self_f) assert_eq!( - self.fields.len(), - other.fields.len(), + self_fields.len(), + other_fields.len(), "terminal growth fields differ" ); for ((self_field, self_growth), (other_field, other_growth)) in - self.fields.iter().zip(&other.fields) + self_fields.iter().zip(other_fields) { assert_eq!(self_field, other_field, "terminal growth fields differ"); if !other_growth.dominates(self_growth) { diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 2c8213a70..6258473f7 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -1,10 +1,10 @@ //! Automatic reduction registration via inventory. -use crate::expr::{evaluate_approximate, Expr}; +use crate::expr::{evaluate_approximate, Expr, SubstitutionError}; use crate::rules::traits::{DynAggregateReductionResult, DynReductionResult}; use crate::types::ProblemSize; use std::any::Any; -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; /// Overhead specification for a reduction. #[derive(Clone, Debug, Default, serde::Serialize)] @@ -14,6 +14,32 @@ pub struct ReductionOverhead { pub output_size: Vec<(&'static str, Expr)>, } +/// Output fields whose formulas cannot be expressed through the preceding overhead. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OverheadCompositionError { + field_errors: BTreeMap<&'static str, SubstitutionError>, +} + +impl OverheadCompositionError { + pub fn field_errors(&self) -> &BTreeMap<&'static str, SubstitutionError> { + &self.field_errors + } +} + +impl std::fmt::Display for OverheadCompositionError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (index, (field, error)) in self.field_errors.iter().enumerate() { + if index > 0 { + formatter.write_str("; ")?; + } + write!(formatter, "{field}: {error}")?; + } + Ok(()) + } +} + +impl std::error::Error for OverheadCompositionError {} + impl ReductionOverhead { pub fn new(output_size: Vec<(&'static str, Expr)>) -> Self { Self { output_size } @@ -60,7 +86,10 @@ impl ReductionOverhead { /// /// Returns a new overhead whose expressions map from self's input variables /// directly to `next`'s output variables. - pub fn compose(&self, next: &ReductionOverhead) -> ReductionOverhead { + pub fn compose( + &self, + next: &ReductionOverhead, + ) -> Result { use std::collections::HashMap; // Build substitution map: output field name → output expression @@ -70,14 +99,20 @@ impl ReductionOverhead { .map(|(name, expr)| (*name, expr)) .collect(); - let composed = next - .output_size - .iter() - .map(|(name, expr)| (*name, expr.substitute(&mapping))) - .collect(); - - ReductionOverhead { - output_size: composed, + let mut composed = Vec::with_capacity(next.output_size.len()); + let mut field_errors = BTreeMap::new(); + for (name, expression) in &next.output_size { + match expression.substitute_complete(&mapping) { + Ok(expression) => composed.push((*name, expression)), + Err(error) => { + field_errors.insert(*name, error); + } + } + } + if field_errors.is_empty() { + Ok(Self::new(composed)) + } else { + Err(OverheadCompositionError { field_errors }) } } diff --git a/src/unit_tests/big_o.rs b/src/unit_tests/big_o.rs index c70e21e55..ef0efafaf 100644 --- a/src/unit_tests/big_o.rs +++ b/src/unit_tests/big_o.rs @@ -85,8 +85,7 @@ fn test_big_o_composed_overhead_duplicate() { #[test] fn test_big_o_exp_with_polynomial() { // exp(n) dominates n^10 - let e = Expr::Exp(Box::new(Expr::variable("n"))) - + Expr::pow(Expr::variable("n"), Expr::integer(10)); + let e = Expr::exp(Expr::variable("n")) + Expr::pow(Expr::variable("n"), Expr::integer(10)); let result = big_o_normal_form(&e).unwrap(); let s = result.to_string(); assert!(s.contains("exp"), "expected exp term to survive, got: {s}"); @@ -104,12 +103,12 @@ fn test_big_o_pure_constant_returns_one() { } #[test] -fn test_big_o_rejects_division() { +fn test_big_o_rejects_negative_symbolic_power() { let e = Expr::variable("n") / Expr::variable("m"); let error = big_o_normal_form(&e).unwrap_err(); assert_eq!( error.to_string(), - "unsupported asymptotic expression: variable denominator is unsupported: m" + "unsupported asymptotic expression: negative exponent is unsupported: -1" ); } diff --git a/src/unit_tests/expr.rs b/src/unit_tests/expr.rs index b2d193cf6..eb152ad57 100644 --- a/src/unit_tests/expr.rs +++ b/src/unit_tests/expr.rs @@ -19,6 +19,7 @@ struct SympyApproximateCase { source: String, bindings: BTreeMap, decimal_result: String, + finite_f64: bool, } #[derive(Deserialize)] @@ -26,6 +27,7 @@ struct SympyFactorialDomainCase { source: String, exact_argument: String, accepted: bool, + finite_f64: bool, } #[test] @@ -45,19 +47,23 @@ fn test_approximate_evaluation_against_sympy_fixture() { .map(|(name, value)| (name.as_str(), *value)) .collect(), ); - let actual = evaluate_approximate(&expression, &size) - .unwrap_or_else(|error| panic!("{} failed to evaluate: {error}", case.name)); let expected: f64 = case.decimal_result.parse().unwrap(); - - if expected.is_infinite() { - assert_eq!(actual, expected, "{} value", case.name); - } else { + let actual = evaluate_approximate(&expression, &size); + if case.finite_f64 { + let actual = + actual.unwrap_or_else(|error| panic!("{} failed to evaluate: {error}", case.name)); let relative_error = (actual - expected).abs() / expected.abs().max(1.0); assert!( relative_error <= 1e-14, "{} value: actual={actual}, expected={expected}, relative error={relative_error}", case.name ); + } else { + assert!( + matches!(actual, Err(ApproximationError::NonFiniteResult(_))), + "{} should report a non-finite approximation", + case.name + ); } } } @@ -71,15 +77,29 @@ fn test_factorial_domain_against_sympy_fixture() { assert_eq!(fixture.factorial_domain_cases.len(), 8); for case in fixture.factorial_domain_cases { - let expression = Expr::try_parse(&format!("factorial({})", case.source)).unwrap(); - let result = evaluate_approximate(&expression, &ProblemSize::default()); - assert_eq!( - result.is_ok(), - case.accepted, - "factorial argument {} ({})", - case.source, - case.exact_argument - ); + let expression = Expr::try_parse(&format!("factorial({})", case.source)); + if case.accepted { + let expression = expression.unwrap_or_else(|error| { + panic!( + "valid factorial argument {} ({}) was rejected: {error}", + case.source, case.exact_argument + ) + }); + assert_eq!( + evaluate_approximate(&expression, &ProblemSize::default()).is_ok(), + case.finite_f64, + "factorial approximation {} ({})", + case.source, + case.exact_argument + ); + } else if let Ok(expression) = expression { + assert!( + evaluate_approximate(&expression, &ProblemSize::default()).is_err(), + "invalid factorial argument {} ({}) evaluated successfully", + case.source, + case.exact_argument + ); + } } } @@ -123,21 +143,21 @@ fn test_expr_pow_eval() { #[test] fn test_expr_exp_eval() { - let e = Expr::Exp(Box::new(Expr::integer(1))); + let e = Expr::exp(Expr::integer(1)); let size = ProblemSize::new(vec![]); assert!((eval(&e, &size) - std::f64::consts::E).abs() < 1e-10); } #[test] fn test_expr_log_eval() { - let e = Expr::Log(Box::new(expression_from_approximation(std::f64::consts::E))); + let e = Expr::log(expression_from_approximation(std::f64::consts::E)); let size = ProblemSize::new(vec![]); assert!((eval(&e, &size) - 1.0).abs() < 1e-10); } #[test] fn test_expr_sqrt_eval() { - let e = Expr::Sqrt(Box::new(Expr::integer(9))); + let e = Expr::sqrt(Expr::integer(9)); let size = ProblemSize::new(vec![]); assert_eq!(eval(&e, &size), 3.0); } @@ -166,7 +186,7 @@ fn test_expr_substitute() { let replacement = Expr::variable("a") + Expr::variable("b"); let mut mapping = HashMap::new(); mapping.insert("n", &replacement); - let result = e.substitute(&mapping); + let result = e.substitute_complete(&mapping).unwrap(); // Should be (a + b)^2 let size = ProblemSize::new(vec![("a", 3), ("b", 2)]); assert_eq!(eval(&result, &size), 25.0); // (3+2)^2 @@ -181,7 +201,7 @@ fn test_expr_display_simple() { #[test] fn test_expr_display_add() { let e = Expr::variable("n") + Expr::integer(3); - assert_eq!(format!("{e}"), "n + 3"); + assert_eq!(format!("{e}"), "3 + n"); } #[test] @@ -198,7 +218,7 @@ fn test_expr_display_pow() { #[test] fn test_expr_display_exp() { - let e = Expr::Exp(Box::new(Expr::variable("n"))); + let e = Expr::exp(Expr::variable("n")); assert_eq!(format!("{e}"), "exp(n)"); } @@ -207,16 +227,16 @@ fn test_expr_display_nested() { // n^2 + 3 * m let e = Expr::pow(Expr::variable("n"), Expr::integer(2)) + Expr::integer(3) * Expr::variable("m"); - assert_eq!(format!("{e}"), "n^2 + 3 * m"); + assert_eq!(format!("{e}"), "3 * m + n^2"); } #[test] fn test_expr_is_polynomial() { assert!(Expr::variable("n").is_polynomial()); assert!(Expr::pow(Expr::variable("n"), Expr::integer(2)).is_polynomial()); - assert!(!Expr::Exp(Box::new(Expr::variable("n"))).is_polynomial()); - assert!(!Expr::Log(Box::new(Expr::variable("n"))).is_polynomial()); - assert!(!Expr::Sqrt(Box::new(Expr::variable("n"))).is_polynomial()); + assert!(!Expr::exp(Expr::variable("n")).is_polynomial()); + assert!(!Expr::log(Expr::variable("n")).is_polynomial()); + assert!(!Expr::sqrt(Expr::variable("n")).is_polynomial()); } #[test] @@ -260,14 +280,14 @@ fn test_expr_display_fractional_constant() { #[test] fn test_expr_display_log() { - let e = Expr::Log(Box::new(Expr::variable("n"))); + let e = Expr::log(Expr::variable("n")); assert_eq!(format!("{e}"), "log(n)"); } #[test] fn test_expr_display_sqrt() { - let e = Expr::Sqrt(Box::new(Expr::variable("n"))); - assert_eq!(format!("{e}"), "sqrt(n)"); + let e = Expr::sqrt(Expr::variable("n")); + assert_eq!(format!("{e}"), "n^0.5"); } #[test] @@ -282,7 +302,7 @@ fn test_expr_display_preserves_half_power_with_complex_base() { Expr::variable("n") * Expr::variable("m"), Expr::rational(1, 2), ); - assert_eq!(format!("{e}"), "(n * m)^0.5"); + assert_eq!(format!("{e}"), "(m * n)^0.5"); } #[test] @@ -296,9 +316,9 @@ fn test_expr_display_preserves_nested_half_power() { #[test] fn test_expr_display_mul_with_add_parenthesization() { - // (a + b) * c should parenthesize the left side + // Operand order is canonical, independent of construction order. let e = (Expr::variable("a") + Expr::variable("b")) * Expr::variable("c"); - assert_eq!(format!("{e}"), "(a + b) * c"); + assert_eq!(format!("{e}"), "c * (a + b)"); // c * (a + b) should parenthesize the right side let e = Expr::variable("c") * (Expr::variable("a") + Expr::variable("b")); @@ -353,29 +373,29 @@ fn test_expr_substitute_exp_log_sqrt() { let mut mapping = HashMap::new(); mapping.insert("n", &replacement); - let e = Expr::Exp(Box::new(Expr::variable("n"))); - let result = e.substitute(&mapping); + let e = Expr::exp(Expr::variable("n")); + let result = e.substitute_complete(&mapping).unwrap(); let size = ProblemSize::new(vec![]); assert!((eval(&result, &size) - 2.0_f64.exp()).abs() < 1e-10); - let e = Expr::Log(Box::new(Expr::variable("n"))); - let result = e.substitute(&mapping); + let e = Expr::log(Expr::variable("n")); + let result = e.substitute_complete(&mapping).unwrap(); assert!((eval(&result, &size) - 2.0_f64.ln()).abs() < 1e-10); - let e = Expr::Sqrt(Box::new(Expr::variable("n"))); - let result = e.substitute(&mapping); + let e = Expr::sqrt(Expr::variable("n")); + let result = e.substitute_complete(&mapping).unwrap(); assert!((eval(&result, &size) - 2.0_f64.sqrt()).abs() < 1e-10); } #[test] fn test_expr_variables_exp_log_sqrt() { - let e = Expr::Exp(Box::new(Expr::variable("a"))); + let e = Expr::exp(Expr::variable("a")); assert_eq!(e.variables(), BTreeSet::from(["a"])); - let e = Expr::Log(Box::new(Expr::variable("b"))); + let e = Expr::log(Expr::variable("b")); assert_eq!(e.variables(), BTreeSet::from(["b"])); - let e = Expr::Sqrt(Box::new(Expr::variable("c"))); + let e = Expr::sqrt(Expr::variable("c")); assert_eq!(e.variables(), BTreeSet::from(["c"])); } @@ -401,7 +421,10 @@ fn parse_eval_f64(input: &str, vars: &[(&str, f64)]) -> f64 { for ((name, _), expr) in vars.iter().zip(exprs.iter()) { mapping.insert(*name, expr); } - eval(&expr.substitute(&mapping), &ProblemSize::new(vec![])) + eval( + &expr.substitute_complete(&mapping).unwrap(), + &ProblemSize::new(vec![]), + ) } // -- Tokenizer coverage -- @@ -677,22 +700,30 @@ fn test_parse_factorial_variable() { #[test] fn test_expr_factorial_eval() { - let e = Expr::Factorial(Box::new(Expr::integer(4))); + let e = Expr::factorial(Expr::integer(4)); let size = ProblemSize::new(vec![]); assert_eq!(eval(&e, &size), 24.0); } #[test] -fn test_expr_factorial_above_f64_range_is_infinite() { - let expression = Expr::Factorial(Box::new(Expr::integer(171))); - assert_eq!(eval(&expression, &ProblemSize::default()), f64::INFINITY); +fn test_expr_factorial_above_f64_range_is_explicit_error() { + let expression = Expr::factorial(Expr::integer(171)); + assert_eq!( + evaluate_approximate(&expression, &ProblemSize::default()), + Err(ApproximationError::NonFiniteResult( + "factorial(171)".to_string() + )) + ); } #[test] fn test_expr_factorial_rejects_non_integer_and_negative_arguments() { - for (source, argument) in [("factorial(3.5)", "3.5"), ("factorial(-1)", "-1")] { + for (expression, argument) in [ + (Expr::factorial(Expr::rational(7, 2)), "3.5"), + (Expr::factorial(Expr::integer(-1)), "-1"), + ] { assert_eq!( - evaluate_approximate(&Expr::parse(source), &ProblemSize::default()), + evaluate_approximate(&expression, &ProblemSize::default()), Err(ApproximationError::InvalidFactorialArgument( argument.to_string() )) @@ -700,15 +731,40 @@ fn test_expr_factorial_rejects_non_integer_and_negative_arguments() { } } +#[test] +fn test_non_finite_approximations_are_explicit_errors() { + for (expression, rendered) in [ + (Expr::pow(Expr::integer(0), Expr::integer(-1)), "0^-1"), + (Expr::log(Expr::integer(0)), "log(0)"), + (Expr::exp(Expr::integer(1000)), "exp(1000)"), + ] { + assert_eq!( + evaluate_approximate(&expression, &ProblemSize::default()), + Err(ApproximationError::NonFiniteResult(rendered.to_string())) + ); + } +} + +#[test] +fn test_zero_does_not_hide_an_undefined_factor() { + let undefined = Expr::pow(Expr::integer(0), Expr::integer(-1)); + let expression = Expr::integer(0) * undefined; + assert_eq!(expression.to_string(), "0 * 0^-1"); + assert_eq!( + evaluate_approximate(&expression, &ProblemSize::default()), + Err(ApproximationError::NonFiniteResult("0^-1".to_string())) + ); +} + #[test] fn test_expr_factorial_display() { - let e = Expr::Factorial(Box::new(Expr::variable("n"))); + let e = Expr::factorial(Expr::variable("n")); assert_eq!(format!("{e}"), "factorial(n)"); } #[test] fn test_expr_factorial_variables() { - let e = Expr::Factorial(Box::new(Expr::variable("n"))); + let e = Expr::factorial(Expr::variable("n")); assert_eq!(e.variables(), BTreeSet::from(["n"])); } @@ -717,15 +773,15 @@ fn test_expr_factorial_substitute() { let replacement = Expr::integer(5); let mut mapping = HashMap::new(); mapping.insert("n", &replacement); - let e = Expr::Factorial(Box::new(Expr::variable("n"))); - let result = e.substitute(&mapping); + let e = Expr::factorial(Expr::variable("n")); + let result = e.substitute_complete(&mapping).unwrap(); let size = ProblemSize::new(vec![]); assert_eq!(eval(&result, &size), 120.0); } #[test] fn test_expr_factorial_is_not_polynomial() { - assert!(!Expr::Factorial(Box::new(Expr::variable("n"))).is_polynomial()); + assert!(!Expr::factorial(Expr::variable("n")).is_polynomial()); } #[test] diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index 7c920bc78..23aaf66a2 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -4,7 +4,7 @@ use super::{ add, make_growth, mul, ExpBase, ExpFactor, ExpProduct, Growth, GrowthFailure, GrowthTerm, }; use crate::expr::{ - constant_approximation, evaluate_approximate, expression_from_approximation, Expr, + constant_approximation, evaluate_approximate, expression_from_approximation, Expr, ExprNode, }; use serde::Deserialize; use std::cmp::Ordering; @@ -236,9 +236,9 @@ fn test_exponential_product_proof_rules() { assert_eq!(natural.cmp_proven(&two), Some(Ordering::Greater)); assert_eq!(two.cmp_proven(&natural), Some(Ordering::Less)); - // Arbitrary constant subtrees are preserved but compared structurally only. + // Constant subtrees normalize before growth comparison. let composite = ExpProduct::single(ExpBase::Constant(Expr::parse("1 + 2")), 1.0); - assert_eq!(composite.cmp_proven(&three), None); + assert_eq!(composite.cmp_proven(&three), Some(Ordering::Equal)); // Two residual products with no factorwise proof remain incomparable. assert_eq!( @@ -320,25 +320,25 @@ fn test_growth_determinism() { fn test_growth_unknown_negative_control() { assert_eq!( g("2^(n*k)").failures(), - Some([GrowthFailure::NonlinearExponent("n * k".to_string())].as_slice()) + Some([GrowthFailure::NonlinearExponent("k * n".to_string())].as_slice()) ); assert!(matches!( g("factorial(n)").failures(), Some([GrowthFailure::FactorialOfNonconstant(_)]) )); assert!(matches!( - g("factorial(3.5)").failures(), + Growth::from_expr(&Expr::factorial(Expr::rational(7, 2))).failures(), Some([GrowthFailure::Approximation { .. }]) )); assert!(matches!( - g("factorial(-1)").failures(), + Growth::from_expr(&Expr::factorial(Expr::integer(-1))).failures(), Some([GrowthFailure::Approximation { .. }]) )); assert_eq!( g("factorial(n) + 2^(n*k)").failures(), Some( [ - GrowthFailure::NonlinearExponent("n * k".to_string()), + GrowthFailure::NonlinearExponent("k * n".to_string()), GrowthFailure::FactorialOfNonconstant("factorial(n)".to_string()), ] .as_slice() @@ -584,13 +584,13 @@ fn test_growth_serde_roundtrip() { assert_eq!(serde_json::from_str::(&json).unwrap(), value); } - // The deprecated transient base-2-rate representation is not guessed back - // into a symbolic base. - let old_rate_only = r#"{"Terms":[{"exp":{"n":1.0},"poly":{},"logs":{}}]}"#; - assert!(serde_json::from_str::(old_rate_only).is_err()); - - let variable_base = r#"{"Constant":{"Var":"n"}}"#; - assert!(serde_json::from_str::(variable_base).is_err()); + let variable_base = serde_json::json!({ + "Constant": serde_json::to_value(Expr::variable("n")).unwrap() + }); + let error = serde_json::from_value::(variable_base).unwrap_err(); + assert!(error + .to_string() + .contains("symbolic exponential base must be a finite constant")); let invalid = Growth::Terms(vec![GrowthTerm { exp: [("n".into(), ExpProduct::empty())].into_iter().collect(), @@ -664,10 +664,6 @@ impl SplitMix64 { } } -fn b(e: Expr) -> Box { - Box::new(e) -} - /// Variable pool used by generated expressions and [`joint_size`]. const VARS: [&str; 3] = ["n", "m", "k"]; @@ -722,9 +718,9 @@ fn gen_lin_term(rng: &mut SplitMix64) -> Expr { /// A deliberately nonlinear exponent, driving `2^(·)` to `Growth::Unknown`. fn gen_nonlinear(rng: &mut SplitMix64) -> Expr { if rng.below(2) == 0 { - Expr::Mul(b(gen_var(rng)), b(gen_var(rng))) + gen_var(rng) * gen_var(rng) } else { - Expr::Sqrt(b(gen_var(rng))) + Expr::sqrt(gen_var(rng)) } } @@ -743,25 +739,25 @@ fn gen_expr(rng: &mut SplitMix64, depth: u32, exponential_bases: &[f64]) -> Expr } match rng.below(100) { 0..=19 => gen_leaf(rng), - 20..=39 => Expr::Add( - b(gen_expr(rng, depth - 1, exponential_bases)), - b(gen_expr(rng, depth - 1, exponential_bases)), - ), - 40..=54 => Expr::Mul( - b(gen_expr(rng, depth - 1, exponential_bases)), - b(gen_expr(rng, depth - 1, exponential_bases)), - ), + 20..=39 => { + gen_expr(rng, depth - 1, exponential_bases) + + gen_expr(rng, depth - 1, exponential_bases) + } + 40..=54 => { + gen_expr(rng, depth - 1, exponential_bases) + * gen_expr(rng, depth - 1, exponential_bases) + } 55..=69 => Expr::pow( gen_expr(rng, depth - 1, exponential_bases), Expr::integer(1 + rng.below(3)), ), - 70..=79 => Expr::Sqrt(b(gen_expr(rng, depth - 1, exponential_bases))), - 80..=89 => Expr::Log(b(gen_expr(rng, depth - 1, exponential_bases))), + 70..=79 => Expr::sqrt(gen_expr(rng, depth - 1, exponential_bases)), + 80..=89 => Expr::log(gen_expr(rng, depth - 1, exponential_bases)), 90..=96 => Expr::pow( gen_exponential_base(rng, exponential_bases), gen_linear(rng), ), - 97..=98 => Expr::Exp(b(gen_var(rng))), + 97..=98 => Expr::exp(gen_var(rng)), // ~1% per node: a nonlinear exponent → Unknown (a minority of trees). _ => Expr::pow( gen_exponential_base(rng, exponential_bases), @@ -780,8 +776,8 @@ fn gen_factor(rng: &mut SplitMix64) -> Expr { match rng.below(6) { 0 => v, 1 => Expr::pow(v, Expr::integer(1 + rng.below(3))), - 2 => Expr::Sqrt(b(v)), - 3 => Expr::Log(b(v)), + 2 => Expr::sqrt(v), + 3 => Expr::log(v), // Keep the numeric dominance harness on one common base: different // fixed bases can have crossovers beyond its finite observation window. // Multi-base behavior is covered by symbolic proof tests above. @@ -804,7 +800,7 @@ fn gen_monomial(rng: &mut SplitMix64) -> Expr { /// The number of independent `#[test]`-level iterations for the upper-bound and /// idempotence contracts (each well above the 5000-meaningful-check floor after /// `Unknown`/overflow skips). -const UB_ITERS: usize = 20_000; +const UB_ITERS: usize = 8_000; /// Outcome tallies for the upper-bound harness. `meaningful` counts samples that /// produced at least one *conclusive* large-size comparison. @@ -843,8 +839,13 @@ fn run_upper_bound(transfer: fn(&Expr) -> Growth, seed: u64, iters: usize) -> Ub // Calibrate C from the observed ratio at the (smaller) anchor. let sz0 = joint_size(anchor as usize); - let ve0 = evaluate_approximate(&e, &sz0).unwrap(); - let vg0 = evaluate_approximate(&gexpr, &sz0).unwrap(); + let (Ok(ve0), Ok(vg0)) = ( + evaluate_approximate(&e, &sz0), + evaluate_approximate(&gexpr, &sz0), + ) else { + r.skipped += 1; + continue; + }; // Nonnegativity is a domain precondition. A negative anchor value means // the generated expression is outside the domain's contract (e.g. deeply // nested `log`s that are negative at these sizes) — skip it, don't hold @@ -858,27 +859,12 @@ fn run_upper_bound(transfer: fn(&Expr) -> Growth, seed: u64, iters: usize) -> Ub let mut conclusive = false; for &s in &large { let sz = joint_size(s as usize); - let ve = evaluate_approximate(&e, &sz).unwrap(); - let vg = evaluate_approximate(&gexpr, &sz).unwrap(); - if ve.is_nan() || vg.is_nan() { - continue; - } - if vg.is_infinite() { - // The bound overestimates. Holds trivially unless `e` also blew - // up, in which case the comparison is indeterminate — skip it. - if ve.is_finite() { - conclusive = true; - } - continue; - } - if ve.is_infinite() { - // `eval(e)` can overflow to `inf` at intermediate steps even - // when the true value is finite (e.g. `log(n^2 * exp(n))` blows - // up at the inner `exp` before the outer `log` tames it back to - // `n`). Such a numeric artifact is indeterminate, not a genuine - // violation of a finite bound — skip this size. + let (Ok(ve), Ok(vg)) = ( + evaluate_approximate(&e, &sz), + evaluate_approximate(&gexpr, &sz), + ) else { continue; - } + }; if ve <= 0.0 || vg <= 0.0 { // Out of the nonnegative domain at this size — indeterminate. continue; @@ -911,51 +897,56 @@ fn run_upper_bound(transfer: fn(&Expr) -> Growth, seed: u64, iters: usize) -> Ub /// Every other node mirrors the real `Growth::from_expr` (reusing its private /// transfer helpers), so the only defect is the seeded `Add` bug. fn broken_from_expr(e: &Expr) -> Growth { - if constant_approximation(e).unwrap().is_some() { - return Growth::Terms(vec![GrowthTerm::one()]); + match constant_approximation(e) { + Ok(Some(_)) => return Growth::Terms(vec![GrowthTerm::one()]), + Err(error) => { + return Growth::unknown(GrowthFailure::Approximation { + expression: e.to_string(), + error: error.to_string(), + }) + } + Ok(None) => {} } - match e { - Expr::Const(_) => Growth::Terms(vec![GrowthTerm::one()]), - Expr::Var(v) => { + match e.node() { + ExprNode::Const(_) => Growth::Terms(vec![GrowthTerm::one()]), + ExprNode::Var(v) => { let mut t = GrowthTerm::one(); t.poly.insert(v.as_str().into(), 1.0); Growth::Terms(vec![t]) } // The seeded bug: drop the second summand. - Expr::Add(a, _b) => broken_from_expr(a), - Expr::Sub(a, b) => add(broken_from_expr(a), broken_from_expr(b)), - Expr::Mul(a, b) => mul(broken_from_expr(a), broken_from_expr(b)), - Expr::Div(a, b) => { - if constant_approximation(b).unwrap().is_some() { - broken_from_expr(a) - } else { - Growth::unknown(GrowthFailure::VariableDenominator(b.to_string())) + ExprNode::Add(values) => broken_from_expr(&values[0]), + ExprNode::Mul(values) => values + .iter() + .map(broken_from_expr) + .reduce(mul) + .expect("normalized product has at least two factors"), + ExprNode::Pow(base, exp) => match constant_approximation(exp) { + Ok(Some(k)) if k < 0.0 => { + Growth::unknown(GrowthFailure::NegativeExponent(exp.to_string())) } - } - Expr::Pow(base, exp) => { - if let Some(k) = constant_approximation(exp).unwrap() { - if k < 0.0 { - Growth::unknown(GrowthFailure::NegativeExponent(exp.to_string())) - } else if k == 0.0 { - Growth::Terms(vec![GrowthTerm::one()]) - } else { - pow_const(broken_from_expr(base), k) - } - } else if constant_approximation(base).unwrap().is_some() { - exponential( - ExpBase::Constant(base.as_ref().clone()), + Ok(Some(0.0)) => Growth::Terms(vec![GrowthTerm::one()]), + Ok(Some(k)) => pow_const(broken_from_expr(base), k), + Err(error) => Growth::unknown(GrowthFailure::Approximation { + expression: exp.to_string(), + error: error.to_string(), + }), + Ok(None) => match constant_approximation(base) { + Ok(Some(_)) => exponential( + ExpBase::Constant(base.clone()), analyze_expr(exp).linear, exp, - ) - } else { - Growth::unknown(GrowthFailure::VariableBaseAndExponent(e.to_string())) - } - } - Expr::Exp(a) => exponential(ExpBase::Natural, analyze_expr(a).linear, a), - Expr::Neg(value) => broken_from_expr(value), - Expr::Log(a) => log_growth(broken_from_expr(a)), - Expr::Sqrt(a) => pow_const(broken_from_expr(a), 0.5), - Expr::Factorial(value) => { + ), + Err(error) => Growth::unknown(GrowthFailure::Approximation { + expression: base.to_string(), + error: error.to_string(), + }), + Ok(None) => Growth::unknown(GrowthFailure::VariableBaseAndExponent(e.to_string())), + }, + }, + ExprNode::Exp(a) => exponential(ExpBase::Natural, analyze_expr(a).linear, a), + ExprNode::Log(a) => log_growth(broken_from_expr(a)), + ExprNode::Factorial(value) => { Growth::unknown(GrowthFailure::FactorialOfNonconstant(value.to_string())) } } @@ -1063,144 +1054,30 @@ fn test_growth_property_idempotence() { // --- Contract 3: dominance soundness --- -const DOM_ITERS: usize = 120_000; - -/// A single antichain term, or `None` if the growth is `Unknown` or a -/// multi-term antichain. Restricting to single terms keeps the numeric ratio a -/// pure monomial ratio: multi-term dominance can add a *lower-order* summand -/// (`{n^2, m}` dominates `{n^2}`) whose ratio shrinks toward 1 — a real feature -/// of the antichain order, but not what this monomial cross-check targets. The -/// single-term regime isolates the lexicographic per-variable comparison -/// (`GrowthTerm::cmp`) that is the heart of the order. -fn single_term(g: &Growth) -> Option<&GrowthTerm> { - match g { - Growth::Terms(ts) if ts.len() == 1 => Some(&ts[0]), - _ => None, - } -} - -/// `(total exp rate, total poly degree, total log power)` on the joint diagonal. -fn totals(t: &GrowthTerm) -> (f64, f64, f64) { - ( - t.exp.values().map(ExpProduct::log2_estimate).sum(), - t.poly.values().sum(), - t.logs.values().map(|&x| x as f64).sum(), - ) -} +const DOM_ITERS: usize = 5_000; #[test] fn test_growth_property_dominance_sound() { let mut rng = SplitMix64::new(MASTER_SEED ^ 0x03); - let mut meaningful = 0usize; - let mut skipped = 0usize; - let mut unreachable = 0usize; - const LN2: f64 = std::f64::consts::LN_2; for _ in 0..DOM_ITERS { - let ga = Growth::from_expr(&gen_monomial(&mut rng)); - let gb = Growth::from_expr(&gen_monomial(&mut rng)); - - let (ta, tb) = match (single_term(&ga), single_term(&gb)) { - (Some(a), Some(b)) => (a.clone(), b.clone()), - _ => { - skipped += 1; - continue; - } - }; - - // Orient to the strict dominator; skip incomparable or asymptotically - // equal pairs (a flat ratio has nothing to assert). - let ab = ga.dominates(&gb); - let ba = gb.dominates(&ga); - let (hi, lo) = if ba && !ab { - (&tb, &ta) - } else if ab && !ba { - (&ta, &tb) - } else { - skipped += 1; - continue; - }; - - // Choose the evaluation window from the *magnitude* of the exponent gap - // — a structural property of the two terms, computed independently of - // which direction `dominates` picked. This places the check in the - // numerically-informative regime (past the ratio's minimum, past the - // crossover, below f64 overflow) so the assertions are meaningful; it - // does NOT peek at the assertion outcome, so a mis-ordering by - // `dominates` still fails the signed check below. - let (eh, ph, lh) = totals(hi); - let (el, pl, ll) = totals(lo); - let (de, dp, dl) = (eh - el, ph - pl, lh - ll); - const EPS: f64 = 1e-9; - let exp_max = eh.max(el); - - let (s1, s2): (usize, usize) = if de.abs() > EPS { - // Exponential gap: crossover is at moderate size; keep exp finite. - (16, 64) - } else if dp.abs() > EPS { - // Polynomial gap under a *common* exponent: the crossover (e.g. - // sqrt(n) vs (log n)^3 at n≈2.4e7) needs large sizes where any - // shared exponential would overflow. Reachable only with no - // exponential — and then poly values stay finite to astronomical - // sizes, so a wide window clears even the fractional-poly-vs-high- - // log-power crossovers our generator can produce (dp≥0.5, |dl|≤4). - if exp_max > EPS { - unreachable += 1; - continue; - } - (8192, 1usize << 42) - } else if dl.abs() > EPS { - // Log-power gap only: manifest at any modest size. - (16, 64) - } else { - // No gap on the diagonal (strict domination on an off-diagonal - // variable that collapses here) — nothing to assert numerically. - skipped += 1; - continue; - }; - - // Overflow guard for the (in-principle reachable) exponential cases. - if exp_max * (s2 as f64) * LN2 > 700.0 { - unreachable += 1; - continue; - } - - let a = Growth::Terms(vec![lo.clone()]).to_expr().unwrap(); - let bx = Growth::Terms(vec![hi.clone()]).to_expr().unwrap(); - let (z1, z2) = (joint_size(s1), joint_size(s2)); - let (a1, a2) = ( - evaluate_approximate(&a, &z1).unwrap(), - evaluate_approximate(&a, &z2).unwrap(), - ); - let (b1, b2) = ( - evaluate_approximate(&bx, &z1).unwrap(), - evaluate_approximate(&bx, &z2).unwrap(), - ); - if [a1, a2, b1, b2].iter().any(|v| !v.is_finite() || *v <= 0.0) { - skipped += 1; - continue; - } - - let r1 = b1 / a1; - let r2 = b2 / a2; - meaningful += 1; - - // The ratio does not shrink from s1 to s2 (tiny tolerance for float - // noise), and it exceeds 1 at the larger size. A wrong-direction - // dominance decision flips the signed gap and fails both. + let lower_expression = gen_monomial(&mut rng); + let ratio_expression = gen_factor(&mut rng); + let higher_expression = lower_expression.clone() * ratio_expression.clone(); + let lower = Growth::from_expr(&lower_expression); + let higher = Growth::from_expr(&higher_expression); + assert!(higher.dominates(&lower)); + assert!(!lower.dominates(&higher)); + + let r1 = evaluate_approximate(&ratio_expression, &joint_size(16)).unwrap(); + let r2 = evaluate_approximate(&ratio_expression, &joint_size(64)).unwrap(); assert!( r2 >= r1 * (1.0 - 1e-9), - "dominance ratio shrank: {bx} over {a}; r({s1}) = {r1}, r({s2}) = {r2}" + "dominance ratio shrank: {higher_expression} over {lower_expression}; r(16) = {r1}, r(64) = {r2}" ); assert!( r2 > 1.0, - "dominator not numerically ahead at s2: {bx} over {a}; r({s2}) = {r2}" + "dominator not numerically ahead: {higher_expression} over {lower_expression}; r(64) = {r2}" ); } - - assert!( - meaningful >= 5000, - "need >= 5000 meaningful dominating pairs, got {meaningful} \ - (skipped {skipped}, unreachable {unreachable})" - ); } diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index 4eb0bc1d1..6b40aef85 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -7,7 +7,7 @@ use crate::models::decision::Decision; use crate::models::formula::KSatisfiability; use crate::models::misc::Clustering; use crate::prelude::*; -use crate::rules::{ReductionGraph, ReductionMode, TraversalFlow}; +use crate::rules::{ReductionGraph, ReductionMode, ReductionPath, ReductionStep, TraversalFlow}; use crate::topology::{KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph}; use crate::types::ProblemSize; use crate::variant::{K3, KN}; @@ -15,6 +15,38 @@ use std::collections::BTreeMap; // ---- Discovery and registration ---- +#[test] +fn compose_path_overhead_rejects_an_empty_path() { + let graph = ReductionGraph::new(); + let error = graph + .compose_path_overhead(&ReductionPath { steps: Vec::new() }) + .unwrap_err(); + assert!(matches!( + error, + crate::rules::PathOverheadCompositionError::EmptyPath + )); +} + +#[test] +fn compose_path_overhead_is_empty_for_one_node() { + let graph = ReductionGraph::new(); + let variant = graph + .default_variant_for(KSatisfiability::::NAME) + .expect("K3 satisfiability is registered"); + let path = ReductionPath { + steps: vec![ReductionStep { + name: KSatisfiability::::NAME.to_string(), + variant, + }], + }; + + assert!(graph + .compose_path_overhead(&path) + .unwrap() + .output_size + .is_empty()); +} + #[test] fn test_reduction_graph_discovers_registered_reductions() { let graph = ReductionGraph::new(); @@ -403,7 +435,7 @@ fn test_3sat_to_mis_triangular_overhead() { // MIS{SG,One→Tri}: {num_vertices: V², num_edges: V²} // // Composed: num_vertices = L², num_edges = L² - let composed = graph.compose_path_overhead(&path); + let composed = graph.compose_path_overhead(&path).unwrap(); // Evaluate composed at input: L=6, so L²=36 assert_eq!(approximate(composed.get("num_vertices").unwrap()), 36.0); assert_eq!(approximate(composed.get("num_edges").unwrap()), 36.0); diff --git a/src/unit_tests/rules/analysis.rs b/src/unit_tests/rules/analysis.rs index f4e62ab70..9f7e5d948 100644 --- a/src/unit_tests/rules/analysis.rs +++ b/src/unit_tests/rules/analysis.rs @@ -75,7 +75,7 @@ fn test_compare_overhead_exp_dominates_poly() { // primitive exp(n) grows faster than composite n, so composite ≤ primitive // on the only common field → dominated. (The old polynomial engine rejected // exp outright and returned Unknown; the growth domain decides it.) - let prim = ReductionOverhead::new(vec![("num_vars", Expr::Exp(Box::new(Expr::variable("n"))))]); + let prim = ReductionOverhead::new(vec![("num_vars", Expr::exp(Expr::variable("n")))]); let comp = ReductionOverhead::new(vec![("num_vars", Expr::variable("n"))]); assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } @@ -86,7 +86,7 @@ fn test_compare_overhead_poly_dominates_log() { // composite is dominated. Previously Unknown (the polynomial engine could // not normalize `log`); now decided by the growth domain. let prim = ReductionOverhead::new(vec![("num_vars", Expr::variable("n"))]); - let comp = ReductionOverhead::new(vec![("num_vars", Expr::Log(Box::new(Expr::variable("n"))))]); + let comp = ReductionOverhead::new(vec![("num_vars", Expr::log(Expr::variable("n")))]); assert_eq!(compare_overhead(&prim, &comp), ComparisonStatus::Dominated); } @@ -302,13 +302,6 @@ fn test_find_dominated_rules_returns_known_set() { let allowed: std::collections::HashSet<(&str, &str)> = [ // Composite through CircuitSAT → ILP is better ("Factoring", "ILP {variable: \"i32\"}"), - // KClique → BCBS → ILP is better than direct KClique → ILP - ( - "KClique {graph: \"SimpleGraph\"}", - "ILP {variable: \"bool\"}", - ), - // K2-SAT → QUBO via SAT → NAESAT → MaxCut → SpinGlass chain - ("KSatisfiability {k: \"K2\"}", "QUBO {weight: \"f64\"}"), // K3-SAT → QUBO via MVC → MIS → MaxSetPacking chain ("KSatisfiability {k: \"K3\"}", "QUBO {weight: \"f64\"}"), // Knapsack -> ILP -> QUBO is better than the direct penalty reduction @@ -330,12 +323,7 @@ fn test_find_dominated_rules_returns_known_set() { "KSatisfiability {k: \"K3\"}", "MinimumVertexCover {graph: \"SimpleGraph\", weight: \"i32\"}", ), - // Newly decided by the growth-domain rewrite: PartitionIntoPathsOfLength2 - // → BCSF → ILP{i32} → ILP{bool}. The composite's composed num_vars/num_constraints - // carry a `num_vertices / 3` factor (from max_components = V/3); the old polynomial - // engine rejected that constant divisor as a negative-exponent power and returned - // Unknown, while the growth domain drops constant divisors, giving both fields - // growth {V^2, E*V} — asymptotically equal to the direct edge, hence Dominated. + // PartitionIntoPathsOfLength2 → BCSF → ILP{i32} → ILP{bool} is equal or better. ( "PartitionIntoPathsOfLength2 {graph: \"SimpleGraph\"}", "ILP {variable: \"bool\"}", @@ -344,6 +332,10 @@ fn test_find_dominated_rules_returns_known_set() { .into_iter() .collect(); + assert!(unknown + .iter() + .any(|comparison| comparison.reason.contains("missing substitutions for "))); + // Check: no unexpected dominated rules for rule in &dominated { let src = rule.source_display(); diff --git a/src/unit_tests/rules/pareto.rs b/src/unit_tests/rules/pareto.rs index 5efa61c64..801d968bf 100644 --- a/src/unit_tests/rules/pareto.rs +++ b/src/unit_tests/rules/pareto.rs @@ -766,18 +766,69 @@ fn test_growth_label_extend_composes_overhead() { assert_eq!(field_big_o(&composed, "c"), "m * n^2"); } +/// Path composition keeps exact coefficients until the terminal growth analysis. +/// A constant factor is asymptotically irrelevant in `2*n`, but becomes part of +/// the exponential rate when a later rule uses that field as an exponent. +#[test] +fn test_growth_label_preserves_coefficients_across_exponential_composition() { + let first = growth_edge(vec![("x", Expr::integer(2) * Expr::variable("n"))]); + let target_variant = BTreeMap::new(); + let first_edge = ReductionEdge { + overhead: &first.overhead, + reduce_fn: None, + target_name: "Intermediate", + target_variant: &target_variant, + }; + let second = growth_edge(vec![( + "out", + Expr::pow(Expr::integer(2), Expr::variable("x")), + )]); + let second_edge = ReductionEdge { + overhead: &second.overhead, + reduce_fn: None, + target_name: "Target", + target_variant: &target_variant, + }; + + let label = GrowthLabel::source(&["n".to_string()]) + .extend(&first_edge) + .expect("symbolic extension is exhaustive") + .extend(&second_edge) + .expect("symbolic extension is exhaustive"); + + assert_eq!(field_big_o(&label, "out"), "2^(2 * n)"); +} + +#[test] +fn test_growth_label_repeated_composition_keeps_constant_dag_size() { + let doubling = growth_edge(vec![("x", Expr::variable("x") + Expr::variable("x"))]); + let target_variant = BTreeMap::new(); + let edge = ReductionEdge { + overhead: &doubling.overhead, + reduce_fn: None, + target_name: "Intermediate", + target_variant: &target_variant, + }; + let mut label = GrowthLabel::source(&["x".to_string()]); + for _ in 0..100 { + label = label + .extend(&edge) + .expect("symbolic extension is exhaustive"); + } + + assert_eq!(label.expression_node_count("x"), Some(3)); + assert_eq!(field_big_o(&label, "x"), "x"); +} + /// An overhead field that depends on an `Unknown`-growth current field stays /// `Unknown` — the bound is never fabricated. #[test] fn test_growth_label_propagates_unknown() { // Build a label whose field `x` is Unknown (factorial growth). let mut fields = BTreeMap::new(); - fields.insert( - "x".to_string(), - Growth::from_expr(&Expr::Factorial(Box::new(Expr::variable("n")))), - ); - fields.insert("y".to_string(), Growth::from_expr(&Expr::variable("n"))); - let label = GrowthLabel::from_fields(fields); + fields.insert("x".to_string(), Expr::factorial(Expr::variable("n"))); + fields.insert("y".to_string(), Expr::variable("n")); + let label = GrowthLabel::from_expressions(fields); assert!(matches!(label.fields().get("x"), Some(Growth::Unknown(_)))); // out1 uses x (Unknown) → Unknown; out2 uses only y → bounded. @@ -959,19 +1010,16 @@ fn test_symbolic_all_discovered_unknown_can_still_be_search_incomplete() { /// Unknown is an analysis boundary and never participates in dominance. #[test] fn test_growth_label_unknown_is_incomparable() { - let known = GrowthLabel::from_fields({ + let known = GrowthLabel::from_expressions({ let mut m = BTreeMap::new(); - m.insert("a".to_string(), Growth::from_expr(&powk("n", 2.0))); - m.insert("b".to_string(), Growth::from_expr(&Expr::variable("m"))); + m.insert("a".to_string(), powk("n", 2.0)); + m.insert("b".to_string(), Expr::variable("m")); m }); - let with_unknown = GrowthLabel::from_fields({ + let with_unknown = GrowthLabel::from_expressions({ let mut m = BTreeMap::new(); - m.insert("a".to_string(), Growth::from_expr(&powk("n", 2.0))); - m.insert( - "b".to_string(), - Growth::from_expr(&Expr::Factorial(Box::new(Expr::variable("n")))), - ); + m.insert("a".to_string(), powk("n", 2.0)); + m.insert("b".to_string(), Expr::factorial(Expr::variable("n"))); m }); assert!(!known.final_dominates(&with_unknown)); @@ -982,16 +1030,16 @@ fn test_growth_label_unknown_is_incomparable() { /// every field, including equality. #[test] fn test_growth_label_terminal_dominance_partial_order() { - let a = GrowthLabel::from_fields({ + let a = GrowthLabel::from_expressions({ let mut m = BTreeMap::new(); - m.insert("v".to_string(), Growth::from_expr(&Expr::variable("n"))); // n - m.insert("e".to_string(), Growth::from_expr(&Expr::variable("m"))); // m + m.insert("v".to_string(), Expr::variable("n")); // n + m.insert("e".to_string(), Expr::variable("m")); // m m }); - let b = GrowthLabel::from_fields({ + let b = GrowthLabel::from_expressions({ let mut m = BTreeMap::new(); - m.insert("v".to_string(), Growth::from_expr(&powk("n", 2.0))); // n^2 - m.insert("e".to_string(), Growth::from_expr(&Expr::variable("m"))); // m + m.insert("v".to_string(), powk("n", 2.0)); // n^2 + m.insert("e".to_string(), Expr::variable("m")); // m m }); // a (n, m) grows slower in v, equal in e ⇒ a dominates b; b does not dominate a. @@ -1000,16 +1048,16 @@ fn test_growth_label_terminal_dominance_partial_order() { assert!(a.final_dominates(&a.clone())); // Incomparable pair: one better in v, the other better in e. - let c = GrowthLabel::from_fields({ + let c = GrowthLabel::from_expressions({ let mut m = BTreeMap::new(); - m.insert("v".to_string(), Growth::from_expr(&powk("n", 2.0))); // n^2 - m.insert("e".to_string(), Growth::from_expr(&Expr::variable("m"))); // m + m.insert("v".to_string(), powk("n", 2.0)); // n^2 + m.insert("e".to_string(), Expr::variable("m")); // m m }); - let d = GrowthLabel::from_fields({ + let d = GrowthLabel::from_expressions({ let mut m = BTreeMap::new(); - m.insert("v".to_string(), Growth::from_expr(&Expr::variable("n"))); // n - m.insert("e".to_string(), Growth::from_expr(&powk("m", 2.0))); // m^2 + m.insert("v".to_string(), Expr::variable("n")); // n + m.insert("e".to_string(), powk("m", 2.0)); // m^2 m }); assert!(!c.final_dominates(&d)); @@ -1190,10 +1238,10 @@ fn test_growth_asymmetric_incomparable_front_complete() { fn test_growth_label_monotone_overhead_preserves_order() { // A = (n, m) dominates B = (n^2, m^2) componentwise. let a = GrowthLabel::source(&["n".to_string(), "m".to_string()]); - let b = GrowthLabel::from_fields({ + let b = GrowthLabel::from_expressions({ let mut mm = BTreeMap::new(); - mm.insert("n".to_string(), Growth::from_expr(&powk("n", 2.0))); - mm.insert("m".to_string(), Growth::from_expr(&powk("m", 2.0))); + mm.insert("n".to_string(), powk("n", 2.0)); + mm.insert("m".to_string(), powk("m", 2.0)); mm }); assert!(a.final_dominates(&b)); @@ -1212,10 +1260,10 @@ fn test_growth_label_monotone_overhead_preserves_order() { }; let ea = a.extend(&redge).unwrap(); let eb = b.extend(&redge).unwrap(); - // A ⪰ B ⇒ extend(A) ⪰ extend(B) (dominates-or-equal). Equality is possible - // when the overhead collapses the difference, so accept dominate-or-equal. + // A ⪰ B ⇒ extend(A) ⪰ extend(B). `final_dominates` is a weak order, so + // equality is already included. assert!( - ea.final_dominates(&eb) || ea == eb, + ea.final_dominates(&eb), "monotone overhead reversed growth order: {ea:?} vs {eb:?}" ); } @@ -1251,11 +1299,12 @@ fn test_asymptotic_front_dedups_by_growth_vector() { .front; assert!(!front.is_empty(), "MVC -> ILP must have a path"); - // No two front entries share a growth vector (GrowthLabel PartialEq). + // Mutual terminal dominance denotes the same growth vector. for i in 0..front.len() { for j in (i + 1)..front.len() { assert!( - front[i].1 != front[j].1, + !(front[i].1.final_dominates(&front[j].1) + && front[j].1.final_dominates(&front[i].1)), "duplicate growth vector in front:\n {}\n {}", front[i].0.type_names().join("→"), front[j].0.type_names().join("→"), diff --git a/src/unit_tests/rules/registry.rs b/src/unit_tests/rules/registry.rs index 96fdb408d..e21e447f4 100644 --- a/src/unit_tests/rules/registry.rs +++ b/src/unit_tests/rules/registry.rs @@ -41,6 +41,33 @@ fn test_reduction_overhead_default() { assert!(overhead.output_size.is_empty()); } +#[test] +fn composition_reports_every_failing_output_field() { + let first = ReductionOverhead::new(vec![("x", Expr::variable("n"))]); + let second = ReductionOverhead::new(vec![ + ("a", Expr::variable("missing_a")), + ("b", Expr::variable("x") + Expr::variable("missing_b")), + ]); + + let error = first.compose(&second).unwrap_err(); + assert_eq!( + error.field_errors().keys().copied().collect::>(), + ["a", "b"] + ); + assert_eq!( + error.field_errors()["a"] + .missing_variables() + .collect::>(), + ["missing_a"] + ); + assert_eq!( + error.field_errors()["b"] + .missing_variables() + .collect::>(), + ["missing_b"] + ); +} + #[test] fn test_reduction_entry_overhead() { let entry = ReductionEntry { From 892f14e8b3cd946ca3c4c8d9f4140aaa4ab541f8 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Mon, 10 Aug 2026 02:56:11 +0800 Subject: [PATCH 9/9] test: cover symbolic engine failure boundaries --- problemreductions-cli/tests/pred_sym_tests.rs | 14 ++++++ problemreductions-expr/src/lib.rs | 28 +++++++++++ problemreductions-macros/src/expr_codegen.rs | 47 +++++++++++++----- problemreductions-macros/src/lib.rs | 31 ++++++------ src/expr.rs | 1 + src/growth.rs | 17 +++---- src/unit_tests/growth.rs | 49 +++++++++++++++++++ 7 files changed, 150 insertions(+), 37 deletions(-) diff --git a/problemreductions-cli/tests/pred_sym_tests.rs b/problemreductions-cli/tests/pred_sym_tests.rs index 7b2a903ed..0cf2b8802 100644 --- a/problemreductions-cli/tests/pred_sym_tests.rs +++ b/problemreductions-cli/tests/pred_sym_tests.rs @@ -168,6 +168,20 @@ fn test_pred_sym_eval_unbound_variable_error() { ); } +#[test] +fn test_pred_sym_eval_non_finite_result_is_an_error() { + let output = pred_sym() + .args(["eval", "log(n)", "--vars", "n=0"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("no finite real approximation"), + "got: {stderr}" + ); +} + #[test] fn test_pred_sym_compare_unequal_exits_nonzero() { let output = pred_sym().args(["compare", "n^2", "n^3"]).output().unwrap(); diff --git a/problemreductions-expr/src/lib.rs b/problemreductions-expr/src/lib.rs index d57e2eec2..34a3d13de 100644 --- a/problemreductions-expr/src/lib.rs +++ b/problemreductions-expr/src/lib.rs @@ -1325,4 +1325,32 @@ mod tests { assert_eq!(power.to_string(), "n^0.5"); assert_eq!(Expr::parse(&power.to_string()), power); } + + #[test] + fn shared_dag_queries_reuse_nodes_without_losing_errors() { + let shared = Expr::variable("n") + Expr::variable("m"); + let expression = Expr::pow(shared.clone(), shared); + + assert_eq!(expression.variables(), BTreeSet::from(["m", "n"])); + assert!(!expression.is_constant()); + assert!(!expression.is_polynomial()); + assert!(expression.is_valid_complexity_notation()); + assert_eq!(expression.unique_node_count(), 4); + + let error = expression.substitute_complete(&HashMap::new()).unwrap_err(); + assert_eq!( + error.missing_variables().collect::>(), + vec!["m", "n"] + ); + + let mut expressions = HashSet::new(); + assert!(expressions.insert(expression.clone())); + assert!(!expressions.insert(expression)); + } + + #[test] + fn display_and_parser_cover_non_decimal_rationals() { + assert_eq!(Expr::rational(1, 3).to_string(), "1/3"); + assert!(Expr::try_parse(".").is_err()); + } } diff --git a/problemreductions-macros/src/expr_codegen.rs b/problemreductions-macros/src/expr_codegen.rs index 43ccff342..a086da3b4 100644 --- a/problemreductions-macros/src/expr_codegen.rs +++ b/problemreductions-macros/src/expr_codegen.rs @@ -46,21 +46,19 @@ pub(crate) fn expr_tokens(expression: &Expr) -> TokenStream { pub(crate) fn eval_tokens(expression: &Expr, source: &syn::Ident) -> syn::Result { Ok(match expression.node() { ExprNode::Const(value) => { - let value = value.to_f64().ok_or_else(|| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!("exact expression constant {value} is outside the f64 evaluator"), - ) - })?; + let value = value + .to_f64() + .filter(|value| value.is_finite()) + .ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + format!("exact expression constant {value} is outside the f64 evaluator"), + ) + })?; quote! { #value } } ExprNode::Var(name) => { - let getter = syn::parse_str::(name.as_str()).map_err(|_| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!("expression variable {name:?} is not a valid Rust getter name"), - ) - })?; + let getter = syn::Ident::new(name.as_str(), proc_macro2::Span::call_site()); quote! { (#source.#getter() as f64) } } ExprNode::Add(values) => { @@ -161,4 +159,29 @@ mod tests { let source = syn::Ident::new("source", proc_macro2::Span::call_site()); assert!(!eval_tokens(&expression, &source).unwrap().is_empty()); } + + #[test] + fn codegen_covers_every_semantic_operator() { + let expression = Expr::parse("exp(n) + log(n) + factorial(n) + n^2"); + let constructed = expr_tokens(&expression).to_string(); + assert!(constructed.contains("Expr :: exp")); + assert!(constructed.contains("Expr :: log")); + assert!(constructed.contains("Expr :: factorial")); + assert!(constructed.contains("Expr :: pow")); + + let source = syn::Ident::new("source", proc_macro2::Span::call_site()); + let evaluated = eval_tokens(&expression, &source).unwrap().to_string(); + assert!(evaluated.contains("f64 :: exp")); + assert!(evaluated.contains("f64 :: ln")); + assert!(evaluated.contains("approximate_factorial")); + assert!(evaluated.contains("f64 :: powf")); + } + + #[test] + fn compiled_evaluator_rejects_constants_outside_f64() { + let expression = Expr::parse(&format!("1{}", "0".repeat(400))); + let source = syn::Ident::new("source", proc_macro2::Span::call_site()); + let error = eval_tokens(&expression, &source).unwrap_err(); + assert!(error.to_string().contains("outside the f64 evaluator")); + } } diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index 46793931f..007de13ac 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -266,10 +266,7 @@ fn generate_overhead_eval_fn( /// /// Collects all variable names referenced in the overhead expressions, generates /// getter calls for each, and returns a `ProblemSize`. -fn generate_source_size_fn( - fields: &[ParsedOverheadField], - source_type: &Type, -) -> syn::Result { +fn generate_source_size_fn(fields: &[ParsedOverheadField], source_type: &Type) -> TokenStream2 { let src_ident = syn::Ident::new("__src", proc_macro2::Span::call_site()); let var_names: std::collections::BTreeSet<_> = fields .iter() @@ -278,22 +275,17 @@ fn generate_source_size_fn( let getter_tokens = var_names .into_iter() .map(|name| { - let getter = syn::parse_str::(name).map_err(|_| { - syn::Error::new( - proc_macro2::Span::call_site(), - format!("expression variable {name:?} is not a valid Rust getter name"), - ) - })?; - Ok(quote! { (#name, #src_ident.#getter() as usize) }) + let getter = syn::Ident::new(name, proc_macro2::Span::call_site()); + quote! { (#name, #src_ident.#getter() as usize) } }) - .collect::>>()?; + .collect::>(); - Ok(quote! { + quote! { |__any_src: &dyn std::any::Any| -> crate::types::ProblemSize { let #src_ident = __any_src.downcast_ref::<#source_type>().unwrap(); crate::types::ProblemSize::new(vec![#(#getter_tokens),*]) } - }) + } } /// Generate the reduction entry code @@ -349,7 +341,7 @@ fn generate_reduction_entry( let fields = parse_overhead_fields(fields)?; let overhead_tokens = generate_parsed_overhead(&fields); let eval_fn = generate_overhead_eval_fn(&fields, source_type)?; - let size_fn = generate_source_size_fn(&fields, source_type)?; + let size_fn = generate_source_size_fn(&fields, source_type); (overhead_tokens, eval_fn, size_fn) } None => { @@ -695,6 +687,15 @@ mod tests { use super::*; use syn::{parse_str, Type}; + #[test] + fn overhead_fields_report_expression_domain_errors() { + let fields = vec![("num_vertices".to_string(), "0 / 0".to_string())]; + let Err(error) = parse_overhead_fields(&fields) else { + panic!("invalid overhead expression was accepted"); + }; + assert!(error.to_string().contains("division by zero")); + } + #[test] fn extract_type_name_strips_non_decision_generics() { let ty: Type = parse_str("MinimumVertexCover").unwrap(); diff --git a/src/expr.rs b/src/expr.rs index 5eecd469a..fc79dd9aa 100644 --- a/src/expr.rs +++ b/src/expr.rs @@ -72,6 +72,7 @@ pub(crate) fn expression_from_approximation(value: f64) -> Expr { pub(crate) fn rational_to_f64(value: &BigRational) -> Result { value .to_f64() + .filter(|value| value.is_finite()) .ok_or_else(|| ApproximationError::OutOfRange(value.to_string())) } diff --git a/src/growth.rs b/src/growth.rs index 3389bcb7a..44d88cfcc 100644 --- a/src/growth.rs +++ b/src/growth.rs @@ -106,15 +106,14 @@ impl ExpBase { /// `Expr::exp`; arbitrary constant subtrees remain structural-only. fn directly_comparable_value(&self) -> Option { match self { - ExpBase::Constant(base) if matches!(base.node(), ExprNode::Const(_)) => Some( - rational_to_f64(match base.node() { - ExprNode::Const(value) => value, - _ => unreachable!(), - }) - .expect("direct exponential constants are validated when constructed"), - ), + ExpBase::Constant(base) => match base.node() { + ExprNode::Const(value) => Some( + rational_to_f64(value) + .expect("direct exponential constants are validated when constructed"), + ), + _ => None, + }, ExpBase::Natural => Some(std::f64::consts::E), - ExpBase::Constant(_) => None, } } @@ -698,8 +697,6 @@ fn analyze_expr_inner( } else if let Some(power) = exponent_analysis.constant { if power < 0.0 { unknown(GrowthFailure::NegativeExponent(exponent.to_string())) - } else if power == 0.0 { - constant_growth() } else { pow_const(base_analysis.growth, power) } diff --git a/src/unit_tests/growth.rs b/src/unit_tests/growth.rs index 23aaf66a2..e5d8b7114 100644 --- a/src/unit_tests/growth.rs +++ b/src/unit_tests/growth.rs @@ -369,6 +369,55 @@ fn test_growth_unknown_negative_control() { assert_eq!(mul(n2, factorial_failure.clone()), factorial_failure); } +#[test] +fn test_growth_reports_nested_and_numeric_failures() { + let huge_constant = Expr::parse(&format!("1{}", "0".repeat(400))); + assert!(matches!( + Growth::from_expr(&huge_constant).failures(), + Some([GrowthFailure::Approximation { .. }]) + )); + + let unsupported = Expr::factorial(Expr::variable("n")); + assert!(matches!( + Growth::from_expr(&Expr::exp(unsupported.clone())).failures(), + Some([GrowthFailure::FactorialOfNonconstant(_)]) + )); + assert!(matches!( + Growth::from_expr(&Expr::factorial(unsupported)).failures(), + Some([GrowthFailure::FactorialOfNonconstant(_)]) + )); + + assert_eq!(Growth::from_expr(&Expr::variable("n")).failures(), None); + assert_eq!(Growth::Terms(Vec::new()).to_expr(), Some(Expr::integer(1))); +} + +#[test] +fn test_growth_rejects_invalid_internal_terms_explicitly() { + let mut invalid = GrowthTerm::one(); + invalid.poly.insert("n".into(), -1.0); + assert_eq!( + make_growth(vec![invalid]).failures(), + Some([GrowthFailure::InvalidGrowthTerm].as_slice()) + ); + + let mut coefficients = std::collections::BTreeMap::new(); + coefficients.insert("n".into(), f64::INFINITY); + let exponent = Expr::variable("n"); + assert!(matches!( + super::exponential(ExpBase::Natural, Some(coefficients), &exponent).failures(), + Some([GrowthFailure::NonFiniteLinearCoefficient(_)]) + )); +} + +#[test] +fn test_exponential_base_deserialization_reports_invalid_constant_domain() { + let invalid = serde_json::json!({ + "Constant": serde_json::to_value(Expr::log(Expr::integer(0))).unwrap() + }); + let error = serde_json::from_value::(invalid).unwrap_err(); + assert!(error.to_string().contains("finite real approximation")); +} + // --- Additional coverage --- /// Pure constants, constant factors, and constant division are all O(1) / dropped.