diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 602e28d7f..31c091c21 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). - **An analytical solver for first-order linear ordinary differential equations.** `MathS.SolveOde(equation, function, variable)`, by the integrating factor. The unknown is written as an application — `apply(y, x)` — rather than a bare variable, because `derivative(y, x)` is 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 803db95a9..f03d344f8 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"); + } + } +}