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..1a1010d1 100644 --- a/internal/vm/math_test.go +++ b/internal/vm/math_test.go @@ -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) + } + } +}