fixed some issues with Decimals

This commit is contained in:
sergeych committed 2026-03-29 00:39:48 +03:00
1 parent e55d9c835a
commit c7c333b71a
11 files changed
+385 -73

No files matched your search

@@ -252,6 +252,44 @@ class Script(
companion object {
private suspend fun ScopeFacade.numberToDouble(value: Obj): Double =
ObjBigDecimalSupport.toDoubleOrNull(value) ?: value.toDouble()
private suspend fun ScopeFacade.decimalAwareUnaryMath(
value: Obj,
exactDecimal: (suspend ScopeFacade.(Obj) -> Obj?)? = null,
fallback: (Double) -> Double
): Obj {
exactDecimal?.let { exact ->
exact(value)?.let { return it }
}
if (ObjBigDecimalSupport.isDecimalValue(value)) {
return ObjBigDecimalSupport.fromRealLike(this, value, fallback(numberToDouble(value)))
?: raiseIllegalState("failed to convert Real result back to BigDecimal")
}
return ObjReal(fallback(numberToDouble(value)))
}
private suspend fun ScopeFacade.decimalAwareRoundLike(
value: Obj,
exactDecimal: suspend ScopeFacade.(Obj) -> Obj?,
realFallback: (Double) -> Double
): Obj {
if (value is ObjInt) return value
exactDecimal(value)?.let { return it }
return ObjReal(realFallback(numberToDouble(value)))
}
private suspend fun ScopeFacade.decimalAwarePow(base: Obj, exponent: Obj): Obj {
ObjBigDecimalSupport.exactPow(this, base, exponent)?.let { return it }
if (ObjBigDecimalSupport.isDecimalValue(base) || ObjBigDecimalSupport.isDecimalValue(exponent)) {
return ObjBigDecimalSupport.fromRealLike(this, base, numberToDouble(base).pow(numberToDouble(exponent)))
?: ObjBigDecimalSupport.fromRealLike(this, exponent, numberToDouble(base).pow(numberToDouble(exponent)))
?: raiseIllegalState("failed to convert Real pow result back to BigDecimal")
}
return ObjReal(numberToDouble(base).pow(numberToDouble(exponent)))
}
/**
* Create new scope using a standard safe set of modules, using [defaultImportManager]. It is
* suspended as first time invocation requires compilation of standard library or other
@@ -289,87 +327,81 @@ class Script(
}
addFn("floor") {
val x = args.firstAndOnly()
(if (x is ObjInt) x
else ObjReal(floor(x.toDouble())))
decimalAwareRoundLike(x, ObjBigDecimalSupport::exactFloor, ::floor)
}
addFn("ceil") {
val x = args.firstAndOnly()
(if (x is ObjInt) x
else ObjReal(ceil(x.toDouble())))
decimalAwareRoundLike(x, ObjBigDecimalSupport::exactCeil, ::ceil)
}
addFn("round") {
val x = args.firstAndOnly()
(if (x is ObjInt) x
else ObjReal(round(x.toDouble())))
decimalAwareRoundLike(x, ObjBigDecimalSupport::exactRound, ::round)
}
addFn("sin") {
ObjReal(sin(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::sin)
}
addFn("cos") {
ObjReal(cos(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::cos)
}
addFn("tan") {
ObjReal(tan(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::tan)
}
addFn("asin") {
ObjReal(asin(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::asin)
}
addFn("acos") {
ObjReal(acos(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::acos)
}
addFn("atan") {
ObjReal(atan(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::atan)
}
addFn("sinh") {
ObjReal(sinh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::sinh)
}
addFn("cosh") {
ObjReal(cosh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::cosh)
}
addFn("tanh") {
ObjReal(tanh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::tanh)
}
addFn("asinh") {
ObjReal(asinh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::asinh)
}
addFn("acosh") {
ObjReal(acosh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::acosh)
}
addFn("atanh") {
ObjReal(atanh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::atanh)
}
addFn("exp") {
ObjReal(exp(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::exp)
}
addFn("ln") {
ObjReal(ln(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::ln)
}
addFn("log10") {
ObjReal(log10(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::log10)
}
addFn("log2") {
ObjReal(log2(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::log2)
}
addFn("pow") {
requireExactCount(2)
ObjReal(
(args[0].toDouble()).pow(args[1].toDouble())
)
decimalAwarePow(args[0], args[1])
}
addFn("sqrt") {
ObjReal(
sqrt(args.firstAndOnly().toDouble())
)
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::sqrt)
}
addFn("abs") {
val x = args.firstAndOnly()
if (x is ObjInt) ObjInt(x.value.absoluteValue) else ObjReal(x.toDouble().absoluteValue)
if (x is ObjInt) ObjInt(x.value.absoluteValue)
else decimalAwareUnaryMath(x, ObjBigDecimalSupport::exactAbs) { it.absoluteValue }
}
addFnDoc(
@@ -629,78 +661,75 @@ class Script(
)
ensureFn("floor") {
val x = args.firstAndOnly()
if (x is ObjInt) x else ObjReal(floor(x.toDouble()))
decimalAwareRoundLike(x, ObjBigDecimalSupport::exactFloor, ::floor)
}
ensureFn("ceil") {
val x = args.firstAndOnly()
if (x is ObjInt) x else ObjReal(ceil(x.toDouble()))
decimalAwareRoundLike(x, ObjBigDecimalSupport::exactCeil, ::ceil)
}
ensureFn("round") {
val x = args.firstAndOnly()
if (x is ObjInt) x else ObjReal(round(x.toDouble()))
decimalAwareRoundLike(x, ObjBigDecimalSupport::exactRound, ::round)
}
ensureFn("sin") {
ObjReal(sin(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::sin)
}
ensureFn("cos") {
ObjReal(cos(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::cos)
}
ensureFn("tan") {
ObjReal(tan(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::tan)
}
ensureFn("asin") {
ObjReal(asin(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::asin)
}
ensureFn("acos") {
ObjReal(acos(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::acos)
}
ensureFn("atan") {
ObjReal(atan(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::atan)
}
ensureFn("sinh") {
ObjReal(sinh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::sinh)
}
ensureFn("cosh") {
ObjReal(cosh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::cosh)
}
ensureFn("tanh") {
ObjReal(tanh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::tanh)
}
ensureFn("asinh") {
ObjReal(asinh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::asinh)
}
ensureFn("acosh") {
ObjReal(acosh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::acosh)
}
ensureFn("atanh") {
ObjReal(atanh(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::atanh)
}
ensureFn("exp") {
ObjReal(exp(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::exp)
}
ensureFn("ln") {
ObjReal(ln(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::ln)
}
ensureFn("log10") {
ObjReal(log10(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::log10)
}
ensureFn("log2") {
ObjReal(log2(args.firstAndOnly().toDouble()))
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::log2)
}
ensureFn("pow") {
requireExactCount(2)
ObjReal(
(args[0].toDouble()).pow(args[1].toDouble())
)
decimalAwarePow(args[0], args[1])
}
ensureFn("sqrt") {
ObjReal(
sqrt(args.firstAndOnly().toDouble())
)
decimalAwareUnaryMath(args.firstAndOnly(), fallback = ::sqrt)
}
ensureFn("abs") {
val x = args.firstAndOnly()
if (x is ObjInt) ObjInt(x.value.absoluteValue) else ObjReal(x.toDouble().absoluteValue)
if (x is ObjInt) ObjInt(x.value.absoluteValue)
else decimalAwareUnaryMath(x, ObjBigDecimalSupport::exactAbs) { it.absoluteValue }
}
}
}
@@ -448,37 +448,37 @@ private fun buildStdlibDocs(): List<MiniDecl> {
// Math helpers (scalar versions)
fun math1(name: String) = mod.funDoc(
name = name,
doc = StdlibInlineDocIndex.topFunDoc(name) ?: "Compute $name(x).",
doc = StdlibInlineDocIndex.topFunDoc(name) ?: "Compute $name(x). Accepts Int, Real, and BigDecimal. BigDecimal currently uses a temporary Real bridge and will get native decimal implementations later.",
params = listOf(ParamDoc("x", type("lyng.Number")))
)
math1("sin"); math1("cos"); math1("tan"); math1("asin"); math1("acos"); math1("atan")
mod.funDoc(name = "floor", doc = StdlibInlineDocIndex.topFunDoc("floor") ?: "Round down the number to the nearest integer.", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "ceil", doc = StdlibInlineDocIndex.topFunDoc("ceil") ?: "Round up the number to the nearest integer.", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "round", doc = StdlibInlineDocIndex.topFunDoc("round") ?: "Round the number to the nearest integer.", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "floor", doc = StdlibInlineDocIndex.topFunDoc("floor") ?: "Round down the number to the nearest integer. BigDecimal is handled directly and stays BigDecimal.", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "ceil", doc = StdlibInlineDocIndex.topFunDoc("ceil") ?: "Round up the number to the nearest integer. BigDecimal is handled directly and stays BigDecimal.", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "round", doc = StdlibInlineDocIndex.topFunDoc("round") ?: "Round the number to the nearest integer. BigDecimal is handled directly and stays BigDecimal.", params = listOf(ParamDoc("x", type("lyng.Number"))))
// Hyperbolic and inverse hyperbolic
math1("sinh"); math1("cosh"); math1("tanh"); math1("asinh"); math1("acosh"); math1("atanh")
// Exponentials and logarithms
mod.funDoc(name = "exp", doc = StdlibInlineDocIndex.topFunDoc("exp") ?: "Euler's exponential e^x.", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "ln", doc = StdlibInlineDocIndex.topFunDoc("ln") ?: "Natural logarithm (base e).", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "log10", doc = StdlibInlineDocIndex.topFunDoc("log10") ?: "Logarithm base 10.", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "log2", doc = StdlibInlineDocIndex.topFunDoc("log2") ?: "Logarithm base 2.", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "exp", doc = StdlibInlineDocIndex.topFunDoc("exp") ?: "Euler's exponential e^x. BigDecimal currently uses a temporary Real bridge and will get a native decimal implementation later.", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "ln", doc = StdlibInlineDocIndex.topFunDoc("ln") ?: "Natural logarithm (base e). BigDecimal currently uses a temporary Real bridge and will get a native decimal implementation later.", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "log10", doc = StdlibInlineDocIndex.topFunDoc("log10") ?: "Logarithm base 10. BigDecimal currently uses a temporary Real bridge and will get a native decimal implementation later.", params = listOf(ParamDoc("x", type("lyng.Number"))))
mod.funDoc(name = "log2", doc = StdlibInlineDocIndex.topFunDoc("log2") ?: "Logarithm base 2. BigDecimal currently uses a temporary Real bridge and will get a native decimal implementation later.", params = listOf(ParamDoc("x", type("lyng.Number"))))
// Power/roots and absolute value
mod.funDoc(
name = "pow",
doc = StdlibInlineDocIndex.topFunDoc("pow") ?: "Raise `x` to the power `y`.",
doc = StdlibInlineDocIndex.topFunDoc("pow") ?: "Raise `x` to the power `y`. BigDecimal with integral `y` is handled directly; other BigDecimal cases currently use a temporary Real bridge.",
params = listOf(ParamDoc("x", type("lyng.Number")), ParamDoc("y", type("lyng.Number")))
)
mod.funDoc(
name = "sqrt",
doc = StdlibInlineDocIndex.topFunDoc("sqrt") ?: "Square root of `x`.",
doc = StdlibInlineDocIndex.topFunDoc("sqrt") ?: "Square root of `x`. BigDecimal currently uses a temporary Real bridge and will get a native decimal implementation later.",
params = listOf(ParamDoc("x", type("lyng.Number")))
)
mod.funDoc(
name = "abs",
doc = StdlibInlineDocIndex.topFunDoc("abs") ?: "Absolute value of a number (works for Int and Real).",
doc = StdlibInlineDocIndex.topFunDoc("abs") ?: "Absolute value of a number. Int stays Int, and BigDecimal stays BigDecimal.",
params = listOf(ParamDoc("x", type("lyng.Number")))
)
@@ -128,6 +128,37 @@ object ObjBigDecimalSupport {
registerInterop(decimalClass)
}
fun isDecimalValue(value: Obj): Boolean =
value is ObjInstance && value.objClass.className == "BigDecimal"
suspend fun exactAbs(scope: ScopeFacade, value: Obj): Obj? =
decimalValueOrNull(value)?.let { scope.newInstanceLikeDecimal(value, it.abs()) }
suspend fun exactFloor(scope: ScopeFacade, value: Obj): Obj? =
decimalValueOrNull(value)?.let { scope.newInstanceLikeDecimal(value, it.floor()) }
suspend fun exactCeil(scope: ScopeFacade, value: Obj): Obj? =
decimalValueOrNull(value)?.let { scope.newInstanceLikeDecimal(value, it.ceil()) }
suspend fun exactRound(scope: ScopeFacade, value: Obj): Obj? =
decimalValueOrNull(value)?.let {
scope.newInstanceLikeDecimal(value, it.roundToDigitPositionAfterDecimalPoint(0, RoundingMode.ROUND_HALF_CEILING))
}
suspend fun exactPow(scope: ScopeFacade, base: Obj, exponent: Obj): Obj? {
val decimal = decimalValueOrNull(base) ?: return null
val intExponent = exponent as? ObjInt ?: return null
return scope.newInstanceLikeDecimal(base, decimal.pow(intExponent.value))
}
suspend fun fromRealLike(scope: ScopeFacade, sample: Obj, value: Double): Obj? {
if (!isDecimalValue(sample)) return null
return scope.newInstanceLikeDecimal(sample, IonBigDecimal.fromDouble(value, realConversionMode))
}
fun toDoubleOrNull(value: Obj): Double? =
decimalValueOrNull(value)?.doubleValue(false)
private fun valueOf(obj: Obj): IonBigDecimal {
val instance = obj as? ObjInstance ?: error("BigDecimal receiver must be an object instance")
return instance.kotlinInstanceData as? IonBigDecimal ?: zero
@@ -160,6 +191,12 @@ object ObjBigDecimalSupport {
return instance
}
private suspend fun ScopeFacade.newInstanceLikeDecimal(sample: Obj, value: IonBigDecimal): ObjInstance {
val decimalClass = (sample as? ObjInstance)?.objClass
?: raiseIllegalState("BigDecimal sample must be an object instance")
return newInstance(decimalClass, value)
}
private fun coerceArg(scope: Scope, value: Obj): IonBigDecimal = when (value) {
is ObjInt -> IonBigDecimal.fromLongAsSignificand(value.value)
is ObjReal -> IonBigDecimal.fromDouble(value.value, realConversionMode)
@@ -248,6 +285,12 @@ object ObjBigDecimalSupport {
}
}
private fun decimalValueOrNull(value: Obj): IonBigDecimal? {
if (!isDecimalValue(value)) return null
val instance = value as ObjInstance
return instance.kotlinInstanceData as? IonBigDecimal ?: zero
}
private fun registerBuiltinConversions(decimalClass: ObjClass) {
ObjInt.type.addPropertyDoc(
name = "d",
@@ -146,4 +146,84 @@ class BigDecimalModuleTest {
assertEquals("0.2", s0.toString())
""".trimIndent())
}
@Test
fun testDecimalMathHelpersUseExactImplementationsWhenAvailable() = runTest {
val scope = Script.newScope()
scope.eval(
"""
import lyng.decimal
val absValue = abs("-2.5".d) as BigDecimal
val floorPos = floor("2.9".d) as BigDecimal
val floorNeg = floor("-2.1".d) as BigDecimal
val ceilPos = ceil("2.1".d) as BigDecimal
val ceilNeg = ceil("-2.1".d) as BigDecimal
val roundPos = round("2.5".d) as BigDecimal
val roundNeg = round("-2.5".d) as BigDecimal
val powInt = pow("1.5".d, 2) as BigDecimal
assertEquals("2.5", absValue.toStringExpanded())
assertEquals("2", floorPos.toStringExpanded())
assertEquals("-3", floorNeg.toStringExpanded())
assertEquals("3", ceilPos.toStringExpanded())
assertEquals("-2", ceilNeg.toStringExpanded())
assertEquals("3", roundPos.toStringExpanded())
assertEquals("-2", roundNeg.toStringExpanded())
assertEquals("2.25", powInt.toStringExpanded())
""".trimIndent()
)
}
@Test
fun testDecimalMathHelpersFallbackThroughRealTemporarily() = runTest {
val scope = Script.newScope()
scope.eval(
"""
import lyng.decimal
val sinDecimal = sin("0.5".d) as BigDecimal
val expDecimal = exp("1.25".d) as BigDecimal
val sqrtDecimal = sqrt("2".d) as BigDecimal
val lnDecimal = ln("2".d) as BigDecimal
val log10Decimal = log10("2".d) as BigDecimal
val log2Decimal = log2("2".d) as BigDecimal
val powDecimal = pow("2".d, "0.5".d) as BigDecimal
assertEquals((sin(0.5) as Real).d.toStringExpanded(), sinDecimal.toStringExpanded())
assertEquals((exp(1.25) as Real).d.toStringExpanded(), expDecimal.toStringExpanded())
assertEquals((sqrt(2.0) as Real).d.toStringExpanded(), sqrtDecimal.toStringExpanded())
assertEquals((ln(2.0) as Real).d.toStringExpanded(), lnDecimal.toStringExpanded())
assertEquals((log10(2.0) as Real).d.toStringExpanded(), log10Decimal.toStringExpanded())
assertEquals((log2(2.0) as Real).d.toStringExpanded(), log2Decimal.toStringExpanded())
assertEquals((pow(2.0, 0.5) as Real).d.toStringExpanded(), powDecimal.toStringExpanded())
""".trimIndent()
)
}
@Test
fun decimalMustBeObj() = runTest {
eval("""
import lyng.decimal
val decimal = 42.d
val context = DecimalContext(12)
assert(decimal is BigDecimal)
assertEquals(BigDecimal, decimal::class)
assert(context is DecimalContext)
assertEquals(DecimalContext, context::class)
""".trimIndent())
}
@Test
fun testFromRealLife1() = runTest {
eval("""
import lyng.decimal
var X = 42.d
X += 11
assertEquals(53.d, X)
""")
}
}
@@ -152,4 +152,20 @@ class MatrixModuleTest {
""".trimIndent()
)
}
@Test
fun matrixAndVectorMustBeObjs() = runTest {
eval("""
import lyng.matrix
val matrixValue = matrix([[1, 2], [3, 4]])
val vectorValue = vector([1, 2, 3])
assert(matrixValue is Matrix)
assertEquals(Matrix, matrixValue::class)
assert(vectorValue is Vector)
assertEquals(Vector, vectorValue::class)
""".trimIndent())
}
}
@@ -0,0 +1,33 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
class RandomModuleTest {
@Test
fun seededRandomMustBeObj() = runTest {
eval("""
val rng = Random.seeded(12345)
assert(rng is SeededRandom)
assertEquals(SeededRandom, rng::class)
""".trimIndent())
}
}