From a54ddaa7eb36602f81c45906facccc8b37d15d0e Mon Sep 17 00:00:00 2001 From: 40% Date: Mon, 19 Oct 2026 21:18:36 +0800 Subject: [PATCH 1/7] Fix negative-power expression evaluation Evaluate PowExpr values with C's pow function and raise ZeroDivisionError for zero raised to a negative exponent. Add regression coverage for negative exponents and division by zero. --- src/pyscipopt/expr.pxi | 7 ++++++- tests/test_expr.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/pyscipopt/expr.pxi b/src/pyscipopt/expr.pxi index 3886df3d4..2bd883e82 100644 --- a/src/pyscipopt/expr.pxi +++ b/src/pyscipopt/expr.pxi @@ -58,6 +58,7 @@ from libc.math cimport cos as c_cos from libc.math cimport exp as c_exp from libc.math cimport fabs as c_fabs from libc.math cimport log as c_log +from libc.math cimport pow as c_pow from libc.math cimport sqrt as c_sqrt from libc.math cimport sin as c_sin from libc.math cimport INFINITY @@ -841,7 +842,11 @@ cdef class PowExpr(GenExpr): return f"**({self.children[0]},{self.expo})" cpdef double _evaluate(self, Solution sol) except *: - return (self.children[0])._evaluate(sol) ** self.expo + cdef double val = (self.children[0])._evaluate(sol) + cdef double expo = self.expo + if val == 0.0 and expo < 0: + raise ZeroDivisionError("0.0 cannot be raised to a negative power") + return c_pow(val, expo) cdef PowExpr copy(self, bint copy=True): cdef PowExpr res = PowExpr.__new__(PowExpr) diff --git a/tests/test_expr.py b/tests/test_expr.py index c5beb2ecb..a0fff1cfb 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -257,6 +257,23 @@ def test_getVal_with_GenExpr(): with pytest.raises(ValueError, match="math domain error"): m.getVal(sin(math.inf)) + +def test_getVal_pow_negative_exponent(): + m = Model() + x = m.addVar(lb=1, ub=1, name="x") + y = m.addVar(lb=2, ub=2, name="y") + z = m.addVar(lb=0, ub=0, name="z") + m.optimize() + + # C-level pow with a negative exponent (matches Python: 2.0**-2.0 == 0.25) + assert 2.0 ** -2.0 == 0.25 + assert m.getVal(x ** -2.0) == 1 + + # zero to a negative power raises, like Python's 0.0**-1.0; + # z / x would short-circuit to 0 in ProdExpr before evaluating x**-1, so divide by z + with pytest.raises(ZeroDivisionError): + m.getVal(y / z) + with pytest.raises(ValueError, match="math domain error"): m.getVal(cos(-math.inf)) From 751c7be239344f801f130a678ed0f1c7b96e6c69 Mon Sep 17 00:00:00 2001 From: 40% Date: Mon, 19 Oct 2026 22:04:10 +0800 Subject: [PATCH 2/7] Update CHANGELOG with PowExpr._evaluate speedup --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b875887ae..2a53c7fb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Replaced `format()` with f-strings in examples - Made expression fields read-only to prevent accidental runtime modification - Methods taking a list of variables (e.g. `addConsKnapsack()`) no longer convert it to a NumPy array to check its dimension +- Speed up `PowExpr._evaluate` via the C-level API ### Removed ## 6.3.0 - 2026.10.11 From ed1156bde6a5962e27aeb71ecc7dc8997c70e9d3 Mon Sep 17 00:00:00 2001 From: 40% Date: Tue, 20 Oct 2026 08:54:52 +0800 Subject: [PATCH 3/7] Clarify power expression base variable Rename the evaluated operand from `val` to `base` in `PowExpr._evaluate` for clearer naming. --- src/pyscipopt/expr.pxi | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pyscipopt/expr.pxi b/src/pyscipopt/expr.pxi index c601194f7..ef65a34ec 100644 --- a/src/pyscipopt/expr.pxi +++ b/src/pyscipopt/expr.pxi @@ -840,11 +840,11 @@ cdef class PowExpr(GenExpr): return f"**({self.children[0]},{self.expo})" cpdef double _evaluate(self, Solution sol) except *: - cdef double val = (self.children[0])._evaluate(sol) + cdef double base = (self.children[0])._evaluate(sol) cdef double expo = self.expo - if val == 0.0 and expo < 0: + if base == 0.0 and expo < 0: raise ZeroDivisionError("0.0 cannot be raised to a negative power") - return c_pow(val, expo) + return c_pow(base, expo) cdef PowExpr copy(self, bint copy=True): cdef PowExpr res = PowExpr.__new__(PowExpr) From 53eaefca445652e12f2d4a5b742ce5cb80665c77 Mon Sep 17 00:00:00 2001 From: 40% Date: Wed, 28 Oct 2026 20:23:58 +0800 Subject: [PATCH 4/7] Raise domain errors for invalid powers Make PowExpr evaluation raise ValueError for negative bases with non-integer exponents, matching SCIP's power-expression domain. Add coverage for invalid powers and related evaluation errors. --- CHANGELOG.md | 1 + src/pyscipopt/expr.pxi | 3 +++ tests/test_expr.py | 30 +++++++++++++++++------------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a53c7fb6..dae03c47d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Dual and Farkas getters for linear, knapsack, and logicor constraints now return zero when presolving has removed them. - Fixed incorrect `getObjVal()` result when called before `getBestSol()` (e.g., in event handlers). - Fixed `getDualsolKnapsack()` and `getDualfarkasKnapsack()` returning 0 for original constraints, and the handler check in `getVarsAnd()` that could never fail +- `PowExpr._evaluate` now raises `ValueError("math domain error")` for a negative base with a non-integer exponent instead of returning `NaN`, matching SCIP's power expression domain ### Changed - Replaced `format()` with f-strings in examples - Made expression fields read-only to prevent accidental runtime modification diff --git a/src/pyscipopt/expr.pxi b/src/pyscipopt/expr.pxi index ef65a34ec..59216f0d9 100644 --- a/src/pyscipopt/expr.pxi +++ b/src/pyscipopt/expr.pxi @@ -57,6 +57,7 @@ from cpython.tuple cimport PyTuple_GET_ITEM from libc.math cimport cos as c_cos from libc.math cimport exp as c_exp from libc.math cimport fabs as c_fabs +from libc.math cimport floor as c_floor from libc.math cimport log as c_log from libc.math cimport pow as c_pow from libc.math cimport sqrt as c_sqrt @@ -844,6 +845,8 @@ cdef class PowExpr(GenExpr): cdef double expo = self.expo if base == 0.0 and expo < 0: raise ZeroDivisionError("0.0 cannot be raised to a negative power") + elif base < 0.0 and expo != c_floor(expo): + raise ValueError("math domain error") return c_pow(base, expo) cdef PowExpr copy(self, bint copy=True): diff --git a/tests/test_expr.py b/tests/test_expr.py index a0fff1cfb..d331d7222 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -236,6 +236,9 @@ def test_getVal_with_GenExpr(): with pytest.raises(ZeroDivisionError): m.getVal(1 / z) + # a zero factor evaluated before the zero divisor must not hide the error + with pytest.raises(ZeroDivisionError): + m.getVal(y / z) # math domain errors match the math module with pytest.raises(ValueError, match="math domain error"): @@ -256,30 +259,31 @@ def test_getVal_with_GenExpr(): # sin and cos reject infinite arguments, like math.sin(inf) with pytest.raises(ValueError, match="math domain error"): m.getVal(sin(math.inf)) + with pytest.raises(ValueError, match="math domain error"): + m.getVal(cos(-math.inf)) + # nested unary expressions propagate the inner domain error + with pytest.raises(ValueError, match="math domain error"): + m.getVal(exp(log(-x))) -def test_getVal_pow_negative_exponent(): + +def test_getVal_pow(): m = Model() - x = m.addVar(lb=1, ub=1, name="x") y = m.addVar(lb=2, ub=2, name="y") z = m.addVar(lb=0, ub=0, name="z") + n = m.addVar(lb=-2, ub=-2, name="n") m.optimize() # C-level pow with a negative exponent (matches Python: 2.0**-2.0 == 0.25) - assert 2.0 ** -2.0 == 0.25 - assert m.getVal(x ** -2.0) == 1 - - # zero to a negative power raises, like Python's 0.0**-1.0; - # z / x would short-circuit to 0 in ProdExpr before evaluating x**-1, so divide by z - with pytest.raises(ZeroDivisionError): - m.getVal(y / z) + assert m.getVal(y ** -2.0) == 0.25 + # non-integer exponents are undefined for negative bases, like math.pow with pytest.raises(ValueError, match="math domain error"): - m.getVal(cos(-math.inf)) + m.getVal(n ** 0.5) - # nested unary expressions propagate the inner domain error - with pytest.raises(ValueError, match="math domain error"): - m.getVal(exp(log(-x))) + # zero to a negative power raises, like Python's 0.0**-1.0 + with pytest.raises(ZeroDivisionError): + m.getVal(z ** -1.0) def test_unary_ufunc(model): From 15e619108f1164edb93aa15a831118a142ffb8d4 Mon Sep 17 00:00:00 2001 From: 40% Date: Wed, 28 Oct 2026 20:42:23 +0800 Subject: [PATCH 5/7] Remove fragile division-by-zero assertion Drop the expectation that evaluating `y / z` raises `ZeroDivisionError` when the numerator is a variable. --- tests/test_expr.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/test_expr.py b/tests/test_expr.py index d331d7222..061bd3ec4 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -236,9 +236,6 @@ def test_getVal_with_GenExpr(): with pytest.raises(ZeroDivisionError): m.getVal(1 / z) - # a zero factor evaluated before the zero divisor must not hide the error - with pytest.raises(ZeroDivisionError): - m.getVal(y / z) # math domain errors match the math module with pytest.raises(ValueError, match="math domain error"): From 53f82f585bc7c01c231f7c0ab0d94e740c13d744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Dion=C3=ADsio?= <57299939+Joao-Dionisio@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:52:22 +0000 Subject: [PATCH 6/7] Apply suggestion from @Joao-Dionisio --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f89efe9b..b005313db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,13 +11,13 @@ - Dual and Farkas getters for linear, knapsack, and logicor constraints now return zero when presolving has removed them. - Fixed incorrect `getObjVal()` result when called before `getBestSol()` (e.g., in event handlers). - Fixed `getDualsolKnapsack()` and `getDualfarkasKnapsack()` returning 0 for original constraints, and the handler check in `getVarsAnd()` that could never fail -- `PowExpr._evaluate` now raises `ValueError("math domain error")` for a negative base with a non-integer exponent instead of returning `NaN`, matching SCIP's power expression domain ### Changed - Replaced `format()` with f-strings in examples - Refactor general expression conversion into per-class `to_scip_expr` recursion - Made expression fields read-only to prevent accidental runtime modification - Methods taking a list of variables (e.g. `addConsKnapsack()`) no longer convert it to a NumPy array to check its dimension - Speed up `PowExpr._evaluate` via the C-level API +- `PowExpr._evaluate` now raises `ValueError("math domain error")` for a negative base with a non-integer exponent. ### Removed ## 6.3.0 - 2026.10.11 From 89ea2f24c14868d01e064ffdda91bcd7bdf89335 Mon Sep 17 00:00:00 2001 From: 40% Date: Wed, 28 Oct 2026 22:37:38 +0800 Subject: [PATCH 7/7] Raise OverflowError on power overflow Match math.pow by raising OverflowError when c_pow returns an infinite result for finite base and exponent. --- CHANGELOG.md | 2 +- src/pyscipopt/expr.pxi | 6 +++++- tests/test_expr.py | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b005313db..29b8597bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - Made expression fields read-only to prevent accidental runtime modification - Methods taking a list of variables (e.g. `addConsKnapsack()`) no longer convert it to a NumPy array to check its dimension - Speed up `PowExpr._evaluate` via the C-level API -- `PowExpr._evaluate` now raises `ValueError("math domain error")` for a negative base with a non-integer exponent. +- `PowExpr._evaluate` now raises `ValueError("math domain error")` for a negative base with a non-integer exponent and `OverflowError` when the result overflows. ### Removed ## 6.3.0 - 2026.10.11 diff --git a/src/pyscipopt/expr.pxi b/src/pyscipopt/expr.pxi index d24f900a2..46414b2ca 100644 --- a/src/pyscipopt/expr.pxi +++ b/src/pyscipopt/expr.pxi @@ -911,7 +911,11 @@ cdef class PowExpr(GenExpr): raise ZeroDivisionError("0.0 cannot be raised to a negative power") elif base < 0.0 and expo != c_floor(expo): raise ValueError("math domain error") - return c_pow(base, expo) + + cdef double res = c_pow(base, expo) + if c_fabs(res) == INFINITY and c_fabs(base) != INFINITY and c_fabs(expo) != INFINITY: + raise OverflowError("math range error") + return res cdef PowExpr copy(self, bint copy=True): cdef PowExpr res = PowExpr.__new__(PowExpr) diff --git a/tests/test_expr.py b/tests/test_expr.py index 061bd3ec4..7368dd3ca 100644 --- a/tests/test_expr.py +++ b/tests/test_expr.py @@ -282,6 +282,9 @@ def test_getVal_pow(): with pytest.raises(ZeroDivisionError): m.getVal(z ** -1.0) + with pytest.raises(OverflowError, match="math range error"): + m.getVal(exp(y) ** 10000.0) + def test_unary_ufunc(model): m, x, y, z = model