From 0a9665ed4e34b48a5b9ac262d97dab90c7d47c08 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sun, 25 Oct 2026 21:45:15 +0000 Subject: [PATCH] The MCP server's tools answer from the catalogue of operations Part of https://github.com/asc-community/AngouriMath/issues/1684 Each tool called the library's methods itself. Its answers now come from the entries of Core/Operations/Catalogue.cs: Catalogue.Solve.Ask(e, x) where it called e.Solve(x), and so on. Every entry answers exactly what its method answers, so no answer changes, and the 335 tests that drive the server pass as they did. The server's own checks of an answer stay direct calls: differentiating an antiderivative back, reading an input as an integer or a decimal, and the guard before quadrature. Tools.Offers says which entries each tool offers. Tools.OwnComputations lists the six tools whose answer is the server's own computation, such as am_check_steps, each with what it computes. Tools.NotOffered lists the two entries no tool offers, normalize and evaluate, each with the reason. The server's CatalogueTest checks the three against the tool list and the catalogue. An entry added to the catalogue fails it until a tool offers it or NotOffered says why none does. amcli's one-shot commands get the same check, without calling the catalogue: OneShot.offers names the entry each command offers, ownCommands says what info, mcp and help do, and notOffered lists the entries no command offers. Which commands to add is #1019 item 16's question. OneShotTest reads the catalogue's names by reflection, since the terminal is not one of the library's friends. Removing one tool and one entry from the server's lists fails both of its tests and names each, and the same for one command and one entry in the terminal's. The catalogue is internal, so the library names the server in its InternalsVisibleTo. A strong-named assembly may only befriend another one, so the server is signed with the library's key, as the unit tests are, and so is McpUnitTests, which the server befriends. The server ships inside amcli with the library it was built against. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- AGENTS.md | 4 +- .../Functions/InternalAMExtensions.cs | 10 + .../AngouriMath.Mcp/AngouriMath.Mcp.csproj | 10 +- Sources/MCP/AngouriMath.Mcp/Tools.cs | 174 ++++++++++++------ Sources/MCP/README.md | 6 + .../AngouriMath.Terminal.Lib/OneShot.fs | 35 ++++ Sources/Tests/McpUnitTests/CatalogueTest.cs | 42 +++++ .../Tests/McpUnitTests/McpUnitTests.csproj | 4 + .../Tests/TerminalUnitTests/OneShotTest.fs | 32 ++++ 9 files changed, 259 insertions(+), 58 deletions(-) create mode 100644 Sources/Tests/McpUnitTests/CatalogueTest.cs diff --git a/AGENTS.md b/AGENTS.md index e3f99929b..2ecc3d6f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -878,7 +878,9 @@ landed — and **the MCP server and two other repositories brought to the releas MCP still says what the library does. An operation the library offers by name is an entry of `Core/Operations/Catalogue.cs` ([#1684](https://github.com/asc-community/AngouriMath/issues/1684)), and a new factory of `Transformation` fails `CatalogueTest` until it is an entry or a building - block with its reason. + block with its reason. The MCP server's tools answer from those entries, and a new entry fails + the server's own `CatalogueTest` until a tool offers it or `Tools.NotOffered` says why none does, + and the terminal's `OneShotTest` until a command offers it or `OneShot.notOffered` lists it. - [CSharpMath](https://github.com/verybadcat/CSharpMath), for the round trip of `Latexize` — see *The standard command wins* above. If a PR there from an earlier release is still unmerged, add to it rather than opening a second. diff --git a/Sources/AngouriMath/Functions/InternalAMExtensions.cs b/Sources/AngouriMath/Functions/InternalAMExtensions.cs index b41a2d8f6..35574154b 100644 --- a/Sources/AngouriMath/Functions/InternalAMExtensions.cs +++ b/Sources/AngouriMath/Functions/InternalAMExtensions.cs @@ -37,6 +37,16 @@ + "552aeb08b7a67f49a5557ef4faab3d9f033182be1a646b24c932399732107462848dcb97acecc844" + "9997b1b77e6bd337e1116878226dd4954e004f11193ccd8e29fc156615c798f733712923ffe8d6c5")] +// And to the MCP server behind `amcli mcp`, whose tools answer from the catalogue of operations in +// Core/Operations, which stays internal until v3. The server is no package of its own: it ships +// inside the terminal's tool with the library it was built against, so it never meets another +// version's internals. https://github.com/asc-community/AngouriMath/issues/1684 +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("AngouriMath.Mcp, PublicKey=" + + "0024000004800000940000000602000000240000525341310004000001000100f54ef0f5905ba32e" + + "cae04751103c5565283a4c4fa2b07627ff7a7e556b287f198203a09935d998ce9d812a696aa2e71c" + + "552aeb08b7a67f49a5557ef4faab3d9f033182be1a646b24c932399732107462848dcb97acecc844" + + "9997b1b77e6bd337e1116878226dd4954e004f11193ccd8e29fc156615c798f733712923ffe8d6c5")] + namespace AngouriMath { // Visibility for the class is internal so we can use public for methods as we like diff --git a/Sources/MCP/AngouriMath.Mcp/AngouriMath.Mcp.csproj b/Sources/MCP/AngouriMath.Mcp/AngouriMath.Mcp.csproj index b1ae0d429..03e9df424 100644 --- a/Sources/MCP/AngouriMath.Mcp/AngouriMath.Mcp.csproj +++ b/Sources/MCP/AngouriMath.Mcp/AngouriMath.Mcp.csproj @@ -18,10 +18,18 @@ true false + + true + ../../AngouriMath/key.snk - + + diff --git a/Sources/MCP/AngouriMath.Mcp/Tools.cs b/Sources/MCP/AngouriMath.Mcp/Tools.cs index 5f6e6c855..a6e0fd9f3 100644 --- a/Sources/MCP/AngouriMath.Mcp/Tools.cs +++ b/Sources/MCP/AngouriMath.Mcp/Tools.cs @@ -8,6 +8,7 @@ using System.Text.Json.Nodes; using AngouriMath; using AngouriMath.Core; +using AngouriMath.Core.Operations; using AngouriMath.Extensions; using static AngouriMath.Entity; @@ -70,6 +71,69 @@ private static JsonObject Schema(JsonObject props, params string[] required) private const string PreliminaryMark = "Preliminary: its arguments and answers may change in a minor release. "; + /// + /// The operations of the library's catalogue that each tool offers, by the tool's name. A + /// tool's answers come from the catalogue's entries; its guards, its checks of an answer and + /// its formatting are its own. + /// + /// + /// A test checks this against the tools and the catalogue: every tool offers an entry or is in + /// , and every entry is offered by a tool or is in + /// . So an operation added to the catalogue fails that test until a + /// tool offers it or this file says why none does. + /// https://github.com/asc-community/AngouriMath/issues/1684 + /// + public static readonly IReadOnlyDictionary Offers = new Dictionary + { + ["am_parse"] = [Catalogue.Variables, Catalogue.Latex], + ["am_simplify"] = [Catalogue.Simplify, Catalogue.Alternatives], + ["am_solve"] = [Catalogue.Solve], + ["am_differentiate"] = [Catalogue.Differentiate], + ["am_integrate"] = [Catalogue.Integrate], + ["am_limit"] = [Catalogue.Limit], + ["am_evaluate"] = [Catalogue.EvaluateNumerically], + ["am_truth_table"] = [Catalogue.TruthTable, Catalogue.SatisfyingAssignments], + ["am_solve_system"] = [Catalogue.SolveSystem], + ["am_represent"] = [Catalogue.ToBase, Catalogue.FromBase], + ["am_matrix"] = [Catalogue.Determinant, Catalogue.Inverse, Catalogue.Transpose, Catalogue.Rank, + Catalogue.ReducedRowEchelonForm, Catalogue.Trace, Catalogue.TensorProduct], + ["am_substitute"] = [Catalogue.Substitute], + ["am_expand"] = [Catalogue.Expand], + ["am_factor"] = [Catalogue.Factorize], + ["am_series"] = [Catalogue.Series], + ["am_number_theory"] = [Catalogue.PrimeFactorization, Catalogue.Totient, Catalogue.GreatestCommonDivisor, + Catalogue.DivisorCount, Catalogue.IsPrime], + ["am_to_sympy"] = [Catalogue.Sympy], + }; + + /// + /// The tools whose answer is the server's own computation rather than an operation of the + /// catalogue, each with what it computes. + /// + public static readonly IReadOnlyDictionary OwnComputations = new Dictionary + { + ["am_verify_equal"] = "whether two expressions are equal, by simplifying their difference and by sampling it", + ["am_check_steps"] = "whether each step of a derivation follows from the one before", + ["am_domain_check"] = "where an expression is undefined or leaves the real line, by sampling", + ["am_eigenvalues"] = "a matrix's eigenvalues, as the roots of its characteristic polynomial", + ["am_compare_numeric"] = "where an approximation parts from what it approximates", + ["am_classify"] = "a guess at a formula's field, from the names of its symbols", + }; + + /// The catalogue's entries that no tool offers, by name, each with the reason. + public static readonly IReadOnlyDictionary NotOffered = new Dictionary + { + [Catalogue.Normalize.Name] = "am_simplify offers the whole simplification, which starts with this pass", + [Catalogue.Evaluate.Name] = "am_evaluate keeps an exact value exact, simplifying rather than evaluating, and adds a numeric value beside it", + }; + + /// The names of the catalogue's entries, which a test checks the lists above against. + internal static IEnumerable CatalogueNames => Catalogue.All.Select(operation => operation.Name); + + /// by the entries' names. + internal static IReadOnlyDictionary OfferedNames => + Offers.ToDictionary(tool => tool.Key, tool => tool.Value.Select(operation => operation.Name).ToArray()); + /// The tools offered to a client: every one, or all but the preliminary ones. public static JsonArray List(bool preliminary) { @@ -625,7 +689,7 @@ private static JsonObject Respond(Entity input, Entity result, List warn ["status"] = status, ["parsed"] = input.Stringize(), ["result"] = raw, - ["latex"] = result.Latexize(), + ["latex"] = Catalogue.Latex.Ask(result), }; if (status == "declined") @@ -682,14 +746,14 @@ private static JsonObject Parse(JsonObject args) return error!; var vars = new JsonArray(); - foreach (var v in e.Vars) vars.Add(v.Stringize()); + foreach (var v in Catalogue.Variables.Ask(e)) vars.Add(v.Stringize()); var response = new JsonObject { ["status"] = "solved", ["parsed"] = e.Stringize(), ["result"] = e.Stringize(), - ["latex"] = e.Latexize(), + ["latex"] = Catalogue.Latex.Ask(e), ["free_variables"] = vars, ["node_count"] = e.Nodes.Count(), }; @@ -707,10 +771,10 @@ private static JsonObject Simplify(JsonObject args) return FromOutcome(Guard.Run(() => { - var simplified = e.Simplify(); + var simplified = Catalogue.Simplify.Apply(e); var alternates = new List(); if (wantAlternatives) - foreach (var alt in e.Alternate(4).Take(5)) + foreach (var alt in Catalogue.Alternatives.Ask(e, 4).Take(5)) { var s = alt.Stringize(); if (s != simplified.Stringize() && !alternates.Contains(s)) @@ -748,7 +812,7 @@ private static JsonObject Solve(JsonObject args) if (!TryParse(combined, "constraints", false, out var e, out var warnings, out var error)) return error!; - return FromOutcome(Guard.Run(() => e.Solve(Var(variable!))), set => + return FromOutcome(Guard.Run(() => Catalogue.Solve.Ask(e, Var(variable!))), set => { var response = Respond(e, set, warnings); if (set is Set.FiniteSet finite) @@ -761,7 +825,7 @@ private static JsonObject Solve(JsonObject args) // output-tidiness weakness the library is known for, so tidy each root. // Guarded because Simplify can be slow and must not lose the answer. Entity tidy; - try { tidy = root.Simplify(); } + try { tidy = Catalogue.Simplify.Apply(root); } catch { tidy = root; } roots.Add(tidy.Stringize()); } @@ -790,7 +854,7 @@ private static JsonObject Differentiate(JsonObject args) if (order < 1) return Fail("'order' must be at least 1"); return FromOutcome( - Guard.Run(() => e.Differentiate(Var(variable!), order).Simplify()), + Guard.Run(() => Catalogue.Simplify.Apply(Catalogue.Differentiate.Apply(e, Var(variable!), order))), d => Respond(e, d, warnings)); } @@ -834,13 +898,13 @@ private static JsonObject Integrate(JsonObject args) return Numeric.TryDefiniteIntegral(e, v, a, b); } - var antiderivative = e.Integrate(v); + var antiderivative = Catalogue.Integrate.Apply(e, v); // Decline check on the RAW result, before Simplify — see Guard.IsDeclined. if (Guard.IsDeclined(antiderivative.Stringize())) return (antiderivative, (bool?)null, (Entity?)null, Quadrature()); - var tidy = antiderivative.Simplify(); + var tidy = Catalogue.Simplify.Apply(antiderivative); // Verify by differentiating back, which is the whole reason to trust the answer. bool? verified; @@ -859,7 +923,7 @@ private static JsonObject Integrate(JsonObject args) if (lower is not null && upper is not null) { var bare = Numeric.Strip(tidy); - definite = (bare.Substitute(v, upper) - bare.Substitute(v, lower)).Simplify(); + definite = Catalogue.Simplify.Apply(Catalogue.Substitute.Apply(bare, v, upper) - Catalogue.Substitute.Apply(bare, v, lower)); } // Only where the exact route came back with nothing to show. A NaN definite @@ -894,7 +958,7 @@ private static JsonObject Integrate(JsonObject args) if (r.Item3 is { } definite) { response["definite_value"] = definite.Stringize(); - response["definite_latex"] = definite.Latexize(); + response["definite_latex"] = Catalogue.Latex.Ask(definite); response["definite_caveat"] = "Computed as F(to) - F(from) from the verified antiderivative. That is " + "only valid when the integrand is continuous across the whole interval — " + @@ -954,10 +1018,10 @@ private static JsonObject Limit(JsonObject args) return FromOutcome(Guard.Run(() => { - var raw = e.Limit(Var(variable!), destination, side); + var raw = Catalogue.Limit.Apply(e, Var(variable!), destination, side); // Check for a decline BEFORE simplifying: an unevaluated limit(...) simplifies // to NaN, which would look like a wrong answer instead of a missing solver. - return Guard.IsDeclined(raw.Stringize()) ? raw : raw.Simplify(); + return Guard.IsDeclined(raw.Stringize()) ? raw : Catalogue.Simplify.Apply(raw); }), r => { var response = Respond(e, r, warnings); @@ -1011,19 +1075,19 @@ private static JsonObject Evaluate(JsonObject args) // Resolve calculus nodes BEFORE substituting. Substituting into // derivative(f(x), x) cannot work — x is bound there — so `d/dx sqrt(1+x) at // x=0` silently came back with x still free. Differentiate first, then plug in. - var resolved = input.InnerSimplified; + var resolved = Catalogue.Normalize.Apply(input); foreach (var (name, value) in substitutions) - resolved = resolved.Substitute(Var(name), value); + resolved = Catalogue.Substitute.Apply(resolved, Var(name), value); // Simplify, not Evaled: Evaled numericises aggressively, turning // sin(pi/3) + cos(pi/6) into a hundred decimal digits and throwing away the // exact sqrt(3) that is the whole reason to use a CAS. - var exact = resolved.Simplify(); + var exact = Catalogue.Simplify.Apply(resolved); string? approx = null; if (exact.EvaluableNumerical) { - var complex = exact.EvalNumerical(); + var complex = Catalogue.EvaluateNumerically.Ask(exact); approx = !complex.ImaginaryPart.EDecimal.IsZero ? complex.Stringize() : digits > 0 @@ -1054,7 +1118,7 @@ private static JsonObject VerifyEqual(JsonObject args) // Exact first: if the difference reduces to zero, that settles it. The zero test // has to look through any `provided` guard — the difference of two equal // expressions is routinely "0 provided x > 0", which is still zero. - var difference = (left - right).Simplify(); + var difference = Catalogue.Simplify.Apply(left - right); var exactlyZero = Numeric.IsZero(difference); bool? numerically = null; @@ -1167,15 +1231,15 @@ private static JsonObject TruthTable(JsonObject args) var names = A(args, "variables"); var vars = names.Count > 0 ? names.Select(Var).ToArray() - : e.Vars.ToArray(); + : Catalogue.Variables.Ask(e).ToArray(); if (vars.Length == 0) return Fail("no variables found in the expression"); if (vars.Length > 12) return Fail($"{vars.Length} variables would need {1L << vars.Length} rows; cap is 12"); return FromOutcome(Guard.Run(() => { - var table = MathS.Boolean.BuildTruthTable(e, vars); - var satisfying = MathS.SolveBooleanTable(e, vars); + var table = Catalogue.TruthTable.Ask(e, vars); + var satisfying = Catalogue.SatisfyingAssignments.Ask(e, vars); return (table, satisfying); }), r => { @@ -1218,7 +1282,7 @@ private static JsonObject SolveSystem(JsonObject args) } return FromOutcome(Guard.Run(() => - MathS.Equations([.. parsed]).Solve([.. variables.Select(Var)])), matrix => + Catalogue.SolveSystem.Ask(parsed, [.. variables.Select(Var)])), matrix => { var order = new JsonArray(); foreach (var v in variables) order.Add(v); @@ -1301,7 +1365,7 @@ private static JsonObject DomainCheck(JsonObject args) return FromOutcome(Guard.Run(() => { Entity simplified; - try { simplified = e.Simplify(); } + try { simplified = Catalogue.Simplify.Apply(e); } catch { simplified = e; } return (simplified, @@ -1385,10 +1449,10 @@ private static JsonObject RepresentBase(JsonObject args) { var asDecimal = fromBase == 10 ? value!.ToEntity() - : MathS.FromBaseN(value!, fromBase); + : Catalogue.FromBase.Ask(value!, fromBase); - var real = (Number.Real)asDecimal.Evaled; - var converted = toBase == 10 ? real.Stringize() : MathS.ToBaseN(real, toBase); + var real = (Number.Real)Catalogue.Evaluate.Ask(asDecimal); + var converted = toBase == 10 ? real.Stringize() : Catalogue.ToBase.Ask(real, toBase); return (real, converted); }), r => new JsonObject { @@ -1496,7 +1560,7 @@ private static JsonObject RepresentFromFixed(JsonObject args) ["fraction_bits"] = fractionBits, ["result"] = v.Stringize(), ["approximate"] = v.EvaluableNumerical - ? v.EvalNumerical().RealPart.EDecimal.ToDouble().ToString("R") + ? Catalogue.EvaluateNumerically.Ask(v).RealPart.EDecimal.ToDouble().ToString("R") : null, }); } @@ -1536,7 +1600,7 @@ private static JsonObject RepresentPolar(JsonObject args) return FromOutcome(Guard.Run(() => { - var evaluated = e.Evaled; + var evaluated = Catalogue.Evaluate.Ask(e); if (evaluated is not Number.Complex complex) throw new ArgumentException($"'{e.Stringize()}' is not a number"); @@ -1577,7 +1641,7 @@ private static JsonObject RepresentRectangular(JsonObject args) ["status"] = "solved", ["operation"] = "rectangular", ["result"] = rectangular.Stringize(), - ["latex"] = rectangular.Latexize(), + ["latex"] = Catalogue.Latex.Ask(rectangular), }; if (warnings.Count > 0) response["warnings"] = Warn(warnings); return response; @@ -1616,16 +1680,16 @@ private static JsonObject MatrixOp(JsonObject args) return FromOutcome(Guard.Run(() => operation switch { - "determinant" => a.Value.Determinant is { } det ? CleanScalar(det, guards) : null, - "inverse" => a.Value.Inverse is { } inv ? CleanGrid(Simplified(inv), guards) : null, - "transpose" => Simplified(a.Value.T), - "rank" => a.Value.Rank, - "rref" => Simplified(a.Value.ReducedRowEchelonForm), - "trace" => a.Value.Trace?.Simplify(), + "determinant" => Catalogue.Determinant.Ask(a.Value) is { } det ? CleanScalar(det, guards) : null, + "inverse" => Catalogue.Inverse.Ask(a.Value) is { } inv ? CleanGrid(Simplified(inv), guards) : null, + "transpose" => Simplified(Catalogue.Transpose.Ask(a.Value)), + "rank" => Catalogue.Rank.Ask(a.Value), + "rref" => Simplified(Catalogue.ReducedRowEchelonForm.Ask(a.Value)), + "trace" => Catalogue.Trace.Ask(a.Value) is { } trace ? Catalogue.Simplify.Apply(trace) : null, "multiply" => Simplified(a.Value * b!), "add" => Simplified(a.Value + b!), "subtract" => Simplified(a.Value - b!), - "tensor_product" => Simplified(Matrix.TensorProduct(a.Value, b!)), + "tensor_product" => Simplified(Catalogue.TensorProduct.Ask(a.Value, b!)), "power" => Simplified(a.Value.Pow(exponent)), _ => throw new ArgumentException($"unknown operation '{operation}'"), }), value => @@ -1643,11 +1707,11 @@ private static JsonObject MatrixOp(JsonObject args) response["result"] = Matrices.Render(m); response["shape_out"] = $"{m.RowCount}x{m.ColumnCount}"; response["pretty"] = m.ToString(multilineFormat: true); - response["latex"] = m.Latexize(); + response["latex"] = Catalogue.Latex.Ask(m); break; case Entity scalar: response["result"] = scalar.Stringize(); - response["latex"] = scalar.Latexize(); + response["latex"] = Catalogue.Latex.Ask(scalar); break; case int rank: response["result"] = rank; @@ -1701,7 +1765,7 @@ static Matrix Simplified(Matrix m) for (var i = 0; i < m.RowCount; i++) for (var j = 0; j < m.ColumnCount; j++) { - try { grid[i, j] = m[i, j].Simplify(); } + try { grid[i, j] = Catalogue.Simplify.Apply(m[i, j]); } catch { grid[i, j] = m[i, j]; } } return MathS.Matrix(grid); @@ -1731,7 +1795,7 @@ private static JsonObject Eigenvalues(JsonObject args) ["status"] = declined ? "declined" : "solved", ["shape"] = $"{a.RowCount}x{a.ColumnCount}", ["characteristic_polynomial"] = eigen.CharacteristicPolynomial.Stringize(), - ["characteristic_polynomial_latex"] = eigen.CharacteristicPolynomial.Latexize(), + ["characteristic_polynomial_latex"] = Catalogue.Latex.Ask(eigen.CharacteristicPolynomial), ["eigenvalues"] = raw, }; @@ -1872,12 +1936,12 @@ private static JsonObject Substitute(JsonObject args) { var result = e; foreach (var (name, value) in replacements) - result = result.Substitute(Var(name), value); + result = Catalogue.Substitute.Apply(result, Var(name), value); // Substitution alone is deliberately structural — no InnerSimplified, no // Evaled. Seeing the unreduced shape is the entire reason to use this rather // than am_evaluate. - return alsoSimplify ? result.Simplify() : result; + return alsoSimplify ? Catalogue.Simplify.Apply(result) : result; }), r => { var response = Respond(e, r, warnings); @@ -1891,7 +1955,7 @@ private static JsonObject Substitute(JsonObject args) "simplify: true, or send the result to am_simplify, if you want it reduced."; var remaining = new JsonArray(); - foreach (var v in r.Vars) remaining.Add(v.Stringize()); + foreach (var v in Catalogue.Variables.Ask(r)) remaining.Add(v.Stringize()); response["free_variables"] = remaining; return response; @@ -1905,7 +1969,7 @@ private static JsonObject Rewrite(JsonObject args, bool expand) return error!; return FromOutcome( - Guard.Run(() => expand ? e.Expand() : e.Factorize()), + Guard.Run(() => expand ? Catalogue.Expand.Apply(e) : Catalogue.Factorize.Apply(e)), r => Respond(e, r, warnings)); } @@ -1935,13 +1999,11 @@ private static JsonObject Series(JsonObject args) return FromOutcome(Guard.Run(() => { var v = Var(variable!); - var raw = around is null - ? MathS.Series.Maclaurin(e, degree, v) - : MathS.Series.Taylor(e, degree, (v, around)); + var raw = Catalogue.Series.Ask(e, v, degree, around); // The raw series keeps factorials unevaluated (`... / 3!`), so it is unreadable // until reduced. - return raw.InnerSimplified.Simplify(); + return Catalogue.Simplify.Apply(Catalogue.Normalize.Apply(raw)); }), r => { var response = Respond(e, r, warnings); @@ -1981,11 +2043,11 @@ private static JsonObject NumberTheory(JsonObject args) return FromOutcome(Guard.Run(() => operation switch { - "factorize" => MathS.NumberTheory.Factorize(a).ToList(), - "totient" => MathS.NumberTheory.Phi(a).Evaled, - "gcd" => MathS.NumberTheory.GreatestCommonDivisor(a, b!), - "count_divisors" => MathS.NumberTheory.CountDivisors(a), - "is_prime" => a.IsPrime, + "factorize" => Catalogue.PrimeFactorization.Ask(a).ToList(), + "totient" => Catalogue.Totient.Ask(a), + "gcd" => Catalogue.GreatestCommonDivisor.Ask(a, b!), + "count_divisors" => Catalogue.DivisorCount.Ask(a), + "is_prime" => Catalogue.IsPrime.Ask(a), _ => throw new ArgumentException($"unknown operation '{operation}'"), }), value => { @@ -2045,7 +2107,7 @@ private static JsonObject ClassifyFormula(JsonObject args) foreach (var f in features) observed.Add(f); var symbols = new JsonArray(); - foreach (var v in e.Vars) symbols.Add(v.Stringize()); + foreach (var v in Catalogue.Variables.Ask(e)) symbols.Add(v.Stringize()); var response = new JsonObject { @@ -2078,7 +2140,7 @@ private static JsonObject ToSympy(JsonObject args) { ["status"] = "solved", ["parsed"] = e.Stringize(), - ["sympy"] = MathS.ToSympyCode(e), + ["sympy"] = Catalogue.Sympy.Ask(e), }; if (warnings.Count > 0) response["warnings"] = Warn(warnings); return response; diff --git a/Sources/MCP/README.md b/Sources/MCP/README.md index 125e10078..a53bd3be7 100644 --- a/Sources/MCP/README.md +++ b/Sources/MCP/README.md @@ -159,6 +159,12 @@ Each of these looks arbitrary and is not: corrupts the stream, and the client reports the server as failed. - **Every library call goes through `Guard.Run`.** It is what keeps a stack overflow or a hang inside one request. +- **A tool's answer comes from the library's catalogue of operations**, `Core/Operations/Catalogue.cs`, + and `Tools.Offers` says which entries each tool offers. An entry added there fails + `CatalogueTest` until a tool offers it or `Tools.NotOffered` says why none does, so the server + keeps up with the library by construction. The server's own checks of an answer, such as + differentiating an antiderivative back, call the library directly. The catalogue is internal, + which is why this assembly is signed with the library's key. - **Decline detection reads the raw result, before any `Simplify`.** An unevaluated `limit(...)` simplifies to `NaN`, which would turn an honest decline into what looks like a wrong answer. - **Simplify before stripping a `provided` guard, never after.** The guard is what licenses the diff --git a/Sources/Terminal/AngouriMath.Terminal.Lib/OneShot.fs b/Sources/Terminal/AngouriMath.Terminal.Lib/OneShot.fs index 2c7b664fc..a985e7c11 100644 --- a/Sources/Terminal/AngouriMath.Terminal.Lib/OneShot.fs +++ b/Sources/Terminal/AngouriMath.Terminal.Lib/OneShot.fs @@ -37,6 +37,41 @@ An argument written as _ is read from stdin, and so is one that is left out: /// The names run answers. Anything else as a first argument is a usage error. let commands = set [ "eval"; "simp"; "fsimp"; "diff"; "solve"; "sub"; "latex"; "info"; "mcp"; "help"; "-h"; "--help" ] +/// +/// The operations of the library's catalogue, Core/Operations/Catalogue.cs, that each command +/// offers, by the entries' names. A test checks this against and the +/// catalogue: every command offers an entry or is in , and every entry is +/// offered or is in . +/// https://github.com/asc-community/AngouriMath/issues/1684 +/// +let offers = + Map [ "eval", [ "evaluate" ] + "simp", [ "simplify" ] + "fsimp", [ "normalize" ] + "diff", [ "differentiate" ] + "solve", [ "solve" ] + "sub", [ "substitute" ] + "latex", [ "latex" ] ] + +/// The commands that are not one of the catalogue's operations, each with what it does. +let ownCommands = + Map [ "info", "prints the variables, derivatives, roots and stationary points, which several operations answer together" + "mcp", "serves the library to an agent" + "help", "prints the usage" + "-h", "prints the usage" + "--help", "prints the usage" ] + +/// +/// The catalogue's entries that no command offers. The one-shot commands are the eight of +/// asc-community/AngouriMathCLI, kept so that its scripts keep working, and which others to add is +/// the question of #1019 item 16; an entry added to the catalogue joins this list or a command. +/// +let notOffered = + set [ "expand"; "factorize"; "integrate"; "limit"; "solve_system"; "evaluate_numerically"; "variables" + "alternatives"; "sympy"; "truth_table"; "satisfying_assignments"; "series"; "to_base"; "from_base" + "determinant"; "inverse"; "transpose"; "rank"; "reduced_row_echelon_form"; "trace"; "tensor_product" + "prime_factorization"; "totient"; "greatest_common_divisor"; "divisor_count"; "is_prime" ] + /// The arguments still to read, falling back to a line of stdin where one is missing or is _. type private Arguments(given: string list, input: TextReader) = let mutable rest = given diff --git a/Sources/Tests/McpUnitTests/CatalogueTest.cs b/Sources/Tests/McpUnitTests/CatalogueTest.cs new file mode 100644 index 000000000..6a9abbb37 --- /dev/null +++ b/Sources/Tests/McpUnitTests/CatalogueTest.cs @@ -0,0 +1,42 @@ +// +// Copyright (c) 2019-2026 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// + +using Xunit; + +namespace AngouriMath.Mcp.Tests; + +/// +/// The tools against the library's catalogue of operations: every tool offers an entry or says +/// what it computes itself, and every entry is offered by a tool or is listed with the reason +/// none offers it. An operation added to the catalogue fails here until the server takes it. +/// https://github.com/asc-community/AngouriMath/issues/1684 +/// +public sealed class CatalogueTest +{ + private static readonly HashSet ToolNames = + Tools.List(preliminary: true).Select(tool => tool!["name"]!.GetValue()).ToHashSet(); + + [Fact] + public void EveryToolOffersAnEntryOrSaysWhatItComputes() + { + var unaccounted = ToolNames.Where(tool => !Tools.OfferedNames.ContainsKey(tool) && !Tools.OwnComputations.ContainsKey(tool)); + Assert.Empty(unaccounted); + Assert.Empty(Tools.OfferedNames.Keys.Intersect(Tools.OwnComputations.Keys)); + Assert.Empty(Tools.OfferedNames.Keys.Concat(Tools.OwnComputations.Keys).Where(tool => !ToolNames.Contains(tool))); + Assert.All(Tools.OfferedNames, tool => Assert.NotEmpty(tool.Value)); + } + + [Fact] + public void EveryEntryIsOfferedOrSaysWhyNot() + { + var offered = Tools.OfferedNames.Values.SelectMany(names => names).ToHashSet(); + var catalogue = Tools.CatalogueNames.ToHashSet(); + Assert.Empty(catalogue.Where(entry => !offered.Contains(entry) && !Tools.NotOffered.ContainsKey(entry))); + Assert.Empty(offered.Intersect(Tools.NotOffered.Keys)); + Assert.Empty(Tools.NotOffered.Keys.Where(entry => !catalogue.Contains(entry))); + } +} diff --git a/Sources/Tests/McpUnitTests/McpUnitTests.csproj b/Sources/Tests/McpUnitTests/McpUnitTests.csproj index 63874ee0d..b2b713cce 100644 --- a/Sources/Tests/McpUnitTests/McpUnitTests.csproj +++ b/Sources/Tests/McpUnitTests/McpUnitTests.csproj @@ -12,6 +12,10 @@ enable false true + + true + ../../AngouriMath/key.snk diff --git a/Sources/Tests/TerminalUnitTests/OneShotTest.fs b/Sources/Tests/TerminalUnitTests/OneShotTest.fs index 746a0e19f..ced7153e4 100644 --- a/Sources/Tests/TerminalUnitTests/OneShotTest.fs +++ b/Sources/Tests/TerminalUnitTests/OneShotTest.fs @@ -127,3 +127,35 @@ let ``info names the variables and gives the derivative and the roots for each`` Assert.Contains("variables: x", lines) Assert.Contains("derivative with respect to x: 2 * x", lines) Assert.Contains(lines, fun line -> line.StartsWith("roots for x: ") && line.Contains("-2") && line.Contains("2")) + +/// The names of the library's catalogue of operations, which is internal, so read here by reflection. +/// https://github.com/asc-community/AngouriMath/issues/1684 +let private catalogue = + let read (name: string) (instance: obj | null) (owner: System.Type | null) = + match owner with + | null -> failwith "the library has no catalogue" + | owner -> + match owner.GetProperty name with + | null -> failwith $"{owner.Name} has no {name}" + | property -> property.GetValue instance + match read "All" null (typeof.Assembly.GetType "AngouriMath.Core.Operations.Catalogue") with + | :? System.Collections.IEnumerable as all -> + set [ for operation in all do + match operation with + | null -> () + | operation -> yield string (read "Name" operation (operation.GetType())) ] + | _ -> failwith "Catalogue.All is not a list" + +[] +let ``every command offers an entry of the catalogue or says what it does`` () = + for command in OneShot.commands do + Assert.True(OneShot.offers.ContainsKey command <> OneShot.ownCommands.ContainsKey command, command) + for command in Seq.append (OneShot.offers |> Map.keys) (OneShot.ownCommands |> Map.keys) do + Assert.Contains(command, OneShot.commands) + +[] +let ``every entry of the catalogue is offered by a command or listed as not offered`` () = + let offered = OneShot.offers |> Map.values |> Seq.concat |> set + Assert.Empty(catalogue - offered - OneShot.notOffered) + Assert.Empty(Set.intersect offered OneShot.notOffered) + Assert.Empty(offered + OneShot.notOffered - catalogue)