Repository: CPMpy/cpmpy
Pull request #630: Discussing whether we support constants for Element decomposition
Author: login: hbierlee
Created: 2025-04-11T13:42:23Z | Merged: 2025-04-16T12:54:20Z

So the issue is the Element's decomposition does not work for integer constants as index, only integer variables. So the following test failed for z3, and after my fix still for or-tools:

```python
# constant look-up
self.assertTrue(cp.Model([cp.boolvar() == cp.Element([0], 0)]).solve(solver=s))
# constant out-of-bounds look-up
self.assertFalse(cp.Model([cp.boolvar() == cp.Element([0], 1)]).solve(solver=s))
```

However, if the user uses the overloaded Python array indexing (e.g. `[0][0]`), then we don't get this issue in the first place.

So the question is a) should the decompositions of global constraints support constants for (some) arguments, and b) do we support the use of cp.Element, or only of array indexing (e.g. should the model just have [0][0], which would just work)

Depending on these questions, the PR can just be closed, otherwise I need to still resolve an issue for OR-tools.


Diff:
diff --git a/cpmpy/expressions/globalfunctions.py b/cpmpy/expressions/globalfunctions.py
index 0e25b002d..3e40c51a8 100644
--- a/cpmpy/expressions/globalfunctions.py
+++ b/cpmpy/expressions/globalfunctions.py
@@ -68,9 +68,9 @@ def decompose_comparison(self):
 import cpmpy as cp
 
 from ..exceptions import CPMpyException, IncompleteFunctionError, TypeError
-from .core import Expression, Operator, Comparison
+from .core import Expression, Operator
 from .variables import boolvar, intvar, cpm_array
-from .utils import flatlist, argval, is_num, eval_comparison, is_any_list, is_boolexpr, get_bounds, argvals
+from .utils import flatlist, argval, is_num, eval_comparison, is_any_list, is_boolexpr, get_bounds, argvals, get_bounds, implies
 
 
 class GlobalFunction(Expression):
@@ -237,6 +237,7 @@ def element(arg_list):
     warnings.warn("Deprecated, use Element(arr,idx) instead, will be removed in stable version", DeprecationWarning)
     assert (len(arg_list) == 2), "Element expression takes 2 arguments: Arr, Idx"
     return Element(arg_list[0], arg_list[1])
+
 class Element(GlobalFunction):
     """
         The 'Element' global constraint enforces that the result equals Arr[Idx]
@@ -285,8 +286,15 @@ def decompose_comparison(self, cpm_op, cpm_rhs):
 
         """
         arr, idx = self.args
-        return [(idx == i).implies(eval_comparison(cpm_op, arr[i], cpm_rhs)) for i in range(len(arr))] + \
-               [idx >= 0, idx < len(arr)], []
+        # Find where the array indices and the bounds of `idx` intersect
+        lb, ub = get_bounds(idx)
+        new_lb, new_ub = max(lb, 0), min(ub, len(arr) - 1)
+        cons=[]
+        # For every `i` in that intersection, post `(idx = i) -> idx=i -> arr[i] <CMP_OP> cpm_rhs`.
+        for i in range(new_lb, new_ub+1):
+            cons.append(implies(idx == i, eval_comparison(cpm_op, arr[i], cpm_rhs)))
+        cons+=[idx >= new_lb, idx <= new_ub]  # also enforce the new bounds 
+        return cons, []  # no auxiliary variables
 
     def __repr__(self):
         return "{}[{}]".format(self.args[0], self.args[1])
diff --git a/cpmpy/expressions/utils.py b/cpmpy/expressions/utils.py
index 8cfda1b06..b511a83b8 100644
--- a/cpmpy/expressions/utils.py
+++ b/cpmpy/expressions/utils.py
@@ -194,6 +194,17 @@ def get_bounds(expr):
             return int(expr), int(expr)
         return math.floor(expr), math.ceil(expr)
 
+def implies(expr, other):
+    """ like :func:`~cpmpy.expressions.core.Expression.implies`, but also safe to use for non-expressions """
+    if isinstance(expr, cp.expressions.core.Expression):
+        return expr.implies(other)
+    elif is_true_cst(expr):
+        return other
+    elif is_false_cst(expr):
+        return BoolVal(True)
+    else:
+        return expr.implies(other)
+
 # Specific stuff for ShortTabel global (should this be in globalconstraints.py instead?)
 STAR = "*" # define constant here
 def is_star(arg):
