From 04684a16ba3fb74433be56bb139c2bd3e5265aa6 Mon Sep 17 00:00:00 2001 From: MilesCranmerBot Date: Fri, 13 Mar 2026 14:25:45 +0000 Subject: [PATCH 1/3] fix: allow traceable custom operators in SymbolicUtils conversion When index_functions=false, try tracing non-whitelisted operators by calling them on symbolic children; only error on MethodError.\n\nAlso update SymbolicUtils tests to cover both traced custom operators and indexed round-tripping paths.\n\nCo-authored-by: Miles Cranmer --- ext/DynamicExpressionsSymbolicUtilsExt.jl | 27 ++++++++++++++++------- test/test_symbolic_utils.jl | 12 ++++++++-- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/ext/DynamicExpressionsSymbolicUtilsExt.jl b/ext/DynamicExpressionsSymbolicUtilsExt.jl index 637faa94..b4d5cc55 100644 --- a/ext/DynamicExpressionsSymbolicUtilsExt.jl +++ b/ext/DynamicExpressionsSymbolicUtilsExt.jl @@ -98,19 +98,30 @@ function parse_tree_to_eqs( # Convert children to symbolic form sym_children = map(x -> parse_tree_to_eqs(x, operators, index_functions), children) - # Only a small subset of functions have symbolic methods in SymbolicUtils. - # For unsupported operators: - # - when `index_functions=true`, we encode them as function-like symbols so they - # can be round-tripped back to operators via their name/arity. - # - when `index_functions=false`, we throw a clear error, since attempting to - # construct a SymbolicUtils term headed by an arbitrary function object can - # fail with a MethodError. + # SymbolicUtils only defines methods for some Base/stdlib functions, but + # user-defined operators may still be traceable if they are written in terms + # of symbolic-friendly primitives (e.g. `pow2(x) = x*x`). + # + # Strategy: + # - when `index_functions=true`, we encode operators as function-like symbols so + # they can be round-tripped back to operators via their name/arity. + # - when `index_functions=false`, we attempt to *trace* the operator by calling + # it on symbolic children. If this fails with a MethodError, throw a clear + # error instead of a cryptic MethodError. if !(op ∈ SUPPORTED_OPS) if index_functions op = _sym_fn(Symbol(op), tree.degree) return subs_bad(op(sym_children...)) else - throw(error("Unsupported operation $(op) in SymbolicUtils conversion")) + traced = try + op(sym_children...) + catch e + if e isa MethodError + throw(error("Unsupported operation $(op) in SymbolicUtils conversion")) + end + rethrow() + end + return subs_bad(traced) end end diff --git a/test/test_symbolic_utils.jl b/test/test_symbolic_utils.jl index 08bfe034..5708e3e2 100644 --- a/test/test_symbolic_utils.jl +++ b/test/test_symbolic_utils.jl @@ -149,9 +149,17 @@ end expr_custom = parse_expression( :(myop(x)); unary_operators=[myop], binary_operators=[+, *, /], variable_names=["x"] ) - @test_throws ErrorException node_to_symbolic( - expr_custom, operators_custom; index_functions=false + + # If the custom operator is traceable (i.e. it operates on symbolic inputs), + # `index_functions=false` should still work by tracing it. + eqn_custom_traced = node_to_symbolic(expr_custom, operators_custom; index_functions=false) + expr_custom_traced_rt = symbolic_to_node( + eqn_custom_traced, operators_custom; variable_names=["x"] ) + @test eval_expr(expr_custom_traced_rt, operators_custom, ["x"], X1) == [3.0] + + # If you want to preserve the custom operator itself for round-tripping, use + # `index_functions=true`. eqn_custom = node_to_symbolic(expr_custom, operators_custom; index_functions=true) expr_custom_rt = symbolic_to_node(eqn_custom, operators_custom; variable_names=["x"]) @test eval_expr(expr_custom_rt, operators_custom, ["x"], X1) == [3.0] From ae6c2a69133e3248e54a860ccbd31df30d94b30a Mon Sep 17 00:00:00 2001 From: MilesCranmerBot Date: Fri, 13 Mar 2026 20:54:55 +0000 Subject: [PATCH 2/3] fix: satisfy formatting and diff coverage for SymbolicUtils tracing - add regression coverage for unsupported custom-op paths\n- ensure SymbolicUtils compat avoids Julia 1.12 precompile breakage\n\nCo-authored-by: Miles Cranmer --- Project.toml | 2 +- test/test_symbolic_utils.jl | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Project.toml b/Project.toml index 22b20989..332b0e83 100644 --- a/Project.toml +++ b/Project.toml @@ -41,7 +41,7 @@ Optim = "1, 2" NLSolversBase = "7, 8" PrecompileTools = "1.2.1" Reexport = "1.2.2" -SymbolicUtils = "4" +SymbolicUtils = "4.1" Zygote = "0.7" julia = "1.10" Random = "1" diff --git a/test/test_symbolic_utils.jl b/test/test_symbolic_utils.jl index 5708e3e2..8a3aa5a5 100644 --- a/test/test_symbolic_utils.jl +++ b/test/test_symbolic_utils.jl @@ -152,7 +152,9 @@ end # If the custom operator is traceable (i.e. it operates on symbolic inputs), # `index_functions=false` should still work by tracing it. - eqn_custom_traced = node_to_symbolic(expr_custom, operators_custom; index_functions=false) + eqn_custom_traced = node_to_symbolic( + expr_custom, operators_custom; index_functions=false + ) expr_custom_traced_rt = symbolic_to_node( eqn_custom_traced, operators_custom; variable_names=["x"] ) @@ -163,4 +165,31 @@ end eqn_custom = node_to_symbolic(expr_custom, operators_custom; index_functions=true) expr_custom_rt = symbolic_to_node(eqn_custom, operators_custom; variable_names=["x"]) @test eval_expr(expr_custom_rt, operators_custom, ["x"], X1) == [3.0] + + # If a custom operator cannot be traced (e.g. no method for symbolic inputs), + # `index_functions=false` should throw a clear error rather than a MethodError. + float_only(x::Float64) = x + 1 + operators_float_only = OperatorEnum(; + unary_operators=(float_only,), binary_operators=(+, *, /) + ) + expr_float_only = parse_expression( + :(float_only(x)); + unary_operators=[float_only], + binary_operators=[+, *, /], + variable_names=["x"], + ) + @test_throws ErrorException node_to_symbolic( + expr_float_only, operators_float_only; index_functions=false + ) + + # Non-MethodError exceptions should be rethrown, so downstream code sees the + # original failure. + boom(x) = throw(ArgumentError("boom")) + operators_boom = OperatorEnum(; unary_operators=(boom,), binary_operators=(+, *, /)) + expr_boom = parse_expression( + :(boom(x)); unary_operators=[boom], binary_operators=[+, *, /], variable_names=["x"] + ) + @test_throws ArgumentError node_to_symbolic( + expr_boom, operators_boom; index_functions=false + ) end From 9558210a64b4c7b7122f34fecc15e2d1fc398f64 Mon Sep 17 00:00:00 2001 From: MilesCranmerBot Date: Sat, 14 Mar 2026 12:35:39 +0000 Subject: [PATCH 3/3] fix: avoid old LoopVectorization macro failure in bumper kernel Co-authored-by: Miles Cranmer --- ext/DynamicExpressionsLoopVectorizationExt.jl | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ext/DynamicExpressionsLoopVectorizationExt.jl b/ext/DynamicExpressionsLoopVectorizationExt.jl index 7b41f767..2db4e07a 100644 --- a/ext/DynamicExpressionsLoopVectorizationExt.jl +++ b/ext/DynamicExpressionsLoopVectorizationExt.jl @@ -215,7 +215,13 @@ function bumper_kern!( op::F, cumulators::Tuple{Vararg{Any,degree}}, ::EvalOptions{true,true,early_exit} ) where {F,degree,early_exit} cumulator_1 = first(cumulators) - @turbo @. cumulator_1 = op(cumulators...) + + # Avoid `@turbo @.` here: older LoopVectorization versions (used by downgrade-compat) + # can error during macro expansion on vararg tuple construction. + @inbounds for j in eachindex(cumulator_1) + cumulator_1[j] = op(map(c -> c[j], cumulators)...) + end + return cumulator_1 end