Write exact results past the range of a double (closes #16) #18

Merged
clawbot merged 3 commits from issue-16-exact-results-past-float64 into next 2026-09-29 09:56:56 +02:00
5 changed files with 90 additions and 35 deletions
Showing only changes of commit c4e4041cd9 - Show all commits
+14 -10
View File
@@ -335,16 +335,20 @@ container.
refused, exact powers are capped, and every number is held as a refused, exact powers are capped, and every number is held as a
fraction, whole numbers too, under the 4096-bit limit below, so a fraction, whole numbers too, under the 4096-bit limit below, so a
message cannot make the bot do unbounded work. Whole numbers below message cannot make the bot do unbounded work. Whole numbers below
10<sup>21</sup> are written exactly; other results in the shortest 10<sup>21</sup> are written exactly. Other results inside the normal
form that reads back as the same double, in exponent notation from range of a double, about 2.2e-308 to 1.8e308 in magnitude, where a
10<sup>21</sup> up and below 10<sup>-6</sup>. Refused as too large or double keeps all its digits, are written in the shortest form that
too small: any number whose numerator or denominator reaches 4096 reads back as the same double, in exponent notation from
bits, wherever it appears, as `go/constant` rounds a fraction that 10<sup>21</sup> up and below 10<sup>-6</sup>. Results outside that
grows that large (`1e-1300 + 1`); a power computed as a double whose range are written from their exact value in exponent notation, rounded
base or result is outside the normal range of a double, about 2.2e-308 to 17 significant digits, the most the shortest form of a double
to 1.8e308 in magnitude, where a double keeps all its digits takes, with trailing zeros dropped: `2^1200` is
(`1e-400^0.5`); and a result other than zero outside that range, as it `1.7218479456385751e+361`, `10^400` is `1e+400` and `2^-1074` is
is written through a double (`1e400`, `2^-1400`). `4.9406564584124654e-324`. Refused as too large or too small: any
number whose numerator or denominator reaches 4096 bits, wherever it
appears, as `go/constant` rounds a fraction that grows that large
(`2^4095`, `1e-1300 + 1`); and a power computed as a double whose base
or result is outside the normal range of a double (`1e-400^0.5`).
- **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
View File
@@ -27,6 +27,8 @@ with no deprecation warning.
# Completed Steps # Completed Steps
- 2026-09-29 Exact results past the range of a double, such as `2^1200`,
are written to 17 significant digits instead of being refused
- 2026-09-29 `GET` and `POST /api/v1/chats/{id}/messages`: a chat's - 2026-09-29 `GET` and `POST /api/v1/chats/{id}/messages`: a chat's
latest messages, and sending one latest messages, and sending one
- 2026-09-29 Added the HTTP API in `internal/api`: the server on `PORT`, - 2026-09-29 Added the HTTP API in `internal/api`: the server on `PORT`,
+2 -1
View File
@@ -18,6 +18,7 @@ func TestReply(t *testing.T) {
"5 * 5/2": "12.5", "5 * 5/2": "12.5",
"2^10": "1024", "2^10": "1024",
"7 % 3": "1", "7 % 3": "1",
"2^1200": "1.7218479456385751e+361",
} { } {
if got := bot.Reply(in); got != want { if got := bot.Reply(in); got != want {
t.Errorf("Reply(%q) = %q, want %q", in, got, want) t.Errorf("Reply(%q) = %q, want %q", in, got, want)
@@ -27,7 +28,7 @@ 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": "That needs a number too large or too small for me.", "1e1300": "That needs a number too large or too small for me.",
"1e-1300": "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",
+27 -13
View File
@@ -48,6 +48,16 @@ const (
plainLower = 1e-6 plainLower = 1e-6
) )
// A result past the normal range of a double is written to
// significantDigits significant digits, the most the shortest form of a
// double takes. It is rounded to them from a float of floatPrecision
// bits, the bits a numerator or denominator can hold and 64 more for the
// digits, so that the float rounds as the exact result would.
const (
significantDigits = 17
floatPrecision = bitLimit + 64
)
// The precedence of the binary operators: the higher, the tighter the // The precedence of the binary operators: the higher, the tighter the
// operator binds. // operator binds.
const ( const (
@@ -82,8 +92,7 @@ var (
) )
// Evaluate computes an arithmetic expression and returns its result as // Evaluate computes an arithmetic expression and returns its result as
// text: whole numbers without a decimal point, fractions in the // text, written as format describes.
// shortest form that reads back as the same float64.
func Evaluate(input string) (string, error) { func Evaluate(input string) (string, error) {
s := strings.TrimSpace(input) s := strings.TrimSpace(input)
if len(s) > MaxInputLength { if len(s) > MaxInputLength {
@@ -106,7 +115,7 @@ func Evaluate(input string) (string, error) {
return "", ErrNotArithmetic return "", ErrNotArithmetic
} }
return format(v) return format(v), nil
} }
// tokenize splits an expression into operators, parentheses and // tokenize splits an expression into operators, parentheses and
@@ -459,26 +468,31 @@ func normal(f float64) bool {
} }
// 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. Any other result in
// through float64, whose shortest round-trip form is free of the noise // the normal range of a double goes through float64, whose shortest
// (0.30000000000000004) that printing a binary fraction to a fixed // round-trip form is free of the noise (0.30000000000000004) that
// precision produces. A result that is not zero must therefore be a // printing a binary fraction to a fixed precision produces. Past that
// normal double: 2^-1074 would be written 5e-324. // range a double keeps fewer digits, or none (2^-1074 would be written
func format(v constant.Value) (string, error) { // 5e-324, and 2^1024 is infinite), so such a result is written from its
// exact value, to significantDigits.
func format(v constant.Value) string {
f, _ := constant.Float64Val(v) f, _ := constant.Float64Val(v)
if constant.Sign(v) != 0 && !normal(f) { if constant.Sign(v) != 0 && !normal(f) {
return "", ErrOutOfRange // Every number here is exact: see exact.
r, _ := constant.Val(v).(*big.Rat)
return new(big.Float).SetPrec(floatPrecision).SetRat(r).Text('g', significantDigits)
} }
abs := math.Abs(f) abs := math.Abs(f)
if i := constant.ToInt(v); i.Kind() == constant.Int && abs < plainUpper { if i := constant.ToInt(v); i.Kind() == constant.Int && abs < plainUpper {
return i.ExactString(), nil return i.ExactString()
} }
if abs >= plainUpper || abs < plainLower { if abs >= plainUpper || abs < plainLower {
return strconv.FormatFloat(f, 'g', -1, 64), nil return strconv.FormatFloat(f, 'g', -1, 64)
} }
return strconv.FormatFloat(f, 'f', -1, 64), nil return strconv.FormatFloat(f, 'f', -1, 64)
} }
+45 -11
View File
@@ -52,6 +52,13 @@ func TestEvaluate(t *testing.T) {
"1234567.5": "1234567.5", "1234567.5": "1234567.5",
"-1 / 4": "-0.25", "-1 / 4": "-0.25",
"1e300 * 1e8": "1e+308", "1e300 * 1e8": "1e+308",
// Past the normal range of a double, written from the exact value
// to 17 significant digits, trailing zeros dropped.
"1e400": "1e+400",
"1e300 * 1e300": "1e+600",
"1 / 2e-400": "5e+399",
"-1e-310": "-1e-310",
"1 / 3e400": "3.3333333333333333e-401",
}) })
} }
@@ -82,6 +89,7 @@ func TestEvaluatePowers(t *testing.T) {
"0.1^2": "0.01", "0.1^2": "0.01",
"2^100 - 2^100 + 1": "1", "2^100 - 2^100 + 1": "1",
"2^64": "18446744073709551616", "2^64": "18446744073709551616",
"2^100": "1.2676506002282294e+30",
"0^0": "1", "0^0": "1",
"0^3": "0", "0^3": "0",
"1.5^2": "2.25", "1.5^2": "2.25",
@@ -99,6 +107,29 @@ func TestEvaluatePowers(t *testing.T) {
"2^-1400 * 2^1365 * 2^35": "1", "2^-1400 * 2^1365 * 2^35": "1",
"0.3^900 * 10^470": "0.25652473503365386", "0.3^900 * 10^470": "0.25652473503365386",
"2^1500 / 2^1000": "3.273390607896142e+150", "2^1500 / 2^1000": "3.273390607896142e+150",
"(2^1200)/(2^1199)": "2",
"2^1200 % 7": "1",
// Results past the range of a double, written to 17 significant
// digits, up to the largest power of 2 under the 4096-bit limit.
"2^1200": "1.7218479456385751e+361",
"2**1200": "1.7218479456385751e+361",
"2^1024": "1.7976931348623159e+308",
"10^400": "1e+400",
"2^-1074": "4.9406564584124654e-324",
"2^-1200": "5.8077137562175032e-362",
"2^-1400": "3.6141491434385841e-422",
"0.5^1100": "7.3621518290228627e-332",
"1.5^2000": "1.5223626185737825e+352",
"(1/3)^-2000": "1.7478712517226516e+954",
"2^1200 - 2^1199": "8.6092397281928753e+360",
"-2^1201": "-3.4436958912771501e+361",
"(-2)^1201": "-3.4436958912771501e+361",
"2^2000 * 2^2000": "1.3182040934309431e+1204",
"10^1232": "1e+1232",
"2^4094": "2.6109722035328813e+1232",
// A power computed in float64 carries its rounding into the exact
// arithmetic after it, past the range of a double as within it.
"2^0.5 * 1e400": "1.4142135623730951e+400",
}) })
} }
@@ -205,25 +236,22 @@ func TestEvaluateRefuses(t *testing.T) {
} }
// TestEvaluateOutOfRange: a number is held exactly, or computed in // TestEvaluateOutOfRange: a number is held exactly, or computed in
// float64 as a normal double, and a result is written as a normal // float64 as a normal double. Anything else is refused.
// double. Anything else is refused.
func TestEvaluateOutOfRange(t *testing.T) { func TestEvaluateOutOfRange(t *testing.T) {
t.Parallel() t.Parallel()
expectErrors(t, map[string]error{ expectErrors(t, map[string]error{
// Results that are not normal doubles: 2^-1074 would be written // Just past the 4096-bit limit, which 2^4094 and 10^1232 are
// 5e-324. // under, and far past it.
"1e400": calc.ErrOutOfRange, "2^4095": calc.ErrOutOfRange,
"1e300 * 1e300": calc.ErrOutOfRange, "-2^4095": calc.ErrOutOfRange,
"2^-4095": calc.ErrOutOfRange,
"10^1233": calc.ErrOutOfRange,
"2^4094 * 2": calc.ErrOutOfRange,
"1e999999999 * 1e999999999": calc.ErrOutOfRange, "1e999999999 * 1e999999999": calc.ErrOutOfRange,
"1 / 1e-400": calc.ErrOutOfRange,
"2^1024": calc.ErrOutOfRange,
"2^5000": calc.ErrOutOfRange, "2^5000": calc.ErrOutOfRange,
"(-2)^5001": calc.ErrOutOfRange, "(-2)^5001": calc.ErrOutOfRange,
"0.5^-5000": 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 // Powers computed in float64 whose base or result is not a
// normal double, and so has lost digits, or all of them. // normal double, and so has lost digits, or all of them.
"2^-1073.5 * 2^1073": calc.ErrOutOfRange, "2^-1073.5 * 2^1073": calc.ErrOutOfRange,
@@ -232,6 +260,7 @@ func TestEvaluateOutOfRange(t *testing.T) {
"1e-310^0.5": calc.ErrOutOfRange, "1e-310^0.5": calc.ErrOutOfRange,
"(0.5^1100)^4 / (0.5^1100)^4": calc.ErrOutOfRange, "(0.5^1100)^4 / (0.5^1100)^4": calc.ErrOutOfRange,
"(1/3)^1e400": calc.ErrOutOfRange, "(1/3)^1e400": calc.ErrOutOfRange,
"(2^1200)^0.5": 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.
@@ -309,6 +338,11 @@ func TestEvaluateBoundsWork(t *testing.T) {
// the limit, and a literal whose exponent is too large to read. // the limit, and a literal whose exponent is too large to read.
{in: "(3^2583/5^1760)^4096", err: calc.ErrOutOfRange}, {in: "(3^2583/5^1760)^4096", err: calc.ErrOutOfRange},
{in: "1e99999999999999999999", err: calc.ErrOutOfRange}, {in: "1e99999999999999999999", err: calc.ErrOutOfRange},
// Results just below the limit, written from their exact value.
{in: "2^4094", want: "2.6109722035328813e+1232"},
{in: "3^2583", want: "2.5363018640659988e+1232"},
{in: "2^-4094", want: "3.8299909843808741e-1233"},
{in: "-1/3^2583", want: "-3.9427483540814775e-1233"},
} }
for _, c := range cases { for _, c := range cases {