flan/HANDOFF-arith.md
Joseph Ferano a431cddd3b A divide by zero names the file and the line, and is answerable
Three arithmetic situations had no defined behaviour and the two backends
disagreed about all three: a divide or remainder by zero, which was a raw
SIGFPE with no message and no location; (/ min -1), whose quotient is one past
the top of the type; and a float to integer cast whose value does not fit,
which LLVM called undefined and would fold to anything.

They now signal ArithError with `error`, exactly as a bad index signals
BoundsError, and die with a sentence naming the file, the line and the operands
only if nothing answered. The guards ride the same --checks flag as the bounds
check and are elided with it.

No restart is established at the failing operation. The sketch this started
from asked for use-value, and the implementation ruled it out: a restart frame
is allocated by the restart-case that offers it, on its own stack, so the
runtime cannot hold one on a program's behalf and use-value here would mean an
alloca and a restart frame at every division in every checked build. That is
the cost already refused for indexing, buying a silently different answer.

The x86 backend is unchanged and is the next commit.
2026-09-13 22:55:26 +07:00

106 lines
7.2 KiB
Markdown

# Arithmetic that has no answer is a condition
Three arithmetic situations had no defined behaviour in Flan, and the two backends disagreed on all three. A divide by
zero was a raw `SIGFPE` — the process died with no message, no location, and nothing to handle. `INT64_MIN / -1` raised
`SIGFPE` under `idiv` and was undefined under LLVM. A float-to-integer cast whose value does not fit produced x86's
fixed "integer indefinite" under one backend and whatever the optimiser liked under the other.
All three now signal `ArithError` with `error`, exactly as an out-of-range index signals `BoundsError` and a failed
allocation signals `StorageExhausted`. A Lisp that dies naming the file and the line beats one that dies with `SIGFPE`,
and a program that genuinely does not care installs a handler once at startup and never thinks about it again.
## The shape
```lisp
(defstruct ArithError [op i32 lhs i64 rhs i64])
```
`op` is a small integer code and not a keyword, because `FileError`'s `op` is already a small integer code
(`prelude.ml:1380`, built at `check.ml:3096`) and the field has to be filled in from C, where a keyword is not a thing
that exists. The codes and what `lhs`/`rhs` hold under each are documented on the `defstruct` itself; the short version
is that a division carries its two operands and a cast carries the destination type's representable range, which is
the violated condition written as a range. That reuse of two fields for two meanings is `BoundsError`'s precedent
exactly — `low` and `high` are one index for an `at` and two ends for a `slice`, so that a handler writes one clause
and not two — and `flan_slice_promise_error` already packs a violated condition into them as `(0, n, 0)`.
There is no location field, because `BoundsError` has none either: the location is an argument to the runtime helper
and is used only in the message printed when nothing answered.
## No restart is established at the failing operation
The brief that started this work sketched `use-value` everywhere and `saturate` where clamping is meaningful. Reading
the existing implementation changed that, and the reason is mechanical rather than a matter of taste.
`runtime/flan_rt.c:70-86` says it: a restart frame is allocated **by the `restart-case` that offers it, on its own
stack**, and a transfer carries that frame's address. The runtime therefore cannot host a restart on a program's
behalf; a `use-value` on division would have to be an `alloca` plus a `flan_restart_push`/`pop` pair emitted at every
division site in every checked build.
That is the identical cost `prelude.ml:70-76` and `flan_rt.c:483-490` already refuse for indexing, in prose, on the
record: a site restart on every operation, buying a silently different answer. Division is if anything the weaker
case — an `at` at least has an element to hand back. So `ArithError` follows `BoundsError`: it signals, a handler may
inspect it and transfer out through a restart the program already established (a frame loop's `continue`), and with
nothing answering it falls through to a message naming the file and the line.
`saturate` on the **cast** arm alone is the one place a site restart might still earn its frame, because casts are rare
and clamping is a canonical answer rather than an arbitrary one. It is not built here and is follow-up shaped.
## The guards ride `--checks`
The same flag as the bounds check, elided with it. The divide guard is a branch *before* the instruction and not a
handler after it, because `SIGFPE` cannot be caught and resumed. Once the zero test is being paid for, the
`INT64_MIN / -1` test is nearly free on the same path.
Unsigned division gets the zero test only; there is no overflow case. Float division is **not** guarded at all:
IEEE `x / 0.0` is `inf`, which is defined and wanted — `rand-f32` in the prelude divides by a float constant.
## Order of work
1. `lib/prelude.ml`, `runtime/flan_rt.c`, `lib/emit.ml`, tests. Committed first and separately.
2. `lib/x86.ml`'s `prim` `Div`/`Rem`/`Cast` arms last, rebased onto the dev-loop tip, kept mechanical — this is item 3
of `HANDOFF-x86-rt.md`'s "What remains".
## What landed
**`lib/prelude.ml`** — `(defstruct ArithError [op i32 lhs i64 rhs i64])`, immediately after `BoundsError`, with the
codes and the per-code meaning of `lhs`/`rhs` written on it.
**`runtime/flan_rt.c`** — `flan_arith_error`, built out of the same three pieces `flan_bounds_error` is: fill a
`flan_arith_cond` on this frame, `flan_signal`, try `flan_break_hook`, and fall through to a sentence and `rt_die()` if
neither answered. `flan_arith_fail` is the sentence, and there is one per code rather than one shared "overflow",
because the reader who reaches `(/ min -1)` has probably never had to think about that case.
**`lib/emit.ml`** — `check_div` and `check_cast`, next to `check_at` and `check_slice` and built on the same
`signal_block`, so an answered failure leaves through the innermost pad and runs the defers. `check_div` is called from
`prim`'s `Div`/`Rem` arm and `check_cast` from `cast`'s `Float -> Int` arm.
Three things in those two functions are worth knowing:
- **Integer division only.** IEEE `x / 0.0` is an infinity and is a defined answer somebody may want — the prelude's
own `rand-f32` divides by a float constant — so guarding a float division would be refusing a result the language
already promises.
- **One branch, not two.** The zero test and the `INT_MIN / -1` test are `or`-ed into a single compare-and-branch, and
*which* of them fired is decided by a `select` that is dead on the fall-through path. The guard is also dropped
outright when the divisor is a literal that cannot trigger it, which is nearly every division anyone writes.
- **The cast test is exact and catches NaN.** Both bounds are powers of two and therefore exact in a double, an `f32`
source is `fpext`-ed first so there is one set of bounds rather than two, and the comparisons are *ordered*, which is
what makes a NaN fail both halves instead of passing both.
**`test/programs/arith.flan`** — the unhandled half, one case per argument in `bounds.flan`'s shape. Case 0 is the one
that must not die and is four shapes rather than one: a dynamic divisor, a literal one the guard drops, unsigned
division, and a float division by zero.
**`test/programs/arith-condition.flan`** — the answered half, in `bounds-condition.flan`'s shape: a `handler-bind`
clause that reads the condition and takes a frame loop's `continue`. Five codes, four frames finishing, eight
abandoned, and twelve defers run.
**`test/test_acceptance.ml`** — both programs at `-O0` and `-O2`, the answered one also as a dev build, and the
`--checks`-off case asserted on the IR rather than by running an unchecked program, because an unchecked divide by zero
has no defined behaviour to assert on — it is the SIGFPE this change exists to replace.
## The tests the brief asked for, and the one substitution
Unhandled-with-a-location, a `handler-bind` that inspects the condition, and `--checks` off are all there. The
`use-value` restart case is **not**, because no `use-value` restart is established — see above. What stands in its
place is the same thing that stands in for it in `bounds-condition.flan`: a handler that transfers out through a
restart the program already had.