Skip to main content

Constraints

Constraints restrict the solution space by specifying relationships between variables. OptalCP distinguishes between Constraint objects and BoolExpr expressions.

Constraint vs BoolExpr

Understanding this distinction matters:

TypeCreationRegistrationExamples
ConstraintReturned by specific functionsAuto-registers when createdendBeforeStart()
noOverlap()
alternative()
BoolExprReturned by comparisons and boolean opsRequires model.enforce()task1.end() <= task2.start()
x + y <= 10
b1 | b2

Why the difference?

  • BoolExpr can be composed into larger expressions (b1 | b2, b.implies(c), b1 + b2), so it cannot auto-register—you may want to use it as a building block.
  • Constraint cannot be used as a subexpression—it's terminal. Since nothing else can be done with it, it auto-registers immediately.

Auto-Registration vs Explicit Enforcement

import optalcp as cp

model = cp.Model()
task1 = model.interval_var(length=10, name="task1")
task2 = model.interval_var(length=20, name="task2")
x = model.int_var(min=0, max=100, name="x")

# Constraint - auto-registers
task1.end_before_start(task2) # Registered immediately

# BoolExpr - requires model.enforce()
model.enforce(task1.end() <= task2.start()) # Must explicitly enforce
model.enforce(x >= 10)

Common mistake: Creating a BoolExpr without enforcing it:

# WRONG - does nothing!
task1.end() <= task2.start()

# CORRECT
model.enforce(task1.end() <= task2.start())

Enforcing BoolExpr Constraints

Use model.enforce() to add any BoolExpr as a constraint:

x = model.int_var(min=0, max=100, name="x")
b = model.bool_var(name="b")
task1 = model.interval_var(length=10, optional=True, name="task1")
task2 = model.interval_var(length=20, optional=True, name="task2")

# Arithmetic comparisons
model.enforce(x + 10 <= 100)
model.enforce(task1.end() <= 200)

# Boolean logic
model.enforce(b.implies(task1.presence()))

# Presence synchronization
model.enforce(task1.presence() == task2.presence())

# Precedence (expression style)
model.enforce(task1.end() <= task2.start())

See Expressions for all available operators (arithmetic, comparison, boolean).

Key Concept: Absent Value Semantics

A constraint is satisfied if its expression is not false—meaning true or absent.

An absent constraint is treated as non-existent: it places no restrictions on the solution. This is the intended behavior—when an optional task is absent, constraints involving it simply don't apply. For example, task1.end() <= task2.start() is trivially satisfied when either task is absent.

Pitfall: Unnecessary Presence Checks

Don't wrap constraints in presence checks—this severely hurts propagation and gains nothing. The system is designed so that absent values automatically satisfy constraints. If you need a default value for absent expressions, use guard() instead.

# WRONG - hurts propagation, gains nothing
model.enforce((x.presence() & y.presence()).implies(x.end() <= y.end()))
# CORRECT - simpler and propagates better
model.enforce(x.end() <= y.end())

# WRONG
model.enforce(~task.presence() | (task.end() <= 100))
# CORRECT
model.enforce(task.end() <= 100)

# WRONG
model.enforce(task.presence().implies(model.eval(cost_func, task.start()) <= 50))
# CORRECT
model.enforce(model.eval(cost_func, task.start()) <= 50)

Precedence Constraints

Precedence constraints express temporal relationships between intervals. OptalCP has 8 precedence functions on IntervalVar:

The 8 Precedence Functions

FunctionRelationshipEquivalent Expression
end_before_starttask1 ends before task2 startstask1.end() + delay <= task2.start()
end_before_endtask1 ends before task2 endstask1.end() + delay <= task2.end()
start_before_starttask1 starts before task2 startstask1.start() + delay <= task2.start()
start_before_endtask1 starts before task2 endstask1.start() + delay <= task2.end()
end_at_starttask1 ends when task2 startstask1.end() + delay == task2.start()
end_at_endtask1 ends when task2 endstask1.end() + delay == task2.end()
start_at_starttask1 starts when task2 startstask1.start() + delay == task2.start()
start_at_endtask1 starts when task2 endstask1.start() + delay == task2.end()

All functions:

  • Return a Constraint object (auto-register)
  • Accept an optional delay parameter (default: 0)
  • Are satisfied trivially if either interval is absent

Examples

task1 = model.interval_var(length=10, name="task1")
task2 = model.interval_var(length=20, name="task2")
task3 = model.interval_var(length=15, name="task3")

# Basic precedence: task1 must finish before task2 starts
task1.end_before_start(task2)

# With delay: task1 must finish at least 5 time units before task2 starts
task1.end_before_start(task2, delay=5)

# task2 and task3 must start at the same time
task2.start_at_start(task3)

# task3 starts when task1 ends
task1.end_at_start(task3)

# task2 ends before task3 ends
task2.end_before_end(task3)

# task1 starts before task2 starts
task1.start_before_start(task2)

# task1 starts before task3 ends
task1.start_before_end(task3)

# task2 and task3 end at the same time
task2.end_at_end(task3)

Method vs Expression Style

You can express precedence in two equivalent ways:

# Method style (clearer intent, auto-registers)
task1.end_before_start(task2, delay=10)

# Expression style (more flexible)
model.enforce(task1.end() + 10 <= task2.start())

Other Constraints

These constraints also auto-register when created:

  • Alternative — Exactly one option interval is present when main is present
  • Span — Parent interval covers all present children
  • No Overlap — Intervals cannot overlap (disjunctive resource)
  • Cumulative — Sum of pulses must not exceed capacity

Complete Example

import optalcp as cp

model = cp.Model()

# Tasks
cut = model.interval_var(length=30, name="cut")
sand = model.interval_var(name="sand") # Length determined by alternative
assemble = model.interval_var(length=45, name="assemble")
inspect = model.interval_var(length=10, name="inspect")

# Sanding can use fast machine (expensive) or slow machine (cheap)
sand_fast = model.interval_var(length=10, optional=True, name="sand_fast")
sand_slow = model.interval_var(length=30, optional=True, name="sand_slow")
model.alternative(sand, [sand_fast, sand_slow])

# Precedence constraints (auto-register)
cut.end_before_start(sand)
sand.end_before_start(assemble)
assemble.end_before_start(inspect, delay=5) # 5-unit cooling period

# Time window constraints (require enforce)
model.enforce(cut.start() >= 0)
model.enforce(inspect.end() <= 200)

# Minimize completion time + machine cost
fast_machine_cost = sand_fast.presence().guard(0) * 50
model.minimize(inspect.end() + fast_machine_cost)

result = model.solve()
if result.solution:
print(f"Cut: {result.solution.get_start(cut)}-{result.solution.get_end(cut)}")
print(f"Sand: {result.solution.get_start(sand)}-{result.solution.get_end(sand)}")
if result.solution.is_present(sand_fast):
print(" (using fast machine)")
else:
print(" (using slow machine)")
print(f"Assemble: {result.solution.get_start(assemble)}-{result.solution.get_end(assemble)}")
print(f"Inspect: {result.solution.get_start(inspect)}-{result.solution.get_end(inspect)}")
print(f"Objective: {result.objective}")

See Also