Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions internal/vm/math.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 80 additions & 0 deletions internal/vm/math_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,83 @@ 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"},
// ⛔ 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.
{"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)
}
}
}
Loading