Skip to content

Commit 2cff37e

Browse files
A rule's declared growth is checked against the corpus (#1158)
A rule's declared growth is checked against the corpus `RewriteRuleGrowth` is a claim about every expression a rule fires on -- Collects says the replacement is smaller, Rearranges the same size, Expands larger -- and for the thirty-five rules with a pattern on both sides it is counted from the two patterns. For the rest it is declared by hand and nothing checked it. It is worth checking because growth is load-bearing rather than documentation. Saturation.RulesUpTo selects by it, so a rule saying Collects or Rearranges is one equality saturation fires and a rule left at Unknown is one it does not. A declaration wrong in the shrinking direction tells the saturation a rewrite is safe to run when nobody established that. Running the declarations against the generated corpus finds one that is wrong. `a-common-factor-is-collected-out-of-a-whole-sum` said Collects, on the stated grounds that "the whole point of it is to be smaller". Over fourteen hundred expressions it fires forty-two times, never shrinks, and grows by as much as eight nodes: `-x + x + -1/2` becomes `x * (-1 + 1) + -1/2`, seven nodes for seven. Collects wants every firing smaller, Rearranges every one the same size, Expands every one bigger, and the measurements refute all three, so Unknown is the only one of the four that is true and that is what it now says. Deliberately not fixed by making the rule decline where it does not shrink. That changes what the rule does rather than what it says about itself, and the folding that turns `x * (-1 + 1)` into zero would have to be checked first -- `-x + x` may be reaching zero through exactly this step. The corpus can only refute. Firing on fourteen hundred expressions without contradicting a claim is evidence for it and not proof, since the claim is about every expression there is; the test fails on a contradiction and says nothing about a rule it could not make fire. Eighty-four rules reach it, eighteen of them carrying a declaration. The other sixty-six are the measured evidence for declaring more of them, and it distinguishes the ones that can be from the ones that cannot: `a-negative-factor-in-a-denominator-comes-out` is +2 across all eighty-four of its firings, `two-powers-of-one-base-multiply-by-adding-exponents` is -2 across all of its, while `a-term-added-to-itself-doubles` ranges -2 to 0 and is honestly Unknown. Full suite 9552 passed, 0 failed. Part of #825. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura
1 parent 0a8dff6 commit 2cff37e

5 files changed

Lines changed: 185 additions & 5 deletions

File tree

‎Sources/.editorconfig‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,3 +195,6 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed
195195

196196
[Tests/UnitTests/Calculus/IntegralAnswerCacheTest.cs]
197197
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n
198+
199+
[Tests/UnitTests/Core/Transformations/RuleGrowthAgreesWithTheCorpusTest.cs]
200+
file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed under MIT.\nDetails: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.\nWebsite: https://am.angouri.org.\n

‎Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,9 +1455,16 @@ is var (divided, remainder)
14551455
MatchPattern.Any<Entity>("x", node => node is Sumf or Minusf),
14561456
(node, _) => Functions.Patterns.CollectCommonFactors(node) ?? node,
14571457
Soundness.Sound,
1458-
description: "k*p + k*q + ... = k*(p + q + ...), over a whole sum rather than two of its terms",
1459-
// Declared, because the replacement is code and nothing counts it: the whole point of it is to be smaller.
1460-
growth: RewriteRuleGrowth.Collects));
1458+
description: "k*p + k*q + ... = k*(p + q + ...), over a whole sum rather than two of its terms"));
1459+
// Undeclared, and it used to say `Collects` on the grounds that the whole point of
1460+
// it is to be smaller. Measured against the corpus it never shrinks and sometimes
1461+
// grows by eight nodes: `-x + x + -1/2` becomes `x * (-1 + 1) + -1/2`, seven nodes
1462+
// for seven. Collects wants every firing smaller, Rearranges wants every one the
1463+
// same size, Expands wants every one bigger, and each of those has a
1464+
// counterexample here -- so Unknown is the only one of the four that is true.
1465+
// Making it collect for real means declining where it does not shrink, which is a
1466+
// change to what the rule does rather than to what it says about itself, and the
1467+
// folding that turns `x * (-1 + 1)` into zero would have to be checked first.
14611468

14621469
/// <summary>
14631470
/// <see cref="Functions.Patterns.TrigonometricRules"/>, as data.

‎Sources/AngouriMath/Docs/Contributing/WritingARule.md‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,26 @@ A pattern replacement gets:
133133
- **an exact growth**, counted from the two patterns rather than declared.
134134

135135
A code replacement gets neither, and its growth is `Unknown` unless you declare one. That is the
136-
honest default — **261** rules sit at `Unknown` — but declare it where you can justify it:
136+
honest default — **262** rules sit at `Unknown` — but declare it where you can justify it:
137137

