diff --git a/README.md b/README.md index 5e042c8..062441b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ C++ wrapper for [GIAC](https://xcas.univ-grenoble-alpes.fr/) computer algebra sy - String-based expression evaluation - Context management for variable persistence +- Per-context evaluation via `giac_eval(expr, ctx)` so distinct `GiacContext` instances isolate `:=` bindings ([#3](https://github.com/s-celles/libgiac-julia-wrapper/issues/3)) - Native Gen object manipulation - Support for Linux, macOS, and Windows diff --git a/meson.build b/meson.build index 6313332..043eb5f 100644 --- a/meson.build +++ b/meson.build @@ -1,5 +1,5 @@ project('giac_wrapper', 'cpp', - version: '0.5.0', + version: '0.6.0', meson_version: '>= 1.2.0', license: 'GPL-3.0-or-later', default_options: [ @@ -9,6 +9,13 @@ project('giac_wrapper', 'cpp', ], ) +# Expose the project version to C++ so get_wrapper_version() never falls +# out of sync with meson.build (issue #2). +add_project_arguments( + '-DWRAPPER_VERSION="' + meson.project_version() + '"', + language: 'cpp', +) + # Find dependencies # JlCxx (libcxxwrap-julia) ships CMake config files, use Meson's cmake module jlcxx_dep = dependency('JlCxx', diff --git a/src/giac_impl.cpp b/src/giac_impl.cpp index 52d9212..a27a4bc 100644 --- a/src/giac_impl.cpp +++ b/src/giac_impl.cpp @@ -96,7 +96,11 @@ std::string get_giac_version() { } std::string get_wrapper_version() { - return "0.1.0"; +#ifdef WRAPPER_VERSION + return WRAPPER_VERSION; +#else + return "unknown"; +#endif } bool check_giac_available() { @@ -161,6 +165,18 @@ Gen giac_eval(const std::string& expr) { return Gen(std::make_unique(result)); } +Gen giac_eval(const std::string& expr, GiacContext& ctx) { + initialize_giac_library(); + giac::context* gctx = ctx.impl_->ctx; + try { + giac::gen parsed = giac::gen(expr, gctx); + giac::gen result = giac::eval(parsed, gctx); + return Gen(std::make_unique(result)); + } catch (const std::exception& e) { + throw std::runtime_error(std::string("GIAC evaluation error: ") + e.what()); + } +} + // ============================================================================ // Generic Dispatch Implementation // ============================================================================ diff --git a/src/giac_impl.h b/src/giac_impl.h index ddd8ed6..9e0cf7b 100644 --- a/src/giac_impl.h +++ b/src/giac_impl.h @@ -20,7 +20,8 @@ namespace giac_julia { // Forward declaration of opaque types struct GiacContextImpl; struct GenImpl; -class Gen; // Forward declaration for free functions +class Gen; // Forward declaration for free functions +class GiacContext; // Forward declaration for free functions taking a context // ============================================================================ // Version Functions @@ -49,9 +50,21 @@ int help_count(); * @param expr Expression string (e.g., "sin(x)+1", "[[1,2],[3,4]]") * @return Evaluated Gen * @note This is the preferred entry point for string expressions + * @note Uses a process-wide singleton context; use the (expr, ctx) + * overload below for per-context isolation. */ Gen giac_eval(const std::string& expr); +/** + * @brief Parse and evaluate a Giac expression string in a specific context + * @param expr Expression string + * @param ctx Context whose state (variable bindings, configuration) is + * used and updated by this evaluation. Two distinct GiacContext + * instances do not share `:=` bindings, enabling true isolation. + * @return Evaluated Gen + */ +Gen giac_eval(const std::string& expr, GiacContext& ctx); + // ============================================================================ // Generic Dispatch (Tier 2) // ============================================================================ @@ -291,6 +304,9 @@ class GiacContext { private: std::unique_ptr impl_; + + // Free function that needs access to the underlying giac::context*. + friend Gen giac_eval(const std::string& expr, GiacContext& ctx); }; // ============================================================================ @@ -403,6 +419,7 @@ class Gen { // Friend functions that need access to private constructor friend Gen giac_eval(const std::string& expr); + friend Gen giac_eval(const std::string& expr, GiacContext& ctx); friend Gen apply_func0(const std::string& name); friend Gen apply_func1(const std::string& name, const Gen& arg); friend Gen apply_func2(const std::string& name, const Gen& arg1, const Gen& arg2); diff --git a/src/giac_wrapper.cpp b/src/giac_wrapper.cpp index 1a2fe12..235ac3b 100644 --- a/src/giac_wrapper.cpp +++ b/src/giac_wrapper.cpp @@ -141,8 +141,13 @@ JLCXX_MODULE define_julia_module(jlcxx::Module& mod) .method("expand", &Gen::expand) .method("factor", &Gen::factor); - // Register giac_eval free function - mod.method("giac_eval", &giac_eval); + // Register giac_eval free function (both overloads). + // The (expr) overload uses the singleton thread-local context; the + // (expr, ctx) overload provides per-context isolation (issue #3). + mod.method("giac_eval", + static_cast(&giac_eval)); + mod.method("giac_eval", + static_cast(&giac_eval)); // Register generic dispatch functions (Tier 2) mod.method("apply_func0", &apply_func0); diff --git a/tests/cpp/test_context.cpp b/tests/cpp/test_context.cpp index 1e176c3..3dece53 100644 --- a/tests/cpp/test_context.cpp +++ b/tests/cpp/test_context.cpp @@ -44,6 +44,59 @@ TEST(context_isolation) { ASSERT_EQ("20", ctx2.get_variable("x")); } +// Issue #3: free-function giac_eval(expr, ctx) — returns a Gen and +// preserves per-context isolation. Binding `a := 5` through one context +// must NOT be visible to a fresh context. +TEST(giac_eval_with_context_returns_gen) { + GiacContext ctx1; + GiacContext ctx2; + + // Bind in ctx1 via the new context-aware giac_eval. + Gen r1 = giac_eval("ctx_iso_a := 7", ctx1); + ASSERT_EQ("7", r1.to_string()); + + // Reading the same name in ctx1 sees the binding. + Gen r1_read = giac_eval("ctx_iso_a", ctx1); + ASSERT_EQ("7", r1_read.to_string()); + + // ctx2 is independent — the same name is still the unbound symbol. + Gen r2_read = giac_eval("ctx_iso_a", ctx2); + ASSERT_EQ("ctx_iso_a", r2_read.to_string()); +} + +// Issue #3 regression (MCP scenario): binding `y` in one context must not +// poison `desolve(..., y)` in a fresh, independent context. Under the +// pre-fix singleton-context behavior, this combination produced +// `Error: Dependent variable assigned. Run purge(y)`. +TEST(issue3_bound_var_does_not_poison_desolve_in_other_context) { + GiacContext ctx_with_binding; + GiacContext ctx_fresh; + + // Establish the binding that used to leak. + (void) giac_eval("y := 42", ctx_with_binding); + + // In a fresh, independent context, a desolve referencing y must work. + // (Calling this same desolve in ctx_with_binding throws + // "Dependent variable assigned. Run purge(y)" — verified manually.) + Gen result = giac_eval("desolve(diff(y,t)=cos(t), t, y)", ctx_fresh); + std::string s = result.to_string(); + + if (s.find("Dependent variable") != std::string::npos) { + throw std::runtime_error( + "desolve in ctx_fresh was poisoned by ctx_with_binding's binding: " + + s + ); + } + if (s.empty()) { + throw std::runtime_error("desolve returned an empty result string"); + } + + // And ctx_with_binding still holds y = 42 — bindings inside a context + // persist across calls within that context. + Gen y_in_ctx1 = giac_eval("y", ctx_with_binding); + ASSERT_EQ("42", y_in_ctx1.to_string()); +} + // Test timeout configuration TEST(timeout_config) { GiacContext ctx; @@ -84,6 +137,8 @@ int main() { RUN_TEST(variable_assignment); RUN_TEST(context_isolation); + RUN_TEST(giac_eval_with_context_returns_gen); + RUN_TEST(issue3_bound_var_does_not_poison_desolve_in_other_context); RUN_TEST(timeout_config); RUN_TEST(precision_config); RUN_TEST(complex_mode); diff --git a/tests/cpp/test_eval.cpp b/tests/cpp/test_eval.cpp index 28de2b9..71b02ff 100644 --- a/tests/cpp/test_eval.cpp +++ b/tests/cpp/test_eval.cpp @@ -61,7 +61,12 @@ TEST(error_handling) { TEST(version_functions) { std::string gv = get_giac_version(); std::string wv = get_wrapper_version(); - ASSERT_EQ("0.1.0", wv); + // Issue #2: wrapper_version() now comes from meson.project_version() + // at build time. Check it is non-empty and SemVer-shaped (X.Y.Z...) + // rather than pinning a literal. + assert(!wv.empty()); + assert(wv != "unknown"); + assert(wv.find('.') != std::string::npos); // GIAC version should be non-empty assert(!gv.empty()); } diff --git a/tests/julia/test_context.jl b/tests/julia/test_context.jl index bc2c228..0f43382 100644 --- a/tests/julia/test_context.jl +++ b/tests/julia/test_context.jl @@ -32,6 +32,23 @@ end @test get_variable(ctx2, "x") == "20" end + # Issue #3: the new free-function `giac_eval(expr, ctx)` returns a Gen + # and preserves per-context isolation of `:=` bindings. + @testset "giac_eval(expr, ctx) — returns Gen and isolates bindings" begin + ctx1 = GiacContext() + ctx2 = GiacContext() + + # Bind via the new overload, exercised from Julia. + r1 = giac_eval("ctx_iso_b := 11", ctx1) + @test to_string(r1) == "11" + + # ctx1 sees the binding. + @test to_string(giac_eval("ctx_iso_b", ctx1)) == "11" + + # ctx2 is independent — still the unbound symbol. + @test to_string(giac_eval("ctx_iso_b", ctx2)) == "ctx_iso_b" + end + @testset "Timeout Configuration" begin ctx = GiacContext() diff --git a/tests/julia/test_eval.jl b/tests/julia/test_eval.jl index 4e8c812..6f3219b 100644 --- a/tests/julia/test_eval.jl +++ b/tests/julia/test_eval.jl @@ -17,7 +17,10 @@ end @testset "Version Functions" begin @test giac_version() isa String @test !isempty(giac_version()) - @test wrapper_version() == "0.1.0" + # Issue #2: wrapper_version() now reads from meson.project_version() + # at build time, so it never falls out of sync with meson.build. + # Accept any SemVer-shaped string instead of a hardcoded literal. + @test occursin(r"^\d+\.\d+\.\d+", wrapper_version()) end @testset "GIAC Availability" begin