Skip to content

strict typing and implemetation of Comparison#879

Draft
tias wants to merge 1 commit intomypy_oper_initfrom
mypy_strict_comparison
Draft

strict typing and implemetation of Comparison#879
tias wants to merge 1 commit intomypy_oper_initfrom
mypy_strict_comparison

Conversation

@tias
Copy link
Copy Markdown
Collaborator

@tias tias commented Mar 23, 2026

A first attempt to make the typing and implementation more precise.

Reveals many interesting things:

  • the test suite also posted Const CMP Expr, which is never possible for a user (python reverse-posts the comparison) so we should not support nor test it
  • the test suite generates BoolExpr == 4 which is invalid and we now raise...
  • it seems like the testsuite also generates Incomplete x // ys which we now trip over because value() no longer uses argval() which silently hid that; maybe we should explicitly catch expected Incomplete's in the test suite? or they are generated by accident (no partial transform)?

Also, we now, as a hot path, try to convert any non-Expression to integer. If it fails, ValueError, if it succeeds we have our int. But this 'silently' converts integers to floats... (and we have a Z3 test that used a float) We don't support floats so we shouldn't test floats, but do we find it acceptable to silently convert floats? I do, but we should document it then...
(the alternative is 2 additional isinstance checks on every non-Expression in the comparison RHS)

@tias tias requested a review from IgnaceBleukx March 23, 2026 11:15
Copy link
Copy Markdown
Collaborator

@IgnaceBleukx IgnaceBleukx left a comment

Choose a reason for hiding this comment

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

Indeed many interesting aspects!

I agree we should not support floats, but the silent cast gives me some anxiety... Numpy used to have a np.can_cast function for checking if the cast is safe, but it no longer works for Python ints/floats; so not sure if there still is an efficient way of handling floats in a safe manner (i.e., raising an error/warning)

For the .value()/argval change, see my comment above, I strongly believe we should either catch the IncompleteFunctionErr in the .value() function of Comparison; or stick with using argval there too.

Comment thread cpmpy/expressions/core.py
elif other_int == 0:
return ~self
else:
raise ValueError(f"Comparison {self} == {other_int} is not valid. Expected Boolean Expression or Boolean constant, but got {other_int}.")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

According to our own spec, it is actually valid? You can use a Boolean expression as an integer anywhere you like. It's just that it is replaced withfalse by the simplify_boolean transformation.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We can't return False here just yet tho, as we need to keep all variables until we arrive at the solver

Comment thread cpmpy/expressions/core.py
if other is False or other == 0:
return ~self
return Comparison("==", self, other)
def __eq__(self, other: object):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Shouldn't other be ExprLike?
Also, we can add the return type Expression?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

we have to be equal or more general than the superclass typing, which is 'object'

Comment thread cpmpy/expressions/core.py
elif other_int == 1:
return ~self
else:
raise ValueError(f"Comparison {self} == {other_int} is not valid. Expected Boolean Expression or Boolean constant, but got {other_int}.")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Copy-paste err in eror message

Comment thread cpmpy/expressions/core.py

def __lt__(self, other):
return Comparison("<", self, other)
def __lt__(self, other: object):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

For these we can add return type Comparison

Comment thread cpmpy/expressions/core.py
allowed = {'==', '!=', '<=', '<', '>=', '>'}

def __init__(self, name: str, left: ExprLike, right: ExprLike) -> None:
def __init__(self, name: str, left: Expression, right: Expression|int) -> None:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I agree with this change, but we should add something inthe docstring saying that these comparisons should not be created manually as we don't do any type checking in the constructor.
The fact that left is never an integer is purely due to the way how Python implements operator overloading

Comment thread cpmpy/expressions/core.py
elif self.name == ">": return arg_vals[0] > arg_vals[1]
elif self.name == ">=": return arg_vals[0] >= arg_vals[1]
return None # default
lhs_val = self.args[0].value()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why not use argvals here? There are certainly cases where this left-hand side can be partial?
On the current master, the IncompleteFunctionErr raising was stopped at the Comparison level; but now it can travel further up when calling .value()
I would have to think a bit harder if it can also result in a wrong answer from the value function, but it's definetly unexpected:

x,y = cp.intvar(0,5, shape=2, name=tuple("xy"))

x._value = 3
y._value = 0

comp = x//y == 2

print(comp.value()) # raises IncompleteFunctionErr
print(argval(comp)) # ok this works fine

bv = cp.boolvar()
bv._value = True

expr = comp == bv
print(expr)

print(expr.value()) # also raises IncompleteFunctionErr, used to work fine on master
print(argval(expr))

Comment thread tests/test_constraints.py
for x,y in [(numexpr,rhs), (rhs,numexpr)]:
# check if the constraint we are trying to construct is always UNSAT
if any(eval_comparison(comp_name, xb,yb) for xb in get_bounds(x) for yb in get_bounds(y)):
if any(eval_comparison(comp_name, xb,yb) for xb in get_bounds(x) for yb in get_bounds(y)) \
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We should update this test to use the utils.eval_comparison function, as this is also the way uses will/should make the comparisons themselves

@tias
Copy link
Copy Markdown
Collaborator Author

tias commented Mar 26, 2026

I'm going to leave this hanging to pick up later (probably in separate PRs)

So, identified issues:

  • floats in tests: we have no intention to support them in our standard Expressions anymore, we should update the tests to not use this
  • floats should not be silently convert in expressions (probably)
  • Comparison can be typed more strictly, but the test generation does put constants as LHS (users cant)
  • value() on comparison with undefined functions... what is the right behaviour? (catch the Incomplete or let it propagate up?)
  • smth with a test update, last comment

@tias tias marked this pull request as draft March 26, 2026 09:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants