Skip to content

Commit c660e30

Browse files
committed
Add global constraint wrappers and tests
Introduce many global constraint types and convenience wrappers in Constraint (e.g. strictly_increasing/decreasing, element, table, cumulative, circuit, path, bin_packing, inverse, lex_* variants, regular) and add an all_different alias. Add unit tests for global constraints (tests/test_global_constraints.py) that verify rendering and required includes. Add a pytest marker no_mzn_verify (pyproject.toml) and update the test fixture to skip MiniZinc CLI validation when that marker is present; mark tests/test_includes.py accordingly. Misc test and tooling tweaks: use a distinct temp filename in mzn verifier, switch test runner bootstrap to invoke pytest in tests/test_all.py, and refactor solver utils imports/patching in tests/test_solver_utils.py.
1 parent 3465ebc commit c660e30

8 files changed

Lines changed: 223 additions & 19 deletions

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ markers = [
2323
"integration: tests that require MiniZinc solver/runtime",
2424
"snapshot: deterministic text rendering regression tests",
2525
"property: equivalence and property-style tests",
26+
"no_mzn_verify: skip global MiniZinc CLI validation for tests that intentionally render unresolved include paths",
2627
]
2728

2829
[tool.coverage.run]

‎src/pymzm/constraint.py‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,22 @@ class Constraint:
3434
CTYPE_ALL_EQUAL,
3535
CTYPE_COUNT,
3636
CTYPE_INCREASING,
37+
CTYPE_STRICTLY_INCREASING,
3738
CTYPE_DECREASING,
39+
CTYPE_STRICTLY_DECREASING,
40+
CTYPE_ELEMENT,
41+
CTYPE_TABLE,
42+
CTYPE_CUMULATIVE,
3843
CTYPE_DISJUNCTIVE,
44+
CTYPE_CIRCUIT,
45+
CTYPE_PATH,
46+
CTYPE_BIN_PACKING,
47+
CTYPE_INVERSE,
48+
CTYPE_LEX_LESS,
49+
CTYPE_LEX_LESSEQ,
50+
CTYPE_LEX_GREATER,
51+
CTYPE_LEX_GREATEREQ,
52+
CTYPE_REGULAR,
3953
CTYPE_ARG_SORT,
4054
CTYPE_DIFFN,
4155
CTYPE_CONNECTED,
@@ -47,8 +61,22 @@ class Constraint:
4761
"all_equal",
4862
"count",
4963
"increasing",
64+
"strictly_increasing",
5065
"decreasing",
66+
"strictly_decreasing",
67+
"element",
68+
"table",
69+
"cumulative",
5170
"disjunctive",
71+
"circuit",
72+
"path",
73+
"bin_packing",
74+
"inverse",
75+
"lex_less",
76+
"lex_lesseq",
77+
"lex_greater",
78+
"lex_greatereq",
79+
"regular",
5280
"arg_sort",
5381
"diffn",
5482
"connected",
@@ -99,6 +127,10 @@ def alldifferent(exprs: List[Expression]) -> "Constraint":
99127
Constraint: Alldifferent constraint
100128
"""
101129
return Constraint._from_global_constraint("alldifferent", Constraint.CTYPE_ALLDIFFERENT, exprs)
130+
131+
@staticmethod
132+
def all_different(exprs: List[Expression]) -> "Constraint":
133+
return Constraint.alldifferent(exprs)
102134

103135
@staticmethod
104136
def among(n: int, exprs: List[Expression], values: List[int]):
@@ -117,10 +149,30 @@ def increasing(exprs: List[Expression]):
117149
# Requires that the array x is in (non-strictly) increasing order (duplicates are allowed).
118150
return Constraint._from_global_constraint("increasing", Constraint.CTYPE_INCREASING, exprs)
119151

152+
@staticmethod
153+
def strictly_increasing(exprs: List[Expression]):
154+
return Constraint._from_global_constraint("strictly_increasing", Constraint.CTYPE_STRICTLY_INCREASING, exprs)
155+
120156
@staticmethod
121157
def decreasing(exprs: List[Expression]):
122158
# Requires that the array x is in (non-strictly) decreasing order (duplicates are allowed).
123159
return Constraint._from_global_constraint("decreasing", Constraint.CTYPE_DECREASING, exprs)
160+
161+
@staticmethod
162+
def strictly_decreasing(exprs: List[Expression]):
163+
return Constraint._from_global_constraint("strictly_decreasing", Constraint.CTYPE_STRICTLY_DECREASING, exprs)
164+
165+
@staticmethod
166+
def element(index: Expression, values: List[Expression], value: Expression):
167+
return Constraint._from_global_constraint("element", Constraint.CTYPE_ELEMENT, index, values, value)
168+
169+
@staticmethod
170+
def table(exprs: List[Expression], rows):
171+
return Constraint._from_global_constraint("table", Constraint.CTYPE_TABLE, exprs, rows)
172+
173+
@staticmethod
174+
def cumulative(s: List[Expression], d: List[Expression], r: List[Expression], b: Expression):
175+
return Constraint._from_global_constraint("cumulative", Constraint.CTYPE_CUMULATIVE, s, d, r, b)
124176

125177
@staticmethod
126178
def disjunctive(s: List[Expression], d: List[Expression]):
@@ -133,6 +185,42 @@ def disjunctive_strict(s: List[Expression], d: List[Expression]):
133185
# Requires that a set of tasks given by start times s and durations d do not overlap in time.
134186
# Tasks with duration 0 CANNOT be scheduled at any time, but only when no other task is running.
135187
return Constraint._from_global_constraint("disjunctive_strict", Constraint.CTYPE_DISJUNCTIVE, s, d)
188+
189+
@staticmethod
190+
def circuit(successors: List[Expression]):
191+
return Constraint._from_global_constraint("circuit", Constraint.CTYPE_CIRCUIT, successors)
192+
193+
@staticmethod
194+
def path(successors: List[Expression], start: Expression, end: Expression):
195+
return Constraint._from_global_constraint("path", Constraint.CTYPE_PATH, successors, start, end)
196+
197+
@staticmethod
198+
def bin_packing(capacity: Expression, bins: List[Expression], weights: List[Expression]):
199+
return Constraint._from_global_constraint("bin_packing", Constraint.CTYPE_BIN_PACKING, capacity, bins, weights)
200+
201+
@staticmethod
202+
def inverse(forward: List[Expression], backward: List[Expression]):
203+
return Constraint._from_global_constraint("inverse", Constraint.CTYPE_INVERSE, forward, backward)
204+
205+
@staticmethod
206+
def lex_less(left: List[Expression], right: List[Expression]):
207+
return Constraint._from_global_constraint("lex_less", Constraint.CTYPE_LEX_LESS, left, right)
208+
209+
@staticmethod
210+
def lex_lesseq(left: List[Expression], right: List[Expression]):
211+
return Constraint._from_global_constraint("lex_lesseq", Constraint.CTYPE_LEX_LESSEQ, left, right)
212+
213+
@staticmethod
214+
def lex_greater(left: List[Expression], right: List[Expression]):
215+
return Constraint._from_global_constraint("lex_greater", Constraint.CTYPE_LEX_GREATER, left, right)
216+
217+
@staticmethod
218+
def lex_greatereq(left: List[Expression], right: List[Expression]):
219+
return Constraint._from_global_constraint("lex_greatereq", Constraint.CTYPE_LEX_GREATEREQ, left, right)
220+
221+
@staticmethod
222+
def regular(exprs: List[Expression], q: Expression, s: Expression, d, q0: Expression, f):
223+
return Constraint._from_global_constraint("regular", Constraint.CTYPE_REGULAR, exprs, q, s, d, q0, f)
136224

137225
@staticmethod
138226
def arg_sort(x: List[Expression], p: List[Expression]):

‎tests/conftest.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@ def pytest_collection_modifyitems(config, items):
2424

2525

2626
@pytest.fixture(autouse=True)
27-
def verify_rendered_mzn_models(monkeypatch):
27+
def verify_rendered_mzn_models(monkeypatch, request):
2828
# Keep local/unit workflow fast when CLI is unavailable.
2929
if (shutil.which("minizinc") is None):
3030
yield
3131
return
3232

33+
if (request.node.get_closest_marker("no_mzn_verify") is not None):
34+
yield
35+
return
36+
3337
import pymzm
3438

3539
original_render = pymzm.Model._render_model_string

‎tests/mzn_verifier.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def _verify_with_minizinc_cli(model_path: Path):
2727

2828
def verify_mzn_text(mzn_text: str):
2929
with tempfile.TemporaryDirectory() as tmpdir:
30-
model_path = Path(tmpdir) / "model.mzn"
30+
model_path = Path(tmpdir) / "pymzm_verify_input.mzn"
3131
model_path.write_text(mzn_text, encoding="utf-8")
3232
return _verify_with_minizinc_cli(model_path)
3333

‎tests/test_all.py‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import unittest
1+
from pathlib import Path
2+
import sys
3+
4+
import pytest
25

3-
from test_expression import TestExpression
4-
from test_examples import TestExamples
5-
from test_misc import TestMisc
6-
76

87
if __name__ == "__main__":
9-
suite = unittest.TestLoader().discover('tests')
10-
unittest.TextTestRunner(verbosity=1).run(suite)
8+
repo_root = Path(__file__).resolve().parents[1]
9+
if (str(repo_root) not in sys.path):
10+
sys.path.insert(0, str(repo_root))
11+
12+
raise SystemExit(pytest.main([str(Path(__file__).resolve().parent)]))

‎tests/test_global_constraints.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import unittest
2+
import pytest
3+
4+
import pymzm
5+
from tests.mzn_verifier import assert_valid_mzn
6+
7+
8+
pytestmark = [pytest.mark.unit, pytest.mark.snapshot]
9+
10+
11+
class TestGlobalConstraints(unittest.TestCase):
12+
def test_ordering_element_and_table_wrappers_render_with_includes(self):
13+
model = pymzm.Model()
14+
x = model.add_variable("x", val_min=1, val_max=3)
15+
y = model.add_variable("y", val_min=1, val_max=3)
16+
z = model.add_variable("z", val_min=1, val_max=3)
17+
idx = model.add_variable("idx", val_min=1, val_max=3)
18+
picked = model.add_variable("picked", val_min=1, val_max=9)
19+
20+
model.add_constraint(pymzm.Constraint.all_different([x, y, z]))
21+
model.add_constraint(pymzm.Constraint.strictly_increasing([x, y, z]))
22+
model.add_constraint(pymzm.Constraint.strictly_decreasing([z, y, x]))
23+
model.add_constraint(pymzm.Constraint.element(idx, [2, 4, 9], picked))
24+
model.add_constraint(pymzm.Constraint.table([x, y], [[1, 2], [2, 3], [3, 1]]))
25+
model.set_solve_criteria(pymzm.SOLVE_SATISFY)
26+
model.generate()
27+
28+
lines = model.model_mzn_str.splitlines()
29+
self.assertIn('include "alldifferent.mzn";', lines)
30+
self.assertIn('include "strictly_increasing.mzn";', lines)
31+
self.assertIn('include "strictly_decreasing.mzn";', lines)
32+
self.assertIn('include "element.mzn";', lines)
33+
self.assertIn('include "table.mzn";', lines)
34+
self.assertIn('constraint alldifferent([x, y, z]);', lines)
35+
self.assertIn('constraint strictly_increasing([x, y, z]);', lines)
36+
self.assertIn('constraint strictly_decreasing([z, y, x]);', lines)
37+
self.assertIn('constraint element(idx, [2, 4, 9], picked);', lines)
38+
self.assertIn('constraint table([x, y], [[1, 2], [2, 3], [3, 1]]);', lines)
39+
assert_valid_mzn(self, model.model_mzn_str)
40+
41+
def test_scheduling_graph_and_other_wrapper_families_render(self):
42+
model = pymzm.Model()
43+
s1 = model.add_variable("s1", val_min=0, val_max=5)
44+
s2 = model.add_variable("s2", val_min=0, val_max=5)
45+
n1 = model.add_variable("n1", val_min=1, val_max=3)
46+
n2 = model.add_variable("n2", val_min=1, val_max=3)
47+
n3 = model.add_variable("n3", val_min=1, val_max=3)
48+
p1 = model.add_variable("p1", val_min=1, val_max=3)
49+
p2 = model.add_variable("p2", val_min=1, val_max=3)
50+
p3 = model.add_variable("p3", val_min=1, val_max=3)
51+
b1 = model.add_variable("b1", val_min=1, val_max=2)
52+
b2 = model.add_variable("b2", val_min=1, val_max=2)
53+
left1 = model.add_variable("left1", val_min=0, val_max=3)
54+
left2 = model.add_variable("left2", val_min=0, val_max=3)
55+
right1 = model.add_variable("right1", val_min=0, val_max=3)
56+
right2 = model.add_variable("right2", val_min=0, val_max=3)
57+
sel1 = model.add_variable("sel1", vtype=pymzm.Variable.VTYPE_BOOL)
58+
sel2 = model.add_variable("sel2", vtype=pymzm.Variable.VTYPE_BOOL)
59+
edge1 = model.add_variable("edge1", vtype=pymzm.Variable.VTYPE_BOOL)
60+
edge2 = model.add_variable("edge2", vtype=pymzm.Variable.VTYPE_BOOL)
61+
62+
model.add_constraint(pymzm.Constraint.cumulative([s1, s2], [2, 3], [1, 1], 2))
63+
model.add_constraint(pymzm.Constraint.disjunctive([s1, s2], [2, 3]))
64+
model.add_constraint(pymzm.Constraint.disjunctive_strict([s1, s2], [2, 3]))
65+
model.add_constraint(pymzm.Constraint.circuit([n1, n2, n3]))
66+
model.add_constraint(pymzm.Constraint.path([p1, p2, p3], 1, 3))
67+
model.add_constraint(pymzm.Constraint.bin_packing(5, [b1, b2], [2, 3]))
68+
model.add_constraint(pymzm.Constraint.inverse([n1, n2, n3], [p1, p2, p3]))
69+
model.add_constraint(pymzm.Constraint.lex_lesseq([left1, left2], [right1, right2]))
70+
model.add_constraint(pymzm.Constraint.lex_less([left1, left2], [right1, right2]))
71+
model.add_constraint(pymzm.Constraint.regular([b1, b2], 2, 2, [[1, 2], [2, 1]], 1, {1}))
72+
model.add_constraint(pymzm.Constraint.connected([1, 2], [2, 3], [sel1, sel2], [edge1, edge2]))
73+
model.add_constraint(pymzm.Constraint.reachable([1, 2], [2, 3], 1, [sel1, sel2], [edge1, edge2]))
74+
model.set_solve_criteria(pymzm.SOLVE_SATISFY)
75+
76+
ir = model.to_ir()
77+
78+
self.assertEqual(
79+
ir.includes,
80+
(
81+
"bin_packing.mzn",
82+
"circuit.mzn",
83+
"connected.mzn",
84+
"cumulative.mzn",
85+
"disjunctive.mzn",
86+
"inverse.mzn",
87+
"lex_less.mzn",
88+
"lex_lesseq.mzn",
89+
"path.mzn",
90+
"reachable.mzn",
91+
"regular.mzn",
92+
),
93+
)
94+
self.assertIn('constraint cumulative([s1, s2], [2, 3], [1, 1], 2);', ir.constraints)
95+
self.assertIn('constraint disjunctive([s1, s2], [2, 3]);', ir.constraints)
96+
self.assertIn('constraint disjunctive_strict([s1, s2], [2, 3]);', ir.constraints)
97+
self.assertIn('constraint circuit([n1, n2, n3]);', ir.constraints)
98+
self.assertIn('constraint path([p1, p2, p3], 1, 3);', ir.constraints)
99+
self.assertIn('constraint bin_packing(5, [b1, b2], [2, 3]);', ir.constraints)
100+
self.assertIn('constraint inverse([n1, n2, n3], [p1, p2, p3]);', ir.constraints)
101+
self.assertIn('constraint lex_lesseq([left1, left2], [right1, right2]);', ir.constraints)
102+
self.assertIn('constraint lex_less([left1, left2], [right1, right2]);', ir.constraints)
103+
self.assertIn('constraint regular([b1, b2], 2, 2, [[1, 2], [2, 1]], 1, {1});', ir.constraints)
104+
self.assertIn('constraint connected([1, 2], [2, 3], [sel1, sel2], [edge1, edge2]);', ir.constraints)
105+
self.assertIn('constraint reachable([1, 2], [2, 3], 1, [sel1, sel2], [edge1, edge2]);', ir.constraints)
106+
107+
108+
if __name__ == "__main__":
109+
unittest.main()

‎tests/test_includes.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import pymzm
55

66

7-
pytestmark = [pytest.mark.unit, pytest.mark.snapshot]
7+
pytestmark = [pytest.mark.unit, pytest.mark.snapshot, pytest.mark.no_mzn_verify]
88

99

1010
class TestIncludes(unittest.TestCase):

‎tests/test_solver_utils.py‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,21 @@
33
import pytest
44

55
import pymzm
6-
from tests.solver_utils import (
7-
DEFAULT_RANDOM_SEED,
8-
DEFAULT_SOLVER_TAG,
9-
DEFAULT_THREADS,
10-
deterministic_instance_solve,
11-
deterministic_solver_config,
12-
lookup_default_solver,
13-
)
6+
from tests import solver_utils as solver_utils_module
7+
8+
DEFAULT_RANDOM_SEED = solver_utils_module.DEFAULT_RANDOM_SEED
9+
DEFAULT_SOLVER_TAG = solver_utils_module.DEFAULT_SOLVER_TAG
10+
DEFAULT_THREADS = solver_utils_module.DEFAULT_THREADS
11+
deterministic_instance_solve = solver_utils_module.deterministic_instance_solve
12+
deterministic_solver_config = solver_utils_module.deterministic_solver_config
13+
lookup_default_solver = solver_utils_module.lookup_default_solver
1414

1515

1616
pytestmark = [pytest.mark.unit]
1717

1818

1919
class TestSolverUtils(unittest.TestCase):
20-
@patch("tests.solver_utils.minizinc.Solver.lookup")
20+
@patch.object(solver_utils_module.minizinc.Solver, "lookup")
2121
def test_lookup_default_solver_uses_gecode(self, lookup_mock):
2222
sentinel = object()
2323
lookup_mock.return_value = sentinel

0 commit comments

Comments
 (0)