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.
Determine how to access results
Section titled “Determine how to access results”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 used | How 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. |
Query a populated relationship
Section titled “Query a populated relationship”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, Modelfrom relationalai.semantics.reasoners.prescriptive import Problemfrom relationalai.semantics.std import aggregates as agg
m = Model("ShiftAssignment")
# 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.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 problemp = Problem(m, Float)
# Declare a decision relationshipWorker.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 assignmentscost = 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_assignto derive new concepts and relationships in your model based on the solver solution. - An optimal solve has an objective value of
17and assigns worker1to shift10and worker2to shift20.
Read solver-level variable values
Section titled “Read solver-level variable values”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, Modelfrom relationalai.semantics.reasoners.prescriptive import Problemfrom relationalai.semantics.std import aggregates as agg
m = Model("ShiftAssignment")
# 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.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 problemp = Problem(m, Float)
# Declare a decision relationshipWorker.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 assignmentscost = 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_indexis 0-based, so0is the first solution.- The
workerandshiftproperties associate each solver-level value with the correspondingWorkerandShiftentities. - An optimal solve has an objective value of
17, and the DataFrame contains worker1with shift10and worker2with shift20. - 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 bysolve_for()and querying its entity properties alongside.values(). Usepopulate=Truewhen 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, Stringfrom relationalai.semantics.reasoners.prescriptive import Problemfrom 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 throughp.Constraint.shadow_price.- If you add
sensitivity=Trueto.solve()in a notebook cell or interactive Python environment, you must re-run all of the code that defines theProblem, since calling.solve()a second time on aProblemraises aValueError. - Because the problem uses
Floatand.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 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_statusbefore reading sensitivity values. Use them only when the status isOPTIMAL, 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
Problemobject:p.Variableexposesreduced_costandbasis_status, andp.Constraintexposesshadow_priceandbasis_status.
Where to go next
Section titled “Where to go next”Continue with a complete example or compare the available solver backends: