diff --git a/.github/workflows/TrimmingAndAot.yml b/.github/workflows/TrimmingAndAot.yml new file mode 100644 index 000000000..618e7af4a --- /dev/null +++ b/.github/workflows/TrimmingAndAot.yml @@ -0,0 +1,65 @@ +name: 'Trimming & NativeAOT' + +# Deliberately without a paths: filter. What breaks a trimmed or NativeAOT build is a run-time +# name lookup, and one can be introduced by a change anywhere in the kernel, by a package bump, +# or by an SDK bump -- so there is no subset of the tree this gate could safely skip. Two +# workflows here have already been silenced by a filter that matched nothing +# (https://github.com/asc-community/AngouriMath/issues/775); an unconditional trigger cannot be. +on: + push: + branches: + - master + pull_request: + branches: + - '*' + workflow_dispatch: + +jobs: + Publish: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + rid: linux-x64 + - os: windows-latest + rid: win-x64 + - os: macos-latest + rid: osx-arm64 + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.x' + dotnet-quality: 'preview' + + # ILC links the runtime into the binary and needs a native linker and zlib to do it. + - name: 'Install the native toolchain for Linux' + if: ${{ matrix.os == 'ubuntu-latest' }} + run: | + sudo apt-get update + sudo apt-get install -y clang zlib1g-dev + + # The project sets PublishTrimmed, TrimmerSingleWarn=false and ILLinkTreatWarningsAsErrors, + # so any IL2xxx from the kernel fails this step. There is no flag to forget on the command + # line and no way to read a warning as noise. + - name: 'Publish trimmed' + run: dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r ${{ matrix.rid }} -o artifacts/trimmed + + # Publishing is not the test. Everything the trimmer removed that the kernel reaches for by + # name is invisible until the binary runs, so the binary runs, and its exit code is the gate. + - name: 'Run the trimmed binary' + shell: bash + run: ./artifacts/trimmed/AotSmokeTest + + - name: 'Publish NativeAOT' + run: dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r ${{ matrix.rid }} -p:AngouriMathPublishAot=true -o artifacts/aot + + - name: 'Run the NativeAOT binary' + shell: bash + run: ./artifacts/aot/AotSmokeTest diff --git a/AGENTS.md b/AGENTS.md index b0629612b..e9ef9d2f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -419,6 +419,7 @@ are short, and a stale one is worse than none — if you change what a file desc | [`Contributing/Transformations.md`](Sources/AngouriMath/Docs/Contributing/Transformations.md) | the transformation layer the 1.x entry points sit on, and how to add the next rule set | | [`Contributing/SimplificationContract.md`](Sources/AngouriMath/Docs/Contributing/SimplificationContract.md) | what a rewrite may assume, and the ten obligations one has to meet. Read it *before* adding or changing a rule | | [`Contributing/CanonicalForm.md`](Sources/AngouriMath/Docs/Contributing/CanonicalForm.md) | canonical versus simplest, why no canonical form exists for the whole language, and what one means per node class. Read it before comparing two expressions for equality | +| [`Contributing/Trimming.md`](Sources/AngouriMath/Docs/Contributing/Trimming.md) | what `IsAotCompatible` on the kernel promises, and what breaks it. Read it before adding reflection anywhere | | [`Contributing/coding_rules.md`](Sources/AngouriMath/Docs/Contributing/coding_rules.md) | sealed-or-abstract, and immutability of `Entity` | | [`WhatsNew/version_performance_control.md`](Sources/AngouriMath/Docs/WhatsNew/version_performance_control.md) | the inter-version performance table, how to add a column, and when the CI performance baseline may be updated | | `Sources/Analyzers/` | the custom analyzers, including the static-field one behind `[ConstantField]` | diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 30152550e..51b406ab3 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -30,6 +30,9 @@ read first. | **Silent** | `"a in (b in c)".ToEntity().Stringize()`, and `Latexize` | `a in b in c` / `a \in b \in c` | `a in (b in c)` / `a \in \left(b \in c\right)` | | **Silent** | `"x * (y mod z)".ToEntity().Stringize()`, and `Latexize` | `x * y mod z` / `x y \bmod z` | `x * (y mod z)` / `x \left(y \bmod z\right)` | | **Silent** | `"-1 * (y mod z)".ToEntity().Stringize()` | `-y mod z` | `-(y mod z)` | +| | any expression mixing a number with a `Complex` argument, `Compile`d in a NativeAOT app — `"x + 1".Compile("x")` | `UncompilableNodeException: ... The binary operator Add is not defined for the types 'System.Numerics.Complex' and 'System.Numerics.Complex'` | the compiled function, answering as it does under the JIT | +| | `Compile` to a nullable integral return type in a NativeAOT app | `AngouriBugException: IsNaN method expected for type System.Double`, which took the process down | the compiled function | +| **Silent** | an app publishing with `PublishTrimmed` or NativeAOT | `AngouriMath.dll` was copied in whole, being unmarked | it is trimmed with the rest, since the assembly now declares `IsTrimmable` | ### A printed operator that is not associative keeps its brackets @@ -94,6 +97,32 @@ bracketing for. The change only ever adds `\left(`/`\right)` groups, which CShar parses, so nothing downstream needs a matching change ([#822](https://github.com/asc-community/AngouriMath/issues/822)). +### `Compile` works in a trimmed or NativeAOT application + +The Linq compilation path found the method for each node by name — +`typeof(MathAllMethods).GetMethod(name, ...)` for the mathematical functions, +`expr.Type.GetMethod("IsNaN")` for the NaN test — and left the operators and conversions between +`Complex`, `BigInteger` and the primitives to `Expression.Add` and `Expression.Convert`, which find +them by reflecting over the operand type. A name resolved at run time is invisible to the trimmer, +so the members were removed and the lookups came back empty. All four are now tables of members +named at compile time: `MathAllMethods.Definitions` generated beside the methods it dispatches to, +and `nanChecks`, `operators` and `conversionOperators` in `CompilationProtocol`. + +Nothing changes under the JIT — the same `MethodInfo` reaches the same `Expression` node — and the +44 assertions of `Sources/Tests/AotSmokeTest` give byte-identical output under the JIT, a trimmed +publish and a NativeAOT publish. What changed is that the second and third of those now run at all: +before this, six of them threw and the seventh aborted the process. + +`AngouriMath` is marked `IsAotCompatible` for `net8.0` and later as a result, which is also the +silent half of the entry above: a trimmer reads that as permission to remove unused code from +inside the assembly, so an app that trims will now get a smaller `AngouriMath` rather than all of +it. That is the point of the mark, and +[`Docs/Contributing/Trimming.md`](Sources/AngouriMath/Docs/Contributing/Trimming.md) says what +keeps it true. + +[#363](https://github.com/asc-community/AngouriMath/issues/363), +[#746](https://github.com/asc-community/AngouriMath/issues/746) item 79. + --- ## 2.3.0 — since 2.2.0 diff --git a/Sources/.editorconfig b/Sources/.editorconfig index b7113aa6d..089151dab 100644 --- a/Sources/.editorconfig +++ b/Sources/.editorconfig @@ -87,3 +87,6 @@ file_header_template=\nCopyright (c) 2019-2026 Angouri.\nAngouriMath is licensed [AngouriMath/Convenience/MathS.Polynomials.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/AotSmokeTest/*.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.sln b/Sources/AngouriMath.sln index 2827231b4..8e196265e 100644 --- a/Sources/AngouriMath.sln +++ b/Sources/AngouriMath.sln @@ -33,6 +33,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "InteractiveWrapperUnitTests EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotnetBenchmark", "Tests\DotnetBenchmark\DotnetBenchmark.csproj", "{C61DD310-AF04-4B4F-8A34-59FFC8A78F41}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AotSmokeTest", "Tests\AotSmokeTest\AotSmokeTest.csproj", "{4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}" +EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "TerminalUnitTests", "Tests\TerminalUnitTests\TerminalUnitTests.fsproj", "{90552AEB-7563-4C06-A0B7-9F810A572E8A}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Analyzers.CodeFixes", "Analyzers\Analyzers.CodeFixes\Analyzers.CodeFixes.csproj", "{9E5FEA3D-F9DE-2F38-D434-8C97DF80633F}" @@ -119,6 +121,10 @@ Global {C61DD310-AF04-4B4F-8A34-59FFC8A78F41}.Debug|Any CPU.Build.0 = Debug|Any CPU {C61DD310-AF04-4B4F-8A34-59FFC8A78F41}.Release|Any CPU.ActiveCfg = Release|Any CPU {C61DD310-AF04-4B4F-8A34-59FFC8A78F41}.Release|Any CPU.Build.0 = Release|Any CPU + {4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1}.Release|Any CPU.Build.0 = Release|Any CPU {90552AEB-7563-4C06-A0B7-9F810A572E8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {90552AEB-7563-4C06-A0B7-9F810A572E8A}.Debug|Any CPU.Build.0 = Debug|Any CPU {90552AEB-7563-4C06-A0B7-9F810A572E8A}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -183,6 +189,7 @@ Global {3C03B5FC-D0D8-4E52-B581-743D4211F819} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53} {FDEE9428-74CB-44ED-BBD7-DAAC9CCBADCA} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53} {C61DD310-AF04-4B4F-8A34-59FFC8A78F41} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53} + {4A21F0B8-9C36-4E9D-B77E-5B84C7D8F2A1} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53} {90552AEB-7563-4C06-A0B7-9F810A572E8A} = {9869C55D-A753-4DE2-9C22-CCF768C6EC53} {9E5FEA3D-F9DE-2F38-D434-8C97DF80633F} = {358BFC2B-1A5B-4740-8BA1-0E53EFC7EDAC} {7C7DE223-D688-E7C3-1021-C86827910720} = {358BFC2B-1A5B-4740-8BA1-0E53EFC7EDAC} diff --git a/Sources/AngouriMath/AngouriMath.csproj b/Sources/AngouriMath/AngouriMath.csproj index dcc02ce36..693d146b5 100644 --- a/Sources/AngouriMath/AngouriMath.csproj +++ b/Sources/AngouriMath/AngouriMath.csproj @@ -21,6 +21,23 @@ bin/$(Configuration)/$(TargetFramework)/$(MSBuildProjectName).xml + + + true + + enable NU5104;CA2252 diff --git a/Sources/AngouriMath/Docs/Contributing/README.md b/Sources/AngouriMath/Docs/Contributing/README.md index 74587e3c9..8fc262f57 100644 --- a/Sources/AngouriMath/Docs/Contributing/README.md +++ b/Sources/AngouriMath/Docs/Contributing/README.md @@ -18,7 +18,9 @@ If you aren't sure about what to add, you may want to check the current projects 6. What canonical form means here — and how it differs from "simplest". Why no canonical form exists for the whole language, what one means per node class, and where the library measurably stands against it -7. Coding rules — sealed-or-abstract, immutability, and what may be +7. Trimming and NativeAOT — what the kernel promises by being marked + `IsAotCompatible`, what checks it, and the four shapes of run-time name lookup that break it +8. Coding rules — sealed-or-abstract, immutability, and what may be made `public` See also BREAKING-CHANGES.md, where a change that makes diff --git a/Sources/AngouriMath/Docs/Contributing/Trimming.md b/Sources/AngouriMath/Docs/Contributing/Trimming.md new file mode 100644 index 000000000..bd196a439 --- /dev/null +++ b/Sources/AngouriMath/Docs/Contributing/Trimming.md @@ -0,0 +1,64 @@ +# Trimming and NativeAOT + +The kernel is published with `IsAotCompatible`, which is a claim to consumers: a trimmer takes it +as licence to remove code *inside* `AngouriMath.dll`, and the NativeAOT compiler keeps no +reflection metadata for a member nothing referenced. The claim is only as good as what checks it, +and what checks it is `Sources/Tests/AotSmokeTest`. + +Run it the way CI does: + +``` +dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r linux-x64 -o artifacts/trimmed +./artifacts/trimmed/AotSmokeTest + +dotnet publish Sources/Tests/AotSmokeTest/AotSmokeTest.csproj -c Release -r linux-x64 -p:AngouriMathPublishAot=true -o artifacts/aot +./artifacts/aot/AotSmokeTest +``` + +`-r` is required for both — trimming and ILC are per-runtime-identifier. On Linux the NativeAOT +half also needs `clang` and `zlib1g-dev`; on Windows the MSVC build tools; on macOS the Xcode +command line tools. + +## Two things it checks, and they are not the same + +**Publishing.** The project sets `TrimmerSingleWarn=false` so a warning names a line rather than an +assembly, and `ILLinkTreatWarningsAsErrors` / `IlcTreatWarningsAsErrors` so any IL2xxx or IL3xxx +from the kernel fails the publish. `TreatWarningsAsErrors` alone does not cover either of those: +Roslyn, the trimmer and the NativeAOT compiler each have their own switch. + +**Running.** The binary is then executed, and its exit code is the gate. This half is not +redundant. Everything that goes wrong here goes wrong *silently at publish time*: a member removed +because only a string named it produces no warning at all, and the failure is a `null` from +`Type.GetMethod` or a "no coercion operator is defined between types" from `Expression.Convert` +some number of calls later. Publishing proves nothing about that; running does. + +The smoke test covers parse, `Simplify`, `Solve`, `Differentiate`, `Integrate`, `Limit` and +`Compile`, asserts values rather than printed forms, and returns non-zero on any mismatch. + +## What breaks a trimmed build + +Anything that names a member at run time. + +- `Type.GetMethod("Name")`, `Activator.CreateInstance(type)`, assembly scanning. Use a table + generated beside what it dispatches to; `MathAllMethods.Definitions` is the worked example, and + its entries are expression trees precisely because the compiler emits an `ldtoken` for the call + inside one, which is how C# names a method without a string. +- `Type.MakeGenericType` over a value type, which NativeAOT cannot promise (IL3050). Where the set + of types is closed, write the closed set down: `CompilationProtocol.nullableForms`. +- Letting `System.Linq.Expressions` find an operator or a conversion for you. + `Expression.Add(l, r)` reflects over the operand type looking for `op_Addition`, and + `Expression.Convert(e, t)` reflects over the target type looking for `op_Implicit`. For `double` + and `long` those are IL instructions and there is nothing to find, but for + `System.Numerics.Complex` and `BigInteger` they are methods, and a NativeAOT build had no + metadata for them: `x + 1` compiled over a complex argument threw "the binary operator Add is not + defined for the types 'System.Numerics.Complex' and 'System.Numerics.Complex'". Pass the method + in — `CompilationProtocol.operators` and `CompilationProtocol.conversionOperators`. + +`Expression.Compile()` itself is fine: under NativeAOT it falls back to the interpreter rather than +emitting IL, so a compiled expression is slower there than under the JIT but it is not broken. +See [#363](https://github.com/asc-community/AngouriMath/issues/363). + +## When you add a node, a function or a protocol + +Add the case to the smoke test as well. It is a few lines, it is the only place the trimmed and +NativeAOT behaviour of the new path is measured, and the publish will not tell you it is missing. diff --git a/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs b/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs index cdc54dc25..0a693948d 100644 --- a/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs +++ b/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs @@ -85,20 +85,21 @@ public virtual Expression ConvertType(Expression expr, Type type) { return Expression.Condition(Expression.Equal(expr, Expression.Constant(null, expr.Type)), ConvertNaN(type), - Expression.Convert(expr, type)); + Coerce(expr, type)); } // ex. double -> long? if (!exprNullable && exprNaNisNaN && typeNullable && !typeNaNisNaN) { - MethodInfo isNaN = expr.Type.GetMethod("IsNaN") ?? throw new AngouriBugException($"IsNaN method expected for type {expr.Type}"); - + if (!nanChecks.TryGetValue(expr.Type, out var isNaN)) + throw new AngouriBugException($"IsNaN method expected for type {expr.Type}"); + return Expression.Condition(Expression.Call(isNaN, expr), Expression.Constant(null, type), - Expression.Convert(expr, type)); + Coerce(expr, type)); } - return Expression.Convert(expr, type); + return Coerce(expr, type); } /// @@ -158,16 +159,18 @@ public virtual Expression ConvertBinaryNode(Expression left, Expression right, E (left, right) = EqualizeTypesIfAble(left, right); return typeHolder switch { - Sumf => Expression.Add(left, right), - Minusf => Expression.Subtract(left, right), - Mulf => Expression.Multiply(left, right), - Divf => Expression.Divide(ShouldBeAtLeastDouble(left), ShouldBeAtLeastDouble(right)), + Sumf => Operator(ExpressionType.Add, left, right), + Minusf => Operator(ExpressionType.Subtract, left, right), + Mulf => Operator(ExpressionType.Multiply, left, right), + Divf => Operator(ExpressionType.Divide, ShouldBeAtLeastDouble(left), ShouldBeAtLeastDouble(right)), // ((a % b) + b) % b, and not the bare a % b: the runtime truncates, where the // node is the floored remainder that takes the sign of the divisor, and this is // the shortest expression that turns the one into the other for every pair of // signs. Not widened to double the way division is -- the remainder of two // integers is an integer, and widening would only lose that. - Modf => Expression.Modulo(Expression.Add(Expression.Modulo(left, right), right), right), + Modf => Operator(ExpressionType.Modulo, + Operator(ExpressionType.Add, Operator(ExpressionType.Modulo, left, right), right), + right), Powf when ShouldBeAtLeastDouble(left) is var newLeft && ShouldBeAtLeastDouble(right) is var newRight @@ -182,11 +185,11 @@ Logf when Xorf => Expression.ExclusiveOr(left, right), Impliesf => Expression.Or(Expression.Not(left), right), - Lessf => Expression.LessThan(left, right), - LessOrEqualf => Expression.LessThanOrEqual(left, right), - Greaterf => Expression.GreaterThan(left, right), - GreaterOrEqualf => Expression.GreaterThanOrEqual(left, right), - Equalsf => Expression.Equal(left, right), + Lessf => Operator(ExpressionType.LessThan, left, right), + LessOrEqualf => Operator(ExpressionType.LessThanOrEqual, left, right), + Greaterf => Operator(ExpressionType.GreaterThan, left, right), + GreaterOrEqualf => Operator(ExpressionType.GreaterThanOrEqual, left, right), + Equalsf => Operator(ExpressionType.Equal, left, right), Providedf => HandleProvidedf(left, right), @@ -267,17 +270,139 @@ private static object DownCast(Number num) } - private static Type[] GenerateArrayOfType(int argCount, Type type) + // MathAllMethods is a generated table of typed overloads, and this used to reach it with + // typeof(MathAllMethods).GetMethod(name, ...) -- a name and a signature resolved at run + // time. Both the trimmer and the NativeAOT compiler answer that by keeping every public + // method of the class, used or not, since neither can tell which name will be asked for; + // and the run paid an overload resolution per node of every expression compiled. The + // dictionary is generated beside the methods, so the two cannot drift apart. + // https://github.com/asc-community/AngouriMath/issues/363 + private static MethodInfo GetDef(string name, int argCount, Type type) + => MathAllMethods.Definitions.TryGetValue((name, argCount, type), out var method) + ? method + : throw new AngouriBugException($"No compiled definition of {name} taking {argCount} argument(s) of type {type}"); + + /// + /// The types whose NaN is a value rather than -- the only ones for + /// which ever needs to ask whether a value is NaN. Named rather + /// than found by Type.GetMethod("IsNaN"), which a trimmed build has no way to keep. + /// + [ConstantField] private static readonly Dictionary nanChecks = new() + { + { typeof(System.Numerics.Complex), Named(v => MathAllMethods.IsNaN(v)) }, + { typeof(double), Named(v => double.IsNaN(v)) }, + { typeof(float), Named(v => float.IsNaN(v)) }, + }; + + /// + /// The user-defined conversion operators between the types in + /// . + /// finds one by reflecting over the target type, and a trimmed or NativeAOT build has no + /// metadata for an operator nothing referenced: double -> Complex came back as "no + /// coercion operator is defined between types", so a compiled expression mixing an integer + /// literal with a complex argument threw where the JIT build answered. Naming them keeps + /// them, and spares the lookup. + /// https://github.com/asc-community/AngouriMath/issues/363 + /// + [ConstantField] private static readonly Dictionary<(Type From, Type To), MethodInfo> conversionOperators = new() + { + { (typeof(int), typeof(System.Numerics.Complex)), Conv(v => (System.Numerics.Complex)v) }, + { (typeof(long), typeof(System.Numerics.Complex)), Conv(v => (System.Numerics.Complex)v) }, + { (typeof(float), typeof(System.Numerics.Complex)), Conv(v => (System.Numerics.Complex)v) }, + { (typeof(double), typeof(System.Numerics.Complex)), Conv(v => (System.Numerics.Complex)v) }, + { (typeof(BigInteger), typeof(System.Numerics.Complex)), Conv(v => (System.Numerics.Complex)v) }, + + { (typeof(int), typeof(BigInteger)), Conv(v => (BigInteger)v) }, + { (typeof(long), typeof(BigInteger)), Conv(v => (BigInteger)v) }, + { (typeof(float), typeof(BigInteger)), Conv(v => (BigInteger)v) }, + { (typeof(double), typeof(BigInteger)), Conv(v => (BigInteger)v) }, + + { (typeof(BigInteger), typeof(int)), Conv(v => (int)v) }, + { (typeof(BigInteger), typeof(long)), Conv(v => (long)v) }, + { (typeof(BigInteger), typeof(float)), Conv(v => (float)v) }, + { (typeof(BigInteger), typeof(double)), Conv(v => (double)v) }, + }; + + /// + /// The nullable form of every type in that has one, in + /// place of typeof(Nullable<>).MakeGenericType(t): constructing a generic over + /// a value type at run time is the one thing NativeAOT cannot promise (IL3050), and the + /// set of types this can be asked for is closed and six long. + /// + [ConstantField] private static readonly Dictionary nullableForms = new() + { + { typeof(System.Numerics.Complex), typeof(System.Numerics.Complex?) }, + { typeof(double), typeof(double?) }, + { typeof(float), typeof(float?) }, + { typeof(long), typeof(long?) }, + { typeof(BigInteger), typeof(BigInteger?) }, + { typeof(int), typeof(int?) }, + }; + + // An expression tree is how C# names a method without a string: the compiler emits an + // ldtoken for the call or the conversion inside it, which resolves the overload at compile + // time and roots the member for the trimmer. + private static MethodInfo Named(Expression> e) + => ((MethodCallExpression)e.Body).Method; + + private static MethodInfo Conv(Expression> e) + => ((UnaryExpression)e.Body).Method + ?? throw new AngouriBugException($"{typeof(TFrom)} -> {typeof(TTo)} is not a user-defined conversion"); + + /// + /// The operators and define + /// as methods. Expression.Add and its neighbours look one up by reflecting over the + /// operand type, which is the same hole as the conversions above: NativeAOT kept no + /// metadata for Complex.op_Addition, so x + 1 compiled over a complex + /// argument threw "the binary operator Add is not defined". The built-in numeric types are + /// absent on purpose -- their operators are IL instructions and there is nothing to find. + /// https://github.com/asc-community/AngouriMath/issues/363 + /// + [ConstantField] private static readonly Dictionary<(ExpressionType Kind, Type Type), MethodInfo> operators = new() + { + { (ExpressionType.Add, typeof(System.Numerics.Complex)), Op((a, b) => a + b) }, + { (ExpressionType.Subtract, typeof(System.Numerics.Complex)), Op((a, b) => a - b) }, + { (ExpressionType.Multiply, typeof(System.Numerics.Complex)), Op((a, b) => a * b) }, + { (ExpressionType.Divide, typeof(System.Numerics.Complex)), Op((a, b) => a / b) }, + { (ExpressionType.Equal, typeof(System.Numerics.Complex)), Cmp((a, b) => a == b) }, + + { (ExpressionType.Add, typeof(BigInteger)), Op((a, b) => a + b) }, + { (ExpressionType.Subtract, typeof(BigInteger)), Op((a, b) => a - b) }, + { (ExpressionType.Multiply, typeof(BigInteger)), Op((a, b) => a * b) }, + { (ExpressionType.Divide, typeof(BigInteger)), Op((a, b) => a / b) }, + { (ExpressionType.Modulo, typeof(BigInteger)), Op((a, b) => a % b) }, + { (ExpressionType.Equal, typeof(BigInteger)), Cmp((a, b) => a == b) }, + { (ExpressionType.LessThan, typeof(BigInteger)), Cmp((a, b) => a < b) }, + { (ExpressionType.LessThanOrEqual, typeof(BigInteger)), Cmp((a, b) => a <= b) }, + { (ExpressionType.GreaterThan, typeof(BigInteger)), Cmp((a, b) => a > b) }, + { (ExpressionType.GreaterThanOrEqual, typeof(BigInteger)), Cmp((a, b) => a >= b) }, + }; + + private static MethodInfo Op(Expression> e) + => ((BinaryExpression)e.Body).Method + ?? throw new AngouriBugException($"{typeof(T)} defines this operator as an instruction, not a method"); + + private static MethodInfo Cmp(Expression> e) + => ((BinaryExpression)e.Body).Method + ?? throw new AngouriBugException($"{typeof(T)} defines this operator as an instruction, not a method"); + + // MakeBinary with a null method is exactly what Expression.Add and its neighbours do, so a + // type that is not in the table is built the way it always was. liftToNull: false is their + // default too, and MakeBinary ignores it for everything but the comparisons. + private static Expression Operator(ExpressionType kind, Expression left, Expression right) + => Expression.MakeBinary( + kind, left, right, liftToNull: false, + method: operators.TryGetValue((kind, Nullable.GetUnderlyingType(left.Type) ?? left.Type), out var op) ? op : null); + + private static Expression Coerce(Expression expr, Type type) { - var l = new List(); - for (int i = 0; i < argCount; i++) - l.Add(type); - return l.ToArray(); + var from = Nullable.GetUnderlyingType(expr.Type) ?? expr.Type; + var to = Nullable.GetUnderlyingType(type) ?? type; + return conversionOperators.TryGetValue((from, to), out var op) + ? Expression.Convert(expr, type, op) + : Expression.Convert(expr, type); } - private static MethodInfo GetDef(string name, int argCount, Type type) - => typeof(MathAllMethods).GetMethod(name, GenerateArrayOfType(argCount, type)) ?? throw new AngouriBugException("No null expected"); - [ConstantField] private static readonly Dictionary typeLevelInHierarchy = new() { @@ -323,7 +448,11 @@ private static Type MaxType(Type a, Type b) var typeToCastTo = MaxType(leftType, rightType); if (nullable && ((ConstantExpression)ConvertNaN(typeToCastTo)).Value == null) { - typeToCastTo = typeof(Nullable<>).MakeGenericType(typeToCastTo); + // object is the one key of typeLevelInHierarchy with no nullable form, and it + // cannot arrive here: nullable is only true when one side unwrapped to a value + // type, whose level is above object's, so MaxType never returns object. + if (nullableForms.TryGetValue(typeToCastTo, out var nullableForm)) + typeToCastTo = nullableForm; } left = ConvertType(left, typeToCastTo); diff --git a/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.cs b/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.cs index 846f1d203..d95112125 100644 --- a/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.cs +++ b/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.cs @@ -1,4 +1,5 @@ -// + +// // Copyright (c) 2019-2022 Angouri. // AngouriMath is licensed under MIT. // Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. @@ -8,7 +9,10 @@ /* THIS FILE IS AUTO-GENERATED */ using System; +using System.Collections.Generic; +using System.Linq.Expressions; using System.Numerics; +using System.Reflection; namespace AngouriMath.Core.Compilation.IntoLinq { @@ -243,5 +247,136 @@ public static BigInteger Sgn(BigInteger a) return -1; return 0; } + + /// + /// True where System.Numerics.Complex.IsNaN is, spelled out because that overload is + /// .NET 7 and later and this assembly also targets netstandard2.0. A complex number is NaN + /// when neither part is infinite and at least one of them is not finite -- (NaN, +oo) is an + /// infinity, not a NaN. + /// + public static bool IsNaN(System.Numerics.Complex a) + => !(double.IsInfinity(a.Real) || double.IsInfinity(a.Imaginary)) + && !(IsFinite(a.Real) && IsFinite(a.Imaginary)); + + private static bool IsFinite(double d) => !double.IsNaN(d) && !double.IsInfinity(d); + + /// + /// What dispatches through, in place of looking a method + /// up on this class by name at run time. A name resolved at run time is invisible to the + /// trimmer and to the NativeAOT compiler, which is what + /// https://github.com/asc-community/AngouriMath/issues/363 is about; every entry here is a + /// direct reference the compiler emits an ldtoken for, so the method is reachable by + /// construction. It is also a dictionary lookup rather than an overload resolution, which + /// is what it costs per node of every compiled expression. + /// + [ConstantField] + internal static readonly Dictionary<(string Name, int ArgCount, Type Type), MethodInfo> Definitions = new() + { + { ("Log", 2, typeof(System.Numerics.Complex)), Def((a, b) => Log(a, b)) }, + { ("Log", 2, typeof(double)), Def((a, b) => Log(a, b)) }, + { ("Log", 2, typeof(float)), Def((a, b) => Log(a, b)) }, + { ("Log", 2, typeof(long)), Def((a, b) => Log(a, b)) }, + { ("Log", 2, typeof(int)), Def((a, b) => Log(a, b)) }, + { ("Log", 2, typeof(BigInteger)), Def((a, b) => Log(a, b)) }, + { ("Pow", 2, typeof(System.Numerics.Complex)), Def((a, b) => Pow(a, b)) }, + { ("Pow", 2, typeof(double)), Def((a, b) => Pow(a, b)) }, + { ("Pow", 2, typeof(float)), Def((a, b) => Pow(a, b)) }, + { ("Pow", 2, typeof(long)), Def((a, b) => Pow(a, b)) }, + { ("Pow", 2, typeof(int)), Def((a, b) => Pow(a, b)) }, + { ("Pow", 2, typeof(BigInteger)), Def((a, b) => Pow(a, b)) }, + { ("Sin", 1, typeof(System.Numerics.Complex)), Def(a => Sin(a)) }, + { ("Sin", 1, typeof(double)), Def(a => Sin(a)) }, + { ("Sin", 1, typeof(float)), Def(a => Sin(a)) }, + { ("Sin", 1, typeof(long)), Def(a => Sin(a)) }, + { ("Sin", 1, typeof(int)), Def(a => Sin(a)) }, + { ("Sin", 1, typeof(BigInteger)), Def(a => Sin(a)) }, + { ("Cos", 1, typeof(System.Numerics.Complex)), Def(a => Cos(a)) }, + { ("Cos", 1, typeof(double)), Def(a => Cos(a)) }, + { ("Cos", 1, typeof(float)), Def(a => Cos(a)) }, + { ("Cos", 1, typeof(long)), Def(a => Cos(a)) }, + { ("Cos", 1, typeof(int)), Def(a => Cos(a)) }, + { ("Cos", 1, typeof(BigInteger)), Def(a => Cos(a)) }, + { ("Tan", 1, typeof(System.Numerics.Complex)), Def(a => Tan(a)) }, + { ("Tan", 1, typeof(double)), Def(a => Tan(a)) }, + { ("Tan", 1, typeof(float)), Def(a => Tan(a)) }, + { ("Tan", 1, typeof(long)), Def(a => Tan(a)) }, + { ("Tan", 1, typeof(int)), Def(a => Tan(a)) }, + { ("Tan", 1, typeof(BigInteger)), Def(a => Tan(a)) }, + { ("Asin", 1, typeof(System.Numerics.Complex)), Def(a => Asin(a)) }, + { ("Asin", 1, typeof(double)), Def(a => Asin(a)) }, + { ("Asin", 1, typeof(float)), Def(a => Asin(a)) }, + { ("Asin", 1, typeof(long)), Def(a => Asin(a)) }, + { ("Asin", 1, typeof(int)), Def(a => Asin(a)) }, + { ("Asin", 1, typeof(BigInteger)), Def(a => Asin(a)) }, + { ("Acos", 1, typeof(System.Numerics.Complex)), Def(a => Acos(a)) }, + { ("Acos", 1, typeof(double)), Def(a => Acos(a)) }, + { ("Acos", 1, typeof(float)), Def(a => Acos(a)) }, + { ("Acos", 1, typeof(long)), Def(a => Acos(a)) }, + { ("Acos", 1, typeof(int)), Def(a => Acos(a)) }, + { ("Acos", 1, typeof(BigInteger)), Def(a => Acos(a)) }, + { ("Atan", 1, typeof(System.Numerics.Complex)), Def(a => Atan(a)) }, + { ("Atan", 1, typeof(double)), Def(a => Atan(a)) }, + { ("Atan", 1, typeof(float)), Def(a => Atan(a)) }, + { ("Atan", 1, typeof(long)), Def(a => Atan(a)) }, + { ("Atan", 1, typeof(int)), Def(a => Atan(a)) }, + { ("Atan", 1, typeof(BigInteger)), Def(a => Atan(a)) }, + { ("Cot", 1, typeof(System.Numerics.Complex)), Def(a => Cot(a)) }, + { ("Cot", 1, typeof(double)), Def(a => Cot(a)) }, + { ("Cot", 1, typeof(float)), Def(a => Cot(a)) }, + { ("Cot", 1, typeof(long)), Def(a => Cot(a)) }, + { ("Cot", 1, typeof(int)), Def(a => Cot(a)) }, + { ("Cot", 1, typeof(BigInteger)), Def(a => Cot(a)) }, + { ("Sec", 1, typeof(System.Numerics.Complex)), Def(a => Sec(a)) }, + { ("Sec", 1, typeof(double)), Def(a => Sec(a)) }, + { ("Sec", 1, typeof(float)), Def(a => Sec(a)) }, + { ("Sec", 1, typeof(long)), Def(a => Sec(a)) }, + { ("Sec", 1, typeof(int)), Def(a => Sec(a)) }, + { ("Sec", 1, typeof(BigInteger)), Def(a => Sec(a)) }, + { ("Csc", 1, typeof(System.Numerics.Complex)), Def(a => Csc(a)) }, + { ("Csc", 1, typeof(double)), Def(a => Csc(a)) }, + { ("Csc", 1, typeof(float)), Def(a => Csc(a)) }, + { ("Csc", 1, typeof(long)), Def(a => Csc(a)) }, + { ("Csc", 1, typeof(int)), Def(a => Csc(a)) }, + { ("Csc", 1, typeof(BigInteger)), Def(a => Csc(a)) }, + { ("Acot", 1, typeof(System.Numerics.Complex)), Def(a => Acot(a)) }, + { ("Acot", 1, typeof(double)), Def(a => Acot(a)) }, + { ("Acot", 1, typeof(float)), Def(a => Acot(a)) }, + { ("Acot", 1, typeof(long)), Def(a => Acot(a)) }, + { ("Acot", 1, typeof(int)), Def(a => Acot(a)) }, + { ("Acot", 1, typeof(BigInteger)), Def(a => Acot(a)) }, + { ("Asec", 1, typeof(System.Numerics.Complex)), Def(a => Asec(a)) }, + { ("Asec", 1, typeof(double)), Def(a => Asec(a)) }, + { ("Asec", 1, typeof(float)), Def(a => Asec(a)) }, + { ("Asec", 1, typeof(long)), Def(a => Asec(a)) }, + { ("Asec", 1, typeof(int)), Def(a => Asec(a)) }, + { ("Asec", 1, typeof(BigInteger)), Def(a => Asec(a)) }, + { ("Acsc", 1, typeof(System.Numerics.Complex)), Def(a => Acsc(a)) }, + { ("Acsc", 1, typeof(double)), Def(a => Acsc(a)) }, + { ("Acsc", 1, typeof(float)), Def(a => Acsc(a)) }, + { ("Acsc", 1, typeof(long)), Def(a => Acsc(a)) }, + { ("Acsc", 1, typeof(int)), Def(a => Acsc(a)) }, + { ("Acsc", 1, typeof(BigInteger)), Def(a => Acsc(a)) }, + { ("Abs", 1, typeof(System.Numerics.Complex)), Def(a => Abs(a)) }, + { ("Abs", 1, typeof(double)), Def(a => Abs(a)) }, + { ("Abs", 1, typeof(float)), Def(a => Abs(a)) }, + { ("Abs", 1, typeof(long)), Def(a => Abs(a)) }, + { ("Abs", 1, typeof(int)), Def(a => Abs(a)) }, + { ("Abs", 1, typeof(BigInteger)), Def(a => Abs(a)) }, + { ("Sgn", 1, typeof(System.Numerics.Complex)), Def(a => Sgn(a)) }, + { ("Sgn", 1, typeof(double)), Def(a => Sgn(a)) }, + { ("Sgn", 1, typeof(float)), Def(a => Sgn(a)) }, + { ("Sgn", 1, typeof(long)), Def(a => Sgn(a)) }, + { ("Sgn", 1, typeof(int)), Def(a => Sgn(a)) }, + { ("Sgn", 1, typeof(BigInteger)), Def(a => Sgn(a)) }, + }; + + // An expression tree is how C# spells "the MethodInfo of this method" without a string: + // the compiler emits ldtoken for the call it contains, which both picks the overload at + // compile time and roots the method for the trimmer. + private static MethodInfo Def(Expression> e) + => ((MethodCallExpression)e.Body).Method; + + private static MethodInfo Def(Expression> e) + => ((MethodCallExpression)e.Body).Method; } } diff --git a/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.tt b/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.tt index 7dde7c3a7..581b632f2 100644 --- a/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.tt +++ b/Sources/AngouriMath/Functions/Compilation/IntoLinq/MathAllMethods.tt @@ -1,11 +1,30 @@  <#@ template debug="false" hostspecific="false" language="C#" #> <#@ output extension=".cs" #> +<# + // The one-argument functions, in the order they are defined below. Both the definitions and + // the dispatch table at the bottom are generated from these, so a function cannot be added + // to one and forgotten in the other. + var unary = new[]{ "Sin", "Cos", "Tan", "Asin", "Acos", "Atan", + "Cot", "Sec", "Csc", "Acot", "Asec", "Acsc", + "Abs", "Sgn" }; + var binary = new[]{ "Log", "Pow" }; + var types = new[]{ "System.Numerics.Complex", "double", "float", "long", "int", "BigInteger" }; +#> +// +// Copyright (c) 2019-2022 Angouri. +// AngouriMath is licensed under MIT. +// Details: https://github.com/asc-community/AngouriMath/blob/master/LICENSE.md. +// Website: https://am.angouri.org. +// /* THIS FILE IS AUTO-GENERATED */ using System; +using System.Collections.Generic; +using System.Linq.Expressions; using System.Numerics; +using System.Reflection; namespace AngouriMath.Core.Compilation.IntoLinq { @@ -128,5 +147,50 @@ namespace AngouriMath.Core.Compilation.IntoLinq return -1; return 0; } + + /// + /// True where System.Numerics.Complex.IsNaN is, spelled out because that overload is + /// .NET 7 and later and this assembly also targets netstandard2.0. A complex number is NaN + /// when neither part is infinite and at least one of them is not finite -- (NaN, +oo) is an + /// infinity, not a NaN. + /// + public static bool IsNaN(System.Numerics.Complex a) + => !(double.IsInfinity(a.Real) || double.IsInfinity(a.Imaginary)) + && !(IsFinite(a.Real) && IsFinite(a.Imaginary)); + + private static bool IsFinite(double d) => !double.IsNaN(d) && !double.IsInfinity(d); + + /// + /// What dispatches through, in place of looking a method + /// up on this class by name at run time. A name resolved at run time is invisible to the + /// trimmer and to the NativeAOT compiler, which is what + /// https://github.com/asc-community/AngouriMath/issues/363 is about; every entry here is a + /// direct reference the compiler emits an ldtoken for, so the method is reachable by + /// construction. It is also a dictionary lookup rather than an overload resolution, which + /// is what it costs per node of every compiled expression. + /// + [ConstantField] + internal static readonly Dictionary<(string Name, int ArgCount, Type Type), MethodInfo> Definitions = new() + { +<# foreach (var name in binary) { #> +<# foreach (var type in types) { #> + { ("<#= name #>", 2, typeof(<#= type #>)), Def<<#= type #>>((a, b) => <#= name #>(a, b)) }, +<# } #> +<# } #> +<# foreach (var name in unary) { #> +<# foreach (var type in types) { #> + { ("<#= name #>", 1, typeof(<#= type #>)), Def<<#= type #>>(a => <#= name #>(a)) }, +<# } #> +<# } #> + }; + + // An expression tree is how C# spells "the MethodInfo of this method" without a string: + // the compiler emits ldtoken for the call it contains, which both picks the overload at + // compile time and roots the method for the trimmer. + private static MethodInfo Def(Expression> e) + => ((MethodCallExpression)e.Body).Method; + + private static MethodInfo Def(Expression> e) + => ((MethodCallExpression)e.Body).Method; } } diff --git a/Sources/Tests/AotSmokeTest/AotSmokeTest.csproj b/Sources/Tests/AotSmokeTest/AotSmokeTest.csproj new file mode 100644 index 000000000..cf8b3d448 --- /dev/null +++ b/Sources/Tests/AotSmokeTest/AotSmokeTest.csproj @@ -0,0 +1,69 @@ + + + + + + Exe + net10.0 + 14.0 + enable + false + AotSmokeTest + AngouriMath.AotSmokeTest + true + + + + + true + full + + + true + + + false + false + + true + true + true + + + true + true + true + + + + + + + diff --git a/Sources/Tests/AotSmokeTest/Program.cs b/Sources/Tests/AotSmokeTest/Program.cs new file mode 100644 index 000000000..d4f6a41ec --- /dev/null +++ b/Sources/Tests/AotSmokeTest/Program.cs @@ -0,0 +1,243 @@ +// +// 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.Globalization; +using System.Linq; +using System.Numerics; +using AngouriMath; +using AngouriMath.Extensions; + +namespace AngouriMath.AotSmokeTest +{ + /// + /// Publishes trimmed and NativeAOT, then runs. Anything the kernel reaches for at run time + /// that the trimmer removed shows up here as an exception rather than as a warning, which is + /// why this program is run and not merely built. + /// + internal static class Program + { + private static int failed; + private static int passed; + + private static int Main() + { + Console.WriteLine($"AngouriMath AOT/trimming smoke test, runtime {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); + Console.WriteLine(); + + Parsing(); + Simplification(); + Solving(); + Differentiation(); + Integration(); + Limits(); + Compilation(); + + Console.WriteLine(); + Console.WriteLine($"passed: {passed}, failed: {failed}"); + if (failed > 0) + Console.WriteLine("FAILED"); + else + Console.WriteLine("OK"); + return failed > 0 ? 1 : 0; + } + + private static void Section(string name) + { + Console.WriteLine($"--- {name}"); + } + + private static void Check(string what, bool ok, string detail) + { + if (ok) + { + passed++; + Console.WriteLine($" pass {what}: {detail}"); + } + else + { + failed++; + Console.WriteLine($" FAIL {what}: {detail}"); + } + } + + private static void CheckClose(string what, Complex actual, Complex expected, double tolerance = 1e-9) + { + var ok = Complex.Abs(actual - expected) <= tolerance * Math.Max(1, Complex.Abs(expected)); + Check(what, ok, $"{Show(actual)} (expected {Show(expected)})"); + } + + private static string Show(Complex c) + => c.Imaginary == 0 + ? c.Real.ToString("G17", CultureInfo.InvariantCulture) + : c.Real.ToString("G17", CultureInfo.InvariantCulture) + " + " + c.Imaginary.ToString("G17", CultureInfo.InvariantCulture) + "i"; + + // Value at a point, not printed form: a smoke test that asserted strings would fail on a + // reordering that the trimmer had nothing to do with. + private static Complex At(Entity expr, string variable, Complex value) + => expr.Substitute(variable, (Entity.Number.Complex)value).EvalNumerical().ToNumerics(); + + private static void Parsing() + { + Section("parse"); + + var parsed = MathS.FromString("x ^ 2 + 2 * x + 1"); + Check("parses to a sum", parsed is Entity.Sumf, parsed.GetType().Name); + CheckClose("value at x = 3", At(parsed, "x", 3), 16); + + // The parser is ANTLR-generated and the lexer tables are static data the trimmer has + // to keep; a trimmed build that lost them throws here rather than at load. + var set = MathS.FromString("{ 1, 2, 3 } \\/ { 3, 5 }").InnerSimplified; + Check("set union parses and evaluates", set is Entity.Set.FiniteSet { Count: 4 }, set.Stringize()); + + var piecewise = MathS.FromString("piecewise(1 provided x > 0, 0 provided x <= 0)"); + CheckClose("piecewise at x = 2", At(piecewise, "x", 2), 1); + } + + private static void Simplification() + { + Section("Simplify"); + + var simplified = "(x ^ 2 - 1) / (x - 1)".Simplify(); + CheckClose("(x2 - 1)/(x - 1) at x = 5", At(simplified, "x", 5), 6); + Check("(x2 - 1)/(x - 1) has no division left", !simplified.Nodes.Any(n => n is Entity.Divf), simplified.Stringize()); + + var trig = "sin(x) ^ 2 + cos(x) ^ 2".Simplify(); + Check("sin2 + cos2 collapses to 1", trig == 1, trig.Stringize()); + + var expanded = "(a + b) ^ 2".Expand().Simplify(); + CheckClose("(a+b)^2 at a = 2, b = 3", At(At2(expanded, "a", 2), "b", 3), 25); + } + + private static Entity At2(Entity expr, string variable, Complex value) + => expr.Substitute(variable, (Entity.Number.Complex)value); + + private static void Solving() + { + Section("Solve"); + + var roots = "x ^ 2 - 4 = 0".Solve("x"); + Check("x2 - 4 = 0 has two roots", roots is Entity.Set.FiniteSet { Count: 2 }, roots.Stringize()); + if (roots is Entity.Set.FiniteSet finite) + foreach (var root in finite) + CheckClose($"root {root.Stringize()} substituted back", At(MathS.FromString("x ^ 2 - 4"), "x", root.EvalNumerical().ToNumerics()), 0); + + var linear = "3 * x + 6 = 0".Solve("x"); + Check("3x + 6 = 0 solves to -2", linear is Entity.Set.FiniteSet { Count: 1 } one && one.First() == -2, linear.Stringize()); + + var system = MathS.Equations("x + y - 3", "x - y - 1").Solve("x", "y"); + Check("the 2x2 system has one solution", system is { RowCount: 1, ColumnCount: 2 }, system?.Stringize() ?? "null"); + } + + private static void Differentiation() + { + Section("Differentiate"); + + var d = "sin(x) * x".Differentiate("x"); + CheckClose("(x sin x)' at x = 1", At(d, "x", 1), Math.Cos(1) + Math.Sin(1)); + + var second = "x ^ 4".Differentiate("x").Differentiate("x").InnerSimplified; + CheckClose("(x^4)'' at x = 2", At(second, "x", 2), 48); + + var chain = "e ^ (x ^ 2)".Differentiate("x"); + CheckClose("(e^(x2))' at x = 1", At(chain, "x", 1), 2 * Math.E); + } + + private static void Integration() + { + Section("Integrate"); + + // Differentiated back rather than compared to a form: the antiderivative is a + // Derivation, so only the derivative is an equality anything may assert. + var antiderivative = "x ^ 2 + 3 * x".Integrate("x"); + var back = antiderivative.Differentiate("x").InnerSimplified; + CheckClose("d/dx of the antiderivative of x2 + 3x, at x = 2", At(back, "x", 2), 4 + 6); + } + + private static void Limits() + { + Section("Limit"); + + var limit = "sin(x) / x".Limit("x", 0).InnerSimplified; + CheckClose("sin(x)/x as x -> 0", limit.EvalNumerical().ToNumerics(), 1); + } + + // The compilation cases are also the before/after comparison for the de-reflection of + // CompilationProtocol: every function GetDef used to look up by string is here. + private static readonly (string Expr, Complex Arg)[] complexCases = + { + ("sin(x)", 0.7), + ("cos(x)", 0.7), + ("tan(x)", 0.7), + ("cotan(x)", 0.7), + ("sec(x)", 0.7), + ("cosec(x)", 0.7), + ("arcsin(x)", 0.3), + ("arccos(x)", 0.3), + ("arctan(x)", 0.3), + ("arccotan(x)", 0.3), + ("arcsec(x)", 2.5), + ("arccosec(x)", 2.5), + ("abs(x)", -1.25), + ("sgn(x)", -1.25), + ("x ^ 2.5", 1.75), + ("log(2, x)", 9.0), + ("ln(x)", 9.0), + ("x / (x + 1)", 4.0), + ("x + 2 * x - 3 * x ^ 2", 1.5), + ("sin(x) ^ 2 + cos(x) ^ 2", 0.9), + }; + + private static void Compilation() + { + Section("Compile"); + + var x = MathS.Var("x"); + var y = MathS.Var("y"); + + foreach (var (text, arg) in complexCases) + { + Complex got; + try + { + got = text.Compile(x)(arg); + } + catch (Exception e) + { + failed++; + Console.WriteLine($" FAIL compile {text}: threw {e.GetType().Name}: {e.Message}"); + continue; + } + + var expected = At(MathS.FromString(text), "x", arg); + CheckClose($"compile {text} at {Show(arg)}", got, expected, 1e-8); + } + + // double, not Complex: a different set of MathAllMethods overloads. + var d = "sin(x) + cos(x)".Compile(x); + CheckClose("compiled double sin(x) + cos(x) at 0.4", d(0.4), Math.Sin(0.4) + Math.Cos(0.4), 1e-12); + + // Two arguments, and the floored remainder that takes the sign of the divisor. + var mod = "x mod y".Compile(x, y); + Check("compiled -7 mod 3", mod(-7, 3) == 2, mod(-7, 3).ToString(CultureInfo.InvariantCulture)); + + // The one path that reached Type.GetMethod("IsNaN"): a double-valued tree converted + // to a nullable integral return type, where NaN has to become null. + var toNullable = "x + 1".Compile>(new(), typeof(long?), new[] { (typeof(double), x) }); + Check("compiled double -> long? at 4.5", toNullable(4.5) == 5, toNullable(4.5)?.ToString(CultureInfo.InvariantCulture) ?? "null"); + Check("compiled double -> long? at NaN", toNullable(double.NaN) is null, toNullable(double.NaN)?.ToString(CultureInfo.InvariantCulture) ?? "null"); + + // Booleans and a piecewise, which is the ConvertOtherNode path. + var boolean = "x > 0 and x < 10".Compile(x); + Check("compiled predicate at 5", boolean(5) && !boolean(50), $"{boolean(5)}, {boolean(50)}"); + + var piecewise = "piecewise(x provided x > 0, 0 provided x <= 0)".Compile(x); + Check("compiled piecewise at -3 and 3", piecewise(-3) == 0 && piecewise(3) == 3, $"{piecewise(-3)}, {piecewise(3)}"); + } + } +} diff --git a/Sources/Tests/DotnetBenchmark/BenchLinqCompilation.cs b/Sources/Tests/DotnetBenchmark/BenchLinqCompilation.cs index 5428e7ff4..b8b6dd2db 100644 --- a/Sources/Tests/DotnetBenchmark/BenchLinqCompilation.cs +++ b/Sources/Tests/DotnetBenchmark/BenchLinqCompilation.cs @@ -13,6 +13,7 @@ namespace DotnetBenchmark { + [MemoryDiagnoser] public class BenchLinqCompilation { public readonly Func NormalSimpleLambda = diff --git a/Sources/Tests/DotnetBenchmark/CacheCompiledFunc.cs b/Sources/Tests/DotnetBenchmark/CacheCompiledFunc.cs index bd2ba2018..316f07d50 100644 --- a/Sources/Tests/DotnetBenchmark/CacheCompiledFunc.cs +++ b/Sources/Tests/DotnetBenchmark/CacheCompiledFunc.cs @@ -14,6 +14,7 @@ namespace DotnetBenchmark { + [MemoryDiagnoser] public class CacheCompiledFunc { private readonly FastExpression complexFunc; diff --git a/Sources/Tests/DotnetBenchmark/Program.cs b/Sources/Tests/DotnetBenchmark/Program.cs index 48d0d29da..6c8dd50b5 100644 --- a/Sources/Tests/DotnetBenchmark/Program.cs +++ b/Sources/Tests/DotnetBenchmark/Program.cs @@ -91,6 +91,11 @@ public static int Main(string[] args) // them. See MatchingEngine for what the factor decides. "MatchingEngine" => GetReportByBenchmark(typeof(MatchingEngine), "Mean", "Error", "StdDev", "Ratio", "Allocated"), "CompiledFuncTest" => GetReportByBenchmark(typeof(CompiledFuncTest), "Mean", "Error", "StdDev"), + // The two compilation benchmarks existed as classes that nothing could + // run: one was a commented-out BenchmarkRunner.Run below, the other had + // no arm at all. A benchmark nobody can invoke measures nothing. + "BenchLinqCompilation" => GetReportByBenchmark(typeof(BenchLinqCompilation), "Mean", "Error", "StdDev", "Allocated"), + "CacheCompiledFunc" => GetReportByBenchmark(typeof(CacheCompiledFunc), "Mean", "Error", "StdDev", "Allocated"), "NumbersBenchmark" => GetReportByBenchmark(typeof(NumbersBenchmark), "Mean", "Error", "StdDev"), _ => throw new($"Unexpected benchmark {arg}") }).ToArray(); // active action @@ -107,7 +112,6 @@ public static int Main(string[] args) Console.WriteLine(); Console.WriteLine(); } - // BenchmarkRunner.Run(); Console.ReadLine(); Console.ReadLine(); Console.ReadLine(); return 0; }