numbers: the clamps are recorded, not silent (#865) - #897
Conversation
There was a problem hiding this comment.
Pull request overview
This PR makes EigenScript’s “finite by construction” numeric behavior observable by introducing sticky per-thread math status flags, so overflow saturation and domain clamps are detectable without changing any arithmetic results (addressing #865).
Changes:
- Adds sticky
math_flags(overflow,invalid) andclear_math_flagsbuiltins, with flags set on existing clamp branches (not the arithmetic fast path). - Extends clamp sites outside
num_guard(e.g.,log,sqrt,asin/acos, tensorlog_softmax) to setinvalidwhile preserving current substituted values. - Adds numeric-guard tests and documents the new behavior in the language contract, builtins docs, and changelog.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_numeric_guard.eigs | Adds coverage for sticky flags, clearing, reassociation case, and domain clamp signaling. |
| src/eigenscript.h | Adds per-thread math_flags, flag bit definitions, and sets flags inside num_guard clamp branches. |
| src/builtins.c | Implements math_flags/clear_math_flags, registers them, and flags asin/acos argument clamps. |
| src/builtins_tensor.c | Flags tensor-domain substitutions (sqrt, log, log_softmax) via invalid. |
| docs/LANGUAGE_CONTRACT.md | Updates the Numbers promise to describe flags and the associativity tradeoff. |
| docs/BUILTINS.md | Documents the two new builtins. |
| CHANGELOG.md | Adds a changelog entry describing the new flags and audit findings. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for (int i = 0; i < rows * cols; i++) { | ||
| if (!(flat[i] > 1e-10)) g_math_flags |= EIGS_MATH_INVALID; /* #865 */ | ||
| flat[i] = log(flat[i] > 1e-10 ? flat[i] : 1e-10); | ||
| } |
| - Finite by construction: no NaN, no Infinity. NaN-producing operations | ||
| return 0; overflow saturates at ±1e308; division by zero warns and | ||
| yields 0. | ||
| - **Every clamp is recorded.** The finite invariant keeps a program |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
docs/LANGUAGE_CONTRACT.md:144
- The reference to
tests/test_numeric_guard.eigssays “NG20–NG29 cover the flags”, but the added flag assertions include several labeled “NG30 …” (string parse clamps). This makes the doc pointer slightly inaccurate when someone greps failure IDs.
**Status:** Enforced — `tests/test_number_format.eigs`,
`tests/test_numeric_guard.eigs` (NG20–NG29 cover the flags).
"Finite by construction" (NaN -> 0, overflow -> +/-1e308) keeps a program
running, which is the point, but it kept it running with a PLAUSIBLE
number and no way to tell. Two consequences the contract never mentioned:
(1e300 * 1e300) / 1e300 -> 1e8 (overflowed and came back down)
1e300 * (1e300 / 1e300) -> 1e300 (same expression, reassociated)
1e8 passes any plausibility check a caller applies. And a saturated value
compares equal to itself under further growth, so no in-language
predicate could distinguish "this is 1e308" from "this overflowed" —
where a language with NaN at least offers `x != x`.
This is IEEE-754's own problem and it takes IEEE-754's answer: sticky
status flags. Arithmetic results are UNCHANGED. The finite invariant is
load-bearing for the JIT's bail comparison, the observer's entropy,
`str of`, and the JSON encoders, so abandoning saturation is a far larger
change than the defect warrants — and the complaint the issue actually
makes is that the clamp is undetectable, not that it is bounded.
clear_math_flags of null
result is risky of xs
if (math_flags of null).overflow:
print of "a value saturated; this result is contaminated"
Set inside num_guard's existing clamp branches, so the arithmetic fast
path is untouched and every one of its ~54 call sites is covered at once.
The JIT needs no mirror: it already bails to the interpreter on any
result past EIGS_NUM_MAX (jit.c:2382, incl. +/-Inf and NaN), so the
interpreter's num_guard runs and the flag cannot become tier-dependent —
the a98cc4a/#279 class is structurally excluded here.
The audit found three more undocumented domain substitutions besides the
`log of 0` the issue named. All keep their values; all now set `invalid`:
log of 0 -> log(1e-10) = -23.025850929940457
sqrt of -1 -> 0 (indistinguishable from sqrt of 0)
asin of 5 -> asin of 1 (argument silently clamped; acos too)
Honest limit, stated in the test and the contract: num_guard's NaN branch
also sets `invalid`, but it is unreachable from pure EigenScript
arithmetic — an over-range literal is capped before it can be an operand,
and `x / 0` warns and yields 0 without producing a NaN. It remains a
guard for values arriving through the embed API, so in practice
`invalid` means a domain clamp fired.
The Numbers promise now states the flags AND the associativity trade, so
it is visible rather than discovered. tests/test_numeric_guard.eigs
NG20-NG29 cover both bits, stickiness, clearing, each domain clamp with
its in-domain control, and that ordinary arithmetic sets nothing.
Suite 3787/3787 release, 3785/3785 ASan+UBSan with detect_leaks=1, leak
tally 0. Builtin doc gate 199 -> 201, both documented.
Closes #865
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4656373 to
e1a50a7
Compare
The commit before this asserted `invalid` was reachable only through the domain clamps, because arithmetic cannot produce a NaN. That is true of arithmetic and false of the language: `num of "nan"` is 0 and `num of "inf"` is 1e308, both through num_guard, and both were the quietest case of all — a data column containing either parsed to a plausible number with nothing to check. No code change; the flags already covered it. The test, the contract, and the changelog said otherwise. NG30 now pins all four cases, including that `num of "abc"` is 0 by the documented parse rule and sets nothing, so the bit means "a clamp fired", not "a parse failed". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e1a50a7 to
bb6e6d8
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (2)
tests/test_numeric_guard.eigs:119
- This test comment claims NaN cannot be produced by arithmetic, but the language exposes math builtins like
powthat can yield NaN (e.g.pow of [-1, 0.5]), which will then be collapsed bynum_guardand setinvalid. Clarifying the comment avoids baking an incorrect guarantee into the test rationale.
# A NaN cannot be produced by ARITHMETIC here — there is no way to obtain an
# Inf to combine (an over-range literal is capped before it is ever an
# operand, and `x / 0` warns and yields 0 without producing a NaN). So an
# over-range literal in an expression is capped and quiet:
docs/LANGUAGE_CONTRACT.md:129
- The contract says arithmetic cannot produce NaN, but
pow(and potentially other math builtins) can return NaN without requiring an Inf operand (e.g.pow of [-1, 0.5]). That NaN will be collapsed bynum_guard, so this statement is misleading about wheninvalidcan be set.
`invalid` is also set when a NaN is collapsed, which arithmetic
cannot produce (there is no way to obtain an Inf to combine) but
string conversion can: `num of "nan"` is `0` and `num of "inf"` is
`1e308`, so a data column containing either used to parse to a
plausible number with nothing to check. Both bits are sticky until
"Finite by construction" (NaN → 0, overflow → ±1e308) keeps a program running, which is the point. But it kept it running with a plausible number and no way to tell:
1e8passes any plausibility check a caller applies. And a saturated value compares equal to itself under further growth, so no in-language predicate could distinguish "this is 1e308" from "this overflowed" — where a language with NaN at least offersx != x. The issue makes exactly that contrast.The decision
This is IEEE-754's own problem, so it takes IEEE-754's answer: sticky status flags.
Arithmetic results are unchanged. The finite invariant is load-bearing for the JIT's bail comparison, the observer's entropy,
str of, and the JSON encoders — abandoning saturation is a far larger change than the defect warrants, and the complaint the issue actually makes is that the clamp is undetectable, not that it is bounded.Of the issue's four options this is #1 (record it) plus #4 (document the trade). Not #2 (warn): the one existing warning site in the runtime prints unbounded, so a per-overflow warning floods a hot loop, and a warning is not programmatically checkable — which is the whole ask. Not #3 (trap): it would make intermediate overflow fatal in a numerics language and break every consumer of the finite invariant.
Why this is cheap and cannot drift
Flags are set inside
num_guard's existing clamp branches, so the arithmetic fast path is untouched (a program that never overflows never takes those branches) and all ~54 call sites are covered at once.The JIT needs no mirror. It already bails to the interpreter on any result past
EIGS_NUM_MAX(jit.c:2382, explicitly including ±Inf and NaN), so the interpreter'snum_guardruns and the flag cannot become tier-dependent. The a98cc4a/#279 tier-divergence class is structurally excluded here rather than defended against.The audit found five more silent cases
The issue named
log of 0. Auditing every clamp outsidenum_guardturned up three, all silent, all now settinginvalid— values unchanged:log of 0log(1e-10)=-23.025850929940457sqrt of -10sqrt of 0asin of 5/acos of -9asin of 1/acos of -1num of "nan"00num of "inf"1e3081e308A claim I had to correct mid-PR
The first commit asserted that
invalidwas reachable only through the domain clamps, reasoning that arithmetic cannot produce a NaN — there is no way to obtain an Inf to combine, since an over-range literal is capped before it can be an operand andx / 0warns and yields0. That reasoning is right about arithmetic and wrong about the language:num of "nan"goes straight throughnum_guard.No code changed — the flags already covered it — but the test, the contract, and the changelog all said something false, and the follow-up commit fixes them.
NG30pins all four conversion cases, including thatnum of "abc"is0by the documented parse rule and sets nothing, so the bit means "a clamp fired", not "a parse failed".Verification
tests/test_numeric_guard.eigsNG20–NG30: both bits, stickiness across statements, clearing, the issue's exact reassociation pair, each domain clamp with its in-domain control, and that ordinary arithmetic sets nothing.detect_leaks=1: 3785/3785, leak tally 0The Numbers promise in
docs/LANGUAGE_CONTRACT.mdnow states the flags and the associativity trade, so it is visible rather than discovered.Not covered here
The AOT (
ouroboros) emits C and would need the same flag on any arithmetic it inlines rather than routing throughnum_guard. Out of this repo; flagging it for the AOT differential pass.Closes #865
🤖 Generated with Claude Code