Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions BREAKING-CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 32 additions & 2 deletions Sources/AngouriMath/Core/Transformations/DerivationPath.cs
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,11 @@ public string Explain()
/// </example>
public sealed class DerivationPath
{
internal DerivationPath(Entity input, Entity result, IReadOnlyList<DerivationStep> steps, int expressionsExplored)
=> (Input, Result, Steps, ExpressionsExplored) = (input, result, steps, expressionsExplored);
internal DerivationPath(
Entity input, Entity result, IReadOnlyList<DerivationStep> steps,
IReadOnlyList<DerivationStep> abandoned, int expressionsExplored)
=> (Input, Result, Steps, Abandoned, ExpressionsExplored)
= (input, result, steps, abandoned, expressionsExplored);

/// <summary>Where it started.</summary>
public Entity Input { get; }
Expand All @@ -181,6 +184,33 @@ internal DerivationPath(Entity input, Entity result, IReadOnlyList<DerivationSte
/// </summary>
public IReadOnlyList<DerivationStep> Steps { get; }

/// <summary>
/// The steps the search took and came back from: everything it tried that is not on
/// <see cref="Steps"/>.
/// </summary>
/// <remarks>
/// <para>
/// Simplifying is a search, and <see cref="Steps"/> 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
/// <a href="https://github.com/asc-community/AngouriMath/issues/273">#273</a> that asks
/// to see where a method failed and went back.
/// </para>
/// <para>
/// Each of these carries its own <see cref="DerivationStep.Before"/>, 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.
/// </para>
/// <para>
/// <b>Not an error list.</b> 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.
/// </para>
/// </remarks>
public IReadOnlyList<DerivationStep> Abandoned { get; }

/// <summary>
/// How many distinct expressions the search produced on the way, of which
/// <see cref="Steps"/> is the chain it kept.
Expand Down
59 changes: 49 additions & 10 deletions Sources/AngouriMath/Core/Transformations/RewriteRecording.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DerivationStep>(), produced.Count);
// Nothing was abandoned to get nowhere: the input was already the answer.
return new DerivationPath(
input, result, Array.Empty<DerivationStep>(),
Array.Empty<DerivationStep>(), produced.Count);

var reachedBy = new Dictionary<Entity, int>();
var seen = new HashSet<Entity> { input };
Expand Down Expand Up @@ -293,17 +296,48 @@ internal void Note(Entity before, Entity after, RewriteRuleSet? ruleSet, string
var recordedSteps = steps.ToArray();
var path = new List<DerivationStep>(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<int>(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<DerivationStep>();
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<RewriteStep>();
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);
}

/// <summary>One recorded edge as a <see cref="DerivationStep"/>, with the rewrites that
/// fired inside it attached.</summary>
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<RewriteStep>();
Array.Copy(recordedSteps, from, rewrites, 0, rewrites.Length);
return new DerivationStep(before, after, ruleSet, name, rewrites);
}

/// <summary>One recorded whole-expression step, before the rewrites inside it are attached.</summary>
Expand All @@ -322,6 +356,11 @@ internal Edge(Entity before, Entity after, RewriteRuleSet? ruleSet, string name,

internal Entity Before => before;

/// <summary>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.</summary>
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);
}
Expand Down
1 change: 1 addition & 0 deletions Sources/Tests/UnitTests/Common/PublicApi.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.DerivationStep>
AngouriMath.Core.Transformations.DerivationPath.Explain() : System.String
AngouriMath.Core.Transformations.DerivationPath.ExpressionsExplored { } : System.Int32
AngouriMath.Core.Transformations.DerivationPath.Input { } : AngouriMath.Entity
Expand Down
150 changes: 150 additions & 0 deletions Sources/Tests/UnitTests/Core/Transformations/AbandonedBranchesTest.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Where the search went and came back from.
/// <a href="https://github.com/asc-community/AngouriMath/issues/273">#273</a>
/// </summary>
/// <remarks>
/// <para>
/// #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 —
/// <see cref="RewriteRecording"/> and <see cref="DerivationPath"/>. This is the second.
/// </para>
/// <para>
/// The data was recorded all along: <c>PathFrom</c> 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 <see cref="DerivationStep.Before"/> — the expression the search was at
/// when it tried this, and the one it returned to.
/// </para>
/// </remarks>
[Trait("Area", "Core")]
public sealed class AbandonedBranchesTest
{
/// <summary>
/// The kept chain is a branch of a search, not a walk to the answer, and the abandoned
/// steps are what says so. <c>(x + 1)^2</c> is the clearest small case: the search
/// expands it and comes back, because the unexpanded form rates better.
/// </summary>
[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);
}

/// <summary>
/// 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.
/// </summary>
[Fact]
public void EveryBranchLeavesFromSomewhereTheSearchHadBeen()
{
var path = DerivationPath.OfSimplifying("x ^ (-1) / (y / z)".ToEntity());
Assert.NotNull(path);

var reached = new System.Collections.Generic.HashSet<Entity> { 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);
}

/// <summary>
/// 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.
/// </summary>
[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);
}
}

/// <summary>
/// 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: <c>x^(-1)/(y/z)</c> 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.
/// </summary>
[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);
}

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// The count of explored expressions and the two lists tell one story: the search
/// produced <see cref="DerivationPath.ExpressionsExplored"/> distinct expressions, and
/// what the two lists reach cannot exceed that.
/// </summary>
[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");
}
}
}
Loading