diff --git a/cpmpy/expressions/__init__.py b/cpmpy/expressions/__init__.py index 1a9a15351..5878c91b7 100644 --- a/cpmpy/expressions/__init__.py +++ b/cpmpy/expressions/__init__.py @@ -22,7 +22,7 @@ from .variables import boolvar, intvar, cpm_array from .variables import BoolVar, IntVar, cparray # Old, to be deprecated from .globalconstraints import AllDifferent, AllDifferentExcept0, AllDifferentLists, AllEqual, Circuit, Inverse, Table, Xor, Cumulative, \ - IfThenElse, GlobalCardinalityCount, DirectConstraint, InDomain, Increasing, Decreasing, IncreasingStrict, DecreasingStrict + IfThenElse, GlobalCardinalityCount, DirectConstraint, InDomain, Increasing, Decreasing, IncreasingStrict, DecreasingStrict, LexLess, LexLessEq, LexChainLess, LexChainLessEq from .globalconstraints import alldifferent, allequal, circuit # Old, to be deprecated from .globalfunctions import Maximum, Minimum, Abs, Element, Count, NValue, NValueExcept from .core import BoolVal diff --git a/cpmpy/expressions/globalconstraints.py b/cpmpy/expressions/globalconstraints.py index 023fd2d62..e7c22dc56 100644 --- a/cpmpy/expressions/globalconstraints.py +++ b/cpmpy/expressions/globalconstraints.py @@ -572,9 +572,8 @@ def decompose(self): return [args[i] <= args[i+1] for i in range(len(args)-1)], [] def value(self): - from .python_builtins import all - args = self.args - return all(args[i].value() <= args[i+1].value() for i in range(len(args)-1)) + args = argvals(self.args) + return all(args[i] <= args[i+1] for i in range(len(args)-1)) class Decreasing(GlobalConstraint): @@ -595,9 +594,8 @@ def decompose(self): return [args[i] >= args[i+1] for i in range(len(args)-1)], [] def value(self): - from .python_builtins import all - args = self.args - return all(args[i].value() >= args[i+1].value() for i in range(len(args)-1)) + args = argvals(self.args) + return all(args[i] >= args[i+1] for i in range(len(args)-1)) class IncreasingStrict(GlobalConstraint): @@ -618,9 +616,8 @@ def decompose(self): return [args[i] < args[i+1] for i in range(len(args)-1)], [] def value(self): - from .python_builtins import all - args = self.args - return all((args[i].value() < args[i+1].value()) for i in range(len(args)-1)) + args = argvals(self.args) + return all(args[i] < args[i+1] for i in range(len(args)-1)) class DecreasingStrict(GlobalConstraint): @@ -641,9 +638,134 @@ def decompose(self): return [(args[i] > args[i+1]) for i in range(len(args)-1)], [] def value(self): - from .python_builtins import all - args = self.args - return all((args[i].value() > args[i+1].value()) for i in range(len(args)-1)) + args = argvals(self.args) + return all(args[i] > args[i+1] for i in range(len(args)-1)) + + +class LexLess(GlobalConstraint): + """ Given lists X,Y, enforcing that X is lexicographically less than Y. + """ + def __init__(self, list1, list2): + X = flatlist(list1) + Y = flatlist(list2) + if len(X) != len(Y): + raise CPMpyException(f"The 2 lists given in LexLess must have the same size: X length is {len(X)} and Y length is {len(Y)}") + super().__init__("lex_less", [X, Y]) + + def decompose(self): + """ + Implementation inspired by Hakan Kjellerstrand (http://hakank.org/cpmpy/cpmpy_hakank.py) + + The decomposition creates auxiliary Boolean variables and constraints that + collectively ensure X is lexicographically less than Y + The auxiliary boolean vars are defined to represent if the given lists are lexicographically ordered + (less or equal) up to the given index. + Decomposition enforces through the constraining part that the first boolean variable needs to be true, and thus + through the defining part it is enforced that if it is not strictly lexicographically less in a given index, + then next index must be lexicographically less or equal. It needs to be strictly less in at least one index. + + The use of auxiliary Boolean variables bvar ensures that the constraints propagate immediately, + maintaining arc-consistency. Each bvar[i] enforces the lexicographic ordering at each position, ensuring that + every value in the domain of X[i] can be extended to a consistent value in the domain of $Y_i$ for all + subsequent positions. + """ + X, Y = cpm_array(self.args) + + bvar = boolvar(shape=(len(X) + 1)) + + # Constraint ensuring that each element in X is less than or equal to the corresponding element in Y, + # until a strict inequality is encountered. + defining = [bvar == ((X <= Y) & ((X < Y) | bvar[1:]))] + # enforce the last element to be true iff (X[-1] < Y[-1]), enforcing strict lexicographic order + defining.append(bvar[-1] == (X[-1] < Y[-1])) + constraining = [bvar[0]] + + return constraining, defining + + def value(self): + X, Y = argvals(self.args) + return any((X[i] < Y[i]) & all(X[j] <= Y[j] for j in range(i)) for i in range(len(X))) + + +class LexLessEq(GlobalConstraint): + """ Given lists X,Y, enforcing that X is lexicographically less than Y (or equal). + """ + def __init__(self, list1, list2): + X = flatlist(list1) + Y = flatlist(list2) + if len(X) != len(Y): + raise CPMpyException(f"The 2 lists given in LexLessEq must have the same size: X length is {len(X)} and Y length is {len(Y)}") + super().__init__("lex_lesseq", [X, Y]) + + def decompose(self): + """ + Implementation inspired by Hakan Kjellerstrand (http://hakank.org/cpmpy/cpmpy_hakank.py) + + The decomposition creates auxiliary Boolean variables and constraints that + collectively ensure X is lexicographically less than Y + The auxiliary boolean vars are defined to represent if the given lists are lexicographically ordered + (less or equal) up to the given index. + Decomposition enforces through the constraining part that the first boolean variable needs to be true, and thus + through the defining part it is enforced that if it is not strictly lexicographically less in a given index, + then next index must be lexicographically less or equal. + + The use of auxiliary Boolean variables bvar ensures that the constraints propagate immediately, + maintaining arc-consistency. Each bvar[i] enforces the lexicographic ordering at each position, ensuring that + every value in the domain of X[i] can be extended to a consistent value in the domain of $Y_i$ for all + subsequent positions. + """ + X, Y = cpm_array(self.args) + + bvar = boolvar(shape=(len(X) + 1)) + defining = [bvar == ((X <= Y) & ((X < Y) | bvar[1:]))] + defining.append(bvar[-1] == (X[-1] <= Y[-1])) + constraining = [bvar[0]] + + return constraining, defining + + def value(self): + X, Y = argvals(self.args) + return any((X[i] < Y[i]) & all(X[j] <= Y[j] for j in range(i)) for i in range(len(X))) | all(X[i] == Y[i] for i in range(len(X))) + + +class LexChainLess(GlobalConstraint): + """ Given a matrix X, LexChainLess enforces that all rows are lexicographically ordered. + """ + def __init__(self, X): + # Ensure the numpy array is 2D + X = cpm_array(X) + assert X.ndim == 2, "Input must be a 2D array or a list of lists" + super().__init__("lex_chain_less", X.tolist()) + + def decompose(self): + """ Decompose to a series of LexLess constraints between subsequent rows + """ + X = self.args + return [LexLess(prev_row, curr_row) for prev_row, curr_row in zip(X, X[1:])], [] + + def value(self): + X = argvals(self.args) + return all(LexLess(prev_row, curr_row).value() for prev_row, curr_row in zip(X, X[1:])) + + +class LexChainLessEq(GlobalConstraint): + """ Given a matrix X, LexChainLessEq enforces that all rows are lexicographically ordered. + """ + def __init__(self, X): + # Ensure the numpy array is 2D + X = cpm_array(X) + assert X.ndim == 2, "Input must be a 2D array or a list of lists" + super().__init__("lex_chain_lesseq", X.tolist()) + + def decompose(self): + """ Decompose to a series of LexLessEq constraints between subsequent rows + """ + X = self.args + return [LexLessEq(prev_row, curr_row) for prev_row, curr_row in zip(X, X[1:])], [] + + def value(self): + X = argvals(self.args) + return all(LexLessEq(prev_row, curr_row).value() for prev_row, curr_row in zip(X, X[1:])) class DirectConstraint(Expression): diff --git a/cpmpy/expressions/utils.py b/cpmpy/expressions/utils.py index e5a78ed95..06bf92fc3 100644 --- a/cpmpy/expressions/utils.py +++ b/cpmpy/expressions/utils.py @@ -133,7 +133,9 @@ def argval(a): def argvals(arr): - return [argval(a) for a in arr] + if is_any_list(arr): + return [argvals(arg) for arg in arr] + return argval(arr) def eval_comparison(str_op, lhs, rhs): diff --git a/cpmpy/solvers/choco.py b/cpmpy/solvers/choco.py index 3869df8be..052d62552 100644 --- a/cpmpy/solvers/choco.py +++ b/cpmpy/solvers/choco.py @@ -314,11 +314,11 @@ def transform(self, cpm_expr): cpm_cons = toplevel_list(cpm_expr) supported = {"min", "max", "abs", "count", "element", "alldifferent", "alldifferent_except0", "allequal", "table", "InDomain", "cumulative", "circuit", "gcc", "inverse", "nvalue", "increasing", - "decreasing","strictly_increasing","strictly_decreasing"} - + "decreasing","strictly_increasing","strictly_decreasing","lex_lesseq", "lex_less"} + # choco supports reification of any constraint, but has a bug in increasing and decreasing supported_reified = {"min", "max", "abs", "count", "element", "alldifferent", "alldifferent_except0", - "allequal", "table", "InDomain", "cumulative", "circuit", "gcc", "inverse", "nvalue"} + "allequal", "table", "InDomain", "cumulative", "circuit", "gcc", "inverse", "nvalue","lex_lesseq", "lex_less"} # for when choco new release comes, fixing the bug on increasing and decreasing #supported_reified = supported cpm_cons = decompose_in_tree(cpm_cons, supported, supported_reified) @@ -497,7 +497,7 @@ def _get_constraint(self, cpm_expr): elif isinstance(cpm_expr, GlobalConstraint): # many globals require all variables as arguments - if cpm_expr.name in {"alldifferent", "alldifferent_except0", "allequal", "circuit", "inverse","increasing","decreasing","strictly_increasing","strictly_decreasing"}: + if cpm_expr.name in {"alldifferent", "alldifferent_except0", "allequal", "circuit", "inverse","increasing","decreasing","strictly_increasing","strictly_decreasing","lex_lesseq","lex_less"}: chc_args = self._to_vars(cpm_expr.args) if cpm_expr.name == 'alldifferent': return self.chc_model.all_different(chc_args) @@ -517,6 +517,13 @@ def _get_constraint(self, cpm_expr): return self.chc_model.increasing(chc_args,1) elif cpm_expr.name == "strictly_decreasing": return self.chc_model.decreasing(chc_args,1) + elif cpm_expr.name in ["lex_lesseq", "lex_less"]: + if cpm_expr.name == "lex_lesseq": + return self.chc_model.lex_less_eq(*chc_args) + return self.chc_model.lex_less(*chc_args) +# Ready for when it is fixed in pychoco (https://github.com/chocoteam/pychoco/issues/30) +# elif cpm_expr.name == "lex_chain_less": +# return self.chc_model.lex_chain_less(chc_args) # but not all elif cpm_expr.name == 'table': diff --git a/cpmpy/solvers/minizinc.py b/cpmpy/solvers/minizinc.py index d7335cf7c..38b5abde5 100644 --- a/cpmpy/solvers/minizinc.py +++ b/cpmpy/solvers/minizinc.py @@ -34,7 +34,7 @@ from .solver_interface import SolverInterface, SolverStatus, ExitStatus from ..exceptions import MinizincNameException, MinizincBoundsException from ..expressions.core import Expression, Comparison, Operator, BoolVal -from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView, intvar +from ..expressions.variables import _NumVarImpl, _IntVarImpl, _BoolVarImpl, NegBoolView, intvar, cpm_array from ..expressions.globalconstraints import DirectConstraint from ..expressions.utils import is_num, is_any_list, eval_comparison, argvals, argval from ..transformations.decompose_global import decompose_in_tree @@ -419,7 +419,8 @@ def transform(self, cpm_expr): cpm_cons = toplevel_list(cpm_expr) supported = {"min", "max", "abs", "element", "count", "nvalue", "alldifferent", "alldifferent_except0", "allequal", "inverse", "ite" "xor", "table", "cumulative", "circuit", "gcc", "increasing", - "decreasing","strictly_increasing","strictly_decreasing"} + "decreasing", "strictly_increasing", "strictly_decreasing", "lex_lesseq", "lex_less", "lex_chain_less", + "lex_chain_lesseq"} return decompose_in_tree(cpm_cons, supported, supported_reified=supported - {"circuit"}) @@ -503,6 +504,20 @@ def zero_based(array): args_str = [self._convert_expression(e) for e in expr.args] return "alldifferent_except_0([{}])".format(",".join(args_str)) + if expr.name in ["lex_lesseq", "lex_less"]: + X = [self._convert_expression(e) for e in expr.args[0]] + Y = [self._convert_expression(e) for e in expr.args[1]] + return f"{expr.name}({{}}, {{}})".format(X, Y) + + + if expr.name in ["lex_chain_less", "lex_chain_lesseq"]: + X = cpm_array([[self._convert_expression(e) for e in row] for row in expr.args]) + str_X = "[|\n" # opening + for row in X.T: # Minizinc enforces lexicographic order on columns + str_X += ",".join(map(str, row)) + " |" # rows + str_X += "\n|]" # closing + return f"{expr.name}({{}})".format(str_X) + args_str = [self._convert_expression(e) for e in expr.args] # standard expressions: comparison, operator, element if isinstance(expr, Comparison): diff --git a/cpmpy/transformations/decompose_global.py b/cpmpy/transformations/decompose_global.py index 2f03726c8..79228d0a9 100644 --- a/cpmpy/transformations/decompose_global.py +++ b/cpmpy/transformations/decompose_global.py @@ -79,7 +79,7 @@ def decompose_in_tree(lst_of_expr, supported=set(), supported_reified=set(), _to _toplevel.extend(define) # definitions should be added toplevel # the `decomposed` expression might contain other global constraints, check it - decomposed = decompose_in_tree(decomposed, supported, supported_reified, [], nested=nested) + decomposed = decompose_in_tree(decomposed, supported, supported_reified, _toplevel, nested=nested) newlist.append(all(decomposed)) else: @@ -146,7 +146,7 @@ def decompose_in_tree(lst_of_expr, supported=set(), supported_reified=set(), _to return toplevel_list(newlist) else: # we are toplevel and some new constraints are introduced, decompose new constraints! - return toplevel_list(newlist) + decompose_in_tree(_toplevel, supported, supported_reified, nested=False) + return toplevel_list(newlist) + decompose_in_tree(toplevel_list(_toplevel), supported, supported_reified, nested=False) # DEPRECATED! diff --git a/tests/test_constraints.py b/tests/test_constraints.py index e5f773e79..3eea09832 100644 --- a/tests/test_constraints.py +++ b/tests/test_constraints.py @@ -14,11 +14,10 @@ ALL_SOLS = False # test wheter all solutions returned by the solver satisfy the constraint # Exclude some global constraints for solvers - NUM_GLOBAL = { "AllEqual", "AllDifferent", "AllDifferentLists", "AllDifferentExcept0", "Cumulative", "GlobalCardinalityCount", "InDomain", "Inverse", "Table", "Circuit", - "Increasing", "IncreasingStrict", "Decreasing", "DecreasingStrict", + "Increasing", "IncreasingStrict", "Decreasing", "DecreasingStrict", "LexLess", "LexLessEq", "LexChainLess", "LexChainLessEq", # also global functions "Abs", "Element", "Minimum", "Maximum", "Count", "NValue", "NValueExcept" } @@ -170,7 +169,7 @@ def global_constraints(solver): """ Generate all global constraints - AllDifferent, AllEqual, Circuit, Minimum, Maximum, Element, - Xor, Cumulative, NValue, Count + Xor, Cumulative, NValue, Count, Increasing, Decreasing, IncreasingStrict, DecreasingStrict, LexLessEq, LexLess """ classes = inspect.getmembers(cpmpy.expressions.globalconstraints, inspect.isclass) classes = [(name, cls) for name, cls in classes if issubclass(cls, GlobalConstraint) and name != "GlobalConstraint"] @@ -199,17 +198,35 @@ def global_constraints(solver): vals = [1, 2, 3] cnts = intvar(0,10,shape=3) expr = cls(NUM_ARGS, vals, cnts) + elif name == "LexLessEq": + X = intvar(0, 3, shape=3) + Y = intvar(0, 3, shape=3) + expr = LexLessEq(X, Y) + + elif name == "LexLess": + X = intvar(0, 3, shape=3) + Y = intvar(0, 3, shape=3) + expr = LexLess(X, Y) + + elif name == "LexChainLess": + X = intvar(0, 3, shape=(3,3)) + expr = LexChainLess(X) + + elif name == "LexChainLessEq": + X = intvar(0, 3, shape=(3,3)) + expr = LexChainLess(X) elif name == "AllDifferentLists": vars = intvar(0,10, shape=(3,4)) expr = cls(vars) else: # default constructor, list of numvars - expr= cls(NUM_ARGS) + expr= cls(NUM_ARGS) if solver in EXCLUDE_GLOBAL and name in EXCLUDE_GLOBAL[solver]: continue else: yield expr + def reify_imply_exprs(solver): """ - Reification (double implication): Boolexpr == Var (CPMpy class 'Comparison') diff --git a/tests/test_globalconstraints.py b/tests/test_globalconstraints.py index a9c15ba03..985b846fd 100644 --- a/tests/test_globalconstraints.py +++ b/tests/test_globalconstraints.py @@ -273,6 +273,78 @@ def test_InDomain(self): self.assertTrue(model.solve()) self.assertIn(bv.value(), vals) + def test_lex_lesseq(self): + from cpmpy import BoolVal + X = cp.intvar(0, 3, shape=10) + c1 = X[:-1] == 1 + Y = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + c = cp.LexLessEq(X, Y) + c2 = c != (BoolVal(True)) + m = cp.Model([c1, c2]) + self.assertTrue(m.solve()) + self.assertTrue(c2.value()) + self.assertFalse(c.value()) + + Y = cp.intvar(0, 0, shape=10) + c = cp.LexLessEq(X, Y) + m = cp.Model(c) + self.assertTrue(m.solve("ortools")) + from cpmpy.expressions.utils import argval + self.assertTrue(sum(argval(X)) == 0) + + def test_lex_less(self): + from cpmpy import BoolVal + X = cp.intvar(0, 3, shape=10) + c1 = X[:-1] == 1 + Y = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + c = cp.LexLess(X, Y) + c2 = c != (BoolVal(True)) + m = cp.Model([c1, c2]) + self.assertTrue(m.solve()) + self.assertTrue(c2.value()) + self.assertFalse(c.value()) + + Y = cp.intvar(0, 0, shape=10) + c = cp.LexLess(X, Y) + m = cp.Model(c) + self.assertFalse(m.solve("ortools")) + + Z = [0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + c = cp.LexLess(X, Z) + m = cp.Model(c) + self.assertTrue(m.solve("ortools")) + from cpmpy.expressions.utils import argval + self.assertTrue(sum(argval(X)) == 0) + + + def test_lex_chain(self): + from cpmpy import BoolVal + X = cp.intvar(0, 3, shape=10) + c1 = X[:-1] == 1 + Y = [1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + c = cp.LexChainLess([X, Y]) + c2 = c != (BoolVal(True)) + m = cp.Model([c1, c2]) + self.assertTrue(m.solve()) + self.assertTrue(c2.value()) + self.assertFalse(c.value()) + + Y = cp.intvar(0, 0, shape=10) + c = cp.LexChainLessEq([X, Y]) + m = cp.Model(c) + self.assertTrue(m.solve("ortools")) + from cpmpy.expressions.utils import argval + self.assertTrue(sum(argval(X)) == 0) + + Z = cp.intvar(0, 1, shape=(3,2)) + c = cp.LexChainLess(Z) + m = cp.Model(c) + self.assertTrue(m.solve()) + self.assertTrue(sum(argval(Z[0])) == 0) + self.assertTrue(sum(argval(Z[1])) == 1) + self.assertTrue(sum(argval(Z[2])) >= 1) + + def test_indomain_onearg(self): iv = cp.intvar(0, 10)