Skip to content

vm: Math.log1p and Math.expm1, the two functions Ruby 4.0 added to Math - #846

Merged
tannevaled merged 2 commits into
mainfrom
math-log1p-expm1
Nov 1, 2026
Merged

tannevaled merged 2 commits into
mainfrom
math-log1p-expm1

Conversation

@tannevaled

Copy link
Copy Markdown
Contributor

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 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

🤖 Generated with Claude Code

tannevaled and others added 2 commits October 31, 2026 22:36
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>
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>
@tannevaled
tannevaled merged commit 25d54e3 into main Nov 1, 2026
25 checks passed
@tannevaled
tannevaled deleted the math-log1p-expm1 branch November 1, 2026 01:18
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.

1 participant