Skip to content

Commit 84d569d

Browse files
authored
Merge pull request #18 from s-celles/067-build-function-tier3
build_function tier1 & tier3
2 parents 9ce43b7 + 8083492 commit 84d569d

10 files changed

Lines changed: 846 additions & 5 deletions

CHANGELOG.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,51 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.14.0] - 2026-05-02
9+
10+
### Added
11+
12+
- **`build_function` Symbolics backend (Tier 3)**: a new `backend::Symbol`
13+
keyword on `build_function` selects the evaluation engine. The default
14+
`backend = :giac` is unchanged from v0.13. The new `backend = :symbolics`
15+
(requires `using Symbolics`) round-trips the expression through
16+
`to_symbolics` and compiles it via `Symbolics.build_function`, returning a
17+
native Julia callable that is autodiff-friendly (ForwardDiff, SciML
18+
solvers) and typically at least an order of magnitude faster in hot
19+
loops. Documented in
20+
[`docs/src/julia_functions.md`](docs/src/julia_functions.md), with a
21+
comparison table and a runtime benchmark.
22+
23+
Error paths: `backend = :symbolics` without `using Symbolics`, free
24+
symbols not bound by `vars`, GIAC heads with no `to_symbolics`
25+
translation, and bad backend symbols all surface as actionable
26+
`ArgumentError`s at `build_function` time. Closes
27+
[#17](https://github.com/s-celles/Giac.jl/issues/17) Tier 3.
28+
29+
Naming caveat: `Symbolics` also exports `build_function`; with both
30+
`using Giac` and `using Symbolics` in scope, qualify as
31+
`Giac.build_function(...)` (this is the standard Julia convention for
32+
name conflicts and is documented in the docstring and docs page).
33+
(067-build-function-tier3)
34+
35+
## [0.13.0] - 2026-05-02
36+
37+
### Added
38+
39+
- **`build_function`**: convert a `GiacExpr` into a native Julia callable
40+
with one named call. `f = build_function(expr, x, y)` returns a closure
41+
satisfying `f(a, b) == to_julia(substitute(expr, x => a, y => b))`, suitable
42+
as a drop-in argument to `Plots.plot`, `Plots.surface`, broadcasting
43+
(`f.(xs)`), and matrix comprehensions. The wrapper is intentionally thin —
44+
it composes the existing `substitute` + `to_julia` chain — and the
45+
underlying primitives remain available for cases that need a custom step
46+
in between. Documented in
47+
[`docs/src/julia_functions.md`](docs/src/julia_functions.md), with a
48+
comparison table to `Symbolics.build_function` and SymPy's `lambdify`, and
49+
showcased in the existing `examples/04_plotting.jl` Pluto notebook.
50+
Closes [#17](https://github.com/s-celles/Giac.jl/issues/17).
51+
(066-build-function)
52+
853
## [0.12.0] - 2026-05-01
954

1055
### Added

Project.toml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name = "Giac"
22
uuid = "e4421f97-9838-4fd0-9fa5-94f11373bf78"
3-
version = "0.12.0"
3+
version = "0.14.0"
44
authors = ["Sébastien Celles <s.celles@gmail.com>"]
55

66
[deps]
@@ -30,10 +30,12 @@ CommonSolve = "0.2"
3030
CxxWrap = "0.16, 0.17"
3131
DataFrames = "1.6, 1.7, 1.8"
3232
Documenter = "1"
33+
ForwardDiff = "0.10, 1"
3334
GIAC_jll = "2"
3435
Libdl = "1.10"
3536
LinearAlgebra = "1.10"
3637
MathJSON = "0.2, 0.3"
38+
Random = "1.10"
3739
Symbolics = "7"
3840
Tables = "1.10, 1.11, 1.12"
3941
TermInterface = "2"
@@ -47,10 +49,12 @@ Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595"
4749
CSV = "336ed68f-0bac-5ca0-87d4-7b16caf5d00b"
4850
DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0"
4951
Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4"
52+
ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210"
5053
MathJSON = "77215b4b-6f01-425c-beac-950ae6536d4d"
54+
Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c"
5155
Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7"
5256
TermInterface = "8ea1fca8-c5ef-4a55-8b96-4e9afe9c9a3c"
5357
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
5458

5559
[targets]
56-
test = ["Test", "Aqua", "Documenter", "Symbolics", "MathJSON", "DataFrames", "CSV", "TermInterface"]
60+
test = ["Test", "Aqua", "Documenter", "Symbolics", "MathJSON", "DataFrames", "CSV", "TermInterface", "Random", "ForwardDiff"]

docs/src/julia_functions.md

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Creating Julia Functions from Expressions
22

3-
Giac.jl lets you build symbolic expressions and then wrap them into regular Julia functions using [`substitute`](@ref) and [`to_julia`](@ref).
3+
Giac.jl lets you build symbolic expressions and then wrap them into regular Julia functions. The recommended named entry point is [`build_function`](@ref); under the hood it composes [`substitute`](@ref) and [`to_julia`](@ref), and you can always drop down to those primitives directly.
44

55
## Basic Idea
66

@@ -20,6 +20,131 @@ f(0) # -1
2020
f(-2) # 3
2121
```
2222

23+
## Using `build_function`
24+
25+
`build_function(expr, vars...)` is the named convenience wrapper for exactly the pattern above. It returns a Julia callable that you can pass to `Plots.plot`, `Plots.surface`, broadcasting (`f.(xs)`), and matrix comprehensions — without writing the `substitute` + `to_julia` line every time.
26+
27+
```julia
28+
using Giac
29+
30+
@giac_var x
31+
f = build_function(x^2 - 1, x)
32+
33+
f(3) # 8
34+
f.([0, 1, 2]) # [-1, 0, 3]
35+
```
36+
37+
For multivariate expressions, list the variables in the desired positional order:
38+
39+
```julia
40+
@giac_var x y
41+
g = build_function(x^2 + 2*x*y - y^2, x, y)
42+
43+
g(1, 2) # 1
44+
g(3, 1) # 14
45+
```
46+
47+
`build_function` is intentionally a *thin* wrapper. It does not introduce any new substitution mechanism: the result of `build_function(expr, vars...)(vals...)` is always equal to `to_julia(substitute(expr, Pair.(vars, vals)...))`. If you need to insert a transformation between substitution and conversion (e.g., `simplify`, `expand`, `evalf` at custom precision), drop down to the primitives directly rather than expecting `build_function` to grow new keyword arguments for every variant.
48+
49+
### Comparison with SymPy and Symbolics.jl
50+
51+
| Library | Function | Signature | Returns | Notes |
52+
|---|---|---|---|---|
53+
| **Giac.jl** | `build_function` | `build_function(expr::GiacExpr, vars::GiacExpr...)` | Julia callable (`<: Function`) | Tier 1 wrapper over `substitute` + `to_julia`; each call goes through the Giac FFI. |
54+
| **Symbolics.jl** | `build_function` | `build_function(expr, args...; kwargs...)` | Julia function (in-house codegen) | Walks the expression tree and emits native Julia code. SciML standard. |
55+
| **SymPy.jl** | `lambdify` | `lambdify(expr, vars; fns=...)` | Julia function | Translates a SymPy expression into a Julia function via a Python intermediary. |
56+
57+
```@docs
58+
build_function
59+
```
60+
61+
### Choosing a backend
62+
63+
`build_function` accepts a `backend::Symbol` keyword that selects the engine
64+
that evaluates the substituted expression. As of Giac.jl v0.14:
65+
66+
| Backend | Default? | Requires | Performance | Autodiff-friendly | Restricted heads? |
67+
|---|---|---|---|---|---|
68+
| `:giac` || nothing extra | one FFI call per evaluation || no — every GIAC head works |
69+
| `:symbolics` || `using Symbolics` | compiled once, cheap per call | ✅ (ForwardDiff, SciML) | yes — only heads `to_symbolics` translates |
70+
71+
The two backends agree numerically on the supported subset (within `1e-10`).
72+
Pick `:symbolics` when you need speed in a hot loop or when downstream code
73+
expects an autodiff-able function. Stay on the default `:giac` for one-off
74+
evaluation or when your expression contains heads outside the `to_symbolics`
75+
map.
76+
77+
#### Side-by-side equivalence
78+
79+
```julia
80+
using Giac, Symbolics
81+
82+
@giac_var x
83+
expr = sin(x)^2 + cos(x)^2 # equals 1 mathematically
84+
85+
f_giac = Giac.build_function(expr, x; backend = :giac)
86+
f_sym = Giac.build_function(expr, x; backend = :symbolics)
87+
88+
xs = -2.0:0.01:2.0
89+
all(isapprox.(f_giac.(xs), f_sym.(xs); atol = 1e-10)) # true
90+
```
91+
92+
#### Speed
93+
94+
```julia
95+
using Giac, Symbolics
96+
97+
@giac_var x y
98+
expr = sin(x)*cos(y) + (x + y)^3
99+
100+
f_giac = Giac.build_function(expr, x, y; backend = :giac)
101+
f_sym = Giac.build_function(expr, x, y; backend = :symbolics)
102+
103+
xs = randn(10_000); ys = randn(10_000)
104+
105+
f_giac(xs[1], ys[1]); f_sym(xs[1], ys[1]) # warm up
106+
107+
t1 = @elapsed for i in 1:10_000; f_giac(xs[i], ys[i]); end
108+
t2 = @elapsed for i in 1:10_000; f_sym(xs[i], ys[i]); end
109+
110+
println("speedup: ", round(t1 / t2; digits = 1), "×")
111+
```
112+
113+
The `:symbolics` backend is typically at least an order of magnitude faster
114+
on hot-loop workloads — for the expression above, a representative laptop
115+
measurement records `:giac` at ~335 ms and `:symbolics` at ~2.4 ms over
116+
10 000 calls, a **141× speedup**. Exact ratios depend on the expression
117+
and hardware.
118+
119+
#### Autodiff
120+
121+
```julia
122+
using Giac, Symbolics, ForwardDiff
123+
124+
@giac_var x
125+
f_sym = Giac.build_function(x^3 - 2x + 1, x; backend = :symbolics)
126+
127+
ForwardDiff.derivative(f_sym, 2.0) # 10.0 ( = 3·4 - 2 )
128+
```
129+
130+
The `:giac` backend cannot do this — each call is opaque to `ForwardDiff`.
131+
132+
#### Naming caveat: qualify when both packages are loaded
133+
134+
`Symbolics` also exports `build_function`. With both `using Giac` and
135+
`using Symbolics` in scope, the bare name is ambiguous and Julia raises
136+
`UndefVarError`. Write `Giac.build_function(...)` (or
137+
`Symbolics.build_function(...)` for the Symbolics-specific call sites).
138+
139+
#### Error paths
140+
141+
| Situation | Error |
142+
|---|---|
143+
| `backend = :symbolics` without `using Symbolics` | `ArgumentError` naming Symbolics |
144+
| Free symbol not bound by `vars` (`:symbolics` only) | `ArgumentError` listing the unbound names; recovery: bind it or use `:giac` |
145+
| GIAC head with no `to_symbolics` translation | Error from `to_symbolics` naming the head; recovery: use `:giac` |
146+
| Unknown `backend` value | `ArgumentError` naming the bad symbol |
147+
23148
## Step by Step
24149

25150
### 1. Declare symbolic variables
@@ -211,7 +336,8 @@ Each call to `f(_x)` goes through the Giac engine (substitution + evaluation). F
211336

212337
| Pattern | Returns | Use case |
213338
|---------|---------|----------|
214-
| `f(_x) = to_julia(substitute(expr, x => _x))` | Native Julia type | Numerical evaluation |
339+
| `f = build_function(expr, x)` | Native Julia type | Recommended named entry point for plotting / broadcasting |
340+
| `f(_x) = to_julia(substitute(expr, x => _x))` | Native Julia type | Manual form; use when you need a custom step between substitution and conversion |
215341
| `f(_x) = substitute(expr, x => _x)` | `GiacExpr` | Further symbolic work |
216342
| `f(_x, _y) = to_julia(substitute(expr, Dict(x => _x, y => _y)))` | Native Julia type | Multivariate evaluation |
217343
| `giac_eval("f(x) := ...")` then `giac_eval("f(5)")` | `GiacExpr` | Giac-native function |

examples/04_plotting.jl

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,54 @@ begin
135135
plot3d()
136136
end
137137

138+
# ╔═╡ b1c2d3e4-f5a6-7890-abcd-200000000050
139+
md"""
140+
---
141+
142+
## 2b. Same surface, with `build_function`
143+
144+
`build_function(expr, vars...)` is the named one-liner for exactly the
145+
`substitute` + `to_julia` pattern you saw above. It returns a Julia callable
146+
that you can pass straight to `Plots.surface` (or broadcast over an array,
147+
or use in a comprehension), without writing the wrapper closure by hand.
148+
149+
The two cells below produce visually identical plots — the only difference
150+
is that `build_function` makes the symbolic-to-numeric step a single named
151+
call.
152+
"""
153+
154+
# ╔═╡ b1c2d3e4-f5a6-7890-abcd-200000000051
155+
md"""
156+
a= $slider_a
157+
158+
b= $slider_b
159+
"""
160+
161+
# ╔═╡ b1c2d3e4-f5a6-7890-abcd-200000000052
162+
begin
163+
function plot3d_build_function()
164+
expr = sin((a * x^2 + b * y^2)) * cos(x/2)
165+
num_expr = substitute(expr, Dict(a => _a, b => _b))
166+
167+
# One named call replaces the manual `f(_x, _y) = to_julia(...)` closure.
168+
f = build_function(num_expr, x, y)
169+
170+
x_ = range(-3, 3, length=100)
171+
y_ = range(-3, 3, length=100)
172+
173+
Plots.surface(x_, y_, f,
174+
xlabel = "x", ylabel = "y", zlabel = "z",
175+
title = "Surface (build_function) z = $num_expr",
176+
colorbar = true,
177+
camera = (30, 45),
178+
color = :viridis,
179+
size = (800, 600)
180+
)
181+
end
182+
183+
plot3d_build_function()
184+
end
185+
138186
# ╔═╡ b1c2d3e4-f5a6-7890-abcd-200000000020
139187
md"""
140188
---
@@ -201,7 +249,8 @@ md"""
201249
| Step | How |
202250
|------|-----|
203251
| Numeric parameters | `substitute(expr, Dict(a => _a, b => _b, ...))` |
204-
| Julia-callable function | `f(_x) = to_julia(substitute(num_expr, x => _x))` |
252+
| Julia-callable function (named) | `f = build_function(num_expr, x)` (or `..., x, y` for multivariate) |
253+
| Julia-callable function (manual) | `f(_x) = to_julia(substitute(num_expr, x => _x))` |
205254
| 1D plot | `Plots.plot(_x, f.(_x))` |
206255
| Surface | `Plots.surface(x_, y_, f)` |
207256
| Vector field | `diff` + `quiver!` over a grid |
@@ -1492,6 +1541,9 @@ version = "1.13.0+0"
14921541
# ╟─b1c2d3e4-f5a6-7890-abcd-200000000010
14931542
# ╟─b1c2d3e4-f5a6-7890-abcd-200000000011
14941543
# ╠═b1c2d3e4-f5a6-7890-abcd-200000000012
1544+
# ╟─b1c2d3e4-f5a6-7890-abcd-200000000050
1545+
# ╟─b1c2d3e4-f5a6-7890-abcd-200000000051
1546+
# ╠═b1c2d3e4-f5a6-7890-abcd-200000000052
14951547
# ╟─b1c2d3e4-f5a6-7890-abcd-200000000020
14961548
# ╟─b1c2d3e4-f5a6-7890-abcd-200000000021
14971549
# ╠═b1c2d3e4-f5a6-7890-abcd-200000000022

ext/GiacSymbolicsExt.jl

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,4 +410,57 @@ end
410410
# Export conversion functions
411411
export to_giac, to_symbolics
412412

413+
# ============================================================================
414+
# build_function — Symbolics backend (Tier 3, 067-build-function-tier3)
415+
# ============================================================================
416+
#
417+
# When this extension is loaded, override the internal hook
418+
# Giac._build_function_symbolics_impl so that `Giac.build_function(expr,
419+
# vars...; backend = :symbolics)` round-trips through `to_symbolics` and
420+
# compiles via `Symbolics.build_function`, returning a native Julia callable
421+
# that is autodiff-friendly and faster in hot loops.
422+
#
423+
# Naming caveat: `Symbolics` also exports `build_function`. When both
424+
# `using Giac` and `using Symbolics` are in scope, the bare name is ambiguous
425+
# and Julia raises UndefVarError. Users must qualify as
426+
# `Giac.build_function(...)` (or `Symbolics.build_function(...)`). Documented
427+
# in the docstring and docs/src/julia_functions.md.
428+
429+
function Giac._build_function_symbolics_impl(expr::Giac.GiacExpr,
430+
vars::Tuple{Vararg{Giac.GiacExpr}})
431+
# Round-trip through Symbolics.
432+
sym_expr = Giac.to_symbolics(expr)
433+
sym_vars = map(Giac.to_symbolics, vars)
434+
435+
# Free-symbol check (contract clause B6). Every symbolic variable that
436+
# appears in the expression must also appear in the bound vars.
437+
expr_vars = Set(Symbolics.get_variables(sym_expr))
438+
bound_vars = Set{Any}()
439+
for sv in sym_vars
440+
for v in Symbolics.get_variables(sv)
441+
push!(bound_vars, v)
442+
end
443+
end
444+
free = setdiff(expr_vars, bound_vars)
445+
if !isempty(free)
446+
names = join(string.(collect(free)), ", ")
447+
throw(ArgumentError(
448+
"expression contains free symbol(s) [$names] not bound by the " *
449+
"vars argument; either bind them as additional vars or use " *
450+
"backend = :giac, which leaves them as a residual GiacExpr."))
451+
end
452+
453+
# Zero-vars constant case: return a 0-arg closure that evaluates the
454+
# constant once. Avoids version-dependent behavior of
455+
# Symbolics.build_function on no-arg input.
456+
if isempty(vars)
457+
v = Symbolics.value(sym_expr)
458+
return () -> v
459+
end
460+
461+
# Generate the compiled callable. expression = Val{false} returns a
462+
# RuntimeGeneratedFunction (world-age safe).
463+
return Symbolics.build_function(sym_expr, sym_vars...; expression = Val{false})
464+
end
465+
413466
end # module GiacSymbolicsExt

src/Giac.jl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ include("operators.jl")
7575
include("macros.jl")
7676
include("tables.jl")
7777
include("substitute.jl")
78+
include("build_function.jl")
7879

7980
# GenTypes module - Scoped enum for GIAC types (041-scoped-type-enum)
8081
include("gen_types.jl")
@@ -123,6 +124,9 @@ export commands_table, clear_commands_cache!, CommandsTable
123124
# Substitute function (028-substitute-mechanism)
124125
export substitute
125126

127+
# Build function (066-build-function)
128+
export build_function
129+
126130
# Type introspection (029-output-handling, 041-scoped-type-enum)
127131
# Legacy GIAC_* constants removed - use Giac.GenTypes: T, INT, DOUBLE, etc.
128132
export giac_type, subtype

0 commit comments

Comments
 (0)