diff --git a/cpmpy/solvers/ortools.py b/cpmpy/solvers/ortools.py
index ef144320c..8cc6bef60 100644
--- a/cpmpy/solvers/ortools.py
+++ b/cpmpy/solvers/ortools.py
@@ -41,9 +41,9 @@
 from ..exceptions import NotSupportedError
 from ..expressions.core import Expression, Comparison, Operator, BoolVal
 from ..expressions.globalconstraints import DirectConstraint
-from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView, boolvar
+from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView, boolvar, intvar
 from ..expressions.globalconstraints import GlobalConstraint
-from ..expressions.utils import is_num, eval_comparison, flatlist, argval, argvals, get_bounds
+from ..expressions.utils import is_num, is_int, eval_comparison, flatlist, argval, argvals, get_bounds
 from ..transformations.decompose_global import decompose_in_tree
 from ..transformations.get_variables import get_variables
 from ..transformations.flatten_model import flatten_constraint, flatten_objective, get_or_make_var
@@ -480,9 +480,15 @@ def _post_constraint(self, cpm_expr, reifiable=False):
                 elif lhs.name == 'div':
                     return self.ort_model.AddDivisionEquality(ortrhs, *self.solver_vars(lhs.args))
                 elif lhs.name == 'element':
-                    # arr[idx]==rvar (arr=arg0,idx=arg1), ort: (idx,arr,target)
-                    return self.ort_model.AddElement(self.solver_var(lhs.args[1]),
-                                                     self.solver_vars(lhs.args[0]), ortrhs)
+                    arr, idx = lhs.args
+                    if is_int(idx): # OR-Tools does not handle all constant integer cases
+                        idx = intvar(idx,idx)
+                    # OR-Tools has slight different in argument order
+                    return self.ort_model.AddElement(
+                        self.solver_var(idx),
+                        self.solver_vars(arr),
+                        ortrhs
+                    )
                 elif lhs.name == 'mod':
                     # catch tricky-to-find ortools limitation
                     x,y = lhs.args
diff --git a/tests/test_constraints.py b/tests/test_constraints.py
index 60383da66..ef3684591 100644
--- a/tests/test_constraints.py
+++ b/tests/test_constraints.py
@@ -4,6 +4,7 @@
 from cpmpy import Model, SolverLookup, BoolVal
 from cpmpy.expressions.globalconstraints import *
 from cpmpy.expressions.globalfunctions import *
+from cpmpy.expressions.core import Comparison
 
 import pytest
 
diff --git a/tests/test_globalconstraints.py b/tests/test_globalconstraints.py
index 16168ae3f..aaf4efa49 100644
--- a/tests/test_globalconstraints.py
+++ b/tests/test_globalconstraints.py
@@ -1325,3 +1325,25 @@ def test_table(self):
         self.assertRaises(TypeError, cp.Table, [iv[0], iv[1], iv[2], 5], [(5, 2, 2)])
         self.assertRaises(TypeError, cp.Table, [iv[0], iv[1], iv[2], [5]], [(5, 2, 2)])
         self.assertRaises(TypeError, cp.Table, [iv[0], iv[1], iv[2], ['a']], [(5, 2, 2)])
+
+    def test_issue627(self):
+        for s, cls in cp.SolverLookup.base_solvers():
+            if cls.supported():
+                try:
+                    # constant look-up
+                    self.assertTrue(cp.Model([cp.boolvar() == cp.Element([0], 0)]).solve(solver=s))
+                    # constant out-of-bounds look-up
+                    self.assertFalse(cp.Model([cp.boolvar() == cp.Element([0], 1)]).solve(solver=s))
+                except (NotImplementedError, NotSupportedError):
+                    pass
+
+    def test_element_index_dom_mismatched(self):
+        """
+            Check transform of `[0,1,2][x in -1..1] == y in 1..5`
+            Note the index variable has a lower bound *outside* the indexable range, and an upper bound inside AND lower than the indexable range upper bound
+        """
+        constraint=cp.Element([0,1,2], cp.intvar(-1,1, name="x"))
+        self.assertEqual(
+            str(constraint.decompose_comparison("==", cp.intvar(1,5, name="y"))),
+            "([(x == 0) -> (y == 0), (x == 1) -> (y == 1), x >= 0, x <= 1], [])"
+        )


