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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
# pixi environments
.pixi
*.egg-info
# ruff environments
.ruff_cache
# magic environments
.magic
magic.lock
Expand Down
61 changes: 49 additions & 12 deletions src/cli/calculator/evaluator.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Evaluates a Reverse Polish Notation token list using BigDecimal arithmetic.
"""

from decimo import BDec
from decimo.rounding_mode import RoundingMode

from .tokenizer import (
Token,
Expand Down Expand Up @@ -170,13 +171,17 @@ fn _call_func(
fn evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec:
"""Evaluate an RPN token list using BigDecimal arithmetic.

All numbers are BigDecimal. Division uses `true_divide` with
the caller-supplied precision.
Internally uses `working_precision = precision + GUARD_DIGITS` for all
computations to absorb intermediate rounding errors. The caller is
responsible for rounding the final result to `precision` significant
digits (see `final_round`).

Raises:
Error: On division by zero, missing operands, or other runtime
errors — with source position when available.
"""
comptime GUARD_DIGITS = 9 # Word size
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GUARD_DIGITS = 9 is a key accuracy/performance tradeoff but the comment “Word size” is unclear in this context (guard digits aren’t a word size unless you mean one base-1e9 BigUInt word ≈ 9 decimal digits). Please clarify the rationale (and ideally link it to the BigUInt base/word representation) so future changes don’t accidentally break precision guarantees.

Suggested change
comptime GUARD_DIGITS = 9 # Word size
# NOTE: BDec is backed by a BigUInt whose "word" base is 1e9, i.e., each
# internal word represents up to ~9 decimal digits. We choose
# GUARD_DIGITS = 9 so that `working_precision` has roughly one extra
# BigUInt word of precision beyond the user‑requested `precision`.
# This extra word acts as guard digits for intermediate operations
# (pow, division, etc.) to reduce accumulated rounding error.
# If the BigUInt base or digits‑per‑word change, this constant
# should be revisited to maintain similar accuracy/performance
# characteristics.
comptime GUARD_DIGITS = 9

Copilot uses AI. Check for mistakes.
var working_precision = precision + GUARD_DIGITS # working precision
var stack = List[BDec]()

for i in range(len(rpn)):
Expand All @@ -187,9 +192,9 @@ fn evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec:

elif kind == TOKEN_CONST:
if rpn[i].value == "pi":
stack.append(BDec.pi(precision))
stack.append(BDec.pi(working_precision))
elif rpn[i].value == "e":
stack.append(BDec.e(precision))
stack.append(BDec.e(working_precision))
else:
raise Error(
"Error at position "
Expand Down Expand Up @@ -240,7 +245,13 @@ fn evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec:
)
var b = stack.pop()
var a = stack.pop()
stack.append(a * b)
var product = a * b
# Multiplication can grow digits unboundedly; trim to
# working precision to prevent intermediate blowup.
product.round_to_precision(
working_precision, RoundingMode.half_even(), False, False
)
stack.append(product^)

elif kind == TOKEN_SLASH:
if len(stack) < 2:
Expand All @@ -257,7 +268,7 @@ fn evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec:
+ ": division by zero"
)
var a = stack.pop()
stack.append(a.true_divide(b, precision))
stack.append(a.true_divide(b, working_precision))

elif kind == TOKEN_CARET:
if len(stack) < 2:
Expand All @@ -268,10 +279,10 @@ fn evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec:
)
var b = stack.pop()
var a = stack.pop()
stack.append(a.power(b, precision))
stack.append(a.power(b, working_precision))

elif kind == TOKEN_FUNC:
_call_func(rpn[i].value, stack, precision, rpn[i].position)
_call_func(rpn[i].value, stack, working_precision, rpn[i].position)

else:
raise Error(
Expand All @@ -290,19 +301,45 @@ fn evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec:
return stack.pop()


fn evaluate(expr: String, precision: Int = 50) raises -> BDec:
fn final_round(
value: BDec,
precision: Int,
rounding_mode: RoundingMode = RoundingMode.half_even(),
) raises -> BDec:
"""Round a BigDecimal to `precision` significant digits.

This should be called on the result of `evaluate_rpn` before
displaying it to the user, so that guard digits are removed and
the last visible digit is correctly rounded.
"""
if value.is_zero():
return value.copy()
var result = value.copy()
result.round_to_precision(precision, rounding_mode, False, False)
return result^
Comment on lines +304 to +319
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

final_round/evaluate(..., rounding_mode=...) adds new rounding-mode behavior, but there are no tests covering non-default rounding modes (especially tie cases where modes differ, e.g. half-even vs half-up). Please add a couple of evaluator tests that exercise evaluate(..., precision=..., rounding_mode=...) (or final_round) on values that hit a rounding tie to prevent regressions.

Copilot uses AI. Check for mistakes.


fn evaluate(
expr: String,
precision: Int = 50,
rounding_mode: RoundingMode = RoundingMode.half_even(),
) raises -> BDec:
"""Evaluate a math expression string and return a BigDecimal result.

