Ref: https://discourse.julialang.org/t/ann-giac-jl-julia-interface-to-the-giac-computer-algebra-system/136681/6
Summary
It would be useful to have a built-in helper that turns a GiacExpr into a Julia function — both as an ergonomic shortcut over substitute + to_julia, and (optionally) as a way to compile the expression into native Julia code that no longer round-trips through Giac on each call.
(AI generated content below)
This is directly analogous to two well-established APIs in the Julia/Python symbolic ecosystem:
- SymPy's
lambdify — converts a SymPy expression into a fast numeric function. Available in Julia via SymPy.jl's lambdify, which wraps SymPy's implementation. Users can write f = lambdify(expr, (x, y)) and get back a callable that evaluates the expression numerically.
- Symbolics.jl's
build_function — generates native Julia code from a symbolic expression, optionally compiling in-place / array-output / GPU-targeted variants. This is the SciML-native equivalent and powers a lot of ModelingToolkit.jl's runtime performance.
Giac.jl currently has no equivalent. Users have to manually compose substitute + to_julia, which works but is undiscoverable for newcomers coming from either ecosystem.
Motivation
The current idiom for plotting or numerical evaluation is:
@giac_var x y a b
expr = sin(√(a * x^2 + b * y^2)) * cos(x/2)
num_expr = substitute(expr, Dict(a => 1.0, b => 1.0))
f(_x, _y) = to_julia(substitute(num_expr, Dict(x => _x, y => _y)))
# or with v0.12.0
num_expr = expr(a => 1.0, b => 1.0)
f(_x, _y) = to_julia(num_expr(x => _x, y => _y))
This works, and the v0.12 improvements — call-syntax, simultaneous semantics, to_julia auto-evalf, direct giac_subst binding — already make it cleaner and faster than v0.11. But:
- Users coming from SymPy / SymPy.jl will look for
lambdify and not find it. Users coming from Symbolics.jl will look for build_function and not find it. A single named entry point closes that gap.
- Every call still crosses the Julia ↔ C++ boundary, which is fine for a 100×100 surface plot (~10k calls) but becomes a bottleneck for interactive Pluto sliders on larger grids, parameter sweeps, or numerical optimization where
f is called millions of times. SymPy's lambdify and Symbolics' build_function both solve exactly this problem by emitting native code.
A lambdify helper would document the recommended pattern and give a clean upgrade path to a compiled version when performance matters.
Proposed API
Tier 1 — convenience wrapper (always available, stays inside Giac)
Roughly equivalent to SymPy's default lambdify behavior, but staying within Giac for evaluation:
"""
lambdify(expr::GiacExpr, vars::GiacExpr...) -> Function
Return a Julia function `f(vals...)` that evaluates `expr` by
simultaneously substituting `vars` with `vals` and converting
the result via `to_julia`.
Mirrors `SymPy.lambdify` and `Symbolics.build_function` in spirit:
turn a symbolic expression into a numeric callable.
"""
lambdify(expr, vars...) =
(vals...) -> to_julia(expr(Pair.(vars, vals)...))
This is essentially documentation + a single exported symbol. Cheap to implement, gives users the API they expect.
Tier 2 — compiled version (opt-in, leverages v0.12 introspection)
This is what makes SymPy's lambdify and Symbolics' build_function actually fast: emit native code instead of calling back into the CAS for each evaluation.
The new v0.12 introspection helpers (iscall, operation, arguments, free_symbols, unwrap_const, plus the GiacTermInterfaceExt weak-dep extension) make this directly feasible — they expose exactly the AST-walking interface needed:
lambdify(expr, vars...; compile::Bool = false)
When compile=true, the implementation walks the AST, maps Giac heads to Julia equivalents (sin → sin, + → +, …), and @evals an anonymous function. Subsequent calls run as plain Julia code — no Giac round-trip, LLVM-vectorizable.
Sketch of the AST walk:
function _giac_to_expr(g, lookup)
if !iscall(g)
c = unwrap_const(g)
return c === nothing ? Symbol(string(g)) : c
end
op = Symbol(string(operation(g)))
args = _giac_to_expr.(arguments(g), Ref(lookup))
jop = get(lookup, op, op)
return Expr(:call, jop, args...)
end
A GIAC_TO_JULIA lookup table handles cases where Giac and Julia names diverge. SymPy maintains a similar table (MODULES in sympy.utilities.lambdify) and Symbolics.jl handles this through its toexpr machinery — both are good references for what coverage looks like in practice.
Optional Tier 3 — bridge to Symbolics.jl
Rather than reimplementing the full toolchain, a third backend could simply delegate to Symbolics.build_function via the existing to_symbolics extension:
lambdify(expr, vars...; backend = :giac)
# backend ∈ (:giac, :compile, :symbolics)
This gives Giac.jl users access to everything Symbolics.jl already does well — in-place mutation, array outputs, sparsity, cse, GPU codegen via @target_func, etc. — without Giac.jl needing to maintain any of that infrastructure. It's the same pragmatic choice SymPy.jl makes by deferring to upstream SymPy's lambdify.
Open questions
- Scalar-only or vector-aware? Both SymPy's
lambdify and Symbolics' build_function support array outputs and in-place forms (build_function(expr, args...; target=Symbolics.JuliaTarget()) returns both an out-of-place and in-place version). Worth scoping for v1, or punt to a follow-up?
- Compiled-mode coverage. What's the right minimum set of Giac heads to support out of the box, and what should the fallback be when an unknown head is encountered (error vs. fall back to Tier 1 evaluation of that subexpression)? SymPy errors loudly; Symbolics tends to pass through.
- World-age. Tier 2 uses
@eval, which means an immediate call in the same global scope can hit the world-age boundary. SymPy.jl works around this with Base.invokelatest; worth documenting either way. For plotting use cases (the most common one) it's a non-issue because Plots calls f from inside its own functions.
- Constants. How does
unwrap_const currently expose π, ℯ, ∞? They need to map to Base.pi, Base.ℯ, Inf in the emitted code (SymPy's lambdify does this via its MODULES mapping).
- Naming.
lambdify is the SymPy name and is widely recognized in both Python and Julia (via SymPy.jl). build_function is the Symbolics.jl name. Going with lambdify probably maximizes discoverability — users coming from either ecosystem will recognize the concept, and the name is shorter and unambiguously refers to numeric evaluation. Symbolics users typing build_function could be helped by a docstring @doc alias or just a mention in the docs.
Proposed scope for a first PR
Just Tier 1, exported as lambdify, plus a docs page showing the three patterns (manual substitute, lambdify, lambdify(...; compile=true)) side by side, with a comparison table to SymPy's lambdify and Symbolics' build_function so users from either ecosystem can map their mental model onto Giac.jl quickly. Tier 2 and Tier 3 can land as follow-ups once the API surface is agreed upon.
Trade-offs of each tier
Tier 1 — convenience wrapper (stays inside Giac)
The deal: lambdify becomes a one-liner that wraps the existing substitute + to_julia pattern. Each call still crosses into Giac/C++.
Strengths
- Zero new dependencies. Works the moment a user types
using Giac. No Symbolics.jl, no codegen machinery.
- Trivial to implement and maintain. ~5 lines of code; nothing to break.
- Zero divergence risk. Whatever Giac computes is what the user gets — no parallel evaluator that could drift from Giac's semantics.
- Handles every Giac expression. If
substitute and to_julia work on it, lambdify works on it. No missing operators, no AST coverage gaps.
- Predictable for newcomers. The mental model is "it's just
substitute with a nicer name."
Weaknesses
- Slow per-call. Every evaluation pays the Julia ↔ C++ FFI cost: marshalling Julia values into Giac generic objects, running Giac's substitution and simplification, marshalling back. Order-of-magnitude estimate: ~1–10 µs/call rather than ~10–100 ns for native Julia. Fine for 10k-point surface plots, painful for 10M-point optimization loops.
- Not autodiff-friendly.
ForwardDiff.gradient(f, x) won't work because Dual numbers can't traverse the Giac boundary. Same for Zygote, Enzyme.
- No vectorization. LLVM can't see through the FFI call, so SIMD and loop unrolling are off the table.
- Allocations. Each call typically allocates at least one
GiacExpr for the substitution result.
Best for: plots, exploratory analysis, teaching, anywhere call count is modest and the value is in the convenience.
Tier 2 — compile to native Julia (in-house codegen)
The deal: Walk the GiacExpr AST, emit a Julia Expr, @eval it. Subsequent calls are pure Julia, JIT-compiled by LLVM, no Giac involvement.
Strengths
- Native speed. Once compiled, performance is identical to a hand-written Julia function. SIMD-vectorizable, inlinable, unboxed.
- Autodiff-compatible. The emitted function is plain Julia operating on whatever number type comes in, so
ForwardDiff and friends work transparently — provided the operator table maps to generic functions like Base.sin rather than Float64-specialized variants.
- Zero runtime dependency on Giac. After compilation, the function survives even if the Giac library is unloaded.
- No new package dependency. Lives entirely inside Giac.jl, using only the v0.12 introspection helpers (
iscall, operation, arguments, unwrap_const).
Weaknesses
- Non-trivial implementation surface. The AST walker plus the Giac→Julia operator mapping table must cover everything users throw at it. Edge cases include: rationals (
3/4 should stay rational, not become 0.75 prematurely), arbitrary-precision integers, π/ℯ/∞, branch cuts (does Giac's log agree with Julia's on negative reals?), piecewise functions, unevaluated integrals/sums/limits, user-defined functions, Ei/Si/erf and other special functions that Giac knows but Base doesn't.
- Maintenance burden grows over time. Each new Giac function someone wants to use forces a table update.
- Divergence risk. If the operator mapping is wrong (e.g. Giac's
log is natural log but the table maps to log10), the compiled function silently produces wrong numbers. Unit tests must compare against Tier 1 evaluation on a sample grid.
- World-age friction.
@eval creates a function in a newer world than the caller's. Calling it immediately from the same global scope hits MethodError: no method matching … (method too new). Mitigations: Base.invokelatest, or document that compiled functions should be passed to consumers (like Plots.surface) rather than called directly at top level.
- Compilation latency. The first call to a freshly-compiled function pays Julia's TTFX cost. For interactive use with frequently-changing expressions (e.g. Pluto sliders that re-trigger compilation on each parameter change), this can be more annoying than Tier 1's per-call FFI.
- Allocation if not careful. Naive codegen can produce intermediate temporaries; well-tuned codegen avoids them. Symbolics.jl spent years on this.
Best for: hot loops where the same expression is evaluated millions of times with fixed structure, autodiff-driven workflows, performance-critical applications where the user is willing to pay the upfront cost.
Tier 3 — delegate to Symbolics.jl build_function
The deal: Convert GiacExpr → Num via the existing to_symbolics extension, then hand off to Symbolics.build_function, which already knows how to do everything Tier 2 promises.
Strengths
- Industrial-strength codegen, for free. Symbolics.jl has years of tuning behind
build_function: out-of-place + in-place pairs, common subexpression elimination, sparsity detection, expression=true for inspectable code, alternative targets (CTarget, StanTarget, GPU via the SciML stack).
- Autodiff-native. The resulting function is plain Julia and integrates cleanly with the entire SciML ecosystem (DifferentialEquations.jl, Optimization.jl, ModelingToolkit.jl).
- Tiny implementation surface in Giac.jl. ~10 lines in a new weak-dep extension. The hard work happens upstream.
- Operator coverage tracks Symbolics.jl. When Symbolics.jl learns a new function, Giac.jl gets it for free — no table to update, no PR to chase.
- Vector-of-expressions for free.
lambdify([expr1, expr2, expr3], (x, y); backend=:symbolics) returns a tuple (f_oop, f_ip!) — exactly what RHS-of-ODE workflows need.
Weaknesses
- Heavy optional dependency. Symbolics.jl plus its transitive closure (SymbolicUtils, TermInterface, Setfield, RuntimeGeneratedFunctions, …) adds substantial precompile time. Educational users on slower machines may notice. Mitigated by the weak-dep extension model — the cost is paid only when
using Symbolics is loaded.
- Round-trip lossiness.
to_symbolics ∘ to_giac is not guaranteed to be the identity. Giac may simplify in one direction, Symbolics in another. Most of the time this is benign (both agree numerically), but it can surface in:
- Branch choices.
√(x²) vs |x| vs x.
- Domain assumptions. Giac may assume real positive variables in some commands; Symbolics treats variables as complex by default.
- Special functions. Functions Giac knows but Symbolics doesn't (or vice versa) hit the boundary as opaque atoms.
- Audit needed. Before this tier can be advertised as "just works,"
to_symbolics needs systematic testing on the full range of Giac heads — trig, exp/log, roots, piecewise (when), unevaluated integrals/sums/limits, Heaviside, Dirac. Bugs here show up as wrong numerical results downstream.
- Two indirection layers between user and result. Debugging a bad output means asking: did Giac compute the wrong thing? Did
to_symbolics translate it wrongly? Did build_function codegen it wrongly? More moving parts means more places to look.
- API stability coupling. If
Symbolics.build_function ever changes its signature or default options, Giac.jl's :symbolics backend has to track. Manageable, but a real cost.
Best for: users already working in the SciML ecosystem, anyone who needs in-place forms / array outputs / autodiff / sparsity, advanced workflows where the dependency cost is dwarfed by the capabilities gained.
Summary table
| Concern |
Tier 1 (Giac) |
Tier 2 (compile) |
Tier 3 (Symbolics) |
| New dependency |
None |
None |
Symbolics.jl (weak) |
| LOC in Giac.jl |
~5 |
~50–150 |
~10 |
| Per-call cost |
Giac FFI (~µs) |
Native Julia (~ns) |
Native Julia (~ns) |
| First-call latency |
~zero |
Compile time |
Compile time + load |
| Autodiff |
✗ |
✓ (if table is generic) |
✓ |
| Array outputs |
Manual |
DIY |
✓ free |
| In-place forms |
✗ |
DIY |
✓ free |
| CSE |
✗ |
DIY |
✓ free |
| GPU/alt targets |
✗ |
DIY |
✓ free |
| Operator coverage |
All of Giac |
Whatever the table covers |
Whatever to_symbolics covers |
| Divergence risk |
None |
Operator-table bugs |
Round-trip semantics |
| Maintenance |
Trivial |
Ongoing (table) |
Trivial (delegated) |
| Best for |
Plots, teaching, exploration |
Hot loops, embedded use, no-deps perf |
SciML workflows, autodiff, vector outputs |
How they compose
These tiers aren't mutually exclusive — they layer cleanly:
- Tier 1 is the floor. It always works, even with no extras loaded. Default
backend = :giac.
- Tier 2 is for users who want native speed without pulling Symbolics.jl. Useful for educational settings, embedded contexts, or anyone wary of dependency weight.
- Tier 3 is for users already in the SciML world or who need features (in-place, CSE, autodiff, sparsity) that would be wasteful to reimplement.
A reasonable shipping order is Tier 1 first (lands the API), Tier 3 second (cheap to add, big payoff for power users), Tier 2 third or never (only if there's demonstrated demand for native speed without Symbolics.jl). Tier 2 is the most work for the narrowest audience — it might honestly never be worth building if Tier 3 covers the performance use cases adequately.
Reference:
Ref: https://discourse.julialang.org/t/ann-giac-jl-julia-interface-to-the-giac-computer-algebra-system/136681/6
Summary
It would be useful to have a built-in helper that turns a
GiacExprinto a Julia function — both as an ergonomic shortcut oversubstitute+to_julia, and (optionally) as a way to compile the expression into native Julia code that no longer round-trips through Giac on each call.(AI generated content below)
This is directly analogous to two well-established APIs in the Julia/Python symbolic ecosystem:
lambdify— converts a SymPy expression into a fast numeric function. Available in Julia via SymPy.jl'slambdify, which wraps SymPy's implementation. Users can writef = lambdify(expr, (x, y))and get back a callable that evaluates the expression numerically.build_function— generates native Julia code from a symbolic expression, optionally compiling in-place / array-output / GPU-targeted variants. This is the SciML-native equivalent and powers a lot of ModelingToolkit.jl's runtime performance.Giac.jl currently has no equivalent. Users have to manually compose
substitute+to_julia, which works but is undiscoverable for newcomers coming from either ecosystem.Motivation
The current idiom for plotting or numerical evaluation is:
This works, and the v0.12 improvements — call-syntax, simultaneous semantics,
to_juliaauto-evalf, directgiac_substbinding — already make it cleaner and faster than v0.11. But:lambdifyand not find it. Users coming from Symbolics.jl will look forbuild_functionand not find it. A single named entry point closes that gap.fis called millions of times. SymPy'slambdifyand Symbolics'build_functionboth solve exactly this problem by emitting native code.A
lambdifyhelper would document the recommended pattern and give a clean upgrade path to a compiled version when performance matters.Proposed API
Tier 1 — convenience wrapper (always available, stays inside Giac)
Roughly equivalent to SymPy's default
lambdifybehavior, but staying within Giac for evaluation:This is essentially documentation + a single exported symbol. Cheap to implement, gives users the API they expect.
Tier 2 — compiled version (opt-in, leverages v0.12 introspection)
This is what makes SymPy's
lambdifyand Symbolics'build_functionactually fast: emit native code instead of calling back into the CAS for each evaluation.The new v0.12 introspection helpers (
iscall,operation,arguments,free_symbols,unwrap_const, plus theGiacTermInterfaceExtweak-dep extension) make this directly feasible — they expose exactly the AST-walking interface needed:When
compile=true, the implementation walks the AST, maps Giac heads to Julia equivalents (sin → sin,+ → +, …), and@evals an anonymous function. Subsequent calls run as plain Julia code — no Giac round-trip, LLVM-vectorizable.Sketch of the AST walk:
A
GIAC_TO_JULIAlookup table handles cases where Giac and Julia names diverge. SymPy maintains a similar table (MODULESinsympy.utilities.lambdify) and Symbolics.jl handles this through itstoexprmachinery — both are good references for what coverage looks like in practice.Optional Tier 3 — bridge to Symbolics.jl
Rather than reimplementing the full toolchain, a third backend could simply delegate to
Symbolics.build_functionvia the existingto_symbolicsextension:This gives Giac.jl users access to everything Symbolics.jl already does well — in-place mutation, array outputs, sparsity,
cse, GPU codegen via@target_func, etc. — without Giac.jl needing to maintain any of that infrastructure. It's the same pragmatic choice SymPy.jl makes by deferring to upstream SymPy'slambdify.Open questions
lambdifyand Symbolics'build_functionsupport array outputs and in-place forms (build_function(expr, args...; target=Symbolics.JuliaTarget())returns both an out-of-place and in-place version). Worth scoping for v1, or punt to a follow-up?@eval, which means an immediate call in the same global scope can hit the world-age boundary. SymPy.jl works around this withBase.invokelatest; worth documenting either way. For plotting use cases (the most common one) it's a non-issue becausePlotscallsffrom inside its own functions.unwrap_constcurrently exposeπ,ℯ,∞? They need to map toBase.pi,Base.ℯ,Infin the emitted code (SymPy'slambdifydoes this via itsMODULESmapping).lambdifyis the SymPy name and is widely recognized in both Python and Julia (via SymPy.jl).build_functionis the Symbolics.jl name. Going withlambdifyprobably maximizes discoverability — users coming from either ecosystem will recognize the concept, and the name is shorter and unambiguously refers to numeric evaluation. Symbolics users typingbuild_functioncould be helped by a docstring@docalias or just a mention in the docs.Proposed scope for a first PR
Just Tier 1, exported as
lambdify, plus a docs page showing the three patterns (manualsubstitute,lambdify,lambdify(...; compile=true)) side by side, with a comparison table to SymPy'slambdifyand Symbolics'build_functionso users from either ecosystem can map their mental model onto Giac.jl quickly. Tier 2 and Tier 3 can land as follow-ups once the API surface is agreed upon.Trade-offs of each tier
Tier 1 — convenience wrapper (stays inside Giac)
The deal:
lambdifybecomes a one-liner that wraps the existingsubstitute+to_juliapattern. Each call still crosses into Giac/C++.Strengths
using Giac. No Symbolics.jl, no codegen machinery.substituteandto_juliawork on it,lambdifyworks on it. No missing operators, no AST coverage gaps.substitutewith a nicer name."Weaknesses
ForwardDiff.gradient(f, x)won't work becauseDualnumbers can't traverse the Giac boundary. Same for Zygote, Enzyme.GiacExprfor the substitution result.Best for: plots, exploratory analysis, teaching, anywhere call count is modest and the value is in the convenience.
Tier 2 — compile to native Julia (in-house codegen)
The deal: Walk the
GiacExprAST, emit a JuliaExpr,@evalit. Subsequent calls are pure Julia, JIT-compiled by LLVM, no Giac involvement.Strengths
ForwardDiffand friends work transparently — provided the operator table maps to generic functions likeBase.sinrather thanFloat64-specialized variants.iscall,operation,arguments,unwrap_const).Weaknesses
3/4should stay rational, not become0.75prematurely), arbitrary-precision integers,π/ℯ/∞, branch cuts (does Giac'slogagree with Julia's on negative reals?), piecewise functions, unevaluated integrals/sums/limits, user-defined functions,Ei/Si/erfand other special functions that Giac knows butBasedoesn't.logis natural log but the table maps tolog10), the compiled function silently produces wrong numbers. Unit tests must compare against Tier 1 evaluation on a sample grid.@evalcreates a function in a newer world than the caller's. Calling it immediately from the same global scope hitsMethodError: no method matching … (method too new). Mitigations:Base.invokelatest, or document that compiled functions should be passed to consumers (likePlots.surface) rather than called directly at top level.Best for: hot loops where the same expression is evaluated millions of times with fixed structure, autodiff-driven workflows, performance-critical applications where the user is willing to pay the upfront cost.
Tier 3 — delegate to Symbolics.jl
build_functionThe deal: Convert
GiacExpr → Numvia the existingto_symbolicsextension, then hand off toSymbolics.build_function, which already knows how to do everything Tier 2 promises.Strengths
build_function: out-of-place + in-place pairs, common subexpression elimination, sparsity detection,expression=truefor inspectable code, alternative targets (CTarget,StanTarget, GPU via the SciML stack).lambdify([expr1, expr2, expr3], (x, y); backend=:symbolics)returns a tuple(f_oop, f_ip!)— exactly what RHS-of-ODE workflows need.Weaknesses
using Symbolicsis loaded.to_symbolics ∘ to_giacis not guaranteed to be the identity. Giac may simplify in one direction, Symbolics in another. Most of the time this is benign (both agree numerically), but it can surface in:√(x²)vs|x|vsx.to_symbolicsneeds systematic testing on the full range of Giac heads — trig, exp/log, roots, piecewise (when), unevaluated integrals/sums/limits, Heaviside, Dirac. Bugs here show up as wrong numerical results downstream.to_symbolicstranslate it wrongly? Didbuild_functioncodegen it wrongly? More moving parts means more places to look.Symbolics.build_functionever changes its signature or default options, Giac.jl's:symbolicsbackend has to track. Manageable, but a real cost.Best for: users already working in the SciML ecosystem, anyone who needs in-place forms / array outputs / autodiff / sparsity, advanced workflows where the dependency cost is dwarfed by the capabilities gained.
Summary table
to_symbolicscoversHow they compose
These tiers aren't mutually exclusive — they layer cleanly:
backend = :giac.A reasonable shipping order is Tier 1 first (lands the API), Tier 3 second (cheap to add, big payoff for power users), Tier 2 third or never (only if there's demonstrated demand for native speed without Symbolics.jl). Tier 2 is the most work for the narrowest audience — it might honestly never be worth building if Tier 3 covers the performance use cases adequately.
Reference:
build_functionhttps://symbolics.juliasymbolics.org/dev/manual/build_function/lambdifyhttps://docs.sympy.org/latest/modules/utilities/lambdify.html