Add constraints
Create requirement fragments with Model.require() and add them as solver constraints with Problem.satisfy() so the solver can only return feasible solutions.
This guide covers creating requirement fragments, writing per-entity constraints with aggregates, and inspecting constraints added to a Problem.
What a solution constraint is
Section titled “What a solution constraint is”A solution constraint is a condition that must hold in any solution the solver returns. Constraints define which solutions are allowed. The solver’s job is to find values for your decision variables that satisfy them.
Constraints come in a few common forms:
| Kind of constraint | What it means | Example |
|---|---|---|
| Hard constraints | Must always be satisfied. | The morning shift requires 1 worker. |
| Per-entity constraint families | The same rule repeated per shift or per worker. | Each worker can only be assigned to at most one shift. |
| Domain and linking constraints | Restrict decision variables and how they connect to the data. | If Alice is assigned to Morning, then Bob cannot be assigned to Morning. |
| Forcing constraints | Prevent unwanted solutions from being accepted. | Each shift must be covered by at least one worker. |
| Soft constraints | Preferences you can violate at a cost, typically modeled in the objective. | Bob prefers morning shifts. |
You can model each of these kinds of constraints in PyRel using prescriptive reasoning.
Add constraints with Problem.satisfy()
Section titled “Add constraints with Problem.satisfy()”Create a requirement fragment with Model.require(), then add it to your decision problem with Problem.satisfy():
from relationalai.semantics import Float, Integer, Modelfrom relationalai.semantics.reasoners.prescriptive import Problem
m = Model("ShiftAssignment")
# Declare the model's schema56 collapsed lines
Worker = m.Concept("Worker", identify_by={"id": Integer})Shift = m.Concept("Shift", identify_by={"id": Integer})
Worker.available_shifts = m.Relationship(f"{Worker} is available for {Shift}")Worker.cost_for_shift = m.Relationship(f"{Worker} working {Shift} has cost {Float:cost}")Shift.required_workers = m.Property(f"{Shift} requires {Integer:n} workers")
workers = m.data([ {"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}, {"id": 3, "name": "Chen"},])shifts = m.data([ {"id": 10, "name": "Morning"}, {"id": 20, "name": "Evening"},])availability = m.data([ {"worker_id": 1, "shift_id": 10}, {"worker_id": 2, "shift_id": 10}, {"worker_id": 2, "shift_id": 20}, {"worker_id": 3, "shift_id": 20},])costs = m.data([ {"worker_id": 1, "shift_id": 10, "cost": 9.0}, {"worker_id": 2, "shift_id": 10, "cost": 10.0}, {"worker_id": 2, "shift_id": 20, "cost": 8.0}, {"worker_id": 3, "shift_id": 20, "cost": 11.0},])required = m.data([ {"shift_id": 10, "required_workers": 1}, {"shift_id": 20, "required_workers": 1},])
m.define( Worker.new(workers.to_schema()), Shift.new(shifts.to_schema()),)
worker = Worker.filter_by(id=availability.worker_id)shift = Shift.filter_by(id=availability.shift_id)m.define(worker.available_shifts(shift))
worker = Worker.filter_by(id=costs.worker_id)shift = Shift.filter_by(id=costs.shift_id)m.define(worker.cost_for_shift(shift, costs.cost))
shift = Shift.filter_by(id=required.shift_id)m.define(shift.required_workers(required.required_workers))
# Create a problem for solver-backed optimizationp = Problem(m, Float)
# Declare a decision-variable relationshipWorker.x_assign = m.Relationship( f"{Worker} is assigned to {Shift} if {Float:assigned}")
# Define `Worker.x_assign` as a decision variable to be solved for,# scoped to available worker-shift pairs, and with binary values of 0 or 1.X_ASSIGN = Float.ref("x")p.solve_for( Worker.x_assign(Shift, X_ASSIGN), populate=True, name=["assign", Worker.id, Shift.id], where=[Worker.available_shifts(Shift)], type="bin", lower=0, upper=1,)
# Constraint: Bob is not assigned to Morning.bob = Worker.filter_by(id=2)morning = Shift.filter_by(id=10)
no_bob_morning = m.require(bob.x_assign(morning, X_ASSIGN) == 0)p.satisfy(no_bob_morning, name=["no-bob-morning"])- The decision variable is
Worker.x_assign(Shift, x), which is 1 if a worker is assigned to a shift and 0 otherwise. m.require(bob.x_assign(morning, x) == 0)creates a requirement fragment for a single worker–shift assignment.p.satisfy(no_bob_morning, name=[...])adds the constraint to the problem with a readable name.
p.satisfy()expects a fragment fromm.require(...). You can’t pass a Python boolean expression.- Constraints must reference at least one decision variable. If a constraint only references fixed data, an error is raised.
- Every decision variable should appear in at least one constraint or the objective. If a variable appears nowhere, it usually indicates a formulation bug. The solver can often set it arbitrarily without affecting feasibility or optimality.
Use common constraint patterns
Section titled “Use common constraint patterns”The following sections show how to express common constraint patterns with prescriptive reasoning.
Write per-entity constraints with aggregates and .per(...)
Section titled “Write per-entity constraints with aggregates and .per(...)”Use aggregation and the .per() method to write families of constraints that must hold per entity:
from relationalai.semantics import Float, Integer, Modelfrom relationalai.semantics.reasoners.prescriptive import Problemfrom relationalai.semantics.std import aggregates as agg
m = Model("ShiftAssignment")
56 collapsed lines
# Declare the model's schemaWorker = m.Concept("Worker", identify_by={"id": Integer})Shift = m.Concept("Shift", identify_by={"id": Integer})
Worker.available_shifts = m.Relationship(f"{Worker} is available for {Shift}")Worker.cost_for_shift = m.Relationship(f"{Worker} working {Shift} has cost {Float:cost}")Shift.required_workers = m.Property(f"{Shift} requires {Integer:n} workers")
# Define base facts.workers = m.data([ {"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}, {"id": 3, "name": "Chen"},])shifts = m.data([ {"id": 10, "name": "Morning"}, {"id": 20, "name": "Evening"},])availability = m.data([ {"worker_id": 1, "shift_id": 10}, {"worker_id": 2, "shift_id": 10}, {"worker_id": 2, "shift_id": 20}, {"worker_id": 3, "shift_id": 20},])costs = m.data([ {"worker_id": 1, "shift_id": 10, "cost": 9.0}, {"worker_id": 2, "shift_id": 10, "cost": 10.0}, {"worker_id": 2, "shift_id": 20, "cost": 8.0}, {"worker_id": 3, "shift_id": 20, "cost": 11.0},])required = m.data([ {"shift_id": 10, "required_workers": 1}, {"shift_id": 20, "required_workers": 1},])
m.define( Worker.new(workers.to_schema()), Shift.new(shifts.to_schema()),)
worker = Worker.filter_by(id=availability.worker_id)shift = Shift.filter_by(id=availability.shift_id)m.define(worker.available_shifts(shift))
worker = Worker.filter_by(id=costs.worker_id)shift = Shift.filter_by(id=costs.shift_id)m.define(worker.cost_for_shift(shift, costs.cost))
shift = Shift.filter_by(id=required.shift_id)m.define(shift.required_workers(required.required_workers))
p = Problem(m, Float)
Worker.x_assign = m.Relationship( f"{Worker} is assigned to {Shift} if {Float:assigned}")
X_ASSIGN = Float.ref("x")p.solve_for( Worker.x_assign(Shift, X_ASSIGN), populate=True, name=["assign", Worker.id, Shift.id], where=[Worker.available_shifts(Shift)], type="bin", lower=0, upper=1,)
# Coverage: each shift must be covered by the required number of workers.assigned_per_shift = ( agg.sum(X_ASSIGN) .where(Worker.x_assign(Shift, X_ASSIGN)) .per(Shift))coverage = m.require(assigned_per_shift == Shift.required_workers)p.satisfy(coverage, name=["coverage", Shift.id])
# Capacity: each worker is assigned to at most one shift.assigned_per_worker = ( agg.sum(X_ASSIGN) .where(Worker.x_assign(Shift, X_ASSIGN)) .per(Worker))capacity = m.require(assigned_per_worker <= 1)p.satisfy(capacity, name=["capacity", Worker.id])In this example:
agg.sum(X_ASSIGN)sums the assignment variables..where(Worker.x_assign(Shift, X_ASSIGN))filters to only the assignment variables for thatWorker-Shiftpair..per(Shift)groups the sum by shift, socoveragerepresents several constraints, one per shift..per(Worker)groups the sum by worker, socapacityalso represents several constraints, one per worker.
- If you only add capacity-style constraints (like
assigned_per_worker <= 1) and then later minimize cost, an all-zero assignment can be feasible and optimal. Coverage-style constraints (likeassigned_per_shift == Shift.required_workers) are forcing constraints that prevent that trivial solution.
Write if-then constraints
Section titled “Write if-then constraints”Use implies(left, right) to express “if left holds, then right must hold”:
from relationalai.semantics import Float, Integer, Modelfrom relationalai.semantics.reasoners.prescriptive import Problem, implies
m = Model("ShiftAssignment")
46 collapsed lines
Worker = m.Concept("Worker", identify_by={"id": Integer})Shift = m.Concept("Shift", identify_by={"id": Integer})
Worker.available_shifts = m.Relationship(f"{Worker} is available for {Shift}")
workers = m.data([ {"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}, {"id": 3, "name": "Chen"},])shifts = m.data([ {"id": 10, "name": "Morning"}, {"id": 20, "name": "Evening"},])availability = m.data([ {"worker_id": 1, "shift_id": 10}, {"worker_id": 2, "shift_id": 10}, {"worker_id": 2, "shift_id": 20}, {"worker_id": 3, "shift_id": 20},])
m.define( Worker.new(workers.to_schema()), Shift.new(shifts.to_schema()),)
worker = Worker.filter_by(id=availability.worker_id)shift = Shift.filter_by(id=availability.shift_id)m.define(worker.available_shifts(shift))
p = Problem(m, Float)
Worker.x_assign = m.Relationship( f"{Worker} is assigned to {Shift} if {Float:assigned}")
x = Float.ref("x")p.solve_for( Worker.x_assign(Shift, x), populate=True, name=["assign", Worker.id, Shift.id], where=[Worker.available_shifts(Shift)], type="bin", lower=0, upper=1,)
bob = Worker.filter_by(id=2)chen = Worker.filter_by(id=3)evening = Shift.filter_by(id=20)
p.satisfy( m.require( implies( bob.x_assign(evening, x) == 1, chen.x_assign(evening, x) == 1 ) ), name=["bob-evening-implies-chen-evening"])implies(left, right)encodes an if-then rule the solver must enforce.- The condition
bob.x_assign(evening, x) == 1and the consequencechen.x_assign(evening, x) == 1both reference decision variables. That makes this a solver constraint instead of a data-only check. - Wrapping the implication with
m.require(...)produces a constraint fragment you can register on the problem withp.satisfy(...). - The
name=[...]value makes this constraint easy to identify inp.display()output.
implies(...)creates a single logical constraint expression. If you need a family of if-then constraints (for example, one per shift), build the per-entity grouping around the expression using.where(...)and.per(...)on your matches.
Require all values to be different
Section titled “Require all values to be different”Use all_different() when you want every value in a set to be different, such as when you want to assign distinct workers to each shift:
from relationalai.semantics import Float, Integer, Modelfrom relationalai.semantics.reasoners.prescriptive import Problem, all_different
m = Model("ShiftAssignment")
35 collapsed lines
Worker = m.Concept("Worker", identify_by={"id": Integer})Shift = m.Concept("Shift", identify_by={"id": Integer})
workers = m.data([ {"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}, {"id": 3, "name": "Chen"},])shifts = m.data([ {"id": 10, "name": "Morning"}, {"id": 20, "name": "Evening"},])
m.define( Worker.new(workers.to_schema()), Shift.new(shifts.to_schema()),)
p = Problem(m, Float)
# Decision variable: each shift picks an integer-coded lead worker id.Shift.lead_worker_id = m.Relationship( f"{Shift} has lead worker id {Float:worker_id}")
X_WORKER_ID = Float.ref("worker_id")p.solve_for( Shift.lead_worker_id(X_WORKER_ID), populate=True, name=["lead", Shift.id], where=[Shift], type="int", lower=1, upper=3,)
# Constraint: no worker can be the lead for more than one shift.unique_leads = m.require( all_different(X_WORKER_ID).where(Shift.lead_worker_id(X_WORKER_ID)))p.satisfy(unique_leads, name=["distinct-shift-leads"])- The decision variable is
Shift.lead_worker_id(worker_id). The solver chooses one integer-coded worker id per shift. all_different(...)forces those chosen ids to be distinct across all shifts. That prevents assigning the same lead to multiple shifts..where(Shift.lead_worker_id(worker_id))scopes the constraint to the decision-variable values.p.satisfy(...)registers the constraint so it applies during solving.
all_different(...)is most useful when the group is small to medium. On large groups it can expand into many pairwise restrictions in some backends, which can increase formulation size.
Limit choices to one option
Section titled “Limit choices to one option”Use special_ordered_set_type_1(index, variables) when you need to limit a set of binary variables so that at most one can be 1:
from relationalai.semantics import Float, Integer, Modelfrom relationalai.semantics.reasoners.prescriptive import Problem, special_ordered_set_type_1
m = Model("ShiftAssignment")
34 collapsed lines
Worker = m.Concept("Worker", identify_by={"id": Integer})Shift = m.Concept("Shift", identify_by={"id": Integer})
workers = m.data([ {"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"},])shifts = m.data([ {"id": 10, "name": "Morning"}, {"id": 20, "name": "Evening"},])
m.define( Worker.new(workers.to_schema()), Shift.new(shifts.to_schema()),)
p = Problem(m, Float)
# Decision variable: for each shift, optionally choose a single lead worker.Shift.is_led_by = m.Relationship( f"{Shift} is led by {Worker} if {Float:is_lead}")
X_IS_LEAD = Float.ref("is_lead")p.solve_for( Shift.is_led_by(Worker, X_IS_LEAD), populate=True, name=["lead", Shift.id, Worker.id], where=[Shift, Worker], type="bin", lower=0, upper=1,)
sos1 = m.require( special_ordered_set_type_1(Worker.id, X_IS_LEAD) .where(Shift.is_led_by(Worker, X_IS_LEAD)) .per(Shift))p.satisfy(sos1, name=["at-most-one-shift-lead", Shift.id])- The decision variable is
Shift.is_led_by(Worker, is_lead), which is 1 if that worker is chosen as the lead for the shift. special_ordered_set_type_1(Worker.id, is_lead)enforces that, within each shift, at most one of the binaryis_leadvariables can be 1..per(Shift)creates one SOS1 constraint per shift.- This example allows a shift to have no lead.
If you want exactly one lead, add a separate forcing constraint such as
agg.sum(is_lead).per(Shift) == 1.
- The
indexargument (hereWorker.id) defines the ordering for the set. For SOS1, the ordering does not change the “at most one is non-zero” meaning. You should still use a stable index so the backend can interpret the set consistently.
Inspect Problem constraints
Section titled “Inspect Problem constraints”Use p.num_constraints and p.display() to confirm your constraints were actually registered on the problem:
from relationalai.semantics import Float, Integer, Modelfrom relationalai.semantics.reasoners.prescriptive import Problemfrom relationalai.semantics.std import aggregates as agg
m = Model("ShiftAssignment")
50 collapsed lines
# Declare the model's schemaWorker = m.Concept("Worker", identify_by={"id": Integer})Shift = m.Concept("Shift", identify_by={"id": Integer})
Worker.available_shifts = m.Relationship(f"{Worker} is available for {Shift}")Worker.cost_for_shift = m.Relationship(f"{Worker} working {Shift} has cost {Float:cost}")Shift.required_workers = m.Property(f"{Shift} requires {Integer:n} workers")
# Define base facts.workers = m.data([ {"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}, {"id": 3, "name": "Chen"},])shifts = m.data([ {"id": 10, "name": "Morning"}, {"id": 20, "name": "Evening"},])availability = m.data([ {"worker_id": 1, "shift_id": 10}, {"worker_id": 2, "shift_id": 10}, {"worker_id": 2, "shift_id": 20}, {"worker_id": 3, "shift_id": 20},])costs = m.data([ {"worker_id": 1, "shift_id": 10, "cost": 9.0}, {"worker_id": 2, "shift_id": 10, "cost": 10.0}, {"worker_id": 2, "shift_id": 20, "cost": 8.0}, {"worker_id": 3, "shift_id": 20, "cost": 11.0},])required = m.data([ {"shift_id": 10, "required_workers": 1}, {"shift_id": 20, "required_workers": 1},])
m.define( Worker.new(workers.to_schema()), Shift.new(shifts.to_schema()),)
worker = Worker.filter_by(id=availability.worker_id)shift = Shift.filter_by(id=availability.shift_id)m.define(worker.available_shifts(shift))
worker = Worker.filter_by(id=costs.worker_id)shift = Shift.filter_by(id=costs.shift_id)m.define(worker.cost_for_shift(shift, costs.cost))
shift = Shift.filter_by(id=required.shift_id)m.define(shift.required_workers(required.required_workers))
p = Problem(m, Float)
22 collapsed lines
Worker.x_assign = m.Relationship( f"{Worker} is assigned to {Shift} if {Float:assigned}")
x = Float.ref("x")p.solve_for( Worker.x_assign(Shift, x), populate=True, name=["assign", Worker.id, Shift.id], where=[Worker.available_shifts(Shift)], type="bin", lower=0, upper=1,)
assigned_per_shift = agg.sum(x).where(Worker.x_assign(Shift, x)).per(Shift)coverage = m.require(assigned_per_shift == Shift.required_workers)p.satisfy(coverage, name=["coverage", Shift.id])
assigned_per_worker = agg.sum(x).where(Worker.x_assign(Shift, x)).per(Worker)capacity = m.require(assigned_per_worker <= 1)p.satisfy(capacity, name=["capacity", Worker.id])
# Show the number of constraints added to the problem.# NOTE: This queries the model.print(p.num_constraints)
# Show info about the problem, including the constraints you just added.p.display()- The
coverageandcapacityconstraints are registered withp.satisfy(). If those calls are skipped or match nothing,p.num_constraintscan stay at 0. p.num_constraintsgives a quick count of constraints registered on the problem. It is a fast sanity check before you callp.solve().p.display()prints a summary of the problem formulation, including the constraint names you set withname=[...].
Avoid common pitfalls
Section titled “Avoid common pitfalls”| Symptom | Likely cause | What to try |
|---|---|---|
p.num_constraints prints 0 | Your Problem has no registered constraints. | Ensure that the conditions in your m.require() have real matches and do not filter out all possible assignments. |
You get an error when adding a constraint with p.satisfy(). | Your constraint references no decision variables, so the solver can’t enforce it. | Ensure that your constraint references at least one decision variable. If it only references fixed data, the solver can’t do anything with it and an error is raised. |