diff --git a/Sources/AngouriMath/Core/Transformations/EGraph.cs b/Sources/AngouriMath/Core/Transformations/EGraph.cs
index e4ffadba9..f58701a45 100644
--- a/Sources/AngouriMath/Core/Transformations/EGraph.cs
+++ b/Sources/AngouriMath/Core/Transformations/EGraph.cs
@@ -98,14 +98,6 @@ public override int GetHashCode()
///
internal sealed class EGraph
{
- ///
- /// How deep will chain through child
- /// classes before declining to build. A crash guard rather than a quality knob: textbook
- /// input nests nowhere near this far, so the cap is only reached where the alternative is
- /// an uncatchable stack overflow.
- ///
- private const int MaxExtractionDepth = 256;
-
private readonly List parent = new();
private readonly Dictionary hashcons = new();
private readonly Dictionary> classes = new();
@@ -336,6 +328,7 @@ internal bool Union(int left, int right)
left = Find(left);
right = Find(right);
if (left == right) return false;
+ extractMemo = null;
parent[right] = left;
foreach (var node in classes[right]) classes[left].Add(node);
classes.Remove(right);
@@ -408,7 +401,25 @@ internal int AddEntity(Entity expr)
/// type does not build.
///
internal Entity? Extract(int id, Func cost)
- => Extract(id, new Cheapest(cost), new Dictionary(), new HashSet());
+ {
+ // One extraction of the whole graph, kept for as long as the graph does not change
+ // and the cost is the same delegate. Only a union can change what an existing class
+ // extracts as -- a new class never alters an old one -- so Union is where it is
+ // dropped. Before this, every witness an e-match attempt wanted was a fresh walk,
+ // once per bound name per binding and once per predicated hole per candidate class,
+ // and one such walk of a 253-node graph took 1.9 s: the walk was top-down with a
+ // cycle guard, and a graph full of union-made cycles defeats a memo that fills only
+ // on completion. https://github.com/asc-community/AngouriMath/issues/1199
+ if (!ReferenceEquals(cost, memoCost) || extractMemo is null)
+ {
+ memoCost = cost;
+ extractMemo = ExtractAll(new Cheapest(cost));
+ }
+ return extractMemo.TryGetValue(Find(id), out var best) ? best : null;
+ }
+
+ private Dictionary? extractMemo;
+ private Func? memoCost;
///
/// The least entity the e-class can be built as under
@@ -421,7 +432,7 @@ internal int AddEntity(Entity expr)
/// well-defined expression where "the least member" is. See .
///
internal Entity? ExtractLeast(int id, IComparer order)
- => Extract(id, new Least(order), new Dictionary(), new HashSet());
+ => ExtractAll(new Least(order)).TryGetValue(Find(id), out var least) ? least : null;
///
/// How Extract chooses between the members of one e-class. A
@@ -488,46 +499,61 @@ public void Offer(Entity candidate)
public readonly Entity? Best => best;
}
- private Entity? Extract(int id, TSelection seed,
- Dictionary memo, HashSet visiting)
+ ///
+ /// The best member of every class under , from the leaves up: a
+ /// round recomputes each class from its children's current best, and the rounds stop
+ /// when one changes nothing. No recursion, so no depth to guard and no stack to exhaust;
+ /// a member that reaches back into its own class through a union is built from that
+ /// class's current best and offered like any other, and loses to it under any selection
+ /// that does not prefer the larger of two writings of one thing. Rounds are bounded by
+ /// the longest chain of classes, and capped at the class count for a selection that is
+ /// not monotone in its children.
+ ///
+ private Dictionary ExtractAll(TSelection seed)
where TSelection : struct, ISelection
{
- id = Find(id);
- if (memo.TryGetValue(id, out var done)) return done;
- // visiting is the chain currently being expanded, so its size is this call's depth.
- // The cycle guard below bounds that chain only by the number of distinct classes,
- // which unions grow past the input expression's own syntactic depth -- and a
- // StackOverflowException cannot be caught, so exhausting the stack takes the process
- // down rather than failing one call. Declining to build is the answer the cycle case
- // already gives, and the same shape as Gruntz's own MaxDepth.
- if (visiting.Count >= MaxExtractionDepth) return null;
- if (!visiting.Add(id)) return null; // a cycle; the other node will do
- var selection = seed;
- selection.Begin();
- // Ordered, not as the set enumerates: where the selection ties, the answer is
- // whichever candidate was reached first, and set order is neither specified nor
- // stable across processes.
- foreach (var node in NodesOf(id).OrderBy(node => node))
+ var best = new Dictionary();
+ var leaves = new Dictionary();
+ // Ordered, not as the dictionary enumerates: where the selection ties, the answer is
+ // whichever candidate was reached first, and dictionary and set order are neither
+ // specified nor stable across processes.
+ var ids = classes.Keys.OrderBy(id => id).ToList();
+ for (var round = 0; round <= ids.Count; round++)
{
- var parts = new Entity[node.Children.Length];
- var ok = true;
- for (var i = 0; i < parts.Length && ok; i++)
+ var changed = false;
+ foreach (var id in ids)
{
- var part = Extract(node.Children[i], seed, memo, visiting);
- if (part is null) ok = false;
- else parts[i] = part;
+ var selection = seed;
+ selection.Begin();
+ foreach (var node in classes[id].OrderBy(node => node))
+ {
+ var parts = new Entity[node.Children.Length];
+ var ok = true;
+ for (var i = 0; i < parts.Length && ok; i++)
+ if (best.TryGetValue(Find(node.Children[i]), out var part)) parts[i] = part;
+ else ok = false;
+ if (!ok) continue;
+ Entity? built;
+ if (parts.Length == 0)
+ {
+ if (!leaves.TryGetValue(node.Op, out built))
+ leaves[node.Op] = built = TryParseLeaf(node.Op);
+ }
+ else built = MatchPattern.ConstructNode(OperatorType(node.Op), parts);
+ if (built is null) continue;
+ if (node.Codomain is { } domain) built = built.WithCodomain(domain);
+ selection.Offer(built);
+ }
+ var chosen = selection.Best;
+ if (chosen is null) continue;
+ if (!best.TryGetValue(id, out var incumbent) || !chosen.Equals(incumbent))
+ {
+ best[id] = chosen;
+ changed = true;
+ }
}
- if (!ok) continue;
- Entity? built = parts.Length == 0
- ? TryParseLeaf(node.Op)
- : MatchPattern.ConstructNode(OperatorType(node.Op), parts);
- if (built is null) continue;
- if (node.Codomain is { } domain) built = built.WithCodomain(domain);
- selection.Offer(built);
+ if (!changed) break;
}
- visiting.Remove(id);
- var best = selection.Best;
- if (best is not null) memo[id] = best;
return best;
}
diff --git a/Sources/AngouriMath/Core/Transformations/Matching/MatchedRule.cs b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRule.cs
index 123addd56..2044a1043 100644
--- a/Sources/AngouriMath/Core/Transformations/Matching/MatchedRule.cs
+++ b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRule.cs
@@ -264,14 +264,31 @@ private MatchedRule(
///
internal bool TryEMatchApply(
EGraph graph, int classId, Func cost, out int resultClassId)
+ => TryEMatchApply(graph, classId, cost, null, out resultClassId);
+
+ ///
+ /// , charging
+ /// once for every binding tried after the first. A binding tried
+ /// is a match attempt: the caller charged the first, and an attempt over a class with a
+ /// thousand members is a thousand of them and not one -- which is what let a two-second
+ /// budget run for sixty-nine. A from
+ /// ends the attempt as not matched; the caller's ledger says why.
+ /// https://github.com/asc-community/AngouriMath/issues/1199
+ ///
+ internal bool TryEMatchApply(
+ EGraph graph, int classId, Func cost, Func? spend, out int resultClassId)
{
if (!Left.CanEMatch)
throw new InvalidOperationException(
$"'{Name}' cannot e-match; check {nameof(Left)}.{nameof(MatchPattern.CanEMatch)} first.");
resultClassId = 0;
+ var first = true;
foreach (var ebindings in Left.EMatch(graph, classId, EBindings.Empty, cost))
{
+ if (!first && spend is not null && !spend()) return false;
+ first = false;
+
Bindings? entityBindings = null;
bool TryEntityBindings(out Bindings result)
{
diff --git a/Sources/AngouriMath/Core/Transformations/Saturation.cs b/Sources/AngouriMath/Core/Transformations/Saturation.cs
index 0703d2ba0..130ad9657 100644
--- a/Sources/AngouriMath/Core/Transformations/Saturation.cs
+++ b/Sources/AngouriMath/Core/Transformations/Saturation.cs
@@ -120,6 +120,10 @@ bool ChargeGrowthSinceLastCall()
return ledger.Spend(delta);
}
+ // Handed to the e-matcher so that a binding tried is a step, not only the attempt --
+ // allocated once here rather than per attempt.
+ Func spend = () => ledger.Spend();
+
var saturated = false;
while (!saturated && !ledger.Exhausted)
{
@@ -189,7 +193,7 @@ bool TryTerm(out Entity value)
// wrote, and a predicate that throws on a shape it did not expect must
// decline the candidate, not escape Apply.
bool matched;
- try { matched = rule.TryEMatchApply(graph, id, witnessCost, out other); }
+ try { matched = rule.TryEMatchApply(graph, id, witnessCost, spend, out other); }
catch { continue; }
if (!matched) continue;
}
diff --git a/Sources/Tests/UnitTests/Core/Transformations/ConstantFoldTest.cs b/Sources/Tests/UnitTests/Core/Transformations/ConstantFoldTest.cs
index 8c0690014..1cd4fa83d 100644
--- a/Sources/Tests/UnitTests/Core/Transformations/ConstantFoldTest.cs
+++ b/Sources/Tests/UnitTests/Core/Transformations/ConstantFoldTest.cs
@@ -35,14 +35,18 @@ namespace AngouriMath.Tests.Core.Transformations
/// to 7, and the run from more than ten minutes to 24 seconds.
///
///
- /// What is left is a stall, not a runaway. The seven are a rational coefficient
- /// beside a variable — 2 * x * 1/2, x / x * -x, x ^ 2 / x — whose
- /// spellings 1/2 * x, x / 2, x * 2 ^ (-1) the Rearranges rules
- /// keep exchanging. Given fifteen times the budget they still report no fixed point, but
- /// the graph barely grows (135–583 e-nodes after thirty seconds) and every one extracts
- /// the right answer. So the claim in RunawayBreadthTest that the coefficient rules
- /// are confluent on plain arithmetic is true of the three inputs it pins and not of the
- /// family; the honest statement is that they are bounded and correct, and not confluent.
+ /// What is left is bounded by the budget and by nothing else. The seven are a
+ /// rational coefficient beside a variable — 2 * x * 1/2, x / x * -x,
+ /// x ^ 2 / x — whose spellings 1/2 * x, x / 2, x * 2 ^ (-1) the
+ /// Rearranges rules keep exchanging. Measured once extraction stopped being the
+ /// cost (#1199):
+ /// x ^ 2 / x, x / x * -x and x / x * x * 1/2 are genuine runaways,
+ /// ten to twelve thousand e-nodes in thirty seconds; 2 * x * 1/2 and its relatives
+ /// plateau at some 560 e-nodes and stop only when 200,000 steps run out. An earlier
+ /// version of this remark called them stalls at 135–583 e-nodes, which was the slow
+ /// extraction and not the graph. Every one extracts the right answer regardless, and the
+ /// claim in RunawayBreadthTest that the coefficient rules are confluent on plain
+ /// arithmetic is true of the three inputs it pins and not of the family.
///
///
/// On the corpus the ceiling is cheap and finds a little more. Every one of the 40
@@ -131,11 +135,12 @@ public void ConstantOnlyArithmeticSaturatesAtTheSafeCeiling(string source)
}
///
- /// Pinned in both directions: an entry that starts saturating is to be deleted, and one
- /// whose graph grows past a thousand e-nodes has stopped being a stall.
+ /// Pinned in both directions under a three-thousand-step budget: an entry that starts
+ /// saturating is to be deleted, and one whose graph passes a thousand e-nodes within
+ /// those steps has changed character. The extraction must be right either way.
///
[Fact]
- public void ACoefficientBesideAVariableStallsRatherThanRunsAway()
+ public void ACoefficientBesideAVariableIsBoundedByTheBudgetAndExtractsRight()
{
var stalls = new[] { "2 * x * 1/2", "x * 1/2 * 2", "x ^ 2 / x", "x / x * -x" };
var answers = new[] { "x", "x", "x", "-x" };
diff --git a/Sources/Tests/UnitTests/Core/Transformations/EGraphTest.cs b/Sources/Tests/UnitTests/Core/Transformations/EGraphTest.cs
index ce3a75167..24ca601cc 100644
--- a/Sources/Tests/UnitTests/Core/Transformations/EGraphTest.cs
+++ b/Sources/Tests/UnitTests/Core/Transformations/EGraphTest.cs
@@ -282,15 +282,14 @@ public void ExtractSettlesACostTieOnADefinedOrder()
}
///
- /// recurses through an e-class's children, and its cycle
- /// guard bounds the chain only by the number of distinct classes -- which unions grow
- /// past the input expression's own syntactic depth. Past the cap it declines to build,
- /// the same answer the cycle case already gives, rather than exhausting the stack: a
- /// cannot be caught, so it takes the process
- /// down instead of failing one call.
+ /// used to recurse through an e-class's children and decline
+ /// to build past 256 levels, because a cannot
+ /// be caught. It is a fixed point from the leaves up now, with no stack to exhaust, so a
+ /// chain three hundred deep extracts as itself -- this test asserted
+ /// for that input while the guard existed.
///
[Fact]
- public void ExtractDeclinesToBuildPastItsDepthCap()
+ public void ExtractBuildsAChainDeeperThanTheOldCap()
{
Entity deep = "x".ToEntity();
for (var i = 0; i < 300; i++) deep += 1;
@@ -299,7 +298,7 @@ public void ExtractDeclinesToBuildPastItsDepthCap()
var extracted = graph.Extract(root, CostModel.Default.Cost);
- Assert.Null(extracted);
+ Assert.Equal(deep, extracted);
}
///