From c66e34dd38e5810be75e5f8f283f030511481805 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Fri, 2 Oct 2026 10:06:25 +0000 Subject: [PATCH] The MCP server is amcli mcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server from asc-community/AngouriMathMCP becomes a library in Sources/MCP, and the terminal serves it with `amcli mcp`, so an MCP client's configuration names the `amcli` users already install. It builds against the library here rather than a released package, and McpUnitTests drives it as a client would: the protocol, one call to each tool on the behaviour its design rests on, the use cases its README describes, its selftest, and its knowledge of the grammar against every function token in AngouriMath.g. The selftest fails there on a documented library defect that no longer reproduces as well as on an identity that fails. Whether a name is a function is asked of the parser, by parsing name(x), rather than kept in a list, so the unknown-function warning follows the grammar. The server's documents say what this library does: integrals answered with Ei, erfi, Si and li, the functions the grammar has gained, and union and intersection, which are indexed. A tool's answer is no longer escaped for HTML, so a model reads `+` and `α` as written. Part of #1649. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- .github/workflows/InteractiveTest.yml | 10 + AGENTS.md | 1 + Sources/.editorconfig | 6 + Sources/AngouriMath.sln | 16 + Sources/MCP/AngouriMath.Mcp/Analysis.cs | 197 ++ .../AngouriMath.Mcp/AngouriMath.Mcp.csproj | 31 + Sources/MCP/AngouriMath.Mcp/Classify.cs | 144 ++ Sources/MCP/AngouriMath.Mcp/Guard.cs | 120 + Sources/MCP/AngouriMath.Mcp/Matrices.cs | 177 ++ Sources/MCP/AngouriMath.Mcp/Numeric.cs | 284 +++ Sources/MCP/AngouriMath.Mcp/Parsing.cs | 165 ++ Sources/MCP/AngouriMath.Mcp/Prompts.cs | 262 +++ .../MCP/AngouriMath.Mcp/Representations.cs | 165 ++ Sources/MCP/AngouriMath.Mcp/Resources.cs | 277 +++ Sources/MCP/AngouriMath.Mcp/SelfTest.cs | 178 ++ Sources/MCP/AngouriMath.Mcp/Server.cs | 261 +++ Sources/MCP/AngouriMath.Mcp/Tools.cs | 2043 +++++++++++++++++ Sources/MCP/README.md | 159 ++ .../AngouriMath.Terminal.Lib.fsproj | 1 + .../AngouriMath.Terminal.Lib/OneShot.fs | 5 +- .../AngouriMath.Terminal/PackageReadme.md | 3 +- .../Terminal/AngouriMath.Terminal/Program.fs | 6 + Sources/Terminal/AngouriMath.Terminal/amcli.1 | 9 + .../Terminal/AngouriMath.Terminal/amcli.bash | 4 +- Sources/Terminal/readme.md | 12 + Sources/Tests/McpUnitTests/Mcp.cs | 62 + .../Tests/McpUnitTests/McpUnitTests.csproj | 34 + Sources/Tests/McpUnitTests/ParsingTest.cs | 83 + Sources/Tests/McpUnitTests/ScenarioTest.cs | 132 ++ Sources/Tests/McpUnitTests/SelfTestTest.cs | 26 + Sources/Tests/McpUnitTests/ServerTest.cs | 134 ++ Sources/Tests/McpUnitTests/ToolsTest.cs | 222 ++ .../Tests/TerminalUnitTests/OneShotTest.fs | 12 +- 33 files changed, 5237 insertions(+), 4 deletions(-) create mode 100644 Sources/MCP/AngouriMath.Mcp/Analysis.cs create mode 100644 Sources/MCP/AngouriMath.Mcp/AngouriMath.Mcp.csproj create mode 100644 Sources/MCP/AngouriMath.Mcp/Classify.cs create mode 100644 Sources/MCP/AngouriMath.Mcp/Guard.cs create mode 100644 Sources/MCP/AngouriMath.Mcp/Matrices.cs create mode 100644 Sources/MCP/AngouriMath.Mcp/Numeric.cs create mode 100644 Sources/MCP/AngouriMath.Mcp/Parsing.cs create mode 100644 Sources/MCP/AngouriMath.Mcp/Prompts.cs create mode 100644 Sources/MCP/AngouriMath.Mcp/Representations.cs create mode 100644 Sources/MCP/AngouriMath.Mcp/Resources.cs create mode 100644 Sources/MCP/AngouriMath.Mcp/SelfTest.cs create mode 100644 Sources/MCP/AngouriMath.Mcp/Server.cs create mode 100644 Sources/MCP/AngouriMath.Mcp/Tools.cs create mode 100644 Sources/MCP/README.md create mode 100644 Sources/Tests/McpUnitTests/Mcp.cs create mode 100644 Sources/Tests/McpUnitTests/McpUnitTests.csproj create mode 100644 Sources/Tests/McpUnitTests/ParsingTest.cs create mode 100644 Sources/Tests/McpUnitTests/ScenarioTest.cs create mode 100644 Sources/Tests/McpUnitTests/SelfTestTest.cs create mode 100644 Sources/Tests/McpUnitTests/ServerTest.cs create mode 100644 Sources/Tests/McpUnitTests/ToolsTest.cs diff --git a/.github/workflows/InteractiveTest.yml b/.github/workflows/InteractiveTest.yml index 37b8b4555..23fbd7855 100644 --- a/.github/workflows/InteractiveTest.yml +++ b/.github/workflows/InteractiveTest.yml @@ -31,6 +31,7 @@ jobs: run: | dotnet build Sources/Tests/InteractiveWrapperUnitTests -c Release dotnet build Sources/Tests/TerminalUnitTests -c Release + dotnet build Sources/Tests/McpUnitTests -c Release - name: Test Interactive Wrapper if: ${{ matrix.os != 'ubuntu-latest' }} @@ -42,11 +43,20 @@ jobs: run: | dotnet test "Sources/Tests/TerminalUnitTests" -c Release + # The server amcli mcp runs. It builds against the library in this repository, so a change + # that moves an answer its documents describe, or a function the grammar gains or loses, + # fails here rather than after a release. + - name: Test MCP server + if: ${{ matrix.os != 'ubuntu-latest' }} + run: | + dotnet test "Sources/Tests/McpUnitTests" -c Release + - name: Test with coverage if: ${{ matrix.os == 'ubuntu-latest' }} run: | dotnet test "Sources/Tests/InteractiveWrapperUnitTests" -c Release --collect:"XPlat Code Coverage" --results-directory ./coverage -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover dotnet test "Sources/Tests/TerminalUnitTests" -c Release --collect:"XPlat Code Coverage" --results-directory ./coverage -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover + dotnet test "Sources/Tests/McpUnitTests" -c Release --collect:"XPlat Code Coverage" --results-directory ./coverage -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=opencover - name: 'Send to codecov' if: ${{ matrix.os == 'ubuntu-latest' }} diff --git a/AGENTS.md b/AGENTS.md index 62bd0e982..de48b5819 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -821,6 +821,7 @@ are short, and a stale one is worse than none — if you change what a file desc | [`Contributing/Packaging.md`](Sources/AngouriMath/Docs/Contributing/Packaging.md) | which capabilities ship in the kernel package and which ship separately, and the four checkable clauses that decide. Read it before anything large lands in the kernel | | [`WhatsNew/version_performance_control.md`](Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md) | the inter-version performance table, how to add a column, and when the CI performance baseline may be updated | | `Sources/Analyzers/` | the custom analyzers, including the static-field one behind `[ConstantField]` | +| [`Sources/MCP/README.md`](Sources/MCP/README.md) | the MCP server `amcli mcp` runs: what it offers an agent, and the invariants that look arbitrary in it and are not | Anything added for the library's own purposes is not `public` — see [`Contributing/coding_rules.md`](Sources/AngouriMath/Docs/Contributing/coding_rules.md). Nothing diff --git a/Sources/.editorconfig b/Sources/.editorconfig index d964644f0..74fff6705 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -720,6 +720,12 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [AngouriMath/Numerics/*.cs] file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n +[MCP/**.cs] +file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n + +[Tests/McpUnitTests/**.cs] +file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n + [Tests/UnitTests/Core/IntervalEvaluationTest.cs] file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n diff --git a/Sources/AngouriMath.sln b/Sources/AngouriMath.sln index 8e196265e..007858d5b 100644 --- a/Sources/AngouriMath.sln +++ b/Sources/AngouriMath.sln @@ -53,6 +53,12 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AngouriMath.Terminal", "Ter EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AngouriMath.Terminal.Lib", "Terminal\AngouriMath.Terminal.Lib\AngouriMath.Terminal.Lib.fsproj", "{405E214B-17A6-83E0-90A1-4F8B7213954F}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MCP", "MCP", "{A3D7217B-225C-4020-980F-EBB8EAECADBA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngouriMath.Mcp", "MCP\AngouriMath.Mcp\AngouriMath.Mcp.csproj", "{50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpUnitTests", "Tests\McpUnitTests\McpUnitTests.csproj", "{0C73C9BD-C99C-486D-A058-EE269F02DB77}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Utils", "Utils", "{DC3A3E46-854E-403D-8FA5-561DF66277D4}" ProjectSection(SolutionItems) = preProject Utils\antlr_rerun.bat = Utils\antlr_rerun.bat @@ -153,6 +159,14 @@ Global {405E214B-17A6-83E0-90A1-4F8B7213954F}.Debug|Any CPU.Build.0 = Debug|Any CPU {405E214B-17A6-83E0-90A1-4F8B7213954F}.Release|Any CPU.ActiveCfg = Release|Any CPU {405E214B-17A6-83E0-90A1-4F8B7213954F}.Release|Any CPU.Build.0 = Release|Any CPU + {50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A}.Release|Any CPU.Build.0 = Release|Any CPU + {0C73C9BD-C99C-486D-A058-EE269F02DB77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0C73C9BD-C99C-486D-A058-EE269F02DB77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0C73C9BD-C99C-486D-A058-EE269F02DB77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0C73C9BD-C99C-486D-A058-EE269F02DB77}.Release|Any CPU.Build.0 = Release|Any CPU {9E733914-18F8-CD3A-EDB8-F32FFEF4A3DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9E733914-18F8-CD3A-EDB8-F32FFEF4A3DA}.Debug|Any CPU.Build.0 = Debug|Any CPU {9E733914-18F8-CD3A-EDB8-F32FFEF4A3DA}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -197,6 +211,8 @@ Global {13409571-0ADC-B9C6-2EE7-44DF0B4AD43C} = {822E64E5-9BB9-4702-B704-18129D0BC9C7} {E34FC29D-1046-0B02-E647-858434BFD9E0} = {7FCB69C7-EC31-4F61-BDD9-43B9DEED8750} {405E214B-17A6-83E0-90A1-4F8B7213954F} = {7FCB69C7-EC31-4F61-BDD9-43B9DEED8750} + {50C3DF3D-BB3E-49D8-BC88-4C7BE573B51A} = {A3D7217B-225C-4020-980F-EBB8EAECADBA} + {0C73C9BD-C99C-486D-A058-EE269F02DB77} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53} {9E733914-18F8-CD3A-EDB8-F32FFEF4A3DA} = {DC3A3E46-854E-403D-8FA5-561DF66277D4} {FD30A551-F0D8-071A-9356-0BF0CDFB2FCC} = {D8022D2F-1DCE-4CE1-98DF-70B00D719D59} {ABAD0619-30C7-242D-670F-2ECC908074A2} = {D8022D2F-1DCE-4CE1-98DF-70B00D719D59} diff --git a/Sources/MCP/AngouriMath.Mcp/Analysis.cs b/Sources/MCP/AngouriMath.Mcp/Analysis.cs new file mode 100644 index 000000000..0be5a3044 --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/Analysis.cs @@ -0,0 +1,197 @@ +// +// 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 AngouriMath; +using AngouriMath.Core; +using static AngouriMath.Entity; + +namespace AngouriMath.Mcp; + +/// +/// Answers two questions the raw library does not: "what should I watch out for in this +/// expression?" and "is this answer real, or merely correct?". +/// +/// AngouriMath has no step engine — there is no derivation output anywhere in the library — +/// so the honest substitute is to check a chain of working the caller supplies, and say +/// which step broke. See . +/// +internal static class Analysis +{ + // Spread across the real line, including negatives and a point near zero, because the + // whole point here is to FIND the places an expression stops being real or defined. + private static readonly Entity[] Probes = + ["-2.7", "-1.3", "-0.4", "0.6", "1.9", "3.2"]; + + public sealed record Caution(string Where, string Risk); + + /// + /// Structural hazards, found by walking the tree. These are the classic ways an + /// expression means less than it appears to. + /// + public static List Hazards(Entity e) + { + var found = new List(); + + foreach (var node in e.Nodes) + { + switch (node) + { + case Divf: + var divisor = node.DirectChildren.Count > 1 + ? node.DirectChildren[1].Stringize() + : "?"; + found.Add(new Caution(node.Stringize(), + $"division: undefined where {divisor} = 0. Any cancellation of this " + + "term silently widens the domain unless a `provided` guard survives.")); + break; + + case Logf: + found.Add(new Caution(node.Stringize(), + "logarithm: real only for a positive argument. Combining " + + "ln(a)+ln(b) into ln(a*b) widens the domain and is a documented " + + "source of extraneous roots.")); + break; + + case Powf pow when pow.Exponent is not Number.Integer: + found.Add(new Caution(node.Stringize(), + "fractional or symbolic power: the principal branch is taken, so " + + "(-8)^(1/3) is a complex number, not -2. sqrt(x^2) is abs(x), not x.")); + break; + + case Arcsinf or Arccosf: + found.Add(new Caution(node.Stringize(), + "inverse trig: real only for an argument in [-1, 1], and the " + + "principal branch is returned — the full solution family is periodic.")); + break; + + case Factorialf: + found.Add(new Caution(node.Stringize(), + "factorial: defined via the gamma function, so it returns values for " + + "non-integers and poles at the negative integers.")); + break; + + case Absf: + found.Add(new Caution(node.Stringize(), + "absolute value: not differentiable at 0, and it hides a sign change " + + "that matters when solving.")); + break; + } + } + + return found; + } + + /// Domain guards AngouriMath itself worked out, pulled out of the tree. + public static List Conditions(Entity simplified) => + simplified.Nodes + .OfType() + .Select(p => p.Predicate.Stringize()) + .Distinct() + .ToList(); + + public sealed record RealityReport( + List ComplexAt, List UndefinedAt, int Sampled); + + /// + /// Where does this expression stop being a real number? A result can be perfectly + /// correct and still be useless as a length, a resistance or a time. + /// + public static RealityReport Reality(Entity e) + { + var complexAt = new List(); + var undefinedAt = new List(); + var variables = e.Vars.ToArray(); + var sampled = 0; + + // With no free variables there is a single value to inspect. + if (variables.Length == 0) + { + Inspect(e, "(constant)"); + return new RealityReport(complexAt, undefinedAt, 1); + } + + foreach (var probe in Probes) + { + var substituted = e; + foreach (var variable in variables) + substituted = substituted.Substitute(variable, probe); + + sampled++; + Inspect(substituted, string.Join(" = ", variables.Select(v => v.Stringize())) + + " = " + probe.Stringize()); + } + + return new RealityReport(complexAt, undefinedAt, sampled); + + void Inspect(Entity at, string label) + { + try + { + if (!at.EvaluableNumerical) { undefinedAt.Add(label); return; } + + var value = at.EvalNumerical(); + var real = value.RealPart.EDecimal.ToDouble(); + var imaginary = value.ImaginaryPart.EDecimal.ToDouble(); + + if (double.IsNaN(real) || double.IsNaN(imaginary)) undefinedAt.Add(label); + else if (Math.Abs(imaginary) > 1e-12) complexAt.Add(label); + } + catch + { + undefinedAt.Add(label); + } + } + } + + /// Force an expression to be real-valued: anything complex becomes NaN. + public static Entity AsReal(Entity e) => + e.Replace(node => node.WithCodomain(Domain.Real)); + + public sealed record StepCheck( + int Index, string From, string To, bool? Equal, string Method, string? Difference); + + /// + /// Check a chain of working, one step at a time, and report the first step that changes + /// the value. + /// + /// This is the useful form of "show me the steps". The library cannot produce a + /// derivation — but a model is perfectly good at PROPOSING one and unreliable at + /// EXECUTING it, so having it write the steps and having this check each transition + /// puts each side on the task it is actually good at. A wrong step is located, not just + /// detected. + /// + public static List CheckSteps(List steps) + { + var checks = new List(); + + for (var i = 1; i < steps.Count; i++) + { + var from = steps[i - 1]; + var to = steps[i]; + + Entity difference; + try { difference = (from - to).Simplify(); } + catch { difference = from - to; } + + if (Numeric.IsZero(difference)) + { + checks.Add(new StepCheck(i, from.Stringize(), to.Stringize(), + true, "exact", null)); + continue; + } + + var numerically = Numeric.Equal(difference, 0); + checks.Add(new StepCheck( + i, from.Stringize(), to.Stringize(), + numerically, + numerically is null ? "inconclusive" : "numeric", + numerically == true ? null : difference.Stringize())); + } + + return checks; + } +} diff --git a/Sources/MCP/AngouriMath.Mcp/AngouriMath.Mcp.csproj b/Sources/MCP/AngouriMath.Mcp/AngouriMath.Mcp.csproj new file mode 100644 index 000000000..b1ae0d429 --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/AngouriMath.Mcp.csproj @@ -0,0 +1,31 @@ + + + + + + net10.0 + enable + AngouriMath.Mcp + true + true + true + + false + + + + + + + + + + + diff --git a/Sources/MCP/AngouriMath.Mcp/Classify.cs b/Sources/MCP/AngouriMath.Mcp/Classify.cs new file mode 100644 index 000000000..3c9bc12f4 --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/Classify.cs @@ -0,0 +1,144 @@ +// +// 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 AngouriMath; +using static AngouriMath.Entity; + +namespace AngouriMath.Mcp; + +/// +/// Guesses which field a formula comes from, by reading its symbols. +/// +/// This is inference from naming convention, not mathematics. `c` means the speed of light +/// in relativity and a concentration in chemistry; `T` is temperature or a period. The tool +/// says what it noticed and how strongly, and the caller decides. It is deliberately +/// unwilling to claim certainty — a wrong confident label is worse than no label. +/// +internal static class Classify +{ + private sealed record Field( + string Name, + string[] Strong, // symbols that are near-diagnostic of the field + string[] Weak, // symbols consistent with it but common elsewhere + string[] Structure); // "derivative", "integral", "trig", "complex", ... + + private static readonly Field[] Fields = + [ + new("special relativity", ["c"], ["m", "E", "v", "p", "t"], []), + new("Newtonian gravitation", ["G"], ["M", "m", "r", "F"], []), + new("classical mechanics", ["F", "a"], ["m", "v", "t", "x", "p", "g", "k"], []), + new("quantum mechanics", ["hbar", "h", "psi"], ["E", "nu", "lambda", "p", "m"], + ["complex"]), + new("electromagnetism / circuits", ["V", "I", "Z", "L", "C", "R"], + ["Q", "omega", "f", "t"], ["complex"]), + new("thermodynamics", ["S", "T"], ["P", "V", "n", "R", "k", "Q", "U"], []), + new("fluid dynamics", ["rho", "Re", "mu"], ["v", "p", "d", "A"], []), + new("statistics / probability", ["sigma", "mu"], ["n", "p", "x", "N"], []), + new("chemistry / kinetics", ["K", "Ea"], ["n", "c", "V", "T", "R"], ["log"]), + new("finance", ["PV", "FV", "APR"], ["P", "r", "n", "t", "i"], []), + new("signal processing", ["omega", "j"], ["t", "f", "T", "A"], ["complex", "trig"]), + new("geometry / mensuration", ["pi"], ["r", "h", "a", "b", "A", "V"], ["trig"]), + new("number theory", [], ["n", "k", "p", "m"], ["factorial", "integers-only"]), + new("analysis / calculus", [], ["x", "y", "t"], ["derivative", "integral", "limit"]), + new("optics", ["n1", "n2"], ["theta", "n", "d", "lambda"], ["trig"]), + ]; + + /// + /// Shapes distinctive enough to name outright. Each also names the field it implies: + /// a recognised form is much stronger evidence than loose symbol overlap, and without + /// this `n*R*T/V` ranked as circuits — R and V are strong circuit markers — while the + /// signature was correctly calling it the ideal gas law. + /// + private static readonly (string Fragment, string Says, string Field)[] Signatures = + [ + ("m * c ^ 2", "mass-energy equivalence (E = mc^2)", "special relativity"), + ("c ^ 2 * m", "mass-energy equivalence (E = mc^2)", "special relativity"), + ("v ^ 2 / c ^ 2", "Lorentz factor — special relativity", "special relativity"), + ("G * M", "Newtonian gravitation", "Newtonian gravitation"), + ("h * nu", "photon energy (E = h*nu)", "quantum mechanics"), + ("sqrt(L * C)", "LC resonant frequency", "electromagnetism / circuits"), + ("2 * pi * sqrt", "a resonant period or LC/pendulum frequency", ""), + ("1 / 2 * m * v ^ 2", "kinetic energy", "classical mechanics"), + ("n * R * T", "ideal gas law", "thermodynamics"), + ("R * T * n", "ideal gas law", "thermodynamics"), + ("sin(x) / x", "the sinc function — diffraction and sampling", "signal processing"), + ]; + + public sealed record Guess(string Field, int Score, string Because); + + public static (List Guesses, List Signatures, List Features) + Analyse(Entity e) + { + var symbols = e.Vars.Select(v => v.Stringize()).ToHashSet(StringComparer.Ordinal); + var features = Features(e); + var text = e.Stringize(); + + var matched = Signatures + .Where(s => text.Contains(s.Fragment, StringComparison.Ordinal)) + .ToList(); + + var signatures = matched.Select(s => s.Says).Distinct().ToList(); + var implied = matched + .Where(s => s.Field.Length > 0) + .Select(s => s.Field) + .ToHashSet(StringComparer.Ordinal); + + var guesses = new List(); + foreach (var field in Fields) + { + var strong = field.Strong.Where(symbols.Contains).ToArray(); + var weak = field.Weak.Where(symbols.Contains).ToArray(); + var structural = field.Structure.Where(features.Contains).ToArray(); + + var recognised = implied.Contains(field.Name); + var score = strong.Length * 3 + weak.Length + structural.Length * 2 + + (recognised ? 10 : 0); + if (score == 0) continue; + + // A field claimed on common symbols alone is barely evidence. + if (!recognised && strong.Length == 0 && structural.Length == 0 && weak.Length < 2) + continue; + + var reasons = new List(); + if (recognised) reasons.Add("matches a known formula shape"); + if (strong.Length > 0) reasons.Add($"distinctive symbols {string.Join(", ", strong)}"); + if (weak.Length > 0) reasons.Add($"also uses {string.Join(", ", weak)}"); + if (structural.Length > 0) reasons.Add($"structure: {string.Join(", ", structural)}"); + + guesses.Add(new Guess(field.Name, score, string.Join("; ", reasons))); + } + + return (guesses.OrderByDescending(g => g.Score).Take(4).ToList(), signatures, features); + } + + private static List Features(Entity e) + { + var features = new List(); + var numeric = true; + + foreach (var node in e.Nodes) + { + switch (node) + { + case Derivativef: features.Add("derivative"); break; + case Integralf: features.Add("integral"); break; + case Limitf: features.Add("limit"); break; + case Sinf or Cosf or Tanf or Cotanf or Secantf or Cosecantf: + features.Add("trig"); break; + case Logf: features.Add("log"); break; + case Factorialf: features.Add("factorial"); break; + case Number.Complex complex when !complex.ImaginaryPart.EDecimal.IsZero: + features.Add("complex"); break; + } + + if (node is Variable) numeric = false; + } + + if (numeric && e.Vars.Count == 0) features.Add("integers-only"); + return features.Distinct().ToList(); + } +} diff --git a/Sources/MCP/AngouriMath.Mcp/Guard.cs b/Sources/MCP/AngouriMath.Mcp/Guard.cs new file mode 100644 index 000000000..5bbdb5b7e --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/Guard.cs @@ -0,0 +1,120 @@ +// +// 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 AngouriMath; +using AngouriMath.Core.Exceptions; + +namespace AngouriMath.Mcp; + +/// +/// Every call into the library goes through here, for three protections: +/// +/// 1. Cancellation through MathS.Multithreading.SetLocalCancellationToken, which stops a +/// runaway Simplify or Solve. +/// 2. A dedicated thread with a 64 MB stack, abandoned rather than killed on timeout. +/// Cancellation cannot rescue a stack overflow, which would take the server down with every +/// request after it; the deep stack is what keeps a deep recursion from becoming one. .NET +/// cannot abort a thread, so a case that hangs keeps its thread for the life of the process. +/// 3. Screening the result for unevaluated nodes and for NaN, below. +/// +internal static class Guard +{ + public const int DefaultTimeoutMs = 20_000; + private const int StackBytes = 64 * 1024 * 1024; + + public readonly record struct Outcome(T? Value, string Status, string? Error) + { + public bool Ok => Status == "ok"; + } + + public static Outcome Run(Func work, int timeoutMs = DefaultTimeoutMs) + { + T? result = default; + Exception? failure = null; + using var cts = new CancellationTokenSource(); + + var thread = new Thread(() => + { + try + { + MathS.Multithreading.SetLocalCancellationToken(cts.Token); + result = work(); + } + catch (Exception e) + { + failure = e; + } + }, StackBytes) { IsBackground = true }; + + thread.Start(); + + if (!thread.Join(timeoutMs)) + { + cts.Cancel(); + // Deliberately not joined again: the thread may be stuck in native/deep + // recursion. Abandon it rather than block the server. + return new Outcome(default, "timeout", + $"no answer within {timeoutMs} ms (the call was abandoned)"); + } + + if (failure is not null) + return new Outcome(default, StatusFor(failure), Describe(failure)); + + return new Outcome(result, "ok", null); + } + + private static Exception Unwrap(Exception e) => + e is AggregateException agg && agg.InnerException is not null ? agg.InnerException : e; + + /// + /// A refusal is not a failure. Where the library knows it has no rule, it raises + /// NotSufficientlySupportedException, and that is reported as `declined`, the word this + /// server uses for an unevaluated integral too. `failed` keeps its meaning: bad input, or + /// something that genuinely went wrong. + /// + private static string StatusFor(Exception e) => + Unwrap(e) is NotSufficientlySupportedException ? "declined" : "failed"; + + private static string Describe(Exception e) + { + var inner = Unwrap(e); + // An AngouriBugException is the library's internal assertion. Surfacing the type name + // tells the caller "library defect", not "bad input". + return $"{inner.GetType().Name}: {inner.Message}"; + } + + /// + /// Operators AngouriMath leaves in the tree when it cannot do the job. Finding one of + /// these in the result means "declined", not "solved". + /// + /// This MUST be tested against the RAW result, before any Simplify: an unevaluated + /// limit(...) simplifies to NaN, so simplifying first converts an honest decline into + /// what looks like a wrong answer. + /// + private static readonly string[] UnevaluatedMarkers = + ["integral(", "derivative(", "limit(", "limitleft(", "limitright("]; + + public static bool IsDeclined(string rawStringized) + { + foreach (var marker in UnevaluatedMarkers) + if (rawStringized.Contains(marker, StringComparison.Ordinal)) + return true; + return false; + } + + /// + /// A printed NaN in an answer is never legitimate output here: an integral or a + /// simplification that exists has none in it, so a NaN inside an otherwise plausible + /// answer, such as `NaN * x`, marks it wrong. + /// + /// Caveat worth knowing: `limit x->0 (1/x)` legitimately evaluates to NaN meaning "the + /// limit does not exist". The limit tool therefore reports that case distinctly rather + /// than calling it suspect. + /// + public static bool LooksLikeNaN(string rendered) => + rendered.Contains("NaN", StringComparison.Ordinal); +} diff --git a/Sources/MCP/AngouriMath.Mcp/Matrices.cs b/Sources/MCP/AngouriMath.Mcp/Matrices.cs new file mode 100644 index 000000000..5b99911bd --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/Matrices.cs @@ -0,0 +1,177 @@ +// +// 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 System.Text.Json.Nodes; +using AngouriMath; +using static AngouriMath.Entity; + +namespace AngouriMath.Mcp; + +/// +/// Matrix work, and symbolic eigenvalues built on top of it. +/// +/// AngouriMath's matrices run on GenericTensor (as GenTensor<Entity, ...>), which has no +/// eigen, SVD or decomposition of any kind. That does not matter here: for a symbolic system +/// the right route to eigenvalues is the characteristic polynomial — det(A - lambda*I) — fed +/// to the existing equation solver. Beyond 4x4 there is no closed form anyway (Abel-Ruffini), +/// so a numerical eigensolver would not have been the answer. +/// +internal static class Matrices +{ + public sealed record Parsed(Matrix? Value, List Warnings, string? Error); + + /// Read a JSON array of arrays of expression strings into a matrix. + public static Parsed Parse(JsonNode? node, string label) + { + if (node is not JsonArray rows || rows.Count == 0) + return new Parsed(null, [], $"'{label}' must be a non-empty array of rows"); + + var warnings = new List(); + var parsed = new List>(); + var width = -1; + + foreach (var row in rows) + { + if (row is not JsonArray cells) + return new Parsed(null, [], $"'{label}' must be an array of ARRAYS (rows)"); + + if (width < 0) width = cells.Count; + else if (cells.Count != width) + return new Parsed(null, [], + $"'{label}' is ragged: a row has {cells.Count} entries, expected {width}"); + + var built = new List(); + foreach (var cell in cells) + { + var text = cell?.GetValue(); + if (string.IsNullOrWhiteSpace(text)) + return new Parsed(null, [], $"'{label}' contains an empty entry"); + + var outcome = Parsing.Parse(text, strict: false); + if (outcome.Entity is null) + return new Parsed(null, [], $"'{label}': could not parse '{text}' — {outcome.Error}"); + + built.Add(outcome.Entity); + warnings.AddRange(outcome.Warnings); + } + parsed.Add(built); + } + + var grid = new Entity[parsed.Count, width]; + for (var i = 0; i < parsed.Count; i++) + for (var j = 0; j < width; j++) + grid[i, j] = parsed[i][j]; + + return new Parsed(MathS.Matrix(grid), warnings.Distinct().ToList(), null); + } + + /// Render a matrix as rows of strings, so the caller can consume it directly. + public static JsonArray Render(Matrix m) + { + var rows = new JsonArray(); + for (var i = 0; i < m.RowCount; i++) + { + var row = new JsonArray(); + for (var j = 0; j < m.ColumnCount; j++) row.Add(m[i, j].Stringize()); + rows.Add(row); + } + return rows; + } + + public sealed record Eigen( + Entity CharacteristicPolynomial, + Entity? Roots, + List DroppedGuards); + + /// + /// Standard wording for what stripping a pivot guard means, so every tool that does it + /// says the same thing. + /// + public const string GuardNote = + "A routine that divides by pivots leaves a `provided` guard behind for each one. On " + + "a symbolic matrix these are artefacts of the algorithm rather than mathematics: " + + "det([[a,b],[c,d]]) is a*d - b*c for ALL a, and [[0,J],[J,0]] has eigenvalues +/-J " + + "including at J = 0. So they are removed here, and listed rather than silently dropped."; + + /// + /// Remove pivot guards from a value, reporting what was removed. + /// + /// Order is load-bearing: simplify FIRST, strip second. The guard is what licenses the + /// cancellation — strip `provided not a = 0` from the raw Gaussian determinant and + /// Simplify can no longer reduce `a*(d*a - c*b)/a`, because a might be zero. Simplifying + /// while the guard is still attached yields `a*d - b*c`, and only then is the guard + /// itself redundant. + /// + public static (Entity Value, List Guards) Clean(Entity e) + { + var simplified = e; + try { simplified = e.Simplify(); } + catch { /* fall back to the original form */ } + + var guards = Analysis.Conditions(simplified); + if (guards.Count == 0) return (simplified, guards); + + return (Numeric.Strip(simplified), guards); + } + + /// The same, entry by entry, for a matrix. + public static (Matrix Value, List Guards) Clean(Matrix m) + { + var guards = new List(); + var grid = new Entity[m.RowCount, m.ColumnCount]; + + for (var i = 0; i < m.RowCount; i++) + for (var j = 0; j < m.ColumnCount; j++) + { + var (value, found) = Clean(m[i, j]); + grid[i, j] = value; + guards.AddRange(found); + } + + return (MathS.Matrix(grid), guards.Distinct().ToList()); + } + + /// + /// Eigenvalues via the characteristic polynomial, which belongs in the library: + /// https://github.com/asc-community/AngouriMath/issues/1676 + /// + /// The guard-dropping is not cosmetic. A determinant taken by dividing by pivots leaves a + /// `provided` guard for each one, and on a symbolic matrix those are artefacts of the + /// algorithm: they would make the characteristic polynomial of the Pauli X matrix + /// `lambda^2 - 1 provided not lambda = 0`, and the eigenvalues of [[0,J],[J,0]] + /// `{J provided not J = 0, -J provided not J = 0}`, where both values are valid at 0 too. + /// The library's determinant leaves none (`lambda^2 - 1`, `lambda^2 - J^2`); the stripping + /// stays, and reports what it dropped, for any routine that pivots. + /// + public static Eigen Compute(Matrix a, Variable lambda) + { + var n = a.RowCount; + var shifted = new Entity[n, n]; + for (var i = 0; i < n; i++) + for (var j = 0; j < n; j++) + shifted[i, j] = i == j ? a[i, j] - lambda : a[i, j]; + + var raw = MathS.Matrix(shifted).Determinant + ?? throw new InvalidOperationException("determinant unavailable"); + + var (poly, guards) = Clean(raw); + + Entity? roots; + try + { + // The solver reintroduces pivot guards of its own, so the result is cleaned + // again — stripping only the characteristic polynomial leaves eigenvalues + // reading 'J provided not J = 0'. + var (cleaned, solverGuards) = Clean(poly.SolveEquation(lambda).Simplify()); + roots = cleaned; + guards.AddRange(solverGuards); + } + catch { roots = null; } + + return new Eigen(poly, roots, guards.Distinct().ToList()); + } +} diff --git a/Sources/MCP/AngouriMath.Mcp/Numeric.cs b/Sources/MCP/AngouriMath.Mcp/Numeric.cs new file mode 100644 index 000000000..7964bf784 --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/Numeric.cs @@ -0,0 +1,284 @@ +// +// 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 AngouriMath; +using static AngouriMath.Entity; + +namespace AngouriMath.Mcp; + +/// +/// Tolerance-based numeric equality. +/// +/// Why not just use MathS.UnsafeAndInternal.AreEqualNumerically? Because despite the name it +/// compares with EXACT equality — the implementation is `if (evaled1 != evaled2 ...) return +/// false` over arbitrary-precision decimals, with no tolerance at all. Any expression +/// involving a transcendental therefore fails: ln(x) computed via two different but +/// mathematically identical routes disagrees in the last digit and the check reports the +/// expressions as different. +/// +/// That is not a hypothetical. `∫ x*ln(x)` returns the correct antiderivative, which +/// differentiates back to the correct integrand, and AreEqualNumerically still says false. +/// So this compares with a relative tolerance instead. +/// https://github.com/asc-community/AngouriMath/issues/1674 +/// +internal static class Numeric +{ + /// Deliberately positive reals: correct antiderivatives contain ln(x) and + /// abs(x), which are undefined or non-holomorphic on the negatives. + public static readonly Entity[] PositivePoints = + ["0.37", "1.31", "2.17", "0.59", "3.43"]; + + private const double RelativeTolerance = 1e-6; + + /// + /// True / false / null, where null means "could not be evaluated at any sample point" + /// — which is not evidence either way and must not be reported as inequality. + /// + public static bool? Equal(Entity left, Entity right, Entity[]? points = null) + { + points ??= PositivePoints; + + // `provided` guards are correct mathematics but do not evaluate to a number, so + // they are dropped for the comparison only. + var a = Strip(left); + var b = Strip(right); + + var variables = a.Vars.Concat(b.Vars) + .Select(v => v.Name) + .Distinct() + .ToArray(); + + var usable = 0; + + // Rotating the offset gives each variable a different value on each pass, so + // f(x, y) = x*y is not mistaken for f(x, y) = x^2. + for (var offset = 0; offset < points.Length; offset++) + { + var subA = a; + var subB = b; + for (var i = 0; i < variables.Length; i++) + { + var point = points[(i + offset) % points.Length]; + var variable = MathS.Var(variables[i]); + subA = subA.Substitute(variable, point); + subB = subB.Substitute(variable, point); + } + + var valueA = AsDouble(subA); + var valueB = AsDouble(subB); + + // A point where either side is undefined tells us nothing; skip it rather than + // counting it as disagreement. + if (valueA is null || valueB is null) continue; + + var (realA, imagA) = valueA.Value; + var (realB, imagB) = valueB.Value; + + var separation = Math.Sqrt( + (realA - realB) * (realA - realB) + (imagA - imagB) * (imagA - imagB)); + var scale = Math.Max( + 1.0, + Math.Max(Math.Sqrt(realA * realA + imagA * imagA), + Math.Sqrt(realB * realB + imagB * imagB))); + + if (separation / scale > RelativeTolerance) return false; + usable++; + } + + return usable == 0 ? null : true; + } + + public sealed record Divergence( + double MaxAbsolute, string MaxAbsoluteAt, + double MaxRelative, string MaxRelativeAt, + double RootMeanSquare, int Sampled, int Skipped, int Complex); + + /// + /// How far apart are two expressions across an interval? + /// + /// A different question from , and the one engineering actually asks: + /// not "is this approximation exact" — it is not — but "does it hold to 1e-6 across the + /// operating range, and where is it worst". + /// + public static Divergence? Diverge( + Entity left, Entity right, Variable variable, double from, double to, int samples) + { + var a = Strip(left); + var b = Strip(right); + + var maxAbsolute = -1.0; var maxAbsoluteAt = ""; + var maxRelative = -1.0; var maxRelativeAt = ""; + var sumOfSquares = 0.0; + int used = 0, skipped = 0, complex = 0; + + var step = samples > 1 ? (to - from) / (samples - 1) : 0.0; + + for (var i = 0; i < samples; i++) + { + var at = samples > 1 ? from + step * i : from; + Entity point = at.ToString("R"); + + var valueA = AsDouble(a.Substitute(variable, point)); + var valueB = AsDouble(b.Substitute(variable, point)); + + // A point where either side is undefined says nothing about agreement, so it is + // counted and skipped rather than scored as a mismatch. + if (valueA is null || valueB is null) { skipped++; continue; } + + // ln and even roots return principal-branch complex values on the negatives + // rather than failing. Comparing their magnitudes is defensible, but a caller + // asking about a physical quantity needs to know the reference left the reals. + if (Math.Abs(valueA.Value.Imaginary) > 1e-12 || + Math.Abs(valueB.Value.Imaginary) > 1e-12) complex++; + + var difference = Math.Sqrt( + Math.Pow(valueA.Value.Real - valueB.Value.Real, 2) + + Math.Pow(valueA.Value.Imaginary - valueB.Value.Imaginary, 2)); + + var magnitude = Math.Sqrt( + valueA.Value.Real * valueA.Value.Real + + valueA.Value.Imaginary * valueA.Value.Imaginary); + + if (difference > maxAbsolute) + { + maxAbsolute = difference; + maxAbsoluteAt = at.ToString("R"); + } + + // Relative error is meaningless where the reference is ~0, so it is scored only + // where there is something to be relative to. + if (magnitude > 1e-12) + { + var relative = difference / magnitude; + if (relative > maxRelative) + { + maxRelative = relative; + maxRelativeAt = at.ToString("R"); + } + } + + sumOfSquares += difference * difference; + used++; + } + + if (used == 0) return null; + + return new Divergence( + maxAbsolute, maxAbsoluteAt, + maxRelative < 0 ? 0 : maxRelative, maxRelativeAt, + Math.Sqrt(sumOfSquares / used), used, skipped, complex); + } + + public static Entity Strip(Entity e) => + e.Replace(node => node is Providedf provided ? provided.Expression : node); + + /// A quadrature result: the value, and how far it can be trusted. + public readonly record struct Quadrature(double Value, double ErrorEstimate, int Digits); + + // Step counts for the two runs. AngouriMath's rule is first order — measured, by + // doubling: the error of `∫[0,1] e^(x^2)` halves at every doubling of the step count + // rather than falling by four — so the gap between a run and its double IS the error of + // the coarser one, to a constant. Two runs at these counts cost about a third of a + // second together, which is inside the request budget; going further buys one digit per + // quadrupling and is not worth the wall clock. + private const int CoarseSteps = 2_000; + private const int FineSteps = 4_000; + + /// + /// Numeric definite integration via `Entity.DefiniteIntegral`, run TWICE so that the + /// answer arrives with an error estimate rather than as a hundred digits of false + /// precision. https://github.com/asc-community/AngouriMath/issues/1675 + /// + /// That precision is the whole reason this is not a one-line call. The library returns + /// an arbitrary-precision decimal whatever the step count, so `∫[0,1] e^(x^2)` prints as + /// 1.4666222366253148581140206684127892598 when the true value is 1.46265174590718 — a + /// figure that is wrong in its third decimal and reads as converged to thirty. Reporting + /// only the digits two runs agree on is exactly the job this server exists to do. + /// + /// Returns null when the integrand cannot be sampled: the rule evaluates at the + /// endpoints, so a singularity at either — `∫[0,1] sin(x)/x`, `∫[0,1] ln(x)`, both of + /// which converge — comes back NaN and must be declined rather than reported. + /// + public static Quadrature? TryDefiniteIntegral(Entity integrand, Variable variable, + double from, double to) + { + var coarse = Sample(integrand, variable, from, to, CoarseSteps); + var fine = Sample(integrand, variable, from, to, FineSteps); + if (coarse is not { } c || fine is not { } f) return null; + + var error = Math.Abs(f - c); + + // Digits justified by the error, relative to the magnitude of the answer: an error + // of 2e-4 on a value near 1.46 pins three decimals, and the fourth is noise. One + // digit is dropped on purpose — the estimate is itself an estimate. + var scale = Math.Max(Math.Abs(f), double.Epsilon); + var digits = error <= 0 + ? 15 + : (int)Math.Floor(-Math.Log10(error / scale)) - 1; + digits = Math.Clamp(digits, 0, 15); + + return new Quadrature(f, error, digits); + } + + private static double? Sample(Entity integrand, Variable variable, + double from, double to, int steps) + { + try + { + var value = integrand.DefiniteIntegral(variable, from, to, steps); + var real = value.RealPart.EDecimal.ToDouble(); + var imaginary = value.ImaginaryPart.EDecimal.ToDouble(); + if (double.IsNaN(real) || double.IsInfinity(real)) return null; + // A real integrand integrated over a real interval that comes back complex means + // the rule wandered off the domain; that is not an answer to report. + if (double.IsNaN(imaginary) || Math.Abs(imaginary) > 1e-12) return null; + return real; + } + catch + { + return null; + } + } + + /// Real part as a double, or null when it is not numerically evaluable. + public static double? AsNumber(Entity e) => AsDouble(e)?.Real; + + /// + /// As , but null rather than the real part when the value is + /// complex. An integration bound of `i` must not quietly become `0`. + /// + public static double? AsRealNumber(Entity e) => + AsDouble(e) is { } value && Math.Abs(value.Imaginary) < 1e-12 ? value.Real : null; + + /// Is this expression structurally zero once domain guards are removed? + public static bool IsZero(Entity e) + { + var bare = Strip(e).InnerSimplified; + return bare.Stringize() is "0" or "0.0" or "-0"; + } + + private static (double Real, double Imaginary)? AsDouble(Entity e) + { + if (!e.EvaluableNumerical) return null; + + try + { + var value = e.EvalNumerical(); + var real = value.RealPart.EDecimal.ToDouble(); + var imaginary = value.ImaginaryPart.EDecimal.ToDouble(); + + if (double.IsNaN(real) || double.IsNaN(imaginary)) return null; + if (double.IsInfinity(real) || double.IsInfinity(imaginary)) return null; + + return (real, imaginary); + } + catch + { + return null; + } + } +} diff --git a/Sources/MCP/AngouriMath.Mcp/Parsing.cs b/Sources/MCP/AngouriMath.Mcp/Parsing.cs new file mode 100644 index 000000000..d63090115 --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/Parsing.cs @@ -0,0 +1,165 @@ +// +// 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 System.Collections.Concurrent; +using System.Text.RegularExpressions; +using AngouriMath; +using HonkSharp.Functional; + +namespace AngouriMath.Mcp; + +/// +/// Parsing plus the two warnings that matter. +/// +/// AngouriMath's parser is permissive in two ways that are silent and therefore dangerous +/// when the caller is a language model rather than a human reading its own formula: +/// +/// * a trailing number is an EXPONENT, not a factor: `x2` is x², `2(g+e)3` is 2(g+e)³. +/// A model that names a variable `x2`, `v1` or `a0` — completely ordinary naming — +/// gets it silently squared. (MathS.cs documents this on ExplicitParsingOnly.) +/// * an unknown identifier becomes implicit multiplication: `im(z)` is the product +/// `im * z`, not the imaginary part. The library refuses some such names by name — +/// `trunc`, `lcm`, `erf`, `conjugate` and the arc- spellings of the inverse hyperbolics — +/// so those fail loudly; the rest degrade with nothing said, because refusing every +/// unknown name is refusing `a(b + c)`. +/// +/// Both produce a valid parse of a DIFFERENT expression, which is the worst failure class +/// available: no exception, plausible answer, wrong. Rather than force strict mode on +/// everyone (which rejects the very common `2x`), the server parses permissively and always +/// reports what it understood, with a warning when either pattern is present. +/// +internal static class Parsing +{ + /// Either a parsed entity with its warnings, or an error string — never both. + public sealed record Outcome(Entity? Entity, List Warnings, string? Error); + + /// + /// Whether the grammar reads name(...) as a call, rather than as the variable + /// name times a bracketed group. Asked of the parser itself, by parsing + /// name(x), so a name that becomes a function or stops being one is answered + /// correctly without anything here changing: a hand-kept list of names warns on correct + /// input when it lags behind the grammar, and that teaches a caller to ignore the channel. + /// + /// + /// A name the grammar refuses outright, or calls with the wrong number of arguments, also + /// counts as known: either way the input fails to parse, and the warnings below only look + /// at an input that parsed. Parsed permissively whatever the caller asked for, since the + /// answer is cached for every later request. + /// + internal static bool IsFunction(string name) => Calls.GetOrAdd(name, static candidate => + { + using var _ = MathS.Settings.ExplicitParsingOnly.Set(false); + try + { + var x = MathS.Var("x"); + return MathS.Parse(candidate + "(x)").Switch( + call => MathS.Parse(candidate).Switch(alone => call != alone * x, _ => true), + _ => true); + } + catch (Exception) + { + return true; + } + }); + + private static readonly ConcurrentDictionary Calls = new(StringComparer.Ordinal); + + /// Names people reach for that this grammar spells differently, and that parse + /// as something else rather than failing. A name the library refuses outright is not + /// here: its own exception says more than this table could. + internal static readonly Dictionary Misspellings = new(StringComparer.OrdinalIgnoreCase) + { + ["setsubtraction"] = @"the infix '\' — write 'A \ B'", + ["elementin"] = "the infix 'in' — write 'x in A'", + ["impl"] = "'->' or 'implies' — write 'a -> b'", + ["re"] = "not in this grammar; there is no real-part function", + ["im"] = "not in this grammar; there is no imaginary-part function", + }; + + // The alphabet the grammar's VARIABLE rule accepts (Core/Antlr/AngouriMath.g): ASCII + // letters, Greek and Coptic, Greek Extended, Cyrillic. Both warnings below are lexical + // guesses at what the grammar will do, so they have to guess in the grammar's alphabet: + // `[A-Za-z]` would exempt every Greek and Cyrillic name from both checks, and `α2` would + // be squared with nothing said. + // Kept as escapes rather than literal Greek so the ranges can be read straight off the + // grammar. The string is verbatim, so C# passes the escapes through untouched and the + // regex engine is what interprets them. + private const string VariableChars = @"a-zA-Z\u0370-\u03FF\u1F00-\u1FFF\u0400-\u04FF"; + + // A digit directly after a LETTER is the trap: `x2` parses as x^2. Two things are NOT + // the trap. A digit after an underscore — `t_0` is a single variable named t_0, and is + // the conventional safe way to write a subscript. And the digits of an exponent: `1.5e3` + // is ONE number token, because EXPONENT is a fragment of NUMBER, so its `e` belongs to + // the literal rather than to a variable. Hence the second branch: an `e` IS a warnable + // identifier when no numeric literal precedes it, which is why bare `e3` (that is, e^3) + // still warns. False positives are how you teach a caller to ignore warnings. + private static readonly Regex TrailingDigit = + new($@"[{VariableChars}-[eE]]\d|(? new Outcome(entity, Warnings(source), null), + failure => new Outcome(null, [], failure.Reason.Switch( + unknown => $"could not parse: {unknown.Reason}", + missingOperator => $"missing operator: {missingOperator.Details}", + internalError => $"internal parser error: {internalError.Details}"))); + } + + private static List Warnings(string source) + { + var warnings = new List(); + var calls = CallLike.Matches(source); + + // A known function's own name is not an implicit power, even when it ends in a + // digit. `log2(8)` is the base-2 logarithm, and the `g2` inside it is + // not the `x2` trap — warning there fires on correct input, which is how a caller + // learns to ignore the channel. Spans rather than a special case for `log2` and + // `log10`, so the next function name carrying a digit needs no second fix. + var functionNameSpans = calls + .Where(m => IsFunction(m.Groups[1].Value)) + .Select(m => (Start: m.Groups[1].Index, End: m.Groups[1].Index + m.Groups[1].Length)) + .ToList(); + + var implicitPower = TrailingDigit.Matches(source).Any(m => + !functionNameSpans.Any(s => m.Index >= s.Start && m.Index + m.Length <= s.End)); + + if (implicitPower) + warnings.Add( + "implicit-power: a number directly after an identifier is an EXPONENT, " + + "not a factor — 'x2' parses as x^2. Check the 'parsed' field; write 'x*2' " + + "if you meant multiplication, and avoid variable names ending in a digit."); + + foreach (Match m in calls) + { + var name = m.Groups[1].Value; + if (IsFunction(name)) continue; + + var hint = Misspellings.TryGetValue(name, out var spelling) + ? $" Use {spelling}." + : string.Empty; + + warnings.Add( + $"unknown-function: '{name}' is not a function AngouriMath knows, so it was " + + $"read as a VARIABLE multiplied by the bracketed group, not as a call." + + hint + " Check the 'parsed' field."); + } + + return warnings; + } +} diff --git a/Sources/MCP/AngouriMath.Mcp/Prompts.cs b/Sources/MCP/AngouriMath.Mcp/Prompts.cs new file mode 100644 index 000000000..99aa3baeb --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/Prompts.cs @@ -0,0 +1,262 @@ +// +// 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 System.Text.Json.Nodes; + +namespace AngouriMath.Mcp; + +/// +/// User-invocable workflows, surfaced by MCP hosts as slash commands. +/// +/// These exist because of the routing problem this whole server is shaped around: a model +/// will not reach for a maths tool, since it is confident it can do algebra unaided. A +/// prompt is the *user* reaching for it instead, which sidesteps the question entirely. They +/// also cost nothing in tool-list context, which matters once a server has twenty-one tools. +/// +/// Each one encodes a workflow already proven in test/scenarios.sh rather than inventing a +/// new one. +/// +internal static class Prompts +{ + private sealed record Argument(string Name, string Description, bool Required); + + private sealed record Definition( + string Name, + string Description, + Argument[] Arguments, + Func, string> Build); + + /// Advice repeated into every prompt, because it is what goes wrong. + private const string Discipline = """ + + While doing this: + - Read the `parsed` field of every response before trusting the answer. A trailing + digit is an exponent here, so a variable named `t0` silently becomes t^0, and an + unknown function name silently becomes a multiplication. + - Treat a `declined` or `unchanged` status as "no answer", never as the answer. + - If a call reports `verified: false` or `status: conflict`, stop and say so rather + than presenting the result. + - Say which tool produced each result. Do not present tool output as your own working. + """; + + private static readonly Definition[] All = + [ + new("verify-derivation", + "Check a chain of algebra step by step and identify exactly which step broke.", + [ + new("working", "Your steps, one per line, each a complete expression.", true), + ], + get => $""" + Here is a derivation. Each line should be mathematically equal to the one + before it: + + {get("working")} + + Split it into successive expressions and pass them to `am_check_steps` in + order. Then tell me plainly whether the working is sound. + + If a step is invalid, report which one, quote the `difference` the tool + returns, and explain in one sentence what went wrong there — a dropped term, + a sign, a bad cancellation. Do not re-derive the whole thing; the point is to + locate the error, not replace my working. + {Discipline} + """), + + new("check-formula", + "Compare a formula as documented against a formula as implemented in code.", + [ + new("documented", "The formula as written in the comment, spec or datasheet.", true), + new("implemented", "The expression as it actually appears in the code.", true), + ], + get => $""" + A formula appears two ways. As documented: + + {get("documented")} + + As implemented in the code: + + {get("implemented")} + + Use `am_verify_equal` to decide whether they are the same function. + + If they differ, quote the `difference` — that names the discrepancy far more + precisely than a description does — and say which of the two is likely wrong. + If they agree, say so without hedging. + + Then call `am_domain_check` on the implemented form and mention anything that + would bite in practice: a division that can vanish, a logarithm or square root + that leaves the reals, a value the hardware could plausibly produce that the + expression cannot handle. + {Discipline} + """), + + new("derive-jacobian", + "Differentiate a model with respect to each state variable, with every partial verified.", + [ + new("model", "The measurement or state function, e.g. 'sqrt(x^2 + y^2)'.", true), + new("variables", "Comma-separated variables to differentiate by, e.g. 'x,y'.", true), + ], + get => $""" + Build the Jacobian row for this model: + + {get("model")} + + with respect to: {get("variables")} + + For each variable, call `am_differentiate`. Then verify each partial rather + than trusting it: integrate it back with `am_integrate` and check it returns + the original, or use `am_verify_equal` against a form you derive + independently. Say which check you used. + + Present the result as a list of partials, then as a single row vector. Keep + the exact symbolic forms — do not convert to decimals. Hand-derived Jacobians + are where silent errors live for months, so flag anything you could not + verify rather than quietly including it. + {Discipline} + """), + + new("analyse-approximation", + "Work out whether a fast approximation is sound and where it stops being good enough.", + [ + new("exact", "The exact expression, e.g. '1/sqrt(x)'.", true), + new("approximation", + "The fast form, or one iteration of it, e.g. 'y*(3 - x*y^2)/2'.", true), + new("variable", "The variable, e.g. 'x'.", true), + new("range", "Operating range as 'from,to', e.g. '1,4'. Optional.", false), + ], + get => $""" + A fast approximation is being used in place of an exact expression. + + exact: {get("exact")} + approximation: {get("approximation")} + variable: {get("variable")} + range: {Or(get("range"), "not stated — ask, or say what you assumed")} + + Work out whether it is sound, in this order: + + 1. **Is the iteration what it claims to be?** If the approximation is an + iterative refinement, it is probably Newton's method for some f. Work out + which f, then use `am_verify_equal` to confirm the update rule really is + `y - f(y)/f'(y)` for it. If it is not Newton, say what it is instead. + + 2. **Derive the error term, do not guess it.** Substitute a perturbed value — + for a reciprocal-root style iteration that means `y = (1+e)/sqrt(x)` — with + `am_substitute`, then use `am_verify_equal` to pin the new error against + your candidate expression. The leading power of `e` is the order of + convergence, and that is the number that decides how many iterations are + needed. Quadratic means one iteration roughly squares the error. + + 3. **Measure it.** Use `am_compare_numeric` across the range to get the worst + absolute and relative error and, more usefully, WHERE they occur. Sampling + says nothing between samples, so if the error curve could have a spike, + raise the sample count and say you did. + + 4. **Account for the constants.** If a magic constant or a bit pattern is + involved, decompose it with `am_represent ieee754` and explain what it does + rather than repeating that it is magic. If the result is destined for fixed + point, run `am_represent fixed_point` and include the quantisation error + alongside the approximation error — they add up, and the smaller one is + often not the one people worry about. + + Finish with a judgement: how many iterations for a stated tolerance, and the + input range over which that holds. If the approximation is unsound, or the + error is unbounded somewhere in range, lead with that. + {Discipline} + """), + + new("solve-with-constraints", + "Solve an equation and keep only the physically meaningful branch.", + [ + new("equation", "The equation, e.g. 'v = k*d^2 + m*d'.", true), + new("variable", "The variable to solve for.", true), + new("constraints", + "Physical constraints, comma-separated, e.g. 'd > 0'. Optional.", false), + ], + get => $""" + Solve this for {get("variable")}: + + {get("equation")} + + Physical constraints: {Or(get("constraints"), "none stated")} + + Pass the equation and the constraints together to `am_solve` as a single + `constraints` list — it handles inequalities, so the solver itself discards + the branches that cannot occur. That is better than solving first and + filtering afterwards. + + If no constraints were given, solve unconstrained, then tell me which roots + are physically implausible and what constraint would exclude them. A negative + length, a complex time, a root outside the measurable range: mathematically + correct and useless. Say so explicitly rather than presenting every root as + an answer. + {Discipline} + """), + ]; + + private static string Or(string value, string fallback) => + string.IsNullOrWhiteSpace(value) ? fallback : value; + + public static JsonArray List() + { + var listed = new JsonArray(); + foreach (var prompt in All) + { + var arguments = new JsonArray(); + foreach (var argument in prompt.Arguments) + arguments.Add(new JsonObject + { + ["name"] = argument.Name, + ["description"] = argument.Description, + ["required"] = argument.Required, + }); + + listed.Add(new JsonObject + { + ["name"] = prompt.Name, + ["description"] = prompt.Description, + ["arguments"] = arguments, + }); + } + return listed; + } + + /// Null when the name is unknown; throws when a required argument is missing. + public static JsonObject? Get(string name, JsonObject? arguments) + { + var prompt = All.FirstOrDefault(p => p.Name == name); + if (prompt is null) return null; + + string Read(string key) => + arguments?[key]?.GetValue() ?? string.Empty; + + var missing = prompt.Arguments + .Where(a => a.Required && string.IsNullOrWhiteSpace(Read(a.Name))) + .Select(a => a.Name) + .ToArray(); + + if (missing.Length > 0) + throw new ArgumentException( + $"prompt '{name}' requires: {string.Join(", ", missing)}"); + + return new JsonObject + { + ["description"] = prompt.Description, + ["messages"] = new JsonArray + { + new JsonObject + { + ["role"] = "user", + ["content"] = new JsonObject + { + ["type"] = "text", + ["text"] = prompt.Build(Read), + }, + }, + }, + }; + } +} diff --git a/Sources/MCP/AngouriMath.Mcp/Representations.cs b/Sources/MCP/AngouriMath.Mcp/Representations.cs new file mode 100644 index 000000000..e48aff4e8 --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/Representations.cs @@ -0,0 +1,165 @@ +// +// 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 AngouriMath; +using PeterO.Numbers; +using static AngouriMath.Entity; + +namespace AngouriMath.Mcp; + +/// +/// How a number is *encoded*, as opposed to what it equals. +/// +/// This is squarely adapter work rather than algebra, which is why it lives here: a Q15 +/// coefficient, the bits of a double, and a phasor are all the same number wearing different +/// clothes, and the question is always "what does the machine actually hold, and what did +/// that cost me". AngouriMath answers none of these because none of them are computer +/// algebra. +/// +internal static class Representations +{ + // ------------------------------------------------------------ fixed point + + public sealed record FixedPoint( + EInteger Raw, Entity Represented, EDecimal Error, EDecimal RelativeError, + bool Saturated, EInteger Min, EInteger Max, EDecimal Resolution); + + /// + /// Quantise to Q-format. `totalBits` is the machine word (16 for a Q15 coefficient in an + /// int16), `fractionBits` how many of them sit after the binary point. + /// + /// Saturation is reported rather than silently wrapped: on most processors a coefficient + /// that does not fit is a bug you want to hear about at design time, not a value that + /// quietly becomes its own negative at run time. + /// + public static FixedPoint ToFixed(EDecimal value, int totalBits, int fractionBits, bool signed) + { + var scale = EInteger.FromInt32(2).Pow(fractionBits); + var scaled = value.Multiply(EDecimal.FromEInteger(scale)); + + // Round to nearest, ties to even — what a decent toolchain does, and it avoids the + // upward bias that ties-away introduces across a table of coefficients. + var raw = scaled.RoundToIntegerNoRoundedFlag( + EContext.ForRounding(ERounding.HalfEven)).ToEInteger(); + + var min = signed ? -EInteger.FromInt32(2).Pow(totalBits - 1) : EInteger.Zero; + var max = signed + ? EInteger.FromInt32(2).Pow(totalBits - 1) - EInteger.One + : EInteger.FromInt32(2).Pow(totalBits) - EInteger.One; + + var saturated = raw < min || raw > max; + if (raw < min) raw = min; + if (raw > max) raw = max; + + // The represented value is exact: an integer over a power of two is a rational, so + // there is no second rounding here. + Entity represented = MathS.Numbers.CreateRational(raw, scale); + + var back = EDecimal.FromEInteger(raw).Divide( + EDecimal.FromEInteger(scale), EContext.Decimal128); + var error = value.Subtract(back); + var relative = value.IsZero + ? EDecimal.Zero + : error.Divide(value, EContext.Decimal128).Abs(); + + var resolution = EDecimal.One.Divide( + EDecimal.FromEInteger(scale), EContext.Decimal128); + + return new FixedPoint(raw, represented, error.Abs(), relative, + saturated, min, max, resolution); + } + + public static Entity FromFixed(EInteger raw, int fractionBits) => + MathS.Numbers.CreateRational(raw, EInteger.FromInt32(2).Pow(fractionBits)); + + // ------------------------------------------------------------ IEEE 754 + + public sealed record Ieee754( + string Bits, string Hex, int Sign, int RawExponent, int UnbiasedExponent, + string MantissaHex, string ExactValue, EDecimal Error, string Classification); + + /// + /// What a float or double actually holds. The `ExactValue` is the point: 0.1 is not 0.1, + /// and printing the exact decimal a double stores settles arguments that explanations do + /// not. + /// + public static Ieee754 Decompose(EDecimal value, bool single) + { + if (single) + { + var f = (float)value.ToDouble(); + var bits = BitConverter.SingleToInt32Bits(f); + var sign = (bits >> 31) & 1; + var exponent = (bits >> 23) & 0xFF; + var mantissa = bits & 0x7FFFFF; + + return Build( + Convert.ToString((uint)bits, 2).PadLeft(32, '0'), + $"0x{(uint)bits:X8}", sign, exponent, exponent == 0 ? -126 : exponent - 127, + $"0x{mantissa:X6}", EDecimal.FromSingle(f), exponent, 0xFF, mantissa, value); + } + else + { + var d = value.ToDouble(); + var bits = BitConverter.DoubleToInt64Bits(d); + var sign = (int)((bits >> 63) & 1); + var exponent = (int)((bits >> 52) & 0x7FF); + var mantissa = bits & 0xFFFFFFFFFFFFFL; + + return Build( + Convert.ToString((long)bits, 2).PadLeft(64, '0'), + $"0x{(ulong)bits:X16}", sign, exponent, exponent == 0 ? -1022 : exponent - 1023, + $"0x{mantissa:X13}", EDecimal.FromDouble(d), exponent, 0x7FF, mantissa, value); + } + + static Ieee754 Build( + string binary, string hex, int sign, int rawExponent, int unbiased, + string mantissaHex, EDecimal exact, int exponentField, int exponentMax, + long mantissa, EDecimal original) + { + var classification = + exponentField == exponentMax + ? (mantissa == 0 ? "infinity" : "NaN") + : exponentField == 0 + ? (mantissa == 0 ? "zero" : "subnormal — reduced precision") + : "normal"; + + var error = classification is "normal" or "subnormal — reduced precision" + ? exact.Subtract(original).Abs() + : EDecimal.Zero; + + return new Ieee754(binary, hex, sign, rawExponent, unbiased, mantissaHex, + exact.ToString(), error, classification); + } + } + + // ------------------------------------------------------------ polar form + + public sealed record Polar(Entity Magnitude, Entity Phase, Entity PhaseDegrees); + + /// + /// Rectangular to polar, kept symbolic where it can be: 1+i gives sqrt(2) and pi/4, not + /// 1.414 and 0.785. + /// + public static Polar ToPolar(Entity real, Entity imaginary) + { + var magnitude = MathS.Sqrt(real * real + imaginary * imaginary).Simplify(); + + // arctan alone loses the quadrant, so the sign of the real part is folded back in. + // Without this, -1-i and 1+i report the same phase. + var phase = MathS.Arctan(imaginary / real); + var adjusted = Numeric.AsNumber(real) is { } r && r < 0 + ? (Numeric.AsNumber(imaginary) is { } im && im < 0 ? phase - MathS.pi : phase + MathS.pi) + : phase; + + var simplified = adjusted.Simplify(); + return new Polar(magnitude, simplified, (simplified * 180 / MathS.pi).Simplify()); + } + + public static Entity FromPolar(Entity magnitude, Entity phase) => + (magnitude * MathS.Cos(phase) + magnitude * MathS.Sin(phase) * MathS.i).Simplify(); +} diff --git a/Sources/MCP/AngouriMath.Mcp/Resources.cs b/Sources/MCP/AngouriMath.Mcp/Resources.cs new file mode 100644 index 000000000..998b98880 --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/Resources.cs @@ -0,0 +1,277 @@ +// +// 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 System.Text.Json.Nodes; + +namespace AngouriMath.Mcp; + +/// +/// Three documents the model can read. They cost nothing in tool-list context, and the +/// reliability one tells the model when to trust a result and when to expect a decline. +/// +/// +/// ResourcesTest checks the syntax document against the grammar, so a function the grammar +/// gains or loses fails the build until the list here says so. +/// +internal static class Resources +{ + public static JsonArray List() => + [ + new JsonObject + { + ["uri"] = "angourimath://syntax", + ["name"] = "AngouriMath expression syntax", + ["description"] = "How to write expressions for this server, including the two " + + "parsing traps that silently change meaning. Read before " + + "composing a non-trivial expression.", + ["mimeType"] = "text/markdown", + }, + new JsonObject + { + ["uri"] = "angourimath://curiosities", + ["name"] = "Verified mathematical curiosities", + ["description"] = "Famous results and coincidences, each with the exact " + + "expression to reproduce it on this server. Doubles as a " + + "self-test corpus.", + ["mimeType"] = "text/markdown", + }, + new JsonObject + { + ["uri"] = "angourimath://reliability", + ["name"] = "Measured reliability by problem class", + ["description"] = "Which operations are trustworthy and which are known to " + + "decline or mislead, as the library's own harnesses measure them.", + ["mimeType"] = "text/markdown", + }, + ]; + + public static string? Read(string uri) => uri switch + { + "angourimath://syntax" => Syntax, + "angourimath://curiosities" => Curiosities, + "angourimath://reliability" => Reliability, + _ => null, + }; + + private const string Syntax = """ + # AngouriMath expression syntax + + Plain text. `x^2 + 3*x - 1`, `sin(x)/x`, `sqrt(2)`, `e^x`, `+oo` / `-oo` for infinity. + + ## Two traps that change the meaning of your expression without any error + + **1. A number directly after an identifier is an EXPONENT, not a factor.** + + | You write | It means | + |---|---| + | `x2` | x² | + | `2x` | 2·x | + | `2(g + e)3` | 2·(g + e)³ | + + So a variable named `x2`, `v1` or `a0` is silently squared / raised. Avoid variable + names ending in a digit; write `x_2` or `xa` instead. Always read the `parsed` field + that every response returns. + + **2. An unknown function name becomes a multiplication.** + + `im(z)` is not a function here — it is the product `im * z`. The parse succeeds and + produces a different expression. Some names are refused outright, but not every + unknown name can be: refusing them all would refuse `a(b + c)`. + + Functions: + + - trigonometric: `sin cos tan cotan sec cosec`, also spelled `cot csc`, and their + inverses `arcsin arccos arctan arccotan arcsec arccosec`, also spelled + `asin acos atan acot acotan arccot asec acsc acosec arccsc` + - hyperbolic: `sinh cosh tanh cotanh sech cosech`, also spelled + `sh ch th cth coth sch csch`, and their inverses as *area* functions: + `arsinh arcosh artanh arcotanh arsech arcosech`, also spelled + `asinh acosh atanh acotanh acoth asech acosech acsch arsh arch arth arcth arcoth + arsch arcsch` — **not** `arcsinh`, which is refused + - `ln log log2 log10 exp pow sqrt cbrt sqr abs signum sign sgn` + - `floor ceil ceiling round min max gcd lcm` + - `gamma factorial binomial phi prime valuation` + - special functions: `erf erfc erfi Ei li Si Ci Shi Chi` + - sets: `card powerset union intersection complement image preimage domain aleph + iverson` + - `derivative integral limit limitleft limitright sum product argmax argmin piecewise + apply lambda` + + ## Spellings worth knowing (verified against the grammar, not guessed) + + | You might write | Reality | + |---|---| + | `arcsinh(x)` | **refused.** The inverse hyperbolics are area functions: write `arsinh`, `asinh` or `arsh` | + | `trunc`, `conjugate` | refused by name — the library has neither | + | `im(z)`, `re(z)` | **silently become `im * z`.** There is no real- or imaginary-part function | + | `elementin(x, A)`, `setsubtraction(A, B)`, `impl(a, b)` | **silently become products.** Use the infix `x in A`, `A \ B`, `a -> b` | + | `union(A, B)`, `intersection(A, B)` | refused: both are indexed, `union(A_i, i in I)`. For two sets write `A \/ B` and `A /\ B` | + | `7 mod 3` | `mod` is FLOORED: `-7 mod 3` is `2`, not `-1` | + | `7 % 3` | a parse error. `%` stays free to mean percent | + | `mod` as a variable name | a parse error: `mod` is a keyword. Rename it | + | `log(2, 8)` | `3` — base first, then argument | + + The "silently become" cases are the dangerous ones: they parse, and they mean + something else. Anything else followed by `(` gets a warning. + + ## Statements + + `=` is equality (`x^2 = 4`), and `>`, `<`, `>=`, `<=` are comparisons. Combine with + `and`, `or`, `not`, `xor`, `implies`. `am_solve` accepts these, so you can solve + under constraints: `['x^2 = 4', 'x > 0']` yields `2`. + + ## LaTeX + + LaTeX is OUTPUT only. This server cannot parse LaTeX input — convert + `\frac{a}{b}` to `a/b` yourself before calling. + """; + + private const string Curiosities = """ + # Curiosities, each verified on this server + + Every entry below was checked here, not copied from memory. The expression given is + exactly what to run — so this doubles as a self-test corpus. If one of these stops + reproducing, something regressed. + + ## Exact identities + + | Result | Run | + |---|---| + | Euler's identity, exactly 0 — not 1e-16 | `am_evaluate e^(i*pi) + 1` | + | Machin's formula: how π was computed to 100 digits by hand in 1706 | `am_verify_equal 4*arctan(1/5) - arctan(1/239) vs pi/4` | + | Two arctans make a right angle's eighth | `am_verify_equal arctan(1/2) + arctan(1/3) vs pi/4` | + | Golden ratio is a pentagon in disguise | `am_verify_equal (1+sqrt(5))/2 vs 2*cos(pi/5)` | + | φ from its defining property | `am_solve ['x^2 = x + 1', 'x > 0'] for x` | + | Ramanujan's taxicab: 1729 two ways | `am_verify_equal 1^3 + 12^3 vs 9^3 + 10^3` | + + ## A machine-checked proof that 22/7 > π + + The integrand is positive on (0,1), so the integral is positive — which proves the + inequality: + + 1. `am_integrate x^4*(1-x)^4/(1+x^2) dx` (verifies by differentiating back) + 2. `am_evaluate 1/7 - 4/6 + 1 - 4/3 + 4 - 4*arctan(1)` → **exactly `22/7 - pi`** + + ## Near misses — where floating point would lie to you + + | Result | Run | + |---|---| + | Homer Simpson's Fermat counterexample. Agrees to **ten** significant figures — exactly a pocket calculator's width, which is the joke | `am_evaluate 3987^12 + 4365^12 - 4472^12` → 1211886809373872630985912112862690, not 0 | + | 42 as a sum of three cubes (Booker & Sutherland, 2019). Each term is ~5e50, so a double returns pure noise | `am_evaluate (-80538738812075974)^3 + 80435758145817515^3 + 12602123297335631^3` → 42 | + | 355/113, the best simple approximation to π (Milü, 5th century) | `am_evaluate 355/113 - pi digits 20` → 2.67e-7 | + | Ramanujan's quartic approximation to π | `am_evaluate (2143/22)^(1/4) - pi digits 20` → -1.01e-9 | + | One term of Ramanujan's 1/π series already gives 8 digits | `am_evaluate 9801/(2*sqrt(2)*1103) - pi digits 20` → 7.6e-8 | + | e^π − π is almost exactly 20, for no known reason | `am_evaluate e^pi - pi digits 20` → 19.999099979189475768 | + + ## 42 + + | Result | Run | + |---|---| + | Adams' "six by nine" is true in base 13 | `am_represent base 54 to_base 13` → 42 | + | 42 = 101010, a perfect alternating bit pattern | `am_represent base 42 to_base 2` | + | The 5th Catalan number — counts the ways to parenthesise six factors | `am_evaluate 10! / (6! * 5!)` | + | 6¹ + 6², and also the sum of the first six even numbers | `am_evaluate 6^1 + 6^2` | + | The rainbow really is at 42°: minimise deviation through a raindrop | `am_solve ['(4/3)^2 - 1 = 3*c^2','c > 0'] for c` → `sqrt(21)/9`, giving 42.03° | + + ## Ramanujan's constant, which this build gets right + + `e^(pi*sqrt(163))` is `262537412640768743.99999999999925007...`, sitting 7.5e-13 + below an integer for a reason from class field theory. Ask for enough digits and the + near-miss reproduces. + + ## Where the answer is honest but not what you asked + + - `sqrt(x^2)` is left as written rather than reduced to `abs(x)`. It is not reduced + to `x` either, which would be wrong for every negative. + + ## Beyond this server + + No infinite series: `sum` adds up finitely many terms, and an infinite sum is left + unevaluated, so Basel (π²/6) and the Leibniz series cannot be evaluated here. Worth + knowing anyway — truncating Leibniz at + 500,000 terms gives + `3.14159065358979324046264338326950` against π's + `3.14159265358979323846264338327950`: nearly every digit correct, with isolated + wrong ones. Those errors are not noise — they are the Euler numbers. + + """; + + private const string Reliability = """ + # Measured reliability + + ## What this server runs against + + The library it ships with: `amcli mcp` is built and released with the library, so an + answer here is the library's answer at this version. + + What follows is a snapshot. If something contradicts it, trust the tool's own `status` + and `verified` fields over this document, and say so. + + The library measures itself on every change. Its CasBench harness runs a corpus of + problems with known answers, drawn from SymPy's test suite, the Rubi integration suite + and Gruntz's thesis, and fails on a wrong answer. Answers are not trusted on their + face: integrals are checked by differentiating back, equation roots by substituting + them in. + + ## Trustworthy + + - **Equations**: linear, quadratic, cubic, quartic, higher polynomial, rational, + radical, trigonometric, exponential, logarithmic, absolute-value and transcendental. + Every root is checked by substituting it back. + - **Derivatives**: fast and dependable. The PropCheck harness compares them with a + difference quotient. + - **Integrals**: table forms, linearity, f(ax+b), u-substitution, by parts (including + cyclic), arctan and arcsin forms, partial fractions, trig powers, trig substitution. + - **Limits**: including Gruntz-class, 0/0, ∞/∞, 1^∞, ∞−∞, and factorial asymptotics: + `lim x→∞ (x!/x^x)^(1/x)` is `1/e`, by Stirling. + + ## Integrals without an elementary antiderivative + + `∫ e^x/x`, `∫ e^(x^2)`, `∫ sin(x)/x` and `∫ 1/ln(x)` have none, and are answered with + the special functions that name them: `Ei(x)`, `sqrt(pi)/2 * erfi(x)`, `Si(x)` and + `li(x)`. They are verified by differentiating back like any other answer. + + ## Expect a decline (this is correct behaviour, not a bug) + + - `∫ x^x` and `∫ sqrt(1 + x^3)`: no closed form in the functions the library has; + the second is elliptic. Do not retry or reword. + + A decline from `am_integrate` with `from` and `to` still carries a + `numeric_definite_value` for that one interval, rounded to the digits it is good for + — roughly four. It is quadrature, not an antiderivative, and it is not verified by + differentiating back. + + ## Where results mislead + + - **`am_verify_equal` decides on POSITIVE real points**, because correct + antiderivatives are full of `ln(x)` and `abs(x)`. So `sqrt(x^2)` against `x` comes + back `equal: true` — true on the positives, false at every negative. It is not + silent about it: a second pass across the whole real line adds a note saying the + two agree only for positive inputs. **Read the note before repeating the verdict.** + - **Simplify is not canonical**: `sqrt(12)+sqrt(27)` comes back as `sqrt(3)*5` while + `5*sqrt(3)` is left alone. Two different-looking outputs can be equal — use + `am_verify_equal` rather than comparing strings. + - **A `status` of `unchanged` means no progress, not "already simplest".** + - **Output tidiness** varies. Results are correct but not always in the form a human + would write. Pass `alternatives: true` to `am_simplify` and pick a nicer form. + - **Nonlinear systems** can come back with no solution when one exists. + + ## How to talk about a result + + State the status you were given. If a tool returns `declined`, say the library has no + rule for it rather than presenting the unevaluated form as an answer. If it returns + `unchanged`, say it made no progress. If `verified` is false or a `conflict` is + reported, do not use the result. Say which tool produced a number rather than + implying you worked it out. + + ## Soundness note + + Unlike some CAS libraries, this one tracks domain conditions: `(x^2-1)/(x-1)` + simplifies to `x + 1 provided not x - 1 = 0`, rather than an unconditional `x + 1` + that is wrong at x = 1. The `provided` clause is a feature — preserve it. + """; +} diff --git a/Sources/MCP/AngouriMath.Mcp/SelfTest.cs b/Sources/MCP/AngouriMath.Mcp/SelfTest.cs new file mode 100644 index 000000000..913e7af76 --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/SelfTest.cs @@ -0,0 +1,178 @@ +// +// 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 AngouriMath; +using AngouriMath.Extensions; +using static AngouriMath.Entity; + +namespace AngouriMath.Mcp; + +/// +/// `amcli mcp --selftest`: does this install work, and does the library still behave the way +/// this server's documents say it does? +/// +/// Two categories, and the distinction matters. Identities must hold; if one fails, the install +/// or the library is broken and the exit code says so. Documented defects are only reported: one +/// that stops reproducing is good news rather than a failure, but it means the documents describe +/// a defect the library no longer has. The tests in McpUnitTests fail on either, so a change to +/// the library that fixes one fails the build here until the documents say so too. +/// +internal static class SelfTest +{ + private static bool IsZero(string expression) + { + var outcome = Parsing.Parse(expression); + return outcome.Entity is not null && Numeric.IsZero(outcome.Entity.Simplify()); + } + + private static string Value(string expression) + { + var outcome = Parsing.Parse(expression); + return outcome.Entity is null ? "" : outcome.Entity.Evaled.Stringize(); + } + + private static readonly (string Name, Func Holds)[] Identities = + [ + ("Euler's identity is exactly zero", () => Value("e^(i*pi) + 1") == "0"), + ("Machin: 4*atan(1/5) - atan(1/239) = pi/4", + () => IsZero("4*arctan(1/5) - arctan(1/239) - pi/4")), + ("golden ratio = 2*cos(pi/5)", () => IsZero("(1+sqrt(5))/2 - 2*cos(pi/5)")), + ("taxicab 1729 two ways", () => IsZero("(1^3 + 12^3) - (9^3 + 10^3)")), + ("42 as a sum of three cubes", + () => Value("(-80538738812075974)^3 + 80435758145817515^3 + 12602123297335631^3") == "42"), + ("Simpsons' Fermat near-miss is NOT equal", + () => Value("3987^12 + 4365^12 - 4472^12") != "0"), + ("six by nine is 42 in base 13", () => MathS.ToBaseN(54, 13) == "42"), + ("42 is 101010 in binary", () => MathS.ToBaseN(42, 2) == "101010"), + ("42 is the 5th Catalan number", () => Value("10! / (6! * 5!)") == "42"), + ("Pythagoras: sin^2 + cos^2 = 1", () => IsZero("sin(x)^2 + cos(x)^2 - 1")), + ("22/7 - pi is the integral of x^4*(1-x)^4/(1+x^2) over [0, 1]", () => + { + // Kept as an identity so the proof the curiosities resource walks through cannot + // silently stop working. + var outcome = Parsing.Parse("x^4*(1-x)^4/(1+x^2)"); + if (outcome.Entity is null) return false; + var x = MathS.Var("x"); + var antiderivative = outcome.Entity.Integrate(x); + var value = antiderivative.Substitute(x, 1) - antiderivative.Substitute(x, 0); + return Numeric.IsZero((value - (MathS.FromString("22/7") - MathS.pi)).Simplify()); + }), + ("d/dx integral of x*ln(x) returns the integrand", () => + { + var outcome = Parsing.Parse("x*ln(x)"); + if (outcome.Entity is null) return false; + var x = MathS.Var("x"); + var back = outcome.Entity.Integrate(x).Simplify().Differentiate(x); + return Numeric.Equal(back, outcome.Entity) == true; + }), + ]; + + private static readonly (string Name, Func StillBroken, string Documented)[] Defects = + [ + ("Simplify(sqrt(x^2))", () => Value("sqrt(x^2)") != "abs(x)", + "left as written rather than reduced to abs(x)"), + ("MathS.Equations does not solve a system written as equalities", () => + { + // x + y = 3 and x - y = 1 meet at (2, 1), which the same system written in '= 0' + // form is solved to. A check for an exception alone would read a system that comes + // back with no solution as fixed. https://github.com/asc-community/AngouriMath/issues/1673 + try + { + var solutions = MathS.Equations("x + y = 3".ToEntity(), "x - y = 1".ToEntity()).Solve("x", "y"); + return solutions is null || solutions.RowCount != 1 + || solutions[0, 0].Evaled != 2 || solutions[0, 1].Evaled != 1; + } + catch (Exception) + { + return true; + } + }, "x + y = 3, x - y = 1 has no solution when written as equalities; each equation must be given in '= 0' form"), + ("DefiniteIntegral is a first-order rule", () => + { + // Halving the step count roughly doubles the error iff the rule is first order. + // https://github.com/asc-community/AngouriMath/issues/1675 + // Numeric.TryDefiniteIntegral's step counts, its error estimate and the caveat + // am_integrate prints all assume that; a rule of higher order would make the + // reported error bound far too pessimistic and the wording wrong. + var e = "e^(x^2)".ToEntity(); + var v = MathS.Var("x"); + var coarse = e.DefiniteIntegral(v, 0, 1, 500).RealPart.EDecimal.ToDouble(); + var fine = e.DefiniteIntegral(v, 0, 1, 1000).RealPart.EDecimal.ToDouble(); + var finer = e.DefiniteIntegral(v, 0, 1, 2000).RealPart.EDecimal.ToDouble(); + var ratio = Math.Abs(fine - coarse) / Math.Abs(finer - fine); + return ratio is > 1.7 and < 2.3; + }, "error halves per doubling, so ~4 digits at 4000 steps; Simpson would give more"), + ]; + + /// Runs every check, writes what it found, and returns the exit code: 1 when an identity fails. + public static int Run(TextWriter output) => Check(output).Failed > 0 ? 1 : 0; + + /// Runs every check and writes what it found. + /// How many identities failed, and how many documented defects no longer reproduce. + internal static (int Failed, int Drifted) Check(TextWriter output) + { + output.WriteLine("amcli mcp self-test"); + output.WriteLine(); + output.WriteLine("Identities (these must hold):"); + + var failed = 0; + foreach (var (name, holds) in Identities) + { + // Through Guard rather than directly: a check that overflowed the stack would take + // the self-test down with it, and a diagnostic that dies on what it is diagnosing + // reports nothing. + var outcome = Guard.Run(holds, timeoutMs: 30_000); + + if (!outcome.Ok) + { + output.WriteLine($" FAIL {name} — {outcome.Status}: {outcome.Error}"); + failed++; + continue; + } + + output.WriteLine($" {(outcome.Value ? "ok " : "FAIL")} {name}"); + if (!outcome.Value) failed++; + } + + output.WriteLine(); + output.WriteLine("Documented defects (a fix here means the documents need updating):"); + + var drifted = 0; + foreach (var (name, stillBroken, documented) in Defects) + { + var outcome = Guard.Run(stillBroken, timeoutMs: 30_000); + if (!outcome.Ok) + { + output.WriteLine($" ? {name} — could not check ({outcome.Status})"); + continue; + } + + var broken = outcome.Value; + if (broken) + { + output.WriteLine($" still {name}: {documented}"); + } + else + { + output.WriteLine($" FIXED {name} — documented as: {documented}"); + output.WriteLine($" Update the resources and Sources/MCP/README.md, and remove this check."); + drifted++; + } + } + + output.WriteLine(); + if (failed > 0) + output.WriteLine($"{failed} identity check(s) FAILED — this install is not trustworthy."); + else + output.WriteLine($"All {Identities.Length} identities hold."); + + if (drifted > 0) + output.WriteLine($"{drifted} documented defect(s) no longer reproduce. The documents have drifted."); + + return (failed, drifted); + } +} diff --git a/Sources/MCP/AngouriMath.Mcp/Server.cs b/Sources/MCP/AngouriMath.Mcp/Server.cs new file mode 100644 index 000000000..0566abc1a --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/Server.cs @@ -0,0 +1,261 @@ +// +// 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 System.Reflection; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace AngouriMath.Mcp; + +/// +/// The MCP server over stdio: newline-delimited JSON-RPC 2.0 read from one stream and written to +/// another, which is what amcli mcp serves on. Nothing but protocol traffic may go to the +/// output -- diagnostics belong on the error stream, or they corrupt the protocol. +/// +/// +/// Requests are handled strictly one at a time. That was once a correctness requirement: +/// MathS.Settings kept its values in thread-static fields, so two concurrent calls with +/// different parse settings interfered. They are an AsyncLocal now and a scope follows the +/// call, so this is no longer load-bearing -- a stdio client sends one request at a time anyway. +/// Making the loop concurrent is a change to measure on its own. +/// +public static class Server +{ + /// The version this server reports to a client, which is the library's: the two ship together. + public static string Version => + typeof(Entity).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] + ?? "unknown"; + + // A tool's answer is JSON inside a text block, and the model reads that text as written, so it + // is not escaped for HTML: `+` stays `+` and `α` stays `α` rather than `\u002B` and `\u03B1`. + // The message around it keeps the default escaping, which a client decodes. + private static readonly JsonSerializerOptions ToolTextOptions = new() + { + WriteIndented = false, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + /// + /// Serves until ends, or runs the self-test or prints the usage, as + /// ask; returns the exit code. + /// + public static int Run(IReadOnlyList arguments, TextReader input, TextWriter output, TextWriter error) + { + // `--selftest` answers "does this install work, and does the library still behave the way + // the docs claim?" It exits rather than serving, so it never interferes with the protocol. + if (arguments.Contains("--selftest")) + return SelfTest.Run(output); + + if (arguments.Contains("--help") || arguments.Contains("-h")) + { + output.WriteLine("amcli mcp -- exact symbolic algebra over MCP (stdio JSON-RPC)."); + output.WriteLine(); + output.WriteLine(" amcli mcp serve on stdin/stdout"); + output.WriteLine(" amcli mcp --selftest verify the install and check the docs for drift"); + output.WriteLine(" amcli mcp --help this message"); + return 0; + } + + var serializerOptions = new JsonSerializerOptions { WriteIndented = false }; + + void Send(JsonObject message) + { + output.WriteLine(message.ToJsonString(serializerOptions)); + output.Flush(); + } + + string? line; + while ((line = input.ReadLine()) is not null) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + + JsonObject? request; + try + { + request = JsonNode.Parse(line) as JsonObject; + } + catch (JsonException e) + { + Send(Error(null, -32700, $"parse error: {e.Message}")); + continue; + } + + if (request is null) + continue; + + var id = request.TryGetPropertyValue("id", out var idNode) ? idNode?.DeepClone() : null; + var method = request.TryGetPropertyValue("method", out var m) ? m?.GetValue() : null; + var parameters = request.TryGetPropertyValue("params", out var p) ? p as JsonObject : null; + + if (method is null) + { + if (id is not null) + Send(Error(id, -32600, "missing 'method'")); + continue; + } + + // Notifications carry no id and must never be answered. + var isNotification = id is null; + + try + { + var result = Dispatch(method, parameters, error); + if (result is null) + { + if (!isNotification) + Send(Error(id, -32601, $"unknown method '{method}'")); + continue; + } + if (!isNotification) + Send(Ok(id, result)); + } + catch (Exception e) + { + error.WriteLine($"[amcli mcp] {method} failed: {e}"); + if (!isNotification) + Send(Error(id, -32603, $"{e.GetType().Name}: {e.Message}")); + } + } + + // The input ended: the host has gone away, which is a normal shutdown. + return 0; + } + + private static JsonObject? Dispatch(string method, JsonObject? parameters, TextWriter error) + { + switch (method) + { + case "initialize": + return new JsonObject + { + // Pinned rather than echoed: this server implements exactly this revision. + ["protocolVersion"] = "2024-11-05", + ["capabilities"] = new JsonObject + { + ["tools"] = new JsonObject(), + ["resources"] = new JsonObject(), + ["prompts"] = new JsonObject(), + }, + ["serverInfo"] = new JsonObject + { + ["name"] = "angourimath", + ["version"] = Version, + }, + ["instructions"] = + "Exact symbolic algebra: simplify, solve, differentiate, integrate, " + + "limits, truth tables. Prefer these tools over doing algebra yourself — " + + "every answer is machine-checked and integrals are verified by " + + "differentiating them back. Always read the 'parsed' field to confirm " + + "the expression was understood as intended, and treat a 'declined' or " + + "'unchanged' status as 'no answer', never as the answer. Read the " + + "angourimath://syntax resource before composing unusual expressions.", + }; + + // Notifications. Acknowledged by returning an empty object; the caller suppresses + // the response because there is no id. + case "notifications/initialized": + case "notifications/cancelled": + return new JsonObject(); + + case "ping": + return new JsonObject(); + + case "tools/list": + return new JsonObject { ["tools"] = Tools.List() }; + + case "tools/call": + { + var name = parameters?["name"]?.GetValue(); + if (name is null) + return ToolText("{\"status\":\"failed\",\"error\":\"missing tool name\"}", true); + + var arguments = parameters?["arguments"] as JsonObject ?? new JsonObject(); + + JsonObject payload; + try + { + payload = Tools.Call(name, arguments); + } + catch (Exception e) + { + // A thrown exception here is a defect in this server, not bad user input; + // report it as a tool error rather than killing the connection. + error.WriteLine($"[amcli mcp] tool {name} threw: {e}"); + payload = new JsonObject + { + ["status"] = "failed", + ["error"] = $"{e.GetType().Name}: {e.Message}", + }; + } + + var isError = payload["status"]?.GetValue() is "failed"; + return ToolText(payload.ToJsonString(ToolTextOptions), isError); + } + + case "prompts/list": + return new JsonObject { ["prompts"] = Prompts.List() }; + + case "prompts/get": + { + var promptName = parameters?["name"]?.GetValue() + ?? throw new InvalidOperationException("prompts/get requires a name"); + return Prompts.Get(promptName, parameters?["arguments"] as JsonObject) + ?? throw new InvalidOperationException($"no such prompt: {promptName}"); + } + + case "resources/list": + return new JsonObject { ["resources"] = Resources.List() }; + + case "resources/read": + { + var uri = parameters?["uri"]?.GetValue(); + var text = (uri is null ? null : Resources.Read(uri)) + ?? throw new InvalidOperationException($"no such resource: {uri}"); + return new JsonObject + { + ["contents"] = new JsonArray + { + new JsonObject + { + ["uri"] = uri, + ["mimeType"] = "text/markdown", + ["text"] = text, + }, + }, + }; + } + + default: + return null; + } + } + + private static JsonObject ToolText(string text, bool isError) => new() + { + ["content"] = new JsonArray + { + new JsonObject { ["type"] = "text", ["text"] = text }, + }, + ["isError"] = isError, + }; + + private static JsonObject Ok(JsonNode? id, JsonObject result) => new() + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["result"] = result, + }; + + private static JsonObject Error(JsonNode? id, int code, string message) => new() + { + ["jsonrpc"] = "2.0", + ["id"] = id, + ["error"] = new JsonObject { ["code"] = code, ["message"] = message }, + }; +} diff --git a/Sources/MCP/AngouriMath.Mcp/Tools.cs b/Sources/MCP/AngouriMath.Mcp/Tools.cs new file mode 100644 index 000000000..b260d487a --- /dev/null +++ b/Sources/MCP/AngouriMath.Mcp/Tools.cs @@ -0,0 +1,2043 @@ +// +// 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 System.Text.Json.Nodes; +using AngouriMath; +using AngouriMath.Core; +using AngouriMath.Extensions; +using static AngouriMath.Entity; + +namespace AngouriMath.Mcp; + +internal static class Tools +{ + // ---------------------------------------------------------------- schemas + + private static JsonObject Str(string desc) => + new() { ["type"] = "string", ["description"] = desc }; + + private static JsonObject Schema(JsonObject props, params string[] required) + { + var req = new JsonArray(); + foreach (var r in required) req.Add(r); + return new JsonObject + { + ["type"] = "object", + ["properties"] = props, + ["required"] = req, + }; + } + + private static JsonObject Tool(string name, string description, JsonObject schema) => new() + { + ["name"] = name, + ["description"] = description, + ["inputSchema"] = schema, + // Pure computation: no side effects, no network, no filesystem. Declaring this lets + // clients auto-approve instead of prompting on every call — and a math tool that + // costs a permission click per invocation simply will not get used. + ["annotations"] = new JsonObject + { + ["readOnlyHint"] = true, + ["destructiveHint"] = false, + ["idempotentHint"] = true, + ["openWorldHint"] = false, + }, + }; + + private const string CallEvenIfConfident = + " Call this even when you believe you can do the algebra unaided — unaided symbolic " + + "manipulation is unreliable for exactly this class of problem, and the result here is " + + "machine-checked."; + + public static JsonArray List() => + [ + Tool("am_parse", + "Parse an expression and show exactly how AngouriMath understood it. Use this " + + "first whenever an expression contains identifiers with digits (x2, v1) or " + + "function-looking names, because both are silently misread. Returns the canonical " + + "form, LaTeX, free variables, and warnings.", + Schema(new JsonObject + { + ["expression"] = Str("Expression in AngouriMath syntax, e.g. 'x^2 + 3*x - 1'."), + ["strict"] = new JsonObject + { + ["type"] = "boolean", + ["description"] = "Reject implicit multiplication instead of guessing. Default false.", + }, + }, "expression")), + + Tool("am_simplify", + "Simplify an expression exactly." + CallEvenIfConfident + + " Set alternatives=true to get several candidate forms ranked by complexity, " + + "which is useful because the default form is not always the tidiest.", + Schema(new JsonObject + { + ["expression"] = Str("Expression to simplify."), + ["alternatives"] = new JsonObject + { + ["type"] = "boolean", + ["description"] = "Also return alternative simplified forms. Default false.", + }, + }, "expression")), + + Tool("am_solve", + "Solve an equation, inequality, or a set of simultaneous constraints for one " + + "variable. Constraints are combined, so you can narrow a solution incrementally " + + "(e.g. ['x^2 = 4', 'x > 0'] gives 2)." + CallEvenIfConfident, + Schema(new JsonObject + { + ["constraints"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" }, + ["description"] = "One or more statements, e.g. ['x^2 - 1 = 0'] or ['x^2=4','x>0'].", + }, + ["variable"] = Str("Variable to solve for, e.g. 'x'."), + }, "constraints", "variable")), + + Tool("am_differentiate", + "Differentiate an expression with respect to a variable." + CallEvenIfConfident, + Schema(new JsonObject + { + ["expression"] = Str("Expression to differentiate."), + ["variable"] = Str("Variable to differentiate by."), + ["order"] = new JsonObject + { + ["type"] = "integer", + ["description"] = "Order of the derivative. Default 1.", + }, + }, "expression", "variable")), + + Tool("am_integrate", + "Integrate an expression symbolically. The answer is ALWAYS verified by " + + "differentiating it back and comparing numerically; check the 'verified' field. " + + "If the library has no rule for the integrand the status is 'declined', which is " + + "an honest answer, not a failure — and when 'from' and 'to' are given, a numeric " + + "value for that one interval still comes back in 'numeric_definite_value', " + + "rounded to the digits it is actually good for." + CallEvenIfConfident, + Schema(new JsonObject + { + ["expression"] = Str("Integrand."), + ["variable"] = Str("Variable of integration."), + ["from"] = Str("Lower limit. Give both 'from' and 'to' for a definite integral."), + ["to"] = Str("Upper limit."), + }, "expression", "variable")), + + Tool("am_limit", + "Compute a limit, optionally one-sided." + CallEvenIfConfident, + Schema(new JsonObject + { + ["expression"] = Str("Expression."), + ["variable"] = Str("Variable approaching the destination."), + ["to"] = Str("Destination, e.g. '0', '+oo', '-oo'."), + ["side"] = new JsonObject + { + ["type"] = "string", + ["enum"] = new JsonArray { "both", "left", "right" }, + ["description"] = "Approach direction. Default 'both'.", + }, + }, "expression", "variable", "to")), + + Tool("am_evaluate", + "Evaluate an expression numerically, optionally substituting values first. " + + "Returns exact form and a decimal approximation.", + Schema(new JsonObject + { + ["expression"] = Str("Expression to evaluate."), + ["substitutions"] = new JsonObject + { + ["type"] = "object", + ["additionalProperties"] = new JsonObject { ["type"] = "string" }, + ["description"] = "Optional map of variable -> value, e.g. {\"x\": \"2\"}.", + }, + ["digits"] = new JsonObject + { + ["type"] = "integer", + ["description"] = "Significant digits for the numeric result, 1-1000. " + + "Omit for a normal double. Raise it when the question turns on the " + + "tail — e^(pi*sqrt(163)) only looks like an integer until digit 18.", + }, + }, "expression")), + + Tool("am_verify_equal", + "Check whether two expressions are mathematically equal. Use this to check your " + + "OWN algebra: give it your hand-derived form and the original, and it will tell " + + "you if you changed the meaning. Tries exact simplification first, then falls " + + "back to numeric sampling.", + Schema(new JsonObject + { + ["left"] = Str("First expression."), + ["right"] = Str("Second expression."), + }, "left", "right")), + + Tool("am_truth_table", + "Build a truth table for a boolean expression, and list the assignments that " + + "satisfy it. Useful for reasoning about branch conditions and predicates. " + + "Operators: 'and', 'or', 'not', 'xor', 'implies'.", + Schema(new JsonObject + { + ["expression"] = Str("Boolean expression, e.g. 'a and (b or not c)'."), + ["variables"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" }, + ["description"] = "Variable order. Inferred from the expression if omitted.", + }, + }, "expression")), + + Tool("am_solve_system", + "Solve a system of simultaneous equations for several variables. Returns one row " + + "per solution. Note: nonlinear systems may return no solution even when one " + + "exists (a known library limitation).", + Schema(new JsonObject + { + ["equations"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" }, + ["description"] = "Equations, e.g. ['x + y = 3', 'x - y = 1'].", + }, + ["variables"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" }, + ["description"] = "Variables to solve for, e.g. ['x','y'].", + }, + }, "equations", "variables")), + + Tool("am_check_steps", + "Check a chain of working step by step and report WHICH step broke. Use this to " + + "present a derivation: write out your steps, have them checked, then explain " + + "them. AngouriMath cannot generate a derivation — but you can, and this makes " + + "yours trustworthy. Each consecutive pair must be mathematically equal.", + Schema(new JsonObject + { + ["steps"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" }, + ["description"] = "Successive forms of the SAME expression, in order, " + + "e.g. ['(x+1)^2 - 1', 'x^2 + 2*x + 1 - 1', 'x^2 + 2*x'].", + }, + }, "steps")), + + Tool("am_domain_check", + "Find what to watch out for in an expression: the domain conditions AngouriMath " + + "derived, the structural hazards (division, logs, fractional powers, inverse " + + "trig), and the sample points where it stops being a real number. Use this " + + "before trusting a result as a physical quantity — a value can be perfectly " + + "correct and still be meaningless as a length, a time or a resistance.", + Schema(new JsonObject + { + ["expression"] = Str("Expression to inspect."), + }, "expression")), + + Tool("am_represent", + "How a number is ENCODED, rather than what it equals. Bases 2-36, including " + + "fractions and repeating expansions. Q-format fixed point, reporting the " + + "quantisation error and whether the value saturated — the question you actually " + + "have when putting a coefficient into a processor. The bits of an IEEE 754 float " + + "or double together with the exact decimal it holds, which is how you settle why " + + "0.1 is not 0.1. And polar form, kept symbolic, so 1+i gives sqrt(2) and pi/4 " + + "rather than 1.414 and 0.785.", + Schema(new JsonObject + { + ["operation"] = new JsonObject + { + ["type"] = "string", + ["enum"] = new JsonArray + { + "base", "fixed_point", "from_fixed_point", "ieee754", + "polar", "rectangular", + }, + ["description"] = "Which representation to produce.", + }, + ["value"] = Str("The number as an expression. For 'from_fixed_point', the " + + "raw stored integer. Unused by 'rectangular'."), + ["from_base"] = new JsonObject + { + ["type"] = "integer", + ["description"] = "base: base of the input, 2-36. Default 10.", + }, + ["to_base"] = new JsonObject + { + ["type"] = "integer", + ["description"] = "base: base to convert to, 2-36.", + }, + ["total_bits"] = new JsonObject + { + ["type"] = "integer", + ["description"] = "fixed_point: machine word width, e.g. 16 or 32. Default 16.", + }, + ["fraction_bits"] = new JsonObject + { + ["type"] = "integer", + ["description"] = "fixed_point / from_fixed_point: bits after the binary " + + "point. Q15 in a 16-bit word means 15.", + }, + ["signed"] = new JsonObject + { + ["type"] = "boolean", + ["description"] = "fixed_point: signed word. Default true.", + }, + ["precision"] = new JsonObject + { + ["type"] = "string", + ["enum"] = new JsonArray { "double", "single" }, + ["description"] = "ieee754: which format. Default double.", + }, + ["magnitude"] = Str("rectangular: magnitude."), + ["phase"] = Str("rectangular: phase in radians."), + }, "operation")), + + Tool("am_matrix", + "Exact symbolic linear algebra. Entries are expressions, so they may contain " + + "variables — the determinant of a matrix of symbols comes back as a formula, not " + + "a number. `tensor_product` builds multi-qubit operators from single-qubit ones, " + + "which is what makes quantum circuit algebra work: CNOT * (H (x) I) * |00> gives " + + "the Bell state as exactly 1/sqrt(2), not 0.7071.", + Schema(new JsonObject + { + ["operation"] = new JsonObject + { + ["type"] = "string", + ["enum"] = new JsonArray + { + "determinant", "inverse", "transpose", "rank", "rref", "trace", + "multiply", "add", "subtract", "tensor_product", "power", + }, + ["description"] = "What to compute.", + }, + ["matrix"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" }, + }, + ["description"] = "Rows of expression strings, e.g. [[\"1\",\"x\"],[\"0\",\"1\"]]. " + + "A column vector is [[\"1\"],[\"0\"]].", + }, + ["matrix_b"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" }, + }, + ["description"] = "Second operand, for multiply / add / subtract / tensor_product.", + }, + ["exponent"] = new JsonObject + { + ["type"] = "integer", + ["description"] = "For 'power'. Non-negative.", + }, + }, "operation", "matrix")), + + Tool("am_eigenvalues", + "Eigenvalues of a square matrix, exactly and symbolically — entries may contain " + + "variables, so a Hamiltonian [[0,J],[J,0]] returns its energy levels in terms of J. Computed " + + "from the characteristic polynomial det(A - lambda*I). Expect a decline above 4x4 " + + "with symbolic entries: quintics have no closed form (Abel-Ruffini), which is a " + + "fact about mathematics rather than a limitation of this server.", + Schema(new JsonObject + { + ["matrix"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject + { + ["type"] = "array", + ["items"] = new JsonObject { ["type"] = "string" }, + }, + ["description"] = "Square matrix as rows of expression strings.", + }, + }, "matrix")), + + Tool("am_compare_numeric", + "Measure how far apart two expressions are across an interval: worst absolute " + + "error, worst relative error, RMS, and WHERE the worst point is. Use this for " + + "'is this approximation good enough over the operating range' — a different " + + "question from am_verify_equal, which asks whether two forms are the same " + + "function. An approximation is never equal; the question is whether it is close " + + "enough, and where it degrades.", + Schema(new JsonObject + { + ["reference"] = Str("The exact expression, used as the baseline."), + ["approximation"] = Str("The expression being checked against it."), + ["variable"] = Str("Variable to sweep."), + ["from"] = new JsonObject + { + ["type"] = "number", + ["description"] = "Start of the interval.", + }, + ["to"] = new JsonObject + { + ["type"] = "number", + ["description"] = "End of the interval.", + }, + ["samples"] = new JsonObject + { + ["type"] = "integer", + ["description"] = "Evenly spaced sample count, 2-10000. Default 200.", + }, + }, "reference", "approximation", "variable", "from", "to")), + + Tool("am_substitute", + "Replace variables with expressions and return the result WITHOUT evaluating or " + + "simplifying it. Use this when you want to see the shape of a substitution — " + + "putting a model into a formula, specialising a general result — rather than a " + + "number. am_evaluate always simplifies; this does not.", + Schema(new JsonObject + { + ["expression"] = Str("Expression to substitute into."), + ["substitutions"] = new JsonObject + { + ["type"] = "object", + ["additionalProperties"] = new JsonObject { ["type"] = "string" }, + ["description"] = "Map of variable -> replacement expression, " + + "e.g. {\"x\": \"a + b\"}.", + }, + ["simplify"] = new JsonObject + { + ["type"] = "boolean", + ["description"] = "Simplify afterwards as well. Default false.", + }, + }, "expression", "substitutions")), + + Tool("am_expand", + "Expand brackets and powers into a sum of terms. The opposite of am_factor.", + Schema(new JsonObject + { + ["expression"] = Str("Expression to expand, e.g. '(x+1)^3'."), + }, "expression")), + + Tool("am_factor", + "Factorise an expression into a product. The opposite of am_expand. Multivariate " + + "factoring is limited, so a result identical to the input means no progress.", + Schema(new JsonObject + { + ["expression"] = Str("Expression to factorise, e.g. 'x^2 - 1'."), + }, "expression")), + + Tool("am_series", + "Taylor or Maclaurin series expansion to a given degree. Use it to linearise a " + + "model around an operating point, to justify a small-angle approximation, or to " + + "decide how many terms a fixed-point implementation actually needs.", + Schema(new JsonObject + { + ["expression"] = Str("Expression to expand, e.g. 'sin(x)'."), + ["variable"] = Str("Variable to expand in."), + ["degree"] = new JsonObject + { + ["type"] = "integer", + ["description"] = "Highest power to include. Keep it modest; cost grows quickly.", + }, + ["around"] = Str("Point to expand about. Omit for 0 (a Maclaurin series)."), + }, "expression", "variable", "degree")), + + Tool("am_number_theory", + "Integer facts: prime factorisation, Euler's totient, greatest common divisor, " + + "divisor count, primality. Exact for large integers.", + Schema(new JsonObject + { + ["operation"] = new JsonObject + { + ["type"] = "string", + ["enum"] = new JsonArray + { + "factorize", "totient", "gcd", "count_divisors", "is_prime", + }, + ["description"] = "Which fact to compute.", + }, + ["value"] = Str("The integer, as an expression, e.g. '5040'."), + ["value_b"] = Str("Second integer, for 'gcd'."), + }, "operation", "value")), + + Tool("am_classify", + "Guess which field of science or mathematics a formula comes from, by reading " + + "its symbols and structure. Inference from naming convention only — 'c' is the " + + "speed of light in relativity and a concentration in chemistry — so treat the " + + "result as a hint, and say it is a guess when you repeat it.", + Schema(new JsonObject + { + ["expression"] = Str("Formula to classify, e.g. 'G*M*m/r^2'."), + }, "expression")), + + Tool("am_to_sympy", + "Emit a runnable SymPy program for an expression. Use this to hand the problem to " + + "Python, or to cross-check a result against a second computer algebra system.", + Schema(new JsonObject { ["expression"] = Str("Expression to translate.") }, + "expression")), + ]; + + // ---------------------------------------------------------------- dispatch + + public static JsonObject Call(string name, JsonObject args) => name switch + { + "am_parse" => Parse(args), + "am_simplify" => Simplify(args), + "am_solve" => Solve(args), + "am_differentiate" => Differentiate(args), + "am_integrate" => Integrate(args), + "am_limit" => Limit(args), + "am_evaluate" => Evaluate(args), + "am_verify_equal" => VerifyEqual(args), + "am_truth_table" => TruthTable(args), + "am_solve_system" => SolveSystem(args), + "am_check_steps" => CheckSteps(args), + "am_domain_check" => DomainCheck(args), + "am_represent" => Represent(args), + "am_matrix" => MatrixOp(args), + "am_eigenvalues" => Eigenvalues(args), + "am_compare_numeric" => CompareNumeric(args), + "am_substitute" => Substitute(args), + "am_expand" => Rewrite(args, expand: true), + "am_factor" => Rewrite(args, expand: false), + "am_series" => Series(args), + "am_number_theory" => NumberTheory(args), + "am_classify" => ClassifyFormula(args), + "am_to_sympy" => ToSympy(args), + _ => Fail($"unknown tool '{name}'"), + }; + + // ---------------------------------------------------------------- helpers + + private static string? S(JsonObject o, string key) => + o.TryGetPropertyValue(key, out var v) ? v?.GetValue() : null; + + private static bool B(JsonObject o, string key, bool fallback = false) => + o.TryGetPropertyValue(key, out var v) && v is not null ? v.GetValue() : fallback; + + private static List A(JsonObject o, string key) + { + var list = new List(); + if (o.TryGetPropertyValue(key, out var v) && v is JsonArray arr) + foreach (var item in arr) + if (item is not null) list.Add(item.GetValue()); + return list; + } + + private static JsonObject Fail(string error) => + new() { ["status"] = "failed", ["error"] = error }; + + private static JsonArray Warn(List warnings) + { + var arr = new JsonArray(); + foreach (var w in warnings) arr.Add(w); + return arr; + } + + /// Parse one input, or produce the error object. + private static bool TryParse(string? src, string label, bool strict, + out Entity entity, out List warnings, out JsonObject? error) + { + entity = 0; warnings = []; error = null; + if (string.IsNullOrWhiteSpace(src)) + { + error = Fail($"'{label}' is required"); + return false; + } + + var outcome = Parsing.Parse(src, strict); + if (outcome.Entity is null) + { + error = Fail(outcome.Error ?? "parse failed"); + return false; + } + + entity = outcome.Entity; + warnings = outcome.Warnings; + return true; + } + + /// + /// Build the standard response. The order matters: decline detection runs on the RAW + /// stringized result before anything simplifies it, because an unevaluated limit(...) + /// simplifies to NaN and would otherwise be reported as a wrong answer. + /// + private static JsonObject Respond(Entity input, Entity result, List warnings) + { + var raw = result.Stringize(); + var status = + Guard.IsDeclined(raw) ? "declined" + : raw == input.Stringize() ? "unchanged" + : Guard.LooksLikeNaN(raw) ? "suspect" + : "solved"; + + var response = new JsonObject + { + ["status"] = status, + ["parsed"] = input.Stringize(), + ["result"] = raw, + ["latex"] = result.Latexize(), + }; + + if (status == "declined") + response["note"] = "AngouriMath returned the expression unevaluated: it has no " + + "rule for this input. This is a decline, not a wrong answer — do not present " + + "the unevaluated form as a result."; + if (status == "unchanged") + response["note"] = "The result is identical to the input. The library could not " + + "reduce it further; treat this as 'no progress', not as a simplest form."; + if (status == "suspect") + response["note"] = "The result contains NaN, which is almost never a legitimate " + + "answer here. Treat it as a library failure."; + + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + } + + private static JsonObject FromOutcome(Guard.Outcome outcome, Func ok) => + outcome.Status switch + { + "ok" => ok(outcome.Value!), + "timeout" => new JsonObject + { + ["status"] = "timeout", + ["error"] = outcome.Error, + ["note"] = "The call exceeded its budget and was abandoned. Try a simpler " + + "form, or accept that this input is out of reach.", + }, + // A NotSufficientlySupportedException, which the library raises where it knows + // it has no rule. Reported with the same word as an unevaluated integral because + // it means the same thing to the caller. See Guard.StatusFor. + "declined" => new JsonObject + { + ["status"] = "declined", + ["error"] = outcome.Error, + ["note"] = "AngouriMath declined this input: it says explicitly that it has " + + "no rule covering this case. That is a limit of the library, not " + + "a wrong answer and not a bug worth reporting — do not retry the " + + "same call, and do not present the message as a result.", + }, + _ => Fail(outcome.Error ?? "unknown failure"), + }; + + private static Variable Var(string name) => MathS.Var(name); + + // Numeric comparison, `provided`-stripping and the sample points all live in Numeric.cs. + + // ---------------------------------------------------------------- tools + + private static JsonObject Parse(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", B(args, "strict"), + out var e, out var warnings, out var error)) + return error!; + + var vars = new JsonArray(); + foreach (var v in e.Vars) vars.Add(v.Stringize()); + + var response = new JsonObject + { + ["status"] = "solved", + ["parsed"] = e.Stringize(), + ["result"] = e.Stringize(), + ["latex"] = e.Latexize(), + ["free_variables"] = vars, + ["node_count"] = e.Nodes.Count(), + }; + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + } + + private static JsonObject Simplify(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + + var wantAlternatives = B(args, "alternatives"); + + return FromOutcome(Guard.Run(() => + { + var simplified = e.Simplify(); + var alternates = new List(); + if (wantAlternatives) + foreach (var alt in e.Alternate(4).Take(5)) + { + var s = alt.Stringize(); + if (s != simplified.Stringize() && !alternates.Contains(s)) + alternates.Add(s); + } + return (simplified, alternates); + }), r => + { + var response = Respond(e, r.simplified, warnings); + if (r.alternates.Count > 0) + { + var arr = new JsonArray(); + foreach (var a in r.alternates) arr.Add(a); + response["alternatives"] = arr; + response["alternatives_note"] = + "Ranked by the library's complexity metric. The first result is not " + + "always the tidiest for a human — pick whichever form suits."; + } + return response; + }); + } + + private static JsonObject Solve(JsonObject args) + { + var constraints = A(args, "constraints"); + var variable = S(args, "variable"); + if (constraints.Count == 0) return Fail("'constraints' must contain at least one statement"); + if (string.IsNullOrWhiteSpace(variable)) return Fail("'variable' is required"); + + // Combining with 'and' turns several constraints into a single boolean statement, + // which Solve handles natively (it accepts statements and inequalities, not just + // equations). Borrowed from the SharpCells Excel example. + var combined = string.Join(" and ", constraints); + + 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 => + { + var response = Respond(e, set, warnings); + if (set is Set.FiniteSet finite) + { + var roots = new JsonArray(); + foreach (var root in finite) + { + // Solve leaves roots in raw form: solving f = 1/(2*pi*R*C) for R yields + // "--1/2 * 1/pi * 1/C/f". Correct, but a double negation is exactly the + // 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(); } + catch { tidy = root; } + roots.Add(tidy.Stringize()); + } + response["solutions"] = roots; + response["solution_count"] = finite.Count; + } + else + { + response["note"] = "The solution set is not finite (an interval, a " + + "conditional set, or a periodic family). Read 'result' directly."; + } + return response; + }); + } + + private static JsonObject Differentiate(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + var variable = S(args, "variable"); + if (string.IsNullOrWhiteSpace(variable)) return Fail("'variable' is required"); + + var order = args.TryGetPropertyValue("order", out var o) && o is not null + ? o.GetValue() : 1; + if (order < 1) return Fail("'order' must be at least 1"); + + return FromOutcome( + Guard.Run(() => e.Differentiate(Var(variable!), order).Simplify()), + d => Respond(e, d, warnings)); + } + + private static JsonObject Integrate(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + var variable = S(args, "variable"); + if (string.IsNullOrWhiteSpace(variable)) return Fail("'variable' is required"); + + var fromText = S(args, "from"); + var toText = S(args, "to"); + if (string.IsNullOrWhiteSpace(fromText) != string.IsNullOrWhiteSpace(toText)) + return Fail("give both 'from' and 'to', or neither"); + + Entity? lower = null, upper = null; + if (!string.IsNullOrWhiteSpace(fromText)) + { + if (!TryParse(fromText, "from", false, out var l, out _, out var lowerError)) + return lowerError!; + if (!TryParse(toText, "to", false, out var u, out _, out var upperError)) + return upperError!; + lower = l; + upper = u; + } + + return FromOutcome(Guard.Run(() => + { + var v = Var(variable!); + + // The numeric route, used only where the symbolic one produced nothing usable. + // It is never a substitute for an antiderivative: it answers one interval, to a + // handful of digits, and it cannot be verified by differentiating back. + Numeric.Quadrature? Quadrature() + { + if (lower is null || upper is null) return null; + if (Numeric.AsRealNumber(lower) is not { } a) return null; + if (Numeric.AsRealNumber(upper) is not { } b) return null; + if (Numeric.Strip(e).Vars.Any(other => other.Name != v.Name)) return null; + return Numeric.TryDefiniteIntegral(e, v, a, b); + } + + var antiderivative = e.Integrate(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(); + + // Verify by differentiating back, which is the whole reason to trust the answer. + bool? verified; + try + { + verified = Numeric.Equal(tidy.Differentiate(v), e); + } + catch + { + verified = null; + } + + // The definite value is evaluated from the SAME antiderivative that was just + // verified, rather than asked for separately — so the verification covers it. + Entity? definite = null; + if (lower is not null && upper is not null) + { + var bare = Numeric.Strip(tidy); + definite = (bare.Substitute(v, upper) - bare.Substitute(v, lower)).Simplify(); + } + + // Only where the exact route came back with nothing to show. A NaN definite + // value is the common case: the antiderivative exists but is undefined at a + // bound, and a number the caller can use beats `NaN` with a caveat attached. + var quadrature = definite is null || Guard.LooksLikeNaN(definite.Stringize()) + ? Quadrature() + : null; + + return (tidy, verified, definite, quadrature); + }), r => + { + var response = Respond(e, r.Item1, warnings); + response["verified"] = r.Item2; + response["verification_method"] = + "differentiated the answer and compared it numerically to the integrand at " + + "five positive real points"; + + if (r.Item2 == false) + { + response["status"] = "suspect"; + response["note"] = "The answer does NOT differentiate back to the integrand. " + + "Do not use it."; + } + else if (r.Item2 is null && (string?)response["status"] == "declined") + { + response["note"] = "No antiderivative was found: the library has no rule " + + "for this integrand. Some, such as x^x, have no closed form in any " + + "function the library knows, so this can be the correct outcome."; + } + + if (r.Item3 is { } definite) + { + response["definite_value"] = definite.Stringize(); + response["definite_latex"] = definite.Latexize(); + 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 — " + + "it will happily return a finite, wrong number for something like " + + "1/x^2 across zero. Check for a singularity between the limits yourself, " + + "or ask am_domain_check."; + } + + if (r.Item4 is { } q) + { + // Rounded to the digits two step counts agree on. Printing the library's + // full arbitrary-precision output here would be a hundred digits of a + // number correct to four, which is the failure this server exists to + // prevent — the caller cannot see the step count and would read it as exact. + response["numeric_definite_value"] = Math.Round(q.Value, q.Digits) + .ToString("G" + Math.Max(q.Digits + 1, 2), + System.Globalization.CultureInfo.InvariantCulture); + response["numeric_definite_error_bound"] = q.ErrorEstimate + .ToString("G3", System.Globalization.CultureInfo.InvariantCulture); + response["numeric_definite_caveat"] = + "APPROXIMATE, and produced only because the exact route gave nothing " + + "usable. It is quadrature over 4000 steps, cross-checked against 2000, " + + "and reported to the digits the two agree on — every digit shown is " + + "believed, and there are no more to be had from this call. Unlike the " + + "symbolic answer it is NOT verified by differentiating back. It also " + + "assumes the integrand is finite across the closed interval: the rule " + + "samples both endpoints, so a convergent integral with a singular " + + "endpoint returns nothing here rather than a value."; + + if ((string?)response["status"] == "declined") + response["note"] = (string?)response["note"] + + " A numeric value is given in 'numeric_definite_value' instead; it " + + "answers this one interval and is not an antiderivative."; + } + + return response; + }); + } + + private static JsonObject Limit(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + var variable = S(args, "variable"); + var to = S(args, "to"); + if (string.IsNullOrWhiteSpace(variable)) return Fail("'variable' is required"); + if (string.IsNullOrWhiteSpace(to)) return Fail("'to' is required"); + + if (!TryParse(to, "to", false, out var destination, out _, out var destError)) + return destError!; + + var side = (S(args, "side") ?? "both").ToLowerInvariant() switch + { + "left" => ApproachFrom.Left, + "right" => ApproachFrom.Right, + _ => ApproachFrom.BothSides, + }; + + return FromOutcome(Guard.Run(() => + { + var raw = e.Limit(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(); + }), r => + { + var response = Respond(e, r, warnings); + if ((string?)response["status"] == "suspect") + { + // NaN from a genuinely evaluated limit means "does not exist", which is a + // real answer rather than a library failure. + response["status"] = "no_limit"; + response["note"] = "The limit evaluated to NaN, which here means the limit " + + "does not exist (e.g. 1/x as x -> 0 from both sides)."; + } + return response; + }); + } + + private static JsonObject Evaluate(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + + var substitutions = new List<(string Name, Entity Value)>(); + if (args.TryGetPropertyValue("substitutions", out var subsNode) && subsNode is JsonObject subs) + { + foreach (var (key, value) in subs) + { + if (value is null) continue; + if (!TryParse(value.GetValue(), $"substitutions.{key}", false, + out var replacement, out _, out var subError)) + return subError!; + substitutions.Add((key, replacement)); + } + } + + var digits = args.TryGetPropertyValue("digits", out var d) && d is not null + ? d.GetValue() : 0; + if (digits is < 0 or > 1000) return Fail("'digits' must be between 1 and 1000"); + + var input = e; + return FromOutcome(Guard.Run(() => + { + // Widen the library's working precision for this call only. Without it the + // answer is computed at the default context and no amount of formatting can + // recover digits that were never computed. + var context = digits > 0 + ? new PeterO.Numbers.EContext(digits, PeterO.Numbers.ERounding.HalfEven, + -int.MaxValue, int.MaxValue, false) + : MathS.Settings.DecimalPrecisionContext.Value; + using var precision = MathS.Settings.DecimalPrecisionContext.Set(context); + + // 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; + foreach (var (name, value) in substitutions) + resolved = resolved.Substitute(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(); + + string? approx = null; + if (exact.EvaluableNumerical) + { + var complex = exact.EvalNumerical(); + approx = !complex.ImaginaryPart.EDecimal.IsZero + ? complex.Stringize() + : digits > 0 + // Full computed precision, not a double — the whole point of asking. + ? complex.RealPart.EDecimal.ToString() + : complex.RealPart.EDecimal.ToDouble().ToString("R"); + } + return (exact, approx); + }), r => + { + var response = Respond(input, r.exact, warnings); + if (r.approx is not null) response["approximate"] = r.approx; + else response["note"] = "The expression is not fully numeric — it still contains " + + "free variables. Supply 'substitutions' to get a number."; + return response; + }); + } + + private static JsonObject VerifyEqual(JsonObject args) + { + if (!TryParse(S(args, "left"), "left", false, out var left, out var lw, out var le)) + return le!; + if (!TryParse(S(args, "right"), "right", false, out var right, out var rw, out var re)) + return re!; + + return FromOutcome(Guard.Run(() => + { + // 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 exactlyZero = Numeric.IsZero(difference); + + bool? numerically = null; + bool? wholeLine = null; + + // Trust, then verify the verifier. The exact path believes Simplify, and Simplify + // can be unsound: it reduces sqrt(x^2) to x, so `sqrt(x^2) = x` comes back + // "exact: difference simplified to 0" — while the library's own evaluator gives + // sqrt(x^2) = 2 at x = -2. Sampling the ORIGINAL sides across the real line + // catches a bad simplification, because it never goes through one. + bool? conflict = null; + if (exactlyZero) + { + try + { + var direct = Numeric.Equal(left, right, + ["-3.1", "-1.7", "-0.4", "0.8", "2.6"]); + if (direct == false) conflict = true; + } + catch { /* no counter-evidence available */ } + } + + if (!exactlyZero) + { + // Compare the DIFFERENCE against zero rather than the two sides against + // each other. Simplify has already collapsed any unevaluated derivative(…) + // or integral(…) node in the difference, whereas the raw sides may still + // contain one — and an unevaluated node is not numerically evaluable, so + // comparing the sides directly returns "inconclusive" for inputs that are + // in fact decidable. + try { numerically = Numeric.Equal(difference, 0); } + catch { numerically = null; } + + // The positive-real points above cannot see a disagreement that only occurs + // on the negatives (abs(x) vs x, say). A second pass across the whole real + // line catches that. + if (numerically == true) + { + try + { + wholeLine = Numeric.Equal(difference, 0, + ["-3.1", "-1.7", "-0.4", "0.8", "2.6"]); + } + catch { wholeLine = null; } + } + } + + return (difference, exactlyZero, numerically, wholeLine, conflict); + }), r => + { + var equal = r.exactlyZero || r.numerically == true; + // Inconclusive is reported as null, never as false: "we could not check" is not + // the same claim as "they differ", and a caller acting on a bare false would be + // acting on something the server never established. + var conclusive = r.exactlyZero || r.numerically is not null; + + var response = new JsonObject + { + ["status"] = conclusive ? "solved" : "inconclusive", + ["equal"] = conclusive ? equal : null, + ["method"] = r.exactlyZero + ? "exact: left - right simplified to 0" + : r.numerically is null + ? "inconclusive: neither side could be evaluated at any sample point" + : "numeric: sampled at five positive real points, relative tolerance 1e-6", + ["difference"] = r.difference.Stringize(), + ["left_parsed"] = left.Stringize(), + ["right_parsed"] = right.Stringize(), + ["note"] = equal + ? "The two expressions agree." + : r.numerically is null + ? "Could not establish equality either way. Not proof of inequality." + : "The expressions differ. Check 'difference' to see by how much.", + ["warnings"] = Warn([.. lw, .. rw]), + }; + + if (r.conflict == true) + { + response["status"] = "conflict"; + response["equal"] = false; + response["conflict"] = true; + response["note"] = + "Symbolic simplification says these are equal, but evaluating the two " + + "sides directly disagrees at sampled points on the real line. The " + + "direct evaluation is the more trustworthy of the two, since it does " + + "not pass through a rewrite. Treat them as NOT equal, and treat the " + + "simplification as suspect — sqrt(x^2) reducing to x rather than abs(x) " + + "is a known instance."; + } + else if (equal && r.wholeLine == false) + { + response["equal_only_on_positive_reals"] = true; + response["note"] = + "The expressions agree for positive real inputs but disagree elsewhere " + + "on the real line — the usual cause is a logarithm, square root or " + + "absolute value. Treat them as equal only if your domain is restricted " + + "to the positives."; + } + + return response; + }); + } + + private static JsonObject TruthTable(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + + var names = A(args, "variables"); + var vars = names.Count > 0 + ? names.Select(Var).ToArray() + : e.Vars.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); + return (table, satisfying); + }), r => + { + var order = new JsonArray(); + foreach (var v in vars) order.Add(v.Stringize()); + + var response = new JsonObject + { + ["status"] = r.table is null ? "failed" : "solved", + ["parsed"] = e.Stringize(), + ["variable_order"] = order, + ["truth_table"] = r.table?.ToString(multilineFormat: true), + ["satisfiable"] = r.satisfying is not null, + ["satisfying_assignments"] = r.satisfying?.ToString(multilineFormat: true), + }; + if (r.satisfying is null) + response["note"] = "No assignment satisfies this expression — it is a contradiction."; + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + }); + } + + private static JsonObject SolveSystem(JsonObject args) + { + var equations = A(args, "equations"); + var variables = A(args, "variables"); + if (equations.Count == 0) return Fail("'equations' must not be empty"); + if (variables.Count == 0) return Fail("'variables' must not be empty"); + + var parsed = new List(); + var warnings = new List(); + foreach (var eq in equations) + { + if (!TryParse(eq, "equations", false, out var e, out var w, out var error)) + return error!; + + // EquationSystem wants each equation in "= 0" form, and handed an equality it + // finds no solution, so normalise here: writing 'x + y = 3' is the natural thing + // for a caller to do, and it should just work. + // https://github.com/asc-community/AngouriMath/issues/1673 + parsed.Add(e is Equalsf equality ? equality.Left - equality.Right : e); + warnings.AddRange(w); + } + + return FromOutcome(Guard.Run(() => + MathS.Equations([.. parsed]).Solve([.. variables.Select(Var)])), matrix => + { + var order = new JsonArray(); + foreach (var v in variables) order.Add(v); + + var response = new JsonObject + { + ["status"] = matrix is null ? "declined" : "solved", + ["variable_order"] = order, + ["solutions"] = matrix?.ToString(multilineFormat: true), + ["solution_count"] = matrix?.RowCount, + }; + + if (matrix is null) + response["note"] = "No solution was returned. A nonlinear system can come " + + "back with nothing even when it has a solution, so absence here is not " + + "proof that the system is unsolvable."; + + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + }); + } + + private static JsonObject CheckSteps(JsonObject args) + { + var raw = A(args, "steps"); + if (raw.Count < 2) return Fail("'steps' needs at least two entries to compare"); + + var steps = new List(); + var warnings = new List(); + foreach (var step in raw) + { + if (!TryParse(step, "steps", false, out var e, out var w, out var error)) + return error!; + steps.Add(e); + warnings.AddRange(w); + } + + return FromOutcome(Guard.Run(() => Analysis.CheckSteps(steps)), checks => + { + var rows = new JsonArray(); + int? firstBad = null; + + foreach (var check in checks) + { + rows.Add(new JsonObject + { + ["step"] = check.Index, + ["from"] = check.From, + ["to"] = check.To, + ["equal"] = check.Equal, + ["method"] = check.Method, + ["difference"] = check.Difference, + }); + if (firstBad is null && check.Equal == false) firstBad = check.Index; + } + + var response = new JsonObject + { + ["status"] = "solved", + ["all_steps_valid"] = firstBad is null, + ["first_invalid_step"] = firstBad, + ["steps"] = rows, + ["note"] = firstBad is null + ? "Every step preserves the value. The working is sound — note this " + + "checks equality, not whether the steps are the clearest route." + : $"Step {firstBad} changes the value. Everything before it is sound, " + + "so the error is introduced exactly there.", + }; + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + }); + } + + private static JsonObject DomainCheck(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + + return FromOutcome(Guard.Run(() => + { + Entity simplified; + try { simplified = e.Simplify(); } + catch { simplified = e; } + + return (simplified, + Analysis.Conditions(simplified), + Analysis.Hazards(e), + Analysis.Reality(e)); + }), r => + { + var conditions = new JsonArray(); + foreach (var c in r.Item2) conditions.Add(c); + + var hazards = new JsonArray(); + foreach (var h in r.Item3) + hazards.Add(new JsonObject { ["in"] = h.Where, ["risk"] = h.Risk }); + + var complexAt = new JsonArray(); + foreach (var p in r.Item4.ComplexAt) complexAt.Add(p); + var undefinedAt = new JsonArray(); + foreach (var p in r.Item4.UndefinedAt) undefinedAt.Add(p); + + var response = new JsonObject + { + ["status"] = "solved", + ["parsed"] = e.Stringize(), + ["simplified"] = r.simplified.Stringize(), + ["conditions"] = conditions, + ["hazards"] = hazards, + ["not_real_at"] = complexAt, + ["undefined_at"] = undefinedAt, + ["points_sampled"] = r.Item4.Sampled, + }; + + if (r.Item2.Count > 0) + response["conditions_note"] = "AngouriMath derived these guards itself. They " + + "are part of the answer — dropping them makes the result wrong on the " + + "excluded points."; + + if (r.Item4.ComplexAt.Count > 0 || r.Item4.UndefinedAt.Count > 0) + response["reality_note"] = "The expression leaves the reals at some sampled " + + "points. If it stands for a physical quantity, constrain the input " + + "range before using it — pass the constraint to am_solve alongside the " + + "equation."; + + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + }); + } + + private static JsonObject Represent(JsonObject args) + { + var operation = S(args, "operation"); + if (string.IsNullOrWhiteSpace(operation)) return Fail("'operation' is required"); + + return operation switch + { + "base" => RepresentBase(args), + "fixed_point" => RepresentFixed(args), + "from_fixed_point" => RepresentFromFixed(args), + "ieee754" => RepresentIeee(args), + "polar" => RepresentPolar(args), + "rectangular" => RepresentRectangular(args), + _ => Fail($"unknown operation '{operation}'"), + }; + } + + private static JsonObject RepresentBase(JsonObject args) + { + var value = S(args, "value"); + if (string.IsNullOrWhiteSpace(value)) return Fail("'value' is required"); + + var fromBase = args.TryGetPropertyValue("from_base", out var f) && f is not null + ? f.GetValue() : 10; + if (!args.TryGetPropertyValue("to_base", out var t) || t is null) + return Fail("'to_base' is required for operation 'base'"); + var toBase = t.GetValue(); + + if (fromBase is < 2 or > 36) return Fail("'from_base' must be between 2 and 36"); + if (toBase is < 2 or > 36) return Fail("'to_base' must be between 2 and 36"); + + return FromOutcome(Guard.Run(() => + { + var asDecimal = fromBase == 10 + ? value!.ToEntity() + : MathS.FromBaseN(value!, fromBase); + + var real = (Number.Real)asDecimal.Evaled; + var converted = toBase == 10 ? real.Stringize() : MathS.ToBaseN(real, toBase); + return (real, converted); + }), r => new JsonObject + { + ["status"] = "solved", + ["operation"] = "base", + ["input"] = value, + ["from_base"] = fromBase, + ["to_base"] = toBase, + ["decimal"] = r.real.Stringize(), + ["result"] = r.converted, + }); + } + + /// Evaluate an expression to an exact decimal, or explain why not. + private static bool TryDecimal(JsonObject args, string key, + out PeterO.Numbers.EDecimal value, out JsonObject? error) + { + value = PeterO.Numbers.EDecimal.Zero; + if (!TryParse(S(args, key), key, false, out var e, out _, out error)) return false; + + if (!e.EvaluableNumerical) + { + error = Fail($"'{key}' must evaluate to a number; got '{e.Stringize()}'"); + return false; + } + + var complex = e.EvalNumerical(); + if (!complex.ImaginaryPart.EDecimal.IsZero) + { + error = Fail($"'{key}' must be real; got '{complex.Stringize()}'"); + return false; + } + + value = complex.RealPart.EDecimal; + return true; + } + + private static JsonObject RepresentFixed(JsonObject args) + { + if (!TryDecimal(args, "value", out var value, out var error)) return error!; + + var totalBits = args.TryGetPropertyValue("total_bits", out var tb) && tb is not null + ? tb.GetValue() : 16; + if (!args.TryGetPropertyValue("fraction_bits", out var fb) || fb is null) + return Fail("'fraction_bits' is required for 'fixed_point'"); + var fractionBits = fb.GetValue(); + var signed = B(args, "signed", true); + + if (totalBits is < 2 or > 128) return Fail("'total_bits' must be between 2 and 128"); + if (fractionBits < 0 || fractionBits > totalBits) + return Fail("'fraction_bits' must be between 0 and 'total_bits'"); + + return FromOutcome( + Guard.Run(() => Representations.ToFixed(value, totalBits, fractionBits, signed)), + fixedPoint => + { + var response = new JsonObject + { + ["status"] = fixedPoint.Saturated ? "suspect" : "solved", + ["operation"] = "fixed_point", + ["format"] = $"Q{totalBits - fractionBits - (signed ? 1 : 0)}.{fractionBits}" + + $" in {totalBits} bits, {(signed ? "signed" : "unsigned")}", + ["raw"] = fixedPoint.Raw.ToString(), + ["raw_hex"] = "0x" + fixedPoint.Raw.Abs().ToRadixString(16).ToUpperInvariant(), + ["represented_value"] = fixedPoint.Represented.Stringize(), + ["absolute_error"] = fixedPoint.Error.ToString(), + ["relative_error"] = fixedPoint.RelativeError.ToString(), + ["resolution"] = fixedPoint.Resolution.ToString(), + ["representable_range"] = + $"[{fixedPoint.Min}, {fixedPoint.Max}] raw", + ["saturated"] = fixedPoint.Saturated, + }; + + if (fixedPoint.Saturated) + response["note"] = "The value does not fit and was clamped to the limit. " + + "On most processors this is a design-time bug rather than something " + + "to accept — widen the word, or move the binary point."; + else + response["note"] = "'represented_value' is exact: an integer over a power " + + "of two is a rational, so there is no second rounding. Use " + + "am_compare_numeric to see what this quantisation costs across your " + + "operating range rather than at a single point."; + + return response; + }); + } + + private static JsonObject RepresentFromFixed(JsonObject args) + { + if (!TryDecimal(args, "value", out var value, out var error)) return error!; + if (!args.TryGetPropertyValue("fraction_bits", out var fb) || fb is null) + return Fail("'fraction_bits' is required for 'from_fixed_point'"); + var fractionBits = fb.GetValue(); + if (fractionBits is < 0 or > 128) return Fail("'fraction_bits' must be between 0 and 128"); + + var raw = value.ToEInteger(); + + return FromOutcome( + Guard.Run(() => Representations.FromFixed(raw, fractionBits)), + v => new JsonObject + { + ["status"] = "solved", + ["operation"] = "from_fixed_point", + ["raw"] = raw.ToString(), + ["fraction_bits"] = fractionBits, + ["result"] = v.Stringize(), + ["approximate"] = v.EvaluableNumerical + ? v.EvalNumerical().RealPart.EDecimal.ToDouble().ToString("R") + : null, + }); + } + + private static JsonObject RepresentIeee(JsonObject args) + { + if (!TryDecimal(args, "value", out var value, out var error)) return error!; + var single = (S(args, "precision") ?? "double") == "single"; + + return FromOutcome( + Guard.Run(() => Representations.Decompose(value, single)), + ieee => new JsonObject + { + ["status"] = "solved", + ["operation"] = "ieee754", + ["precision"] = single ? "single (32-bit)" : "double (64-bit)", + ["bits"] = ieee.Bits, + ["hex"] = ieee.Hex, + ["sign"] = ieee.Sign, + ["exponent_raw"] = ieee.RawExponent, + ["exponent_unbiased"] = ieee.UnbiasedExponent, + ["mantissa_hex"] = ieee.MantissaHex, + ["exact_value"] = ieee.ExactValue, + ["error_vs_input"] = ieee.Error.ToString(), + ["classification"] = ieee.Classification, + ["note"] = "'exact_value' is what the format actually stores, not a rounded " + + "display of it. That difference is the whole reason 0.1 + 0.2 does not " + + "equal 0.3.", + }); + } + + private static JsonObject RepresentPolar(JsonObject args) + { + if (!TryParse(S(args, "value"), "value", false, + out var e, out var warnings, out var error)) + return error!; + + return FromOutcome(Guard.Run(() => + { + var evaluated = e.Evaled; + if (evaluated is not Number.Complex complex) + throw new ArgumentException($"'{e.Stringize()}' is not a number"); + + return Representations.ToPolar(complex.RealPart, complex.ImaginaryPart); + }), polar => + { + var response = new JsonObject + { + ["status"] = "solved", + ["operation"] = "polar", + ["magnitude"] = polar.Magnitude.Stringize(), + ["phase_radians"] = polar.Phase.Stringize(), + ["phase_degrees"] = polar.PhaseDegrees.Stringize(), + ["note"] = "Phase is quadrant-corrected — arctan alone would report the same " + + "angle for 1+i and -1-i.", + }; + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + }); + } + + private static JsonObject RepresentRectangular(JsonObject args) + { + if (!TryParse(S(args, "magnitude"), "magnitude", false, + out var magnitude, out var warnings, out var magnitudeError)) + return magnitudeError!; + if (!TryParse(S(args, "phase"), "phase", false, + out var phase, out var more, out var phaseError)) + return phaseError!; + warnings.AddRange(more); + + return FromOutcome( + Guard.Run(() => Representations.FromPolar(magnitude, phase)), + rectangular => + { + var response = new JsonObject + { + ["status"] = "solved", + ["operation"] = "rectangular", + ["result"] = rectangular.Stringize(), + ["latex"] = rectangular.Latexize(), + }; + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + }); + } + + private static JsonObject MatrixOp(JsonObject args) + { + var operation = S(args, "operation"); + if (string.IsNullOrWhiteSpace(operation)) return Fail("'operation' is required"); + + args.TryGetPropertyValue("matrix", out var aNode); + var a = Matrices.Parse(aNode, "matrix"); + if (a.Value is null) return Fail(a.Error!); + + var needsSecond = operation is "multiply" or "add" or "subtract" or "tensor_product"; + Matrix? b = null; + var warnings = new List(a.Warnings); + + if (needsSecond) + { + args.TryGetPropertyValue("matrix_b", out var bNode); + var parsedB = Matrices.Parse(bNode, "matrix_b"); + if (parsedB.Value is null) + return Fail(parsedB.Error ?? $"'{operation}' needs 'matrix_b'"); + b = parsedB.Value; + warnings.AddRange(parsedB.Warnings); + } + + var exponent = args.TryGetPropertyValue("exponent", out var e) && e is not null + ? e.GetValue() : -1; + if (operation == "power" && exponent < 0) + return Fail("'power' needs a non-negative 'exponent'"); + + var guards = new List(); + + 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(), + "multiply" => Simplified(a.Value * b!), + "add" => Simplified(a.Value + b!), + "subtract" => Simplified(a.Value - b!), + "tensor_product" => Simplified(Matrix.TensorProduct(a.Value, b!)), + "power" => Simplified(a.Value.Pow(exponent)), + _ => throw new ArgumentException($"unknown operation '{operation}'"), + }), value => + { + var response = new JsonObject + { + ["status"] = value is null ? "declined" : "solved", + ["operation"] = operation, + ["shape"] = $"{a.Value.RowCount}x{a.Value.ColumnCount}", + }; + + switch (value) + { + case Matrix m: + response["result"] = Matrices.Render(m); + response["shape_out"] = $"{m.RowCount}x{m.ColumnCount}"; + response["pretty"] = m.ToString(multilineFormat: true); + response["latex"] = m.Latexize(); + break; + case Entity scalar: + response["result"] = scalar.Stringize(); + response["latex"] = scalar.Latexize(); + break; + case int rank: + response["result"] = rank; + break; + case null: + response["note"] = operation switch + { + "determinant" or "trace" => "The matrix is not square.", + "inverse" => "No inverse: the matrix is singular or not square.", + _ => "The operation produced no result.", + }; + break; + } + + if (guards.Count > 0) + { + var dropped = new JsonArray(); + foreach (var g in guards.Distinct()) dropped.Add(g); + response["dropped_guards"] = dropped; + response["dropped_guards_note"] = Matrices.GuardNote; + if (operation == "inverse") + response["inverse_condition"] = + "The inverse exists exactly when the determinant is non-zero; that " + + "is the real condition, not the pivot guards listed above."; + } + + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + }); + + static Entity CleanScalar(Entity e, List sink) + { + var (value, found) = Matrices.Clean(e); + sink.AddRange(found); + return value; + } + + static Matrix CleanGrid(Matrix m, List sink) + { + var (value, found) = Matrices.Clean(m); + sink.AddRange(found); + return value; + } + + static Matrix Simplified(Matrix m) + { + // Products and tensor products come back littered with `1/sqrt(2) * 1` and the + // like — correct, unreadable, and awkward to compare. Tidy each entry, but never + // at the cost of losing one. + var grid = new Entity[m.RowCount, m.ColumnCount]; + for (var i = 0; i < m.RowCount; i++) + for (var j = 0; j < m.ColumnCount; j++) + { + try { grid[i, j] = m[i, j].Simplify(); } + catch { grid[i, j] = m[i, j]; } + } + return MathS.Matrix(grid); + } + } + + private static JsonObject Eigenvalues(JsonObject args) + { + args.TryGetPropertyValue("matrix", out var node); + var parsed = Matrices.Parse(node, "matrix"); + if (parsed.Value is null) return Fail(parsed.Error!); + + var a = parsed.Value; + if (a.RowCount != a.ColumnCount) + return Fail($"eigenvalues need a square matrix; this one is {a.RowCount}x{a.ColumnCount}"); + + return FromOutcome(Guard.Run(() => Matrices.Compute(a, Var("lambda"))), eigen => + { + var raw = eigen.Roots?.Stringize(); + var declined = eigen.Roots is null + || raw is null + || Guard.IsDeclined(raw) + || raw.Contains("solve(", StringComparison.Ordinal); + + var response = new JsonObject + { + ["status"] = declined ? "declined" : "solved", + ["shape"] = $"{a.RowCount}x{a.ColumnCount}", + ["characteristic_polynomial"] = eigen.CharacteristicPolynomial.Stringize(), + ["characteristic_polynomial_latex"] = eigen.CharacteristicPolynomial.Latexize(), + ["eigenvalues"] = raw, + }; + + if (eigen.Roots is Set.FiniteSet finite) + { + var values = new JsonArray(); + foreach (var root in finite) values.Add(root.Stringize()); + response["eigenvalue_list"] = values; + response["count"] = finite.Count; + response["note"] = "Repeated roots appear once — this is the set of distinct " + + "eigenvalues, not a list with algebraic multiplicity."; + } + + if (declined) + response["note"] = "The characteristic polynomial could not be solved in " + + "closed form. Above degree 4 with symbolic entries that is expected and " + + "not a defect: no general radical solution exists (Abel-Ruffini). The " + + "polynomial itself is returned and is still useful."; + + if (eigen.DroppedGuards.Count > 0) + { + var dropped = new JsonArray(); + foreach (var g in eigen.DroppedGuards) dropped.Add(g); + response["dropped_guards"] = dropped; + response["dropped_guards_note"] = + "The determinant routine divides by pivots and leaves a `provided` guard " + + "for each. On a symbolic matrix those are artefacts of the algorithm, not " + + "mathematics — [[0,J],[J,0]] would otherwise report its eigenvalues as " + + "'J provided not J = 0', excluding a perfectly valid case. They are " + + "listed here rather than silently discarded."; + } + + if (parsed.Warnings.Count > 0) response["warnings"] = Warn(parsed.Warnings); + return response; + }); + } + + private static JsonObject CompareNumeric(JsonObject args) + { + if (!TryParse(S(args, "reference"), "reference", false, + out var reference, out var warnings, out var referenceError)) + return referenceError!; + if (!TryParse(S(args, "approximation"), "approximation", false, + out var approximation, out var moreWarnings, out var approximationError)) + return approximationError!; + warnings.AddRange(moreWarnings); + + var variable = S(args, "variable"); + if (string.IsNullOrWhiteSpace(variable)) return Fail("'variable' is required"); + + if (!args.TryGetPropertyValue("from", out var f) || f is null) return Fail("'from' is required"); + if (!args.TryGetPropertyValue("to", out var t) || t is null) return Fail("'to' is required"); + var from = f.GetValue(); + var to = t.GetValue(); + if (double.IsNaN(from) || double.IsNaN(to)) return Fail("'from' and 'to' must be numbers"); + if (to < from) return Fail("'to' must not be less than 'from'"); + + var samples = args.TryGetPropertyValue("samples", out var s) && s is not null + ? s.GetValue() : 200; + if (samples is < 2 or > 10000) return Fail("'samples' must be between 2 and 10000"); + + return FromOutcome( + Guard.Run(() => Numeric.Diverge(reference, approximation, Var(variable!), + from, to, samples)), + divergence => + { + if (divergence is null) + return new JsonObject + { + ["status"] = "declined", + ["note"] = "Neither expression could be evaluated at any sample " + + "point in that interval. Check for free variables other than " + + $"'{variable}', or an interval where both are undefined.", + }; + + var response = new JsonObject + { + ["status"] = "solved", + ["interval"] = $"[{from}, {to}]", + ["samples_used"] = divergence.Sampled, + ["samples_skipped"] = divergence.Skipped, + ["max_absolute_error"] = divergence.MaxAbsolute, + ["max_absolute_error_at"] = divergence.MaxAbsoluteAt, + ["max_relative_error"] = divergence.MaxRelative, + ["max_relative_error_at"] = divergence.MaxRelativeAt, + ["rms_error"] = divergence.RootMeanSquare, + ["samples_complex"] = divergence.Complex, + }; + + if (divergence.Complex > 0) + response["complex_note"] = + $"{divergence.Complex} of {divergence.Sampled} evaluated points were " + + "NOT real — a logarithm or even root took its principal branch on a " + + "negative argument. The errors above compare complex magnitudes, " + + "which is probably not what you meant if this stands for a physical " + + "quantity. Narrow the interval, or run am_domain_check first."; + + if (divergence.Skipped > 0) + response["skipped_note"] = + $"{divergence.Skipped} of {samples} points were undefined for one " + + "side or the other and were excluded. A large count here means the " + + "interval crosses a singularity or leaves the reals — the error " + + "figures describe only the part that was evaluable."; + + response["interpretation_note"] = + "Sampling proves nothing between the samples. A smooth approximation is " + + "well characterised by this; one with a narrow spike is not. Raise " + + "'samples', or use am_domain_check to find where the reference misbehaves."; + + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + }); + } + + private static JsonObject Substitute(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + + if (args["substitutions"] is not JsonObject subs || subs.Count == 0) + return Fail("'substitutions' must be a non-empty map of variable -> expression"); + + var replacements = new List<(string Name, Entity Value)>(); + foreach (var (key, value) in subs) + { + if (value is null) continue; + if (!TryParse(value.GetValue(), $"substitutions.{key}", false, + out var replacement, out var more, out var subError)) + return subError!; + replacements.Add((key, replacement)); + warnings.AddRange(more); + } + + var alsoSimplify = B(args, "simplify"); + + return FromOutcome(Guard.Run(() => + { + var result = e; + foreach (var (name, value) in replacements) + result = result.Substitute(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; + }), r => + { + var response = Respond(e, r, warnings); + var applied = new JsonArray(); + foreach (var (name, value) in replacements) + applied.Add($"{name} := {value.Stringize()}"); + response["applied"] = applied; + + if (!alsoSimplify) + response["note"] = "Substituted structurally, not simplified — pass " + + "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()); + response["free_variables"] = remaining; + + return response; + }); + } + + private static JsonObject Rewrite(JsonObject args, bool expand) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + + return FromOutcome( + Guard.Run(() => expand ? e.Expand() : e.Factorize()), + r => Respond(e, r, warnings)); + } + + private static JsonObject Series(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + + var variable = S(args, "variable"); + if (string.IsNullOrWhiteSpace(variable)) return Fail("'variable' is required"); + + if (!args.TryGetPropertyValue("degree", out var d) || d is null) + return Fail("'degree' is required"); + var degree = d.GetValue(); + if (degree is < 1 or > 30) return Fail("'degree' must be between 1 and 30"); + + var aroundText = S(args, "around"); + Entity? around = null; + if (!string.IsNullOrWhiteSpace(aroundText)) + { + if (!TryParse(aroundText, "around", false, out var point, out _, out var pointError)) + return pointError!; + around = point; + } + + 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)); + + // The raw series keeps factorials unevaluated (`... / 3!`), so it is unreadable + // until reduced. + return raw.InnerSimplified.Simplify(); + }), r => + { + var response = Respond(e, r, warnings); + response["degree"] = degree; + response["around"] = around?.Stringize() ?? "0"; + response["kind"] = around is null ? "Maclaurin" : "Taylor"; + response["truncation_note"] = + "This is a truncated series: it agrees with the original near the expansion " + + "point and diverges away from it. There is no error bound here — treat it as " + + "an approximation whose validity you must argue separately."; + return response; + }); + } + + private static JsonObject NumberTheory(JsonObject args) + { + var operation = S(args, "operation"); + if (string.IsNullOrWhiteSpace(operation)) return Fail("'operation' is required"); + + if (!TryParse(S(args, "value"), "value", false, out var e, out var warnings, out var error)) + return error!; + + if (e.Evaled is not Number.Integer a) + return Fail($"'value' must evaluate to an integer; got '{e.Stringize()}'"); + + Number.Integer? b = null; + if (operation == "gcd") + { + if (!TryParse(S(args, "value_b"), "value_b", false, + out var second, out var moreWarnings, out var secondError)) + return secondError!; + if (second.Evaled is not Number.Integer parsedB) + return Fail("'value_b' must evaluate to an integer"); + b = parsedB; + warnings.AddRange(moreWarnings); + } + + 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, + _ => throw new ArgumentException($"unknown operation '{operation}'"), + }), value => + { + var response = new JsonObject + { + ["status"] = "solved", + ["operation"] = operation, + ["input"] = a.Stringize(), + }; + + if (value is List<(Number.Integer Prime, Number.Integer Power)> factors) + { + var listed = new JsonArray(); + foreach (var (prime, power) in factors) + listed.Add(new JsonObject + { + ["prime"] = prime.Stringize(), + ["power"] = power.Stringize(), + }); + response["factors"] = listed; + response["result"] = string.Join(" * ", factors.Select(f => + f.Power.Stringize() == "1" + ? f.Prime.Stringize() + : $"{f.Prime.Stringize()}^{f.Power.Stringize()}")); + } + else + { + response["result"] = value.ToString(); + } + + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + }); + } + + private static JsonObject ClassifyFormula(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + + var (guesses, signatures, features) = Classify.Analyse(e); + + var ranked = new JsonArray(); + foreach (var g in guesses) + ranked.Add(new JsonObject + { + ["field"] = g.Field, + ["score"] = g.Score, + ["because"] = g.Because, + }); + + var recognised = new JsonArray(); + foreach (var s in signatures) recognised.Add(s); + + var observed = new JsonArray(); + foreach (var f in features) observed.Add(f); + + var symbols = new JsonArray(); + foreach (var v in e.Vars) symbols.Add(v.Stringize()); + + var response = new JsonObject + { + ["status"] = "solved", + ["parsed"] = e.Stringize(), + ["symbols"] = symbols, + ["features"] = observed, + ["recognised_forms"] = recognised, + ["likely_fields"] = ranked, + ["caveat"] = "Inferred from symbol naming conventions, not from meaning. The " + + "same letter means different things in different fields; report this as a " + + "guess, not a fact.", + }; + + if (guesses.Count == 0) + response["note"] = "Nothing distinctive enough to guess from. The symbols are " + + "too generic, or the formula is pure mathematics with no domain markers."; + + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + } + + private static JsonObject ToSympy(JsonObject args) + { + if (!TryParse(S(args, "expression"), "expression", false, + out var e, out var warnings, out var error)) + return error!; + + var response = new JsonObject + { + ["status"] = "solved", + ["parsed"] = e.Stringize(), + ["sympy"] = MathS.ToSympyCode(e), + }; + if (warnings.Count > 0) response["warnings"] = Warn(warnings); + return response; + } +} diff --git a/Sources/MCP/README.md b/Sources/MCP/README.md new file mode 100644 index 000000000..2a037db77 --- /dev/null +++ b/Sources/MCP/README.md @@ -0,0 +1,159 @@ +# AngouriMath over MCP + +`amcli mcp` serves AngouriMath to an LLM agent over the [Model Context Protocol](https://modelcontextprotocol.io): +newline-delimited JSON-RPC 2.0 on stdin and stdout, protocol revision `2024-11-05`. + +The point is verification rather than calculation. A model is confident it can do algebra, so a +tool that only offers to do it goes unused; a tool that checks the model's work gets called. So +every integral is checked by differentiating it back, a decline is reported as a decline rather +than dressed up as an answer, and every response echoes what was parsed. + +## Install + +`amcli` is the [terminal's](../Terminal/readme.md) dotnet tool: + +```sh +dotnet tool install --global AngouriMath.Terminal +amcli mcp --selftest +``` + +The selftest checks a dozen identities, Euler's and Machin's among them, and that each library +defect the server's documents describe still reproduces. A defect that no longer reproduces is +reported as drift, since the documents then need editing. + +Register it with a client by naming `amcli` and the argument `mcp`. For Claude Code: + +```sh +claude mcp add angourimath --scope user -- amcli mcp +``` + +For Claude Desktop, in `claude_desktop_config.json`, and the same shape for any other client: + +```json +{ + "mcpServers": { + "angourimath": { "command": "amcli", "args": ["mcp"] } + } +} +``` + +There is no network access, no file access, no configuration and no secret. Every tool is +annotated `readOnlyHint` and `openWorldHint: false`, so a client can approve calls without asking, +which matters: a maths tool that costs a click per call does not get used. + +To check the protocol without a client: + +```sh +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | amcli mcp +``` + +## Tools + +| Tool | | +|---|---| +| `am_parse` | the parse as understood, its LaTeX, its free variables, and warnings | +| `am_simplify` | `alternatives: true` returns several candidate forms | +| `am_solve` | a list of constraints, combined with `and`, so `['x^2 = 4', 'x > 0']` narrows to `2` | +| `am_differentiate` | any order | +| `am_integrate` | always verified by differentiating back. With `from` and `to`, a declined integral still gets a numeric value for that interval, to the digits two step counts agree on | +| `am_limit` | one-sided with `side`; tells "no limit" from failure | +| `am_evaluate` | the exact form and a decimal, after optional substitutions | +| `am_verify_equal` | did your own algebra change the meaning? Names the difference | +| `am_check_steps` | checks a chain of working and names the step that broke | +| `am_truth_table` | the table and the satisfying assignments | +| `am_solve_system` | takes `x + y = 3` as well as `x + y - 3` | +| `am_domain_check` | domain conditions, structural hazards, and where it stops being real | +| `am_represent` | encodings: bases 2–36, Q-format fixed point, IEEE 754 bits, polar form | +| `am_matrix` | determinant, inverse, transpose, rank, RREF, trace, multiply, tensor product, power | +| `am_eigenvalues` | exact, via the characteristic polynomial; symbolic entries allowed | +| `am_compare_numeric` | worst and RMS error of an approximation over an interval, and where | +| `am_substitute` | plug in without evaluating: the shape, not a number | +| `am_expand`, `am_factor` | brackets out, or back into a product | +| `am_series` | Taylor or Maclaurin, to a given degree | +| `am_number_theory` | factorisation, totient, gcd, divisor count, primality | +| `am_classify` | which field a formula comes from, read off its symbols | +| `am_to_sympy` | a runnable SymPy program, for cross-checking | + +Five **prompts** surface as slash commands in a client: `verify-derivation`, `check-formula`, +`derive-jacobian`, `analyse-approximation` and `solve-with-constraints`. A model rarely reaches for +a maths tool on its own; a prompt is the user reaching for it instead, and it costs nothing in +tool-list context. + +Three **resources**: `angourimath://syntax`, the grammar and its two silent traps; +`angourimath://reliability`, when to trust a result and when to expect a decline; and +`angourimath://curiosities`, famous results with the call that reproduces each. Point a model at +the first two at the start of a session. + +## What the design rests on + +**Every response echoes the parse.** The parser is permissive in two silent ways, and silence is +the dangerous part: a valid parse of a different expression, with a plausible answer. + +- A number after a name is an **exponent**: `x2` is x², and `2(g+e)3` is 2(g+e)³. A variable + named `x2` or `t0` is silently raised to a power. +- An unknown name before a bracket is a **product**: `im(z)` is `im * z`. + +Both raise a warning, and the `parsed` field always shows what was understood. Whether a name is a +function is asked of the parser itself, by parsing `name(x)`, so the warning cannot fall behind the +grammar. + +**The status is explicit**: `solved`, `unchanged`, `declined`, `suspect`, `timeout`, `failed` or +`conflict`. `declined` means the library has no rule, whether it left the expression unevaluated or +raised `NotSufficientlySupportedException`. `unchanged` means no progress, not "already simplest". + +**A printed `NaN` is screened.** An answer that exists has none in it. + +**An approximate answer says how approximate it is.** The numeric fallback for a declined definite +integral, `Entity.DefiniteIntegral`, is a first-order rule that returns a hundred digits whatever +its accuracy. It is run at two step counts, and only the digits they agree on are reported. + +**Every call has a budget and its own stack.** Each runs under the library's cancellation token on a +thread with a 64 MB stack, abandoned rather than killed on timeout, so neither a runaway search nor +a deep recursion takes the server down. + +## Known limits + +- **LaTeX is output only.** Convert `\frac{a}{b}` to `a/b` before calling. +- `am_verify_equal` decides on positive real points, then checks the negatives separately: for + `sqrt(x^2)` against `x` it answers `equal: true` **with a note** that the two agree only on the + positives. The note is part of the answer. +- `am_solve`'s `solutions[]` is tidied root by root and its raw `result` is not, so the two can + read differently. Prefer `solutions[]`. +- A nonlinear system can come back with no solution when one exists. +- Requests are served one at a time. Settings scopes follow the call, so this is not needed for + correctness; a stdio client sends one request at a time anyway. + +## Changing it + +```sh +dotnet test Sources/Tests/McpUnitTests +``` + +The tests drive the server as a client would. They include the selftest, which fails here on a +library change that fixes a documented defect, and a check that `angourimath://syntax` names every +function the grammar has. `.github/workflows/InteractiveTest.yml` runs them. + +Each of these looks arbitrary and is not: + +- **Nothing but JSON-RPC goes to the output.** Diagnostics go to the error stream; one stray line + 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. +- **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 + cancellation: strip `provided not a = 0` first and `a*(d*a - c*b)/a` no longer reduces. +- **A warning that fires on correct input is worse than none**, because it teaches the caller to + ignore the channel. Check a new warning against the correct spelling as well as the broken one. + +Each tool costs context and dilutes the descriptions a model routes on, so prefer a parameter on an +existing tool, then a prompt, and add a tool last. A tool's description is a routing prompt, not +documentation: say when to call it, and where a model would wrongly trust itself, say so. + +This is an adapter, not a second computer algebra system. Mathematics belongs in the library, where +the terminal and the notebooks get it too, and four workarounds here wait for it: +eigenvalues by the characteristic polynomial ([#1676](https://github.com/asc-community/AngouriMath/issues/1676)), +a numeric comparison with a tolerance ([#1674](https://github.com/asc-community/AngouriMath/issues/1674)), +`a = b` rewritten to `a - b` for a system ([#1673](https://github.com/asc-community/AngouriMath/issues/1673)), +and the numeric definite integral run twice to find its good digits +([#1675](https://github.com/asc-community/AngouriMath/issues/1675)). diff --git a/Sources/Terminal/AngouriMath.Terminal.Lib/AngouriMath.Terminal.Lib.fsproj b/Sources/Terminal/AngouriMath.Terminal.Lib/AngouriMath.Terminal.Lib.fsproj index 36de9c6ea..5ca4a15d3 100644 --- a/Sources/Terminal/AngouriMath.Terminal.Lib/AngouriMath.Terminal.Lib.fsproj +++ b/Sources/Terminal/AngouriMath.Terminal.Lib/AngouriMath.Terminal.Lib.fsproj @@ -71,6 +71,7 @@ + diff --git a/Sources/Terminal/AngouriMath.Terminal.Lib/OneShot.fs b/Sources/Terminal/AngouriMath.Terminal.Lib/OneShot.fs index 007786264..2c7b664fc 100644 --- a/Sources/Terminal/AngouriMath.Terminal.Lib/OneShot.fs +++ b/Sources/Terminal/AngouriMath.Terminal.Lib/OneShot.fs @@ -27,6 +27,7 @@ amcli solve VAR STATEMENT solves for VAR, one root per line when there are fi amcli sub VAR VALUE EXPR substitutes VALUE for VAR amcli latex EXPR writes EXPR as LaTeX amcli info EXPR its variables, derivatives, roots and stationary points +amcli mcp serves the library to an agent over MCP, on stdin and stdout amcli help prints this An argument written as _ is read from stdin, and so is one that is left out: @@ -34,7 +35,7 @@ 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"; "help"; "-h"; "--help" ] +let commands = set [ "eval"; "simp"; "fsimp"; "diff"; "solve"; "sub"; "latex"; "info"; "mcp"; "help"; "-h"; "--help" ] /// 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) = @@ -193,6 +194,8 @@ let private answer (command: string) (next: unit -> string option) : ResultThe command line after the program's own name, which must not be empty. let run (args: string list) (input: TextReader) (output: TextWriter) (error: TextWriter) : int = match args with + // Not one answer but a session: the server reads requests until stdin ends. + | "mcp" :: rest -> AngouriMath.Mcp.Server.Run(List.toArray rest, input, output, error) | command :: rest when commands.Contains command -> let arguments = Arguments(rest, input) try diff --git a/Sources/Terminal/AngouriMath.Terminal/PackageReadme.md b/Sources/Terminal/AngouriMath.Terminal/PackageReadme.md index 25c4ea42f..817f0d2e6 100644 --- a/Sources/Terminal/AngouriMath.Terminal/PackageReadme.md +++ b/Sources/Terminal/AngouriMath.Terminal/PackageReadme.md @@ -1,7 +1,8 @@ This is a dotnet tool package to use AngouriMath from F# from console. Run `amcli` for the terminal, or `amcli simp "sin(x)^2 + cos(x)^2"` to answer one command and -exit; `amcli help` lists the commands. +exit; `amcli help` lists the commands. `amcli mcp` serves the library to an agent over the Model +Context Protocol. References: [Website](https://am.angouri.org) · [Github](https://github.com/asc-community/AngouriMath) · [What's new](https://am.angouri.org/whatsnew/) · [Wiki](https://am.angouri.org/wiki/) · [Contacts](https://am.angouri.org/#contacts) diff --git a/Sources/Terminal/AngouriMath.Terminal/Program.fs b/Sources/Terminal/AngouriMath.Terminal/Program.fs index 40db77bc6..f49ce9724 100644 --- a/Sources/Terminal/AngouriMath.Terminal/Program.fs +++ b/Sources/Terminal/AngouriMath.Terminal/Program.fs @@ -12,6 +12,12 @@ open AngouriMath.Terminal.Lib // the console, which a pipe does not have. match Environment.GetCommandLineArgs() |> List.ofArray |> List.tail with | [] -> () +// The protocol is UTF-8 on the pipe whatever code page the console has, so the server is given +// the standard streams as bytes rather than Console.In and Console.Out, which follow the code page. +| "mcp" :: _ as args -> + let input = new IO.StreamReader(Console.OpenStandardInput(), Text.UTF8Encoding(false)) + let output = new IO.StreamWriter(Console.OpenStandardOutput(), Text.UTF8Encoding(false), AutoFlush = true) + exit (OneShot.run args input output Console.Error) | args -> exit (OneShot.run args Console.In Console.Out Console.Error) // All other platforms do not support setting custom window width diff --git a/Sources/Terminal/AngouriMath.Terminal/amcli.1 b/Sources/Terminal/AngouriMath.Terminal/amcli.1 index f619b4f78..9a9d8355f 100644 --- a/Sources/Terminal/AngouriMath.Terminal/amcli.1 +++ b/Sources/Terminal/AngouriMath.Terminal/amcli.1 @@ -66,6 +66,15 @@ Describes its variables, the derivative and the roots for each, and its stationary points, each classified by the second derivative test. .TP +.B mcp +Serves the library to an agent over the Model Context Protocol: JSON-RPC on standard input and +standard output, until standard input ends. An MCP client's configuration names +.B amcli +with the argument +.BR mcp . +.B "amcli mcp \-\-selftest" +checks the install, and that the library still behaves as the server's documents say. +.TP .B help Lists the commands. .SH EXIT STATUS diff --git a/Sources/Terminal/AngouriMath.Terminal/amcli.bash b/Sources/Terminal/AngouriMath.Terminal/amcli.bash index a909bd994..7532d1c16 100644 --- a/Sources/Terminal/AngouriMath.Terminal/amcli.bash +++ b/Sources/Terminal/AngouriMath.Terminal/amcli.bash @@ -3,7 +3,9 @@ _amcli() { local current="${COMP_WORDS[COMP_CWORD]}" if [ "$COMP_CWORD" -eq 1 ]; then - COMPREPLY=( $(compgen -W "eval simp fsimp diff solve sub latex info help" -- "$current") ) + COMPREPLY=( $(compgen -W "eval simp fsimp diff solve sub latex info mcp help" -- "$current") ) + elif [ "$COMP_CWORD" -eq 2 ] && [ "${COMP_WORDS[1]}" = mcp ]; then + COMPREPLY=( $(compgen -W "--selftest --help" -- "$current") ) fi } complete -F _amcli amcli angourimath-terminal diff --git a/Sources/Terminal/readme.md b/Sources/Terminal/readme.md index 5e96f5ced..bf06bc363 100644 --- a/Sources/Terminal/readme.md +++ b/Sources/Terminal/readme.md @@ -50,6 +50,18 @@ is 0 for an answer, 1 when the input does not parse or cannot be answered, and 2 that does not exist or is missing an argument. These are the commands of the earlier standalone command-line tool, with the same names and argument order. +## For an agent, over MCP + +`amcli mcp` serves the library to an LLM agent over the Model Context Protocol, as JSON-RPC on +stdin and stdout. Register it with a client by naming `amcli` and the argument `mcp`: + +``` +claude mcp add angourimath -- amcli mcp +``` + +`amcli mcp --selftest` checks the install. [`Sources/MCP`](../MCP/README.md) says what the server +offers and how it is built. + ## Earlier results diff --git a/Sources/Tests/McpUnitTests/Mcp.cs b/Sources/Tests/McpUnitTests/Mcp.cs new file mode 100644 index 000000000..5b46254e1 --- /dev/null +++ b/Sources/Tests/McpUnitTests/Mcp.cs @@ -0,0 +1,62 @@ +// +// 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 System.Text.Json.Nodes; +using Xunit; + +namespace AngouriMath.Mcp.Tests; + +/// Drives the server through , a line of JSON-RPC at a time. +internal static class Mcp +{ + /// + /// The replies to , one per line the server wrote, and everything it + /// wrote to the error stream. + /// + public static (List Replies, string Errors) Session(params string[] lines) + { + using var input = new StringReader(string.Join("\n", lines)); + using var output = new StringWriter(); + using var error = new StringWriter(); + Assert.Equal(0, Server.Run([], input, output, error)); + var replies = output.ToString() + .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(line => JsonNode.Parse(line) as JsonObject ?? throw new InvalidOperationException($"not an object: {line}")) + .ToList(); + return (replies, error.ToString()); + } + + /// The result of one request with id 1, after initialize. + public static JsonObject Request(string method, string parametersJson = "{}") + { + var (replies, errors) = Session( + """{"jsonrpc":"2.0","id":0,"method":"initialize","params":{}}""", + $$"""{"jsonrpc":"2.0","id":1,"method":"{{method}}","params":{{parametersJson}}}"""); + Assert.Equal("", errors); + var reply = Assert.Single(replies, reply => (int?)reply["id"] == 1); + return reply["result"] as JsonObject ?? throw new InvalidOperationException($"{method} answered {reply.ToJsonString()}"); + } + + /// + /// What one tool answered: the JSON in its text content, which is what a model reads. + /// + public static JsonObject Call(string tool, string argumentsJson) + { + var result = Request("tools/call", $$"""{"name":"{{tool}}","arguments":{{argumentsJson}}}"""); + var text = result["content"]?[0]?["text"]?.GetValue() + ?? throw new InvalidOperationException($"{tool} answered {result.ToJsonString()}"); + return JsonNode.Parse(text) as JsonObject ?? throw new InvalidOperationException($"{tool} answered {text}"); + } + + /// A string field of a tool's answer. + public static string Field(this JsonObject payload, string name) => + payload[name]?.ToString() ?? throw new InvalidOperationException($"no '{name}' in {payload.ToJsonString()}"); + + /// The warnings a tool raised, or none. + public static IEnumerable Warnings(this JsonObject payload) => + payload["warnings"] is JsonArray warnings ? warnings.Select(w => w!.GetValue()) : []; +} diff --git a/Sources/Tests/McpUnitTests/McpUnitTests.csproj b/Sources/Tests/McpUnitTests/McpUnitTests.csproj new file mode 100644 index 000000000..63874ee0d --- /dev/null +++ b/Sources/Tests/McpUnitTests/McpUnitTests.csproj @@ -0,0 +1,34 @@ + + + + + + net10.0 + enable + false + true + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Sources/Tests/McpUnitTests/ParsingTest.cs b/Sources/Tests/McpUnitTests/ParsingTest.cs new file mode 100644 index 000000000..2befc0bff --- /dev/null +++ b/Sources/Tests/McpUnitTests/ParsingTest.cs @@ -0,0 +1,83 @@ +// +// 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 System.Text.RegularExpressions; +using AngouriMath.Core.Exceptions; +using Xunit; + +namespace AngouriMath.Mcp.Tests; + +/// +/// The server's knowledge of the grammar, checked against the grammar itself: every function +/// token in Core/Antlr/AngouriMath.g, so a function the grammar gains or loses fails +/// here until the server's warnings and its syntax document agree with it. +/// +public sealed class ParsingTest +{ + /// The names the grammar has a 'name(' token for. + public static IEnumerable GrammarFunctions() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AngouriMath", "Core", "Antlr", "AngouriMath.g"))) + directory = directory.Parent; + Assert.NotNull(directory); + var grammar = File.ReadAllText(Path.Combine(directory!.FullName, "AngouriMath", "Core", "Antlr", "AngouriMath.g")); + return Regex.Matches(grammar, @"'([A-Za-z][A-Za-z0-9_]*)\('") + .Select(match => match.Groups[1].Value) + .Distinct() + .Order(StringComparer.Ordinal) + .Select(name => new object[] { name }); + } + + [Fact] + public void TheGrammarHasFunctions() => Assert.True(GrammarFunctions().Count() > 100); + + [Theory] + [MemberData(nameof(GrammarFunctions))] + public void EveryFunctionInTheGrammarIsReadAsOne(string name) => + Assert.True(Parsing.IsFunction(name), $"{name}(x) is read as a product"); + + [Theory] + [MemberData(nameof(GrammarFunctions))] + public void TheSyntaxDocumentNamesEveryFunctionTheGrammarAccepts(string name) + { + try + { + MathS.FromString(name + "(x)"); + } + catch (UnrecognizedFunctionParseException) + { + // Refused by name, so the grammar has it only to say why. + return; + } + catch (FunctionArgumentCountException) + { + // A function of more arguments than one. + } + var syntax = Resources.Read("angourimath://syntax")!; + Assert.True(Regex.IsMatch(syntax, $@"(? + Assert.False(Parsing.IsFunction(name), $"{name}(x) is read as a call"); + + [Fact] + public void EveryMisspellingIsReadAsAProduct() + { + // A hint for a name the grammar calls would never be shown. + foreach (var name in Parsing.Misspellings.Keys) + Assert.False(Parsing.IsFunction(name), $"{name}(x) is read as a call, so its hint is never shown"); + } +} diff --git a/Sources/Tests/McpUnitTests/ScenarioTest.cs b/Sources/Tests/McpUnitTests/ScenarioTest.cs new file mode 100644 index 000000000..f622d670e --- /dev/null +++ b/Sources/Tests/McpUnitTests/ScenarioTest.cs @@ -0,0 +1,132 @@ +// +// 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 use cases Sources/MCP/README.md describes, each as the question an agent would actually be +/// asked, asserting what the README says the server contributes at that moment. +/// +public sealed class ScenarioTest +{ + [Fact] + public void AnIntegrationDoneByHandIsConfirmed() => + Assert.True(Mcp.Call("am_verify_equal", """{"left":"derivative(e^x*(x-1), x)","right":"x*e^x"}""")["equal"]!.GetValue()); + + [Fact] + public void ASignSlipIsNamedByTheDifference() + { + var answer = Mcp.Call("am_verify_equal", """{"left":"derivative(e^x*(x+1), x)","right":"x*e^x"}"""); + Assert.False(answer["equal"]!.GetValue()); + Assert.Equal("2 * e ^ x", answer.Field("difference")); + } + + [Fact] + public void TheStepThatBrokeAWorkingIsNamed() + { + var answer = Mcp.Call("am_check_steps", """{"steps":["(x+1)^2 - 1","x^2 + 1 - 1","x^2"]}"""); + Assert.Equal(1, answer["first_invalid_step"]!.GetValue()); + Assert.Equal("2 * x", answer["steps"]![0]!["difference"]!.GetValue()); + Assert.True(answer["steps"]![1]!["equal"]!.GetValue()); + } + + [Fact] + public void AnExpansionInTheCodeAgreesWithTheCommentAboveIt() => + Assert.True(Mcp.Call("am_verify_equal", + """{"left":"(a*(t - tref))^2 + b*(t - tref) + c","right":"a^2*t^2 - 2*a^2*t*tref + a^2*tref^2 + b*t - b*tref + c"}""")["equal"]!.GetValue()); + + [Fact] + public void ASignErrorInTheCodeIsPointedAt() + { + var answer = Mcp.Call("am_verify_equal", + """{"left":"(a*(t - tref))^2 + b*(t - tref) + c","right":"a^2*t^2 - 2*a^2*t*tref + a^2*tref^2 + b*t + b*tref + c"}"""); + Assert.False(answer["equal"]!.GetValue()); + Assert.Equal("(-2) * b * tref", answer.Field("difference")); + } + + [Fact] + public void TheHazardsBeforeHardwareAreListed() + { + var risks = Mcp.Call("am_domain_check", """{"expression":"sqrt(v - vref) / (t - tref)"}""")["hazards"]!.AsArray() + .Select(hazard => hazard!["risk"]!.GetValue()).ToList(); + Assert.Contains(risks, risk => risk.StartsWith("division")); + Assert.Contains(risks, risk => risk.StartsWith("fractional or symbolic power")); + } + + [Fact] + public void AJacobianRow() => + Assert.Equal("x * (x ^ 2 + y ^ 2) ^ (-1/2)", + Mcp.Call("am_differentiate", """{"expression":"sqrt(x^2 + y^2)","variable":"x"}""").Field("result")); + + [Fact] + public void ADesignFormulaIsSolvedForAPart() => + Assert.Equal("""["1/2 / (C * f * pi)"]""", + Mcp.Call("am_solve", """{"constraints":["f = 1/(2*pi*R*C)"],"variable":"R"}""")["solutions"]!.ToJsonString()); + + [Fact] + public void AnExactAreaComesWithItsAntiderivativeVerified() + { + var answer = Mcp.Call("am_integrate", + """{"expression":"x^6-4*x^5+5*x^4-4*x^2+4-4/(1+x^2)","variable":"x","from":"0","to":"1"}"""); + Assert.Equal("22/7 - pi", answer.Field("definite_value")); + Assert.Equal("true", answer.Field("verified")); + } + + [Fact] + public void WhatTruncatingASeriesCostsAndWhere() + { + var answer = Mcp.Call("am_compare_numeric", + """{"reference":"sin(x)","approximation":"x - x^3/6 + x^5/120","variable":"x","from":0,"to":1.5,"samples":200}"""); + // The first term left out, x^7/5040, is 3.4e-3 at 1.5. + Assert.InRange(answer["max_absolute_error"]!.GetValue(), 3.2e-3, 3.4e-3); + Assert.Equal("1.5", answer.Field("max_absolute_error_at")); + } + + [Fact] + public void ACoefficientAsAFixedPointWord() + { + var answer = Mcp.Call("am_represent", """{"operation":"fixed_point","value":"1/6","fraction_bits":15,"total_bits":16}"""); + Assert.Equal("5461", answer.Field("raw")); + Assert.Equal("5461/32768", answer.Field("represented_value")); + Assert.Equal("false", answer.Field("saturated")); + } + + [Fact] + public void AValueTheFormatCannotHoldIsSuspect() + { + var answer = Mcp.Call("am_represent", """{"operation":"fixed_point","value":"1.5","fraction_bits":15,"total_bits":16}"""); + Assert.Equal("suspect", answer.Field("status")); + Assert.Equal("true", answer.Field("saturated")); + } + + [Fact] + public void TheBellStateIsExact() + { + // H (x) I, then CNOT times that, then times |00>: three calls, amplitudes 1/sqrt(2). + var hadamardTimesIdentity = Mcp.Call("am_matrix", + """{"operation":"tensor_product","matrix":[["1/sqrt(2)","1/sqrt(2)"],["1/sqrt(2)","-1/sqrt(2)"]],"matrix_b":[["1","0"],["0","1"]]}""")["result"]!; + var entangled = Mcp.Call("am_matrix", + $$"""{"operation":"multiply","matrix":[["1","0","0","0"],["0","1","0","0"],["0","0","0","1"],["0","0","1","0"]],"matrix_b":{{hadamardTimesIdentity.ToJsonString()}}}""")["result"]!; + var bell = Mcp.Call("am_matrix", + $$"""{"operation":"multiply","matrix":{{entangled.ToJsonString()}},"matrix_b":[["1"],["0"],["0"],["0"]]}""")["result"]!; + Assert.Equal("""[["sqrt(2) / 2"],["0"],["0"],["sqrt(2) / 2"]]""", bell.ToJsonString()); + } + + [Fact] + public void EveryCaseAGuardHasToHandle() + { + var answer = Mcp.Call("am_truth_table", + """{"expression":"(ready and not fault) or override","variables":["ready","fault","override"]}"""); + Assert.StartsWith("Matrix[5 x 3]", answer.Field("satisfying_assignments")); + } + + [Fact] + public void AnExactValueForATest() => + Assert.Equal("sqrt(3)", Mcp.Call("am_evaluate", """{"expression":"sin(pi/3) + cos(pi/6)"}""").Field("result")); +} diff --git a/Sources/Tests/McpUnitTests/SelfTestTest.cs b/Sources/Tests/McpUnitTests/SelfTestTest.cs new file mode 100644 index 000000000..f4e05cce1 --- /dev/null +++ b/Sources/Tests/McpUnitTests/SelfTestTest.cs @@ -0,0 +1,26 @@ +// +// 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; + +/// +/// amcli mcp --selftest, held to more than its exit code: a documented defect that no +/// longer reproduces fails here too, since the server's documents then describe a library this +/// repository no longer builds. +/// +public sealed class SelfTestTest +{ + [Fact] + public void EveryIdentityHoldsAndEveryDocumentedDefectStillReproduces() + { + using var output = new StringWriter(); + var (failed, drifted) = SelfTest.Check(output); + Assert.True(failed == 0 && drifted == 0, output.ToString()); + } +} diff --git a/Sources/Tests/McpUnitTests/ServerTest.cs b/Sources/Tests/McpUnitTests/ServerTest.cs new file mode 100644 index 000000000..040aad48e --- /dev/null +++ b/Sources/Tests/McpUnitTests/ServerTest.cs @@ -0,0 +1,134 @@ +// +// 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 System.Text.Json.Nodes; +using Xunit; + +namespace AngouriMath.Mcp.Tests; + +/// +/// The protocol: what a client sends and what comes back. Nothing but JSON-RPC may reach the +/// output, since one stray line corrupts the stream and the host reports the server as failed. +/// +public sealed class ServerTest +{ + [Fact] + public void PingIsAnsweredWithNothing() => Assert.Empty(Mcp.Request("ping")); + + [Fact] + public void InitializeNamesTheProtocolRevisionAndTheLibrarysVersion() + { + var (replies, errors) = Mcp.Session("""{"jsonrpc":"2.0","id":0,"method":"initialize","params":{}}"""); + Assert.Equal("", errors); + var initialized = Assert.Single(replies)["result"]!; + Assert.Equal("2024-11-05", initialized["protocolVersion"]!.GetValue()); + Assert.Equal("angourimath", initialized["serverInfo"]!["name"]!.GetValue()); + Assert.Equal(Server.Version, initialized["serverInfo"]!["version"]!.GetValue()); + Assert.Matches(@"^\d+\.\d+\.\d+", Server.Version); + } + + [Fact] + public void ANotificationIsNeverAnswered() + { + var (replies, errors) = Mcp.Session( + """{"jsonrpc":"2.0","method":"notifications/initialized"}""", + """{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":3}}""", + """{"jsonrpc":"2.0","method":"no/such/notification"}"""); + Assert.Empty(replies); + Assert.Equal("", errors); + } + + [Theory] + [InlineData("""{"jsonrpc":"2.0","id":7,"method":"no/such/method","params":{}}""", -32601)] + [InlineData("""{"jsonrpc":"2.0","id":7,"params":{}}""", -32600)] + [InlineData("""{"jsonrpc":"2.0","id":7,"method":"prompts/get","params":{"name":"no-such-prompt"}}""", -32603)] + [InlineData("""{"jsonrpc":"2.0","id":7,"method":"resources/read","params":{"uri":"angourimath://nothing"}}""", -32603)] + public void AnErrorIsAnsweredWithItsCode(string request, int code) + { + var (replies, _) = Mcp.Session(request); + var reply = Assert.Single(replies); + Assert.Equal(7, (int)reply["id"]!); + Assert.Equal(code, (int)reply["error"]!["code"]!); + } + + [Fact] + public void ALineThatIsNotJsonIsAParseErrorAndTheSessionGoesOn() + { + var (replies, _) = Mcp.Session("{not json", """{"jsonrpc":"2.0","id":2,"method":"ping"}"""); + Assert.Equal(2, replies.Count); + Assert.Equal(-32700, (int)replies[0]["error"]!["code"]!); + Assert.Equal(2, (int)replies[1]["id"]!); + } + + [Fact] + public void EveryToolIsReadOnlyAndClosedWorld() + { + // An MCP client can approve these without asking, which is what gets a maths tool + // used at all. + var tools = Mcp.Request("tools/list")["tools"]!.AsArray(); + Assert.Equal(23, tools.Count); + foreach (var tool in tools) + { + var name = tool!["name"]!.GetValue(); + Assert.StartsWith("am_", name); + Assert.False(string.IsNullOrWhiteSpace(tool["description"]?.GetValue()), name); + Assert.Equal("object", tool["inputSchema"]!["type"]!.GetValue()); + Assert.True(tool["annotations"]!["readOnlyHint"]!.GetValue(), name); + Assert.False(tool["annotations"]!["openWorldHint"]!.GetValue(), name); + } + } + + [Fact] + public void EveryResourceReadsBack() + { + var resources = Mcp.Request("resources/list")["resources"]!.AsArray(); + Assert.Equal(3, resources.Count); + foreach (var resource in resources) + { + var uri = resource!["uri"]!.GetValue(); + var contents = Mcp.Request("resources/read", $$"""{"uri":"{{uri}}"}""")["contents"]!.AsArray(); + var text = Assert.Single(contents)!["text"]!.GetValue(); + Assert.StartsWith("# ", text); + } + } + + [Fact] + public void EveryPromptReadsBack() + { + var prompts = Mcp.Request("prompts/list")["prompts"]!.AsArray(); + Assert.Equal(5, prompts.Count); + foreach (var prompt in prompts) + { + var name = prompt!["name"]!.GetValue(); + var arguments = new JsonObject(); + foreach (var argument in prompt["arguments"]?.AsArray() ?? []) + arguments[argument!["name"]!.GetValue()] = "x^2"; + var messages = Mcp.Request("prompts/get", $$"""{"name":"{{name}}","arguments":{{arguments.ToJsonString()}}}""")["messages"]!.AsArray(); + Assert.NotEmpty(messages); + } + } + + [Fact] + public void AToolsAnswerIsReadableAsWritten() + { + // The model reads the text block as it is, so `+` and `α` stay themselves rather than + // arriving as JSON escapes. + var (replies, _) = Mcp.Session("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"am_parse","arguments":{"expression":"α + 1"}}}"""); + var text = Assert.Single(replies)["result"]!["content"]![0]!["text"]!.GetValue(); + Assert.Contains("\"parsed\":\"α + 1\"", text); + } + + [Fact] + public void HelpAndTheSelfTestDoNotServe() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + Assert.Equal(0, Server.Run(["--help"], new StringReader("""{"jsonrpc":"2.0","id":1,"method":"ping"}"""), output, error)); + Assert.Contains("amcli mcp --selftest", output.ToString()); + Assert.DoesNotContain("jsonrpc", output.ToString()); + } +} diff --git a/Sources/Tests/McpUnitTests/ToolsTest.cs b/Sources/Tests/McpUnitTests/ToolsTest.cs new file mode 100644 index 000000000..e49683e3b --- /dev/null +++ b/Sources/Tests/McpUnitTests/ToolsTest.cs @@ -0,0 +1,222 @@ +// +// 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; + +/// +/// One call to each tool, on the behaviour its design rests on: the two silent misparses and +/// the correct spellings that must not be warned about, a domain condition kept, a decline +/// reported as one, and an answer verified rather than trusted. +/// +public sealed class ToolsTest +{ + private const string ImplicitPower = "implicit-power"; + private const string UnknownFunction = "unknown-function"; + + [Theory] + [InlineData("x2 + 1", "x ^ 2 + 1")] + // VARIABLE accepts Greek, so `α2` is the same trap as `x2`. + [InlineData("α2 + β", "α ^ 2 + β")] + // No numeric literal precedes this `e`, so it is the constant squared... cubed. + [InlineData("e3", "e ^ 3")] + [InlineData("a*(t - t0)^2 + c", "a * (t - t ^ 0) ^ 2 + c")] + public void ADigitAfterANameIsWarnedAboutAsAPower(string expression, string parsed) + { + var answer = Mcp.Call("am_parse", $$"""{"expression":"{{expression}}"}"""); + Assert.Equal(parsed, answer.Field("parsed")); + Assert.Contains(answer.Warnings(), w => w.StartsWith(ImplicitPower)); + } + + [Theory] + [InlineData("exp(x)", "e ^ x")] + // The `g2` inside the function's own name is not the `x2` trap. + [InlineData("log2(8)", "log(2, 8)")] + // EXPONENT is a fragment of NUMBER, so the `e3` here belongs to the literal. + [InlineData("1.5e3 + 2", "1500 + 2")] + [InlineData("erf(x) + Si(x)", "erf(x) + Si(x)")] + [InlineData("t_0 + x", "t_0 + x")] + public void ACorrectSpellingIsNotWarnedAbout(string expression, string parsed) + { + var answer = Mcp.Call("am_parse", $$"""{"expression":"{{expression}}"}"""); + Assert.Equal(parsed, answer.Field("parsed")); + Assert.Empty(answer.Warnings()); + } + + [Theory] + [InlineData("Γ(x+1)", "Γ * (x + 1)")] + [InlineData("im(z)", "im * z")] + public void AnUnknownNameBeforeABracketIsWarnedAboutAsAProduct(string expression, string parsed) + { + var answer = Mcp.Call("am_parse", $$"""{"expression":"{{expression}}"}"""); + Assert.Equal(parsed, answer.Field("parsed")); + Assert.Contains(answer.Warnings(), w => w.StartsWith(UnknownFunction)); + } + + [Fact] + public void StrictParsingRefusesAnImplicitProduct() => + Assert.Equal("failed", Mcp.Call("am_parse", """{"expression":"2x + 1","strict":true}""").Field("status")); + + [Fact] + public void SimplifyKeepsTheDomainCondition() => + Assert.Equal("x + 1 provided not x - 1 = 0", + Mcp.Call("am_simplify", """{"expression":"(x^2-1)/(x-1)"}""").Field("result")); + + [Fact] + public void SimplifyCancelsAMultivariateRationalFunction() => + Assert.Equal("(x + y) / (x - y) provided not x + y = 0", + Mcp.Call("am_simplify", """{"expression":"(x^2+2*x*y+y^2)/(x^2-y^2)"}""").Field("result")); + + [Fact] + public void SolveFindsBothRoots() => + Assert.Equal("""["2","-2"]""", Mcp.Call("am_solve", """{"constraints":["x^2 = 4"],"variable":"x"}""")["solutions"]!.ToJsonString()); + + [Fact] + public void AConstraintNarrowsTheRoots() => + Assert.Equal("""["2"]""", Mcp.Call("am_solve", """{"constraints":["x^2 = 4","x > 0"],"variable":"x"}""")["solutions"]!.ToJsonString()); + + [Fact] + public void Differentiate() => + Assert.Equal("cos(2 * x)", Mcp.Call("am_differentiate", """{"expression":"sin(x)*cos(x)","variable":"x"}""").Field("result")); + + [Theory] + [InlineData("x*ln(x)")] + // No elementary antiderivative, and one in erfi. + [InlineData("e^(x^2)")] + [InlineData("e^x/x")] + public void AnAntiderivativeIsVerifiedByDifferentiatingItBack(string integrand) + { + var answer = Mcp.Call("am_integrate", $$"""{"expression":"{{integrand}}","variable":"x"}"""); + Assert.Equal("solved", answer.Field("status")); + Assert.Equal("true", answer.Field("verified")); + } + + [Fact] + public void AnIntegralWithoutARuleIsDeclined() + { + var answer = Mcp.Call("am_integrate", """{"expression":"x^x","variable":"x","from":"1","to":"2"}"""); + Assert.Equal("declined", answer.Field("status")); + // Quadrature for the one interval, to the digits two step counts agree on. + Assert.StartsWith("2.05", answer.Field("numeric_definite_value")); + } + + [Fact] + public void ADefiniteIntegralIsExact() => + Assert.Equal("22/7 - pi", Mcp.Call("am_integrate", + """{"expression":"x^6-4*x^5+5*x^4-4*x^2+4-4/(1+x^2)","variable":"x","from":"0","to":"1"}""").Field("definite_value")); + + [Theory] + [InlineData("sin(x)/x", "0", "1")] + [InlineData("(1+x)^(1/x)", "0", "e")] + public void Limit(string expression, string to, string limit) => + Assert.Equal(limit, Mcp.Call("am_limit", $$"""{"expression":"{{expression}}","variable":"x","to":"{{to}}"}""").Field("result")); + + [Fact] + public void EvaluateIsExactWithADecimalBeside() + { + var answer = Mcp.Call("am_evaluate", """{"expression":"sqrt(2)+sqrt(8)"}"""); + Assert.Equal("sqrt(2) * 3", answer.Field("result")); + Assert.StartsWith("4.24264068711928", answer.Field("approximate")); + } + + [Theory] + [InlineData("sin(x)^2+cos(x)^2", "1", true, "0")] + [InlineData("(x+1)^2", "x^2+1", false, "2 * x")] + public void VerifyEqualNamesTheDifference(string left, string right, bool equal, string difference) + { + var answer = Mcp.Call("am_verify_equal", $$"""{"left":"{{left}}","right":"{{right}}"}"""); + Assert.Equal(equal, answer["equal"]!.GetValue()); + Assert.Equal(difference, answer.Field("difference")); + } + + [Fact] + public void TruthTableListsTheSatisfyingAssignments() + { + var answer = Mcp.Call("am_truth_table", """{"expression":"a and (b or not c)"}"""); + Assert.True(answer["satisfiable"]!.GetValue()); + Assert.StartsWith("Matrix[3 x 3]", answer.Field("satisfying_assignments")); + } + + [Fact] + public void ASystemWrittenAsEqualitiesIsSolved() + { + var answer = Mcp.Call("am_solve_system", """{"equations":["x + y = 3","x - y = 1"],"variables":["x","y"]}"""); + Assert.Equal(1, answer["solution_count"]!.GetValue()); + Assert.Equal("2 1", string.Join(" ", answer.Field("solutions").Split('\n')[1].Split(' ', StringSplitOptions.RemoveEmptyEntries))); + } + + [Fact] + public void ToSympyWritesAProgram() => + Assert.Contains("sympy", Mcp.Call("am_to_sympy", """{"expression":"sin(x)/x"}""").Field("sympy")); + + [Fact] + public void ASymbolicDeterminantHasNoPivotGuard() => + Assert.Equal("a * d - b * c", Mcp.Call("am_matrix", """{"operation":"determinant","matrix":[["a","b"],["c","d"]]}""").Field("result")); + + [Fact] + public void TensorProductOfHadamardAndIdentity() + { + var answer = Mcp.Call("am_matrix", + """{"operation":"tensor_product","matrix":[["1/sqrt(2)","1/sqrt(2)"],["1/sqrt(2)","-1/sqrt(2)"]],"matrix_b":[["1","0"],["0","1"]]}"""); + Assert.Equal("4x4", answer.Field("shape_out")); + Assert.Equal("sqrt(2) / 2", answer["result"]![0]![0]!.GetValue()); + } + + [Fact] + public void EigenvaluesHoldAtZeroToo() + { + var answer = Mcp.Call("am_eigenvalues", """{"matrix":[["0","J"],["J","0"]]}"""); + Assert.Equal("lambda ^ 2 - J ^ 2", answer.Field("characteristic_polynomial")); + Assert.DoesNotContain("provided", answer.Field("eigenvalues")); + } + + [Fact] + public void Series() => + Assert.Equal("x ^ 5 / 120 - x ^ 3 / 6 + x", Mcp.Call("am_series", """{"expression":"sin(x)","variable":"x","degree":7}""").Field("result")); + + [Fact] + public void Factorize() => + Assert.Equal("2^4 * 3^2 * 5 * 7", Mcp.Call("am_number_theory", """{"operation":"factorize","value":"5040"}""").Field("result")); + + [Fact] + public void SubstituteIsStructural() => + Assert.Equal("a * (t - t_0) ^ 2 + b * (t - t_0) + c", + Mcp.Call("am_substitute", """{"expression":"a*x^2 + b*x + c","substitutions":{"x":"t - t_0"}}""").Field("result")); + + [Fact] + public void CompareNumericFindsTheWorstPoint() + { + var answer = Mcp.Call("am_compare_numeric", + """{"reference":"sin(x)","approximation":"x - x^3/6","variable":"x","from":0,"to":1,"samples":50}"""); + Assert.InRange(answer["max_absolute_error"]!.GetValue(), 0.0081, 0.0082); + } + + [Fact] + public void FixedPointIsExact() + { + var answer = Mcp.Call("am_represent", """{"operation":"fixed_point","value":"1/sqrt(2)","fraction_bits":15,"total_bits":16}"""); + Assert.Equal("23170", answer.Field("raw")); + Assert.Equal("11585/16384", answer.Field("represented_value")); + } + + [Fact] + public void Ieee754StoresMoreThanATenth() + { + var answer = Mcp.Call("am_represent", """{"operation":"ieee754","value":"0.1"}"""); + Assert.Equal("0x3FB999999999999A", answer.Field("hex")); + Assert.Equal("0.1000000000000000055511151231257827021181583404541015625", answer.Field("exact_value")); + } + + [Fact] + public void PolarIsQuadrantCorrected() + { + var answer = Mcp.Call("am_represent", """{"operation":"polar","value":"-1 - i"}"""); + Assert.Equal("sqrt(2)", answer.Field("magnitude")); + Assert.Equal("-3/4 * pi", answer.Field("phase_radians")); + } +} diff --git a/Sources/Tests/TerminalUnitTests/OneShotTest.fs b/Sources/Tests/TerminalUnitTests/OneShotTest.fs index f447bd146..746a0e19f 100644 --- a/Sources/Tests/TerminalUnitTests/OneShotTest.fs +++ b/Sources/Tests/TerminalUnitTests/OneShotTest.fs @@ -91,9 +91,19 @@ let ``input that does not parse fails, and says why`` () = let ``help lists every command`` () = let code, printed, _ = run [ "help" ] "" Assert.Equal(0, code) - for command in [ "eval"; "simp"; "fsimp"; "diff"; "solve"; "sub"; "latex"; "info" ] do + for command in [ "eval"; "simp"; "fsimp"; "diff"; "solve"; "sub"; "latex"; "info"; "mcp" ] do Assert.Contains($"amcli {command} ", printed) +/// Not one answer but a session: the server reads requests from stdin until it ends, and writes +/// nothing to stdout but its replies. +[] +let ``mcp serves the protocol on stdin and stdout`` () = + let code, printed, error = run [ "mcp" ] "{\"jsonrpc\":\"2.0\",\"id\":0,\"method\":\"initialize\",\"params\":{}}" + Assert.Equal("", error) + Assert.Equal(0, code) + Assert.StartsWith("{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"protocolVersion\":\"2024-11-05\"", printed) + Assert.DoesNotContain("\n", printed) + /// What info says about an expression's stationary points, by the second derivative test. [] []