From cdad68a9a4c0e286deda8a391a30e66175b8b25a Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Tue, 3 Mar 2026 11:10:37 +0100 Subject: [PATCH 1/3] Add working precision and a final rounding --- src/cli/calculator/evaluator.mojo | 49 ++++++++++++++++++++++++------- src/cli/main.mojo | 6 ++-- tests/cli/test_evaluator.mojo | 5 ++-- 3 files changed, 44 insertions(+), 16 deletions(-) diff --git a/src/cli/calculator/evaluator.mojo b/src/cli/calculator/evaluator.mojo index e8fc4b14..959c034c 100644 --- a/src/cli/calculator/evaluator.mojo +++ b/src/cli/calculator/evaluator.mojo @@ -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, @@ -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 + var working_precision = precision + GUARD_DIGITS # working precision var stack = List[BDec]() for i in range(len(rpn)): @@ -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 " @@ -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: @@ -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: @@ -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( @@ -290,19 +301,35 @@ fn evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec: return stack.pop() +fn final_round(value: BDec, precision: Int) 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, RoundingMode.half_even(), False, False) + return result^ + + fn evaluate(expr: String, precision: Int = 50) 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). 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) diff --git a/src/cli/main.mojo b/src/cli/main.mojo index b5ad3689..ae6fd45b 100644 --- a/src/cli/main.mojo +++ b/src/cli/main.mojo @@ -14,7 +14,7 @@ from sys import exit from argmojo import Arg, Command 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 @@ -58,7 +58,7 @@ fn _run() raises: # Named option: decimal precision cmd.add_arg( - Arg("precision", help="Decimal precision for division (default: 50)") + Arg("precision", help="Number of significant digits (default: 50)") .long("precision") .short("p") .default("50") @@ -121,7 +121,7 @@ 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) if scientific: print(value.to_string(scientific=True, delimiter=delimiter)) diff --git a/tests/cli/test_evaluator.mojo b/tests/cli/test_evaluator.mojo index 651307cb..649df222 100644 --- a/tests/cli/test_evaluator.mojo +++ b/tests/cli/test_evaluator.mojo @@ -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", ) From bd6ff22ec9b4a59859dcace799fc5577a96821f4 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Tue, 3 Mar 2026 12:33:06 +0100 Subject: [PATCH 2/3] Use half_even for intermediate --- src/cli/calculator/evaluator.mojo | 18 +++++++++--- src/cli/main.mojo | 48 ++++++++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/cli/calculator/evaluator.mojo b/src/cli/calculator/evaluator.mojo index 959c034c..e0172930 100644 --- a/src/cli/calculator/evaluator.mojo +++ b/src/cli/calculator/evaluator.mojo @@ -301,7 +301,11 @@ fn evaluate_rpn(rpn: List[Token], precision: Int) raises -> BDec: return stack.pop() -fn final_round(value: BDec, precision: Int) 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 @@ -311,11 +315,15 @@ fn final_round(value: BDec, precision: Int) raises -> BDec: if value.is_zero(): return value.copy() var result = value.copy() - result.round_to_precision(precision, RoundingMode.half_even(), False, False) + result.round_to_precision(precision, rounding_mode, False, False) return result^ -fn evaluate(expr: String, precision: Int = 50) raises -> BDec: +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. @@ -325,6 +333,8 @@ fn evaluate(expr: String, precision: Int = 50) raises -> BDec: Args: expr: The math expression to evaluate (e.g. "100 * 12 - 23/17"). 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, rounded to `precision` significant digits. @@ -332,4 +342,4 @@ fn evaluate(expr: String, precision: Int = 50) raises -> BDec: var tokens = tokenize(expr) var rpn = parse_to_rpn(tokens^) var result = evaluate_rpn(rpn^, precision) - return final_round(result, precision) + return final_round(result, precision, rounding_mode) diff --git a/src/cli/main.mojo b/src/cli/main.mojo index ae6fd45b..7f3fbc63 100644 --- a/src/cli/main.mojo +++ b/src/cli/main.mojo @@ -12,6 +12,7 @@ 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, final_round @@ -104,6 +105,27 @@ 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") @@ -111,6 +133,7 @@ fn _run() raises: 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: @@ -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 = final_round(evaluate_rpn(rpn^, precision), precision) + var value = final_round( + evaluate_rpn(rpn^, precision), precision, rounding_mode + ) if scientific: print(value.to_string(scientific=True, delimiter=delimiter)) @@ -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() From b3e8eacb1565f78dd20d3146dc9b93f4d9b278ae Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Tue, 3 Mar 2026 21:17:12 +0100 Subject: [PATCH 3/3] update --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index f257ff9d..f83a1700 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ # pixi environments .pixi *.egg-info +# ruff environments +.ruff_cache # magic environments .magic magic.lock