diff --git a/AGENTS.md b/AGENTS.md
index a148385fd..e3f99929b 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -875,7 +875,10 @@ landed — and **the MCP server and two other repositories brought to the releas
answer back out of the nodes. That documentation is as much a deliverable as the operation (the
maintainer's words on [#1409](https://github.com/asc-community/AngouriMath/issues/1409)); write it
there, or in this repository's `Docs/Usage` and the website, and check at every release that the
- MCP still says what the library does.
+ MCP still says what the library does. An operation the library offers by name is an entry of
+ `Core/Operations/Catalogue.cs` ([#1684](https://github.com/asc-community/AngouriMath/issues/1684)),
+ and a new factory of `Transformation` fails `CatalogueTest` until it is an entry or a building
+ block with its reason.
- [CSharpMath](https://github.com/verybadcat/CSharpMath), for the round trip of `Latexize` — see
*The standard command wins* above. If a PR there from an earlier release is still unmerged, add
to it rather than opening a second.
diff --git a/Sources/.editorconfig b/Sources/.editorconfig
index be901a7f0..45b05543c 100644
--- a/Sources/.editorconfig
+++ b/Sources/.editorconfig
@@ -24,6 +24,9 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed
[AngouriMath/Core/Transformations/*.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
+[AngouriMath/Core/Operations/*.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
+
[AngouriMath/Functions/Continuous/Integration/RothsteinTrager.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
@@ -87,6 +90,9 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed
[Tests/UnitTests/Core/Transformations/*.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/Operations/*.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/Algebra/Polynomials/EvaluationIrreducibilityTest.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/Core/Operations/Catalogue.cs b/Sources/AngouriMath/Core/Operations/Catalogue.cs
new file mode 100644
index 000000000..6dfb2e3a3
--- /dev/null
+++ b/Sources/AngouriMath/Core/Operations/Catalogue.cs
@@ -0,0 +1,255 @@
+//
+// 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;
+using System.Collections.Generic;
+using System.Linq;
+using AngouriMath.Core.Transformations;
+using static AngouriMath.Entity;
+using static AngouriMath.Entity.Number;
+
+namespace AngouriMath.Core.Operations
+{
+ ///
+ /// Every operation the library offers by name, which the terminal and the MCP server take
+ /// theirs from.
+ ///
+ ///
+ ///
+ /// The names are the ones
+ /// #1019 item 16
+ /// proposes for amcli's commands in v3: the library's own verbs, so simplify
+ /// rather than simp and differentiate rather than diff.
+ ///
+ ///
+ /// An operation is in this list when a surface offers it, or could offer it with nothing
+ /// but text in and text out. The building blocks also names,
+ /// such as or
+ /// , are not: a caller composes them, and
+ /// none answers a question by itself. A test lists every factory of
+ /// as one or the other, so that a new one fails the build
+ /// until it is placed.
+ ///
+ /// https://github.com/asc-community/AngouriMath/issues/1684
+ ///
+ internal static class Catalogue
+ {
+ [ConstantField]
+ private static readonly Parameter TheVariable = new("variable", "The variable it is over.");
+
+ // ---------------------------------------------------------------- transformations
+
+ /// .
+ public static TransformationOperation> Simplify { get; } = new(
+ "simplify", "Simplifies, searching the expression's rewritings for the simplest.",
+ TransformationRelation.Equivalence, static e => e.Simplify());
+
+ /// .
+ public static TransformationOperation> Normalize { get; } = new(
+ "normalize", "Tidies the expression in one structural pass, without the search simplify makes.",
+ TransformationRelation.Equivalence, static e => e.InnerSimplified);
+
+ /// .
+ public static TransformationOperation> Expand { get; } = new(
+ "expand", "Multiplies products over sums out.",
+ TransformationRelation.Equivalence, static e => e.Expand());
+
+ /// .
+ public static TransformationOperation> Factorize { get; } = new(
+ "factorize", "Gathers common factors out and factors polynomials.",
+ TransformationRelation.Equivalence, static e => e.Factorize());
+
+ /// .
+ public static TransformationOperation> Differentiate { get; } = new(
+ "differentiate", "Differentiates over a variable, as many times as the order says.",
+ TransformationRelation.Derivation, static (e, variable, order) => e.Differentiate(variable, order),
+ TheVariable, new Parameter("order", "How many times; a negative order integrates instead."));
+
+ /// .
+ public static TransformationOperation> Integrate { get; } = new(
+ "integrate", "Finds an antiderivative over a variable, with a constant of integration, or leaves the integral unevaluated.",
+ TransformationRelation.Derivation, static (e, variable) => e.Integrate(variable),
+ TheVariable);
+
+ /// .
+ public static TransformationOperation> Limit { get; } = new(
+ "limit", "Takes the limit as a variable approaches a value, or leaves it unevaluated.",
+ TransformationRelation.Derivation, static (e, variable, destination, side) => e.Limit(variable, destination, side),
+ TheVariable, new Parameter("destination", "The value it approaches."), new Parameter("side", "From the left, from the right, or from both sides."));
+
+ /// .
+ public static TransformationOperation> Substitute { get; } = new(
+ "substitute", "Replaces every occurrence of a subexpression with another expression.",
+ TransformationRelation.Derivation, static (e, what, with) => e.Substitute(what, with),
+ new Parameter("what", "The subexpression to replace."), new Parameter("with", "What to put in its place."));
+
+ // ---------------------------------------------------------------- queries on an expression
+
+ /// .
+ public static Query> Solve { get; } = new(
+ "solve", "Solves an equation, an inequality or a statement for a variable.",
+ static (e, variable) => e.Solve(variable),
+ TheVariable);
+
+ /// .
+ public static Query, IReadOnlyList, Matrix?>> SolveSystem { get; } = new(
+ "solve_system", "Solves equations jointly for several variables, one row of the answer for each solution.",
+ static (equations, variables) => MathS.Equations(equations).Solve([.. variables]),
+ new Parameter("variables", "The variables to solve for, in the order of the answer's columns."));
+
+ /// .
+ public static Query> Evaluate { get; } = new(
+ "evaluate", "Evaluates as far as it goes: to a number, a boolean or a set where it can, and as an expression where it cannot.",
+ static e => e.Evaled);
+
+ /// .
+ public static Query> EvaluateNumerically { get; } = new(
+ "evaluate_numerically", "Evaluates to a complex number, at the working precision.",
+ static e => e.EvalNumerical());
+
+ /// .
+ public static Query>> Variables { get; } = new(
+ "variables", "Lists the free variables.",
+ static e => e.Vars);
+
+ /// .
+ public static Query>> Alternatives { get; } = new(
+ "alternatives", "Lists forms of the expression that simplify finds, simplest first.",
+ static (e, level) => e.Alternate(level),
+ new Parameter("level", "How hard to look, as simplify's level."));
+
+ /// .
+ public static Query> Latex { get; } = new(
+ "latex", "Writes the expression as LaTeX.",
+ static e => e.Latexize());
+
+ /// .
+ public static Query> Sympy { get; } = new(
+ "sympy", "Writes the expression as Python code for SymPy.",
+ static e => MathS.ToSympyCode(e));
+
+ /// .
+ public static Query, Matrix?>> TruthTable { get; } = new(
+ "truth_table", "Tabulates a statement over every assignment of true and false to its variables.",
+ static (e, variables) => MathS.Boolean.BuildTruthTable(e, [.. variables]),
+ new Parameter("variables", "The variables, in the order of the table's columns."));
+
+ /// .
+ public static Query, Matrix?>> SatisfyingAssignments { get; } = new(
+ "satisfying_assignments", "The assignments of true and false to a statement's variables under which it holds, one row each.",
+ static (e, variables) => MathS.SolveBooleanTable(e, [.. variables]),
+ new Parameter("variables", "The variables, in the order of the rows' columns."));
+
+ ///
+ /// , or
+ /// where there is no point.
+ ///
+ public static Query> Series { get; } = new(
+ "series", "Expands as a Taylor series in a variable, up to a degree, around a point or around zero.",
+ static (e, variable, degree, around) => around is null
+ ? MathS.Series.Maclaurin(e, degree, variable)
+ : MathS.Series.Taylor(e, degree, (variable, around)),
+ TheVariable, new Parameter("degree", "The highest power kept."), new Parameter("around", "The point, or none for zero."));
+
+ // ---------------------------------------------------------------- queries on a number
+
+ /// .
+ public static Query> ToBase { get; } = new(
+ "to_base", "Writes a real number in a base from 2 to 36.",
+ static (number, numberBase) => MathS.ToBaseN(number, numberBase),
+ new Parameter("base", "The base, from 2 to 36."));
+
+ /// .
+ public static Query> FromBase { get; } = new(
+ "from_base", "Reads a real number written in a base from 2 to 36.",
+ static (digits, numberBase) => MathS.FromBaseN(digits, numberBase),
+ new Parameter("base", "The base, from 2 to 36."));
+
+ // ---------------------------------------------------------------- queries on a matrix
+
+ /// .
+ public static Query> Determinant { get; } = new(
+ "determinant", "The determinant of a square matrix, or none for a matrix that is not square.",
+ static m => m.Determinant);
+
+ /// .
+ public static Query> Inverse { get; } = new(
+ "inverse", "The inverse of a square matrix, or none where it has none.",
+ static m => m.Inverse);
+
+ /// .
+ public static Query> Transpose { get; } = new(
+ "transpose", "The transpose of a matrix.",
+ static m => m.T);
+
+ /// .
+ public static Query> Rank { get; } = new(
+ "rank", "The rank of a matrix.",
+ static m => m.Rank);
+
+ /// .
+ public static Query> ReducedRowEchelonForm { get; } = new(
+ "reduced_row_echelon_form", "A matrix in reduced row echelon form.",
+ static m => m.ReducedRowEchelonForm);
+
+ /// .
+ public static Query> Trace { get; } = new(
+ "trace", "The trace of a square matrix.",
+ static m => m.Trace);
+
+ /// .
+ public static Query> TensorProduct { get; } = new(
+ "tensor_product", "The tensor product of two matrices.",
+ static (a, b) => Matrix.TensorProduct(a, b),
+ new Parameter("other", "The matrix on the right."));
+
+ // ---------------------------------------------------------------- queries on an integer
+
+ /// .
+ public static Query>> PrimeFactorization { get; } = new(
+ "prime_factorization", "The primes that divide an integer, each with its power.",
+ static n => MathS.NumberTheory.Factorize(n));
+
+ /// , evaluated.
+ public static Query> Totient { get; } = new(
+ "totient", "Euler's totient of an integer: how many positive integers up to it are coprime to it.",
+ static n => MathS.NumberTheory.Phi(n).Evaled);
+
+ /// .
+ public static Query> GreatestCommonDivisor { get; } = new(
+ "greatest_common_divisor", "The greatest common divisor of two integers.",
+ static (a, b) => MathS.NumberTheory.GreatestCommonDivisor(a, b),
+ new Parameter("other", "The second integer."));
+
+ /// .
+ public static Query> DivisorCount { get; } = new(
+ "divisor_count", "How many positive divisors an integer has.",
+ static n => MathS.NumberTheory.CountDivisors(n));
+
+ /// .
+ public static Query> IsPrime { get; } = new(
+ "is_prime", "Whether an integer is prime.",
+ static n => n.IsPrime);
+
+ // ---------------------------------------------------------------- the list
+
+ /// Every entry above, in the order written.
+ /// Last in the file, because static initialisers run in the order they are written.
+ public static IReadOnlyList All { get; } =
+ [
+ Simplify, Normalize, Expand, Factorize, Differentiate, Integrate, Limit, Substitute,
+ Solve, SolveSystem, Evaluate, EvaluateNumerically, Variables, Alternatives, Latex, Sympy, TruthTable, SatisfyingAssignments, Series,
+ ToBase, FromBase,
+ Determinant, Inverse, Transpose, Rank, ReducedRowEchelonForm, Trace, TensorProduct,
+ PrimeFactorization, Totient, GreatestCommonDivisor, DivisorCount, IsPrime,
+ ];
+
+ /// The entry named , or where there is none.
+ /// Its name, such as simplify.
+ public static Operation? Find(string name) => All.FirstOrDefault(operation => operation.Name == name);
+ }
+}
diff --git a/Sources/AngouriMath/Core/Operations/Operation.cs b/Sources/AngouriMath/Core/Operations/Operation.cs
new file mode 100644
index 000000000..356ddbcaf
--- /dev/null
+++ b/Sources/AngouriMath/Core/Operations/Operation.cs
@@ -0,0 +1,97 @@
+//
+// 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;
+using System.Collections.Generic;
+using AngouriMath.Core.Transformations;
+
+namespace AngouriMath.Core.Operations
+{
+ ///
+ /// One thing the library does, under the name a caller asks for it by. The terminal's
+ /// commands and the MCP server's tools are each made of these, and
+ /// lists every one.
+ ///
+ ///
+ /// Internal until v3, while the shape of an entry may still move. The stable surface is the
+ /// methods on and , and an entry answers exactly
+ /// what the method it names answers.
+ /// https://github.com/asc-community/AngouriMath/issues/1684
+ ///
+ internal abstract class Operation
+ {
+ private protected Operation(string name, string description, Parameter[] parameters)
+ {
+ Name = name;
+ Description = description;
+ Parameters = parameters;
+ }
+
+ /// The name a caller asks for it by, such as simplify.
+ public string Name { get; }
+
+ /// What it does, in one sentence.
+ public string Description { get; }
+
+ ///
+ /// What it takes besides its input, in the order its delegate takes them after the input.
+ ///
+ public IReadOnlyList Parameters { get; }
+
+ ///
+ public override string ToString() => Name;
+ }
+
+ /// One argument an takes besides its input.
+ /// Its name, such as variable.
+ /// What it is, in one sentence.
+ internal sealed record Parameter(string Name, string Description);
+
+ ///
+ /// An operation whose answer is an expression that stands in a stated relation to its
+ /// input: one of the s, applied as the method on
+ /// applies it.
+ ///
+ ///
+ /// Its signature: the input expression, then each of ,
+ /// and an expression back.
+ ///
+ internal sealed class TransformationOperation : Operation where TApply : Delegate
+ {
+ internal TransformationOperation(string name, string description,
+ TransformationRelation relation, TApply apply, params Parameter[] parameters)
+ : base(name, description, parameters)
+ {
+ Relation = relation;
+ Apply = apply;
+ }
+
+ /// What the answer claims about the input.
+ public TransformationRelation Relation { get; }
+
+ /// Answers.
+ public TApply Apply { get; }
+ }
+
+ ///
+ /// An operation whose answer is not an expression standing for the input: a set of
+ /// solutions, a number, text, a table.
+ ///
+ ///
+ /// Its signature: the input, then each of , and the
+ /// answer back.
+ ///
+ internal sealed class Query : Operation where TAsk : Delegate
+ {
+ internal Query(string name, string description, TAsk ask, params Parameter[] parameters)
+ : base(name, description, parameters)
+ => Ask = ask;
+
+ /// Answers.
+ public TAsk Ask { get; }
+ }
+}
diff --git a/Sources/Tests/UnitTests/Core/Operations/CatalogueTest.cs b/Sources/Tests/UnitTests/Core/Operations/CatalogueTest.cs
new file mode 100644
index 000000000..288aa6743
--- /dev/null
+++ b/Sources/Tests/UnitTests/Core/Operations/CatalogueTest.cs
@@ -0,0 +1,231 @@
+//
+// 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;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using AngouriMath;
+using AngouriMath.Core;
+using AngouriMath.Core.Operations;
+using AngouriMath.Core.Transformations;
+using Xunit;
+using static AngouriMath.Entity;
+
+namespace AngouriMath.Tests.Core.Operations
+{
+ ///
+ /// The catalogue of operations: that it lists every entry it declares, that each entry
+ /// describes the delegate it holds, and that every factory of
+ /// is either an entry or a building block that says why it is not.
+ /// https://github.com/asc-community/AngouriMath/issues/1684
+ ///
+ [Trait("Area", "Transformations")]
+ public sealed class CatalogueTest
+ {
+ ///
+ /// Each factory of that an entry applies, by the entry's name.
+ ///
+ private static readonly Dictionary EntryOfFactory = new()
+ {
+ [nameof(Transformation.Simplification)] = "simplify",
+ [nameof(Transformation.SimplificationAtLevel)] = "simplify",
+ [nameof(Transformation.InnerSimplification)] = "normalize",
+ [nameof(Transformation.Expansion)] = "expand",
+ [nameof(Transformation.ExpansionAtLevel)] = "expand",
+ [nameof(Transformation.Factorization)] = "factorize",
+ [nameof(Transformation.FactorizationAtLevel)] = "factorize",
+ [nameof(Transformation.Differentiation)] = "differentiate",
+ [nameof(Transformation.Integration)] = "integrate",
+ [nameof(Transformation.LimitAt)] = "limit",
+ [nameof(Transformation.Substitution)] = "substitute",
+ };
+
+ ///
+ /// The factories that are parts for a caller to compose rather than operations to ask
+ /// for by name, each with the reason.
+ ///
+ private static readonly Dictionary BuildingBlocks = new()
+ {
+ [nameof(Transformation.Rewriting)] = "applies a rule set the caller passes, which no text names",
+ [nameof(Transformation.NumericContentExtraction)] = "the last step of factorize",
+ [nameof(Transformation.AsSingleFraction)] = "a step of the rational canonical form",
+ [nameof(Transformation.RuleBasedFactorizationAtLevel)] = "the rule-based half of factorize",
+ [nameof(Transformation.PolynomialFactorization)] = "the polynomial half of factorize",
+ [nameof(Transformation.Normalization)] = "orders commutative operands, so that expressions can be compared rather than simplified",
+ [nameof(Transformation.RationalCanonicalization)] = "a canonical form for comparing rational functions, which nothing runs by default",
+ [nameof(Transformation.Canonicalization)] = "a canonical form for comparing expressions, which nothing runs by default",
+ [nameof(Transformation.Rationalization)] = "a step simplify takes",
+ [nameof(Transformation.EqualitySaturation)] = "an experimental search, which takes a budget and a cost model",
+ [nameof(Transformation.CanonicalizationOverGraph)] = "an experimental search, which takes a budget",
+ };
+
+ private static readonly Variable x = MathS.Var("x");
+
+ private static string DelegateName(Operation operation) => operation.GetType().GetGenericArguments().Single().Name;
+
+ [Fact]
+ public void TheListHoldsEveryEntryOnce()
+ {
+ var declared = typeof(Catalogue)
+ .GetProperties(BindingFlags.Public | BindingFlags.Static)
+ .Where(property => typeof(Operation).IsAssignableFrom(property.PropertyType))
+ .Select(property => (Operation)property.GetValue(null)!)
+ .ToList();
+ Assert.Equal(declared.Count, Catalogue.All.Count);
+ Assert.Equal(declared.OrderBy(o => o.Name), Catalogue.All.OrderBy(o => o.Name));
+ Assert.Equal(Catalogue.All.Count, Catalogue.All.Select(o => o.Name).Distinct().Count());
+ }
+
+ [Fact]
+ public void NamesAreLowercaseWordsJoinedByUnderscores()
+ {
+ foreach (var operation in Catalogue.All)
+ Assert.Matches("^[a-z]+(_[a-z]+)*$", operation.Name);
+ }
+
+ [Fact]
+ public void FindAnswersByName()
+ {
+ foreach (var operation in Catalogue.All)
+ Assert.Same(operation, Catalogue.Find(operation.Name));
+ Assert.Null(Catalogue.Find("simp"));
+ }
+
+ [Fact]
+ public void EachDescriptionIsOneSentence()
+ {
+ foreach (var operation in Catalogue.All)
+ foreach (var description in operation.Parameters.Select(p => p.Description).Prepend(operation.Description))
+ {
+ Assert.False(string.IsNullOrWhiteSpace(description), operation.Name);
+ Assert.DoesNotContain('\n', description);
+ Assert.EndsWith(".", description);
+ }
+ }
+
+ ///
+ /// The parameters are the delegate's arguments after its input, so a parameter added to
+ /// one and not the other is caught here.
+ ///
+ [Fact]
+ public void TheParametersAreTheDelegatesArgumentsAfterTheInput()
+ {
+ foreach (var operation in Catalogue.All)
+ {
+ var invoke = operation.GetType().GetGenericArguments().Single().GetMethod("Invoke")!;
+ Assert.True(invoke.GetParameters().Length == operation.Parameters.Count + 1,
+ $"{operation.Name}: {DelegateName(operation)} takes {invoke.GetParameters().Length} arguments, and it names {operation.Parameters.Count} parameters");
+ Assert.Equal(operation.Parameters.Count, operation.Parameters.Select(p => p.Name).Distinct().Count());
+ }
+ }
+
+ ///
+ /// A factory added to fails here until it is either an
+ /// entry or a building block, and a name that leaves it fails here too.
+ ///
+ [Fact]
+ public void EveryFactoryOfTransformationIsPlaced()
+ {
+ var factories = typeof(Transformation)
+ .GetMembers(BindingFlags.Public | BindingFlags.Static)
+ .Where(member => member switch
+ {
+ PropertyInfo property => property.PropertyType == typeof(Transformation),
+ MethodInfo method => method.ReturnType == typeof(Transformation) && !method.IsSpecialName,
+ _ => false,
+ })
+ .Select(member => member.Name)
+ .ToHashSet();
+
+ var unplaced = factories.Where(name => !EntryOfFactory.ContainsKey(name) && !BuildingBlocks.ContainsKey(name)).ToList();
+ Assert.True(unplaced.Count == 0, $"Not an entry and not a building block: {string.Join(", ", unplaced)}");
+ var stale = EntryOfFactory.Keys.Concat(BuildingBlocks.Keys).Where(name => !factories.Contains(name)).ToList();
+ Assert.True(stale.Count == 0, $"Listed here and no longer a factory: {string.Join(", ", stale)}");
+ Assert.Empty(EntryOfFactory.Keys.Intersect(BuildingBlocks.Keys));
+ foreach (var entry in EntryOfFactory.Values)
+ Assert.NotNull(Catalogue.Find(entry));
+ }
+
+ public static readonly IEnumerable