Skip to content

Commit

Permalink
Lex list global constraints (#477)
Browse files Browse the repository at this point in the history
LexLess, LexLessEq, LexChainLess, LexChainLessEq
  • Loading branch information
Dimosts authored May 27, 2024
1 parent c5ee489 commit 37f0dbe
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 26 deletions.
2 changes: 1 addition & 1 deletion cpmpy/expressions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 134 additions & 12 deletions cpmpy/expressions/globalconstraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down
4 changes: 3 additions & 1 deletion cpmpy/expressions/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
15 changes: 11 additions & 4 deletions cpmpy/solvers/choco.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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':
Expand Down
19 changes: 17 additions & 2 deletions cpmpy/solvers/minizinc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"})


Expand Down Expand Up @@ -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):
Expand Down
4 changes: 2 additions & 2 deletions cpmpy/transformations/decompose_global.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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!
Expand Down
25 changes: 21 additions & 4 deletions tests/test_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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')
Expand Down
Loading

0 comments on commit 37f0dbe

Please sign in to comment.