diff --git a/Sources/.editorconfig b/Sources/.editorconfig index d95adc5c3..84ecb4a0c 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -33,6 +33,14 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [Tests/UnitTests/Algebra/GroebnerSystemTest.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 +# Named per file rather than per directory: AngouriMath/Core holds files from every year of +# the project, so widening the pattern would demand a header rewrite of all of them. +[AngouriMath/Core/CostModel.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 + +[Tests/UnitTests/Core/CostModelTest.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 + [Tests/UnitTests/Common/ListArgumentOverloadTest.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/Convenience/MathS.cs b/Sources/AngouriMath/Convenience/MathS.cs index 0b08e8deb..d1d3770ec 100644 --- a/Sources/AngouriMath/Convenience/MathS.cs +++ b/Sources/AngouriMath/Convenience/MathS.cs @@ -5642,44 +5642,15 @@ internal static EDecimal DowncastingTolerance /// By default criteria it cannot simplify it further, however, the custom one /// it simplified from 2 to 1. /// - public static Setting> ComplexityCriteria { get; } = new Func(expr => - { - // Those are of the 2nd power to avoid problems with floating numbers - const double TinyWeight = 0.5; - const double MinorWeight = 1.0; - const double Weight = 2.0; - const double MajorWeight = 4.0; - const double HeavyWeight = 8.0; - const double ExtraHeavyWeight = 12.0; - - static double DefaultCriteria(Entity expr) => expr switch { - // Weigh provided predicates much less but nested provideds heavy - Providedf(var inner, var predicate) => - DefaultCriteria(inner) + 0.1 * DefaultCriteria(predicate) + ExtraHeavyWeight * (inner.Nodes.Count(n => n is Providedf) + predicate.Nodes.Count(n => n is Providedf)), - Piecewise { Cases: var cases } => - cases.Sum(@case => - DefaultCriteria(@case.Expression) + 0.1 * DefaultCriteria(@case.Predicate) + ExtraHeavyWeight * (@case.Expression.Nodes.Count(n => n is Providedf) + @case.Predicate.Nodes.Count(n => n is Providedf))), - Variable => Weight, // Number of variables - // A root in a denominator, which the rationalising rule clears out. - // Without a weight here the two forms tie -- 1 / (sqrt(3) + 5) and - // (sqrt(3) - 5) / (-22) are the same rate -- and a tie is settled by - // whichever candidate was generated first, which is not a preference - // so much as an accident. This states the preference instead. - // https://github.com/asc-community/AngouriMath/issues/205 - Divf(_, var divisor) when divisor.Nodes.Any(node => node is Powf(_, Rational and not Integer)) - => MinorWeight + Weight + expr.DirectChildren.Sum(DefaultCriteria), - Divf => MinorWeight + expr.DirectChildren.Sum(DefaultCriteria), // Number of divides - Rational(Integer(1 or -1), _) and not Integer => Weight + expr.DirectChildren.Sum(DefaultCriteria), // Number of rationals with unit numerator - Powf(_, Real { IsNegative: true }) => HeavyWeight + expr.DirectChildren.Sum(DefaultCriteria), // Number of negative powers - Logf => TinyWeight + expr.DirectChildren.Sum(DefaultCriteria), // Number of logarithms - Phif => ExtraHeavyWeight + expr.DirectChildren.Sum(DefaultCriteria), // Number of phi functions - Real { IsNegative: true } => MajorWeight + expr.DirectChildren.Sum(DefaultCriteria), // Number of negative reals - ComparisonSign when expr.DirectChildren[0] == 0 => Weight + expr.DirectChildren.Sum(DefaultCriteria), // 0 < x is bad. x > 0 is good. - Notf (Equalsf eq) => -Weight + DefaultCriteria(eq), // (not x = 0) is equally complex as (x = 0) - _ => expr.DirectChildren.Sum(DefaultCriteria) - } + Weight; // Number of nodes - return DefaultCriteria(expr); - }); + /// + /// The function itself lives on , with the named + /// alternatives beside it — , + /// , . + /// It is referenced rather than repeated so the setting's default and the model + /// cannot drift apart. + /// + public static Setting> ComplexityCriteria { get; } = + new Func(CostModel.DefaultCost); /// /// Settings for the Newton-Raphson's root-search method diff --git a/Sources/AngouriMath/Core/CostModel.cs b/Sources/AngouriMath/Core/CostModel.cs new file mode 100644 index 000000000..42c24de8a --- /dev/null +++ b/Sources/AngouriMath/Core/CostModel.cs @@ -0,0 +1,152 @@ +// +// 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 static AngouriMath.Entity; +using static AngouriMath.Entity.Number; + +namespace AngouriMath.Core +{ + /// + /// What "simpler" means, as a named value rather than as an anonymous function. + /// + /// + /// + /// has always accepted any + /// , so the cost of an expression was already the caller's + /// to choose. What it could not do is be named: two callers wanting "the smallest tree" + /// each wrote the same lambda, neither could say which one they used, and nothing could list + /// what the alternatives are. #746 + /// v2.0 asks for a cost model that is data for exactly that reason, and names the examples — + /// smallest tree, fewest radicals — that now holds. + /// + /// + /// Every model here counts nodes a little, even the ones that are about something else. + /// A criterion that counts only its own feature ties constantly, and a tie is settled by + /// whichever candidate the search happened to generate first — which is an accident rather + /// than a preference. The node term is small enough not to overturn the feature it is added to + /// and large enough to decide between candidates the feature cannot separate. + /// + /// + /// + /// using var _ = MathS.Settings.ComplexityCriteria.Set(CostModel.FewestDivisions.Cost); + /// Console.WriteLine("a / b + b / c".ToEntity().Simplify()); // (a * c + b ^ 2) / (b * c) + /// + /// + /// + /// A short name, so a report can say which model produced an answer. + /// What this model prefers, in a sentence. + /// + /// The cost of an expression; lower is simpler. Pass this to + /// . + /// + public sealed record CostModel(string Name, string Description, Func Cost) + { + /// + /// What uses when nothing is set: a + /// weighted count that prefers few nodes, few divisions, few negative powers, and a + /// rationalised denominator. + /// + /// + /// This is the same function the setting is initialised with, not a copy of it, so the + /// two cannot drift apart. Setting it explicitly changes nothing. + /// + public static CostModel Default { get; } = new( + nameof(Default), + "A weighted count, preferring few nodes, few divisions and no root in a denominator.", + DefaultCost); + + /// Prefers the expression with fewest nodes, counting every node alike. + /// + /// The plainest possible notion of simple, and a useful contrast with + /// : it has no opinion about which node is worse, so it will accept + /// a division or a negative power that would pay to remove. + /// + public static CostModel SmallestTree { get; } = new( + nameof(SmallestTree), + "Fewest nodes, with no preference between them.", + static expr => expr.Nodes.Count()); + + /// Prefers the expression with fewest divisions, then fewest nodes. + /// + /// A negative power is a division written differently, so it counts too — otherwise the + /// model would merely move divisions rather than remove them. + /// + public static CostModel FewestDivisions { get; } = new( + nameof(FewestDivisions), + "Fewest divisions, counting a negative power as one, then fewest nodes.", + static expr => Feature(expr, static node => + node is Divf || node is Powf(_, Real { IsNegative: true }))); + + /// Prefers the expression with fewest radicals, then fewest nodes. + /// + /// A radical is a power by a non-integer rational — sqrt(x) is x ^ (1/2) + /// here, and there is no separate root node to count. + /// + public static CostModel FewestRadicals { get; } = new( + nameof(FewestRadicals), + "Fewest fractional powers, then fewest nodes.", + static expr => Feature(expr, static node => + node is Powf(_, Rational and not Integer))); + + /// + /// Every model here, so a caller can offer the choice rather than hard-code one. + /// + public static IReadOnlyList All { get; } = + new[] { Default, SmallestTree, FewestDivisions, FewestRadicals }; + + /// The name, which is what a report wants. + public override string ToString() => Name; + + /// + /// The count of nodes satisfying , plus a small term in the + /// total size so that expressions the predicate cannot separate are still ordered. + /// + private static double Feature(Entity expr, Func predicate) + => expr.Nodes.Count(predicate) + 0.001 * expr.Nodes.Count(); + + // The default criteria, as a method rather than a lambda so that + // MathS.Settings.ComplexityCriteria and CostModel.Default are one function and not two. + // Those weights are of the 2nd power to avoid problems with floating numbers. + private const double TinyWeight = 0.5; + private const double MinorWeight = 1.0; + private const double Weight = 2.0; + private const double MajorWeight = 4.0; + private const double HeavyWeight = 8.0; + private const double ExtraHeavyWeight = 12.0; + + internal static double DefaultCost(Entity expr) => expr switch + { + // Weigh provided predicates much less but nested provideds heavy + Providedf(var inner, var predicate) => + DefaultCost(inner) + 0.1 * DefaultCost(predicate) + ExtraHeavyWeight * (inner.Nodes.Count(n => n is Providedf) + predicate.Nodes.Count(n => n is Providedf)), + Piecewise { Cases: var cases } => + cases.Sum(@case => + DefaultCost(@case.Expression) + 0.1 * DefaultCost(@case.Predicate) + ExtraHeavyWeight * (@case.Expression.Nodes.Count(n => n is Providedf) + @case.Predicate.Nodes.Count(n => n is Providedf))), + Variable => Weight, // Number of variables + // A root in a denominator, which the rationalising rule clears out. + // Without a weight here the two forms tie -- 1 / (sqrt(3) + 5) and + // (sqrt(3) - 5) / (-22) are the same rate -- and a tie is settled by + // whichever candidate was generated first, which is not a preference + // so much as an accident. This states the preference instead. + // https://github.com/asc-community/AngouriMath/issues/205 + Divf(_, var divisor) when divisor.Nodes.Any(node => node is Powf(_, Rational and not Integer)) + => MinorWeight + Weight + expr.DirectChildren.Sum(DefaultCost), + Divf => MinorWeight + expr.DirectChildren.Sum(DefaultCost), // Number of divides + Rational(Integer(1 or -1), _) and not Integer => Weight + expr.DirectChildren.Sum(DefaultCost), // Number of rationals with unit numerator + Powf(_, Real { IsNegative: true }) => HeavyWeight + expr.DirectChildren.Sum(DefaultCost), // Number of negative powers + Logf => TinyWeight + expr.DirectChildren.Sum(DefaultCost), // Number of logarithms + Phif => ExtraHeavyWeight + expr.DirectChildren.Sum(DefaultCost), // Number of phi functions + Real { IsNegative: true } => MajorWeight + expr.DirectChildren.Sum(DefaultCost), // Number of negative reals + ComparisonSign when expr.DirectChildren[0] == 0 => Weight + expr.DirectChildren.Sum(DefaultCost), // 0 < x is bad. x > 0 is good. + Notf(Equalsf eq) => -Weight + DefaultCost(eq), // (not x = 0) is equally complex as (x = 0) + _ => expr.DirectChildren.Sum(DefaultCost) + } + Weight; // Number of nodes + } +} diff --git a/Sources/Tests/UnitTests/Common/PublicApi.txt b/Sources/Tests/UnitTests/Common/PublicApi.txt index cc2cc4741..3a40f5c30 100644 --- a/Sources/Tests/UnitTests/Common/PublicApi.txt +++ b/Sources/Tests/UnitTests/Common/PublicApi.txt @@ -28,6 +28,23 @@ AngouriMath.Core.Compilation.IntoLinq.CompilationProtocol.ctor() AngouriMath.Core.Compilation.IntoLinq.CompilationProtocol.ctor(AngouriMath.Core.Compilation.IntoLinq.CompilationProtocol) AngouriMath.Core.Compilation.IntoLinq.CompilationProtocol.op_Equality(AngouriMath.Core.Compilation.IntoLinq.CompilationProtocol, AngouriMath.Core.Compilation.IntoLinq.CompilationProtocol) : System.Boolean AngouriMath.Core.Compilation.IntoLinq.CompilationProtocol.op_Inequality(AngouriMath.Core.Compilation.IntoLinq.CompilationProtocol, AngouriMath.Core.Compilation.IntoLinq.CompilationProtocol) : System.Boolean +AngouriMath.Core.CostModel.$() : AngouriMath.Core.CostModel +AngouriMath.Core.CostModel.All { } : System.Collections.Generic.IReadOnlyList +AngouriMath.Core.CostModel.Cost { } : System.Func +AngouriMath.Core.CostModel.Deconstruct(System.String&, System.String&, System.Func&) : System.Void +AngouriMath.Core.CostModel.Default { } : AngouriMath.Core.CostModel +AngouriMath.Core.CostModel.Description { } : System.String +AngouriMath.Core.CostModel.Equals(AngouriMath.Core.CostModel) : System.Boolean +AngouriMath.Core.CostModel.Equals(System.Object) : System.Boolean +AngouriMath.Core.CostModel.FewestDivisions { } : AngouriMath.Core.CostModel +AngouriMath.Core.CostModel.FewestRadicals { } : AngouriMath.Core.CostModel +AngouriMath.Core.CostModel.GetHashCode() : System.Int32 +AngouriMath.Core.CostModel.Name { } : System.String +AngouriMath.Core.CostModel.SmallestTree { } : AngouriMath.Core.CostModel +AngouriMath.Core.CostModel.ToString() : System.String +AngouriMath.Core.CostModel.ctor(System.String, System.String, System.Func) +AngouriMath.Core.CostModel.op_Equality(AngouriMath.Core.CostModel, AngouriMath.Core.CostModel) : System.Boolean +AngouriMath.Core.CostModel.op_Inequality(AngouriMath.Core.CostModel, AngouriMath.Core.CostModel) : System.Boolean AngouriMath.Core.Domain.Any : AngouriMath.Core.Domain AngouriMath.Core.Domain.Boolean : AngouriMath.Core.Domain AngouriMath.Core.Domain.Complex : AngouriMath.Core.Domain @@ -2452,6 +2469,7 @@ AngouriMath.MathS.oo : AngouriMath.Entity+Number+Real AngouriMath.MathS.pi : AngouriMath.Entity+Variable class AngouriMath.Convenience.Setting class AngouriMath.Core.Compilation.IntoLinq.CompilationProtocol +class AngouriMath.Core.CostModel class AngouriMath.Core.EquationSystem class AngouriMath.Core.Exceptions.AngouriBugException class AngouriMath.Core.Exceptions.AngouriMathBaseException diff --git a/Sources/Tests/UnitTests/Core/CostModelTest.cs b/Sources/Tests/UnitTests/Core/CostModelTest.cs new file mode 100644 index 000000000..9ebacbfcd --- /dev/null +++ b/Sources/Tests/UnitTests/Core/CostModelTest.cs @@ -0,0 +1,150 @@ +// +// 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.Linq; +using AngouriMath; +using AngouriMath.Core; +using AngouriMath.Extensions; +using Xunit; + +namespace AngouriMath.Tests.Core +{ + /// + /// The named cost models: that they are usable, that they differ from one another, and that + /// the default one is the default rather than a copy of it. + /// + [Trait("Area", "Core")] + public sealed class CostModelTest + { + /// + /// The property that makes worth having: it is the very + /// function the setting already uses, so a caller who names it explicitly changes + /// nothing. A copy would pass the other tests here and quietly drift later. + /// + [Theory] + [InlineData("a / b + b / c")] + [InlineData("1 / (sqrt(3) + 5)")] + [InlineData("x + 3 / 3 + x ^ 0 - log(e, e2)")] + [InlineData("(x + y) ^ 3")] + [InlineData("sin(x) ^ 2 + cos(x) ^ 2")] + public void NamingTheDefaultChangesNothing(string expression) + { + var untouched = expression.ToEntity().Simplify().Stringize(); + using var _ = MathS.Settings.ComplexityCriteria.Set(CostModel.Default.Cost); + var named = MathS.FromString(expression, useCache: false).Simplify().Stringize(); + Assert.Equal(untouched, named); + } + + /// And the rate itself is the same number, not merely the same winner. + [Theory] + [InlineData("a / b + b / c")] + [InlineData("1 / (sqrt(3) + 5)")] + [InlineData("-x + 1/2")] + public void TheDefaultCostIsTheRateTheLibraryUses(string expression) + { + var expr = expression.ToEntity(); + Assert.Equal(expr.SimplifiedRate, CostModel.Default.Cost(expr)); + } + + /// + /// The documented example, which is the point of the whole feature: a different notion of + /// simple produces a different answer. + /// + [Fact] + public void FewestDivisionsClearsTheDivisionsOut() + { + Assert.Equal("a / b + b / c", "a / b + b / c".ToEntity().Simplify().Stringize()); + + using var _ = MathS.Settings.ComplexityCriteria.Set(CostModel.FewestDivisions.Cost); + var byFewestDivisions = MathS.FromString("a / b + b / c", useCache: false).Simplify(); + Assert.True(byFewestDivisions.Nodes.Count(node => node is Entity.Divf) < 2, + $"expected fewer divisions, got {byFewestDivisions.Stringize()}"); + } + + /// + /// Every model orders the obvious pair the obvious way, which is the least a cost model + /// has to do and is worth pinning per model rather than for the default alone. + /// + [Theory] + [InlineData("x")] + [InlineData("x + y")] + [InlineData("sin(x)")] + public void EveryModelPrefersTheSmallerOfTwoForms(string small) + { + var smaller = small.ToEntity(); + Entity bigger = smaller + 0 * MathS.Var("q") * MathS.Var("r"); + foreach (var model in CostModel.All) + Assert.True(model.Cost(smaller) < model.Cost(bigger), + $"{model.Name} did not prefer {smaller.Stringize()} to {bigger.Stringize()}"); + } + + /// + /// The models genuinely disagree about the same pair, which is the whole reason for + /// having more than one. + /// + /// + /// Two ways of writing one expression: two quotients added, or one quotient over a + /// common denominator. The default prefers the first (24 against 33, because combining + /// costs nodes) and counting divisions prefers the second (one division against two). + /// + [Fact] + public void TheModelsDisagreeWithEachOther() + { + var split = "a / b + b / c".ToEntity(); + var combined = "(a * c + b ^ 2) / (b * c)".ToEntity(); + + Assert.True(CostModel.Default.Cost(split) < CostModel.Default.Cost(combined), + "the default should prefer the split form"); + Assert.True(CostModel.FewestDivisions.Cost(combined) < CostModel.FewestDivisions.Cost(split), + "counting divisions should prefer the common denominator"); + } + + /// + /// The default pays nodes to clear a root out of a denominator, which is the preference + /// #205 put there. A plain node count does not: the two forms tie on size, so the + /// weighting is the only thing separating them. + /// + [Fact] + public void OnlyTheDefaultCaresAboutARootInADenominator() + { + var withRadicalBelow = "1 / (sqrt(3) + 5)".ToEntity(); + var rationalised = withRadicalBelow.Simplify(); + + Assert.True(CostModel.Default.Cost(rationalised) < CostModel.Default.Cost(withRadicalBelow)); + Assert.Equal(CostModel.SmallestTree.Cost(withRadicalBelow), + CostModel.SmallestTree.Cost(rationalised)); + } + + /// + /// A feature model still orders expressions its feature cannot tell apart, because a tie + /// is settled by whichever candidate was generated first — an accident, not a preference. + /// + [Fact] + public void AFeatureModelStillBreaksTiesBySize() + { + var small = "x + y".ToEntity(); + var large = "x + y + 0 * q * r * s".ToEntity(); + Assert.Equal(0, small.Nodes.Count(n => n is Entity.Divf)); + Assert.Equal(0, large.Nodes.Count(n => n is Entity.Divf)); + Assert.True(CostModel.FewestDivisions.Cost(small) < CostModel.FewestDivisions.Cost(large)); + } + + /// They can be listed and named, which is what "as data" buys. + [Fact] + public void TheyCanBeListedAndNamed() + { + Assert.Contains(CostModel.Default, CostModel.All); + Assert.Equal(CostModel.All.Count, CostModel.All.Select(m => m.Name).Distinct().Count()); + foreach (var model in CostModel.All) + { + Assert.False(string.IsNullOrWhiteSpace(model.Name)); + Assert.False(string.IsNullOrWhiteSpace(model.Description)); + Assert.Equal(model.Name, model.ToString()); + } + } + } +}