Reviews:
user: login: IgnaceBleukx
body: The idx-replacement with a variable should not happen at construction time, but rather in the OR-Tools interface.

Other than that 2 small comments to fix
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-15T09:36:09Z
user: login: hbierlee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T11:37:59Z
user: login: hbierlee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T11:39:19Z
user: login: hbierlee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T16:35:10Z
user: login: IgnaceBleukx
body: Hmm, I actually think this is a bit of a step backwards compared to the last review... Especially the decomposition code is much less readable and maintainable than before imho
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T07:24:48Z
user: login: hbierlee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:16:16Z
user: login: IgnaceBleukx
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T09:18:50Z
user: login: hbierlee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:27:31Z
user: login: hbierlee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:27:51Z
user: login: hbierlee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:34:19Z
user: login: hbierlee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:43:50Z
user: login: IgnaceBleukx
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T09:46:59Z
user: login: hbierlee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T09:59:23Z
user: login: hbierlee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T10:10:26Z
user: login: hbierlee
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T10:11:06Z
user: login: IgnaceBleukx
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-16T12:16:51Z
user: login: IgnaceBleukx
body: Yes, good to merge
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-16T12:17:35Z

Review comments:
diff_hunk: @@ -285,8 +287,19 @@ def decompose_comparison(self, cpm_op, cpm_rhs):
 
         """
         arr, idx = self.args
-        return [(idx == i).implies(eval_comparison(cpm_op, arr[i], cpm_rhs)) for i in range(len(arr))] + \
-               [idx >= 0, idx < len(arr)], []
+        cons = []
+        # the decomposition posts `idx=i -> arr[i] <CMP_OP> cpm_rhs`
+        # for every  `i` in the intersection of array indices and the bounds of `idx`
+        lb, ub = idx.get_bounds()
path: cpmpy/expressions/globalfunctions.py
user: login: IgnaceBleukx
body: This will go wrong when idx is not an expression (see prev comment). Better to use `utils.get_bounds(idx)` instead.
created_at: 2025-04-15T09:31:11Z
author_association: COLLABORATOR
diff_hunk: @@ -256,6 +256,8 @@ def __init__(self, arr, idx):
             raise TypeError("index cannot be a boolean expression: {}".format(idx))
         if is_any_list(idx):
             raise TypeError("For using multiple dimensions in the Element constraint, use comma-separated indices")
+        if is_int(idx): # prevents OR-Tools error
path: cpmpy/expressions/globalfunctions.py
user: login: IgnaceBleukx
body: If it is OR-Tools specific, it should be fixed in the OR-Tools interface before posting the constraint. Now the user will see another expression that he created when printing it.

I.e., print(`Element(arr, 42) <= 1337`) will print `arr[IV0] <= 1337`
created_at: 2025-04-15T09:33:00Z
author_association: COLLABORATOR
diff_hunk: @@ -285,8 +287,19 @@ def decompose_comparison(self, cpm_op, cpm_rhs):
 
         """
         arr, idx = self.args
