Skip to content

This feature is currently in Preview.

Work with solutions

After Problem.solve() completes, you can access the solved values in your model or application logic. This guide covers how to access solved values and estimate how a small change to a constraint limit could affect the result.

Where solved values appear depends on how you declared decision variables with Problem.solve_for(). In other words, you don’t choose an output method after the solve. You determine where the results are based on what you already set when you declared the decision variables.

Use this table to determine where to look:

What you usedHow to access results
solve_for(..., populate=True)Query the populated decision relationship like any other relationship in your model. This is the most straightforward option and is ideal for workflows with a single Problem or multiple Problem instances that do not share decision variables.
solve_for(..., populate=False)Read solver-level variable values from the Problem with the Variable.values property. This is ideal for multi-Problem workflows that share decision variables, like scenario analysis.

If you used populate=True, the solve writes solved values into the relationship you declared as a decision variable. Query that relationship to read entity-aware results after the solve:

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")
# 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.
58 collapsed lines
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()),
)
m.define(
Worker.lookup(id=availability.worker_id).available_shifts(
Shift.lookup(id=availability.shift_id)
)
)
m.define(
Worker.lookup(id=costs.worker_id).cost_for_shift(
Shift.lookup(id=costs.shift_id), costs.cost
)
)
m.define(
Shift.lookup(id=required.shift_id).required_workers(
required.required_workers
)
)
# Create a decision problem
p = Problem(m, Float)
# Declare a decision relationship
Worker.x_assign = m.Relationship(
f"{Worker} is assigned to {Shift} if {Float:assigned}"
)
# Define decision variables with populate=True to write solved values back into the model.
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 assigned the required number of workers.
assigned_per_shift = agg.sum(X_ASSIGN).where(Worker.x_assign(Shift, X_ASSIGN)).per(Shift)
p.satisfy(m.require(assigned_per_shift == Shift.required_workers))
# Worker limit: each worker can be assigned to at most one shift.
assigned_per_worker = agg.sum(X_ASSIGN).where(Worker.x_assign(Shift, X_ASSIGN)).per(Worker)
p.satisfy(m.require(assigned_per_worker <= 1))
# Minimize total cost of assignments
cost = Float.ref("cost")
p.minimize(
agg.sum(cost * X_ASSIGN).where(
Worker.x_assign(Shift, X_ASSIGN),
Worker.cost_for_shift(Shift, cost),
)
)
# Solve the problem with a 1% relative optimality gap tolerance.
p.solve("highs", relative_gap_tolerance=0.01)
# Check the termination status and objective value.
info = p.solve_info()
print("status:", info.termination_status)
print("objective:", info.objective_value)
if info.termination_status == "OPTIMAL":
# Query active assignments from the populated relationship.
assigned = Float.ref("assigned")
q = (
m
.select(Worker.id, Shift.id, assigned)
.where(Worker.x_assign(Shift, assigned), assigned > 0.5)
)
# Inspect query results.
q.inspect()
  • p.solve_for(..., populate=True) writes solved values back into the model, so you can query them like any other relationship.
  • This model has a ternary decision relationship Worker.x_assign(Shift, X_ASSIGN) that is populated with solved values.
  • You can use conditions on Worker.x_assign to derive new concepts and relationships in your model based on the solver solution.
  • An optimal solve has an objective value of 17 and assigns worker 1 to shift 10 and worker 2 to shift 20.

If you used populate=False, the solve does not write values back into your model. Instead, read solved variable values from the Problem. This is the safer default when you want to run multiple Problem instances over the same Model, especially if they share decision variables, like in scenario analysis workflows.

After solving, query the Variable.values property for the solution you want and materialize it to a DataFrame:

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")
# 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.
58 collapsed lines
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()),
)
m.define(
Worker.lookup(id=availability.worker_id).available_shifts(
Shift.lookup(id=availability.shift_id)
)
)
m.define(
Worker.lookup(id=costs.worker_id).cost_for_shift(
Shift.lookup(id=costs.shift_id), costs.cost
)
)
m.define(
Shift.lookup(id=required.shift_id).required_workers(
required.required_workers
)
)
# Create a decision problem
p = Problem(m, Float)
# Declare a decision relationship
Worker.x_assign = m.Relationship(
f"{Worker} is assigned to {Shift} if {Float:assigned}"
)
# Define decision variables with populate=False to keep solved values at the Problem-level.
X_ASSIGN = Float.ref("x")
assignment_variable = p.solve_for(
Worker.x_assign(Shift, X_ASSIGN),
populate=False,
name=["assign", Worker.id, Shift.id],
where=[Worker.available_shifts(Shift)],
type="bin",
lower=0,
upper=1,
)
# Coverage: each shift must be assigned the required number of workers.
assigned_per_shift = agg.sum(X_ASSIGN).where(Worker.x_assign(Shift, X_ASSIGN)).per(Shift)
p.satisfy(m.require(assigned_per_shift == Shift.required_workers))
# Worker limit: each worker can be assigned to at most one shift.
assigned_per_worker = agg.sum(X_ASSIGN).where(Worker.x_assign(Shift, X_ASSIGN)).per(Worker)
p.satisfy(m.require(assigned_per_worker <= 1))
# Minimize total cost of assignments
cost = Float.ref("cost")
p.minimize(
agg.sum(cost * X_ASSIGN).where(
Worker.x_assign(Shift, X_ASSIGN),
Worker.cost_for_shift(Shift, cost),
)
)
# Solve the problem with a 1% relative optimality gap tolerance.
p.solve("highs", relative_gap_tolerance=0.01)
# Check the termination status and objective value.
info = p.solve_info()
print("status:", info.termination_status)
print("objective:", info.objective_value)
if info.termination_status == "OPTIMAL":
# Read active assignments from the first solution as a DataFrame.
val = Float.ref("val")
df = (
m
.select(
assignment_variable.worker.id.alias("worker_id"),
assignment_variable.shift.id.alias("shift_id"),
val.alias("value"),
)
.where(assignment_variable.values(0, val), val > 0.5)
.to_df()
)
print(df)
  • Retaining the variable returned by p.solve_for() lets you query solved values and the model entities they represent.
  • assignment_variable.values(sol_index, val) reads solved values for one solution; sol_index is 0-based, so 0 is the first solution.
  • The worker and shift properties associate each solver-level value with the corresponding Worker and Shift entities.
  • An optimal solve has an objective value of 17, and the DataFrame contains worker 1 with shift 10 and worker 2 with shift 20.
  • Using name=[...] is optional but recommended when you also need readable variable names in generic solver-level queries or logs.
  • With populate=False, solved values are not written to the original decision relationship. You can still associate them with model entities by retaining the variable returned by solve_for() and querying its entity properties alongside .values(). Use populate=True when you want PyRel to populate the original relationship automatically.
  • You can use the DataFrame returned by this query to send results to downstream workflows.

