Skip to content

Commit 3465ebc

Browse files
committed
Add optional constraints and enable/disable support
Introduce an enabled flag for Constraint (default True) and propagate it through Model APIs so constraints can be toggled on/off. Updated add_constraint and add_constraints to accept an enabled parameter, and added convenience methods: add_optional_constraint, add_assumption, set_constraint_enabled, enable_constraint, and disable_constraint. to_ir now ignores disabled constraints and only auto-includes global constraint files for enabled constraints. Added unit tests (tests/test_optional_constraints.py) to verify rendering and include emission behavior. This enables creating optional/global constraints that are omitted until explicitly enabled while preserving default behavior for existing constraints.
1 parent f8dfdad commit 3465ebc

3 files changed

Lines changed: 96 additions & 7 deletions

File tree

‎src/pymzm/constraint.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class Constraint:
5454
"connected",
5555
"reachable"
5656
]
57-
def __init__(self, cstr: ExpressionBool, ctype: str=CTYPE_NORMAL, annotation: str=None, is_redundant=False):
57+
def __init__(self, cstr: ExpressionBool, ctype: str=CTYPE_NORMAL, annotation: str=None, is_redundant=False, enabled=True):
5858
self.cstr = cstr
5959
if (not isinstance(self.cstr, (ExpressionBool, bool, str))):
6060
raise PymzmValueIsNotCondition("cstr", self.cstr)
@@ -69,6 +69,7 @@ def __init__(self, cstr: ExpressionBool, ctype: str=CTYPE_NORMAL, annotation: st
6969
raise PymzmInvalidConstraintAnnotation("annotation")
7070

7171
self.is_redundant = is_redundant
72+
self.enabled = enabled
7273

7374
def __str__(self):
7475
return self.cstr

‎src/pymzm/model.py‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -376,21 +376,39 @@ def add_variables(self, name: str, indices: List[Tuple[int]], vtype: int=Variabl
376376

377377
return variables
378378

379-
def add_constraint(self, constraint: ExpressionBool, is_redundant=False):
379+
def add_constraint(self, constraint: ExpressionBool, is_redundant=False, enabled=True):
380380
if (isinstance(constraint, Constraint)):
381381
constraint.is_redundant = is_redundant
382+
constraint.enabled = enabled
382383
if (constraint.ctype != Constraint.CTYPE_NORMAL):
383384
self.global_constraints.add(constraint.ctype)
384385

385386
elif (isinstance(constraint, ExpressionBool)):
386-
constraint = Constraint(constraint.name, is_redundant=is_redundant)
387+
constraint = Constraint(constraint.name, is_redundant=is_redundant, enabled=enabled)
387388

388389
else:
389390
raise Exception("invalid constraint type")
390391

391392
self.constraints.append(constraint)
392393
return constraint
393394

395+
def add_optional_constraint(self, constraint: ExpressionBool, enabled=False, is_redundant=False):
396+
return self.add_constraint(constraint, is_redundant=is_redundant, enabled=enabled)
397+
398+
def add_assumption(self, constraint: ExpressionBool, enabled=True, is_redundant=False):
399+
return self.add_optional_constraint(constraint, enabled=enabled, is_redundant=is_redundant)
400+
401+
def set_constraint_enabled(self, constraint: Constraint, enabled=True):
402+
assert constraint in self.constraints
403+
constraint.enabled = enabled
404+
return constraint
405+
406+
def enable_constraint(self, constraint: Constraint):
407+
return self.set_constraint_enabled(constraint, True)
408+
409+
def disable_constraint(self, constraint: Constraint):
410+
return self.set_constraint_enabled(constraint, False)
411+
394412
def add_include(self, include_file: str):
395413
assert isinstance(include_file, str)
396414
include_file = include_file.strip()
@@ -402,11 +420,11 @@ def add_includes(self, include_files):
402420
for include_file in include_files:
403421
self.add_include(include_file)
404422

405-
def add_constraints(self, constraints: List[Constraint], is_redundant=False):
423+
def add_constraints(self, constraints: List[Constraint], is_redundant=False, enabled=True):
406424
constraints = list(constraints)
407425
assert all(isinstance(constraint, (Constraint, Expression, str, bool)) for constraint in constraints)
408426
for constraint in constraints:
409-
self.add_constraint(constraint, is_redundant=is_redundant)
427+
self.add_constraint(constraint, is_redundant=is_redundant, enabled=enabled)
410428

411429
def add_function_declaration(self, declaration: str):
412430
assert isinstance(declaration, str)
@@ -636,7 +654,16 @@ def check_satisfiable(
636654
def to_ir(self) -> ModelIR:
637655
assert self.solve_criteria is not None
638656

639-
includes = tuple(sorted({*(f"{gconst}.mzn" for gconst in self.global_constraints), *self.includes}))
657+
enabled_constraints = tuple(
658+
constraint for constraint in self.constraints
659+
if constraint.enabled
660+
)
661+
auto_includes = {
662+
f"{constraint.ctype}.mzn"
663+
for constraint in enabled_constraints
664+
if constraint.ctype != Constraint.CTYPE_NORMAL
665+
}
666+
includes = tuple(sorted({*auto_includes, *self.includes}))
640667

641668
constants_sorted = sorted(self.constants, key=lambda c: c.name)
642669
parameters_sorted = sorted(self.parameters, key=lambda p: p.name)
@@ -657,7 +684,7 @@ def to_ir(self) -> ModelIR:
657684
)
658685
constraints = tuple(
659686
line.rstrip("\n")
660-
for line in (constraint._to_mz() for constraint in self.constraints)
687+
for line in (constraint._to_mz() for constraint in enabled_constraints)
661688
)
662689
output_items = tuple(self.output_items)
663690
solve = SolveIR(

‎tests/test_optional_constraints.py‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import unittest
2+
import pytest
3+
4+
import pymzm
5+
from pymzm.backends import MznTextBackend
6+
from tests.mzn_verifier import assert_valid_mzn
7+
8+
9+
pytestmark = [pytest.mark.unit, pytest.mark.snapshot]
10+
11+
12+
class TestOptionalConstraints(unittest.TestCase):
13+
def test_disabled_constraint_is_omitted_until_enabled(self):
14+
model = pymzm.Model()
15+
x = model.add_variable("x", val_min=0, val_max=3)
16+
active = model.add_constraint(x >= 1)
17+
optional = model.add_optional_constraint(x <= 2)
18+
model.set_solve_criteria(pymzm.SOLVE_SATISFY)
19+
20+
disabled_render = MznTextBackend().render_model(model.to_ir())
21+
self.assertIn("constraint (x >= 1);", disabled_render)
22+
self.assertNotIn("constraint (x <= 2);", disabled_render)
23+
24+
model.enable_constraint(optional)
25+
enabled_render = MznTextBackend().render_model(model.to_ir())
26+
self.assertIn("constraint (x >= 1);", enabled_render)
27+
self.assertIn("constraint (x <= 2);", enabled_render)
28+
self.assertIs(active.enabled, True)
29+
assert_valid_mzn(self, enabled_render)
30+
31+
def test_disabled_global_constraint_does_not_emit_include(self):
32+
model = pymzm.Model()
33+
x = model.add_variable("x", val_min=1, val_max=3)
34+
y = model.add_variable("y", val_min=1, val_max=3)
35+
optional = model.add_optional_constraint(pymzm.Constraint.alldifferent([x, y]))
36+
model.set_solve_criteria(pymzm.SOLVE_SATISFY)
37+
38+
disabled_render = MznTextBackend().render_model(model.to_ir())
39+
self.assertNotIn('include "alldifferent.mzn";', disabled_render)
40+
self.assertNotIn("constraint alldifferent", disabled_render)
41+
42+
model.enable_constraint(optional)
43+
enabled_render = MznTextBackend().render_model(model.to_ir())
44+
self.assertIn('include "alldifferent.mzn";', enabled_render)
45+
self.assertIn("constraint alldifferent([x, y]);", enabled_render)
46+
47+
def test_add_assumption_alias_can_be_disabled(self):
48+
model = pymzm.Model()
49+
x = model.add_variable("x", val_min=0, val_max=2)
50+
assumption = model.add_assumption(x >= 0)
51+
model.set_solve_criteria(pymzm.SOLVE_SATISFY)
52+
53+
self.assertTrue(assumption.enabled)
54+
model.disable_constraint(assumption)
55+
56+
rendered = MznTextBackend().render_model(model.to_ir())
57+
self.assertNotIn("constraint (x >= 0);", rendered)
58+
59+
60+
if __name__ == "__main__":
61+
unittest.main()

0 commit comments

Comments
 (0)