-        return [(idx == i).implies(eval_comparison(cpm_op, arr[i], cpm_rhs)) for i in range(len(arr))] + \
-               [idx >= 0, idx < len(arr)], []
+        cons = []
+        # the decomposition posts `idx=i -> arr[i] <CMP_OP> cpm_rhs`
+        # for every  `i` in the intersection of array indices and the bounds of `idx`
+        lb, ub = idx.get_bounds()
+        new_lb, new_ub = max(lb, 0), min(ub, len(arr))
+        for i in range(new_lb, new_ub):
+            cond = idx == i
+            if is_true_cst(cond):
+                return [eval_comparison(cpm_op, arr[i], cpm_rhs)], []
+            elif is_false_cst(cond):
path: cpmpy/expressions/globalfunctions.py
user: login: IgnaceBleukx
body: Not strictly necesarry, as we also check this in the `implies` function of `BoolVal`, but fine to keep.
Would prefer to add a small comment tho
created_at: 2025-04-15T09:34:02Z
author_association: COLLABORATOR
diff_hunk: @@ -285,8 +287,19 @@ def decompose_comparison(self, cpm_op, cpm_rhs):
 
         """
         arr, idx = self.args
-        return [(idx == i).implies(eval_comparison(cpm_op, arr[i], cpm_rhs)) for i in range(len(arr))] + \
-               [idx >= 0, idx < len(arr)], []
+        cons = []
+        # the decomposition posts `idx=i -> arr[i] <CMP_OP> cpm_rhs`
+        # for every  `i` in the intersection of array indices and the bounds of `idx`
+        lb, ub = idx.get_bounds()
path: cpmpy/expressions/globalfunctions.py
user: login: hbierlee
body: Ah, definitely the function I was looking for! Thanks
created_at: 2025-04-15T11:37:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -256,6 +256,8 @@ def __init__(self, arr, idx):
             raise TypeError("index cannot be a boolean expression: {}".format(idx))
         if is_any_list(idx):
             raise TypeError("For using multiple dimensions in the Element constraint, use comma-separated indices")
+        if is_int(idx): # prevents OR-Tools error
path: cpmpy/expressions/globalfunctions.py
user: login: hbierlee
body: Right, definitely the better approach (I was unable to come up with).
created_at: 2025-04-15T11:39:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -285,8 +287,19 @@ def decompose_comparison(self, cpm_op, cpm_rhs):
 
         """
         arr, idx = self.args
