From c4e4041cd91452d08ee708a6b60938e92c10d018 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 06:18:19 +0000 Subject: [PATCH 1/2] Write exact results past the range of a double (closes #16) An exact result outside the normal range of a double, such as 2^1200, 10^400 or 2^-1200, was refused because format wrote every result through float64. Such a result is now written from its exact value, rounded to 17 significant digits with trailing zeros dropped, through a big.Float precise enough to round as the exact value would. Results inside the range are written as before, and a power computed in float64 must still be a normal double. The tests check the written text of these results, the refusals just past the 4096-bit limit, and the time taken to write the largest and smallest numbers under it. Model: opus-5-5 --- README.md | 24 +++++++++------- docs/TODO.md | 2 ++ internal/bot/bot_test.go | 3 +- internal/calc/calc.go | 40 ++++++++++++++++++--------- internal/calc/calc_test.go | 56 ++++++++++++++++++++++++++++++-------- 5 files changed, 90 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index c7f8d06..1506d7e 100644 --- a/README.md +++ b/README.md @@ -335,16 +335,20 @@ container. refused, exact powers are capped, and every number is held as a fraction, whole numbers too, under the 4096-bit limit below, so a message cannot make the bot do unbounded work. Whole numbers below - 1021 are written exactly; other results in the shortest - form that reads back as the same double, in exponent notation from - 1021 up and below 10-6. 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 (`1e-1300 + 1`); a power 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`). + 1021 are written exactly. Other results inside the normal + range of a double, about 2.2e-308 to 1.8e308 in magnitude, where a + double keeps all its digits, are written in the shortest form that + reads back as the same double, in exponent notation from + 1021 up and below 10-6. Results outside that + range are written from their exact value in exponent notation, rounded + to 17 significant digits, the most the shortest form of a double + takes, with trailing zeros dropped: `2^1200` is + `1.7218479456385751e+361`, `10^400` is `1e+400` and `2^-1074` is + `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 it drops, the bot exits with an error and the container's restart policy starts both again. `SIGTERM` stops the bot, which stops the diff --git a/docs/TODO.md b/docs/TODO.md index 7fa040e..d7f18d3 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -27,6 +27,8 @@ with no deprecation warning. # 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 latest messages, and sending one - 2026-09-29 Added the HTTP API in `internal/api`: the server on `PORT`, diff --git a/internal/bot/bot_test.go b/internal/bot/bot_test.go index 3bbb661..e76914e 100644 --- a/internal/bot/bot_test.go +++ b/internal/bot/bot_test.go @@ -18,6 +18,7 @@ func TestReply(t *testing.T) { "5 * 5/2": "12.5", "2^10": "1024", "7 % 3": "1", + "2^1200": "1.7218479456385751e+361", } { if got := bot.Reply(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{ "hello": "I only understand arithmetic", "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.", "(-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", diff --git a/internal/calc/calc.go b/internal/calc/calc.go index 606a1f7..b193687 100644 --- a/internal/calc/calc.go +++ b/internal/calc/calc.go @@ -48,6 +48,16 @@ const ( 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 // operator binds. const ( @@ -82,8 +92,7 @@ var ( ) // Evaluate computes an arithmetic expression and returns its result as -// text: whole numbers without a decimal point, fractions in the -// shortest form that reads back as the same float64. +// text, written as format describes. func Evaluate(input string) (string, error) { s := strings.TrimSpace(input) if len(s) > MaxInputLength { @@ -106,7 +115,7 @@ func Evaluate(input string) (string, error) { return "", ErrNotArithmetic } - return format(v) + return format(v), nil } // 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 -// ordinary size is written exactly, digit for digit; anything else goes -// through float64, whose shortest round-trip form is free of the noise -// (0.30000000000000004) that printing a binary fraction to a fixed -// 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) { +// ordinary size is written exactly, digit for digit. Any other result in +// the normal range of a double goes through float64, whose shortest +// round-trip form is free of the noise (0.30000000000000004) that +// printing a binary fraction to a fixed precision produces. Past that +// range a double keeps fewer digits, or none (2^-1074 would be written +// 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) 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) if i := constant.ToInt(v); i.Kind() == constant.Int && abs < plainUpper { - return i.ExactString(), nil + return i.ExactString() } 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) } diff --git a/internal/calc/calc_test.go b/internal/calc/calc_test.go index 21e40ee..f5b1b3f 100644 --- a/internal/calc/calc_test.go +++ b/internal/calc/calc_test.go @@ -52,6 +52,13 @@ func TestEvaluate(t *testing.T) { "1234567.5": "1234567.5", "-1 / 4": "-0.25", "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", "2^100 - 2^100 + 1": "1", "2^64": "18446744073709551616", + "2^100": "1.2676506002282294e+30", "0^0": "1", "0^3": "0", "1.5^2": "2.25", @@ -99,6 +107,29 @@ func TestEvaluatePowers(t *testing.T) { "2^-1400 * 2^1365 * 2^35": "1", "0.3^900 * 10^470": "0.25652473503365386", "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 -// float64 as a normal double, and a result is written as a normal -// double. Anything else is refused. +// float64 as a normal double. Anything else is refused. func TestEvaluateOutOfRange(t *testing.T) { 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, + // Just past the 4096-bit limit, which 2^4094 and 10^1232 are + // under, and far past it. + "2^4095": calc.ErrOutOfRange, + "-2^4095": calc.ErrOutOfRange, + "2^-4095": calc.ErrOutOfRange, + "10^1233": calc.ErrOutOfRange, + "2^4094 * 2": 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, @@ -232,6 +260,7 @@ func TestEvaluateOutOfRange(t *testing.T) { "1e-310^0.5": calc.ErrOutOfRange, "(0.5^1100)^4 / (0.5^1100)^4": 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 // can lose the answer (this one would be 0), and so can a // 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. {in: "(3^2583/5^1760)^4096", 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 { -- 2.54.0 From c4094fff7d2ee54e52f431624413c22e3660346c Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 07:37:57 +0000 Subject: [PATCH 2/2] Answer fractional powers of exact numbers past a double's range (closes #16) A fractional power turned its base into a double first and refused a base outside the normal range of a double, so (2^1200)^0.5, (2^1024)^0.5, (2^-1200)^0.5 and 1e400^0.5 were refused although each answer is an ordinary double. Such a base is now brought into that range by square roots taken from its exact value in a big.Float, at most three under the 4096-bit limit, with the exponent doubled for each, and only a result that is not a normal double is refused. The tests give these powers and the edges of the range their values, and the bounded-work test covers a base that needs three roots. The README's refusal sentence and example are updated. Model: opus-5-5 --- README.md | 7 +++++-- docs/TODO.md | 3 ++- internal/calc/calc.go | 26 +++++++++++++++++++++----- internal/calc/calc_test.go | 33 +++++++++++++++++++++++++++------ 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 96ba4ca..3083b4e 100644 --- a/README.md +++ b/README.md @@ -438,8 +438,11 @@ container. `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`). + (`2^4095`, `1e-1300 + 1`); and a power computed as a double whose + result is outside the normal range of a double (`2^1500.5`). The base + of such a power may be outside that range: square roots taken from its + exact value bring it inside first, so `(2^1200)^0.5` is + `4.149515568880993e+180` and `1e-400^0.5` is `1e-200`. - **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 policy starts both again. `SIGTERM` stops the bot, which stops the diff --git a/docs/TODO.md b/docs/TODO.md index 4683084..3de5c1a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -28,7 +28,8 @@ with no deprecation warning. # 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 + are written to 17 significant digits instead of being refused, and a + fractional power of such a number, such as `(2^1200)^0.5`, is answered - 2026-09-29 `POST`, `GET` and `DELETE` on a chat's webhooks, under `/api/v1/chats/{id}/webhooks`, kept in `$DATA_DIR/webhooks.json` - 2026-09-29 `GET` and `POST /api/v1/chats/{id}/messages`: a chat's diff --git a/internal/calc/calc.go b/internal/calc/calc.go index b193687..17801fb 100644 --- a/internal/calc/calc.go +++ b/internal/calc/calc.go @@ -52,7 +52,9 @@ const ( // 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. +// digits, so that the float rounds as the exact result would. The square +// roots of a power's base past that range are taken in such a float too: +// see nonNegativePower. const ( significantDigits = 17 floatPrecision = bitLimit + 64 @@ -404,11 +406,25 @@ func nonNegativePower(x, y, n constant.Value) (constant.Value, error) { xf, _ := constant.Float64Val(x) yf, _ := constant.Float64Val(y) - f := math.Pow(xf, yf) - // Neither x nor x^y is zero. If either is not a normal double, it - // has lost digits, or all of them. - if !normal(xf) || !normal(f) { + // x^y is (√x)^(2y). An x outside the normal range of a double, such + // as 2^1200, would lose digits as a double, or all of them, so square + // roots taken from its exact value bring it into that range first. As + // x is between 2^-4096 and 2^4096 (see exact), three at most are + // needed. + r, _ := constant.Val(x).(*big.Rat) + root := new(big.Float).SetPrec(floatPrecision).SetRat(r) + + for !normal(xf) { + root.Sqrt(root) + xf, _ = root.Float64() + yf *= 2 + } + + // x^y is not zero. If it is not a normal double, it has lost digits, + // or all of them. + f := math.Pow(xf, yf) + if !normal(f) { return nil, ErrOutOfRange } diff --git a/internal/calc/calc_test.go b/internal/calc/calc_test.go index f5b1b3f..e43bb5a 100644 --- a/internal/calc/calc_test.go +++ b/internal/calc/calc_test.go @@ -130,6 +130,21 @@ func TestEvaluatePowers(t *testing.T) { // 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", + // A fractional power of an exact number outside the range of a + // double, taken from its exact value, up to the edges of that + // range. + "(2^1200)^0.5": "4.149515568880993e+180", + "(2^1024)^0.5": "1.3407807929942597e+154", + "(2^-1200)^0.5": "2.409919865102884e-181", + "1e400^0.5": "1e+200", + "1e-400^0.5": "1e-200", + "1e-310^0.5": "1e-155", + "(2^1200)^-0.5": "2.409919865102884e-181", + "(2^1200)^0.5 / 2^600": "1", + "1e400^-0.001": "0.39810717055349726", + "1e-400^0.001": "0.39810717055349726", + "(2^2047)^0.5": "1.2711610061536464e+308", + "(2^-2044)^0.5": "2.2250738585072014e-308", }) } @@ -252,15 +267,17 @@ func TestEvaluateOutOfRange(t *testing.T) { "2^5000": calc.ErrOutOfRange, "(-2)^5001": calc.ErrOutOfRange, "0.5^-5000": calc.ErrOutOfRange, - // Powers computed in float64 whose base or result is not a - // normal double, and so has lost digits, or all of them. + // Powers computed in float64 whose result is not a normal double, + // and so has lost digits, or all of them, whatever the size of + // the base. "2^-1073.5 * 2^1073": calc.ErrOutOfRange, - "1e400^-0.001": calc.ErrOutOfRange, - "1e-400^0.001": calc.ErrOutOfRange, - "1e-310^0.5": calc.ErrOutOfRange, + "2^1500.5": calc.ErrOutOfRange, + "(2^1200)^0.9": calc.ErrOutOfRange, + "1e-400^0.9": calc.ErrOutOfRange, + "(2^2048)^0.5": calc.ErrOutOfRange, + "(2^-2046)^0.5": calc.ErrOutOfRange, "(0.5^1100)^4 / (0.5^1100)^4": 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 // can lose the answer (this one would be 0), and so can a // remainder or the sign of -1 to such a power. @@ -343,6 +360,10 @@ func TestEvaluateBoundsWork(t *testing.T) { {in: "3^2583", want: "2.5363018640659988e+1232"}, {in: "2^-4094", want: "3.8299909843808741e-1233"}, {in: "-1/3^2583", want: "-3.9427483540814775e-1233"}, + // Fractional powers of numbers just below the limit, whose bases + // take the most square roots to bring into the range of a double. + {in: "(2^-4094)^0.125", want: "8.869511863657883e-155"}, + {in: "(1/3^2583)^0.5", err: calc.ErrOutOfRange}, } for _, c := range cases { -- 2.54.0