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
139 changes: 139 additions & 0 deletions Sources/AngouriMath/Core/Transformations/Matching/MatchPattern.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,66 @@ internal bool TryMatchOnce(Entity expr, Bindings bindings, out Bindings result)
private protected abstract bool TryMatchOnceCore(
Entity expr, Bindings bindings, out Bindings result);

/// <summary>
/// How many candidate matches this pattern can offer at most, or
/// <see cref="Unbounded"/> when it cannot say.
/// </summary>
/// <remarks>
/// <para>
/// Between <see cref="IsDeterministic"/> — exactly one — and <see cref="Match"/> —
/// however many — sits the case that is neither and is very common: a
/// <see cref="Commutative{T}"/> node of deterministic children, which offers the written
/// order and the swapped one and nothing else. Enumerating two candidates through
/// <see cref="MatchCore"/> allocates an iterator state machine per pattern node, and a
/// rewrite pass makes an attempt at every node of the tree, so on a set that runs on
/// every pass that is measurable: 165.05 MB to 171.37 MB of <c>SolveMediumHard</c>, +3.8%,
/// for two commutative rules in <c>RewriteRules.Power</c>.
/// <a href="https://github.com/asc-community/AngouriMath/issues/1079">#1079</a>
/// </para>
/// <para>
/// This is an <b>upper bound</b>, not a count: a child whose name is already bound offers
/// one candidate or none depending on what it is asked to match, which is not known until
/// it is asked. <see cref="TryMatchChoice"/> answers <see langword="false"/> for an index
/// that does not exist, so a caller walks every index and skips the misses.
/// </para>
/// </remarks>
internal virtual int ChoiceCount => 1;

/// <summary>A pattern that cannot bound its candidates, and must be enumerated.</summary>
internal const int Unbounded = 0;

/// <summary>
/// The <paramref name="choice"/>th way this matches, counted the way
/// <see cref="Match"/> yields them — so choice <c>i</c> is the <c>i</c>th element of that
/// sequence, once the indices that do not exist are skipped.
/// </summary>
/// <remarks>
/// It must agree with <see cref="Match"/> in content and in order.
/// <c>BoundedMatchingAgreesWithEnumeration</c> is the test that holds the two together
/// over generated expressions, for the reason the deterministic path has one: two
/// implementations of one thing is how a matcher acquires a case where they differ.
/// </remarks>
internal bool TryMatchChoice(Entity expr, Bindings bindings, int choice, out Bindings result)
{
if (RootType is { } required && !required.IsInstanceOfType(expr))
{
result = bindings;
return false;
}
return TryMatchChoiceCore(expr, bindings, choice, out result);
}

/// <summary>
/// One candidate by index. The default is the deterministic one, which is right for every
/// pattern that offers a single match; <see cref="NodePattern"/> overrides it.
/// </summary>
private protected virtual bool TryMatchChoiceCore(
Entity expr, Bindings bindings, int choice, out Bindings result)
{
result = bindings;
return choice == 0 && TryMatchOnceCore(expr, bindings, out result);
}

/// <summary>The names this pattern binds, so a right-hand side can be checked for a typo.</summary>
internal abstract IEnumerable<string> BoundNames { get; }

Expand Down Expand Up @@ -574,6 +634,78 @@ private protected override bool TryMatchOnceCore(
return true;
}

/// <summary>
/// The product over the children, doubled for a commutative node because it offers
/// the written order and the swapped one. <see cref="Unbounded"/> as soon as one
/// child is, which is how a <see cref="GatheredPattern"/> anywhere inside makes the
/// whole pattern something to enumerate.
/// </summary>
internal override int ChoiceCount
{
get
{
if (choices != -1) return choices;
long total = 1;
foreach (var child in children)
{
var count = child.ChoiceCount;
if (count == Unbounded) return choices = Unbounded;
total *= count;
if (total > MaxChoices) return choices = Unbounded;
}
if (commutative) total *= 2;
return choices = total > MaxChoices ? Unbounded : (int)total;
}
}