138138
```csharp
139139
// The Chebyshev expansion of sin(n * a) is a sum of n terms where the pattern is one node,
140140
// for every n this fires on.
141141
RewriteRuleGrowth.Expands,
142142
```
143143

144+
**A declaration is a claim about every expression the rule fires on, and it is checked.**
145+
`RuleGrowthAgreesWithTheCorpusTest` runs every declaration against the generated corpus and fails on
146+
a contradiction, so "the whole point of it is to be smaller" is not a reason — that was the stated
147+
reason for the one declaration this caught, and the rule it was on never shrank over fourteen
148+
hundred expressions and grew by as much as eight nodes.
149+
150+
Growth is not documentation. `Saturation.RulesUpTo` selects by it, so `Collects` or `Rearranges`
151+
puts a rule among the ones equality saturation fires and `Unknown` keeps it out. Claiming a rule
152+
shrinks when it does not tells the saturation a rewrite is safe to run when nobody established
153+
that. A corpus can only refute — firing without contradiction is evidence, not proof — so where you
154+
cannot argue the claim from the code, leave it `Unknown`.
155+
144156
Take `(Entity node, Bindings bound)` rather than `(Bindings bound)` where the replacement needs the
145157
whole matched node. Rebuilding it from the bindings makes a different object with none of the
146158
original's cached `InnerSimplified`, `Evaled`, `Codomain` or rate — measured at **4.0–5.2 s** on one

