From 2cbf93aa8b5aafc20aa8f0733e6dfccfbfa57d5b Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Tue, 1 Sep 2026 10:49:38 +0000 Subject: [PATCH] The branches a simplification went down and came back from #273 asks for two things: recording the steps taken while solving, and being able to see the return to a branch's root where a method fails. The first was already here -- `RewriteRecording` and `DerivationPath`. This is the second, and it turned out to need no new recording at all. `PathFrom` searches the recorded edges for a chain from the input to the result and throws the rest away. The rest is exactly the branches: each carries the expression it left from, which is the root the search returned to. `DerivationPath.Abandoned` hands them over beside the `Steps` it kept. What that shows, on the smallest case that has any: simplifying `(x + 1)^2` keeps one step and abandons seven, among them `AsPolynomial` and `Expand` both reaching `x^2 + 2x + 1` -- the search expanding the square and coming back, because the unexpanded form rates better. A `Steps` list on its own reads as though the library had walked straight to the answer. **Deduplicated, and that is not tidying.** The simplifier runs the same passes over the same expressions at every level of its candidate search, so the raw edges are mostly one rewrite recorded over and over: `x^(-1)/(y/z)` produces 425 of them across 13 distinct steps. Worse, the raw list included edges the kept chain had also taken, so the branches read as though the search had rejected the very step it went on to keep -- `sin(x)^2 + cos(x)^2` reported two abandoned branches, both duplicates of its two kept steps, and now reports none. A list of 425 is a record of how often the search was asked rather than of where it went. Neither list is an error list. A step here is a road not taken, usually to a perfectly correct expression that rated worse than the one kept, and the documentation says so rather than leaving a reader to assume otherwise. One member added to the public surface, `PublicApi.txt` regenerated: nothing removed. Part of #273. --- BREAKING-CHANGES.md | 8 + .../Core/Transformations/DerivationPath.cs | 34 +++- .../Core/Transformations/RewriteRecording.cs | 59 +++++-- Sources/Tests/UnitTests/Common/PublicApi.txt | 1 + .../Transformations/AbandonedBranchesTest.cs | 150 ++++++++++++++++++ 5 files changed, 240 insertions(+), 12 deletions(-) create mode 100644 Sources/Tests/UnitTests/Core/Transformations/AbandonedBranchesTest.cs diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 2a5f4df00..6096d7fd5 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -4826,6 +4826,14 @@ And what cannot be compiled is named rather than failing obscurely. Not behavioural changes, listed so that a reader working through this file has the whole picture. +- **The branches a simplification went down and came back from.** + `DerivationPath.Abandoned`, beside the `Steps` it kept. The data was recorded already — + reconstructing the path searched the recorded edges and discarded everything not on the chain — + so this exposes it rather than collecting anything new. Each abandoned step carries the + expression it left from, which is the branch's root. Deduplicated against itself and against + the kept chain: the raw edges are mostly one rewrite recorded once per level of the candidate + search, and `x^(-1)/(y/z)` produced 425 of them across 13 distinct steps. + [#273](https://github.com/asc-community/AngouriMath/issues/273). - **A modulus node.** `Modf`, `MathS.Mod`, the `%` operator on `Entity` in C# and F#, the `mod` keyword in the parser, evaluation, simplification, differentiation, limits, both compilers, LaTeX and SymPy export. `a % b` on `Entity` is *not* `int`'s `%` — it is floored, and its documentation diff --git a/Sources/AngouriMath/Core/Transformations/DerivationPath.cs b/Sources/AngouriMath/Core/Transformations/DerivationPath.cs index d1e5bbe05..e37f2ef1f 100644 --- a/Sources/AngouriMath/Core/Transformations/DerivationPath.cs +++ b/Sources/AngouriMath/Core/Transformations/DerivationPath.cs @@ -166,8 +166,11 @@ public string Explain() /// public sealed class DerivationPath { - internal DerivationPath(Entity input, Entity result, IReadOnlyList steps, int expressionsExplored) - => (Input, Result, Steps, ExpressionsExplored) = (input, result, steps, expressionsExplored); + internal DerivationPath( + Entity input, Entity result, IReadOnlyList steps, + IReadOnlyList abandoned, int expressionsExplored) + => (Input, Result, Steps, Abandoned, ExpressionsExplored) + = (input, result, steps, abandoned, expressionsExplored); /// Where it started. public Entity Input { get; } @@ -181,6 +184,33 @@ internal DerivationPath(Entity input, Entity result, IReadOnlyList public IReadOnlyList Steps { get; } + /// + /// The steps the search took and came back from: everything it tried that is not on + /// . + /// + /// + /// + /// Simplifying is a search, and is only the branch that survived it. + /// Presented alone it reads as though the library had walked straight to the answer, + /// which is the half of + /// #273 that asks + /// to see where a method failed and went back. + /// + /// + /// Each of these carries its own , which is the + /// expression the search was at when it tried this and the one it returned to — the + /// branch's root, in the issue's words. Several abandoned steps may share one, which is + /// that expression having been tried several ways. + /// + /// + /// Not an error list. A step here is not wrong; it is a road not taken, and often + /// one that leads to a perfectly correct expression that simply rated worse than the one + /// kept. The simplifier keeps the best candidate rather than the first, so most of these + /// are alternatives rather than failures. + /// + /// + public IReadOnlyList Abandoned { get; } + /// /// How many distinct expressions the search produced on the way, of which /// is the chain it kept. diff --git a/Sources/AngouriMath/Core/Transformations/RewriteRecording.cs b/Sources/AngouriMath/Core/Transformations/RewriteRecording.cs index 91963b03f..a631a2d6b 100644 --- a/Sources/AngouriMath/Core/Transformations/RewriteRecording.cs +++ b/Sources/AngouriMath/Core/Transformations/RewriteRecording.cs @@ -260,7 +260,10 @@ internal void Note(Entity before, Entity after, RewriteRuleSet? ruleSet, string // `4` before the candidate search starts, and asking how is a fair question with // "it did not have to do anything" as the answer. if (input == result) - return new DerivationPath(input, result, Array.Empty(), produced.Count); + // Nothing was abandoned to get nowhere: the input was already the answer. + return new DerivationPath( + input, result, Array.Empty(), + Array.Empty(), produced.Count); var reachedBy = new Dictionary(); var seen = new HashSet { input }; @@ -293,17 +296,48 @@ internal void Note(Entity before, Entity after, RewriteRuleSet? ruleSet, string var recordedSteps = steps.ToArray(); var path = new List(chain.Count); foreach (var edge in chain) + path.Add(Step(all, edge, recordedSteps)); + + // Everything the search tried and came back from. The chain above is the branch that + // survived, and handing it over on its own reads as though the library had walked + // straight to the answer -- which #273's second half is precisely about. + // + // Deduplicated, and that is not tidying. The simplifier runs the same passes over the + // same expressions at every level of its candidate search, so the raw edges are + // mostly one rewrite recorded over and over: `x^(-1)/(y/z)` produces 425 of them + // across 8 distinct steps, and one expression's list included the very edge the kept + // chain had taken. A list like that is not a record of where the search went, it is a + // record of how often it was asked. + // https://github.com/asc-community/AngouriMath/issues/273 + var kept = new HashSet(chain); + var seenStep = new HashSet<(Entity, Entity, string)>(); + foreach (var edge in chain) + seenStep.Add((all[edge].Before, all[edge].After, all[edge].Name)); + var abandoned = new List(); + for (var edge = 0; edge < all.Length; edge++) { - var (before, after, ruleSet, name, from, to) = all[edge]; - // Clamped because a recording that is still being written to can hand back - // fewer rewrites than an edge counted, and a torn read is not worth throwing over. - from = Math.Min(from, recordedSteps.Length); - to = Math.Min(to, recordedSteps.Length); - var rewrites = to > from ? new RewriteStep[to - from] : Array.Empty(); - Array.Copy(recordedSteps, from, rewrites, 0, rewrites.Length); - path.Add(new DerivationStep(before, after, ruleSet, name, rewrites)); + if (kept.Contains(edge)) + continue; + if (!seenStep.Add((all[edge].Before, all[edge].After, all[edge].Name))) + continue; + abandoned.Add(Step(all, edge, recordedSteps)); } - return new DerivationPath(input, result, path, produced.Count); + + return new DerivationPath(input, result, path, abandoned, produced.Count); + } + + /// One recorded edge as a , with the rewrites that + /// fired inside it attached. + private static DerivationStep Step(Edge[] all, int edge, RewriteStep[] recordedSteps) + { + var (before, after, ruleSet, name, from, to) = all[edge]; + // Clamped because a recording that is still being written to can hand back + // fewer rewrites than an edge counted, and a torn read is not worth throwing over. + from = Math.Min(from, recordedSteps.Length); + to = Math.Min(to, recordedSteps.Length); + var rewrites = to > from ? new RewriteStep[to - from] : Array.Empty(); + Array.Copy(recordedSteps, from, rewrites, 0, rewrites.Length); + return new DerivationStep(before, after, ruleSet, name, rewrites); } /// One recorded whole-expression step, before the rewrites inside it are attached. @@ -322,6 +356,11 @@ internal Edge(Entity before, Entity after, RewriteRuleSet? ruleSet, string name, internal Entity Before => before; + /// What did this step, named. Read when deduplicating the abandoned + /// branches, where two edges are the same step if they are between the same two + /// expressions and were done by the same thing. + internal string Name => name; + internal void Deconstruct(out Entity before, out Entity after, out RewriteRuleSet? ruleSet, out string name, out int from, out int to) => (before, after, ruleSet, name, from, to) = (this.before, this.after, this.ruleSet, this.name, this.from, this.to); } diff --git a/Sources/Tests/UnitTests/Common/PublicApi.txt b/Sources/Tests/UnitTests/Common/PublicApi.txt index 1b386dd48..561c7ea8d 100644 --- a/Sources/Tests/UnitTests/Common/PublicApi.txt +++ b/Sources/Tests/UnitTests/Common/PublicApi.txt @@ -153,6 +153,7 @@ AngouriMath.Core.Serialization.EntityJsonConverter.ReadAsPropertyName(System.Tex AngouriMath.Core.Serialization.EntityJsonConverter.Write(System.Text.Json.Utf8JsonWriter, AngouriMath.Entity, System.Text.Json.JsonSerializerOptions) : System.Void AngouriMath.Core.Serialization.EntityJsonConverter.WriteAsPropertyName(System.Text.Json.Utf8JsonWriter, AngouriMath.Entity, System.Text.Json.JsonSerializerOptions) : System.Void AngouriMath.Core.Serialization.EntityJsonConverter.ctor() +AngouriMath.Core.Transformations.DerivationPath.Abandoned { } : System.Collections.Generic.IReadOnlyList AngouriMath.Core.Transformations.DerivationPath.Explain() : System.String AngouriMath.Core.Transformations.DerivationPath.ExpressionsExplored { } : System.Int32 AngouriMath.Core.Transformations.DerivationPath.Input { } : AngouriMath.Entity diff --git a/Sources/Tests/UnitTests/Core/Transformations/AbandonedBranchesTest.cs b/Sources/Tests/UnitTests/Core/Transformations/AbandonedBranchesTest.cs new file mode 100644 index 000000000..24ebf9829 --- /dev/null +++ b/Sources/Tests/UnitTests/Core/Transformations/AbandonedBranchesTest.cs @@ -0,0 +1,150 @@ +// +// 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.Linq; +using AngouriMath.Core.Transformations; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Core.Transformations +{ + /// + /// Where the search went and came back from. + /// #273 + /// + /// + /// + /// #273 asks for two things: recording the steps taken while solving, and being able to see + /// the return to a branch's root where a method fails. The first was already there — + /// and . This is the second. + /// + /// + /// The data was recorded all along: PathFrom searches the recorded edges for a chain + /// from the input to the result and throws the rest away. Those are the branches, and each + /// carries its own — the expression the search was at + /// when it tried this, and the one it returned to. + /// + /// + [Trait("Area", "Core")] + public sealed class AbandonedBranchesTest + { + /// + /// The kept chain is a branch of a search, not a walk to the answer, and the abandoned + /// steps are what says so. (x + 1)^2 is the clearest small case: the search + /// expands it and comes back, because the unexpanded form rates better. + /// + [Fact] + public void TheSearchExpandsAndComesBack() + { + var path = DerivationPath.OfSimplifying("(x + 1)^2".ToEntity()); + Assert.NotNull(path); + + Assert.NotEmpty(path!.Abandoned); + // It reached the expanded form and did not keep it. + var expanded = "x ^ 2 + 2 * x + 1".ToEntity(); + Assert.Contains(path.Abandoned, step => step.After == expanded); + Assert.DoesNotContain(path.Steps, step => step.After == expanded); + Assert.NotEqual(expanded, path.Result); + } + + /// + /// Every abandoned step leaves from an expression the search had actually reached: its + /// root is the input, or something a kept step or another attempt produced. A step + /// leaving from nowhere would mean the branches were being read off the wrong recording. + /// + [Fact] + public void EveryBranchLeavesFromSomewhereTheSearchHadBeen() + { + var path = DerivationPath.OfSimplifying("x ^ (-1) / (y / z)".ToEntity()); + Assert.NotNull(path); + + var reached = new System.Collections.Generic.HashSet { path!.Input }; + foreach (var step in path.Steps) + reached.Add(step.After); + foreach (var step in path.Abandoned) + reached.Add(step.After); + + foreach (var step in path.Abandoned) + Assert.Contains(step.Before, reached); + } + + /// + /// Nothing on the kept chain is also reported as abandoned. It was the same edge in both + /// lists before they were deduplicated, which made the branch list read as though the + /// search had rejected the very step it took. + /// + [Fact] + public void AKeptStepIsNotAlsoAbandoned() + { + foreach (var source in new[] { "(x + 1)^2", "x ^ (-1) / (y / z)", "sin(x)^2 + cos(x)^2" }) + { + var path = DerivationPath.OfSimplifying(source.ToEntity()); + Assert.NotNull(path); + foreach (var kept in path!.Steps) + Assert.DoesNotContain(path.Abandoned, + step => step.Before == kept.Before + && step.After == kept.After + && step.Name == kept.Name); + } + } + + /// + /// And no step is reported twice. The simplifier runs the same passes over the same + /// expressions at every level of its candidate search, so the raw edges are mostly one + /// rewrite recorded over and over: x^(-1)/(y/z) produced 425 of them across 13 + /// distinct steps. A list of 425 is a record of how often the search was asked rather + /// than of where it went. + /// + [Fact] + public void NoBranchIsReportedTwice() + { + var path = DerivationPath.OfSimplifying("x ^ (-1) / (y / z)".ToEntity()); + Assert.NotNull(path); + + var distinct = path!.Abandoned + .Select(step => (step.Before, step.After, step.Name)) + .Distinct() + .Count(); + Assert.Equal(distinct, path.Abandoned.Count); + } + + /// + /// An expression that is already its own answer abandoned nothing, and one whose whole + /// derivation was kept abandoned nothing either. Both are zero rather than "unknown", + /// and asserting them keeps the list from filling up with noise unnoticed. + /// + [Theory] + [InlineData("2 + 2")] + [InlineData("sin(x)^2 + cos(x)^2")] + public void NothingIsAbandonedWhereNothingWasTried(string source) + { + var path = DerivationPath.OfSimplifying(source.ToEntity()); + Assert.NotNull(path); + Assert.Empty(path!.Abandoned); + } + + /// + /// The count of explored expressions and the two lists tell one story: the search + /// produced distinct expressions, and + /// what the two lists reach cannot exceed that. + /// + [Fact] + public void TheListsAgreeWithWhatWasExplored() + { + var path = DerivationPath.OfSimplifying("x ^ (-1) / (y / z)".ToEntity()); + Assert.NotNull(path); + + var produced = path!.Steps.Select(step => step.After) + .Concat(path.Abandoned.Select(step => step.After)) + .Distinct() + .Count(); + Assert.True(produced <= path.ExpressionsExplored, + $"the two lists reach {produced} expressions, but only " + + $"{path.ExpressionsExplored} were recorded as produced"); + } + } +}