/// <summary>
/// Past this a pattern is treated as unbounded. It is not a correctness limit — the
/// indexing is exact at any size — but a bound past which walking every index is no
/// longer obviously cheaper than enumerating, and a guard against a pattern whose
/// product overflows.
/// </summary>
private const int MaxChoices = 64;

private int choices = -1;

private protected override bool TryMatchChoiceCore(
Entity expr, Bindings bindings, int choice, out Bindings result)
{
result = bindings;
var actual = expr.DirectChildren;
if (actual.Count != children.Length) return false;

// `MatchCore` yields every solution in the written order first and then, for a
// commutative node, every solution in the swapped one -- so the low half of the
// index space is the written order and the high half is the swapped one.
var perOrder = ChoiceCount;
if (perOrder == Unbounded) return false;
var swapped = false;
if (commutative)
{
perOrder /= 2;
if (choice >= perOrder) { swapped = true; choice -= perOrder; }
}

if (choice < 0 || choice >= perOrder) return false;

// Mixed radix over the children, with the first child the most significant digit
// -- because `MatchInOrder` makes it the outermost loop, so it is the one that
// varies slowest in the sequence this has to agree with. The suffix product is
// recomputed rather than stored: there are one to three children, and an array
// here would be the allocation this whole path exists to avoid.
for (var i = 0; i < children.Length; i++)
{
var suffix = 1;
for (var j = i + 1; j < children.Length; j++) suffix *= children[j].ChoiceCount;
var digit = choice / suffix % children[i].ChoiceCount;
var against = swapped ? actual[children.Length - 1 - i] : actual[i];
if (!children[i].TryMatchChoice(against, result, digit, out result))
return false;
}
return true;
}

internal override bool IsBuildable => buildable;

internal override bool TryBuild(Bindings bindings, out Entity built)
Expand Down Expand Up @@ -703,6 +835,13 @@ private Entity Leftover(List<Entity> operands, bool[] used)
/// <summary>Choosing which operands the parts claim is the whole of what it does.</summary>
internal override bool IsDeterministic => false;

/// <summary>
/// Unbounded, and that is the point of it: how many ways k parts sit among n operands
/// is a property of the expression rather than of the pattern, so this is the one
/// shape that has to be enumerated.
/// </summary>
internal override int ChoiceCount => Unbounded;

private protected override bool TryMatchOnceCore(
Entity expr, Bindings bindings, out Bindings result)
=> throw new InvalidOperationException(
Expand Down
20 changes: 20 additions & 0 deletions Sources/AngouriMath/Core/Transformations/Matching/MatchedRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,26 @@ private MatchedRule(
return Build(expr, only);
}

// Between one match and however many sits the case that is neither and is common:
// a commutative node of deterministic children offers the written order and the
// swapped one and nothing else. Walking those by index costs nothing, where
// enumerating them allocates an iterator state machine per pattern node at every
// node of the tree -- 6.3 MB on `SolveMediumHard` for two commutative rules in
// `RewriteRules.Power`. https://github.com/asc-community/AngouriMath/issues/1079
//
// The index is an upper bound rather than a count, so a candidate that does not
// exist answers false and is skipped, exactly as an enumeration would omit it.
if (Left.ChoiceCount is var choices and not MatchPattern.Unbounded)
{
for (var choice = 0; choice < choices; choice++)
{
if (!Left.TryMatchChoice(expr, Bindings.Empty, choice, out var bound)) continue;
if (when is not null && !when(bound)) continue;
if (Build(expr, bound) is { } rewritten) return rewritten;
}
return null;
}

// Every way the pattern matches, in order, and the first that also satisfies the
// side condition wins. Taking only the first *match* would be wrong: commutativity
// means `b*a + c*a` matches `k*p + k*q` several ways and only some of them bind
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
// Website: https://am.angouri.org.
//

using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using AngouriMath.Core.Transformations;
using AngouriMath.Functions;
using AngouriMath;
using AngouriMath.Core.Transformations.Matching;
using AngouriMath.Extensions;
Expand All @@ -30,14 +34,30 @@ namespace AngouriMath.Tests.Core.Transformations
[Trait("Area", "Core")]
public sealed class DeterministicMatchingTest
{
private static IEnumerable<MatchedRuleSet> AllSets => new[]
/// <summary>
/// Every set in <see cref="MatchedRules"/>, read off the class rather than listed.
/// </summary>
/// <remarks>
/// It was a list of five, written when there were five, and it stayed five while the
/// class grew past twenty — so the test whose subject is "every rule" was looking at a
/// tenth of them. A list of names cannot notice that it is out of date; this can.
/// </remarks>
private static IEnumerable<MatchedRuleSet> AllSets
{
MatchedRules.DivisionPreparing,
MatchedRules.CollapseMultipleFractions,
MatchedRules.PowerOfPower,
MatchedRules.SharedFactor,
MatchedRules.PythagoreanIdentity,
};
get
{
const BindingFlags Any = BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static;
foreach (var property in typeof(MatchedRules).GetProperties(Any))
if (property.PropertyType == typeof(MatchedRuleSet))
yield return (MatchedRuleSet)property.GetValue(null)!;
foreach (var factory in typeof(MatchedRules).GetMethods(Any))
if (factory.ReturnType == typeof(MatchedRuleSet)
&& factory.GetParameters() is { Length: 1 } parameters
&& parameters[0].ParameterType == typeof(TreeAnalyzer.SortLevel))
foreach (var level in Enum.GetValues(typeof(TreeAnalyzer.SortLevel)))
yield return (MatchedRuleSet)factory.Invoke(null, new[] { level })!;
}
}

/// <summary>
/// Shapes chosen to hit the rules and to miss them, including the node types they are
Expand Down Expand Up @@ -91,6 +111,56 @@ public void DeterministicMatchingAgreesWithEnumeration()
Assert.True(checkedPairs > 100, $"only {checkedPairs} pattern/expression pairs checked");
}