-        return [(idx == i).implies(eval_comparison(cpm_op, arr[i], cpm_rhs)) for i in range(len(arr))] + \
-               [idx >= 0, idx < len(arr)], []
+        cons = []
+        # the decomposition posts `idx=i -> arr[i] <CMP_OP> cpm_rhs`
+        # for every  `i` in the intersection of array indices and the bounds of `idx`
+        lb, ub = idx.get_bounds()
+        new_lb, new_ub = max(lb, 0), min(ub, len(arr))
+        for i in range(new_lb, new_ub):
+            cond = idx == i
+            if is_true_cst(cond):
+                return [eval_comparison(cpm_op, arr[i], cpm_rhs)], []
+            elif is_false_cst(cond):
path: cpmpy/expressions/globalfunctions.py
user: login: hbierlee
body: Ah true, I only checked implies of `BoolVar`, which only checks if the consequent is fixed. But then I'd rather go for simple code depending on the (good) implementation of implies, forgoing the early quit. So I'll remove these. I did have to add an utils.implies function which is also safe for expressions. I think this is very usefull to make more of the globals safe for constants. You can let me know what you think of the final decomposition
created_at: 2025-04-15T16:35:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -285,8 +285,14 @@ def decompose_comparison(self, cpm_op, cpm_rhs):
 
         """
         arr, idx = self.args
-        return [(idx == i).implies(eval_comparison(cpm_op, arr[i], cpm_rhs)) for i in range(len(arr))] + \
-               [idx >= 0, idx < len(arr)], []
+        # for every  `i` in the intersection of array indices and the bounds of `idx`..
+        lb, ub = get_bounds(idx)
+        new_lb, new_ub = max(lb, 0), min(ub, len(arr) - 1)
+        return [  # ..we post `(idx = i) -> idx=i -> arr[i] <CMP_OP> cpm_rhs`.
path: cpmpy/expressions/globalfunctions.py
user: login: IgnaceBleukx
body: I very very very much prefer the previous code with the explicit for-loop here... This is quite hard to parse and it is very unclear what is happening...

created_at: 2025-04-16T07:05:57Z
author_association: COLLABORATOR
diff_hunk: @@ -194,6 +194,17 @@ def get_bounds(expr):
             return int(expr), int(expr)
         return math.floor(expr), math.ceil(expr)
 
+def implies(expr, other):
+    """ like :func:`~cpmpy.expressions.core.Expression.implies`, but also safe to use for non-expressions """
+    if isinstance(expr, cp.expressions.core.Expression):
+        return expr.implies(other)
+    elif arg is True or arg is np.True_:
path: cpmpy/expressions/utils.py
user: login: IgnaceBleukx
body: This is the same logic as `is_true_cst` which is already in utils.
That being said, I don't think you need this helper function for this PR, and I prefer just checking whether `idx == i` is Boolean is (not) an expression in the Element decomposition.

We could introduce the helper-function nevertheless, but then we should also change the use of `.implies` in other places in the code base. I suppose we have this logic in multiple places at the moment, thereby explicitly checking for `is_bool` and converting to `BoolVal`.
 Better to open an separate issue and/or PR for that as it is out of scope for this one.
created_at: 2025-04-16T07:08:33Z
author_association: COLLABORATOR
diff_hunk: @@ -70,7 +70,7 @@ def decompose_comparison(self):
 from ..exceptions import CPMpyException, IncompleteFunctionError, TypeError
 from .core import Expression, Operator, Comparison
 from .variables import boolvar, intvar, cpm_array
-from .utils import flatlist, argval, is_num, eval_comparison, is_any_list, is_boolexpr, get_bounds, argvals
+from .utils import flatlist, argval, is_num, is_int, eval_comparison, is_any_list, is_boolexpr, get_bounds, argvals, is_bool, is_true_cst, is_false_cst, get_bounds, implies
path: cpmpy/expressions/globalfunctions.py
user: login: IgnaceBleukx
body: Not all of these new imports are used
created_at: 2025-04-16T07:15:29Z
author_association: COLLABORATOR
diff_hunk: @@ -480,9 +480,15 @@ def _post_constraint(self, cpm_expr, reifiable=False):
                 elif lhs.name == 'div':
                     return self.ort_model.AddDivisionEquality(ortrhs, *self.solver_vars(lhs.args))
                 elif lhs.name == 'element':
-                    # arr[idx]==rvar (arr=arg0,idx=arg1), ort: (idx,arr,target)
-                    return self.ort_model.AddElement(self.solver_var(lhs.args[1]),
-                                                     self.solver_vars(lhs.args[0]), ortrhs)
+                    arr, idx = lhs.args
+                    if is_int(idx) and (idx < 0 or idx >= len(arr)):
path: cpmpy/solvers/ortools.py
user: login: IgnaceBleukx
body: So, OR-Tools is fine with constants as long as it is within bounds?
created_at: 2025-04-16T07:20:39Z
author_association: COLLABORATOR
diff_hunk: @@ -1325,3 +1325,22 @@ def test_table(self):
         self.assertRaises(TypeError, cp.Table, [iv[0], iv[1], iv[2], 5], [(5, 2, 2)])
         self.assertRaises(TypeError, cp.Table, [iv[0], iv[1], iv[2], [5]], [(5, 2, 2)])
         self.assertRaises(TypeError, cp.Table, [iv[0], iv[1], iv[2], ['a']], [(5, 2, 2)])
+
+    def test_issue627(self):
+        for s, cls in cp.SolverLookup.base_solvers():
+            if cls.supported():
+                try:
+                    # constant look-up
+                    self.assertTrue(cp.Model([cp.boolvar() == cp.Element([0], 0)]).solve(solver=s))
+                    # constant out-of-bounds look-up
+                    self.assertFalse(cp.Model([cp.boolvar() == cp.Element([0], 1)]).solve(solver=s))
+                except (NotImplementedError, NotSupportedError):
+                    pass
+
+    def test_element_index_dom_mismatched(self):
+        """
+            Check transform of `[0,1,2][x in -1..1] == y in 1..5`
+            Note the index variable has a lower bound *outside* the indexable range, and an upper bound inside AND lower than the indexable range upper bound
+        """
+        assert str(cp.Element([0,1,2], cp.intvar(-1,1, name="x")).decompose_comparison("==", cp.intvar(1,5, name="y"))) \
path: tests/test_globalconstraints.py
user: login: IgnaceBleukx
body: Please split this into multiple lines for maintainability, and use `self.assertEquals`
created_at: 2025-04-16T07:22:02Z
author_association: COLLABORATOR
diff_hunk: @@ -194,6 +194,17 @@ def get_bounds(expr):
             return int(expr), int(expr)
         return math.floor(expr), math.ceil(expr)
 
+def implies(expr, other):
+    """ like :func:`~cpmpy.expressions.core.Expression.implies`, but also safe to use for non-expressions """
+    if isinstance(expr, cp.expressions.core.Expression):
+        return expr.implies(other)
+    elif arg is True or arg is np.True_:
path: cpmpy/expressions/utils.py
user: login: IgnaceBleukx
body: Also, `arg` is undefined here, you mean `expr`
created_at: 2025-04-16T07:23:07Z
author_association: COLLABORATOR
diff_hunk: @@ -285,8 +285,14 @@ def decompose_comparison(self, cpm_op, cpm_rhs):
 
         """
         arr, idx = self.args
