Skip to content

Document the exception hierarchy, and make a refusal name what it choked on (#746 item 11) - #1027

Merged
Rafael-SOWNet merged 6 commits into
masterfrom
feat/exceptions-name-the-expression
Aug 23, 2026
Merged

Rafael-SOWNet merged 6 commits into
masterfrom
feat/exceptions-name-the-expression

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Member

Roadmap #746, item 11. One of that
issue's three founding complaints is that failure here is uninformative: "I could not settle
this"
tells a caller nothing about what to try instead and a contributor nothing about what to
build. An exception that names the sub-expression it choked on is the cheapest instance of the
principle.

What was measured

On master at v2.3.0, over Sources/AngouriMath/**/*.cs:

before after
throw new sites (excluding 9 that are inside XML doc <code> samples, not code) 197 197
whose message is a compile-time constant 106 73
whose message carries a runtime value 31 67
whose message names the offending sub-expression 8 25
exception types under AngouriMathBaseException 25 25
of those, documented in Docs/Usage/Exceptions.md 1 25

Docs/Usage/Exceptions.md

Rewritten from three lines to the whole hierarchy, derived by reading
Core/Exceptions/** and every throw site rather than from memory. Each type gets what it means,
when a caller can expect it, and where it is raised from. The organising split is the one a caller
actually acts on:

  • MathSException — the input was wrong. Catch it and show the message.
  • NotSufficientlySupportedException — the library has no procedure for the question. A feature
    request, and catchable.
  • AngouriBugException — a defect. Not a catch target; catching it turns a reproducible bug
    into a silent wrong answer.

It also states the overlaps rather than leaving them to be discovered:
FunctionArgumentCountException is the arity complaint about a call written in a string and
WrongNumberOfArgumentsException the one about a call made through the API — only the first is
a ParseException, so they are not interchangeable; BadMatrixShapeException exists only for
concatenation and InvalidMatrixOperationException is the type any new shape check should use.
And it documents MathS.Diagnostic.DiagnosticCatchException, the one exception the library throws
that is deliberately outside the hierarchy, so that a caller's catch (AngouriMathBaseException)
cannot swallow their own debugging aid.

One gap the reading found, recorded and not fixed here. UnrecognizedDomainException is thrown
by nothing. The place that would raise it — Entity.Set.SpecialSet.ToDomain(string), reachable
through the public SpecialSet.Create(string) — answers an unknown domain name with
AngouriBugException, which asks the caller to report their own typo as a library defect. That is
the wrong answer to a wrong string, but correcting it changes which exception a caller sees, so it
wants a pull request of its own rather than this one.

The messages

Where the offending expression or value was already in scope, it is now in the message. The
user-reachable paths first:

  • parsing — ANTLR's diagnostic is a line and a column and nothing else, which names no input
    once the string has travelled through a caller's own layers. The source is now appended (not
    prepended: FromStringTest asserts on the line L:C prefix, and it is what a reader looks for
    first).
  • evaluation — EvalNumerical / EvalBoolean say what the expression did evaluate to,
    instead of "Result cannot be represented as a simple number!".
  • matrices — every operation that compares shapes now prints both of them, and AsScalar,
    Pow, WithElement, WithRow, WithColumn and MatrixBuilder print the shape they got.
    operator + and operator - no longer share one message that says which of them it was.
  • sets — an undecidable Contains names the element and the set.
  • compilation — a node with no compiled form prints itself rather than saying "Tensors cannot
    be compiled".
  • truth tables — the expression and the variables it is actually over.
  • plus MathS.ToBaseN's base, Rational.Create's non-finite value, the decimal precision that did
    not fit in an int32, and a handful of internal AngouriBugException asserts that now print the
    two sizes or the enum value that did not match.

Nothing was manufactured: where a site has nothing in scope to name — "No rows were provided",
"Cannot concat 0 matrix" — it was left alone.

Cost. Every interpolation is written inside the throw new expression, so it is evaluated only
when the exception is constructed and a path that does not throw pays nothing.

What was deliberately not done

  • No exception type was added, removed or changed, and no site throws a different type than it
    did. That is a breaking change and this is not the PR for it.
  • No BREAKING-CHANGES.md entry: message text is not an answer.
  • The parse errors that live in the generated ANTLR parser (arcsinh and its neighbours,
    "Expected integer number for the third argument of derivative", the apply/lambda arity
    refusals) were left alone — they come from AngouriMath.g and changing them means regenerating
    the parser, which is its own change.

Tests asserting on message text

Checked before touching anything, by grepping every string being changed. Six tests do assert on a
message: FromStringTest (the line L:C prefix, and the exact FunctionArgumentCountException
wording), MissingFunctionNamesRefusedTest and ArcHyperbolicRefusedTest (substrings of the
parser's refusals), MatrixCompilationTest ("Matrix"), CircleTest (the whole
CannotParseInstanceException message) and CompileUnboundVariableTest (the named variable).
None of the messages they pin were moved, and all six still pass unmodified.

Measured

dotnet build Sources/AngouriMath -c Release — 0 warnings, 0 errors.

dotnet test Sources/Tests/UnitTests -c Release:

  • with the message changes and before the new test file: 7533 passed / 0 failed / 14 skipped,
    identical to master's baseline.
  • with the new test file: 7549 passed / 0 failed / 14 skipped (+17 new tests, of which one
    wall-clock limit test flaked under load and passes on its own).

dotnet test Sources/Tests/FSharpWrapperUnitTests -c Release — 134 passed / 0 failed.

New file: Sources/Tests/UnitTests/Core/ExceptionMessageNamesTheExpressionTest.cs, pinning the
parse-failure input, the evaluated-to form, both matrix shapes, the element and the set, the
uncompilable node, the base, the truth-table variables and the builder widths — plus a test that
the same paths still succeed when nothing is wrong.

Rafael-SOWNet and others added 2 commits August 23, 2026 12:24
`Docs/Usage/Exceptions.md` was three lines and covered one of the 25 types
under `AngouriMathBaseException`. It now covers all of them: what each means,
when a caller can expect it, and which ones are worth catching. The split the
document is built around is the one a caller acts on -- `MathSException` means
the input was wrong, `NotSufficientlySupportedException` means the library has
no procedure for the question, and `AngouriBugException` is a defect that
should not be caught at all.

Two things the reading turned up and the document records rather than changes:
`UnrecognizedDomainException` is thrown by nothing, and the place that would
raise it -- `SpecialSet.ToDomain(string)`, behind the public
`SpecialSet.Create(string)` -- answers a caller's typo with an
`AngouriBugException` asking them to report it. Changing which type is thrown
there is a breaking change and belongs in its own pull request.

Then the messages. 106 of the 197 `throw new` sites passed a message that was a
compile-time constant, so a refusal named neither the sub-expression nor the
shape nor the value it was about. Where the offending thing was already in
scope it is now in the message: the source string on a parse failure, what an
expression did evaluate to when it did not evaluate to a number, both shapes on
every matrix operation that compares them, the element and the set on an
undecidable membership, the node on anything that cannot be compiled, the
expression and its variables on a truth table over the wrong ones. That leaves
73 constants, and 67 messages carrying a runtime value against 31 before.

Every interpolation sits inside the `throw new` expression, so a path that does
not throw pays nothing for it.

No exception type was added, removed or changed, and no site throws a different
type than it did. Message text is not an answer, so there is no
`BREAKING-CHANGES.md` entry -- but the messages that tests do assert on were
checked first: `FromStringTest` pins the `line L:C` prefix of a parse failure
(the source is appended, not prepended, for that reason) and the exact
`FunctionArgumentCountException` wording, `MissingFunctionNamesRefusedTest` and
`ArcHyperbolicRefusedTest` pin substrings of the parser's refusals,
`MatrixCompilationTest` pins "Matrix", `CircleTest` pins the whole
`CannotParseInstanceException` message and `CompileUnboundVariableTest` the
named variable. None of those messages moved.

Roadmap #746 item 11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjumi5K7fg8yx6UK1mZTQd
Sources/.editorconfig pins the header per directory, so a new file needs its
own scope or it ships with a copyright four years out of date. Three other
branches append a scope to the same place; all four entries survive the
conflict.
Rafael-SOWNet added a commit that referenced this pull request Aug 23, 2026
EvalEasy went 0.98 ns -> 3.52 ns on #1027, a branch that changes exception
message strings and a documentation file, and failed the gate at 3.58x. Its
allocation moved 0.0%, and so did almost everything else's: every one of the
seventeen benchmarks came out slower, between 1.30x and 3.58x, because the
baseline was taken on an 8-core runner and pull requests run on a 4-core one.

The inflation tracks how small the benchmark is, which is what says it is
overhead rather than code:

  EvalEasy    0.98 ns  3.58x     ParseEasy     6,919 ns  1.59x
  RunEasy       23 ns  1.81x     Derivate     17,215 ns  1.52x
  RunHard      346 ns  1.74x     SimplifyHard  2.16e9 ns 1.30x
  RunMedium    203 ns  1.57x

Allocation already has this guard -- AllocationFloorBytes exists so that a
benchmark allocating almost nothing cannot fail on a percentage of almost
nothing -- and the same argument applies to time with no floor at all. Below
roughly a microsecond a ratio measures the runner: scheduling, cache state, one
fewer core. So time under 1,000 ns/op is reported and never fails.

The gate still catches what it was built for. SimplifyHard at 2.16 seconds
would have to reach 6.5 seconds, and allocation is untouched -- it remains the
metric that fails, at 3%, on every benchmark regardless of size.

A gate that goes red without a defect is a gate people learn to ignore, and
this one is a day old.
@Rafael-SOWNet
Rafael-SOWNet merged commit 8a851a0 into master Aug 23, 2026
31 checks passed
Rafael-SOWNet added a commit that referenced this pull request Aug 23, 2026
#1027 improved this site's message and left it an AngouriBugException; this
branch changes what it throws. Merging kept both, which is two default arms in
one switch and does not compile. The point of this change is that neither site
is a library defect, so the AngouriBugException arms go and the messages that
say what the caller passed stay.
Rafael-SOWNet added a commit that referenced this pull request Aug 23, 2026
* Do not call the caller's typo a library defect

SpecialSet.Create(string) and SpecialSet.Create(Domain) are both public, and
both answered an input they did not recognise with AngouriBugException, whose
message asks the caller to report the problem to this repository. Somebody who
writes "NN" is told their own typo is a defect here.

The string overload now throws UnrecognizedDomainException, which has existed
for exactly this since it was written and which nothing threw. The Domain
overload throws NotSufficientlySupportedException, because Domain.Any is a
documented member of the enum -- it means no restriction, which is not a set
this library has a node for -- and a value cast in from outside the enum is not
one either. Neither is the library's fault and neither now says it is.

Both stay under AngouriMathBaseException, so the catch the documentation tells
a caller to write is unaffected, and a test pins that.

Measured on a build of each side, with every accepted name and every domain
that is a set checked to still answer:

  Create("NN")           AngouriBugException  ->  UnrecognizedDomainException
  Create(Domain.Any)     AngouriBugException  ->  NotSufficientlySupportedException
  Create((Domain)99)     AngouriBugException  ->  NotSufficientlySupportedException
  Create("RR")           RR                   ->  RR

Nothing inside the library reaches either arm: Domains.IsWithinDomain answers
Domain.Any before Create sees it, and the only other route into the string
overload is a SPECIALSET token, every spelling of which the switch lists.

* Resolve against master by taking the type change, not both arms

#1027 improved this site's message and left it an AngouriBugException; this
branch changes what it throws. Merging kept both, which is two default arms in
one switch and does not compile. The point of this change is that neither site
is a library defect, so the AngouriBugException arms go and the messages that
say what the caller passed stay.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant