diff --git a/pkg/calc/calc.go b/pkg/calc/calc.go index 71c9831..6f9f5c5 100644 --- a/pkg/calc/calc.go +++ b/pkg/calc/calc.go @@ -8,8 +8,8 @@ // gemini-2.5-flash-lite wrote 365 * 1,380.5 / 7,890 = 67.75, which is 63.86 // (HAL-3161). // -// An expression holds numbers, + - * /, parentheses and signs, nothing -// else. Numbers are read the way a filing writes them: "$1,174" is 1174, +// An expression holds numbers, + - * /, powers (** or ^), parentheses and +// signs, nothing else. Numbers are read the way a filing writes them: "$1,174" is 1174, // "12.5%" is 0.125, "(1,234)" stays a parenthesised 1234. package calc @@ -27,8 +27,9 @@ import ( // can do it. Every answer prompt that may derive a figure includes them. const Instructions = "When the question asks for a figure the evidence lets you derive (a ratio, margin, growth rate, " + "change, average or sum), take each input from the evidence and set the calculation up, but do not work it out: " + - "write it as <> using only numbers from the evidence, + - * / and parentheses, and it is computed " + - "for you. Example: operating margin was <<11,512 / 86,392 * 100>>% [1]. Apply the question's own definitions " + + "write it as <> using only numbers from the evidence, + - * /, ** for a power and parentheses, and it is computed " + + "for you. Example: operating margin was <<11,512 / 86,392 * 100>>% [1]; a two-year compound growth rate is " + + "<<((65,984 / 65,398) ** (1 / 2) - 1) * 100>>%. Apply the question's own definitions " + "term by term. When the question asks for a judgement (is a business cyclical, what drove a change), answer it " + "from the facts the evidence states and cite them." @@ -273,7 +274,15 @@ func tokenize(expr string) ([]token, error) { switch c { case ' ', '\t', '\n': i++ - case '+', '-', '*', '/': + case '*': + if i+1 < len(expr) && expr[i+1] == '*' { // ** is a power + toks = append(toks, token{kind: tOp, op: '^'}) + i += 2 + continue + } + toks = append(toks, token{kind: tOp, op: c}) + i++ + case '+', '-', '/', '^': toks = append(toks, token{kind: tOp, op: c}) i++ case '(': @@ -348,10 +357,19 @@ func (p *parser) expr() (float64, error) { } else { v -= r } + if !finite(v) { + return 0, errOverflow + } } return v, nil } +// errOverflow is an intermediate result past float range: it fails the +// whole set-up, so no later operation can turn it back into a number. +var errOverflow = fmt.Errorf("%w: an intermediate result is not a finite number", ErrInvalid) + +func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) } + // term := unary (("*"|"/") unary)* func (p *parser) term() (float64, error) { v, err := p.unary() @@ -372,11 +390,14 @@ func (p *parser) term() (float64, error) { } v /= r } + if !finite(v) { + return 0, errOverflow + } } return v, nil } -// unary := ("+"|"-") unary | atom +// unary := ("+"|"-") unary | power func (p *parser) unary() (float64, error) { if t := p.peek(); t != nil && t.kind == tOp && (t.op == '+' || t.op == '-') { p.pos++ @@ -386,7 +407,46 @@ func (p *parser) unary() (float64, error) { } return v, err } - return p.atom() + return p.power() +} + +// maxExponent bounds a power's exponent: a count of periods or a root +// (CAGR over n years is (end / start) ** (1 / n)), not more. +const maxExponent = 100 + +// power := atom [("**"|"^") unary]: right-associative (2 ** 3 ** 2 is +// 2 ** 9), and it binds before a sign on its left (-2 ** 2 is -4), as +// Python and spreadsheets read it (HAL-3274). +func (p *parser) power() (float64, error) { + base, err := p.atom() + if err != nil { + return 0, err + } + t := p.peek() + if t == nil || t.kind != tOp || t.op != '^' { + return base, nil + } + p.pos++ + exp, err := p.unary() + if err != nil { + return 0, err + } + switch { + case !finite(base) || !finite(exp): + // Inf ** 0 is 1: an operand that already overflowed must not vanish + return 0, fmt.Errorf("%w: an operand of the power is not a finite number", ErrInvalid) + case math.Abs(exp) > maxExponent: + return 0, fmt.Errorf("%w: exponent %v is beyond %d", ErrInvalid, exp, maxExponent) + case base < 0 && exp != math.Trunc(exp): + return 0, fmt.Errorf("%w: a fractional power of a negative number has no real value", ErrInvalid) + case base == 0 && exp < 0: + return 0, fmt.Errorf("%w: division by zero", ErrInvalid) + } + v := math.Pow(base, exp) + if math.IsNaN(v) || math.IsInf(v, 0) { + return 0, fmt.Errorf("%w: not a finite number", ErrInvalid) + } + return v, nil } // atom := number | "(" expr ")" diff --git a/pkg/calc/calc_test.go b/pkg/calc/calc_test.go index 96b5170..ef97c0a 100644 --- a/pkg/calc/calc_test.go +++ b/pkg/calc/calc_test.go @@ -42,7 +42,7 @@ func TestEvaluate(t *testing.T) { } func TestOnlyArithmeticEvaluates(t *testing.T) { - for _, expr := range []string{"", "1 +", "2 ** 3", "abs(-1)", "x + 1", "1 / 0", "(1 + 2", "1 + 2)", "os.exit(1)", "1 2", "5 3", "1, 2", "1,2", "1,174,5"} { + for _, expr := range []string{"", "1 +", "abs(-1)", "x + 1", "1 / 0", "(1 + 2", "1 + 2)", "os.exit(1)", "1 2", "5 3", "1, 2", "1,2", "1,174,5"} { if _, err := Evaluate(expr); !errors.Is(err, ErrInvalid) { t.Errorf("%q: err = %v, want ErrInvalid", expr, err) } @@ -220,3 +220,33 @@ func TestInnerResultKeepsFullPrecision(t *testing.T) { } } } + +// Powers, for compound growth and roots (HAL-3274). +func TestPowers(t *testing.T) { + for expr, want := range map[string]float64{ + "((65984 / 65398) ** (1/2) - 1) * 100": 0.4470268, // FinanceBench 03718, a two-year CAGR + "((65,984 / 65,398) ^ (1 / 2) - 1) * 100": 0.4470268, + "16 ** 0.5": 4, + "2 ** 3 ** 2": 512, // right-associative + "-2 ** 2": -4, // the power binds before the sign + "(-2) ** 2": 4, + "2 ** -1": 0.5, + "3 * 2 ^ 2": 12, + } { + got, err := Evaluate(expr) + if err != nil || math.Abs(got-want) > 1e-6*math.Max(1, math.Abs(want)) { + t.Errorf("%q = %v, %v; want %v", expr, got, err, want) + } + } + for _, expr := range []string{"(10 ** 100 * 10 ** 100 * 10 ** 100 * 10 ** 100) ** 0", + "(10 ** 100 * 10 ** 100 * 10 ** 100 * 10 ** 100) * 0", "(-8) ** (1/3)", "2 ** 101", "10 ** 99 ** 2", "0 ** -1", "(10 ** 100) ** 4", "2 ***3", "2 ^"} { + if _, err := Evaluate(expr); !errors.Is(err, ErrInvalid) { + t.Errorf("%q: err = %v, want ErrInvalid", expr, err) + } + } + // a sign after a power is the exponent's, and stays on its operand + _, calcs := Resolve("<<2 ** -1>>") + if ops := calcs[0].Operands; len(ops) != 2 || ops[1].Value != -1 { + t.Errorf("operands = %+v", ops) + } +}