Skip to content

object: lay out a Float the way MRI does, not the way Go's %g does (+11) - #622

Merged
tannevaled merged 3 commits into
mainfrom
wave25/float
Sep 21, 2026
Merged

tannevaled merged 3 commits into
mainfrom
wave25/float

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

Closes #619.

Float#to_s used strconv.FormatFloat(v, 'g', -1, 64) and appended .0 only when the result contained no ., e or E. Two independent divergences from MRI followed.

1. The mantissa lost its fraction in exponential notation

The .0 was only ever appended in fixed notation:

MRI 4.0.5 rbgo before
1e-06 1.0e-06 1e-06
1e+20 1.0e+20 1e+20
-1e-06 -1.0e-06 -1e-06
5e-324 5.0e-324 5e-324

2. The fixed/exponential threshold was Go's, not Ruby's

Go's %g 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:

1e14                100000000000000.0        decpt 15, fixed
1e15                1.0e+15                  decpt 16, exponential
123456789012345.0   123456789012345.0        decpt 15, fixed
1234567890123456.0  1.234567890123456e+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.

The change

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 MRI (e+06, e-05, e+100, e-323), so nothing there changes. No new error branch: strconv.Atoi cannot fail on an exponent Go has just produced, so there is nothing uncoverable.

Verification

Beyond the 24 pinned cases in the new test, 200 pseudorandom floats spanning 10^-20 to 10^20 were rendered by both engines and compared: byte-identical.

Sweeping core/float, core/kernel and language with the pre-fix and post-fix binaries against the same pinned corpus, same timeout:

passing fail+error
before 3580 542
after 3591 531

The control binary was built from this same tree with only object.go stashed, so the two differ in exactly the change under test.

Gates

Full CGO_ENABLED=0 go test ./... green (no pinned test needed updating — the blast radius turned out smaller than feared), Float.ToS at 100% in the package profile, gofmt clean, wasip1 builds.

🤖 Generated with Claude Code

tannevaled and others added 3 commits September 20, 2026 20:17
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 <noreply@anthropic.com>
#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 <noreply@anthropic.com>
@tannevaled
tannevaled merged commit 7e4ca23 into main Sep 21, 2026
7 checks passed
@tannevaled
tannevaled deleted the wave25/float branch September 21, 2026 19:22
tannevaled added a commit that referenced this pull request Sep 23, 2026
Seven units since the last bump:

  #617  core/process: spawn, exec, the rlimits, priorities, process groups,
        kill, the wait family                     (pass 20 -> 165)
  #618  core/kernel + core/string: require_relative with an absolute path,
        the require/load argument contract, LoadError#path, byteindex/
        byterindex, split, chop/chars/each_char   (kernel 233 -> 184,
                                                   string 179 -> 153 failing)
  #620  language: compound assignment evaluation order, the flip-flop
        operator, magic-comment encodings, loop break values
                                                  (pass 1308 -> 1443)
  #622  object: Float#to_s laid out by MRI's rule rather than Go's %g  (+11)
  #624  the mspec shim: a nested describe can see the helpers of the one
        around it                                                     (+28)
  #625  deps: parser v0.2.0                        (language +333)
  #626  compiler: for-loop targets, block-local variables, what defined?
        declares                                   (language +382)

Measured with scripts/conformance/rubyspec/run.sh against the pinned corpus at
87b1631, 16-way, TIMEOUT=25, three consecutive runs: 21749, 21750, 21750. The
floor is set nine below the lowest, the same margin rule as the last bump: it
absorbs the run-to-run jitter without being wide enough to hide the loss of a
whole spec file, which is what the ratchet exists to catch. #615 (an
intermittent frame-pop crash) is still open and can cost a file when it fires.

TWO OF THESE ARE NOT VM CONFORMANCE, and the number should not be read as if
they were. #624 fixed our own mspec shim: core/string/valid_encoding/utf_8_spec
scored 0/28 under MRI 4.0.5 too, so those 28 were harness noise counted as VM
failure. #625 is a dependency bump; the VM did not change, but thirteen
language spec files became parseable for the first time and brought 333 already-
passing examples into view. Both are real gains in what the measurement can
see, not in what the VM can do.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Float#to_s diverges from MRI: missing mantissa fraction, and the wrong fixed/exponential threshold

1 participant