diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 638469163..d95adc5c3 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -18,6 +18,9 @@ file_header_template=\nCopyright (c) 2019-2022 Angouri.\nAngouriMath is licensed # IDE0073 fail on all 367 files that already carry the old one, which is a rewrite of every # header folded into whatever branch happened to need a new file -- so the year moves per # directory as directories are added, not all at once. +[AngouriMath/Core/Transformations/Matching/*.cs] +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 + [AngouriMath/Core/Transformations/*.cs] 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 diff --git a/Sources/AngouriMath/Core/Transformations/Matching/MatchPattern.cs b/Sources/AngouriMath/Core/Transformations/Matching/MatchPattern.cs new file mode 100644 index 000000000..6ee005390 --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/Matching/MatchPattern.cs @@ -0,0 +1,217 @@ +// +// 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; +using System.Collections.Generic; +using System.Linq; + +namespace AngouriMath.Core.Transformations.Matching +{ + /// A set of named holes and what they stood for. + internal sealed class Bindings + { + private readonly Dictionary bound; + + internal static Bindings Empty { get; } = new(new Dictionary()); + + private Bindings(Dictionary bound) => this.bound = bound; + + internal bool TryGet(string name, out Entity value) => bound.TryGetValue(name, out value!); + + internal Entity this[string name] => bound[name]; + + internal int Count => bound.Count; + + /// + /// A new set with one more name bound. Copied rather than mutated because matching + /// backtracks: a branch that fails must leave nothing behind for the branch tried next, + /// and sharing one dictionary across attempts is how a matcher silently starts + /// accepting things it should not. + /// + internal Bindings With(string name, Entity value) + { + var copy = new Dictionary(bound) { [name] = value }; + return new Bindings(copy); + } + } + + /// + /// The left-hand side of a rewrite rule, as a value rather than as an arm of a + /// switch. + /// + /// + /// + /// #746 v1.0 asks for + /// "pattern matching as a data structure, not a switch: matchable, enumerable, + /// testable, with commutative and n-ary matching handled by the engine" + /// (#248). Three + /// things tier 2 wants are blocked on rules not being values: a rule cannot carry its own + /// justification tier, a rule cannot be addressed individually + /// (#825), and an + /// e-graph cannot match against an e-class because there is no pattern to match with. + /// + /// + /// Matching enumerates solutions rather than returning one, and that is not a + /// refinement — it is what commutativity requires. b*a + c*a has to match + /// k*p + k*q with k = a, and a matcher that commits to the first way of + /// matching the left operand binds k = b and then fails on the right, in both + /// orders of the sum. Only backtracking finds it, so every pattern yields every way it can + /// match and the caller takes the first that survives to the end. + /// + /// + /// Commutativity is over a binary node: a + b matches b + a. Matching + /// across a flattened chain — a + b + c against x + y with x = a + b — + /// is the n-ary half of #248 and is not here; the associative case wants the operands + /// gathered first and is a larger change than the commutative one. + /// + /// + internal abstract class MatchPattern + { + /// + /// Every way can match, extending . + /// Empty where it cannot. Lazy, so a caller that wants one solution does not pay for + /// the rest. + /// + internal abstract IEnumerable Match(Entity expr, Bindings bindings); + + /// The names this pattern binds, so a right-hand side can be checked for a typo. + internal abstract IEnumerable BoundNames { get; } + + /// Whether it matches at all, which is asked for one answer. + internal bool Matches(Entity expr) => Match(expr, Bindings.Empty).Any(); + + /// Matches anything and binds it. + internal static MatchPattern Any(string name) => new AnyPattern(name, null, null); + + /// Matches anything of the given node type and binds it. + internal static MatchPattern Any(string name) where T : Entity + => new AnyPattern(name, typeof(T), null); + + /// + /// Matches anything of the given node type that also satisfies . + /// + /// + /// The C# property pattern — Integer { IsPositive: true } — as data. A predicate + /// on the node travels with the hole and can be read off a rule, where a condition about + /// the match as a whole belongs in the rule's when and cannot. + /// + internal static MatchPattern Any(string name, Func where) where T : Entity + => new AnyPattern(name, typeof(T), node => where((T)node)); + + /// Matches exactly this expression, binding nothing. + internal static MatchPattern Exact(Entity value) => new ExactPattern(value); + + /// Matches a node of the given type whose children match, in order. + internal static MatchPattern Node(params MatchPattern[] children) where T : Entity + => new NodePattern(typeof(T), children, commutative: false); + + /// + /// Matches a two-child node of the given type whose children match in either + /// order. One of these replaces the four arms a switch needs to say the same + /// thing about a commutative operator. + /// + internal static MatchPattern Commutative(MatchPattern left, MatchPattern right) where T : Entity + => new NodePattern(typeof(T), new[] { left, right }, commutative: true); + + private sealed class AnyPattern : MatchPattern + { + private readonly string name; + private readonly Type? required; + private readonly Func? where; + + internal AnyPattern(string name, Type? required, Func? where) + { + this.name = name; + this.required = required; + this.where = where; + } + + internal override IEnumerable BoundNames => new[] { name }; + + internal override IEnumerable Match(Entity expr, Bindings bindings) + { + if (required is not null && !required.IsInstanceOfType(expr)) yield break; + if (where is not null && !where(expr)) yield break; + // A repeated name is the `when any1 == any1a` guard, made structural: the + // second occurrence matches only what the first one already stood for. + if (bindings.TryGet(name, out var already)) + { + if (already.Equals(expr)) yield return bindings; + yield break; + } + yield return bindings.With(name, expr); + } + } + + private sealed class ExactPattern : MatchPattern + { + private readonly Entity value; + + internal ExactPattern(Entity value) => this.value = value; + + internal override IEnumerable BoundNames => Array.Empty(); + + internal override IEnumerable Match(Entity expr, Bindings bindings) + { + if (value.Equals(expr)) yield return bindings; + } + } + + private sealed class NodePattern : MatchPattern + { + private readonly Type nodeType; + private readonly MatchPattern[] children; + private readonly bool commutative; + + internal NodePattern(Type nodeType, MatchPattern[] children, bool commutative) + { + this.nodeType = nodeType; + this.children = children; + this.commutative = commutative; + if (commutative && children.Length != 2) + throw new ArgumentException("commutative matching is over a two-child node", + nameof(children)); + } + + internal override IEnumerable BoundNames => children.SelectMany(c => c.BoundNames); + + internal override IEnumerable Match(Entity expr, Bindings bindings) + { + if (!nodeType.IsInstanceOfType(expr)) yield break; + var actual = expr.DirectChildren; + if (actual.Count != children.Length) yield break; + + foreach (var solution in MatchInOrder(actual, bindings, 0)) + yield return solution; + if (!commutative) yield break; + // The other way round. Yielded second so that a rule reading the first solution + // sees the same one a `switch` arm written in this order would have produced. + var swapped = new[] { actual[1], actual[0] }; + foreach (var solution in MatchInOrder(swapped, bindings, 0)) + yield return solution; + } + + /// + /// The cross product over the children: every way the first child matches, times + /// every way the rest match given that. This is where backtracking happens, and it + /// is why returns a sequence. + /// + private IEnumerable MatchInOrder( + IReadOnlyList actual, Bindings bindings, int index) + { + if (index == children.Length) + { + yield return bindings; + yield break; + } + foreach (var head in children[index].Match(actual[index], bindings)) + foreach (var rest in MatchInOrder(actual, head, index + 1)) + yield return rest; + } + } + } +} diff --git a/Sources/AngouriMath/Core/Transformations/Matching/MatchedRule.cs b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRule.cs new file mode 100644 index 000000000..8779991a2 --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRule.cs @@ -0,0 +1,123 @@ +// +// 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; +using System.Collections.Generic; +using System.Linq; + +namespace AngouriMath.Core.Transformations.Matching +{ + /// + /// One rewrite rule, addressable on its own: a name, a pattern to match, a side condition, + /// what to build, and the tier its claim is justified at. + /// + /// + /// + /// This is what #825 + /// asks for and what a switch arm cannot be. A rule here can be listed, named in a + /// bug report, tested by itself, and — the part that matters most — + /// carry its own . Today the tier is declared per rule *set*, + /// and since a set's tier is the minimum over its arms, one conditional arm drags eighteen + /// unconditional ones down with it; that is why all thirty sets in the registry declare the + /// same value and the field distinguishes nothing. + /// + /// + /// The right-hand side is a builder over the bindings rather than a second pattern. That is + /// a deliberate first step and not the end state: a rule whose right-hand side is also data + /// can be read backwards, which is what tier 2's "direction" field wants. Building it as + /// data before the matching half is proven would be guessing at two shapes at once. + /// + /// + internal sealed class MatchedRule + { + internal MatchedRule( + string name, + MatchPattern left, + Func right, + Soundness soundness, + Func? when = null) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Left = left ?? throw new ArgumentNullException(nameof(left)); + this.right = right ?? throw new ArgumentNullException(nameof(right)); + Soundness = soundness; + this.when = when; + } + + private readonly Func right; + private readonly Func? when; + + /// What to call this rule in a report, a test or a bug. + internal string Name { get; } + + /// The shape it fires on. + internal MatchPattern Left { get; } + + /// How well justified this rule's claim is — per rule, which is the point. + internal Soundness Soundness { get; } + + /// + /// The rewritten expression, or where the rule does not apply. + /// Never throws: a builder that fails on the bindings it was handed is a rule that did + /// not apply, which is a refusal rather than an error. + /// + internal Entity? TryApply(Entity expr) + { + // 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 + // `k` to the factor the condition is about. + foreach (var bindings in Left.Match(expr, Bindings.Empty)) + { + if (when is not null && !when(bindings)) + continue; + try { return right(bindings); } + catch { return null; } + } + return null; + } + } + + /// + /// An ordered list of , applied first-match-wins over every node — + /// the same discipline the switch-based rule sets follow, so that one can be + /// exchanged for the other and the two compared. + /// + internal sealed class MatchedRuleSet + { + internal MatchedRuleSet(string name, params MatchedRule[] rules) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + Rules = rules ?? throw new ArgumentNullException(nameof(rules)); + } + + internal string Name { get; } + + /// The rules, in the order they are tried. Enumerable, which is the whole point. + internal IReadOnlyList Rules { get; } + + /// + /// The weakest tier any of its rules is justified at — derived rather than declared, + /// so it cannot drift from the rules it is about. + /// + internal Soundness Soundness + => Rules.Count == 0 ? Soundness.Sound : Rules.Max(rule => rule.Soundness); + + /// The first rule that applies at this node, or null. + internal MatchedRule? FirstMatching(Entity expr) + => Rules.FirstOrDefault(rule => rule.TryApply(expr) is not null); + + /// One rewrite at this node only, leaving children alone. + internal Entity ApplyHere(Entity expr) + { + foreach (var rule in Rules) + if (rule.TryApply(expr) is { } rewritten) + return rewritten; + return expr; + } + } +} diff --git a/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs new file mode 100644 index 000000000..63aa5f48e --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/Matching/MatchedRules.cs @@ -0,0 +1,221 @@ +// +// 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 static AngouriMath.Entity; +using static AngouriMath.Entity.Number; + +namespace AngouriMath.Core.Transformations.Matching +{ + /// + /// Rule sets written as data. One so far, deliberately: the value of this file is that a + /// set expressed here can be checked against the switch that already expresses it, + /// so the migration is proven one set at a time rather than asserted wholesale. + /// + /// + /// MatchedRulesAgreeWithTheSwitchTest is that check. It runs both forms over + /// generated expressions and requires them to agree on every one, which is what makes + /// replacing the switch a mechanical step rather than a leap. + /// + internal static class MatchedRules + { + /// + /// , as data. + /// + /// + /// Chosen first because it is three rules with no side conditions, so it exercises node + /// matching, a literal, a typed hole and a repeated-free binding without also needing + /// commutativity — which the matcher does not have and which #248 is about. + /// + internal static MatchedRuleSet DivisionPreparing { get; } = new( + nameof(DivisionPreparing), + + // a * (1 / b) -> a / b + new MatchedRule( + "reciprocal-factor-becomes-a-quotient", + MatchPattern.Node( + MatchPattern.Any("a"), + MatchPattern.Node(MatchPattern.Exact(Integer.Create(1)), MatchPattern.Any("b"))), + bound => bound["a"] / bound["b"], + // a * (1/b) and a/b are undefined at exactly the same points, but the quotient + // is a quotient either way, so this inherits division's own condition rather + // than adding one. Left at the conservative tier until the audit reaches it. + Soundness.SoundUnderAssumptions), + + // (c * a) / b -> c * (a / b), for a numeric c + new MatchedRule( + "numeric-factor-out-of-a-quotient", + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("c"), MatchPattern.Any("a")), + MatchPattern.Any("b")), + bound => bound["c"] * (bound["a"] / bound["b"]), + Soundness.SoundUnderAssumptions), + + // (c / a) * b -> c * (b / a), for a numeric c + new MatchedRule( + "numeric-numerator-out-of-a-product", + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("c"), MatchPattern.Any("a")), + MatchPattern.Any("b")), + bound => bound["c"] * (bound["b"] / bound["a"]), + Soundness.SoundUnderAssumptions)); + + /// + /// , as data. + /// + /// + /// + /// The second set expressed here, and chosen because it is harder in three ways that + /// test whether the shape generalises rather than whether it works once. It has eight + /// rules instead of three; it is order-dependent, since + /// Mulf(Divf, Divf) has to be tried before Mulf(a, Divf) or the more + /// general rule would swallow the special one; and it needs a predicate on a + /// holeInteger { IsPositive: true } — which the matcher did not have. + /// + /// + /// One feature was added for it and nothing else changed, which is the answer to the + /// question this set was picked to ask. + /// + /// + internal static MatchedRuleSet CollapseMultipleFractions { get; } = new( + nameof(CollapseMultipleFractions), + + // (a / b) ^ c -> a^c / b^c, for a positive whole c + new MatchedRule( + "positive-power-of-a-quotient-distributes", + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a"), MatchPattern.Any("b")), + MatchPattern.Any("c", whole => whole.IsPositive)), + bound => bound["a"].Pow(bound["c"]) / bound["b"].Pow(bound["c"]), + Soundness.SoundUnderAssumptions), + + // (a * b) ^ c -> a^c * b^c, for a positive whole c + new MatchedRule( + "positive-power-of-a-product-distributes", + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a"), MatchPattern.Any("b")), + MatchPattern.Any("c", whole => whole.IsPositive)), + bound => bound["a"].Pow(bound["c"]) * bound["b"].Pow(bound["c"]), + Soundness.SoundUnderAssumptions), + + // (a/b) * (c/d) -> (a*c) / (b*d). Before the two below it, which are more general. + new MatchedRule( + "product-of-two-quotients", + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a"), MatchPattern.Any("b")), + MatchPattern.Node(MatchPattern.Any("c"), MatchPattern.Any("d"))), + bound => bound["a"] * bound["c"] / (bound["b"] * bound["d"]), + Soundness.SoundUnderAssumptions), + + new MatchedRule( + "product-with-a-quotient-on-the-right", + MatchPattern.Node( + MatchPattern.Any("a"), + MatchPattern.Node(MatchPattern.Any("b"), MatchPattern.Any("c"))), + bound => bound["a"] * bound["b"] / bound["c"], + Soundness.SoundUnderAssumptions), + + new MatchedRule( + "product-with-a-quotient-on-the-left", + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a"), MatchPattern.Any("b")), + MatchPattern.Any("c")), + bound => bound["a"] * bound["c"] / bound["b"], + Soundness.SoundUnderAssumptions), + + // (a/b) / (c/d) -> (a*d) / (b*c). Likewise before the two below it. + new MatchedRule( + "quotient-of-two-quotients", + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a"), MatchPattern.Any("b")), + MatchPattern.Node(MatchPattern.Any("c"), MatchPattern.Any("d"))), + bound => bound["a"] * bound["d"] / (bound["b"] * bound["c"]), + Soundness.SoundUnderAssumptions), + + new MatchedRule( + "quotient-whose-numerator-is-a-quotient", + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a"), MatchPattern.Any("b")), + MatchPattern.Any("c")), + bound => bound["a"] / (bound["b"] * bound["c"]), + Soundness.SoundUnderAssumptions), + + new MatchedRule( + "quotient-whose-denominator-is-a-quotient", + MatchPattern.Node( + MatchPattern.Any("a"), + MatchPattern.Node(MatchPattern.Any("b"), MatchPattern.Any("c"))), + bound => bound["a"] * bound["c"] / bound["b"], + Soundness.SoundUnderAssumptions)); + + /// + /// The one rule from that carries a real + /// side condition, as data. + /// + /// + /// + /// The third set expressed here, and the one that exercises the last piece of the + /// design: a condition about the match as a whole rather than about one hole. + /// (a^b)^c = a^(b*c) is true for a positive base whatever the exponents, and for + /// any base when the outer exponent is whole — and false outside those two, which is + /// #752: applied + /// unconditionally it turned sqrt(x^2) into x, which at -0.63 is -0.63 + /// where the expression is 0.63. + /// + /// + /// It is also the first rule here whose carries information + /// rather than repeating its neighbours'. The condition is what makes it + /// , and a reader can see the condition + /// and the tier in one place — which is the whole argument for rules being data, since + /// in the switch the tier lives on the set and the condition lives forty lines + /// away from it. + /// + /// + internal static MatchedRuleSet PowerOfPower { get; } = new( + nameof(PowerOfPower), + + new MatchedRule( + "power-of-a-power-multiplies-its-exponents", + MatchPattern.Node( + MatchPattern.Node(MatchPattern.Any("a"), MatchPattern.Any("b")), + MatchPattern.Any("c")), + bound => new Powf(bound["a"], bound["b"] * bound["c"]), + Soundness.SoundUnderAssumptions, + // Two bindings at once, which no predicate on a single hole can express. + when: bound => bound["c"] is Integer + || bound["a"].Evaled is Real { IsPositive: true })); + + /// + /// k*p + k*q = k*(p + q), written once, where the switch writes it four + /// times. + /// + /// + /// + /// This is what #248 + /// is for. Patterns.CommonRules spells the same identity out in four arms — + /// (k*p) + (k*q), (p*k) + (k*q), (k*p) + (q*k), (p*k) + (q*k) + /// — because a C# pattern cannot say "either way round". One commutative pattern says + /// it, and the four arms become one rule. + /// + /// + /// It is also the first rule here that is . + /// Distributivity holds for every complex k, p and q with no side + /// condition and no branch to choose, so the tier says something its neighbours' does + /// not — which is the whole reason a tier belongs on a rule rather than on a set. + /// + /// + internal static MatchedRuleSet SharedFactor { get; } = new( + nameof(SharedFactor), + + new MatchedRule( + "a-shared-factor-comes-out-of-a-sum", + MatchPattern.Commutative( + MatchPattern.Commutative(MatchPattern.Any("k"), MatchPattern.Any("p")), + MatchPattern.Commutative(MatchPattern.Any("k"), MatchPattern.Any("q"))), + bound => bound["k"] * (bound["p"] + bound["q"]), + Soundness.Sound)); + } +} diff --git a/Sources/Tests/UnitTests/Core/Transformations/MatchedRulesAgreeWithTheSwitchTest.cs b/Sources/Tests/UnitTests/Core/Transformations/MatchedRulesAgreeWithTheSwitchTest.cs new file mode 100644 index 000000000..4e8f65419 --- /dev/null +++ b/Sources/Tests/UnitTests/Core/Transformations/MatchedRulesAgreeWithTheSwitchTest.cs @@ -0,0 +1,424 @@ +// +// 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.Collections.Generic; +using System.Linq; +using AngouriMath; +using AngouriMath.Core.Transformations; +using AngouriMath.Core.Transformations.Matching; +using AngouriMath.Extensions; +using AngouriMath.Functions; +using Xunit; + +namespace AngouriMath.Tests.Core.Transformations +{ + /// + /// A rule set written as **data** has to do exactly what the switch that already + /// expresses it does. https://github.com/asc-community/AngouriMath/issues/746 v1.0 asks for + /// pattern matching as a data structure; this is what makes replacing a `switch` with one a + /// mechanical step rather than a leap of faith. + /// + /// + /// The comparison is differential and generative: both forms are run over every expression + /// a small grammar produces, and they must agree on all of them. A hand-written list of + /// cases would only prove the cases someone thought of, and the interesting disagreements + /// in a matcher are the shapes nobody pictured — a literal that is a `Rational` rather than + /// an `Integer`, a node whose child count differs from the pattern's, a name bound twice. + /// + [Trait("Area", "Core")] + public sealed class MatchedRulesAgreeWithTheSwitchTest + { + private static readonly string[] Leaves = { "x", "y", "2", "-1", "1/2", "1", "0" }; + + private static readonly string[] Unary = + { + "-({0})", "1 / ({0})", "({0}) ^ 2", "sqrt({0})", "sin({0})", "abs({0})", + }; + + private static readonly string[] Binary = + { + "({0}) + ({1})", "({0}) - ({1})", "({0}) * ({1})", "({0}) / ({1})", "({0}) ^ ({1})", + }; + + private static List Corpus() + { + var level1 = new List(Leaves); + var level2 = new List(); + foreach (var shape in Unary) + foreach (var inner in level1) + level2.Add(string.Format(shape, inner)); + foreach (var shape in Binary) + foreach (var left in level1) + foreach (var right in level1) + level2.Add(string.Format(shape, left, right)); + var level3 = new List(); + foreach (var shape in Binary) + foreach (var left in level2.Where((_, i) => i % 17 == 0)) + foreach (var right in level2.Where((_, i) => i % 23 == 0)) + level3.Add(string.Format(shape, left, right)); + + var parsed = new List(); + foreach (var source in level1.Concat(level2).Concat(level3)) + { + try { parsed.Add(source.ToEntity()); } + catch { /* the generator makes some strings the parser declines; not its subject */ } + } + return parsed; + } + + private static void AssertAgrees( + string what, System.Func bySwitch, MatchedRuleSet byData, int leastFirings) + { + var corpus = Corpus(); + Assert.True(corpus.Count > 500, $"the corpus is only {corpus.Count} expressions"); + + var disagreements = new List(); + var fired = 0; + foreach (var expr in corpus) + { + var expected = bySwitch(expr); + var actual = byData.ApplyHere(expr); + if (!expected.Equals(expr)) fired++; + if (!expected.Equals(actual)) + disagreements.Add($"{expr.Stringize()}: switch gave {expected.Stringize()}, " + + $"data gave {actual.Stringize()}"); + } + + // The set has to actually fire, or agreement is the agreement of two things that + // both did nothing. + Assert.True(fired >= leastFirings, + $"{what}: the rules only fired on {fired} of {corpus.Count} expressions"); + Assert.True(disagreements.Count == 0, + $"{what}: {disagreements.Count} of {corpus.Count} disagreed:\n" + + string.Join("\n", disagreements.Take(10))); + } + + [Fact] + public void DivisionPreparingAsDataMatchesTheSwitch() + => AssertAgrees("DivisionPreparing", Patterns.DivisionPreparingRules, + MatchedRules.DivisionPreparing, leastFirings: 20); + + /// + /// The second set, and the one that says whether the shape generalises. It is harder in + /// three ways: eight rules rather than three, an order that is load-bearing — the + /// quotient-times-quotient rule has to be tried before the general product rule or the + /// general one swallows it — and a predicate on a hole, + /// Integer { IsPositive: true }. + /// + [Fact] + public void CollapseMultipleFractionsAsDataMatchesTheSwitch() + => AssertAgrees("CollapseMultipleFractions", Patterns.CollapseMultipleFractions, + MatchedRules.CollapseMultipleFractions, leastFirings: 50); + + /// + /// A predicate on a hole refuses what fails it, which is the C# property pattern + /// Integer { IsPositive: true } as data. + /// + [Theory] + [InlineData("(x / y) ^ 2", true)] + [InlineData("(x / y) ^ (-2)", false)] + [InlineData("(x / y) ^ 0", false)] + [InlineData("(x / y) ^ z", false)] + public void APredicateOnAHoleIsChecked(string expression, bool shouldFire) + { + var expr = expression.ToEntity(); + var rewritten = MatchedRules.CollapseMultipleFractions.ApplyHere(expr); + Assert.Equal(shouldFire, !rewritten.Equals(expr)); + } + + /// + /// Order is part of the data. Reversing the two rules that overlap makes the general + /// one swallow the special one, which is what an ordered list is for and what a + /// switch gets by accident of being written top to bottom. + /// + [Fact] + public void TheOrderOfTheRulesIsLoadBearing() + { + var expr = "(a / b) * (c / d)".ToEntity(); + var asWritten = MatchedRules.CollapseMultipleFractions.FirstMatching(expr); + Assert.Equal("product-of-two-quotients", asWritten!.Name); + + var reversed = new MatchedRuleSet("reversed", + MatchedRules.CollapseMultipleFractions.Rules.Reverse().ToArray()); + Assert.NotEqual("product-of-two-quotients", reversed.FirstMatching(expr)!.Name); + } + + /// + /// A rule-level guard over two bindings at once, which no predicate on a single + /// hole can express: (a^b)^c = a^(b*c) holds for a positive base whatever the + /// exponents, and for any base when the outer exponent is whole. + /// + /// + /// Compared against the switch only where the comparison is meaningful. + /// PowerRules is a large set and an earlier arm may fire on the same expression, + /// so a case counts only where the switch either did nothing or produced exactly the + /// power-of-a-power answer; anything else means a different arm matched and says + /// nothing about this rule. That the rule cannot be isolated from its `switch` any + /// other way is itself the argument for rules being data. + /// + [Fact] + public void AGuardOverTwoBindingsMatchesTheSwitch() + { + var disagreements = new List(); + var compared = 0; + var fired = 0; + foreach (var expr in Corpus()) + { + if (expr is not Entity.Powf(Entity.Powf(var a, var b), var c)) continue; + var expected = Patterns.PowerRules(expr); + var mine = MatchedRules.PowerOfPower.ApplyHere(expr); + var theAnswer = (Entity)new Entity.Powf(a, b * c); + + if (!expected.Equals(expr) && !expected.Equals(theAnswer)) continue; + compared++; + if (!expected.Equals(expr)) fired++; + if (!expected.Equals(mine)) + disagreements.Add($"{expr.Stringize()}: switch gave {expected.Stringize()}, " + + $"data gave {mine.Stringize()}"); + } + Assert.True(compared > 20, $"only {compared} comparable cases"); + Assert.True(fired > 0, "the switch never applied this rule, so agreement proves nothing"); + Assert.True(disagreements.Count == 0, + $"{disagreements.Count} of {compared} disagreed:\n" + string.Join("\n", disagreements.Take(10))); + } + + /// + /// The guard is the whole point: #752 is what happens when this rule is applied without + /// one. sqrt(x^2) must not become x, since at -0.63 that is -0.63 where + /// the expression is 0.63. + /// + [Theory] + [InlineData("(x ^ 2) ^ 3", true)] // whole outer exponent, any base + [InlineData("(x ^ 2) ^ (-1)", true)] // still whole + [InlineData("(2 ^ x) ^ (1/2)", true)] // base is a positive real + [InlineData("(x ^ 2) ^ (1/2)", false)] // neither, and this one is #752 + [InlineData("(x ^ 2) ^ (3/2)", false)] + [InlineData("(x ^ y) ^ z", false)] + public void TheGuardDecidesWhetherItFires(string expression, bool shouldFire) + { + var expr = expression.ToEntity(); + Assert.Equal(shouldFire, !MatchedRules.PowerOfPower.ApplyHere(expr).Equals(expr)); + } + + /// + /// And where it does fire the value survives, checked at a negative point — which is + /// where the unguarded version went wrong. + /// + [Theory] + [InlineData("(x ^ 2) ^ 3", -0.63)] + [InlineData("(x ^ 2) ^ (-1)", -1.7)] + [InlineData("(x ^ 3) ^ 2", -2.4)] + public void WhereItFiresTheValueSurvives(string expression, double at) + { + var expr = expression.ToEntity(); + var rewritten = MatchedRules.PowerOfPower.ApplyHere(expr); + Assert.NotEqual(expr, rewritten); + var before = expr.Substitute("x", at).EvalNumerical().RealPart.EDecimal.ToDouble(); + var after = rewritten.Substitute("x", at).EvalNumerical().RealPart.EDecimal.ToDouble(); + Assert.Equal(before, after, 8); + } + + /// + /// Every p*q + r*s over a handful of operands. The general corpus above only + /// happens to contain five expressions of that shape, which is too few to conclude + /// anything from, and the shape is the whole subject here — so it is generated rather + /// than hoped for. Four operands give 256 sums, most sharing a factor and some sharing + /// two, which is the case the tie-break is about. + /// + private static List ProductSums() + { + var operands = new[] { "x", "y", "z", "2" }; + var made = new List(); + foreach (var p in operands) + foreach (var q in operands) + foreach (var r in operands) + foreach (var s in operands) + { + try { made.Add($"{p} * {q} + {r} * {s}".ToEntity()); } + catch { /* every one of these parses; the guard is for the generator */ } + } + return made; + } + + /// + /// The four hand-written arms of `{1}*{2} + {1}*{3}`, as an oracle. `CommonRules` + /// writes the identity out once per ordering because a C# pattern cannot say "either + /// way round"; this reproduces them, in their order, so the one commutative rule can be + /// held against them. + /// + private static Entity? TheFourArms(Entity expr) + { + if (expr is not Entity.Sumf(Entity.Mulf(var l1, var l2), Entity.Mulf(var r1, var r2))) + return null; + if (l1.Equals(r1)) return l1 * (l2 + r2); + if (l2.Equals(r1)) return l2 * (l1 + r2); + if (l1.Equals(r2)) return l1 * (l2 + r1); + if (l2.Equals(r2)) return l2 * (l1 + r1); + return null; + } + + /// + /// **One commutative rule fires exactly where the four arms fire.** That is the claim + /// #248 is about, and it holds. + /// + /// + /// The *value* is always the same. The *tree* is not always the same, and that is a + /// real finding rather than a defect in either: where more than one factor is shared, + /// the four arms and the commutative rule pull out different ones — `a*b + b*a` gives + /// `b*(a+a)` from the arms and `a*(b+b)` from the rule, both of which are `2ab`. Which + /// one you get is a tie-break that the `switch` fixes by the order its arms happen to be + /// written in, and that nothing ever chose deliberately. Migrating this rule is + /// therefore **not** purely mechanical: it needs a tie-break convention, or the printed + /// answer moves for expressions with two shared factors. + /// + [Fact] + public void OneCommutativeRuleFiresWhereTheFourArmsDo() + { + var firedBoth = 0; + var sameTree = 0; + var disagreedOnWhether = new List(); + var differentTree = new List(); + + foreach (var expr in ProductSums()) + { + var byArms = TheFourArms(expr); + var byRule = MatchedRules.SharedFactor.Rules[0].TryApply(expr); + + if ((byArms is null) != (byRule is null)) + { + disagreedOnWhether.Add($"{expr.Stringize()}: arms {(byArms is null ? "no" : "yes")}, " + + $"rule {(byRule is null ? "no" : "yes")}"); + continue; + } + if (byArms is null) continue; + firedBoth++; + if (byArms.Equals(byRule)) sameTree++; + else differentTree.Add($"{expr.Stringize()}: arms {byArms.Stringize()}, " + + $"rule {byRule!.Stringize()}"); + } + + Assert.True(firedBoth > 100, $"only {firedBoth} expressions exercised the rule"); + Assert.True(disagreedOnWhether.Count == 0, + $"{disagreedOnWhether.Count} disagreed about *whether* to fire:\n" + + string.Join("\n", disagreedOnWhether.Take(10))); + + // Where the trees differ, the values must not. Checked numerically rather than + // asserted, because "both are correct" is the entire claim being made. + foreach (var expr in ProductSums()) + { + var byArms = TheFourArms(expr); + var byRule = MatchedRules.SharedFactor.Rules[0].TryApply(expr); + if (byArms is null || byRule is null || byArms.Equals(byRule)) continue; + foreach (var at in new[] { 0.37, -1.7, 2.4 }) + { + static Entity Point(Entity e, double at) + => e.Substitute("x", at).Substitute("y", at + 1).Substitute("z", at - 0.5); + var one = Point(byArms, at); + var two = Point(byRule, at); + if (!one.EvaluableNumerical || !two.EvaluableNumerical) continue; + var left = one.EvalNumerical().RealPart.EDecimal.ToDouble(); + var right = two.EvalNumerical().RealPart.EDecimal.ToDouble(); + if (double.IsNaN(left) || double.IsNaN(right)) continue; + Assert.Equal(left, right, 8); + } + } + } + + /// + /// Backtracking, which commutativity needs and a first-match matcher does not have. + /// `b*a + c*a` shares `a`, and finding it means abandoning the first way the left + /// product matched — bind `k = b`, fail on the right, come back and try `k = a`. + /// + [Theory] + [InlineData("b * a + c * a")] + [InlineData("a * b + a * c")] + [InlineData("a * b + c * a")] + [InlineData("b * a + a * c")] + public void CommutativeMatchingBacktracks(string expression) + => Assert.NotNull(MatchedRules.SharedFactor.Rules[0].TryApply(expression.ToEntity())); + + /// And it does not invent a shared factor where there is none. + [Theory] + [InlineData("a * b + c * d")] + [InlineData("a + b")] + [InlineData("a * b - a * c")] + public void CommutativeMatchingDoesNotOverreach(string expression) + => Assert.Null(MatchedRules.SharedFactor.Rules[0].TryApply(expression.ToEntity())); + + /// + /// Distributivity needs no condition, so this is the first rule here whose tier says + /// something its neighbours' does not. + /// + [Fact] + public void ARuleCanBeSoundWhileItsNeighboursAreNot() + { + Assert.Equal(Soundness.Sound, MatchedRules.SharedFactor.Soundness); + Assert.Equal(Soundness.SoundUnderAssumptions, MatchedRules.PowerOfPower.Soundness); + } + + /// + /// A name used twice binds the same subexpression both times — which is the + /// when any1 == any1a guard the existing rules write out by hand, made + /// structural. + /// + [Fact] + public void ARepeatedNameMustMatchTheSameSubexpression() + { + var pattern = MatchPattern.Node(MatchPattern.Any("a"), MatchPattern.Any("a")); + Assert.NotNull(new MatchedRule("doubles", pattern, + bound => 2 * bound["a"], Soundness.Sound).TryApply("x + x".ToEntity())); + Assert.Null(new MatchedRule("doubles", pattern, + bound => 2 * bound["a"], Soundness.Sound).TryApply("x + y".ToEntity())); + } + + /// A typed hole refuses what is not of its type. + [Fact] + public void ATypedHoleIsTyped() + { + var rule = new MatchedRule("numeric-left", + MatchPattern.Node( + MatchPattern.Any("c"), MatchPattern.Any("a")), + bound => bound["c"] + bound["a"], Soundness.Sound); + Assert.NotNull(rule.TryApply("2 * x".ToEntity())); + Assert.Null(rule.TryApply("y * x".ToEntity())); + } + + /// + /// The set is enumerable and each rule is addressable by name — the property the + /// `switch` cannot have and the reason three separate tier-2 items are blocked on this. + /// + [Fact] + public void TheRulesAreEnumerableAndNamed() + { + var rules = MatchedRules.DivisionPreparing.Rules; + Assert.Equal(3, rules.Count); + Assert.All(rules, rule => Assert.False(string.IsNullOrWhiteSpace(rule.Name))); + Assert.Equal(rules.Count, rules.Select(rule => rule.Name).Distinct().Count()); + Assert.NotNull(MatchedRules.DivisionPreparing.FirstMatching("2 / x * y".ToEntity())); + } + + /// + /// A set's tier is derived from its rules rather than declared beside them, so it + /// cannot drift from what it is about. That is the fix for the registry's thirty sets + /// all declaring the same value. + /// + [Fact] + public void TheSetsTierIsTheWeakestOfItsRules() + { + Assert.Equal(Soundness.SoundUnderAssumptions, MatchedRules.DivisionPreparing.Soundness); + + var mixed = new MatchedRuleSet("mixed", + new MatchedRule("sound", MatchPattern.Any("a"), b => b["a"], Soundness.Sound), + new MatchedRule("heuristic", MatchPattern.Any("a"), b => b["a"], Soundness.Heuristic)); + Assert.Equal(Soundness.Heuristic, mixed.Soundness); + + var allSound = new MatchedRuleSet("sound", + new MatchedRule("one", MatchPattern.Any("a"), b => b["a"], Soundness.Sound)); + Assert.Equal(Soundness.Sound, allSound.Soundness); + } + } +}