Repository navigation
vm: Math.log1p and Math.expm1, the two functions Ruby 4.0 added to Math - #846
Merged
Merged
Conversation
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` (3a3fb81) | 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 <noreply@anthropic.com>
… panic
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 <noreply@anthropic.com>
This was referenced Oct 31, 2026
tannevaled
added a commit
that referenced
this pull request
Nov 1, 2026
The last of the three Ruby 4.0 additions rbgo was missing. Of 31 surfaces ruby/spec gates behind `ruby_version_is "4.0"`, rbgo already answered 28; #846 added `Math.log1p`/`Math.expm1`, and this is `Array#rfind`. Array's own, not Enumerable's: MRI defines find, detect **and** rfind on `rb_cArray` itself (array.c at v4.0.9, lines 8916-8918), which is why `core/array/{find,detect,rfind}_spec.rb` all sit behind the 4.0 guard. ## ⛔ Why `to_a.reverse.find` and `reverse_each` are both wrong `core/array/rfind_spec.rb:81`: ```ruby ary = [4, 2, 1, 5, 1, 3] seen = [] ary.rfind { |x| seen << x; ary.clear; false } seen.should == [3] ``` **One element, not six.** `rb_ary_rfind` (array.c:2155) re-reads the length after every yield and clamps the index down to it: ```c idx = RARRAY_LEN(ary); while (idx--) { VALUE elem = RARRAY_AREF(ary, idx); if (RTEST(rb_yield(elem))) return elem; len = RARRAY_LEN(ary); idx = (idx >= len) ? len : idx; } ``` so a block that empties the receiver ends the walk immediately. Any snapshot — `reverse_each`, `to_a.reverse`, an index range computed up front — sees all six and **passes every other example in the file**. The implementation here walks the live array by index and carries that clamp, with the C quoted at the site. The clamp only ever lowers the index, so a block that grows the array does not revisit the tail already passed; that is a test too. ## Measured, with the control `core/array/rfind_spec.rb` on a private corpus at the pinned `SPEC_SHA`, shim at 4.0.9 (the whole file is behind the guard, so at 3.4.1 it yields nothing): | | `main` (3a3fb81) | this branch | |---|---|---| | `core/array/rfind_spec.rb` | `pass=1 fail=0 error=15` | **`pass=16 fail=0 error=0`** | ## ⛔ And that `pass=1` on main is a FALSE pass Accounting for it rather than rounding it off turned up something worth knowing. The 15 errors are 14 named `Array#rfind` examples plus the shared `:enumeratorized_with_unknown_size` block. The sixteenth — the one that "passes" without the method existing at all — is: ```ruby it "raises a NoMethodError if the ifnone argument does not respond to #call and no element is found" do -> { [1, 2, 3].rfind(42) { |x| false } }.should.raise(NoMethodError) end ``` On `main`, `[1,2,3].rfind(42)` raises NoMethodError **because `rfind` is undefined**, and the expectation is satisfied by the wrong exception. So the gate's total rises by 15, and the sixteenth example — which it already counted — becomes a pass for the right reason. One of the 16 was never conformance. This shape is not unique to rfind: `language/`+`core/` contain **150 `should.raise(NoMethodError)` expectations across 72 files** (an upper bound on candidates, not a count — most of them name a method that does exist and assert NoMethodError for another reason, such as a private call). Filed separately, because it means `BASELINE` can record passes that come from absence, and the only way to tell is to ask whether the receiver answers the method the describe block names. ## No regression - Full `internal/vm` suite green (237s, `-count=1`). - `core/array/find_spec.rb` **16/16** and `core/array/detect_spec.rb` **1/1** on `main` *and* on this branch — rbgo's Enumerable#find already satisfies Array's 4.0 specs for those, so they are 17 examples the version move will reveal as passing with no work at all. - `core/module/refine_spec.rb` is the only spec in `language/`+`core/` that reads `Array.instance_methods`, and it compares that list with the same list seen from inside a refinement rather than against a fixed count — so a new method changes both sides. Measured anyway: `pass=30 fail=5 error=3` on both binaries, identical. `gofmt` clean. The frozen prelude is regenerated (`go generate ./internal/vm`), and `prelude_frozen_test.go` is what would have caught forgetting to. ⚠ Note for whoever runs that generate next: it also runs `cmd/aotgen`, whose output is **not** gofmt-clean, so `go generate ./internal/vm` leaves `internal/vm/aot_e2e_gen_test.go` modified with pure formatting churn. The committed form is generated-then-gofmt'd; `gofmt -w` on it restores the committed bytes exactly. Not touched here, so this diff is prelude only. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
vm: Math.log1p and Math.expm1, the two functions Ruby 4.0 added to Math
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 alreadyanswers 28. The three it does not are
Array#rfind,Math.log1pandMath.expm1. These are the two pure ones.Measured, with the control
core/math/log1p_spec.rbandcore/math/expm1_spec.rb, run on a private corpusat the pinned
SPEC_SHAwith the shim'sCUR_VERSIONset to 4.0.9 (the wholeof both files sits behind a 4.0 guard, so at 3.4.1 they yield nothing at all):
main(3a3fb81)core/math/log1p_spec.rbpass=0 fail=5 error=5pass=10 fail=0 error=0core/math/expm1_spec.rbpass=0 fail=2 error=5pass=7 fail=0 error=0All 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_VERSIONmoves (#845, #799) — thesame 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)islog(1+x)andexpm1(x)isexp(x)-1, computed so the resultkeeps 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:
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 thatspells out the identity passes the first example of each file and fails the
second. Go's
math.Log1p/math.Expm1are the same primitives C hands MRI; thetests 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 valueand not an error — so
Math::DomainErrorbelongs to x < -1, which is what thespec exercises with
-1 - 1e-15.⛔ A pre-existing divergence these two do NOT inherit
core/math/log1p_spec.rb:27assertsMath.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:The extra argument is silently dropped, for every function the
unaryhelperinstalls. That is not fixed here, and the asymmetry is deliberate rather than
an oversight: the arities are not uniform (
Math.loglegitimately takes one ORtwo arguments,
atan2/hypot/ldexptake two), so a blanket check is wrong anda 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.
unaryStrictcarries the check, the older helpers are leftas 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/vmsuite passes (230s,-count=1). The only spec inlanguage/+core/that touches Math's method list iscore/math/shared/atanh.rb:32, and it asserts inclusion, not a count — twomore private instance methods cannot move it.
gofmtclean,go vetclean. No new input is parsed; both functions are pureand allocate one Float.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
🤖 Generated with Claude Code