Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 67 additions & 7 deletions pkg/calc/calc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <<expression>> 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 <<expression>> 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."

Expand Down Expand Up @@ -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 '(':
Expand Down Expand Up @@ -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()
Expand All @@ -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++
Expand All @@ -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 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 ")"
Expand Down
32 changes: 31 additions & 1 deletion pkg/calc/calc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
Loading