carry Lua numbers as doubles - #11
Conversation
A Lua number is a C double: luaconf.h sets LUA_FLOAT_DEFAULT to LUA_FLOAT_DOUBLE, so lua_Number is double. push-float/get-float were registered against lua_pushnumber/lua_tonumber with Float, and because lua.h declares the prototypes C narrowed at every call site. Measured against a real 5.4 state: 3.141592653589793 came back as 3.1415927410125732, 16777217 came back as 16777216, and 1e300 came back as inf. All silent. Register push-double/get-double at the honest C type and generate the parallel Luax family (maybe-get-double, set/get-double-global, set/get-double-field) through the existing macros. push-float/get-float keep their signatures and become thin conversions over the double pair, so every current caller still compiles and behaves identically -- the float family is now documented as lossy rather than being the only way to reach a Lua number. Making them Carp functions also removes a latent hazard: a registered extern with a mismatched signature is only safe when called directly, since taking its address yields a function pointer of the wrong type. Tests: 19 new assertions across test/lua.carp and test/midlevel.carp. Verified they detect the bug by routing push-double/get-double back through Float -- 11 of them fail against that, the remaining 8 are type-guard and float-family pins that are meant to hold either way.
There was a problem hiding this comment.
Build & Tests
Checked out and built d1242df against a real Lua 5.4 on this armhf box. All five suites:
| suite | assertions | failures |
|---|---|---|
test/lua.carp |
76 | 0 |
test/midlevel.carp |
64 | 0 |
test/cfunction.carp |
10 | 0 |
test/metatable.carp |
22 | 0 |
test/coroutine.carp |
25 | 0 |
Exactly the numbers in the PR. CI green on ubuntu + macOS, and the branch's merge-base is current master (6c300f6), so CI tested the state that would land.
Findings
Nothing blocking. Both claims the PR rests on hold up under checking.
Non-vacuity — confirmed exactly. I routed the double pair back through Float (wrapping both registrations in Double.from-float (Double.to-float …), signatures unchanged) and re-ran: 5 failures in test/lua.carp, 6 in test/midlevel.carp — precisely the 11 claimed, and precisely the double-family assertions (push-double/get-double round trip, integer above 2^24, ±1e300 finite, maybe-get-double, set/get-double-global, set/get-double-field). The float-pin and type-guard assertions held under the mutant, as intended.
Non-breaking — verified rather than assumed. I ran the float family through a 24-case differential on master vs branch: push-float/get-float round trips for ±0, ±1, 2.5, Float.MAX, the smallest normal, 2^24+1, 1e30; the same via Luax.set/get-float-global and set/get-float-field; and values originating in Lua (pi, ±1e300, 2^24+1, 2^53+1, 1/0, 0/0). Output identical on both sides, including -0, inf, -inf and nan. The claim that current callers behave identically is true.
I also checked the one place a getter crosses as a name rather than a call: Luax.call-fn takes it as a macro argument, so get-float going from register to defn cannot turn into a function-pointer problem. No test covers a float getter there, so I exercised it directly — (Luax.call-fn lua half Lua.get-float (Lua.push-float 7.0f)) → (Success 3.5f), and the get-double equivalent → (Success 1.5708). Both fine.
Two residuals, neither blocking:
1. get-double still loses Lua integers above 2^53. Lua 5.4 keeps a separate integer subtype, and lua_tonumber converts it to a double:
2^53 | lua says 9007199254740992 | get-double gives 9007199254740992
2^53+1 | lua says 9007199254740993 | get-double gives 9007199254740992
maxint | lua says 9223372036854775807 | get-double gives 9.2233720368547758e+18
This is the same lua_Integer-is-64-bit axis you flagged as out of scope, and I agree it is out of scope — but the README currently states the double pair moves numbers across "without losing anything", which is true for Lua floats and not for integers. A 64-bit integer id read through get-double corrupts silently, exactly the failure mode this PR exists to remove. Worth one clause narrowing the claim to Lua's float subtype and pointing at the integer gap.
2. README.md:66-67 still demonstrates Lua.push-float 3.5f, in the make-table sample directly below the new paragraph recommending push-double. The reason given for leaving it (the samples pin @0.4.0) does not really apply to the README, since the same file already advertises push-double at lines 39 and 47 — so as it stands the README recommends the double pair and then demonstrates the float one. Either switch that sample or leave the float family out of the recommendation; the examples/ decision itself is right and I verified all four still pin @0.4.0.
The design call is the right one, for the reason given in the PR and for the one added in the body: an extern whose declared signature disagrees with the real prototype is only safe when called directly, so making these two Carp functions removes a latent hazard rather than just adding precision.
Verdict: merge
The bug is real and measured, the fix is additive and provably non-breaking, and the new tests demonstrably fail against the old behaviour. The two items above are documentation-level and can ride along or follow. Left as a draft, and not merged.
| (doc push-double "Push a double (Lua number) onto the stack. Lua numbers are | ||
| C doubles, so this is the lossless way to hand Lua a number.") |
There was a problem hiding this comment.
| (doc push-double "Push a double (Lua number) onto the stack. Lua numbers are | |
| C doubles, so this is the lossless way to hand Lua a number.") | |
| (doc push-double "Push a double (Lua number) onto the stack.") |
| (doc push-float "Push a float onto the stack as a Lua number. Lua numbers are | ||
| doubles, so the value arrives carrying only single precision; use |
There was a problem hiding this comment.
| (doc push-float "Push a float onto the stack as a Lua number. Lua numbers are | |
| doubles, so the value arrives carrying only single precision; use | |
| (doc push-float "Push a float onto the stack as a Lua number.") |
|
|
||
| A Lua number is a C double. [`push-float`](#push-float) and | ||
| [`get-float`](#get-float) still work, but they round to single precision and | ||
| turn anything above the float range into an infinity, so prefer the `double` |
There was a problem hiding this comment.
push-float does not round. it is a lossless upgrade into a bigger numerical class.
| without losing anything. The `float` versions still work, but they round to | ||
| single precision and turn anything above the float range into an infinity. |
push-float widens a Float into a double, which is exact. The loss is entirely on the way back out, where get-float narrows. The two push docstrings, the module doc block and the README all framed the push side as lossy. Also narrows the README's claim that the double pair moves numbers "without losing anything": lua_tonumber converts Lua's 64-bit integer subtype to a double, so integers above 2^53 come back rounded (9007199254740993 reads as 9007199254740992, math.maxinteger as 9223372036854775808). And switches the make-table sample to push-double, so the README stops demonstrating the float pair right under the paragraph recommending the double one.
|
@hellerve — you're right, and it was worse than the two lines you marked. Both suggestions applied verbatim:
And the rest of the diff swept for the same framing:
I checked the push side is exact rather than assuming it this time: Two residuals from @carpentry-reviewer folded in while in there:
Docs only — no code changed. Suites still 76 / 64 / 10 / 22 / 25 locally, One stale spot in the PR description above that I'd rather flag than quietly rewrite: where it says the float family's "docstrings now say it is lossy", that's true only of |
There was a problem hiding this comment.
Build & Tests
Checked out and built 11561c7 against a real Lua 5.4 on this armhf box. All five suites, matching the comment exactly:
| suite | assertions | failures |
|---|---|---|
test/lua.carp |
76 | 0 |
test/midlevel.carp |
64 | 0 |
test/cfunction.carp |
10 | 0 |
test/metatable.carp |
22 | 0 |
test/coroutine.carp |
25 | 0 |
CI green on ubuntu + macOS with head_sha 11561c73, and the merge-base is current origin/master (6c300f6), so CI tested the state that would land. carp-fmt --check and angler --disable lonely-do clean over the CI file set, carp -x gendocs.carp runs, docs/ and examples/ untouched by the diff, and all four examples still pin @0.4.0. No changelog in this repo.
Prior feedback
Both suggestion blocks applied byte-exactly, and the two follow-on comments are addressed: the module doc block (lua.carp:618) now says push-float "widens exactly, but get-float narrows on the way back", and README.md:47 is reframed the same way.
I checked the direction claim rather than taking it. Pushed four values through push-float and read each back with get-double against a live Lua 5.4 state:
push-float 3.14f -> 3.1400001049041748 == Double.from-float: YES
push-float 0.1f -> 0.10000000149011612 == Double.from-float: YES
push-float 3.40282e+38f -> 3.4028234663852886e+38 == Double.from-float: YES
push-float 1.67772e+07f -> 16777216 == Double.from-float: YES
Bit-identical in every case, including at Float.MAX. @hellerve's correction is right and the new framing matches the machine: the push side is an exact widening, the loss is entirely in get-float. push-float 3.14f -> get-float also still returns 3.14f.
Both of my residuals are folded in — the README's "without losing anything" is narrowed, and the make-table sample now uses push-double.
I also swept the rest of the repo for the old framing: nothing is left. The push-float/push-double docstrings, the module doc block and README.md:47 all agree now, and the Luax float family carries no precision claim of its own to drift.
Findings
One new claim is imprecise in the same direction the round was about. README.md:51 and the commit message both say Lua integers "above 2^53 come back rounded". Measured against Lua 5.4 on this box:
| Lua value | get-double |
|---|---|
9007199254740993 (2^53+1) |
9007199254740992 — rounded |
18014398509481984 (2^54) |
18014398509481984 — exact |
18014398509481986 (2^54+2) |
18014398509481984 — rounded |
math.maxinteger |
9.2233720368547758e+18 — rounded |
2^54 is above 2^53 and survives intact, as does every even integer up to 2^55, and so on. The accurate statement is that above 2^53 they are no longer all representable — "may come back rounded", or "are no longer exactly representable". This one errs conservatively (it overstates the risk rather than hiding it), so it is not blocking — but this is a PR whose entire subject is getting precision claims right, and it's a one-word change.
The PR description is still stale, as your comment says. Since you've already put the correction on the record, editing the body is the cleaner end state rather than leaving the flag standing — it's the text that outlives the thread.
Verdict: merge
The two suggestions are applied verbatim, the error behind them is fixed everywhere it appeared rather than only at the two marked lines, and I confirmed against a live Lua state that the push side really is exact — so the corrected framing is the true one, not just the requested one. Both residuals from the last round are closed. The only thing left is the "integers above 2^53 come back rounded" clause, which is still slightly stronger than the hardware, and can ride along or follow. Left as a draft, and not merged.
measured against Lua 5.4 on this box: 2^53+1 comes back 9007199254740992 (rounded), but 2^54 comes back 18014398509481984 (exact), and so does math.mininteger. above 2^53 the spacing between doubles grows, so an integer survives only when it lands on a representable value — "come back rounded" claimed more than the hardware does.
|
@carpentry-reviewer — taking the one open finding from your 13:32Z review: Measured rather than taken on faith. Each value set as a Lua integer in a live Lua 5.4 state on this armhf box, read back through
Your finding holds: 2^54 is above 2^53 and comes back intact, and so does The new text: That was the only instance on the branch — I grepped the tree for Stale PR description. I'm leaving the body untouched rather than editing it, so the corrected text goes here. Two spots:
should read:
and:
should read:
Documentation wording only. No code touched, and the Gates on |
There was a problem hiding this comment.
Build & Tests
Checked out and built 18dc3b1 against a real Lua 5.4 on this armhf box. All five suites:
| suite | assertions | failures |
|---|---|---|
test/lua.carp |
76 | 0 |
test/midlevel.carp |
64 | 0 |
test/cfunction.carp |
10 | 0 |
test/metatable.carp |
22 | 0 |
test/coroutine.carp |
25 | 0 |
CI green on ubuntu + macOS with head_sha 18dc3b11, and the branch is a clean fast-forward on current origin/master (6c300f6) — merge-base is master, so CI tested the state that would land. angler --disable lonely-do and carp-fmt --check clean over the CI file set, carp -x gendocs.carp runs, working tree clean afterwards. No changelog in this repo.
Prior feedback
The one open finding from my last review — README.md:51 claiming Lua integers "above 2^53 come back rounded" — is fixed. It now reads "are no longer all exactly representable and may come back rounded", which is the accurate statement.
And the correction that came with it is right; my reasoning was wrong. I wrote that "every even integer up to 2^55" survives. It doesn't. I checked this two independent ways:
IEEE-754 arithmetic, and then a live Lua 5.4 state on this box — each value set as a Lua integer, read back through Lua.get-double, and the double pushed back into Lua so Lua itself does the exact math.type/math.tointeger comparison rather than me eyeballing decimal:
2^53 exact=yes 2^54 exact=yes
2^53+1 exact=no 2^54+2 exact=no
2^53+2 exact=yes 2^55+4 exact=no
mininteger exact=yes maxinteger exact=no
18014398509481986 is even, is above 2^53, and still rounds away — double spacing is 2 on [2^53, 2^54) but 4 on [2^54, 2^55), so being even stops being sufficient at 2^54. What survives at a given magnitude is a multiple of the spacing there, which is exactly why "not all" is the right fix rather than a higher threshold. Both halves of the finding still hold — 2^54 is above 2^53 and survives, and so does math.mininteger — but the rule I gave for why was wrong, and the PR caught it rather than applying it on faith.
Findings
None. I re-swept the tree for the old framing and the remaining precision claims all agree with the machine: README.md:49-53, the get-float docstring at lua.carp:198, and the module doc block at lua.carp:618-620 are each accurate as written, and the Luax float family carries no precision claim of its own to drift.
The get-int / 64-bit lua_Integer axis stays out of scope, as agreed in both earlier rounds.
Verdict: merge
The last finding is closed, and closed in the direction the evidence actually supports rather than the one I asked for — the session measured the claim, found my justification wrong, and fixed the text to match the hardware instead of the review. Everything else I checked last round still holds. Left as a draft, and not merged.
A Lua number is a C double —
luaconf.hsetsLUA_FLOAT_DEFAULTtoLUA_FLOAT_DOUBLE, solua_Numberisdouble. Butpush-floatandget-floatwere registered againstlua_pushnumber/lua_tonumberwithFloat.Because
lua.hdeclares the prototypes, C narrows at every call site, so thiswas never an ABI break — just silent data loss on every number crossing the
binding.
Measured against a real Lua 5.4 state before the change:
What changed
Lua.push-double/Lua.get-doubleare registered at the honest C type, andthe parallel
Luaxfamily (maybe-get-double,set-double-global,get-double-global,set-double-field,get-double-field) is generatedthrough the existing
luax--def-*macros.push-float/get-floatkeep their exact signatures and become thinconversions over the double pair. This is additive and non-breaking: every
current caller still compiles and behaves identically, the float family just
stops being the only way to reach a Lua number, and its docstrings now say it
is lossy and point at the double pair.
Turning those two into Carp functions also removes a latent hazard. A
registered extern whose declared signature disagrees with the real prototype is
only safe when called directly; taking its address yields a function pointer of
the wrong type. Going through
Double.from-float/Double.to-floatmakes theconversion explicit and the C-level registration honest.
Naming follows the existing convention of naming the Carp type
(
push-int/push-bool/push-float), not the C name.Tests
19 new assertions: 8 in
test/lua.carp, 11 intest/midlevel.carp. They covera 17-significant-digit round trip, a Lua integer above 2^24, ±1e300 surviving
as finite values,
push-doublehanding Lua the full value (checked by makingLua do arithmetic on it and reading it back), and every new
Luaxentry pointincluding the type-mismatch paths. Three of them pin the float family's
existing behaviour so the non-breaking claim is enforced rather than asserted.
I checked the new tests actually detect the bug rather than passing for free:
routing
push-double/get-doubleback throughFloat(i.e. the pre-fixbehaviour) fails 11 of them — 5 in
test/lua.carp, 6 intest/midlevel.carp.The other 8 are the type-guard and float-pin assertions, which are meant to
hold either way.
Full suite locally on this box (Lua 5.4, armhf):
carp-fmt --checkandangler --disable lonely-doboth clean over the samefile set CI uses;
carp -x gendocs.carpruns.docs/is left untouched, andexamples/is untouched because those pin@0.4.0, which has nopush-double.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.