From 69d10bec4ec5275f69a2a7fda37f554ca55213f7 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sun, 13 Sep 2026 22:46:26 +0700 Subject: [PATCH] Arithmetic with no answer is a condition: the plan --- HANDOFF-arith.md | 64 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 HANDOFF-arith.md diff --git a/HANDOFF-arith.md b/HANDOFF-arith.md new file mode 100644 index 0000000..9583c1f --- /dev/null +++ b/HANDOFF-arith.md @@ -0,0 +1,64 @@ +# 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". + +## Status + +Stub. Nothing below the line has landed yet; this section is updated as it does.