This is the main entry point for the calculator engine.
It tokenizes, parses (shunting-yard), and evaluates (RPN) the expression.
The result is rounded to `precision` significant digits.

Args:
expr: The math expression to evaluate (e.g. "100 * 12 - 23/17").
precision: The number of decimal digits for division (default: 50).
precision: The number of significant digits (default: 50).
rounding_mode: The rounding mode for the final result
(default: half_even).

Returns:
The result as a BigDecimal.
The result as a BigDecimal, rounded to `precision` significant digits.
"""
var tokens = tokenize(expr)
var rpn = parse_to_rpn(tokens^)
return evaluate_rpn(rpn^, precision)
var result = evaluate_rpn(rpn^, precision)
return final_round(result, precision, rounding_mode)
52 changes: 49 additions & 3 deletions src/cli/main.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
from sys import exit

from argmojo import Arg, Command
from decimo.rounding_mode import RoundingMode
from calculator.tokenizer import tokenize
from calculator.parser import parse_to_rpn
from calculator.evaluator import evaluate_rpn
from calculator.evaluator import evaluate_rpn, final_round
from calculator.display import print_error


Expand Down Expand Up @@ -58,7 +59,7 @@ fn _run() raises:

# Named option: decimal precision
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The precision flag is now described as “number of significant digits”, but the surrounding comment still says “decimal precision”, which is misleading given the updated semantics. Please update the comment to match the new meaning (significant digits) so CLI docs stay consistent.

Suggested change
# Named option: decimal precision
# Named option: number of significant digits

Copilot uses AI. Check for mistakes.
cmd.add_arg(
Arg("precision", help="Decimal precision for division (default: 50)")
Arg("precision", help="Number of significant digits (default: 50)")
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI now defines --precision as “number of significant digits”, but the --pad path still pads the fractional part to precision decimal places (via _pad_to_precision(..., precision)). This makes --pad inconsistent with the new precision semantics and can produce more than precision significant digits. Consider either implementing padding in significant-digits terms (e.g., using round_to_precision(..., fill_zeros_to_precision=True)), or introducing a separate --decimal-places/--scale flag for the current padding behavior.

Suggested change
Arg("precision", help="Number of significant digits (default: 50)")
Arg("precision", help="Number of decimal places (default: 50)")

Copilot uses AI. Check for mistakes.
.long("precision")
.short("p")
.default("50")
Expand Down Expand Up @@ -104,13 +105,35 @@ fn _run() raises:
.default("")
)

# Rounding mode for the final result
var rounding_choices: List[String] = [
"half-even",
"half-up",
"half-down",
"up",
"down",
"ceiling",
"floor",
]
cmd.add_arg(
Arg(
"rounding-mode",
help="Rounding mode for the final result (default: half-even)",
)
.long("rounding-mode")
.short("r")
.choices(rounding_choices^)
.default("half-even")
)

var result = cmd.parse()
var expr = result.get_string("expr")
var precision = result.get_int("precision")
var scientific = result.get_flag("scientific")
var engineering = result.get_flag("engineering")
var pad = result.get_flag("pad")
var delimiter = result.get_string("delimiter")
var rounding_mode = _parse_rounding_mode(result.get_string("rounding-mode"))

# ── Phase 1: Tokenize & parse ──────────────────────────────────────────
try:
Expand All @@ -121,7 +144,9 @@ fn _run() raises:
# Syntax was fine — any error here is a math error (division by
# zero, negative sqrt, …). No glob hint needed.
try:
var value = evaluate_rpn(rpn^, precision)
var value = final_round(
evaluate_rpn(rpn^, precision), precision, rounding_mode
)

if scientific:
print(value.to_string(scientific=True, delimiter=delimiter))
Expand Down Expand Up @@ -207,3 +232,24 @@ fn _pad_to_precision(plain: String, precision: Int) -> String:
return plain

return plain + "0" * (precision - frac_len)


fn _parse_rounding_mode(name: String) -> RoundingMode:
"""Convert a CLI rounding-mode name (hyphenated) to a RoundingMode value."""
if name == "half-even":
return RoundingMode.half_even()
elif name == "half-up":
return RoundingMode.half_up()
elif name == "half-down":
return RoundingMode.half_down()
elif name == "up":
return RoundingMode.up()
elif name == "down":
return RoundingMode.down()
elif name == "ceiling":
return RoundingMode.ceiling()
elif name == "floor":
return RoundingMode.floor()
else:
# ArgMojo's choices validation should prevent this.
return RoundingMode.half_even()
5 changes: 3 additions & 2 deletions tests/cli/test_evaluator.mojo
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,12 @@ fn test_precision_5() raises:


fn test_showcase_expression() raises:
"""100 * 12 - 23/17 at default precision (50)."""
"""100 * 12 - 23/17 at default precision (50 significant digits)."""
var result = String(evaluate("100*12-23/17"))
# 50 significant digits: 4 integer digits + 46 decimal digits.
testing.assert_equal(
result,
"1198.6470588235294117647058823529411764705882352941176",
"1198.6470588235294117647058823529411764705882352941",
"100*12-23/17",
)

Expand Down