Powers and remainders in the calculator (closes #3) #11

Merged
clawbot merged 4 commits from issue-3-power-modulo into next 2026-09-29 04:28:16 +02:00
5 changed files with 171 additions and 97 deletions
Showing only changes of commit a1125e59dd - Show all commits
+18 -14
View File
@@ -166,20 +166,24 @@ container.
`-4`, `(-2)^2` is `4` and `2^-1` is `0.5`. `%` is the remainder and `-4`, `(-2)^2` is `4` and `2^-1` is `0.5`. `%` is the remainder and
ranks with `*` and `/`; its result takes the sign of the divisor, as ranks with `*` and `/`; its result takes the sign of the divisor, as
in Python, so `7 % 3` is `1`, `-7 % 3` is `2` and `7.5 % 2` is `1.5`. in Python, so `7 % 3` is `1`, `-7 % 3` is `2` and `7.5 % 2` is `1.5`.
A power with a whole exponent is exact, so `0.1^2` is `0.01`, unless A power with a whole exponent is exact, so `0.1^2` is `0.01` and
its numerator and denominator together could pass 4096 bits; that `2^-1400 * 2^1400` is `1`, unless `go/constant` could hold the result
power, and one with a fractional exponent, is computed as a double, so only rounded; that power, and one with a fractional exponent, is
`2^0.5` is `1.4142135623730951`. A negative number to a fractional computed as a double, so `2^0.5` is `1.4142135623730951`. A negative
power is refused, as having no real result. Numbers are read as number to a fractional power is refused, as having no real result.
decimal, so `010` is ten. Input over 256 bytes is refused and exact Numbers are read as decimal, so `010` is ten. Input over 256 bytes is
powers are capped, so a message cannot make the bot do unbounded work. refused and exact powers are capped, so a message cannot make the bot
Whole numbers below 10<sup>21</sup> are written exactly; other results do unbounded work. Whole numbers below 10<sup>21</sup> are written
in the shortest form that reads back as the same double, in exponent exactly; other results in the shortest form that reads back as the
notation from 10<sup>21</sup> up and below 10<sup>-6</sup>. A result same double, in exponent notation from 10<sup>21</sup> up and below
beyond the range of a double is refused as too large, and so is any 10<sup>-6</sup>. Refused as too large or too small: any number whose
number, even a small one such as `1e-1300` or one inside a longer numerator or denominator reaches 4096 bits, wherever it appears, as
expression, whose numerator or denominator reaches 4096 bits: `go/constant` could hold it only rounded (`1e-1300 + 1`); a power
`go/constant` could hold it only rounded. computed as a double whose base or result is outside the normal range
of a double, about 2.2e-308 to 1.8e308 in magnitude, where a double
keeps all its digits (`1e-400^0.5`); and a result other than zero
outside that range, as it is written through a double (`1e400`,
`2^-1400`).
- **Failure is an exit.** If the chat client exits or the connection to - **Failure is an exit.** If the chat client exits or the connection to
it drops, the bot exits with an error and the container's restart it drops, the bot exits with an error and the container's restart
policy starts both again. `SIGTERM` stops the bot, which stops the policy starts both again. `SIGTERM` stops the bot, which stops the
+2 -2
View File
@@ -221,8 +221,8 @@ func Reply(text string) string {
calc.MaxInputLength) calc.MaxInputLength)
case errors.Is(err, calc.ErrDivisionByZero): case errors.Is(err, calc.ErrDivisionByZero):
return "I cannot divide by zero." return "I cannot divide by zero."
case errors.Is(err, calc.ErrTooLarge): case errors.Is(err, calc.ErrOutOfRange):
return "The result is too large for me." return "That needs a number too large or too small for me."
case errors.Is(err, calc.ErrNoRealResult): case errors.Is(err, calc.ErrNoRealResult):
return "A negative number to a fractional power has no real result." return "A negative number to a fractional power has no real result."
default: default:
+2 -1
View File
@@ -27,7 +27,8 @@ func TestReply(t *testing.T) {
for in, want := range map[string]string{ for in, want := range map[string]string{
"hello": "I only understand arithmetic", "hello": "I only understand arithmetic",
"1 / 0": "I cannot divide by zero.", "1 / 0": "I cannot divide by zero.",
"1e400": "The result is too large for me.", "1e400": "That needs a number too large or too small for me.",
"1e-1300": "That needs a number too large or too small for me.",
"(-8)^0.5": "A negative number to a fractional power has no real", "(-8)^0.5": "A negative number to a fractional power has no real",
strings.Repeat("1+", calc.MaxInputLength) + "1": "That is too long for me", strings.Repeat("1+", calc.MaxInputLength) + "1": "That is too long for me",
} { } {
+81 -55
View File
@@ -6,8 +6,8 @@
// go/constant, which does exact rational arithmetic: 5 * 5/2 is exactly // go/constant, which does exact rational arithmetic: 5 * 5/2 is exactly
// 12.5, and 0.1 + 0.2 is exactly 0.3, so a result carries no binary // 12.5, and 0.1 + 0.2 is exactly 0.3, so a result carries no binary
// floating point noise until the moment it is formatted. A power is the // floating point noise until the moment it is formatted. A power is the
// exception: one with a fractional exponent, or too large to compute // exception: one with a fractional exponent, or whose result go/constant
// exactly, is computed in float64. // cannot hold exactly, is computed in float64.
package calc package calc
import ( import (
@@ -21,16 +21,20 @@ import (
"strings" "strings"
) )
// MaxInputLength caps an expression, in bytes, and maxExactPowerBits // MaxInputLength caps an expression, in bytes, and maxExactExponent caps
// caps a power, so that a message cannot make the bot do unbounded work. // a power computed exactly, so that a message cannot make the bot do
// unbounded work.
const MaxInputLength = 256 const MaxInputLength = 256
// maxExactPowerBits caps a power computed exactly: its numerator and // maxExactExponent is the largest exponent, either way, of a power
// denominator together have at most this many bits, estimated before // computed exactly. Past it, x^n has a numerator or denominator of more
// multiplying as the exponent times the bits in the base's numerator and // than 4096 bits, which go/constant holds only rounded, unless x is 0 or
// denominator. A larger power is computed in float64, whose cost does not // 1, and float64 computes those exactly.
// grow with it. const maxExactExponent = 4096
const maxExactPowerBits = 4096
// smallestNormal is the smallest positive normal double, about 2.2e-308.
// Below it a double keeps fewer digits, down to one.
const smallestNormal = 0x1p-1022
// Results of magnitude plainUpper or more are written in exponent form // Results of magnitude plainUpper or more are written in exponent form
// (1e+21 rather than twenty-two digits), and so are fractions smaller // (1e+21 rather than twenty-two digits), and so are fractions smaller
@@ -53,7 +57,7 @@ var (
ErrTooLong = errors.New("expression too long") ErrTooLong = errors.New("expression too long")
ErrNotArithmetic = errors.New("not an arithmetic expression") ErrNotArithmetic = errors.New("not an arithmetic expression")
ErrDivisionByZero = errors.New("division by zero") ErrDivisionByZero = errors.New("division by zero")
ErrTooLarge = errors.New("result too large") ErrOutOfRange = errors.New("number too large or too small")
ErrNoRealResult = errors.New("no real result") ErrNoRealResult = errors.New("no real result")
) )
@@ -245,10 +249,16 @@ func number(tok string) (constant.Value, error) {
// leading zero would make it octal. // leading zero would make it octal.
v := constant.MakeFromLiteral(tok, token.FLOAT, 0) v := constant.MakeFromLiteral(tok, token.FLOAT, 0)
// The syntax was checked above, so Unknown here means the exponent // A literal such as 1e1300 or 1e-1300 is held rounded: see exact.
// overflowed. A literal such as 1e1300 is held rounded: see rounded. if !exact(v) {
if v.Kind() == constant.Unknown || rounded(v) { return nil, ErrOutOfRange
return nil, ErrTooLarge }
// One too small even to be held rounded, such as 1e-999999999, is
// read as 0.
mantissa, _, _ := strings.Cut(strings.ToLower(tok), "e")
if constant.Sign(v) == 0 && strings.ContainsAny(mantissa, "123456789") {
return nil, ErrOutOfRange
} }
return v, nil return v, nil
@@ -282,10 +292,8 @@ func apply(x constant.Value, op string, y constant.Value) (constant.Value, error
return nil, err return nil, err
} }
// go/constant represents an overflow to infinity as Unknown. A if !exact(v) {
// rounded number is refused too: see rounded. return nil, ErrOutOfRange
if v.Kind() == constant.Unknown || rounded(v) {
return nil, ErrTooLarge
} }
return v, nil return v, nil
@@ -312,8 +320,8 @@ func modulo(x, y constant.Value) (constant.Value, error) {
// The fractional part of a rounded quotient, and so the remainder, // The fractional part of a rounded quotient, and so the remainder,
// would be wrong. // would be wrong.
if rounded(q) { if !exact(q) {
return nil, ErrTooLarge return nil, ErrOutOfRange
} }
// x % y is y times the fractional part of x/y, which is at least 0 // x % y is y times the fractional part of x/y, which is at least 0
@@ -345,13 +353,16 @@ func power(x, y constant.Value) (constant.Value, error) {
case constant.Sign(x) == 0 && constant.Sign(y) < 0: case constant.Sign(x) == 0 && constant.Sign(y) < 0:
return nil, ErrDivisionByZero return nil, ErrDivisionByZero
case constant.Sign(x) >= 0: case constant.Sign(x) >= 0:
return nonNegativePower(x, y, n), nil return nonNegativePower(x, y, n)
case n.Kind() != constant.Int: case n.Kind() != constant.Int:
return nil, ErrNoRealResult return nil, ErrNoRealResult
} }
// x is negative and n whole: x^n is (-x)^n, negated if n is odd. // x is negative and n whole: x^n is (-x)^n, negated if n is odd.
v := nonNegativePower(constant.UnaryOp(token.SUB, x, 0), y, n) v, err := nonNegativePower(constant.UnaryOp(token.SUB, x, 0), y, n)
if err != nil {
return nil, err
}
odd := constant.BinaryOp(n, token.AND, constant.MakeInt64(1)) odd := constant.BinaryOp(n, token.AND, constant.MakeInt64(1))
if constant.Sign(odd) != 0 { if constant.Sign(odd) != 0 {
@@ -362,36 +373,38 @@ func power(x, y constant.Value) (constant.Value, error) {
} }
// nonNegativePower computes x^y for x of at least zero, and y not below // nonNegativePower computes x^y for x of at least zero, and y not below
// zero if x is zero: exactly if y is a whole number n and the result // zero if x is zero: exactly if y is a whole number n and go/constant
// fits in maxExactPowerBits, otherwise in float64. // holds the result exactly, otherwise in float64.
func nonNegativePower(x, y, n constant.Value) constant.Value { func nonNegativePower(x, y, n constant.Value) (constant.Value, error) {
e, ok := constant.Int64Val(n) e, ok := constant.Int64Val(n)
if ok && exactPowerFits(x, e) { if ok && -maxExactExponent <= e && e <= maxExactExponent {
return exactPower(x, e) v := exactPower(x, e)
if exact(v) {
return v, nil
}
}
// y is above zero here if x is zero.
if constant.Sign(x) == 0 {
return x, nil
} }
xf, _ := constant.Float64Val(x) xf, _ := constant.Float64Val(x)
yf, _ := constant.Float64Val(y) yf, _ := constant.Float64Val(y)
f := math.Pow(xf, yf)
// An infinite result becomes Unknown, which apply refuses as too // Neither x nor x^y is zero. If either is not a normal double, it
// large. // has lost digits, or all of them.
return constant.MakeFloat64(math.Pow(xf, yf)) if !normal(xf) || !normal(f) {
return nil, ErrOutOfRange
} }
// exactPowerFits reports whether x^e fits in maxExactPowerBits. return constant.MakeFloat64(f), nil
func exactPowerFits(x constant.Value, e int64) bool {
// Checked first so that the product below cannot overflow.
if e < -maxExactPowerBits || e > maxExactPowerBits {
return false
}
bits := int64(constant.BitLen(constant.Num(x)) + constant.BitLen(constant.Denom(x)))
return bits*max(e, -e) <= maxExactPowerBits
} }
// exactPower computes x^e by repeated squaring. x is not zero if e is // exactPower computes x^e by repeated squaring. x is not zero if e is
// negative. // negative. Each step's numbers stay small: go/constant holds one whose
// numerator or denominator reaches 4096 bits as a 512-bit float.
func exactPower(x constant.Value, e int64) constant.Value { func exactPower(x constant.Value, e int64) constant.Value {
result := constant.MakeInt64(1) result := constant.MakeInt64(1)
@@ -410,28 +423,41 @@ func exactPower(x constant.Value, e int64) constant.Value {
return result return result
} }
// rounded reports whether go/constant holds v rounded. It holds a // exact reports whether go/constant holds v exactly. It holds a number
// number exactly, as a fraction, only while the numerator and the // as a fraction until its numerator or denominator reaches 4096 bits,
// denominator each stay under 4096 bits; past that, and for a literal of // then as a 512-bit float, and past that float's range as Unknown. A
// that size, it holds a 512-bit float. Such a number is refused as too // number not held exactly is refused wherever it appears: a sum can lose
// large wherever it appears: a sum can lose the answer entirely // the answer entirely (7^1000*7^1000 + 5 - 7^1000*7^1000 would be 0), and
// (7^1000*7^1000 + 5 - 7^1000*7^1000 would be 0), and a remainder, or // a remainder, or whether an exponent is whole or odd, cannot be read
// whether an exponent is whole or odd, cannot be read from one. // from one.
func rounded(v constant.Value) bool { func exact(v constant.Value) bool {
_, isFloat := constant.Val(v).(*big.Float) switch constant.Val(v).(type) {
case int64, *big.Int, *big.Rat:
return true
default:
return false
}
}
return isFloat // normal reports whether f is a normal double, finite and at least
// smallestNormal in magnitude: a number other than zero keeps all of a
// double's digits only as one.
func normal(f float64) bool {
abs := math.Abs(f)
return abs >= smallestNormal && abs <= math.MaxFloat64
} }
// format writes a result for a person to read. A whole number of // format writes a result for a person to read. A whole number of
// ordinary size is written exactly, digit for digit; anything else goes // ordinary size is written exactly, digit for digit; anything else goes
// through float64, whose shortest round-trip form is free of the noise // through float64, whose shortest round-trip form is free of the noise
// (0.30000000000000004) that printing a binary fraction to a fixed // (0.30000000000000004) that printing a binary fraction to a fixed
// precision produces. // precision produces. A result that is not zero must therefore be a
// normal double: 2^-1074 would be written 5e-324.
func format(v constant.Value) (string, error) { func format(v constant.Value) (string, error) {
f, _ := constant.Float64Val(v) f, _ := constant.Float64Val(v)
if math.IsInf(f, 0) || math.IsNaN(f) { if constant.Sign(v) != 0 && !normal(f) {
return "", ErrTooLarge return "", ErrOutOfRange
} }
abs := math.Abs(f) abs := math.Abs(f)
+65 -22
View File
@@ -90,9 +90,15 @@ func TestEvaluatePowers(t *testing.T) {
"4^0.5": "2", "4^0.5": "2",
"0^0.5": "0", "0^0.5": "0",
"2^1023": "8.98846567431158e+307", "2^1023": "8.98846567431158e+307",
"2^-1022": "2.2250738585072014e-308",
// Past 2^53 a float64 cannot tell odd from even. // Past 2^53 a float64 cannot tell odd from even.
"(-1)^(2^53 + 1)": "-1", "(-1)^(2^53 + 1)": "-1",
"(-1)^(10^30)": "1", "(-1)^(10^30)": "1",
"(-1)^-9223372036854775808": "1",
// Whole powers beyond the range of a double, held exactly.
"2^-1400 * 2^1365 * 2^35": "1",
"0.3^900 * 10^470": "0.25652473503365386",
"2^1500 / 2^1000": "3.273390607896142e+150",
}) })
} }
@@ -148,11 +154,11 @@ func expectResults(t *testing.T, cases map[string]string) {
} }
// TestEvaluateRefuses covers what must be answered with an error rather // TestEvaluateRefuses covers what must be answered with an error rather
// than a number, and never with a panic. // than a number.
func TestEvaluateRefuses(t *testing.T) { func TestEvaluateRefuses(t *testing.T) {
t.Parallel() t.Parallel()
cases := map[string]error{ expectErrors(t, map[string]error{
"": calc.ErrNotArithmetic, "": calc.ErrNotArithmetic,
" ": calc.ErrNotArithmetic, " ": calc.ErrNotArithmetic,
"hello": calc.ErrNotArithmetic, "hello": calc.ErrNotArithmetic,
@@ -193,26 +199,61 @@ func TestEvaluateRefuses(t *testing.T) {
"(-2)^0.5": calc.ErrNoRealResult, "(-2)^0.5": calc.ErrNoRealResult,
"(-8)^(1/3)": calc.ErrNoRealResult, "(-8)^(1/3)": calc.ErrNoRealResult,
"(-1)^-0.5": calc.ErrNoRealResult, "(-1)^-0.5": calc.ErrNoRealResult,
"1e400": calc.ErrTooLarge, })
"1e300 * 1e300": calc.ErrTooLarge, }
"1e999999999 * 1e999999999": calc.ErrTooLarge,
"1 / 1e-400": calc.ErrTooLarge, // TestEvaluateOutOfRange: a number is held exactly, or computed in
"2^1024": calc.ErrTooLarge, // float64 as a normal double, and a result is written as a normal
"2^5000": calc.ErrTooLarge, // double. Anything else is refused.
"(-2)^5001": calc.ErrTooLarge, func TestEvaluateOutOfRange(t *testing.T) {
"0.5^-5000": calc.ErrTooLarge, t.Parallel()
expectErrors(t, map[string]error{
// Results that are not normal doubles: 2^-1074 would be written
// 5e-324.
"1e400": calc.ErrOutOfRange,
"1e300 * 1e300": calc.ErrOutOfRange,
"1e999999999 * 1e999999999": calc.ErrOutOfRange,
"1 / 1e-400": calc.ErrOutOfRange,
"2^1024": calc.ErrOutOfRange,
"2^5000": calc.ErrOutOfRange,
"(-2)^5001": calc.ErrOutOfRange,
"0.5^-5000": calc.ErrOutOfRange,
"2^-1074": calc.ErrOutOfRange,
"2^-1400": calc.ErrOutOfRange,
"-1e-310": calc.ErrOutOfRange,
// Powers computed in float64 whose base or result is not a
// normal double, and so has lost digits, or all of them.
"2^-1073.5 * 2^1073": calc.ErrOutOfRange,
"1e400^-0.001": calc.ErrOutOfRange,
"1e-400^0.001": calc.ErrOutOfRange,
"1e-310^0.5": calc.ErrOutOfRange,
"(0.5^1100)^4 / (0.5^1100)^4": calc.ErrOutOfRange,
"(1/3)^1e400": calc.ErrOutOfRange,
// go/constant holds numbers of this size rounded. A sum of them // go/constant holds numbers of this size rounded. A sum of them
// can lose the answer (this one would be 0), and so can a // can lose the answer (this one would be 0), and so can a
// remainder or the sign of -1 to such a power. // remainder or the sign of -1 to such a power.
"7^1000 * 7^1000 + 5 - 7^1000 * 7^1000": calc.ErrTooLarge, "7^1000 * 7^1000 + 5 - 7^1000 * 7^1000": calc.ErrOutOfRange,
"7^1000 * 7^1000 / 7^1000 % 10": calc.ErrTooLarge, "7^1000 * 7^1000 / 7^1000 % 10": calc.ErrOutOfRange,
"(-1)^(3^1365 * 3^1365 / 3^1365)": calc.ErrTooLarge, "(-1)^(3^1365 * 3^1365 / 3^1365)": calc.ErrOutOfRange,
"(-1)^1e1300": calc.ErrTooLarge, "(-1)^1e1300": calc.ErrOutOfRange,
"1e-1300": calc.ErrTooLarge, "1e-1300": calc.ErrOutOfRange,
"1e-1300 + 1": calc.ErrOutOfRange,
"1e-700 * 1e-700": calc.ErrOutOfRange,
"0.1^800 * 0.1^800": calc.ErrOutOfRange,
// Both operands are held exactly, but their quotient is not. // Both operands are held exactly, but their quotient is not.
"3^1365 % 7^-1000": calc.ErrTooLarge, "3^1365 % 7^-1000": calc.ErrOutOfRange,
// go/constant reads this literal as 0.
"1e-999999999": calc.ErrOutOfRange,
"1 / 1e-999999999": calc.ErrOutOfRange,
})
} }
// expectErrors checks that each expression is refused with its error,
// and never with a panic.
func expectErrors(t *testing.T, cases map[string]error) {
t.Helper()
for in, want := range cases { for in, want := range cases {
t.Run(in, func(t *testing.T) { t.Run(in, func(t *testing.T) {
t.Parallel() t.Parallel()
@@ -236,15 +277,17 @@ func TestEvaluateBoundsWork(t *testing.T) {
want string want string
err error err error
}{ }{
{in: "9^9^9^9^9", err: calc.ErrTooLarge}, {in: "9^9^9^9^9", err: calc.ErrOutOfRange},
{in: "((9^999)^999)^999", err: calc.ErrTooLarge}, {in: "((9^999)^999)^999", err: calc.ErrOutOfRange},
{in: "(3^2583)^4096", err: calc.ErrOutOfRange},
{in: "1.0000001^99999", want: "1.01005006557947"}, {in: "1.0000001^99999", want: "1.01005006557947"},
{in: "0.5^99999999999999999999", want: "0"}, {in: "0.5^99999999999999999999", err: calc.ErrOutOfRange},
{in: "2^-9223372036854775808", err: calc.ErrOutOfRange},
{in: "(-1)^99999999999999999999", want: "-1"}, {in: "(-1)^99999999999999999999", want: "-1"},
// The longest tower that fits. // The longest tower that fits.
{in: strings.Repeat("9^", 127) + "9", err: calc.ErrTooLarge}, {in: strings.Repeat("9^", 127) + "9", err: calc.ErrOutOfRange},
// The largest power computed exactly, as often as fits. // The largest power of 3 computed exactly, as often as fits.
{in: "0" + strings.Repeat("*3^1365", 36), want: "0"}, {in: "0" + strings.Repeat("*3^2583", 36), want: "0"},
} }
for _, c := range cases { for _, c := range cases {