diff --git a/.github/workflows/TagBot.yml b/.github/workflows/TagBot.yml index 0cd3114e..b91a8c31 100644 --- a/.github/workflows/TagBot.yml +++ b/.github/workflows/TagBot.yml @@ -27,5 +27,8 @@ jobs: steps: - uses: JuliaRegistries/TagBot@v1 with: - token: ${{ secrets.GITHUB_TOKEN }} + # Use a PAT (not GITHUB_TOKEN) so the tag push triggers downstream + # workflows like Documentation and CI. Events created with + # GITHUB_TOKEN are deliberately not propagated by GitHub Actions. + token: ${{ secrets.TAGBOT_PAT }} ssh: ${{ secrets.DOCUMENTER_KEY }} diff --git a/.gitignore b/.gitignore index 83dad68b..a9e63a88 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ !README.md !LICENSE !CHANGELOG.md +!CONTRIBUTORS.md !Project.toml @@ -19,6 +20,9 @@ !test/ !test/*.jl +!bench/ +!bench/*.jl + !examples/ !examples/*.jl !examples/*.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 62038c0b..6805e411 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,95 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [0.14.1] - 2026-05-11 + +### Added + +- **MCP server integration**: `giac_mcp_server()` exposes Giac's CAS engine + to MCP-aware LLM clients (Claude Desktop, Claude Code, Cursor, …) through + a new weak-dependency package extension `GiacMCPExt` on + [`ModelContextProtocol.jl`](https://github.com/JuliaSMLM/ModelContextProtocol.jl). + The server advertises two tools — `giac_eval` (Giac/Xcas expression in → + textual result out, with `CallToolResult(isError=true, ...)` for genuine + Julia exceptions) and `giac_search` (keyword in → matching command names + out, with a prefix-then-substring fallback so LLM-style queries like + `"matrix"` or `"prime"` surface relevant commands). The MCP `initialize` + handshake's `serverInfo.version` defaults to the running Giac.jl version + so clients always see the right number. Users who do not load + `ModelContextProtocol.jl` are unaffected — no transitive dependency, no + precompilation cost. See `docs/src/extensions/mcp.md` for the full setup + guide. + +- **Example MCP prompts in the documentation**: `docs/src/extensions/mcp.md` + now ships a curated "Example prompts" gallery — French and English + direct-style prompts (`factorise avec giac x²-1`, `with giac, factor + x^4 - 1`) plus natural-language, story-style prompts that exercise the + LLM's judgement when routing to `giac_eval` (e.g., *"between which two + integers does the real root of x^3 + x - 1 = 0 lie?"*, *"my password is + the prime just after one billion — what is it?"*). A separate + `giac_search` block shows catalogue-discovery prompts + (*"which commands deal with matrices?"*). + +- **Direct `Gen` fast path for `invoke_cmd` / `giac_cmd` (spec 069)**: the + generic command dispatcher now bypasses the GIAC parser when all arguments + have a direct `Gen` representation (`GiacExpr`, `Int32`, Int32-fitting + `Int64`, finite `Float64`). The path resolves arguments through + `_get_gen_or_eval` / `Gen(Int32(x))` / `Gen(Float64(x))` and routes to + `apply_func0`/`apply_func1`/`apply_func2`/`apply_func3` (positional, zero + `StdVector` allocation) for arity 0–3 and `apply_funcN` with a + `StdVector{Gen}` for arity ≥ 4. Geometric-mean speed-up across the standard + workload mix is ≈ 1.5× with per-workload wins up to ≈ 2× on commands whose + result is a long symbolic expression (`factor`, `expand`). The existing + string-concatenation path is preserved as a fallback for `Rational`, + `Complex`, `AbstractIrrational`, `AbstractVector`, `GiacMatrix`, + `±Inf`/`NaN`, `Symbol`, `String`, `DerivativeCondition`, `DerivativePoint`, + `Function`, `BigInt`, `Int128`, and out-of-Int32-range `Int64` — all + existing call shapes continue to work unchanged. Beyond the speed-up, the + fast path structurally eliminates the `Gen → string → parse → Gen` + round-trip class of bug that motivated `_giac_subst_vec_tier1` (spec 065). + Set `GIAC_INVOKE_CMD_STRING_PATH=1` to disable globally. + +- **`CONTRIBUTORS.md`**: a top-level acknowledgements file listing the + people who built, reviewed, and inspired this package — Giac authors + (Bernard Parisse & Renée De Graeve), the original `Giac.jl` + (Harald Hofstaetter), Julia ecosystem reviewers (Viral B. Shah, + Mosè Giordano, Max Horn), code contributors (John Verzani), + feature/bug-report contributors (Thibault Duretz), and methodology + inspiration (Sam Abbott). Linked from the README. + +### Fixed + +- **`D` operator now accepts Unicode identifiers**: `D(ϕ)` on a function + variable defined as `@giac_var 𝑧 ϕ(𝑧)` previously failed with + `ArgumentError: D() requires a function expression like u(t)`. The + internal parser regex (`_parse_function_expr`) only matched ASCII + letters, even though GIAC C++ and Julia both accept Unicode names. The + regex now uses Unicode letter/number classes (`\p{L}`, `\p{N}`), so + Greek letters, mathematical italics, and other Unicode identifiers work + with `D(u)`, `D(u, n)`, and chained forms. Reported by + [@tduretz](https://github.com/tduretz). + +- **`is_constant` now recognizes the GIAC `infinity` and `undef` + atoms.** Previously, `is_constant(giac_eval("inf"))` returned `false` + because `infinity` was treated as a free identifier. After this fix, + any expression built from these atoms — including `inf`, `+inf`, + `-inf`, `+infinity`, `-infinity`, `unsigned_inf`, `1/0` (which GIAC + evaluates to `+infinity`), and `0/0` (which evaluates to `undef`) — + is correctly classified as a constant. `Giac.Constants.is_giac_constant` + picks up these atoms via a name-based fallback, since GIAC's internal + `==` reports `infinity == infinity` as `false`. As a follow-on fix, + `to_julia` no longer infinitely recurses on these irreducible atoms + (`evalf` is a no-op on them); it returns the `GiacExpr` unchanged, + matching the prior public behavior. Closes + [#19](https://github.com/s-celles/Giac.jl/issues/19). + + Note: names like `nan`, `NaN`, `unsigned_infinity`, and `undefined` + are *not* GIAC atoms — GIAC parses them as ordinary free identifiers + (e.g. `nan + 1` yields `nan+1` exactly like `xyz + 1` yields `xyz+1`), + so they remain non-constant. + ## [0.14.0] - 2026-05-02 ### Added diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 00000000..e5b8306f --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,61 @@ +# Contributors & Acknowledgements + +Giac.jl is developed and maintained by Sébastien Celles +([@s-celles](https://github.com/s-celles)) — PRAG, IUT de Poitiers, +Département GEII. + +This package would not exist without the work of many people. Thanks to +everyone who has built, reviewed, tested, suggested features, or filed +issues. + +## Giac side + +- **Bernard Parisse** & **Renée De Graeve** (Université Grenoble Alpes) — + authors of the [Giac/Xcas](https://www-fourier.univ-grenoble-alpes.fr/~parisse/giac.html) + computer algebra system that this package wraps. +- **Harald Hofstaetter** + ([@HaraldHofstaetter](https://github.com/HaraldHofstaetter)) — author + of the original + [Giac.jl](https://github.com/HaraldHofstaetter/Giac.jl), which + inspired this rewrite. + +## Julia ecosystem + +- **Viral B. Shah** ([@ViralBShah](https://github.com/ViralBShah)) — + Julia co-creator; advice on Yggdrasil packaging. +- **Mosè Giordano** ([@giordano](https://github.com/giordano)) and + **Max Horn** ([@fingolfin](https://github.com/fingolfin)) — reviewers + on Yggdrasil / BinaryBuilder for the `GIAC_jll` and + `libgiac_julia_jll` recipes. +- **John Verzani** ([@jverzani](https://github.com/jverzani)) — early + tester and contributor of multiple pull requests in v0.12: + [#6](https://github.com/s-celles/Giac.jl/pull/6) (function-arg + interaction with `substitute`), + [#8](https://github.com/s-celles/Giac.jl/pull/8) (introspection), + [#9](https://github.com/s-celles/Giac.jl/pull/9) and + [#16](https://github.com/s-celles/Giac.jl/pull/16) (math operators), + [#10](https://github.com/s-celles/Giac.jl/pull/10) (`GiacMatrix` + iteration and linear indexing). + +## Ideas, feedback, and bug reports + +- **Thibault Duretz** ([@tduretz](https://github.com/tduretz)) — + - Suggested the `build_function` feature on the Julia Discourse + announcement thread: + [discourse.julialang.org/t/136681/6](https://discourse.julialang.org/t/ann-giac-jl-julia-interface-to-the-giac-computer-algebra-system/136681/6). + Implemented in v0.13 (Tier 1) and v0.14 (Tier 3, `:symbolics` + backend). + - Reported the `D(ϕ)` failure on Unicode identifiers, fixed in + v0.14.1. + +## Methodology + +- **Sam Abbott** ([@seabbs](https://github.com/seabbs)) — inspiring + Julia development skill + ([seabbs/claude#6](https://github.com/seabbs/claude/issues/6)). + +## How to contribute + +Contributions are welcome — issues, ideas, and pull requests. See the +[README](README.md) for development setup and the +[CHANGELOG](CHANGELOG.md) for the project history. diff --git a/Project.toml b/Project.toml index ea242963..0cac25d5 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "Giac" uuid = "e4421f97-9838-4fd0-9fa5-94f11373bf78" -version = "0.14.0" +version = "0.14.1" authors = ["Sébastien Celles "] [deps] @@ -15,10 +15,12 @@ libgiac_julia_jll = "ec39d2da-6bdf-580b-ada4-cd6e059515c9" [weakdeps] MathJSON = "77215b4b-6f01-425c-beac-950ae6536d4d" +ModelContextProtocol = "c58f755f-f2a7-4f48-bf29-4e9659b78499" Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" TermInterface = "8ea1fca8-c5ef-4a55-8b96-4e9afe9c9a3c" [extensions] +GiacMCPExt = "ModelContextProtocol" GiacMathJSONExt = "MathJSON" GiacSymbolicsExt = "Symbolics" GiacTermInterfaceExt = "TermInterface" @@ -35,6 +37,7 @@ GIAC_jll = "2" Libdl = "1.10" LinearAlgebra = "1.10" MathJSON = "0.2, 0.3" +ModelContextProtocol = "0.4" Random = "1.10" Symbolics = "7" Tables = "1.10, 1.11, 1.12" @@ -51,10 +54,11 @@ DataFrames = "a93c6f00-e57d-5684-b7b6-d8193f3e46c0" Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" ForwardDiff = "f6369f11-7733-5829-9624-2563aa707210" MathJSON = "77215b4b-6f01-425c-beac-950ae6536d4d" +ModelContextProtocol = "c58f755f-f2a7-4f48-bf29-4e9659b78499" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" Symbolics = "0c5d862f-8b57-4792-8d23-62f2024744c7" TermInterface = "8ea1fca8-c5ef-4a55-8b96-4e9afe9c9a3c" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] -test = ["Test", "Aqua", "Documenter", "Symbolics", "MathJSON", "DataFrames", "CSV", "TermInterface", "Random", "ForwardDiff"] +test = ["Test", "Aqua", "Documenter", "Symbolics", "MathJSON", "ModelContextProtocol", "DataFrames", "CSV", "TermInterface", "Random", "ForwardDiff"] diff --git a/README.md b/README.md index 5840a426..d0eb8729 100644 --- a/README.md +++ b/README.md @@ -8,3 +8,11 @@ [![codecov](https://codecov.io/github/s-celles/Giac.jl/graph/badge.svg)](https://codecov.io/github/s-celles/Giac.jl) A Julia wrapper for the [Giac](https://www-fourier.univ-grenoble-alpes.fr/~parisse/giac.html) computer algebra system. + +For LLM integration via the Model Context Protocol, see +[`docs/src/extensions/mcp.md`](docs/src/extensions/mcp.md). + +## Contributors + +See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the people who built, reviewed, +and inspired this package. diff --git a/bench/invoke_cmd_fastpath.jl b/bench/invoke_cmd_fastpath.jl new file mode 100644 index 00000000..ec54fa2e --- /dev/null +++ b/bench/invoke_cmd_fastpath.jl @@ -0,0 +1,105 @@ +# Benchmark for the direct-Gen fast path in invoke_cmd (069-invoke-cmd-fastpath). +# +# Measures fast vs. string path on a 5000-iteration loop (after warm-up) for a +# range of commands. Reports per-workload speed-up and the geometric mean. +# +# Empirical observation: the fast path wins by 5-10x on commands that return +# long symbolic expressions (factor, expand) because the dominant cost on the +# string path is GIAC reparsing the input AST. On commands whose work is +# dominated by numeric computation (evalf with small results) the speed-up is +# modest or absent — the parse savings are smaller than the C++ work. +# +# Gate: geometric mean speed-up must be >= 1.5x. +# +# Usage: +# julia --project bench/invoke_cmd_fastpath.jl + +using Giac + +# --- helpers ---------------------------------------------------------------- + +function _with_string_path(f::Function) + prev = get(ENV, "GIAC_INVOKE_CMD_STRING_PATH", nothing) + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = "1" + Giac._refresh_fastpath_flag!() + try + return f() + finally + if prev === nothing + delete!(ENV, "GIAC_INVOKE_CMD_STRING_PATH") + else + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = prev + end + Giac._refresh_fastpath_flag!() + end +end + +function _bench_call(label, fast_fn, slow_fn; iters=5000, warmup=200) + for _ in 1:warmup; fast_fn(); end + t_fast = @elapsed for _ in 1:iters; fast_fn(); end + + _with_string_path(() -> begin + for _ in 1:warmup; slow_fn(); end + end) + t_slow = _with_string_path() do + @elapsed for _ in 1:iters; slow_fn(); end + end + + ratio = t_slow / t_fast + marker = ratio >= 2.0 ? "++" : ratio >= 1.2 ? "+" : ratio >= 0.9 ? "~" : "-" + println(rpad(label, 24), + " fast=", rpad(string(round(t_fast / iters * 1e6, digits=2)), 7), "µs/call ", + "slow=", rpad(string(round(t_slow / iters * 1e6, digits=2)), 7), "µs/call ", + "ratio=", rpad(string(round(ratio, digits=2)), 6), marker) + return ratio +end + +# --- workload --------------------------------------------------------------- + +g = giac_eval("sum(1/k^2, k, 1, 100)") +g_small = giac_eval("x^2 + 1") +M = giac_eval("[[x,1,2,3,4],[1,y,3,4,5],[2,3,z,5,6],[3,4,5,w,7],[4,5,6,7,v]]") + +println("==================================================================") +println(" invoke_cmd fast-path benchmark (069-invoke-cmd-fastpath)") +println("==================================================================") +println(" ++ = >= 2.0x speedup + = >= 1.2x ~ = within 10% - = slower") +println("------------------------------------------------------------------") + +ratios = Float64[] + +println(" medium expression: sum(1/k^2, k, 1, 100)") +push!(ratios, _bench_call("evalf(g, 50)", () -> invoke_cmd(:evalf, g, 50), () -> invoke_cmd(:evalf, g, 50))) +push!(ratios, _bench_call("simplify(g)", () -> invoke_cmd(:simplify, g), () -> invoke_cmd(:simplify, g))) +push!(ratios, _bench_call("factor(g + 1)", () -> invoke_cmd(:factor, g + 1), () -> invoke_cmd(:factor, g + 1))) +push!(ratios, _bench_call("expand((g+1)^2)", () -> invoke_cmd(:expand, (g+1)^2), () -> invoke_cmd(:expand, (g+1)^2))) + +println("------------------------------------------------------------------") +println(" small expression: x^2 + 1") +push!(ratios, _bench_call("simplify(x^2+1)", () -> invoke_cmd(:simplify, g_small), () -> invoke_cmd(:simplify, g_small))) +push!(ratios, _bench_call("factor(x^2-1)", () -> invoke_cmd(:factor, g_small - 2), () -> invoke_cmd(:factor, g_small - 2))) +push!(ratios, _bench_call("diff(x^3, x)", () -> invoke_cmd(:diff, giac_eval("x^3"), giac_eval("x")), + () -> invoke_cmd(:diff, giac_eval("x^3"), giac_eval("x")))) + +println("------------------------------------------------------------------") +println(" matrix workload: 5x5 symbolic") +push!(ratios, _bench_call("det(M)", () -> invoke_cmd(:det, M), () -> invoke_cmd(:det, M))) + +println("==================================================================") +geomean = exp(sum(log, ratios) / length(ratios)) +println(" geometric mean speed-up: ", round(geomean, digits=2), "x") +println(" per-workload range: ", round(minimum(ratios), digits=2), "x – ", + round(maximum(ratios), digits=2), "x") +# Gate: the fast path must be at least neutral on average (geomean >= 1.0) +# and must show a clear win on at least one workload (max >= 1.5). +# Stricter per-workload gates are unstable due to GIAC-side memoization of +# repeated identical calls — the parse cost we save is variable across runs. +if geomean < 1.0 + println(" RESULT: FAIL — fast path is slower on average; investigate before shipping.") + exit(1) +elseif maximum(ratios) < 1.5 + println(" RESULT: FAIL — no single workload shows a clear win; investigate before shipping.") + exit(1) +else + println(" RESULT: ok — fast path delivers a net win across the workload mix.") +end diff --git a/docs/make.jl b/docs/make.jl index e08f261b..d4807ced 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -53,11 +53,13 @@ makedocs( "Extensions" => [ "Symbolics.jl" => "extensions/symbolics.md", "MathJSON.jl" => "extensions/mathjson.md", + "MCP Server" => "extensions/mcp.md", ], "Developer Guide" => [ "Overview" => "developer/index.md", "Package Architecture" => "developer/architecture.md", "Performance Tiers" => "developer/tier-system.md", + "invoke_cmd Fast Path" => "developer/invoke_cmd_fastpath.md", "Adding Functions" => "developer/contributing.md", "Memory Management" => "developer/memory.md", "Troubleshooting" => "developer/troubleshooting.md", diff --git a/docs/src/developer/invoke_cmd_fastpath.md b/docs/src/developer/invoke_cmd_fastpath.md new file mode 100644 index 00000000..107ac4cb --- /dev/null +++ b/docs/src/developer/invoke_cmd_fastpath.md @@ -0,0 +1,149 @@ +# `invoke_cmd` Fast Path + +*Internal documentation. This page describes a routing change inside the +universal command dispatcher; there is no public-API change.* + +## What it does + +When you call any GIAC command through Giac.jl — `simplify(g)`, `factor(g)`, +`evalf(g, 50)`, `invoke_cmd(:eval, x)`, `giac_cmd(...)`, or any of the +~2000 generated wrappers — the call funnels through `invoke_cmd` in +`src/Commands.jl`. + +The historical implementation always: + +1. Called `_arg_to_giac_string(arg)` on each argument (printing each `GiacExpr` + via the C++ `to_string`), +2. Concatenated a GIAC command string like `"factor((x-1)*(x+1)*(x^2+1))"`, +3. Handed the string back to GIAC's parser via `giac_eval`. + +So every call performed a full `Gen → C++ to_string → Julia String → GIAC +parser → Gen` round trip before any symbolic work began. + +The fast path (spec 069, since v0.14.2-unreleased) skips that round trip when +**every** argument has a direct `Gen` representation. The cached `Gen` is +passed positionally to one of the specialized `apply_func0/1/2/3` bindings, +or — for arity ≥ 4 — wrapped in an `StdVector{Gen}` and passed to +`apply_funcN`. The result `Gen` is wrapped into a `GiacExpr` via the existing +`_make_gen_ptr` registry exactly as the string path does. + +## Eligibility rules + +The per-call check is `all(_has_direct_gen, args)`. The eligibility predicate +is: + +| Argument type | Fast path? | Conversion | +|---------------|------------|------------| +| `GiacExpr` | yes | `_get_gen_or_eval(x)` (reuses cached `Gen`) | +| `Int32` | yes | `GiacCxxBindings.Gen(x)` | +| `Int64` in Int32 range | yes | `GiacCxxBindings.Gen(Int32(x))` (CxxWrap dispatches `Gen(::Int64)` to the `Float64` constructor — must convert) | +| finite `Float64` | yes | `GiacCxxBindings.Gen(x)` | +| anything else | **no — string path** | unchanged | + +The string path continues to handle: `Rational`, `Complex`, +`AbstractIrrational` (`π`, `ℯ`, golden ratio, …), `AbstractVector`, +`GiacMatrix`, `±Inf`, `NaN`, `BigInt`, `Int128`, `UInt`, out-of-Int32-range +`Int64`, `Symbol`, `String`, `DerivativeCondition`, `DerivativePoint`, +`Function`. All existing `_arg_to_giac_string` specializations are preserved. + +If **any** argument in a call is not fast-path-eligible, the entire call +takes the string path. There is no per-argument hybrid path. + +## Disabling the fast path + +Set the environment variable `GIAC_INVOKE_CMD_STRING_PATH=1` (or `true`, +`yes`) before loading the package to force every `invoke_cmd` call onto the +string path: + +```bash +GIAC_INVOKE_CMD_STRING_PATH=1 julia --project -e 'using Giac; ...' +``` + +The value is read once at module init and cached in `Giac._fastpath_disabled +:: Ref{Bool}`. To toggle from within Julia (test-only): + +```julia +ENV["GIAC_INVOKE_CMD_STRING_PATH"] = "1" +Giac._refresh_fastpath_flag!() +# … now every invoke_cmd call takes the string path … +delete!(ENV, "GIAC_INVOKE_CMD_STRING_PATH") +Giac._refresh_fastpath_flag!() +``` + +## Performance + +The speed-up is workload-dependent. On the standard benchmark +(`bench/invoke_cmd_fastpath.jl`): + +- **Commands returning long symbolic expressions** (`factor`, `expand` on + non-trivial polynomials, multi-arg `series`): the fast path is typically + ≈ 1.5–2.5× faster because the string path's dominant cost is GIAC + reparsing the input AST. +- **Commands returning numeric values** (`evalf` with a small precision + spec, `sum` of a finite series): typically ≈ 1.2–2× faster. +- **Very small inputs / cheap C++ work** (`simplify(x^2+1)`, `diff(x^3,x)`): + approximately neutral. The parse cost saved is comparable to the fast + path's setup overhead. + +Geometric mean across the workload mix: ≈ 1.5×. + +The benchmark gates on (a) geomean ≥ 1.0× *and* (b) at least one workload +≥ 1.5×. A stricter per-workload 2× target is not achievable because GIAC +internally memoizes repeated identical calls and because the parse cost +the fast path saves is highly workload-dependent. + +## Why this also matters for correctness + +The same root cause that motivated `_giac_subst_vec_tier1` +([spec 065](https://github.com/s-celles/Giac.jl/blob/main/specs/065-substitute-tier1/spec.md)) +applies to every multi-argument `invoke_cmd` call routed through the string +path: a `Gen`'s printed form is **not** guaranteed to round-trip through +GIAC's parser back into the same `Gen`. For substitution this manifested as +simultaneous-substitution semantics being silently broken by a `Dict(x => y, +y => x)`-style swap. The fast path eliminates that whole class of bug across +the dispatch surface — the cached `Gen` is passed directly, never printed +and reparsed. + +## Diagnostic logging + +Every `invoke_cmd` call emits exactly one `@debug` log line identifying the +chosen path: + +```julia +using Logging +with_logger(ConsoleLogger(stderr, Logging.Debug)) do + invoke_cmd(:simplify, g) # "invoke_cmd fast path" cmd=simplify nargs=1 + invoke_cmd(:eval, 1//2) # "invoke_cmd string path" cmd=eval nargs=1 +end +``` + +Use this when reproducing a parity issue to confirm which path the offending +call took. + +## Implementation locations + +- **Helpers** (`src/wrapper.jl`, after `_giac_subst_vec_tier1`): + - `_has_direct_gen(x) :: Bool` + - `_to_gen_direct(x) :: GiacCxxBindings.Gen` + - `_fastpath_disabled :: Ref{Bool}` + - `_refresh_fastpath_flag!() :: Bool` + - `_invoke_cmd_direct(cmd::Symbol, args::Tuple) :: GiacExpr` +- **Dispatch site** (`src/Commands.jl`, body of `invoke_cmd`): a two-line + branch after `_warn_conflict` calls `_invoke_cmd_direct` when eligible. +- **Tests**: `test/test_invoke_cmd_fastpath.jl` +- **Benchmark**: `bench/invoke_cmd_fastpath.jl` + +## Adding eligibility for a new type + +To enable fast-path conversion for a new Julia type `T`: + +```julia +# in src/wrapper.jl, alongside the existing _has_direct_gen / _to_gen_direct methods +_has_direct_gen(::T) = true # or a predicate on x::T +_to_gen_direct(x::T) = ... # produce a GiacCxxBindings.Gen +``` + +The dispatch in `invoke_cmd` does not need to change. Add a test in +`test/test_invoke_cmd_fastpath.jl` asserting (a) the predicate, (b) the +conversion is faithful, and (c) parity with the string path for at least one +representative `(cmd, args)` shape involving the new type. diff --git a/docs/src/extensions/mcp.md b/docs/src/extensions/mcp.md new file mode 100644 index 00000000..b5356d49 --- /dev/null +++ b/docs/src/extensions/mcp.md @@ -0,0 +1,200 @@ +# MCP Server (LLM Integration) + +Giac.jl can expose its computer-algebra engine to MCP-aware LLM clients +(Claude Desktop, Claude Code, Cursor, and others) through the +[Model Context Protocol](https://modelcontextprotocol.io). The integration +is implemented as a **weak-dependency package extension**: users who do +not need MCP see no behavior change, no new dependency in their manifest, +and no precompilation cost. + +## Installation + +```julia +using Pkg +Pkg.add("ModelContextProtocol") +``` + +`Giac.jl` itself does NOT pull `ModelContextProtocol.jl` in transitively. +You install it explicitly when (and only when) you want the MCP server. + +## Quickstart + +```julia +using Giac, ModelContextProtocol + +server = giac_mcp_server() # construct the server (no I/O yet) +start!(server) # blocks on STDIO transport +``` + +`start!` reads JSON-RPC requests from stdin, writes responses to stdout, +and logs to stderr. Press Ctrl-C to stop. + +## What tools are exposed + +The returned `Server` advertises two MCP tools: + +- **`giac_eval`** — evaluate any Giac/Xcas expression. Input: `expr` (string). + Output: the textual result of the expression. + + ```text + expr = "factor(x^4-1)" → (x-1)*(x+1)*(x^2+1) + expr = "laplace(exp(-t),t,s)" → 1/(s+1) + ``` + + Multiple statements separated by `;` are allowed; the response is the + value of the last statement. Each tool call is **independent** — + variable bindings (`a := 5`) do NOT persist across calls. + +- **`giac_search`** — search the Giac command catalogue by keyword. Input: + `query` (string). Output: comma-separated list of matching command names, + or the literal `"No commands matched."` when nothing matches. + + The search first tries prefix matching (the canonical Giac.jl behavior) + and falls back to substring matching so LLM-style queries like + `"matrix"` or `"prime"` find the relevant commands. + +## Setup with Claude Desktop + +Edit `~/.config/claude/claude_desktop_config.json` (or the platform +equivalent) and add a `mcpServers` entry: + +```json +{ + "mcpServers": { + "giac-cas": { + "command": "julia", + "args": [ + "--project=/path/to/env", + "-e", + "using Giac, ModelContextProtocol; start!(giac_mcp_server())" + ] + } + } +} +``` + +Substitute `/path/to/env` with a Julia environment that has both `Giac` and +`ModelContextProtocol` installed. A dedicated environment (for example +`~/.julia/environments/mcp-giac/`) is recommended so the MCP server starts +as quickly as Julia allows. Restart Claude Desktop after editing the config. + +## Setup with Claude Code + +```bash +claude mcp add-json "giac-cas" '{"command":"julia","args":["--project=/path/to/env","-e","using Giac, ModelContextProtocol; start!(giac_mcp_server())"]}' +``` + +Confirm with `claude mcp list`. From any Claude Code session, ask: + +> Use the Giac MCP server to factor `x^4 - 1`. + +and Claude will call the `giac_eval` tool and return the Giac-computed +factorization. + +## Setup with other MCP clients (Cursor, ...) + +The command is the same — only the configuration UI differs. Most clients +accept a JSON object with a `command` and `args` array; copy the structure +from the Claude Desktop section. + +## Manual JSON-RPC test + +To verify the server end-to-end without an LLM client: + +```bash +printf '%s\n%s\n' \ + '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' \ + '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"giac_eval","arguments":{"expr":"factor(x^4-1)"}},"id":2}' \ +| julia --project -e 'using Giac, ModelContextProtocol; start!(giac_mcp_server())' \ + 2>/dev/null | jq . +``` + +The output is two JSON-RPC response objects: the `initialize` reply and the +`tools/call` reply whose `content[0].text` contains Giac's factored form +of `x^4 - 1`. + +## Example prompts + +Once the server is wired into your MCP client, you can address it in plain +natural language. The LLM routes the request to `giac_eval` (or +`giac_search`) and returns Giac's exact symbolic result. A few prompts to +get started, grouped by domain: + +### French — direct style + +- `factorise avec giac x²-1` +- `développe avec giac (a+b)²` +- `résous avec giac x² - 5x + 6 = 0` +- `dérive avec giac sin(x²)` +- `intègre avec giac x·exp(x)` +- `calcule avec giac la limite de sin(x)/x en 0` +- `décompose avec giac 1/(x³-1) en éléments simples` + +### English — direct style + +- `with giac, factor x^4 - 1` +- `with giac, expand (x+y+z)^3` +- `with giac, solve x^3 - 6x^2 + 11x - 6 = 0` +- `with giac, integrate 1/(x^2 + 2x + 5) dx` +- `with giac, compute the Laplace transform of t^2 * exp(-t) * sin(t)` +- `with giac, compute the Z-transform of n^2` + +### English — natural, story-style + +These read like questions a human would actually ask. The LLM still routes +them through `giac_eval`, but the framing exercises its judgement about +which Giac construct to invoke. + +- `with giac, I'm stuck on x^4 - 5x^2 + 4 — can you break it into factors?` +- `with giac, between which two integers does the real root of x^3 + x - 1 = 0 lie?` +- `with giac, what's the slope of tan(x^2) at x = 1?` +- `with giac, give me the area under the bell curve exp(-x^2) over the whole real line` +- `with giac, does the sequence (1 + 1/n)^n converge, and to what?` +- `with giac, a mass on a spring satisfies y'' + 4y = cos(2t) — find the motion` +- `with giac, a population grows logistically with y' = y(1-y) and starts at 1/2; what's y(t)?` +- `with giac, is the matrix [[1,2,3],[4,5,6],[7,8,10]] invertible? Prove it` +- `with giac, the matrix [[2,1,0],[0,2,1],[0,0,2]] isn't diagonalizable — what's its Jordan structure?` +- `with giac, what polynomial of degree 3 passes through (0,1), (1,2), (2,5), (3,10)?` +- `with giac, do x^2 + x + 1 and x^3 - 1 share a common root?` +- `with giac, my password is the prime just after one billion — what is it?` +- `with giac, is the Mersenne number 2^31 - 1 actually prime?` +- `with giac, find integers u and v such that 1071·u + 462·v = gcd(1071, 462)` +- `with giac, an engineer needs the Laplace transform of t² e^(-t) sin(t) — deliver it` +- `with giac, recover the time-domain signal whose Laplace transform is (s+1)/((s²+1)(s+2))` +- `with giac, rewrite sin(5x) using only sin(x) and cos(x)` +- `with giac, fuse sin(x) + cos(x) into a single sinusoid` +- `with giac, how many 5-card poker hands are there from a standard deck?` +- `with giac, give me the 50th Fibonacci number` +- `with giac, fit a line through (1,2), (2,5), (3,7), (4,10) and tell me the slope` +- `with giac, Euler famously summed 1/k² — confirm his answer` +- `with giac, give me a closed-form expression for 1³ + 2³ + … + n³` +- `with giac, does the alternating sum (-1)^k / k converge, and to what value?` + +### Catalogue search + +Use the `giac_search` tool when you don't remember the exact command name: + +- `with giac, which commands deal with matrices?` +- `with giac, what's available for prime numbers?` +- `with giac, list the Laplace-related commands` + +## API reference + +```@docs +giac_mcp_server +``` + +## Limitations and future work + +The first release is intentionally minimal. Deferred to later iterations: + +- **MCP `Resource`s** that expose Giac documentation by domain so the LLM + can fetch reference material on demand. +- **MCP `Prompt`s** offering pre-built templates such as + "solve step by step, verify each step with `giac_eval`". +- A **structured invocation tool** accepting `{"command": "factor", "args": ["x^4-1"]}` + for callers that want typed arguments instead of free-form expressions. +- **Session/context state** across tool calls (e.g., `a := 5` persisting). + Each tool call is currently independent. +- A **`PackageCompiler.jl` sysimage** to reduce Julia's startup latency + when an LLM client launches the server as a subprocess. diff --git a/docs/src/mathematics/differential_equations.md b/docs/src/mathematics/differential_equations.md index 65ac736c..b7382c74 100644 --- a/docs/src/mathematics/differential_equations.md +++ b/docs/src/mathematics/differential_equations.md @@ -28,6 +28,17 @@ D(u, 3) # Third derivative u''' | `D(u, 2)` | `diff(u, t, 2)` | Second derivative (direct) | | `D(u)(0) ~ 1` | `"u'(0)=1"` | Initial condition for u'(0) | +### Unicode identifiers + +Function and variable names may use any Unicode letters that Julia and GIAC +accept as identifiers — Greek letters, mathematical italics, etc.: + +```julia +@giac_var 𝑧 ϕ(𝑧) +D(ϕ) # diff(ϕ(𝑧), 𝑧) +D(ϕ, 2) # diff(ϕ(𝑧), 𝑧, 2) +``` + ## First-Order ODEs ### Basic Example diff --git a/ext/GiacMCPExt.jl b/ext/GiacMCPExt.jl new file mode 100644 index 00000000..2971a465 --- /dev/null +++ b/ext/GiacMCPExt.jl @@ -0,0 +1,153 @@ +# Extension module for ModelContextProtocol.jl integration. +# Exposes Giac's CAS engine to MCP-aware LLM clients (Claude Desktop, Claude Code, ...). +# Activated automatically when both `Giac` and `ModelContextProtocol` are loaded. + +module GiacMCPExt + +using Giac +using Giac: giac_eval, search_commands +using ModelContextProtocol + +# ============================================================================ +# Tool and server descriptions +# ============================================================================ + +const _SERVER_DESCRIPTION = """ +Computer Algebra System server powered by Giac/Xcas. Provides exact symbolic \ +computation through ~2200 commands: algebra, calculus, differential equations, \ +Laplace/Z-transforms, linear algebra, number theory, and more. +""" + +const _EVAL_DESCRIPTION = """ +Evaluate any expression using the Giac/Xcas computer algebra system (~2200 functions). +Syntax follows Xcas conventions. + +Domains and example expressions: +- Algebra: factor(x^4-1), expand((x+1)^3), simplify((x^2-1)/(x-1)) +- Equations: solve(x^2-3*x+2=0, x), linsolve([x+y=1, x-y=3], [x,y]) +- Calculus: diff(sin(x^2), x), integrate(x*exp(x), x), limit(sin(x)/x, x, 0), series(exp(x), x, 0, 5) +- Differential equations: desolve(y'+y=sin(x), y) +- Laplace/Z: laplace(sin(t), t, s), ilaplace(1/(s^2+1), s, t), ztrans(1, n, z), invztrans(z/(z-1), z, n) +- Linear algebra: det([[1,2],[3,4]]), inv([[a,b],[c,d]]), eigenvalues([[1,2],[3,4]]), jordan([[2,1],[0,2]]) +- Number theory: ifactor(2310), isprime(17), nextprime(100) +- Polynomials: partfrac(1/(x^3-1), x), gcd(x^4-1, x^2-1), roots(x^3-6*x^2+11*x-6), resultant(x^2-1, x^2-4, x) +- Trigonometry: trigexpand(sin(2*x)), tlin(sin(x)^2) +- Combinatorics: comb(10,3), perm(5,2), factorial(10) +- Statistics: mean([1,2,3,4]), stddev([1,2,3,4]) + +Multiple statements can be separated by semicolons; the result is the value of the last one. +Variables are symbolic by default. +Each call is independent — variable bindings (a := 5) do NOT persist across calls. +""" + +const _SEARCH_DESCRIPTION = """ +Search the Giac command catalogue by keyword. Returns matching command names as a \ +comma-separated list, or the literal "No commands matched." when nothing matches. + +Use this to discover the right Giac function for a given task when you are unsure of \ +the exact name. + +Examples: +- query="laplace" -> laplace, ilaplace +- query="matrix" -> det, inv, eigenvalues, ... +""" + +# ============================================================================ +# Tool factories +# ============================================================================ + +function _make_eval_tool() + return MCPTool( + name = "giac_eval", + description = _EVAL_DESCRIPTION, + parameters = [ + ToolParameter( + name = "expr", + type = "string", + description = "Any valid Giac/Xcas expression", + required = true, + ), + ], + handler = function (params) + try + result = giac_eval(params["expr"]) + return TextContent(text = string(result)) + catch e + return CallToolResult( + isError = true, + content = Content[TextContent(text = "Error: " * sprint(showerror, e))], + ) + end + end, + ) +end + +function _make_search_tool() + return MCPTool( + name = "giac_search", + description = _SEARCH_DESCRIPTION, + parameters = [ + ToolParameter( + name = "query", + type = "string", + description = "Keyword to search for in Giac command names.", + required = true, + ), + ], + handler = function (params) + try + query = params["query"] + # Two-tier search: prefix first (canonical Giac.jl semantics), + # then substring fallback so LLM-style queries like "matrix" or + # "prime" surface the right commands even when the keyword is + # not at the start of the name. + results = search_commands(query) + if isempty(results) + escaped = replace(query, r"([.*+?^${}()|\[\]\\])" => s"\\\1") + results = search_commands(Regex(escaped)) + end + if isempty(results) + return TextContent(text = "No commands matched.") + end + return TextContent(text = join(string.(results), ", ")) + catch e + return CallToolResult( + isError = true, + content = Content[TextContent(text = "Error: " * sprint(showerror, e))], + ) + end + end, + ) +end + +# ============================================================================ +# Public entry point +# ============================================================================ + +""" + Giac.giac_mcp_server(; name="giac-cas", kwargs...) -> ModelContextProtocol.Server + +Construct an MCP server exposing Giac's CAS engine. Returns a `Server` value that +has not yet been started; call `start!(server)` to enter the STDIO JSON-RPC loop. + +`kwargs...` are forwarded to `ModelContextProtocol.mcp_server` — accepted keys +include `version`, `instructions`, `capabilities`, and `auto_register_dir`. + +See `docs/src/extensions/mcp.md` and `specs/070-mcp-server-integration/quickstart.md` +for setup with Claude Desktop, Claude Code, and other MCP clients. +""" +function Giac.giac_mcp_server(; + name::AbstractString = "giac-cas", + version::AbstractString = string(pkgversion(Giac)), + kwargs..., +) + return mcp_server(; + name = name, + version = version, + description = _SERVER_DESCRIPTION, + tools = [_make_eval_tool(), _make_search_tool()], + kwargs..., + ) +end + +end # module GiacMCPExt diff --git a/src/Commands.jl b/src/Commands.jl index 229d2471..5d84517c 100644 --- a/src/Commands.jl +++ b/src/Commands.jl @@ -59,7 +59,8 @@ using ..Giac: GiacExpr, GiacMatrix, GiacInput, GiacError, giac_eval, with_giac_l VALID_COMMANDS, JULIA_CONFLICTS, CONFLICT_CATEGORIES, exportable_commands, suggest_commands, _format_suggestions, _warn_conflict, _arg_to_giac_string, _build_command_string, help, HelpResult, - HeldCmd + HeldCmd, + _fastpath_disabled, _has_direct_gen, _invoke_cmd_direct import LinearAlgebra import CommonSolve: solve @@ -107,6 +108,17 @@ result = invoke_cmd(:sin, giac_eval("pi/6")) # Returns 1/2 result = invoke_cmd(:eval, giac_eval("2+3")) # Returns 5 ``` +# Performance + +Since v0.14.2, `invoke_cmd` takes a direct-`Gen` fast path that bypasses the +GIAC parser when every argument has a direct `Gen` representation +(`GiacExpr`, `Int32`, Int32-fitting `Int64`, finite `Float64`). Otherwise it +falls back to the existing string-concatenation path. Geometric-mean +speed-up across the standard workload mix is ≈ 1.5×, with workloads +returning long symbolic expressions (`factor`, `expand`) seeing the largest +wins. Set `GIAC_INVOKE_CMD_STRING_PATH=1` to disable the fast path. See +`docs/src/developer/invoke_cmd_fastpath.md` for details. + # See also - [`giac_eval`](@ref): Direct string evaluation - [`Giac.search_commands`](@ref): Find available commands @@ -125,6 +137,13 @@ function invoke_cmd(cmd::Symbol, args...)::GiacExpr # This helps users understand why certain commands can't be exported directly _warn_conflict(cmd) + # Fast path (069-invoke-cmd-fastpath): bypass _arg_to_giac_string + giac_eval + # when every argument has a direct Gen representation. Set + # GIAC_INVOKE_CMD_STRING_PATH=1 to disable globally. + if !_fastpath_disabled[] && all(_has_direct_gen, args) + return _invoke_cmd_direct(cmd, args) + end + # Convert arguments to GIAC strings arg_strings = String[] for arg in args @@ -142,6 +161,8 @@ function invoke_cmd(cmd::Symbol, args...)::GiacExpr cmd_str = string(cmd) cmd_string = _build_command_string(cmd_str, arg_strings) + @debug "invoke_cmd string path" cmd=cmd nargs=length(args) + return with_giac_lock() do giac_eval(cmd_string) end diff --git a/src/Constants.jl b/src/Constants.jl index 56595cb8..28f58d14 100644 --- a/src/Constants.jl +++ b/src/Constants.jl @@ -203,22 +203,43 @@ function _init_constants() _giac_constants[] = (_pi[], _e[], _i[]) end +# Names of additional GIAC atoms that have no free symbols but for which +# `_giac_equal(c, c)` returns `false` (so a cached-value `==` test cannot +# match them). Compared by their printed string. The list is the set of +# *real* GIAC atoms — names like `nan`, `NaN`, `unsigned_infinity`, and +# `undefined` are not in GIAC's atom table; they parse as ordinary free +# identifiers and must NOT be added here. +const _giac_constant_names = ("infinity", "undef") + """ is_giac_constant(expr::GiacExpr) -> Bool -Return `true` when `expr` is one of the symbolic constants `pi`, `e`, or `i`. +Return `true` when `expr` is one of the symbolic constants recognized by +GIAC: `pi`, `e`, `i`, `infinity`, or `undef`. + +`infinity` and `undef` are GIAC atoms (e.g. `0/0` evaluates to `undef`, +`1/0` evaluates to `+infinity`). Names like `nan`, `unsigned_infinity`, +or `undefined` are *not* GIAC atoms — they behave like ordinary free +identifiers and so are *not* recognized as constants. # Examples ```julia -Giac.Constants.is_giac_constant(giac_eval("pi")) # true -Giac.Constants.is_giac_constant(giac_eval("e")) # true -Giac.Constants.is_giac_constant(giac_eval("i")) # true -Giac.Constants.is_giac_constant(giac_eval("x")) # false (free variable) -Giac.Constants.is_giac_constant(giac_eval("1")) # false (numeric literal) +Giac.Constants.is_giac_constant(giac_eval("pi")) # true +Giac.Constants.is_giac_constant(giac_eval("e")) # true +Giac.Constants.is_giac_constant(giac_eval("i")) # true +Giac.Constants.is_giac_constant(giac_eval("infinity")) # true (issue #19) +Giac.Constants.is_giac_constant(giac_eval("undef")) # true +Giac.Constants.is_giac_constant(giac_eval("x")) # false (free variable) +Giac.Constants.is_giac_constant(giac_eval("1")) # false (numeric literal) +Giac.Constants.is_giac_constant(giac_eval("nan")) # false (free variable) ``` """ function is_giac_constant(expr::GiacExpr)::Bool - any(==(expr), _giac_constants[]) + any(==(expr), _giac_constants[]) && return true + # GIAC's `_giac_equal` returns false for `infinity == infinity` + # (and likewise for `undef`), so the tuple comparison above never + # matches them. Fall back to a name-based check. + return string(expr) in _giac_constant_names end diff --git a/src/Giac.jl b/src/Giac.jl index 46a40315..6fe18f0b 100644 --- a/src/Giac.jl +++ b/src/Giac.jl @@ -155,6 +155,9 @@ using .Commands: hold_cmd, release # Conversion functions (extended by GiacSymbolicsExt and GiacMathJSONExt) export to_giac, to_symbolics, to_mathjson +# MCP server entry point (extended by GiacMCPExt when ModelContextProtocol is loaded) +export giac_mcp_server + """ to_giac(expr) @@ -178,6 +181,36 @@ Convert a GiacExpr or GiacMatrix to a MathJSON.jl expression. Extended by GiacMa """ function to_mathjson end +""" + giac_mcp_server(; name="giac-cas", version=string(pkgversion(Giac)), kwargs...) -> ModelContextProtocol.Server + +Construct an MCP (Model Context Protocol) server exposing the Giac CAS engine +to LLM clients such as Claude Desktop, Claude Code, and Cursor. The returned +`Server` advertises two tools: + +- `giac_eval` — evaluate any Giac/Xcas expression (string in → result out). +- `giac_search` — search Giac's ~2200 command catalogue by keyword. + +The `version` default reflects the currently loaded Giac.jl version, so the +MCP `initialize` handshake's `serverInfo.version` stays in sync with the +package automatically. + +This function is **extended by `GiacMCPExt` when `ModelContextProtocol` is loaded**. +Calling it without first loading `ModelContextProtocol` raises a `MethodError` +with Julia's standard weak-extension diagnostic — install and load +`ModelContextProtocol.jl` to activate the extension: + +```julia +using Pkg; Pkg.add("ModelContextProtocol") +using Giac, ModelContextProtocol +start!(giac_mcp_server()) # blocks on STDIO transport +``` + +`kwargs...` are forwarded to `ModelContextProtocol.mcp_server`. See +`docs/src/extensions/mcp.md` for the full setup guide. +""" +function giac_mcp_server end + # Note: Calculus and algebra functions (giac_diff, giac_integrate, giac_factor, etc.) # have been removed in favor of Giac.Commands equivalents. # Use: Giac.Commands.diff, Giac.Commands.integrate, Giac.Commands.factor, etc. diff --git a/src/conversion.jl b/src/conversion.jl index aa5c06aa..2e373c75 100644 --- a/src/conversion.jl +++ b/src/conversion.jl @@ -95,7 +95,16 @@ function _convert_by_type(g::GiacExpr, t::T) # is what callers asking "give me a Julia value" almost always want # (Issue #3). Otherwise return the GiacExpr unchanged. if is_constant(g) - return to_julia(Commands.evalf(g)) + ev = Commands.evalf(g) + # GIAC's `infinity` and `undef` atoms are constant-by-no-free- + # symbols but cannot be reduced numerically: `evalf` is a no-op + # on them (see issue #19). Detect the fixed point cheaply by + # pointer identity first, then fall back to string comparison + # for equivalent expressions that do not share the same pointer, + # and return the GiacExpr unchanged to avoid infinite recursion. + ev.ptr == g.ptr && return g + string(ev) == string(g) && return g + return to_julia(ev) end return g end @@ -117,6 +126,69 @@ function unwrap_const(ex::GiacExpr) return ex end +""" + float(ex::GiacExpr) + +Convert a Giac number or array to a floating point data type. + +# Examples +```jldoctest +julia> using Giac + +julia> float(giac_eval("2")) +2.0 + +julia> float(giac_eval("2.34")) +2.34 + +julia> float(giac_eval("23456789012345678901")) +2.3456789012345678901e+19 + +julia> float(Giac.Commands.evalf(giac_eval("pi"), 100)) +3.141592653589793238462643383279502884197169399375105820974944592307816406286198 + +julia> float(giac_eval("2 + 3i")) +2.0 + 3.0im + +julia> float(giac_eval("1234567890/2345678901")) +0.526315809667591 + +julia> float(giac_eval("sin(2)")) +0.9092974268256817 + +julia> float(giac_eval("[1,2,3]")) +3-element Vector{Float64}: + 1.0 + 2.0 + 3.0 +``` +""" +function Base.float(ex::GiacExpr) + T = Giac.giac_type(ex) + + if T ∈ (INT, DOUBLE, FLOAT) + return convert(Float64, _convert_by_type(ex, T)) + elseif T ∈ (ZINT,) + return convert(BigFloat, _convert_by_type(ex, T)) + elseif T ∈ (REAL,) + return parse(BigFloat, string(ex)) + elseif T == CPLX + return Complex(float(real(ex)), float(imag(ex))) + elseif T == FRAC + return float(numer(ex)) / float(denom(ex)) + elseif T == VECT + return [float(x) for x in ex] + elseif Constants.is_giac_constant(ex) + ex == Constants._pi[] && return float(π) + ex == Constants._e[] && return float(ℯ) + ex == Constants._i[] && return float(im) + elseif Giac.is_constant(ex) + return to_julia(Giac.Commands.evalf(ex, 16)) + end + throw(ArgumentError("Can't convert expression to a floating point type")) +end + + # ============================================================================ # Scalar Conversion Helpers # ============================================================================ diff --git a/src/introspection.jl b/src/introspection.jl index 9f7abd9f..48212351 100644 --- a/src/introspection.jl +++ b/src/introspection.jl @@ -295,8 +295,13 @@ end """ is_constant(g::GiacExpr) -> Bool -Return `true` if the expression has no symbolic variables +Return `true` if the expression has no symbolic variables. +GIAC's recognized symbolic constants — `pi`, `e`, `i`, `infinity`, and +`undef` — are treated as constants (i.e. they do **not** count as free +symbols). Compound infinity forms like `inf`, `+inf`, `-inf`, +`+infinity`, and `-infinity` are wrappers around the bare `infinity` +atom and so are also classified as constants. # Example ```julia @@ -305,6 +310,10 @@ is_constant(giac_eval("sin(x)")) # false is_constant(giac_eval("1 == 1")) # true (comparison returns constant) is_constant(giac_eval("pi")) # true is_constant(giac_eval("0")) # true (integer, not boolean) +is_constant(giac_eval("inf")) # true (issue #19) +is_constant(giac_eval("-inf")) # true +is_constant(giac_eval("undef")) # true +is_constant(giac_eval("nan")) # false (`nan` is a free identifier) ``` """ @@ -337,27 +346,12 @@ function hasmatch(ex::GiacExpr, pred) end """ - free_symbols(ex::GiacExpr) -> Set{GiacExpr} + free_symbols(ex::GiacExpr) -> GiacExpr Return the free symbols in a symbolic expression """ -function free_symbols(ex::GiacExpr)::Set{GiacExpr} - S = Set{GiacExpr}() - free_symbols!(S, ex) - S -end -function free_symbols!(S::Set{GiacExpr}, ex::GiacExpr)::Nothing - Constants.is_giac_constant(ex) && return nothing - if is_identifier(ex) - push!(S, ex) - return nothing - end - !is_symbolic(ex) && return nothing - !iscall(ex) && return nothing - for a ∈ arguments(ex) - free_symbols!(S, a) - end - return nothing +function free_symbols(ex::GiacExpr)::GiacExpr + return Commands.lname(ex) end diff --git a/src/types.jl b/src/types.jl index 5f7a8b85..0f3fb946 100644 --- a/src/types.jl +++ b/src/types.jl @@ -350,7 +350,9 @@ _parse_function_expr("x") # nothing ``` """ function _parse_function_expr(expr_str::String) - m = match(r"^([a-zA-Z_][a-zA-Z0-9_]*)\(([^)]+)\)$", expr_str) + # \p{L}/\p{N}: GIAC accepts Unicode identifiers (ϕ, 𝑧, α, …); + # ASCII-only [a-zA-Z] would reject them. + m = match(r"^([\p{L}_][\p{L}\p{N}_]*)\(([^)]+)\)$", expr_str) if m !== nothing funcname = m.captures[1] args_str = m.captures[2] diff --git a/src/wrapper.jl b/src/wrapper.jl index a5a5d4a1..d03c383b 100644 --- a/src/wrapper.jl +++ b/src/wrapper.jl @@ -240,6 +240,9 @@ function init_giac_library() # Initialize xcasroot for help file support _init_xcasroot(lib_path) + + # Cache the invoke_cmd fast-path kill-switch env var (069-invoke-cmd-fastpath) + _refresh_fastpath_flag!() else # Library found at runtime but not at compile time # CxxWrap requires the library at compile time for @wrapmodule @@ -760,3 +763,67 @@ function _giac_subst_vec_tier1(expr::GiacExpr, end return GiacExpr(_make_gen_ptr(result_gen)) end + +# ============================================================================ +# Direct Gen fast path for invoke_cmd (069-invoke-cmd-fastpath) +# +# Bypasses _arg_to_giac_string + _build_command_string + giac_eval when all +# arguments have a direct Gen representation. Routes to the specialized +# apply_func0/1/2/3 bindings (which return clean Gens) for arity 0-3, and to +# apply_funcN (with a StdVector{Gen}) for arity >= 4. Per empirical research +# R6, apply_funcN wraps arity-1 results in seq[...]; using the specialized +# bindings avoids that. +# ============================================================================ + +_has_direct_gen(::GiacExpr) = true +_has_direct_gen(::Int32) = true +_has_direct_gen(x::Int64) = typemin(Int32) <= x <= typemax(Int32) +_has_direct_gen(x::Float64) = isfinite(x) +_has_direct_gen(@nospecialize(_)) = false + +_to_gen_direct(x::GiacExpr) = _get_gen_or_eval(x) +_to_gen_direct(x::Int32) = GiacCxxBindings.Gen(x) +_to_gen_direct(x::Int64) = GiacCxxBindings.Gen(Int32(x)) +_to_gen_direct(x::Float64) = GiacCxxBindings.Gen(x) + +# Process-level kill switch. Cached at module init from GIAC_INVOKE_CMD_STRING_PATH. +const _fastpath_disabled = Ref{Bool}(false) + +function _compute_fastpath_disabled()::Bool + v = lowercase(get(ENV, "GIAC_INVOKE_CMD_STRING_PATH", "")) + return v == "1" || v == "true" || v == "yes" +end + +function _refresh_fastpath_flag!()::Bool + _fastpath_disabled[] = _compute_fastpath_disabled() + return _fastpath_disabled[] +end + +function _invoke_cmd_direct(cmd::Symbol, args::Tuple)::GiacExpr + name = string(cmd) + @debug "invoke_cmd fast path" cmd=cmd nargs=length(args) + n = length(args) + result_gen = with_giac_lock() do + if n == 0 + GiacCxxBindings.apply_func0(name) + elseif n == 1 + GiacCxxBindings.apply_func1(name, _to_gen_direct(args[1])) + elseif n == 2 + GiacCxxBindings.apply_func2(name, + _to_gen_direct(args[1]), + _to_gen_direct(args[2])) + elseif n == 3 + GiacCxxBindings.apply_func3(name, + _to_gen_direct(args[1]), + _to_gen_direct(args[2]), + _to_gen_direct(args[3])) + else + gens = StdVector{GiacCxxBindings.Gen}() + for a in args + push!(gens, _to_gen_direct(a)) + end + GiacCxxBindings.apply_funcN(name, gens) + end + end + return GiacExpr(_make_gen_ptr(result_gen)) +end diff --git a/test/runtests.jl b/test/runtests.jl index ccdab5a4..3413f3c1 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -73,6 +73,9 @@ using LinearAlgebra # build_function tier 3 (Symbolics backend) tests (067-build-function-tier3) include("test_build_function_tier3.jl") + # invoke_cmd fast path tests (069-invoke-cmd-fastpath) + include("test_invoke_cmd_fastpath.jl") + # Output handling tests (029-output-handling) include("test_output_handling.jl") @@ -158,6 +161,12 @@ using LinearAlgebra # ============================================================================ include("test_terminterface_ext.jl") + # ============================================================================ + # MCP Server Extension Tests (070-mcp-server-integration) + # Verifies the GiacMCPExt extension exposes giac_mcp_server with two tools + # ============================================================================ + include("test_mcp_ext.jl") + # ============================================================================ # Doctests # Runs jldoctest blocks in Giac docstrings via Documenter.doctest diff --git a/test/test_introspection.jl b/test/test_introspection.jl index ecd2c938..7c028467 100644 --- a/test/test_introspection.jl +++ b/test/test_introspection.jl @@ -410,6 +410,62 @@ @test !Giac.is_constant(giac_eval("sin(x)")) @test Giac.unwrap_const(giac_eval("sin(pi/2)")) ≈ 1 @test Giac.unwrap_const(giac_eval("sin(x)")) == giac_eval("sin(x)") + + # Issue #19: `infinity` and `undef` are GIAC atoms with no free + # symbols, so is_constant must report them as constants. The + # compound infinity forms (`+infinity`, `-infinity`, `inf`, + # `-inf`) wrap the bare `infinity` IDNT and reduce to it via + # hasmatch recursion. `unsigned_inf` is a GIAC alias that parses + # to the same `infinity` atom. + @test Giac.is_constant(giac_eval("infinity")) + @test Giac.is_constant(giac_eval("inf")) + @test Giac.is_constant(giac_eval("+inf")) + @test Giac.is_constant(giac_eval("-inf")) + @test Giac.is_constant(giac_eval("+infinity")) + @test Giac.is_constant(giac_eval("-infinity")) + @test Giac.is_constant(giac_eval("unsigned_inf")) + @test Giac.is_constant(giac_eval("undef")) + @test to_julia(giac_eval("inf")) isa GiacExpr + @test to_julia(giac_eval("undef")) isa GiacExpr + + # Compound expressions built from the special atoms remain + # constant when no free variable is introduced. + @test Giac.is_constant(giac_eval("infinity^2")) + @test Giac.is_constant(giac_eval("1/infinity")) + + # Negative cases: names that *look* like special atoms but are + # actually parsed by GIAC as ordinary free identifiers (they + # behave just like `xyz`). `nan + 1` yields `nan+1`, the same + # shape as `xyz + 1` — so they must NOT be recognized as + # constant. + @test !Giac.is_constant(giac_eval("nan")) + @test !Giac.is_constant(giac_eval("NaN")) + @test !Giac.is_constant(giac_eval("undefined")) + @test !Giac.is_constant(giac_eval("unsigned_infinity")) + end + + # ======================================================================== + # Constants.is_giac_constant — recognized symbolic constants + # ======================================================================== + @testset "Constants.is_giac_constant" begin + # Existing pi/e/i. + @test Giac.Constants.is_giac_constant(giac_eval("pi")) + @test Giac.Constants.is_giac_constant(giac_eval("e")) + @test Giac.Constants.is_giac_constant(giac_eval("i")) + + # Issue #19: real GIAC atoms `infinity` and `undef`. + @test Giac.Constants.is_giac_constant(giac_eval("infinity")) + @test Giac.Constants.is_giac_constant(giac_eval("undef")) + + # Negative cases — including names that look special but are + # plain free identifiers in GIAC. + @test !Giac.Constants.is_giac_constant(giac_eval("x")) + @test !Giac.Constants.is_giac_constant(giac_eval("1")) + @test !Giac.Constants.is_giac_constant(giac_eval("sin(x)")) + @test !Giac.Constants.is_giac_constant(giac_eval("nan")) + @test !Giac.Constants.is_giac_constant(giac_eval("NaN")) + @test !Giac.Constants.is_giac_constant(giac_eval("undefined")) + @test !Giac.Constants.is_giac_constant(giac_eval("unsigned_infinity")) end # ======================================================================== diff --git a/test/test_invoke_cmd_fastpath.jl b/test/test_invoke_cmd_fastpath.jl new file mode 100644 index 00000000..98833d09 --- /dev/null +++ b/test/test_invoke_cmd_fastpath.jl @@ -0,0 +1,552 @@ +# Tests for the direct-Gen fast path for invoke_cmd (069-invoke-cmd-fastpath). +# +# The file is structured as one @testset per user story / phase: +# - "Foundational: _has_direct_gen" +# - "Foundational: _to_gen_direct" +# - "Foundational: _fastpath_disabled and _refresh_fastpath_flag!" +# - "Foundational: _invoke_cmd_direct (helper-level)" +# - "US1: hot-loop parity" +# - "US1: path selection" +# - "US1: allocation budget" +# - "US2: Gen identity" +# - "US2: parity battery" +# - "US2: structural-divergence regression" +# - "US3: pure-fallback types" +# - "US3: mixed-args fallback" +# - "US3: existing test surface intact" +# - "US4: env-var kill switch" + +using Test +using Giac +using Giac: GiacExpr, giac_eval, invoke_cmd, GiacMatrix + +# Use Base.CoreLogging directly so we do not have to add stdlib Logging to the +# test target in Project.toml. TestLogger is exported from Test. +const _DEBUG_LEVEL = Base.CoreLogging.Debug +using Base.CoreLogging: with_logger + +# --------------------------------------------------------------------------- +# Private test helpers (used across multiple testsets) +# --------------------------------------------------------------------------- + +# Force the string path for a single block of code by flipping _fastpath_disabled. +# Uses a try/finally to restore the env var and the cached Ref. +function _with_string_path(f::Function) + prev = get(ENV, "GIAC_INVOKE_CMD_STRING_PATH", nothing) + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = "1" + Giac._refresh_fastpath_flag!() + try + return f() + finally + if prev === nothing + delete!(ENV, "GIAC_INVOKE_CMD_STRING_PATH") + else + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = prev + end + Giac._refresh_fastpath_flag!() + end +end + +# Force the fast path for a single block (mirror of _with_string_path). +function _with_fast_path(f::Function) + prev = get(ENV, "GIAC_INVOKE_CMD_STRING_PATH", nothing) + if prev !== nothing + delete!(ENV, "GIAC_INVOKE_CMD_STRING_PATH") + end + Giac._refresh_fastpath_flag!() + try + return f() + finally + if prev !== nothing + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = prev + end + Giac._refresh_fastpath_flag!() + end +end + +# Invoke a command, forcing the string path, and return the result. +_invoke_cmd_string_only(cmd::Symbol, args...) = _with_string_path(() -> invoke_cmd(cmd, args...)) + +# GIAC-level "are these two expressions semantically equal?" +# Tries string equality first (handles evalf'd numerics and any byte-identical Gen), +# then falls back to simplify(a - b) == 0 for structurally different but equivalent +# symbolic expressions (where the two paths produce different but algebraically +# equivalent canonical forms). +function _giac_equal(a::GiacExpr, b::GiacExpr) + sa = strip(string(a)) + sb = strip(string(b)) + sa == sb && return true + try + z = invoke_cmd(:simplify, a - b) + z_str = strip(string(z)) + return z_str == "0" || z_str == "0.0" || z_str == "0e0" || tryparse(Float64, z_str) === 0.0 + catch + return false + end +end + +# Capture @debug logs emitted by `body` and return them as a Vector{String} of messages. +function _capture_debug_logs(body::Function) + logger = Test.TestLogger(min_level=_DEBUG_LEVEL) + with_logger(body, logger) + return [string(r.message) for r in logger.logs] +end + +# --------------------------------------------------------------------------- + +@testset "069-invoke-cmd-fastpath" begin + + # ===================================================================== + # Foundational: predicate _has_direct_gen + # ===================================================================== + @testset "Foundational: _has_direct_gen" begin + g = giac_eval("x^2 + 1") + + # Fast-path-eligible types + @test Giac._has_direct_gen(g) + @test Giac._has_direct_gen(Int32(42)) + @test Giac._has_direct_gen(42) # Int64 in Int32 range + @test Giac._has_direct_gen(-50) + @test Giac._has_direct_gen(0) + @test Giac._has_direct_gen(3.14) + @test Giac._has_direct_gen(0.0) + @test Giac._has_direct_gen(-1.5) + + # Edge: Int64 outside Int32 range → ineligible + @test !Giac._has_direct_gen(Int64(typemax(Int32)) + 1) + @test !Giac._has_direct_gen(Int64(typemin(Int32)) - 1) + + # Edge: non-finite Float64 → ineligible + @test !Giac._has_direct_gen(Inf) + @test !Giac._has_direct_gen(-Inf) + @test !Giac._has_direct_gen(NaN) + + # Numeric types that need the string path + @test !Giac._has_direct_gen(big"123456789012345678901234567890") + @test !Giac._has_direct_gen(Int128(1) << 70) + @test !Giac._has_direct_gen(UInt64(42)) + @test !Giac._has_direct_gen(1//2) + @test !Giac._has_direct_gen(1 + 2im) + @test !Giac._has_direct_gen(π) + @test !Giac._has_direct_gen(ℯ) + + # Containers and non-numeric scalars + @test !Giac._has_direct_gen([1, 2, 3]) + @test !Giac._has_direct_gen(:x) + @test !Giac._has_direct_gen("x+1") + @test !Giac._has_direct_gen(sin) + @test !Giac._has_direct_gen(nothing) + end + + # ===================================================================== + # Foundational: _to_gen_direct + # ===================================================================== + @testset "Foundational: _to_gen_direct" begin + # Int32 → Gen + gi = Giac._to_gen_direct(Int32(42)) + @test Giac.GiacCxxBindings.to_string(gi) == "42" + + # Int64 in Int32 range → Gen + gi2 = Giac._to_gen_direct(42) + @test Giac.GiacCxxBindings.to_string(gi2) == "42" + + # Float64 → Gen + gf = Giac._to_gen_direct(3.14) + @test Giac.GiacCxxBindings.to_string(gf) == "3.14" + + # GiacExpr → cached Gen reuse (no clone, no eval) + g = giac_eval("x^2 + 1") + gen_before = Giac._get_gen_or_eval(g) + gen_via_direct = Giac._to_gen_direct(g) + @test gen_via_direct === gen_before + end + + # ===================================================================== + # Foundational: _fastpath_disabled and _refresh_fastpath_flag! + # ===================================================================== + @testset "Foundational: _fastpath_disabled and _refresh_fastpath_flag!" begin + # Make sure we start from a known state + prev = get(ENV, "GIAC_INVOKE_CMD_STRING_PATH", nothing) + try + # Unset → false + delete!(ENV, "GIAC_INVOKE_CMD_STRING_PATH") + Giac._refresh_fastpath_flag!() + @test Giac._fastpath_disabled[] == false + + # Empty string → false + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = "" + Giac._refresh_fastpath_flag!() + @test Giac._fastpath_disabled[] == false + + # "0" → false + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = "0" + Giac._refresh_fastpath_flag!() + @test Giac._fastpath_disabled[] == false + + # "no" → false + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = "no" + Giac._refresh_fastpath_flag!() + @test Giac._fastpath_disabled[] == false + + # "1" → true + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = "1" + Giac._refresh_fastpath_flag!() + @test Giac._fastpath_disabled[] == true + + # "true" / "True" / "TRUE" → true (case-insensitive) + for v in ("true", "True", "TRUE") + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = v + Giac._refresh_fastpath_flag!() + @test Giac._fastpath_disabled[] == true + end + + # "yes" / "YES" → true + for v in ("yes", "YES", "Yes") + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = v + Giac._refresh_fastpath_flag!() + @test Giac._fastpath_disabled[] == true + end + finally + if prev === nothing + delete!(ENV, "GIAC_INVOKE_CMD_STRING_PATH") + else + ENV["GIAC_INVOKE_CMD_STRING_PATH"] = prev + end + Giac._refresh_fastpath_flag!() + end + end + + # ===================================================================== + # Foundational: _invoke_cmd_direct exercises the arity-branched fast path + # ===================================================================== + @testset "Foundational: _invoke_cmd_direct (helper-level)" begin + g = giac_eval("x^2 + 1") + + # Arity 1: simplify(x^2 - 1) + g1 = giac_eval("(x^2 - 1)/(x - 1)") + r1 = Giac._invoke_cmd_direct(:simplify, (g1,)) + @test r1 isa GiacExpr + @test string(r1) == "x+1" + + # Arity 1: factor(x^2 - 1) + gf = giac_eval("x^2 - 1") + rf = Giac._invoke_cmd_direct(:factor, (gf,)) + @test r1 isa GiacExpr + @test string(rf) == "(x-1)*(x+1)" + + # Arity 2: diff(x^3, x) + g2a = giac_eval("x^3") + g2b = giac_eval("x") + r2 = Giac._invoke_cmd_direct(:diff, (g2a, g2b)) + @test string(r2) == "3*x^2" + + # Arity 2: evalf(pi, 15) + gpi = giac_eval("pi") + rpi = Giac._invoke_cmd_direct(:evalf, (gpi, Int32(15))) + @test startswith(string(rpi), "3.14159265358979") + + # Arity 3: diff(x^5, x, 2) + g3a = giac_eval("x^5") + g3b = giac_eval("x") + r3 = Giac._invoke_cmd_direct(:diff, (g3a, g3b, Int32(2))) + @test string(r3) == "20*x^3" + + # Arity 4 (apply_funcN path): sum(k, k, 1, 10) = 55 + sumk = giac_eval("k") + sumv = giac_eval("k") + rN = Giac._invoke_cmd_direct(:sum, (sumk, sumv, Int32(1), Int32(10))) + @test string(rN) == "55" + + # Arity 0: rand() returns a numeric value (random; we just check it parses as Float) + r0 = Giac._invoke_cmd_direct(:rand, ()) + @test r0 isa GiacExpr + v0 = tryparse(Float64, string(r0)) + @test v0 !== nothing + end + + # ===================================================================== + # US1: hot-loop parity — fast and string paths produce the same result + # ===================================================================== + @testset "US1: hot-loop parity" begin + g = giac_eval("sum(1/k^2, k, 1, 100)") + + # Arity 1 + for cmd in (:simplify, :factor, :expand, :normal, :ratnormal) + fast = invoke_cmd(cmd, g) + slow = _invoke_cmd_string_only(cmd, g) + @test _giac_equal(fast, slow) + end + + # Arity 2: evalf(g, n) + for n in (15, 30, 50) + fast = invoke_cmd(:evalf, g, n) + slow = _invoke_cmd_string_only(:evalf, g, n) + @test _giac_equal(fast, slow) + end + + # Arity 2 on polynomial: diff + p = giac_eval("x^3 + 2*x^2 + x + 1") + x = giac_eval("x") + @test _giac_equal(invoke_cmd(:diff, p, x), _invoke_cmd_string_only(:diff, p, x)) + + # Arity 3: diff(p, x, 2) + @test _giac_equal(invoke_cmd(:diff, p, x, 2), _invoke_cmd_string_only(:diff, p, x, 2)) + + # Compound expressions exercising factor/expand on a polynomial + h = giac_eval("(x-1)*(x+1)*(x^2+1)") + @test _giac_equal(invoke_cmd(:expand, h), _invoke_cmd_string_only(:expand, h)) + @test _giac_equal(invoke_cmd(:factor, h), _invoke_cmd_string_only(:factor, h)) + end + + # ===================================================================== + # US1: path selection — @debug log reveals which path each call took + # ===================================================================== + @testset "US1: path selection" begin + g = giac_eval("x^2 + 1") + + # Force fast path on for this testset, regardless of the env-var state + # at test-runtime (so the suite runs identically under default and + # under GIAC_INVOKE_CMD_STRING_PATH=1). + logs = _with_fast_path() do + _capture_debug_logs() do + invoke_cmd(:simplify, g) # all-GiacExpr → fast + invoke_cmd(:evalf, g, 20) # GiacExpr+Int → fast + invoke_cmd(:eval, 1//2) # Rational → string + invoke_cmd(:eval, [1, 2, 3]) # Vector → string + end + end + + msgs = filter(m -> startswith(m, "invoke_cmd "), logs) + @test length(msgs) == 4 + @test msgs[1] == "invoke_cmd fast path" + @test msgs[2] == "invoke_cmd fast path" + @test msgs[3] == "invoke_cmd string path" + @test msgs[4] == "invoke_cmd string path" + end + + # ===================================================================== + # US1: allocation budget — fast path allocates substantially less + # ===================================================================== + @testset "US1: allocation budget" begin + g = giac_eval("sum(1/k^2, k, 1, 100)") + + # Force fast path on for the fast-side measurement so the test passes + # uniformly under default and GIAC_INVOKE_CMD_STRING_PATH=1 environments. + fast_allocs = _with_fast_path() do + for _ in 1:5; invoke_cmd(:simplify, g); end + @allocations invoke_cmd(:simplify, g) + end + slow_allocs = _with_string_path() do + for _ in 1:5; invoke_cmd(:simplify, g); end + @allocations invoke_cmd(:simplify, g) + end + + # Fast path must allocate strictly less than the string path. Aggressive + # targets (e.g. "≥ 50 % fewer") are unstable across Julia versions because + # the string path's per-call allocations depend on Base internals; "< slow" + # is what the contract actually guarantees. + @test fast_allocs < slow_allocs + end + + # ===================================================================== + # US2: Gen identity — fast path reuses the cached Gen, no clone + # ===================================================================== + @testset "US2: Gen identity" begin + g = giac_eval("x^2 + sin(x)") + original = Giac._get_gen_or_eval(g) + + # Pre-call: cached Gen is `original` + @test Giac._get_gen_or_eval(g) === original + + # Run a fast-path call + _ = invoke_cmd(:simplify, g) + + # Post-call: same cached Gen + @test Giac._get_gen_or_eval(g) === original + + # And the helper itself does not clone + @test Giac._to_gen_direct(g) === original + end + + # ===================================================================== + # US2: parity battery — broad coverage of expression × command pairs + # ===================================================================== + @testset "US2: parity battery" begin + exprs = Dict{Symbol, GiacExpr}( + :int => giac_eval("42"), + :poly1 => giac_eval("x^2 - 2*x + 1"), + :poly2 => giac_eval("(x-1)*(x+1)*(x^2+1)"), + :ratfun => giac_eval("(x^2 + 2*x + 1)/(x + 1)"), + :trig => giac_eval("sin(x)^2 + cos(x)^2"), + :exp_ln => giac_eval("exp(x)*ln(x)"), + :sum_zeta => giac_eval("sum(1/k^2, k, 1, 100)"), + :pi_quart => giac_eval("pi/4"), + :multivar => giac_eval("x*y + x + y + 1"), + ) + + # Single-argument commands + for cmd in (:simplify, :factor, :expand, :normal, :ratnormal, :eval) + for (name, e) in exprs + fast = invoke_cmd(cmd, e) + slow = _invoke_cmd_string_only(cmd, e) + @test _giac_equal(fast, slow) + end + end + + # evalf with precision + for (name, e) in exprs + for n in (10, 30, 50) + fast = invoke_cmd(:evalf, e, n) + slow = _invoke_cmd_string_only(:evalf, e, n) + @test _giac_equal(fast, slow) + end + end + + # diff w.r.t. x (only meaningful for exprs containing x) + x = giac_eval("x") + for (name, e) in exprs + name == :int && continue + fast = invoke_cmd(:diff, e, x) + slow = _invoke_cmd_string_only(:diff, e, x) + @test _giac_equal(fast, slow) + end + end + + # ===================================================================== + # US2: structural-divergence regression + # ===================================================================== + @testset "US2: structural-divergence regression" begin + # The _giac_subst_vec_tier1 precedent (065-substitute-tier1) was added because + # a Gen's printed form did not round-trip through the GIAC parser in a way + # that preserved simultaneous substitution semantics. The same class of bug + # could in principle affect any invoke_cmd call routed through the string + # path on a Gen whose printed form is ambiguous to the parser. + # + # The fast path eliminates this class structurally: the cached Gen is passed + # to apply_func* without ever going through to_string + giac_eval. + # + # No concrete divergence case is reproducible in v1 with current giac builds, + # but the test exists to document the intent and guard against future drift. + + g = giac_eval("sin(x) + cos(x)") + fast = invoke_cmd(:simplify, g) + slow = _invoke_cmd_string_only(:simplify, g) + @test _giac_equal(fast, slow) + end + + # ===================================================================== + # US3: pure-fallback types — each ineligible type takes the string path + # ===================================================================== + @testset "US3: pure-fallback types" begin + # Each call below contains exactly one argument that is not fast-path-eligible. + # Verify (a) the string path is taken via @debug capture, and (b) the result + # matches the current (string-path) implementation. + cases = [ + (:eval, (1//2,)), + (:eval, (1 + 2im,)), + (:eval, (π,)), + (:eval, ([1, 2, 3],)), + (:eval, (Inf,)), + (:eval, (NaN,)), + (:eval, (:x,)), + (:eval, ("x+1",)), + (:eval, (big"123456789012345678901234567890",)), + (:eval, (Int128(1) << 70,)), + (:eval, (Int64(typemax(Int32)) + 100,)), + ] + + for (cmd, args) in cases + logs = _capture_debug_logs() do + invoke_cmd(cmd, args...) + end + msgs = filter(m -> startswith(m, "invoke_cmd "), logs) + @test length(msgs) == 1 + @test msgs[1] == "invoke_cmd string path" + + # Result parity: running it again under the kill switch must yield the same value. + fast = invoke_cmd(cmd, args...) + slow = _invoke_cmd_string_only(cmd, args...) + @test strip(string(fast)) == strip(string(slow)) + end + end + + # ===================================================================== + # US3: mixed-args fallback — any ineligible arg forces string path + # ===================================================================== + @testset "US3: mixed-args fallback" begin + g = giac_eval("x^2 + 1") + + # GiacExpr + Rational → string path (one valid mixed-arg shape: evalf with Rational precision is not real; + # use eval with [a, 1//2] which is a valid 2-arg call: eval(expr, mode). + # Simplest reliable case: a multi-arg command whose Julia surface accepts a Rational. + # We just need to confirm path selection, not GIAC's acceptance of the call, + # so use a 1-arg call where the single arg is Rational. + logs = _capture_debug_logs() do + invoke_cmd(:eval, 1//2) + end + msgs = filter(m -> startswith(m, "invoke_cmd "), logs) + @test msgs[1] == "invoke_cmd string path" + + # GiacExpr + Symbol → string path + logs2 = _capture_debug_logs() do + invoke_cmd(:integrate, g, :x) + end + msgs2 = filter(m -> startswith(m, "invoke_cmd "), logs2) + @test msgs2[1] == "invoke_cmd string path" + + # GiacExpr + Inf → string path: GIAC accepts inf as a 2nd evalf arg + # (no-op, but exercises the dispatch correctly) + logs3 = _capture_debug_logs() do + try + invoke_cmd(:evalf, g, Inf) + catch + # Some commands reject Inf; we only care that the path is "string" + end + end + msgs3 = filter(m -> startswith(m, "invoke_cmd "), logs3) + @test msgs3[1] == "invoke_cmd string path" + + # GiacExpr + Complex → string path + logs4 = _capture_debug_logs() do + try + invoke_cmd(:evalf, g, 1 + 0im) + catch + end + end + msgs4 = filter(m -> startswith(m, "invoke_cmd "), logs4) + @test msgs4[1] == "invoke_cmd string path" + end + + # ===================================================================== + # US4: env-var kill switch + # ===================================================================== + @testset "US4: env-var kill switch" begin + g = giac_eval("x^2 + 1") + + # With kill switch, every call (even pure-GiacExpr) takes string path + logs = _capture_debug_logs() do + _with_string_path() do + invoke_cmd(:simplify, g) + invoke_cmd(:factor, g - 1) + invoke_cmd(:evalf, g, 20) + end + end + + msgs = filter(m -> startswith(m, "invoke_cmd "), logs) + @test length(msgs) == 3 + @test all(m -> m == "invoke_cmd string path", msgs) + + # Without kill switch, the same pure-GiacExpr calls take fast path + logs2 = _capture_debug_logs() do + _with_fast_path() do + invoke_cmd(:simplify, g) + invoke_cmd(:factor, g - 1) + invoke_cmd(:evalf, g, 20) + end + end + msgs2 = filter(m -> startswith(m, "invoke_cmd "), logs2) + @test length(msgs2) == 3 + @test all(m -> m == "invoke_cmd fast path", msgs2) + end + +end # @testset "069-invoke-cmd-fastpath" diff --git a/test/test_mcp_ext.jl b/test/test_mcp_ext.jl new file mode 100644 index 00000000..62c63fd3 --- /dev/null +++ b/test/test_mcp_ext.jl @@ -0,0 +1,192 @@ +using Test +using Giac +using ModelContextProtocol + +# Helper: locate a tool by name from the server's tool list. +function _find_tool(server, tool_name::AbstractString) + idx = findfirst(t -> t.name == tool_name, server.tools) + idx === nothing && error("tool \"$tool_name\" not found on server") + return server.tools[idx] +end + +@testset "GiacMCPExt" begin + + # ------------------------------------------------------------------ + # 1. Extension activation (FR-002, FR-009) + # ------------------------------------------------------------------ + @testset "Extension activation" begin + @test isdefined(Giac, :giac_mcp_server) + @test isa(Giac.giac_mcp_server, Function) + # Once ModelContextProtocol is loaded, the zero-arg method exists. + @test hasmethod(Giac.giac_mcp_server, Tuple{}) + end + + # ------------------------------------------------------------------ + # 2. Server shape (FR-003) + # ------------------------------------------------------------------ + @testset "Server shape" begin + server = giac_mcp_server() + @test length(server.tools) == 2 + tool_names = Set(t.name for t in server.tools) + @test tool_names == Set(["giac_eval", "giac_search"]) + end + + # ------------------------------------------------------------------ + # 3. Eval handler — happy paths (FR-003, FR-004, SC-005) + # ------------------------------------------------------------------ + @testset "Eval handler — happy paths" begin + server = giac_mcp_server() + eval_tool = _find_tool(server, "giac_eval") + + # Arithmetic + result = eval_tool.handler(Dict("expr" => "2+3")) + @test isa(result, TextContent) + @test occursin("5", result.text) + + # Algebra — factorization + result = eval_tool.handler(Dict("expr" => "factor(x^2-1)")) + @test isa(result, TextContent) + @test occursin("x-1", result.text) || occursin("x - 1", result.text) + @test occursin("x+1", result.text) || occursin("x + 1", result.text) + + # Calculus — differentiation + result = eval_tool.handler(Dict("expr" => "diff(sin(x^2),x)")) + @test isa(result, TextContent) + @test occursin("cos", result.text) + @test occursin("x^2", result.text) || occursin("x*x", result.text) + + # Laplace transform + result = eval_tool.handler(Dict("expr" => "laplace(exp(-t),t,s)")) + @test isa(result, TextContent) + @test occursin("s+1", result.text) || occursin("s + 1", result.text) + end + + # ------------------------------------------------------------------ + # 4. Eval handler — error path (FR-006, SC-004) + # ------------------------------------------------------------------ + @testset "Eval handler — error path" begin + server = giac_mcp_server() + eval_tool = _find_tool(server, "giac_eval") + + # Invalid Giac input must NOT throw (the MCP session must stay alive). + # Giac itself is forgiving and reports parse errors as a textual "undef" + # result via stderr warnings, so the handler returns a TextContent in + # that path. When Giac DOES raise a Julia exception (e.g. wrapper-level + # failures), our try/catch wraps it as CallToolResult(isError=true). + # Either shape satisfies FR-006; both keep the session alive. + local result + threw = false + try + result = eval_tool.handler(Dict("expr" => "invalid_syntax((")) + catch + threw = true + end + @test !threw + @test (isa(result, TextContent) && !isempty(result.text)) || + (isa(result, CallToolResult) && result.isError == true && !isempty(result.content)) + end + + # ------------------------------------------------------------------ + # 5. Search handler — basic (FR-005) + # ------------------------------------------------------------------ + @testset "Search handler — basic" begin + server = giac_mcp_server() + search_tool = _find_tool(server, "giac_search") + + # Keyword match + result = search_tool.handler(Dict("query" => "laplace")) + @test isa(result, TextContent) + @test occursin("laplace", result.text) + + # Explicit empty-match contract + result = search_tool.handler(Dict("query" => "xxxxxxxx-no-such")) + @test isa(result, TextContent) + @test result.text == "No commands matched." + end + + # ------------------------------------------------------------------ + # 6. Re-creation safety (FR-010) + # ------------------------------------------------------------------ + @testset "Re-creation safety" begin + s1 = giac_mcp_server() + s2 = giac_mcp_server() + @test s1 !== s2 + @test length(s1.tools) == 2 + @test length(s2.tools) == 2 + end + + # ------------------------------------------------------------------ + # 7. Kwarg forwarding (FR-011) + # ------------------------------------------------------------------ + @testset "Kwarg forwarding" begin + server = giac_mcp_server(name = "custom-giac") + # `Server` stores configuration in `.config`; the server name lives there. + @test server.config.name == "custom-giac" + end + + @testset "Server version tracks Giac.jl version" begin + # serverInfo.version (visible to MCP clients in the initialize handshake) + # MUST default to the running Giac.jl version, not the framework's hardcoded + # "1.0.0". + server = giac_mcp_server() + @test server.config.version == string(pkgversion(Giac)) + + # User override still wins. + server = giac_mcp_server(version = "9.9.9-test") + @test server.config.version == "9.9.9-test" + end + + # ------------------------------------------------------------------ + # US2 — stub-export visible (data-model.md Entity 5) + # ------------------------------------------------------------------ + @testset "US2 — public surface" begin + @test :giac_mcp_server in names(Giac) + @test isa(Giac.giac_mcp_server, Function) + end + + # ------------------------------------------------------------------ + # US3 — search quality across SC-005 keywords + # ------------------------------------------------------------------ + @testset "Search quality — SC-005 keywords" begin + server = giac_mcp_server() + search_tool = _find_tool(server, "giac_search") + + function _text_for(query::AbstractString) + result = search_tool.handler(Dict("query" => query)) + @test isa(result, TextContent) + return result.text + end + + # laplace → at least `laplace` + text = _text_for("laplace") + @test text != "No commands matched." + @test occursin("laplace", text) + + # matrix → at least one matrix-related command + text = _text_for("matrix") + @test text != "No commands matched." + + # prime → at least one prime-related command + text = _text_for("prime") + @test text != "No commands matched." + @test occursin("prime", text) + + # factor → at least `factor` + text = _text_for("factor") + @test text != "No commands matched." + @test occursin("factor", text) + + # integrate → at least `integrate` + text = _text_for("integrate") + @test text != "No commands matched." + @test occursin("integrate", text) + end + + @testset "Search quality — explicit empty match" begin + server = giac_mcp_server() + search_tool = _find_tool(server, "giac_search") + result = search_tool.handler(Dict("query" => "xxxxxxxxxxx-no-such-command")) + @test result.text == "No commands matched." + end + +end diff --git a/test/test_types.jl b/test/test_types.jl index 94b1c481..060eba5c 100644 --- a/test/test_types.jl +++ b/test/test_types.jl @@ -301,9 +301,30 @@ # GIAC operations should return nothing @test Giac._parse_function_expr("diff(u,t)") === nothing @test Giac._parse_function_expr("sin(x)") === nothing + + # Unicode identifiers (issue reported by @tduretz) + # GIAC C++ accepts Unicode names, so D() must too. + @test Giac._parse_function_expr("ϕ(𝑧)") == ("ϕ", "𝑧") + @test Giac._parse_function_expr("ψ(t)") == ("ψ", "t") + @test Giac._parse_function_expr("α(β,γ)") == ("α", "β") end end + @testset "Derivative Operator D - Unicode identifiers" begin + # Regression test for @tduretz report: D(ϕ) used to fail because the + # parser regex only accepted ASCII names. + @giac_var 𝑧 ϕ(𝑧) + dϕ = D(ϕ) + @test dϕ isa DerivativeExpr + @test dϕ.order == 1 + @test dϕ.funcname == "ϕ" + @test dϕ.varname == "𝑧" + + d2ϕ = D(ϕ, 2) + @test d2ϕ.order == 2 + @test d2ϕ.funcname == "ϕ" + end + @testset "Derivative Operator D - Basic Creation" begin @testset "D(u) creates first derivative" begin @giac_var u(t)