From aa0b5dfe2d984fba1c13e3f53a639b9e68c53ee3 Mon Sep 17 00:00:00 2001 From: Reuben Brooks Date: Sun, 14 Jun 2026 14:18:49 -0500 Subject: [PATCH] Fix #24: render non-integer floats in shortest round-trippable form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LuaJIT's tostring / %g defaults to 14 significant digits, so numToStr (and the debug printer to_str) printed (+ 0.1 0.2) as "0.3" instead of the actual double "0.30000000000000004" — a lossy, non-round-tripping form that diverged from shen-cl/shen-rust/shen-go/ShenScript. Add a shortest_float helper (runtime.lua) that emits the smallest %.

g (p = 1..17) whose tonumber() parses back to exactly the same double, and route both numToStr (the str/print path) and to_str (the REPL/debug printer) through it. Integer-valued floats still print bare via the existing %d branch; kernel/ klambda untouched. Verified: (str (+ 0.1 0.2)) => 0.30000000000000004, (str (/ 10 4)) => 2.5, (str (+ 3.5 1.25)) => 4.75. make test 448 pass / 0 fail; make certify 100%. bifrost float cases (int-div-to-float, float-add-clean, float-add-imprecise) now agree across all five impls. Co-Authored-By: Claude Opus 4.8 (1M context) --- prims.lua | 3 ++- runtime.lua | 16 +++++++++++++++- test/primitives_spec.lua | 10 +++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/prims.lua b/prims.lua index 91e8f9f..4d0119d 100644 --- a/prims.lua +++ b/prims.lua @@ -270,7 +270,8 @@ local function numToStr(n) end return string.format("%d", n) end - return tostring(n) + -- non-integer: shortest round-trippable form, not LuaJIT's lossy %.14g (#24) + return R.shortest_float(n) end defprim("str", 1, function(x) diff --git a/runtime.lua b/runtime.lua index 3d99ae3..3aecc39 100644 --- a/runtime.lua +++ b/runtime.lua @@ -197,6 +197,19 @@ M.read_all = read_all -- mtoint: PUC 5.3+ %d-format guard (string.format("%d", x) errors there for -- an integral float outside int64 range). nil under LuaJIT/5.1: path unchanged. local mtoint = math.tointeger +-- Shortest round-trippable decimal for a non-integer float. LuaJIT's tostring +-- (and %g) default to 14 significant digits, which is lossy: (+ 0.1 0.2) would +-- print "0.3" instead of the actual double "0.30000000000000004". Emit the +-- smallest %.

g (p = 1..17) that parses back to exactly the same double, so +-- the output round-trips and matches the shen-cl/shen-rust/shen-go/ShenScript +-- reference (issue #24). +local function shortest_float(n) + for p = 1, 17 do + local s = string.format("%." .. p .. "g", n) + if tonumber(s) == n then return s end + end + return string.format("%.17g", n) +end local function to_str(x, seen) local t = type(x) if t == "number" then @@ -208,7 +221,7 @@ local function to_str(x, seen) end return string.format("%d", x) end - return tostring(x) + return shortest_float(x) elseif t == "boolean" then return x and "true" or "false" elseif t == "string" then return '"' .. x .. '"' elseif x == NIL then return "()" @@ -229,5 +242,6 @@ local function to_str(x, seen) else return tostring(x) end end M.to_str = to_str +M.shortest_float = shortest_float return M diff --git a/test/primitives_spec.lua b/test/primitives_spec.lua index d634191..736a709 100644 --- a/test/primitives_spec.lua +++ b/test/primitives_spec.lua @@ -90,9 +90,17 @@ checkeq('(cn "foo" "bar")', '"foobar"') checkeq('(tlstr "hello")', '"ello"') checkeq('(pos "hello" 1)', '"e"') checkeq("(str 42)", '"42"') -checkeq("(str 4.5)", '"4.5"') -- divergence: bare float +checkeq("(str 4.5)", '"4.5"') -- bare float (exactly representable) checkeq("(str foo)", '"foo"') checkeq("(str true)", '"true"') + +-- issue #24: non-integer floats render in SHORTEST round-trippable form, not +-- LuaJIT's lossy %.14g. (+ 0.1 0.2) is genuinely 0.30000000000000004 as a +-- double; printing "0.3" would not round-trip. Matches shen-cl/rust/go/ShenScript. +checkeq("(str (+ 0.1 0.2))", '"0.30000000000000004"') +checkeq("(str (/ 10 4))", '"2.5"') -- exactly representable -> short form +checkeq("(str (+ 3.5 1.25))",'"4.75"') +checkeq("(str 2.0)", '"2"') -- integer-valued float still prints bare checkeq('(string? "hi")', "true") checkeq("(string? 1)", "false") -- str on () is NOT representable in this port's kernel — it raises a clean,