‎Sources/Tests/UnitTests/Core/Transformations/RuleAuthoringGuideTest.cs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ public void ReplacementAPatternOrCode()
8484
"how many rules have a pattern on both sides");
8585
Stated(33, rules.Count(rule => rule.Reversed is not null),
8686
"how many two-sided rules have a direction");
87-
Stated(261, rules.Count(rule => rule.Growth is RewriteRuleGrowth.Unknown),
87+
Stated(262, rules.Count(rule => rule.Growth is RewriteRuleGrowth.Unknown),
8888
"how many rules sit at Unknown growth");
8989

9090
// And the two the document names. By their own names rather than by what the prose
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
//
2+
// Copyright (c) 2019-2026 Angouri.
3+
// AngouriMath is licensed under MIT.
4+
// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md.
5+
// Website: https://am.angouri.org.
6+
//
7+
8+
using System;
9+
using System.Collections.Generic;
10+
using System.Linq;
11+
using AngouriMath;
12+
using AngouriMath.Core.Transformations;
13+
using AngouriMath.Core.Transformations.Matching;
14+
using AngouriMath.Extensions;
15+
using Xunit;
16+
17+
namespace AngouriMath.Tests.Core.Transformations
18+
{
19+
/// <summary>
20+
/// A rule's <see cref="RewriteRuleGrowth"/> is a claim about every expression it fires on:
21+
/// <c>Collects</c> says the replacement is smaller, <c>Rearranges</c> that it is the same
22+
/// size, <c>Expands</c> that it is larger. Here that claim is run against a generated corpus
23+
/// instead of being taken on trust.
24+
/// </summary>
25+
/// <remarks>
26+
/// <para>
27+
/// It is worth checking because growth is <b>load-bearing</b> and not documentation:
28+
/// <c>Saturation.RulesUpTo</c> selects by it, so a rule declaring <c>Collects</c> or
29+
/// <c>Rearranges</c> is one equality saturation fires and a rule left at <c>Unknown</c> is
30+
/// one it does not. A declaration that is wrong in the shrinking direction tells the
31+
/// saturation a rewrite is safe to run when it is not.
32+
/// </para>
33+
/// <para>
34+
/// A corpus can only refute, never confirm: firing on fourteen hundred expressions without
35+
/// contradicting a claim is evidence for it and not proof of it, since the claim is about
36+
/// every expression there is. So this fails on a contradiction and says nothing about a rule
37+
/// it could not make fire.
38+
/// </para>
39+
/// </remarks>
40+
[Trait("Area", "Core")]
41+
public sealed class RuleGrowthAgreesWithTheCorpusTest
42+
{
43+
private static readonly string[] Leaves =
44+
{ "x", "y", "2", "-1", "-2", "1/2", "-1/2", "1", "0" };
45+
46+
private static readonly string[] Unary =
47+
{ "-({0})", "1 / ({0})", "({0}) ^ 2", "({0}) ^ (-2)", "sqrt({0})", "sin({0})", "abs({0})" };
48+
49+
private static readonly string[] Binary =
50+
{ "({0}) + ({1})", "({0}) - ({1})", "({0}) * ({1})", "({0}) / ({1})", "({0}) ^ ({1})" };
51+
52+
/// <summary>
53+
/// The same shape as the corpus <c>MatchedRulesAgreeWithTheSwitchTest</c> builds, and for
54+
/// the same reason: generated inputs reach arrangements nobody would think to write down.
55+
/// </summary>
56+
private static List<Entity> Corpus()
57+
{
58+
var level1 = new List<string>(Leaves);
59+
var level2 = new List<string>();
60+
foreach (var shape in Unary)
61+
foreach (var inner in level1)
62+
level2.Add(string.Format(shape, inner));
63+
foreach (var shape in Binary)
64+
foreach (var left in level1)
65+
foreach (var right in level1)
66+
level2.Add(string.Format(shape, left, right));
67+
var level3 = new List<string>();
68+
foreach (var shape in Binary)
69+
foreach (var left in level2.Where((_, i) => i % 17 == 0))
70+
foreach (var right in level2.Where((_, i) => i % 23 == 0))
71+
level3.Add(string.Format(shape, left, right));
72+
73+
var parsed = new List<Entity>();
74+
foreach (var source in level1.Concat(level2).Concat(level3))
75+
{
76+
try { parsed.Add(source.ToEntity()); }
77+
catch { /* the generator makes some strings the parser declines */ }
78+
}
79+
return parsed;
80+
}
81+
82+
private static int Size(Entity entity) => entity.Nodes.Count();
83+
84+
/// <summary>
85+
/// Every firing of every rule over the corpus, as (rule, how the size moved). A rule that
86+
/// hands back what it was given has not fired and is not counted — the factorial sets
87+
/// have arms that match a shape and then decline.
88+
/// </summary>
89+
private static Dictionary<MatchedRule, List<(Entity Before, Entity After)>> Firings()
90+
{
91+
var corpus = Corpus();
92+
var firings = new Dictionary<MatchedRule, List<(Entity, Entity)>>();
93+
foreach (var set in MatchedRules.All)
94+
foreach (var rule in set.Rules)
95+
{
96+
var seen = new List<(Entity, Entity)>();
97+
foreach (var expr in corpus)
98+
{
99+
Entity? after;
100+
try { after = rule.TryApply(expr); }
101+
catch { continue; /* a rule declining loudly is not this test's subject */ }
102+
if (after is not null && after != expr)
103+
seen.Add((expr, after));
104+
}
105+
firings[rule] = seen;
106+
}
107+
return firings;
108+
}
109+
110+
[Fact]
111+
public void NoDeclaredGrowthIsContradictedByTheCorpus()
112+
{
113+
var wrong = new List<string>();
114+
foreach (var (rule, firings) in Firings())
115+
{
116+
if (rule.Growth is RewriteRuleGrowth.Unknown || firings.Count == 0)
117+
continue;
118+
foreach (var (before, after) in firings)
119+
{
120+
var delta = Size(after) - Size(before);
121+
var contradicts = rule.Growth switch
122+
{
123+
RewriteRuleGrowth.Collects => delta >= 0,
124+
RewriteRuleGrowth.Rearranges => delta != 0,
125+
RewriteRuleGrowth.Expands => delta <= 0,
126+
_ => false
127+
};
128+
if (contradicts)
129+
{
130+
wrong.Add($"{rule.Name} declares {rule.Growth} but {before} -> {after} "
131+
+ $"moves {Size(before)} nodes to {Size(after)}");
132+
break;
133+
}
134+
}
135+
}
136+
137+
Assert.True(wrong.Count == 0,
138+
$"{wrong.Count} rules contradicted by the corpus:\n" + string.Join("\n", wrong));
139+
}
140+
141+
/// <summary>
142+
/// The corpus has to actually exercise the declarations, or the test above passes by
143+
/// never running. Named as a count that moves rather than as "most of them".
144+
/// </summary>
145+
[Fact]
146+
public void TheCorpusExercisesTheDeclarationsItChecks()
147+
{
148+
var fired = Firings().Where(pair => pair.Value.Count > 0).ToList();
149+
var declared = fired.Count(pair => pair.Key.Growth is not RewriteRuleGrowth.Unknown);
150+
151+
// Named as counts that move rather than as "most of them". 84 rules reach the corpus,
152+
// 18 of them carrying a declaration -- so the check above is exercised, and the other
153+
// 66 are the measured evidence for declaring more of them.
154+
Assert.True(fired.Count >= 80, $"only {fired.Count} rules fired at all");
155+
Assert.True(declared >= 15, $"only {declared} rules with a declared growth fired");
156+
}
157+
}
158+
}

0 commit comments

Comments
 (0)