Estimate the effect of changing a constraint limit

Section titled “Estimate the effect of changing a constraint limit”

After an optimal solve, you can estimate how a small change to a constraint’s limit would affect the best possible result using a constraint’s shadow price.

This example models a furniture workshop that produces desk and chair production lots. It checks whether one more hour of assembly or finishing time would increase the workshop’s best possible profit:

42 collapsed lines
from relationalai.semantics import Float, Model, String
from relationalai.semantics.reasoners.prescriptive import Problem
from relationalai.semantics.std import aggregates as agg
m = Model("FurnitureProduction")
Product = m.Concept("Product", identify_by={"name": String})
Product.profit_per_lot = m.Property(
f"{Product} earns {Float:profit_per_lot} per production lot"
)
Product.assembly_hours_per_lot = m.Property(
f"{Product} uses {Float:assembly_hours_per_lot} assembly hours per production lot"
)
Product.finishing_hours_per_lot = m.Property(
f"{Product} uses {Float:finishing_hours_per_lot} finishing hours per production lot"
)
m.define(
Product.new(
name="desk",
profit_per_lot=500.0,
assembly_hours_per_lot=1.0,
finishing_hours_per_lot=2.0,
),
Product.new(
name="chair",
profit_per_lot=300.0,
assembly_hours_per_lot=1.0,
finishing_hours_per_lot=1.0,
),
)
Product.production_lots = m.Property(
f"{Product} production is {Float:production_lots} lots"
)
p = Problem(m, Float)
p.solve_for(
Product.production_lots,
name=["production_lots", Product.name],
lower=0,
)
# Maximize profit across all products.
p.maximize(agg.sum(Product.profit_per_lot * Product.production_lots))
# Keep assembly and finishing time within the workshop's available hours.
p.satisfy(
m.require(
agg.sum(Product.assembly_hours_per_lot * Product.production_lots) <= 4
),
name="assembly_hours",
)
p.satisfy(
m.require(
agg.sum(Product.finishing_hours_per_lot * Product.production_lots) <= 6
),
name="finishing_hours",
)
p.solve("highs", sensitivity=True)
if p.solve_info().termination_status == "OPTIMAL":
result = m.select(
p.Constraint.name.alias("constraint"),
p.Constraint.shadow_price.alias("estimated_profit_per_extra_hour"),
).to_df()
print(result)
  • .solve(..., sensitivity=True) requests post-solve sensitivity data, including shadow prices available through p.Constraint.shadow_price.
  • If you add sensitivity=True to .solve() in a notebook cell or interactive Python environment, you must re-run all of the code that defines the Problem, since calling .solve() a second time on a Problem raises a ValueError.
  • Because the problem uses Float and .solve_for() does not specify another variable type, each product gets one nonnegative continuous production variable.
  • The names passed to p.satisfy() identify the assembly and finishing limits, and the query pairs each name with the estimated change in maximum profit from increasing that limit by one hour.

The assembly_hours constraint has a shadow price of 100, while finishing_hours has a shadow price of 200. Near the current solution, one more assembly hour would increase the best possible profit by about 200. A shadow price of 0 would mean that increasing that constraint’s limit by one hour wouldn’t improve the result.

  • Sensitivity analysis requires an objective. PyRel raises an error if you request it on a satisfaction problem without one. If any decision variable is restricted to whole numbers or yes/no values, PyRel warns you and returns no sensitivity rows.
  • Check p.solve_info().termination_status before reading sensitivity values. Use them only when the status is OPTIMAL, as shown in the example above.
  • Treat a shadow price as an estimate for a small change, not a prediction for a large one.
  • A shadow price’s sign depends on whether the problem minimizes or maximizes and on the constraint’s direction, such as <= or >=. The prices are positive in this maximization example with <= limits.
  • Advanced sensitivity results are available on the Problem object: p.Variable exposes reduced_cost and basis_status, and p.Constraint exposes shadow_price and basis_status.

Continue with a complete example or compare the available solver backends: