From d4de75651051f82d7d66c087f58509f4a1f16fec Mon Sep 17 00:00:00 2001 From: Anas Khan <83116240+anxkhn@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:48:36 +0530 Subject: [PATCH] fix(vars): match Python round semantics Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com> --- packages/reflex-base/news/6757.bugfix.md | 1 + .../.templates/web/utils/helpers/round.js | 43 ++++++++++++++ .../src/reflex_base/vars/number.py | 15 ++++- tests/units/test_var.py | 56 ++++++++++++++++++- 4 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 packages/reflex-base/news/6757.bugfix.md create mode 100644 packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/round.js diff --git a/packages/reflex-base/news/6757.bugfix.md b/packages/reflex-base/news/6757.bugfix.md new file mode 100644 index 00000000000..ed31f7cc824 --- /dev/null +++ b/packages/reflex-base/news/6757.bugfix.md @@ -0,0 +1 @@ +`NumberVar` rounding now matches Python's round-half-even behavior and supports negative `ndigits` values in compiled frontend expressions. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/round.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/round.js new file mode 100644 index 00000000000..21da745ff6c --- /dev/null +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/round.js @@ -0,0 +1,43 @@ +/** + * Round a number using Python's round-half-even semantics. + * Decompose the IEEE 754 value into a rational so ties are compared exactly. + * + * @param {number} value The value to round. + * @param {number} ndigits The decimal precision. + * @returns {number} The rounded value. + */ +export default function pyRound(value, ndigits) { + if (!Number.isFinite(value) || ndigits > 323) return value; + if (ndigits < -308) return value * 0; + + const view = new DataView(new ArrayBuffer(8)); + view.setFloat64(0, Math.abs(value)); + const bits = view.getBigUint64(0); + const exponent = Number((bits >> 52n) & 0x7ffn); + const fraction = bits & 0xfffffffffffffn; + const significand = exponent === 0 ? fraction : fraction | 0x10000000000000n; + const binaryExponent = exponent === 0 ? -1074 : exponent - 1075; + const fivePower = 5n ** BigInt(Math.abs(ndigits)); + + let numerator = significand * (ndigits >= 0 ? fivePower : 1n); + let denominator = ndigits < 0 ? fivePower : 1n; + const shift = binaryExponent + ndigits; + if (shift >= 0) { + numerator <<= BigInt(shift); + } else { + denominator <<= BigInt(-shift); + } + + let rounded = numerator / denominator; + const doubledRemainder = (numerator % denominator) * 2n; + if ( + doubledRemainder > denominator || + (doubledRemainder === denominator && rounded % 2n === 1n) + ) { + rounded += 1n; + } + + const sign = value < 0 || Object.is(value, -0) ? "-" : ""; + const result = Number(`${sign}${rounded}e${-ndigits}`); + return result === 0 && sign ? -0 : result; +} diff --git a/packages/reflex-base/src/reflex_base/vars/number.py b/packages/reflex-base/src/reflex_base/vars/number.py index ae3f8ac4987..ac6aa424a6c 100644 --- a/packages/reflex-base/src/reflex_base/vars/number.py +++ b/packages/reflex-base/src/reflex_base/vars/number.py @@ -656,6 +656,13 @@ def number_exponent_operation(lhs: NumberVar, rhs: NumberVar): return f"({lhs} ** {rhs})" +_PY_ROUND_IMPORT: ImportDict = { + "$/utils/helpers/round.js": [ + ImportVar(tag="pyRound", is_default=True, install=False) + ], +} + + @var_operation def number_round_operation(value: NumberVar, ndigits: NumberVar | int): """Round the number. @@ -670,9 +677,13 @@ def number_round_operation(value: NumberVar, ndigits: NumberVar | int): if (isinstance(ndigits, LiteralNumberVar) and ndigits._var_value == 0) or ( isinstance(ndigits, int) and ndigits == 0 ): - return var_operation_return(js_expression=f"Math.round({value})", var_type=int) + var_type = int + else: + var_type = float return var_operation_return( - js_expression=f"(+{value}.toFixed({ndigits}))", var_type=float + js_expression=f"pyRound({value}, {ndigits})", + var_type=var_type, + var_data=VarData(imports=_PY_ROUND_IMPORT), ) diff --git a/tests/units/test_var.py b/tests/units/test_var.py index 1bb03eae9a1..8256d392e06 100644 --- a/tests/units/test_var.py +++ b/tests/units/test_var.py @@ -2,8 +2,10 @@ import json import math import re +import subprocess import typing from collections.abc import Mapping, Sequence +from pathlib import Path from typing import cast import pytest @@ -54,9 +56,14 @@ pytest.importorskip("pydantic") - from pydantic import BaseModel as Base +ROUND_HELPER = ( + Path(__file__).parents[2] + / "packages/reflex-base/src/reflex_base/.templates/web/utils/helpers/round.js" +) + + test_vars = [ Var(_js_expr="prop1", _var_type=int), Var(_js_expr="key", _var_type=str), @@ -1038,7 +1045,7 @@ def test_all_number_operations(): assert ( str(even_more_complicated_number) - == "!(isTrue(pyOr(Math.abs(Math.floor(((Math.floor(((-((-5.4 + 1)) * 2) / 3) / 2) % 3) ** 2))), () => (pyAnd(2, () => (Math.round(((Math.floor(((-((-5.4 + 1)) * 2) / 3) / 2) % 3) ** 2))))))))" + == "!(isTrue(pyOr(Math.abs(Math.floor(((Math.floor(((-((-5.4 + 1)) * 2) / 3) / 2) % 3) ** 2))), () => (pyAnd(2, () => (pyRound(((Math.floor(((-((-5.4 + 1)) * 2) / 3) / 2) % 3) ** 2), 0)))))))" ) assert str(LiteralNumberVar.create(5) > False) == "(5 > 0)" @@ -1049,6 +1056,51 @@ def test_all_number_operations(): ) +@pytest.mark.parametrize( + ("value", "ndigits", "expected"), + [ + (2.5, 0, "2"), + (3.5, 0, "4"), + (-2.5, 0, "-2"), + (-3.5, 0, "-4"), + (3.6, 0, "4"), + (250, -2, "200"), + (350, -2, "400"), + (-250, -2, "-200"), + (-350, -2, "-400"), + (2.675, 2, "2.67"), + (5e-324, 323, "0"), + (1e20, 0, "100000000000000000000"), + (1.2345, 324, "1.2345"), + (-1.2345, -309, "-0"), + (float("inf"), 2, "Infinity"), + ], +) +def test_number_round_python_semantics(value, ndigits, expected): + rounded = round(LiteralNumberVar.create(value), ndigits) + var_data = rounded._get_all_var_data() + assert var_data is not None + assert any( + imported.tag == "pyRound" and imported.is_default + for imported in dict(var_data.imports)["$/utils/helpers/round.js"] + ) + + result = subprocess.run( + [ + "node", + "--input-type=module", + "--eval", + f"{ROUND_HELPER.read_text(encoding='utf-8')}\nconsole.log({rounded!s})", + ], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout.strip() == expected + assert rounded._var_type is (int if ndigits == 0 else float) + + @pytest.mark.parametrize( ("var", "expected"), [