From 7fc7208a5b4d2d336bc70b9aa3cd5bf6ccb127be Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sun, 20 Sep 2026 20:17:46 +0200 Subject: [PATCH 1/2] object: lay out a Float the way MRI does, not the way Go's %g does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Float#to_s formatted with strconv.FormatFloat(v, 'g', -1, 64) and appended ".0" only when the result held no '.', 'e' or 'E'. That diverged from MRI in two independent ways. The mantissa lost its fraction in exponential notation, because the ".0" was only ever appended in fixed notation: 1e-06 MRI 1.0e-06 rbgo 1e-06 1e+20 MRI 1.0e+20 rbgo 1e+20 5e-324 MRI 5.0e-324 rbgo 5e-324 And the fixed/exponential threshold was Go's, not Ruby's. Go's 'g' verb compares the exponent against the number of significant digits; MRI's flo_to_s (numeric.c) switches on where the decimal point falls — exponential iff decpt < -3 || decpt > DBL_DIG (15). Measured against ruby 4.0.5, the boundaries are exactly: 1e14 100000000000000.0 decpt 15, fixed 1e15 1.0e+15 decpt 16, exponential 1e-4 0.0001 decpt -3, fixed 1e-5 1.0e-05 decpt -4, exponential The visible symptom was 1e6: MRI prints 1000000.0, rbgo printed 1e+06. Take the shortest round-tripping digits and the exponent from 'e' with precision -1, decide the layout by MRI's rule, and in exponential notation give the mantissa a fractional digit. Go's exponent padding already matches (e+06, e-05, e+100, e-323), so nothing there changes. Verified beyond the pinned cases: 200 pseudorandom floats spanning 10^-20 to 10^20 render byte-identically to ruby 4.0.5. Sweeping core/float, core/kernel and language with the pre- and post-fix binaries against the same corpus: 3580 -> 3591 passing, 542 -> 531 failing. Closes #619. Co-Authored-By: Claude Opus 5 --- internal/object/float_tos_test.go | 61 +++++++++++++++++++++++++++++++ internal/object/object.go | 33 ++++++++++++++--- 2 files changed, 88 insertions(+), 6 deletions(-) create mode 100644 internal/object/float_tos_test.go diff --git a/internal/object/float_tos_test.go b/internal/object/float_tos_test.go new file mode 100644 index 00000000..b63d1ab7 --- /dev/null +++ b/internal/object/float_tos_test.go @@ -0,0 +1,61 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package object + +import ( + "math" + "testing" +) + +// TestFloatToSMatchesMRI pins Float#to_s against MRI's flo_to_s (numeric.c): +// the shortest round-tripping digits are laid out in fixed notation while the +// decimal point falls within -3..DBL_DIG (15), and in exponential notation +// outside that, where the mantissa always carries a fractional digit. +// +// Every expected value is what `ruby` 4.0.5 prints for the same literal. +func TestFloatToSMatchesMRI(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + // Fixed notation, including the "1.0 not 1" rule. + {0, "0.0"}, + {math.Copysign(0, -1), "-0.0"}, + {1, "1.0"}, + {3.14, "3.14"}, + {-2.5, "-2.5"}, + {0.1, "0.1"}, + {100, "100.0"}, + {1e6, "1000000.0"}, // Go's 'g' verb gives "1e+06" here + // The upper boundary: decpt 15 stays fixed, 16 goes exponential. + {1e14, "100000000000000.0"}, + {123456789012345, "123456789012345.0"}, + {1e15, "1.0e+15"}, + {1234567890123456, "1.234567890123456e+15"}, + // The lower boundary: decpt -3 stays fixed, -4 goes exponential. + {1e-4, "0.0001"}, + {1e-5, "1.0e-05"}, + // Exponential, with and without a fraction of its own. + {1e-6, "1.0e-06"}, + {1.5e-6, "1.5e-06"}, + {-1e-6, "-1.0e-06"}, + {1e20, "1.0e+20"}, + {1e100, "1.0e+100"}, + {5e-324, "5.0e-324"}, + {math.MaxFloat64, "1.7976931348623157e+308"}, + // The non-finite values. + {math.Inf(1), "Infinity"}, + {math.Inf(-1), "-Infinity"}, + {math.NaN(), "NaN"}, + } + for _, c := range cases { + if got := Float(c.in).ToS(); got != c.want { + t.Errorf("Float(%v).ToS() = %q, want %q", c.in, got, c.want) + } + if got := Float(c.in).Inspect(); got != c.want { + t.Errorf("Float(%v).Inspect() = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/object/object.go b/internal/object/object.go index e5097323..9692dcbe 100644 --- a/internal/object/object.go +++ b/internal/object/object.go @@ -101,18 +101,39 @@ func BigOf(v Value) (*big.Int, bool) { type Float float64 func (f Float) ToS() string { - if math.IsInf(float64(f), 1) { + v := float64(f) + if math.IsInf(v, 1) { return "Infinity" } - if math.IsInf(float64(f), -1) { + if math.IsInf(v, -1) { return "-Infinity" } - if math.IsNaN(float64(f)) { + if math.IsNaN(v) { return "NaN" } - // Ruby always shows a decimal point for floats (1.0 not 1). - s := strconv.FormatFloat(float64(f), 'g', -1, 64) - if !strings.ContainsAny(s, ".eE") { + // MRI's flo_to_s (numeric.c) lays the shortest round-tripping digits out in + // fixed notation when the decimal point falls within -3..DBL_DIG (15), and in + // exponential notation otherwise. Go's 'g' verb decides differently — it + // compares the exponent against the number of significant digits — so 1e6 + // rendered as "1e+06" where Ruby prints "1000000.0", and 1e15 as + // "1e+15" where Ruby prints "1.0e+15". + // + // 'e' with precision -1 gives the shortest digits plus the exponent, which is + // what decides the layout. Atoi cannot fail on an exponent Go just produced. + e := strconv.FormatFloat(v, 'e', -1, 64) + at := strings.IndexByte(e, 'e') + exp, _ := strconv.Atoi(e[at+1:]) + if decpt := exp + 1; decpt < -3 || decpt > 15 { + // Ruby always shows a fractional digit in the mantissa: 1.0e-06, not 1e-06. + mant := e[:at] + if !strings.ContainsRune(mant, '.') { + mant += ".0" + } + return mant + e[at:] + } + s := strconv.FormatFloat(v, 'f', -1, 64) + // And a decimal point in fixed notation: 1.0, not 1. + if !strings.ContainsRune(s, '.') { s += ".0" } return s From f2a727b4355a71d71471820d3361e9efd1378e9d Mon Sep 17 00:00:00 2001 From: tannevaled Date: Mon, 21 Sep 2026 06:23:28 +0200 Subject: [PATCH 2/2] test: clock_getres now prints MRI's spelling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #617 pinned `p Process.clock_getres(Process::CLOCK_MONOTONIC)` to "1e-06" and its comment said why: the value matched MRI but the spelling did not, because Float#inspect dropped the ".0" before the exponent. That is the divergence this branch fixes, so the pinned value becomes MRI's — `ruby -e 'p Process.clock_getres(Process::CLOCK_MONOTONIC)'` prints 1.0e-06 here. The case only surfaced after #617 merged: this branch was cut before it, so the full suite was green when the float change was written and red once the two met. Co-Authored-By: Claude Opus 5 --- internal/vm/process_limits_internal_test.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/vm/process_limits_internal_test.go b/internal/vm/process_limits_internal_test.go index 4cbb2c97..8c80ab4e 100644 --- a/internal/vm/process_limits_internal_test.go +++ b/internal/vm/process_limits_internal_test.go @@ -384,11 +384,10 @@ p Process.times.utime > u`, "true\n"}, {`p Process.clock_getres(:TIME_BASED_CLOCK_REALTIME, :nanosecond)`, "1000000000\n"}, {`p Process.clock_getres(:GETRUSAGE_BASED_CLOCK_PROCESS_CPUTIME_ID, :nanosecond)`, "1000\n"}, {`p Process.clock_getres(Process::CLOCK_REALTIME, :nanosecond)`, "1000\n"}, - // 1e-06 second, the same value MRI reports; the spelling differs only - // because this VM's Float#inspect drops the ".0" before the exponent - // (`p 0.000001` prints 1e-06 here and 1.0e-06 in MRI) — a core - // Float-formatting divergence, not a clock one. - {`p Process.clock_getres(Process::CLOCK_MONOTONIC)`, "1e-06\n"}, + // 1e-06 second, the same value and now the same spelling as MRI: the + // Float#inspect divergence this case used to document (1e-06 here, + // 1.0e-06 in MRI) is fixed in internal/object. + {`p Process.clock_getres(Process::CLOCK_MONOTONIC)`, "1.0e-06\n"}, // argv0 is frozen and is the same object every time. {`p [Process.argv0.is_a?(String), Process.argv0.frozen?, Process.argv0.equal?(Process.argv0)]`, "[true, true, true]\n"},