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)