Skip to content

This feature is currently in Preview.

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.

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 constraintWhat it meansExample
Hard constraintsMust always be satisfied.The morning shift requires 1 worker.
Per-entity constraint familiesThe same rule repeated per shift or per worker.Each worker can only be assigned to at most one shift.
Domain and linking constraintsRestrict decision variables and how they connect to the data.If Alice is assigned to Morning, then Bob cannot be assigned to Morning.
Forcing constraintsPrevent unwanted solutions from being accepted.Each shift must be covered by at least one worker.
Soft constraintsPreferences 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.

Create a requirement fragment with Model.require(), then add it to your decision problem with Problem.satisfy():

from relationalai.semantics import Float, Integer, Model
from relationalai.semantics.reasoners.prescriptive import Problem
m = Model("ShiftAssignment")
# Declare the model's schema
56 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 optimization
p = Problem(m, Float)
# Declare a decision-variable relationship
Worker.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 from m.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.

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, Model
from relationalai.semantics.reasoners.prescriptive import Problem
from relationalai.semantics.std import aggregates as agg
m = Model("ShiftAssignment")
56 collapsed lines
# Declare the model's schema
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")
# 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 that Worker-Shift pair.
  • .per(Shift) groups the sum by shift, so coverage represents several constraints, one per shift.
  • .per(Worker) groups the sum by worker, so capacity also 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 (like assigned_per_shift == Shift.required_workers) are forcing constraints that prevent that trivial solution.

Use implies(left, right) to express “if left holds, then right must hold”:

from relationalai.semantics import Float, Integer, Model
from 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) == 1 and the consequence chen.x_assign(evening, x) == 1 both 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 with p.satisfy(...).
  • The name=[...] value makes this constraint easy to identify in p.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.

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, Model
from 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.

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, Model
from 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 binary is_lead variables 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 index argument (here Worker.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.

Use p.num_constraints and p.display() to confirm your constraints were actually registered on the problem:

from relationalai.semantics import Float, Integer, Model
from relationalai.semantics.reasoners.prescriptive import Problem
from relationalai.semantics.std import aggregates as agg
m = Model("ShiftAssignment")
50 collapsed lines
# Declare the model's schema
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")
# 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 coverage and capacity constraints are registered with p.satisfy(). If those calls are skipped or match nothing, p.num_constraints can stay at 0.
  • p.num_constraints gives a quick count of constraints registered on the problem. It is a fast sanity check before you call p.solve().
  • p.display() prints a summary of the problem formulation, including the constraint names you set with name=[...].
SymptomLikely causeWhat to try
p.num_constraints prints 0Your 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.