diff --git a/Sources/.editorconfig b/Sources/.editorconfig index 84ecb4a0c..eff7e1b3e 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -66,3 +66,6 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [Utils/Utils/CiTiming.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 + +[Analyzers/RuleRegistryGenerator/*.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/Analyzers/RuleRegistryGenerator/RuleRegistryGenerator.cs b/Sources/Analyzers/RuleRegistryGenerator/RuleRegistryGenerator.cs new file mode 100644 index 000000000..3f1200093 --- /dev/null +++ b/Sources/Analyzers/RuleRegistryGenerator/RuleRegistryGenerator.cs @@ -0,0 +1,335 @@ +// +// 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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace AngouriMath.Generators +{ + /// + /// Turns each arm of a rewrite rule switch into an addressable value, without the + /// switch ceasing to be the thing a human edits. + /// + /// + /// + /// This is what https://github.com/asc-community/AngouriMath/issues/825 settled on. The two + /// alternatives both fail on something measured: transcribing forty arms by hand is forty + /// chances to change a pattern silently, and expressing them through the runtime matcher costs + /// about five percent of Simplify per rule set exchanged. A generator over the arms + /// costs nothing at run time, because the method it reads is left exactly as it was and keeps + /// being what the simplifier calls. + /// + /// + /// The arm is copied as syntax rather than re-derived, so the generated rule cannot say + /// something the arm does not. What is derived is only what a reader would otherwise have to + /// count for themselves: the node type at the root of the pattern, and whether the rewrite + /// grows, shrinks or rearranges. + /// + /// + [Generator(LanguageNames.CSharp)] + public sealed class RuleRegistryGenerator : IIncrementalGenerator + { + private const string AttributeName = "AddressableRules"; + + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var files = context.SyntaxProvider.CreateSyntaxProvider( + predicate: static (node, _) => IsCandidate(node), + transform: static (ctx, _) => Describe((MethodDeclarationSyntax)ctx.Node)) + .Where(static file => file is not null); + + // The transform produces the finished text, so what travels through the pipeline is a + // pair of strings. Anything holding a syntax node or a symbol would keep a compilation + // alive and compare unequal on every keystroke. + context.RegisterSourceOutput(files, static (spc, file) => + spc.AddSource(file!.HintName, SourceText.From(file.Source, Encoding.UTF8))); + } + + private static bool IsCandidate(SyntaxNode node) + => node is MethodDeclarationSyntax method + && method.AttributeLists + .SelectMany(list => list.Attributes) + .Any(attribute => NameOf(attribute) is AttributeName or AttributeName + "Attribute"); + + private static string NameOf(AttributeSyntax attribute) + => attribute.Name switch + { + QualifiedNameSyntax qualified => qualified.Right.Identifier.Text, + SimpleNameSyntax simple => simple.Identifier.Text, + _ => attribute.Name.ToString() + }; + + private sealed class GeneratedFile + { + internal GeneratedFile(string hintName, string source) + => (HintName, Source) = (hintName, source); + + internal string HintName { get; } + internal string Source { get; } + } + + private static GeneratedFile? Describe(MethodDeclarationSyntax method) + { + var unit = method.FirstAncestorOrSelf(); + var owner = method.FirstAncestorOrSelf(); + if (unit is null || owner is null) + return null; + + var containers = new List(); + for (var at = owner; at is not null; at = at.Parent as TypeDeclarationSyntax) + containers.Insert(0, at); + + var namespaceName = NamespaceOf(method); + var hint = Sanitize(namespaceName + "." + string.Join(".", containers.Select(c => c.Identifier.Text)) + + "." + method.Identifier.Text) + ".Rules.g.cs"; + + var text = new StringBuilder(); + text.AppendLine("// "); + text.AppendLine("#nullable enable"); + // The arms are copied verbatim, so they need the names the file they came from had in + // scope. Carrying its using directives over is what makes that true, and a directive + // the compilation already has globally is then written twice. + text.AppendLine("#pragma warning disable CS0105 // duplicate using directive"); + text.AppendLine("#pragma warning disable CS8933 // using directive appeared previously as global using"); + foreach (var directive in unit.Usings) + text.AppendLine(directive.ToString()); + foreach (var declared in method.Ancestors().OfType()) + foreach (var directive in declared.Usings) + text.AppendLine(directive.ToString()); + text.AppendLine(); + + var indent = ""; + if (namespaceName.Length > 0) + { + text.AppendLine($"namespace {namespaceName}"); + text.AppendLine("{"); + indent = " "; + } + foreach (var container in containers) + { + text.AppendLine($"{indent}{Modifiers(container)} partial {container.Keyword.Text} {container.Identifier.Text}"); + text.AppendLine($"{indent}{{"); + indent += " "; + } + + var body = Body(method, indent); + if (body is null) + { + // A `#error` rather than a reported diagnostic: it cannot be turned off by a + // severity setting, which matters because the failure mode being guarded against + // is a rule set silently having no rules rather than a rule being wrong. + text.AppendLine($"{indent}#error AddressableRules: '{method.Identifier.Text}' must be an " + + "expression-bodied method whose body is `parameter switch { ... }`."); + } + else + text.Append(body); + + foreach (var _ in containers) + { + indent = indent.Substring(4); + text.AppendLine($"{indent}}}"); + } + if (namespaceName.Length > 0) + text.AppendLine("}"); + + return new GeneratedFile(hint, text.ToString()); + } + + private static string Modifiers(TypeDeclarationSyntax type) + => string.Join(" ", type.Modifiers.Where(m => !m.IsKind(SyntaxKind.PartialKeyword)).Select(m => m.Text)); + + private static string NamespaceOf(SyntaxNode node) + { + var names = node.Ancestors().OfType() + .Select(declared => declared.Name.ToString()).Reverse(); + return string.Join(".", names); + } + + private static string Sanitize(string name) + { + var builder = new StringBuilder(name.Length); + foreach (var c in name) + builder.Append(char.IsLetterOrDigit(c) || c == '_' ? c : '.'); + return builder.ToString(); + } + + private static string? Body(MethodDeclarationSyntax method, string indent) + { + if (method.ExpressionBody?.Expression is not SwitchExpressionSyntax dispatch) + return null; + if (dispatch.GoverningExpression is not IdentifierNameSyntax governing) + return null; + if (method.ParameterList.Parameters.Count != 1) + return null; + var parameter = method.ParameterList.Parameters[0]; + if (parameter.Identifier.Text != governing.Identifier.Text) + return null; + + var arms = dispatch.Arms.Where(arm => arm.Pattern is not DiscardPatternSyntax).ToList(); + var names = new Dictionary(StringComparer.Ordinal); + + var text = new StringBuilder(); + text.AppendLine($"{indent}/// "); + text.AppendLine($"{indent}/// The arms of , each one addressable on its own."); + text.AppendLine($"{indent}/// Generated from the switch itself, so the two cannot disagree."); + text.AppendLine($"{indent}/// "); + text.AppendLine($"{indent}[global::AngouriMath.Core.ConstantField]"); + text.AppendLine($"{indent}internal static readonly global::System.Collections.Generic.IReadOnlyList" + + $" {method.Identifier.Text}Arms ="); + text.AppendLine($"{indent} new global::AngouriMath.Core.Transformations.RewriteRule[]"); + text.AppendLine($"{indent} {{"); + + for (var index = 0; index < arms.Count; index++) + { + var arm = arms[index]; + var pattern = Flatten(arm.Pattern.ToString()); + var guard = arm.WhenClause is null ? null : Flatten(arm.WhenClause.Condition.ToString()); + var replacement = Flatten(arm.Expression.ToString()); + var key = guard is null ? pattern : pattern + " when " + guard; + names.TryGetValue(key, out var seen); + names[key] = seen + 1; + var name = seen == 0 ? key : $"{key} #{seen + 1}"; + + var line = arm.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + var nodeTypes = RootTypesOf(arm.Pattern); + var growth = Growth(arm.Pattern, arm.Expression); + + text.AppendLine($"{indent} new global::AngouriMath.Core.Transformations.RewriteRule("); + text.AppendLine($"{indent} source: \"{method.Identifier.Text}\","); + text.AppendLine($"{indent} index: {index},"); + text.AppendLine($"{indent} name: {Literal(name)},"); + text.AppendLine($"{indent} description: {Literal(Description(arm))},"); + text.AppendLine($"{indent} nodeTypes: new global::System.Type[] {{ " + + string.Join(", ", nodeTypes.Select(type => $"typeof({type})")) + " },"); + text.AppendLine($"{indent} patternSource: {Literal(pattern)},"); + text.AppendLine($"{indent} guardSource: {Literal(guard)},"); + text.AppendLine($"{indent} replacementSource: {Literal(replacement)},"); + text.AppendLine($"{indent} growth: global::AngouriMath.Core.Transformations.RewriteRuleGrowth.{growth},"); + text.AppendLine($"{indent} sourceLine: {line},"); + text.AppendLine($"{indent} apply: static global::AngouriMath.Entity? " + + $"({parameter.Type} {parameter.Identifier.Text}) => {parameter.Identifier.Text} switch"); + text.AppendLine($"{indent} {{"); + text.AppendLine($"{indent} {arm.Pattern}{(arm.WhenClause is null ? "" : " " + arm.WhenClause)} => {arm.Expression},"); + text.AppendLine($"{indent} _ => null"); + text.AppendLine($"{indent} }}),"); + } + + text.AppendLine($"{indent} }};"); + return text.ToString(); + } + + /// The comment written above an arm, which is the rule stated as mathematics. + private static string? Description(SwitchExpressionArmSyntax arm) + { + var lines = arm.GetLeadingTrivia() + .Where(trivia => trivia.IsKind(SyntaxKind.SingleLineCommentTrivia)) + .Select(trivia => trivia.ToString().TrimStart('/').Trim()) + .Where(line => line.Length > 0) + .ToList(); + return lines.Count == 0 ? null : string.Join(" ", lines); + } + + /// + /// The node types the pattern admits at its root. Usually one; two where the arm is an + /// or of node types, and none where the constraint cannot be read off the syntax. + /// + /// + /// The list is a necessary condition and has to stay one, because the point of + /// recording it is that a scheduler may skip the rule on any other type. So an + /// unrecognised shape yields nothing rather than a guess, and an and takes one + /// side's constraint — both hold, so either is necessary — while an or has to take + /// both or neither. + /// + private static IReadOnlyList RootTypesOf(PatternSyntax pattern) + { + switch (pattern) + { + case RecursivePatternSyntax { Type: { } type }: + return new[] { type.ToString() }; + case DeclarationPatternSyntax declaration: + return new[] { declaration.Type.ToString() }; + case TypePatternSyntax typed: + return new[] { typed.Type.ToString() }; + case ParenthesizedPatternSyntax parenthesized: + return RootTypesOf(parenthesized.Pattern); + case BinaryPatternSyntax binary when binary.IsKind(SyntaxKind.OrPattern): + { + var left = RootTypesOf(binary.Left); + var right = RootTypesOf(binary.Right); + return left.Count == 0 || right.Count == 0 + ? Array.Empty() + : left.Concat(right).Distinct().ToArray(); + } + case BinaryPatternSyntax binary when binary.IsKind(SyntaxKind.AndPattern): + { + var left = RootTypesOf(binary.Left); + return left.Count > 0 ? left : RootTypesOf(binary.Right); + } + default: + return Array.Empty(); + } + } + + /// + /// Whether the rewrite makes the expression bigger, smaller or the same size — counted as + /// operators plus operands on each side. + /// + /// + /// A syntactic proxy, and deliberately a crude one: it counts what is written rather than + /// what the expression evaluates to, so a rule whose replacement calls a helper is counted + /// as the call. It is enough for the question a saturation scheduler asks, which is which + /// pairs of rules are each other's inverse. + /// + private static string Growth(PatternSyntax pattern, ExpressionSyntax replacement) + { + var before = Size(pattern); + var after = Size(replacement); + return after > before ? "Expands" : after < before ? "Collects" : "Rearranges"; + } + + private static int Size(PatternSyntax pattern) + => pattern switch + { + RecursivePatternSyntax recursive => + 1 + (recursive.PositionalPatternClause?.Subpatterns.Sum(sub => Size(sub.Pattern)) ?? 0) + + (recursive.PropertyPatternClause?.Subpatterns.Sum(sub => Size(sub.Pattern)) ?? 0), + ParenthesizedPatternSyntax parenthesized => Size(parenthesized.Pattern), + BinaryPatternSyntax binary => Size(binary.Left) + Size(binary.Right), + UnaryPatternSyntax unary => Size(unary.Pattern), + _ => 1 + }; + + private static int Size(ExpressionSyntax expression) + => expression switch + { + ParenthesizedExpressionSyntax parenthesized => Size(parenthesized.Expression), + BinaryExpressionSyntax binary => 1 + Size(binary.Left) + Size(binary.Right), + PrefixUnaryExpressionSyntax prefix => 1 + Size(prefix.Operand), + CastExpressionSyntax cast => Size(cast.Expression), + InvocationExpressionSyntax invocation => + 1 + invocation.ArgumentList.Arguments.Sum(argument => Size(argument.Expression)) + + (invocation.Expression is MemberAccessExpressionSyntax access ? Size(access.Expression) : 0), + ObjectCreationExpressionSyntax creation => + 1 + (creation.ArgumentList?.Arguments.Sum(argument => Size(argument.Expression)) ?? 0), + MemberAccessExpressionSyntax access => 1 + Size(access.Expression), + _ => 1 + }; + + private static string Flatten(string text) + => string.Join(" ", text.Split(new[] { '\r', '\n', '\t', ' ' }, StringSplitOptions.RemoveEmptyEntries)); + + private static string Literal(string? text) + => text is null ? "null" : "\"" + text.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\""; + } +} diff --git a/Sources/Analyzers/RuleRegistryGenerator/RuleRegistryGenerator.csproj b/Sources/Analyzers/RuleRegistryGenerator/RuleRegistryGenerator.csproj new file mode 100644 index 000000000..33506ce62 --- /dev/null +++ b/Sources/Analyzers/RuleRegistryGenerator/RuleRegistryGenerator.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0 + false + true + + + *$(MSBuildProjectFullPath)* + + + + + + + + diff --git a/Sources/AngouriMath.sln b/Sources/AngouriMath.sln index e983a6a63..2827231b4 100644 --- a/Sources/AngouriMath.sln +++ b/Sources/AngouriMath.sln @@ -39,6 +39,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Analyzers.CodeFixes", "Anal EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Analyzers.Debug", "Analyzers\Analyzers.Debug\Analyzers.Debug.csproj", "{7C7DE223-D688-E7C3-1021-C86827910720}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RuleRegistryGenerator", "Analyzers\RuleRegistryGenerator\RuleRegistryGenerator.csproj", "{1E2C70F3-EB0F-46AC-8D12-293243D4C135}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AngouriMathPlot", "Samples\AngouriMathPlot\AngouriMathPlot.csproj", "{4B2B48EE-B454-A3AC-5534-63E894592140}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GraphicExample", "Samples\GraphicExample\GraphicExample.csproj", "{13409571-0ADC-B9C6-2EE7-44DF0B4AD43C}" @@ -161,6 +163,10 @@ Global {40BCFEFF-F691-B86D-DEF7-1B3D75C72011}.Debug|Any CPU.Build.0 = Debug|Any CPU {40BCFEFF-F691-B86D-DEF7-1B3D75C72011}.Release|Any CPU.ActiveCfg = Release|Any CPU {40BCFEFF-F691-B86D-DEF7-1B3D75C72011}.Release|Any CPU.Build.0 = Release|Any CPU + {1E2C70F3-EB0F-46AC-8D12-293243D4C135}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E2C70F3-EB0F-46AC-8D12-293243D4C135}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E2C70F3-EB0F-46AC-8D12-293243D4C135}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E2C70F3-EB0F-46AC-8D12-293243D4C135}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -188,5 +194,6 @@ Global {FD30A551-F0D8-071A-9356-0BF0CDFB2FCC} = {D8022D2F-1DCE-4CE1-98DF-70B00D719D59} {ABAD0619-30C7-242D-670F-2ECC908074A2} = {D8022D2F-1DCE-4CE1-98DF-70B00D719D59} {40BCFEFF-F691-B86D-DEF7-1B3D75C72011} = {D8022D2F-1DCE-4CE1-98DF-70B00D719D59} + {1E2C70F3-EB0F-46AC-8D12-293243D4C135} = {358BFC2B-1A5B-4740-8BA1-0E53EFC7EDAC} EndGlobalSection EndGlobal diff --git a/Sources/AngouriMath/AngouriMath.csproj b/Sources/AngouriMath/AngouriMath.csproj index c851c7e03..dcc02ce36 100644 --- a/Sources/AngouriMath/AngouriMath.csproj +++ b/Sources/AngouriMath/AngouriMath.csproj @@ -49,6 +49,13 @@ + + + diff --git a/Sources/AngouriMath/Core/CoreAttributes.cs b/Sources/AngouriMath/Core/CoreAttributes.cs index 674e49e55..5f8c7bf09 100644 --- a/Sources/AngouriMath/Core/CoreAttributes.cs +++ b/Sources/AngouriMath/Core/CoreAttributes.cs @@ -22,4 +22,17 @@ internal sealed class ConstantFieldAttribute : Attribute { } /// [AttributeUsage(AttributeTargets.Field, Inherited = true, AllowMultiple = false)] internal sealed class ConcurrentFieldAttribute : Attribute { } + + /// + /// Marks a rewrite rule switch whose arms are to be generated as individually + /// addressable values, in a field named after the + /// method with Arms appended. + /// + /// + /// The method must be expression-bodied with a body of the form parameter switch { ... }; + /// anything else is a build error rather than an empty list, since a rule set that silently + /// has no rules reads exactly like one that has been checked. + /// + [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)] + internal sealed class AddressableRulesAttribute : Attribute { } } diff --git a/Sources/AngouriMath/Core/Transformations/RewriteRecording.cs b/Sources/AngouriMath/Core/Transformations/RewriteRecording.cs index 58bd9a528..de67ccfea 100644 --- a/Sources/AngouriMath/Core/Transformations/RewriteRecording.cs +++ b/Sources/AngouriMath/Core/Transformations/RewriteRecording.cs @@ -127,11 +127,11 @@ public void Dispose() /// internal static RewriteRecording? Current => current.Value; - internal void Add(RewriteRuleSet ruleSet, Entity before, Entity after) + internal void Add(RewriteRuleSet ruleSet, RewriteRule? rule, Entity before, Entity after) { if (closed) return; - steps.Enqueue(new RewriteStep(ruleSet, before, after)); + steps.Enqueue(new RewriteStep(ruleSet, rule, before, after)); } } } diff --git a/Sources/AngouriMath/Core/Transformations/RewriteRule.cs b/Sources/AngouriMath/Core/Transformations/RewriteRule.cs new file mode 100644 index 000000000..47d0d9f7f --- /dev/null +++ b/Sources/AngouriMath/Core/Transformations/RewriteRule.cs @@ -0,0 +1,195 @@ +// +// 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; + +namespace AngouriMath.Core.Transformations +{ + /// + /// Which way a rewrite moves: does it make the expression bigger, smaller, or neither. + /// + /// + /// + /// Counted from what the rule is written as — operators plus operands on the pattern side + /// against operators plus operands on the replacement side — and therefore a statement about + /// the rule, not about any particular expression it fires on. + /// + /// + /// It exists because a rewrite graph needs it and a rewrite pipeline does not. + /// applies a set, keeps a candidate and moves on, so an + /// expanding rule and a collecting one never meet: the order they run in decides which wins. + /// Equality saturation deletes that order and keeps both results, so it has to be told which + /// pairs undo each other or it will grow without bound — + /// #746 tier 2, measured + /// in the egraph harness at up to 7,143 times the e-nodes when it is not told. + /// + /// + public enum RewriteRuleGrowth + { + /// The replacement is written with fewer operators and operands than the pattern. + Collects, + + /// The two are written with the same number, so the rule moves things about. + Rearranges, + + /// The replacement is written with more, so the rule opens the expression out. + Expands + } + + /// + /// One rewrite, addressable on its own: what it matches, what it puts there instead, where it + /// is written and which way it moves. + /// + /// + /// + /// A is the unit the library applies; this is the unit inside it. + /// The distinction is what + /// #28 asks for — a + /// derivation that says which rewrite fired rather than which group of them — and what + /// #825 is about. + /// + /// + /// These are generated from the switch that defines them, arm by arm, rather + /// than written out a second time. That is deliberate and it is the whole design: the + /// switch stays the thing a human edits and the thing the simplifier calls, so nothing + /// on the hot path changes and the two forms cannot drift apart. Transcribing forty arms into + /// forty objects by hand would be forty chances to alter a pattern silently, and expressing + /// them through the runtime matcher in + /// AngouriMath.Core.Transformations.Matching was measured at about five percent of + /// per rule set exchanged. + /// + /// + /// What is not here. A per-rule . A rule's tier is a claim + /// somebody has to argue for, and there is no honest way to derive one from syntax — so + /// remains the declared tier and this type does not + /// invent a finer one it cannot justify. What being addressable buys is that the finer tier + /// now has somewhere to live once the argument is made, which it did not before. + /// + /// + public sealed class RewriteRule + { + internal RewriteRule( + string source, + int index, + string name, + string? description, + IReadOnlyList nodeTypes, + string patternSource, + string? guardSource, + string replacementSource, + RewriteRuleGrowth growth, + int sourceLine, + Func apply) + { + Source = source; + Index = index; + Name = name; + Description = description; + NodeTypes = nodeTypes; + PatternSource = patternSource; + GuardSource = guardSource; + ReplacementSource = replacementSource; + Growth = growth; + SourceLine = sourceLine; + this.apply = apply; + } + + private readonly Func apply; + + /// The method whose switch this arm belongs to. + public string Source { get; } + + /// Where it sits among that method's arms, which is the order it is tried in. + /// + /// First match wins, so a rule's index is part of what it does: two rules that can both + /// fire on one node are resolved by this and nothing else. + /// + public int Index { get; } + + /// + /// What to call this rule in a report, a test or a bug — the pattern it matches, written + /// as the source writes it. + /// + /// + /// The pattern rather than the position, because a position moves whenever an arm is + /// inserted above it and the point of a name is to survive that. Where a set really does + /// write one pattern twice, the later ones are suffixed #2, #3 — and a set + /// that does is worth looking at, since the second is unreachable. + /// + public string Name { get; } + + /// The comment written above the rule, where there is one: the identity in the notation a mathematician would use. + public string? Description { get; } + + /// + /// The node types the rule can fire on — usually one, occasionally two, and empty where + /// the pattern's shape does not say. + /// + /// + /// A necessary condition, not a sufficient one: a node of one of these types may + /// still fail the rest of the pattern. That is the direction that makes it useful, since + /// what it licenses is skipping the rule on every other type — which is the dispatch a + /// large switch over distinct node types gets from the compiler for free and a + /// list of rules has to be told. + /// + public IReadOnlyList NodeTypes { get; } + + /// The pattern the arm matches, as the C# source writes it. + /// + /// + /// Source text, and named so. This is what a reader would see in the + /// switch, not a representation anything can match against — to ask whether this + /// rule fires on a node, call , which runs the arm itself. + /// + /// + /// The name carries Source because + /// #746 tier 1 is + /// pattern matching as data, and when a pattern becomes a value it should be able to be + /// called Pattern without first breaking somebody. Deciding that after the + /// property shipped would have cost a major version; deciding it here cost nothing. + /// + /// + public string PatternSource { get; } + + /// + /// The side condition as the C# source writes it, or where the arm + /// has no when clause. Source text — see . + /// + public string? GuardSource { get; } + + /// + /// What the arm builds, as the C# source writes it. Source text — see + /// . + /// + public string ReplacementSource { get; } + + /// Which way the rewrite moves. See . + public RewriteRuleGrowth Growth { get; } + + /// The line of the source file the arm is written on. + public int SourceLine { get; } + + /// + /// This one rule at this one node, ignoring its children and every other rule — or + /// where it does not apply. + /// + /// + /// Null means "this rule does not fire here", which is a different claim from the rule + /// set's handing back the expression it was + /// given. The set has to return something; a rule may decline. + /// + public Entity? TryApply(Entity node) + => node is null ? throw new ArgumentNullException(nameof(node)) : apply(node); + + /// + public override string ToString() + => GuardSource is null + ? $"{PatternSource} => {ReplacementSource}" + : $"{PatternSource} when {GuardSource} => {ReplacementSource}"; + } +} diff --git a/Sources/AngouriMath/Core/Transformations/RewriteRuleSet.cs b/Sources/AngouriMath/Core/Transformations/RewriteRuleSet.cs index f00b2ca69..d7c8859e9 100644 --- a/Sources/AngouriMath/Core/Transformations/RewriteRuleSet.cs +++ b/Sources/AngouriMath/Core/Transformations/RewriteRuleSet.cs @@ -17,31 +17,21 @@ namespace AngouriMath.Core.Transformations /// /// /// - /// The set, rather than the single pattern -> replacement line, is the unit - /// here because that is what has been built so far — not because the finer grain - /// is too expensive. It was asserted here that it would be, on the grounds that each - /// rule would cost a delegate call per node; measuring it found the opposite, and the - /// measurement is in - /// #825: rules - /// bucketed by the node type they match run as fast as the hand-written switch - /// at realistic set sizes and faster at small ones, because a large switch over - /// distinct node types is compiled into that same dispatch anyway. - /// - /// - /// What actually stands in the way is transcription: splitting forty switch arms - /// by hand is forty chances to change a pattern silently. That points at a source - /// generator over the existing bodies rather than at leaving the rewrites unnamed. See - /// #746 item 50, - /// and note that nothing here forecloses it: a set whose rewrites become individually - /// addressable keeps the same name and the same entry in the registry. + /// The set is the unit the library applies; is the unit inside + /// it. Those rules are generated from the switch that defines the set rather than + /// written out again, so the switch stays the thing a human edits and the thing + /// this calls, and the two cannot drift apart. See + /// #825 and + /// #746 item 50. /// /// public sealed class RewriteRuleSet { private readonly Func rules; - internal RewriteRuleSet(string name, string description, TransformationRelation relation, Soundness soundness, Func rules) - => (Name, Description, Relation, Soundness, this.rules) = (name, description, relation, soundness, rules); + internal RewriteRuleSet(string name, string description, TransformationRelation relation, Soundness soundness, Func rules, IReadOnlyList? addressable = null) + => (Name, Description, Relation, Soundness, this.rules, Rules) + = (name, description, relation, soundness, rules, addressable ?? Array.Empty()); /// A stable identity for this set. public string Name { get; } @@ -55,6 +45,44 @@ internal RewriteRuleSet(string name, string description, TransformationRelation /// How well justified that claim is. See on what a tier here is and is not. public Soundness Soundness { get; } + /// + /// The individual rewrites this set is made of, in the order they are tried. + /// + /// + /// + /// Empty is not the same as "no rewrites". A set whose rewrites are written as a + /// switch over the expression has every arm listed here; a set built some other + /// way — a sort, a polynomial division, a method with branches and locals — has none, + /// because there are no arms to generate from. and + /// behave identically either way, so this is a statement + /// about how finely the set can be reported on and not about what it does. + /// + /// + /// First match wins, exactly as in the switch, so the order is part of the + /// meaning: where two rules can fire on one node, the earlier one does. + /// + /// + public IReadOnlyList Rules { get; } + + /// + /// The rule that fires at this node, or where none does. + /// + /// + /// At this node only, leaving its children alone — which is what an arm of the + /// switch sees. Always null for a set with no . + /// + public RewriteRule? RuleFiringAt(Entity node) + { + if (node is null) + throw new ArgumentNullException(nameof(node)); + // The array, and by index: this is walked once per recorded rewrite and the sets are + // long -- CommonRules alone has 103 arms. + for (var i = 0; i < Rules.Count; i++) + if (Rules[i].TryApply(node) is not null) + return Rules[i]; + return null; + } + /// /// Applies the set once, bottom-up over every node, exactly as /// does. One pass: a rewrite @@ -88,7 +116,10 @@ private Entity ApplyOnceRecording(Entity expression, RewriteRecording recording) { var rewritten = rules(node); if (rewritten != node) - recording.Add(this, node, rewritten); + // Which rule did it, asked only of a node that actually changed and only + // while somebody is recording. The arms are tried in the same order the + // switch tries them, so the first that applies is the one that fired. + recording.Add(this, RuleFiringAt(node), node, rewritten); return rewritten; }); diff --git a/Sources/AngouriMath/Core/Transformations/RewriteRules.cs b/Sources/AngouriMath/Core/Transformations/RewriteRules.cs index fccc41f19..e377cbe41 100644 --- a/Sources/AngouriMath/Core/Transformations/RewriteRules.cs +++ b/Sources/AngouriMath/Core/Transformations/RewriteRules.cs @@ -92,7 +92,8 @@ public static class RewriteRules "Moves a negative numeric factor out of a product into the sign of the term.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.InvertNegativeMultipliers); + Patterns.InvertNegativeMultipliers, + Patterns.InvertNegativeMultipliersArms); /// /// The arithmetic housekeeping rules — collecting like terms, flattening nested @@ -103,7 +104,8 @@ public static class RewriteRules "Collects like terms and normalises the arrangement of products and quotients.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.CommonRules); + Patterns.CommonRules, + Patterns.CommonRulesArms); /// /// Gets a quotient into the shape the division rules expect before they run. @@ -113,7 +115,8 @@ public static class RewriteRules "Lifts numeric factors out of a quotient so that the division rules can see it.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.DivisionPreparingRules); + Patterns.DivisionPreparingRules, + Patterns.DivisionPreparingRulesArms); /// /// Cosmetic arrangement of signs, so that adding a negative reads as a difference. @@ -123,7 +126,8 @@ public static class RewriteRules "Arranges signs so that adding a negative is written as subtracting a positive.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.NumericNeatRules); + Patterns.NumericNeatRules, + Patterns.NumericNeatRulesArms); #endregion @@ -139,7 +143,8 @@ public static class RewriteRules // (a ^ b) ^ c is a ^ (b c) only on a branch; the rules guard for it, and the // guard is what the tier is stating. Soundness.SoundUnderAssumptions, - Patterns.PowerRules); + Patterns.PowerRules, + Patterns.PowerRulesArms); /// /// Multiplies products over sums out. @@ -149,7 +154,8 @@ public static class RewriteRules "Distributes products and powers over sums.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.ExpandRules); + Patterns.ExpandRules, + Patterns.ExpandRulesArms); /// /// Takes common factors back out of a sum. @@ -159,7 +165,8 @@ public static class RewriteRules "Gathers common factors out of sums.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.FactorizeRules); + Patterns.FactorizeRules, + Patterns.FactorizeRulesArms); /// /// Recognises a perfect square written out, so that factorisation has something to @@ -261,7 +268,8 @@ public static class RewriteRules // tan and cot bring poles with them, so an identity that introduces one holds // away from those points rather than everywhere. Soundness.SoundUnderAssumptions, - Patterns.TrigonometricRules); + Patterns.TrigonometricRules, + Patterns.TrigonometricRulesArms); /// /// Rewrites the derived trigonometric functions in terms of sine and cosine. @@ -271,7 +279,8 @@ public static class RewriteRules "Writes tangents, cotangents, secants and cosecants as sines and cosines.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.NormalTrigonometricForm); + Patterns.NormalTrigonometricForm, + Patterns.NormalTrigonometricFormArms); /// /// Gathers sines and cosines back into the derived functions where that is shorter. @@ -281,7 +290,8 @@ public static class RewriteRules "Recognises a quotient or reciprocal of sines and cosines as a tangent, cotangent, secant or cosecant.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.CollapseTrigonometricFunctions); + Patterns.CollapseTrigonometricFunctions, + Patterns.CollapseTrigonometricFunctionsArms); /// /// Opens a trigonometric function of a sum into functions of its terms. @@ -291,7 +301,8 @@ public static class RewriteRules "Expands a sine or cosine of a sum into products of sines and cosines of its terms.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.ExpandTrigonometricRules); + Patterns.ExpandTrigonometricRules, + Patterns.ExpandTrigonometricRulesArms); /// /// Opens a trigonometric function of a multiplied angle. @@ -305,7 +316,8 @@ public static class RewriteRules "Expands a sine or cosine of an integer multiple of an angle.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.ExpandMultipleAngleRules); + Patterns.ExpandMultipleAngleRules, + Patterns.ExpandMultipleAngleRulesArms); #endregion @@ -319,7 +331,8 @@ public static class RewriteRules "Applies the identities of boolean algebra to conjunctions, disjunctions and negations.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.BooleanRules); + Patterns.BooleanRules, + Patterns.BooleanRulesArms); /// /// Rules about equalities and inequalities. @@ -329,7 +342,8 @@ public static class RewriteRules "Rearranges equalities and inequalities into their usual form.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.InequalityEqualityRules); + Patterns.InequalityEqualityRules, + Patterns.InequalityEqualityRulesArms); /// /// Rules about unions, intersections and set differences. @@ -339,7 +353,8 @@ public static class RewriteRules "Applies the identities of set algebra to unions, intersections and set differences.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.SetOperatorRules); + Patterns.SetOperatorRules, + Patterns.SetOperatorRulesArms); /// /// Cancels a quotient of factorials down to the terms that survive. @@ -369,7 +384,8 @@ public static class RewriteRules "Applies the multiplicative identities of Euler's totient function.", TransformationRelation.Equivalence, Soundness.SoundUnderAssumptions, - Patterns.PhiFunctionRules); + Patterns.PhiFunctionRules, + Patterns.PhiFunctionRulesArms); #endregion diff --git a/Sources/AngouriMath/Core/Transformations/RewriteStep.cs b/Sources/AngouriMath/Core/Transformations/RewriteStep.cs index 8b10274e5..e6caab826 100644 --- a/Sources/AngouriMath/Core/Transformations/RewriteStep.cs +++ b/Sources/AngouriMath/Core/Transformations/RewriteStep.cs @@ -19,12 +19,24 @@ namespace AngouriMath.Core.Transformations /// public readonly struct RewriteStep { - internal RewriteStep(RewriteRuleSet ruleSet, Entity before, Entity after) - => (RuleSet, Before, After) = (ruleSet, before, after); + internal RewriteStep(RewriteRuleSet ruleSet, RewriteRule? rule, Entity before, Entity after) + => (RuleSet, Rule, Before, After) = (ruleSet, rule, before, after); /// Which rule set rewrote it. public RewriteRuleSet RuleSet { get; } + /// + /// Which single rewrite in that set did it, where the set is addressable at that grain — + /// see . Null where it is not. + /// + /// + /// This is the grain + /// #28 asks for: a + /// derivation that names the identity applied, rather than the group of identities it + /// was filed under. + /// + public RewriteRule? Rule { get; } + /// The subexpression as it was matched. public Entity Before { get; } @@ -38,6 +50,9 @@ internal RewriteStep(RewriteRuleSet ruleSet, Entity before, Entity after) public Soundness Soundness => RuleSet.Soundness; /// - public override string ToString() => $"{RuleSet.Name}: {Before.Stringize()} -> {After.Stringize()}"; + public override string ToString() + => Rule is null + ? $"{RuleSet.Name}: {Before.Stringize()} -> {After.Stringize()}" + : $"{RuleSet.Name}/{Rule.Name}: {Before.Stringize()} -> {After.Stringize()}"; } } diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Boolean.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Boolean.cs index dbfb3d8e7..e87e9888b 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Boolean.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Boolean.cs @@ -22,6 +22,7 @@ private static bool IsLogic(Entity a, Entity b) private static bool IsLogic(Entity a, Entity b, Entity c) => IsLogic(a, b) && IsLogic(c); + [AddressableRules] internal static Entity BooleanRules(Entity x) => x switch { Impliesf(var ass, var other) when ass == False && IsLogic(other) => True.Provided(other.DomainCondition), diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Common.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Common.cs index c1b75c753..720fb55f3 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Common.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Common.cs @@ -12,6 +12,7 @@ namespace AngouriMath.Functions { internal static partial class Patterns { + [AddressableRules] internal static Entity DivisionPreparingRules(Entity x) => x switch { Mulf(var any1, Divf(Integer(1), var any2)) => any1 / any2, @@ -20,6 +21,7 @@ internal static partial class Patterns _ => x }; + [AddressableRules] internal static Entity NumericNeatRules(Entity x) => x switch { // (-a) + (-b) is -(a + b), and the operands are already negative here, so what is @@ -61,6 +63,7 @@ internal static partial class Patterns _ => x }; + [AddressableRules] internal static Entity CommonRules(Entity x) => x switch { // (a * f(x)) * g(x) = a * (f(x) * g(x)) @@ -149,9 +152,6 @@ internal static partial class Patterns Sumf(Divf(var anyButNot1 and not Integer(1), var any2), var anyButNot1a) when anyButNot1 == anyButNot1a => anyButNot1 * (1 + 1 / any2), - // {1} * {2} - {1} * {3} = {1} * ({2} - {3}) - Minusf(Mulf(var any1, var any2), Mulf(var any1a, var any3)) when any1 == any1a => any1 * (any2 - any3), - // x * x = x ^ 2 Mulf(var any1, var any1a) when any1 == any1a => new Powf(any1, 2), @@ -205,10 +205,6 @@ internal static partial class Patterns // a * (b * {}) = (a * b) * {} Mulf(Number const1, Mulf(Number const2, var any1)) => const1 * const2 * any1, - // {1} - {2} * {1} - Minusf(var any1, Mulf(var any2, var any1a)) when any1 == any1a => any1 * (1 - any2), - Minusf(var any1, Mulf(var any1a, var any2)) when any1 == any1a => any1 * (1 - any2), - Sumf(var any1, Sumf(var any2, var any1a)) when any1 == any1a => 2 * any1 + any2, Sumf(var any1, Sumf(var any1a, var any2)) when any1 == any1a => 2 * any1 + any2, Sumf(Sumf(var any2, var any1), var any1a) when any1 == any1a => 2 * any1 + any2, diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.EqualityInequality.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.EqualityInequality.cs index a51a5dc83..1f368e9d2 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.EqualityInequality.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.EqualityInequality.cs @@ -96,6 +96,7 @@ private static Entity OrderedCondition(Entity entity) private static Entity BothHold(Entity left, Entity right) => left == True ? right : right == True ? left : left & right; + [AddressableRules] internal static Entity InequalityEqualityRules(Entity x) => x switch { Orf(Lessf(var any1, var any2), Equalsf(var any1a, var any2a)) when any1 == any1a && any2 == any2a => any1 <= any2, diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs index a6c6867fc..fc102f4e6 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.ExpandFactorize.cs @@ -12,6 +12,7 @@ namespace AngouriMath.Functions { internal static partial class Patterns { + [AddressableRules] internal static Entity ExpandRules(Entity x) => x switch { Sinf(Sumf(var any1, var any2)) => new Sinf(any1) * new Cosf(any2) + new Sinf(any2) * new Cosf(any1), @@ -20,6 +21,7 @@ internal static partial class Patterns _ => x }; + [AddressableRules] internal static Entity FactorizeRules(Entity x) => x switch { // {1}^2n - {2}^2m = ({1}^n - {2}^m) * ({1}^n + {2}^m). diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.NumberTheory.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.NumberTheory.cs index 0f1e7caf1..9a3ef866b 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.NumberTheory.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.NumberTheory.cs @@ -11,6 +11,7 @@ namespace AngouriMath.Functions { internal static partial class Patterns { + [AddressableRules] internal static Entity PhiFunctionRules(Entity x) => x switch { Phif(Powf(Integer prime, var variable)) when prime.IsPrime => new Powf(prime, variable - 1) * (prime - 1), diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs index f701fbd10..9dcae3108 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Power.cs @@ -18,6 +18,7 @@ expr is Powf(var @base, Integer { IsNegative: true } pow) ? 1 / MathS.Pow(@base, -1 * pow) : expr; /// 1 + (-x) => 1 - x, and -(a - b) => b - a + [AddressableRules] internal static Entity InvertNegativeMultipliers(Entity expr) => expr switch { Sumf(var any1, Mulf(Real { IsNegative: true } const1, var any2)) @@ -40,6 +41,7 @@ expr is Powf(var @base, Integer { IsNegative: true } pow) _ => expr }; + [AddressableRules] internal static Entity PowerRules(Entity x) => x switch { // {} / {} = 1 provided not {} = 0 @@ -127,9 +129,6 @@ expr is Powf(var @base, Integer { IsNegative: true } pow) // x^n / x Divf(Powf(var any1, var any2), var any1a) when any1 == any1a => new Powf(any1, any2 - 1), - // x^n / x^m - Divf(Powf(var any1, var any2), Powf(var any1a, var any3)) when any1 == any1a => new Powf(any1, any2 - any3), - // c ^ log(c, a) = a Powf(Number const1, Logf(Number const1a, var any1)) when const1 == const1a => any1, diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Sets.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Sets.cs index dc4bfc2ab..ca37aeea7 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Sets.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Sets.cs @@ -15,6 +15,7 @@ internal static partial class Patterns { [ConstantField] private static readonly FiniteSet FullBooleanSet = new FiniteSet(True, False); + [AddressableRules] internal static Entity SetOperatorRules(Entity x) => x switch { Intersectionf(var any1, var any1a) when any1 == any1a => any1, diff --git a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Trigonometry.cs b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Trigonometry.cs index 49c0218d6..b23e46d29 100644 --- a/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Trigonometry.cs +++ b/Sources/AngouriMath/Functions/Simplification/Patterns/Patterns.Trigonometry.cs @@ -92,6 +92,7 @@ private static bool WithinZeroAndPi(Entity argument, bool closed) } + [AddressableRules] internal static Entity TrigonometricRules(Entity x) => x switch { // sin({}) * cos({}) = 1/2 * sin(2{}) @@ -220,6 +221,7 @@ private static bool WithinZeroAndPi(Entity argument, bool closed) _ => x }; + [AddressableRules] internal static Entity ExpandTrigonometricRules(Entity x) => x switch { Mulf(Rational(Integer(1), Integer(2)), Sinf(Mulf(Integer(2), var any1))) => new Sinf(any1) * new Cosf(any1), @@ -247,6 +249,7 @@ private static bool WithinZeroAndPi(Entity argument, bool closed) /// zero and neither did (sin(2t)csc(t))^2/4 - cos(2t) - sin(t)^2, which is /// https://github.com/asc-community/AngouriMath/issues/557. /// + [AddressableRules] internal static Entity ExpandMultipleAngleRules(Entity x) => x switch { Sinf(Mulf(Integer n, var inner)) when IsWorthExpanding(n) => @@ -263,6 +266,7 @@ private static bool WithinZeroAndPi(Entity argument, bool closed) private static bool IsWorthExpanding(Integer n) => n.EInteger.Abs() >= 2 && n.EInteger.Abs() <= MaxAngleMultiplier; + [AddressableRules] internal static Entity CollapseTrigonometricFunctions(Entity x) => x switch { // sin / cos = tan @@ -279,6 +283,7 @@ private static bool IsWorthExpanding(Integer n) /// /// For this it is true that any trigonometric function is either sin or cos /// + [AddressableRules] internal static Entity NormalTrigonometricForm(Entity x) => x switch { Tanf(var any1) => any1.Sin() / any1.Cos(), diff --git a/Sources/Tests/UnitTests/Common/PublicApi.txt b/Sources/Tests/UnitTests/Common/PublicApi.txt index 3a40f5c30..c8b48db33 100644 --- a/Sources/Tests/UnitTests/Common/PublicApi.txt +++ b/Sources/Tests/UnitTests/Common/PublicApi.txt @@ -120,11 +120,29 @@ AngouriMath.Core.ReasonOfFailureWhileParsing.op_Inequality(AngouriMath.Core.Reas AngouriMath.Core.Transformations.RewriteRecording.Dispose() : System.Void AngouriMath.Core.Transformations.RewriteRecording.Start() : AngouriMath.Core.Transformations.RewriteRecording AngouriMath.Core.Transformations.RewriteRecording.Steps { } : System.Collections.Generic.IReadOnlyList +AngouriMath.Core.Transformations.RewriteRule.Description { } : System.String +AngouriMath.Core.Transformations.RewriteRule.Growth { } : AngouriMath.Core.Transformations.RewriteRuleGrowth +AngouriMath.Core.Transformations.RewriteRule.GuardSource { } : System.String +AngouriMath.Core.Transformations.RewriteRule.Index { } : System.Int32 +AngouriMath.Core.Transformations.RewriteRule.Name { } : System.String +AngouriMath.Core.Transformations.RewriteRule.NodeTypes { } : System.Collections.Generic.IReadOnlyList +AngouriMath.Core.Transformations.RewriteRule.PatternSource { } : System.String +AngouriMath.Core.Transformations.RewriteRule.ReplacementSource { } : System.String +AngouriMath.Core.Transformations.RewriteRule.Source { } : System.String +AngouriMath.Core.Transformations.RewriteRule.SourceLine { } : System.Int32 +AngouriMath.Core.Transformations.RewriteRule.ToString() : System.String +AngouriMath.Core.Transformations.RewriteRule.TryApply(AngouriMath.Entity) : AngouriMath.Entity +AngouriMath.Core.Transformations.RewriteRuleGrowth.Collects : AngouriMath.Core.Transformations.RewriteRuleGrowth +AngouriMath.Core.Transformations.RewriteRuleGrowth.Expands : AngouriMath.Core.Transformations.RewriteRuleGrowth +AngouriMath.Core.Transformations.RewriteRuleGrowth.Rearranges : AngouriMath.Core.Transformations.RewriteRuleGrowth +AngouriMath.Core.Transformations.RewriteRuleGrowth.value__ : System.Int32 AngouriMath.Core.Transformations.RewriteRuleSet.ApplyOnce(AngouriMath.Entity) : AngouriMath.Entity AngouriMath.Core.Transformations.RewriteRuleSet.AsTransformation() : AngouriMath.Core.Transformations.Transformation AngouriMath.Core.Transformations.RewriteRuleSet.Description { } : System.String AngouriMath.Core.Transformations.RewriteRuleSet.Name { } : System.String AngouriMath.Core.Transformations.RewriteRuleSet.Relation { } : AngouriMath.Core.Transformations.TransformationRelation +AngouriMath.Core.Transformations.RewriteRuleSet.RuleFiringAt(AngouriMath.Entity) : AngouriMath.Core.Transformations.RewriteRule +AngouriMath.Core.Transformations.RewriteRuleSet.Rules { } : System.Collections.Generic.IReadOnlyList AngouriMath.Core.Transformations.RewriteRuleSet.Soundness { } : AngouriMath.Core.Transformations.Soundness AngouriMath.Core.Transformations.RewriteRuleSet.ToString() : System.String AngouriMath.Core.Transformations.RewriteRules.All { } : System.Collections.Generic.IReadOnlyList @@ -161,6 +179,7 @@ AngouriMath.Core.Transformations.RewriteRules.Trigonometric { } : AngouriMath.Co AngouriMath.Core.Transformations.RewriteStep.After { } : AngouriMath.Entity AngouriMath.Core.Transformations.RewriteStep.Before { } : AngouriMath.Entity AngouriMath.Core.Transformations.RewriteStep.Relation { } : AngouriMath.Core.Transformations.TransformationRelation +AngouriMath.Core.Transformations.RewriteStep.Rule { } : AngouriMath.Core.Transformations.RewriteRule AngouriMath.Core.Transformations.RewriteStep.RuleSet { } : AngouriMath.Core.Transformations.RewriteRuleSet AngouriMath.Core.Transformations.RewriteStep.Soundness { } : AngouriMath.Core.Transformations.Soundness AngouriMath.Core.Transformations.RewriteStep.ToString() : System.String @@ -2504,6 +2523,7 @@ class AngouriMath.Core.ReasonOfFailureWhileParsing+InternalError class AngouriMath.Core.ReasonOfFailureWhileParsing+MissingOperator class AngouriMath.Core.ReasonOfFailureWhileParsing+Unknown class AngouriMath.Core.Transformations.RewriteRecording +class AngouriMath.Core.Transformations.RewriteRule class AngouriMath.Core.Transformations.RewriteRuleSet class AngouriMath.Core.Transformations.RewriteRules class AngouriMath.Core.Transformations.Transformation @@ -2604,6 +2624,7 @@ class AngouriMath.MathS+UnsafeAndInternal class AngouriMath.MathS+Utils enum AngouriMath.Core.ApproachFrom enum AngouriMath.Core.Domain +enum AngouriMath.Core.Transformations.RewriteRuleGrowth enum AngouriMath.Core.Transformations.Soundness enum AngouriMath.Core.Transformations.TransformationRelation enum AngouriMath.MathS+Matrices+Direction diff --git a/Sources/Tests/UnitTests/Core/Transformations/AddressableRulesTest.cs b/Sources/Tests/UnitTests/Core/Transformations/AddressableRulesTest.cs new file mode 100644 index 000000000..f14e65e39 --- /dev/null +++ b/Sources/Tests/UnitTests/Core/Transformations/AddressableRulesTest.cs @@ -0,0 +1,356 @@ +// +// 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; +using AngouriMath.Core.Transformations; +using AngouriMath.Extensions; +using AngouriMath.Functions; +using Xunit; + +namespace AngouriMath.Tests.Core.Transformations +{ + /// + /// The generated rules have to be the switch they were generated from, arm for arm. + /// https://github.com/asc-community/AngouriMath/issues/825 + /// + /// + /// + /// This is the check that makes the generator worth trusting, and it is why the design is a + /// generator rather than a hand transcription: the arms are copied as syntax, so a rule + /// cannot say something its arm does not — and this test says so out loud, over + /// generated input, at every node rather than only at the root. Most rules match a shape that + /// only ever occurs as a subexpression, so a root-only comparison would exercise a handful of + /// them and report green. + /// + /// + /// The comparison is per node and three-way: which rule the set says fired, what that rule + /// produces, and what the switch produces. Agreeing on the answer while disagreeing on + /// which rule gave it would make every derivation built on this wrong in a way no output + /// comparison notices. + /// + /// + [Trait("Area", "Core")] + public sealed class AddressableRulesTest + { + /// Each addressable set, next to the method its arms were generated from. + private static IEnumerable<(RewriteRuleSet Set, Func Switch)> Addressable() + { + yield return (RewriteRules.Common, Patterns.CommonRules); + yield return (RewriteRules.DivisionPreparing, Patterns.DivisionPreparingRules); + yield return (RewriteRules.NumericNeat, Patterns.NumericNeatRules); + yield return (RewriteRules.InvertNegativeMultipliers, Patterns.InvertNegativeMultipliers); + yield return (RewriteRules.Power, Patterns.PowerRules); + yield return (RewriteRules.Expansion, Patterns.ExpandRules); + yield return (RewriteRules.Factorization, Patterns.FactorizeRules); + yield return (RewriteRules.Trigonometric, Patterns.TrigonometricRules); + yield return (RewriteRules.NormalTrigonometricForm, Patterns.NormalTrigonometricForm); + yield return (RewriteRules.CollapseTrigonometricFunctions, Patterns.CollapseTrigonometricFunctions); + yield return (RewriteRules.ExpandTrigonometric, Patterns.ExpandTrigonometricRules); + yield return (RewriteRules.ExpandMultipleAngle, Patterns.ExpandMultipleAngleRules); + yield return (RewriteRules.Boolean, Patterns.BooleanRules); + yield return (RewriteRules.InequalityEquality, Patterns.InequalityEqualityRules); + yield return (RewriteRules.SetOperator, Patterns.SetOperatorRules); + yield return (RewriteRules.PhiFunction, Patterns.PhiFunctionRules); + } + + public static IEnumerable AddressableSets() + => Addressable().Select(pair => new object[] { pair.Set.Name }); + + private static Func SwitchFor(string setName) + => Addressable().First(pair => pair.Set.Name == setName).Switch; + + private static RewriteRuleSet SetFor(string setName) + => Addressable().First(pair => pair.Set.Name == setName).Set; + + private static readonly string[] Leaves = + { "x", "y", "2", "-1", "1/2", "1", "0", "pi", "x > 0", "true", "false", "{ 1, 2 }" }; + + private static readonly string[] Unary = + { + "-({0})", "1 / ({0})", "({0}) ^ 2", "sqrt({0})", "sin({0})", "cos({0})", "tan({0})", + "abs({0})", "ln({0})", "not ({0})", "({0})!", "sgn({0})", "phi({0})", + }; + + private static readonly string[] Binary = + { + "({0}) + ({1})", "({0}) - ({1})", "({0}) * ({1})", "({0}) / ({1})", "({0}) ^ ({1})", + "({0}) and ({1})", "({0}) or ({1})", "({0}) = ({1})", "({0}) >= ({1})", + "({0}) unionwith ({1})", "log({0}, {1})", + }; + + /// + /// Shapes a small grammar does not reach, one per rule set that the grammar alone leaves + /// untouched. Generated input finds what nobody wrote down; a rule that only fires on + /// sin(x)*cos(x) is found by writing sin(x)*cos(x) down. Neither subsumes + /// the other, which is why both are here. + /// + private static readonly string[] Seeds = + { + // Trigonometric + "sin(x) * cos(x)", "cos(x) * sin(x)", "arcsin(x) + arccos(x)", "arccos(x) + arcsin(x)", + "arctan(x) + arccotan(x)", "arctan(1/2) + arctan(1/3)", "sin(x) / cos(x)", + "sin(x) ^ 2 + cos(x) ^ 2", "cos(x) ^ 2 + sin(x) ^ 2", "tan(x) * cotan(x)", + // ExpandRules, which is what RewriteRules.Expansion is + "sin(x + y)", "sin(x - y)", "sin(2 + y)", + // ExpandTrigonometric + "1/2 * sin(2 * x)", "cos(2 * x)", "cos(2 * y)", + // ExpandMultipleAngle + "sin(2 * x)", "cos(3 * x)", "sin(-4 * y)", + // SetOperator + "{ 1, 2 } intersect { 1, 2 }", "{ 1, 2 } unionwith { 1, 2 }", + "{ 1, 2 } setsubtract { 1, 2 }", "{ 1, 2 } intersect ({ 3 } unionwith { 4 })", + "x in { 1 }", "x in [0; 1]", "[-oo; +oo]", + // PhiFunction + "phi(2 ^ x)", "phi(3 ^ y)", + // NormalTrigonometricForm and CollapseTrigonometricFunctions + "tan(x)", "cotan(x)", "sec(x)", "csc(x)", "1 / sin(x)", "cos(x) / sin(x)", + // Factorization + "x ^ 2 - y ^ 2", "x ^ 4 - y ^ 2", "x * y + x * 2", + // Power + "log(x, y) + log(x, 2)", "(x ^ 2) ^ 3", "sqrt(x) * sqrt(x)", "2 ^ x * 2 ^ y", + }; + + /// + /// Every expression a small grammar makes, and every node of each. A written list would + /// only prove the cases somebody pictured, and what a rule set does at a node nobody + /// pictured is the whole subject. + /// + 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 % 29 == 0)) + foreach (var right in level2.Where((_, i) => i % 31 == 0)) + level3.Add(string.Format(shape, left, right)); + + var parsed = new List(); + foreach (var source in level1.Concat(level2).Concat(level3).Concat(Seeds)) + { + try { parsed.Add(source.ToEntity()); } + catch { /* the grammar makes some strings the parser declines; not its subject */ } + } + return parsed; + } + + private static readonly Lazy> nodes = new(() => + { + var seen = new HashSet(); + var all = new List(); + foreach (var expr in Corpus()) + foreach (var node in expr.Nodes) + if (seen.Add(node)) + all.Add(node); + return all; + }); + + private static List Nodes() => nodes.Value; + + [Theory] + [MemberData(nameof(AddressableSets))] + public void EveryArmIsTheArmItWasGeneratedFrom(string setName) + { + var set = SetFor(setName); + var bySwitch = SwitchFor(setName); + var corpus = Nodes(); + Assert.True(corpus.Count > 2000, $"the corpus is only {corpus.Count} nodes"); + + var disagreements = new List(); + var fired = 0; + foreach (var node in corpus) + { + Entity expected; + try { expected = bySwitch(node); } + catch { continue; /* an arm that throws is not a claim about the registry */ } + + RewriteRule? rule; + try { rule = set.RuleFiringAt(node); } + catch (Exception thrown) + { + disagreements.Add($"{node.Stringize()}: no rule could be asked ({thrown.GetType().Name})"); + continue; + } + + if (expected.Equals(node)) + { + // The switch fell through to its discard arm, so no arm matched — and no rule + // may claim to have. + if (rule is not null && !rule.TryApply(node).Equals(node)) + disagreements.Add($"{node.Stringize()}: the switch did nothing, " + + $"but '{rule.Name}' claims {rule.TryApply(node).Stringize()}"); + continue; + } + + fired++; + if (rule is null) + { + disagreements.Add($"{node.Stringize()}: the switch gave {expected.Stringize()}, " + + "no rule claims it"); + continue; + } + var actual = rule.TryApply(node); + if (!expected.Equals(actual)) + disagreements.Add($"{node.Stringize()}: the switch gave {expected.Stringize()}, " + + $"'{rule.Name}' gave {actual?.Stringize() ?? "null"}"); + } + + Assert.True(disagreements.Count == 0, + $"{setName}: {disagreements.Count} of {corpus.Count} nodes disagree\n" + + string.Join("\n", disagreements.Take(20))); + Assert.True(fired > 0, $"{setName} never fired on the corpus, so nothing was compared"); + } + + /// + /// A rule whose pattern and guard repeat an earlier rule's can never fire: first match + /// wins, so the earlier one always takes it. + /// + /// + /// The generator names a rule after its pattern, so a repeat is exactly a name it has had + /// to suffix. That makes an unreachable arm a thing the registry states rather than a + /// thing somebody has to notice while reading a hundred of them. + /// + [Fact] + public void NoArmIsShadowedByAnIdenticalOneAboveIt() + { + var shadowed = RewriteRules.All + .SelectMany(set => set.Rules.Select(rule => (set, rule))) + .Where(pair => pair.rule.Name.Contains(" #")) + .Select(pair => $"{pair.set.Name}: line {pair.rule.SourceLine}, {pair.rule.Name}") + .ToList(); + Assert.True(shadowed.Count == 0, + $"{shadowed.Count} unreachable arms:\n" + string.Join("\n", shadowed)); + } + + /// + /// How much of the registry is addressable at rule grain, as a number that moves rather + /// than a claim that it is "mostly" done. + /// + /// + /// The sets that are not are the ones whose rewrites are not a switch over the + /// expression — a sort, a polynomial division, a method with branches and locals. Each is + /// named here so that making one addressable is a change to this list rather than a + /// silent improvement nobody records. + /// + [Fact] + public void TheRegistryIsAddressableAsFarAsItSaysItIs() + { + var withRules = RewriteRules.All.Where(set => set.Rules.Count > 0).ToList(); + var without = RewriteRules.All.Where(set => set.Rules.Count == 0) + .Select(set => set.Name).OrderBy(name => name, StringComparer.Ordinal).ToList(); + + Assert.Equal(new[] + { + "CanonicalOrder", + "CanonicalOrderCountingConstants", + "CanonicalOrderExact", + "CollapseMultipleFractions", + "CommonDenominator", + "CommonDenominatorCountingConstants", + "CommonDenominatorExact", + "ExpandFactorialDivisions", + "FactorizeFactorialMultiplications", + "InvertNegativePowers", + "PerfectSquare", + "PolynomialGcdCancellation", + "PolynomialLongDivision", + "RationalizeDenominator", + }, without); + + Assert.Equal(16, withRules.Count); + Assert.Equal(347, withRules.Sum(set => set.Rules.Count)); + } + + [Fact] + public void EveryRuleSaysWhereItIsWrittenAndWhatItMatches() + { + foreach (var set in RewriteRules.All) + foreach (var rule in set.Rules) + { + Assert.False(string.IsNullOrWhiteSpace(rule.Name), $"{set.Name} has a nameless rule"); + Assert.False(string.IsNullOrWhiteSpace(rule.PatternSource), $"{set.Name}/{rule.Name} has no pattern"); + Assert.False(string.IsNullOrWhiteSpace(rule.ReplacementSource), $"{set.Name}/{rule.Name} builds nothing"); + Assert.True(rule.SourceLine > 0, $"{set.Name}/{rule.Name} has no line"); + Assert.All(rule.NodeTypes, type => Assert.True(typeof(Entity).IsAssignableFrom(type), + $"{set.Name}/{rule.Name} is filed under {type}, which is not a node")); + } + } + + /// + /// A rule's node type has to be the type it actually fires on, since the point of + /// recording it is that a scheduler may use it to skip the rule. + /// + [Fact] + public void ARuleOnlyFiresOnTheNodeTypeItIsFiledUnder() + { + var wrong = new List(); + foreach (var set in RewriteRules.All) + foreach (var rule in set.Rules) + foreach (var node in Nodes().Where((_, i) => i % 3 == 0)) + { + if (rule.NodeTypes.Count == 0 + || rule.NodeTypes.Any(type => type.IsInstanceOfType(node))) continue; + Entity? applied; + try { applied = rule.TryApply(node); } + catch { continue; } + if (applied is not null) + wrong.Add($"{set.Name}/{rule.Name} is filed under {string.Join("/", rule.NodeTypes.Select(type => type.Name))} " + + $"but fired on {node.Stringize()}"); + } + Assert.True(wrong.Count == 0, string.Join("\n", wrong.Take(10))); + } + + /// + /// The growth field has to agree with what the rule sets are for: expansion opens + /// expressions out and factorisation gathers them up. + /// + [Fact] + public void GrowthSaysWhichWayTheSetMoves() + { + Assert.All(RewriteRules.Expansion.Rules, + rule => Assert.Equal(RewriteRuleGrowth.Expands, rule.Growth)); + Assert.Contains(RewriteRules.Factorization.Rules, rule => rule.Growth == RewriteRuleGrowth.Collects); + Assert.Contains(RewriteRules.ExpandMultipleAngle.Rules, rule => rule.Growth == RewriteRuleGrowth.Expands); + } + + [Fact] + public void ARecordedStepNamesTheRuleAndNotOnlyTheSet() + { + using var recording = RewriteRecording.Start(); + RewriteRules.Common.ApplyOnce("a / (b / c)".ToEntity()); + recording.Dispose(); + + var step = Assert.Single(recording.Steps); + Assert.Equal(RewriteRules.Common, step.RuleSet); + Assert.NotNull(step.Rule); + Assert.Equal("a / (b / c) = a * c / b", step.Rule.Description); + Assert.Equal("Divf(var any1, Divf(var any2, var any3))", step.Rule.PatternSource); + Assert.Equal(step.After, step.Rule.TryApply(step.Before)); + } + + [Fact] + public void ASetWithNoAddressableRulesStillRecordsItsStep() + { + using var recording = RewriteRecording.Start(); + RewriteRules.CanonicalOrderExact.ApplyOnce("y + x".ToEntity()); + recording.Dispose(); + + var step = Assert.Single(recording.Steps); + Assert.Equal(RewriteRules.CanonicalOrderExact, step.RuleSet); + Assert.Null(step.Rule); + } + } +}