plugins/rai/skills/rai-prescriptive-results-interpretation/SKILL.md
Interprets optimization solver output including solution extraction, status codes, quality assessment, result explanation, and sensitivity analysis. Use when analyzing solve results or communicating optimization outcomes.
npx skillsauth add RelationalAI/rai-agent-skills rai-prescriptive-results-interpretationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
What: Post-solve activities — solution extraction, status interpretation, quality assessment, result explanation, and sensitivity analysis.
When to use:
Root-cause diagnosis of INFEASIBLE/DUAL_INFEASIBLE (demand vs capacity, contradictory constraints, missing bounds) lives in this skill — see Status Interpretation below. For solver-level error messages and engine-driven debugging (si.error, print_format=), see rai-prescriptive-solver-management.
When NOT to use:
rai-prescriptive-problem-formulation. In particular, if the result is OPTIMAL and technically valid but the user rejects it on preference grounds ("that's too much X", "I don't like this allocation"), this indicates latent constraints, not a solver or formulation bug — route to rai-prescriptive-problem-formulation > Constraint Elicitation > Post-Solve: Iterative Refinement.rai-prescriptive-solver-managementrai-queryingrai-pyrel-codingOverview:
After a solve completes, interpret results in this order:
si = problem.solve_info()
if si.termination_status == "OPTIMAL":
# With populate=True (default): query via model.select()
# With populate=False: use Variable.values() — see Solution Extraction below
print(f"Objective: {si.objective_value}")
For the full extraction patterns and result-attribute tables, see Solution Extraction below.
After a successful solve, extract results using result attributes and model queries.
After problem.solve(), result attributes are available via two interfaces:
Engine-side (Relationships) — usable in model.require(), model.select(), and solver expressions:
| Method | Return type | Description |
|--------|-------------|-------------|
| problem.termination_status() | Relationship | "OPTIMAL", "INFEASIBLE", "DUAL_INFEASIBLE", "TIME_LIMIT", "LOCALLY_SOLVED", "SOLUTION_LIMIT" |
| problem.objective_value() | Relationship | Optimal objective value |
| problem.num_points() | Relationship | Number of solutions returned |
| problem.error() | Relationship | Error message tuple (if solve failed) |
| problem.printed_model() | Relationship | Text representation (with print_format=) |
| problem.num_variables() | Relationship | Total registered variables |
| problem.num_constraints() | Relationship | Total constraints |
| problem.num_min_objectives() | Relationship | Number of minimize objectives |
| problem.num_max_objectives() | Relationship | Number of maximize objectives |
Python-side (solve_info()) — for formatted output, scenario loops, and Python logic:
| Attribute | Type | Description |
|-----------|------|-------------|
| si.termination_status | str | "OPTIMAL", "INFEASIBLE", "DUAL_INFEASIBLE", "TIME_LIMIT", "LOCALLY_SOLVED", "SOLUTION_LIMIT" |
| si.objective_value | float / int | Optimal objective value |
| si.solve_time_sec | float | Wall-clock solve time in seconds |
| si.num_points | int | Number of solutions returned |
| si.error | tuple | Error message (if solve failed) |
| si.solver_version | str | Version of the solver used |
| si.printed_model | str | Solver model text (if print_format= was set) |
problem.solve("highs", time_limit_sec=60)
# Engine-side: integrity constraint on status
model.require(problem.termination_status() == "OPTIMAL")
# Python-side: formatted output
si = problem.solve_info()
si.display()
print("status:", si.termination_status)
print("objective:", si.objective_value)
print("solve time:", si.solve_time_sec, "sec")
print("solutions:", si.num_points)
Where solved values appear depends on the populate parameter in solve_for():
| populate setting | Where results live | How to access | When to use |
|---|---|---|---|
| populate=True (default) | Written back into model properties | model.select() queries | Standard single-solve workflows |
| populate=False | Solver-level only | Variable.values(sol_index, value_ref) on the ProblemVariable returned by solve_for() | Scenario loops, multi-solve workflows with shared variables |
Primary approach: model.select() with populate=True (default, recommended)
With populate=True, solved values are written back into the model as property values. Query them like any other model data — with entity context, aliases, and filtering:
# Canonical extraction: filter active binary decisions, alias columns, display or convert to DataFrame
model.select(
MachinePeriod.machine.machine_id.alias("machine_id"),
MachinePeriod.period.pid.alias("period"),
MachinePeriod.x_maintain.alias("maintained"),
).where(MachinePeriod.x_maintain > 0.5).inspect() # or .to_df() for Python analysis
populate=False approach: Variable.values() on ProblemVariable
solve_for() returns a ProblemVariable — a Concept with back-pointer attributes for each non-value field in the Property's format string. Call .values(sol_index, value_ref) on it to extract solution values:
# Property: f"{Assignment} has {Float:x}" → back-pointer var.assignment, value field x
assign_var = problem.solve_for(Assignment.x, type="bin", populate=False)
problem.solve("highs")
value_ref = Float.ref()
df = model.select(
assign_var.assignment.worker.name.alias("worker"),
value_ref.alias("value"),
).where(assign_var.values(0, value_ref), value_ref > 0.5).to_df()
Extraction principles:
> 0.5, not == 1 (numeric tolerance in MIP solvers)> 1e-6 to remove near-zero solver noiseproblem.solve_info().objective_value for single values (no query needed).alias() on every output column for clean DataFramesaggs.count, aggs.sum over Integer) and integer-typed solver columns return as nullable Int128Array — pandas reductions raise TypeError without a cast. Inline the cast at extraction time, not later:
df = model.select(
Route.name.alias("route"),
aggs.count(Trip).per(Route).alias("trips"), # Integer aggregate → Int128Array
).to_df()
df["trips"] = df["trips"].astype("int64") # Cast before .sum() / .groupby().agg() / .iloc[]
For per-pattern variations (multiple solutions, iterative solving, scenario/parametric extraction, full Variable.values() back-pointer naming rules with table of examples, silent-failure warnings, and Snowflake table export), see references/solution-extraction-details.md.
problem.verify(*fragments) temporarily installs constraint ICs, triggers a query to evaluate them, and removes them. Useful for checking that the solver's solution satisfies constraints — particularly for exact solvers (HiGHS MIP, MiniZinc):
coverage_ic = model.where(...).require(...)
problem.satisfy(coverage_ic)
problem.solve("minizinc", time_limit_sec=60)
problem.verify(coverage_ic) # Warns if any constraint is violated
verify() checks termination_status first — warns and returns early for non-successful solves. ICs are cleaned up in a finally block even on exceptions.
The solver found the best possible solution within its tolerance settings (typically 1e-6 for LP, 0.01% MIP gap for MIP).
What to tell users: "We found the best possible plan given your requirements. Here is what it recommends..." Next steps: Proceed to quality assessment, then explain results.
No solution satisfies all constraints simultaneously. The problem as stated is impossible.
Diagnosis steps:
problem.satisfy(...) call removed at a time and re-solve. The constraint whose removal makes the problem feasible is the binding conflict. (Problem accumulates satisfy calls — there's no in-place remove_constraint API; build a fresh Problem each iteration.)What to tell users: "The requirements as stated cannot all be satisfied simultaneously. The most likely conflict is [specific conflict]. Options: relax [constraint], increase [capacity], or allow unmet demand with a penalty." Next steps: Identify the binding conflict, present trade-off options, add slack/penalty variables. A common and valuable path is moving the conflicting hard constraint to the objective with a penalty — feasibility restoration through softening is often more useful than pure diagnosis.
The objective can improve infinitely — the solver can keep making the solution "better" without limit. The termination status is "DUAL_INFEASIBLE" (not "UNBOUNDED").
Diagnosis steps:
What to tell users: "The model is missing limits that would bound the solution. Likely cause: [missing capacity constraint / missing budget limit / wrong objective direction]." Next steps: Add missing bounds or constraints, verify objective direction and coefficient signs.
For MIP problems, HiGHS may return "Feasible" instead of "OPTIMAL" when a solution is found but optimality is not proven within the default MIP gap tolerance. Treat "Feasible" the same as "TIME_LIMIT" for gap interpretation below; check the solver log for the realized gap.
The solver found a feasible solution but could not prove it is optimal within the time allowed.
Gap interpretation:
What to tell users: "The solver found a solution within [X%] of the best possible. [If gap is small: This is likely very close to optimal.] [If gap is large: More time or a simpler model could improve this.]" Next steps: For large gaps, increase time limit, tighten Big-M values, add symmetry-breaking constraints, or simplify the model.
A non-optimal termination status is information about the problem structure, not necessarily a failure to fix.
INFEASIBLE as diagnostic tool:
satisfy(...) call at a time and re-solve. The omitted constraint whose absence makes the problem feasible is the binding conflict. This is faster than manual inspection for large formulations. (Problem accumulates satisfy calls — there's no in-place removal; build a fresh Problem each iteration.)TIME_LIMIT with acceptable gap:
Reframing for users:
When to iterate vs. accept:
Compilation or solver errors prevented a solution.
Common causes: Undefined properties referenced in formulation, type mismatches, syntax errors in expressions, solver license issues. What to tell users: "The model could not be solved due to a technical error: [error message]. This needs to be fixed before we can get results." Next steps: Check compilation output, fix expression syntax, verify all referenced properties exist.
When the question is "does the property hold?" or "is there any configuration where X happens?", the answer comes from the termination status, not the objective value. This inverts MIP intuition: INFEASIBLE is the desired outcome.
| Termination status | Audit verdict |
|--------------------|---------------|
| INFEASIBLE | PASS — no configuration satisfies the witness condition. The property holds. |
| OPTIMAL or SOLUTION_LIMIT | FAIL — at least one configuration was found that violates the property. Extract witnesses for the report. |
| Other (TIME_LIMIT, error, LOCALLY_SOLVED on a CSP) | INCONCLUSIVE — solver did not exhaust the search. Do not interpret as PASS. |
Critical: num_points() == 0 does not prove the property holds — the solver may have crashed, hit a time limit, or produced zero solutions for any other reason. Always check the termination status first.
For the full pattern (counterexample-IC-as-negation, verdict-gated extraction, witness reporting), see rai-prescriptive-problem-formulation/references/csp-formulation.md § 5 and examples/audit_witness.py.
The solvability ladder defines progressive quality gates for an optimization formulation. Each level subsumes the previous — reaching "non-trivial" means all prior gates also passed. Use this to classify where a formulation stands and what to fix next.
| Level | Gate | What it proves | Check |
|-------|------|---------------|-------|
| generates | Code generates | LLM produced syntactically valid PyRel | Code parses without syntax errors |
| compiles | Compiles | display() succeeds — formulation converts to solver-ready form | display() returns without error; variables, constraints, objective all registered |
| solves | Solves | Solver accepts the problem and returns a result (any status, no crash/error) | solve() completes without exception; problem.solve_info() returns a status |
| optimal | OPTIMAL | Solver found a proven optimum (or TIME_LIMIT with acceptable gap <5%) | problem.termination_status() == "OPTIMAL" or (status == "TIME_LIMIT" and gap < 0.05) |
| non-trivial | Non-trivial | Solution has meaningful activity — not all zeros, not vacuous | problem.objective_value() != 0, non_zero_ratio > 0.01, not all variables at bounds |
| meaningful | Meaningful | Decisions are actionable — right scale, distribution, entity coverage | Domain-specific: quantities match demand scale, assignments cover tasks, flows balance |
How to use the ladder:
For detailed root causes by level (generates, compiles, solves, optimal, non-trivial, meaningful) and the 5-step diagnosis protocol, see references/failure-taxonomy.md.
After reaching optimal on the solvability ladder, the solution still needs quality checks to reach non-trivial and meaningful. A solver can return "optimal" for a trivially empty or degenerate problem. Type-specific expectations: Resource Allocation should show non-zero allocations summing near budget/capacity; Network Flow should show balanced flows with non-zero arcs; Scheduling/Assignment should show each task/shift covered; Pricing should show differentiated prices across entities. For multi-period problems of any type, verify non-trivial quantities across periods.
| Status | Ladder Gate | Meaning | Action | |--------|------------|---------|--------| | GOOD | meaningful | No critical or warning issues; decisions are actionable | Proceed to explain results | | WARNING | non-trivial | Solution has activity but minor concerns (some at bounds, objective outside expected range) | Review flagged items, proceed if acceptable | | POOR | Below non-trivial | Critical issues (trivial solution, zero objective, all variables at bounds) | Diagnose root cause using failure taxonomy before presenting results |
A solution is trivial when the solver technically found an optimum, but the result is meaningless:
All-zero solution (non_zero_ratio < 1%): The solver set all decision variables to zero. For a minimize objective, this means "do nothing" is cheapest -- almost always indicates missing forcing constraints (demand satisfaction, coverage requirements).
Objective value = 0 on minimize: A zero-cost solution nearly always means the solver found that doing nothing satisfies all constraints. The forcing constraints that should require activity (demand fulfillment, assignments, coverage) are either missing or their joins matched zero rows.
All binary variables = 0 or all = 1: In assignment/selection problems, if every binary variable takes the same value, the problem likely has no meaningful differentiation between choices. Check that selection costs/values differ and that cardinality constraints are present.
Only one entity selected when many expected: If the problem should distribute across multiple entities but concentrates on one, check for missing balance/fairness constraints or extreme cost differentials.
All variables at bounds (vacuous satisfaction):
A forcing constraint is one that requires decision variables to take positive values. Without them, minimize objectives will produce all-zero solutions.
Diagnosis: If the objective is minimize and the solution is all zeros, look for which requirements from the data should force positive activity, and verify those constraints exist and their joins match actual data. For common forcing constraint patterns and code examples, see rai-prescriptive-problem-formulation/constraint-formulation.md > Forcing Constraints.
Beyond automated detection, apply domain judgment:
When diagnosing solution quality issues, follow this sequence:
Prefer constraint fixes over variable fixes. All fixes must be grounded in actual model context (concept names, properties, relationships). See rai-prescriptive-problem-formulation/references/fix-generation-guidelines.md for root cause taxonomy, grounding rules, and constraint fix requirements.
x_ prefix (e.g., x_quantity, x_assigned). When generating user-facing prose (rationale, business_mapping, result tables shown to the user), translate the x_ names to business language. Code samples, API references, and Python variable names stay technical.
x_flow → "shipment quantity" or "units shipped"x_assigned → "assigned" or "selected"x_quantity → "production quantity" or "units allocated"x_open → "facility is open" or "selected for use"When fixing trivial solutions, fix broken join paths in constraints — not aggregate workarounds. Navigate from bound concepts (e.g., Demand.customer.site not Customer.site), and always navigate FROM the .per() entity. See rai-prescriptive-problem-formulation/references/fix-generation-guidelines.md for full diagnosis steps, examples, and navigation path rules.
Solution explanation is the return leg of bidirectional translation: the solver produced math, now translate it back into business language and actionable recommendations. Decision makers should never need to interpret solver output directly.
Solution results contain values for "decision concepts" — entities created to represent optimization choices.
x_ prefix → decision variables controlled by the solver. Translate x_ prefixed names to business terms when presenting results — the prefix is an internal convention that confuses business users (e.g., x_quantity = 150 → "produce 150 units", x_assigned = 1 → "assigned")Present results in this order (6-part template):
Problem Context (2-3 sentences): What problem was being solved and the business context.
What Was Decided: The key allocations, assignments, or quantities. Lead with the actionable output. Include objective value and what it means in business terms.
Why These Decisions (Key Drivers): Which constraints and costs drove the solution. Answer: "Why was X selected?", "Why was Y excluded?", "What's preventing Z?" — using actual entity names from the solution.
Solution Quality Assessment: Is the solution useful? Check for non-triviality, actionability, interpretability. Flag any red flags:
Business Impact: Translate the objective value and key metrics into business language. What does this mean for the organization?
Recommended Next Steps: What should the user do with this solution?
Decision makers need to understand not just what the solution recommends, but why. Reason from the formulation — which constraints are tight, which costs/coefficients drove the choice — to answer:
Frame every explanation in terms the decision maker already knows — their entities, their resources, their constraints — not variable indices or solver internals. (Shadow prices / dual values are not yet exposed in PyRel v1; reason from binding-constraint identification and scenario deltas instead.)
Frame sensitivity results as conditional business statements:
Sensitivity analysis answers: "What happens if our assumptions change?" Present results as scenario comparison tables, not mathematical derivatives. Prioritize parameters that are uncertain (demand forecasts, cost estimates), controllable (budget limits, service level targets), or high-impact (small changes cause structural solution changes).
Scenarios should be treated as a default post-solve step, not an optional advanced feature. After every solve, proactively suggest 1-2 scenario variations based on binding constraints and parameter sensitivity.
Strategic vs. operational context: For strategic (one-time planning) decisions, Pareto frontiers showing the tradeoff surface are preferred — stakeholders choose from the frontier. For operational (recurring) decisions, weighted objectives with tunable weights are more practical — set once, run daily. Detect which context applies and frame scenario results accordingly.
Pareto frontier / efficient frontier results: When results come from an epsilon constraint loop (bi-objective optimization), each point on the frontier is a complete, valid solution — no point is strictly better than another. Explain to the user: "Each point represents a different balance between your two goals. The knee is where further improvement in one goal starts costing significantly more in the other." For the full analysis structure (tradeoff table, marginal rate analysis, knee detection, allocation shifts, regime characterization) and how to present results as a menu of operating points, see references/sensitivity-analysis.md.
Parameter types: numeric (range with step), entity (select/exclude specific entities), categorical (discrete named options). A parameter is critical if small changes cause different facilities/assets to be selected, >5% objective change per 10% parameter variation, or constraint status flips.
For parameter sweep patterns, scenario comparison tables, and Pareto frontier construction, see references/sensitivity-analysis.md.
| Mistake | Cause | Fix |
|---------|-------|-----|
| Zero objective on minimize | Missing forcing constraints | Add sum(x).per(Entity) >= Entity.demand or equivalent |
| All-zero from join mismatch | Forcing constraints exist but .where() joins match zero rows | Verify constraint joins match actual data |
| Infeasible: demand > capacity | Total demand exceeds supply | Add slack/penalty variables or relax demand constraints |
| Silent: problem.termination_status == "OPTIMAL" (no parens) — always False | termination_status on the Problem object is a bound method, not a property; comparing a method to a string is never equal and the bug is silent | Read status Python-side: problem.solve_info().termination_status == "OPTIMAL" (no parens — solve_info() returns a dataclass-like value with a string field). Engine-side inside model.require(...): problem.termination_status() == "OPTIMAL" (with parens — that's the engine-side Relationship) |
| Silent: non-OPTIMAL result extraction returns empty DataFrame / None objective | Loop / scenario workflows extract Variable.values() or read si.objective_value without checking status first. Infeasible / time-limited solves produce an empty query and None objective, silently propagated into downstream code | Always guard: if si.termination_status not in ("OPTIMAL", "LOCALLY_SOLVED"): continue (or raise) before touching si.objective_value or the extraction query |
| Multi-solution overclaim — treating solution_limit=K results as top-K-optimal, ranked, or diversity-maximized | MiniZinc returns up to K distinct feasible solutions; the set is neither ranked by objective nor maximally diverse | Document the semantics to consumers: up to K distinct feasible, no ordering guarantee. For "top-K by objective," resolve K times with the previous-best as a constraint. See rai-prescriptive-problem-formulation/references/csp-formulation.md § Multi-solution mode. |
| Silent: verify() returns OK on solver-only-IC bodies (implies-bodied or all_different-bodied) even when the IC is violated | The verify engine cannot ground these wire-format constraint relations at check time, so it silently returns OK. Documented engine limitation, not a solver bug | Pick the regime that matches the constraint mix (rai-prescriptive-problem-formulation/references/csp-formulation.md § 6): mixed → call verify() + post-solve assertions on solver-only ICs; all-solver-only → skip verify() entirely; populate=False → skip verify() (no relational-layer values to ground) |
| Audit misread: num_points() == 0 interpreted as "property holds" | In status-aware audit problems, a zero num_points can result from a crash, time-limit, or any non-success status — NOT necessarily proof that the property holds | Always check termination_status first. Only INFEASIBLE proves the property holds. TIME_LIMIT, errors, and LOCALLY_SOLVED on a CSP are INCONCLUSIVE. See Audit / witness mode section above. |
For additional pitfalls (numerical instability, degenerate solutions, wrong aggregation scope, missing/null data) — distinct from the silent-bug rows above — see references/common-pitfalls.md.
Use this after every solve to ensure result quality:
If checks fail: Trivial solution (all zeros) → add forcing constraints first. Infeasible → relax constraints (or rebuild the Problem omitting a conflicting satisfy(...) call). See rai-prescriptive-problem-formulation/references/fix-generation-guidelines.md for fix strategies.
| Pattern | Description | File |
|---|---|---|
| Scenario Concept results | Results in ontology via model.select(Scenario.name, ...), per-scenario aggregation, comparison queries | examples/scenario_concept_extraction.py |
| Loop-based results | Variable.values(), solve_info().display(), status/objective access, scenario comparison table | examples/loop_based_extraction.py |
| Pareto frontier analysis | Tradeoff table, marginal rates + knee detection, allocation shifts + regime detection, ASCII frontier visualization | examples/pareto_frontier_analysis.py |
| Reference | Description | File |
|-----------|-------------|------|
| Solution extraction details | Query-pattern variations (populate=True vs populate=False — multiple solutions, iterative, scenario/parametric), Variable.values() back-pointer naming rules with examples table, silent-failure warnings, Snowflake export | solution-extraction-details.md |
| Failure taxonomy | Detailed root causes by solvability level and 5-step diagnosis protocol | failure-taxonomy.md |
| Common pitfalls | Additional result-interpretation pitfalls (numerical instability, degenerate solutions, aggregation scope, missing/null data, etc.) — distinct from the silent-bug rows in this SKILL | common-pitfalls.md |
| Sensitivity analysis | Sensitivity analysis techniques and parameter sweeps | sensitivity-analysis.md |
data-ai
Configure and train graph neural network (GNN) models, generate predictions, evaluate results, and manage trained models. Use when ready to train, generate predictions, evaluate, or manage models; for concepts, data loading, edges, and feature configuration, see `rai-predictive-modeling`.
development
Build graph neural network (GNN) models — concepts, Snowflake data loading, task relationships, graph edges, and PropertyTransformer features. Use for node classification, regression, and link prediction tasks; for training, predictions, and evaluation, see `rai-predictive-training`.
development
Setup and configuration for RelationalAI — first-time install walkthrough and all raiconfig.yaml tuning. Use when installing RAI, connecting to Snowflake, or editing raiconfig.yaml. Not for writing PyRel model code (see rai-pyrel-coding) or solver usage and diagnostics (see rai-prescriptive-solver-management).
testing
Converts natural language business rules into PyRel derived properties — validation, classification, derivation, alerting, and reconciliation. Use whenever a task assigns each entity a new tier, segment, score, or flag, or derives a new property; author it here as a derived property, then query it with rai-querying.