From da198fc65d0f5b150fe29dd7940d737b674126fb Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 31 Oct 2026 22:36:55 +0100 Subject: [PATCH 1/2] vm: Math.log1p and Math.expm1, the two functions Ruby 4.0 added to Math MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by asking the interpreter, rather than by reading a changelog: of 31 Ruby 4.0 surfaces that ruby/spec gates behind `ruby_version_is "4.0"`, rbgo already answers **28**. The three it does not are `Array#rfind`, `Math.log1p` and `Math.expm1`. These are the two pure ones. ## Measured, with the control `core/math/log1p_spec.rb` and `core/math/expm1_spec.rb`, run on a private corpus at the pinned `SPEC_SHA` with the shim's `CUR_VERSION` set to 4.0.9 (the whole of both files sits behind a 4.0 guard, so at 3.4.1 they yield nothing at all): | spec | `main` (3a3fb818) | this branch | |---|---|---| | `core/math/log1p_spec.rb` | `pass=0 fail=5 error=5` | **`pass=10 fail=0 error=0`** | | `core/math/expm1_spec.rb` | `pass=0 fail=2 error=5` | **`pass=7 fail=0 error=0`** | All 17 examples, and the control row is what makes the right column mean something: every one of them failed or errored before. Those 17 remain **invisible to CI** until `CUR_VERSION` moves (#845, #799) — the same ordering as #844: capability first, so that when the version moves, a file that shifts because a method was missing is not noise in the ratchet's reading. ## Why these two functions exist at all `log1p(x)` is `log(1+x)` and `expm1(x)` is `exp(x)-1`, computed so the result keeps its precision for |x| far below 1. Written the naive way they both return **0.0** for 1e-16, because the leading 1 swallows every significant digit: Math.log(1 + 1e-16) # => 0.0 Math.exp(1e-16) - 1 # => 0.0 Both specs assert exactly that this must not happen (log1p_spec.rb:10, expm1_spec.rb:10: close to 1e-16 **and** `!= 0.0`). So an implementation that spells out the identity passes the first example of each file and fails the second. Go's `math.Log1p`/`math.Expm1` are the same primitives C hands MRI; the tests carry the naive forms beside the real ones as the control. Domain: log1p is real for x >= -1 — `log1p(-1)` is `-Infinity`, which is a value and not an error — so `Math::DomainError` belongs to x < -1, which is what the spec exercises with `-1 - 1e-15`. ## ⛔ A pre-existing divergence these two do NOT inherit `core/math/log1p_spec.rb:27` asserts `Math.log1p(9, 3)` raises ArgumentError "wrong number of arguments (given 2, expected 1)". rbgo does not check arity on Math functions at all — measured on `main`: $ rbgo -e 'p Math.cbrt(9, 3)' 2.080083823051904 # MRI: ArgumentError The extra argument is silently dropped, for every function the `unary` helper installs. **That is not fixed here**, and the asymmetry is deliberate rather than an oversight: the arities are not uniform (`Math.log` legitimately takes one OR two arguments, `atan2`/`hypot`/`ldexp` take two), so a blanket check is wrong and a per-function sweep of ~20 functions moves what the gate measures and needs its own measurement. It is filed separately. What must not happen is a new method inheriting the old leniency because its neighbours have it. `unaryStrict` carries the check, the older helpers are left as they are, and the comment at the site says why so the next reader does not read the asymmetry as sloppiness. ## No regression The whole `internal/vm` suite passes (230s, `-count=1`). The only spec in `language/`+`core/` that touches Math's method list is `core/math/shared/atanh.rb:32`, and it asserts **inclusion**, not a count — two more private instance methods cannot move it. `gofmt` clean, `go vet` clean. No new input is parsed; both functions are pure and allocate one Float. Co-Authored-By: Claude Opus 5 --- internal/vm/math.go | 44 +++++++++++++++++++++++++++ internal/vm/math_test.go | 65 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/internal/vm/math.go b/internal/vm/math.go index 39c896ec..11616c24 100644 --- a/internal/vm/math.go +++ b/internal/vm/math.go @@ -175,6 +175,50 @@ func (vm *VM) registerMath() { unaryDom("atanh", math.Atanh, unit) unaryDom("acosh", math.Acosh, func(x float64) bool { return x < 1 }) + // unaryStrict installs a single-argument function that also CHECKS ITS + // ARITY, with an optional domain restriction. + // + // ⛔ The arity check is here and not in `unary` above, and that asymmetry is + // deliberate. Measured 2026-10-31: rbgo answers `Math.cbrt(9, 3)` with + // 2.080083823051904, where MRI raises ArgumentError "wrong number of + // arguments (given 2, expected 1)" — the extra argument is silently + // dropped, for every function `unary` installs. That is a pre-existing + // divergence across the whole module, and it cannot be repaired by a + // blanket check here: `Math.log` legitimately takes one OR two arguments, + // `atan2`/`hypot`/`ldexp` take two, so each arity is its own fact and + // changing twenty of them at once moves what the gate measures. It is + // tracked separately. + // + // What must not happen is a NEW method inheriting the old leniency because + // its neighbours have it. core/math/log1p_spec.rb:27 asserts the + // ArgumentError outright, so the two functions below are strict and the + // older ones stay as they are until that divergence is measured on its own. + unaryStrict := func(name string, f func(float64) float64, bad func(float64) bool) { + install(name, func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + if len(args) != 1 { + raise("ArgumentError", "wrong number of arguments (given %d, expected 1)", len(args)) + } + x := mathFloat(vm, args[0]) + if bad != nil && bad(x) { + mathDomainError(name) + } + return object.Float(f(x)) + }) + } + + // log1p(x) is log(1+x) and expm1(x) is exp(x)-1, computed so that the result + // keeps its precision for |x| far below 1 — where `log(1 + 1e-16)` and + // `exp(1e-16) - 1` lose every significant digit to the leading 1 and return + // 0.0. core/math/log1p_spec.rb:10 and core/math/expm1_spec.rb:10 assert + // exactly that: the answer must be close to 1e-16 and must not be 0.0. + // Go's math.Log1p and math.Expm1 are the same primitives C gives MRI. + // + // The domain: log1p is real for x >= -1 (log1p(-1) is -Infinity, which is a + // value and not an error), so DomainError belongs to x < -1 — which is what + // the spec exercises with -1-1e-15. expm1 has no domain restriction. + unaryStrict("log1p", math.Log1p, func(x float64) bool { return x < -1 }) + unaryStrict("expm1", math.Expm1, nil) + // logFamily installs a base-2/e/10 logarithm: it raises DomainError - name for // a negative argument and combines the mantissa/exponent split from mathLogArg // so a huge Bignum keeps full precision instead of overflowing to Infinity. diff --git a/internal/vm/math_test.go b/internal/vm/math_test.go index 15076bb6..176b6e9c 100644 --- a/internal/vm/math_test.go +++ b/internal/vm/math_test.go @@ -185,3 +185,68 @@ func TestMathPrivateInstanceMethods(t *testing.T) { } } } + +// TestMathLog1pAndExpm1 covers the two functions Ruby 4.0 added +// (core/math/log1p_spec.rb, core/math/expm1_spec.rb — both behind +// `ruby_version_is "4.0"`, so the conformance gate cannot see them while the +// shim resolves guards at 3.4.1). +// +// ⛔ THE POINT OF THESE FUNCTIONS IS THE PRECISION, not the identity. Anyone +// could write `log(1+x)` and `exp(x)-1` and satisfy the first example of each +// spec; both specs then assert that the answer for 1e-16 is close to 1e-16 and +// **is not 0.0**, which the naive forms fail — the leading 1 swallows every +// significant digit. The cases marked "the whole reason" below are those, and +// the two after them show the naive forms really do lose it, so those +// assertions are not holding either way. +func TestMathLog1pAndExpm1(t *testing.T) { + cases := []struct{ src, want string }{ + // log1p(x) == log(1 + x) in the ordinary range. + {`p Math.log1p(3) == Math.log(4)`, "true\n"}, + {`p Math.expm1(3) == Math.exp(3) - 1`, "true\n"}, + + // ⛔ the whole reason these exist. + {`p Math.log1p(1e-16) == 0.0`, "false\n"}, + {`p Math.expm1(1e-16) == 0.0`, "false\n"}, + // and the naive forms answer 0.0, which is the control for the two above. + {`p Math.log(1 + 1e-16)`, "0.0\n"}, + {`p Math.exp(1e-16) - 1`, "0.0\n"}, + + // log1p is real for x >= -1: -1 is -Infinity, which is a value, and + // below -1 is Math::DomainError with MRI's exact wording. + {`p Math.log1p(-1)`, "-Infinity\n"}, + {mathRescue(`Math.log1p(-1 - 1e-15)`), + "Math::DomainError: Numerical argument is out of domain - log1p\n"}, + {`p Math.expm1(-1000) == -1.0`, "true\n"}, + + // NaN in, NaN out. + {`p Math.log1p(Float::NAN).nan?`, "true\n"}, + {`p Math.expm1(Float::NAN).nan?`, "true\n"}, + + // Float() coercion, with MRI's messages. + {mathRescue(`Math.log1p("10")`), "TypeError: can't convert String into Float\n"}, + {mathRescue(`Math.expm1("test")`), "TypeError: can't convert String into Float\n"}, + {mathRescue(`Math.log1p(nil)`), "TypeError: can't convert nil into Float\n"}, + {mathRescue(`Math.expm1(nil)`), "TypeError: can't convert nil into Float\n"}, + + // ⛔ Arity, which core/math/log1p_spec.rb:27 asserts outright. These two + // are strict where the older unary functions are not: `Math.cbrt(9, 3)` + // still answers 2.080083823051904 instead of raising, a pre-existing + // divergence across the module that cannot be fixed by a blanket check + // (`Math.log` takes one OR two arguments) and is tracked on its own. A + // new method must not inherit it. + {mathRescue(`Math.log1p(9, 3)`), + "ArgumentError: wrong number of arguments (given 2, expected 1)\n"}, + {mathRescue(`Math.expm1(9, 3)`), + "ArgumentError: wrong number of arguments (given 2, expected 1)\n"}, + + // module_function: reachable as a private instance method on an includer, + // which is the last example of each spec file. + {"class IML\n include Math\nend\np IML.new.send(:log1p, 4.21).round(11)", "1.65057985577\n"}, + {"class IME\n include Math\nend\np IME.new.send(:expm1, 23.1415).round(4)", "11226018483.0012\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} From 4b4e95fd05e447e508d3c331bfaf485d03b3b813 Mon Sep 17 00:00:00 2001 From: tannevaled Date: Sat, 31 Oct 2026 22:40:07 +0100 Subject: [PATCH 2/2] tests: pin the zero-argument message, where the old helpers leak a Go panic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probing the arity divergence further turned up something worse than leniency. With **no** argument the older helpers index `args[0]` out of range, and the VM's panic-to-exception net surfaces the Go runtime error as the Ruby message. One run, three calls, on this branch's binary: Math.log1p -> ArgumentError: wrong number of arguments (given 0, expected 1) Math.expm1 -> ArgumentError: wrong number of arguments (given 0, expected 1) Math.cbrt -> ArgumentError: cbrt: runtime error: index out of range [0] with length 0 The class is right by accident. The message is a Go internal handed to Ruby code, and it would read differently on another Go version or architecture — so anything matching on it is matching on our build. The two new functions already answered correctly, because `unaryStrict` checks `len(args) != 1` rather than only the two-argument case. **A measurement in a PR body is not a regression guard**, so the wording is now pinned by two test cases; without them, nothing would stop these two drifting into the same leak. `Math.cbrt` and its ~20 siblings are not changed here — that is the separate arity issue, where the fix has to be per-function because `Math.log` takes one or two arguments. Co-Authored-By: Claude Opus 5 --- internal/vm/math_test.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/internal/vm/math_test.go b/internal/vm/math_test.go index 176b6e9c..1a1010d1 100644 --- a/internal/vm/math_test.go +++ b/internal/vm/math_test.go @@ -238,6 +238,21 @@ func TestMathLog1pAndExpm1(t *testing.T) { "ArgumentError: wrong number of arguments (given 2, expected 1)\n"}, {mathRescue(`Math.expm1(9, 3)`), "ArgumentError: wrong number of arguments (given 2, expected 1)\n"}, + // ⛔ And with NO argument, where the older helpers do something worse than + // being lenient: they index args[0] out of range, and the VM's + // panic-to-exception net surfaces the Go runtime error as the Ruby + // message. Measured on main: + // + // Math.cbrt -> ArgumentError: cbrt: runtime error: index out of range [0] with length 0 + // Math.log1p -> ArgumentError: wrong number of arguments (given 0, expected 1) + // + // The class is right by accident; the message is a Go internal leaked to + // Ruby code, and it would read differently on another Go version. These + // two cases pin MRI's wording so that cannot regress into it. + {mathRescue(`Math.log1p`), + "ArgumentError: wrong number of arguments (given 0, expected 1)\n"}, + {mathRescue(`Math.expm1`), + "ArgumentError: wrong number of arguments (given 0, expected 1)\n"}, // module_function: reachable as a private instance method on an includer, // which is the last example of each spec file.