/// <summary>
/// The same contract one step out: where a pattern can bound its candidates, walking them
/// by index must produce exactly the sequence enumerating them produces — same bindings,
/// same order, same count.
/// </summary>
/// <remarks>
/// Order is asserted and not only membership. <c>TryApply</c> takes the first candidate
/// that also satisfies the rule's <c>when</c>, so two implementations that agree on the
/// <i>set</i> of matches and differ on which comes first are two different rewriters.
/// <a href="https://github.com/asc-community/AngouriMath/issues/1079">#1079</a>
/// </remarks>
[Fact]
public void BoundedMatchingAgreesWithEnumeration()
{
var checkedPairs = 0;
var sawSeveral = 0;
foreach (var rule in AllSets.SelectMany(set => set.Rules))
{
var choices = rule.Left.ChoiceCount;
if (choices == MatchPattern.Unbounded) continue;
foreach (var text in Corpus)
{
var expr = text.ToEntity();
var enumerated = rule.Left.Match(expr, Bindings.Empty).ToList();

var byIndex = new List<Bindings>();
for (var choice = 0; choice < choices; choice++)
if (rule.Left.TryMatchChoice(expr, Bindings.Empty, choice, out var bound))
byIndex.Add(bound);

Assert.True(enumerated.Count == byIndex.Count,
$"{rule.Name} on '{text}': enumeration found {enumerated.Count} matches, "
+ $"indexing found {byIndex.Count}");
for (var i = 0; i < enumerated.Count; i++)
foreach (var name in rule.Left.BoundNames.Distinct())
{
Assert.True(enumerated[i].TryGet(name, out var slow));
Assert.True(byIndex[i].TryGet(name, out var fast));
Assert.Equal(slow, fast);
}
if (enumerated.Count > 1) sawSeveral++;
checkedPairs++;
}
}
Assert.True(checkedPairs > 500, $"only {checkedPairs} pattern/expression pairs checked");
// The whole point is the pattern that matches more than one way. If none of them did,
// this would be the deterministic test again under another name.
Assert.True(sawSeveral > 0, "no bounded pattern matched an expression more than one way");
}

// That the sets still *rewrite* the same thing end to end is not restated here:
// MatchedRulesAgreeWithTheSwitchTest already runs both the data sets and the `switch`
// they mirror over generated expressions and requires them to agree, and it now goes
Expand Down
Loading