-        return [(idx == i).implies(eval_comparison(cpm_op, arr[i], cpm_rhs)) for i in range(len(arr))] + \
-               [idx >= 0, idx < len(arr)], []
+        # for every  `i` in the intersection of array indices and the bounds of `idx`..
+        lb, ub = get_bounds(idx)
+        new_lb, new_ub = max(lb, 0), min(ub, len(arr) - 1)
+        return [  # ..we post `(idx = i) -> idx=i -> arr[i] <CMP_OP> cpm_rhs`.
path: cpmpy/expressions/globalfunctions.py
user: login: hbierlee
body: I can change it back to that loop style (I'm a big fan of generators/maps/functional style, but it's a choice). Note that the original code was structured exactly the same, but now we have a tighter loop, safer `implies`, and more comments.
created_at: 2025-04-16T09:16:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -285,8 +285,14 @@ def decompose_comparison(self, cpm_op, cpm_rhs):
 
         """
         arr, idx = self.args
-        return [(idx == i).implies(eval_comparison(cpm_op, arr[i], cpm_rhs)) for i in range(len(arr))] + \
-               [idx >= 0, idx < len(arr)], []
+        # for every  `i` in the intersection of array indices and the bounds of `idx`..
+        lb, ub = get_bounds(idx)
+        new_lb, new_ub = max(lb, 0), min(ub, len(arr) - 1)
+        return [  # ..we post `(idx = i) -> idx=i -> arr[i] <CMP_OP> cpm_rhs`.
path: cpmpy/expressions/globalfunctions.py
user: login: IgnaceBleukx
body: Yes, I agree the generators are great, but not if they get so complicated we need to split them and add a comment to explain what they are doing ;) 
created_at: 2025-04-16T09:18:50Z
author_association: COLLABORATOR
diff_hunk: @@ -194,6 +194,17 @@ def get_bounds(expr):
             return int(expr), int(expr)
         return math.floor(expr), math.ceil(expr)
 
+def implies(expr, other):
+    """ like :func:`~cpmpy.expressions.core.Expression.implies`, but also safe to use for non-expressions """
+    if isinstance(expr, cp.expressions.core.Expression):
+        return expr.implies(other)
+    elif arg is True or arg is np.True_:
path: cpmpy/expressions/utils.py
user: login: hbierlee
body: the reason I don't use `is_true_cst` is because it would check more types than needed. Not sure if that is worth it though for readability.

Definitly good catch on the `arg`.

I thought this function will be helpfull in other casese, but I indeed didn't want to start looking for them in this PR. Please read the previous comment on the decomp itself, this function is necessary for it, if we change it I can put this logic inline and make a separate PR to fix this in some places.

created_at: 2025-04-16T09:27:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -480,9 +480,15 @@ def _post_constraint(self, cpm_expr, reifiable=False):
                 elif lhs.name == 'div':
                     return self.ort_model.AddDivisionEquality(ortrhs, *self.solver_vars(lhs.args))
                 elif lhs.name == 'element':
-                    # arr[idx]==rvar (arr=arg0,idx=arg1), ort: (idx,arr,target)
-                    return self.ort_model.AddElement(self.solver_var(lhs.args[1]),
-                                                     self.solver_vars(lhs.args[0]), ortrhs)
+                    arr, idx = lhs.args
+                    if is_int(idx) and (idx < 0 or idx >= len(arr)):
path: cpmpy/solvers/ortools.py
user: login: hbierlee
body: yes, this is tested
created_at: 2025-04-16T09:27:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -480,9 +480,15 @@ def _post_constraint(self, cpm_expr, reifiable=False):
                 elif lhs.name == 'div':
                     return self.ort_model.AddDivisionEquality(ortrhs, *self.solver_vars(lhs.args))
                 elif lhs.name == 'element':
-                    # arr[idx]==rvar (arr=arg0,idx=arg1), ort: (idx,arr,target)
-                    return self.ort_model.AddElement(self.solver_var(lhs.args[1]),
-                                                     self.solver_vars(lhs.args[0]), ortrhs)
+                    arr, idx = lhs.args
+                    if is_int(idx) and (idx < 0 or idx >= len(arr)):
path: cpmpy/solvers/ortools.py
user: login: hbierlee
body: (although I'd be in favour of just removing the 2nd condition for simplicity)
created_at: 2025-04-16T09:34:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -285,8 +285,14 @@ def decompose_comparison(self, cpm_op, cpm_rhs):
 
         """
         arr, idx = self.args
-        return [(idx == i).implies(eval_comparison(cpm_op, arr[i], cpm_rhs)) for i in range(len(arr))] + \
-               [idx >= 0, idx < len(arr)], []
+        # for every  `i` in the intersection of array indices and the bounds of `idx`..
+        lb, ub = get_bounds(idx)
+        new_lb, new_ub = max(lb, 0), min(ub, len(arr) - 1)
+        return [  # ..we post `(idx = i) -> idx=i -> arr[i] <CMP_OP> cpm_rhs`.
path: cpmpy/expressions/globalfunctions.py
user: login: hbierlee
body: Maybe I could have structured it better (not concatenate them in place, the comments are a bit creatively placed). I will change it to a loop, I can definitely understand why this is harder to read. Although I like this because it just exactly specifies the decomp as you would mathematically: `idx=i -> (arr[i] CMP rhs), for all i in domain(idx)`.
created_at: 2025-04-16T09:43:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -480,9 +480,15 @@ def _post_constraint(self, cpm_expr, reifiable=False):
                 elif lhs.name == 'div':
                     return self.ort_model.AddDivisionEquality(ortrhs, *self.solver_vars(lhs.args))
                 elif lhs.name == 'element':
-                    # arr[idx]==rvar (arr=arg0,idx=arg1), ort: (idx,arr,target)
-                    return self.ort_model.AddElement(self.solver_var(lhs.args[1]),
-                                                     self.solver_vars(lhs.args[0]), ortrhs)
+                    arr, idx = lhs.args
+                    if is_int(idx) and (idx < 0 or idx >= len(arr)):
path: cpmpy/solvers/ortools.py
user: login: IgnaceBleukx
body: Agreed, no strong opinion on keeping or removing it
created_at: 2025-04-16T09:46:59Z
author_association: COLLABORATOR
diff_hunk: @@ -480,9 +480,15 @@ def _post_constraint(self, cpm_expr, reifiable=False):
                 elif lhs.name == 'div':
                     return self.ort_model.AddDivisionEquality(ortrhs, *self.solver_vars(lhs.args))
                 elif lhs.name == 'element':
-                    # arr[idx]==rvar (arr=arg0,idx=arg1), ort: (idx,arr,target)
-                    return self.ort_model.AddElement(self.solver_var(lhs.args[1]),
-                                                     self.solver_vars(lhs.args[0]), ortrhs)
+                    arr, idx = lhs.args
+                    if is_int(idx) and (idx < 0 or idx >= len(arr)):
path: cpmpy/solvers/ortools.py
user: login: hbierlee
body: I'll go with simplicity
created_at: 2025-04-16T09:59:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -285,8 +285,14 @@ def decompose_comparison(self, cpm_op, cpm_rhs):
 
         """
         arr, idx = self.args
-        return [(idx == i).implies(eval_comparison(cpm_op, arr[i], cpm_rhs)) for i in range(len(arr))] + \
-               [idx >= 0, idx < len(arr)], []
+        # for every  `i` in the intersection of array indices and the bounds of `idx`..
+        lb, ub = get_bounds(idx)
+        new_lb, new_ub = max(lb, 0), min(ub, len(arr) - 1)
+        return [  # ..we post `(idx = i) -> idx=i -> arr[i] <CMP_OP> cpm_rhs`.
path: cpmpy/expressions/globalfunctions.py
user: login: hbierlee
body: I've changed it, but I think I will keep the `implies` util function, because a) it still makes the loop nicer imo and b) I don't think it's the effort or effective to create a PR to hunt for where this implies can be used, but in the future if we see the `bool has no implies` we can immediately fix it with this function. Let me know if you really prefer keeping it out, then I can still remove it to finish this PR
created_at: 2025-04-16T10:10:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -194,6 +194,17 @@ def get_bounds(expr):
             return int(expr), int(expr)
         return math.floor(expr), math.ceil(expr)
 
+def implies(expr, other):
+    """ like :func:`~cpmpy.expressions.core.Expression.implies`, but also safe to use for non-expressions """
+    if isinstance(expr, cp.expressions.core.Expression):
+        return expr.implies(other)
+    elif arg is True or arg is np.True_:
path: cpmpy/expressions/utils.py
user: login: hbierlee
body: See new refactor of the decomp (and resolve this comment; if I resolve it, I'm not sure if you will see this)
created_at: 2025-04-16T10:11:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -285,8 +285,14 @@ def decompose_comparison(self, cpm_op, cpm_rhs):
 
         """
         arr, idx = self.args
-        return [(idx == i).implies(eval_comparison(cpm_op, arr[i], cpm_rhs)) for i in range(len(arr))] + \
-               [idx >= 0, idx < len(arr)], []
+        # for every  `i` in the intersection of array indices and the bounds of `idx`..
+        lb, ub = get_bounds(idx)
+        new_lb, new_ub = max(lb, 0), min(ub, len(arr) - 1)
+        return [  # ..we post `(idx = i) -> idx=i -> arr[i] <CMP_OP> cpm_rhs`.
path: cpmpy/expressions/globalfunctions.py
user: login: IgnaceBleukx
body: Ok, sounds good indeed!
created_at: 2025-04-16T12:16:50Z
author_association: COLLABORATOR

PR comments:
user: login: IgnaceBleukx
created_at: 2025-04-13T07:42:46Z
body: I think we should support constants in the array of Element. 
In CPMPy we give the user freedom to use any constant or Expression as a subexpression in a constraint, as long as the types match.
Only constants is of course somewhat silly as it will just result in `BoolVal(False)` or `BoolVal(True)`, but I can certainly imagine a user mixing constants and variables in an Element array.

One way to fix this is in the decomposition is as follows:
```python
def decompose(self):
    arr, idx = self.args
    cons = []
    for i,var in enumerate(arr):
        cond = idx == i
        if is_bool(cond): # constant
            cond = BoolVal(cond)
        cons += [cond.implies(eval_comparison(cpm_op, var, cpm_rhs)]

    return cons + [idx >= 0, idx < len(arr)], []
```



author_association: COLLABORATOR
user: login: hbierlee
created_at: 2025-04-14T14:45:04Z
body: As discussed offline, we *should* supports constants whereever we support variables.

I had some fix which handles the special case of `idx` being aconstant integer, but I like Ignace more general approach where we handle the case of `cond` turning out to be Boolean. However it has some overlap (we only need one iteration if `idx` is constant), which I think I can easily fix.
author_association: CONTRIBUTOR
user: login: hbierlee
created_at: 2025-04-14T16:02:27Z
body: @IgnaceBleukx you can let me know what you think, it should work all cases. So I tightened the loop for the decomposition, which will lead to smaller models in some cases (when the indexes domain is smaller than the array's indexable range). It also checks whether `i==idx` like your approach, with on `True` an early stop, and on `False` skipping the constraint. The question is if these checks have an avoidable transformation performance overhead.

A separate issue was that without the decomposition, the `Element([0],1)` would get errors (like from OR-tools). I fixed this as best I could in the init by turning 1 into a variable, also ensuring the `get_bounds` works later on... not sure if there is a better approach.
author_association: CONTRIBUTOR
user: login: hbierlee
created_at: 2025-04-16T10:13:09Z
body: I processed all comments, should be about good to merge I think, just we can decide on this new until function in the decomp comment
author_association: CONTRIBUTOR