From 904edfef30f84f0bd14d3cbb7afe5bf6aed38b31 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Tue, 11 Aug 2026 15:12:09 +0000 Subject: [PATCH] Let Compile fail with its own exception rather than someone else's (#894) Two ways compilation reported the impossible, neither of them the exception written down for it. A node the Linq converter has no case for threw AngouriBugException, which asks the caller to report a bug -- for a gap in coverage the library already knows about. So compiling floor(x), ceil(x), round(x), phi(x), gamma(x) or x! told the user to file an issue, and floor, ceil and round are nodes 2.0 itself added and never taught the compiler. #872 moved twelve other known gaps off AngouriBugException for this reason; these are the same thing. Linq.Expression's own refusals reached the caller unwrapped: an InvalidOperationException for `not x` compiled as a double, an ArgumentException for a Providedf whose condition is not boolean. The mismatch is real -- a boolean node has no double-valued compiled form -- but Docs/Usage/Exceptions.md tells a caller to write catch (AngouriMathBaseException) around a library call, and those are not under it, so they escaped the handler the documentation asks for. Both now throw UncompilableNodeException. The node set and the compiled output are unchanged; only the failure is. Teaching the compiler floor, ceil and round is worth doing and is not this. Found by work/crashcheck: on 1652 cases it reports 0 crashes and 0 hangs, and these 16 were every remaining finding. Measured after the change, the same 118 compile cases produce none. 6284 tests pass, 14 new ones covering both families. --- BREAKING-CHANGES.md | 33 +++++++++++++++++++ .../IntoLinq/CompilationProtocol.cs | 12 ++++++- .../Compilation/IntoLinq/IntoLinqCompiler.cs | 27 ++++++++++++--- .../UnitTests/Core/UserInvalidExceptions.cs | 32 ++++++++++++++++++ 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/BREAKING-CHANGES.md b/BREAKING-CHANGES.md index 3e48af284..616f41607 100644 --- a/BREAKING-CHANGES.md +++ b/BREAKING-CHANGES.md @@ -47,6 +47,8 @@ read first. | **silent** | two integrals | answered correctly | unevaluated — a deliberate loss | | **silent** | `k/(a x^2 + c)` and `k/sqrt(a x^2 + c)` for symbolic `a` | `NaN` | a piecewise on the sign of the discriminant | | loud | `Compile` over a missing variable | `KeyNotFoundException` | `UncompilableNodeException` | +| loud | `Compile` of `floor`, `ceil`, `round`, `phi`, `gamma`, `!` | `AngouriBugException`, asking to be reported | `UncompilableNodeException` | +| loud | `Compile` of a boolean or lambda node as `double` | `InvalidOperationException` from Linq | `UncompilableNodeException` | | loud | `Expand` of a quotient of factorials | `AngouriBugException` | the expanded polynomial | | loud | parsing a `provided` in a parenthesised comma list | `NullReferenceException` | `UnhandledParseException` | | loud | `floor(x)`, `ceil(x)`, `ceiling(x)` | `UnrecognizedFunctionParseException` | the functions | @@ -240,6 +242,37 @@ when in fact it merely has another value — trading a wrong value for a wrong d asserts that the expression is left alone. Issue [#884](https://github.com/asc-community/AngouriMath/issues/884). +### `Compile` fails with its own exception rather than someone else's + +Two families, both reported as `UncompilableNodeException` — the exception +[`Docs/Usage/Exceptions.md`](Sources/AngouriMath/Docs/Usage/Exceptions.md) documents for a node with +no compiled form. Neither the node set nor the compiled output changes; only what is thrown when +compilation is impossible. + +| input, compiled as `` | was | is | +|---|---|---| +| `floor(x)`, `ceil(x)`, `round(x)`, `phi(x)`, `gamma(x)`, `x!` | `AngouriBugException`: *An unary node seems to be not added* | `UncompilableNodeException` | +| `not x`, `x and 2`, `x or 2`, `x xor 2`, `x implies 2`, `x -> x + 1` | `InvalidOperationException` from `System.Linq.Expressions` | `UncompilableNodeException` | +| `x provided 2` | `ArgumentException`: *Argument must be boolean* | `UncompilableNodeException` | + +`AngouriBugException` means "an internal error occurred, report it", and none of the first row is one +— the converter has no case for the node, which is a gap in coverage. **Four of those nodes are ones +2.0 added**: `floor`, `ceil` and `round` arrived with #809 and the compiler was never taught them, so +a caller compiling a 2.0 feature was asked to file a bug report. + +The second family matters for a caller who followed the documentation: `catch (AngouriMathBaseException)` +is what the exception reference tells you to write around a library call, and Linq's exceptions are +not under it, so those escaped the handler entirely. + +**What this does not do** is teach the compiler `floor`, `ceil`, `round` and the rest. That is worth +doing and is separate; this is only about the failure being honest rather than either a request for a +bug report or an exception nobody was told to expect. Issue +[#894](https://github.com/asc-community/AngouriMath/issues/894). + +Found by `crashcheck`, which runs each case in a child process so that a crash is a result rather +than the end of the run. On 1652 cases it reports 0 crashes and 0 hangs, and these 16 were every +remaining finding. + ### A known gap no longer presents as a bug `FutureReleaseException` is removed, and the twelve places that threw through it now throw diff --git a/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs b/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs index 9a18bca94..cdc54dc25 100644 --- a/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs +++ b/Sources/AngouriMath/Functions/Compilation/IntoLinq/CompilationProtocol.cs @@ -128,7 +128,17 @@ Arccosecantf when ShouldBeAtLeastDouble(e) is var newE => Expression.Call(GetDef Notf => Expression.Not(e), - _ => throw new AngouriBugException("An unary node seems to be not added") + // A node this converter has no case for has no compiled form, and that is a gap + // in coverage rather than an internal error. It threw AngouriBugException, which + // asks the caller to report a bug -- so compiling floor(x), ceil(x), round(x), + // x!, gamma(x) or phi(x) told the user to file an issue for something the + // library already knows it cannot do. UncompilableNodeException is the exception + // documented for exactly this, and #872 moved the other known gaps off + // AngouriBugException for the same reason. + // https://github.com/asc-community/AngouriMath/issues/894 + _ => throw new UncompilableNodeException( + $"There is no compiled form for {typeHolder.GetType().Name}. " + + "Define a CompilationProtocol which overrides ConvertUnaryNode to add one.") }; } diff --git a/Sources/AngouriMath/Functions/Compilation/IntoLinq/IntoLinqCompiler.cs b/Sources/AngouriMath/Functions/Compilation/IntoLinq/IntoLinqCompiler.cs index ac3996c5f..fb9665c1c 100644 --- a/Sources/AngouriMath/Functions/Compilation/IntoLinq/IntoLinqCompiler.cs +++ b/Sources/AngouriMath/Functions/Compilation/IntoLinq/IntoLinqCompiler.cs @@ -36,12 +36,29 @@ internal static TDelegate Compile( var localVars = new List(); var variableAssignments = new List(); - var tree = BuildTree(expr, subexpressionsCache, variableAssignments, localVars, protocol); - var treeWithLocals = Expression.Block(localVars, variableAssignments.Append(tree)); - Expression entireExpression = returnType is not null ? protocol.ConvertType(treeWithLocals, returnType) : treeWithLocals; - var finalLambda = Expression.Lambda(entireExpression, functionArguments); + // Linq.Expression refuses a mismatch by throwing its own exceptions -- an + // InvalidOperationException for `not x` where x is a double, an ArgumentException + // for a Providedf whose condition is not boolean -- and those were reaching the + // caller unwrapped, so a CAS raised exceptions outside its own documented hierarchy + // for input it simply cannot compile. The mismatch is real and the answer is the + // exception written down for it. + // https://github.com/asc-community/AngouriMath/issues/894 + try + { + var tree = BuildTree(expr, subexpressionsCache, variableAssignments, localVars, protocol); + var treeWithLocals = Expression.Block(localVars, variableAssignments.Append(tree)); + Expression entireExpression = returnType is not null ? protocol.ConvertType(treeWithLocals, returnType) : treeWithLocals; + var finalLambda = Expression.Lambda(entireExpression, functionArguments); - return finalLambda.Compile(); + return finalLambda.Compile(); + } + catch (Exception e) when (e is InvalidOperationException or ArgumentException + or NotSupportedException + && e is not AngouriMathBaseException) + { + throw new UncompilableNodeException( + $"`{expr.Stringize()}` has no compiled form for the types requested: {e.Message}"); + } } internal static Expression BuildTree( diff --git a/Sources/Tests/UnitTests/Core/UserInvalidExceptions.cs b/Sources/Tests/UnitTests/Core/UserInvalidExceptions.cs index 0d847312a..c1ce1d562 100644 --- a/Sources/Tests/UnitTests/Core/UserInvalidExceptions.cs +++ b/Sources/Tests/UnitTests/Core/UserInvalidExceptions.cs @@ -51,6 +51,38 @@ [Fact] public void UncompilableNode4() => Assert.Throws(() => "x + { x, x }".Compile("x")); + // https://github.com/asc-community/AngouriMath/issues/894 + // A node the Linq converter has no case for threw AngouriBugException, which asks the + // caller to report a bug -- for a gap in coverage the library already knows about. Four + // of these are nodes 2.0 added and never taught the compiler. + [Theory] + [InlineData("floor(x)")] + [InlineData("ceil(x)")] + [InlineData("round(x)")] + [InlineData("phi(x)")] + [InlineData("gamma(x)")] + [InlineData("x!")] + [InlineData("(x + 1)! / x!")] + public void ANodeWithNoCompiledFormSaysSoRatherThanAskingForABugReport(string expression) => + Assert.Throws(() => + expression.ToEntity().Compile("x")); + + // The same, for a mismatch Linq.Expression refuses rather than the converter: a boolean + // node has no double-valued compiled form, and the exception used to be + // System.Linq.Expressions' own, so `catch (AngouriMathBaseException)` -- which is what + // Docs/Usage/Exceptions.md tells a caller to write -- did not catch it. + [Theory] + [InlineData("not x")] + [InlineData("x and 2")] + [InlineData("x or 2")] + [InlineData("x xor 2")] + [InlineData("x implies 2")] + [InlineData("x provided 2")] + [InlineData("x -> x + 1")] + public void ATypeMismatchInTheCompiledFormStaysInsideTheLibrarysHierarchy(string expression) => + Assert.Throws(() => + expression.ToEntity().Compile("x")); + [Fact] public void CannotEvalNum1() => Assert.Throws(() => "x".EvalNumerical());